internal/httpd/web.go
399 lines · 10657 bytes
1package httpd
2
3import (
4 "bytes"
5 "fmt"
6 "html/template"
7 "net/http"
8 "path"
9 "strings"
10 "time"
11
12 "github.com/alecthomas/chroma/v2/formatters/html"
13 "github.com/alecthomas/chroma/v2/lexers"
14 "github.com/alecthomas/chroma/v2/styles"
15 "github.com/yuin/goldmark"
16
17 "github.com/krazywarez/forge/internal/control"
18 "github.com/krazywarez/forge/internal/gitutil"
19 "github.com/krazywarez/forge/internal/sig"
20 "github.com/krazywarez/forge/internal/store"
21 "github.com/krazywarez/forge/internal/web"
22)
23
24const maxRenderBytes = 1 << 20 // largest blob rendered inline
25
26func (s *Server) render(w http.ResponseWriter, page string, data any) {
27 var buf bytes.Buffer
28 if err := web.Render(&buf, page, data); err != nil {
29 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
30 return
31 }
32 w.Header().Set("Content-Type", "text/html; charset=utf-8")
33 buf.WriteTo(w)
34}
35
36func (s *Server) siteName() string {
37 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
38 return strings.TrimSuffix(h, "/")
39}
40
41func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
42 w.Header().Set("Content-Type", "text/css; charset=utf-8")
43 w.Write(web.StyleCSS)
44}
45
46func (s *Server) index(w http.ResponseWriter, r *http.Request) {
47 repos, err := s.st.ListPublicRepos()
48 if err != nil {
49 http.Error(w, "internal error", http.StatusInternalServerError)
50 return
51 }
52 s.render(w, "index.html", struct {
53 Site string
54 Repos []store.Repo
55 }{s.siteName(), repos})
56}
57
58// repoPage is the shared context for repo-scoped pages.
59type repoPage struct {
60 Site string
61 Repo store.Repo
62 Ref string
63 CloneURL string
64 Dir string
65}
66
67// repoFor resolves the repo for a web request; false means 404 was sent.
68// The anonymous web sees public repos only — private and missing repos are
69// indistinguishable.
70func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
71 repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo"))
72 if !ok {
73 http.NotFound(w, r)
74 return repoPage{}, false
75 }
76 if ref == "" {
77 ref = repo.DefaultBranch
78 }
79 return repoPage{
80 Site: s.siteName(),
81 Repo: repo,
82 Ref: ref,
83 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
84 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
85 }, true
86}
87
88type crumb struct {
89 Name string
90 URL string
91}
92
93func crumbs(p repoPage, kind, filePath string) []crumb {
94 var cs []crumb
95 base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
96 acc := ""
97 for _, part := range strings.Split(filePath, "/") {
98 if part == "" {
99 continue
100 }
101 acc = path.Join(acc, part)
102 cs = append(cs, crumb{Name: part, URL: base + acc})
103 }
104 return cs
105}
106
107func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
108 p, ok := s.repoFor(w, r, "")
109 if !ok {
110 return
111 }
112 s.renderTree(w, r, p, "")
113}
114
115func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
116 p, ok := s.repoFor(w, r, r.PathValue("ref"))
117 if !ok {
118 return
119 }
120 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
121}
122
123func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
124 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
125 // Empty repo: render the page with no entries rather than 404.
126 s.render(w, "tree.html", struct {
127 repoPage
128 Crumbs []crumb
129 Prefix string
130 Entries []gitutil.TreeEntry
131 ReadmeHTML template.HTML
132 }{repoPage: p})
133 return
134 }
135 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
136 if err != nil {
137 http.NotFound(w, r)
138 return
139 }
140 prefix := ""
141 if dirPath != "" {
142 prefix = dirPath + "/"
143 }
144
145 var readmeHTML template.HTML
146 for _, e := range entries {
147 if e.Type != "blob" {
148 continue
149 }
150 lower := strings.ToLower(e.Name)
151 if lower == "readme" || lower == "readme.md" || lower == "readme.markdown" {
152 raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+e.Name, maxRenderBytes)
153 if err == nil {
154 var buf bytes.Buffer
155 if strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") {
156 // goldmark's default renderer drops raw HTML: safe.
157 if goldmark.Convert(raw, &buf) == nil {
158 readmeHTML = template.HTML(buf.String())
159 }
160 } else {
161 readmeHTML = template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
162 }
163 }
164 break
165 }
166 }
167
168 s.render(w, "tree.html", struct {
169 repoPage
170 Crumbs []crumb
171 Prefix string
172 Entries []gitutil.TreeEntry
173 ReadmeHTML template.HTML
174 }{p, crumbs(p, "tree", dirPath), prefix, entries, readmeHTML})
175}
176
177func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
178 p, ok := s.repoFor(w, r, r.PathValue("ref"))
179 if !ok {
180 return
181 }
182 filePath := strings.Trim(r.PathValue("path"), "/")
183 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
184 if err != nil {
185 http.NotFound(w, r)
186 return
187 }
188 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
189
190 var codeHTML template.HTML
191 if !binary {
192 codeHTML = highlight(filePath, data)
193 }
194 cs := crumbs(p, "blob", filePath)
195 base := ""
196 if len(cs) > 0 {
197 base = cs[len(cs)-1].Name
198 cs = cs[:len(cs)-1]
199 }
200 s.render(w, "blob.html", struct {
201 repoPage
202 Crumbs []crumb
203 Base string
204 Path string
205 Binary bool
206 Size int
207 CodeHTML template.HTML
208 }{p, cs, base, filePath, binary, len(data), codeHTML})
209}
210
211func highlight(filePath string, data []byte) template.HTML {
212 lexer := lexers.Match(filePath)
213 if lexer == nil {
214 lexer = lexers.Fallback
215 }
216 style := styles.Get("friendly")
217 formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false))
218 iterator, err := lexer.Tokenise(nil, string(data))
219 if err != nil {
220 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
221 }
222 var buf bytes.Buffer
223 if err := formatter.Format(&buf, style, iterator); err != nil {
224 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
225 }
226 return template.HTML(buf.String())
227}
228
229func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
230 p, ok := s.repoFor(w, r, r.PathValue("ref"))
231 if !ok {
232 return
233 }
234 filePath := strings.Trim(r.PathValue("path"), "/")
235 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
236 if err != nil {
237 http.NotFound(w, r)
238 return
239 }
240 // Serve inert: never let repo content execute in the forge's origin.
241 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
242 w.Header().Set("X-Content-Type-Options", "nosniff")
243 w.Write(data)
244}
245
246type sigView struct {
247 State string
248 Signer string
249 Fingerprint string
250}
251
252func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
253 raw, err := gitutil.ReadCommit(dir, sha)
254 if err != nil {
255 return sigView{State: "unsigned"}, nil
256 }
257 parsed, err := sig.ParseCommit(raw)
258 if err != nil {
259 return sigView{State: "unsigned"}, nil
260 }
261 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
262 if err != nil {
263 return sigView{State: "unsigned"}, parsed
264 }
265 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
266 if res.SignerUserID != 0 {
267 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
268 v.Signer = u.Username
269 }
270 }
271 return v, parsed
272}
273
274func (s *Server) log(w http.ResponseWriter, r *http.Request) {
275 ref := r.PathValue("ref")
276 p, ok := s.repoFor(w, r, ref)
277 if !ok {
278 return
279 }
280 const pageSize = 50
281 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
282 if err != nil {
283 http.NotFound(w, r)
284 return
285 }
286 next := ""
287 if len(shas) > pageSize {
288 next = shas[pageSize]
289 shas = shas[:pageSize]
290 }
291 type row struct {
292 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
293 Sig sigView
294 }
295 var rows []row
296 for _, sha := range shas {
297 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
298 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
299 if parsed != nil {
300 rw.Subject = parsed.Subject
301 rw.AuthorName = parsed.AuthorName
302 rw.AuthorEmail = parsed.AuthorEmail
303 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
304 }
305 rows = append(rows, rw)
306 }
307 s.render(w, "log.html", struct {
308 repoPage
309 Commits []row
310 NextSHA string
311 }{p, rows, next})
312}
313
314func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
315 p, ok := s.repoFor(w, r, "")
316 if !ok {
317 return
318 }
319 sha := r.PathValue("sha")
320 full, err := gitutil.ResolveRef(p.Dir, sha)
321 if err != nil {
322 http.NotFound(w, r)
323 return
324 }
325 v, parsed := s.sigFor(p.Repo, p.Dir, full)
326 if parsed == nil {
327 http.NotFound(w, r)
328 return
329 }
330 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
331 type diffLine struct {
332 Class string
333 Text string
334 }
335 var lines []diffLine
336 for _, l := range strings.Split(patch, "\n") {
337 class := ""
338 switch {
339 case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
340 class = "meta"
341 case strings.HasPrefix(l, "@@"):
342 class = "hunk"
343 case strings.HasPrefix(l, "+"):
344 class = "add"
345 case strings.HasPrefix(l, "-"):
346 class = "del"
347 }
348 lines = append(lines, diffLine{class, l})
349 }
350 committerEmail := ""
351 if parsed.CommitterEmail != parsed.AuthorEmail {
352 committerEmail = parsed.CommitterEmail
353 }
354 msg := ""
355 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
356 msg = string(parsed.Payload[i+2:])
357 }
358 s.render(w, "commit.html", struct {
359 repoPage
360 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
361 Sig sigView
362 DiffLines []diffLine
363 }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
364 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines})
365}
366
367func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
368 p, ok := s.repoFor(w, r, "")
369 if !ok {
370 return
371 }
372 branches, _ := gitutil.Refs(p.Dir, "heads")
373 tags, _ := gitutil.Refs(p.Dir, "tags")
374 s.render(w, "refs.html", struct {
375 repoPage
376 Branches, Tags []gitutil.Ref
377 }{p, branches, tags})
378}
379
380func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
381 p, ok := s.repoFor(w, r, "")
382 if !ok {
383 return
384 }
385 file := r.PathValue("file")
386 ref, ok := strings.CutSuffix(file, ".tar.gz")
387 if !ok {
388 http.NotFound(w, r)
389 return
390 }
391 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
392 http.NotFound(w, r)
393 return
394 }
395 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
396 w.Header().Set("Content-Type", "application/gzip")
397 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
398 gitutil.Archive(p.Dir, ref, prefix, w)
399}