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