2 SGBlog -- Git-backed CGI/inetd blogging/phlogging engine
3 Copyright (C) 2020-2021 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/>.
38 "github.com/go-git/go-git/v5"
39 "github.com/go-git/go-git/v5/plumbing"
40 "github.com/go-git/go-git/v5/plumbing/object"
41 "go.stargrave.org/sgblog"
42 "go.stargrave.org/sgblog/cmd/sgblog/atom"
43 "golang.org/x/crypto/blake2b"
47 AtomPostsFeed = "feed.atom"
48 AtomCommentsFeed = "comments.atom"
49 TmplHTMLIndex = `<html>
51 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
52 <meta name="generator" content="SGBlog {{.Version}}">
53 <title>{{.Cfg.Title}} {{if .Topic}}(topic: {{.Topic}}) {{end}}({{.Offset}}-{{.OffsetNext}})</title>
54 {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
55 {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end}}
56 {{range .Cfg.GitURLs}}<link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
57 <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
58 <link rel="alternate" title="Posts feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomPostsFeed}}{{if .Topic}}?topic={{.Topic}}{{end}}" type="application/atom+xml">
59 {{if .CommentsEnabled}}<link rel="alternate" title="Comments feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomCommentsFeed}}" type="application/atom+xml">{{end}}
60 {{if .Offset}}<link rel="prev" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="prev">{{end}}
61 {{if not .LogEnded}}<link rel="next" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="next">{{end}}
64 {{with .Cfg.AboutURL}}[<a href="{{.}}">about</a>]{{end}}
66 {{if .Offset}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}">prev</a>]{{end}}
67 {{if not .LogEnded}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}">next</a>]{{end}}
71 Topics: [<tt><a href="{{$Cfg.URLPrefix}}/">ALL</a></tt>]
72 {{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]
75 {{- $TopicsEnabled := .TopicsEnabled -}}
76 {{- $datePrev := "0001-01-01" -}}
79 <th>N</th><th>When</th><th>Title</th>
80 <th size="5%"><a title="Lines">L</a></th>
81 <th size="5%"><a title="Comments">C</a></th>
83 {{if .TopicsEnabled}}<th>Topics</th>{{end}}
86 {{- $dateCur := .Commit.Author.When.Format "2006-01-02" -}}
87 {{- if ne $dateCur $datePrev -}}
88 <tr><td colspan={{if $TopicsEnabled}}7{{else}}7{{end}}><center><tt>{{$dateCur}}</tt></center></td></tr>
89 {{- $datePrev = $dateCur -}}
93 <td><tt>{{.Commit.Author.When.Format "15:04"}}</tt></td>
94 <td><a href="{{$Cfg.URLPrefix}}/{{.Commit.Hash.String}}">{{.Title}}</a></td>
95 <td>{{.LinesNum}}</td>
96 <td>{{if .CommentsNum}}{{.CommentsNum}}{{else}} {{end}}</td>
97 <td>{{if .DomainURLs}}{{range .DomainURLs}} {{.}} {{end}}{{else}} {{end}}</td>
98 {{if $TopicsEnabled}}<td>{{if .Topics}}{{range .Topics}} <a href="{{$Cfg.URLPrefix}}/?topic={{.}}">{{.}}</a> {{end}}{{else}} {{end}}</td>{{end}}
101 {{template "links" .}}
105 TmplHTMLEntry = `{{$Cfg := .Cfg}}<html>
107 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
108 <meta name="generator" content="SGBlog {{.Version}}">
109 <title>{{.Title}} ({{.When}})</title>
110 {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
111 {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end -}}
112 {{- range .Cfg.GitURLs}}
113 <link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
114 <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
115 <link rel="alternate" title="Comments feed" href="{{.AtomCommentsURL}}" type="application/atom+xml">
116 {{if .Parent}}<link rel="prev" href="{{.Cfg.URLPrefix}}/{{.Parent}}" title="prev">{{end}}
119 {{with .Cfg.AboutURL}}[<a href="{{.}}">about</a>]{{end}}
120 [<a href="{{.Cfg.URLPrefix}}/">index</a>]
121 {{if .Parent}}[<a href="{{.Cfg.URLPrefix}}/{{.Parent}}">prev</a>]{{end}}
122 [<tt><a title="When">{{.When}}</a></tt>]
123 [<tt><a title="What">{{.Commit.Hash.String}}</a></tt>]
127 Topics: {{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]{{end}}
133 {{range .Lines}}{{. | lineURLize $Cfg.URLPrefix}}
137 {{if .NoteLines}}Note:<pre>
138 {{range .NoteLines}}{{. | lineURLize $Cfg.URLPrefix}}
142 {{if .Cfg.CommentsEmail}}[<a href="mailto:{{.Cfg.CommentsEmail}}?subject={{.TitleEscaped}}">leave comment</a>]{{end}}
144 <dl>{{range $idx, $comment := .Comments}}
145 <dt><a name="comment{{$idx}}"><a href="#comment{{$idx}}">comment {{$idx}}</a>:</dt>
147 {{range $comment.HeaderLines}}{{.}}
148 {{end}}{{range $comment.BodyLines}}{{. | lineURLize $Cfg.URLPrefix}}
158 renderableSchemes = map[string]struct{}{
167 type TableEntry struct {
168 Commit *object.Commit
179 type CommentEntry struct {
184 func makeA(href, text string) string {
185 return `<a href="` + href + `">` + text + `</a>`
188 func etagString(etag hash.Hash) string {
189 return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
192 func urlParse(what string) *url.URL {
193 if u, err := url.ParseRequestURI(what); err == nil {
194 if _, exists := renderableSchemes[u.Scheme]; exists {
201 func lineURLize(urlPrefix, line string) string {
202 cols := strings.Split(html.EscapeString(line), " ")
203 for i, col := range cols {
204 if u := urlParse(col); u != nil {
205 cols[i] = makeA(col, col)
208 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
210 return strings.Join(cols, " ")
213 func lineURLizeInTemplate(urlPrefix, line interface{}) string {
214 return lineURLize(urlPrefix.(string), line.(string))
217 func startHeader(etag hash.Hash, gziped bool) string {
219 "Content-Type: text/html; charset=UTF-8",
220 "ETag: " + etagString(etag),
223 lines = append(lines, "Content-Encoding: gzip")
225 lines = append(lines, "")
226 lines = append(lines, "")
227 return strings.Join(lines, "\n")
230 func makeErr(err error) {
231 fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
236 func checkETag(etag hash.Hash) {
237 ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
238 if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
239 fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch)
244 func bytes2uuid(b []byte) string {
247 raw[6] = (raw[6] & 0x0F) | uint8(4<<4) // version 4
248 return fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:])
251 type CommitIterNext interface {
252 Next() (*object.Commit, error)
256 cfgPath := os.Getenv("SGBLOG_CFG")
258 log.Fatalln("SGBLOG_CFG is not set")
260 cfg, err := readCfg(cfgPath)
265 pathInfo, exists := os.LookupEnv("PATH_INFO")
269 queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
274 etagHash, err := blake2b.New256(nil)
278 for _, s := range []string{
289 if _, err = etagHash.Write([]byte(s)); err != nil {
293 etagHashForWeb := []string{
297 cfg.CommentsNotesRef,
300 for _, gitURL := range cfg.GitURLs {
301 etagHashForWeb = append(etagHashForWeb, gitURL)
304 headHash, err := initRepo(cfg)
309 if notes, err := repo.Notes(); err == nil {
310 var notesRef *plumbing.Reference
311 var commentsRef *plumbing.Reference
312 notes.ForEach(func(ref *plumbing.Reference) error {
313 switch string(ref.Name()) {
314 case "refs/notes/commits":
316 case cfg.CommentsNotesRef:
322 if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
323 notesTree, _ = commentsCommit.Tree()
326 if commentsRef != nil {
327 if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
328 commentsTree, _ = commentsCommit.Tree()
333 var outBuf bytes.Buffer
336 var gzipWriter *gzip.Writer
337 acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
338 for _, encoding := range strings.Split(acceptEncoding, ", ") {
339 if encoding == "gzip" {
340 gzipWriter = gzip.NewWriter(&outBuf)
347 if offsetRaw, exists := queryValues["offset"]; exists {
348 offset, err = strconv.Atoi(offsetRaw[0])
353 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
357 topicsCache, err := getTopicsCache(cfg, repoLog)
361 repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
367 var commits CommitIterNext
369 if t, exists := queryValues["topic"]; exists {
371 hashes := topicsCache[topic]
373 makeErr(errors.New("no posts with that topic"))
375 if len(hashes) > offset {
376 hashes = hashes[offset:]
379 commits = &HashesIter{hashes}
381 for i := 0; i < offset; i++ {
382 if _, err = repoLog.Next(); err != nil {
390 entries := make([]TableEntry, 0, PageEntries)
392 for _, data := range etagHashForWeb {
393 etagHash.Write([]byte(data))
395 etagHash.Write([]byte("INDEX"))
396 etagHash.Write([]byte(topic))
397 for i := 0; i < PageEntries; i++ {
398 commit, err := commits.Next()
403 etagHash.Write(commit.Hash[:])
404 commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash)
405 etagHash.Write(commentsRaw)
406 topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash)
407 etagHash.Write(topicsRaw)
408 entries = append(entries, TableEntry{
410 CommentsRaw: commentsRaw,
411 TopicsRaw: topicsRaw,
416 for i, entry := range entries {
419 lines := msgSplit(entry.Commit.Message)
420 entry.Title = lines[0]
421 entry.LinesNum = len(lines) - 2
422 for _, line := range lines[2:] {
427 entry.DomainURLs = append(entry.DomainURLs, makeA(line, u.Host))
429 entry.CommentsNum = len(sgblog.ParseComments(entry.CommentsRaw))
430 entry.Topics = sgblog.ParseTopics(entry.TopicsRaw)
433 offsetPrev := offset - PageEntries
437 tmpl := template.Must(template.New("index").Parse(TmplHTMLIndex))
438 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
439 err = tmpl.Execute(out, struct {
447 AtomCommentsFeed string
454 Version: sgblog.Version,
457 TopicsEnabled: topicsTree != nil,
458 Topics: topicsCache.Topics(),
459 CommentsEnabled: commentsTree != nil,
460 AtomPostsFeed: AtomPostsFeed,
461 AtomCommentsFeed: AtomCommentsFeed,
463 OffsetPrev: offsetPrev,
464 OffsetNext: offset + PageEntries,
471 } else if pathInfo == "/"+AtomPostsFeed {
472 commit, err := repo.CommitObject(*headHash)
478 if t, exists := queryValues["topic"]; exists {
482 etagHash.Write([]byte("ATOM POSTS"))
483 etagHash.Write([]byte(topic))
484 etagHash.Write(commit.Hash[:])
490 title = fmt.Sprintf("%s (topic: %s)", cfg.Title, topic)
492 idHasher, err := blake2b.New256(nil)
496 idHasher.Write([]byte("ATOM POSTS"))
497 idHasher.Write([]byte(cfg.AtomId))
498 idHasher.Write([]byte(topic))
501 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
502 Updated: atom.Time(commit.Author.When),
505 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomPostsFeed,
507 Author: &atom.Person{Name: cfg.AtomAuthor},
510 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
514 var commits CommitIterNext
518 topicsCache, err := getTopicsCache(cfg, repoLog)
522 hashes := topicsCache[topic]
524 makeErr(errors.New("no posts with that topic"))
526 commits = &HashesIter{hashes}
529 for i := 0; i < PageEntries; i++ {
530 commit, err = commits.Next()
534 lines := msgSplit(commit.Message)
535 var categories []atom.Category
536 for _, topic := range sgblog.ParseTopics(sgblog.GetNote(repo, topicsTree, commit.Hash)) {
537 categories = append(categories, atom.Category{Term: topic})
539 htmlized := make([]string, 0, len(lines))
540 htmlized = append(htmlized, "<pre>")
541 for _, l := range lines[2:] {
542 htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
544 htmlized = append(htmlized, "</pre>")
545 feed.Entry = append(feed.Entry, &atom.Entry{
547 ID: "urn:uuid:" + bytes2uuid(commit.Hash[:]),
550 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
552 Published: atom.Time(commit.Author.When),
553 Updated: atom.Time(commit.Author.When),
554 Summary: &atom.Text{Type: "text", Body: lines[0]},
557 Body: strings.Join(htmlized, "\n"),
559 Category: categories,
562 data, err := xml.MarshalIndent(&feed, "", " ")
568 } else if pathInfo == "/"+AtomCommentsFeed {
569 commit, err := repo.CommitObject(commentsRef.Hash())
573 etagHash.Write([]byte("ATOM COMMENTS"))
574 etagHash.Write(commit.Hash[:])
576 idHasher, err := blake2b.New256(nil)
580 idHasher.Write([]byte("ATOM COMMENTS"))
581 idHasher.Write([]byte(cfg.AtomId))
583 Title: cfg.Title + " comments",
584 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
585 Updated: atom.Time(commit.Author.When),
588 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomCommentsFeed,
590 Author: &atom.Person{Name: cfg.AtomAuthor},
592 repoLog, err := repo.Log(&git.LogOptions{From: commentsRef.Hash()})
596 for i := 0; i < PageEntries; i++ {
597 commit, err = repoLog.Next()
601 fileStats, err := commit.Stats()
605 t, err := commit.Tree()
609 commentedHash := plumbing.NewHash(strings.ReplaceAll(
610 fileStats[0].Name, "/", "",
612 commit, err = repo.CommitObject(commentedHash)
616 comments := sgblog.ParseComments(sgblog.GetNote(repo, t, commentedHash))
617 if len(comments) == 0 {
620 commentN := strconv.Itoa(len(comments) - 1)
621 lines := strings.Split(comments[len(comments)-1], "\n")
622 from := strings.TrimPrefix(lines[0], "From: ")
623 date := strings.TrimPrefix(lines[1], "Date: ")
624 htmlized := make([]string, 0, len(lines))
625 htmlized = append(htmlized, "<pre>")
626 for _, l := range lines[2:] {
627 htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
629 htmlized = append(htmlized, "</pre>")
631 idHasher.Write([]byte("COMMENT"))
632 idHasher.Write(commit.Hash[:])
633 idHasher.Write([]byte(commentN))
634 feed.Entry = append(feed.Entry, &atom.Entry{
636 "Comment %s for \"%s\" by %s",
637 commentN, msgSplit(commit.Message)[0], from,
639 Author: &atom.Person{Name: from},
640 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
643 Href: strings.Join([]string{
644 cfg.AtomBaseURL, cfg.URLPrefix, "/",
645 commit.Hash.String(), "#comment", commentN,
648 Published: atom.TimeStr(strings.Replace(date, " ", "T", -1)),
649 Updated: atom.TimeStr(strings.Replace(date, " ", "T", -1)),
652 Body: strings.Join(htmlized, "\n"),
656 data, err := xml.MarshalIndent(&feed, "", " ")
662 } else if sha1DigestRe.MatchString(pathInfo[1:]) {
663 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1 : 1+sha1.Size*2]))
667 for _, data := range etagHashForWeb {
668 etagHash.Write([]byte(data))
670 etagHash.Write([]byte("ENTRY"))
671 etagHash.Write(commit.Hash[:])
672 atomCommentsURL := strings.Join([]string{
673 cfg.AtomBaseURL, cfg.URLPrefix, "/",
674 commit.Hash.String(), "/", AtomCommentsFeed,
676 commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash)
677 etagHash.Write(commentsRaw)
678 topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash)
679 etagHash.Write(topicsRaw)
680 if strings.HasSuffix(pathInfo, AtomCommentsFeed) {
681 etagHash.Write([]byte("ATOM COMMENTS"))
683 type Comment struct {
689 commentsRaw := sgblog.ParseComments(commentsRaw)
691 if len(commentsRaw) > PageEntries {
692 toSkip = len(commentsRaw) - PageEntries
694 comments := make([]Comment, 0, len(commentsRaw)-toSkip)
695 for i := len(commentsRaw) - 1; i >= toSkip; i-- {
696 lines := strings.Split(commentsRaw[i], "\n")
697 from := strings.TrimPrefix(lines[0], "From: ")
698 date := strings.TrimPrefix(lines[1], "Date: ")
699 comments = append(comments, Comment{
702 date: strings.Replace(date, " ", "T", 1),
706 idHasher, err := blake2b.New256(nil)
710 idHasher.Write([]byte("ATOM COMMENTS"))
711 idHasher.Write(commit.Hash[:])
713 Title: fmt.Sprintf("\"%s\" comments", msgSplit(commit.Message)[0]),
714 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
715 Link: []atom.Link{{Rel: "self", Href: atomCommentsURL}},
716 Author: &atom.Person{Name: cfg.AtomAuthor},
718 if len(comments) > 0 {
719 feed.Updated = atom.TimeStr(comments[0].date)
721 feed.Updated = atom.Time(commit.Author.When)
723 for _, comment := range comments {
725 idHasher.Write([]byte("COMMENT"))
726 idHasher.Write(commit.Hash[:])
727 idHasher.Write([]byte(comment.n))
728 htmlized := make([]string, 0, len(comment.body))
729 htmlized = append(htmlized, "<pre>")
730 for _, l := range comment.body {
733 lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l),
736 htmlized = append(htmlized, "</pre>")
737 feed.Entry = append(feed.Entry, &atom.Entry{
738 Title: fmt.Sprintf("Comment %s by %s", comment.n, comment.from),
739 Author: &atom.Person{Name: comment.from},
740 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
743 Href: strings.Join([]string{
746 commit.Hash.String(),
747 "#comment", comment.n,
750 Published: atom.TimeStr(
751 strings.Replace(comment.date, " ", "T", -1),
753 Updated: atom.TimeStr(
754 strings.Replace(comment.date, " ", "T", -1),
758 Body: strings.Join(htmlized, "\n"),
762 data, err := xml.MarshalIndent(&feed, "", " ")
769 notesRaw := sgblog.GetNote(repo, notesTree, commit.Hash)
770 etagHash.Write(notesRaw)
773 lines := msgSplit(commit.Message)
775 when := commit.Author.When.Format(sgblog.WhenFmt)
777 if len(commit.ParentHashes) > 0 {
778 parent = commit.ParentHashes[0].String()
780 commentsParsed := sgblog.ParseComments(commentsRaw)
781 comments := make([]CommentEntry, 0, len(commentsParsed))
782 for _, comment := range commentsParsed {
783 lines := strings.Split(comment, "\n")
784 comments = append(comments, CommentEntry{lines[:3], lines[3:]})
786 var notesLines []string
787 if len(notesRaw) > 0 {
788 notesLines = strings.Split(string(notesRaw), "\n")
791 tmpl := template.New("entry")
792 tmpl = tmpl.Funcs(template.FuncMap{"lineURLize": lineURLizeInTemplate})
793 tmpl = template.Must(tmpl.Parse(TmplHTMLEntry))
794 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
795 err = tmpl.Execute(out, struct {
801 AtomCommentsURL string
803 Commit *object.Commit
806 Comments []CommentEntry
809 Version: sgblog.Version,
812 TitleEscaped: url.PathEscape(fmt.Sprintf("Re: %s (%s)", title, commit.Hash)),
814 AtomCommentsURL: atomCommentsURL,
818 NoteLines: notesLines,
820 Topics: sgblog.ParseTopics(topicsRaw),
826 makeErr(errors.New("unknown URL action"))
828 out.Write([]byte("</body></html>\n"))
829 if gzipWriter != nil {
832 os.Stdout.Write(outBuf.Bytes())
836 os.Stdout.WriteString("Content-Type: application/atom+xml; charset=UTF-8\n")
837 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
838 if gzipWriter != nil {
839 os.Stdout.WriteString("Content-Encoding: gzip\n")
842 os.Stdout.WriteString("\n")
843 os.Stdout.Write(outBuf.Bytes())