internal/httpd/web.go

590c75d99c31b5b7358d22561dcb0a134c1dbf57
gitbay/internal/httpd/web.go history · blame · raw

845 lines · 23392 bytes

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