internal/httpd/web.go

6b2b26db548cdb89367154ba6ff4b0bcce21dd67
gitbay/internal/httpd/web.go history · blame · raw

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