🔩 ねじき教室 Go と Web の教室

投稿と一覧

読了目安 約5分

ログインなしで動く最小の掲示板を作り、投稿の保存と一覧表示、html/template の XSS 対策を確かめます。

この章の目次

まず「投稿できて、一覧が見られる」だけの掲示板を動かします。

この章で足すものは次の 3 つです。

  • MySQL への接続
  • GET /(投稿を新しい順に一覧表示する)
  • POST /posts(フォームから投稿を受け取って保存する)

作る順番と仮ユーザー

ログイン機能はこの章では作りません。 ただし、posts テーブルの user_id は NOT NULL なので、投稿には持ち主が要ります。 そこで仮のユーザーを 1 人だけ、SQL で直接作っておきます。

SQL
USE neziki_board;
INSERT INTO users (name, password_hash) VALUES ('neziko', 'placeholder');

placeholder はただの文字列で、これでログインはできません(本物のハッシュ値は 3 章で入れます)。 空のテーブルへの最初の INSERT なので、id は 1 になります。

DB につないで起動する

main.go の冒頭を書きます。

Go
package main

import (
	"database/sql"
	"html/template"
	"log"
	"net/http"
	"time"

	_ "github.com/go-sql-driver/mysql"
)

var (
	db        *sql.DB
	templates = template.Must(template.ParseGlob("templates/*.html"))
)

func main() {
	var err error
	db, err = sql.Open("mysql", "root:neziki@tcp(127.0.0.1:3306)/neziki_board?parseTime=true")
	if err != nil {
		log.Fatal(err)
	}
	if err := db.Ping(); err != nil {
		log.Fatal(err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", handleIndex)
	mux.HandleFunc("POST /posts", handleCreatePost)

	log.Println("http://localhost:8080 で待ち受けます")
	log.Fatal(http.ListenAndServe(":8080", mux))
}
  • sql.Open、起動時の Ping、アンダースコア付きの import は Go から SQL を使う の復習
  • 接続文字列末尾の parseTime=true が新顔。これが無いと DATETIME 列が生のバイト列で返り、Go の time.Time で受け取れない
  • templates 変数は templates/ の HTML ファイルをすべて読み込んだもの。template.Must は読み込みに失敗したら panic させる補助関数
  • ルーティングは ルーティングとハンドラ のメソッド付きパターン。/ だけのパターンは一致しなかったパス全部を受けてしまうので、{$} を付けて「パスがちょうど / のときだけ」に限定する
  • 相対パス templates/ が効くように、go run は必ずプロジェクトのルートで実行する

一覧のハンドラ

投稿 1 件を struct で表し、GET / のハンドラを書きます。

Go
type Post struct {
	ID        int64
	UserName  string
	Body      string
	CreatedAt time.Time
}

func handleIndex(w http.ResponseWriter, r *http.Request) {
	rows, err := db.Query(`
		SELECT posts.id, users.name, posts.body, posts.created_at
		FROM posts
		JOIN users ON posts.user_id = users.id
		ORDER BY posts.id DESC`)
	if err != nil {
		serverError(w, err)
		return
	}
	defer rows.Close()

	var posts []Post
	for rows.Next() {
		var p Post
		if err := rows.Scan(&p.ID, &p.UserName, &p.Body, &p.CreatedAt); err != nil {
			serverError(w, err)
			return
		}
		posts = append(posts, p)
	}
	if err := rows.Err(); err != nil {
		serverError(w, err)
		return
	}

	if err := templates.ExecuteTemplate(w, "index.html", map[string]any{"Posts": posts}); err != nil {
		log.Println(err)
	}
}

func serverError(w http.ResponseWriter, err error) {
	log.Println(err)
	http.Error(w, "サーバー内部でエラーが起きました", http.StatusInternalServerError)
}
  • posts テーブルには user_id しかないので、複数のテーブルと JOIN のとおり users と JOIN して投稿者の名前を取り出す
  • id は挿入のたびに増える自動採番なので、ORDER BY posts.id DESC がそのまま投稿の新しい順
  • serverError はエラーをログに残して 500 Internal Server Error を返す補助関数。応答にエラーの中身を入れず、詳細はサーバーのログにだけ残すのが定石

一覧のテンプレート

templates/index.html を作ります。

HTML
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <title>ねじ板</title>
  </head>
  <body>
    <h1>ねじ板</h1>
    <form action="/posts" method="post">
      <textarea name="body" rows="3" cols="40"></textarea>
      <button type="submit">投稿する</button>
    </form>
    <ul>
      {{range .Posts}}
        <li>{{.UserName}}: {{.Body}}({{.CreatedAt.Format "2006-01-02 15:04"}})</li>
      {{end}}
    </ul>
  </body>
</html>

テンプレートとは、HTML の下書きに、プログラムから渡した値をはめ込んで最終的なページを作る仕組みです。

  • {{range .Posts}} はハンドラから渡した slice のループ。{{.Body}} のように各要素のフィールドを埋め込む
  • 日時の Format は Go 特有の流儀で、「2006 年 1 月 2 日 15 時 4 分」という決まった見本の日時で書式を表す

投稿のハンドラ

POST /posts のハンドラを書きます。

Go
func handleCreatePost(w http.ResponseWriter, r *http.Request) {
	body := r.FormValue("body")
	if body == "" {
		http.Redirect(w, r, "/", http.StatusSeeOther)
		return
	}

	// ログインはまだ無いので、投稿者は仮ユーザー(id が 1)に固定する
	if _, err := db.Exec("INSERT INTO posts (user_id, body) VALUES (?, ?)", 1, body); err != nil {
		serverError(w, err)
		return
	}
	http.Redirect(w, r, "/", http.StatusSeeOther)
}

フォームの値は リクエストを受け取るr.FormValue、プレースホルダ(?)は Go から SQL を使う のとおりです。

保存後にページを直接返さず GET / へリダイレクトするのは、PRG パターン(Post/Redirect/Get)という定石です。

  • POST の応答にそのまま画面を返すと、再読み込みで同じ POST が再送されて二重投稿になる
  • 303 See Other は「処理は受け付けたので、続きはこの URL を GET で見よ」という意味のステータスコード

動作確認

プロジェクトのルートで起動します。

シェル
go run main.go

ブラウザで http://localhost:8080 を開き、フォームから何か投稿してください。 一覧に「neziko: (書いた内容)」が新しい順に並べば成功です。

-d を付けた curl は、フォーム送信と同じ形式の POST になります。

シェル
curl -i -d 'body=curl からの投稿' http://localhost:8080/posts
テキスト
HTTP/1.1 303 See Other
Location: /

curl はリダイレクトを勝手に追わないので、303 と行き先がそのまま見えます。 続けて一覧を取ると、投稿が含まれています。

シェル
curl http://localhost:8080/

スクリプトを投稿してみる

フォームから次の内容をそのまま投稿してください。

テキスト
<script>alert(1)</script>

一覧にはそのまま文字として表示され、ページのソースでは次のように変換されています。

HTML
&lt;script&gt;alert(1)&lt;/script&gt;

この変換(エスケープ)が無ければ、script タグはページの一部としてブラウザに実行されてしまいます。 このように、入力に仕込まれたスクリプトを閲覧者のブラウザで実行させる攻撃を XSS(クロスサイトスクリプティング)と呼びます。

  • ねじ板が無事なのは、html/template が {{.Body}} のような埋め込みをすべて自動でエスケープするから
  • text/template や fmt.Fprintf で自力で HTML を組み立てる方法にはこの保護が無い

XSS には 4 章で、Cookie を守る話としてもう一度出会います。