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
37 "github.com/google/uuid"
38 "golang.org/x/crypto/blake2b"
39 "golang.org/x/tools/blog/atom"
40 "gopkg.in/src-d/go-git.v4"
41 "gopkg.in/src-d/go-git.v4/plumbing"
42 "gopkg.in/src-d/go-git.v4/plumbing/object"
47 WhenFmt = "2006-01-02 15:04:05Z07:00"
48 AtomFeed = "feed.atom"
53 ETagVersion = []byte("2")
54 sha1DigestRe = regexp.MustCompilePOSIX("([0-9a-f]{40,40})")
55 defaultLinks = []string{}
57 commentsTree *object.Tree
59 renderableSchemes = map[string]struct{}{
67 func makeA(href, text string) string {
68 return fmt.Sprintf(`<a href="%s">%s</a>`, href, text)
71 func etagString(etag hash.Hash) string {
72 return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
75 func urlParse(what string) *url.URL {
76 if u, err := url.ParseRequestURI(what); err == nil {
77 if _, exists := renderableSchemes[u.Scheme]; exists {
84 func msgSplit(msg string) []string {
85 lines := strings.Split(msg, "\n")
86 lines = lines[:len(lines)-1]
88 lines = []string{lines[0], "", ""}
93 func getNote(what plumbing.Hash) string {
94 if commentsTree == nil {
97 entry, err := commentsTree.FindEntry(what.String())
101 blob, err := repo.BlobObject(entry.Hash)
105 r, err := blob.Reader()
109 data, err := ioutil.ReadAll(r)
116 func startHeader(etag hash.Hash, gziped bool) string {
118 "Content-Type: text/html; charset=UTF-8",
119 "ETag: " + etagString(etag),
122 lines = append(lines, "Content-Encoding: gzip")
124 lines = append(lines, "")
125 lines = append(lines, "")
126 return strings.Join(lines, "\n")
129 func startHTML(title string, additional []string) string {
130 return fmt.Sprintf(`<html>
132 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
133 <meta name="generator" content="SGBlog %s">
140 strings.Join(append(defaultLinks, additional...), "\n "),
144 func makeErr(err error) {
145 fmt.Println("Content-Type: text/plain; charset=UTF-8\n")
150 func checkETag(etag hash.Hash) {
151 ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
152 if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
153 fmt.Println("Status: 304\nETag:", ifNoneMatch, "\n")
159 gitPath, exists := os.LookupEnv("SGBLOG_GIT_PATH")
161 makeErr(errors.New("SGBLOG_GIT_PATH is unset"))
163 branchName, exists := os.LookupEnv("SGBLOG_BRANCH")
165 makeErr(errors.New("SGBLOG_BRANCH is unset"))
167 blogBaseURL, exists := os.LookupEnv("SGBLOG_BASE_URL")
169 makeErr(errors.New("SGBLOG_BASE_URL is unset"))
171 blogTitle, exists := os.LookupEnv("SGBLOG_TITLE")
173 makeErr(errors.New("SGBLOG_TITLE is unset"))
175 atomId, exists := os.LookupEnv("SGBLOG_ATOM_ID")
177 makeErr(errors.New("SGBLOG_ATOM_ID is unset"))
179 atomAuthorName, exists := os.LookupEnv("SGBLOG_ATOM_AUTHOR")
181 makeErr(errors.New("SGBLOG_ATOM_AUTHOR is unset"))
184 etagHash, err := blake2b.New256(nil)
188 etagHash.Write(ETagVersion)
189 etagHash.Write([]byte(gitPath))
190 etagHash.Write([]byte(branchName))
191 etagHash.Write([]byte(blogBaseURL))
192 etagHash.Write([]byte(blogTitle))
193 etagHash.Write([]byte(atomId))
194 etagHash.Write([]byte(atomAuthorName))
197 urlPrefix := os.Getenv("SGBLOG_URL_PREFIX")
198 etagHash.Write([]byte(urlPrefix))
201 if cssUrl, exists := os.LookupEnv("SGBLOG_CSS"); exists {
202 defaultLinks = append(defaultLinks, fmt.Sprintf(
203 `<link rel="stylesheet" type="text/css" href="%s">`,
206 etagHash.Write([]byte(cssUrl))
210 if webmaster, exists := os.LookupEnv("SGBLOG_WEBMASTER"); exists {
211 defaultLinks = append(defaultLinks, fmt.Sprintf(
212 `<link rev="made" href="mailto:%s">`,
215 etagHash.Write([]byte(webmaster))
219 aboutUrl := os.Getenv("SGBLOG_ABOUT")
220 etagHash.Write([]byte(aboutUrl))
223 if gitUrls, exists := os.LookupEnv("SGBLOG_GIT_URLS"); exists {
224 for _, gitUrl := range strings.Split(gitUrls, " ") {
225 defaultLinks = append(defaultLinks, fmt.Sprintf(
226 `<link rel="vcs-git" href="%s" title="Git repository">`,
230 etagHash.Write([]byte(gitUrls))
233 defaultLinks = append(defaultLinks, fmt.Sprintf(
234 `<link rel="top" href="%s/" title="top">`,
237 atomUrl := blogBaseURL + urlPrefix + "/" + AtomFeed
238 defaultLinks = append(defaultLinks, fmt.Sprintf(
239 `<link rel="alternate" title="Atom feed" href="%s" type="application/atom+xml">`,
243 pathInfo, exists := os.LookupEnv("PATH_INFO")
247 queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
252 repo, err = git.PlainOpen(gitPath)
256 head, err := repo.Reference(plumbing.ReferenceName(branchName), false)
260 if notes, err := repo.Notes(); err == nil {
261 var comments *plumbing.Reference
262 notes.ForEach(func(ref *plumbing.Reference) error {
263 if ref.Name() == "refs/notes/commits" {
269 if commentsCommit, err := repo.CommitObject(comments.Hash()); err == nil {
270 commentsTree, _ = commentsCommit.Tree()
275 var outBuf bytes.Buffer
278 var gzipWriter *gzip.Writer
279 acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
280 for _, encoding := range strings.Split(acceptEncoding, ", ") {
281 if encoding == "gzip" {
282 gzipWriter = gzip.NewWriter(&outBuf)
287 var commit *object.Commit
290 if offsetRaw, exists := queryValues["offset"]; exists {
291 offset, err = strconv.Atoi(offsetRaw[0])
296 var table bytes.Buffer
297 table.WriteString("<table border=1>\n<tr><th>When</th><th>Title</th><th>Comment of</th></tr>\n")
298 log, err := repo.Log(&git.LogOptions{From: head.Hash()})
303 for i := 0; i < offset; i++ {
304 commit, err = log.Next()
309 for i := 0; i < PageEntries; i++ {
310 commit, err = log.Next()
316 etagHash.Write(commit.Hash[:])
319 lines := msgSplit(commit.Message)
320 domains := []string{}
321 for _, line := range lines[2:] {
322 if u := urlParse(line); u == nil {
325 domains = append(domains, makeA(line, u.Host))
329 makeA(urlPrefix+"/"+commit.Hash.String(), lines[0]),
330 fmt.Sprintf("(%dL)", len(lines)-2),
332 if note := getNote(commit.Hash); note != "" {
333 entry = append(entry, "(N)")
335 table.WriteString(fmt.Sprintf(
336 "<tr><td><tt>%s</tt></td><td>%s</td><td>%s</td></tr>\n",
337 commit.Author.When.Format(WhenFmt),
338 strings.Join(entry, " "),
339 strings.Join(domains, " "),
342 table.WriteString("</table>")
344 var refs bytes.Buffer
346 offsetPrev := offset - PageEntries
350 href := urlPrefix + "/?offset=" + strconv.Itoa(offsetPrev)
351 links = append(links, fmt.Sprintf(
352 `<link rel="prev" href="%s" title="newer">`, href,
354 refs.WriteString(makeA(href, "[prev]"))
357 href := urlPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries)
358 links = append(links, fmt.Sprintf(
359 `<link rel="next" href="%s" title="older">`, href,
361 refs.WriteString(makeA(href, "[next]"))
363 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
364 out.Write([]byte(startHTML(
365 fmt.Sprintf("%s (%d-%d)", blogTitle, offset, offset+PageEntries),
368 out.Write(refs.Bytes())
369 out.Write(table.Bytes())
370 out.Write(refs.Bytes())
371 out.Write([]byte("\n"))
372 } else if pathInfo == "/"+AtomFeed {
373 commit, err = repo.CommitObject(head.Hash())
377 etagHash.Write(commit.Hash[:])
378 etagHash.Write([]byte("ATOM"))
383 Updated: atom.Time(commit.Author.When),
388 Author: &atom.Person{Name: atomAuthorName},
390 log, err := repo.Log(&git.LogOptions{From: head.Hash()})
394 for i := 0; i < PageEntries; i++ {
395 commit, err = log.Next()
399 lines := msgSplit(commit.Message)
400 feedId, err := uuid.FromBytes(commit.Hash[:16])
404 feed.Entry = append(feed.Entry, &atom.Entry{
406 ID: "urn:uuid:" + feedId.String(),
409 Href: blogBaseURL + urlPrefix + "/" + commit.Hash.String(),
411 Published: atom.Time(commit.Author.When),
412 Updated: atom.Time(commit.Author.When),
419 Body: strings.Join(lines[2:], "\n"),
423 data, err := xml.MarshalIndent(&feed, "", " ")
428 os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
429 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
430 if gzipWriter != nil {
431 os.Stdout.WriteString("Content-Encoding: gzip\n")
434 os.Stdout.WriteString("\n")
435 os.Stdout.Write(outBuf.Bytes())
437 } else if sha1DigestRe.MatchString(pathInfo[1:]) {
438 commit, err = repo.CommitObject(plumbing.NewHash(pathInfo[1:]))
442 etagHash.Write(commit.Hash[:])
444 lines := msgSplit(commit.Message)
446 when := commit.Author.When.Format(WhenFmt)
447 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
450 if len(commit.ParentHashes) > 0 {
451 parent = commit.ParentHashes[0].String()
452 links = append(links, fmt.Sprintf(
453 `<link rel="prev" href="%s" title="older">`,
454 urlPrefix+"/"+parent,
457 out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), links)))
459 out.Write([]byte(fmt.Sprintf(
460 "[%s] [<tt>%s</tt>]\n<hr/>\n",
461 makeA(urlPrefix+"/"+parent, "older"),
465 out.Write([]byte(fmt.Sprintf("<h2>%s</h2>\n<pre>\n", title)))
466 for _, line := range lines[2:] {
467 line = strings.ReplaceAll(line, "&", "&")
468 line = strings.ReplaceAll(line, "<", "<")
469 line = strings.ReplaceAll(line, ">", ">")
470 cols := strings.Split(line, " ")
471 for i, col := range cols {
472 if u := urlParse(col); u != nil {
473 cols[i] = makeA(col, col)
476 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
478 line = strings.Join(cols, " ")
479 out.Write([]byte(line + "\n"))
481 out.Write([]byte("</pre>\n"))
482 if note := getNote(commit.Hash); note != "" {
483 out.Write([]byte(fmt.Sprintf("Note:\n<pre>\n%s</pre>\n", note)))
486 makeErr(errors.New("unknown URL action"))
489 out.Write([]byte(fmt.Sprintf(
491 makeA(aboutUrl, "About"),
495 out.Write([]byte("</body></html>\n"))
496 if gzipWriter != nil {
499 os.Stdout.Write(outBuf.Bytes())