Closed20

Golangの"Writing Web Applications"を読む

nanaenanae

昨日Using net/http to serve wiki pagesまで読んだ

ここまでで http://localhost:8080/view/{{ title }} で{{ title }}.txtを読み込み、それを内容とするシンプルなwikiページが表示できるようになった。

ただし、エラーハンドリングはしていないので、存在しないファイルを指定すると404になる。
またアプリ側から直接記事を追加したり編集する機能はまだ無い。

今日は続きのEditing Pagesから読む

nanaenanae

ちなみに404エラーになったとき、ターミナルには以下のようなエラーが表示されている
エラーハンドリングをしていないので当然だろうが、不正なメモリアクセス・ぬるぽがあったことしか分からない。実行にはgoroutineというものが使われていそうなこともなんとなく読み取れる、goroutineで並列処理を行っているみたいな話は聞いたことがあるけど詳しくは知らない。

2021/02/13 17:33:14 http: panic serving [::1]:57290: runtime error: invalid memory address or nil pointer dereference
goroutine 9 [running]:
net/http.(*conn).serve.func1(0xc000122be0)
        /usr/local/go/src/net/http/server.go:1801 +0x147
panic(0x663300, 0x855ed0)
        /usr/local/go/src/runtime/panic.go:975 +0x47a
main.viewHandler(0x6fb220, 0xc0002141c0, 0xc000176300)
        /home/nanae/gowiki/wiki.go:32 +0x7c
net/http.HandlerFunc.ServeHTTP(0x6bbca8, 0x6fb220, 0xc0002141c0, 0xc000176300)
        /usr/local/go/src/net/http/server.go:2042 +0x44
net/http.(*ServeMux).ServeHTTP(0x8625e0, 0x6fb220, 0xc0002141c0, 0xc000176300)
        /usr/local/go/src/net/http/server.go:2417 +0x1ad
net/http.serverHandler.ServeHTTP(0xc000154000, 0x6fb220, 0xc0002141c0, 0xc000176300)
        /usr/local/go/src/net/http/server.go:2843 +0xa3
net/http.(*conn).serve(0xc000122be0, 0x6fb660, 0xc0000784c0)
        /usr/local/go/src/net/http/server.go:1925 +0x8ad
created by net/http.(*Server).Serve
        /usr/local/go/src/net/http/server.go:2969 +0x36c
nanaenanae

ここではeditHandlerという関数を作り、localhost:8080/edit/{{ title }}にアクセスしたらeditHandlerを実行して編集ページを返すということをやっているっぽい。

func editHandler(w http.ResponseWriter, r *http.Request) {
    title := r.URL.Path[len("/edit/"):]
    p, err := loadPage(title)
    if err != nil {
        p = &Page{Title: title}
    }
    fmt.Fprintf(w, "<h1>Editing %s</h1>"+
        "<form action=\"/save/%s\" method=\"POST\">"+
        "<textarea name=\"body\">%s</textarea><br>"+
        "<input type=\"submit\" value=\"Save\">"+
        "</form>",
        p.Title, p.Title, p.Body)
}

HTMLべた書きで今時こんなレガシーアプリみたいな…と思ったら

This function will work fine, but all that hard-coded HTML is ugly. Of course, there is a better way.

と書いてあった、まんまと引っかかりましたね。というわけで次のThe html/template packageへ。

nanaenanae

html/templateはGoの標準ライブラリにあるものらしい。標準であるものなんですね。
webアプリとかほとんど作ったこと無いけど、N予備校でnode.jsでwebアプリを作るチュートリアルをやったときはPugというテンプレートエンジンを使った記憶がある。

ともかく、html/templateをimportしてedit.html

<h1>Editing {{.Title}}</h1>

<form action="/save/{{.Title}}" method="POST">
<div><textarea name="body" rows="20" cols="80">{{printf "%s" .Body}}</textarea></div>
<div><input type="submit" value="Save"></div>
</form>

と書いておけばいいらしい。この{{}}で囲まれた部分に渡された変数が入るという感じで、.で渡した変数の値が展開されるけど、今回は構造体を渡しているので.Titleとかで値を取得している。
ということは変数は1つしか渡せなくて、複数渡したいときはこういった形で構造体にしなきゃいけないということなんだろうか…?

あと急に出てきたprintf "%s" .Bodyって何?って感じだけど、これはhtml/templatetext/templateと共通のinterfaceを持っていて、そちらに

printf
An alias for fmt.Sprintf

と書いてありました。
https://golang.org/pkg/text/template/

nanaenanae

そういえばhtmll/templateはサニタイジングも自動的にやってくれるみたい、<&gt;にするとか。今時そういうのは当たり前なのかもしれないけど。

nanaenanae

saveHandlerも実装して一通り最低限機能するwikiページになった

nanaenanae

その後、renderTemplateやsaveHandlerにもちゃんとエラーハンドリングを実装した。

しかしページが見つからない404と違ってこのあたりは意図的にエラーを起こしづらそう
新規ページのsaveに失敗するようにしてみよう、~/gowikiの書き込みを不可にしてみる。

nanae@silent:~$ chmod 555 gowiki/
nanae@silent:~$ ll | grep gowiki/
dr-xr-xr-x  2 nanae nanae  4096 Feb 13 19:25 gowiki/

その後、適当なページを新規作成しようとするとディレクトリに書き込み権限が無いのでp.save()でエラーが起こるはず…すると

open def.txt: permission denied

という簡素なエラーメッセージがブラウザ上に表示され500エラーも返ってくることが確認できた。

nanaenanae

As you may have observed, this program has a serious security flaw: a user can supply an arbitrary path to be read/written on the server. To mitigate this, we can write a function to validate the title with a regular expression.

気づかなかったが、確かにURLに突っ込まれた文字列をそのままファイルの名前として開こうとしているので../みたいなのが使われるとディレクトリトラバーサルされそうである。(自分で試してもうまくできなかったが)
https://ja.wikipedia.org/wiki/ディレクトリトラバーサル

こうならないようにあらかじめ許容するURLを/(view|edit|save)/[a-zA-Z0-9]+$で表せるもののみに限定しようということで正規表現を使ってvalidationを行う

var validPath = regexp.MustCompile("^/(?:edit|save|view)/([a-zA-Z0-9]+)$")

本編と少し変えて(edit|save|view)の部分は後で使わないので(?:)を使って囲むようにしてみた
https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Regular_Expressions#special-non-capturing-parentheses

そしてこれを使ってタイトルを取得する用の関数を作って、その際にvalidationに失敗したら404に飛ばしてエラーを返すようにする

func getTitle(w http.ResponseWriter, r *http.Request) (string, error) {
	m := validPath.FindStringSubmatch(r.URL.Path)
	if m == nil {
		http.NotFound(w, r)
		return "", errors.New("Invalid Page Title")
	}
	fmt.Print(m)
	return m[1], nil
}
nanaenanae

機能としては完成だがxxxHandlerの最初に

    title, err := getTitle(w, r)
    if err != nil {
        return
    }

という同じ記述が連続しているのがなんかアレじゃない?とのこと。確かに。

というわけでFunction Literals(要するに無名関数?)とクロージャを使ってhandlerを作る関数を作ったらいい感じになるよ~っていう話。
https://golang.org/ref/spec#Function_literals

こんな感じで書くらしい

func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        m := validPath.FindStringSubmatch(r.URL.Path)
        if m == nil {
            http.NotFound(w, r)
            return
        }
        fn(w, r, m[1])
    }
}

この辺は「ほ~そうやるもんですかあ」っていう感じで、あんまりよくわかっていない。が、まぁそういうもんなのだと受け取っておく。

nanaenanae

これで一通りWriting Web Applicationsのチュートリアルは終わり、お疲れさまでした!
https://golang.org/doc/articles/wiki/

触ってみて気になることは

  • TopPageが欲しい、今作成されている記事へのリンクぐらいあればよさそう
  • /save/{{ title }}にGETでリクエストすると自動的に空の内容で上書きされてしまう、よくない
  • 見た目が簡素すぎる

そしてチュートリアルの追加タスク

  • Store templates in tmpl/ and page data in data/.
  • Add a handler to make the web root redirect to /view/FrontPage.
  • Spruce up the page templates by making them valid HTML and adding some CSS rules.
  • Implement inter-page linking by converting instances of [PageName] to
    <a href="/view/PageName">PageName</a>. (hint: you could use regexp.ReplaceAllFunc to do this)

今回はWebアプリを本格的に作るために始めたわけではないのでそんなに時間割くつもりは無いができそうなところは明日時間使ってやってみてもいいかな。

nanaenanae

せっかくなので一旦ここまでの内容をGithubにpushしておいた
https://github.com/nanae772/gowiki

あと主題とは関係無いけどgit logしたら色変化のための制御文字?がESC[33mみたいに表示されてしまっていて焦ったけどどうやらlessの環境変数に$LESSっていうのがあってそこで-Rがついてないとこのように表示されてしまうということを知った
https://qiita.com/delphinus/items/b04752bb5b64e6cc4ea9

~/.bashrcを見ると

export LESS=-q

としか書いておらず、なんでこんなことになっているのかと思い起こすとそういえばwindows terminalでlessで見るときになんかポッみたいな音がうるさいなと思って-qだけ指定してそのままにしていたからか

nanaenanae

チュートリアルの追加タスクであった

  • Store templates in tmpl/ and page data in data/.
  • Add a handler to make the web root redirect to /view/FrontPage.
  • Spruce up the page templates by making them valid HTML and adding some CSS rules.

をやった、ただし最後にCSSが当たらずコンソールに

Resource interpreted as Stylesheet but transferred with MIME type text/html: "http://localhost:8080/style/style.css".

と表示されおり、cssがcssとして読み込めていない?様子だった。この手のバグは別の場所でも聞いたことがある。
https://github.com/nanae772/gowiki/issues/1

調べたところResponse-headerにContent-Type:text/cssをつけてやればいいらしいがそれがちょっとめんどそうなので一旦issueにだけして置いておく

nanaenanae

/saveにPOST以外のメソッドでリクエストされた場合、saveしないようにした
実装は以下のようなシンプルなものだが、もう少しいい書き方があるだろうか

func saveHandler(w http.ResponseWriter, r *http.Request, title string) {
	if r.Method != http.MethodPost {
		http.Redirect(w, r, "/view/"+title, http.StatusSeeOther)
		return
	}

本当はエラーページに飛ばすほうがよいのでは、と思い405 Method Not Allowedを返してエラーページに飛ばそうと思っていたのだがMDNに

サーバーがリクエストメソッドを理解しているものの、無効にされており使用することができません。例えば、 API がリソースを DELETE することを禁止できます。 GET および HEAD の二つは必須で、無効にすることができず、このエラーコードを返してはいけません。

と書いていたのでGETに対して405を返すのはよくないってことなのか…?と思い、とりあえずリダイレクトにしておいた。こういうのってどうするのが正しいのだろうか。
https://developer.mozilla.org/ja/docs/Web/HTTP/Status

nanaenanae

追加タスクである

Implement inter-page linking by converting instances of [PageName] to
<a href="/view/PageName">PageName</a>. (hint: you could use regexp.ReplaceAllFunc to do this)

をやろうとしているのだが、これも難しい、renderTemplateの中で正規表現による置き換えをやればよさそうなので

func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) {
	regInnerLink := regexp.MustCompile("\\[([a-zA-Z0-9]+)\\]")
	page := Page{
		Title: p.Title,
		Body:  regInnerLink.ReplaceAll(p.Body, []byte("<a href=\"/view/$1\">$1</a>")),
	}
	err := templates.ExecuteTemplate(w, tmpl+".html", page)

と書いたが、html/templateの親切機能であるHTMLの自動エスケープが効いてしまうので<a href=\"/view/$1\">$1</a>の部分がリンクにならない

template.HTMLを使えば自動エスケープを避けられるようだが、Page.Bodyは[]byteだしうまいやり方が思いつかない
https://golang.org/pkg/html/template/#hdr-Typed_Strings

nanaenanae

PageにHTMLを返却する用のメソッドを作り、それをhtml側で呼び出せばよさそう?

nanaenanae
func (p Page) getBodyHTML() template.HTML {
	return template.HTML(regInnerLink.ReplaceAll(p.Body, []byte("<a href=\"/view/$1\">$1</a>")))
}

とmethod作って呼び出そうとしても

http: superfluous response.WriteHeader call from main.renderTemplate (wiki.go:63)

って出てくるのでうまくいかず、よくわからない。流石にそろそろ雰囲気でやっているツケが回ってきた気がする。

またそれとは無関係に適当に/view/testなどにアクセスしたときも/view/FrontPageへのリクエストが複数飛んでるという現象が起きているというのがあった。なんとなく不味いんじゃないかなとは思っていたけどやっぱり

	http.HandleFunc("/", rootHandler)
	http.HandleFunc("/view/", makeHandler(viewHandler))

って書いててそれがなんか競合起こしてたりする感じだろうか。

nanaenanae

よく見ると競合してるとかそういうのじゃなくて/favicon.icoとか/style/style.cssが全部rootHandlerで処理されてFrontPageが返っているっていう現象が起きているということか、CSSが当たらないっていうのもそれが原因っぽい、たぶん

このスクラップは2021/02/20にクローズされました