]> Sergey Matveev's repositories - sgblog.git/blob - cmd/sgblog/http.go
a91da3c9422338204a7617d66a7b2bf6c2308bcb
[sgblog.git] / cmd / sgblog / http.go
1 /*
2 SGBlog -- Git-based CGI/inetd blogging/phlogging 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 package main
19
20 import (
21         "bytes"
22         "compress/gzip"
23         "encoding/hex"
24         "encoding/json"
25         "encoding/xml"
26         "errors"
27         "fmt"
28         "hash"
29         "html"
30         "io"
31         "io/ioutil"
32         "log"
33         "net/url"
34         "os"
35         "strconv"
36         "strings"
37         "time"
38
39         "github.com/hjson/hjson-go"
40         "go.stargrave.org/sgblog"
41         "golang.org/x/crypto/blake2b"
42         "golang.org/x/tools/blog/atom"
43         "gopkg.in/src-d/go-git.v4"
44         "gopkg.in/src-d/go-git.v4/plumbing"
45         "gopkg.in/src-d/go-git.v4/plumbing/object"
46 )
47
48 const (
49         AtomFeed = "feed.atom"
50 )
51
52 var (
53         defaultLinks = []string{}
54
55         renderableSchemes = map[string]struct{}{
56                 "ftp":    struct{}{},
57                 "gopher": struct{}{},
58                 "http":   struct{}{},
59                 "https":  struct{}{},
60                 "telnet": struct{}{},
61         }
62 )
63
64 type TableEntry struct {
65         commit      *object.Commit
66         commentsRaw []byte
67 }
68
69 func makeA(href, text string) string {
70         return `<a href="` + href + `">` + text + `</a>`
71 }
72
73 func etagString(etag hash.Hash) string {
74         return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
75 }
76
77 func urlParse(what string) *url.URL {
78         if u, err := url.ParseRequestURI(what); err == nil {
79                 if _, exists := renderableSchemes[u.Scheme]; exists {
80                         return u
81                 }
82         }
83         return nil
84 }
85
86 func lineURLize(urlPrefix, line string) string {
87         cols := strings.Split(html.EscapeString(line), " ")
88         for i, col := range cols {
89                 if u := urlParse(col); u != nil {
90                         cols[i] = makeA(col, col)
91                         continue
92                 }
93                 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(
94                         urlPrefix+"/$1", "$1",
95                 ))
96         }
97         return strings.Join(cols, " ")
98 }
99
100 func startHeader(etag hash.Hash, gziped bool) string {
101         lines := []string{
102                 "Content-Type: text/html; charset=UTF-8",
103                 "ETag: " + etagString(etag),
104         }
105         if gziped {
106                 lines = append(lines, "Content-Encoding: gzip")
107         }
108         lines = append(lines, "")
109         lines = append(lines, "")
110         return strings.Join(lines, "\n")
111 }
112
113 func startHTML(title string, additional []string) string {
114         return fmt.Sprintf(`<html>
115 <head>
116         <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
117         <meta name="generator" content="SGBlog %s">
118         <title>%s</title>
119         %s
120 </head>
121 <body>
122 `,
123                 sgblog.Version, title,
124                 strings.Join(append(defaultLinks, additional...), "\n   "),
125         )
126 }
127
128 func makeErr(err error) {
129         fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
130         fmt.Println(err)
131         panic(err)
132 }
133
134 func checkETag(etag hash.Hash) {
135         ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
136         if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
137                 fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch)
138                 os.Exit(0)
139         }
140 }
141
142 func serveHTTP() {
143         cfgPath := os.Getenv("SGBLOG_CFG")
144         if cfgPath == "" {
145                 log.Fatalln("SGBLOG_CFG is not set")
146         }
147         cfgRaw, err := ioutil.ReadFile(cfgPath)
148         if err != nil {
149                 makeErr(err)
150         }
151         var cfgGeneral map[string]interface{}
152         if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
153                 makeErr(err)
154         }
155         cfgRaw, err = json.Marshal(cfgGeneral)
156         if err != nil {
157                 makeErr(err)
158         }
159         var cfg *Cfg
160         if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
161                 makeErr(err)
162         }
163         pathInfo, exists := os.LookupEnv("PATH_INFO")
164         if !exists {
165                 pathInfo = "/"
166         }
167         queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
168         if err != nil {
169                 makeErr(err)
170         }
171
172         etagHash, err := blake2b.New256(nil)
173         if err != nil {
174                 panic(err)
175         }
176         etagHash.Write([]byte("SGBLOG"))
177         etagHash.Write([]byte(sgblog.Version))
178         etagHash.Write([]byte(cfg.GitPath))
179         etagHash.Write([]byte(cfg.Branch))
180         etagHash.Write([]byte(cfg.Title))
181         etagHash.Write([]byte(cfg.URLPrefix))
182         etagHash.Write([]byte(cfg.AtomBaseURL))
183         etagHash.Write([]byte(cfg.AtomId))
184         etagHash.Write([]byte(cfg.AtomAuthor))
185
186         etagHashForWeb := [][]byte{}
187         if cfg.CSS != "" {
188                 defaultLinks = append(defaultLinks, `<link rel="stylesheet" type="text/css" href="`+cfg.CSS+`">`)
189                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CSS))
190         }
191         if cfg.Webmaster != "" {
192                 defaultLinks = append(defaultLinks, `<link rev="made" href="mailto:`+cfg.Webmaster+`">`)
193                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.Webmaster))
194         }
195         if cfg.AboutURL != "" {
196                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.AboutURL))
197         }
198         for _, gitURL := range cfg.GitURLs {
199                 defaultLinks = append(defaultLinks, `<link rel="vcs-git" href="`+gitURL+`" title="Git repository">`)
200                 etagHashForWeb = append(etagHashForWeb, []byte(gitURL))
201         }
202         if cfg.CommentsNotesRef != "" {
203                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsNotesRef))
204         }
205         if cfg.CommentsEmail != "" {
206                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsEmail))
207         }
208
209         defaultLinks = append(defaultLinks, `<link rel="top" href="`+cfg.URLPrefix+`/" title="top">`)
210         atomURL := cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomFeed
211         defaultLinks = append(defaultLinks, `<link rel="alternate" title="Atom feed" href="`+atomURL+`" type="application/atom+xml">`)
212
213         headHash, err := initRepo(cfg)
214         if err != nil {
215                 makeErr(err)
216         }
217
218         if notes, err := repo.Notes(); err == nil {
219                 var notesRef *plumbing.Reference
220                 var commentsRef *plumbing.Reference
221                 notes.ForEach(func(ref *plumbing.Reference) error {
222                         switch string(ref.Name()) {
223                         case "refs/notes/commits":
224                                 notesRef = ref
225                         case cfg.CommentsNotesRef:
226                                 commentsRef = ref
227                         }
228                         return nil
229                 })
230                 if notesRef != nil {
231                         if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
232                                 notesTree, _ = commentsCommit.Tree()
233                         }
234                 }
235                 if commentsRef != nil {
236                         if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
237                                 commentsTree, _ = commentsCommit.Tree()
238                         }
239                 }
240         }
241
242         var outBuf bytes.Buffer
243         var out io.Writer
244         out = &outBuf
245         var gzipWriter *gzip.Writer
246         acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
247         for _, encoding := range strings.Split(acceptEncoding, ", ") {
248                 if encoding == "gzip" {
249                         gzipWriter = gzip.NewWriter(&outBuf)
250                         out = gzipWriter
251                 }
252         }
253
254         if pathInfo == "/" {
255                 offset := 0
256                 if offsetRaw, exists := queryValues["offset"]; exists {
257                         offset, err = strconv.Atoi(offsetRaw[0])
258                         if err != nil {
259                                 makeErr(err)
260                         }
261                 }
262                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
263                 if err != nil {
264                         makeErr(err)
265                 }
266                 commitN := 0
267                 for i := 0; i < offset; i++ {
268                         if _, err = repoLog.Next(); err != nil {
269                                 break
270                         }
271                         commitN++
272                 }
273
274                 entries := make([]TableEntry, 0, PageEntries)
275                 logEnded := false
276                 for _, data := range etagHashForWeb {
277                         etagHash.Write(data)
278                 }
279                 etagHash.Write([]byte("INDEX"))
280                 for i := 0; i < PageEntries; i++ {
281                         commit, err := repoLog.Next()
282                         if err != nil {
283                                 logEnded = true
284                                 break
285                         }
286                         etagHash.Write(commit.Hash[:])
287                         commentsRaw := getNote(commentsTree, commit.Hash)
288                         etagHash.Write(commentsRaw)
289                         entries = append(entries, TableEntry{commit, commentsRaw})
290                 }
291                 checkETag(etagHash)
292
293                 var table bytes.Buffer
294                 table.WriteString(
295                         "<table border=1>\n" +
296                                 "<caption>Comments</caption>\n<tr>" +
297                                 "<th>N</th>" +
298                                 "<th>When</th>" +
299                                 "<th>Title</th>" +
300                                 `<th size="5%"><a title="Lines">L</a></th>` +
301                                 `<th size="5%"><a title="Comments">C</a></th>` +
302                                 "<th>Linked to</th></tr>\n")
303                 var yearPrev int
304                 var monthPrev time.Month
305                 var dayPrev int
306                 for _, entry := range entries {
307                         yearCur, monthCur, dayCur := entry.commit.Author.When.Date()
308                         if dayCur != dayPrev || monthCur != monthPrev || yearCur != yearPrev {
309                                 table.WriteString(fmt.Sprintf(
310                                         "<tr><td colspan=6><center><tt>%04d-%02d-%02d</tt></center></td></tr>\n",
311                                         yearCur, monthCur, dayCur,
312                                 ))
313                                 yearPrev, monthPrev, dayPrev = yearCur, monthCur, dayCur
314                         }
315                         commitN++
316                         lines := msgSplit(entry.commit.Message)
317                         domains := []string{}
318                         for _, line := range lines[2:] {
319                                 if u := urlParse(line); u == nil {
320                                         break
321                                 } else {
322                                         domains = append(domains, makeA(line, u.Host))
323                                 }
324                         }
325                         var commentsValue string
326                         if l := len(parseComments(entry.commentsRaw)); l > 0 {
327                                 commentsValue = strconv.Itoa(l)
328                         } else {
329                                 commentsValue = "&nbsp;"
330                         }
331                         table.WriteString(fmt.Sprintf(
332                                 "<tr><td>%d</td><td><tt>%02d:%02d</tt></td>"+
333                                         "<td>%s</td>"+
334                                         "<td>%d</td><td>%s</td>"+
335                                         "<td>%s</td></tr>\n",
336                                 commitN,
337                                 entry.commit.Author.When.Hour(),
338                                 entry.commit.Author.When.Minute(),
339                                 makeA(cfg.URLPrefix+"/"+entry.commit.Hash.String(), lines[0]),
340                                 len(lines)-2,
341                                 commentsValue,
342                                 strings.Join(domains, " "),
343                         ))
344                 }
345                 table.WriteString("</table>")
346
347                 var href string
348                 var links []string
349                 var refs bytes.Buffer
350                 if offset > 0 {
351                         if offsetPrev := offset - PageEntries; offsetPrev > 0 {
352                                 href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offsetPrev)
353                         } else {
354                                 href = cfg.URLPrefix + "/"
355                         }
356                         links = append(links, `<link rel="prev" href="`+href+`" title="newer">`)
357                         refs.WriteString("\n" + makeA(href, "[prev]"))
358                 }
359                 if !logEnded {
360                         href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries)
361                         links = append(links, `<link rel="next" href="`+href+`" title="older">`)
362                         refs.WriteString("\n" + makeA(href, "[next]"))
363                 }
364
365                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
366                 out.Write([]byte(startHTML(
367                         fmt.Sprintf("%s (%d-%d)", cfg.Title, offset, offset+PageEntries),
368                         links,
369                 )))
370                 if cfg.AboutURL != "" {
371                         out.Write([]byte(fmt.Sprintf("[%s]", makeA(cfg.AboutURL, "about"))))
372                 }
373                 out.Write(refs.Bytes())
374                 out.Write(table.Bytes())
375                 out.Write(refs.Bytes())
376                 out.Write([]byte("\n"))
377         } else if pathInfo == "/"+AtomFeed {
378                 commit, err := repo.CommitObject(*headHash)
379                 if err != nil {
380                         makeErr(err)
381                 }
382                 etagHash.Write([]byte("ATOM"))
383                 etagHash.Write(commit.Hash[:])
384                 checkETag(etagHash)
385                 feed := atom.Feed{
386                         Title:   cfg.Title,
387                         ID:      cfg.AtomId,
388                         Updated: atom.Time(commit.Author.When),
389                         Link: []atom.Link{{
390                                 Rel:  "self",
391                                 Href: atomURL,
392                         }},
393                         Author: &atom.Person{Name: cfg.AtomAuthor},
394                 }
395                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
396                 if err != nil {
397                         makeErr(err)
398                 }
399                 for i := 0; i < PageEntries; i++ {
400                         commit, err = repoLog.Next()
401                         if err != nil {
402                                 break
403                         }
404
405                         feedIdRaw := new([16]byte)
406                         copy(feedIdRaw[:], commit.Hash[:])
407                         feedIdRaw[6] = (feedIdRaw[6] & 0x0F) | uint8(4<<4) // version 4
408                         feedId := fmt.Sprintf(
409                                 "%x-%x-%x-%x-%x",
410                                 feedIdRaw[0:4],
411                                 feedIdRaw[4:6],
412                                 feedIdRaw[6:8],
413                                 feedIdRaw[8:10],
414                                 feedIdRaw[10:],
415                         )
416
417                         lines := msgSplit(commit.Message)
418                         feed.Entry = append(feed.Entry, &atom.Entry{
419                                 Title: lines[0],
420                                 ID:    "urn:uuid:" + feedId,
421                                 Link: []atom.Link{{
422                                         Rel:  "alternate",
423                                         Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
424                                 }},
425                                 Published: atom.Time(commit.Author.When),
426                                 Updated:   atom.Time(commit.Author.When),
427                                 Summary: &atom.Text{
428                                         Type: "text",
429                                         Body: lines[0],
430                                 },
431                                 Content: &atom.Text{
432                                         Type: "text",
433                                         Body: strings.Join(lines[2:], "\n"),
434                                 },
435                         })
436                 }
437                 data, err := xml.MarshalIndent(&feed, "", "  ")
438                 if err != nil {
439                         makeErr(err)
440                 }
441                 out.Write(data)
442                 os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
443                 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
444                 if gzipWriter != nil {
445                         os.Stdout.WriteString("Content-Encoding: gzip\n")
446                         gzipWriter.Close()
447                 }
448                 os.Stdout.WriteString("\n")
449                 os.Stdout.Write(outBuf.Bytes())
450                 return
451         } else if sha1DigestRe.MatchString(pathInfo[1:]) {
452                 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1:]))
453                 if err != nil {
454                         makeErr(err)
455                 }
456                 for _, data := range etagHashForWeb {
457                         etagHash.Write(data)
458                 }
459                 etagHash.Write([]byte("ENTRY"))
460                 etagHash.Write(commit.Hash[:])
461                 notesRaw := getNote(notesTree, commit.Hash)
462                 etagHash.Write(notesRaw)
463                 commentsRaw := getNote(commentsTree, commit.Hash)
464                 etagHash.Write(commentsRaw)
465                 checkETag(etagHash)
466                 lines := msgSplit(commit.Message)
467                 title := lines[0]
468                 when := commit.Author.When.Format(sgblog.WhenFmt)
469                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
470                 links := []string{}
471                 var parent string
472                 if len(commit.ParentHashes) > 0 {
473                         parent = commit.ParentHashes[0].String()
474                         links = append(links, `<link rel="prev" href="`+cfg.URLPrefix+"/"+parent+`" title="older">`)
475                 }
476                 out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), links)))
477                 if cfg.AboutURL != "" {
478                         out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.AboutURL, "about"))))
479                 }
480                 out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.URLPrefix+"/", "index"))))
481                 if parent != "" {
482                         out.Write([]byte(fmt.Sprintf(
483                                 "[%s]\n",
484                                 makeA(cfg.URLPrefix+"/"+parent, "older"),
485                         )))
486                 }
487                 out.Write([]byte(fmt.Sprintf(
488                         "[<tt><a title=\"When\">%s</a></tt>]\n"+
489                                 "[<tt><a title=\"Hash\">%s</a></tt>]\n"+
490                                 "<hr/>\n<h2>%s</h2>\n<pre>\n",
491                         when, commit.Hash.String(), title,
492                 )))
493                 for _, line := range lines[2:] {
494                         out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
495                 }
496                 out.Write([]byte("</pre>\n<hr/>\n"))
497                 if len(notesRaw) > 0 {
498                         out.Write([]byte("Note:<pre>\n" + string(notesRaw) + "\n</pre>\n<hr/>\n"))
499                 }
500                 if cfg.CommentsEmail != "" {
501                         out.Write([]byte("[" + makeA(
502                                 "mailto:"+cfg.CommentsEmail+"?subject="+commit.Hash.String(),
503                                 "write comment",
504                         ) + "]\n"))
505                 }
506                 out.Write([]byte("<dl>\n"))
507                 for i, comment := range parseComments(commentsRaw) {
508                         out.Write([]byte(fmt.Sprintf(
509                                 "<dt><a name=\"comment%d\"><a href=\"#comment%d\">comment %d</a>:"+
510                                         "</dt>\n<dd><pre>\n",
511                                 i, i, i,
512                         )))
513                         lines = strings.Split(comment, "\n")
514                         for _, line := range lines[:3] {
515                                 out.Write([]byte(line + "\n"))
516                         }
517                         for _, line := range lines[3:] {
518                                 out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
519                         }
520                         out.Write([]byte("</pre></dd>\n"))
521                 }
522                 out.Write([]byte("</dl>\n"))
523         } else {
524                 makeErr(errors.New("unknown URL action"))
525         }
526         out.Write([]byte("</body></html>\n"))
527         if gzipWriter != nil {
528                 gzipWriter.Close()
529         }
530         os.Stdout.Write(outBuf.Bytes())
531 }