]> Sergey Matveev's repositories - sgblog.git/blobdiff - cmd/sgblog/http.go
No reason to use gzip, when zstd exists
[sgblog.git] / cmd / sgblog / http.go
index 5b69fc50ecd9509e0d0cf5c222fc0d68cb97c600..9c39c44dab7f6ae2103ba2cca995ff5868e31d66 100644 (file)
-/*
-SGBlog -- Git-backed CGI/inetd blogging/phlogging engine
-Copyright (C) 2020 Sergey Matveev <stargrave@stargrave.org>
-
-This program is free software: you can redistribute it and/or modify
-it under the terms of the GNU Affero General Public License as
-published by the Free Software Foundation, version 3 of the License.
-
-This program is distributed in the hope that it will be useful,
-but WITHOUT ANY WARRANTY; without even the implied warranty of
-MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-GNU General Public License for more details.
-
-You should have received a copy of the GNU Affero General Public License
-along with this program.  If not, see <http://www.gnu.org/licenses/>.
-*/
+// SGBlog -- Git-backed CGI/UCSPI blogging/phlogging/gemlogging engine
+// Copyright (C) 2020-2024 Sergey Matveev <stargrave@stargrave.org>
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as
+// published by the Free Software Foundation, version 3 of the License.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 package main
 
 import (
        "bytes"
-       "compress/gzip"
        "crypto/sha1"
+       _ "embed"
        "encoding/hex"
-       "encoding/json"
        "encoding/xml"
        "errors"
        "fmt"
        "hash"
        "html"
        "io"
-       "io/ioutil"
        "log"
+       "net/http"
        "net/url"
        "os"
        "strconv"
        "strings"
        "text/template"
+       "time"
 
        "github.com/go-git/go-git/v5"
        "github.com/go-git/go-git/v5/plumbing"
        "github.com/go-git/go-git/v5/plumbing/object"
-       "github.com/hjson/hjson-go"
+       "github.com/klauspost/compress/zstd"
+       "github.com/vorlif/spreak"
        "go.stargrave.org/sgblog"
        "go.stargrave.org/sgblog/cmd/sgblog/atom"
-       "golang.org/x/crypto/blake2b"
+       "lukechampine.com/blake3"
 )
 
 const (
        AtomPostsFeed    = "feed.atom"
        AtomCommentsFeed = "comments.atom"
-       TmplHTMLIndex    = `<html>
-<head>
-       <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
-       <meta name="generator" content="SGBlog {{.Version}}">
-       <title>{{.Cfg.Title}} {{if .Topic}}(topic: {{.Topic}}) {{end}}({{.Offset}}-{{.OffsetNext}})</title>
-       {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
-       {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end}}
-       {{range .Cfg.GitURLs}}<link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
-       <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
-       <link rel="alternate" title="Posts feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomPostsFeed}}{{if .Topic}}?topic={{.Topic}}{{end}}" type="application/atom+xml">
-       {{if .CommentsEnabled}}<link rel="alternate" title="Comments feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomCommentsFeed}}" type="application/atom+xml">{{end}}
-       {{if .Offset}}<link rel="prev" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="prev">{{end}}
-       {{if not .LogEnded}}<link rel="next" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="next">{{end}}
-</head>
-<body>
-{{with .Cfg.AboutURL}}[<a href="{{.}}">about</a>]{{end}}
-{{block "links" .}}
-{{if .Offset}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}">prev</a>]{{end}}
-{{if not .LogEnded}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}">next</a>]{{end}}
-{{end}}
-{{- $Cfg := .Cfg -}}
-{{if .Topics}}<hr/>
-Topics: [<tt><a href="{{$Cfg.URLPrefix}}/">ALL</a></tt>]
-{{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]
-{{end}}
-{{end}}
-{{- $TopicsEnabled := .TopicsEnabled -}}
-{{- $datePrev := "0001-01-01" -}}
-<table border=1>
-<tr>
-       <th>N</th><th>When</th><th>Title</th>
-       <th size="5%"><a title="Lines">L</a></th>
-       <th size="5%"><a title="Comments">C</a></th>
-       <th>Linked to</th>
-       {{if .TopicsEnabled}}<th>Topics</th>{{end}}
-</tr>
-{{range .Entries -}}
-{{- $dateCur := .Commit.Author.When.Format "2006-01-02" -}}
-{{- if ne $dateCur $datePrev -}}
-       <tr><td colspan={{if $TopicsEnabled}}7{{else}}7{{end}}><center><tt>{{$dateCur}}</tt></center></td></tr>
-       {{- $datePrev = $dateCur -}}
-{{- end -}}
-<tr>
-       <td>{{.Num}}</td>
-       <td><tt>{{.Commit.Author.When.Format "15:04"}}</tt></td>
-       <td><a href="{{$Cfg.URLPrefix}}/{{.Commit.Hash.String}}">{{.Title}}</a></td>
-       <td>{{.LinesNum}}</td>
-       <td>{{if .CommentsNum}}{{.CommentsNum}}{{else}}&nbsp;{{end}}</td>
-       <td>{{if .DomainURLs}}{{range .DomainURLs}} {{.}} {{end}}{{else}}&nbsp;{{end}}</td>
-       {{if $TopicsEnabled}}<td>{{if .Topics}}{{range .Topics}} <a href="{{$Cfg.URLPrefix}}/?topic={{.}}">{{.}}</a> {{end}}{{else}}&nbsp;{{end}}</td>{{end}}
-</tr>
-{{end}}</table>
-{{template "links" .}}
-</body>
-</html>
-`
-       TmplHTMLEntry = `{{$Cfg := .Cfg}}<html>
-<head>
-       <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
-       <meta name="generator" content="SGBlog {{.Version}}">
-       <title>{{.Title}} ({{.When}})</title>
-       {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
-       {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end -}}
-       {{- range .Cfg.GitURLs}}
-       <link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
-       <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
-       <link rel="alternate" title="Comments feed" href="{{.AtomCommentsURL}}" type="application/atom+xml">
-       {{if .Parent}}<link rel="prev" href="{{.Cfg.URLPrefix}}/{{.Parent}}" title="prev">{{end}}
-</head>
-<body>
-{{with .Cfg.AboutURL}}[<a href="{{.}}">about</a>]{{end}}
-[<a href="{{.Cfg.URLPrefix}}/">index</a>]
-{{if .Parent}}[<a href="{{.Cfg.URLPrefix}}/{{.Parent}}">prev</a>]{{end}}
-[<tt><a title="When">{{.When}}</a></tt>]
-[<tt><a title="What">{{.Commit.Hash.String}}</a></tt>]
-
-{{if .Topics}}
-<hr/>
-Topics: {{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]{{end}}
-{{end}}
-
-<hr/>
-<h2>{{.Title}}</h2>
-<pre>
-{{range .Lines}}{{. | lineURLize $Cfg.URLPrefix}}
-{{end}}</pre>
-<hr/>
-
-{{if .NoteLines}}Note:<pre>
-{{range .NoteLines}}{{. | lineURLize $Cfg.URLPrefix}}
-{{end}}</pre>
-<hr/>{{end}}
-
-{{if .Cfg.CommentsEmail}}[<a href="mailto:{{.Cfg.CommentsEmail}}?subject={{.TitleEscaped}}">leave comment</a>]{{end}}
-
-<dl>{{range $idx, $comment := .Comments}}
-<dt><a name="comment{{$idx}}"><a href="#comment{{$idx}}">comment {{$idx}}</a>:</dt>
-<dd><pre>
-{{range $comment.HeaderLines}}{{.}}
-{{end}}{{range $comment.BodyLines}}{{. | lineURLize $Cfg.URLPrefix}}
-{{end}}</pre></dd>
-{{end}}</dl>
-
-</body>
-</html>
-`
 )
 
 var (
        renderableSchemes = map[string]struct{}{
-               "ftp":    struct{}{},
-               "gopher": struct{}{},
-               "http":   struct{}{},
-               "https":  struct{}{},
-               "telnet": struct{}{},
+               "finger": {},
+               "ftp":    {},
+               "gemini": {},
+               "gopher": {},
+               "http":   {},
+               "https":  {},
+               "irc":    {},
+               "ircs":   {},
+               "news":   {},
+               "telnet": {},
        }
+
+       //go:embed http-index.tmpl
+       TmplHTMLIndexRaw string
+       TmplHTMLIndex    = template.Must(template.New("http-index").Parse(TmplHTMLIndexRaw))
+
+       //go:embed http-entry.tmpl
+       TmplHTMLEntryRaw string
+       TmplHTMLEntry    = template.Must(template.New("http-entry").Funcs(
+               template.FuncMap{"lineURLize": lineURLizeInTemplate},
+       ).Parse(TmplHTMLEntryRaw))
 )
 
 type TableEntry struct {
@@ -175,6 +83,7 @@ type TableEntry struct {
        Title       string
        LinesNum    int
        CommentsNum int
+       ImagesNum   int
        DomainURLs  []string
        Topics      []string
 }
@@ -217,23 +126,24 @@ func lineURLizeInTemplate(urlPrefix, line interface{}) string {
        return lineURLize(urlPrefix.(string), line.(string))
 }
 
-func startHeader(etag hash.Hash, gziped bool) string {
+func startHeader(etag hash.Hash, zstded bool) string {
        lines := []string{
-               "Content-Type: text/html; charset=UTF-8",
+               "Content-Type: text/html; charset=utf-8",
                "ETag: " + etagString(etag),
        }
-       if gziped {
-               lines = append(lines, "Content-Encoding: gzip")
+       if zstded {
+               lines = append(lines, "Content-Encoding: zstd")
        }
        lines = append(lines, "")
        lines = append(lines, "")
        return strings.Join(lines, "\n")
 }
 
-func makeErr(err error) {
-       fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
+func makeErr(err error, status int) {
+       fmt.Println("Status:", status)
+       fmt.Print("Content-Type: text/plain; charset=utf-8\n\n")
        fmt.Println(err)
-       panic(err)
+       log.Fatalln(err)
 }
 
 func checkETag(etag hash.Hash) {
@@ -260,35 +170,22 @@ func serveHTTP() {
        if cfgPath == "" {
                log.Fatalln("SGBLOG_CFG is not set")
        }
-       cfgRaw, err := ioutil.ReadFile(cfgPath)
+       cfg, err := readCfg(cfgPath)
        if err != nil {
-               makeErr(err)
-       }
-       var cfgGeneral map[string]interface{}
-       if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
-               makeErr(err)
+               log.Fatalln(err)
        }
-       cfgRaw, err = json.Marshal(cfgGeneral)
-       if err != nil {
-               makeErr(err)
-       }
-       var cfg *Cfg
-       if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
-               makeErr(err)
-       }
-       pathInfo, exists := os.LookupEnv("PATH_INFO")
-       if !exists {
+       initLocalizer(cfg.Lang)
+
+       pathInfo := os.Getenv("PATH_INFO")
+       if len(pathInfo) == 0 {
                pathInfo = "/"
        }
        queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
        if err != nil {
-               makeErr(err)
+               makeErr(err, http.StatusBadRequest)
        }
 
-       etagHash, err := blake2b.New256(nil)
-       if err != nil {
-               panic(err)
-       }
+       etagHash := blake3.New(32, nil)
        for _, s := range []string{
                "SGBLOG",
                sgblog.Version,
@@ -311,13 +208,11 @@ func serveHTTP() {
                cfg.CommentsNotesRef,
                cfg.CommentsEmail,
        }
-       for _, gitURL := range cfg.GitURLs {
-               etagHashForWeb = append(etagHashForWeb, gitURL)
-       }
+       etagHashForWeb = append(etagHashForWeb, cfg.GitURLs...)
 
        headHash, err := initRepo(cfg)
        if err != nil {
-               makeErr(err)
+               makeErr(err, http.StatusInternalServerError)
        }
 
        if notes, err := repo.Notes(); err == nil {
@@ -347,12 +242,15 @@ func serveHTTP() {
        var outBuf bytes.Buffer
        var out io.Writer
        out = &outBuf
-       var gzipWriter *gzip.Writer
+       var zstdWriter *zstd.Encoder
        acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
        for _, encoding := range strings.Split(acceptEncoding, ", ") {
-               if encoding == "gzip" {
-                       gzipWriter = gzip.NewWriter(&outBuf)
-                       out = gzipWriter
+               if encoding == "zstd" {
+                       zstdWriter, err = zstd.NewWriter(&outBuf, zstd.WithEncoderLevel(zstd.SpeedDefault))
+                       if err != nil {
+                               panic(err)
+                       }
+                       out = zstdWriter
                }
        }
 
@@ -361,20 +259,20 @@ func serveHTTP() {
                if offsetRaw, exists := queryValues["offset"]; exists {
                        offset, err = strconv.Atoi(offsetRaw[0])
                        if err != nil {
-                               makeErr(err)
+                               makeErr(err, http.StatusBadRequest)
                        }
                }
                repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusInternalServerError)
                }
                topicsCache, err := getTopicsCache(cfg, repoLog)
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusInternalServerError)
                }
                repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusInternalServerError)
                }
 
                commitN := 0
@@ -384,7 +282,7 @@ func serveHTTP() {
                        topic = t[0]
                        hashes := topicsCache[topic]
                        if hashes == nil {
-                               makeErr(errors.New("no posts with that topic"))
+                               makeErr(errors.New("no posts with that topic"), http.StatusBadRequest)
                        }
                        if len(hashes) > offset {
                                hashes = hashes[offset:]
@@ -415,9 +313,9 @@ func serveHTTP() {
                                break
                        }
                        etagHash.Write(commit.Hash[:])
-                       commentsRaw := getNote(commentsTree, commit.Hash)
+                       commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash)
                        etagHash.Write(commentsRaw)
-                       topicsRaw := getNote(topicsTree, commit.Hash)
+                       topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash)
                        etagHash.Write(topicsRaw)
                        entries = append(entries, TableEntry{
                                Commit:      commit,
@@ -440,17 +338,18 @@ func serveHTTP() {
                                }
                                entry.DomainURLs = append(entry.DomainURLs, makeA(line, u.Host))
                        }
-                       entry.CommentsNum = len(parseComments(entry.CommentsRaw))
-                       entry.Topics = parseTopics(entry.TopicsRaw)
+                       entry.CommentsNum = len(sgblog.ParseComments(entry.CommentsRaw))
+                       entry.ImagesNum = len(listImgs(cfg, entry.Commit.Hash))
+                       entry.Topics = sgblog.ParseTopics(entry.TopicsRaw)
                        entries[i] = entry
                }
                offsetPrev := offset - PageEntries
                if offsetPrev < 0 {
                        offsetPrev = 0
                }
-               tmpl := template.Must(template.New("index").Parse(TmplHTMLIndex))
-               os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
-               err = tmpl.Execute(out, struct {
+               os.Stdout.Write([]byte(startHeader(etagHash, zstdWriter != nil)))
+               err = TmplHTMLIndex.Execute(out, struct {
+                       T                *spreak.Localizer
                        Version          string
                        Cfg              *Cfg
                        Topic            string
@@ -465,6 +364,7 @@ func serveHTTP() {
                        LogEnded         bool
                        Entries          []TableEntry
                }{
+                       T:                localizer,
                        Version:          sgblog.Version,
                        Cfg:              cfg,
                        Topic:            topic,
@@ -480,12 +380,44 @@ func serveHTTP() {
                        Entries:          entries,
                })
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusInternalServerError)
                }
+       } else if pathInfo == "/twtxt.txt" {
+               commit, err := repo.CommitObject(*headHash)
+               if err != nil {
+                       makeErr(err, http.StatusInternalServerError)
+               }
+               etagHash.Write([]byte("TWTXT POSTS"))
+               etagHash.Write(commit.Hash[:])
+               checkETag(etagHash)
+               repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
+               if err != nil {
+                       makeErr(err, http.StatusInternalServerError)
+               }
+               for i := 0; i < PageEntries; i++ {
+                       commit, err = repoLog.Next()
+                       if err != nil {
+                               break
+                       }
+                       fmt.Fprintf(
+                               out, "%s\t%s\n",
+                               commit.Author.When.Format(time.RFC3339),
+                               msgSplit(commit.Message)[0],
+                       )
+               }
+               os.Stdout.WriteString("Content-Type: text/plain; charset=utf-8\n")
+               os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
+               if zstdWriter != nil {
+                       os.Stdout.WriteString("Content-Encoding: zstd\n")
+                       zstdWriter.Close()
+               }
+               os.Stdout.WriteString("\n")
+               os.Stdout.Write(outBuf.Bytes())
+               return
        } else if pathInfo == "/"+AtomPostsFeed {
                commit, err := repo.CommitObject(*headHash)
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusInternalServerError)
                }
 
                var topic string
@@ -503,10 +435,7 @@ func serveHTTP() {
                } else {
                        title = fmt.Sprintf("%s (topic: %s)", cfg.Title, topic)
                }
-               idHasher, err := blake2b.New256(nil)
-               if err != nil {
-                       panic(err)
-               }
+               idHasher := blake3.New(32, nil)
                idHasher.Write([]byte("ATOM POSTS"))
                idHasher.Write([]byte(cfg.AtomId))
                idHasher.Write([]byte(topic))
@@ -523,7 +452,7 @@ func serveHTTP() {
 
                repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusInternalServerError)
                }
                var commits CommitIterNext
                if topic == "" {
@@ -531,11 +460,11 @@ func serveHTTP() {
                } else {
                        topicsCache, err := getTopicsCache(cfg, repoLog)
                        if err != nil {
-                               makeErr(err)
+                               makeErr(err, http.StatusInternalServerError)
                        }
                        hashes := topicsCache[topic]
                        if hashes == nil {
-                               makeErr(errors.New("no posts with that topic"))
+                               makeErr(errors.New("no posts with that topic"), http.StatusBadRequest)
                        }
                        commits = &HashesIter{hashes}
                }
@@ -547,22 +476,33 @@ func serveHTTP() {
                        }
                        lines := msgSplit(commit.Message)
                        var categories []atom.Category
-                       for _, topic := range parseTopics(getNote(topicsTree, commit.Hash)) {
+                       for _, topic := range sgblog.ParseTopics(sgblog.GetNote(
+                               repo, topicsTree, commit.Hash,
+                       )) {
                                categories = append(categories, atom.Category{Term: topic})
                        }
                        htmlized := make([]string, 0, len(lines))
                        htmlized = append(htmlized, "<pre>")
                        for _, l := range lines[2:] {
-                               htmlized = append(htmlized, html.EscapeString(l))
+                               htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
                        }
                        htmlized = append(htmlized, "</pre>")
+                       links := []atom.Link{{
+                               Rel:  "alternate",
+                               Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
+                       }}
+                       for _, img := range listImgs(cfg, commit.Hash) {
+                               links = append(links, atom.Link{
+                                       Rel:    "enclosure",
+                                       Href:   "http://" + cfg.ImgDomain + "/" + img.Path,
+                                       Type:   img.Typ,
+                                       Length: uint(img.Size),
+                               })
+                       }
                        feed.Entry = append(feed.Entry, &atom.Entry{
-                               Title: lines[0],
-                               ID:    "urn:uuid:" + bytes2uuid(commit.Hash[:]),
-                               Link: []atom.Link{{
-                                       Rel:  "alternate",
-                                       Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
-                               }},
+                               Title:     lines[0],
+                               ID:        "urn:uuid:" + bytes2uuid(commit.Hash[:]),
+                               Link:      links,
                                Published: atom.Time(commit.Author.When),
                                Updated:   atom.Time(commit.Author.When),
                                Summary:   &atom.Text{Type: "text", Body: lines[0]},
@@ -575,22 +515,20 @@ func serveHTTP() {
                }
                data, err := xml.MarshalIndent(&feed, "", "  ")
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusInternalServerError)
                }
+               out.Write([]byte(xml.Header))
                out.Write(data)
                goto AtomFinish
        } else if pathInfo == "/"+AtomCommentsFeed {
                commit, err := repo.CommitObject(commentsRef.Hash())
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusInternalServerError)
                }
                etagHash.Write([]byte("ATOM COMMENTS"))
                etagHash.Write(commit.Hash[:])
                checkETag(etagHash)
-               idHasher, err := blake2b.New256(nil)
-               if err != nil {
-                       panic(err)
-               }
+               idHasher := blake3.New(32, nil)
                idHasher.Write([]byte("ATOM COMMENTS"))
                idHasher.Write([]byte(cfg.AtomId))
                feed := atom.Feed{
@@ -605,7 +543,7 @@ func serveHTTP() {
                }
                repoLog, err := repo.Log(&git.LogOptions{From: commentsRef.Hash()})
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusInternalServerError)
                }
                for i := 0; i < PageEntries; i++ {
                        commit, err = repoLog.Next()
@@ -614,20 +552,20 @@ func serveHTTP() {
                        }
                        fileStats, err := commit.Stats()
                        if err != nil {
-                               makeErr(err)
+                               makeErr(err, http.StatusInternalServerError)
                        }
                        t, err := commit.Tree()
                        if err != nil {
-                               makeErr(err)
+                               makeErr(err, http.StatusInternalServerError)
                        }
                        commentedHash := plumbing.NewHash(strings.ReplaceAll(
                                fileStats[0].Name, "/", "",
                        ))
                        commit, err = repo.CommitObject(commentedHash)
                        if err != nil {
-                               makeErr(err)
+                               continue
                        }
-                       comments := parseComments(getNote(t, commentedHash))
+                       comments := sgblog.ParseComments(sgblog.GetNote(repo, t, commentedHash))
                        if len(comments) == 0 {
                                continue
                        }
@@ -635,6 +573,12 @@ func serveHTTP() {
                        lines := strings.Split(comments[len(comments)-1], "\n")
                        from := strings.TrimPrefix(lines[0], "From: ")
                        date := strings.TrimPrefix(lines[1], "Date: ")
+                       htmlized := make([]string, 0, len(lines))
+                       htmlized = append(htmlized, "<pre>")
+                       for _, l := range lines[2:] {
+                               htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
+                       }
+                       htmlized = append(htmlized, "</pre>")
                        idHasher.Reset()
                        idHasher.Write([]byte("COMMENT"))
                        idHasher.Write(commit.Hash[:])
@@ -653,24 +597,25 @@ func serveHTTP() {
                                                commit.Hash.String(), "#comment", commentN,
                                        }, ""),
                                }},
-                               Published: atom.TimeStr(date),
-                               Updated:   atom.TimeStr(date),
+                               Published: atom.TimeStr(strings.Replace(date, " ", "T", -1)),
+                               Updated:   atom.TimeStr(strings.Replace(date, " ", "T", -1)),
                                Content: &atom.Text{
-                                       Type: "text",
-                                       Body: strings.Join(lines[2:], "\n"),
+                                       Type: "html",
+                                       Body: strings.Join(htmlized, "\n"),
                                },
                        })
                }
                data, err := xml.MarshalIndent(&feed, "", "  ")
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusInternalServerError)
                }
+               out.Write([]byte(xml.Header))
                out.Write(data)
                goto AtomFinish
        } else if sha1DigestRe.MatchString(pathInfo[1:]) {
                commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1 : 1+sha1.Size*2]))
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusBadRequest)
                }
                for _, data := range etagHashForWeb {
                        etagHash.Write([]byte(data))
@@ -681,9 +626,9 @@ func serveHTTP() {
                        cfg.AtomBaseURL, cfg.URLPrefix, "/",
                        commit.Hash.String(), "/", AtomCommentsFeed,
                }, "")
-               commentsRaw := getNote(commentsTree, commit.Hash)
+               commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash)
                etagHash.Write(commentsRaw)
-               topicsRaw := getNote(topicsTree, commit.Hash)
+               topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash)
                etagHash.Write(topicsRaw)
                if strings.HasSuffix(pathInfo, AtomCommentsFeed) {
                        etagHash.Write([]byte("ATOM COMMENTS"))
@@ -694,7 +639,7 @@ func serveHTTP() {
                                date string
                                body []string
                        }
-                       commentsRaw := parseComments(commentsRaw)
+                       commentsRaw := sgblog.ParseComments(commentsRaw)
                        var toSkip int
                        if len(commentsRaw) > PageEntries {
                                toSkip = len(commentsRaw) - PageEntries
@@ -711,10 +656,7 @@ func serveHTTP() {
                                        body: lines[3:],
                                })
                        }
-                       idHasher, err := blake2b.New256(nil)
-                       if err != nil {
-                               panic(err)
-                       }
+                       idHasher := blake3.New(32, nil)
                        idHasher.Write([]byte("ATOM COMMENTS"))
                        idHasher.Write(commit.Hash[:])
                        feed := atom.Feed{
@@ -733,6 +675,15 @@ func serveHTTP() {
                                idHasher.Write([]byte("COMMENT"))
                                idHasher.Write(commit.Hash[:])
                                idHasher.Write([]byte(comment.n))
+                               htmlized := make([]string, 0, len(comment.body))
+                               htmlized = append(htmlized, "<pre>")
+                               for _, l := range comment.body {
+                                       htmlized = append(
+                                               htmlized,
+                                               lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l),
+                                       )
+                               }
+                               htmlized = append(htmlized, "</pre>")
                                feed.Entry = append(feed.Entry, &atom.Entry{
                                        Title:  fmt.Sprintf("Comment %s by %s", comment.n, comment.from),
                                        Author: &atom.Person{Name: comment.from},
@@ -746,22 +697,27 @@ func serveHTTP() {
                                                        "#comment", comment.n,
                                                }, ""),
                                        }},
-                                       Published: atom.TimeStr(comment.date),
-                                       Updated:   atom.TimeStr(comment.date),
+                                       Published: atom.TimeStr(
+                                               strings.Replace(comment.date, " ", "T", -1),
+                                       ),
+                                       Updated: atom.TimeStr(
+                                               strings.Replace(comment.date, " ", "T", -1),
+                                       ),
                                        Content: &atom.Text{
-                                               Type: "text",
-                                               Body: strings.Join(comment.body, "\n"),
+                                               Type: "html",
+                                               Body: strings.Join(htmlized, "\n"),
                                        },
                                })
                        }
                        data, err := xml.MarshalIndent(&feed, "", "  ")
                        if err != nil {
-                               makeErr(err)
+                               makeErr(err, http.StatusInternalServerError)
                        }
+                       out.Write([]byte(xml.Header))
                        out.Write(data)
                        goto AtomFinish
                }
-               notesRaw := getNote(notesTree, commit.Hash)
+               notesRaw := sgblog.GetNote(repo, notesTree, commit.Hash)
                etagHash.Write(notesRaw)
                checkETag(etagHash)
 
@@ -772,7 +728,7 @@ func serveHTTP() {
                if len(commit.ParentHashes) > 0 {
                        parent = commit.ParentHashes[0].String()
                }
-               commentsParsed := parseComments(commentsRaw)
+               commentsParsed := sgblog.ParseComments(commentsRaw)
                comments := make([]CommentEntry, 0, len(commentsParsed))
                for _, comment := range commentsParsed {
                        lines := strings.Split(comment, "\n")
@@ -783,11 +739,9 @@ func serveHTTP() {
                        notesLines = strings.Split(string(notesRaw), "\n")
                }
 
-               tmpl := template.New("entry")
-               tmpl = tmpl.Funcs(template.FuncMap{"lineURLize": lineURLizeInTemplate})
-               tmpl = template.Must(tmpl.Parse(TmplHTMLEntry))
-               os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
-               err = tmpl.Execute(out, struct {
+               os.Stdout.Write([]byte(startHeader(etagHash, zstdWriter != nil)))
+               err = TmplHTMLEntry.Execute(out, struct {
+                       T               *spreak.Localizer
                        Version         string
                        Cfg             *Cfg
                        Title           string
@@ -800,7 +754,9 @@ func serveHTTP() {
                        NoteLines       []string
                        Comments        []CommentEntry
                        Topics          []string
+                       Imgs            []Img
                }{
+                       T:               localizer,
                        Version:         sgblog.Version,
                        Cfg:             cfg,
                        Title:           title,
@@ -812,27 +768,28 @@ func serveHTTP() {
                        Lines:           lines[2:],
                        NoteLines:       notesLines,
                        Comments:        comments,
-                       Topics:          parseTopics(topicsRaw),
+                       Topics:          sgblog.ParseTopics(topicsRaw),
+                       Imgs:            listImgs(cfg, commit.Hash),
                })
                if err != nil {
-                       makeErr(err)
+                       makeErr(err, http.StatusInternalServerError)
                }
        } else {
-               makeErr(errors.New("unknown URL action"))
+               makeErr(errors.New("unknown URL action"), http.StatusNotFound)
        }
        out.Write([]byte("</body></html>\n"))
-       if gzipWriter != nil {
-               gzipWriter.Close()
+       if zstdWriter != nil {
+               zstdWriter.Close()
        }
        os.Stdout.Write(outBuf.Bytes())
        return
 
 AtomFinish:
-       os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
+       os.Stdout.WriteString("Content-Type: application/atom+xml; charset=utf-8\n")
        os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
-       if gzipWriter != nil {
-               os.Stdout.WriteString("Content-Encoding: gzip\n")
-               gzipWriter.Close()
+       if zstdWriter != nil {
+               os.Stdout.WriteString("Content-Encoding: zstd\n")
+               zstdWriter.Close()
        }
        os.Stdout.WriteString("\n")
        os.Stdout.Write(outBuf.Bytes())