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