internal/httpd/web.go

1790a024c2586d46bfb1b57f69adc529e7f78282
gitbay/internal/httpd/web.go history · blame · raw

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