]> Sergey Matveev's repositories - sgblog.git/blob - cmd/sgblog/main.go
Initial working version
[sgblog.git] / cmd / sgblog / main.go
1 /*
2 SGBlog -- Git-based CGI blogging engine
3 Copyright (C) 2020 Sergey Matveev <stargrave@stargrave.org>
4
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.
8
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.
13
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/>.
16 */
17
18 // Git-based CGI blogging engine
19 package main
20
21 import (
22         "bytes"
23         "compress/gzip"
24         "encoding/hex"
25         "encoding/xml"
26         "errors"
27         "fmt"
28         "hash"
29         "io"
30         "io/ioutil"
31         "net/url"
32         "os"
33         "regexp"
34         "strconv"
35         "strings"
36
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"
43 )
44
45 const (
46         PageEntries = 50
47         WhenFmt     = "2006-01-02 15:04:05Z07:00"
48         AtomFeed    = "feed.atom"
49 )
50
51 var (
52         Version      = "0.0.1"
53         ETagVersion  = []byte("1")
54         sha1DigestRe = regexp.MustCompilePOSIX("([0-9a-f]{40,40})")
55         defaultLinks = []string{}
56         repo         *git.Repository
57         commentsTree *object.Tree
58
59         renderableSchemes = map[string]struct{}{
60                 "http":   struct{}{},
61                 "https":  struct{}{},
62                 "ftp":    struct{}{},
63                 "gopher": struct{}{},
64         }
65 )
66
67 func makeA(href, text string) string {
68         return fmt.Sprintf(`<a href="%s">%s</a>`, href, text)
69 }
70
71 func etagString(etag hash.Hash) string {
72         return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
73 }
74
75 func urlParse(what string) *url.URL {
76         if u, err := url.ParseRequestURI(what); err == nil {
77                 if _, exists := renderableSchemes[u.Scheme]; exists {
78                         return u
79                 }
80         }
81         return nil
82 }
83
84 func msgSplit(msg string) []string {
85         lines := strings.Split(msg, "\n")
86         lines = lines[:len(lines)-1]
87         if len(lines) < 3 {
88                 lines = []string{lines[0], "", ""}
89         }
90         return lines
91 }
92
93 func getNote(what plumbing.Hash) string {
94         if commentsTree == nil {
95                 return ""
96         }
97         entry, err := commentsTree.FindEntry(what.String())
98         if err != nil {
99                 return ""
100         }
101         blob, err := repo.BlobObject(entry.Hash)
102         if err != nil {
103                 return ""
104         }
105         r, err := blob.Reader()
106         if err != nil {
107                 return ""
108         }
109         data, err := ioutil.ReadAll(r)
110         if err != nil {
111                 return ""
112         }
113         return string(data)
114 }
115
116 func startHeader(etag hash.Hash, gziped bool) string {
117         lines := []string{
118                 "Content-Type: text/html; charset=UTF-8",
119                 "ETag: " + etagString(etag),
120         }
121         if gziped {
122                 lines = append(lines, "Content-Encoding: gzip")
123         }
124         lines = append(lines, "")
125         lines = append(lines, "")
126         return strings.Join(lines, "\n")
127 }
128
129 func startHTML(title string, additional []string) string {
130         return fmt.Sprintf(`<html>
131 <head>
132         <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
133         <meta name="generator" content="SGBlog %s">
134         <title>%s</title>
135         %s
136 </head>
137 <body>
138 `,
139                 Version, title,
140                 strings.Join(append(defaultLinks, additional...), "\n   "),
141         )
142 }
143
144 func makeErr(err error) {
145         fmt.Println("Content-Type: text/plain; charset=UTF-8\n")
146         fmt.Println(err)
147         panic(err)
148 }
149
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")
154                 os.Exit(0)
155         }
156 }
157
158 func main() {
159         gitPath, exists := os.LookupEnv("SGBLOG_GIT_PATH")
160         if !exists {
161                 makeErr(errors.New("SGBLOG_GIT_PATH is unset"))
162         }
163         branchName, exists := os.LookupEnv("SGBLOG_BRANCH")
164         if !exists {
165                 makeErr(errors.New("SGBLOG_BRANCH is unset"))
166         }
167         blogBaseURL, exists := os.LookupEnv("SGBLOG_BASE_URL")
168         if !exists {
169                 makeErr(errors.New("SGBLOG_BASE_URL is unset"))
170         }
171         blogTitle, exists := os.LookupEnv("SGBLOG_TITLE")
172         if !exists {
173                 makeErr(errors.New("SGBLOG_TITLE is unset"))
174         }
175         atomId, exists := os.LookupEnv("SGBLOG_ATOM_ID")
176         if !exists {
177                 makeErr(errors.New("SGBLOG_ATOM_ID is unset"))
178         }
179         atomAuthorName, exists := os.LookupEnv("SGBLOG_ATOM_AUTHOR")
180         if !exists {
181                 makeErr(errors.New("SGBLOG_ATOM_AUTHOR is unset"))
182         }
183
184         etagHash, err := blake2b.New256(nil)
185         if err != nil {
186                 panic(err)
187         }
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))
195
196         // SGBLOG_URL_PREFIX
197         urlPrefix := os.Getenv("SGBLOG_URL_PREFIX")
198         etagHash.Write([]byte(urlPrefix))
199
200         // SGBLOG_CSS
201         if cssUrl, exists := os.LookupEnv("SGBLOG_CSS"); exists {
202                 defaultLinks = append(defaultLinks, fmt.Sprintf(
203                         `<link rel="stylesheet" type="text/css" href="%s">`,
204                         cssUrl,
205                 ))
206                 etagHash.Write([]byte(cssUrl))
207         }
208
209         // SGBLOG_WEBMASTER
210         if webmaster, exists := os.LookupEnv("SGBLOG_WEBMASTER"); exists {
211                 defaultLinks = append(defaultLinks, fmt.Sprintf(
212                         `<link rev="made" href="mailto:%s">`,
213                         webmaster,
214                 ))
215                 etagHash.Write([]byte(webmaster))
216         }
217
218         // SGBLOG_ABOUT
219         aboutUrl := os.Getenv("SGBLOG_ABOUT")
220         etagHash.Write([]byte(aboutUrl))
221
222         // SGBLOG_GIT_URLS
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">`,
227                                 gitUrl,
228                         ))
229                 }
230                 etagHash.Write([]byte(gitUrls))
231         }
232
233         defaultLinks = append(defaultLinks, fmt.Sprintf(
234                 `<link rel="top" href="%s/" title="top">`,
235                 urlPrefix,
236         ))
237         atomUrl := blogBaseURL + urlPrefix + "/" + AtomFeed
238         defaultLinks = append(defaultLinks, fmt.Sprintf(
239                 `<link rel="alternate" title="Atom feed" href="%s" type="application/atom+xml">`,
240                 atomUrl,
241         ))
242
243         pathInfo, exists := os.LookupEnv("PATH_INFO")
244         if !exists {
245                 pathInfo = "/"
246         }
247         queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
248         if err != nil {
249                 makeErr(err)
250         }
251
252         repo, err = git.PlainOpen(gitPath)
253         if err != nil {
254                 makeErr(err)
255         }
256         head, err := repo.Reference(plumbing.ReferenceName(branchName), false)
257         if err != nil {
258                 makeErr(err)
259         }
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" {
264                                 comments = ref
265                         }
266                         return nil
267                 })
268                 if comments != nil {
269                         if commentsCommit, err := repo.CommitObject(comments.Hash()); err == nil {
270                                 commentsTree, _ = commentsCommit.Tree()
271                         }
272                 }
273         }
274
275         var outBuf bytes.Buffer
276         var out io.Writer
277         out = &outBuf
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)
283                         out = gzipWriter
284                 }
285         }
286
287         var commit *object.Commit
288         if pathInfo == "/" {
289                 offset := 0
290                 if offsetRaw, exists := queryValues["offset"]; exists {
291                         offset, err = strconv.Atoi(offsetRaw[0])
292                         if err != nil {
293                                 makeErr(err)
294                         }
295                 }
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()})
299                 if err != nil {
300                         makeErr(err)
301                 }
302                 errOccured := false
303                 for i := 0; i < offset; i++ {
304                         commit, err = log.Next()
305                         if err != nil {
306                                 break
307                         }
308                 }
309                 for i := 0; i < PageEntries; i++ {
310                         commit, err = log.Next()
311                         if err != nil {
312                                 errOccured = true
313                                 break
314                         }
315                         if i == 0 {
316                                 etagHash.Write(commit.Hash[:])
317                                 checkETag(etagHash)
318                         }
319                         lines := msgSplit(commit.Message)
320                         domains := []string{}
321                         for _, line := range lines[2:] {
322                                 if u := urlParse(line); u == nil {
323                                         break
324                                 } else {
325                                         domains = append(domains, makeA(line, u.Host))
326                                 }
327                         }
328                         entry := []string{
329                                 makeA(urlPrefix+"/"+commit.Hash.String(), lines[0]),
330                                 fmt.Sprintf("(%dL)", len(lines)-2),
331                         }
332                         if note := getNote(commit.Hash); note != "" {
333                                 entry = append(entry, "(N)")
334                         }
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, " "),
340                         ))
341                 }
342                 table.WriteString("</table>")
343                 var links []string
344                 var refs bytes.Buffer
345                 if offset > 0 {
346                         offsetPrev := offset - PageEntries
347                         if offsetPrev < 0 {
348                                 offsetPrev = 0
349                         }
350                         href := urlPrefix + "/?offset=" + strconv.Itoa(offsetPrev)
351                         links = append(links, fmt.Sprintf(
352                                 `<link rel="prev" href="%s" title="newer">`, href,
353                         ))
354                         refs.WriteString(makeA(href, "[prev]"))
355                 }
356                 if !errOccured {
357                         href := urlPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries)
358                         links = append(links, fmt.Sprintf(
359                                 `<link rel="next" href="%s" title="older">`, href,
360                         ))
361                         refs.WriteString(makeA(href, "[next]"))
362                 }
363                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
364                 out.Write([]byte(startHTML(
365                         fmt.Sprintf("%s (%d-%d)", blogTitle, offset, offset+PageEntries),
366                         links,
367                 )))
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())
374                 if err != nil {
375                         makeErr(err)
376                 }
377                 etagHash.Write(commit.Hash[:])
378                 etagHash.Write([]byte("ATOM"))
379                 checkETag(etagHash)
380                 feed := atom.Feed{
381                         Title:   blogTitle,
382                         ID:      atomId,
383                         Updated: atom.Time(commit.Author.When),
384                         Link: []atom.Link{{
385                                 Rel:  "self",
386                                 Href: atomUrl,
387                         }},
388                         Author: &atom.Person{Name: atomAuthorName},
389                 }
390                 log, err := repo.Log(&git.LogOptions{From: head.Hash()})
391                 if err != nil {
392                         makeErr(err)
393                 }
394                 for i := 0; i < PageEntries; i++ {
395                         commit, err = log.Next()
396                         if err != nil {
397                                 break
398                         }
399                         lines := msgSplit(commit.Message)
400                         feedId, err := uuid.FromBytes(commit.Hash[:16])
401                         if err != nil {
402                                 panic(err)
403                         }
404                         feed.Entry = append(feed.Entry, &atom.Entry{
405                                 Title: lines[0],
406                                 ID:    "urn:uuid:" + feedId.String(),
407                                 Link: []atom.Link{{
408                                         Rel:  "alternate",
409                                         Href: blogBaseURL + urlPrefix + "/" + commit.Hash.String(),
410                                 }},
411                                 Published: atom.Time(commit.Author.When),
412                                 Updated:   atom.Time(commit.Author.When),
413                                 Summary: &atom.Text{
414                                         Type: "text",
415                                         Body: lines[0],
416                                 },
417                                 Content: &atom.Text{
418                                         Type: "text",
419                                         Body: strings.Join(lines[2:], "\n"),
420                                 },
421                         })
422                 }
423                 data, err := xml.MarshalIndent(&feed, "", "  ")
424                 if err != nil {
425                         makeErr(err)
426                 }
427                 out.Write(data)
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")
432                         gzipWriter.Close()
433                 }
434                 os.Stdout.WriteString("\n")
435                 os.Stdout.Write(outBuf.Bytes())
436                 return
437         } else if sha1DigestRe.MatchString(pathInfo[1:]) {
438                 commit, err = repo.CommitObject(plumbing.NewHash(pathInfo[1:]))
439                 if err != nil {
440                         makeErr(err)
441                 }
442                 etagHash.Write(commit.Hash[:])
443                 checkETag(etagHash)
444                 lines := msgSplit(commit.Message)
445                 title := lines[0]
446                 when := commit.Author.When.Format(WhenFmt)
447                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
448                 parent := commit.ParentHashes[0].String()
449                 out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), []string{
450                         fmt.Sprintf(`<link rel="prev" href="%s" title="older">`, "/"+parent),
451                 })))
452                 out.Write([]byte(fmt.Sprintf(
453                         "[%s]&nbsp;[<tt>%s</tt>]\n<hr/>\n",
454                         makeA(urlPrefix+"/"+parent, "older"),
455                         when,
456                 )))
457                 out.Write([]byte(fmt.Sprintf("<h2>%s</h2>\n<pre>\n", title)))
458                 for _, line := range lines[2:] {
459                         line = strings.ReplaceAll(line, "&", "&amp;")
460                         line = strings.ReplaceAll(line, "<", "&lt;")
461                         line = strings.ReplaceAll(line, ">", "&gt;")
462                         cols := strings.Split(line, " ")
463                         for i, col := range cols {
464                                 if u := urlParse(col); u != nil {
465                                         cols[i] = makeA(col, col)
466                                         continue
467                                 }
468                                 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
469                         }
470                         line = strings.Join(cols, " ")
471                         out.Write([]byte(line + "\n"))
472                 }
473                 out.Write([]byte("</pre>\n"))
474                 if note := getNote(commit.Hash); note != "" {
475                         out.Write([]byte(fmt.Sprintf("Note:\n<pre>\n%s</pre>\n", note)))
476                 }
477         } else {
478                 makeErr(errors.New("unknown URL action"))
479         }
480         if aboutUrl != "" {
481                 out.Write([]byte(fmt.Sprintf(
482                         "<hr/>%s %s\n",
483                         makeA(aboutUrl, "About"),
484                         blogTitle,
485                 )))
486         }
487         out.Write([]byte("</body></html>\n"))
488         if gzipWriter != nil {
489                 gzipWriter.Close()
490         }
491         os.Stdout.Write(outBuf.Bytes())
492 }