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