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