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