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