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