]> Sergey Matveev's repositories - sgblog.git/blob - cmd/sgblog/http.go
More reliable and simpler PATH_INFO parse
[sgblog.git] / cmd / sgblog / http.go
1 /*
2 SGBlog -- Git-backed CGI/UCSPI blogging/phlogging/gemlogging engine
3 Copyright (C) 2020-2021 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
40         "github.com/go-git/go-git/v5"
41         "github.com/go-git/go-git/v5/plumbing"
42         "github.com/go-git/go-git/v5/plumbing/object"
43         "go.stargrave.org/sgblog"
44         "go.stargrave.org/sgblog/cmd/sgblog/atom"
45         "golang.org/x/crypto/blake2b"
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                 "telnet": {},
62         }
63
64         //go:embed http-index.tmpl
65         TmplHTMLIndexRaw string
66         TmplHTMLIndex    = template.Must(template.New("http-index").Parse(TmplHTMLIndexRaw))
67
68         //go:embed http-entry.tmpl
69         TmplHTMLEntryRaw string
70         TmplHTMLEntry    = template.Must(template.New("http-entry").Funcs(
71                 template.FuncMap{"lineURLize": lineURLizeInTemplate},
72         ).Parse(TmplHTMLEntryRaw))
73 )
74
75 type TableEntry struct {
76         Commit      *object.Commit
77         CommentsRaw []byte
78         TopicsRaw   []byte
79         Num         int
80         Title       string
81         LinesNum    int
82         CommentsNum int
83         DomainURLs  []string
84         Topics      []string
85 }
86
87 type CommentEntry struct {
88         HeaderLines []string
89         BodyLines   []string
90 }
91
92 func makeA(href, text string) string {
93         return `<a href="` + href + `">` + text + `</a>`
94 }
95
96 func etagString(etag hash.Hash) string {
97         return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
98 }
99
100 func urlParse(what string) *url.URL {
101         if u, err := url.ParseRequestURI(what); err == nil {
102                 if _, exists := renderableSchemes[u.Scheme]; exists {
103                         return u
104                 }
105         }
106         return nil
107 }
108
109 func lineURLize(urlPrefix, line string) string {
110         cols := strings.Split(html.EscapeString(line), " ")
111         for i, col := range cols {
112                 if u := urlParse(col); u != nil {
113                         cols[i] = makeA(col, col)
114                         continue
115                 }
116                 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
117         }
118         return strings.Join(cols, " ")
119 }
120
121 func lineURLizeInTemplate(urlPrefix, line interface{}) string {
122         return lineURLize(urlPrefix.(string), line.(string))
123 }
124
125 func startHeader(etag hash.Hash, gziped bool) string {
126         lines := []string{
127                 "Content-Type: text/html; charset=UTF-8",
128                 "ETag: " + etagString(etag),
129         }
130         if gziped {
131                 lines = append(lines, "Content-Encoding: gzip")
132         }
133         lines = append(lines, "")
134         lines = append(lines, "")
135         return strings.Join(lines, "\n")
136 }
137
138 func makeErr(err error, status int) {
139         fmt.Println("Status:", status)
140         fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
141         fmt.Println(err)
142         log.Fatalln(err)
143 }
144
145 func checkETag(etag hash.Hash) {
146         ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
147         if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
148                 fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch)
149                 os.Exit(0)
150         }
151 }
152
153 func bytes2uuid(b []byte) string {
154         raw := new([16]byte)
155         copy(raw[:], b)
156         raw[6] = (raw[6] & 0x0F) | uint8(4<<4) // version 4
157         return fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:])
158 }
159
160 type CommitIterNext interface {
161         Next() (*object.Commit, error)
162 }
163
164 func serveHTTP() {
165         cfgPath := os.Getenv("SGBLOG_CFG")
166         if cfgPath == "" {
167                 log.Fatalln("SGBLOG_CFG is not set")
168         }
169         cfg, err := readCfg(cfgPath)
170         if err != nil {
171                 log.Fatalln(err)
172         }
173
174         pathInfo := os.Getenv("PATH_INFO")
175         if len(pathInfo) == 0 {
176                 pathInfo = "/"
177         }
178         queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
179         if err != nil {
180                 makeErr(err, http.StatusBadRequest)
181         }
182
183         etagHash, err := blake2b.New256(nil)
184         if err != nil {
185                 panic(err)
186         }
187         for _, s := range []string{
188                 "SGBLOG",
189                 sgblog.Version,
190                 cfg.GitPath,
191                 cfg.Branch,
192                 cfg.Title,
193                 cfg.URLPrefix,
194                 cfg.AtomBaseURL,
195                 cfg.AtomId,
196                 cfg.AtomAuthor,
197         } {
198                 if _, err = etagHash.Write([]byte(s)); err != nil {
199                         panic(err)
200                 }
201         }
202         etagHashForWeb := []string{
203                 cfg.CSS,
204                 cfg.Webmaster,
205                 cfg.AboutURL,
206                 cfg.CommentsNotesRef,
207                 cfg.CommentsEmail,
208         }
209         for _, gitURL := range cfg.GitURLs {
210                 etagHashForWeb = append(etagHashForWeb, gitURL)
211         }
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.Topics = sgblog.ParseTopics(entry.TopicsRaw)
340                         entries[i] = entry
341                 }
342                 offsetPrev := offset - PageEntries
343                 if offsetPrev < 0 {
344                         offsetPrev = 0
345                 }
346                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
347                 err = TmplHTMLIndex.Execute(out, struct {
348                         Version          string
349                         Cfg              *Cfg
350                         Topic            string
351                         TopicsEnabled    bool
352                         Topics           []string
353                         CommentsEnabled  bool
354                         AtomPostsFeed    string
355                         AtomCommentsFeed string
356                         Offset           int
357                         OffsetPrev       int
358                         OffsetNext       int
359                         LogEnded         bool
360                         Entries          []TableEntry
361                 }{
362                         Version:          sgblog.Version,
363                         Cfg:              cfg,
364                         Topic:            topic,
365                         TopicsEnabled:    topicsTree != nil,
366                         Topics:           topicsCache.Topics(),
367                         CommentsEnabled:  commentsTree != nil,
368                         AtomPostsFeed:    AtomPostsFeed,
369                         AtomCommentsFeed: AtomCommentsFeed,
370                         Offset:           offset,
371                         OffsetPrev:       offsetPrev,
372                         OffsetNext:       offset + PageEntries,
373                         LogEnded:         logEnded,
374                         Entries:          entries,
375                 })
376                 if err != nil {
377                         makeErr(err, http.StatusInternalServerError)
378                 }
379         } else if pathInfo == "/"+AtomPostsFeed {
380                 commit, err := repo.CommitObject(*headHash)
381                 if err != nil {
382                         makeErr(err, http.StatusInternalServerError)
383                 }
384
385                 var topic string
386                 if t, exists := queryValues["topic"]; exists {
387                         topic = t[0]
388                 }
389
390                 etagHash.Write([]byte("ATOM POSTS"))
391                 etagHash.Write([]byte(topic))
392                 etagHash.Write(commit.Hash[:])
393                 checkETag(etagHash)
394                 var title string
395                 if topic == "" {
396                         title = cfg.Title
397                 } else {
398                         title = fmt.Sprintf("%s (topic: %s)", cfg.Title, topic)
399                 }
400                 idHasher, err := blake2b.New256(nil)
401                 if err != nil {
402                         panic(err)
403                 }
404                 idHasher.Write([]byte("ATOM POSTS"))
405                 idHasher.Write([]byte(cfg.AtomId))
406                 idHasher.Write([]byte(topic))
407                 feed := atom.Feed{
408                         Title:   title,
409                         ID:      "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
410                         Updated: atom.Time(commit.Author.When),
411                         Link: []atom.Link{{
412                                 Rel:  "self",
413                                 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomPostsFeed,
414                         }},
415                         Author: &atom.Person{Name: cfg.AtomAuthor},
416                 }
417
418                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
419                 if err != nil {
420                         makeErr(err, http.StatusInternalServerError)
421                 }
422                 var commits CommitIterNext
423                 if topic == "" {
424                         commits = repoLog
425                 } else {
426                         topicsCache, err := getTopicsCache(cfg, repoLog)
427                         if err != nil {
428                                 makeErr(err, http.StatusInternalServerError)
429                         }
430                         hashes := topicsCache[topic]
431                         if hashes == nil {
432                                 makeErr(errors.New("no posts with that topic"), http.StatusBadRequest)
433                         }
434                         commits = &HashesIter{hashes}
435                 }
436
437                 for i := 0; i < PageEntries; i++ {
438                         commit, err = commits.Next()
439                         if err != nil {
440                                 break
441                         }
442                         lines := msgSplit(commit.Message)
443                         var categories []atom.Category
444                         for _, topic := range sgblog.ParseTopics(sgblog.GetNote(repo, topicsTree, commit.Hash)) {
445                                 categories = append(categories, atom.Category{Term: topic})
446                         }
447                         htmlized := make([]string, 0, len(lines))
448                         htmlized = append(htmlized, "<pre>")
449                         for _, l := range lines[2:] {
450                                 htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
451                         }
452                         htmlized = append(htmlized, "</pre>")
453                         feed.Entry = append(feed.Entry, &atom.Entry{
454                                 Title: lines[0],
455                                 ID:    "urn:uuid:" + bytes2uuid(commit.Hash[:]),
456                                 Link: []atom.Link{{
457                                         Rel:  "alternate",
458                                         Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
459                                 }},
460                                 Published: atom.Time(commit.Author.When),
461                                 Updated:   atom.Time(commit.Author.When),
462                                 Summary:   &atom.Text{Type: "text", Body: lines[0]},
463                                 Content: &atom.Text{
464                                         Type: "html",
465                                         Body: strings.Join(htmlized, "\n"),
466                                 },
467                                 Category: categories,
468                         })
469                 }
470                 data, err := xml.MarshalIndent(&feed, "", "  ")
471                 if err != nil {
472                         makeErr(err, http.StatusInternalServerError)
473                 }
474                 out.Write(data)
475                 goto AtomFinish
476         } else if pathInfo == "/"+AtomCommentsFeed {
477                 commit, err := repo.CommitObject(commentsRef.Hash())
478                 if err != nil {
479                         makeErr(err, http.StatusInternalServerError)
480                 }
481                 etagHash.Write([]byte("ATOM COMMENTS"))
482                 etagHash.Write(commit.Hash[:])
483                 checkETag(etagHash)
484                 idHasher, err := blake2b.New256(nil)
485                 if err != nil {
486                         panic(err)
487                 }
488                 idHasher.Write([]byte("ATOM COMMENTS"))
489                 idHasher.Write([]byte(cfg.AtomId))
490                 feed := atom.Feed{
491                         Title:   cfg.Title + " comments",
492                         ID:      "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
493                         Updated: atom.Time(commit.Author.When),
494                         Link: []atom.Link{{
495                                 Rel:  "self",
496                                 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomCommentsFeed,
497                         }},
498                         Author: &atom.Person{Name: cfg.AtomAuthor},
499                 }
500                 repoLog, err := repo.Log(&git.LogOptions{From: commentsRef.Hash()})
501                 if err != nil {
502                         makeErr(err, http.StatusInternalServerError)
503                 }
504                 for i := 0; i < PageEntries; i++ {
505                         commit, err = repoLog.Next()
506                         if err != nil {
507                                 break
508                         }
509                         fileStats, err := commit.Stats()
510                         if err != nil {
511                                 makeErr(err, http.StatusInternalServerError)
512                         }
513                         t, err := commit.Tree()
514                         if err != nil {
515                                 makeErr(err, http.StatusInternalServerError)
516                         }
517                         commentedHash := plumbing.NewHash(strings.ReplaceAll(
518                                 fileStats[0].Name, "/", "",
519                         ))
520                         commit, err = repo.CommitObject(commentedHash)
521                         if err != nil {
522                                 continue
523                         }
524                         comments := sgblog.ParseComments(sgblog.GetNote(repo, t, commentedHash))
525                         if len(comments) == 0 {
526                                 continue
527                         }
528                         commentN := strconv.Itoa(len(comments) - 1)
529                         lines := strings.Split(comments[len(comments)-1], "\n")
530                         from := strings.TrimPrefix(lines[0], "From: ")
531                         date := strings.TrimPrefix(lines[1], "Date: ")
532                         htmlized := make([]string, 0, len(lines))
533                         htmlized = append(htmlized, "<pre>")
534                         for _, l := range lines[2:] {
535                                 htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
536                         }
537                         htmlized = append(htmlized, "</pre>")
538                         idHasher.Reset()
539                         idHasher.Write([]byte("COMMENT"))
540                         idHasher.Write(commit.Hash[:])
541                         idHasher.Write([]byte(commentN))
542                         feed.Entry = append(feed.Entry, &atom.Entry{
543                                 Title: fmt.Sprintf(
544                                         "Comment %s for \"%s\" by %s",
545                                         commentN, msgSplit(commit.Message)[0], from,
546                                 ),
547                                 Author: &atom.Person{Name: from},
548                                 ID:     "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
549                                 Link: []atom.Link{{
550                                         Rel: "alternate",
551                                         Href: strings.Join([]string{
552                                                 cfg.AtomBaseURL, cfg.URLPrefix, "/",
553                                                 commit.Hash.String(), "#comment", commentN,
554                                         }, ""),
555                                 }},
556                                 Published: atom.TimeStr(strings.Replace(date, " ", "T", -1)),
557                                 Updated:   atom.TimeStr(strings.Replace(date, " ", "T", -1)),
558                                 Content: &atom.Text{
559                                         Type: "html",
560                                         Body: strings.Join(htmlized, "\n"),
561                                 },
562                         })
563                 }
564                 data, err := xml.MarshalIndent(&feed, "", "  ")
565                 if err != nil {
566                         makeErr(err, http.StatusInternalServerError)
567                 }
568                 out.Write(data)
569                 goto AtomFinish
570         } else if sha1DigestRe.MatchString(pathInfo[1:]) {
571                 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1 : 1+sha1.Size*2]))
572                 if err != nil {
573                         makeErr(err, http.StatusBadRequest)
574                 }
575                 for _, data := range etagHashForWeb {
576                         etagHash.Write([]byte(data))
577                 }
578                 etagHash.Write([]byte("ENTRY"))
579                 etagHash.Write(commit.Hash[:])
580                 atomCommentsURL := strings.Join([]string{
581                         cfg.AtomBaseURL, cfg.URLPrefix, "/",
582                         commit.Hash.String(), "/", AtomCommentsFeed,
583                 }, "")
584                 commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash)
585                 etagHash.Write(commentsRaw)
586                 topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash)
587                 etagHash.Write(topicsRaw)
588                 if strings.HasSuffix(pathInfo, AtomCommentsFeed) {
589                         etagHash.Write([]byte("ATOM COMMENTS"))
590                         checkETag(etagHash)
591                         type Comment struct {
592                                 n    string
593                                 from string
594                                 date string
595                                 body []string
596                         }
597                         commentsRaw := sgblog.ParseComments(commentsRaw)
598                         var toSkip int
599                         if len(commentsRaw) > PageEntries {
600                                 toSkip = len(commentsRaw) - PageEntries
601                         }
602                         comments := make([]Comment, 0, len(commentsRaw)-toSkip)
603                         for i := len(commentsRaw) - 1; i >= toSkip; i-- {
604                                 lines := strings.Split(commentsRaw[i], "\n")
605                                 from := strings.TrimPrefix(lines[0], "From: ")
606                                 date := strings.TrimPrefix(lines[1], "Date: ")
607                                 comments = append(comments, Comment{
608                                         n:    strconv.Itoa(i),
609                                         from: from,
610                                         date: strings.Replace(date, " ", "T", 1),
611                                         body: lines[3:],
612                                 })
613                         }
614                         idHasher, err := blake2b.New256(nil)
615                         if err != nil {
616                                 panic(err)
617                         }
618                         idHasher.Write([]byte("ATOM COMMENTS"))
619                         idHasher.Write(commit.Hash[:])
620                         feed := atom.Feed{
621                                 Title:  fmt.Sprintf("\"%s\" comments", msgSplit(commit.Message)[0]),
622                                 ID:     "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
623                                 Link:   []atom.Link{{Rel: "self", Href: atomCommentsURL}},
624                                 Author: &atom.Person{Name: cfg.AtomAuthor},
625                         }
626                         if len(comments) > 0 {
627                                 feed.Updated = atom.TimeStr(comments[0].date)
628                         } else {
629                                 feed.Updated = atom.Time(commit.Author.When)
630                         }
631                         for _, comment := range comments {
632                                 idHasher.Reset()
633                                 idHasher.Write([]byte("COMMENT"))
634                                 idHasher.Write(commit.Hash[:])
635                                 idHasher.Write([]byte(comment.n))
636                                 htmlized := make([]string, 0, len(comment.body))
637                                 htmlized = append(htmlized, "<pre>")
638                                 for _, l := range comment.body {
639                                         htmlized = append(
640                                                 htmlized,
641                                                 lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l),
642                                         )
643                                 }
644                                 htmlized = append(htmlized, "</pre>")
645                                 feed.Entry = append(feed.Entry, &atom.Entry{
646                                         Title:  fmt.Sprintf("Comment %s by %s", comment.n, comment.from),
647                                         Author: &atom.Person{Name: comment.from},
648                                         ID:     "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
649                                         Link: []atom.Link{{
650                                                 Rel: "alternate",
651                                                 Href: strings.Join([]string{
652                                                         cfg.AtomBaseURL,
653                                                         cfg.URLPrefix, "/",
654                                                         commit.Hash.String(),
655                                                         "#comment", comment.n,
656                                                 }, ""),
657                                         }},
658                                         Published: atom.TimeStr(
659                                                 strings.Replace(comment.date, " ", "T", -1),
660                                         ),
661                                         Updated: atom.TimeStr(
662                                                 strings.Replace(comment.date, " ", "T", -1),
663                                         ),
664                                         Content: &atom.Text{
665                                                 Type: "html",
666                                                 Body: strings.Join(htmlized, "\n"),
667                                         },
668                                 })
669                         }
670                         data, err := xml.MarshalIndent(&feed, "", "  ")
671                         if err != nil {
672                                 makeErr(err, http.StatusInternalServerError)
673                         }
674                         out.Write(data)
675                         goto AtomFinish
676                 }
677                 notesRaw := sgblog.GetNote(repo, notesTree, commit.Hash)
678                 etagHash.Write(notesRaw)
679                 checkETag(etagHash)
680
681                 lines := msgSplit(commit.Message)
682                 title := lines[0]
683                 when := commit.Author.When.Format(sgblog.WhenFmt)
684                 var parent string
685                 if len(commit.ParentHashes) > 0 {
686                         parent = commit.ParentHashes[0].String()
687                 }
688                 commentsParsed := sgblog.ParseComments(commentsRaw)
689                 comments := make([]CommentEntry, 0, len(commentsParsed))
690                 for _, comment := range commentsParsed {
691                         lines := strings.Split(comment, "\n")
692                         comments = append(comments, CommentEntry{lines[:3], lines[3:]})
693                 }
694                 var notesLines []string
695                 if len(notesRaw) > 0 {
696                         notesLines = strings.Split(string(notesRaw), "\n")
697                 }
698
699                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
700                 err = TmplHTMLEntry.Execute(out, struct {
701                         Version         string
702                         Cfg             *Cfg
703                         Title           string
704                         TitleEscaped    string
705                         When            string
706                         AtomCommentsURL string
707                         Parent          string
708                         Commit          *object.Commit
709                         Lines           []string
710                         NoteLines       []string
711                         Comments        []CommentEntry
712                         Topics          []string
713                 }{
714                         Version:         sgblog.Version,
715                         Cfg:             cfg,
716                         Title:           title,
717                         TitleEscaped:    url.PathEscape(fmt.Sprintf("Re: %s (%s)", title, commit.Hash)),
718                         When:            when,
719                         AtomCommentsURL: atomCommentsURL,
720                         Parent:          parent,
721                         Commit:          commit,
722                         Lines:           lines[2:],
723                         NoteLines:       notesLines,
724                         Comments:        comments,
725                         Topics:          sgblog.ParseTopics(topicsRaw),
726                 })
727                 if err != nil {
728                         makeErr(err, http.StatusInternalServerError)
729                 }
730         } else {
731                 makeErr(errors.New("unknown URL action"), http.StatusNotFound)
732         }
733         out.Write([]byte("</body></html>\n"))
734         if gzipWriter != nil {
735                 gzipWriter.Close()
736         }
737         os.Stdout.Write(outBuf.Bytes())
738         return
739
740 AtomFinish:
741         os.Stdout.WriteString("Content-Type: application/atom+xml; charset=UTF-8\n")
742         os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
743         if gzipWriter != nil {
744                 os.Stdout.WriteString("Content-Encoding: gzip\n")
745                 gzipWriter.Close()
746         }
747         os.Stdout.WriteString("\n")
748         os.Stdout.Write(outBuf.Bytes())
749 }