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