internal/httpd/web.go

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

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