internal/httpd/web.go

9bf001e41785e5c85d396154fe8d5cb2da235c14
gitbay/internal/httpd/web.go history · blame · raw

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}