internal/httpd/web.go
513 lines · 13272 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 diffLine struct {
248 Class string
249 Text string
250}
251
252func classifyDiff(patch string) []diffLine {
253 var lines []diffLine
254 for _, l := range strings.Split(patch, "\n") {
255 class := ""
256 switch {
257 case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
258 class = "meta"
259 case strings.HasPrefix(l, "@@"):
260 class = "hunk"
261 case strings.HasPrefix(l, "+"):
262 class = "add"
263 case strings.HasPrefix(l, "-"):
264 class = "del"
265 }
266 lines = append(lines, diffLine{class, l})
267 }
268 return lines
269}
270
271type sigView struct {
272 State string
273 Signer string
274 Fingerprint string
275}
276
277func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
278 raw, err := gitutil.ReadCommit(dir, sha)
279 if err != nil {
280 return sigView{State: "unsigned"}, nil
281 }
282 parsed, err := sig.ParseCommit(raw)
283 if err != nil {
284 return sigView{State: "unsigned"}, nil
285 }
286 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
287 if err != nil {
288 return sigView{State: "unsigned"}, parsed
289 }
290 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
291 if res.SignerUserID != 0 {
292 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
293 v.Signer = u.Username
294 }
295 }
296 return v, parsed
297}
298
299func (s *Server) log(w http.ResponseWriter, r *http.Request) {
300 ref := r.PathValue("ref")
301 p, ok := s.repoFor(w, r, ref)
302 if !ok {
303 return
304 }
305 const pageSize = 50
306 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
307 if err != nil {
308 http.NotFound(w, r)
309 return
310 }
311 next := ""
312 if len(shas) > pageSize {
313 next = shas[pageSize]
314 shas = shas[:pageSize]
315 }
316 type row struct {
317 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
318 Sig sigView
319 }
320 var rows []row
321 for _, sha := range shas {
322 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
323 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
324 if parsed != nil {
325 rw.Subject = parsed.Subject
326 rw.AuthorName = parsed.AuthorName
327 rw.AuthorEmail = parsed.AuthorEmail
328 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
329 }
330 rows = append(rows, rw)
331 }
332 s.render(w, "log.html", struct {
333 repoPage
334 Commits []row
335 NextSHA string
336 }{p, rows, next})
337}
338
339func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
340 p, ok := s.repoFor(w, r, "")
341 if !ok {
342 return
343 }
344 sha := r.PathValue("sha")
345 full, err := gitutil.ResolveRef(p.Dir, sha)
346 if err != nil {
347 http.NotFound(w, r)
348 return
349 }
350 v, parsed := s.sigFor(p.Repo, p.Dir, full)
351 if parsed == nil {
352 http.NotFound(w, r)
353 return
354 }
355 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
356 lines := classifyDiff(patch)
357 committerEmail := ""
358 if parsed.CommitterEmail != parsed.AuthorEmail {
359 committerEmail = parsed.CommitterEmail
360 }
361 msg := ""
362 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
363 msg = string(parsed.Payload[i+2:])
364 }
365 s.render(w, "commit.html", struct {
366 repoPage
367 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
368 Sig sigView
369 DiffLines []diffLine
370 }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
371 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines})
372}
373
374func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
375 p, ok := s.repoFor(w, r, "")
376 if !ok {
377 return
378 }
379 state := r.URL.Query().Get("state")
380 if state != "closed" && state != "all" {
381 state = "open"
382 }
383 issues, err := s.st.ListIssues(p.Repo.ID, state)
384 if err != nil {
385 http.Error(w, "internal error", http.StatusInternalServerError)
386 return
387 }
388 s.render(w, "issues.html", struct {
389 repoPage
390 State string
391 Issues []store.Issue
392 }{p, state, issues})
393}
394
395func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
396 p, ok := s.repoFor(w, r, "")
397 if !ok {
398 return
399 }
400 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
401 if err != nil {
402 http.NotFound(w, r)
403 return
404 }
405 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
406 if err != nil {
407 http.NotFound(w, r)
408 return
409 }
410 comments, err := s.st.ListIssueComments(iss.ID)
411 if err != nil {
412 http.Error(w, "internal error", http.StatusInternalServerError)
413 return
414 }
415 s.render(w, "issue.html", struct {
416 repoPage
417 Issue store.Issue
418 Comments []store.IssueComment
419 }{p, iss, comments})
420}
421
422func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
423 p, ok := s.repoFor(w, r, "")
424 if !ok {
425 return
426 }
427 state := r.URL.Query().Get("state")
428 if state == "" {
429 state = "open"
430 }
431 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
432 if !valid[state] {
433 state = "open"
434 }
435 mrs, err := s.st.ListMRs(p.Repo.ID, state)
436 if err != nil {
437 http.Error(w, "internal error", http.StatusInternalServerError)
438 return
439 }
440 s.render(w, "mrs.html", struct {
441 repoPage
442 State string
443 MRs []store.MR
444 }{p, state, mrs})
445}
446
447func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
448 p, ok := s.repoFor(w, r, "")
449 if !ok {
450 return
451 }
452 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
453 if err != nil {
454 http.NotFound(w, r)
455 return
456 }
457 m, err := s.st.MRByNumber(p.Repo.ID, n)
458 if err != nil {
459 http.NotFound(w, r)
460 return
461 }
462 comments, _ := s.st.ListMRComments(m.ID)
463 reviews, _ := s.st.ListMRReviews(m.ID)
464
465 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
466 var lines []diffLine
467 if base, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
468 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
469 lines = classifyDiff(patch)
470 }
471 }
472 s.render(w, "mr.html", struct {
473 repoPage
474 MR store.MR
475 Comments []store.IssueComment
476 Reviews []store.MRReview
477 DiffLines []diffLine
478 }{p, m, comments, reviews, lines})
479}
480
481func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
482 p, ok := s.repoFor(w, r, "")
483 if !ok {
484 return
485 }
486 branches, _ := gitutil.Refs(p.Dir, "heads")
487 tags, _ := gitutil.Refs(p.Dir, "tags")
488 s.render(w, "refs.html", struct {
489 repoPage
490 Branches, Tags []gitutil.Ref
491 }{p, branches, tags})
492}
493
494func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
495 p, ok := s.repoFor(w, r, "")
496 if !ok {
497 return
498 }
499 file := r.PathValue("file")
500 ref, ok := strings.CutSuffix(file, ".tar.gz")
501 if !ok {
502 http.NotFound(w, r)
503 return
504 }
505 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
506 http.NotFound(w, r)
507 return
508 }
509 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
510 w.Header().Set("Content-Type", "application/gzip")
511 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
512 gitutil.Archive(p.Dir, ref, prefix, w)
513}