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