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