]> Sergey Matveev's repositories - sgblog.git/blob - cmd/sgblog/http.go
Unify copyright comment format
[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         "compress/gzip"
21         "crypto/sha1"
22         _ "embed"
23         "encoding/hex"
24         "encoding/xml"
25         "errors"
26         "fmt"
27         "hash"
28         "html"
29         "io"
30         "log"
31         "net/http"
32         "net/url"
33         "os"
34         "strconv"
35         "strings"
36         "text/template"
37         "time"
38
39         "github.com/go-git/go-git/v5"
40         "github.com/go-git/go-git/v5/plumbing"
41         "github.com/go-git/go-git/v5/plumbing/object"
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, gziped bool) string {
130         lines := []string{
131                 "Content-Type: text/html; charset=utf-8",
132                 "ETag: " + etagString(etag),
133         }
134         if gziped {
135                 lines = append(lines, "Content-Encoding: gzip")
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.GitPath,
193                 cfg.Branch,
194                 cfg.Title,
195                 cfg.URLPrefix,
196                 cfg.AtomBaseURL,
197                 cfg.AtomId,
198                 cfg.AtomAuthor,
199         } {
200                 if _, err = etagHash.Write([]byte(s)); err != nil {
201                         panic(err)
202                 }
203         }
204         etagHashForWeb := []string{
205                 cfg.CSS,
206                 cfg.Webmaster,
207                 cfg.AboutURL,
208                 cfg.CommentsNotesRef,
209                 cfg.CommentsEmail,
210         }
211         etagHashForWeb = append(etagHashForWeb, cfg.GitURLs...)
212
213         headHash, err := initRepo(cfg)
214         if err != nil {
215                 makeErr(err, http.StatusInternalServerError)
216         }
217
218         if notes, err := repo.Notes(); err == nil {
219                 var notesRef *plumbing.Reference
220                 var commentsRef *plumbing.Reference
221                 notes.ForEach(func(ref *plumbing.Reference) error {
222                         switch string(ref.Name()) {
223                         case "refs/notes/commits":
224                                 notesRef = ref
225                         case cfg.CommentsNotesRef:
226                                 commentsRef = ref
227                         }
228                         return nil
229                 })
230                 if notesRef != nil {
231                         if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
232                                 notesTree, _ = commentsCommit.Tree()
233                         }
234                 }
235                 if commentsRef != nil {
236                         if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
237                                 commentsTree, _ = commentsCommit.Tree()
238                         }
239                 }
240         }
241
242         var outBuf bytes.Buffer
243         var out io.Writer
244         out = &outBuf
245         var gzipWriter *gzip.Writer
246         acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
247         for _, encoding := range strings.Split(acceptEncoding, ", ") {
248                 if encoding == "gzip" {
249                         gzipWriter = gzip.NewWriter(&outBuf)
250                         out = gzipWriter
251                 }
252         }
253
254         if pathInfo == "/" {
255                 offset := 0
256                 if offsetRaw, exists := queryValues["offset"]; exists {
257                         offset, err = strconv.Atoi(offsetRaw[0])
258                         if err != nil {
259                                 makeErr(err, http.StatusBadRequest)
260                         }
261                 }
262                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
263                 if err != nil {
264                         makeErr(err, http.StatusInternalServerError)
265                 }
266                 topicsCache, err := getTopicsCache(cfg, repoLog)
267                 if err != nil {
268                         makeErr(err, http.StatusInternalServerError)
269                 }
270                 repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
271                 if err != nil {
272                         makeErr(err, http.StatusInternalServerError)
273                 }
274
275                 commitN := 0
276                 var commits CommitIterNext
277                 var topic string
278                 if t, exists := queryValues["topic"]; exists {
279                         topic = t[0]
280                         hashes := topicsCache[topic]
281                         if hashes == nil {
282                                 makeErr(errors.New("no posts with that topic"), http.StatusBadRequest)
283                         }
284                         if len(hashes) > offset {
285                                 hashes = hashes[offset:]
286                                 commitN += offset
287                         }
288                         commits = &HashesIter{hashes}
289                 } else {
290                         for i := 0; i < offset; i++ {
291                                 if _, err = repoLog.Next(); err != nil {
292                                         break
293                                 }
294                                 commitN++
295                         }
296                         commits = repoLog
297                 }
298
299                 entries := make([]TableEntry, 0, PageEntries)
300                 logEnded := false
301                 for _, data := range etagHashForWeb {
302                         etagHash.Write([]byte(data))
303                 }
304                 etagHash.Write([]byte("INDEX"))
305                 etagHash.Write([]byte(topic))
306                 for i := 0; i < PageEntries; i++ {
307                         commit, err := commits.Next()
308                         if err != nil {
309                                 logEnded = true
310                                 break
311                         }
312                         etagHash.Write(commit.Hash[:])
313                         commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash)
314                         etagHash.Write(commentsRaw)
315                         topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash)
316                         etagHash.Write(topicsRaw)
317                         entries = append(entries, TableEntry{
318                                 Commit:      commit,
319                                 CommentsRaw: commentsRaw,
320                                 TopicsRaw:   topicsRaw,
321                         })
322                 }
323                 checkETag(etagHash)
324
325                 for i, entry := range entries {
326                         commitN++
327                         entry.Num = commitN
328                         lines := msgSplit(entry.Commit.Message)
329                         entry.Title = lines[0]
330                         entry.LinesNum = len(lines) - 2
331                         for _, line := range lines[2:] {
332                                 u := urlParse(line)
333                                 if u == nil {
334                                         break
335                                 }
336                                 entry.DomainURLs = append(entry.DomainURLs, makeA(line, u.Host))
337                         }
338                         entry.CommentsNum = len(sgblog.ParseComments(entry.CommentsRaw))
339                         entry.ImagesNum = len(listImgs(cfg, entry.Commit.Hash))
340                         entry.Topics = sgblog.ParseTopics(entry.TopicsRaw)
341                         entries[i] = entry
342                 }
343                 offsetPrev := offset - PageEntries
344                 if offsetPrev < 0 {
345                         offsetPrev = 0
346                 }
347                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
348                 err = TmplHTMLIndex.Execute(out, struct {
349                         T                *spreak.Localizer
350                         Version          string
351                         Cfg              *Cfg
352                         Topic            string
353                         TopicsEnabled    bool
354                         Topics           []string
355                         CommentsEnabled  bool
356                         AtomPostsFeed    string
357                         AtomCommentsFeed string
358                         Offset           int
359                         OffsetPrev       int
360                         OffsetNext       int
361                         LogEnded         bool
362                         Entries          []TableEntry
363                 }{
364                         T:                localizer,
365                         Version:          sgblog.Version,
366                         Cfg:              cfg,
367                         Topic:            topic,
368                         TopicsEnabled:    topicsTree != nil,
369                         Topics:           topicsCache.Topics(),
370                         CommentsEnabled:  commentsTree != nil,
371                         AtomPostsFeed:    AtomPostsFeed,
372                         AtomCommentsFeed: AtomCommentsFeed,
373                         Offset:           offset,
374                         OffsetPrev:       offsetPrev,
375                         OffsetNext:       offset + PageEntries,
376                         LogEnded:         logEnded,
377                         Entries:          entries,
378                 })
379                 if err != nil {
380                         makeErr(err, http.StatusInternalServerError)
381                 }
382         } else if pathInfo == "/twtxt.txt" {
383                 commit, err := repo.CommitObject(*headHash)
384                 if err != nil {
385                         makeErr(err, http.StatusInternalServerError)
386                 }
387                 etagHash.Write([]byte("TWTXT POSTS"))
388                 etagHash.Write(commit.Hash[:])
389                 checkETag(etagHash)
390                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
391                 if err != nil {
392                         makeErr(err, http.StatusInternalServerError)
393                 }
394                 for i := 0; i < PageEntries; i++ {
395                         commit, err = repoLog.Next()
396                         if err != nil {
397                                 break
398                         }
399                         fmt.Fprintf(
400                                 out, "%s\t%s\n",
401                                 commit.Author.When.Format(time.RFC3339),
402                                 msgSplit(commit.Message)[0],
403                         )
404                 }
405                 os.Stdout.WriteString("Content-Type: text/plain; charset=utf-8\n")
406                 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
407                 if gzipWriter != nil {
408                         os.Stdout.WriteString("Content-Encoding: gzip\n")
409                         gzipWriter.Close()
410                 }
411                 os.Stdout.WriteString("\n")
412                 os.Stdout.Write(outBuf.Bytes())
413                 return
414         } else if pathInfo == "/"+AtomPostsFeed {
415                 commit, err := repo.CommitObject(*headHash)
416                 if err != nil {
417                         makeErr(err, http.StatusInternalServerError)
418                 }
419
420                 var topic string
421                 if t, exists := queryValues["topic"]; exists {
422                         topic = t[0]
423                 }
424
425                 etagHash.Write([]byte("ATOM POSTS"))
426                 etagHash.Write([]byte(topic))
427                 etagHash.Write(commit.Hash[:])
428                 checkETag(etagHash)
429                 var title string
430                 if topic == "" {
431                         title = cfg.Title
432                 } else {
433                         title = fmt.Sprintf("%s (topic: %s)", cfg.Title, topic)
434                 }
435                 idHasher := blake3.New(32, nil)
436                 idHasher.Write([]byte("ATOM POSTS"))
437                 idHasher.Write([]byte(cfg.AtomId))
438                 idHasher.Write([]byte(topic))
439                 feed := atom.Feed{
440                         Title:   title,
441                         ID:      "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
442                         Updated: atom.Time(commit.Author.When),
443                         Link: []atom.Link{{
444                                 Rel:  "self",
445                                 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomPostsFeed,
446                         }},
447                         Author: &atom.Person{Name: cfg.AtomAuthor},
448                 }
449
450                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
451                 if err != nil {
452                         makeErr(err, http.StatusInternalServerError)
453                 }
454                 var commits CommitIterNext
455                 if topic == "" {
456                         commits = repoLog
457                 } else {
458                         topicsCache, err := getTopicsCache(cfg, repoLog)
459                         if err != nil {
460                                 makeErr(err, http.StatusInternalServerError)
461                         }
462                         hashes := topicsCache[topic]
463                         if hashes == nil {
464                                 makeErr(errors.New("no posts with that topic"), http.StatusBadRequest)
465                         }
466                         commits = &HashesIter{hashes}
467                 }
468
469                 for i := 0; i < PageEntries; i++ {
470                         commit, err = commits.Next()
471                         if err != nil {
472                                 break
473                         }
474                         lines := msgSplit(commit.Message)
475                         var categories []atom.Category
476                         for _, topic := range sgblog.ParseTopics(sgblog.GetNote(
477                                 repo, topicsTree, commit.Hash,
478                         )) {
479                                 categories = append(categories, atom.Category{Term: topic})
480                         }
481                         htmlized := make([]string, 0, len(lines))
482                         htmlized = append(htmlized, "<pre>")
483                         for _, l := range lines[2:] {
484                                 htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
485                         }
486                         htmlized = append(htmlized, "</pre>")
487                         links := []atom.Link{{
488                                 Rel:  "alternate",
489                                 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
490                         }}
491                         for _, img := range listImgs(cfg, commit.Hash) {
492                                 links = append(links, atom.Link{
493                                         Rel:    "enclosure",
494                                         Href:   "http://" + cfg.ImgDomain + "/" + img.Path,
495                                         Type:   img.Typ,
496                                         Length: uint(img.Size),
497                                 })
498                         }
499                         feed.Entry = append(feed.Entry, &atom.Entry{
500                                 Title:     lines[0],
501                                 ID:        "urn:uuid:" + bytes2uuid(commit.Hash[:]),
502                                 Link:      links,
503                                 Published: atom.Time(commit.Author.When),
504                                 Updated:   atom.Time(commit.Author.When),
505                                 Summary:   &atom.Text{Type: "text", Body: lines[0]},
506                                 Content: &atom.Text{
507                                         Type: "html",
508                                         Body: strings.Join(htmlized, "\n"),
509                                 },
510                                 Category: categories,
511                         })
512                 }
513                 data, err := xml.MarshalIndent(&feed, "", "  ")
514                 if err != nil {
515                         makeErr(err, http.StatusInternalServerError)
516                 }
517                 out.Write([]byte(xml.Header))
518                 out.Write(data)
519                 goto AtomFinish
520         } else if pathInfo == "/"+AtomCommentsFeed {
521                 commit, err := repo.CommitObject(commentsRef.Hash())
522                 if err != nil {
523                         makeErr(err, http.StatusInternalServerError)
524                 }
525                 etagHash.Write([]byte("ATOM COMMENTS"))
526                 etagHash.Write(commit.Hash[:])
527                 checkETag(etagHash)
528                 idHasher := blake3.New(32, nil)
529                 idHasher.Write([]byte("ATOM COMMENTS"))
530                 idHasher.Write([]byte(cfg.AtomId))
531                 feed := atom.Feed{
532                         Title:   cfg.Title + " comments",
533                         ID:      "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
534                         Updated: atom.Time(commit.Author.When),
535                         Link: []atom.Link{{
536                                 Rel:  "self",
537                                 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomCommentsFeed,
538                         }},
539                         Author: &atom.Person{Name: cfg.AtomAuthor},
540                 }
541                 repoLog, err := repo.Log(&git.LogOptions{From: commentsRef.Hash()})
542                 if err != nil {
543                         makeErr(err, http.StatusInternalServerError)
544                 }
545                 for i := 0; i < PageEntries; i++ {
546                         commit, err = repoLog.Next()
547                         if err != nil {
548                                 break
549                         }
550                         fileStats, err := commit.Stats()
551                         if err != nil {
552                                 makeErr(err, http.StatusInternalServerError)
553                         }
554                         t, err := commit.Tree()
555                         if err != nil {
556                                 makeErr(err, http.StatusInternalServerError)
557                         }
558                         commentedHash := plumbing.NewHash(strings.ReplaceAll(
559                                 fileStats[0].Name, "/", "",
560                         ))
561                         commit, err = repo.CommitObject(commentedHash)
562                         if err != nil {
563                                 continue
564                         }
565                         comments := sgblog.ParseComments(sgblog.GetNote(repo, t, commentedHash))
566                         if len(comments) == 0 {
567                                 continue
568                         }
569                         commentN := strconv.Itoa(len(comments) - 1)
570                         lines := strings.Split(comments[len(comments)-1], "\n")
571                         from := strings.TrimPrefix(lines[0], "From: ")
572                         date := strings.TrimPrefix(lines[1], "Date: ")
573                         htmlized := make([]string, 0, len(lines))
574                         htmlized = append(htmlized, "<pre>")
575                         for _, l := range lines[2:] {
576                                 htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
577                         }
578                         htmlized = append(htmlized, "</pre>")
579                         idHasher.Reset()
580                         idHasher.Write([]byte("COMMENT"))
581                         idHasher.Write(commit.Hash[:])
582                         idHasher.Write([]byte(commentN))
583                         feed.Entry = append(feed.Entry, &atom.Entry{
584                                 Title: fmt.Sprintf(
585                                         "Comment %s for \"%s\" by %s",
586                                         commentN, msgSplit(commit.Message)[0], from,
587                                 ),
588                                 Author: &atom.Person{Name: from},
589                                 ID:     "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
590                                 Link: []atom.Link{{
591                                         Rel: "alternate",
592                                         Href: strings.Join([]string{
593                                                 cfg.AtomBaseURL, cfg.URLPrefix, "/",
594                                                 commit.Hash.String(), "#comment", commentN,
595                                         }, ""),
596                                 }},
597                                 Published: atom.TimeStr(strings.Replace(date, " ", "T", -1)),
598                                 Updated:   atom.TimeStr(strings.Replace(date, " ", "T", -1)),
599                                 Content: &atom.Text{
600                                         Type: "html",
601                                         Body: strings.Join(htmlized, "\n"),
602                                 },
603                         })
604                 }
605                 data, err := xml.MarshalIndent(&feed, "", "  ")
606                 if err != nil {
607                         makeErr(err, http.StatusInternalServerError)
608                 }
609                 out.Write([]byte(xml.Header))
610                 out.Write(data)
611                 goto AtomFinish
612         } else if sha1DigestRe.MatchString(pathInfo[1:]) {
613                 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1 : 1+sha1.Size*2]))
614                 if err != nil {
615                         makeErr(err, http.StatusBadRequest)
616                 }
617                 for _, data := range etagHashForWeb {
618                         etagHash.Write([]byte(data))
619                 }
620                 etagHash.Write([]byte("ENTRY"))
621                 etagHash.Write(commit.Hash[:])
622                 atomCommentsURL := strings.Join([]string{
623                         cfg.AtomBaseURL, cfg.URLPrefix, "/",
624                         commit.Hash.String(), "/", AtomCommentsFeed,
625                 }, "")
626                 commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash)
627                 etagHash.Write(commentsRaw)
628                 topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash)
629                 etagHash.Write(topicsRaw)
630                 if strings.HasSuffix(pathInfo, AtomCommentsFeed) {
631                         etagHash.Write([]byte("ATOM COMMENTS"))
632                         checkETag(etagHash)
633                         type Comment struct {
634                                 n    string
635                                 from string
636                                 date string
637                                 body []string
638                         }
639                         commentsRaw := sgblog.ParseComments(commentsRaw)
640                         var toSkip int
641                         if len(commentsRaw) > PageEntries {
642                                 toSkip = len(commentsRaw) - PageEntries
643                         }
644                         comments := make([]Comment, 0, len(commentsRaw)-toSkip)
645                         for i := len(commentsRaw) - 1; i >= toSkip; i-- {
646                                 lines := strings.Split(commentsRaw[i], "\n")
647                                 from := strings.TrimPrefix(lines[0], "From: ")
648                                 date := strings.TrimPrefix(lines[1], "Date: ")
649                                 comments = append(comments, Comment{
650                                         n:    strconv.Itoa(i),
651                                         from: from,
652                                         date: strings.Replace(date, " ", "T", 1),
653                                         body: lines[3:],
654                                 })
655                         }
656                         idHasher := blake3.New(32, nil)
657                         idHasher.Write([]byte("ATOM COMMENTS"))
658                         idHasher.Write(commit.Hash[:])
659                         feed := atom.Feed{
660                                 Title:  fmt.Sprintf("\"%s\" comments", msgSplit(commit.Message)[0]),
661                                 ID:     "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
662                                 Link:   []atom.Link{{Rel: "self", Href: atomCommentsURL}},
663                                 Author: &atom.Person{Name: cfg.AtomAuthor},
664                         }
665                         if len(comments) > 0 {
666                                 feed.Updated = atom.TimeStr(comments[0].date)
667                         } else {
668                                 feed.Updated = atom.Time(commit.Author.When)
669                         }
670                         for _, comment := range comments {
671                                 idHasher.Reset()
672                                 idHasher.Write([]byte("COMMENT"))
673                                 idHasher.Write(commit.Hash[:])
674                                 idHasher.Write([]byte(comment.n))
675                                 htmlized := make([]string, 0, len(comment.body))
676                                 htmlized = append(htmlized, "<pre>")
677                                 for _, l := range comment.body {
678                                         htmlized = append(
679                                                 htmlized,
680                                                 lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l),
681                                         )
682                                 }
683                                 htmlized = append(htmlized, "</pre>")
684                                 feed.Entry = append(feed.Entry, &atom.Entry{
685                                         Title:  fmt.Sprintf("Comment %s by %s", comment.n, comment.from),
686                                         Author: &atom.Person{Name: comment.from},
687                                         ID:     "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
688                                         Link: []atom.Link{{
689                                                 Rel: "alternate",
690                                                 Href: strings.Join([]string{
691                                                         cfg.AtomBaseURL,
692                                                         cfg.URLPrefix, "/",
693                                                         commit.Hash.String(),
694                                                         "#comment", comment.n,
695                                                 }, ""),
696                                         }},
697                                         Published: atom.TimeStr(
698                                                 strings.Replace(comment.date, " ", "T", -1),
699                                         ),
700                                         Updated: atom.TimeStr(
701                                                 strings.Replace(comment.date, " ", "T", -1),
702                                         ),
703                                         Content: &atom.Text{
704                                                 Type: "html",
705                                                 Body: strings.Join(htmlized, "\n"),
706                                         },
707                                 })
708                         }
709                         data, err := xml.MarshalIndent(&feed, "", "  ")
710                         if err != nil {
711                                 makeErr(err, http.StatusInternalServerError)
712                         }
713                         out.Write([]byte(xml.Header))
714                         out.Write(data)
715                         goto AtomFinish
716                 }
717                 notesRaw := sgblog.GetNote(repo, notesTree, commit.Hash)
718                 etagHash.Write(notesRaw)
719                 checkETag(etagHash)
720
721                 lines := msgSplit(commit.Message)
722                 title := lines[0]
723                 when := commit.Author.When.Format(sgblog.WhenFmt)
724                 var parent string
725                 if len(commit.ParentHashes) > 0 {
726                         parent = commit.ParentHashes[0].String()
727                 }
728                 commentsParsed := sgblog.ParseComments(commentsRaw)
729                 comments := make([]CommentEntry, 0, len(commentsParsed))
730                 for _, comment := range commentsParsed {
731                         lines := strings.Split(comment, "\n")
732                         comments = append(comments, CommentEntry{lines[:3], lines[3:]})
733                 }
734                 var notesLines []string
735                 if len(notesRaw) > 0 {
736                         notesLines = strings.Split(string(notesRaw), "\n")
737                 }
738
739                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
740                 err = TmplHTMLEntry.Execute(out, struct {
741                         T               *spreak.Localizer
742                         Version         string
743                         Cfg             *Cfg
744                         Title           string
745                         TitleEscaped    string
746                         When            string
747                         AtomCommentsURL string
748                         Parent          string
749                         Commit          *object.Commit
750                         Lines           []string
751                         NoteLines       []string
752                         Comments        []CommentEntry
753                         Topics          []string
754                         Imgs            []Img
755                 }{
756                         T:               localizer,
757                         Version:         sgblog.Version,
758                         Cfg:             cfg,
759                         Title:           title,
760                         TitleEscaped:    url.PathEscape(fmt.Sprintf("Re: %s (%s)", title, commit.Hash)),
761                         When:            when,
762                         AtomCommentsURL: atomCommentsURL,
763                         Parent:          parent,
764                         Commit:          commit,
765                         Lines:           lines[2:],
766                         NoteLines:       notesLines,
767                         Comments:        comments,
768                         Topics:          sgblog.ParseTopics(topicsRaw),
769                         Imgs:            listImgs(cfg, commit.Hash),
770                 })
771                 if err != nil {
772                         makeErr(err, http.StatusInternalServerError)
773                 }
774         } else {
775                 makeErr(errors.New("unknown URL action"), http.StatusNotFound)
776         }
777         out.Write([]byte("</body></html>\n"))
778         if gzipWriter != nil {
779                 gzipWriter.Close()
780         }
781         os.Stdout.Write(outBuf.Bytes())
782         return
783
784 AtomFinish:
785         os.Stdout.WriteString("Content-Type: application/atom+xml; charset=utf-8\n")
786         os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
787         if gzipWriter != nil {
788                 os.Stdout.WriteString("Content-Encoding: gzip\n")
789                 gzipWriter.Close()
790         }
791         os.Stdout.WriteString("\n")
792         os.Stdout.Write(outBuf.Bytes())
793 }