internal/httpd/web.go

075af6c002f84e2edcbd793668a23239fbe18636
gitbay/internal/httpd/web.go history · blame · raw

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