internal/httpd/web.go
599 lines · 15621 bytes
1package httpd
2
3import (
4 "bytes"
5 "fmt"
6
7 "gitbay.org/gitbay/internal/policy"
8 "html/template"
9 "net/http"
10 "path"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/alecthomas/chroma/v2/formatters/html"
16 "github.com/alecthomas/chroma/v2/lexers"
17 "github.com/alecthomas/chroma/v2/styles"
18 "github.com/microcosm-cc/bluemonday"
19 "github.com/niklasfasching/go-org/org"
20 "github.com/yuin/goldmark"
21
22 "gitbay.org/gitbay/internal/control"
23 "gitbay.org/gitbay/internal/gitutil"
24 "gitbay.org/gitbay/internal/sig"
25 "gitbay.org/gitbay/internal/store"
26 "gitbay.org/gitbay/internal/web"
27)
28
29const maxRenderBytes = 1 << 20 // largest blob rendered inline
30
31func (s *Server) render(w http.ResponseWriter, page string, data any) {
32 var buf bytes.Buffer
33 if err := web.Render(&buf, page, data); err != nil {
34 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
35 return
36 }
37 w.Header().Set("Content-Type", "text/html; charset=utf-8")
38 buf.WriteTo(w)
39}
40
41func (s *Server) siteName() string {
42 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
43 return strings.TrimSuffix(h, "/")
44}
45
46func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
47 w.Header().Set("Content-Type", "text/css; charset=utf-8")
48 w.Write(web.StyleCSS)
49}
50
51func (s *Server) index(w http.ResponseWriter, r *http.Request) {
52 repos, err := s.st.ListPublicRepos()
53 if err != nil {
54 http.Error(w, "internal error", http.StatusInternalServerError)
55 return
56 }
57 var viewer store.User
58 var mine []store.Repo
59 if s.cfg.Web.Mode == "accounts" {
60 if viewer = s.viewer(r); viewer.ID != 0 {
61 all, err := s.st.ListReposForUser(viewer.ID)
62 if err == nil {
63 for _, rp := range all {
64 if rp.Visibility == "private" {
65 mine = append(mine, rp)
66 }
67 }
68 }
69 }
70 }
71 s.render(w, "index.html", struct {
72 Site string
73 Viewer string
74 Repos []store.Repo
75 Mine []store.Repo
76 }{s.siteName(), viewer.Username, repos, mine})
77}
78
79// repoPage is the shared context for repo-scoped pages.
80type repoPage struct {
81 Site string
82 Viewer string
83 Repo store.Repo
84 Ref string
85 CloneURL string
86 Dir string
87}
88
89// repoFor resolves the repo for a web request; false means 404 was sent.
90// Anonymous visitors see public repos only; in accounts mode a logged-in
91// viewer additionally sees repos their grants allow. Private and missing
92// repos are indistinguishable either way.
93func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
94 var repo store.Repo
95 var viewer store.User
96 if s.cfg.Web.Mode == "accounts" {
97 viewer = s.viewer(r)
98 }
99 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
100 ok := err == nil
101 if ok {
102 grant := ""
103 if viewer.ID != 0 {
104 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
105 }
106 ok = policyCanRead(viewer, repo, grant)
107 }
108 if !ok {
109 http.NotFound(w, r)
110 return repoPage{}, false
111 }
112 if ref == "" {
113 ref = repo.DefaultBranch
114 }
115 return repoPage{
116 Site: s.siteName(),
117 Viewer: viewer.Username,
118 Repo: repo,
119 Ref: ref,
120 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
121 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
122 }, true
123}
124
125type crumb struct {
126 Name string
127 URL string
128}
129
130func crumbs(p repoPage, kind, filePath string) []crumb {
131 var cs []crumb
132 base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
133 acc := ""
134 for _, part := range strings.Split(filePath, "/") {
135 if part == "" {
136 continue
137 }
138 acc = path.Join(acc, part)
139 cs = append(cs, crumb{Name: part, URL: base + acc})
140 }
141 return cs
142}
143
144func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
145 p, ok := s.repoFor(w, r, "")
146 if !ok {
147 return
148 }
149 s.renderTree(w, r, p, "")
150}
151
152func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
153 p, ok := s.repoFor(w, r, r.PathValue("ref"))
154 if !ok {
155 return
156 }
157 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
158}
159
160func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
161 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
162 // Empty repo: render the page with no entries rather than 404.
163 s.render(w, "tree.html", struct {
164 repoPage
165 Crumbs []crumb
166 Prefix string
167 Entries []gitutil.TreeEntry
168 ReadmeHTML template.HTML
169 }{repoPage: p})
170 return
171 }
172 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
173 if err != nil {
174 http.NotFound(w, r)
175 return
176 }
177 prefix := ""
178 if dirPath != "" {
179 prefix = dirPath + "/"
180 }
181
182 var readmeHTML template.HTML
183 if name := pickReadme(entries); name != "" {
184 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+name, maxRenderBytes); err == nil {
185 readmeHTML = renderReadme(name, raw)
186 }
187 }
188
189 s.render(w, "tree.html", struct {
190 repoPage
191 Crumbs []crumb
192 Prefix string
193 Entries []gitutil.TreeEntry
194 ReadmeHTML template.HTML
195 }{p, crumbs(p, "tree", dirPath), prefix, entries, readmeHTML})
196}
197
198func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
199 p, ok := s.repoFor(w, r, r.PathValue("ref"))
200 if !ok {
201 return
202 }
203 filePath := strings.Trim(r.PathValue("path"), "/")
204 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
205 if err != nil {
206 http.NotFound(w, r)
207 return
208 }
209 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
210
211 var codeHTML template.HTML
212 if !binary {
213 codeHTML = highlight(filePath, data)
214 }
215 cs := crumbs(p, "blob", filePath)
216 base := ""
217 if len(cs) > 0 {
218 base = cs[len(cs)-1].Name
219 cs = cs[:len(cs)-1]
220 }
221 s.render(w, "blob.html", struct {
222 repoPage
223 Crumbs []crumb
224 Base string
225 Path string
226 Binary bool
227 Size int
228 CodeHTML template.HTML
229 }{p, cs, base, filePath, binary, len(data), codeHTML})
230}
231
232func highlight(filePath string, data []byte) template.HTML {
233 lexer := lexers.Match(filePath)
234 if lexer == nil {
235 lexer = lexers.Fallback
236 }
237 style := styles.Get("friendly")
238 formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false))
239 iterator, err := lexer.Tokenise(nil, string(data))
240 if err != nil {
241 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
242 }
243 var buf bytes.Buffer
244 if err := formatter.Format(&buf, style, iterator); err != nil {
245 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
246 }
247 return template.HTML(buf.String())
248}
249
250func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
251 p, ok := s.repoFor(w, r, r.PathValue("ref"))
252 if !ok {
253 return
254 }
255 filePath := strings.Trim(r.PathValue("path"), "/")
256 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
257 if err != nil {
258 http.NotFound(w, r)
259 return
260 }
261 // Serve inert: never let repo content execute in the forge's origin.
262 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
263 w.Header().Set("X-Content-Type-Options", "nosniff")
264 w.Write(data)
265}
266
267// readmeRank orders competing README files: richer renderers win.
268var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
269
270// pickReadme returns the best README-ish blob in a tree listing: any file
271// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
272// we can render richly.
273func pickReadme(entries []gitutil.TreeEntry) string {
274 best, bestRank := "", 1<<30
275 for _, e := range entries {
276 if e.Type != "blob" {
277 continue
278 }
279 lower := strings.ToLower(e.Name)
280 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
281 continue
282 }
283 rank, ok := readmeRank[path.Ext(lower)]
284 if !ok {
285 rank = 10 // plaintext fallback
286 }
287 if rank < bestRank {
288 best, bestRank = e.Name, rank
289 }
290 }
291 return best
292}
293
294// ugcPolicy sanitizes rendered repo content before it enters the forge's
295// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
296// output and repo-authored HTML are not.
297var ugcPolicy = bluemonday.UGCPolicy()
298
299// renderReadme renders a README by extension: markdown, org-mode, and
300// (sanitized) HTML richly; everything else as escaped plaintext.
301func renderReadme(name string, raw []byte) template.HTML {
302 plain := func() template.HTML {
303 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
304 }
305 if gitutil.IsBinary(raw) {
306 return ""
307 }
308 switch path.Ext(strings.ToLower(name)) {
309 case ".md", ".markdown":
310 var buf bytes.Buffer
311 if goldmark.Convert(raw, &buf) != nil {
312 return plain()
313 }
314 return template.HTML(buf.String())
315 case ".org":
316 doc := org.New().Parse(bytes.NewReader(raw), name)
317 html, err := doc.Write(org.NewHTMLWriter())
318 if err != nil {
319 return plain()
320 }
321 return template.HTML(ugcPolicy.Sanitize(html))
322 case ".html", ".htm":
323 return template.HTML(ugcPolicy.Sanitize(string(raw)))
324 default:
325 return plain()
326 }
327}
328
329type diffLine struct {
330 Class string
331 Text string
332}
333
334func classifyDiff(patch string) []diffLine {
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 return lines
351}
352
353type sigView struct {
354 State string
355 Signer string
356 Fingerprint string
357}
358
359func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
360 raw, err := gitutil.ReadCommit(dir, sha)
361 if err != nil {
362 return sigView{State: "unsigned"}, nil
363 }
364 parsed, err := sig.ParseCommit(raw)
365 if err != nil {
366 return sigView{State: "unsigned"}, nil
367 }
368 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
369 if err != nil {
370 return sigView{State: "unsigned"}, parsed
371 }
372 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
373 if res.SignerUserID != 0 {
374 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
375 v.Signer = u.Username
376 }
377 }
378 return v, parsed
379}
380
381func (s *Server) log(w http.ResponseWriter, r *http.Request) {
382 ref := r.PathValue("ref")
383 p, ok := s.repoFor(w, r, ref)
384 if !ok {
385 return
386 }
387 const pageSize = 50
388 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
389 if err != nil {
390 http.NotFound(w, r)
391 return
392 }
393 next := ""
394 if len(shas) > pageSize {
395 next = shas[pageSize]
396 shas = shas[:pageSize]
397 }
398 type row struct {
399 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
400 Sig sigView
401 }
402 var rows []row
403 for _, sha := range shas {
404 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
405 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
406 if parsed != nil {
407 rw.Subject = parsed.Subject
408 rw.AuthorName = parsed.AuthorName
409 rw.AuthorEmail = parsed.AuthorEmail
410 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
411 }
412 rows = append(rows, rw)
413 }
414 s.render(w, "log.html", struct {
415 repoPage
416 Commits []row
417 NextSHA string
418 }{p, rows, next})
419}
420
421func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
422 p, ok := s.repoFor(w, r, "")
423 if !ok {
424 return
425 }
426 sha := r.PathValue("sha")
427 full, err := gitutil.ResolveRef(p.Dir, sha)
428 if err != nil {
429 http.NotFound(w, r)
430 return
431 }
432 v, parsed := s.sigFor(p.Repo, p.Dir, full)
433 if parsed == nil {
434 http.NotFound(w, r)
435 return
436 }
437 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
438 lines := classifyDiff(patch)
439 committerEmail := ""
440 if parsed.CommitterEmail != parsed.AuthorEmail {
441 committerEmail = parsed.CommitterEmail
442 }
443 msg := ""
444 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
445 msg = string(parsed.Payload[i+2:])
446 }
447 s.render(w, "commit.html", struct {
448 repoPage
449 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
450 Sig sigView
451 DiffLines []diffLine
452 }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
453 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines})
454}
455
456func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
457 p, ok := s.repoFor(w, r, "")
458 if !ok {
459 return
460 }
461 state := r.URL.Query().Get("state")
462 if state != "closed" && state != "all" {
463 state = "open"
464 }
465 issues, err := s.st.ListIssues(p.Repo.ID, state)
466 if err != nil {
467 http.Error(w, "internal error", http.StatusInternalServerError)
468 return
469 }
470 s.render(w, "issues.html", struct {
471 repoPage
472 State string
473 Issues []store.Issue
474 }{p, state, issues})
475}
476
477func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
478 p, ok := s.repoFor(w, r, "")
479 if !ok {
480 return
481 }
482 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
483 if err != nil {
484 http.NotFound(w, r)
485 return
486 }
487 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
488 if err != nil {
489 http.NotFound(w, r)
490 return
491 }
492 comments, err := s.st.ListIssueComments(iss.ID)
493 if err != nil {
494 http.Error(w, "internal error", http.StatusInternalServerError)
495 return
496 }
497 s.render(w, "issue.html", struct {
498 repoPage
499 Issue store.Issue
500 Comments []store.IssueComment
501 }{p, iss, comments})
502}
503
504func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
505 p, ok := s.repoFor(w, r, "")
506 if !ok {
507 return
508 }
509 state := r.URL.Query().Get("state")
510 if state == "" {
511 state = "open"
512 }
513 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
514 if !valid[state] {
515 state = "open"
516 }
517 mrs, err := s.st.ListMRs(p.Repo.ID, state)
518 if err != nil {
519 http.Error(w, "internal error", http.StatusInternalServerError)
520 return
521 }
522 s.render(w, "mrs.html", struct {
523 repoPage
524 State string
525 MRs []store.MR
526 }{p, state, mrs})
527}
528
529func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
530 p, ok := s.repoFor(w, r, "")
531 if !ok {
532 return
533 }
534 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
535 if err != nil {
536 http.NotFound(w, r)
537 return
538 }
539 m, err := s.st.MRByNumber(p.Repo.ID, n)
540 if err != nil {
541 http.NotFound(w, r)
542 return
543 }
544 comments, _ := s.st.ListMRComments(m.ID)
545 reviews, _ := s.st.ListMRReviews(m.ID)
546
547 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
548 var lines []diffLine
549 if base, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
550 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
551 lines = classifyDiff(patch)
552 }
553 }
554 s.render(w, "mr.html", struct {
555 repoPage
556 MR store.MR
557 Comments []store.IssueComment
558 Reviews []store.MRReview
559 DiffLines []diffLine
560 }{p, m, comments, reviews, lines})
561}
562
563func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
564 p, ok := s.repoFor(w, r, "")
565 if !ok {
566 return
567 }
568 branches, _ := gitutil.Refs(p.Dir, "heads")
569 tags, _ := gitutil.Refs(p.Dir, "tags")
570 s.render(w, "refs.html", struct {
571 repoPage
572 Branches, Tags []gitutil.Ref
573 }{p, branches, tags})
574}
575
576func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
577 p, ok := s.repoFor(w, r, "")
578 if !ok {
579 return
580 }
581 file := r.PathValue("file")
582 ref, ok := strings.CutSuffix(file, ".tar.gz")
583 if !ok {
584 http.NotFound(w, r)
585 return
586 }
587 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
588 http.NotFound(w, r)
589 return
590 }
591 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
592 w.Header().Set("Content-Type", "application/gzip")
593 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
594 gitutil.Archive(p.Dir, ref, prefix, w)
595}
596
597func policyCanRead(u store.User, repo store.Repo, grant string) bool {
598 return policy.CanRead(u, repo, grant)
599}