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