internal/httpd/web.go

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

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