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