internal/httpd/web.go

1601 lines · 46341 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	s.render(w, "owner.html", struct {
 396		basePage
 397		Owner         string
 398		Kind          string
 399		Profile       store.Profile
 400		Repos         []describedRepo
 401		Members       []store.OrgMember
 402		Orgs          []store.OrgMember
 403		Activity      []activityWeek
 404		ActivityTotal int
 405	}{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs,
 406		weeks, activityTotal})
 407}
 408
 409func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
 410	p, ok := s.repoFor(w, r, "")
 411	if !ok {
 412		return
 413	}
 414	p.Tab = "files"
 415	p.RepoHome = true
 416	s.renderTree(w, r, p, "")
 417}
 418
 419func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
 420	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 421	if !ok {
 422		return
 423	}
 424	p.Tab = "files"
 425	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
 426}
 427
 428func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
 429	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
 430		// Empty repo: render the page with no entries rather than 404.
 431		s.render(w, "tree.html", struct {
 432			repoPage
 433			Crumbs      []crumb
 434			Prefix      string
 435			DirPath     string
 436			RefKind     string
 437			Entries     []gitutil.TreeEntry
 438			Branches    []gitutil.Ref
 439			ReadmeName  string
 440			ReadmeHTML  template.HTML
 441			LastCommits map[string]namedCommit
 442			Tip         namedCommit
 443		}{repoPage: p, RefKind: "tree"})
 444		return
 445	}
 446	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
 447	if err != nil {
 448		s.notFound(w, r)
 449		return
 450	}
 451	// Directories first. git's tree order interleaves them with files, but
 452	// a listing is scanned by shape before name. Stable, so each group
 453	// keeps the ordering git gave it.
 454	sort.SliceStable(entries, func(i, j int) bool {
 455		return entries[i].Type == "tree" && entries[j].Type != "tree"
 456	})
 457	prefix := ""
 458	if dirPath != "" {
 459		prefix = dirPath + "/"
 460	}
 461
 462	var readmeHTML template.HTML
 463	readmeName := pickReadme(entries)
 464	if readmeName != "" {
 465		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
 466			readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
 467		}
 468	}
 469
 470	branches, _ := gitutil.Refs(p.Dir, "heads")
 471	names := make([]string, 0, len(entries))
 472	for _, e := range entries {
 473		names = append(names, e.Name)
 474	}
 475	s.render(w, "tree.html", struct {
 476		repoPage
 477		Crumbs      []crumb
 478		Prefix      string
 479		DirPath     string
 480		RefKind     string
 481		Entries     []gitutil.TreeEntry
 482		Branches    []gitutil.Ref
 483		ReadmeName  string
 484		ReadmeHTML  template.HTML
 485		LastCommits map[string]namedCommit
 486		Tip         namedCommit
 487	}{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
 488		readmeName, readmeHTML,
 489		s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
 490		s.namedTip(gitutil.TipCommit(p.Dir, p.Ref))})
 491}
 492
 493func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
 494	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 495	if !ok {
 496		return
 497	}
 498	p.Tab = "files"
 499	filePath := strings.Trim(r.PathValue("path"), "/")
 500	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
 501	if err != nil {
 502		s.notFound(w, r)
 503		return
 504	}
 505	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
 506	_, image := imageTypes[strings.ToLower(path.Ext(filePath))]
 507
 508	var codeHTML template.HTML
 509	if !binary && !image {
 510		codeHTML = highlight(filePath, data)
 511	}
 512	cs := crumbs(p, "blob", filePath)
 513	base := ""
 514	if len(cs) > 0 {
 515		base = cs[len(cs)-1].Name
 516		cs = cs[:len(cs)-1]
 517	}
 518	branches, _ := gitutil.Refs(p.Dir, "heads")
 519	lines := 0
 520	if !binary && !image && len(data) > 0 {
 521		lines = bytes.Count(data, []byte("\n"))
 522		if data[len(data)-1] != '\n' {
 523			lines++
 524		}
 525	}
 526	// The file listing leads with the last commit now, so the facts about
 527	// the file itself are reported here instead.
 528	entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
 529	s.render(w, "blob.html", struct {
 530		repoPage
 531		Crumbs   []crumb
 532		Base     string
 533		Path     string
 534		DirPath  string
 535		RefKind  string
 536		Binary   bool
 537		Image    bool
 538		Size     int
 539		Lines    int
 540		Exec     bool
 541		Symlink  bool
 542		Branches []gitutil.Ref
 543		CodeHTML template.HTML
 544	}{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
 545		entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML})
 546}
 547
 548// releases lists tag-anchored releases with notes and assets.
 549func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 550	p, ok := s.repoFor(w, r, "")
 551	if !ok {
 552		return
 553	}
 554	p.Tab = "releases"
 555	rels, err := s.st.ListReleases(p.Repo.ID)
 556	if err != nil {
 557		http.Error(w, "internal error", http.StatusInternalServerError)
 558		return
 559	}
 560	md := s.ugcFor(r, p.Repo)
 561	type relView struct {
 562		store.Release
 563		NotesHTML template.HTML
 564	}
 565	var views []relView
 566	for _, rel := range rels {
 567		views = append(views, relView{rel, md(rel.Notes)})
 568	}
 569	// Tags without a release yet are what a create form can offer.
 570	released := map[string]bool{}
 571	for _, rel := range rels {
 572		released[rel.Tag] = true
 573	}
 574	var freeTags []string
 575	if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
 576		for _, tg := range tags {
 577			if !released[tg.Name] {
 578				freeTags = append(freeTags, tg.Name)
 579			}
 580		}
 581	}
 582	s.render(w, "releases.html", struct {
 583		repoPage
 584		Releases []relView
 585		FreeTags []string
 586		CanWrite bool
 587		Notice   string
 588	}{p, views, freeTags, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")})
 589}
 590
 591// releaseAsset streams one uploaded asset. Tags containing '/' are not
 592// reachable here (single path segment); SSH download always works.
 593func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 594	p, ok := s.repoFor(w, r, "")
 595	if !ok {
 596		return
 597	}
 598	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 599	if err != nil {
 600		s.notFound(w, r)
 601		return
 602	}
 603	name := r.PathValue("name")
 604	found := false
 605	for _, a := range rel.Assets {
 606		if a.Name == name {
 607			found = true
 608		}
 609	}
 610	if !found {
 611		s.notFound(w, r)
 612		return
 613	}
 614	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 615		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 616	if err != nil {
 617		s.notFound(w, r)
 618		return
 619	}
 620	defer f.Close()
 621	w.Header().Set("Content-Type", "application/octet-stream")
 622	w.Header().Set("X-Content-Type-Options", "nosniff")
 623	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 624	if fi, err := f.Stat(); err == nil {
 625		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 626	}
 627	io.Copy(w, f)
 628}
 629
 630// milestones lists a repo's milestones with progress.
 631func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 632	p, ok := s.repoFor(w, r, "")
 633	if !ok {
 634		return
 635	}
 636	p.Tab = "issues"
 637	state := r.URL.Query().Get("state")
 638	if state != "closed" && state != "all" {
 639		state = "open"
 640	}
 641	ms, err := s.st.ListMilestones(p.Repo.ID, state)
 642	if err != nil {
 643		http.Error(w, "internal error", http.StatusInternalServerError)
 644		return
 645	}
 646	type msView struct {
 647		store.Milestone
 648		Percent int
 649	}
 650	var views []msView
 651	for _, m := range ms {
 652		v := msView{Milestone: m}
 653		if total := m.OpenItems + m.ClosedItems; total > 0 {
 654			v.Percent = m.ClosedItems * 100 / total
 655		}
 656		views = append(views, v)
 657	}
 658	s.render(w, "milestones.html", struct {
 659		repoPage
 660		State      string
 661		Milestones []msView
 662	}{p, state, views})
 663}
 664
 665// search runs a bounded literal git grep over the repo's default branch.
 666func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 667	p, ok := s.repoFor(w, r, "")
 668	if !ok {
 669		return
 670	}
 671	p.Tab = "search"
 672	q := strings.TrimSpace(r.URL.Query().Get("q"))
 673	type matchView struct {
 674		Path     string
 675		Line     int
 676		TextHTML template.HTML
 677	}
 678	var matches []matchView
 679	var queryErr string
 680	if q != "" {
 681		if len(q) < 2 || len(q) > 200 {
 682			queryErr = "query must be 2 to 200 characters"
 683		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 684			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 685			if err != nil {
 686				http.Error(w, "internal error", http.StatusInternalServerError)
 687				return
 688			}
 689			for _, m := range raw {
 690				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 691			}
 692		}
 693	}
 694	s.render(w, "search.html", struct {
 695		repoPage
 696		Query    string
 697		QueryErr string
 698		Matches  []matchView
 699		Capped   bool
 700	}{p, q, queryErr, matches, len(matches) == 200})
 701}
 702
 703// markMatch escapes a matched line and wraps case-insensitive occurrences
 704// of the query in <mark>.
 705func markMatch(text, q string) template.HTML {
 706	lower, lq := strings.ToLower(text), strings.ToLower(q)
 707	var b strings.Builder
 708	pos := 0
 709	for {
 710		i := strings.Index(lower[pos:], lq)
 711		if i < 0 {
 712			break
 713		}
 714		i += pos
 715		b.WriteString(template.HTMLEscapeString(text[pos:i]))
 716		b.WriteString("<mark>")
 717		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
 718		b.WriteString("</mark>")
 719		pos = i + len(q)
 720	}
 721	b.WriteString(template.HTMLEscapeString(text[pos:]))
 722	return template.HTML(b.String())
 723}
 724
 725// blamePageSize caps how many lines one blame page renders; blame is a
 726// per-line subprocess cost, so large files paginate.
 727const blamePageSize = 1000
 728
 729func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 730	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 731	if !ok {
 732		return
 733	}
 734	p.Tab = "files"
 735	filePath := strings.Trim(r.PathValue("path"), "/")
 736	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 737	if err != nil {
 738		s.notFound(w, r)
 739		return
 740	}
 741	total := bytes.Count(data, []byte("\n"))
 742	if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
 743		total++
 744	}
 745	binary := gitutil.IsBinary(data)
 746
 747	type hunkView struct {
 748		gitutil.BlameHunk
 749		ShortSHA string
 750		Date     string
 751		Sig      sigView
 752		Numbered []numberedLine
 753	}
 754	var hunks []hunkView
 755	page, pages := 1, (total+blamePageSize-1)/blamePageSize
 756	if pages == 0 {
 757		pages = 1
 758	}
 759	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
 760		page = n
 761	}
 762	if !binary && total > 0 {
 763		start := (page-1)*blamePageSize + 1
 764		end := min(total, page*blamePageSize)
 765		raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
 766		if err != nil {
 767			s.notFound(w, r)
 768			return
 769		}
 770		sigs := map[string]sigView{}
 771		for _, h := range raw {
 772			v, ok := sigs[h.SHA]
 773			if !ok {
 774				v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 775				sigs[h.SHA] = v
 776			}
 777			hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
 778				Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
 779			for i, l := range h.Lines {
 780				hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 781			}
 782			hunks = append(hunks, hv)
 783		}
 784	}
 785	cs := crumbs(p, "blame", filePath)
 786	base := ""
 787	if len(cs) > 0 {
 788		base = cs[len(cs)-1].Name
 789		cs = cs[:len(cs)-1]
 790	}
 791	s.render(w, "blame.html", struct {
 792		repoPage
 793		Crumbs      []crumb
 794		Base        string
 795		Path        string
 796		Binary      bool
 797		Hunks       []hunkView
 798		Page, Pages int
 799	}{p, cs, base, filePath, binary, hunks, page, pages})
 800}
 801
 802type numberedLine struct {
 803	N    int
 804	Text string
 805}
 806
 807// chromaFormatter emits class-based markup (no inline colors), so the
 808// stylesheet can swap palettes with the color scheme.
 809var chromaFormatter = html.New(html.WithClasses(true),
 810	html.WithLineNumbers(true), html.LineNumbersInTable(false),
 811	html.WithLinkableLineNumbers(true, "L"))
 812
 813func highlight(filePath string, data []byte) template.HTML {
 814	lexer := lexers.Match(filePath)
 815	if lexer == nil {
 816		lexer = lexers.Fallback
 817	}
 818	iterator, err := lexer.Tokenise(nil, string(data))
 819	if err != nil {
 820		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 821	}
 822	var buf bytes.Buffer
 823	if err := chromaFormatter.Format(&buf, styles.Get("friendly"), iterator); err != nil {
 824		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 825	}
 826	return template.HTML(buf.String())
 827}
 828
 829// chromaCSS is both syntax palettes: light by default, dark under the same
 830// media query the rest of the stylesheet uses. The site's --code-bg stays
 831// the background either way.
 832var chromaCSS = func() []byte {
 833	var buf bytes.Buffer
 834	chromaFormatter.WriteCSS(&buf, styles.Get("friendly"))
 835	buf.WriteString("\n@media (prefers-color-scheme: dark) {\n")
 836	chromaFormatter.WriteCSS(&buf, styles.Get("github-dark"))
 837	buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
 838	return buf.Bytes()
 839}()
 840
 841func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 842	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 843	if !ok {
 844		return
 845	}
 846	filePath := strings.Trim(r.PathValue("path"), "/")
 847	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 848	if err != nil {
 849		s.notFound(w, r)
 850		return
 851	}
 852	// Serve inert: never let repo content execute in the forge's origin.
 853	// Images get their real type so <img> works under nosniff; SVG script
 854	// is dead on arrival because the instance CSP is script-src 'none'.
 855	ct := "text/plain; charset=utf-8"
 856	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
 857		ct = t
 858	}
 859	w.Header().Set("Content-Type", ct)
 860	w.Header().Set("X-Content-Type-Options", "nosniff")
 861	w.Write(data)
 862}
 863
 864// imageTypes are the formats raw serves with a real content type and blob
 865// pages preview inline.
 866var imageTypes = map[string]string{
 867	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
 868	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
 869	".svg": "image/svg+xml", ".ico": "image/x-icon",
 870}
 871
 872// readmeRank orders competing README files: richer renderers win.
 873var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 874
 875// pickReadme returns the best README-ish blob in a tree listing: any file
 876// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 877// we can render richly.
 878func pickReadme(entries []gitutil.TreeEntry) string {
 879	best, bestRank := "", 1<<30
 880	for _, e := range entries {
 881		if e.Type != "blob" {
 882			continue
 883		}
 884		lower := strings.ToLower(e.Name)
 885		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 886			continue
 887		}
 888		rank, ok := readmeRank[path.Ext(lower)]
 889		if !ok {
 890			rank = 10 // plaintext fallback
 891		}
 892		if rank < bestRank {
 893			best, bestRank = e.Name, rank
 894		}
 895	}
 896	return best
 897}
 898
 899// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
 900// task lists) on top of CommonMark, with class-based fence highlighting
 901// (the palette lives in the stylesheet, per scheme). Raw HTML is still
 902// dropped.
 903var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
 904	highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
 905
 906// fenceHighlight renders one code block with chroma classes, for org and
 907// anything else outside goldmark. Unknown languages fall back to plain.
 908func fenceHighlight(source, lang string) string {
 909	lexer := lexers.Get(lang)
 910	if lexer == nil {
 911		lexer = lexers.Fallback
 912	}
 913	iterator, err := lexer.Tokenise(nil, source)
 914	if err != nil {
 915		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 916	}
 917	var buf bytes.Buffer
 918	f := html.New(html.WithClasses(true))
 919	if err := f.Format(&buf, styles.Get("friendly"), iterator); err != nil {
 920		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 921	}
 922	return buf.String()
 923}
 924
 925// mdHTML renders user-authored markdown (issue and MR bodies, comments).
 926// goldmark's default renderer drops raw HTML, so this is safe as-is.
 927func mdHTML(raw string) template.HTML {
 928	if strings.TrimSpace(raw) == "" {
 929		return ""
 930	}
 931	var buf bytes.Buffer
 932	if markdown.Convert([]byte(raw), &buf) != nil {
 933		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
 934	}
 935	return template.HTML(buf.String())
 936}
 937
 938// webResolver answers autolink lookups for one viewer. Cross-repo
 939// references to repositories the viewer cannot read stay plain text, per
 940// the enumeration rule: a link would confirm the repo exists.
 941type webResolver struct {
 942	s      *Server
 943	viewer store.User
 944}
 945
 946func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
 947	repo, err := r.s.st.RepoByPath(owner + "/" + name)
 948	if err != nil {
 949		return ""
 950	}
 951	grant := ""
 952	if r.viewer.ID != 0 {
 953		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
 954	}
 955	if !policy.CanRead(r.viewer, repo, grant) {
 956		return ""
 957	}
 958	if kind == '#' {
 959		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
 960			return ""
 961		}
 962		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
 963	}
 964	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
 965		return ""
 966	}
 967	return autolink.MRURL(repo.OwnerName, repo.Name, n)
 968}
 969
 970func (r webResolver) UserURL(name string) string {
 971	if _, err := r.s.st.UserByUsername(name); err == nil {
 972		return "/" + name
 973	}
 974	if _, err := r.s.st.OrgByName(name); err == nil {
 975		return "/" + name
 976	}
 977	return ""
 978}
 979
 980// ugcFor returns a renderer for user-authored markdown on one repo's pages:
 981// mdHTML plus cross-reference and mention autolinking for this viewer.
 982func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
 983	viewer := store.User{}
 984	if s.cfg.Web.Mode == "accounts" {
 985		viewer = s.viewer(r)
 986	}
 987	res := webResolver{s, viewer}
 988	return func(raw string) template.HTML {
 989		h := mdHTML(raw)
 990		if h == "" {
 991			return h
 992		}
 993		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
 994	}
 995}
 996
 997// renderedComment pairs a comment with its rendered body for templates.
 998type renderedComment struct {
 999	Author    string
1000	CreatedAt string
1001	Kind      string
1002	BodyHTML  template.HTML
1003}
1004
1005func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
1006	var out []renderedComment
1007	for _, c := range cs {
1008		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
1009	}
1010	return out
1011}
1012
1013// ugcPolicy sanitizes rendered repo content before it enters the forge's
1014// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1015// output and repo-authored HTML are not. Chroma's highlighting classes
1016// must survive; the pattern admits only short token codes, not the site's
1017// own class names.
1018var ugcPolicy = func() *bluemonday.Policy {
1019	p := bluemonday.UGCPolicy()
1020	p.AllowAttrs("class").
1021		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1022		OnElements("span", "pre", "code", "div")
1023	return p
1024}()
1025
1026// renderReadme renders a README by extension: markdown, org-mode, and
1027// (sanitized) HTML richly; everything else as escaped plaintext.
1028func renderReadme(name string, raw []byte) template.HTML {
1029	plain := func() template.HTML {
1030		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1031	}
1032	if gitutil.IsBinary(raw) {
1033		return ""
1034	}
1035	switch path.Ext(strings.ToLower(name)) {
1036	case ".md", ".markdown":
1037		var buf bytes.Buffer
1038		if markdown.Convert(raw, &buf) != nil {
1039			return plain()
1040		}
1041		return template.HTML(buf.String())
1042	case ".org":
1043		doc := org.New().Parse(bytes.NewReader(raw), name)
1044		writer := org.NewHTMLWriter()
1045		writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1046			if inline {
1047				return "<code>" + template.HTMLEscapeString(source) + "</code>"
1048			}
1049			return fenceHighlight(source, lang)
1050		}
1051		out, err := doc.Write(writer)
1052		if err != nil {
1053			return plain()
1054		}
1055		return template.HTML(ugcPolicy.Sanitize(out))
1056	case ".html", ".htm":
1057		return template.HTML(ugcPolicy.Sanitize(string(raw)))
1058	default:
1059		return plain()
1060	}
1061}
1062
1063type diffLine struct {
1064	Class   string
1065	Text    string
1066	Path    string // file this line belongs to
1067	NewLine int64  // line number in the new file (0 when absent)
1068	OldLine int64  // line number in the old file (0 when absent)
1069	Threads []diffThread
1070}
1071
1072var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
1073
1074// classifyDiff parses a unified diff into rendered lines, tracking the
1075// file and old/new line numbers so review threads can anchor inline.
1076func classifyDiff(patch string) []diffLine {
1077	var lines []diffLine
1078	path := ""
1079	var oldN, newN int64
1080	for _, l := range strings.Split(patch, "\n") {
1081		d := diffLine{Text: l}
1082		switch {
1083		case strings.HasPrefix(l, "+++ "):
1084			d.Class = "meta"
1085			path = strings.TrimPrefix(strings.TrimPrefix(l, "+++ "), "b/")
1086		case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
1087			d.Class = "meta"
1088		case strings.HasPrefix(l, "@@"):
1089			d.Class = "hunk"
1090			if m := hunkPat.FindStringSubmatch(l); m != nil {
1091				oldN, _ = strconv.ParseInt(m[1], 10, 64)
1092				newN, _ = strconv.ParseInt(m[2], 10, 64)
1093			}
1094		case strings.HasPrefix(l, "+"):
1095			d.Class, d.Path, d.NewLine = "add", path, newN
1096			newN++
1097		case strings.HasPrefix(l, "-"):
1098			d.Class, d.Path, d.OldLine = "del", path, oldN
1099			oldN++
1100		default:
1101			d.Path, d.OldLine, d.NewLine = path, oldN, newN
1102			oldN++
1103			newN++
1104		}
1105		lines = append(lines, d)
1106	}
1107	return lines
1108}
1109
1110type diffThread struct {
1111	ID       int64
1112	Resolved string
1113	Stale    bool
1114	Comments []renderedComment
1115}
1116
1117// attachThreads injects review threads under their anchored diff lines;
1118// threads whose anchor no longer appears (stale after force-push, or on a
1119// context line outside the current diff) are returned separately.
1120func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) {
1121	type anchor struct {
1122		path string
1123		side string
1124		line int64
1125	}
1126	threads := map[int64]*diffThread{}
1127	anchors := map[int64]anchor{}
1128	var order []int64
1129	for _, cm := range comments {
1130		if cm.ReplyTo == 0 {
1131			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1132				Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
1133			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1134			order = append(order, cm.ID)
1135		} else if th, ok := threads[cm.ReplyTo]; ok {
1136			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
1137		}
1138	}
1139	placed := map[int64]bool{}
1140	for i := range lines {
1141		for _, id := range order {
1142			if placed[id] || threads[id].Stale {
1143				continue
1144			}
1145			a := anchors[id]
1146			if lines[i].Path != a.path {
1147				continue
1148			}
1149			if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1150				(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1151				lines[i].Threads = append(lines[i].Threads, *threads[id])
1152				placed[id] = true
1153			}
1154		}
1155	}
1156	var unplaced []diffThread
1157	for _, id := range order {
1158		if !placed[id] {
1159			unplaced = append(unplaced, *threads[id])
1160		}
1161	}
1162	return lines, unplaced
1163}
1164
1165type sigView struct {
1166	State       string
1167	Signer      string
1168	Fingerprint string
1169}
1170
1171func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1172	raw, err := gitutil.ReadCommit(dir, sha)
1173	if err != nil {
1174		return sigView{State: "unsigned"}, nil
1175	}
1176	parsed, err := sig.ParseCommit(raw)
1177	if err != nil {
1178		return sigView{State: "unsigned"}, nil
1179	}
1180	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1181	if err != nil {
1182		return sigView{State: "unsigned"}, parsed
1183	}
1184	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1185	if res.SignerUserID != 0 {
1186		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1187			v.Signer = u.Username
1188		}
1189	}
1190	return v, parsed
1191}
1192
1193func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1194	ref := r.PathValue("ref")
1195	p, ok := s.repoFor(w, r, ref)
1196	if !ok {
1197		return
1198	}
1199	p.Tab = "log"
1200	const pageSize = 50
1201	// ?path= filters to commits touching one file or directory.
1202	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1203	if filePath == "." {
1204		filePath = ""
1205	}
1206	var shas []string
1207	var err error
1208	if filePath != "" {
1209		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1210	} else {
1211		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1212	}
1213	if err != nil {
1214		s.notFound(w, r)
1215		return
1216	}
1217	next := ""
1218	if len(shas) > pageSize {
1219		next = shas[pageSize]
1220		shas = shas[:pageSize]
1221	}
1222	type row struct {
1223		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1224		Sig                                                               sigView
1225	}
1226	names := s.authorNames()
1227	var rows []row
1228	for _, sha := range shas {
1229		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1230		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
1231		if parsed != nil {
1232			rw.Subject = parsed.Subject
1233			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1234			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1235			rw.AuthorEmail = parsed.AuthorEmail
1236			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1237		}
1238		rows = append(rows, rw)
1239	}
1240	s.render(w, "log.html", struct {
1241		repoPage
1242		Commits  []row
1243		NextSHA  string
1244		FilePath string
1245	}{p, rows, next, filePath})
1246}
1247
1248func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1249	p, ok := s.repoFor(w, r, "")
1250	if !ok {
1251		return
1252	}
1253	p.Tab = "log"
1254	sha := r.PathValue("sha")
1255	full, err := gitutil.ResolveRef(p.Dir, sha)
1256	if err != nil {
1257		s.notFound(w, r)
1258		return
1259	}
1260	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1261	if parsed == nil {
1262		s.notFound(w, r)
1263		return
1264	}
1265	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1266	lines := classifyDiff(patch)
1267	committerEmail := ""
1268	if parsed.CommitterEmail != parsed.AuthorEmail {
1269		committerEmail = parsed.CommitterEmail
1270	}
1271	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1272	commitNames := s.authorNames()
1273	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1274	msg := ""
1275	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1276		msg = string(parsed.Payload[i+2:])
1277	}
1278	s.render(w, "commit.html", struct {
1279		repoPage
1280		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1281		Parents                                                                           []string
1282		Sig                                                                               sigView
1283		Checks                                                                            []store.CommitStatus
1284		DiffLines                                                                         []diffLine
1285	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1286		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1287		gitutil.Parents(p.Dir, full), v, checks, lines})
1288}
1289
1290// labelPalette provides default label chip colors: mid-tone hues that stay
1291// legible on light and dark backgrounds.
1292var labelPalette = []string{
1293	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1294	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1295}
1296
1297var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1298
1299// labelColors returns a complete label-name -> chip color map for a repo:
1300// the stored labels.color when it is a valid hex color, otherwise a
1301// stable default picked from the palette by name hash.
1302func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1303	stored, _ := s.st.LabelColors(repoID)
1304	out := make(map[string]template.CSS, len(stored))
1305	for name, color := range stored {
1306		if !hexColorPat.MatchString(color) {
1307			h := fnv.New32a()
1308			h.Write([]byte(name))
1309			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1310		}
1311		out[name] = template.CSS("--chip:" + color)
1312	}
1313	return out
1314}
1315
1316func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1317	p, ok := s.repoFor(w, r, "")
1318	if !ok {
1319		return
1320	}
1321	p.Tab = "issues"
1322	state := r.URL.Query().Get("state")
1323	if state != "closed" && state != "all" {
1324		state = "open"
1325	}
1326	issues, err := s.st.ListIssues(p.Repo.ID, state)
1327	if err != nil {
1328		http.Error(w, "internal error", http.StatusInternalServerError)
1329		return
1330	}
1331	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1332		for i := range issues {
1333			issues[i].Labels = labels[issues[i].ID]
1334		}
1335	}
1336	// ?label=x narrows to issues carrying that label (chips link here).
1337	labelFilter := r.URL.Query().Get("label")
1338	if labelFilter != "" {
1339		var kept []store.Issue
1340		for _, iss := range issues {
1341			for _, l := range iss.Labels {
1342				if l == labelFilter {
1343					kept = append(kept, iss)
1344					break
1345				}
1346			}
1347		}
1348		issues = kept
1349	}
1350	s.render(w, "issues.html", struct {
1351		repoPage
1352		State       string
1353		Label       string
1354		Issues      []store.Issue
1355		LabelColors map[string]template.CSS
1356	}{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1357}
1358
1359func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1360	p, ok := s.repoFor(w, r, "")
1361	if !ok {
1362		return
1363	}
1364	p.Tab = "issues"
1365	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1366	if err != nil {
1367		s.notFound(w, r)
1368		return
1369	}
1370	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1371	if err != nil {
1372		s.notFound(w, r)
1373		return
1374	}
1375	comments, err := s.st.ListIssueComments(iss.ID)
1376	if err != nil {
1377		http.Error(w, "internal error", http.StatusInternalServerError)
1378		return
1379	}
1380	md := s.ugcFor(r, p.Repo)
1381	milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1382	s.render(w, "issue.html", struct {
1383		repoPage
1384		Issue       store.Issue
1385		BodyHTML    template.HTML
1386		Comments    []renderedComment
1387		CanEdit     bool
1388		CanWrite    bool
1389		Milestones  []store.Milestone
1390		Notice      string
1391		LabelColors map[string]template.CSS
1392	}{p, iss, md(iss.Body), renderComments(comments, md),
1393		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1394		milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1395}
1396
1397// canEditItem: the author or anyone with write access may edit.
1398// canWriteRepo reports whether the browser session may push to the repo,
1399// which is what gates the review and merge controls.
1400func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1401	if s.cfg.Web.Mode != "accounts" {
1402		return false
1403	}
1404	u := s.viewer(r)
1405	if u.ID == 0 {
1406		return false
1407	}
1408	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1409	return policy.CanWrite(u, repo, grant)
1410}
1411
1412func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1413	if s.cfg.Web.Mode != "accounts" {
1414		return false
1415	}
1416	u := s.viewer(r)
1417	if u.ID == 0 {
1418		return false
1419	}
1420	if u.Username == author {
1421		return true
1422	}
1423	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1424	return policy.CanWrite(u, repo, grant)
1425}
1426
1427func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1428	p, ok := s.repoFor(w, r, "")
1429	if !ok {
1430		return
1431	}
1432	p.Tab = "merge requests"
1433	state := r.URL.Query().Get("state")
1434	if state == "" {
1435		state = "open"
1436	}
1437	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1438	if !valid[state] {
1439		state = "open"
1440	}
1441	mrs, err := s.st.ListMRs(p.Repo.ID, state)
1442	if err != nil {
1443		http.Error(w, "internal error", http.StatusInternalServerError)
1444		return
1445	}
1446	s.render(w, "mrs.html", struct {
1447		repoPage
1448		State string
1449		MRs   []store.MR
1450	}{p, state, mrs})
1451}
1452
1453func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1454	p, ok := s.repoFor(w, r, "")
1455	if !ok {
1456		return
1457	}
1458	p.Tab = "merge requests"
1459	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1460	if err != nil {
1461		s.notFound(w, r)
1462		return
1463	}
1464	m, err := s.st.MRByNumber(p.Repo.ID, n)
1465	if err != nil {
1466		s.notFound(w, r)
1467		return
1468	}
1469	comments, _ := s.st.ListMRComments(m.ID)
1470	reviews, _ := s.st.ListMRReviews(m.ID)
1471	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1472	diffComments, _ := s.st.ListDiffComments(m.ID)
1473
1474	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1475	var lines []diffLine
1476	base := m.MergedBase
1477	if base == "" {
1478		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1479			base = b
1480		}
1481	}
1482	if base != "" {
1483		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1484			lines = classifyDiff(patch)
1485		}
1486	}
1487	md := s.ugcFor(r, p.Repo)
1488	var detachedThreads []diffThread
1489	lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
1490	type diffStat struct{ Files, Adds, Dels int }
1491	var stat diffStat
1492	seenFiles := map[string]bool{}
1493	for _, l := range lines {
1494		switch l.Class {
1495		case "add":
1496			stat.Adds++
1497		case "del":
1498			stat.Dels++
1499		}
1500		if l.Path != "" && !seenFiles[l.Path] {
1501			seenFiles[l.Path] = true
1502			stat.Files++
1503		}
1504	}
1505	// The commits this MR carries: base..head, the same range as the diff.
1506	type commitRow struct {
1507		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1508		Sig                                                  sigView
1509	}
1510	mrNames := s.authorNames()
1511	var commits []commitRow
1512	if base != "" {
1513		const maxMRCommits = 100
1514		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1515		if len(shas) > maxMRCommits {
1516			shas = shas[:maxMRCommits]
1517		}
1518		for _, sha := range shas {
1519			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1520			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1521			if parsed != nil {
1522				cr.Subject = parsed.Subject
1523				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1524				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1525				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1526			}
1527			commits = append(commits, cr)
1528		}
1529	}
1530	// The diff is the reason most people open a merge request, so it gets
1531	// its own view rather than a fold at the foot of the conversation.
1532	// A query parameter keeps this working without JavaScript.
1533	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1534	view := r.URL.Query().Get("view")
1535	if view != "commits" && view != "diff" {
1536		view = "conversation"
1537	}
1538	s.render(w, "mr.html", struct {
1539		repoPage
1540		MR              store.MR
1541		View            string
1542		BodyHTML        template.HTML
1543		Checks          []store.CommitStatus
1544		Combined        string
1545		Comments        []renderedComment
1546		Reviews         []store.MRReview
1547		DiffLines       []diffLine
1548		Stat            diffStat
1549		Commits         []commitRow
1550		CanEdit         bool
1551		CanWrite        bool
1552		Unresolved      int
1553		Notice          string
1554		DetachedThreads []diffThread
1555	}{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1556		reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1557		s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
1558}
1559
1560func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1561	p, ok := s.repoFor(w, r, "")
1562	if !ok {
1563		return
1564	}
1565	p.Tab = "refs"
1566	branches, _ := gitutil.Refs(p.Dir, "heads")
1567	tags, _ := gitutil.Refs(p.Dir, "tags")
1568	s.render(w, "refs.html", struct {
1569		repoPage
1570		Branches, Tags []gitutil.Ref
1571	}{p, branches, tags})
1572}
1573
1574func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1575	p, ok := s.repoFor(w, r, "")
1576	if !ok {
1577		return
1578	}
1579	file := r.PathValue("file")
1580	ref, ok := strings.CutSuffix(file, ".tar.gz")
1581	if !ok {
1582		s.notFound(w, r)
1583		return
1584	}
1585	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1586		s.notFound(w, r)
1587		return
1588	}
1589	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1590	w.Header().Set("Content-Type", "application/gzip")
1591	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1592	gitutil.Archive(p.Dir, ref, prefix, w)
1593}
1594
1595func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1596	return policy.CanAdmin(u, repo, grant)
1597}
1598
1599func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1600	return policy.CanRead(u, repo, grant)
1601}