internal/httpd/web.go
680 lines · 17935 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
144// ownerPage renders /{owner} for users and orgs: the repositories the
145// viewer may see, org membership either direction. Owner names are not
146// secret (they are on every commit); repository visibility rules hold.
147func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
148 name := r.PathValue("owner")
149 var viewer store.User
150 if s.cfg.Web.Mode == "accounts" {
151 viewer = s.viewer(r)
152 }
153
154 kind := "user"
155 var ownerID int64
156 var members []store.OrgMember
157 var orgs []store.OrgMember
158 if u, err := s.st.UserByUsername(name); err == nil {
159 ownerID = u.ID
160 orgs, _ = s.st.ListOrgsForUser(u.ID)
161 } else if o, err := s.st.OrgByName(name); err == nil {
162 kind, ownerID = "org", o.ID
163 members, _ = s.st.OrgMembers(o.ID)
164 } else {
165 http.NotFound(w, r)
166 return
167 }
168
169 all, err := s.st.ListReposForOwner(kind, ownerID)
170 if err != nil {
171 http.Error(w, "internal error", http.StatusInternalServerError)
172 return
173 }
174 var visible []store.Repo
175 for _, repo := range all {
176 grant := ""
177 if viewer.ID != 0 {
178 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
179 }
180 if policy.CanRead(viewer, repo, grant) {
181 visible = append(visible, repo)
182 }
183 }
184 s.render(w, "owner.html", struct {
185 Site string
186 Viewer string
187 Owner string
188 Kind string
189 Repos []store.Repo
190 Members []store.OrgMember
191 Orgs []store.OrgMember
192 }{s.siteName(), viewer.Username, name, kind, visible, members, orgs})
193}
194
195func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
196 p, ok := s.repoFor(w, r, "")
197 if !ok {
198 return
199 }
200 s.renderTree(w, r, p, "")
201}
202
203func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
204 p, ok := s.repoFor(w, r, r.PathValue("ref"))
205 if !ok {
206 return
207 }
208 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
209}
210
211func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
212 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
213 // Empty repo: render the page with no entries rather than 404.
214 s.render(w, "tree.html", struct {
215 repoPage
216 Crumbs []crumb
217 Prefix string
218 Entries []gitutil.TreeEntry
219 ReadmeHTML template.HTML
220 }{repoPage: p})
221 return
222 }
223 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
224 if err != nil {
225 http.NotFound(w, r)
226 return
227 }
228 prefix := ""
229 if dirPath != "" {
230 prefix = dirPath + "/"
231 }
232
233 var readmeHTML template.HTML
234 if name := pickReadme(entries); name != "" {
235 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+name, maxRenderBytes); err == nil {
236 readmeHTML = renderReadme(name, raw)
237 }
238 }
239
240 s.render(w, "tree.html", struct {
241 repoPage
242 Crumbs []crumb
243 Prefix string
244 Entries []gitutil.TreeEntry
245 ReadmeHTML template.HTML
246 }{p, crumbs(p, "tree", dirPath), prefix, entries, readmeHTML})
247}
248
249func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
250 p, ok := s.repoFor(w, r, r.PathValue("ref"))
251 if !ok {
252 return
253 }
254 filePath := strings.Trim(r.PathValue("path"), "/")
255 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
256 if err != nil {
257 http.NotFound(w, r)
258 return
259 }
260 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
261
262 var codeHTML template.HTML
263 if !binary {
264 codeHTML = highlight(filePath, data)
265 }
266 cs := crumbs(p, "blob", filePath)
267 base := ""
268 if len(cs) > 0 {
269 base = cs[len(cs)-1].Name
270 cs = cs[:len(cs)-1]
271 }
272 s.render(w, "blob.html", struct {
273 repoPage
274 Crumbs []crumb
275 Base string
276 Path string
277 Binary bool
278 Size int
279 CodeHTML template.HTML
280 }{p, cs, base, filePath, binary, len(data), codeHTML})
281}
282
283func highlight(filePath string, data []byte) template.HTML {
284 lexer := lexers.Match(filePath)
285 if lexer == nil {
286 lexer = lexers.Fallback
287 }
288 style := styles.Get("friendly")
289 formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false))
290 iterator, err := lexer.Tokenise(nil, string(data))
291 if err != nil {
292 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
293 }
294 var buf bytes.Buffer
295 if err := formatter.Format(&buf, style, iterator); err != nil {
296 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
297 }
298 return template.HTML(buf.String())
299}
300
301func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
302 p, ok := s.repoFor(w, r, r.PathValue("ref"))
303 if !ok {
304 return
305 }
306 filePath := strings.Trim(r.PathValue("path"), "/")
307 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
308 if err != nil {
309 http.NotFound(w, r)
310 return
311 }
312 // Serve inert: never let repo content execute in the forge's origin.
313 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
314 w.Header().Set("X-Content-Type-Options", "nosniff")
315 w.Write(data)
316}
317
318// readmeRank orders competing README files: richer renderers win.
319var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
320
321// pickReadme returns the best README-ish blob in a tree listing: any file
322// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
323// we can render richly.
324func pickReadme(entries []gitutil.TreeEntry) string {
325 best, bestRank := "", 1<<30
326 for _, e := range entries {
327 if e.Type != "blob" {
328 continue
329 }
330 lower := strings.ToLower(e.Name)
331 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
332 continue
333 }
334 rank, ok := readmeRank[path.Ext(lower)]
335 if !ok {
336 rank = 10 // plaintext fallback
337 }
338 if rank < bestRank {
339 best, bestRank = e.Name, rank
340 }
341 }
342 return best
343}
344
345// mdHTML renders user-authored markdown (issue and MR bodies, comments).
346// goldmark's default renderer drops raw HTML, so this is safe as-is.
347func mdHTML(raw string) template.HTML {
348 if strings.TrimSpace(raw) == "" {
349 return ""
350 }
351 var buf bytes.Buffer
352 if goldmark.Convert([]byte(raw), &buf) != nil {
353 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
354 }
355 return template.HTML(buf.String())
356}
357
358// renderedComment pairs a comment with its rendered body for templates.
359type renderedComment struct {
360 Author string
361 CreatedAt string
362 BodyHTML template.HTML
363}
364
365func renderComments(cs []store.IssueComment) []renderedComment {
366 var out []renderedComment
367 for _, c := range cs {
368 out = append(out, renderedComment{c.Author, c.CreatedAt, mdHTML(c.Body)})
369 }
370 return out
371}
372
373// ugcPolicy sanitizes rendered repo content before it enters the forge's
374// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
375// output and repo-authored HTML are not.
376var ugcPolicy = bluemonday.UGCPolicy()
377
378// renderReadme renders a README by extension: markdown, org-mode, and
379// (sanitized) HTML richly; everything else as escaped plaintext.
380func renderReadme(name string, raw []byte) template.HTML {
381 plain := func() template.HTML {
382 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
383 }
384 if gitutil.IsBinary(raw) {
385 return ""
386 }
387 switch path.Ext(strings.ToLower(name)) {
388 case ".md", ".markdown":
389 var buf bytes.Buffer
390 if goldmark.Convert(raw, &buf) != nil {
391 return plain()
392 }
393 return template.HTML(buf.String())
394 case ".org":
395 doc := org.New().Parse(bytes.NewReader(raw), name)
396 html, err := doc.Write(org.NewHTMLWriter())
397 if err != nil {
398 return plain()
399 }
400 return template.HTML(ugcPolicy.Sanitize(html))
401 case ".html", ".htm":
402 return template.HTML(ugcPolicy.Sanitize(string(raw)))
403 default:
404 return plain()
405 }
406}
407
408type diffLine struct {
409 Class string
410 Text string
411}
412
413func classifyDiff(patch string) []diffLine {
414 var lines []diffLine
415 for _, l := range strings.Split(patch, "\n") {
416 class := ""
417 switch {
418 case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
419 class = "meta"
420 case strings.HasPrefix(l, "@@"):
421 class = "hunk"
422 case strings.HasPrefix(l, "+"):
423 class = "add"
424 case strings.HasPrefix(l, "-"):
425 class = "del"
426 }
427 lines = append(lines, diffLine{class, l})
428 }
429 return lines
430}
431
432type sigView struct {
433 State string
434 Signer string
435 Fingerprint string
436}
437
438func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
439 raw, err := gitutil.ReadCommit(dir, sha)
440 if err != nil {
441 return sigView{State: "unsigned"}, nil
442 }
443 parsed, err := sig.ParseCommit(raw)
444 if err != nil {
445 return sigView{State: "unsigned"}, nil
446 }
447 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
448 if err != nil {
449 return sigView{State: "unsigned"}, parsed
450 }
451 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
452 if res.SignerUserID != 0 {
453 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
454 v.Signer = u.Username
455 }
456 }
457 return v, parsed
458}
459
460func (s *Server) log(w http.ResponseWriter, r *http.Request) {
461 ref := r.PathValue("ref")
462 p, ok := s.repoFor(w, r, ref)
463 if !ok {
464 return
465 }
466 const pageSize = 50
467 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
468 if err != nil {
469 http.NotFound(w, r)
470 return
471 }
472 next := ""
473 if len(shas) > pageSize {
474 next = shas[pageSize]
475 shas = shas[:pageSize]
476 }
477 type row struct {
478 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
479 Sig sigView
480 }
481 var rows []row
482 for _, sha := range shas {
483 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
484 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
485 if parsed != nil {
486 rw.Subject = parsed.Subject
487 rw.AuthorName = parsed.AuthorName
488 rw.AuthorEmail = parsed.AuthorEmail
489 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
490 }
491 rows = append(rows, rw)
492 }
493 s.render(w, "log.html", struct {
494 repoPage
495 Commits []row
496 NextSHA string
497 }{p, rows, next})
498}
499
500func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
501 p, ok := s.repoFor(w, r, "")
502 if !ok {
503 return
504 }
505 sha := r.PathValue("sha")
506 full, err := gitutil.ResolveRef(p.Dir, sha)
507 if err != nil {
508 http.NotFound(w, r)
509 return
510 }
511 v, parsed := s.sigFor(p.Repo, p.Dir, full)
512 if parsed == nil {
513 http.NotFound(w, r)
514 return
515 }
516 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
517 lines := classifyDiff(patch)
518 committerEmail := ""
519 if parsed.CommitterEmail != parsed.AuthorEmail {
520 committerEmail = parsed.CommitterEmail
521 }
522 msg := ""
523 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
524 msg = string(parsed.Payload[i+2:])
525 }
526 s.render(w, "commit.html", struct {
527 repoPage
528 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
529 Sig sigView
530 DiffLines []diffLine
531 }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
532 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines})
533}
534
535func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
536 p, ok := s.repoFor(w, r, "")
537 if !ok {
538 return
539 }
540 state := r.URL.Query().Get("state")
541 if state != "closed" && state != "all" {
542 state = "open"
543 }
544 issues, err := s.st.ListIssues(p.Repo.ID, state)
545 if err != nil {
546 http.Error(w, "internal error", http.StatusInternalServerError)
547 return
548 }
549 s.render(w, "issues.html", struct {
550 repoPage
551 State string
552 Issues []store.Issue
553 }{p, state, issues})
554}
555
556func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
557 p, ok := s.repoFor(w, r, "")
558 if !ok {
559 return
560 }
561 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
562 if err != nil {
563 http.NotFound(w, r)
564 return
565 }
566 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
567 if err != nil {
568 http.NotFound(w, r)
569 return
570 }
571 comments, err := s.st.ListIssueComments(iss.ID)
572 if err != nil {
573 http.Error(w, "internal error", http.StatusInternalServerError)
574 return
575 }
576 s.render(w, "issue.html", struct {
577 repoPage
578 Issue store.Issue
579 BodyHTML template.HTML
580 Comments []renderedComment
581 }{p, iss, mdHTML(iss.Body), renderComments(comments)})
582}
583
584func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
585 p, ok := s.repoFor(w, r, "")
586 if !ok {
587 return
588 }
589 state := r.URL.Query().Get("state")
590 if state == "" {
591 state = "open"
592 }
593 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
594 if !valid[state] {
595 state = "open"
596 }
597 mrs, err := s.st.ListMRs(p.Repo.ID, state)
598 if err != nil {
599 http.Error(w, "internal error", http.StatusInternalServerError)
600 return
601 }
602 s.render(w, "mrs.html", struct {
603 repoPage
604 State string
605 MRs []store.MR
606 }{p, state, mrs})
607}
608
609func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
610 p, ok := s.repoFor(w, r, "")
611 if !ok {
612 return
613 }
614 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
615 if err != nil {
616 http.NotFound(w, r)
617 return
618 }
619 m, err := s.st.MRByNumber(p.Repo.ID, n)
620 if err != nil {
621 http.NotFound(w, r)
622 return
623 }
624 comments, _ := s.st.ListMRComments(m.ID)
625 reviews, _ := s.st.ListMRReviews(m.ID)
626
627 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
628 var lines []diffLine
629 if base, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
630 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
631 lines = classifyDiff(patch)
632 }
633 }
634 s.render(w, "mr.html", struct {
635 repoPage
636 MR store.MR
637 BodyHTML template.HTML
638 Comments []renderedComment
639 Reviews []store.MRReview
640 DiffLines []diffLine
641 }{p, m, mdHTML(m.Body), renderComments(comments), reviews, lines})
642}
643
644func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
645 p, ok := s.repoFor(w, r, "")
646 if !ok {
647 return
648 }
649 branches, _ := gitutil.Refs(p.Dir, "heads")
650 tags, _ := gitutil.Refs(p.Dir, "tags")
651 s.render(w, "refs.html", struct {
652 repoPage
653 Branches, Tags []gitutil.Ref
654 }{p, branches, tags})
655}
656
657func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
658 p, ok := s.repoFor(w, r, "")
659 if !ok {
660 return
661 }
662 file := r.PathValue("file")
663 ref, ok := strings.CutSuffix(file, ".tar.gz")
664 if !ok {
665 http.NotFound(w, r)
666 return
667 }
668 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
669 http.NotFound(w, r)
670 return
671 }
672 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
673 w.Header().Set("Content-Type", "application/gzip")
674 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
675 gitutil.Archive(p.Dir, ref, prefix, w)
676}
677
678func policyCanRead(u store.User, repo store.Repo, grant string) bool {
679 return policy.CanRead(u, repo, grant)
680}