]> Sergey Matveev's repositories - sgblog.git/blob - cmd/sgblog/http.go
URLize feed entry, not just escape it
[sgblog.git] / cmd / sgblog / http.go
1 /*
2 SGBlog -- Git-backed CGI/inetd blogging/phlogging engine
3 Copyright (C) 2020 Sergey Matveev <stargrave@stargrave.org>
4
5 This program is free software: you can redistribute it and/or modify
6 it under the terms of the GNU Affero General Public License as
7 published by the Free Software Foundation, version 3 of the License.
8
9 This program is distributed in the hope that it will be useful,
10 but WITHOUT ANY WARRANTY; without even the implied warranty of
11 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 GNU General Public License for more details.
13
14 You should have received a copy of the GNU Affero General Public License
15 along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 */
17
18 package main
19
20 import (
21         "bytes"
22         "compress/gzip"
23         "crypto/sha1"
24         "encoding/hex"
25         "encoding/json"
26         "encoding/xml"
27         "errors"
28         "fmt"
29         "hash"
30         "html"
31         "io"
32         "io/ioutil"
33         "log"
34         "net/url"
35         "os"
36         "strconv"
37         "strings"
38         "text/template"
39
40         "github.com/go-git/go-git/v5"
41         "github.com/go-git/go-git/v5/plumbing"
42         "github.com/go-git/go-git/v5/plumbing/object"
43         "github.com/hjson/hjson-go"
44         "go.stargrave.org/sgblog"
45         "go.stargrave.org/sgblog/cmd/sgblog/atom"
46         "golang.org/x/crypto/blake2b"
47 )
48
49 const (
50         AtomPostsFeed    = "feed.atom"
51         AtomCommentsFeed = "comments.atom"
52         TmplHTMLIndex    = `<html>
53 <head>
54         <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
55         <meta name="generator" content="SGBlog {{.Version}}">
56         <title>{{.Cfg.Title}} {{if .Topic}}(topic: {{.Topic}}) {{end}}({{.Offset}}-{{.OffsetNext}})</title>
57         {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
58         {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end}}
59         {{range .Cfg.GitURLs}}<link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
60         <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
61         <link rel="alternate" title="Posts feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomPostsFeed}}{{if .Topic}}?topic={{.Topic}}{{end}}" type="application/atom+xml">
62         {{if .CommentsEnabled}}<link rel="alternate" title="Comments feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomCommentsFeed}}" type="application/atom+xml">{{end}}
63         {{if .Offset}}<link rel="prev" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="prev">{{end}}
64         {{if not .LogEnded}}<link rel="next" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="next">{{end}}
65 </head>
66 <body>
67 {{with .Cfg.AboutURL}}[<a href="{{.}}">about</a>]{{end}}
68 {{block "links" .}}
69 {{if .Offset}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}">prev</a>]{{end}}
70 {{if not .LogEnded}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}">next</a>]{{end}}
71 {{end}}
72 {{- $Cfg := .Cfg -}}
73 {{if .Topics}}<hr/>
74 Topics: [<tt><a href="{{$Cfg.URLPrefix}}/">ALL</a></tt>]
75 {{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]
76 {{end}}
77 {{end}}
78 {{- $TopicsEnabled := .TopicsEnabled -}}
79 {{- $datePrev := "0001-01-01" -}}
80 <table border=1>
81 <tr>
82         <th>N</th><th>When</th><th>Title</th>
83         <th size="5%"><a title="Lines">L</a></th>
84         <th size="5%"><a title="Comments">C</a></th>
85         <th>Linked to</th>
86         {{if .TopicsEnabled}}<th>Topics</th>{{end}}
87 </tr>
88 {{range .Entries -}}
89 {{- $dateCur := .Commit.Author.When.Format "2006-01-02" -}}
90 {{- if ne $dateCur $datePrev -}}
91         <tr><td colspan={{if $TopicsEnabled}}7{{else}}7{{end}}><center><tt>{{$dateCur}}</tt></center></td></tr>
92         {{- $datePrev = $dateCur -}}
93 {{- end -}}
94 <tr>
95         <td>{{.Num}}</td>
96         <td><tt>{{.Commit.Author.When.Format "15:04"}}</tt></td>
97         <td><a href="{{$Cfg.URLPrefix}}/{{.Commit.Hash.String}}">{{.Title}}</a></td>
98         <td>{{.LinesNum}}</td>
99         <td>{{if .CommentsNum}}{{.CommentsNum}}{{else}}&nbsp;{{end}}</td>
100         <td>{{if .DomainURLs}}{{range .DomainURLs}} {{.}} {{end}}{{else}}&nbsp;{{end}}</td>
101         {{if $TopicsEnabled}}<td>{{if .Topics}}{{range .Topics}} <a href="{{$Cfg.URLPrefix}}/?topic={{.}}">{{.}}</a> {{end}}{{else}}&nbsp;{{end}}</td>{{end}}
102 </tr>
103 {{end}}</table>
104 {{template "links" .}}
105 </body>
106 </html>
107 `
108         TmplHTMLEntry = `{{$Cfg := .Cfg}}<html>
109 <head>
110         <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
111         <meta name="generator" content="SGBlog {{.Version}}">
112         <title>{{.Title}} ({{.When}})</title>
113         {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
114         {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end -}}
115         {{- range .Cfg.GitURLs}}
116         <link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
117         <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
118         <link rel="alternate" title="Comments feed" href="{{.AtomCommentsURL}}" type="application/atom+xml">
119         {{if .Parent}}<link rel="prev" href="{{.Cfg.URLPrefix}}/{{.Parent}}" title="prev">{{end}}
120 </head>
121 <body>
122 {{with .Cfg.AboutURL}}[<a href="{{.}}">about</a>]{{end}}
123 [<a href="{{.Cfg.URLPrefix}}/">index</a>]
124 {{if .Parent}}[<a href="{{.Cfg.URLPrefix}}/{{.Parent}}">prev</a>]{{end}}
125 [<tt><a title="When">{{.When}}</a></tt>]
126 [<tt><a title="What">{{.Commit.Hash.String}}</a></tt>]
127
128 {{if .Topics}}
129 <hr/>
130 Topics: {{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]{{end}}
131 {{end}}
132
133 <hr/>
134 <h2>{{.Title}}</h2>
135 <pre>
136 {{range .Lines}}{{. | lineURLize $Cfg.URLPrefix}}
137 {{end}}</pre>
138 <hr/>
139
140 {{if .NoteLines}}Note:<pre>
141 {{range .NoteLines}}{{. | lineURLize $Cfg.URLPrefix}}
142 {{end}}</pre>
143 <hr/>{{end}}
144
145 {{if .Cfg.CommentsEmail}}[<a href="mailto:{{.Cfg.CommentsEmail}}?subject={{.TitleEscaped}}">leave comment</a>]{{end}}
146
147 <dl>{{range $idx, $comment := .Comments}}
148 <dt><a name="comment{{$idx}}"><a href="#comment{{$idx}}">comment {{$idx}}</a>:</dt>
149 <dd><pre>
150 {{range $comment.HeaderLines}}{{.}}
151 {{end}}{{range $comment.BodyLines}}{{. | lineURLize $Cfg.URLPrefix}}
152 {{end}}</pre></dd>
153 {{end}}</dl>
154
155 </body>
156 </html>
157 `
158 )
159
160 var (
161         renderableSchemes = map[string]struct{}{
162                 "ftp":    struct{}{},
163                 "gopher": struct{}{},
164                 "http":   struct{}{},
165                 "https":  struct{}{},
166                 "telnet": struct{}{},
167         }
168 )
169
170 type TableEntry struct {
171         Commit      *object.Commit
172         CommentsRaw []byte
173         TopicsRaw   []byte
174         Num         int
175         Title       string
176         LinesNum    int
177         CommentsNum int
178         DomainURLs  []string
179         Topics      []string
180 }
181
182 type CommentEntry struct {
183         HeaderLines []string
184         BodyLines   []string
185 }
186
187 func makeA(href, text string) string {
188         return `<a href="` + href + `">` + text + `</a>`
189 }
190
191 func etagString(etag hash.Hash) string {
192         return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
193 }
194
195 func urlParse(what string) *url.URL {
196         if u, err := url.ParseRequestURI(what); err == nil {
197                 if _, exists := renderableSchemes[u.Scheme]; exists {
198                         return u
199                 }
200         }
201         return nil
202 }
203
204 func lineURLize(urlPrefix, line string) string {
205         cols := strings.Split(html.EscapeString(line), " ")
206         for i, col := range cols {
207                 if u := urlParse(col); u != nil {
208                         cols[i] = makeA(col, col)
209                         continue
210                 }
211                 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
212         }
213         return strings.Join(cols, " ")
214 }
215
216 func lineURLizeInTemplate(urlPrefix, line interface{}) string {
217         return lineURLize(urlPrefix.(string), line.(string))
218 }
219
220 func startHeader(etag hash.Hash, gziped bool) string {
221         lines := []string{
222                 "Content-Type: text/html; charset=UTF-8",
223                 "ETag: " + etagString(etag),
224         }
225         if gziped {
226                 lines = append(lines, "Content-Encoding: gzip")
227         }
228         lines = append(lines, "")
229         lines = append(lines, "")
230         return strings.Join(lines, "\n")
231 }
232
233 func makeErr(err error) {
234         fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
235         fmt.Println(err)
236         panic(err)
237 }
238
239 func checkETag(etag hash.Hash) {
240         ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
241         if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
242                 fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch)
243                 os.Exit(0)
244         }
245 }
246
247 func bytes2uuid(b []byte) string {
248         raw := new([16]byte)
249         copy(raw[:], b)
250         raw[6] = (raw[6] & 0x0F) | uint8(4<<4) // version 4
251         return fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:])
252 }
253
254 type CommitIterNext interface {
255         Next() (*object.Commit, error)
256 }
257
258 func serveHTTP() {
259         cfgPath := os.Getenv("SGBLOG_CFG")
260         if cfgPath == "" {
261                 log.Fatalln("SGBLOG_CFG is not set")
262         }
263         cfgRaw, err := ioutil.ReadFile(cfgPath)
264         if err != nil {
265                 makeErr(err)
266         }
267         var cfgGeneral map[string]interface{}
268         if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
269                 makeErr(err)
270         }
271         cfgRaw, err = json.Marshal(cfgGeneral)
272         if err != nil {
273                 makeErr(err)
274         }
275         var cfg *Cfg
276         if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
277                 makeErr(err)
278         }
279         pathInfo, exists := os.LookupEnv("PATH_INFO")
280         if !exists {
281                 pathInfo = "/"
282         }
283         queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
284         if err != nil {
285                 makeErr(err)
286         }
287
288         etagHash, err := blake2b.New256(nil)
289         if err != nil {
290                 panic(err)
291         }
292         for _, s := range []string{
293                 "SGBLOG",
294                 sgblog.Version,
295                 cfg.GitPath,
296                 cfg.Branch,
297                 cfg.Title,
298                 cfg.URLPrefix,
299                 cfg.AtomBaseURL,
300                 cfg.AtomId,
301                 cfg.AtomAuthor,
302         } {
303                 if _, err = etagHash.Write([]byte(s)); err != nil {
304                         panic(err)
305                 }
306         }
307         etagHashForWeb := []string{
308                 cfg.CSS,
309                 cfg.Webmaster,
310                 cfg.AboutURL,
311                 cfg.CommentsNotesRef,
312                 cfg.CommentsEmail,
313         }
314         for _, gitURL := range cfg.GitURLs {
315                 etagHashForWeb = append(etagHashForWeb, gitURL)
316         }
317
318         headHash, err := initRepo(cfg)
319         if err != nil {
320                 makeErr(err)
321         }
322
323         if notes, err := repo.Notes(); err == nil {
324                 var notesRef *plumbing.Reference
325                 var commentsRef *plumbing.Reference
326                 notes.ForEach(func(ref *plumbing.Reference) error {
327                         switch string(ref.Name()) {
328                         case "refs/notes/commits":
329                                 notesRef = ref
330                         case cfg.CommentsNotesRef:
331                                 commentsRef = ref
332                         }
333                         return nil
334                 })
335                 if notesRef != nil {
336                         if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
337                                 notesTree, _ = commentsCommit.Tree()
338                         }
339                 }
340                 if commentsRef != nil {
341                         if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
342                                 commentsTree, _ = commentsCommit.Tree()
343                         }
344                 }
345         }
346
347         var outBuf bytes.Buffer
348         var out io.Writer
349         out = &outBuf
350         var gzipWriter *gzip.Writer
351         acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
352         for _, encoding := range strings.Split(acceptEncoding, ", ") {
353                 if encoding == "gzip" {
354                         gzipWriter = gzip.NewWriter(&outBuf)
355                         out = gzipWriter
356                 }
357         }
358
359         if pathInfo == "/" {
360                 offset := 0
361                 if offsetRaw, exists := queryValues["offset"]; exists {
362                         offset, err = strconv.Atoi(offsetRaw[0])
363                         if err != nil {
364                                 makeErr(err)
365                         }
366                 }
367                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
368                 if err != nil {
369                         makeErr(err)
370                 }
371                 topicsCache, err := getTopicsCache(cfg, repoLog)
372                 if err != nil {
373                         makeErr(err)
374                 }
375                 repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
376                 if err != nil {
377                         makeErr(err)
378                 }
379
380                 commitN := 0
381                 var commits CommitIterNext
382                 var topic string
383                 if t, exists := queryValues["topic"]; exists {
384                         topic = t[0]
385                         hashes := topicsCache[topic]
386                         if hashes == nil {
387                                 makeErr(errors.New("no posts with that topic"))
388                         }
389                         if len(hashes) > offset {
390                                 hashes = hashes[offset:]
391                                 commitN += offset
392                         }
393                         commits = &HashesIter{hashes}
394                 } else {
395                         for i := 0; i < offset; i++ {
396                                 if _, err = repoLog.Next(); err != nil {
397                                         break
398                                 }
399                                 commitN++
400                         }
401                         commits = repoLog
402                 }
403
404                 entries := make([]TableEntry, 0, PageEntries)
405                 logEnded := false
406                 for _, data := range etagHashForWeb {
407                         etagHash.Write([]byte(data))
408                 }
409                 etagHash.Write([]byte("INDEX"))
410                 etagHash.Write([]byte(topic))
411                 for i := 0; i < PageEntries; i++ {
412                         commit, err := commits.Next()
413                         if err != nil {
414                                 logEnded = true
415                                 break
416                         }
417                         etagHash.Write(commit.Hash[:])
418                         commentsRaw := getNote(commentsTree, commit.Hash)
419                         etagHash.Write(commentsRaw)
420                         topicsRaw := getNote(topicsTree, commit.Hash)
421                         etagHash.Write(topicsRaw)
422                         entries = append(entries, TableEntry{
423                                 Commit:      commit,
424                                 CommentsRaw: commentsRaw,
425                                 TopicsRaw:   topicsRaw,
426                         })
427                 }
428                 checkETag(etagHash)
429
430                 for i, entry := range entries {
431                         commitN++
432                         entry.Num = commitN
433                         lines := msgSplit(entry.Commit.Message)
434                         entry.Title = lines[0]
435                         entry.LinesNum = len(lines) - 2
436                         for _, line := range lines[2:] {
437                                 u := urlParse(line)
438                                 if u == nil {
439                                         break
440                                 }
441                                 entry.DomainURLs = append(entry.DomainURLs, makeA(line, u.Host))
442                         }
443                         entry.CommentsNum = len(parseComments(entry.CommentsRaw))
444                         entry.Topics = parseTopics(entry.TopicsRaw)
445                         entries[i] = entry
446                 }
447                 offsetPrev := offset - PageEntries
448                 if offsetPrev < 0 {
449                         offsetPrev = 0
450                 }
451                 tmpl := template.Must(template.New("index").Parse(TmplHTMLIndex))
452                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
453                 err = tmpl.Execute(out, struct {
454                         Version          string
455                         Cfg              *Cfg
456                         Topic            string
457                         TopicsEnabled    bool
458                         Topics           []string
459                         CommentsEnabled  bool
460                         AtomPostsFeed    string
461                         AtomCommentsFeed string
462                         Offset           int
463                         OffsetPrev       int
464                         OffsetNext       int
465                         LogEnded         bool
466                         Entries          []TableEntry
467                 }{
468                         Version:          sgblog.Version,
469                         Cfg:              cfg,
470                         Topic:            topic,
471                         TopicsEnabled:    topicsTree != nil,
472                         Topics:           topicsCache.Topics(),
473                         CommentsEnabled:  commentsTree != nil,
474                         AtomPostsFeed:    AtomPostsFeed,
475                         AtomCommentsFeed: AtomCommentsFeed,
476                         Offset:           offset,
477                         OffsetPrev:       offsetPrev,
478                         OffsetNext:       offset + PageEntries,
479                         LogEnded:         logEnded,
480                         Entries:          entries,
481                 })
482                 if err != nil {
483                         makeErr(err)
484                 }
485         } else if pathInfo == "/"+AtomPostsFeed {
486                 commit, err := repo.CommitObject(*headHash)
487                 if err != nil {
488                         makeErr(err)
489                 }
490
491                 var topic string
492                 if t, exists := queryValues["topic"]; exists {
493                         topic = t[0]
494                 }
495
496                 etagHash.Write([]byte("ATOM POSTS"))
497                 etagHash.Write([]byte(topic))
498                 etagHash.Write(commit.Hash[:])
499                 checkETag(etagHash)
500                 var title string
501                 if topic == "" {
502                         title = cfg.Title
503                 } else {
504                         title = fmt.Sprintf("%s (topic: %s)", cfg.Title, topic)
505                 }
506                 idHasher, err := blake2b.New256(nil)
507                 if err != nil {
508                         panic(err)
509                 }
510                 idHasher.Write([]byte("ATOM POSTS"))
511                 idHasher.Write([]byte(cfg.AtomId))
512                 idHasher.Write([]byte(topic))
513                 feed := atom.Feed{
514                         Title:   title,
515                         ID:      "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
516                         Updated: atom.Time(commit.Author.When),
517                         Link: []atom.Link{{
518                                 Rel:  "self",
519                                 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomPostsFeed,
520                         }},
521                         Author: &atom.Person{Name: cfg.AtomAuthor},
522                 }
523
524                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
525                 if err != nil {
526                         makeErr(err)
527                 }
528                 var commits CommitIterNext
529                 if topic == "" {
530                         commits = repoLog
531                 } else {
532                         topicsCache, err := getTopicsCache(cfg, repoLog)
533                         if err != nil {
534                                 makeErr(err)
535                         }
536                         hashes := topicsCache[topic]
537                         if hashes == nil {
538                                 makeErr(errors.New("no posts with that topic"))
539                         }
540                         commits = &HashesIter{hashes}
541                 }
542
543                 for i := 0; i < PageEntries; i++ {
544                         commit, err = commits.Next()
545                         if err != nil {
546                                 break
547                         }
548                         lines := msgSplit(commit.Message)
549                         var categories []atom.Category
550                         for _, topic := range parseTopics(getNote(topicsTree, commit.Hash)) {
551                                 categories = append(categories, atom.Category{Term: topic})
552                         }
553                         htmlized := make([]string, 0, len(lines))
554                         htmlized = append(htmlized, "<pre>")
555                         for _, l := range lines[2:] {
556                                 htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
557                         }
558                         htmlized = append(htmlized, "</pre>")
559                         feed.Entry = append(feed.Entry, &atom.Entry{
560                                 Title: lines[0],
561                                 ID:    "urn:uuid:" + bytes2uuid(commit.Hash[:]),
562                                 Link: []atom.Link{{
563                                         Rel:  "alternate",
564                                         Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
565                                 }},
566                                 Published: atom.Time(commit.Author.When),
567                                 Updated:   atom.Time(commit.Author.When),
568                                 Summary:   &atom.Text{Type: "text", Body: lines[0]},
569                                 Content: &atom.Text{
570                                         Type: "html",
571                                         Body: strings.Join(htmlized, "\n"),
572                                 },
573                                 Category: categories,
574                         })
575                 }
576                 data, err := xml.MarshalIndent(&feed, "", "  ")
577                 if err != nil {
578                         makeErr(err)
579                 }
580                 out.Write(data)
581                 goto AtomFinish
582         } else if pathInfo == "/"+AtomCommentsFeed {
583                 commit, err := repo.CommitObject(commentsRef.Hash())
584                 if err != nil {
585                         makeErr(err)
586                 }
587                 etagHash.Write([]byte("ATOM COMMENTS"))
588                 etagHash.Write(commit.Hash[:])
589                 checkETag(etagHash)
590                 idHasher, err := blake2b.New256(nil)
591                 if err != nil {
592                         panic(err)
593                 }
594                 idHasher.Write([]byte("ATOM COMMENTS"))
595                 idHasher.Write([]byte(cfg.AtomId))
596                 feed := atom.Feed{
597                         Title:   cfg.Title + " comments",
598                         ID:      "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
599                         Updated: atom.Time(commit.Author.When),
600                         Link: []atom.Link{{
601                                 Rel:  "self",
602                                 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomCommentsFeed,
603                         }},
604                         Author: &atom.Person{Name: cfg.AtomAuthor},
605                 }
606                 repoLog, err := repo.Log(&git.LogOptions{From: commentsRef.Hash()})
607                 if err != nil {
608                         makeErr(err)
609                 }
610                 for i := 0; i < PageEntries; i++ {
611                         commit, err = repoLog.Next()
612                         if err != nil {
613                                 break
614                         }
615                         fileStats, err := commit.Stats()
616                         if err != nil {
617                                 makeErr(err)
618                         }
619                         t, err := commit.Tree()
620                         if err != nil {
621                                 makeErr(err)
622                         }
623                         commentedHash := plumbing.NewHash(strings.ReplaceAll(
624                                 fileStats[0].Name, "/", "",
625                         ))
626                         commit, err = repo.CommitObject(commentedHash)
627                         if err != nil {
628                                 makeErr(err)
629                         }
630                         comments := parseComments(getNote(t, commentedHash))
631                         if len(comments) == 0 {
632                                 continue
633                         }
634                         commentN := strconv.Itoa(len(comments) - 1)
635                         lines := strings.Split(comments[len(comments)-1], "\n")
636                         from := strings.TrimPrefix(lines[0], "From: ")
637                         date := strings.TrimPrefix(lines[1], "Date: ")
638                         idHasher.Reset()
639                         idHasher.Write([]byte("COMMENT"))
640                         idHasher.Write(commit.Hash[:])
641                         idHasher.Write([]byte(commentN))
642                         feed.Entry = append(feed.Entry, &atom.Entry{
643                                 Title: fmt.Sprintf(
644                                         "Comment %s for \"%s\" by %s",
645                                         commentN, msgSplit(commit.Message)[0], from,
646                                 ),
647                                 Author: &atom.Person{Name: from},
648                                 ID:     "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
649                                 Link: []atom.Link{{
650                                         Rel: "alternate",
651                                         Href: strings.Join([]string{
652                                                 cfg.AtomBaseURL, cfg.URLPrefix, "/",
653                                                 commit.Hash.String(), "#comment", commentN,
654                                         }, ""),
655                                 }},
656                                 Published: atom.TimeStr(date),
657                                 Updated:   atom.TimeStr(date),
658                                 Content: &atom.Text{
659                                         Type: "text",
660                                         Body: strings.Join(lines[2:], "\n"),
661                                 },
662                         })
663                 }
664                 data, err := xml.MarshalIndent(&feed, "", "  ")
665                 if err != nil {
666                         makeErr(err)
667                 }
668                 out.Write(data)
669                 goto AtomFinish
670         } else if sha1DigestRe.MatchString(pathInfo[1:]) {
671                 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1 : 1+sha1.Size*2]))
672                 if err != nil {
673                         makeErr(err)
674                 }
675                 for _, data := range etagHashForWeb {
676                         etagHash.Write([]byte(data))
677                 }
678                 etagHash.Write([]byte("ENTRY"))
679                 etagHash.Write(commit.Hash[:])
680                 atomCommentsURL := strings.Join([]string{
681                         cfg.AtomBaseURL, cfg.URLPrefix, "/",
682                         commit.Hash.String(), "/", AtomCommentsFeed,
683                 }, "")
684                 commentsRaw := getNote(commentsTree, commit.Hash)
685                 etagHash.Write(commentsRaw)
686                 topicsRaw := getNote(topicsTree, commit.Hash)
687                 etagHash.Write(topicsRaw)
688                 if strings.HasSuffix(pathInfo, AtomCommentsFeed) {
689                         etagHash.Write([]byte("ATOM COMMENTS"))
690                         checkETag(etagHash)
691                         type Comment struct {
692                                 n    string
693                                 from string
694                                 date string
695                                 body []string
696                         }
697                         commentsRaw := parseComments(commentsRaw)
698                         var toSkip int
699                         if len(commentsRaw) > PageEntries {
700                                 toSkip = len(commentsRaw) - PageEntries
701                         }
702                         comments := make([]Comment, 0, len(commentsRaw)-toSkip)
703                         for i := len(commentsRaw) - 1; i >= toSkip; i-- {
704                                 lines := strings.Split(commentsRaw[i], "\n")
705                                 from := strings.TrimPrefix(lines[0], "From: ")
706                                 date := strings.TrimPrefix(lines[1], "Date: ")
707                                 comments = append(comments, Comment{
708                                         n:    strconv.Itoa(i),
709                                         from: from,
710                                         date: strings.Replace(date, " ", "T", 1),
711                                         body: lines[3:],
712                                 })
713                         }
714                         idHasher, err := blake2b.New256(nil)
715                         if err != nil {
716                                 panic(err)
717                         }
718                         idHasher.Write([]byte("ATOM COMMENTS"))
719                         idHasher.Write(commit.Hash[:])
720                         feed := atom.Feed{
721                                 Title:  fmt.Sprintf("\"%s\" comments", msgSplit(commit.Message)[0]),
722                                 ID:     "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
723                                 Link:   []atom.Link{{Rel: "self", Href: atomCommentsURL}},
724                                 Author: &atom.Person{Name: cfg.AtomAuthor},
725                         }
726                         if len(comments) > 0 {
727                                 feed.Updated = atom.TimeStr(comments[0].date)
728                         } else {
729                                 feed.Updated = atom.Time(commit.Author.When)
730                         }
731                         for _, comment := range comments {
732                                 idHasher.Reset()
733                                 idHasher.Write([]byte("COMMENT"))
734                                 idHasher.Write(commit.Hash[:])
735                                 idHasher.Write([]byte(comment.n))
736                                 feed.Entry = append(feed.Entry, &atom.Entry{
737                                         Title:  fmt.Sprintf("Comment %s by %s", comment.n, comment.from),
738                                         Author: &atom.Person{Name: comment.from},
739                                         ID:     "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
740                                         Link: []atom.Link{{
741                                                 Rel: "alternate",
742                                                 Href: strings.Join([]string{
743                                                         cfg.AtomBaseURL,
744                                                         cfg.URLPrefix, "/",
745                                                         commit.Hash.String(),
746                                                         "#comment", comment.n,
747                                                 }, ""),
748                                         }},
749                                         Published: atom.TimeStr(comment.date),
750                                         Updated:   atom.TimeStr(comment.date),
751                                         Content: &atom.Text{
752                                                 Type: "text",
753                                                 Body: strings.Join(comment.body, "\n"),
754                                         },
755                                 })
756                         }
757                         data, err := xml.MarshalIndent(&feed, "", "  ")
758                         if err != nil {
759                                 makeErr(err)
760                         }
761                         out.Write(data)
762                         goto AtomFinish
763                 }
764                 notesRaw := getNote(notesTree, commit.Hash)
765                 etagHash.Write(notesRaw)
766                 checkETag(etagHash)
767
768                 lines := msgSplit(commit.Message)
769                 title := lines[0]
770                 when := commit.Author.When.Format(sgblog.WhenFmt)
771                 var parent string
772                 if len(commit.ParentHashes) > 0 {
773                         parent = commit.ParentHashes[0].String()
774                 }
775                 commentsParsed := parseComments(commentsRaw)
776                 comments := make([]CommentEntry, 0, len(commentsParsed))
777                 for _, comment := range commentsParsed {
778                         lines := strings.Split(comment, "\n")
779                         comments = append(comments, CommentEntry{lines[:3], lines[3:]})
780                 }
781                 var notesLines []string
782                 if len(notesRaw) > 0 {
783                         notesLines = strings.Split(string(notesRaw), "\n")
784                 }
785
786                 tmpl := template.New("entry")
787                 tmpl = tmpl.Funcs(template.FuncMap{"lineURLize": lineURLizeInTemplate})
788                 tmpl = template.Must(tmpl.Parse(TmplHTMLEntry))
789                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
790                 err = tmpl.Execute(out, struct {
791                         Version         string
792                         Cfg             *Cfg
793                         Title           string
794                         TitleEscaped    string
795                         When            string
796                         AtomCommentsURL string
797                         Parent          string
798                         Commit          *object.Commit
799                         Lines           []string
800                         NoteLines       []string
801                         Comments        []CommentEntry
802                         Topics          []string
803                 }{
804                         Version:         sgblog.Version,
805                         Cfg:             cfg,
806                         Title:           title,
807                         TitleEscaped:    url.PathEscape(fmt.Sprintf("Re: %s (%s)", title, commit.Hash)),
808                         When:            when,
809                         AtomCommentsURL: atomCommentsURL,
810                         Parent:          parent,
811                         Commit:          commit,
812                         Lines:           lines[2:],
813                         NoteLines:       notesLines,
814                         Comments:        comments,
815                         Topics:          parseTopics(topicsRaw),
816                 })
817                 if err != nil {
818                         makeErr(err)
819                 }
820         } else {
821                 makeErr(errors.New("unknown URL action"))
822         }
823         out.Write([]byte("</body></html>\n"))
824         if gzipWriter != nil {
825                 gzipWriter.Close()
826         }
827         os.Stdout.Write(outBuf.Bytes())
828         return
829
830 AtomFinish:
831         os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
832         os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
833         if gzipWriter != nil {
834                 os.Stdout.WriteString("Content-Encoding: gzip\n")
835                 gzipWriter.Close()
836         }
837         os.Stdout.WriteString("\n")
838         os.Stdout.Write(outBuf.Bytes())
839 }