internal/httpd/web.go

027a72496beee4257ee50fd36d104e0959193691
gitbay/internal/httpd/web.go history · blame · raw

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