/*
-SGBlog -- Git-backed CGI/inetd blogging/phlogging engine
-Copyright (C) 2020-2021 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
"bytes"
"compress/gzip"
"crypto/sha1"
+ _ "embed"
"encoding/hex"
"encoding/xml"
"errors"
"html"
"io"
"log"
+ "net/http"
"net/url"
"os"
"strconv"
"strings"
"text/template"
+ "time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
const (
AtomPostsFeed = "feed.atom"
AtomCommentsFeed = "comments.atom"
- TmplHTMLIndex = `<html>
-<head>
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
- <meta name="generator" content="SGBlog {{.Version}}">
- <title>{{.Cfg.Title}} {{if .Topic}}(topic: {{.Topic}}) {{end}}({{.Offset}}-{{.OffsetNext}})</title>
- {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
- {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end}}
- {{range .Cfg.GitURLs}}<link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
- <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
- <link rel="alternate" title="Posts feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomPostsFeed}}{{if .Topic}}?topic={{.Topic}}{{end}}" type="application/atom+xml">
- {{if .CommentsEnabled}}<link rel="alternate" title="Comments feed" href="{{.Cfg.AtomBaseURL}}{{.Cfg.URLPrefix}}/{{.AtomCommentsFeed}}" type="application/atom+xml">{{end}}
- {{if .Offset}}<link rel="prev" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="prev">{{end}}
- {{if not .LogEnded}}<link rel="next" href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}" title="next">{{end}}
-</head>
-<body>
-{{with .Cfg.AboutURL}}[<a href="{{.}}">about</a>]{{end}}
-{{block "links" .}}
-{{if .Offset}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetPrev}}{{if .Topic}}&topic={{.Topic}}{{end}}">prev</a>]{{end}}
-{{if not .LogEnded}}[<a href="{{.Cfg.URLPrefix}}/?offset={{.OffsetNext}}{{if .Topic}}&topic={{.Topic}}{{end}}">next</a>]{{end}}
-{{end}}
-{{- $Cfg := .Cfg -}}
-{{if .Topics}}<hr/>
-Topics: [<tt><a href="{{$Cfg.URLPrefix}}/">ALL</a></tt>]
-{{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]
-{{end}}
-{{end}}
-{{- $TopicsEnabled := .TopicsEnabled -}}
-{{- $datePrev := "0001-01-01" -}}
-<table border=1>
-<tr>
- <th>N</th><th>When</th><th>Title</th>
- <th size="5%"><a title="Lines">L</a></th>
- <th size="5%"><a title="Comments">C</a></th>
- <th>Linked to</th>
- {{if .TopicsEnabled}}<th>Topics</th>{{end}}
-</tr>
-{{range .Entries -}}
-{{- $dateCur := .Commit.Author.When.Format "2006-01-02" -}}
-{{- if ne $dateCur $datePrev -}}
- <tr><td colspan={{if $TopicsEnabled}}7{{else}}7{{end}}><center><tt>{{$dateCur}}</tt></center></td></tr>
- {{- $datePrev = $dateCur -}}
-{{- end -}}
-<tr>
- <td>{{.Num}}</td>
- <td><tt>{{.Commit.Author.When.Format "15:04"}}</tt></td>
- <td><a href="{{$Cfg.URLPrefix}}/{{.Commit.Hash.String}}">{{.Title}}</a></td>
- <td>{{.LinesNum}}</td>
- <td>{{if .CommentsNum}}{{.CommentsNum}}{{else}} {{end}}</td>
- <td>{{if .DomainURLs}}{{range .DomainURLs}} {{.}} {{end}}{{else}} {{end}}</td>
- {{if $TopicsEnabled}}<td>{{if .Topics}}{{range .Topics}} <a href="{{$Cfg.URLPrefix}}/?topic={{.}}">{{.}}</a> {{end}}{{else}} {{end}}</td>{{end}}
-</tr>
-{{end}}</table>
-{{template "links" .}}
-</body>
-</html>
-`
- TmplHTMLEntry = `{{$Cfg := .Cfg}}<html>
-<head>
- <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
- <meta name="generator" content="SGBlog {{.Version}}">
- <title>{{.Title}} ({{.When}})</title>
- {{with .Cfg.CSS}}<link rel="stylesheet" type="text/css" href="{{.}}">{{end}}
- {{with .Cfg.Webmaster}}<link rev="made" href="mailto:{{.}}">{{end -}}
- {{- range .Cfg.GitURLs}}
- <link rel="vcs-git" href="{{.}}" title="Git repository">{{end}}
- <link rel="top" href="{{.Cfg.URLPrefix}}/" title="top">
- <link rel="alternate" title="Comments feed" href="{{.AtomCommentsURL}}" type="application/atom+xml">
- {{if .Parent}}<link rel="prev" href="{{.Cfg.URLPrefix}}/{{.Parent}}" title="prev">{{end}}
-</head>
-<body>
-{{with .Cfg.AboutURL}}[<a href="{{.}}">about</a>]{{end}}
-[<a href="{{.Cfg.URLPrefix}}/">index</a>]
-{{if .Parent}}[<a href="{{.Cfg.URLPrefix}}/{{.Parent}}">prev</a>]{{end}}
-[<tt><a title="When">{{.When}}</a></tt>]
-[<tt><a title="What">{{.Commit.Hash.String}}</a></tt>]
-
-{{if .Topics}}
-<hr/>
-Topics: {{range .Topics}}[<tt><a href="{{$Cfg.URLPrefix}}?topic={{.}}">{{.}}</a></tt>]{{end}}
-{{end}}
-
-<hr/>
-<h2>{{.Title}}</h2>
-<pre>
-{{range .Lines}}{{. | lineURLize $Cfg.URLPrefix}}
-{{end}}</pre>
-<hr/>
-
-{{if .NoteLines}}Note:<pre>
-{{range .NoteLines}}{{. | lineURLize $Cfg.URLPrefix}}
-{{end}}</pre>
-<hr/>{{end}}
-
-{{if .Cfg.CommentsEmail}}[<a href="mailto:{{.Cfg.CommentsEmail}}?subject={{.TitleEscaped}}">leave comment</a>]{{end}}
-
-<dl>{{range $idx, $comment := .Comments}}
-<dt><a name="comment{{$idx}}"><a href="#comment{{$idx}}">comment {{$idx}}</a>:</dt>
-<dd><pre>
-{{range $comment.HeaderLines}}{{.}}
-{{end}}{{range $comment.BodyLines}}{{. | lineURLize $Cfg.URLPrefix}}
-{{end}}</pre></dd>
-{{end}}</dl>
-
-</body>
-</html>
-`
)
var (
renderableSchemes = map[string]struct{}{
"finger": {},
"ftp": {},
+ "gemini": {},
"gopher": {},
"http": {},
"https": {},
+ "irc": {},
+ "ircs": {},
+ "news": {},
"telnet": {},
}
+
+ //go:embed http-index.tmpl
+ TmplHTMLIndexRaw string
+ TmplHTMLIndex = template.Must(template.New("http-index").Parse(TmplHTMLIndexRaw))
+
+ //go:embed http-entry.tmpl
+ TmplHTMLEntryRaw string
+ TmplHTMLEntry = template.Must(template.New("http-entry").Funcs(
+ template.FuncMap{"lineURLize": lineURLizeInTemplate},
+ ).Parse(TmplHTMLEntryRaw))
)
type TableEntry struct {
func startHeader(etag hash.Hash, gziped bool) string {
lines := []string{
- "Content-Type: text/html; charset=UTF-8",
+ "Content-Type: text/html; charset=utf-8",
"ETag: " + etagString(etag),
}
if gziped {
return strings.Join(lines, "\n")
}
-func makeErr(err error) {
- fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
+func makeErr(err error, status int) {
+ fmt.Println("Status:", status)
+ fmt.Print("Content-Type: text/plain; charset=utf-8\n\n")
fmt.Println(err)
log.Fatalln(err)
}
log.Fatalln(err)
}
- pathInfo, exists := os.LookupEnv("PATH_INFO")
- if !exists {
+ pathInfo := os.Getenv("PATH_INFO")
+ if len(pathInfo) == 0 {
pathInfo = "/"
}
queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusBadRequest)
}
etagHash, err := blake2b.New256(nil)
headHash, err := initRepo(cfg)
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
if notes, err := repo.Notes(); err == nil {
if offsetRaw, exists := queryValues["offset"]; exists {
offset, err = strconv.Atoi(offsetRaw[0])
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusBadRequest)
}
}
repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
topicsCache, err := getTopicsCache(cfg, repoLog)
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
commitN := 0
topic = t[0]
hashes := topicsCache[topic]
if hashes == nil {
- makeErr(errors.New("no posts with that topic"))
+ makeErr(errors.New("no posts with that topic"), http.StatusBadRequest)
}
if len(hashes) > offset {
hashes = hashes[offset:]
if offsetPrev < 0 {
offsetPrev = 0
}
- tmpl := template.Must(template.New("index").Parse(TmplHTMLIndex))
os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
- err = tmpl.Execute(out, struct {
+ err = TmplHTMLIndex.Execute(out, struct {
Version string
Cfg *Cfg
Topic string
Entries: entries,
})
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
+ }
+ } else if pathInfo == "/twtxt.txt" {
+ commit, err := repo.CommitObject(*headHash)
+ if err != nil {
+ makeErr(err, http.StatusInternalServerError)
}
+ etagHash.Write([]byte("TWTXT POSTS"))
+ etagHash.Write(commit.Hash[:])
+ checkETag(etagHash)
+ repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
+ if err != nil {
+ makeErr(err, http.StatusInternalServerError)
+ }
+ for i := 0; i < PageEntries; i++ {
+ commit, err = repoLog.Next()
+ if err != nil {
+ break
+ }
+ fmt.Fprintf(
+ out, "%s\t%s\n",
+ commit.Author.When.Format(time.RFC3339),
+ msgSplit(commit.Message)[0],
+ )
+ }
+ os.Stdout.WriteString("Content-Type: text/plain; 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 pathInfo == "/"+AtomPostsFeed {
commit, err := repo.CommitObject(*headHash)
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
var topic string
repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
var commits CommitIterNext
if topic == "" {
} else {
topicsCache, err := getTopicsCache(cfg, repoLog)
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
hashes := topicsCache[topic]
if hashes == nil {
- makeErr(errors.New("no posts with that topic"))
+ makeErr(errors.New("no posts with that topic"), http.StatusBadRequest)
}
commits = &HashesIter{hashes}
}
}
lines := msgSplit(commit.Message)
var categories []atom.Category
- for _, topic := range sgblog.ParseTopics(sgblog.GetNote(repo, topicsTree, commit.Hash)) {
+ for _, topic := range sgblog.ParseTopics(sgblog.GetNote(
+ repo, topicsTree, commit.Hash,
+ )) {
categories = append(categories, atom.Category{Term: topic})
}
htmlized := make([]string, 0, len(lines))
}
data, err := xml.MarshalIndent(&feed, "", " ")
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
out.Write(data)
goto AtomFinish
} else if pathInfo == "/"+AtomCommentsFeed {
commit, err := repo.CommitObject(commentsRef.Hash())
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
etagHash.Write([]byte("ATOM COMMENTS"))
etagHash.Write(commit.Hash[:])
}
repoLog, err := repo.Log(&git.LogOptions{From: commentsRef.Hash()})
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
for i := 0; i < PageEntries; i++ {
commit, err = repoLog.Next()
}
fileStats, err := commit.Stats()
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
t, err := commit.Tree()
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
commentedHash := plumbing.NewHash(strings.ReplaceAll(
fileStats[0].Name, "/", "",
}
data, err := xml.MarshalIndent(&feed, "", " ")
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
out.Write(data)
goto AtomFinish
} else if sha1DigestRe.MatchString(pathInfo[1:]) {
commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1 : 1+sha1.Size*2]))
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusBadRequest)
}
for _, data := range etagHashForWeb {
etagHash.Write([]byte(data))
}
data, err := xml.MarshalIndent(&feed, "", " ")
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
out.Write(data)
goto AtomFinish
notesLines = strings.Split(string(notesRaw), "\n")
}
- tmpl := template.New("entry")
- tmpl = tmpl.Funcs(template.FuncMap{"lineURLize": lineURLizeInTemplate})
- tmpl = template.Must(tmpl.Parse(TmplHTMLEntry))
os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
- err = tmpl.Execute(out, struct {
+ err = TmplHTMLEntry.Execute(out, struct {
Version string
Cfg *Cfg
Title string
Topics: sgblog.ParseTopics(topicsRaw),
})
if err != nil {
- makeErr(err)
+ makeErr(err, http.StatusInternalServerError)
}
} else {
- makeErr(errors.New("unknown URL action"))
+ makeErr(errors.New("unknown URL action"), http.StatusNotFound)
}
out.Write([]byte("</body></html>\n"))
if gzipWriter != nil {
return
AtomFinish:
- os.Stdout.WriteString("Content-Type: application/atom+xml; charset=UTF-8\n")
+ os.Stdout.WriteString("Content-Type: application/atom+xml; charset=utf-8\n")
os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
if gzipWriter != nil {
os.Stdout.WriteString("Content-Encoding: gzip\n")