1 // SGBlog -- Git-backed CGI/UCSPI blogging/phlogging/gemlogging engine
2 // Copyright (C) 2020-2025 Sergey Matveev <stargrave@stargrave.org>
4 // This program is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU Affero General Public License as
6 // published by the Free Software Foundation, version 3 of the License.
8 // This program is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 // GNU General Public License for more details.
13 // You should have received a copy of the GNU Affero General Public License
14 // along with this program. If not, see <http://www.gnu.org/licenses/>.
30 "github.com/go-git/go-git/v5"
31 "github.com/go-git/go-git/v5/plumbing"
32 "github.com/go-git/go-git/v5/plumbing/object"
33 "github.com/vorlif/spreak"
34 "go.stargrave.org/sgblog"
38 //go:embed gopher-menu.tmpl
39 TmplGopherMenuRaw string
40 //go:embed gopher-entry.tmpl
41 TmplGopherEntryRaw string
43 TmplGopherMenu = template.Must(template.New("gopher-menu").Parse(TmplGopherMenuRaw))
44 TmplGopherEntry = template.Must(template.New("gopher-entry").Parse(TmplGopherEntryRaw))
47 type TableMenuEntry struct {
52 func serveGopher(cfgPath string) {
53 cfg, err := readCfg(cfgPath)
57 if cfg.GopherDomain == "" {
58 log.Fatalln("GopherDomain is not configured")
60 initLocalizer(cfg.Lang)
62 headHash, err := initRepo(cfg)
67 scanner := bufio.NewScanner(io.LimitReader(os.Stdin, 1<<8))
69 log.Fatalln(errors.New("no CRLF found"))
71 selector := scanner.Text()
75 selectorParts := strings.Split(selector, "/")
76 if strings.HasPrefix(selector, "URL:") {
77 selector = selector[len("URL:"):]
78 fmt.Printf(`<!DOCTYPE html>
81 <meta http-equiv="Refresh" content="1; url=%s" />
82 <title>Redirect to non-gopher URL</title>
85 Redirecting to <a href="%s">%s</a>...
88 `, selector, selector, selector)
89 } else if sha1DigestRe.MatchString(selector) {
90 commit, err := repo.CommitObject(plumbing.NewHash(selector[1:]))
94 err = TmplGopherEntry.Execute(os.Stdout, struct {
106 When: commit.Author.When.Format(sgblog.WhenFmt),
108 Note: string(sgblog.GetNote(repo, notesTree, commit.Hash)),
109 Images: listImgs(cfg, commit.Hash),
110 Comments: sgblog.ParseComments(sgblog.GetNote(repo, commentsTree, commit.Hash)),
111 Topics: sgblog.ParseTopics(sgblog.GetNote(repo, topicsTree, commit.Hash)),
116 } else if len(selectorParts) > 1 &&
117 selectorParts[len(selectorParts)-2] == "offset" {
118 offset, err := strconv.Atoi(selectorParts[len(selectorParts)-1])
122 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
126 topicsCache, err := getTopicsCache(cfg, repoLog)
130 repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
136 if len(selectorParts) == 3 {
137 topic = selectorParts[0]
139 var commits CommitIterNext
142 if _, err = repoLog.Next(); err != nil {
148 hashes := topicsCache[topic]
150 log.Fatalln(errors.New("no posts with that topic"))
152 if len(hashes) > offset {
153 hashes = hashes[offset:]
155 commits = &HashesIter{hashes}
159 entries := make([]TableMenuEntry, 0, PageEntries)
160 for range PageEntries {
161 var commit *object.Commit
162 commit, err = commits.Next()
167 lines := msgSplit(commit.Message)
168 entries = append(entries, TableMenuEntry{
173 offsetPrev := max(offset-PageEntries, 0)
174 err = TmplGopherMenu.Execute(os.Stdout, struct {
182 Entries []TableMenuEntry
189 OffsetPrev: offsetPrev,
190 OffsetNext: offset + PageEntries,
193 Topics: topicsCache.Topics(),
199 log.Fatalln(errors.New("unknown selector"))