@node Top
@top SGBlog
-SGBlog is minimalistic and simple Git-backed CGI/inetd
-@url{https://en.wikipedia.org/wiki/Blog, blogging} and
-@url{https://en.wikipedia.org/wiki/Phlog, phlogging} engine
+SGBlog is minimalistic and simple Git-backed CGI/UCSPI
+@url{https://en.wikipedia.org/wiki/Blog, blogging} (@code{http://}/@code{https://}),
+@url{https://en.wikipedia.org/wiki/Phlog, phlogging} (@code{gopher://}) and
+gemlogging (@code{gemini://}) engine
with email-backed comments support, written on @url{https://golang.org/, Go}.
Its main competitive features:
@itemize
-@item Single binary, responsible for both blog and phlog
+@item Single binary, responsible for both blog, phlog and gemlog
@item @url{https://git-scm.com/, Git} DVCS as a storage for posts and comments
@item Single small @url{https://hjson.github.io/, Hjson} configuration file
@item Uses @url{https://en.wikipedia.org/wiki/Common_Gateway_Interface, CGI}
for posts, comments and per-post comments
@item Single binary for email-backed comments posting
@item If access is granted, then everyone can easily create an offline
- copy of your blog/phlog!
+ copy of your blog/phlog/gemlog!
@end itemize
-All of that, except for comments, topics and phlog, could be achieved
+All of that, except for comments, topics and phlog/gemlog, could be achieved
with some Git viewer like @url{https://git.zx2c4.com/cgit/about/, cgit}.
But SGBlog also is able to:
# mv /var/service/.phlog-ipv6 /var/service/phlog-ipv6
@end example
+Gemlog uses Gemini protocol that requires TLS usage, that can be
+achieved with @url{go.cypherpunks.ru/ucspi} tools:
+
+@example
+exec tcpserver -DRH -u $uid -g $gid -l 0 ::0 1965 \
+ tlss -key gemlog.key.pem -cert gemlog.pem \
+ sgblog -gemini /home/sgblog/gemlog.hjson 2>&1
+@end example
+
For comments workability you have to configure your SMTP server to feed
incoming messages to @command{sgblog-comment-add} utility. For example,
Postfix'es @file{/etc/aliases} can contain:
--- /dev/null
+/*
+SGBlog -- Git-backed CGI/UCSPI blogging/phlogging/gemlogging engine
+Copyright (C) 2020-2021 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
+published by the Free Software Foundation, version 3 of the License.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+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/>.
+*/
+
+package main
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/url"
+ "os"
+ "strconv"
+ "text/template"
+
+ "github.com/go-git/go-git/v5"
+ "github.com/go-git/go-git/v5/plumbing"
+ "github.com/go-git/go-git/v5/plumbing/object"
+ "go.stargrave.org/sgblog"
+)
+
+const (
+ TmplGemMenu = `{{$CR := printf "\r"}}20 text/gemini{{$CR}}
+# {{.Cfg.Title}} {{if .Topic}}(topic: {{.Topic}}) {{end}}({{.Offset}}-{{.OffsetNext}})
+{{if .Cfg.AboutURL}}=> {{.Cfg.AboutURL}} About{{end}}
+{{if .Offset}}=> /?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}} Prev{{end}}
+{{if not .LogEnded}}=> /?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}} Next{{end}}
+
+{{$datePrev := "0001-01-01" -}}
+{{- range .Entries -}}
+{{- $dateCur := .Commit.Author.When.Format "2006-01-02" -}}
+{{- if ne $dateCur $datePrev}}{{$datePrev = $dateCur}}## {{$dateCur}}
+{{end -}}
+=> /{{.Commit.Hash.String}} [{{.Commit.Author.When.Format "15:04"}}] {{.Title}} ({{.LinesNum}}L){{with .CommentsNum}} ({{.}}C){{end}}{{if .Topics}}{{range .Topics}} {{.}}{{end}}{{end}}
+{{end}}
+
+{{range .Topics -}}=> /?topic={{.}} Topic: {{.}}
+{{end}}
+Generated by: SGBlog {{.Version}}
+`
+ TmplGemEntry = `{{$CR := printf "\r"}}20 text/gemini{{$CR}}
+# {{.Title}}
+What: {{.Commit.Hash.String}}
+When: {{.When}}
+{{if .Topics}}Topics:{{range .Topics}} {{.}}{{end}}{{end}}
+` + "```" + `
+{{.Commit.Message}}` + "```" + `
+{{- if .Note}}
+## Note:
+` + "```" + `
+{{.Note}}
+` + "```" + `
+{{end -}}
+{{- if .Cfg.CommentsEmail}}
+=> mailto:{{.Cfg.CommentsEmail}}?subject={{.TitleEscaped}} leave comment
+{{end}}{{range $idx, $comment := .Comments}}
+## comment {{$idx}}:
+` + "```" + `
+{{$comment}}
+` + "```" + `
+{{end}}
+Generated by: SGBlog {{.Version}}
+`
+)
+
+func makeGemErr(err error) {
+ fmt.Print("59 " + err.Error() + "\r\n")
+ log.Fatalln(err)
+}
+
+func serveGemini(cfgPath string) {
+ cfg, err := readCfg(cfgPath)
+ if err != nil {
+ log.Fatalln(err)
+ }
+
+ headHash, err := initRepo(cfg)
+ if err != nil {
+ log.Fatalln(err)
+ }
+
+ scanner := bufio.NewScanner(io.LimitReader(os.Stdin, 2+1<<10))
+ if !scanner.Scan() {
+ makeGemErr(errors.New("no CRLF found"))
+ }
+ urlRaw := scanner.Text()
+ u, err := url.Parse(urlRaw)
+ if err != nil {
+ makeGemErr(err)
+ }
+ if u.Scheme != "gemini" {
+ makeGemErr(errors.New("only gemini:// is supported" + u.String()))
+ }
+
+ if u.Path == "/" {
+ offset := 0
+ if offsetRaw, exists := u.Query()["offset"]; exists {
+ offset, err = strconv.Atoi(offsetRaw[0])
+ if err != nil {
+ makeGemErr(err)
+ }
+ }
+ repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
+ if err != nil {
+ makeGemErr(err)
+ }
+ topicsCache, err := getTopicsCache(cfg, repoLog)
+ if err != nil {
+ makeGemErr(err)
+ }
+ repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
+ if err != nil {
+ makeGemErr(err)
+ }
+
+ commitN := 0
+ var commits CommitIterNext
+ var topic string
+ if t, exists := u.Query()["topic"]; exists {
+ topic = t[0]
+ hashes := topicsCache[topic]
+ if hashes == nil {
+ makeGemErr(errors.New("no posts with that topic"))
+ }
+ if len(hashes) > offset {
+ hashes = hashes[offset:]
+ commitN += offset
+ }
+ commits = &HashesIter{hashes}
+ } else {
+ for i := 0; i < offset; i++ {
+ if _, err = repoLog.Next(); err != nil {
+ break
+ }
+ commitN++
+ }
+ commits = repoLog
+ }
+
+ logEnded := false
+ entries := make([]TableMenuEntry, 0, PageEntries)
+ for i := 0; i < PageEntries; i++ {
+ commit, err := commits.Next()
+ if err != nil {
+ logEnded = true
+ break
+ }
+ lines := msgSplit(commit.Message)
+ entries = append(entries, TableMenuEntry{
+ Commit: commit,
+ Title: lines[0],
+ LinesNum: len(lines) - 2,
+ CommentsNum: len(sgblog.ParseComments(sgblog.GetNote(
+ repo, commentsTree, commit.Hash,
+ ))),
+ Topics: sgblog.ParseTopics(sgblog.GetNote(
+ repo, topicsTree, commit.Hash,
+ )),
+ })
+ }
+ tmpl := template.Must(template.New("menu").Parse(TmplGemMenu))
+ offsetPrev := offset - PageEntries
+ if offsetPrev < 0 {
+ offsetPrev = 0
+ }
+ err = tmpl.Execute(os.Stdout, struct {
+ Cfg *Cfg
+ Topic string
+ Offset int
+ OffsetPrev int
+ OffsetNext int
+ LogEnded bool
+ Entries []TableMenuEntry
+ Topics []string
+ Version string
+ }{
+ Cfg: cfg,
+ Topic: topic,
+ Offset: offset,
+ OffsetPrev: offsetPrev,
+ OffsetNext: offset + PageEntries,
+ LogEnded: logEnded,
+ Entries: entries,
+ Topics: topicsCache.Topics(),
+ Version: sgblog.Version,
+ })
+ if err != nil {
+ log.Fatalln(err)
+ }
+ } else if sha1DigestRe.MatchString(u.Path[1:]) {
+ commit, err := repo.CommitObject(plumbing.NewHash(u.Path[1:]))
+ if err != nil {
+ log.Fatalln(err)
+ }
+ tmpl := template.Must(template.New("entry").Parse(TmplGemEntry))
+ title := msgSplit(commit.Message)[0]
+ err = tmpl.Execute(os.Stdout, struct {
+ Title string
+ Commit *object.Commit
+ When string
+ Cfg *Cfg
+ Note string
+ Comments []string
+ Topics []string
+ Version string
+ TitleEscaped string
+ }{
+ Title: title,
+ Commit: commit,
+ When: commit.Author.When.Format(sgblog.WhenFmt),
+ Cfg: cfg,
+ Note: string(sgblog.GetNote(repo, notesTree, commit.Hash)),
+ Comments: sgblog.ParseComments(sgblog.GetNote(repo, commentsTree, commit.Hash)),
+ Topics: sgblog.ParseTopics(sgblog.GetNote(repo, topicsTree, commit.Hash)),
+ Version: sgblog.Version,
+ TitleEscaped: url.PathEscape(fmt.Sprintf(
+ "Re: %s (%s)", title, commit.Hash,
+ )),
+ })
+ if err != nil {
+ log.Fatalln(err)
+ }
+ } else {
+ makeGemErr(errors.New("unknown URL action"))
+ }
+}