internal/httpd/web.go

8e64f8208b18dc40fb5c27c873802282e722255e
gitbay/internal/httpd/web.go history · blame · raw

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