2 SGBlog -- Git-based CGI blogging 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/>.
18 // Git-based CGI blogging engine
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"
52 AtomFeed = "feed.atom"
56 sha1DigestRe = regexp.MustCompilePOSIX("([0-9a-f]{40,40})")
57 defaultLinks = []string{}
59 notesTree *object.Tree
60 commentsTree *object.Tree
62 renderableSchemes = map[string]struct{}{
71 type TableEntry struct {
92 CommentsNotesRef string
96 func makeA(href, text string) string {
97 return `<a href="` + href + `">` + text + `</a>`
100 func etagString(etag hash.Hash) string {
101 return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
104 func urlParse(what string) *url.URL {
105 if u, err := url.ParseRequestURI(what); err == nil {
106 if _, exists := renderableSchemes[u.Scheme]; exists {
113 func msgSplit(msg string) []string {
114 lines := strings.Split(msg, "\n")
115 lines = lines[:len(lines)-1]
117 lines = []string{lines[0], "", ""}
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)
129 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(
130 urlPrefix+"/$1", "$1",
133 return strings.Join(cols, " ")
136 func getNote(tree *object.Tree, what plumbing.Hash) []byte {
140 var entry *object.TreeEntry
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)
155 blob, err := repo.BlobObject(entry.Hash)
159 r, err := blob.Reader()
163 data, err := ioutil.ReadAll(r)
167 return bytes.TrimSuffix(data, []byte{'\n'})
170 func parseComments(data []byte) []string {
171 comments := []string{}
172 nsr := netstring.NewReader(bytes.NewReader(data))
174 if _, err := nsr.Next(); err != nil {
177 if comment, err := ioutil.ReadAll(nsr); err == nil {
178 comments = append(comments, string(comment))
184 func startHeader(etag hash.Hash, gziped bool) string {
186 "Content-Type: text/html; charset=UTF-8",
187 "ETag: " + etagString(etag),
190 lines = append(lines, "Content-Encoding: gzip")
192 lines = append(lines, "")
193 lines = append(lines, "")
194 return strings.Join(lines, "\n")
197 func startHTML(title string, additional []string) string {
198 return fmt.Sprintf(`<html>
200 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
201 <meta name="generator" content="SGBlog %s">
207 sgblog.Version, title,
208 strings.Join(append(defaultLinks, additional...), "\n "),
212 func makeErr(err error) {
213 fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
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)
227 cfgPath := os.Getenv("SGBLOG_CFG")
229 log.Fatalln("SGBLOG_CFG is not set")
231 pathInfo, exists := os.LookupEnv("PATH_INFO")
235 queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
240 cfgRaw, err := ioutil.ReadFile(cfgPath)
244 var cfgGeneral map[string]interface{}
245 if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
248 cfgRaw, err = json.Marshal(cfgGeneral)
253 if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
257 etagHash, err := blake2b.New256(nil)
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))
271 etagHashForWeb := [][]byte{}
273 defaultLinks = append(defaultLinks, `<link rel="stylesheet" type="text/css" href="`+cfg.CSS+`">`)
274 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CSS))
276 if cfg.Webmaster != "" {
277 defaultLinks = append(defaultLinks, `<link rev="made" href="mailto:`+cfg.Webmaster+`">`)
278 etagHashForWeb = append(etagHashForWeb, []byte(cfg.Webmaster))
280 if cfg.AboutURL != "" {
281 etagHashForWeb = append(etagHashForWeb, []byte(cfg.AboutURL))
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))
287 if cfg.CommentsNotesRef != "" {
288 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsNotesRef))
290 if cfg.CommentsEmail != "" {
291 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsEmail))
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">`)
298 repo, err = git.PlainOpen(cfg.GitPath)
302 head, err := repo.Reference(plumbing.ReferenceName(cfg.Branch), false)
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":
314 case cfg.CommentsNotesRef:
320 if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
321 notesTree, _ = commentsCommit.Tree()
324 if commentsRef != nil {
325 if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
326 commentsTree, _ = commentsCommit.Tree()
331 var outBuf bytes.Buffer
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)
345 if offsetRaw, exists := queryValues["offset"]; exists {
346 offset, err = strconv.Atoi(offsetRaw[0])
351 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
356 for i := 0; i < offset; i++ {
357 if _, err = repoLog.Next(); err != nil {
363 entries := make([]TableEntry, 0, PageEntries)
365 for _, data := range etagHashForWeb {
368 etagHash.Write([]byte("INDEX"))
369 for i := 0; i < PageEntries; i++ {
370 commit, err := repoLog.Next()
375 etagHash.Write(commit.Hash[:])
376 commentsRaw := getNote(commentsTree, commit.Hash)
377 etagHash.Write(commentsRaw)
378 entries = append(entries, TableEntry{commit, commentsRaw})
382 var table bytes.Buffer
384 "<table border=1>\n" +
385 "<caption>Comments</caption>\n<tr>" +
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 {
394 lines := msgSplit(entry.commit.Message)
395 domains := []string{}
396 for _, line := range lines[2:] {
397 if u := urlParse(line); u == nil {
400 domains = append(domains, makeA(line, u.Host))
403 var commentsValue string
404 if l := len(parseComments(entry.commentsRaw)); l > 0 {
405 commentsValue = strconv.Itoa(l)
407 commentsValue = " "
409 table.WriteString(fmt.Sprintf(
410 "<tr><td>%d</td><td><tt>%s</tt></td>"+
412 "<td>%d</td><td>%s</td>"+
413 "<td>%s</td></tr>\n",
414 commitN, entry.commit.Author.When.Format(sgblog.WhenFmt),
415 makeA(cfg.URLPrefix+"/"+entry.commit.Hash.String(), lines[0]),
418 strings.Join(domains, " "),
421 table.WriteString("</table>")
425 var refs bytes.Buffer
427 if offsetPrev := offset - PageEntries; offsetPrev > 0 {
428 href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offsetPrev)
430 href = cfg.URLPrefix + "/"
432 links = append(links, `<link rel="prev" href="`+href+`" title="newer">`)
433 refs.WriteString("\n" + makeA(href, "[prev]"))
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]"))
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),
446 if cfg.AboutURL != "" {
447 out.Write([]byte(fmt.Sprintf("[%s]", makeA(cfg.AboutURL, "about"))))
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())
458 etagHash.Write([]byte("ATOM"))
459 etagHash.Write(commit.Hash[:])
464 Updated: atom.Time(commit.Author.When),
469 Author: &atom.Person{Name: cfg.AtomAuthor},
471 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
475 for i := 0; i < PageEntries; i++ {
476 commit, err = repoLog.Next()
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(
493 lines := msgSplit(commit.Message)
494 feed.Entry = append(feed.Entry, &atom.Entry{
496 ID: "urn:uuid:" + feedId,
499 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
501 Published: atom.Time(commit.Author.When),
502 Updated: atom.Time(commit.Author.When),
509 Body: strings.Join(lines[2:], "\n"),
513 data, err := xml.MarshalIndent(&feed, "", " ")
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")
524 os.Stdout.WriteString("\n")
525 os.Stdout.Write(outBuf.Bytes())
527 } else if sha1DigestRe.MatchString(pathInfo[1:]) {
528 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1:]))
532 for _, data := range etagHashForWeb {
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)
542 lines := msgSplit(commit.Message)
544 when := commit.Author.When.Format(sgblog.WhenFmt)
545 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
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">`)
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"))))
556 out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.URLPrefix+"/", "index"))))
558 out.Write([]byte(fmt.Sprintf(
560 makeA(cfg.URLPrefix+"/"+parent, "older"),
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,
569 for _, line := range lines[2:] {
570 out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
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"))
576 if cfg.CommentsEmail != "" {
577 out.Write([]byte("[" + makeA(
578 "mailto:"+cfg.CommentsEmail+"?subject="+commit.Hash.String(),
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",
589 lines = strings.Split(comment, "\n")
590 for _, line := range lines[:3] {
591 out.Write([]byte(line + "\n"))
593 for _, line := range lines[3:] {
594 out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
596 out.Write([]byte("</pre></dd>\n"))
598 out.Write([]byte("</dl>\n"))
600 makeErr(errors.New("unknown URL action"))
602 out.Write([]byte("</body></html>\n"))
603 if gzipWriter != nil {
606 os.Stdout.Write(outBuf.Bytes())