2 SGBlog -- Git-backed CGI/inetd blogging/phlogging engine
3 Copyright (C) 2020 Sergey Matveev <stargrave@stargrave.org>
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.
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.
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/>.
40 "github.com/go-git/go-git/v5"
41 "github.com/go-git/go-git/v5/plumbing"
42 "github.com/go-git/go-git/v5/plumbing/object"
43 "github.com/hjson/hjson-go"
44 "go.stargrave.org/sgblog"
45 "go.stargrave.org/sgblog/cmd/sgblog/atom"
46 "golang.org/x/crypto/blake2b"
50 AtomPostsFeed = "feed.atom"
51 AtomCommentsFeed = "comments.atom"
52 TmplHTMLIndex = `<html>
54 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
55 <meta name="generator" content="SGBlog {{.Version}}">
56 <title>{{.Cfg.Title}} {{if .Topic}}(topic: {{.Topic}}) {{end}}({{.Offset}}-{{.OffsetNext}})</title>
57 {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
58 {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end}}
59 {{range .Cfg.GitURLs}}<link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
60 <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
61 <link rel="alternate" title="Posts feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomPostsFeed}}{{if .Topic}}?topic={{.Topic}}{{end}}" type="application/atom+xml">
62 {{if .CommentsEnabled}}<link rel="alternate" title="Comments feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomCommentsFeed}}" type="application/atom+xml">{{end}}
63 {{if .Offset}}<link rel="prev" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="prev">{{end}}
64 {{if not .LogEnded}}<link rel="next" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="next">{{end}}
67 {{with .Cfg.AboutURL}}[<a href="{{.}}">about</a>]{{end}}
69 {{if .Offset}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}">prev</a>]{{end}}
70 {{if not .LogEnded}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}">next</a>]{{end}}
74 Topics: [<tt><a href="{{$Cfg.URLPrefix}}/">ALL</a></tt>]
75 {{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]
78 {{- $TopicsEnabled := .TopicsEnabled -}}
79 {{- $datePrev := "0001-01-01" -}}
82 <th>N</th><th>When</th><th>Title</th>
83 <th size="5%"><a title="Lines">L</a></th>
84 <th size="5%"><a title="Comments">C</a></th>
86 {{if .TopicsEnabled}}<th>Topics</th>{{end}}
89 {{- $dateCur := .Commit.Author.When.Format "2006-01-02" -}}
90 {{- if ne $dateCur $datePrev -}}
91 <tr><td colspan={{if $TopicsEnabled}}7{{else}}7{{end}}><center><tt>{{$dateCur}}</tt></center></td></tr>
92 {{- $datePrev = $dateCur -}}
96 <td><tt>{{.Commit.Author.When.Format "15:04"}}</tt></td>
97 <td><a href="{{$Cfg.URLPrefix}}/{{.Commit.Hash.String}}">{{.Title}}</a></td>
98 <td>{{.LinesNum}}</td>
99 <td>{{if .CommentsNum}}{{.CommentsNum}}{{else}} {{end}}</td>
100 <td>{{if .DomainURLs}}{{range .DomainURLs}} {{.}} {{end}}{{else}} {{end}}</td>
101 {{if $TopicsEnabled}}<td>{{if .Topics}}{{range .Topics}} <a href="{{$Cfg.URLPrefix}}/?topic={{.}}">{{.}}</a> {{end}}{{else}} {{end}}</td>{{end}}
104 {{template "links" .}}
108 TmplHTMLEntry = `{{$Cfg := .Cfg}}<html>
110 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
111 <meta name="generator" content="SGBlog {{.Version}}">
112 <title>{{.Title}} ({{.When}})</title>
113 {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
114 {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end -}}
115 {{- range .Cfg.GitURLs}}
116 <link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
117 <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
118 <link rel="alternate" title="Comments feed" href="{{.AtomCommentsURL}}" type="application/atom+xml">
119 {{if .Parent}}<link rel="prev" href="{{.Cfg.URLPrefix}}/{{.Parent}}" title="prev">{{end}}
122 {{with .Cfg.AboutURL}}[<a href="{{.}}">about</a>]{{end}}
123 [<a href="{{.Cfg.URLPrefix}}/">index</a>]
124 {{if .Parent}}[<a href="{{.Cfg.URLPrefix}}/{{.Parent}}">prev</a>]{{end}}
125 [<tt><a title="When">{{.When}}</a></tt>]
126 [<tt><a title="What">{{.Commit.Hash.String}}</a></tt>]
130 Topics: {{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]{{end}}
136 {{range .Lines}}{{. | lineURLize $Cfg.URLPrefix}}
140 {{if .NoteLines}}Note:<pre>
141 {{range .NoteLines}}{{. | lineURLize $Cfg.URLPrefix}}
145 {{if .Cfg.CommentsEmail}}[<a href="mailto:{{.Cfg.CommentsEmail}}?subject=Re:%20{{.TitleEscaped}}%20({{.Commit.Hash.String}})">leave comment</a>]{{end}}
147 <dl>{{range $idx, $comment := .Comments}}
148 <dt><a name="comment{{$idx}}"><a href="#comment{{$idx}}">comment {{$idx}}</a>:</dt>
150 {{range $comment.HeaderLines}}{{.}}
151 {{end}}{{range $comment.BodyLines}}{{. | lineURLize $Cfg.URLPrefix}}
161 renderableSchemes = map[string]struct{}{
163 "gopher": struct{}{},
166 "telnet": struct{}{},
170 type TableEntry struct {
171 Commit *object.Commit
182 type CommentEntry struct {
187 func makeA(href, text string) string {
188 return `<a href="` + href + `">` + text + `</a>`
191 func etagString(etag hash.Hash) string {
192 return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
195 func urlParse(what string) *url.URL {
196 if u, err := url.ParseRequestURI(what); err == nil {
197 if _, exists := renderableSchemes[u.Scheme]; exists {
204 func lineURLize(urlPrefix, line string) string {
205 cols := strings.Split(html.EscapeString(line), " ")
206 for i, col := range cols {
207 if u := urlParse(col); u != nil {
208 cols[i] = makeA(col, col)
211 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
213 return strings.Join(cols, " ")
216 func lineURLizeInTemplate(urlPrefix, line interface{}) string {
217 return lineURLize(urlPrefix.(string), line.(string))
220 func startHeader(etag hash.Hash, gziped bool) string {
222 "Content-Type: text/html; charset=UTF-8",
223 "ETag: " + etagString(etag),
226 lines = append(lines, "Content-Encoding: gzip")
228 lines = append(lines, "")
229 lines = append(lines, "")
230 return strings.Join(lines, "\n")
233 func makeErr(err error) {
234 fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
239 func checkETag(etag hash.Hash) {
240 ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
241 if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
242 fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch)
247 func bytes2uuid(b []byte) string {
250 raw[6] = (raw[6] & 0x0F) | uint8(4<<4) // version 4
251 return fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:])
254 type CommitIterNext interface {
255 Next() (*object.Commit, error)
259 cfgPath := os.Getenv("SGBLOG_CFG")
261 log.Fatalln("SGBLOG_CFG is not set")
263 cfgRaw, err := ioutil.ReadFile(cfgPath)
267 var cfgGeneral map[string]interface{}
268 if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
271 cfgRaw, err = json.Marshal(cfgGeneral)
276 if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
279 pathInfo, exists := os.LookupEnv("PATH_INFO")
283 queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
288 etagHash, err := blake2b.New256(nil)
292 for _, s := range []string{
303 if _, err = etagHash.Write([]byte(s)); err != nil {
307 etagHashForWeb := []string{
311 cfg.CommentsNotesRef,
314 for _, gitURL := range cfg.GitURLs {
315 etagHashForWeb = append(etagHashForWeb, gitURL)
318 headHash, err := initRepo(cfg)
323 if notes, err := repo.Notes(); err == nil {
324 var notesRef *plumbing.Reference
325 var commentsRef *plumbing.Reference
326 notes.ForEach(func(ref *plumbing.Reference) error {
327 switch string(ref.Name()) {
328 case "refs/notes/commits":
330 case cfg.CommentsNotesRef:
336 if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
337 notesTree, _ = commentsCommit.Tree()
340 if commentsRef != nil {
341 if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
342 commentsTree, _ = commentsCommit.Tree()
347 var outBuf bytes.Buffer
350 var gzipWriter *gzip.Writer
351 acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
352 for _, encoding := range strings.Split(acceptEncoding, ", ") {
353 if encoding == "gzip" {
354 gzipWriter = gzip.NewWriter(&outBuf)
361 if offsetRaw, exists := queryValues["offset"]; exists {
362 offset, err = strconv.Atoi(offsetRaw[0])
367 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
371 topicsCache, err := getTopicsCache(cfg, repoLog)
375 repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
381 var commits CommitIterNext
383 if t, exists := queryValues["topic"]; exists {
385 hashes := topicsCache[topic]
387 makeErr(errors.New("no posts with that topic"))
389 if len(hashes) > offset {
390 hashes = hashes[offset:]
393 commits = &HashesIter{hashes}
395 for i := 0; i < offset; i++ {
396 if _, err = repoLog.Next(); err != nil {
404 entries := make([]TableEntry, 0, PageEntries)
406 for _, data := range etagHashForWeb {
407 etagHash.Write([]byte(data))
409 etagHash.Write([]byte("INDEX"))
410 etagHash.Write([]byte(topic))
411 for i := 0; i < PageEntries; i++ {
412 commit, err := commits.Next()
417 etagHash.Write(commit.Hash[:])
418 commentsRaw := getNote(commentsTree, commit.Hash)
419 etagHash.Write(commentsRaw)
420 topicsRaw := getNote(topicsTree, commit.Hash)
421 etagHash.Write(topicsRaw)
422 entries = append(entries, TableEntry{
424 CommentsRaw: commentsRaw,
425 TopicsRaw: topicsRaw,
430 for i, entry := range entries {
433 lines := msgSplit(entry.Commit.Message)
434 entry.Title = lines[0]
435 entry.LinesNum = len(lines) - 2
436 for _, line := range lines[2:] {
441 entry.DomainURLs = append(entry.DomainURLs, makeA(line, u.Host))
443 entry.CommentsNum = len(parseComments(entry.CommentsRaw))
444 entry.Topics = parseTopics(entry.TopicsRaw)
447 offsetPrev := offset - PageEntries
451 tmpl := template.Must(template.New("index").Parse(TmplHTMLIndex))
452 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
453 err = tmpl.Execute(out, struct {
461 AtomCommentsFeed string
468 Version: sgblog.Version,
471 TopicsEnabled: topicsTree != nil,
472 Topics: topicsCache.Topics(),
473 CommentsEnabled: commentsTree != nil,
474 AtomPostsFeed: AtomPostsFeed,
475 AtomCommentsFeed: AtomCommentsFeed,
477 OffsetPrev: offsetPrev,
478 OffsetNext: offset + PageEntries,
485 } else if pathInfo == "/"+AtomPostsFeed {
486 commit, err := repo.CommitObject(*headHash)
492 if t, exists := queryValues["topic"]; exists {
496 etagHash.Write([]byte("ATOM POSTS"))
497 etagHash.Write([]byte(topic))
498 etagHash.Write(commit.Hash[:])
504 title = fmt.Sprintf("%s (topic: %s)", cfg.Title, topic)
506 idHasher, err := blake2b.New256(nil)
510 idHasher.Write([]byte("ATOM POSTS"))
511 idHasher.Write([]byte(cfg.AtomId))
512 idHasher.Write([]byte(topic))
515 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
516 Updated: atom.Time(commit.Author.When),
519 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomPostsFeed,
521 Author: &atom.Person{Name: cfg.AtomAuthor},
524 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
528 var commits CommitIterNext
532 topicsCache, err := getTopicsCache(cfg, repoLog)
536 hashes := topicsCache[topic]
538 makeErr(errors.New("no posts with that topic"))
540 commits = &HashesIter{hashes}
543 for i := 0; i < PageEntries; i++ {
544 commit, err = commits.Next()
548 lines := msgSplit(commit.Message)
549 var categories []atom.Category
550 for _, topic := range parseTopics(getNote(topicsTree, commit.Hash)) {
551 categories = append(categories, atom.Category{Term: topic})
553 feed.Entry = append(feed.Entry, &atom.Entry{
555 ID: "urn:uuid:" + bytes2uuid(commit.Hash[:]),
558 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
560 Published: atom.Time(commit.Author.When),
561 Updated: atom.Time(commit.Author.When),
562 Summary: &atom.Text{Type: "text", Body: lines[0]},
565 Body: strings.Join(lines[2:], "\n"),
567 Category: categories,
570 data, err := xml.MarshalIndent(&feed, "", " ")
576 } else if pathInfo == "/"+AtomCommentsFeed {
577 commit, err := repo.CommitObject(commentsRef.Hash())
581 etagHash.Write([]byte("ATOM COMMENTS"))
582 etagHash.Write(commit.Hash[:])
584 idHasher, err := blake2b.New256(nil)
588 idHasher.Write([]byte("ATOM COMMENTS"))
589 idHasher.Write([]byte(cfg.AtomId))
591 Title: cfg.Title + " comments",
592 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
593 Updated: atom.Time(commit.Author.When),
596 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomCommentsFeed,
598 Author: &atom.Person{Name: cfg.AtomAuthor},
600 repoLog, err := repo.Log(&git.LogOptions{From: commentsRef.Hash()})
604 for i := 0; i < PageEntries; i++ {
605 commit, err = repoLog.Next()
609 fileStats, err := commit.Stats()
613 t, err := commit.Tree()
617 commentedHash := plumbing.NewHash(strings.ReplaceAll(
618 fileStats[0].Name, "/", "",
620 commit, err = repo.CommitObject(commentedHash)
624 comments := parseComments(getNote(t, commentedHash))
625 if len(comments) == 0 {
628 commentN := strconv.Itoa(len(comments) - 1)
629 lines := strings.Split(comments[len(comments)-1], "\n")
630 from := strings.TrimPrefix(lines[0], "From: ")
631 date := strings.TrimPrefix(lines[1], "Date: ")
633 idHasher.Write([]byte("COMMENT"))
634 idHasher.Write(commit.Hash[:])
635 idHasher.Write([]byte(commentN))
636 feed.Entry = append(feed.Entry, &atom.Entry{
638 "Comment %s for \"%s\" by %s",
639 commentN, msgSplit(commit.Message)[0], from,
641 Author: &atom.Person{Name: from},
642 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
645 Href: strings.Join([]string{
646 cfg.AtomBaseURL, cfg.URLPrefix, "/",
647 commit.Hash.String(), "#comment", commentN,
650 Published: atom.TimeStr(date),
651 Updated: atom.TimeStr(date),
654 Body: strings.Join(lines[2:], "\n"),
658 data, err := xml.MarshalIndent(&feed, "", " ")
664 } else if sha1DigestRe.MatchString(pathInfo[1:]) {
665 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1 : 1+sha1.Size*2]))
669 for _, data := range etagHashForWeb {
670 etagHash.Write([]byte(data))
672 etagHash.Write([]byte("ENTRY"))
673 etagHash.Write(commit.Hash[:])
674 atomCommentsURL := strings.Join([]string{
675 cfg.AtomBaseURL, cfg.URLPrefix, "/",
676 commit.Hash.String(), "/", AtomCommentsFeed,
678 commentsRaw := getNote(commentsTree, commit.Hash)
679 etagHash.Write(commentsRaw)
680 topicsRaw := getNote(topicsTree, commit.Hash)
681 etagHash.Write(topicsRaw)
682 if strings.HasSuffix(pathInfo, AtomCommentsFeed) {
683 etagHash.Write([]byte("ATOM COMMENTS"))
685 type Comment struct {
691 commentsRaw := parseComments(commentsRaw)
693 if len(commentsRaw) > PageEntries {
694 toSkip = len(commentsRaw) - PageEntries
696 comments := make([]Comment, 0, len(commentsRaw)-toSkip)
697 for i := len(commentsRaw) - 1; i >= toSkip; i-- {
698 lines := strings.Split(commentsRaw[i], "\n")
699 from := strings.TrimPrefix(lines[0], "From: ")
700 date := strings.TrimPrefix(lines[1], "Date: ")
701 comments = append(comments, Comment{
704 date: strings.Replace(date, " ", "T", 1),
708 idHasher, err := blake2b.New256(nil)
712 idHasher.Write([]byte("ATOM COMMENTS"))
713 idHasher.Write(commit.Hash[:])
715 Title: fmt.Sprintf("\"%s\" comments", msgSplit(commit.Message)[0]),
716 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
717 Link: []atom.Link{{Rel: "self", Href: atomCommentsURL}},
718 Author: &atom.Person{Name: cfg.AtomAuthor},
720 if len(comments) > 0 {
721 feed.Updated = atom.TimeStr(comments[0].date)
723 feed.Updated = atom.Time(commit.Author.When)
725 for _, comment := range comments {
727 idHasher.Write([]byte("COMMENT"))
728 idHasher.Write(commit.Hash[:])
729 idHasher.Write([]byte(comment.n))
730 feed.Entry = append(feed.Entry, &atom.Entry{
731 Title: fmt.Sprintf("Comment %s by %s", comment.n, comment.from),
732 Author: &atom.Person{Name: comment.from},
733 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
736 Href: strings.Join([]string{
739 commit.Hash.String(),
740 "#comment", comment.n,
743 Published: atom.TimeStr(comment.date),
744 Updated: atom.TimeStr(comment.date),
747 Body: strings.Join(comment.body, "\n"),
751 data, err := xml.MarshalIndent(&feed, "", " ")
758 notesRaw := getNote(notesTree, commit.Hash)
759 etagHash.Write(notesRaw)
762 lines := msgSplit(commit.Message)
764 when := commit.Author.When.Format(sgblog.WhenFmt)
766 if len(commit.ParentHashes) > 0 {
767 parent = commit.ParentHashes[0].String()
769 commentsParsed := parseComments(commentsRaw)
770 comments := make([]CommentEntry, 0, len(commentsParsed))
771 for _, comment := range commentsParsed {
772 lines := strings.Split(comment, "\n")
773 comments = append(comments, CommentEntry{lines[:3], lines[3:]})
775 var notesLines []string
776 if len(notesRaw) > 0 {
777 notesLines = strings.Split(string(notesRaw), "\n")
780 tmpl := template.New("entry")
781 tmpl = tmpl.Funcs(template.FuncMap{"lineURLize": lineURLizeInTemplate})
782 tmpl = template.Must(tmpl.Parse(TmplHTMLEntry))
783 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
784 err = tmpl.Execute(out, struct {
790 AtomCommentsURL string
792 Commit *object.Commit
795 Comments []CommentEntry
798 Version: sgblog.Version,
801 TitleEscaped: strings.ReplaceAll(title, " ", "%20"),
803 AtomCommentsURL: atomCommentsURL,
807 NoteLines: notesLines,
809 Topics: parseTopics(topicsRaw),
815 makeErr(errors.New("unknown URL action"))
817 out.Write([]byte("</body></html>\n"))
818 if gzipWriter != nil {
821 os.Stdout.Write(outBuf.Bytes())
825 os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
826 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
827 if gzipWriter != nil {
828 os.Stdout.WriteString("Content-Encoding: gzip\n")
831 os.Stdout.WriteString("\n")
832 os.Stdout.Write(outBuf.Bytes())