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