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/hjson/hjson-go"
41 "go.stargrave.org/sgblog"
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"
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}} ({{.Offset}}-{{.OffsetNext}})</title>
57 {{if .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.Cfg.CSS}}">{{end}}
58 {{if .Cfg.Webmaster}}<link rev="made" href="mailto:{{.Cfg.Webmaster}}">{{end -}}
59 {{- range .Cfg.GitURLs}}
60 <link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
61 <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
62 <link rel="alternate" title="Posts feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomPostsFeed}}" type="application/atom+xml">
63 {{if .CommentsEnabled}}<link rel="alternate" title="Comments feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomCommentsFeed}}" type="application/atom+xml">{{end}}
64 {{if .Offset}}<link rel="prev" href="{{.Cfg.URLPrefix}}/{{if .OffsetPrev}}?offset={{.OffsetPrev}}{{end}}" title="prev">{{end}}
65 {{if not .LogEnded}}<link rel="next" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}" title="next">{{end}}
68 {{if .Cfg.AboutURL}}[<a href="{{.Cfg.AboutURL}}">about</a>]{{end}}
70 {{if .Offset}}[<a href="{{.Cfg.URLPrefix}}/{{if .OffsetPrev}}?offset={{.OffsetPrev}}{{end}}">prev</a>]{{end}}
71 {{if not .LogEnded}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}">next</a>]{{end}}
74 {{- $yearPrev := 0 -}}
75 {{- $monthPrev := 0 -}}
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 </tr>{{range .Entries -}}
84 {{- $yearCur := .Commit.Author.When.Year -}}
85 {{- $monthCur := .Commit.Author.When.Month -}}
86 {{- $dayCur := .Commit.Author.When.Day -}}
87 {{- if or (ne $dayCur $dayPrev) (ne $monthCur $monthPrev) (ne $yearCur $yearPrev) -}}
88 <tr><td colspan=6><center><tt>{{$yearCur | printf "%04d"}}-{{$monthCur | printf "%02d"}}-{{$dayCur | printf "%02d"}}</tt></center></td></tr>
89 {{- $dayPrev = $dayCur}}{{$monthPrev = $monthCur}}{{$yearPrev = $yearCur -}}
93 <td><tt>{{.Commit.Author.When.Hour | printf "%02d" -}}:{{- .Commit.Author.When.Minute | printf "%02d"}}</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>{{range .DomainURLs}} {{.}} {{end}}</td>
100 {{template "links" .}}
104 TmplHTMLEntry = `{{$Cfg := .Cfg}}<html>
106 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
107 <meta name="generator" content="SGBlog {{.Version}}">
108 <title>{{.Title}} ({{.When}})</title>
109 {{if .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.Cfg.CSS}}">{{end}}
110 {{if .Cfg.Webmaster}}<link rev="made" href="mailto:{{.Cfg.Webmaster}}">{{end -}}
111 {{- range .Cfg.GitURLs}}
112 <link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
113 <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
114 <link rel="alternate" title="Comments feed" href="{{.AtomCommentsURL}}" type="application/atom+xml">
115 {{if .Parent}}<link rel="prev" href="{{.Cfg.URLPrefix}}/{{.Parent}}" title="prev">{{end}}
118 {{if .Cfg.AboutURL}}[<a href="{{.Cfg.AboutURL}}">about</a>]{{end}}
119 [<a href="{{.Cfg.URLPrefix}}/">index</a>]
120 {{if .Parent}}[<a href="{{.Cfg.URLPrefix}}/{{.Parent}}">prev</a>]{{end}}
121 [<tt><a title="When">{{.When}}</a></tt>]
122 [<tt><a title="What">{{.Commit.Hash.String}}</a></tt>]
127 {{range .Lines}}{{. | lineURLize $Cfg.URLPrefix}}
131 {{if .NoteLines}}Note:<pre>
132 {{range .NoteLines}}{{. | lineURLize $Cfg.URLPrefix}}
136 {{if .Cfg.CommentsEmail}}[<a href="mailto:{{.Cfg.CommentsEmail}}?subject={{.Commit.Hash.String}}">leave comment</a>]{{end}}
138 <dl>{{range $idx, $comment := .Comments}}
139 <dt><a name="comment{{$idx}}"><a href="#comment{{$idx}}">comment {{$idx}}</a>:</dt>
141 {{range $comment.HeaderLines}}{{.}}
142 {{end}}{{range $comment.BodyLines}}{{. | lineURLize $Cfg.URLPrefix}}
152 renderableSchemes = map[string]struct{}{
154 "gopher": struct{}{},
157 "telnet": struct{}{},
161 type TableEntry struct {
162 Commit *object.Commit
171 type CommentEntry struct {
176 func makeA(href, text string) string {
177 return `<a href="` + href + `">` + text + `</a>`
180 func etagString(etag hash.Hash) string {
181 return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
184 func urlParse(what string) *url.URL {
185 if u, err := url.ParseRequestURI(what); err == nil {
186 if _, exists := renderableSchemes[u.Scheme]; exists {
193 func lineURLize(urlPrefix, line string) string {
194 cols := strings.Split(html.EscapeString(line), " ")
195 for i, col := range cols {
196 if u := urlParse(col); u != nil {
197 cols[i] = makeA(col, col)
200 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
202 return strings.Join(cols, " ")
205 func lineURLizeInTemplate(urlPrefix, line interface{}) string {
206 return lineURLize(urlPrefix.(string), line.(string))
209 func startHeader(etag hash.Hash, gziped bool) string {
211 "Content-Type: text/html; charset=UTF-8",
212 "ETag: " + etagString(etag),
215 lines = append(lines, "Content-Encoding: gzip")
217 lines = append(lines, "")
218 lines = append(lines, "")
219 return strings.Join(lines, "\n")
222 func makeErr(err error) {
223 fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
228 func checkETag(etag hash.Hash) {
229 ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
230 if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
231 fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch)
236 func bytes2uuid(b []byte) string {
239 raw[6] = (raw[6] & 0x0F) | uint8(4<<4) // version 4
240 return fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:])
244 cfgPath := os.Getenv("SGBLOG_CFG")
246 log.Fatalln("SGBLOG_CFG is not set")
248 cfgRaw, err := ioutil.ReadFile(cfgPath)
252 var cfgGeneral map[string]interface{}
253 if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
256 cfgRaw, err = json.Marshal(cfgGeneral)
261 if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
264 pathInfo, exists := os.LookupEnv("PATH_INFO")
268 queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
273 etagHash, err := blake2b.New256(nil)
277 for _, s := range []string{
288 if _, err = etagHash.Write([]byte(s)); err != nil {
292 etagHashForWeb := []string{
296 cfg.CommentsNotesRef,
299 for _, gitURL := range cfg.GitURLs {
300 etagHashForWeb = append(etagHashForWeb, gitURL)
303 headHash, err := initRepo(cfg)
308 if notes, err := repo.Notes(); err == nil {
309 var notesRef *plumbing.Reference
310 var commentsRef *plumbing.Reference
311 notes.ForEach(func(ref *plumbing.Reference) error {
312 switch string(ref.Name()) {
313 case "refs/notes/commits":
315 case cfg.CommentsNotesRef:
321 if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
322 notesTree, _ = commentsCommit.Tree()
325 if commentsRef != nil {
326 if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
327 commentsTree, _ = commentsCommit.Tree()
332 var outBuf bytes.Buffer
335 var gzipWriter *gzip.Writer
336 acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
337 for _, encoding := range strings.Split(acceptEncoding, ", ") {
338 if encoding == "gzip" {
339 gzipWriter = gzip.NewWriter(&outBuf)
346 if offsetRaw, exists := queryValues["offset"]; exists {
347 offset, err = strconv.Atoi(offsetRaw[0])
352 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
357 for i := 0; i < offset; i++ {
358 if _, err = repoLog.Next(); err != nil {
364 entries := make([]TableEntry, 0, PageEntries)
366 for _, data := range etagHashForWeb {
367 etagHash.Write([]byte(data))
369 etagHash.Write([]byte("INDEX"))
370 for i := 0; i < PageEntries; i++ {
371 commit, err := repoLog.Next()
376 etagHash.Write(commit.Hash[:])
377 commentsRaw := getNote(commentsTree, commit.Hash)
378 etagHash.Write(commentsRaw)
379 entries = append(entries, TableEntry{
381 CommentsRaw: commentsRaw,
386 for i, entry := range entries {
389 lines := msgSplit(entry.Commit.Message)
390 entry.Title = lines[0]
391 entry.LinesNum = len(lines) - 2
392 for _, line := range lines[2:] {
397 entry.DomainURLs = append(entry.DomainURLs, makeA(line, u.Host))
399 entry.CommentsNum = len(parseComments(entry.CommentsRaw))
402 tmpl := template.Must(template.New("index").Parse(TmplHTMLIndex))
403 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
404 err = tmpl.Execute(out, struct {
409 AtomCommentsFeed string
416 Version: sgblog.Version,
418 CommentsEnabled: commentsTree != nil,
419 AtomPostsFeed: AtomPostsFeed,
420 AtomCommentsFeed: AtomCommentsFeed,
422 OffsetPrev: offset - PageEntries,
423 OffsetNext: offset + PageEntries,
430 } else if pathInfo == "/"+AtomPostsFeed {
431 commit, err := repo.CommitObject(*headHash)
435 etagHash.Write([]byte("ATOM POSTS"))
436 etagHash.Write(commit.Hash[:])
441 Updated: atom.Time(commit.Author.When),
444 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomPostsFeed,
446 Author: &atom.Person{Name: cfg.AtomAuthor},
448 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
452 for i := 0; i < PageEntries; i++ {
453 commit, err = repoLog.Next()
457 lines := msgSplit(commit.Message)
458 feed.Entry = append(feed.Entry, &atom.Entry{
460 ID: "urn:uuid:" + bytes2uuid(commit.Hash[:]),
463 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
465 Published: atom.Time(commit.Author.When),
466 Updated: atom.Time(commit.Author.When),
467 Summary: &atom.Text{Type: "text", Body: lines[0]},
470 Body: strings.Join(lines[2:], "\n"),
474 data, err := xml.MarshalIndent(&feed, "", " ")
480 } else if pathInfo == "/"+AtomCommentsFeed {
481 commit, err := repo.CommitObject(commentsRef.Hash())
485 etagHash.Write([]byte("ATOM COMMENTS"))
486 etagHash.Write(commit.Hash[:])
488 idHasher, err := blake2b.New256(nil)
492 idHasher.Write([]byte("ATOM COMMENTS"))
493 idHasher.Write([]byte(cfg.AtomId))
495 Title: cfg.Title + " comments",
496 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
497 Updated: atom.Time(commit.Author.When),
500 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomCommentsFeed,
502 Author: &atom.Person{Name: cfg.AtomAuthor},
504 repoLog, err := repo.Log(&git.LogOptions{From: commentsRef.Hash()})
508 for i := 0; i < PageEntries; i++ {
509 commit, err = repoLog.Next()
513 fileStats, err := commit.Stats()
517 t, err := commit.Tree()
521 commentedHash := plumbing.NewHash(strings.ReplaceAll(
522 fileStats[0].Name, "/", "",
524 commit, err = repo.CommitObject(commentedHash)
528 comments := parseComments(getNote(t, commentedHash))
529 if len(comments) == 0 {
532 commentN := strconv.Itoa(len(comments) - 1)
533 lines := strings.Split(comments[len(comments)-1], "\n")
534 from := strings.TrimPrefix(lines[0], "From: ")
535 date := strings.TrimPrefix(lines[1], "Date: ")
537 idHasher.Write([]byte("COMMENT"))
538 idHasher.Write(commit.Hash[:])
539 idHasher.Write([]byte(commentN))
540 feed.Entry = append(feed.Entry, &atom.Entry{
542 "Comment %s for \"%s\" by %s",
543 commentN, msgSplit(commit.Message)[0], from,
545 Author: &atom.Person{Name: from},
546 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
549 Href: strings.Join([]string{
550 cfg.AtomBaseURL, cfg.URLPrefix, "/",
551 commit.Hash.String(), "#comment", commentN,
554 Published: atom.TimeStr(date),
555 Updated: atom.TimeStr(date),
558 Body: strings.Join(lines[2:], "\n"),
562 data, err := xml.MarshalIndent(&feed, "", " ")
568 } else if sha1DigestRe.MatchString(pathInfo[1:]) {
569 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1 : 1+sha1.Size*2]))
573 for _, data := range etagHashForWeb {
574 etagHash.Write([]byte(data))
576 etagHash.Write([]byte("ENTRY"))
577 etagHash.Write(commit.Hash[:])
578 atomCommentsURL := strings.Join([]string{
579 cfg.AtomBaseURL, cfg.URLPrefix, "/",
580 commit.Hash.String(), "/", AtomCommentsFeed,
582 commentsRaw := getNote(commentsTree, commit.Hash)
583 etagHash.Write(commentsRaw)
584 if strings.HasSuffix(pathInfo, AtomCommentsFeed) {
585 etagHash.Write([]byte("ATOM COMMENTS"))
587 type Comment struct {
593 commentsRaw := parseComments(commentsRaw)
595 if len(commentsRaw) > PageEntries {
596 toSkip = len(commentsRaw) - PageEntries
598 comments := make([]Comment, 0, len(commentsRaw)-toSkip)
599 for i := len(commentsRaw) - 1; i >= toSkip; i-- {
600 lines := strings.Split(commentsRaw[i], "\n")
601 from := strings.TrimPrefix(lines[0], "From: ")
602 date := strings.TrimPrefix(lines[1], "Date: ")
603 comments = append(comments, Comment{
606 date: strings.Replace(date, " ", "T", 1),
610 idHasher, err := blake2b.New256(nil)
614 idHasher.Write([]byte("ATOM COMMENTS"))
615 idHasher.Write(commit.Hash[:])
617 Title: fmt.Sprintf("\"%s\" comments", msgSplit(commit.Message)[0]),
618 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
619 Link: []atom.Link{{Rel: "self", Href: atomCommentsURL}},
620 Author: &atom.Person{Name: cfg.AtomAuthor},
622 if len(comments) > 0 {
623 feed.Updated = atom.TimeStr(comments[0].date)
625 feed.Updated = atom.Time(commit.Author.When)
627 for _, comment := range comments {
629 idHasher.Write([]byte("COMMENT"))
630 idHasher.Write(commit.Hash[:])
631 idHasher.Write([]byte(comment.n))
632 feed.Entry = append(feed.Entry, &atom.Entry{
633 Title: fmt.Sprintf("Comment %s by %s", comment.n, comment.from),
634 Author: &atom.Person{Name: comment.from},
635 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
638 Href: strings.Join([]string{
641 commit.Hash.String(),
642 "#comment", comment.n,
645 Published: atom.TimeStr(comment.date),
646 Updated: atom.TimeStr(comment.date),
649 Body: strings.Join(comment.body, "\n"),
653 data, err := xml.MarshalIndent(&feed, "", " ")
660 notesRaw := getNote(notesTree, commit.Hash)
661 etagHash.Write(notesRaw)
664 lines := msgSplit(commit.Message)
666 when := commit.Author.When.Format(sgblog.WhenFmt)
668 if len(commit.ParentHashes) > 0 {
669 parent = commit.ParentHashes[0].String()
671 commentsParsed := parseComments(commentsRaw)
672 comments := make([]CommentEntry, 0, len(commentsParsed))
673 for _, comment := range commentsParsed {
674 lines := strings.Split(comment, "\n")
675 comments = append(comments, CommentEntry{lines[:3], lines[3:]})
677 var notesLines []string
678 if len(notesRaw) > 0 {
679 notesLines = strings.Split(string(notesRaw), "\n")
682 tmpl := template.New("entry")
683 tmpl = tmpl.Funcs(template.FuncMap{"lineURLize": lineURLizeInTemplate})
684 tmpl = template.Must(tmpl.Parse(TmplHTMLEntry))
685 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
686 err = tmpl.Execute(out, struct {
691 AtomCommentsURL string
693 Commit *object.Commit
696 Comments []CommentEntry
698 Version: sgblog.Version,
702 AtomCommentsURL: atomCommentsURL,
706 NoteLines: notesLines,
713 makeErr(errors.New("unknown URL action"))
715 out.Write([]byte("</body></html>\n"))
716 if gzipWriter != nil {
719 os.Stdout.Write(outBuf.Bytes())
723 os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
724 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
725 if gzipWriter != nil {
726 os.Stdout.WriteString("Content-Encoding: gzip\n")
729 os.Stdout.WriteString("\n")
730 os.Stdout.Write(outBuf.Bytes())