internal/httpd/web.go

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

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}