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