]> Sergey Matveev's repositories - sgblog.git/blob - cmd/sgblog/http.go
Shorter datetimes displaying
[sgblog.git] / cmd / sgblog / http.go
1 /*
2 SGBlog -- Git-based CGI blogging 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 // Git-based CGI blogging engine
19 package main
20
21 import (
22         "bytes"
23         "compress/gzip"
24         "encoding/hex"
25         "encoding/json"
26         "encoding/xml"
27         "errors"
28         "fmt"
29         "hash"
30         "html"
31         "io"
32         "io/ioutil"
33         "log"
34         "net/url"
35         "os"
36         "strconv"
37         "strings"
38         "time"
39
40         "github.com/hjson/hjson-go"
41         "go.stargrave.org/sgblog"
42         "golang.org/x/crypto/blake2b"
43         "golang.org/x/tools/blog/atom"
44         "gopkg.in/src-d/go-git.v4"
45         "gopkg.in/src-d/go-git.v4/plumbing"
46         "gopkg.in/src-d/go-git.v4/plumbing/object"
47 )
48
49 const (
50         AtomFeed = "feed.atom"
51 )
52
53 var (
54         defaultLinks = []string{}
55
56         renderableSchemes = map[string]struct{}{
57                 "ftp":    struct{}{},
58                 "gopher": struct{}{},
59                 "http":   struct{}{},
60                 "https":  struct{}{},
61                 "telnet": struct{}{},
62         }
63 )
64
65 type TableEntry struct {
66         commit      *object.Commit
67         commentsRaw []byte
68 }
69
70 func makeA(href, text string) string {
71         return `<a href="` + href + `">` + text + `</a>`
72 }
73
74 func etagString(etag hash.Hash) string {
75         return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
76 }
77
78 func urlParse(what string) *url.URL {
79         if u, err := url.ParseRequestURI(what); err == nil {
80                 if _, exists := renderableSchemes[u.Scheme]; exists {
81                         return u
82                 }
83         }
84         return nil
85 }
86
87 func lineURLize(urlPrefix, line string) string {
88         cols := strings.Split(html.EscapeString(line), " ")
89         for i, col := range cols {
90                 if u := urlParse(col); u != nil {
91                         cols[i] = makeA(col, col)
92                         continue
93                 }
94                 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(
95                         urlPrefix+"/$1", "$1",
96                 ))
97         }
98         return strings.Join(cols, " ")
99 }
100
101 func startHeader(etag hash.Hash, gziped bool) string {
102         lines := []string{
103                 "Content-Type: text/html; charset=UTF-8",
104                 "ETag: " + etagString(etag),
105         }
106         if gziped {
107                 lines = append(lines, "Content-Encoding: gzip")
108         }
109         lines = append(lines, "")
110         lines = append(lines, "")
111         return strings.Join(lines, "\n")
112 }
113
114 func startHTML(title string, additional []string) string {
115         return fmt.Sprintf(`<html>
116 <head>
117         <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
118         <meta name="generator" content="SGBlog %s">
119         <title>%s</title>
120         %s
121 </head>
122 <body>
123 `,
124                 sgblog.Version, title,
125                 strings.Join(append(defaultLinks, additional...), "\n   "),
126         )
127 }
128
129 func makeErr(err error) {
130         fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
131         fmt.Println(err)
132         panic(err)
133 }
134
135 func checkETag(etag hash.Hash) {
136         ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
137         if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
138                 fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch)
139                 os.Exit(0)
140         }
141 }
142
143 func serveHTTP() {
144         cfgPath := os.Getenv("SGBLOG_CFG")
145         if cfgPath == "" {
146                 log.Fatalln("SGBLOG_CFG is not set")
147         }
148         cfgRaw, err := ioutil.ReadFile(cfgPath)
149         if err != nil {
150                 makeErr(err)
151         }
152         var cfgGeneral map[string]interface{}
153         if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
154                 makeErr(err)
155         }
156         cfgRaw, err = json.Marshal(cfgGeneral)
157         if err != nil {
158                 makeErr(err)
159         }
160         var cfg *Cfg
161         if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
162                 makeErr(err)
163         }
164         pathInfo, exists := os.LookupEnv("PATH_INFO")
165         if !exists {
166                 pathInfo = "/"
167         }
168         queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
169         if err != nil {
170                 makeErr(err)
171         }
172
173         etagHash, err := blake2b.New256(nil)
174         if err != nil {
175                 panic(err)
176         }
177         etagHash.Write([]byte("SGBLOG"))
178         etagHash.Write([]byte(sgblog.Version))
179         etagHash.Write([]byte(cfg.GitPath))
180         etagHash.Write([]byte(cfg.Branch))
181         etagHash.Write([]byte(cfg.Title))
182         etagHash.Write([]byte(cfg.URLPrefix))
183         etagHash.Write([]byte(cfg.AtomBaseURL))
184         etagHash.Write([]byte(cfg.AtomId))
185         etagHash.Write([]byte(cfg.AtomAuthor))
186
187         etagHashForWeb := [][]byte{}
188         if cfg.CSS != "" {
189                 defaultLinks = append(defaultLinks, `<link rel="stylesheet" type="text/css" href="`+cfg.CSS+`">`)
190                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CSS))
191         }
192         if cfg.Webmaster != "" {
193                 defaultLinks = append(defaultLinks, `<link rev="made" href="mailto:`+cfg.Webmaster+`">`)
194                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.Webmaster))
195         }
196         if cfg.AboutURL != "" {
197                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.AboutURL))
198         }
199         for _, gitURL := range cfg.GitURLs {
200                 defaultLinks = append(defaultLinks, `<link rel="vcs-git" href="`+gitURL+`" title="Git repository">`)
201                 etagHashForWeb = append(etagHashForWeb, []byte(gitURL))
202         }
203         if cfg.CommentsNotesRef != "" {
204                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsNotesRef))
205         }
206         if cfg.CommentsEmail != "" {
207                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsEmail))
208         }
209
210         defaultLinks = append(defaultLinks, `<link rel="top" href="`+cfg.URLPrefix+`/" title="top">`)
211         atomURL := cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomFeed
212         defaultLinks = append(defaultLinks, `<link rel="alternate" title="Atom feed" href="`+atomURL+`" type="application/atom+xml">`)
213
214         headHash, err := initRepo(cfg)
215         if err != nil {
216                 makeErr(err)
217         }
218
219         if notes, err := repo.Notes(); err == nil {
220                 var notesRef *plumbing.Reference
221                 var commentsRef *plumbing.Reference
222                 notes.ForEach(func(ref *plumbing.Reference) error {
223                         switch string(ref.Name()) {
224                         case "refs/notes/commits":
225                                 notesRef = ref
226                         case cfg.CommentsNotesRef:
227                                 commentsRef = ref
228                         }
229                         return nil
230                 })
231                 if notesRef != nil {
232                         if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
233                                 notesTree, _ = commentsCommit.Tree()
234                         }
235                 }
236                 if commentsRef != nil {
237                         if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
238                                 commentsTree, _ = commentsCommit.Tree()
239                         }
240                 }
241         }
242
243         var outBuf bytes.Buffer
244         var out io.Writer
245         out = &outBuf
246         var gzipWriter *gzip.Writer
247         acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
248         for _, encoding := range strings.Split(acceptEncoding, ", ") {
249                 if encoding == "gzip" {
250                         gzipWriter = gzip.NewWriter(&outBuf)
251                         out = gzipWriter
252                 }
253         }
254
255         if pathInfo == "/" {
256                 offset := 0
257                 if offsetRaw, exists := queryValues["offset"]; exists {
258                         offset, err = strconv.Atoi(offsetRaw[0])
259                         if err != nil {
260                                 makeErr(err)
261                         }
262                 }
263                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
264                 if err != nil {
265                         makeErr(err)
266                 }
267                 commitN := 0
268                 for i := 0; i < offset; i++ {
269                         if _, err = repoLog.Next(); err != nil {
270                                 break
271                         }
272                         commitN++
273                 }
274
275                 entries := make([]TableEntry, 0, PageEntries)
276                 logEnded := false
277                 for _, data := range etagHashForWeb {
278                         etagHash.Write(data)
279                 }
280                 etagHash.Write([]byte("INDEX"))
281                 for i := 0; i < PageEntries; i++ {
282                         commit, err := repoLog.Next()
283                         if err != nil {
284                                 logEnded = true
285                                 break
286                         }
287                         etagHash.Write(commit.Hash[:])
288                         commentsRaw := getNote(commentsTree, commit.Hash)
289                         etagHash.Write(commentsRaw)
290                         entries = append(entries, TableEntry{commit, commentsRaw})
291                 }
292                 checkETag(etagHash)
293
294                 var table bytes.Buffer
295                 table.WriteString(
296                         "<table border=1>\n" +
297                                 "<caption>Comments</caption>\n<tr>" +
298                                 "<th>N</th>" +
299                                 "<th>When</th>" +
300                                 "<th>Title</th>" +
301                                 `<th size="5%"><a title="Lines">L</a></th>` +
302                                 `<th size="5%"><a title="Comments">C</a></th>` +
303                                 "<th>Linked to</th></tr>\n")
304                 var yearPrev int
305                 var monthPrev time.Month
306                 var dayPrev int
307                 for _, entry := range entries {
308                         yearCur, monthCur, dayCur := entry.commit.Author.When.Date()
309                         if dayCur != dayPrev || monthCur != monthPrev || yearCur != yearPrev {
310                                 table.WriteString(fmt.Sprintf(
311                                         "<tr><td colspan=6><center><tt>%04d-%02d-%02d</tt></center></td></tr>\n",
312                                         yearCur, monthCur, dayCur,
313                                 ))
314                                 yearPrev, monthPrev, dayPrev = yearCur, monthCur, dayCur
315                         }
316                         commitN++
317                         lines := msgSplit(entry.commit.Message)
318                         domains := []string{}
319                         for _, line := range lines[2:] {
320                                 if u := urlParse(line); u == nil {
321                                         break
322                                 } else {
323                                         domains = append(domains, makeA(line, u.Host))
324                                 }
325                         }
326                         var commentsValue string
327                         if l := len(parseComments(entry.commentsRaw)); l > 0 {
328                                 commentsValue = strconv.Itoa(l)
329                         } else {
330                                 commentsValue = "&nbsp;"
331                         }
332                         table.WriteString(fmt.Sprintf(
333                                 "<tr><td>%d</td><td><tt>%02d:%02d</tt></td>"+
334                                         "<td>%s</td>"+
335                                         "<td>%d</td><td>%s</td>"+
336                                         "<td>%s</td></tr>\n",
337                                 commitN,
338                                 entry.commit.Author.When.Hour(),
339                                 entry.commit.Author.When.Minute(),
340                                 makeA(cfg.URLPrefix+"/"+entry.commit.Hash.String(), lines[0]),
341                                 len(lines)-2,
342                                 commentsValue,
343                                 strings.Join(domains, " "),
344                         ))
345                 }
346                 table.WriteString("</table>")
347
348                 var href string
349                 var links []string
350                 var refs bytes.Buffer
351                 if offset > 0 {
352                         if offsetPrev := offset - PageEntries; offsetPrev > 0 {
353                                 href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offsetPrev)
354                         } else {
355                                 href = cfg.URLPrefix + "/"
356                         }
357                         links = append(links, `<link rel="prev" href="`+href+`" title="newer">`)
358                         refs.WriteString("\n" + makeA(href, "[prev]"))
359                 }
360                 if !logEnded {
361                         href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries)
362                         links = append(links, `<link rel="next" href="`+href+`" title="older">`)
363                         refs.WriteString("\n" + makeA(href, "[next]"))
364                 }
365
366                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
367                 out.Write([]byte(startHTML(
368                         fmt.Sprintf("%s (%d-%d)", cfg.Title, offset, offset+PageEntries),
369                         links,
370                 )))
371                 if cfg.AboutURL != "" {
372                         out.Write([]byte(fmt.Sprintf("[%s]", makeA(cfg.AboutURL, "about"))))
373                 }
374                 out.Write(refs.Bytes())
375                 out.Write(table.Bytes())
376                 out.Write(refs.Bytes())
377                 out.Write([]byte("\n"))
378         } else if pathInfo == "/"+AtomFeed {
379                 commit, err := repo.CommitObject(*headHash)
380                 if err != nil {
381                         makeErr(err)
382                 }
383                 etagHash.Write([]byte("ATOM"))
384                 etagHash.Write(commit.Hash[:])
385                 checkETag(etagHash)
386                 feed := atom.Feed{
387                         Title:   cfg.Title,
388                         ID:      cfg.AtomId,
389                         Updated: atom.Time(commit.Author.When),
390                         Link: []atom.Link{{
391                                 Rel:  "self",
392                                 Href: atomURL,
393                         }},
394                         Author: &atom.Person{Name: cfg.AtomAuthor},
395                 }
396                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
397                 if err != nil {
398                         makeErr(err)
399                 }
400                 for i := 0; i < PageEntries; i++ {
401                         commit, err = repoLog.Next()
402                         if err != nil {
403                                 break
404                         }
405
406                         feedIdRaw := new([16]byte)
407                         copy(feedIdRaw[:], commit.Hash[:])
408                         feedIdRaw[6] = (feedIdRaw[6] & 0x0F) | uint8(4<<4) // version 4
409                         feedId := fmt.Sprintf(
410                                 "%x-%x-%x-%x-%x",
411                                 feedIdRaw[0:4],
412                                 feedIdRaw[4:6],
413                                 feedIdRaw[6:8],
414                                 feedIdRaw[8:10],
415                                 feedIdRaw[10:],
416                         )
417
418                         lines := msgSplit(commit.Message)
419                         feed.Entry = append(feed.Entry, &atom.Entry{
420                                 Title: lines[0],
421                                 ID:    "urn:uuid:" + feedId,
422                                 Link: []atom.Link{{
423                                         Rel:  "alternate",
424                                         Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
425                                 }},
426                                 Published: atom.Time(commit.Author.When),
427                                 Updated:   atom.Time(commit.Author.When),
428                                 Summary: &atom.Text{
429                                         Type: "text",
430                                         Body: lines[0],
431                                 },
432                                 Content: &atom.Text{
433                                         Type: "text",
434                                         Body: strings.Join(lines[2:], "\n"),
435                                 },
436                         })
437                 }
438                 data, err := xml.MarshalIndent(&feed, "", "  ")
439                 if err != nil {
440                         makeErr(err)
441                 }
442                 out.Write(data)
443                 os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
444                 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
445                 if gzipWriter != nil {
446                         os.Stdout.WriteString("Content-Encoding: gzip\n")
447                         gzipWriter.Close()
448                 }
449                 os.Stdout.WriteString("\n")
450                 os.Stdout.Write(outBuf.Bytes())
451                 return
452         } else if sha1DigestRe.MatchString(pathInfo[1:]) {
453                 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1:]))
454                 if err != nil {
455                         makeErr(err)
456                 }
457                 for _, data := range etagHashForWeb {
458                         etagHash.Write(data)
459                 }
460                 etagHash.Write([]byte("ENTRY"))
461                 etagHash.Write(commit.Hash[:])
462                 notesRaw := getNote(notesTree, commit.Hash)
463                 etagHash.Write(notesRaw)
464                 commentsRaw := getNote(commentsTree, commit.Hash)
465                 etagHash.Write(commentsRaw)
466                 checkETag(etagHash)
467                 lines := msgSplit(commit.Message)
468                 title := lines[0]
469                 when := commit.Author.When.Format(sgblog.WhenFmt)
470                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
471                 links := []string{}
472                 var parent string
473                 if len(commit.ParentHashes) > 0 {
474                         parent = commit.ParentHashes[0].String()
475                         links = append(links, `<link rel="prev" href="`+cfg.URLPrefix+"/"+parent+`" title="older">`)
476                 }
477                 out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), links)))
478                 if cfg.AboutURL != "" {
479                         out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.AboutURL, "about"))))
480                 }
481                 out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.URLPrefix+"/", "index"))))
482                 if parent != "" {
483                         out.Write([]byte(fmt.Sprintf(
484                                 "[%s]\n",
485                                 makeA(cfg.URLPrefix+"/"+parent, "older"),
486                         )))
487                 }
488                 out.Write([]byte(fmt.Sprintf(
489                         "[<tt><a title=\"When\">%s</a></tt>]\n"+
490                                 "[<tt><a title=\"Hash\">%s</a></tt>]\n"+
491                                 "<hr/>\n<h2>%s</h2>\n<pre>\n",
492                         when, commit.Hash.String(), title,
493                 )))
494                 for _, line := range lines[2:] {
495                         out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
496                 }
497                 out.Write([]byte("</pre>\n<hr/>\n"))
498                 if len(notesRaw) > 0 {
499                         out.Write([]byte("Note:<pre>\n" + string(notesRaw) + "\n</pre>\n<hr/>\n"))
500                 }
501                 if cfg.CommentsEmail != "" {
502                         out.Write([]byte("[" + makeA(
503                                 "mailto:"+cfg.CommentsEmail+"?subject="+commit.Hash.String(),
504                                 "write comment",
505                         ) + "]\n"))
506                 }
507                 out.Write([]byte("<dl>\n"))
508                 for i, comment := range parseComments(commentsRaw) {
509                         out.Write([]byte(fmt.Sprintf(
510                                 "<dt><a name=\"comment%d\"><a href=\"#comment%d\">comment %d</a>:"+
511                                         "</dt>\n<dd><pre>\n",
512                                 i, i, i,
513                         )))
514                         lines = strings.Split(comment, "\n")
515                         for _, line := range lines[:3] {
516                                 out.Write([]byte(line + "\n"))
517                         }
518                         for _, line := range lines[3:] {
519                                 out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
520                         }
521                         out.Write([]byte("</pre></dd>\n"))
522                 }
523                 out.Write([]byte("</dl>\n"))
524         } else {
525                 makeErr(errors.New("unknown URL action"))
526         }
527         out.Write([]byte("</body></html>\n"))
528         if gzipWriter != nil {
529                 gzipWriter.Close()
530         }
531         os.Stdout.Write(outBuf.Bytes())
532 }