internal/httpd/web.go

dde95912081319c5dd693cfd886a1043ac4ca43f
gitbay/internal/httpd/web.go history · blame · raw

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}