/*
-SGBlog -- Git-based CGI blogging engine
+SGBlog -- Git-backed CGI/inetd blogging/phlogging engine
Copyright (C) 2020 Sergey Matveev <stargrave@stargrave.org>
This program is free software: you can redistribute it and/or modify
along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
-// Git-based CGI blogging engine
+// Git-backed CGI/inetd blogging/phlogging engine
package main
import (
"bytes"
- "compress/gzip"
- "encoding/hex"
- "encoding/xml"
- "errors"
- "fmt"
- "hash"
- "io"
"io/ioutil"
- "net/url"
"os"
"regexp"
- "strconv"
"strings"
- "github.com/google/uuid"
- "golang.org/x/crypto/blake2b"
- "golang.org/x/tools/blog/atom"
+ "go.cypherpunks.ru/netstring/v2"
"gopkg.in/src-d/go-git.v4"
"gopkg.in/src-d/go-git.v4/plumbing"
"gopkg.in/src-d/go-git.v4/plumbing/object"
const (
PageEntries = 50
- WhenFmt = "2006-01-02 15:04:05Z07:00"
- AtomFeed = "feed.atom"
)
var (
- Version = "0.0.2"
- ETagVersion = []byte("2")
sha1DigestRe = regexp.MustCompilePOSIX("([0-9a-f]{40,40})")
- defaultLinks = []string{}
repo *git.Repository
+ notesTree *object.Tree
commentsTree *object.Tree
-
- renderableSchemes = map[string]struct{}{
- "http": struct{}{},
- "https": struct{}{},
- "ftp": struct{}{},
- "gopher": struct{}{},
- }
)
-func makeA(href, text string) string {
- return fmt.Sprintf(`<a href="%s">%s</a>`, href, text)
-}
+type Cfg struct {
+ GitPath string
+ Branch string
+ Title string
-func etagString(etag hash.Hash) string {
- return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
-}
+ URLPrefix string
-func urlParse(what string) *url.URL {
- if u, err := url.ParseRequestURI(what); err == nil {
- if _, exists := renderableSchemes[u.Scheme]; exists {
- return u
- }
- }
- return nil
+ AtomBaseURL string
+ AtomId string
+ AtomAuthor string
+
+ CSS string
+ Webmaster string
+ AboutURL string
+ GitURLs []string
+
+ CommentsNotesRef string
+ CommentsEmail string
+
+ GopherDomain string
}
func msgSplit(msg string) []string {
return lines
}
-func getNote(what plumbing.Hash) string {
- if commentsTree == nil {
- return ""
+func getNote(tree *object.Tree, what plumbing.Hash) []byte {
+ if tree == nil {
+ return nil
}
- entry, err := commentsTree.FindEntry(what.String())
- if err != nil {
- return ""
+ var entry *object.TreeEntry
+ var err error
+ paths := make([]string, 3)
+ paths[0] = what.String()
+ paths[1] = paths[0][:2] + "/" + paths[0][2:]
+ paths[2] = paths[1][:4+1] + "/" + paths[1][4+1:]
+ for _, p := range paths {
+ entry, err = tree.FindEntry(p)
+ if err == nil {
+ break
+ }
+ }
+ if entry == nil {
+ return nil
}
blob, err := repo.BlobObject(entry.Hash)
if err != nil {
- return ""
+ return nil
}
r, err := blob.Reader()
if err != nil {
- return ""
+ return nil
}
data, err := ioutil.ReadAll(r)
if err != nil {
- return ""
- }
- return string(data)
-}
-
-func startHeader(etag hash.Hash, gziped bool) string {
- lines := []string{
- "Content-Type: text/html; charset=UTF-8",
- "ETag: " + etagString(etag),
- }
- if gziped {
- lines = append(lines, "Content-Encoding: gzip")
- }
- lines = append(lines, "")
- lines = append(lines, "")
- return strings.Join(lines, "\n")
-}
-
-func startHTML(title string, additional []string) string {
- return fmt.Sprintf(`<html>
-<head>
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
- <meta name="generator" content="SGBlog %s">
- <title>%s</title>
- %s
-</head>
-<body>
-`,
- Version, title,
- strings.Join(append(defaultLinks, additional...), "\n "),
- )
-}
-
-func makeErr(err error) {
- fmt.Println("Content-Type: text/plain; charset=UTF-8\n")
- fmt.Println(err)
- panic(err)
-}
-
-func checkETag(etag hash.Hash) {
- ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
- if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
- fmt.Println("Status: 304\nETag:", ifNoneMatch, "\n")
- os.Exit(0)
+ return nil
}
+ return bytes.TrimSuffix(data, []byte{'\n'})
}
-func main() {
- gitPath, exists := os.LookupEnv("SGBLOG_GIT_PATH")
- if !exists {
- makeErr(errors.New("SGBLOG_GIT_PATH is unset"))
- }
- branchName, exists := os.LookupEnv("SGBLOG_BRANCH")
- if !exists {
- makeErr(errors.New("SGBLOG_BRANCH is unset"))
- }
- blogBaseURL, exists := os.LookupEnv("SGBLOG_BASE_URL")
- if !exists {
- makeErr(errors.New("SGBLOG_BASE_URL is unset"))
- }
- blogTitle, exists := os.LookupEnv("SGBLOG_TITLE")
- if !exists {
- makeErr(errors.New("SGBLOG_TITLE is unset"))
- }
- atomId, exists := os.LookupEnv("SGBLOG_ATOM_ID")
- if !exists {
- makeErr(errors.New("SGBLOG_ATOM_ID is unset"))
- }
- atomAuthorName, exists := os.LookupEnv("SGBLOG_ATOM_AUTHOR")
- if !exists {
- makeErr(errors.New("SGBLOG_ATOM_AUTHOR is unset"))
- }
-
- etagHash, err := blake2b.New256(nil)
- if err != nil {
- panic(err)
- }
- etagHash.Write(ETagVersion)
- etagHash.Write([]byte(gitPath))
- etagHash.Write([]byte(branchName))
- etagHash.Write([]byte(blogBaseURL))
- etagHash.Write([]byte(blogTitle))
- etagHash.Write([]byte(atomId))
- etagHash.Write([]byte(atomAuthorName))
-
- // SGBLOG_URL_PREFIX
- urlPrefix := os.Getenv("SGBLOG_URL_PREFIX")
- etagHash.Write([]byte(urlPrefix))
-
- // SGBLOG_CSS
- if cssUrl, exists := os.LookupEnv("SGBLOG_CSS"); exists {
- defaultLinks = append(defaultLinks, fmt.Sprintf(
- `<link rel="stylesheet" type="text/css" href="%s">`,
- cssUrl,
- ))
- etagHash.Write([]byte(cssUrl))
- }
-
- // SGBLOG_WEBMASTER
- if webmaster, exists := os.LookupEnv("SGBLOG_WEBMASTER"); exists {
- defaultLinks = append(defaultLinks, fmt.Sprintf(
- `<link rev="made" href="mailto:%s">`,
- webmaster,
- ))
- etagHash.Write([]byte(webmaster))
- }
-
- // SGBLOG_ABOUT
- aboutUrl := os.Getenv("SGBLOG_ABOUT")
- etagHash.Write([]byte(aboutUrl))
-
- // SGBLOG_GIT_URLS
- if gitUrls, exists := os.LookupEnv("SGBLOG_GIT_URLS"); exists {
- for _, gitUrl := range strings.Split(gitUrls, " ") {
- defaultLinks = append(defaultLinks, fmt.Sprintf(
- `<link rel="vcs-git" href="%s" title="Git repository">`,
- gitUrl,
- ))
+func parseComments(data []byte) []string {
+ comments := []string{}
+ nsr := netstring.NewReader(bytes.NewReader(data))
+ for {
+ if _, err := nsr.Next(); err != nil {
+ break
+ }
+ if comment, err := ioutil.ReadAll(nsr); err == nil {
+ comments = append(comments, string(comment))
}
- etagHash.Write([]byte(gitUrls))
- }
-
- defaultLinks = append(defaultLinks, fmt.Sprintf(
- `<link rel="top" href="%s/" title="top">`,
- urlPrefix,
- ))
- atomUrl := blogBaseURL + urlPrefix + "/" + AtomFeed
- defaultLinks = append(defaultLinks, fmt.Sprintf(
- `<link rel="alternate" title="Atom feed" href="%s" type="application/atom+xml">`,
- atomUrl,
- ))
-
- pathInfo, exists := os.LookupEnv("PATH_INFO")
- if !exists {
- pathInfo = "/"
- }
- queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
- if err != nil {
- makeErr(err)
}
+ return comments
+}
- repo, err = git.PlainOpen(gitPath)
+func initRepo(cfg *Cfg) (*plumbing.Hash, error) {
+ var err error
+ repo, err = git.PlainOpen(cfg.GitPath)
if err != nil {
- makeErr(err)
+ return nil, err
}
- head, err := repo.Reference(plumbing.ReferenceName(branchName), false)
+ head, err := repo.Reference(plumbing.ReferenceName(cfg.Branch), false)
if err != nil {
- makeErr(err)
+ return nil, err
}
+ headHash := head.Hash()
if notes, err := repo.Notes(); err == nil {
- var comments *plumbing.Reference
+ var notesRef *plumbing.Reference
+ var commentsRef *plumbing.Reference
notes.ForEach(func(ref *plumbing.Reference) error {
- if ref.Name() == "refs/notes/commits" {
- comments = ref
+ switch string(ref.Name()) {
+ case "refs/notes/commits":
+ notesRef = ref
+ case cfg.CommentsNotesRef:
+ commentsRef = ref
}
return nil
})
- if comments != nil {
- if commentsCommit, err := repo.CommitObject(comments.Hash()); err == nil {
- commentsTree, _ = commentsCommit.Tree()
+ if notesRef != nil {
+ if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
+ notesTree, _ = commentsCommit.Tree()
}
}
- }
-
- var outBuf bytes.Buffer
- var out io.Writer
- out = &outBuf
- var gzipWriter *gzip.Writer
- acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
- for _, encoding := range strings.Split(acceptEncoding, ", ") {
- if encoding == "gzip" {
- gzipWriter = gzip.NewWriter(&outBuf)
- out = gzipWriter
+ if commentsRef != nil {
+ if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
+ commentsTree, _ = commentsCommit.Tree()
+ }
}
}
+ return &headHash, nil
+}
- var commit *object.Commit
- if pathInfo == "/" {
- offset := 0
- if offsetRaw, exists := queryValues["offset"]; exists {
- offset, err = strconv.Atoi(offsetRaw[0])
- if err != nil {
- makeErr(err)
- }
- }
- var table bytes.Buffer
- table.WriteString("<table border=1>\n<tr><th>When</th><th>Title</th><th>Comment of</th></tr>\n")
- log, err := repo.Log(&git.LogOptions{From: head.Hash()})
- if err != nil {
- makeErr(err)
- }
- errOccured := false
- for i := 0; i < offset; i++ {
- commit, err = log.Next()
- if err != nil {
- break
- }
- }
- for i := 0; i < PageEntries; i++ {
- commit, err = log.Next()
- if err != nil {
- errOccured = true
- break
- }
- if i == 0 {
- etagHash.Write(commit.Hash[:])
- checkETag(etagHash)
- }
- lines := msgSplit(commit.Message)
- domains := []string{}
- for _, line := range lines[2:] {
- if u := urlParse(line); u == nil {
- break
- } else {
- domains = append(domains, makeA(line, u.Host))
- }
- }
- entry := []string{
- makeA(urlPrefix+"/"+commit.Hash.String(), lines[0]),
- fmt.Sprintf("(%dL)", len(lines)-2),
- }
- if note := getNote(commit.Hash); note != "" {
- entry = append(entry, "(N)")
- }
- table.WriteString(fmt.Sprintf(
- "<tr><td><tt>%s</tt></td><td>%s</td><td>%s</td></tr>\n",
- commit.Author.When.Format(WhenFmt),
- strings.Join(entry, " "),
- strings.Join(domains, " "),
- ))
- }
- table.WriteString("</table>")
- var links []string
- var refs bytes.Buffer
- if offset > 0 {
- offsetPrev := offset - PageEntries
- if offsetPrev < 0 {
- offsetPrev = 0
- }
- href := urlPrefix + "/?offset=" + strconv.Itoa(offsetPrev)
- links = append(links, fmt.Sprintf(
- `<link rel="prev" href="%s" title="newer">`, href,
- ))
- refs.WriteString(makeA(href, "[prev]"))
- }
- if !errOccured {
- href := urlPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries)
- links = append(links, fmt.Sprintf(
- `<link rel="next" href="%s" title="older">`, href,
- ))
- refs.WriteString(makeA(href, "[next]"))
- }
- os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
- out.Write([]byte(startHTML(
- fmt.Sprintf("%s (%d-%d)", blogTitle, offset, offset+PageEntries),
- links,
- )))
- out.Write(refs.Bytes())
- out.Write(table.Bytes())
- out.Write(refs.Bytes())
- out.Write([]byte("\n"))
- } else if pathInfo == "/"+AtomFeed {
- commit, err = repo.CommitObject(head.Hash())
- if err != nil {
- makeErr(err)
- }
- etagHash.Write(commit.Hash[:])
- etagHash.Write([]byte("ATOM"))
- checkETag(etagHash)
- feed := atom.Feed{
- Title: blogTitle,
- ID: atomId,
- Updated: atom.Time(commit.Author.When),
- Link: []atom.Link{{
- Rel: "self",
- Href: atomUrl,
- }},
- Author: &atom.Person{Name: atomAuthorName},
- }
- log, err := repo.Log(&git.LogOptions{From: head.Hash()})
- if err != nil {
- makeErr(err)
- }
- for i := 0; i < PageEntries; i++ {
- commit, err = log.Next()
- if err != nil {
- break
- }
- lines := msgSplit(commit.Message)
- feedId, err := uuid.FromBytes(commit.Hash[:16])
- if err != nil {
- panic(err)
- }
- feed.Entry = append(feed.Entry, &atom.Entry{
- Title: lines[0],
- ID: "urn:uuid:" + feedId.String(),
- Link: []atom.Link{{
- Rel: "alternate",
- Href: blogBaseURL + urlPrefix + "/" + commit.Hash.String(),
- }},
- Published: atom.Time(commit.Author.When),
- Updated: atom.Time(commit.Author.When),
- Summary: &atom.Text{
- Type: "text",
- Body: lines[0],
- },
- Content: &atom.Text{
- Type: "text",
- Body: strings.Join(lines[2:], "\n"),
- },
- })
- }
- data, err := xml.MarshalIndent(&feed, "", " ")
- if err != nil {
- makeErr(err)
- }
- out.Write(data)
- os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
- os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
- if gzipWriter != nil {
- os.Stdout.WriteString("Content-Encoding: gzip\n")
- gzipWriter.Close()
- }
- os.Stdout.WriteString("\n")
- os.Stdout.Write(outBuf.Bytes())
- return
- } else if sha1DigestRe.MatchString(pathInfo[1:]) {
- commit, err = repo.CommitObject(plumbing.NewHash(pathInfo[1:]))
- if err != nil {
- makeErr(err)
- }
- etagHash.Write(commit.Hash[:])
- checkETag(etagHash)
- lines := msgSplit(commit.Message)
- title := lines[0]
- when := commit.Author.When.Format(WhenFmt)
- os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
- links := []string{}
- var parent string
- if len(commit.ParentHashes) > 0 {
- parent = commit.ParentHashes[0].String()
- links = append(links, fmt.Sprintf(
- `<link rel="prev" href="%s" title="older">`,
- urlPrefix+"/"+parent,
- ))
- }
- out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), links)))
- if parent != "" {
- out.Write([]byte(fmt.Sprintf(
- "[%s] [<tt>%s</tt>]\n<hr/>\n",
- makeA(urlPrefix+"/"+parent, "older"),
- when,
- )))
- }
- out.Write([]byte(fmt.Sprintf("<h2>%s</h2>\n<pre>\n", title)))
- for _, line := range lines[2:] {
- line = strings.ReplaceAll(line, "&", "&")
- line = strings.ReplaceAll(line, "<", "<")
- line = strings.ReplaceAll(line, ">", ">")
- cols := strings.Split(line, " ")
- for i, col := range cols {
- if u := urlParse(col); u != nil {
- cols[i] = makeA(col, col)
- continue
- }
- cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
- }
- line = strings.Join(cols, " ")
- out.Write([]byte(line + "\n"))
- }
- out.Write([]byte("</pre>\n"))
- if note := getNote(commit.Hash); note != "" {
- out.Write([]byte(fmt.Sprintf("Note:\n<pre>\n%s</pre>\n", note)))
- }
+func main() {
+ if len(os.Args) == 3 && os.Args[1] == "-gopher" {
+ serveGopher()
} else {
- makeErr(errors.New("unknown URL action"))
- }
- if aboutUrl != "" {
- out.Write([]byte(fmt.Sprintf(
- "<hr/>%s %s\n",
- makeA(aboutUrl, "About"),
- blogTitle,
- )))
- }
- out.Write([]byte("</body></html>\n"))
- if gzipWriter != nil {
- gzipWriter.Close()
+ serveHTTP()
}
- os.Stdout.Write(outBuf.Bytes())
}