]> Sergey Matveev's repositories - sgblog.git/blob - cmd/sgblog/main.go
a55411f798265291cee0e4f4cfdb0a3241061cb7
[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         "bufio"
23         "bytes"
24         "compress/gzip"
25         "encoding/hex"
26         "encoding/json"
27         "encoding/xml"
28         "errors"
29         "fmt"
30         "hash"
31         "html"
32         "io"
33         "io/ioutil"
34         "log"
35         "net/url"
36         "os"
37         "regexp"
38         "strconv"
39         "strings"
40
41         "github.com/hjson/hjson-go"
42         "go.cypherpunks.ru/netstring/v2"
43         "go.stargrave.org/sgblog"
44         "golang.org/x/crypto/blake2b"
45         "golang.org/x/tools/blog/atom"
46         "gopkg.in/src-d/go-git.v4"
47         "gopkg.in/src-d/go-git.v4/plumbing"
48         "gopkg.in/src-d/go-git.v4/plumbing/object"
49 )
50
51 const (
52         PageEntries = 50
53         AtomFeed    = "feed.atom"
54         CRLF        = "\r\n"
55 )
56
57 var (
58         sha1DigestRe = regexp.MustCompilePOSIX("([0-9a-f]{40,40})")
59         defaultLinks = []string{}
60         repo         *git.Repository
61         notesTree    *object.Tree
62         commentsTree *object.Tree
63
64         renderableSchemes = map[string]struct{}{
65                 "ftp":    struct{}{},
66                 "gopher": struct{}{},
67                 "http":   struct{}{},
68                 "https":  struct{}{},
69                 "telnet": struct{}{},
70         }
71         DashLine = strings.Repeat("-", 72)
72 )
73
74 type TableEntry struct {
75         commit      *object.Commit
76         commentsRaw []byte
77 }
78
79 type Cfg struct {
80         GitPath string
81         Branch  string
82         Title   string
83
84         URLPrefix string
85
86         AtomBaseURL string
87         AtomId      string
88         AtomAuthor  string
89
90         CSS       string
91         Webmaster string
92         AboutURL  string
93         GitURLs   []string
94
95         CommentsNotesRef string
96         CommentsEmail    string
97
98         GopherDomain string
99 }
100
101 func makeA(href, text string) string {
102         return `<a href="` + href + `">` + text + `</a>`
103 }
104
105 func etagString(etag hash.Hash) string {
106         return `"` + hex.EncodeToString(etag.Sum(nil)) + `"`
107 }
108
109 func urlParse(what string) *url.URL {
110         if u, err := url.ParseRequestURI(what); err == nil {
111                 if _, exists := renderableSchemes[u.Scheme]; exists {
112                         return u
113                 }
114         }
115         return nil
116 }
117
118 func msgSplit(msg string) []string {
119         lines := strings.Split(msg, "\n")
120         lines = lines[:len(lines)-1]
121         if len(lines) < 3 {
122                 lines = []string{lines[0], "", ""}
123         }
124         return lines
125 }
126
127 func lineURLize(urlPrefix, line string) string {
128         cols := strings.Split(html.EscapeString(line), " ")
129         for i, col := range cols {
130                 if u := urlParse(col); u != nil {
131                         cols[i] = makeA(col, col)
132                         continue
133                 }
134                 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(
135                         urlPrefix+"/$1", "$1",
136                 ))
137         }
138         return strings.Join(cols, " ")
139 }
140
141 func getNote(tree *object.Tree, what plumbing.Hash) []byte {
142         if tree == nil {
143                 return nil
144         }
145         var entry *object.TreeEntry
146         var err error
147         paths := make([]string, 3)
148         paths[0] = what.String()
149         paths[1] = paths[0][:2] + "/" + paths[0][2:]
150         paths[2] = paths[1][:4+1] + "/" + paths[1][4+1:]
151         for _, p := range paths {
152                 entry, err = tree.FindEntry(p)
153                 if err == nil {
154                         break
155                 }
156         }
157         if entry == nil {
158                 return nil
159         }
160         blob, err := repo.BlobObject(entry.Hash)
161         if err != nil {
162                 return nil
163         }
164         r, err := blob.Reader()
165         if err != nil {
166                 return nil
167         }
168         data, err := ioutil.ReadAll(r)
169         if err != nil {
170                 return nil
171         }
172         return bytes.TrimSuffix(data, []byte{'\n'})
173 }
174
175 func parseComments(data []byte) []string {
176         comments := []string{}
177         nsr := netstring.NewReader(bytes.NewReader(data))
178         for {
179                 if _, err := nsr.Next(); err != nil {
180                         break
181                 }
182                 if comment, err := ioutil.ReadAll(nsr); err == nil {
183                         comments = append(comments, string(comment))
184                 }
185         }
186         return comments
187 }
188
189 func startHeader(etag hash.Hash, gziped bool) string {
190         lines := []string{
191                 "Content-Type: text/html; charset=UTF-8",
192                 "ETag: " + etagString(etag),
193         }
194         if gziped {
195                 lines = append(lines, "Content-Encoding: gzip")
196         }
197         lines = append(lines, "")
198         lines = append(lines, "")
199         return strings.Join(lines, "\n")
200 }
201
202 func startHTML(title string, additional []string) string {
203         return fmt.Sprintf(`<html>
204 <head>
205         <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
206         <meta name="generator" content="SGBlog %s">
207         <title>%s</title>
208         %s
209 </head>
210 <body>
211 `,
212                 sgblog.Version, title,
213                 strings.Join(append(defaultLinks, additional...), "\n   "),
214         )
215 }
216
217 func makeErr(err error) {
218         fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n")
219         fmt.Println(err)
220         panic(err)
221 }
222
223 func checkETag(etag hash.Hash) {
224         ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
225         if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
226                 fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch)
227                 os.Exit(0)
228         }
229 }
230
231 func initRepo(cfg *Cfg) (*plumbing.Hash, error) {
232         var err error
233         repo, err = git.PlainOpen(cfg.GitPath)
234         if err != nil {
235                 return nil, err
236         }
237         head, err := repo.Reference(plumbing.ReferenceName(cfg.Branch), false)
238         if err != nil {
239                 return nil, err
240         }
241         headHash := head.Hash()
242         if notes, err := repo.Notes(); err == nil {
243                 var notesRef *plumbing.Reference
244                 var commentsRef *plumbing.Reference
245                 notes.ForEach(func(ref *plumbing.Reference) error {
246                         switch string(ref.Name()) {
247                         case "refs/notes/commits":
248                                 notesRef = ref
249                         case cfg.CommentsNotesRef:
250                                 commentsRef = ref
251                         }
252                         return nil
253                 })
254                 if notesRef != nil {
255                         if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
256                                 notesTree, _ = commentsCommit.Tree()
257                         }
258                 }
259                 if commentsRef != nil {
260                         if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
261                                 commentsTree, _ = commentsCommit.Tree()
262                         }
263                 }
264         }
265         return &headHash, nil
266 }
267
268 func serveHTTP() {
269         cfgPath := os.Getenv("SGBLOG_CFG")
270         if cfgPath == "" {
271                 log.Fatalln("SGBLOG_CFG is not set")
272         }
273         cfgRaw, err := ioutil.ReadFile(cfgPath)
274         if err != nil {
275                 makeErr(err)
276         }
277         var cfgGeneral map[string]interface{}
278         if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
279                 makeErr(err)
280         }
281         cfgRaw, err = json.Marshal(cfgGeneral)
282         if err != nil {
283                 makeErr(err)
284         }
285         var cfg *Cfg
286         if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
287                 makeErr(err)
288         }
289         pathInfo, exists := os.LookupEnv("PATH_INFO")
290         if !exists {
291                 pathInfo = "/"
292         }
293         queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
294         if err != nil {
295                 makeErr(err)
296         }
297
298         etagHash, err := blake2b.New256(nil)
299         if err != nil {
300                 panic(err)
301         }
302         etagHash.Write([]byte("SGBLOG"))
303         etagHash.Write([]byte(sgblog.Version))
304         etagHash.Write([]byte(cfg.GitPath))
305         etagHash.Write([]byte(cfg.Branch))
306         etagHash.Write([]byte(cfg.Title))
307         etagHash.Write([]byte(cfg.URLPrefix))
308         etagHash.Write([]byte(cfg.AtomBaseURL))
309         etagHash.Write([]byte(cfg.AtomId))
310         etagHash.Write([]byte(cfg.AtomAuthor))
311
312         etagHashForWeb := [][]byte{}
313         if cfg.CSS != "" {
314                 defaultLinks = append(defaultLinks, `<link rel="stylesheet" type="text/css" href="`+cfg.CSS+`">`)
315                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CSS))
316         }
317         if cfg.Webmaster != "" {
318                 defaultLinks = append(defaultLinks, `<link rev="made" href="mailto:`+cfg.Webmaster+`">`)
319                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.Webmaster))
320         }
321         if cfg.AboutURL != "" {
322                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.AboutURL))
323         }
324         for _, gitURL := range cfg.GitURLs {
325                 defaultLinks = append(defaultLinks, `<link rel="vcs-git" href="`+gitURL+`" title="Git repository">`)
326                 etagHashForWeb = append(etagHashForWeb, []byte(gitURL))
327         }
328         if cfg.CommentsNotesRef != "" {
329                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsNotesRef))
330         }
331         if cfg.CommentsEmail != "" {
332                 etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsEmail))
333         }
334
335         defaultLinks = append(defaultLinks, `<link rel="top" href="`+cfg.URLPrefix+`/" title="top">`)
336         atomURL := cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomFeed
337         defaultLinks = append(defaultLinks, `<link rel="alternate" title="Atom feed" href="`+atomURL+`" type="application/atom+xml">`)
338
339         headHash, err := initRepo(cfg)
340         if err != nil {
341                 makeErr(err)
342         }
343
344         if notes, err := repo.Notes(); err == nil {
345                 var notesRef *plumbing.Reference
346                 var commentsRef *plumbing.Reference
347                 notes.ForEach(func(ref *plumbing.Reference) error {
348                         switch string(ref.Name()) {
349                         case "refs/notes/commits":
350                                 notesRef = ref
351                         case cfg.CommentsNotesRef:
352                                 commentsRef = ref
353                         }
354                         return nil
355                 })
356                 if notesRef != nil {
357                         if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
358                                 notesTree, _ = commentsCommit.Tree()
359                         }
360                 }
361                 if commentsRef != nil {
362                         if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
363                                 commentsTree, _ = commentsCommit.Tree()
364                         }
365                 }
366         }
367
368         var outBuf bytes.Buffer
369         var out io.Writer
370         out = &outBuf
371         var gzipWriter *gzip.Writer
372         acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
373         for _, encoding := range strings.Split(acceptEncoding, ", ") {
374                 if encoding == "gzip" {
375                         gzipWriter = gzip.NewWriter(&outBuf)
376                         out = gzipWriter
377                 }
378         }
379
380         if pathInfo == "/" {
381                 offset := 0
382                 if offsetRaw, exists := queryValues["offset"]; exists {
383                         offset, err = strconv.Atoi(offsetRaw[0])
384                         if err != nil {
385                                 makeErr(err)
386                         }
387                 }
388                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
389                 if err != nil {
390                         makeErr(err)
391                 }
392                 commitN := 0
393                 for i := 0; i < offset; i++ {
394                         if _, err = repoLog.Next(); err != nil {
395                                 break
396                         }
397                         commitN++
398                 }
399
400                 entries := make([]TableEntry, 0, PageEntries)
401                 logEnded := false
402                 for _, data := range etagHashForWeb {
403                         etagHash.Write(data)
404                 }
405                 etagHash.Write([]byte("INDEX"))
406                 for i := 0; i < PageEntries; i++ {
407                         commit, err := repoLog.Next()
408                         if err != nil {
409                                 logEnded = true
410                                 break
411                         }
412                         etagHash.Write(commit.Hash[:])
413                         commentsRaw := getNote(commentsTree, commit.Hash)
414                         etagHash.Write(commentsRaw)
415                         entries = append(entries, TableEntry{commit, commentsRaw})
416                 }
417                 checkETag(etagHash)
418
419                 var table bytes.Buffer
420                 table.WriteString(
421                         "<table border=1>\n" +
422                                 "<caption>Comments</caption>\n<tr>" +
423                                 "<th>N</th>" +
424                                 "<th>When</th>" +
425                                 "<th>Title</th>" +
426                                 `<th size="5%"><a title="Lines">L</a></th>` +
427                                 `<th size="5%"><a title="Comments">C</a></th>` +
428                                 "<th>Linked to</th></tr>\n")
429                 for _, entry := range entries {
430                         commitN++
431                         lines := msgSplit(entry.commit.Message)
432                         domains := []string{}
433                         for _, line := range lines[2:] {
434                                 if u := urlParse(line); u == nil {
435                                         break
436                                 } else {
437                                         domains = append(domains, makeA(line, u.Host))
438                                 }
439                         }
440                         var commentsValue string
441                         if l := len(parseComments(entry.commentsRaw)); l > 0 {
442                                 commentsValue = strconv.Itoa(l)
443                         } else {
444                                 commentsValue = "&nbsp;"
445                         }
446                         table.WriteString(fmt.Sprintf(
447                                 "<tr><td>%d</td><td><tt>%s</tt></td>"+
448                                         "<td>%s</td>"+
449                                         "<td>%d</td><td>%s</td>"+
450                                         "<td>%s</td></tr>\n",
451                                 commitN, entry.commit.Author.When.Format(sgblog.WhenFmt),
452                                 makeA(cfg.URLPrefix+"/"+entry.commit.Hash.String(), lines[0]),
453                                 len(lines)-2,
454                                 commentsValue,
455                                 strings.Join(domains, " "),
456                         ))
457                 }
458                 table.WriteString("</table>")
459
460                 var href string
461                 var links []string
462                 var refs bytes.Buffer
463                 if offset > 0 {
464                         if offsetPrev := offset - PageEntries; offsetPrev > 0 {
465                                 href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offsetPrev)
466                         } else {
467                                 href = cfg.URLPrefix + "/"
468                         }
469                         links = append(links, `<link rel="prev" href="`+href+`" title="newer">`)
470                         refs.WriteString("\n" + makeA(href, "[prev]"))
471                 }
472                 if !logEnded {
473                         href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries)
474                         links = append(links, `<link rel="next" href="`+href+`" title="older">`)
475                         refs.WriteString("\n" + makeA(href, "[next]"))
476                 }
477
478                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
479                 out.Write([]byte(startHTML(
480                         fmt.Sprintf("%s (%d-%d)", cfg.Title, offset, offset+PageEntries),
481                         links,
482                 )))
483                 if cfg.AboutURL != "" {
484                         out.Write([]byte(fmt.Sprintf("[%s]", makeA(cfg.AboutURL, "about"))))
485                 }
486                 out.Write(refs.Bytes())
487                 out.Write(table.Bytes())
488                 out.Write(refs.Bytes())
489                 out.Write([]byte("\n"))
490         } else if pathInfo == "/"+AtomFeed {
491                 commit, err := repo.CommitObject(*headHash)
492                 if err != nil {
493                         makeErr(err)
494                 }
495                 etagHash.Write([]byte("ATOM"))
496                 etagHash.Write(commit.Hash[:])
497                 checkETag(etagHash)
498                 feed := atom.Feed{
499                         Title:   cfg.Title,
500                         ID:      cfg.AtomId,
501                         Updated: atom.Time(commit.Author.When),
502                         Link: []atom.Link{{
503                                 Rel:  "self",
504                                 Href: atomURL,
505                         }},
506                         Author: &atom.Person{Name: cfg.AtomAuthor},
507                 }
508                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
509                 if err != nil {
510                         makeErr(err)
511                 }
512                 for i := 0; i < PageEntries; i++ {
513                         commit, err = repoLog.Next()
514                         if err != nil {
515                                 break
516                         }
517
518                         feedIdRaw := new([16]byte)
519                         copy(feedIdRaw[:], commit.Hash[:])
520                         feedIdRaw[6] = (feedIdRaw[6] & 0x0F) | uint8(4<<4) // version 4
521                         feedId := fmt.Sprintf(
522                                 "%x-%x-%x-%x-%x",
523                                 feedIdRaw[0:4],
524                                 feedIdRaw[4:6],
525                                 feedIdRaw[6:8],
526                                 feedIdRaw[8:10],
527                                 feedIdRaw[10:],
528                         )
529
530                         lines := msgSplit(commit.Message)
531                         feed.Entry = append(feed.Entry, &atom.Entry{
532                                 Title: lines[0],
533                                 ID:    "urn:uuid:" + feedId,
534                                 Link: []atom.Link{{
535                                         Rel:  "alternate",
536                                         Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
537                                 }},
538                                 Published: atom.Time(commit.Author.When),
539                                 Updated:   atom.Time(commit.Author.When),
540                                 Summary: &atom.Text{
541                                         Type: "text",
542                                         Body: lines[0],
543                                 },
544                                 Content: &atom.Text{
545                                         Type: "text",
546                                         Body: strings.Join(lines[2:], "\n"),
547                                 },
548                         })
549                 }
550                 data, err := xml.MarshalIndent(&feed, "", "  ")
551                 if err != nil {
552                         makeErr(err)
553                 }
554                 out.Write(data)
555                 os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n")
556                 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
557                 if gzipWriter != nil {
558                         os.Stdout.WriteString("Content-Encoding: gzip\n")
559                         gzipWriter.Close()
560                 }
561                 os.Stdout.WriteString("\n")
562                 os.Stdout.Write(outBuf.Bytes())
563                 return
564         } else if sha1DigestRe.MatchString(pathInfo[1:]) {
565                 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1:]))
566                 if err != nil {
567                         makeErr(err)
568                 }
569                 for _, data := range etagHashForWeb {
570                         etagHash.Write(data)
571                 }
572                 etagHash.Write([]byte("ENTRY"))
573                 etagHash.Write(commit.Hash[:])
574                 notesRaw := getNote(notesTree, commit.Hash)
575                 etagHash.Write(notesRaw)
576                 commentsRaw := getNote(commentsTree, commit.Hash)
577                 etagHash.Write(commentsRaw)
578                 checkETag(etagHash)
579                 lines := msgSplit(commit.Message)
580                 title := lines[0]
581                 when := commit.Author.When.Format(sgblog.WhenFmt)
582                 os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil)))
583                 links := []string{}
584                 var parent string
585                 if len(commit.ParentHashes) > 0 {
586                         parent = commit.ParentHashes[0].String()
587                         links = append(links, `<link rel="prev" href="`+cfg.URLPrefix+"/"+parent+`" title="older">`)
588                 }
589                 out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), links)))
590                 if cfg.AboutURL != "" {
591                         out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.AboutURL, "about"))))
592                 }
593                 out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.URLPrefix+"/", "index"))))
594                 if parent != "" {
595                         out.Write([]byte(fmt.Sprintf(
596                                 "[%s]\n",
597                                 makeA(cfg.URLPrefix+"/"+parent, "older"),
598                         )))
599                 }
600                 out.Write([]byte(fmt.Sprintf(
601                         "[<tt><a title=\"When\">%s</a></tt>]\n"+
602                                 "[<tt><a title=\"Hash\">%s</a></tt>]\n"+
603                                 "<hr/>\n<h2>%s</h2>\n<pre>\n",
604                         when, commit.Hash.String(), title,
605                 )))
606                 for _, line := range lines[2:] {
607                         out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
608                 }
609                 out.Write([]byte("</pre>\n<hr/>\n"))
610                 if len(notesRaw) > 0 {
611                         out.Write([]byte("Note:<pre>\n" + string(notesRaw) + "\n</pre>\n<hr/>\n"))
612                 }
613                 if cfg.CommentsEmail != "" {
614                         out.Write([]byte("[" + makeA(
615                                 "mailto:"+cfg.CommentsEmail+"?subject="+commit.Hash.String(),
616                                 "write comment",
617                         ) + "]\n"))
618                 }
619                 out.Write([]byte("<dl>\n"))
620                 for i, comment := range parseComments(commentsRaw) {
621                         out.Write([]byte(fmt.Sprintf(
622                                 "<dt><a name=\"comment%d\"><a href=\"#comment%d\">comment %d</a>:"+
623                                         "</dt>\n<dd><pre>\n",
624                                 i, i, i,
625                         )))
626                         lines = strings.Split(comment, "\n")
627                         for _, line := range lines[:3] {
628                                 out.Write([]byte(line + "\n"))
629                         }
630                         for _, line := range lines[3:] {
631                                 out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
632                         }
633                         out.Write([]byte("</pre></dd>\n"))
634                 }
635                 out.Write([]byte("</dl>\n"))
636         } else {
637                 makeErr(errors.New("unknown URL action"))
638         }
639         out.Write([]byte("</body></html>\n"))
640         if gzipWriter != nil {
641                 gzipWriter.Close()
642         }
643         os.Stdout.Write(outBuf.Bytes())
644 }
645
646 func serveGopher() {
647         cfgPath := os.Args[2]
648         cfgRaw, err := ioutil.ReadFile(cfgPath)
649         if err != nil {
650                 log.Fatalln(err)
651         }
652         var cfgGeneral map[string]interface{}
653         if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil {
654                 log.Fatalln(err)
655         }
656         cfgRaw, err = json.Marshal(cfgGeneral)
657         if err != nil {
658                 log.Fatalln(err)
659         }
660         var cfg *Cfg
661         if err = json.Unmarshal(cfgRaw, &cfg); err != nil {
662                 log.Fatalln(err)
663         }
664         if cfg.GopherDomain == "" {
665                 log.Fatalln("GopherDomain is not configured")
666         }
667
668         headHash, err := initRepo(cfg)
669         if err != nil {
670                 log.Fatalln(err)
671         }
672
673         scanner := bufio.NewScanner(io.LimitReader(os.Stdin, 1<<8))
674         if !scanner.Scan() {
675                 log.Fatalln(errors.New("no CRLF found"))
676         }
677         selector := scanner.Text()
678         if selector == "" {
679                 selector = "offset/0"
680         }
681         if strings.HasPrefix(selector, "offset/") {
682                 offset, err := strconv.Atoi(selector[len("offset/"):])
683                 if err != nil {
684                         log.Fatalln(err)
685                 }
686                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
687                 if err != nil {
688                         log.Fatalln(err)
689                 }
690                 commitN := 0
691                 for i := 0; i < offset; i++ {
692                         if _, err = repoLog.Next(); err != nil {
693                                 break
694                         }
695                         commitN++
696                 }
697
698                 logEnded := false
699                 var menu bytes.Buffer
700                 for i := 0; i < PageEntries; i++ {
701                         commit, err := repoLog.Next()
702                         if err != nil {
703                                 logEnded = true
704                                 break
705                         }
706                         commitN++
707                         lines := msgSplit(commit.Message)
708
709                         var commentsValue string
710                         if l := len(parseComments(getNote(commentsTree, commit.Hash))); l > 0 {
711                                 commentsValue = fmt.Sprintf(" (%dC)", l)
712                         }
713                         menu.WriteString(fmt.Sprintf(
714                                 "0[%s] %s (%dL)%s\t/%s\t%s\t%d%s",
715                                 commit.Author.When.Format(sgblog.WhenFmt),
716                                 lines[0],
717                                 len(lines)-2,
718                                 commentsValue,
719                                 commit.Hash.String(),
720                                 cfg.GopherDomain, 70, CRLF,
721                         ))
722                 }
723
724                 var links bytes.Buffer
725                 if offset > 0 {
726                         offsetPrev := offset - PageEntries
727                         if offsetPrev < 0 {
728                                 offsetPrev = 0
729                         }
730                         links.WriteString(fmt.Sprintf(
731                                 "1Prev\toffset/%d\t%s\t%d%s",
732                                 offsetPrev,
733                                 cfg.GopherDomain, 70, CRLF,
734                         ))
735                 }
736                 if !logEnded {
737                         links.WriteString(fmt.Sprintf(
738                                 "1Next\toffset/%d\t%s\t%d%s",
739                                 offset+PageEntries,
740                                 cfg.GopherDomain, 70, CRLF,
741                         ))
742                 }
743
744                 fmt.Printf(
745                         "i%s (%d-%d)\t\tnull.host\t1%s",
746                         cfg.Title,
747                         offset,
748                         offset+PageEntries,
749                         CRLF,
750                 )
751                 if cfg.AboutURL != "" {
752                         fmt.Printf("iAbout: %s\t\tnull.host\t1%s", cfg.AboutURL, CRLF)
753                 }
754                 fmt.Print(links.String())
755                 fmt.Print(menu.String())
756                 fmt.Print("." + CRLF)
757         } else if sha1DigestRe.MatchString(selector) {
758                 commit, err := repo.CommitObject(plumbing.NewHash(selector[1:]))
759                 if err != nil {
760                         log.Fatalln(err)
761                 }
762                 fmt.Printf(
763                         "What: %s\nWhen: %s\n%s\n%s",
764                         commit.Hash.String(),
765                         commit.Author.When.Format(sgblog.WhenFmt),
766                         DashLine,
767                         commit.Message,
768                 )
769                 notesRaw := getNote(notesTree, commit.Hash)
770                 if len(notesRaw) > 0 {
771                         fmt.Printf("%s\nNote:\n%s\n", DashLine, string(notesRaw))
772                 }
773                 for i, comment := range parseComments(getNote(commentsTree, commit.Hash)) {
774                         fmt.Printf("%s\ncomment %d:\n%s\n", DashLine, i, comment)
775                 }
776         } else {
777                 log.Fatalln(errors.New("unknown selector"))
778         }
779 }
780
781 func main() {
782         if len(os.Args) == 3 && os.Args[1] == "-gopher" {
783                 serveGopher()
784         } else {
785                 serveHTTP()
786         }
787 }