internal/httpd/web.go

20b06b791bf4bdd23b633bbfde819d5d8564751a
gitbay/internal/httpd/web.go history · blame · raw

1572 lines · 46401 bytes

   1package httpd
   2
   3import (
   4	"bytes"
   5	"fmt"
   6	"hash/fnv"
   7	"io"
   8	"os"
   9	"path/filepath"
  10
  11	"gitbay.org/gitbay/internal/policy"
  12	"html/template"
  13	"net/http"
  14	"path"
  15	"regexp"
  16	"sort"
  17	"strconv"
  18	"strings"
  19	"time"
  20
  21	"github.com/alecthomas/chroma/v2/formatters/html"
  22	"github.com/alecthomas/chroma/v2/lexers"
  23	"github.com/alecthomas/chroma/v2/styles"
  24	"github.com/microcosm-cc/bluemonday"
  25	"github.com/niklasfasching/go-org/org"
  26	"github.com/yuin/goldmark"
  27	highlighting "github.com/yuin/goldmark-highlighting/v2"
  28	"github.com/yuin/goldmark/extension"
  29
  30	"gitbay.org/gitbay/internal/autolink"
  31	"gitbay.org/gitbay/internal/control"
  32	"gitbay.org/gitbay/internal/gitutil"
  33	"gitbay.org/gitbay/internal/sig"
  34	"gitbay.org/gitbay/internal/store"
  35	"gitbay.org/gitbay/internal/web"
  36)
  37
  38const maxRenderBytes = 1 << 20 // largest blob rendered inline
  39
  40func (s *Server) render(w http.ResponseWriter, page string, data any) {
  41	var buf bytes.Buffer
  42	if err := web.Render(&buf, page, data); err != nil {
  43		http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
  44		return
  45	}
  46	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  47	buf.WriteTo(w)
  48}
  49
  50// siteName is the instance's display name: the operator's [web] title,
  51// or the site host when they have not set one.
  52func (s *Server) siteName() string {
  53	if t := strings.TrimSpace(s.cfg.Web.Title); t != "" {
  54		return t
  55	}
  56	h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
  57	return strings.TrimSuffix(h, "/")
  58}
  59
  60func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
  61	w.Header().Set("Content-Type", "text/css; charset=utf-8")
  62	w.Write(web.StyleCSS)
  63	w.Write(chromaCSS)
  64}
  65
  66func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
  67	w.Header().Set("Content-Type", "image/svg+xml")
  68	w.Write(web.FaviconSVG)
  69}
  70
  71// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
  72// so the CSP's default-src 'self' covers it — no font CDN.
  73func (s *Server) font(w http.ResponseWriter, r *http.Request) {
  74	data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
  75	if err != nil {
  76		http.NotFound(w, r)
  77		return
  78	}
  79	w.Header().Set("Content-Type", "font/woff2")
  80	w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
  81	w.Write(data)
  82}
  83
  84// notFound renders the designed 404 page with a 404 status. Falls back to
  85// the stock plain-text response if the template fails.
  86func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
  87	var buf bytes.Buffer
  88	if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
  89		http.NotFound(w, r)
  90		return
  91	}
  92	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  93	w.WriteHeader(http.StatusNotFound)
  94	buf.WriteTo(w)
  95}
  96
  97// describedRepo pairs a repo with the listing metadata: description,
  98// topics, license, and last-updated date.
  99type describedRepo struct {
 100	store.Repo
 101	Desc    string
 102	Topics  []string
 103	License string
 104	Updated string
 105}
 106
 107func (s *Server) describeAll(repos []store.Repo) []describedRepo {
 108	var out []describedRepo
 109	for _, r := range repos {
 110		dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
 111		d := describedRepo{
 112			Repo:    r,
 113			Desc:    gitutil.ReadDescription(dir),
 114			License: detectLicense(dir, r.DefaultBranch),
 115			Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
 116		}
 117		d.Topics, _ = s.st.ListTopics(r.ID)
 118		out = append(out, d)
 119	}
 120	return out
 121}
 122
 123// index is the homepage: a dashboard for logged-in users, a landing page
 124// for everyone else. The full public listing lives at /explore.
 125func (s *Server) index(w http.ResponseWriter, r *http.Request) {
 126	if s.cfg.Web.Mode == "accounts" {
 127		if viewer := s.viewer(r); viewer.ID != 0 {
 128			s.dashboard(w, r, viewer)
 129			return
 130		}
 131	}
 132	host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
 133		s.cfg.Server.SiteURL, "https://"), "http://"), "/")
 134	s.render(w, "landing.html", struct {
 135		basePage
 136		Host     string
 137		Accounts bool
 138		Signup   bool
 139	}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
 140		s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
 141}
 142
 143func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
 144	pinned, _ := s.st.PinnedRepos(viewer.ID)
 145	var visible []store.Repo
 146	for _, rp := range pinned {
 147		grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
 148		if policy.CanRead(viewer, rp, grant) {
 149			visible = append(visible, rp)
 150		}
 151	}
 152	mrs, _ := s.st.DashboardMRs(viewer.ID)
 153	issues, _ := s.st.DashboardIssues(viewer.ID)
 154	reviews, _ := s.st.ReviewQueue(viewer.ID)
 155	assigned, _ := s.st.AssignedIssues(viewer.ID)
 156	events, _ := s.st.RecentEvents(viewer.ID, 20)
 157	s.render(w, "dashboard.html", struct {
 158		basePage
 159		Pinned   []store.Repo
 160		Reviews  []store.DashboardItem
 161		Assigned []store.DashboardItem
 162		MRs      []store.DashboardItem
 163		Issues   []store.DashboardItem
 164		Feed     []feedLine
 165	}{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)})
 166}
 167
 168func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
 169	repos, err := s.st.ListPublicRepos()
 170	if err != nil {
 171		http.Error(w, "internal error", http.StatusInternalServerError)
 172		return
 173	}
 174	var viewer store.User
 175	if s.cfg.Web.Mode == "accounts" {
 176		viewer = s.viewer(r)
 177	}
 178	q := strings.TrimSpace(r.URL.Query().Get("q"))
 179	s.render(w, "explore.html", struct {
 180		basePage
 181		Query string
 182		Repos []describedRepo
 183	}{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
 184}
 185
 186// privacy renders the privacy page: what the gitbay software does with
 187// data, plus this instance's operator-provided notes.
 188func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
 189	s.render(w, "privacy.html", struct {
 190		basePage
 191		Host   string
 192		Notice string
 193	}{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
 194}
 195
 196// filterRepos keeps repos whose path, description, or topics contain the
 197// query, case-insensitively. An empty query keeps everything.
 198func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
 199	if q == "" {
 200		return repos
 201	}
 202	q = strings.ToLower(q)
 203	var out []describedRepo
 204	for _, d := range repos {
 205		if strings.Contains(strings.ToLower(d.Path()), q) ||
 206			strings.Contains(strings.ToLower(d.Desc), q) {
 207			out = append(out, d)
 208			continue
 209		}
 210		for _, t := range d.Topics {
 211			if strings.Contains(t, q) {
 212				out = append(out, d)
 213				break
 214			}
 215		}
 216	}
 217	return out
 218}
 219
 220// repoPage is the shared context for repo-scoped pages.
 221type repoPage struct {
 222	basePage
 223	Desc     string
 224	Repo     store.Repo
 225	Ref      string
 226	CloneURL string
 227	Dir      string
 228	Tab      string // active tab in the repo header
 229	Topics   []string
 230	Pinned   bool // by the viewer
 231	HasWiki  bool
 232	Host     string
 233	Mirrors  []mirrorLine // repo admins only
 234	CanAdmin bool         // gates the settings tab
 235	// OpenIssues and OpenMRs are the counts on the header tabs.
 236	OpenIssues int
 237	OpenMRs    int
 238	// RepoHome asks the layout for the full header — description, topics,
 239	// website, mirrors. Every other page gets identity and tabs only, so a
 240	// repo describes itself once rather than on all twelve of its pages.
 241	RepoHome bool
 242}
 243
 244// mirrorLine is the admin-only mirror status shown in the repo header.
 245// It carries no credentials: the stored URL is credential-free.
 246type mirrorLine struct {
 247	Direction string
 248	URL       string
 249	Target    string // URL without the scheme, for display
 250	Synced    string
 251	Error     string
 252}
 253
 254// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
 255// readable "2026-08-25 03:39 UTC".
 256func syncedAt(ts string) string {
 257	if len(ts) < 16 {
 258		return ts
 259	}
 260	return ts[:10] + " " + ts[11:16] + " UTC"
 261}
 262
 263// repoFor resolves the repo for a web request; false means 404 was sent.
 264// Anonymous visitors see public repos only; in accounts mode a logged-in
 265// viewer additionally sees repos their grants allow. Private and missing
 266// repos are indistinguishable either way.
 267func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
 268	var repo store.Repo
 269	var viewer store.User
 270	if s.cfg.Web.Mode == "accounts" {
 271		viewer = s.viewer(r)
 272	}
 273	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
 274	ok := err == nil
 275	grant := ""
 276	if ok {
 277		if viewer.ID != 0 {
 278			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 279		}
 280		ok = policyCanRead(viewer, repo, grant)
 281	}
 282	if !ok {
 283		s.notFound(w, r)
 284		return repoPage{}, false
 285	}
 286	if ref == "" {
 287		ref = repo.DefaultBranch
 288	}
 289	topics, _ := s.st.ListTopics(repo.ID)
 290	pinned := false
 291	if viewer.ID != 0 {
 292		pinned = s.st.IsPinned(viewer.ID, repo.ID)
 293	}
 294	canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
 295	var mirrors []mirrorLine
 296	if canAdmin {
 297		ms, _ := s.st.ListMirrors(repo.ID)
 298		for _, m := range ms {
 299			mirrors = append(mirrors, mirrorLine{
 300				Direction: m.Direction,
 301				URL:       m.URL,
 302				Target:    strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
 303				Synced:    syncedAt(m.LastSync),
 304				Error:     m.LastError,
 305			})
 306		}
 307	}
 308	openIssues, openMRs := s.st.OpenCounts(repo.ID)
 309	return repoPage{
 310		basePage:   s.baseFor(viewer),
 311		CanAdmin:   canAdmin,
 312		Mirrors:    mirrors,
 313		Pinned:     pinned,
 314		HasWiki:    s.wikiDir(repo.OwnerName, repo.Name) != "",
 315		Host:       s.cfg.SiteHost(),
 316		Desc:       gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
 317		Repo:       repo,
 318		Ref:        ref,
 319		CloneURL:   s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
 320		Dir:        control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
 321		Topics:     topics,
 322		OpenIssues: openIssues,
 323		OpenMRs:    openMRs,
 324	}, true
 325}
 326
 327type crumb struct {
 328	Name string
 329	URL  string
 330}
 331
 332func crumbs(p repoPage, kind, filePath string) []crumb {
 333	var cs []crumb
 334	base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
 335	acc := ""
 336	for _, part := range strings.Split(filePath, "/") {
 337		if part == "" {
 338			continue
 339		}
 340		acc = path.Join(acc, part)
 341		cs = append(cs, crumb{Name: part, URL: base + acc})
 342	}
 343	return cs
 344}
 345
 346// ownerPage renders /{owner} for users and orgs: the repositories the
 347// viewer may see, org membership either direction. Owner names are not
 348// secret (they are on every commit); repository visibility rules hold.
 349func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
 350	name := r.PathValue("owner")
 351	var viewer store.User
 352	if s.cfg.Web.Mode == "accounts" {
 353		viewer = s.viewer(r)
 354	}
 355
 356	kind := "user"
 357	var ownerID int64
 358	var members []store.OrgMember
 359	var orgs []store.OrgMember
 360	if u, err := s.st.UserByUsername(name); err == nil {
 361		ownerID = u.ID
 362		orgs, _ = s.st.ListOrgsForUser(u.ID)
 363	} else if o, err := s.st.OrgByName(name); err == nil {
 364		kind, ownerID = "org", o.ID
 365		members, _ = s.st.OrgMembers(o.ID)
 366	} else {
 367		s.notFound(w, r)
 368		return
 369	}
 370	profile, _ := s.st.OwnerProfile(kind, ownerID)
 371
 372	all, err := s.st.ListReposForOwner(kind, ownerID)
 373	if err != nil {
 374		http.Error(w, "internal error", http.StatusInternalServerError)
 375		return
 376	}
 377	var visible []store.Repo
 378	for _, repo := range all {
 379		grant := ""
 380		if viewer.ID != 0 {
 381			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 382		}
 383		if policy.CanRead(viewer, repo, grant) {
 384			visible = append(visible, repo)
 385		}
 386	}
 387	var counts map[string]int
 388	if kind == "user" {
 389		counts, _ = s.st.ActivityByDay(ownerID, activitySince())
 390	} else {
 391		counts, _ = s.st.OrgActivityByDay(ownerID, activitySince())
 392	}
 393	weeks, activityTotal := activityGrid(counts)
 394
 395	teams, canAdmin := s.orgAdminView(viewer, kind, name)
 396	s.render(w, "owner.html", struct {
 397		basePage
 398		Owner         string
 399		Kind          string
 400		Profile       store.Profile
 401		Repos         []describedRepo
 402		Members       []store.OrgMember
 403		Orgs          []store.OrgMember
 404		Activity      []activityWeek
 405		ActivityTotal int
 406		Teams         []teamView
 407		CanAdmin      bool
 408		Notice        string
 409	}{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs,
 410		weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")})
 411}
 412
 413func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
 414	p, ok := s.repoFor(w, r, "")
 415	if !ok {
 416		return
 417	}
 418	p.Tab = "files"
 419	p.RepoHome = true
 420	s.renderTree(w, r, p, "")
 421}
 422
 423func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
 424	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 425	if !ok {
 426		return
 427	}
 428	p.Tab = "files"
 429	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
 430}
 431
 432// treePage is shared by the populated and empty-repository renders: two
 433// anonymous structs drifted apart once already.
 434type treePage struct {
 435	repoPage
 436	Crumbs      []crumb
 437	Prefix      string
 438	DirPath     string
 439	RefKind     string
 440	Entries     []gitutil.TreeEntry
 441	Branches    []gitutil.Ref
 442	ReadmeName  string
 443	ReadmeHTML  template.HTML
 444	LastCommits map[string]namedCommit
 445	Tip         namedCommit
 446	Facts       repoFacts
 447}
 448
 449func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
 450	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
 451		// Empty repo: render the page with no entries rather than 404.
 452		s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"})
 453		return
 454	}
 455	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
 456	if err != nil {
 457		s.notFound(w, r)
 458		return
 459	}
 460	// Directories first. git's tree order interleaves them with files, but
 461	// a listing is scanned by shape before name. Stable, so each group
 462	// keeps the ordering git gave it.
 463	sort.SliceStable(entries, func(i, j int) bool {
 464		return entries[i].Type == "tree" && entries[j].Type != "tree"
 465	})
 466	prefix := ""
 467	if dirPath != "" {
 468		prefix = dirPath + "/"
 469	}
 470
 471	var readmeHTML template.HTML
 472	readmeName := pickReadme(entries)
 473	if readmeName != "" {
 474		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
 475			readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
 476		}
 477	}
 478
 479	branches, _ := gitutil.Refs(p.Dir, "heads")
 480	names := make([]string, 0, len(entries))
 481	for _, e := range entries {
 482		names = append(names, e.Name)
 483	}
 484	// The facts bar is about the repository, not this directory, so it is
 485	// computed once at the root and left off subdirectory listings.
 486	var facts repoFacts
 487	if dirPath == "" {
 488		facts = s.factsFor(p)
 489	}
 490	s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
 491		readmeName, readmeHTML,
 492		s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
 493		s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts})
 494}
 495
 496func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
 497	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 498	if !ok {
 499		return
 500	}
 501	p.Tab = "files"
 502	filePath := strings.Trim(r.PathValue("path"), "/")
 503	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
 504	if err != nil {
 505		s.notFound(w, r)
 506		return
 507	}
 508	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
 509	_, image := imageTypes[strings.ToLower(path.Ext(filePath))]
 510
 511	var codeHTML template.HTML
 512	if !binary && !image {
 513		codeHTML = highlight(filePath, data)
 514	}
 515	cs := crumbs(p, "blob", filePath)
 516	base := ""
 517	if len(cs) > 0 {
 518		base = cs[len(cs)-1].Name
 519		cs = cs[:len(cs)-1]
 520	}
 521	branches, _ := gitutil.Refs(p.Dir, "heads")
 522	lines := 0
 523	if !binary && !image && len(data) > 0 {
 524		lines = bytes.Count(data, []byte("\n"))
 525		if data[len(data)-1] != '\n' {
 526			lines++
 527		}
 528	}
 529	// The file listing leads with the last commit now, so the facts about
 530	// the file itself are reported here instead.
 531	entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
 532	s.render(w, "blob.html", struct {
 533		repoPage
 534		Crumbs   []crumb
 535		Base     string
 536		Path     string
 537		DirPath  string
 538		RefKind  string
 539		Binary   bool
 540		Image    bool
 541		Size     int
 542		Lines    int
 543		Exec     bool
 544		Symlink  bool
 545		Branches []gitutil.Ref
 546		CodeHTML template.HTML
 547	}{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
 548		entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML})
 549}
 550
 551// releases lists tag-anchored releases with notes and assets.
 552func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 553	p, ok := s.repoFor(w, r, "")
 554	if !ok {
 555		return
 556	}
 557	p.Tab = "releases"
 558	rels, err := s.st.ListReleases(p.Repo.ID)
 559	if err != nil {
 560		http.Error(w, "internal error", http.StatusInternalServerError)
 561		return
 562	}
 563	md := s.ugcFor(r, p.Repo)
 564	type relView struct {
 565		store.Release
 566		NotesHTML template.HTML
 567	}
 568	var views []relView
 569	for _, rel := range rels {
 570		views = append(views, relView{rel, md(rel.Notes)})
 571	}
 572	// Tags without a release yet are what a create form can offer.
 573	released := map[string]bool{}
 574	for _, rel := range rels {
 575		released[rel.Tag] = true
 576	}
 577	var freeTags []string
 578	if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
 579		for _, tg := range tags {
 580			if !released[tg.Name] {
 581				freeTags = append(freeTags, tg.Name)
 582			}
 583		}
 584	}
 585	s.render(w, "releases.html", struct {
 586		repoPage
 587		Releases []relView
 588		FreeTags []string
 589		CanWrite bool
 590		Notice   string
 591	}{p, views, freeTags, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")})
 592}
 593
 594// releaseAsset streams one uploaded asset. Tags containing '/' are not
 595// reachable here (single path segment); SSH download always works.
 596func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 597	p, ok := s.repoFor(w, r, "")
 598	if !ok {
 599		return
 600	}
 601	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 602	if err != nil {
 603		s.notFound(w, r)
 604		return
 605	}
 606	name := r.PathValue("name")
 607	found := false
 608	for _, a := range rel.Assets {
 609		if a.Name == name {
 610			found = true
 611		}
 612	}
 613	if !found {
 614		s.notFound(w, r)
 615		return
 616	}
 617	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 618		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 619	if err != nil {
 620		s.notFound(w, r)
 621		return
 622	}
 623	defer f.Close()
 624	w.Header().Set("Content-Type", "application/octet-stream")
 625	w.Header().Set("X-Content-Type-Options", "nosniff")
 626	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 627	if fi, err := f.Stat(); err == nil {
 628		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 629	}
 630	io.Copy(w, f)
 631}
 632
 633// milestones lists a repo's milestones with progress.
 634func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 635	p, ok := s.repoFor(w, r, "")
 636	if !ok {
 637		return
 638	}
 639	p.Tab = "issues"
 640	state := r.URL.Query().Get("state")
 641	if state != "closed" && state != "all" {
 642		state = "open"
 643	}
 644	ms, err := s.st.ListMilestones(p.Repo.ID, state)
 645	if err != nil {
 646		http.Error(w, "internal error", http.StatusInternalServerError)
 647		return
 648	}
 649	type msView struct {
 650		store.Milestone
 651		Percent int
 652	}
 653	var views []msView
 654	for _, m := range ms {
 655		v := msView{Milestone: m}
 656		if total := m.OpenItems + m.ClosedItems; total > 0 {
 657			v.Percent = m.ClosedItems * 100 / total
 658		}
 659		views = append(views, v)
 660	}
 661	s.render(w, "milestones.html", struct {
 662		repoPage
 663		State      string
 664		Milestones []msView
 665	}{p, state, views})
 666}
 667
 668// search runs a bounded literal git grep over the repo's default branch.
 669func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 670	p, ok := s.repoFor(w, r, "")
 671	if !ok {
 672		return
 673	}
 674	p.Tab = "search"
 675	q := strings.TrimSpace(r.URL.Query().Get("q"))
 676	type matchView struct {
 677		Path     string
 678		Line     int
 679		TextHTML template.HTML
 680	}
 681	var matches []matchView
 682	var queryErr string
 683	if q != "" {
 684		if len(q) < 2 || len(q) > 200 {
 685			queryErr = "query must be 2 to 200 characters"
 686		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 687			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 688			if err != nil {
 689				http.Error(w, "internal error", http.StatusInternalServerError)
 690				return
 691			}
 692			for _, m := range raw {
 693				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 694			}
 695		}
 696	}
 697	s.render(w, "search.html", struct {
 698		repoPage
 699		Query    string
 700		QueryErr string
 701		Matches  []matchView
 702		Capped   bool
 703	}{p, q, queryErr, matches, len(matches) == 200})
 704}
 705
 706// markMatch escapes a matched line and wraps case-insensitive occurrences
 707// of the query in <mark>.
 708func markMatch(text, q string) template.HTML {
 709	lower, lq := strings.ToLower(text), strings.ToLower(q)
 710	var b strings.Builder
 711	pos := 0
 712	for {
 713		i := strings.Index(lower[pos:], lq)
 714		if i < 0 {
 715			break
 716		}
 717		i += pos
 718		b.WriteString(template.HTMLEscapeString(text[pos:i]))
 719		b.WriteString("<mark>")
 720		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
 721		b.WriteString("</mark>")
 722		pos = i + len(q)
 723	}
 724	b.WriteString(template.HTMLEscapeString(text[pos:]))
 725	return template.HTML(b.String())
 726}
 727
 728// blamePageSize caps how many lines one blame page renders; blame is a
 729// per-line subprocess cost, so large files paginate.
 730const blamePageSize = 1000
 731
 732func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 733	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 734	if !ok {
 735		return
 736	}
 737	p.Tab = "files"
 738	filePath := strings.Trim(r.PathValue("path"), "/")
 739	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 740	if err != nil {
 741		s.notFound(w, r)
 742		return
 743	}
 744	total := bytes.Count(data, []byte("\n"))
 745	if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
 746		total++
 747	}
 748	binary := gitutil.IsBinary(data)
 749
 750	type hunkView struct {
 751		gitutil.BlameHunk
 752		ShortSHA string
 753		Date     string
 754		Sig      sigView
 755		Numbered []numberedLine
 756	}
 757	var hunks []hunkView
 758	page, pages := 1, (total+blamePageSize-1)/blamePageSize
 759	if pages == 0 {
 760		pages = 1
 761	}
 762	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
 763		page = n
 764	}
 765	if !binary && total > 0 {
 766		start := (page-1)*blamePageSize + 1
 767		end := min(total, page*blamePageSize)
 768		raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
 769		if err != nil {
 770			s.notFound(w, r)
 771			return
 772		}
 773		sigs := map[string]sigView{}
 774		for _, h := range raw {
 775			v, ok := sigs[h.SHA]
 776			if !ok {
 777				v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 778				sigs[h.SHA] = v
 779			}
 780			hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
 781				Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
 782			for i, l := range h.Lines {
 783				hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 784			}
 785			hunks = append(hunks, hv)
 786		}
 787	}
 788	cs := crumbs(p, "blame", filePath)
 789	base := ""
 790	if len(cs) > 0 {
 791		base = cs[len(cs)-1].Name
 792		cs = cs[:len(cs)-1]
 793	}
 794	s.render(w, "blame.html", struct {
 795		repoPage
 796		Crumbs      []crumb
 797		Base        string
 798		Path        string
 799		Binary      bool
 800		Hunks       []hunkView
 801		Page, Pages int
 802	}{p, cs, base, filePath, binary, hunks, page, pages})
 803}
 804
 805type numberedLine struct {
 806	N    int
 807	Text string
 808}
 809
 810// chromaFormatter emits class-based markup (no inline colors), so the
 811// stylesheet can swap palettes with the color scheme.
 812var chromaFormatter = html.New(html.WithClasses(true),
 813	html.WithLineNumbers(true), html.LineNumbersInTable(false),
 814	html.WithLinkableLineNumbers(true, "L"))
 815
 816func highlight(filePath string, data []byte) template.HTML {
 817	lexer := lexers.Match(filePath)
 818	if lexer == nil {
 819		lexer = lexers.Fallback
 820	}
 821	iterator, err := lexer.Tokenise(nil, string(data))
 822	if err != nil {
 823		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 824	}
 825	var buf bytes.Buffer
 826	if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
 827		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 828	}
 829	return template.HTML(buf.String())
 830}
 831
 832// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
 833// The light one cannot be left unscoped: the two palettes do not name the
 834// same token set, and every token github-dark omits would keep its
 835// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
 836// Scoped, an unnamed token inherits the wrapper's colour instead, which is
 837// readable in both. The site's --code-bg stays the background either way.
 838// lightStyle and darkStyle are chosen on measured contrast against the
 839// grounds code actually sits on here — page, code block, and the diff
 840// tints. friendly, the chroma default, put 61 token/ground pairs under
 841// 4.5:1; xcode puts one.
 842const (
 843	lightStyle = "xcode"
 844	darkStyle  = "github-dark"
 845)
 846
 847var chromaCSS = func() []byte {
 848	var buf bytes.Buffer
 849	buf.WriteString("@media (prefers-color-scheme: light) {\n")
 850	chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
 851	// xcode's NameAttribute is its one token under 4.5:1 against the diff
 852	// tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
 853	buf.WriteString(".chroma .na { color: #6f5a21 }\n")
 854	buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
 855	chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
 856	buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
 857	// Line numbers take the site's own gutter colour in both schemes. Left
 858	// alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
 859	// chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
 860	// latter is a formatter fallback, not a style entry, so no palette test
 861	// can see it.
 862	buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
 863	return buf.Bytes()
 864}()
 865
 866func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 867	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 868	if !ok {
 869		return
 870	}
 871	filePath := strings.Trim(r.PathValue("path"), "/")
 872	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 873	if err != nil {
 874		s.notFound(w, r)
 875		return
 876	}
 877	// Serve inert: never let repo content execute in the forge's origin.
 878	// Images get their real type so <img> works under nosniff; SVG script
 879	// is dead on arrival because the instance CSP is script-src 'none'.
 880	ct := "text/plain; charset=utf-8"
 881	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
 882		ct = t
 883	}
 884	w.Header().Set("Content-Type", ct)
 885	w.Header().Set("X-Content-Type-Options", "nosniff")
 886	w.Write(data)
 887}
 888
 889// imageTypes are the formats raw serves with a real content type and blob
 890// pages preview inline.
 891var imageTypes = map[string]string{
 892	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
 893	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
 894	".svg": "image/svg+xml", ".ico": "image/x-icon",
 895}
 896
 897// readmeRank orders competing README files: richer renderers win.
 898var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 899
 900// pickReadme returns the best README-ish blob in a tree listing: any file
 901// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 902// we can render richly.
 903func pickReadme(entries []gitutil.TreeEntry) string {
 904	best, bestRank := "", 1<<30
 905	for _, e := range entries {
 906		if e.Type != "blob" {
 907			continue
 908		}
 909		lower := strings.ToLower(e.Name)
 910		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 911			continue
 912		}
 913		rank, ok := readmeRank[path.Ext(lower)]
 914		if !ok {
 915			rank = 10 // plaintext fallback
 916		}
 917		if rank < bestRank {
 918			best, bestRank = e.Name, rank
 919		}
 920	}
 921	return best
 922}
 923
 924// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
 925// task lists) on top of CommonMark, with class-based fence highlighting
 926// (the palette lives in the stylesheet, per scheme). Raw HTML is still
 927// dropped.
 928var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
 929	highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
 930
 931// fenceHighlight renders one code block with chroma classes, for org and
 932// anything else outside goldmark. Unknown languages fall back to plain.
 933func fenceHighlight(source, lang string) string {
 934	lexer := lexers.Get(lang)
 935	if lexer == nil {
 936		lexer = lexers.Fallback
 937	}
 938	iterator, err := lexer.Tokenise(nil, source)
 939	if err != nil {
 940		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 941	}
 942	var buf bytes.Buffer
 943	f := html.New(html.WithClasses(true))
 944	if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
 945		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 946	}
 947	return buf.String()
 948}
 949
 950// mdHTML renders user-authored markdown (issue and MR bodies, comments).
 951// goldmark's default renderer drops raw HTML, so this is safe as-is.
 952func mdHTML(raw string) template.HTML {
 953	if strings.TrimSpace(raw) == "" {
 954		return ""
 955	}
 956	var buf bytes.Buffer
 957	if markdown.Convert([]byte(raw), &buf) != nil {
 958		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
 959	}
 960	return template.HTML(buf.String())
 961}
 962
 963// webResolver answers autolink lookups for one viewer. Cross-repo
 964// references to repositories the viewer cannot read stay plain text, per
 965// the enumeration rule: a link would confirm the repo exists.
 966type webResolver struct {
 967	s      *Server
 968	viewer store.User
 969}
 970
 971func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
 972	repo, err := r.s.st.RepoByPath(owner + "/" + name)
 973	if err != nil {
 974		return ""
 975	}
 976	grant := ""
 977	if r.viewer.ID != 0 {
 978		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
 979	}
 980	if !policy.CanRead(r.viewer, repo, grant) {
 981		return ""
 982	}
 983	if kind == '#' {
 984		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
 985			return ""
 986		}
 987		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
 988	}
 989	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
 990		return ""
 991	}
 992	return autolink.MRURL(repo.OwnerName, repo.Name, n)
 993}
 994
 995func (r webResolver) UserURL(name string) string {
 996	if _, err := r.s.st.UserByUsername(name); err == nil {
 997		return "/" + name
 998	}
 999	if _, err := r.s.st.OrgByName(name); err == nil {
1000		return "/" + name
1001	}
1002	return ""
1003}
1004
1005// ugcFor returns a renderer for user-authored markdown on one repo's pages:
1006// mdHTML plus cross-reference and mention autolinking for this viewer.
1007func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
1008	viewer := store.User{}
1009	if s.cfg.Web.Mode == "accounts" {
1010		viewer = s.viewer(r)
1011	}
1012	res := webResolver{s, viewer}
1013	return func(raw string) template.HTML {
1014		h := mdHTML(raw)
1015		if h == "" {
1016			return h
1017		}
1018		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1019	}
1020}
1021
1022// renderedComment pairs a comment with its rendered body for templates.
1023type renderedComment struct {
1024	Author    string
1025	CreatedAt string
1026	Kind      string
1027	BodyHTML  template.HTML
1028}
1029
1030func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
1031	var out []renderedComment
1032	for _, c := range cs {
1033		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
1034	}
1035	return out
1036}
1037
1038// ugcPolicy sanitizes rendered repo content before it enters the forge's
1039// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1040// output and repo-authored HTML are not. Chroma's highlighting classes
1041// must survive; the pattern admits only short token codes, not the site's
1042// own class names.
1043var ugcPolicy = func() *bluemonday.Policy {
1044	p := bluemonday.UGCPolicy()
1045	p.AllowAttrs("class").
1046		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1047		OnElements("span", "pre", "code", "div")
1048	return p
1049}()
1050
1051// renderReadme renders a README by extension: markdown, org-mode, and
1052// (sanitized) HTML richly; everything else as escaped plaintext.
1053func renderReadme(name string, raw []byte) template.HTML {
1054	plain := func() template.HTML {
1055		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1056	}
1057	if gitutil.IsBinary(raw) {
1058		return ""
1059	}
1060	switch path.Ext(strings.ToLower(name)) {
1061	case ".md", ".markdown":
1062		var buf bytes.Buffer
1063		if markdown.Convert(raw, &buf) != nil {
1064			return plain()
1065		}
1066		return template.HTML(buf.String())
1067	case ".org":
1068		doc := org.New().Parse(bytes.NewReader(raw), name)
1069		writer := org.NewHTMLWriter()
1070		writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1071			if inline {
1072				return "<code>" + template.HTMLEscapeString(source) + "</code>"
1073			}
1074			return fenceHighlight(source, lang)
1075		}
1076		out, err := doc.Write(writer)
1077		if err != nil {
1078			return plain()
1079		}
1080		return template.HTML(ugcPolicy.Sanitize(out))
1081	case ".html", ".htm":
1082		return template.HTML(ugcPolicy.Sanitize(string(raw)))
1083	default:
1084		return plain()
1085	}
1086}
1087
1088type diffThread struct {
1089	ID       int64
1090	Resolved string
1091	Stale    bool
1092	Comments []renderedComment
1093}
1094
1095// attachThreads injects review threads under their anchored diff lines;
1096// threads whose anchor no longer appears (stale after force-push, or on a
1097// context line outside the current diff) are returned separately.
1098func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffFile, []diffThread) {
1099	type anchor struct {
1100		path string
1101		side string
1102		line int64
1103	}
1104	threads := map[int64]*diffThread{}
1105	anchors := map[int64]anchor{}
1106	var order []int64
1107	for _, cm := range comments {
1108		if cm.ReplyTo == 0 {
1109			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1110				Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
1111			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1112			order = append(order, cm.ID)
1113		} else if th, ok := threads[cm.ReplyTo]; ok {
1114			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
1115		}
1116	}
1117	placed := map[int64]bool{}
1118	for f := range files {
1119		lines := files[f].Lines
1120		for i := range lines {
1121			for _, id := range order {
1122				if placed[id] || threads[id].Stale {
1123					continue
1124				}
1125				a := anchors[id]
1126				if lines[i].Path != a.path {
1127					continue
1128				}
1129				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1130					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1131					lines[i].Threads = append(lines[i].Threads, *threads[id])
1132					files[f].Threads++
1133					files[f].Open = true
1134					placed[id] = true
1135				}
1136			}
1137		}
1138	}
1139	var unplaced []diffThread
1140	for _, id := range order {
1141		if !placed[id] {
1142			unplaced = append(unplaced, *threads[id])
1143		}
1144	}
1145	return files, unplaced
1146}
1147
1148type sigView struct {
1149	State       string
1150	Signer      string
1151	Fingerprint string
1152}
1153
1154func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1155	raw, err := gitutil.ReadCommit(dir, sha)
1156	if err != nil {
1157		return sigView{State: "unsigned"}, nil
1158	}
1159	parsed, err := sig.ParseCommit(raw)
1160	if err != nil {
1161		return sigView{State: "unsigned"}, nil
1162	}
1163	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1164	if err != nil {
1165		return sigView{State: "unsigned"}, parsed
1166	}
1167	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1168	if res.SignerUserID != 0 {
1169		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1170			v.Signer = u.Username
1171		}
1172	}
1173	return v, parsed
1174}
1175
1176func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1177	ref := r.PathValue("ref")
1178	p, ok := s.repoFor(w, r, ref)
1179	if !ok {
1180		return
1181	}
1182	p.Tab = "log"
1183	const pageSize = 50
1184	// ?path= filters to commits touching one file or directory.
1185	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1186	if filePath == "." {
1187		filePath = ""
1188	}
1189	var shas []string
1190	var err error
1191	if filePath != "" {
1192		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1193	} else {
1194		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1195	}
1196	if err != nil {
1197		s.notFound(w, r)
1198		return
1199	}
1200	next := ""
1201	if len(shas) > pageSize {
1202		next = shas[pageSize]
1203		shas = shas[:pageSize]
1204	}
1205	type row struct {
1206		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1207		Sig                                                               sigView
1208		Check                                                             string // combined status, "" when none ran
1209	}
1210	names := s.authorNames()
1211	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1212	var rows []row
1213	for _, sha := range shas {
1214		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1215		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1216		if parsed != nil {
1217			rw.Subject = parsed.Subject
1218			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1219			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1220			rw.AuthorEmail = parsed.AuthorEmail
1221			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1222		}
1223		rows = append(rows, rw)
1224	}
1225	s.render(w, "log.html", struct {
1226		repoPage
1227		Commits  []row
1228		NextSHA  string
1229		FilePath string
1230	}{p, rows, next, filePath})
1231}
1232
1233func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1234	p, ok := s.repoFor(w, r, "")
1235	if !ok {
1236		return
1237	}
1238	p.Tab = "log"
1239	sha := r.PathValue("sha")
1240	full, err := gitutil.ResolveRef(p.Dir, sha)
1241	if err != nil {
1242		s.notFound(w, r)
1243		return
1244	}
1245	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1246	if parsed == nil {
1247		s.notFound(w, r)
1248		return
1249	}
1250	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1251	files := parseDiff(patch)
1252	committerEmail := ""
1253	if parsed.CommitterEmail != parsed.AuthorEmail {
1254		committerEmail = parsed.CommitterEmail
1255	}
1256	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1257	commitNames := s.authorNames()
1258	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1259	msg := ""
1260	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1261		msg = string(parsed.Payload[i+2:])
1262	}
1263	s.render(w, "commit.html", struct {
1264		repoPage
1265		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1266		Parents                                                                           []string
1267		Sig                                                                               sigView
1268		Checks                                                                            []store.CommitStatus
1269		DiffFiles                                                                         []diffFile
1270	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1271		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1272		gitutil.Parents(p.Dir, full), v, checks, files})
1273}
1274
1275// labelPalette provides default label chip colors: mid-tone hues that stay
1276// legible on light and dark backgrounds.
1277var labelPalette = []string{
1278	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1279	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1280}
1281
1282var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1283
1284// labelColors returns a complete label-name -> chip color map for a repo:
1285// the stored labels.color when it is a valid hex color, otherwise a
1286// stable default picked from the palette by name hash.
1287func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1288	stored, _ := s.st.LabelColors(repoID)
1289	out := make(map[string]template.CSS, len(stored))
1290	for name, color := range stored {
1291		if !hexColorPat.MatchString(color) {
1292			h := fnv.New32a()
1293			h.Write([]byte(name))
1294			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1295		}
1296		out[name] = template.CSS("--chip:" + color)
1297	}
1298	return out
1299}
1300
1301func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1302	p, ok := s.repoFor(w, r, "")
1303	if !ok {
1304		return
1305	}
1306	p.Tab = "issues"
1307	state := r.URL.Query().Get("state")
1308	if state != "closed" && state != "all" {
1309		state = "open"
1310	}
1311	issues, err := s.st.ListIssues(p.Repo.ID, state)
1312	if err != nil {
1313		http.Error(w, "internal error", http.StatusInternalServerError)
1314		return
1315	}
1316	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1317		for i := range issues {
1318			issues[i].Labels = labels[issues[i].ID]
1319		}
1320	}
1321	// ?label=x narrows to issues carrying that label (chips link here).
1322	labelFilter := r.URL.Query().Get("label")
1323	if labelFilter != "" {
1324		var kept []store.Issue
1325		for _, iss := range issues {
1326			for _, l := range iss.Labels {
1327				if l == labelFilter {
1328					kept = append(kept, iss)
1329					break
1330				}
1331			}
1332		}
1333		issues = kept
1334	}
1335	s.render(w, "issues.html", struct {
1336		repoPage
1337		State       string
1338		Label       string
1339		Issues      []store.Issue
1340		LabelColors map[string]template.CSS
1341	}{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1342}
1343
1344func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1345	p, ok := s.repoFor(w, r, "")
1346	if !ok {
1347		return
1348	}
1349	p.Tab = "issues"
1350	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1351	if err != nil {
1352		s.notFound(w, r)
1353		return
1354	}
1355	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1356	if err != nil {
1357		s.notFound(w, r)
1358		return
1359	}
1360	comments, err := s.st.ListIssueComments(iss.ID)
1361	if err != nil {
1362		http.Error(w, "internal error", http.StatusInternalServerError)
1363		return
1364	}
1365	md := s.ugcFor(r, p.Repo)
1366	milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1367	s.render(w, "issue.html", struct {
1368		repoPage
1369		Issue       store.Issue
1370		BodyHTML    template.HTML
1371		Comments    []renderedComment
1372		CanEdit     bool
1373		CanWrite    bool
1374		Milestones  []store.Milestone
1375		Notice      string
1376		LabelColors map[string]template.CSS
1377	}{p, iss, md(iss.Body), renderComments(comments, md),
1378		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1379		milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1380}
1381
1382// canEditItem: the author or anyone with write access may edit.
1383// canWriteRepo reports whether the browser session may push to the repo,
1384// which is what gates the review and merge controls.
1385func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1386	if s.cfg.Web.Mode != "accounts" {
1387		return false
1388	}
1389	u := s.viewer(r)
1390	if u.ID == 0 {
1391		return false
1392	}
1393	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1394	return policy.CanWrite(u, repo, grant)
1395}
1396
1397func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1398	if s.cfg.Web.Mode != "accounts" {
1399		return false
1400	}
1401	u := s.viewer(r)
1402	if u.ID == 0 {
1403		return false
1404	}
1405	if u.Username == author {
1406		return true
1407	}
1408	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1409	return policy.CanWrite(u, repo, grant)
1410}
1411
1412func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1413	p, ok := s.repoFor(w, r, "")
1414	if !ok {
1415		return
1416	}
1417	p.Tab = "merge requests"
1418	state := r.URL.Query().Get("state")
1419	if state == "" {
1420		state = "open"
1421	}
1422	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1423	if !valid[state] {
1424		state = "open"
1425	}
1426	mrs, err := s.st.ListMRs(p.Repo.ID, state)
1427	if err != nil {
1428		http.Error(w, "internal error", http.StatusInternalServerError)
1429		return
1430	}
1431	s.render(w, "mrs.html", struct {
1432		repoPage
1433		State string
1434		MRs   []store.MR
1435	}{p, state, mrs})
1436}
1437
1438func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1439	p, ok := s.repoFor(w, r, "")
1440	if !ok {
1441		return
1442	}
1443	p.Tab = "merge requests"
1444	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1445	if err != nil {
1446		s.notFound(w, r)
1447		return
1448	}
1449	m, err := s.st.MRByNumber(p.Repo.ID, n)
1450	if err != nil {
1451		s.notFound(w, r)
1452		return
1453	}
1454	comments, _ := s.st.ListMRComments(m.ID)
1455	reviews, _ := s.st.ListMRReviews(m.ID)
1456	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1457	diffComments, _ := s.st.ListDiffComments(m.ID)
1458
1459	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1460	var files []diffFile
1461	base := m.MergedBase
1462	if base == "" {
1463		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1464			base = b
1465		}
1466	}
1467	if base != "" {
1468		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1469			files = parseDiff(patch)
1470		}
1471	}
1472	md := s.ugcFor(r, p.Repo)
1473	var detachedThreads []diffThread
1474	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md)
1475	stat := statOf(files)
1476	// The commits this MR carries: base..head, the same range as the diff.
1477	type commitRow struct {
1478		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1479		Sig                                                  sigView
1480	}
1481	mrNames := s.authorNames()
1482	var commits []commitRow
1483	if base != "" {
1484		const maxMRCommits = 100
1485		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1486		if len(shas) > maxMRCommits {
1487			shas = shas[:maxMRCommits]
1488		}
1489		for _, sha := range shas {
1490			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1491			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1492			if parsed != nil {
1493				cr.Subject = parsed.Subject
1494				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1495				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1496				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1497			}
1498			commits = append(commits, cr)
1499		}
1500	}
1501	// The diff is the reason most people open a merge request, so it gets
1502	// its own view rather than a fold at the foot of the conversation.
1503	// A query parameter keeps this working without JavaScript.
1504	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1505	view := r.URL.Query().Get("view")
1506	if view != "commits" && view != "diff" {
1507		view = "conversation"
1508	}
1509	s.render(w, "mr.html", struct {
1510		repoPage
1511		MR              store.MR
1512		View            string
1513		BodyHTML        template.HTML
1514		Checks          []store.CommitStatus
1515		Combined        string
1516		Comments        []renderedComment
1517		Reviews         []store.MRReview
1518		DiffFiles       []diffFile
1519		Stat            diffStat
1520		Commits         []commitRow
1521		CanEdit         bool
1522		CanWrite        bool
1523		Unresolved      int
1524		Notice          string
1525		DetachedThreads []diffThread
1526	}{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1527		reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1528		s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
1529}
1530
1531func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1532	p, ok := s.repoFor(w, r, "")
1533	if !ok {
1534		return
1535	}
1536	p.Tab = "refs"
1537	branches, _ := gitutil.Refs(p.Dir, "heads")
1538	tags, _ := gitutil.Refs(p.Dir, "tags")
1539	s.render(w, "refs.html", struct {
1540		repoPage
1541		Branches, Tags []gitutil.Ref
1542	}{p, branches, tags})
1543}
1544
1545func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1546	p, ok := s.repoFor(w, r, "")
1547	if !ok {
1548		return
1549	}
1550	file := r.PathValue("file")
1551	ref, ok := strings.CutSuffix(file, ".tar.gz")
1552	if !ok {
1553		s.notFound(w, r)
1554		return
1555	}
1556	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1557		s.notFound(w, r)
1558		return
1559	}
1560	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1561	w.Header().Set("Content-Type", "application/gzip")
1562	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1563	gitutil.Archive(p.Dir, ref, prefix, w)
1564}
1565
1566func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1567	return policy.CanAdmin(u, repo, grant)
1568}
1569
1570func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1571	return policy.CanRead(u, repo, grant)
1572}