internal/httpd/web.go

069bd1e10730364492cbb50ac43b86fcc3a35c40
gitbay/internal/httpd/web.go history · blame · raw

1005 lines · 27690 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
 332// blamePageSize caps how many lines one blame page renders; blame is a
 333// per-line subprocess cost, so large files paginate.
 334const blamePageSize = 1000
 335
 336func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 337	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 338	if !ok {
 339		return
 340	}
 341	p.Tab = "files"
 342	filePath := strings.Trim(r.PathValue("path"), "/")
 343	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 344	if err != nil {
 345		s.notFound(w, r)
 346		return
 347	}
 348	total := bytes.Count(data, []byte("\n"))
 349	if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
 350		total++
 351	}
 352	binary := gitutil.IsBinary(data)
 353
 354	type hunkView struct {
 355		gitutil.BlameHunk
 356		ShortSHA string
 357		Date     string
 358		Sig      sigView
 359		Numbered []numberedLine
 360	}
 361	var hunks []hunkView
 362	page, pages := 1, (total+blamePageSize-1)/blamePageSize
 363	if pages == 0 {
 364		pages = 1
 365	}
 366	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
 367		page = n
 368	}
 369	if !binary && total > 0 {
 370		start := (page-1)*blamePageSize + 1
 371		end := min(total, page*blamePageSize)
 372		raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
 373		if err != nil {
 374			s.notFound(w, r)
 375			return
 376		}
 377		sigs := map[string]sigView{}
 378		for _, h := range raw {
 379			v, ok := sigs[h.SHA]
 380			if !ok {
 381				v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 382				sigs[h.SHA] = v
 383			}
 384			hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
 385				Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
 386			for i, l := range h.Lines {
 387				hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 388			}
 389			hunks = append(hunks, hv)
 390		}
 391	}
 392	cs := crumbs(p, "blame", filePath)
 393	base := ""
 394	if len(cs) > 0 {
 395		base = cs[len(cs)-1].Name
 396		cs = cs[:len(cs)-1]
 397	}
 398	s.render(w, "blame.html", struct {
 399		repoPage
 400		Crumbs      []crumb
 401		Base        string
 402		Path        string
 403		Binary      bool
 404		Hunks       []hunkView
 405		Page, Pages int
 406	}{p, cs, base, filePath, binary, hunks, page, pages})
 407}
 408
 409type numberedLine struct {
 410	N    int
 411	Text string
 412}
 413
 414func highlight(filePath string, data []byte) template.HTML {
 415	lexer := lexers.Match(filePath)
 416	if lexer == nil {
 417		lexer = lexers.Fallback
 418	}
 419	style := styles.Get("friendly")
 420	formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false))
 421	iterator, err := lexer.Tokenise(nil, string(data))
 422	if err != nil {
 423		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 424	}
 425	var buf bytes.Buffer
 426	if err := formatter.Format(&buf, style, iterator); err != nil {
 427		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 428	}
 429	return template.HTML(buf.String())
 430}
 431
 432func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 433	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 434	if !ok {
 435		return
 436	}
 437	filePath := strings.Trim(r.PathValue("path"), "/")
 438	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 439	if err != nil {
 440		s.notFound(w, r)
 441		return
 442	}
 443	// Serve inert: never let repo content execute in the forge's origin.
 444	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
 445	w.Header().Set("X-Content-Type-Options", "nosniff")
 446	w.Write(data)
 447}
 448
 449// readmeRank orders competing README files: richer renderers win.
 450var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 451
 452// pickReadme returns the best README-ish blob in a tree listing: any file
 453// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 454// we can render richly.
 455func pickReadme(entries []gitutil.TreeEntry) string {
 456	best, bestRank := "", 1<<30
 457	for _, e := range entries {
 458		if e.Type != "blob" {
 459			continue
 460		}
 461		lower := strings.ToLower(e.Name)
 462		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 463			continue
 464		}
 465		rank, ok := readmeRank[path.Ext(lower)]
 466		if !ok {
 467			rank = 10 // plaintext fallback
 468		}
 469		if rank < bestRank {
 470			best, bestRank = e.Name, rank
 471		}
 472	}
 473	return best
 474}
 475
 476// mdHTML renders user-authored markdown (issue and MR bodies, comments).
 477// goldmark's default renderer drops raw HTML, so this is safe as-is.
 478func mdHTML(raw string) template.HTML {
 479	if strings.TrimSpace(raw) == "" {
 480		return ""
 481	}
 482	var buf bytes.Buffer
 483	if goldmark.Convert([]byte(raw), &buf) != nil {
 484		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
 485	}
 486	return template.HTML(buf.String())
 487}
 488
 489// webResolver answers autolink lookups for one viewer. Cross-repo
 490// references to repositories the viewer cannot read stay plain text, per
 491// the enumeration rule: a link would confirm the repo exists.
 492type webResolver struct {
 493	s      *Server
 494	viewer store.User
 495}
 496
 497func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
 498	repo, err := r.s.st.RepoByPath(owner + "/" + name)
 499	if err != nil {
 500		return ""
 501	}
 502	grant := ""
 503	if r.viewer.ID != 0 {
 504		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
 505	}
 506	if !policy.CanRead(r.viewer, repo, grant) {
 507		return ""
 508	}
 509	if kind == '#' {
 510		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
 511			return ""
 512		}
 513		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
 514	}
 515	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
 516		return ""
 517	}
 518	return autolink.MRURL(repo.OwnerName, repo.Name, n)
 519}
 520
 521func (r webResolver) UserURL(name string) string {
 522	if _, err := r.s.st.UserByUsername(name); err == nil {
 523		return "/" + name
 524	}
 525	if _, err := r.s.st.OrgByName(name); err == nil {
 526		return "/" + name
 527	}
 528	return ""
 529}
 530
 531// ugcFor returns a renderer for user-authored markdown on one repo's pages:
 532// mdHTML plus cross-reference and mention autolinking for this viewer.
 533func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
 534	viewer := store.User{}
 535	if s.cfg.Web.Mode == "accounts" {
 536		viewer = s.viewer(r)
 537	}
 538	res := webResolver{s, viewer}
 539	return func(raw string) template.HTML {
 540		h := mdHTML(raw)
 541		if h == "" {
 542			return h
 543		}
 544		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
 545	}
 546}
 547
 548// renderedComment pairs a comment with its rendered body for templates.
 549type renderedComment struct {
 550	Author    string
 551	CreatedAt string
 552	BodyHTML  template.HTML
 553}
 554
 555func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
 556	var out []renderedComment
 557	for _, c := range cs {
 558		out = append(out, renderedComment{c.Author, c.CreatedAt, md(c.Body)})
 559	}
 560	return out
 561}
 562
 563// ugcPolicy sanitizes rendered repo content before it enters the forge's
 564// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
 565// output and repo-authored HTML are not.
 566var ugcPolicy = bluemonday.UGCPolicy()
 567
 568// renderReadme renders a README by extension: markdown, org-mode, and
 569// (sanitized) HTML richly; everything else as escaped plaintext.
 570func renderReadme(name string, raw []byte) template.HTML {
 571	plain := func() template.HTML {
 572		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
 573	}
 574	if gitutil.IsBinary(raw) {
 575		return ""
 576	}
 577	switch path.Ext(strings.ToLower(name)) {
 578	case ".md", ".markdown":
 579		var buf bytes.Buffer
 580		if goldmark.Convert(raw, &buf) != nil {
 581			return plain()
 582		}
 583		return template.HTML(buf.String())
 584	case ".org":
 585		doc := org.New().Parse(bytes.NewReader(raw), name)
 586		html, err := doc.Write(org.NewHTMLWriter())
 587		if err != nil {
 588			return plain()
 589		}
 590		return template.HTML(ugcPolicy.Sanitize(html))
 591	case ".html", ".htm":
 592		return template.HTML(ugcPolicy.Sanitize(string(raw)))
 593	default:
 594		return plain()
 595	}
 596}
 597
 598type diffLine struct {
 599	Class   string
 600	Text    string
 601	Path    string // file this line belongs to
 602	NewLine int64  // line number in the new file (0 when absent)
 603	OldLine int64  // line number in the old file (0 when absent)
 604	Threads []diffThread
 605}
 606
 607var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
 608
 609// classifyDiff parses a unified diff into rendered lines, tracking the
 610// file and old/new line numbers so review threads can anchor inline.
 611func classifyDiff(patch string) []diffLine {
 612	var lines []diffLine
 613	path := ""
 614	var oldN, newN int64
 615	for _, l := range strings.Split(patch, "\n") {
 616		d := diffLine{Text: l}
 617		switch {
 618		case strings.HasPrefix(l, "+++ "):
 619			d.Class = "meta"
 620			path = strings.TrimPrefix(strings.TrimPrefix(l, "+++ "), "b/")
 621		case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
 622			d.Class = "meta"
 623		case strings.HasPrefix(l, "@@"):
 624			d.Class = "hunk"
 625			if m := hunkPat.FindStringSubmatch(l); m != nil {
 626				oldN, _ = strconv.ParseInt(m[1], 10, 64)
 627				newN, _ = strconv.ParseInt(m[2], 10, 64)
 628			}
 629		case strings.HasPrefix(l, "+"):
 630			d.Class, d.Path, d.NewLine = "add", path, newN
 631			newN++
 632		case strings.HasPrefix(l, "-"):
 633			d.Class, d.Path, d.OldLine = "del", path, oldN
 634			oldN++
 635		default:
 636			d.Path, d.OldLine, d.NewLine = path, oldN, newN
 637			oldN++
 638			newN++
 639		}
 640		lines = append(lines, d)
 641	}
 642	return lines
 643}
 644
 645type diffThread struct {
 646	ID       int64
 647	Resolved string
 648	Stale    bool
 649	Comments []renderedComment
 650}
 651
 652// attachThreads injects review threads under their anchored diff lines;
 653// threads whose anchor no longer appears (stale after force-push, or on a
 654// context line outside the current diff) are returned separately.
 655func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) {
 656	type anchor struct {
 657		path string
 658		side string
 659		line int64
 660	}
 661	threads := map[int64]*diffThread{}
 662	anchors := map[int64]anchor{}
 663	var order []int64
 664	for _, cm := range comments {
 665		if cm.ReplyTo == 0 {
 666			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
 667				Comments: []renderedComment{{cm.Author, cm.CreatedAt, md(cm.Body)}}}
 668			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
 669			order = append(order, cm.ID)
 670		} else if th, ok := threads[cm.ReplyTo]; ok {
 671			th.Comments = append(th.Comments, renderedComment{cm.Author, cm.CreatedAt, md(cm.Body)})
 672		}
 673	}
 674	placed := map[int64]bool{}
 675	for i := range lines {
 676		for _, id := range order {
 677			if placed[id] || threads[id].Stale {
 678				continue
 679			}
 680			a := anchors[id]
 681			if lines[i].Path != a.path {
 682				continue
 683			}
 684			if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
 685				(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
 686				lines[i].Threads = append(lines[i].Threads, *threads[id])
 687				placed[id] = true
 688			}
 689		}
 690	}
 691	var unplaced []diffThread
 692	for _, id := range order {
 693		if !placed[id] {
 694			unplaced = append(unplaced, *threads[id])
 695		}
 696	}
 697	return lines, unplaced
 698}
 699
 700type sigView struct {
 701	State       string
 702	Signer      string
 703	Fingerprint string
 704}
 705
 706func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
 707	raw, err := gitutil.ReadCommit(dir, sha)
 708	if err != nil {
 709		return sigView{State: "unsigned"}, nil
 710	}
 711	parsed, err := sig.ParseCommit(raw)
 712	if err != nil {
 713		return sigView{State: "unsigned"}, nil
 714	}
 715	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
 716	if err != nil {
 717		return sigView{State: "unsigned"}, parsed
 718	}
 719	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
 720	if res.SignerUserID != 0 {
 721		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
 722			v.Signer = u.Username
 723		}
 724	}
 725	return v, parsed
 726}
 727
 728func (s *Server) log(w http.ResponseWriter, r *http.Request) {
 729	ref := r.PathValue("ref")
 730	p, ok := s.repoFor(w, r, ref)
 731	if !ok {
 732		return
 733	}
 734	p.Tab = "log"
 735	const pageSize = 50
 736	shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
 737	if err != nil {
 738		s.notFound(w, r)
 739		return
 740	}
 741	next := ""
 742	if len(shas) > pageSize {
 743		next = shas[pageSize]
 744		shas = shas[:pageSize]
 745	}
 746	type row struct {
 747		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
 748		Sig                                                   sigView
 749	}
 750	var rows []row
 751	for _, sha := range shas {
 752		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
 753		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
 754		if parsed != nil {
 755			rw.Subject = parsed.Subject
 756			rw.AuthorName = parsed.AuthorName
 757			rw.AuthorEmail = parsed.AuthorEmail
 758			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
 759		}
 760		rows = append(rows, rw)
 761	}
 762	s.render(w, "log.html", struct {
 763		repoPage
 764		Commits []row
 765		NextSHA string
 766	}{p, rows, next})
 767}
 768
 769func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
 770	p, ok := s.repoFor(w, r, "")
 771	if !ok {
 772		return
 773	}
 774	p.Tab = "log"
 775	sha := r.PathValue("sha")
 776	full, err := gitutil.ResolveRef(p.Dir, sha)
 777	if err != nil {
 778		s.notFound(w, r)
 779		return
 780	}
 781	v, parsed := s.sigFor(p.Repo, p.Dir, full)
 782	if parsed == nil {
 783		s.notFound(w, r)
 784		return
 785	}
 786	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
 787	lines := classifyDiff(patch)
 788	committerEmail := ""
 789	if parsed.CommitterEmail != parsed.AuthorEmail {
 790		committerEmail = parsed.CommitterEmail
 791	}
 792	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
 793	msg := ""
 794	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
 795		msg = string(parsed.Payload[i+2:])
 796	}
 797	s.render(w, "commit.html", struct {
 798		repoPage
 799		SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
 800		Sig                                                                   sigView
 801		Checks                                                                []store.CommitStatus
 802		DiffLines                                                             []diffLine
 803	}{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
 804		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, checks, lines})
 805}
 806
 807// labelPalette provides default label chip colors: mid-tone hues that stay
 808// legible on light and dark backgrounds.
 809var labelPalette = []string{
 810	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
 811	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
 812}
 813
 814var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
 815
 816// labelColors returns a complete label-name -> chip color map for a repo:
 817// the stored labels.color when it is a valid hex color, otherwise a
 818// stable default picked from the palette by name hash.
 819func (s *Server) labelColors(repoID int64) map[string]template.CSS {
 820	stored, _ := s.st.LabelColors(repoID)
 821	out := make(map[string]template.CSS, len(stored))
 822	for name, color := range stored {
 823		if !hexColorPat.MatchString(color) {
 824			h := fnv.New32a()
 825			h.Write([]byte(name))
 826			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
 827		}
 828		out[name] = template.CSS("--chip:" + color)
 829	}
 830	return out
 831}
 832
 833func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
 834	p, ok := s.repoFor(w, r, "")
 835	if !ok {
 836		return
 837	}
 838	p.Tab = "issues"
 839	state := r.URL.Query().Get("state")
 840	if state != "closed" && state != "all" {
 841		state = "open"
 842	}
 843	issues, err := s.st.ListIssues(p.Repo.ID, state)
 844	if err != nil {
 845		http.Error(w, "internal error", http.StatusInternalServerError)
 846		return
 847	}
 848	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
 849		for i := range issues {
 850			issues[i].Labels = labels[issues[i].ID]
 851		}
 852	}
 853	s.render(w, "issues.html", struct {
 854		repoPage
 855		State       string
 856		Issues      []store.Issue
 857		LabelColors map[string]template.CSS
 858	}{p, state, issues, s.labelColors(p.Repo.ID)})
 859}
 860
 861func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
 862	p, ok := s.repoFor(w, r, "")
 863	if !ok {
 864		return
 865	}
 866	p.Tab = "issues"
 867	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
 868	if err != nil {
 869		s.notFound(w, r)
 870		return
 871	}
 872	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
 873	if err != nil {
 874		s.notFound(w, r)
 875		return
 876	}
 877	comments, err := s.st.ListIssueComments(iss.ID)
 878	if err != nil {
 879		http.Error(w, "internal error", http.StatusInternalServerError)
 880		return
 881	}
 882	md := s.ugcFor(r, p.Repo)
 883	s.render(w, "issue.html", struct {
 884		repoPage
 885		Issue       store.Issue
 886		BodyHTML    template.HTML
 887		Comments    []renderedComment
 888		LabelColors map[string]template.CSS
 889	}{p, iss, md(iss.Body), renderComments(comments, md), s.labelColors(p.Repo.ID)})
 890}
 891
 892func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
 893	p, ok := s.repoFor(w, r, "")
 894	if !ok {
 895		return
 896	}
 897	p.Tab = "merge requests"
 898	state := r.URL.Query().Get("state")
 899	if state == "" {
 900		state = "open"
 901	}
 902	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
 903	if !valid[state] {
 904		state = "open"
 905	}
 906	mrs, err := s.st.ListMRs(p.Repo.ID, state)
 907	if err != nil {
 908		http.Error(w, "internal error", http.StatusInternalServerError)
 909		return
 910	}
 911	s.render(w, "mrs.html", struct {
 912		repoPage
 913		State string
 914		MRs   []store.MR
 915	}{p, state, mrs})
 916}
 917
 918func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
 919	p, ok := s.repoFor(w, r, "")
 920	if !ok {
 921		return
 922	}
 923	p.Tab = "merge requests"
 924	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
 925	if err != nil {
 926		s.notFound(w, r)
 927		return
 928	}
 929	m, err := s.st.MRByNumber(p.Repo.ID, n)
 930	if err != nil {
 931		s.notFound(w, r)
 932		return
 933	}
 934	comments, _ := s.st.ListMRComments(m.ID)
 935	reviews, _ := s.st.ListMRReviews(m.ID)
 936	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
 937	diffComments, _ := s.st.ListDiffComments(m.ID)
 938
 939	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
 940	var lines []diffLine
 941	base := m.MergedBase
 942	if base == "" {
 943		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
 944			base = b
 945		}
 946	}
 947	if base != "" {
 948		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
 949			lines = classifyDiff(patch)
 950		}
 951	}
 952	md := s.ugcFor(r, p.Repo)
 953	var detachedThreads []diffThread
 954	lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
 955	s.render(w, "mr.html", struct {
 956		repoPage
 957		MR              store.MR
 958		BodyHTML        template.HTML
 959		Checks          []store.CommitStatus
 960		Combined        string
 961		Comments        []renderedComment
 962		Reviews         []store.MRReview
 963		DiffLines       []diffLine
 964		DetachedThreads []diffThread
 965	}{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), reviews, lines, detachedThreads})
 966}
 967
 968func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
 969	p, ok := s.repoFor(w, r, "")
 970	if !ok {
 971		return
 972	}
 973	p.Tab = "refs"
 974	branches, _ := gitutil.Refs(p.Dir, "heads")
 975	tags, _ := gitutil.Refs(p.Dir, "tags")
 976	s.render(w, "refs.html", struct {
 977		repoPage
 978		Branches, Tags []gitutil.Ref
 979	}{p, branches, tags})
 980}
 981
 982func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
 983	p, ok := s.repoFor(w, r, "")
 984	if !ok {
 985		return
 986	}
 987	file := r.PathValue("file")
 988	ref, ok := strings.CutSuffix(file, ".tar.gz")
 989	if !ok {
 990		s.notFound(w, r)
 991		return
 992	}
 993	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
 994		s.notFound(w, r)
 995		return
 996	}
 997	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
 998	w.Header().Set("Content-Type", "application/gzip")
 999	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1000	gitutil.Archive(p.Dir, ref, prefix, w)
1001}
1002
1003func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1004	return policy.CanRead(u, repo, grant)
1005}