]> Sergey Matveev's repositories - sgblog.git/blobdiff - cmd/sgblog/main.go
Raised copyright years
[sgblog.git] / cmd / sgblog / main.go
index 0987bacf637646bfe440bfa3feebfd6b75a6fd33..7ee4e723f2c3d4ec081aca28b2aa334483420fe2 100644 (file)
@@ -1,6 +1,6 @@
 /*
-SGBlog -- Git-based CGI blogging engine
-Copyright (C) 2020 Sergey Matveev <stargrave@stargrave.org>
+SGBlog -- Git-backed CGI/UCSPI blogging/phlogging/gemlogging engine
+Copyright (C) 2020-2022 Sergey Matveev <stargrave@stargrave.org>
 
 This program is free software: you can redistribute it and/or modify
 it under the terms of the GNU Affero General Public License as
@@ -15,70 +15,62 @@ You should have received a copy of the GNU Affero General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
-// Git-based CGI blogging engine
+// Git-backed CGI/UCSPI blogging/phlogging/gemlogging engine
 package main
 
 import (
-       "bytes"
-       "compress/gzip"
-       "encoding/hex"
-       "encoding/xml"
-       "errors"
+       "crypto/sha1"
+       "encoding/json"
+       "flag"
        "fmt"
-       "hash"
-       "io"
        "io/ioutil"
-       "net/url"
-       "os"
+       "log"
        "regexp"
-       "strconv"
        "strings"
 
-       "github.com/google/uuid"
-       "golang.org/x/crypto/blake2b"
-       "golang.org/x/tools/blog/atom"
-       "gopkg.in/src-d/go-git.v4"
-       "gopkg.in/src-d/go-git.v4/plumbing"
-       "gopkg.in/src-d/go-git.v4/plumbing/object"
+       "github.com/go-git/go-git/v5"
+       "github.com/go-git/go-git/v5/plumbing"
+       "github.com/go-git/go-git/v5/plumbing/object"
+       "github.com/hjson/hjson-go"
 )
 
 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{}
+       sha1DigestRe = regexp.MustCompilePOSIX(fmt.Sprintf("([0-9a-f]{%d,%d})", sha1.Size*2, sha1.Size*2))
        repo         *git.Repository
+       notesTree    *object.Tree
+       commentsRef  *plumbing.Reference
        commentsTree *object.Tree
-
-       renderableSchemes = map[string]struct{}{
-               "http":   struct{}{},
-               "https":  struct{}{},
-               "ftp":    struct{}{},
-               "gopher": struct{}{},
-       }
+       topicsRef    *plumbing.Reference
+       topicsTree   *object.Tree
 )
 
-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
+
+       TopicsNotesRef  string
+       TopicsCachePath string
+
+       GopherDomain string
 }
 
 func msgSplit(msg string) []string {
@@ -90,411 +82,86 @@ func msgSplit(msg string) []string {
        return lines
 }
 
-func getNote(what plumbing.Hash) string {
-       if commentsTree == nil {
-               return ""
-       }
-       entry, err := commentsTree.FindEntry(what.String())
-       if err != nil {
-               return ""
-       }
-       blob, err := repo.BlobObject(entry.Hash)
-       if err != nil {
-               return ""
-       }
-       r, err := blob.Reader()
-       if err != nil {
-               return ""
-       }
-       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)
-       }
-}
-
-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,
-                       ))
-               }
-               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)
-       }
-
-       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
                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
+                       case cfg.TopicsNotesRef:
+                               topicsRef = ref
                        }
                        return nil
                })
-               if comments != nil {
-                       if commentsCommit, err := repo.CommitObject(comments.Hash()); err == nil {
-                               commentsTree, _ = 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
-               }
-       }
-
-       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
+               if notesRef != nil {
+                       if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
+                               notesTree, _ = commentsCommit.Tree()
                        }
-                       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)
+               if commentsRef != nil {
+                       if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
+                               commentsTree, _ = commentsCommit.Tree()
                        }
-                       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]&nbsp;[<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, "&", "&amp;")
-                       line = strings.ReplaceAll(line, "<", "&lt;")
-                       line = strings.ReplaceAll(line, ">", "&gt;")
-                       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"))
+               if topicsRef != nil {
+                       if topicsCommit, err := repo.CommitObject(topicsRef.Hash()); err == nil {
+                               topicsTree, _ = topicsCommit.Tree()
                        }
-                       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)))
                }
-       } else {
-               makeErr(errors.New("unknown URL action"))
        }
-       if aboutUrl != "" {
-               out.Write([]byte(fmt.Sprintf(
-                       "<hr/>%s %s\n",
-                       makeA(aboutUrl, "About"),
-                       blogTitle,
-               )))
+       return &headHash, nil
+}
+
+func readCfg(cfgPath string) (*Cfg, error) {
+       cfgRaw, err := ioutil.ReadFile(cfgPath)
+       if err != nil {
+               return nil, err
+       }
+       var cfgGeneral map[string]interface{}
+       if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
+               return nil, err
+       }
+       cfgRaw, err = json.Marshal(cfgGeneral)
+       if err != nil {
+               return nil, err
+       }
+       var cfg *Cfg
+       if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
+               return nil, err
        }
-       out.Write([]byte("</body></html>\n"))
-       if gzipWriter != nil {
-               gzipWriter.Close()
+       return cfg, nil
+}
+
+func main() {
+       gopherCfgPath := flag.String("gopher", "", "Path to gopher-related configuration file")
+       geminiCfgPath := flag.String("gemini", "", "Path to gemini-related configuration file")
+       flag.Usage = func() {
+               fmt.Fprintf(flag.CommandLine.Output(), `Usage of sgblog:
+       sgblog -- run CGI HTTP backend
+       sgblog -gopher /path/to/cfg.hjson -- run UCSPI/inetd Gopher backend
+       sgblog -gemini /path/to/cfg.hjson -- run UCSPI+tlss Gemini backend
+`)
+       }
+       flag.Parse()
+       log.SetFlags(log.Lshortfile)
+       if *gopherCfgPath != "" {
+               serveGopher(*gopherCfgPath)
+       } else if *geminiCfgPath != "" {
+               serveGemini(*geminiCfgPath)
+       } else {
+               serveHTTP()
        }
-       os.Stdout.Write(outBuf.Bytes())
 }