前回、最低限の機能を持った掲示板を作成しましたが、いろいろ問題点があったので、今回はそれらを改善していきます。
改善すること
具体的には下記の問題点を改善していきます。
- CSRF脆弱性に対する対策
- カスタムテンプレート関数を導入する
- テンプレート表示時に改行が効くようにする
- テンプレート表示時の日時表示を整える
- ページングを作成
- CSSを読みこむ
CSRF脆弱性対策
まずは CSRF の対策をしていきます。この対策をしておかないと「ぼくはまちちゃん!」と大量に書き込まれることになるかもしれません。
こちらは既に gin-csrf というパッケージがあったので、それを導入することで対策します。gin-csrf をインストールした上で、まずは route.go
を編集します。
package main
import (
"bbs/handler"
"bbs/lib"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/utrack/gin-csrf"
"html/template"
)
func main() {
r := gin.Default()
// テンプレートの設定
r.LoadHTMLGlob("templates/*")
// セッションとCSRFの設定
// secret の値は必ずランダムな値にし公開しないこと!
store := cookie.NewStore([]byte("secret"))
r.Use(sessions.Sessions("mysession", store))
r.Use(csrf.Middleware(csrf.Options{
// ここの secret も!
Secret: "secret123",
ErrorFunc: func(c *gin.Context) {
c.String(400, "CSRF token mismatch")
c.Abort()
},
}))
// ルーティング設定
r.GET("/articles", handler.GetArticles)
r.POST("/articles", handler.CreateArticle)
r.Run()
}
「セッションとCSRFの設定」とコメントした部分を追加しました。gin-csrf のREADMEに書いてあるものと全く同じ内容です。ただし、コメントにも書きましたが、実際に利用する場合は、secret の値はランダムにし、公開しないように注意してください。
追加した内容としては、インストールした gin-csrf パッケージと、それを利用するために必要なセッションについて、それらをミドルウェアとして利用する設定をした、という内容ですね。
続いて handler/article.go
を編集します。
package handler
import (
"bbs/repository"
"github.com/gin-gonic/gin"
csrf "github.com/utrack/gin-csrf"
"net/http"
"strconv"
)
...(省略)...
// GetArticles 掲示板の記事を取得し画面表示
func GetArticles(c *gin.Context) {
// クエリパラメータ取得
// パラメータ error
// CreateArticle() からのリダイレクト時バリデーションエラーが起きたかどうか
// 文字列なので、bool型にキャストする
hasValidateErrorStr := c.DefaultQuery("error", "false")
hasValidateError, err := strconv.ParseBool(hasValidateErrorStr)
if err != nil {
panic(err)
}
// DBから記事取得
articles, _, err := repository.GetArticles()
if err != nil {
panic(err)
}
// 画面表示
c.HTML(http.StatusOK, "article.html", gin.H{
"articles": articles,
"csrfToken": csrf.GetToken(c), // CSRFトークン生成
"error": hasValidateError,
})
}
追加したのは、「CSRFトークン生成」とコメントした部分のみです。見ての通り、GetToken()
メソッドでトークンを生成して、テンプレートに渡しているだけです。では、テンプレート templates/article.html
を見ていきましょう。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>掲示板</title>
</head>
<body>
<div class="container">
<h1>掲示板</h1>
<!-- エラーがあったら表示 -->
{{ if .error }}
<span class="error">投稿エラーです。入力内容を確認してください。</span>
<br><br>
{{ end }}
<!-- 投稿フォーム -->
<form method="POST" action="/articles">
<div class="flex-box">
<label class="form-label" for="input-name">名前</label>
<input type="text" name="name" id="input-name">
</div>
<div class="flex-box">
<label class="form-label" for="input-body">本文</label>
<textarea name="body" id="input-body" cols="60" rows="5"></textarea>
</div>
<!-- CSRFトークンを送信する _csrf という名前で送ればOKっぽい -->
<input type="hidden" name="_csrf" value="{{ .csrfToken }}">
<input type="submit" class="post-article-button" value="投稿する">
</form>
...(省略)...
「CSRFトークンを送信する」とコメントした部分のみを追加しました。gin-csrf のREADME には書いてないのですが、パッケージの中身を開けて確認したところ、_csrf
という名前で送信すると、CSRFトークンだと認識されるようです。これで正しいCSRFトークンを送信しないと、CSRF token mismatch
と表示されエラーになるように修正できました。
カスタムテンプレート関数を導入
続いて、カスタムテンプレート関数を導入して、投稿した記事本文の改行が効くように、そして投稿日時がフォーマットされるようにします。
まずは、新たに lib
というディレクトリを作成、その中に lib/template_func.go
というファイルを作成し、そこに呼び出すテンプレート関数を実装します。
package lib
import (
"html/template"
"strings"
"time"
)
// Nl2br テンプレートで改行を有効にする
func Nl2br(text string) template.HTML {
return template.HTML(strings.Replace(template.HTMLEscapeString(text), "\n", "<br />", -1))
}
// FormatAsDatetime time.Time 型をフォーマットして返す
func FormatAsDatetime(t time.Time) string {
return t.Format("2006/01/02 15:04:05")
}
Nl2br()
関数がテンプレートで改行を有効にする関数で、html/templateに追加して便利だったテンプレート関数4選 という記事の記述をそのまま拝借させて頂きました。FormatAsDatetime()
関数が time.Time 型をフォーマットして返す関数です。
続いて route.go
を編集します。
package main
import (
"bbs/handler"
"bbs/lib"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
"github.com/utrack/gin-csrf"
"html/template"
)
func main() {
r := gin.Default()
// カスタムテンプレート関数を設定
r.SetFuncMap(template.FuncMap{
"formatAsDatetime": lib.FormatAsDatetime,
"nl2br": lib.Nl2br,
})
r.LoadHTMLGlob("templates/*")
...(省略)...
}
「カスタムテンプレート関数を設定」とコメントした部分を追加しました。Gin ドキュメント HTML をレンダリングする の「カスタムテンプレート関数」を参考にしています。先ほど lib/template_func.go
で定義した関数を設定しています。
最後に呼び出すテンプレート templates/article.html
の修正です。
...(省略)...
<!-- 取得した記事の数だけループ -->
{{ range .articles }}
<div class="flex-box">
<span class="article-id">#{{ .ID }}</span>
<span class="article-datetime">{{ .CreatedAt | formatAsDatetime }}</span>
<span class="article-name">名前: {{ .Name }}</span>
</div>
<div class="article-body">{{ .Body | nl2br }}</div>
<hr class="delimiter-line">
{{ end }}
...(省略)...
上記 {{ .CreatedAt | formatAsDatetime }}
のように書くとテンプレート関数が適用されます。これで改行が効き、日時も見やすくフォーマットされるようになりました。
ページング
続いてページングを作成します。ページングを作成するにあたり、利用できるパッケージもあるかと思いますが、今回は自力で作成します。
まずは、repository/article.go
を編集します。
package repository
import (
"gorm.io/gorm"
"time"
)
...(省略)...
// GetArticles DBから記事を取得する
func GetArticles(limit int, offset int) ([]Article, *gorm.DB, error) {
// gorm.DB を取得
db, err := CreateDB()
if err != nil {
return nil, nil, err
}
var articles []Article
// 実行
// 最新の投稿から表示したいので id の降順で並べ替えておく
// limit, offset を追加してページネーションに対応
result := db.Limit(limit).Offset(offset).Order("id desc").Find(&articles)
return articles, result, nil
}
記事を取得する関数 GetArticles()
に、引数 limit
offset
を追加、SQL SELECT 実行時に limit
offset
を指定することで、ページネーションに対応しています。
続いて、 handler/article.go
を編集します。
package handler
import (
"bbs/repository"
"fmt"
"github.com/gin-gonic/gin"
csrf "github.com/utrack/gin-csrf"
"net/http"
"strconv"
)
// 1ページあたりいくつの記事を表示するか
const perPage = 5
...(省略)...
// GetArticles 掲示板の記事を取得し画面表示
func GetArticles(c *gin.Context) {
// クエリパラメータ取得
// パラメータ error
// CreateArticle() からのリダイレクト時バリデーションエラーが起きたかどうか
// 文字列なので、bool型にキャストする
hasValidateErrorStr := c.DefaultQuery("error", "false")
hasValidateError, err := strconv.ParseBool(hasValidateErrorStr)
if err != nil {
panic(err)
}
// パラメータ page を追加
// ページネーションの何ページ目を表示しているか
// 文字列なので、int型にキャストする
pageStr := c.DefaultQuery("page", "1")
page, err := strconv.Atoi(pageStr)
if err != nil {
panic(err)
}
// DBから記事取得
// 1ページ当たりの表示件数 + 1 件を取得して、次のページが存在するかどうかを判定する材料にする
limit := perPage + 1
offset := (page - 1) * perPage
rowArticles, result, err := repository.GetArticles(limit, offset)
if err != nil {
panic(err)
}
// DBから取得できた記事数 count
count := int(result.RowsAffected)
// 1ページ当たり表示する記事数にスライス
sliceNum := perPage
// count が perPage より少ない場合、スライスする数 sliceNum を count に合わせておく
if count < sliceNum {
sliceNum = count
}
articles := rowArticles[:sliceNum]
// ページネーション生成処理を追加
paginate := CreatePaginate(page, limit, count)
// 画面表示
c.HTML(http.StatusOK, "article.html", gin.H{
"articles": articles,
"csrfToken": csrf.GetToken(c), // CSRFトークン生成
"error": hasValidateError,
"paginate": paginate,
})
}
// Paginate ページネーション用の struct
type Paginate struct {
BeforePage int
NextPage int
}
// CreatePaginate ページネーションを作成する
// 計算しているのは、前のページ・次のページのページ番号
// 前のページ・次のページが無いのであれば、0を入れておく
// page: 現在のページ
// limit: DB検索時に利用した limit
// count: DBから取得した記事数
func CreatePaginate(page int, limit int, count int) Paginate {
// 前のページ
beforePage := page - 1
// 0以下になっていたら0にしておく
if beforePage <= 0 {
beforePage = 0
}
// 次のページ
nextPage := page + 1
// limit で指定した分だけ取得できていなければ、次のページ無しということで0にしておく
if count != limit {
nextPage = 0
}
return Paginate{
BeforePage: beforePage,
NextPage: nextPage,
}
}
修正(追加)箇所が多く、わかりづらいかもしれませんが、それぞれコメントを付けたのでご参照ください。ロジックを簡単に説明すると、1ページ当たりの表示記事数 perPage
+ 1件の記事をDBから取得して、その数だけ記事が取得できたかどうかで、「次のページ」が存在するかどうかを判定します。「前のページ」が存在するかどうかは、現在のページ page
から1を引いて0以下になるかどうかで判定します。
最後に templates/article.html
を編集します。
...(省略)...
<!-- ページネーション -->
{{ if gt .paginate.BeforePage 0 }}
<a href="/articles?page={{ .paginate.BeforePage }}">前のページ</a>
{{ end }}
{{ if gt .paginate.NextPage 0 }}
<a href="/articles?page={{ .paginate.NextPage }}">次のページ</a>
{{ end }}
</div>
</body>
</html>
ページの下部にページネーションを追加しています。これで、「前のページ」「次のページ」が存在するとき、ページネーションのリンクが表示されるようになりました。
CSSを読みこむ
続いてCSS(静的ファイル)を読み込んで、ちょっとだけ見た目を良くします。
route.go
を編集します。
...(省略)...
func main() {
r := gin.Default()
// 静的ファイルとテンプレートの設定
r.Static("/assets", "./assets") // 追加
r.SetFuncMap(template.FuncMap{
"formatAsDatetime": lib.FormatAsDatetime,
"nl2br": lib.Nl2br,
})
r.LoadHTMLGlob("templates/*")
...(省略)...
}
gin ドキュメント 静的ファイルを返す の通り設定を追加しました。
assets
ディレクトリを新たに作成して、asserts/article.css
というファイルを作成し、良い感じにCSSを記述します(私はフロントエンジニアではなく専門外なので、asserts/article.css
の中身については割愛します)。
templates/article.html
を編集して、作成した CSS ファイルを読み込めばスタイルが当たるようになります。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<!-- CSS読み込みを追加 -->
<link rel="stylesheet" href="/assets/css/article.css">
<title>掲示板</title>
</head>
...(省略)...
これで下記のような見た目(これまでの修正分全部入り)になりました。
最後に
これで幾分かは問題点が解消できました。ただ、まだ下記のような問題点が残されています。
- 記事投稿時バリデーションエラーが起きたとき
- エラーの詳細がよくわからない
- 投稿しようとした内容がフォームに残っていない
- クエリパラメータ error=true を付けている限り、エラー表示が出続けてしまう
- ページネーションが「前のページ」「次のページ」しか出ていない
- そもそも掲示板の機能として足りなさすぎる
これらについては、私の個人ブログの方で実装するかもしれないですし、この記事を読んでくださった方が課題として取り組まれるのも良いかと思います。