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