]> Sergey Matveev's repositories - sgblog.git/blob - cmd/sgblog/http.go
Unnecessary table caption
[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                                 "<th>N</th>" +
297                                 "<th>When</th>" +
298                                 "<th>Title</th>" +
299                                 `<th size="5%"><a title="Lines">L</a></th>` +
300                                 `<th size="5%"><a title="Comments">C</a></th>` +
301                                 "<th>Linked to</th></tr>\n")
302                 var yearPrev int
303                 var monthPrev time.Month
304                 var dayPrev int
305                 for _, entry := range entries {
306                         yearCur, monthCur, dayCur := entry.commit.Author.When.Date()
307                         if dayCur != dayPrev || monthCur != monthPrev || yearCur != yearPrev {
308                                 table.WriteString(fmt.Sprintf(
309                                         "<tr><td colspan=6><center><tt>%04d-%02d-%02d</tt></center></td></tr>\n",
310                                         yearCur, monthCur, dayCur,
311                                 ))
312                                 yearPrev, monthPrev, dayPrev = yearCur, monthCur, dayCur
313                         }
314                         commitN++
315                         lines := msgSplit(entry.commit.Message)
316                         domains := []string{}
317                         for _, line := range lines[2:] {
318                                 if u := urlParse(line); u == nil {
319                                         break
320                                 } else {
321                                         domains = append(domains, makeA(line, u.Host))
322                                 }
323                         }
324                         var commentsValue string
325                         if l := len(parseComments(entry.commentsRaw)); l > 0 {
326                                 commentsValue = strconv.Itoa(l)
327                         } else {
328                                 commentsValue = "&nbsp;"
329                         }
330                         table.WriteString(fmt.Sprintf(
331                                 "<tr><td>%d</td><td><tt>%02d:%02d</tt></td>"+
332                                         "<td>%s</td>"+
333                                         "<td>%d</td><td>%s</td>"+
334                                         "<td>%s</td></tr>\n",
335                                 commitN,
336                                 entry.commit.Author.When.Hour(),
337                                 entry.commit.Author.When.Minute(),
338                                 makeA(cfg.URLPrefix+"/"+entry.commit.Hash.String(), lines[0]),
339                                 len(lines)-2,
340                                 commentsValue,
341                                 strings.Join(domains, " "),
342                         ))
343                 }
344                 table.WriteString("</table>")
345
346                 var href string
347                 var links []string
348                 var refs bytes.Buffer
349                 if offset > 0 {
350                         if offsetPrev := offset - PageEntries; offsetPrev > 0 {
351                                 href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offsetPrev)
352                         } else {
353                                 href = cfg.URLPrefix + "/"
354                         }
355                         links = append(links, `<link rel="prev" href="`+href+`" title="newer">`)
356                         refs.WriteString("\n" + makeA(href, "[prev]"))
357                 }
358                 if !logEnded {
359                         href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries)
360                         links = append(links, `<link rel="next" href="`+href+`" title="older">`)
361                         refs.WriteString("\n" + makeA(href, "[next]"))
362                 }
363
364                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
365                 out.Write([]byte(startHTML(
366                         fmt.Sprintf("%s (%d-%d)", cfg.Title, offset, offset+PageEntries),
367                         links,
368                 )))
369                 if cfg.AboutURL != "" {
370                         out.Write([]byte(fmt.Sprintf("[%s]", makeA(cfg.AboutURL, "about"))))
371                 }
372                 out.Write(refs.Bytes())
373                 out.Write(table.Bytes())
374                 out.Write(refs.Bytes())
375                 out.Write([]byte("\n"))
376         } else if pathInfo == "/"+AtomFeed {
377                 commit, err := repo.CommitObject(*headHash)
378                 if err != nil {
379                         makeErr(err)
380                 }
381                 etagHash.Write([]byte("ATOM"))
382                 etagHash.Write(commit.Hash[:])
383                 checkETag(etagHash)
384                 feed := atom.Feed{
385                         Title:   cfg.Title,
386                         ID:      cfg.AtomId,
387                         Updated: atom.Time(commit.Author.When),
388                         Link: []atom.Link{{
389                                 Rel:  "self",
390                                 Href: atomURL,
391                         }},
392                         Author: &atom.Person{Name: cfg.AtomAuthor},
393                 }
394                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
395                 if err != nil {
396                         makeErr(err)
397                 }
398                 for i := 0; i < PageEntries; i++ {
399                         commit, err = repoLog.Next()
400                         if err != nil {
401                                 break
402                         }
403
404                         feedIdRaw := new([16]byte)
405                         copy(feedIdRaw[:], commit.Hash[:])
406                         feedIdRaw[6] = (feedIdRaw[6] & 0x0F) | uint8(4<<4) // version 4
407                         feedId := fmt.Sprintf(
408                                 "%x-%x-%x-%x-%x",
409                                 feedIdRaw[0:4],
410                                 feedIdRaw[4:6],
411                                 feedIdRaw[6:8],
412                                 feedIdRaw[8:10],
413                                 feedIdRaw[10:],
414                         )
415
416                         lines := msgSplit(commit.Message)
417                         feed.Entry = append(feed.Entry, &atom.Entry{
418                                 Title: lines[0],
419                                 ID:    "urn:uuid:" + feedId,
420                                 Link: []atom.Link{{
421                                         Rel:  "alternate",
422                                         Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
423                                 }},
424                                 Published: atom.Time(commit.Author.When),
425                                 Updated:   atom.Time(commit.Author.When),
426                                 Summary: &atom.Text{
427                                         Type: "text",
428                                         Body: lines[0],
429                                 },
430                                 Content: &atom.Text{
431                                         Type: "text",
432                                         Body: strings.Join(lines[2:], "\n"),
433                                 },
434                         })
435                 }
436                 data, err := xml.MarshalIndent(&feed, "", "  ")
437                 if err != nil {
438                         makeErr(err)
439                 }
440                 out.Write(data)
441                 os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
442                 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
443                 if gzipWriter != nil {
444                         os.Stdout.WriteString("Content-Encoding: gzip\n")
445                         gzipWriter.Close()
446                 }
447                 os.Stdout.WriteString("\n")
448                 os.Stdout.Write(outBuf.Bytes())
449                 return
450         } else if sha1DigestRe.MatchString(pathInfo[1:]) {
451                 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1:]))
452                 if err != nil {
453                         makeErr(err)
454                 }
455                 for _, data := range etagHashForWeb {
456                         etagHash.Write(data)
457                 }
458                 etagHash.Write([]byte("ENTRY"))
459                 etagHash.Write(commit.Hash[:])
460                 notesRaw := getNote(notesTree, commit.Hash)
461                 etagHash.Write(notesRaw)
462                 commentsRaw := getNote(commentsTree, commit.Hash)
463                 etagHash.Write(commentsRaw)
464                 checkETag(etagHash)
465                 lines := msgSplit(commit.Message)
466                 title := lines[0]
467                 when := commit.Author.When.Format(sgblog.WhenFmt)
468                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
469                 links := []string{}
470                 var parent string
471                 if len(commit.ParentHashes) > 0 {
472                         parent = commit.ParentHashes[0].String()
473                         links = append(links, `<link rel="prev" href="`+cfg.URLPrefix+"/"+parent+`" title="older">`)
474                 }
475                 out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), links)))
476                 if cfg.AboutURL != "" {
477                         out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.AboutURL, "about"))))
478                 }
479                 out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.URLPrefix+"/", "index"))))
480                 if parent != "" {
481                         out.Write([]byte(fmt.Sprintf(
482                                 "[%s]\n",
483                                 makeA(cfg.URLPrefix+"/"+parent, "older"),
484                         )))
485                 }
486                 out.Write([]byte(fmt.Sprintf(
487                         "[<tt><a title=\"When\">%s</a></tt>]\n"+
488                                 "[<tt><a title=\"Hash\">%s</a></tt>]\n"+
489                                 "<hr/>\n<h2>%s</h2>\n<pre>\n",
490                         when, commit.Hash.String(), title,
491                 )))
492                 for _, line := range lines[2:] {
493                         out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
494                 }
495                 out.Write([]byte("</pre>\n<hr/>\n"))
496                 if len(notesRaw) > 0 {
497                         out.Write([]byte("Note:<pre>\n" + string(notesRaw) + "\n</pre>\n<hr/>\n"))
498                 }
499                 if cfg.CommentsEmail != "" {
500                         out.Write([]byte("[" + makeA(
501                                 "mailto:"+cfg.CommentsEmail+"?subject="+commit.Hash.String(),
502                                 "write comment",
503                         ) + "]\n"))
504                 }
505                 out.Write([]byte("<dl>\n"))
506                 for i, comment := range parseComments(commentsRaw) {
507                         out.Write([]byte(fmt.Sprintf(
508                                 "<dt><a name=\"comment%d\"><a href=\"#comment%d\">comment %d</a>:"+
509                                         "</dt>\n<dd><pre>\n",
510                                 i, i, i,
511                         )))
512                         lines = strings.Split(comment, "\n")
513                         for _, line := range lines[:3] {
514                                 out.Write([]byte(line + "\n"))
515                         }
516                         for _, line := range lines[3:] {
517                                 out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
518                         }
519                         out.Write([]byte("</pre></dd>\n"))
520                 }
521                 out.Write([]byte("</dl>\n"))
522         } else {
523                 makeErr(errors.New("unknown URL action"))
524         }
525         out.Write([]byte("</body></html>\n"))
526         if gzipWriter != nil {
527                 gzipWriter.Close()
528         }
529         os.Stdout.Write(outBuf.Bytes())
530 }