internal/httpd/web.go

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

1554 lines · 45520 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("friendly"), 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.
 838var chromaCSS = func() []byte {
 839	var buf bytes.Buffer
 840	buf.WriteString("@media (prefers-color-scheme: light) {\n")
 841	chromaFormatter.WriteCSS(&buf, styles.Get("friendly"))
 842	buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
 843	chromaFormatter.WriteCSS(&buf, styles.Get("github-dark"))
 844	buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
 845	return buf.Bytes()
 846}()
 847
 848func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 849	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 850	if !ok {
 851		return
 852	}
 853	filePath := strings.Trim(r.PathValue("path"), "/")
 854	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 855	if err != nil {
 856		s.notFound(w, r)
 857		return
 858	}
 859	// Serve inert: never let repo content execute in the forge's origin.
 860	// Images get their real type so <img> works under nosniff; SVG script
 861	// is dead on arrival because the instance CSP is script-src 'none'.
 862	ct := "text/plain; charset=utf-8"
 863	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
 864		ct = t
 865	}
 866	w.Header().Set("Content-Type", ct)
 867	w.Header().Set("X-Content-Type-Options", "nosniff")
 868	w.Write(data)
 869}
 870
 871// imageTypes are the formats raw serves with a real content type and blob
 872// pages preview inline.
 873var imageTypes = map[string]string{
 874	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
 875	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
 876	".svg": "image/svg+xml", ".ico": "image/x-icon",
 877}
 878
 879// readmeRank orders competing README files: richer renderers win.
 880var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 881
 882// pickReadme returns the best README-ish blob in a tree listing: any file
 883// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 884// we can render richly.
 885func pickReadme(entries []gitutil.TreeEntry) string {
 886	best, bestRank := "", 1<<30
 887	for _, e := range entries {
 888		if e.Type != "blob" {
 889			continue
 890		}
 891		lower := strings.ToLower(e.Name)
 892		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 893			continue
 894		}
 895		rank, ok := readmeRank[path.Ext(lower)]
 896		if !ok {
 897			rank = 10 // plaintext fallback
 898		}
 899		if rank < bestRank {
 900			best, bestRank = e.Name, rank
 901		}
 902	}
 903	return best
 904}
 905
 906// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
 907// task lists) on top of CommonMark, with class-based fence highlighting
 908// (the palette lives in the stylesheet, per scheme). Raw HTML is still
 909// dropped.
 910var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
 911	highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
 912
 913// fenceHighlight renders one code block with chroma classes, for org and
 914// anything else outside goldmark. Unknown languages fall back to plain.
 915func fenceHighlight(source, lang string) string {
 916	lexer := lexers.Get(lang)
 917	if lexer == nil {
 918		lexer = lexers.Fallback
 919	}
 920	iterator, err := lexer.Tokenise(nil, source)
 921	if err != nil {
 922		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 923	}
 924	var buf bytes.Buffer
 925	f := html.New(html.WithClasses(true))
 926	if err := f.Format(&buf, styles.Get("friendly"), iterator); err != nil {
 927		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 928	}
 929	return buf.String()
 930}
 931
 932// mdHTML renders user-authored markdown (issue and MR bodies, comments).
 933// goldmark's default renderer drops raw HTML, so this is safe as-is.
 934func mdHTML(raw string) template.HTML {
 935	if strings.TrimSpace(raw) == "" {
 936		return ""
 937	}
 938	var buf bytes.Buffer
 939	if markdown.Convert([]byte(raw), &buf) != nil {
 940		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
 941	}
 942	return template.HTML(buf.String())
 943}
 944
 945// webResolver answers autolink lookups for one viewer. Cross-repo
 946// references to repositories the viewer cannot read stay plain text, per
 947// the enumeration rule: a link would confirm the repo exists.
 948type webResolver struct {
 949	s      *Server
 950	viewer store.User
 951}
 952
 953func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
 954	repo, err := r.s.st.RepoByPath(owner + "/" + name)
 955	if err != nil {
 956		return ""
 957	}
 958	grant := ""
 959	if r.viewer.ID != 0 {
 960		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
 961	}
 962	if !policy.CanRead(r.viewer, repo, grant) {
 963		return ""
 964	}
 965	if kind == '#' {
 966		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
 967			return ""
 968		}
 969		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
 970	}
 971	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
 972		return ""
 973	}
 974	return autolink.MRURL(repo.OwnerName, repo.Name, n)
 975}
 976
 977func (r webResolver) UserURL(name string) string {
 978	if _, err := r.s.st.UserByUsername(name); err == nil {
 979		return "/" + name
 980	}
 981	if _, err := r.s.st.OrgByName(name); err == nil {
 982		return "/" + name
 983	}
 984	return ""
 985}
 986
 987// ugcFor returns a renderer for user-authored markdown on one repo's pages:
 988// mdHTML plus cross-reference and mention autolinking for this viewer.
 989func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
 990	viewer := store.User{}
 991	if s.cfg.Web.Mode == "accounts" {
 992		viewer = s.viewer(r)
 993	}
 994	res := webResolver{s, viewer}
 995	return func(raw string) template.HTML {
 996		h := mdHTML(raw)
 997		if h == "" {
 998			return h
 999		}
1000		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1001	}
1002}
1003
1004// renderedComment pairs a comment with its rendered body for templates.
1005type renderedComment struct {
1006	Author    string
1007	CreatedAt string
1008	Kind      string
1009	BodyHTML  template.HTML
1010}
1011
1012func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
1013	var out []renderedComment
1014	for _, c := range cs {
1015		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
1016	}
1017	return out
1018}
1019
1020// ugcPolicy sanitizes rendered repo content before it enters the forge's
1021// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1022// output and repo-authored HTML are not. Chroma's highlighting classes
1023// must survive; the pattern admits only short token codes, not the site's
1024// own class names.
1025var ugcPolicy = func() *bluemonday.Policy {
1026	p := bluemonday.UGCPolicy()
1027	p.AllowAttrs("class").
1028		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1029		OnElements("span", "pre", "code", "div")
1030	return p
1031}()
1032
1033// renderReadme renders a README by extension: markdown, org-mode, and
1034// (sanitized) HTML richly; everything else as escaped plaintext.
1035func renderReadme(name string, raw []byte) template.HTML {
1036	plain := func() template.HTML {
1037		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1038	}
1039	if gitutil.IsBinary(raw) {
1040		return ""
1041	}
1042	switch path.Ext(strings.ToLower(name)) {
1043	case ".md", ".markdown":
1044		var buf bytes.Buffer
1045		if markdown.Convert(raw, &buf) != nil {
1046			return plain()
1047		}
1048		return template.HTML(buf.String())
1049	case ".org":
1050		doc := org.New().Parse(bytes.NewReader(raw), name)
1051		writer := org.NewHTMLWriter()
1052		writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1053			if inline {
1054				return "<code>" + template.HTMLEscapeString(source) + "</code>"
1055			}
1056			return fenceHighlight(source, lang)
1057		}
1058		out, err := doc.Write(writer)
1059		if err != nil {
1060			return plain()
1061		}
1062		return template.HTML(ugcPolicy.Sanitize(out))
1063	case ".html", ".htm":
1064		return template.HTML(ugcPolicy.Sanitize(string(raw)))
1065	default:
1066		return plain()
1067	}
1068}
1069
1070type diffThread struct {
1071	ID       int64
1072	Resolved string
1073	Stale    bool
1074	Comments []renderedComment
1075}
1076
1077// attachThreads injects review threads under their anchored diff lines;
1078// threads whose anchor no longer appears (stale after force-push, or on a
1079// context line outside the current diff) are returned separately.
1080func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffFile, []diffThread) {
1081	type anchor struct {
1082		path string
1083		side string
1084		line int64
1085	}
1086	threads := map[int64]*diffThread{}
1087	anchors := map[int64]anchor{}
1088	var order []int64
1089	for _, cm := range comments {
1090		if cm.ReplyTo == 0 {
1091			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1092				Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
1093			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1094			order = append(order, cm.ID)
1095		} else if th, ok := threads[cm.ReplyTo]; ok {
1096			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
1097		}
1098	}
1099	placed := map[int64]bool{}
1100	for f := range files {
1101		lines := files[f].Lines
1102		for i := range lines {
1103			for _, id := range order {
1104				if placed[id] || threads[id].Stale {
1105					continue
1106				}
1107				a := anchors[id]
1108				if lines[i].Path != a.path {
1109					continue
1110				}
1111				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1112					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1113					lines[i].Threads = append(lines[i].Threads, *threads[id])
1114					files[f].Threads++
1115					files[f].Open = true
1116					placed[id] = true
1117				}
1118			}
1119		}
1120	}
1121	var unplaced []diffThread
1122	for _, id := range order {
1123		if !placed[id] {
1124			unplaced = append(unplaced, *threads[id])
1125		}
1126	}
1127	return files, unplaced
1128}
1129
1130type sigView struct {
1131	State       string
1132	Signer      string
1133	Fingerprint string
1134}
1135
1136func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1137	raw, err := gitutil.ReadCommit(dir, sha)
1138	if err != nil {
1139		return sigView{State: "unsigned"}, nil
1140	}
1141	parsed, err := sig.ParseCommit(raw)
1142	if err != nil {
1143		return sigView{State: "unsigned"}, nil
1144	}
1145	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1146	if err != nil {
1147		return sigView{State: "unsigned"}, parsed
1148	}
1149	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1150	if res.SignerUserID != 0 {
1151		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1152			v.Signer = u.Username
1153		}
1154	}
1155	return v, parsed
1156}
1157
1158func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1159	ref := r.PathValue("ref")
1160	p, ok := s.repoFor(w, r, ref)
1161	if !ok {
1162		return
1163	}
1164	p.Tab = "log"
1165	const pageSize = 50
1166	// ?path= filters to commits touching one file or directory.
1167	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1168	if filePath == "." {
1169		filePath = ""
1170	}
1171	var shas []string
1172	var err error
1173	if filePath != "" {
1174		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1175	} else {
1176		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1177	}
1178	if err != nil {
1179		s.notFound(w, r)
1180		return
1181	}
1182	next := ""
1183	if len(shas) > pageSize {
1184		next = shas[pageSize]
1185		shas = shas[:pageSize]
1186	}
1187	type row struct {
1188		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1189		Sig                                                               sigView
1190		Check                                                             string // combined status, "" when none ran
1191	}
1192	names := s.authorNames()
1193	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1194	var rows []row
1195	for _, sha := range shas {
1196		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1197		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1198		if parsed != nil {
1199			rw.Subject = parsed.Subject
1200			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1201			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1202			rw.AuthorEmail = parsed.AuthorEmail
1203			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1204		}
1205		rows = append(rows, rw)
1206	}
1207	s.render(w, "log.html", struct {
1208		repoPage
1209		Commits  []row
1210		NextSHA  string
1211		FilePath string
1212	}{p, rows, next, filePath})
1213}
1214
1215func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1216	p, ok := s.repoFor(w, r, "")
1217	if !ok {
1218		return
1219	}
1220	p.Tab = "log"
1221	sha := r.PathValue("sha")
1222	full, err := gitutil.ResolveRef(p.Dir, sha)
1223	if err != nil {
1224		s.notFound(w, r)
1225		return
1226	}
1227	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1228	if parsed == nil {
1229		s.notFound(w, r)
1230		return
1231	}
1232	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1233	files := parseDiff(patch)
1234	committerEmail := ""
1235	if parsed.CommitterEmail != parsed.AuthorEmail {
1236		committerEmail = parsed.CommitterEmail
1237	}
1238	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1239	commitNames := s.authorNames()
1240	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1241	msg := ""
1242	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1243		msg = string(parsed.Payload[i+2:])
1244	}
1245	s.render(w, "commit.html", struct {
1246		repoPage
1247		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1248		Parents                                                                           []string
1249		Sig                                                                               sigView
1250		Checks                                                                            []store.CommitStatus
1251		DiffFiles                                                                         []diffFile
1252	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1253		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1254		gitutil.Parents(p.Dir, full), v, checks, files})
1255}
1256
1257// labelPalette provides default label chip colors: mid-tone hues that stay
1258// legible on light and dark backgrounds.
1259var labelPalette = []string{
1260	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1261	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1262}
1263
1264var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1265
1266// labelColors returns a complete label-name -> chip color map for a repo:
1267// the stored labels.color when it is a valid hex color, otherwise a
1268// stable default picked from the palette by name hash.
1269func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1270	stored, _ := s.st.LabelColors(repoID)
1271	out := make(map[string]template.CSS, len(stored))
1272	for name, color := range stored {
1273		if !hexColorPat.MatchString(color) {
1274			h := fnv.New32a()
1275			h.Write([]byte(name))
1276			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1277		}
1278		out[name] = template.CSS("--chip:" + color)
1279	}
1280	return out
1281}
1282
1283func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1284	p, ok := s.repoFor(w, r, "")
1285	if !ok {
1286		return
1287	}
1288	p.Tab = "issues"
1289	state := r.URL.Query().Get("state")
1290	if state != "closed" && state != "all" {
1291		state = "open"
1292	}
1293	issues, err := s.st.ListIssues(p.Repo.ID, state)
1294	if err != nil {
1295		http.Error(w, "internal error", http.StatusInternalServerError)
1296		return
1297	}
1298	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1299		for i := range issues {
1300			issues[i].Labels = labels[issues[i].ID]
1301		}
1302	}
1303	// ?label=x narrows to issues carrying that label (chips link here).
1304	labelFilter := r.URL.Query().Get("label")
1305	if labelFilter != "" {
1306		var kept []store.Issue
1307		for _, iss := range issues {
1308			for _, l := range iss.Labels {
1309				if l == labelFilter {
1310					kept = append(kept, iss)
1311					break
1312				}
1313			}
1314		}
1315		issues = kept
1316	}
1317	s.render(w, "issues.html", struct {
1318		repoPage
1319		State       string
1320		Label       string
1321		Issues      []store.Issue
1322		LabelColors map[string]template.CSS
1323	}{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1324}
1325
1326func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1327	p, ok := s.repoFor(w, r, "")
1328	if !ok {
1329		return
1330	}
1331	p.Tab = "issues"
1332	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1333	if err != nil {
1334		s.notFound(w, r)
1335		return
1336	}
1337	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1338	if err != nil {
1339		s.notFound(w, r)
1340		return
1341	}
1342	comments, err := s.st.ListIssueComments(iss.ID)
1343	if err != nil {
1344		http.Error(w, "internal error", http.StatusInternalServerError)
1345		return
1346	}
1347	md := s.ugcFor(r, p.Repo)
1348	milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1349	s.render(w, "issue.html", struct {
1350		repoPage
1351		Issue       store.Issue
1352		BodyHTML    template.HTML
1353		Comments    []renderedComment
1354		CanEdit     bool
1355		CanWrite    bool
1356		Milestones  []store.Milestone
1357		Notice      string
1358		LabelColors map[string]template.CSS
1359	}{p, iss, md(iss.Body), renderComments(comments, md),
1360		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1361		milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1362}
1363
1364// canEditItem: the author or anyone with write access may edit.
1365// canWriteRepo reports whether the browser session may push to the repo,
1366// which is what gates the review and merge controls.
1367func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1368	if s.cfg.Web.Mode != "accounts" {
1369		return false
1370	}
1371	u := s.viewer(r)
1372	if u.ID == 0 {
1373		return false
1374	}
1375	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1376	return policy.CanWrite(u, repo, grant)
1377}
1378
1379func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1380	if s.cfg.Web.Mode != "accounts" {
1381		return false
1382	}
1383	u := s.viewer(r)
1384	if u.ID == 0 {
1385		return false
1386	}
1387	if u.Username == author {
1388		return true
1389	}
1390	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1391	return policy.CanWrite(u, repo, grant)
1392}
1393
1394func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1395	p, ok := s.repoFor(w, r, "")
1396	if !ok {
1397		return
1398	}
1399	p.Tab = "merge requests"
1400	state := r.URL.Query().Get("state")
1401	if state == "" {
1402		state = "open"
1403	}
1404	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1405	if !valid[state] {
1406		state = "open"
1407	}
1408	mrs, err := s.st.ListMRs(p.Repo.ID, state)
1409	if err != nil {
1410		http.Error(w, "internal error", http.StatusInternalServerError)
1411		return
1412	}
1413	s.render(w, "mrs.html", struct {
1414		repoPage
1415		State string
1416		MRs   []store.MR
1417	}{p, state, mrs})
1418}
1419
1420func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1421	p, ok := s.repoFor(w, r, "")
1422	if !ok {
1423		return
1424	}
1425	p.Tab = "merge requests"
1426	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1427	if err != nil {
1428		s.notFound(w, r)
1429		return
1430	}
1431	m, err := s.st.MRByNumber(p.Repo.ID, n)
1432	if err != nil {
1433		s.notFound(w, r)
1434		return
1435	}
1436	comments, _ := s.st.ListMRComments(m.ID)
1437	reviews, _ := s.st.ListMRReviews(m.ID)
1438	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1439	diffComments, _ := s.st.ListDiffComments(m.ID)
1440
1441	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1442	var files []diffFile
1443	base := m.MergedBase
1444	if base == "" {
1445		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1446			base = b
1447		}
1448	}
1449	if base != "" {
1450		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1451			files = parseDiff(patch)
1452		}
1453	}
1454	md := s.ugcFor(r, p.Repo)
1455	var detachedThreads []diffThread
1456	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md)
1457	stat := statOf(files)
1458	// The commits this MR carries: base..head, the same range as the diff.
1459	type commitRow struct {
1460		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1461		Sig                                                  sigView
1462	}
1463	mrNames := s.authorNames()
1464	var commits []commitRow
1465	if base != "" {
1466		const maxMRCommits = 100
1467		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1468		if len(shas) > maxMRCommits {
1469			shas = shas[:maxMRCommits]
1470		}
1471		for _, sha := range shas {
1472			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1473			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1474			if parsed != nil {
1475				cr.Subject = parsed.Subject
1476				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1477				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1478				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1479			}
1480			commits = append(commits, cr)
1481		}
1482	}
1483	// The diff is the reason most people open a merge request, so it gets
1484	// its own view rather than a fold at the foot of the conversation.
1485	// A query parameter keeps this working without JavaScript.
1486	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1487	view := r.URL.Query().Get("view")
1488	if view != "commits" && view != "diff" {
1489		view = "conversation"
1490	}
1491	s.render(w, "mr.html", struct {
1492		repoPage
1493		MR              store.MR
1494		View            string
1495		BodyHTML        template.HTML
1496		Checks          []store.CommitStatus
1497		Combined        string
1498		Comments        []renderedComment
1499		Reviews         []store.MRReview
1500		DiffFiles       []diffFile
1501		Stat            diffStat
1502		Commits         []commitRow
1503		CanEdit         bool
1504		CanWrite        bool
1505		Unresolved      int
1506		Notice          string
1507		DetachedThreads []diffThread
1508	}{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1509		reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1510		s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
1511}
1512
1513func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1514	p, ok := s.repoFor(w, r, "")
1515	if !ok {
1516		return
1517	}
1518	p.Tab = "refs"
1519	branches, _ := gitutil.Refs(p.Dir, "heads")
1520	tags, _ := gitutil.Refs(p.Dir, "tags")
1521	s.render(w, "refs.html", struct {
1522		repoPage
1523		Branches, Tags []gitutil.Ref
1524	}{p, branches, tags})
1525}
1526
1527func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1528	p, ok := s.repoFor(w, r, "")
1529	if !ok {
1530		return
1531	}
1532	file := r.PathValue("file")
1533	ref, ok := strings.CutSuffix(file, ".tar.gz")
1534	if !ok {
1535		s.notFound(w, r)
1536		return
1537	}
1538	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1539		s.notFound(w, r)
1540		return
1541	}
1542	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1543	w.Header().Set("Content-Type", "application/gzip")
1544	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1545	gitutil.Archive(p.Dir, ref, prefix, w)
1546}
1547
1548func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1549	return policy.CanAdmin(u, repo, grant)
1550}
1551
1552func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1553	return policy.CanRead(u, repo, grant)
1554}