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