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
40 "github.com/hjson/hjson-go"
41 "go.stargrave.org/sgblog"
42 "golang.org/x/crypto/blake2b"
43 "golang.org/x/tools/blog/atom"
44 "gopkg.in/src-d/go-git.v4"
45 "gopkg.in/src-d/go-git.v4/plumbing"
46 "gopkg.in/src-d/go-git.v4/plumbing/object"
50 AtomFeed = "feed.atom"
54 defaultLinks = []string{}
56 renderableSchemes = map[string]struct{}{
65 type TableEntry struct {
70 func makeA(href, text string) string {
71 return `<a href="` + href + `">` + text + `</a>`
74 func etagString(etag hash.Hash) string {
75 return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
78 func urlParse(what string) *url.URL {
79 if u, err := url.ParseRequestURI(what); err == nil {
80 if _, exists := renderableSchemes[u.Scheme]; exists {
87 func lineURLize(urlPrefix, line string) string {
88 cols := strings.Split(html.EscapeString(line), " ")
89 for i, col := range cols {
90 if u := urlParse(col); u != nil {
91 cols[i] = makeA(col, col)
94 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(
95 urlPrefix+"/$1", "$1",
98 return strings.Join(cols, " ")
101 func startHeader(etag hash.Hash, gziped bool) string {
103 "Content-Type: text/html; charset=UTF-8",
104 "ETag: " + etagString(etag),
107 lines = append(lines, "Content-Encoding: gzip")
109 lines = append(lines, "")
110 lines = append(lines, "")
111 return strings.Join(lines, "\n")
114 func startHTML(title string, additional []string) string {
115 return fmt.Sprintf(`<html>
117 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
118 <meta name="generator" content="SGBlog %s">
124 sgblog.Version, title,
125 strings.Join(append(defaultLinks, additional...), "\n "),
129 func makeErr(err error) {
130 fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
135 func checkETag(etag hash.Hash) {
136 ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
137 if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
138 fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch)
144 cfgPath := os.Getenv("SGBLOG_CFG")
146 log.Fatalln("SGBLOG_CFG is not set")
148 cfgRaw, err := ioutil.ReadFile(cfgPath)
152 var cfgGeneral map[string]interface{}
153 if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
156 cfgRaw, err = json.Marshal(cfgGeneral)
161 if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
164 pathInfo, exists := os.LookupEnv("PATH_INFO")
168 queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
173 etagHash, err := blake2b.New256(nil)
177 etagHash.Write([]byte("SGBLOG"))
178 etagHash.Write([]byte(sgblog.Version))
179 etagHash.Write([]byte(cfg.GitPath))
180 etagHash.Write([]byte(cfg.Branch))
181 etagHash.Write([]byte(cfg.Title))
182 etagHash.Write([]byte(cfg.URLPrefix))
183 etagHash.Write([]byte(cfg.AtomBaseURL))
184 etagHash.Write([]byte(cfg.AtomId))
185 etagHash.Write([]byte(cfg.AtomAuthor))
187 etagHashForWeb := [][]byte{}
189 defaultLinks = append(defaultLinks, `<link rel="stylesheet" type="text/css" href="`+cfg.CSS+`">`)
190 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CSS))
192 if cfg.Webmaster != "" {
193 defaultLinks = append(defaultLinks, `<link rev="made" href="mailto:`+cfg.Webmaster+`">`)
194 etagHashForWeb = append(etagHashForWeb, []byte(cfg.Webmaster))
196 if cfg.AboutURL != "" {
197 etagHashForWeb = append(etagHashForWeb, []byte(cfg.AboutURL))
199 for _, gitURL := range cfg.GitURLs {
200 defaultLinks = append(defaultLinks, `<link rel="vcs-git" href="`+gitURL+`" title="Git repository">`)
201 etagHashForWeb = append(etagHashForWeb, []byte(gitURL))
203 if cfg.CommentsNotesRef != "" {
204 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsNotesRef))
206 if cfg.CommentsEmail != "" {
207 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsEmail))
210 defaultLinks = append(defaultLinks, `<link rel="top" href="`+cfg.URLPrefix+`/" title="top">`)
211 atomURL := cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomFeed
212 defaultLinks = append(defaultLinks, `<link rel="alternate" title="Atom feed" href="`+atomURL+`" type="application/atom+xml">`)
214 headHash, err := initRepo(cfg)
219 if notes, err := repo.Notes(); err == nil {
220 var notesRef *plumbing.Reference
221 var commentsRef *plumbing.Reference
222 notes.ForEach(func(ref *plumbing.Reference) error {
223 switch string(ref.Name()) {
224 case "refs/notes/commits":
226 case cfg.CommentsNotesRef:
232 if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
233 notesTree, _ = commentsCommit.Tree()
236 if commentsRef != nil {
237 if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
238 commentsTree, _ = commentsCommit.Tree()
243 var outBuf bytes.Buffer
246 var gzipWriter *gzip.Writer
247 acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
248 for _, encoding := range strings.Split(acceptEncoding, ", ") {
249 if encoding == "gzip" {
250 gzipWriter = gzip.NewWriter(&outBuf)
257 if offsetRaw, exists := queryValues["offset"]; exists {
258 offset, err = strconv.Atoi(offsetRaw[0])
263 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
268 for i := 0; i < offset; i++ {
269 if _, err = repoLog.Next(); err != nil {
275 entries := make([]TableEntry, 0, PageEntries)
277 for _, data := range etagHashForWeb {
280 etagHash.Write([]byte("INDEX"))
281 for i := 0; i < PageEntries; i++ {
282 commit, err := repoLog.Next()
287 etagHash.Write(commit.Hash[:])
288 commentsRaw := getNote(commentsTree, commit.Hash)
289 etagHash.Write(commentsRaw)
290 entries = append(entries, TableEntry{commit, commentsRaw})
294 var table bytes.Buffer
296 "<table border=1>\n" +
297 "<caption>Comments</caption>\n<tr>" +
301 `<th size="5%"><a title="Lines">L</a></th>` +
302 `<th size="5%"><a title="Comments">C</a></th>` +
303 "<th>Linked to</th></tr>\n")
305 var monthPrev time.Month
307 for _, entry := range entries {
308 yearCur, monthCur, dayCur := entry.commit.Author.When.Date()
309 if dayCur != dayPrev || monthCur != monthPrev || yearCur != yearPrev {
310 table.WriteString(fmt.Sprintf(
311 "<tr><td colspan=6><center><tt>%04d-%02d-%02d</tt></center></td></tr>\n",
312 yearCur, monthCur, dayCur,
314 yearPrev, monthPrev, dayPrev = yearCur, monthCur, dayCur
317 lines := msgSplit(entry.commit.Message)
318 domains := []string{}
319 for _, line := range lines[2:] {
320 if u := urlParse(line); u == nil {
323 domains = append(domains, makeA(line, u.Host))
326 var commentsValue string
327 if l := len(parseComments(entry.commentsRaw)); l > 0 {
328 commentsValue = strconv.Itoa(l)
330 commentsValue = " "
332 table.WriteString(fmt.Sprintf(
333 "<tr><td>%d</td><td><tt>%02d:%02d</tt></td>"+
335 "<td>%d</td><td>%s</td>"+
336 "<td>%s</td></tr>\n",
338 entry.commit.Author.When.Hour(),
339 entry.commit.Author.When.Minute(),
340 makeA(cfg.URLPrefix+"/"+entry.commit.Hash.String(), lines[0]),
343 strings.Join(domains, " "),
346 table.WriteString("</table>")
350 var refs bytes.Buffer
352 if offsetPrev := offset - PageEntries; offsetPrev > 0 {
353 href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offsetPrev)
355 href = cfg.URLPrefix + "/"
357 links = append(links, `<link rel="prev" href="`+href+`" title="newer">`)
358 refs.WriteString("\n" + makeA(href, "[prev]"))
361 href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries)
362 links = append(links, `<link rel="next" href="`+href+`" title="older">`)
363 refs.WriteString("\n" + makeA(href, "[next]"))
366 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
367 out.Write([]byte(startHTML(
368 fmt.Sprintf("%s (%d-%d)", cfg.Title, offset, offset+PageEntries),
371 if cfg.AboutURL != "" {
372 out.Write([]byte(fmt.Sprintf("[%s]", makeA(cfg.AboutURL, "about"))))
374 out.Write(refs.Bytes())
375 out.Write(table.Bytes())
376 out.Write(refs.Bytes())
377 out.Write([]byte("\n"))
378 } else if pathInfo == "/"+AtomFeed {
379 commit, err := repo.CommitObject(*headHash)
383 etagHash.Write([]byte("ATOM"))
384 etagHash.Write(commit.Hash[:])
389 Updated: atom.Time(commit.Author.When),
394 Author: &atom.Person{Name: cfg.AtomAuthor},
396 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
400 for i := 0; i < PageEntries; i++ {
401 commit, err = repoLog.Next()
406 feedIdRaw := new([16]byte)
407 copy(feedIdRaw[:], commit.Hash[:])
408 feedIdRaw[6] = (feedIdRaw[6] & 0x0F) | uint8(4<<4) // version 4
409 feedId := fmt.Sprintf(
418 lines := msgSplit(commit.Message)
419 feed.Entry = append(feed.Entry, &atom.Entry{
421 ID: "urn:uuid:" + feedId,
424 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
426 Published: atom.Time(commit.Author.When),
427 Updated: atom.Time(commit.Author.When),
434 Body: strings.Join(lines[2:], "\n"),
438 data, err := xml.MarshalIndent(&feed, "", " ")
443 os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
444 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
445 if gzipWriter != nil {
446 os.Stdout.WriteString("Content-Encoding: gzip\n")
449 os.Stdout.WriteString("\n")
450 os.Stdout.Write(outBuf.Bytes())
452 } else if sha1DigestRe.MatchString(pathInfo[1:]) {
453 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1:]))
457 for _, data := range etagHashForWeb {
460 etagHash.Write([]byte("ENTRY"))
461 etagHash.Write(commit.Hash[:])
462 notesRaw := getNote(notesTree, commit.Hash)
463 etagHash.Write(notesRaw)
464 commentsRaw := getNote(commentsTree, commit.Hash)
465 etagHash.Write(commentsRaw)
467 lines := msgSplit(commit.Message)
469 when := commit.Author.When.Format(sgblog.WhenFmt)
470 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
473 if len(commit.ParentHashes) > 0 {
474 parent = commit.ParentHashes[0].String()
475 links = append(links, `<link rel="prev" href="`+cfg.URLPrefix+"/"+parent+`" title="older">`)
477 out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), links)))
478 if cfg.AboutURL != "" {
479 out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.AboutURL, "about"))))
481 out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.URLPrefix+"/", "index"))))
483 out.Write([]byte(fmt.Sprintf(
485 makeA(cfg.URLPrefix+"/"+parent, "older"),
488 out.Write([]byte(fmt.Sprintf(
489 "[<tt><a title=\"When\">%s</a></tt>]\n"+
490 "[<tt><a title=\"Hash\">%s</a></tt>]\n"+
491 "<hr/>\n<h2>%s</h2>\n<pre>\n",
492 when, commit.Hash.String(), title,
494 for _, line := range lines[2:] {
495 out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
497 out.Write([]byte("</pre>\n<hr/>\n"))
498 if len(notesRaw) > 0 {
499 out.Write([]byte("Note:<pre>\n" + string(notesRaw) + "\n</pre>\n<hr/>\n"))
501 if cfg.CommentsEmail != "" {
502 out.Write([]byte("[" + makeA(
503 "mailto:"+cfg.CommentsEmail+"?subject="+commit.Hash.String(),
507 out.Write([]byte("<dl>\n"))
508 for i, comment := range parseComments(commentsRaw) {
509 out.Write([]byte(fmt.Sprintf(
510 "<dt><a name=\"comment%d\"><a href=\"#comment%d\">comment %d</a>:"+
511 "</dt>\n<dd><pre>\n",
514 lines = strings.Split(comment, "\n")
515 for _, line := range lines[:3] {
516 out.Write([]byte(line + "\n"))
518 for _, line := range lines[3:] {
519 out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
521 out.Write([]byte("</pre></dd>\n"))
523 out.Write([]byte("</dl>\n"))
525 makeErr(errors.New("unknown URL action"))
527 out.Write([]byte("</body></html>\n"))
528 if gzipWriter != nil {
531 os.Stdout.Write(outBuf.Bytes())