投稿と一覧
読了目安 約5分
ログインなしで動く最小の掲示板を作り、投稿の保存と一覧表示、html/template の XSS 対策を確かめます。
この章の目次
まず「投稿できて、一覧が見られる」だけの掲示板を動かします。
この章で足すものは次の 3 つです。
- MySQL への接続
- GET /(投稿を新しい順に一覧表示する)
- POST /posts(フォームから投稿を受け取って保存する)
作る順番と仮ユーザー
ログイン機能はこの章では作りません。 ただし、posts テーブルの user_id は NOT NULL なので、投稿には持ち主が要ります。 そこで仮のユーザーを 1 人だけ、SQL で直接作っておきます。
USE neziki_board;
INSERT INTO users (name, password_hash) VALUES ('neziko', 'placeholder');placeholder はただの文字列で、これでログインはできません(本物のハッシュ値は 3 章で入れます)。 空のテーブルへの最初の INSERT なので、id は 1 になります。
DB につないで起動する
main.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 / のハンドラを書きます。
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 を作ります。
<!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 のハンドラを書きます。
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/postsHTTP/1.1 303 See Other
Location: /curl はリダイレクトを勝手に追わないので、303 と行き先がそのまま見えます。 続けて一覧を取ると、投稿が含まれています。
curl http://localhost:8080/スクリプトを投稿してみる
フォームから次の内容をそのまま投稿してください。
<script>alert(1)</script>一覧にはそのまま文字として表示され、ページのソースでは次のように変換されています。
<script>alert(1)</script>この変換(エスケープ)が無ければ、script タグはページの一部としてブラウザに実行されてしまいます。 このように、入力に仕込まれたスクリプトを閲覧者のブラウザで実行させる攻撃を XSS(クロスサイトスクリプティング)と呼びます。
- ねじ板が無事なのは、html/template が
{{.Body}}のような埋め込みをすべて自動でエスケープするから - text/template や
fmt.Fprintfで自力で HTML を組み立てる方法にはこの保護が無い
XSS には 4 章で、Cookie を守る話としてもう一度出会います。