]> Sergey Matveev's repositories - sgblog.git/blob - cmd/sgblog/gemini.go
Serve /img/ on gemini
[sgblog.git] / cmd / sgblog / gemini.go
1 /*
2 SGBlog -- Git-backed CGI/UCSPI blogging/phlogging/gemlogging engine
3 Copyright (C) 2020-2023 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         "bufio"
22         _ "embed"
23         "errors"
24         "fmt"
25         "io"
26         "log"
27         "net/url"
28         "os"
29         "path"
30         "strconv"
31         "strings"
32         "text/template"
33
34         "github.com/go-git/go-git/v5"
35         "github.com/go-git/go-git/v5/plumbing"
36         "github.com/go-git/go-git/v5/plumbing/object"
37         "github.com/vorlif/spreak"
38         "go.stargrave.org/sgblog"
39 )
40
41 var (
42         //go:embed gemini-menu.tmpl
43         TmplGemMenuRaw string
44         //go:embed gemini-entry.tmpl
45         TmplGemEntryRaw string
46
47         TmplGemMenu  = template.Must(template.New("menu").Parse(TmplGemMenuRaw))
48         TmplGemEntry = template.Must(template.New("entry").Parse(TmplGemEntryRaw))
49 )
50
51 func makeGemErr(err error) {
52         fmt.Print("59 " + err.Error() + "\r\n")
53         log.Fatalln(err)
54 }
55
56 func serveGemini(cfgPath string) {
57         cfg, err := readCfg(cfgPath)
58         if err != nil {
59                 log.Fatalln(err)
60         }
61         initLocalizer(cfg.Lang)
62
63         headHash, err := initRepo(cfg)
64         if err != nil {
65                 log.Fatalln(err)
66         }
67
68         scanner := bufio.NewScanner(io.LimitReader(os.Stdin, 2+1<<10))
69         if !scanner.Scan() {
70                 makeGemErr(errors.New("no CRLF found"))
71         }
72         urlRaw := scanner.Text()
73         u, err := url.Parse(urlRaw)
74         if err != nil {
75                 makeGemErr(err)
76         }
77         if u.Scheme != "gemini" {
78                 makeGemErr(errors.New("only gemini:// is supported" + u.String()))
79         }
80
81         if u.Path == "/" {
82                 offset := 0
83                 if offsetRaw, exists := u.Query()["offset"]; exists {
84                         offset, err = strconv.Atoi(offsetRaw[0])
85                         if err != nil {
86                                 makeGemErr(err)
87                         }
88                 }
89                 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
90                 if err != nil {
91                         makeGemErr(err)
92                 }
93                 topicsCache, err := getTopicsCache(cfg, repoLog)
94                 if err != nil {
95                         makeGemErr(err)
96                 }
97                 repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
98                 if err != nil {
99                         makeGemErr(err)
100                 }
101
102                 commitN := 0
103                 var commits CommitIterNext
104                 var topic string
105                 if t, exists := u.Query()["topic"]; exists {
106                         topic = t[0]
107                         hashes := topicsCache[topic]
108                         if hashes == nil {
109                                 makeGemErr(errors.New("no posts with that topic"))
110                         }
111                         if len(hashes) > offset {
112                                 hashes = hashes[offset:]
113                                 commitN += offset
114                         }
115                         commits = &HashesIter{hashes}
116                 } else {
117                         for i := 0; i < offset; i++ {
118                                 if _, err = repoLog.Next(); err != nil {
119                                         break
120                                 }
121                                 commitN++
122                         }
123                         commits = repoLog
124                 }
125
126                 logEnded := false
127                 entries := make([]TableMenuEntry, 0, PageEntries)
128                 for i := 0; i < PageEntries; i++ {
129                         commit, err := commits.Next()
130                         if err != nil {
131                                 logEnded = true
132                                 break
133                         }
134                         lines := msgSplit(commit.Message)
135                         entries = append(entries, TableMenuEntry{
136                                 Commit:    commit,
137                                 Title:     lines[0],
138                                 LinesNum:  len(lines) - 2,
139                                 ImagesNum: len(listImgs(cfg, commit.Hash)),
140                                 CommentsNum: len(sgblog.ParseComments(sgblog.GetNote(
141                                         repo, commentsTree, commit.Hash,
142                                 ))),
143                                 Topics: sgblog.ParseTopics(sgblog.GetNote(
144                                         repo, topicsTree, commit.Hash,
145                                 )),
146                         })
147                 }
148                 offsetPrev := offset - PageEntries
149                 if offsetPrev < 0 {
150                         offsetPrev = 0
151                 }
152                 err = TmplGemMenu.Execute(os.Stdout, struct {
153                         T          *spreak.Localizer
154                         Cfg        *Cfg
155                         Topic      string
156                         Offset     int
157                         OffsetPrev int
158                         OffsetNext int
159                         LogEnded   bool
160                         Entries    []TableMenuEntry
161                         Topics     []string
162                         Version    string
163                 }{
164                         T:          localizer,
165                         Cfg:        cfg,
166                         Topic:      topic,
167                         Offset:     offset,
168                         OffsetPrev: offsetPrev,
169                         OffsetNext: offset + PageEntries,
170                         LogEnded:   logEnded,
171                         Entries:    entries,
172                         Topics:     topicsCache.Topics(),
173                         Version:    sgblog.Version,
174                 })
175                 if err != nil {
176                         log.Fatalln(err)
177                 }
178         } else if sha1DigestRe.MatchString(u.Path[1:]) {
179                 commit, err := repo.CommitObject(plumbing.NewHash(u.Path[1:]))
180                 if err != nil {
181                         log.Fatalln(err)
182                 }
183                 title := msgSplit(commit.Message)[0]
184                 err = TmplGemEntry.Execute(os.Stdout, struct {
185                         T            *spreak.Localizer
186                         Title        string
187                         Commit       *object.Commit
188                         When         string
189                         Cfg          *Cfg
190                         Note         string
191                         Images       []Img
192                         Comments     []string
193                         Topics       []string
194                         Version      string
195                         TitleEscaped string
196                 }{
197                         T:        localizer,
198                         Title:    title,
199                         Commit:   commit,
200                         When:     commit.Author.When.Format(sgblog.WhenFmt),
201                         Cfg:      cfg,
202                         Note:     string(sgblog.GetNote(repo, notesTree, commit.Hash)),
203                         Images:   listImgs(cfg, commit.Hash),
204                         Comments: sgblog.ParseComments(sgblog.GetNote(repo, commentsTree, commit.Hash)),
205                         Topics:   sgblog.ParseTopics(sgblog.GetNote(repo, topicsTree, commit.Hash)),
206                         Version:  sgblog.Version,
207                         TitleEscaped: url.PathEscape(fmt.Sprintf(
208                                 "Re: %s (%s)", title, commit.Hash,
209                         )),
210                 })
211                 if err != nil {
212                         log.Fatalln(err)
213                 }
214         } else if strings.HasPrefix(u.Path, "/img/") {
215                 pth := strings.TrimPrefix(u.Path, "/img/")
216                 if strings.Contains(pth, "..") {
217                         log.Fatalln("unacceptable double dots")
218                 }
219                 typ := ImgTypes[path.Ext(pth)]
220                 if typ == "" {
221                         typ = "application/octet-stream"
222                 }
223                 fd, err := os.Open(path.Join(cfg.ImgPath, pth))
224                 if err != nil {
225                         log.Fatalln(err)
226                 }
227                 bw := bufio.NewWriter(os.Stdout)
228                 bw.Write([]byte("20 " + typ + "\r\n"))
229                 io.Copy(bw, bufio.NewReader(fd))
230                 fd.Close()
231                 bw.Flush()
232         } else {
233                 makeGemErr(errors.New("unknown URL action"))
234         }
235 }