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