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