internal/httpd/web.go

96df83f2d3eb9f241bcaa53fcc243d090c53ab2b
gitbay/internal/httpd/web.go history · blame · raw

1897 lines · 59413 bytes

   1package httpd
   2
   3import (
   4	"bytes"
   5	"crypto/sha256"
   6	"encoding/hex"
   7	"errors"
   8	"fmt"
   9	"hash/fnv"
  10	"io"
  11	"log"
  12	"math"
  13	"os"
  14	"path/filepath"
  15
  16	"gitbay.org/gitbay/internal/policy"
  17	"gitbay.org/gitbay/internal/protocol"
  18	"html/template"
  19	"net/http"
  20	"net/url"
  21	"path"
  22	"regexp"
  23	"sort"
  24	"strconv"
  25	"strings"
  26	"time"
  27
  28	"github.com/alecthomas/chroma/v2/formatters/html"
  29	"github.com/alecthomas/chroma/v2/lexers"
  30	"github.com/alecthomas/chroma/v2/styles"
  31	"github.com/microcosm-cc/bluemonday"
  32	"github.com/niklasfasching/go-org/org"
  33	"github.com/yuin/goldmark"
  34	highlighting "github.com/yuin/goldmark-highlighting/v2"
  35	"github.com/yuin/goldmark/extension"
  36	"github.com/yuin/goldmark/parser"
  37
  38	"gitbay.org/gitbay/internal/autolink"
  39	"gitbay.org/gitbay/internal/control"
  40	"gitbay.org/gitbay/internal/gitutil"
  41	"gitbay.org/gitbay/internal/sig"
  42	"gitbay.org/gitbay/internal/store"
  43	"gitbay.org/gitbay/internal/web"
  44)
  45
  46const maxRenderBytes = 1 << 20 // largest blob rendered inline
  47
  48func (s *Server) render(w http.ResponseWriter, page string, data any) {
  49	var buf bytes.Buffer
  50	if err := web.Render(&buf, page, data); err != nil {
  51		http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
  52		return
  53	}
  54	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  55	buf.WriteTo(w)
  56}
  57
  58// siteName is the instance's display name: the operator's [web] title,
  59// or the site host when they have not set one.
  60func (s *Server) siteName() string {
  61	if t := strings.TrimSpace(s.cfg.Web.Title); t != "" {
  62		return t
  63	}
  64	h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
  65	return strings.TrimSuffix(h, "/")
  66}
  67
  68// stylesheetETag is the hash of what stylesheet serves, computed once:
  69// a browser revalidates with If-None-Match and gets a 304 until a deploy
  70// changes the bytes (#132).
  71var stylesheetETag = func() string {
  72	h := sha256.New()
  73	h.Write(web.StyleCSS)
  74	h.Write(chromaCSS)
  75	return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
  76}()
  77
  78func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
  79	w.Header().Set("ETag", stylesheetETag)
  80	w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
  81	if r.Header.Get("If-None-Match") == stylesheetETag {
  82		w.WriteHeader(http.StatusNotModified)
  83		return
  84	}
  85	w.Header().Set("Content-Type", "text/css; charset=utf-8")
  86	w.Write(web.StyleCSS)
  87	w.Write(chromaCSS)
  88}
  89
  90func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
  91	w.Header().Set("Content-Type", "image/svg+xml")
  92	w.Write(web.FaviconSVG)
  93}
  94
  95// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
  96// so the CSP's default-src 'self' covers it — no font CDN.
  97func (s *Server) font(w http.ResponseWriter, r *http.Request) {
  98	data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
  99	if err != nil {
 100		http.NotFound(w, r)
 101		return
 102	}
 103	w.Header().Set("Content-Type", "font/woff2")
 104	w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
 105	w.Write(data)
 106}
 107
 108// notFound renders the designed 404 page with a 404 status. Falls back to
 109// the stock plain-text response if the template fails.
 110func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
 111	var buf bytes.Buffer
 112	if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
 113		http.NotFound(w, r)
 114		return
 115	}
 116	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 117	w.WriteHeader(http.StatusNotFound)
 118	buf.WriteTo(w)
 119}
 120
 121// describedRepo pairs a repo with the listing metadata: description,
 122// topics, license, and last-updated date.
 123type describedRepo struct {
 124	store.Repo
 125	Desc    string
 126	Topics  []string
 127	License string
 128	Updated string
 129}
 130
 131// Archived flattens the settings flag so the reporow partial can read the
 132// same field name from a describedRepo and from a profile's repo row.
 133func (d describedRepo) Archived() bool { return d.Settings.Archived }
 134
 135func (s *Server) describeAll(repos []store.Repo) []describedRepo {
 136	var out []describedRepo
 137	for _, r := range repos {
 138		dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
 139		d := describedRepo{
 140			Repo:    r,
 141			Desc:    gitutil.ReadDescription(dir),
 142			License: control.DetectLicense(dir, r.DefaultBranch),
 143			Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
 144		}
 145		d.Topics, _ = s.st.ListTopics(r.ID)
 146		out = append(out, d)
 147	}
 148	return out
 149}
 150
 151// index is the homepage: a dashboard for logged-in users, a landing page
 152// for everyone else. The full public listing lives at /explore.
 153func (s *Server) index(w http.ResponseWriter, r *http.Request) {
 154	if s.cfg.Web.Mode == "accounts" {
 155		if viewer := s.viewer(r); viewer.ID != 0 {
 156			s.dashboard(w, r, viewer)
 157			return
 158		}
 159	}
 160	host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
 161		s.cfg.Server.SiteURL, "https://"), "http://"), "/")
 162	s.render(w, "landing.html", struct {
 163		basePage
 164		Host     string
 165		Accounts bool
 166		Signup   bool
 167	}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
 168		s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
 169}
 170
 171func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
 172	pinned, _ := s.st.PinnedRepos(viewer.ID)
 173	var visible []store.Repo
 174	for _, rp := range pinned {
 175		grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
 176		if policy.CanRead(viewer, rp, grant) {
 177			visible = append(visible, rp)
 178		}
 179	}
 180	mrs, _ := s.st.DashboardMRs(viewer.ID)
 181	issues, _ := s.st.DashboardIssues(viewer.ID)
 182	reviews, _ := s.st.ReviewQueue(viewer.ID)
 183	assigned, _ := s.st.AssignedIssues(viewer.ID)
 184	events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
 185	s.render(w, "dashboard.html", struct {
 186		basePage
 187		Pinned   []store.Repo
 188		Reviews  []store.DashboardItem
 189		Assigned []store.DashboardItem
 190		MRs      []store.DashboardItem
 191		Issues   []store.DashboardItem
 192		Feed     []feedLine
 193	}{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)})
 194}
 195
 196func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
 197	repos, err := s.st.ListPublicRepos()
 198	if err != nil {
 199		http.Error(w, "internal error", http.StatusInternalServerError)
 200		return
 201	}
 202	var viewer store.User
 203	if s.cfg.Web.Mode == "accounts" {
 204		viewer = s.viewer(r)
 205	}
 206	q := strings.TrimSpace(r.URL.Query().Get("q"))
 207	s.render(w, "explore.html", struct {
 208		basePage
 209		Query string
 210		Repos []describedRepo
 211	}{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
 212}
 213
 214// privacy renders the privacy page: what the gitbay software does with
 215// data, plus this instance's operator-provided notes.
 216func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
 217	s.render(w, "privacy.html", struct {
 218		basePage
 219		Host   string
 220		Notice string
 221	}{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
 222}
 223
 224// filterRepos keeps repos matching the query by the same rule `repo
 225// search` uses. An empty query keeps everything.
 226func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
 227	if q == "" {
 228		return repos
 229	}
 230	var out []describedRepo
 231	for _, d := range repos {
 232		if control.MatchesRepo(q, d.Path(), d.Desc, d.Topics) {
 233			out = append(out, d)
 234		}
 235	}
 236	return out
 237}
 238
 239// repoPage is the shared context for repo-scoped pages.
 240type repoPage struct {
 241	basePage
 242	Desc     string
 243	Repo     store.Repo
 244	Ref      string
 245	CloneURL string
 246	Dir      string
 247	Tab      string // active tab in the repo header
 248	Topics   []string
 249	Pinned   bool   // by the viewer
 250	Watch    string // the viewer's watch state: watching, muted, or ""
 251	HasWiki  bool
 252	Host     string
 253	Mirrors  []mirrorLine // repo admins only
 254	CanAdmin bool         // gates the settings tab
 255	// OpenIssues and OpenMRs are the counts on the header tabs.
 256	OpenIssues int
 257	OpenMRs    int
 258	// RepoHome asks the layout for the full header — description, topics,
 259	// website, mirrors. Every other page gets identity and tabs only, so a
 260	// repo describes itself once rather than on all twelve of its pages.
 261	RepoHome bool
 262}
 263
 264// mirrorLine is the admin-only mirror status shown in the repo header.
 265// It carries no credentials: the stored URL is credential-free.
 266type mirrorLine struct {
 267	Direction string
 268	URL       string
 269	Target    string // URL without the scheme, for display
 270	Synced    string
 271	Error     string
 272}
 273
 274// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
 275// readable "2026-08-25 03:39 UTC".
 276func syncedAt(ts string) string {
 277	if len(ts) < 16 {
 278		return ts
 279	}
 280	return ts[:10] + " " + ts[11:16] + " UTC"
 281}
 282
 283// repoFor resolves the repo for a web request; false means 404 was sent.
 284// Anonymous visitors see public repos only; in accounts mode a logged-in
 285// viewer additionally sees repos their grants allow. Private and missing
 286// repos are indistinguishable either way.
 287func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
 288	var repo store.Repo
 289	var viewer store.User
 290	if s.cfg.Web.Mode == "accounts" {
 291		viewer = s.viewer(r)
 292	}
 293	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
 294	ok := err == nil
 295	grant := ""
 296	if ok {
 297		if viewer.ID != 0 {
 298			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 299		}
 300		ok = policyCanRead(viewer, repo, grant)
 301	}
 302	if !ok {
 303		s.notFound(w, r)
 304		return repoPage{}, false
 305	}
 306	if ref == "" {
 307		ref = repo.DefaultBranch
 308	}
 309	topics, _ := s.st.ListTopics(repo.ID)
 310	pinned, watch := false, ""
 311	if viewer.ID != 0 {
 312		pinned = s.st.IsPinned(viewer.ID, repo.ID)
 313		watch = s.st.RepoWatchState(repo.ID, viewer.ID)
 314	}
 315	canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
 316	var mirrors []mirrorLine
 317	if canAdmin {
 318		ms, _ := s.st.ListMirrors(repo.ID)
 319		for _, m := range ms {
 320			mirrors = append(mirrors, mirrorLine{
 321				Direction: m.Direction,
 322				URL:       m.URL,
 323				Target:    strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
 324				Synced:    syncedAt(m.LastSync),
 325				Error:     m.LastError,
 326			})
 327		}
 328	}
 329	openIssues, openMRs := s.st.OpenCounts(repo.ID)
 330	return repoPage{
 331		basePage:   s.baseFor(viewer),
 332		CanAdmin:   canAdmin,
 333		Mirrors:    mirrors,
 334		Pinned:     pinned,
 335		Watch:      watch,
 336		HasWiki:    s.wikiDir(repo.OwnerName, repo.Name) != "",
 337		Host:       s.cfg.SiteHost(),
 338		Desc:       gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
 339		Repo:       repo,
 340		Ref:        ref,
 341		CloneURL:   s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
 342		Dir:        control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
 343		Topics:     topics,
 344		OpenIssues: openIssues,
 345		OpenMRs:    openMRs,
 346	}, true
 347}
 348
 349type crumb struct {
 350	Name string
 351	URL  string
 352}
 353
 354// crumbs builds one crumb per path component. Every component but the
 355// last is a directory and links to the tree; only the leaf is a page of
 356// the given kind.
 357func crumbs(p repoPage, kind, filePath string) []crumb {
 358	var cs []crumb
 359	parts := strings.Split(strings.Trim(filePath, "/"), "/")
 360	acc := ""
 361	for i, part := range parts {
 362		if part == "" {
 363			continue
 364		}
 365		acc = path.Join(acc, part)
 366		k := "tree"
 367		if i == len(parts)-1 {
 368			k = kind
 369		}
 370		cs = append(cs, crumb{Name: part, URL: "/" + p.Repo.Path() + "/" + k + "/" + p.Ref + "/" + acc})
 371	}
 372	return cs
 373}
 374
 375// profileView is profile show's payload, shaped for the templates. The
 376// repo rows carry the same names the reporow partial reads, so a profile
 377// listing renders identically to explore's.
 378// profileView is profile show's payload with the repository rows wrapped
 379// so the reporow partial can reach them. The fields themselves are the
 380// command's: a field it gains appears here without being re-declared.
 381type profileView struct {
 382	control.ProfileOut
 383	Repos []profileRepoRow `json:"repos"`
 384}
 385
 386// profileRepoRow is one repository row on a profile. The partial asks for
 387// OwnerName, Name and Desc; the payload carries a path and a description.
 388type profileRepoRow struct {
 389	control.ProfileRepo
 390}
 391
 392func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
 393func (p profileRepoRow) Name() string      { _, name, _ := strings.Cut(p.Path, "/"); return name }
 394func (p profileRepoRow) Desc() string      { return p.Description }
 395
 396// ownerPage renders /{owner} for users and orgs: the repositories the
 397// viewer may see, org membership either direction. Owner names are not
 398// secret (they are on every commit); repository visibility rules hold.
 399func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
 400	name := r.PathValue("owner")
 401	var viewer store.User
 402	if s.cfg.Web.Mode == "accounts" {
 403		viewer = s.viewer(r)
 404	}
 405
 406	// Everything on this page — membership, the repositories this viewer
 407	// may see, the activity year — comes from profile show, so the page
 408	// and the command cannot report different things.
 409	var d profileView
 410	code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d)
 411	switch {
 412	case code == protocol.ExitNotFound:
 413		s.notFound(w, r)
 414		return
 415	case code != protocol.ExitOK:
 416		log.Printf("profile %s: %s", name, msg)
 417		http.Error(w, "internal error", http.StatusInternalServerError)
 418		return
 419	}
 420
 421	counts := make(map[string]int, len(d.Activity))
 422	for _, day := range d.Activity {
 423		counts[day.Date] = day.Count
 424	}
 425	weeks, activityTotal := activityGrid(counts)
 426
 427	teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
 428	profile := store.Profile{Description: d.Description, Website: d.Website,
 429		About: d.About, AboutFormat: d.AboutFormat, Links: d.Links}
 430	s.render(w, "owner.html", struct {
 431		basePage
 432		Owner         string
 433		Kind          string
 434		Profile       store.Profile
 435		AboutHTML     template.HTML
 436		Repos         []profileRepoRow
 437		Members       []control.ProfileMember
 438		Orgs          []control.ProfileMember
 439		Activity      []activityWeek
 440		ActivityTotal int
 441		Teams         []teamView
 442		CanAdmin      bool
 443		Notice        string
 444	}{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
 445		d.Repos, d.Members, d.Orgs,
 446		weeks, activityTotal, teams, canAdmin, s.takeFlash(w, r)})
 447}
 448
 449func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
 450	p, ok := s.repoFor(w, r, "")
 451	if !ok {
 452		return
 453	}
 454	p.Tab = "files"
 455	p.RepoHome = true
 456	s.renderTree(w, r, p, "")
 457}
 458
 459func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
 460	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 461	if !ok {
 462		return
 463	}
 464	p.Tab = "files"
 465	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
 466}
 467
 468// treePage is shared by the populated and empty-repository renders: two
 469// anonymous structs drifted apart once already.
 470type treePage struct {
 471	repoPage
 472	Crumbs      []crumb
 473	Prefix      string
 474	DirPath     string
 475	RefKind     string
 476	Entries     []gitutil.TreeEntry
 477	Branches    []gitutil.Ref
 478	ReadmeName  string
 479	ReadmeHTML  template.HTML
 480	LastCommits map[string]namedCommit
 481	Tip         namedCommit
 482	Facts       repoFacts
 483}
 484
 485func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
 486	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
 487		// Empty repo: render the page with no entries rather than 404.
 488		s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"})
 489		return
 490	}
 491	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
 492	if err != nil {
 493		s.notFound(w, r)
 494		return
 495	}
 496	// Directories first. git's tree order interleaves them with files, but
 497	// a listing is scanned by shape before name. Stable, so each group
 498	// keeps the ordering git gave it.
 499	sort.SliceStable(entries, func(i, j int) bool {
 500		return entries[i].Type == "tree" && entries[j].Type != "tree"
 501	})
 502	prefix := ""
 503	if dirPath != "" {
 504		prefix = dirPath + "/"
 505	}
 506
 507	var readmeHTML template.HTML
 508	readmeName := pickReadme(entries)
 509	if readmeName != "" {
 510		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
 511			readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
 512		}
 513	}
 514
 515	branches, _ := gitutil.Refs(p.Dir, "heads")
 516	names := make([]string, 0, len(entries))
 517	for _, e := range entries {
 518		names = append(names, e.Name)
 519	}
 520	// The facts bar is about the repository, not this directory, so it is
 521	// computed once at the root and left off subdirectory listings.
 522	var facts repoFacts
 523	if dirPath == "" {
 524		facts = s.factsFor(p)
 525	}
 526	s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
 527		readmeName, readmeHTML,
 528		s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
 529		s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts})
 530}
 531
 532func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
 533	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 534	if !ok {
 535		return
 536	}
 537	p.Tab = "files"
 538	filePath := strings.Trim(r.PathValue("path"), "/")
 539	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
 540	if err != nil {
 541		s.notFound(w, r)
 542		return
 543	}
 544	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
 545	_, image := imageTypes[strings.ToLower(path.Ext(filePath))]
 546
 547	var codeHTML template.HTML
 548	if !binary && !image {
 549		codeHTML = highlight(filePath, data)
 550	}
 551	// Markdown and org render like a README, with the source one click
 552	// away; ?view=source shows the text instead.
 553	renderable := false
 554	switch path.Ext(strings.ToLower(filePath)) {
 555	case ".md", ".markdown", ".org":
 556		renderable = !binary
 557	}
 558	var renderedHTML template.HTML
 559	rendered := renderable && r.URL.Query().Get("view") != "source"
 560	if rendered {
 561		renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
 562	}
 563	cs := crumbs(p, "blob", filePath)
 564	base := ""
 565	if len(cs) > 0 {
 566		base = cs[len(cs)-1].Name
 567		cs = cs[:len(cs)-1]
 568	}
 569	branches, _ := gitutil.Refs(p.Dir, "heads")
 570	lines := 0
 571	if !binary && !image && len(data) > 0 {
 572		lines = bytes.Count(data, []byte("\n"))
 573		if data[len(data)-1] != '\n' {
 574			lines++
 575		}
 576	}
 577	// The file listing leads with the last commit now, so the facts about
 578	// the file itself are reported here instead.
 579	entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
 580	s.render(w, "blob.html", struct {
 581		repoPage
 582		Crumbs       []crumb
 583		Base         string
 584		Path         string
 585		DirPath      string
 586		RefKind      string
 587		Binary       bool
 588		Image        bool
 589		Size         int
 590		Lines        int
 591		Exec         bool
 592		Symlink      bool
 593		Branches     []gitutil.Ref
 594		CodeHTML     template.HTML
 595		Renderable   bool // markdown or org: the toggle is offered
 596		Rendered     bool // this response shows the rendering
 597		RenderedHTML template.HTML
 598	}{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
 599		entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
 600}
 601
 602// releases lists tag-anchored releases with notes and assets.
 603func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 604	p, ok := s.repoFor(w, r, "")
 605	if !ok {
 606		return
 607	}
 608	p.Tab = "releases"
 609	rels, err := s.st.ListReleases(p.Repo.ID)
 610	if err != nil {
 611		http.Error(w, "internal error", http.StatusInternalServerError)
 612		return
 613	}
 614	md := s.ugcFor(r, p.Repo)
 615	type relView struct {
 616		store.Release
 617		NotesHTML template.HTML
 618	}
 619	var views []relView
 620	for _, rel := range rels {
 621		views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
 622	}
 623	// Tags without a release yet are what a create form can offer.
 624	released := map[string]bool{}
 625	for _, rel := range rels {
 626		released[rel.Tag] = true
 627	}
 628	var freeTags []string
 629	if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
 630		for _, tg := range tags {
 631			if !released[tg.Name] {
 632				freeTags = append(freeTags, tg.Name)
 633			}
 634		}
 635	}
 636	s.render(w, "releases.html", struct {
 637		repoPage
 638		Releases []relView
 639		FreeTags []string
 640		CanWrite bool
 641		Notice   string
 642	}{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
 643}
 644
 645// releaseAsset streams one uploaded asset. Tags containing '/' are not
 646// reachable here (single path segment); SSH download always works.
 647func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 648	p, ok := s.repoFor(w, r, "")
 649	if !ok {
 650		return
 651	}
 652	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 653	if err != nil {
 654		s.notFound(w, r)
 655		return
 656	}
 657	name := r.PathValue("name")
 658	found := false
 659	for _, a := range rel.Assets {
 660		if a.Name == name {
 661			found = true
 662		}
 663	}
 664	if !found {
 665		s.notFound(w, r)
 666		return
 667	}
 668	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 669		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 670	if err != nil {
 671		s.notFound(w, r)
 672		return
 673	}
 674	defer f.Close()
 675	w.Header().Set("Content-Type", "application/octet-stream")
 676	w.Header().Set("X-Content-Type-Options", "nosniff")
 677	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 678	if fi, err := f.Stat(); err == nil {
 679		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 680	}
 681	io.Copy(w, f)
 682}
 683
 684// milestones lists a repo's milestones with progress.
 685func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 686	p, ok := s.repoFor(w, r, "")
 687	if !ok {
 688		return
 689	}
 690	p.Tab = "issues"
 691	state := r.URL.Query().Get("state")
 692	if state != "closed" && state != "all" {
 693		state = "open"
 694	}
 695	ms, err := s.st.ListMilestones(p.Repo.ID, state)
 696	if err != nil {
 697		http.Error(w, "internal error", http.StatusInternalServerError)
 698		return
 699	}
 700	type msView struct {
 701		store.Milestone
 702		Percent int
 703	}
 704	var views []msView
 705	for _, m := range ms {
 706		v := msView{Milestone: m}
 707		if total := m.OpenItems + m.ClosedItems; total > 0 {
 708			v.Percent = m.ClosedItems * 100 / total
 709		}
 710		views = append(views, v)
 711	}
 712	s.render(w, "milestones.html", struct {
 713		repoPage
 714		State      string
 715		Milestones []msView
 716	}{p, state, views})
 717}
 718
 719// search runs a bounded literal git grep over the repo's default branch.
 720func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 721	p, ok := s.repoFor(w, r, "")
 722	if !ok {
 723		return
 724	}
 725	p.Tab = "search"
 726	q := strings.TrimSpace(r.URL.Query().Get("q"))
 727	type matchView struct {
 728		Path     string
 729		Line     int
 730		TextHTML template.HTML
 731	}
 732	var matches []matchView
 733	var queryErr string
 734	if q != "" {
 735		if len(q) < 2 || len(q) > 200 {
 736			queryErr = "query must be 2 to 200 characters"
 737		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 738			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 739			if err != nil {
 740				http.Error(w, "internal error", http.StatusInternalServerError)
 741				return
 742			}
 743			for _, m := range raw {
 744				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 745			}
 746		}
 747	}
 748	s.render(w, "search.html", struct {
 749		repoPage
 750		Query    string
 751		QueryErr string
 752		Matches  []matchView
 753		Capped   bool
 754	}{p, q, queryErr, matches, len(matches) == 200})
 755}
 756
 757// markMatch escapes a matched line and wraps case-insensitive occurrences
 758// of the query in <mark>.
 759func markMatch(text, q string) template.HTML {
 760	lower, lq := strings.ToLower(text), strings.ToLower(q)
 761	var b strings.Builder
 762	pos := 0
 763	for {
 764		i := strings.Index(lower[pos:], lq)
 765		if i < 0 {
 766			break
 767		}
 768		i += pos
 769		b.WriteString(template.HTMLEscapeString(text[pos:i]))
 770		b.WriteString("<mark>")
 771		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
 772		b.WriteString("</mark>")
 773		pos = i + len(q)
 774	}
 775	b.WriteString(template.HTMLEscapeString(text[pos:]))
 776	return template.HTML(b.String())
 777}
 778
 779func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 780	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 781	if !ok {
 782		return
 783	}
 784	p.Tab = "files"
 785	filePath := strings.Trim(r.PathValue("path"), "/")
 786
 787	// Blame is a control command; the web renders what it returns rather
 788	// than shelling out to git itself, so all three surfaces agree.
 789	page := 1
 790	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
 791		page = n
 792	}
 793	from := (page-1)*control.BlameSpan + 1
 794
 795	var out struct {
 796		From       int `json:"from"`
 797		To         int `json:"to"`
 798		TotalLines int `json:"total_lines"`
 799		Hunks      []struct {
 800			SHA         string   `json:"sha"`
 801			AuthorName  string   `json:"author_name"`
 802			AuthorEmail string   `json:"author_email"`
 803			Date        string   `json:"date"`
 804			Summary     string   `json:"summary"`
 805			StartLine   int      `json:"start_line"`
 806			Lines       []string `json:"lines"`
 807		} `json:"hunks"`
 808	}
 809	argv := []string{"repo", "blame", p.Repo.Path(), filePath,
 810		"--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
 811	var viewer store.User
 812	if s.cfg.Web.Mode == "accounts" {
 813		viewer = s.viewer(r)
 814	}
 815	msg, ok := s.runControlInto(viewer, argv, &out)
 816
 817	// A binary or empty file is a refusal, not a 404: the page still
 818	// renders and says why there is nothing to attribute.
 819	binary := false
 820	if !ok {
 821		if strings.Contains(msg, "is binary") {
 822			binary = true
 823		} else {
 824			s.notFound(w, r)
 825			return
 826		}
 827	}
 828
 829	type hunkView struct {
 830		gitutil.BlameHunk
 831		ShortSHA string
 832		Date     string
 833		Sig      sigView
 834		Numbered []numberedLine
 835	}
 836	var hunks []hunkView
 837	sigs := map[string]sigView{}
 838	for _, h := range out.Hunks {
 839		v, seen := sigs[h.SHA]
 840		if !seen {
 841			v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 842			sigs[h.SHA] = v
 843		}
 844		date := h.Date
 845		if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
 846			date = t.Format("2006-01-02")
 847		}
 848		hv := hunkView{
 849			BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
 850				AuthorEmail: h.AuthorEmail, Summary: h.Summary,
 851				StartLine: h.StartLine, Lines: h.Lines},
 852			ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
 853		}
 854		for i, l := range h.Lines {
 855			hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 856		}
 857		hunks = append(hunks, hv)
 858	}
 859
 860	pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
 861	if pages == 0 {
 862		pages = 1
 863	}
 864	if page > pages {
 865		page = pages
 866	}
 867
 868	cs := crumbs(p, "blame", filePath)
 869	base := ""
 870	if len(cs) > 0 {
 871		base = cs[len(cs)-1].Name
 872		cs = cs[:len(cs)-1]
 873	}
 874	s.render(w, "blame.html", struct {
 875		repoPage
 876		Crumbs      []crumb
 877		Base        string
 878		Path        string
 879		Binary      bool
 880		Hunks       []hunkView
 881		Page, Pages int
 882	}{p, cs, base, filePath, binary, hunks, page, pages})
 883}
 884
 885type numberedLine struct {
 886	N    int
 887	Text string
 888}
 889
 890// chromaFormatter emits class-based markup (no inline colors), so the
 891// stylesheet can swap palettes with the color scheme.
 892var chromaFormatter = html.New(html.WithClasses(true),
 893	html.WithLineNumbers(true), html.LineNumbersInTable(false),
 894	html.WithLinkableLineNumbers(true, "L"))
 895
 896func highlight(filePath string, data []byte) template.HTML {
 897	lexer := lexers.Match(filePath)
 898	if lexer == nil {
 899		lexer = lexers.Fallback
 900	}
 901	iterator, err := lexer.Tokenise(nil, string(data))
 902	if err != nil {
 903		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 904	}
 905	var buf bytes.Buffer
 906	if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
 907		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 908	}
 909	return template.HTML(buf.String())
 910}
 911
 912// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
 913// The light one cannot be left unscoped: the two palettes do not name the
 914// same token set, and every token github-dark omits would keep its
 915// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
 916// Scoped, an unnamed token inherits the wrapper's colour instead, which is
 917// readable in both. The site's --code-bg stays the background either way.
 918// lightStyle and darkStyle are chosen on measured contrast against the
 919// grounds code actually sits on here — page, code block, and the diff
 920// tints. friendly, the chroma default, put 61 token/ground pairs under
 921// 4.5:1; xcode puts one.
 922const (
 923	lightStyle = "xcode"
 924	darkStyle  = "github-dark"
 925)
 926
 927var chromaCSS = func() []byte {
 928	var buf bytes.Buffer
 929	buf.WriteString("@media (prefers-color-scheme: light) {\n")
 930	chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
 931	// xcode's NameAttribute is its one token under 4.5:1 against the diff
 932	// tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
 933	buf.WriteString(".chroma .na { color: #6f5a21 }\n")
 934	buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
 935	chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
 936	buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
 937	// Line numbers take the site's own gutter colour in both schemes. Left
 938	// alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
 939	// chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
 940	// latter is a formatter fallback, not a style entry, so no palette test
 941	// can see it.
 942	buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
 943	return buf.Bytes()
 944}()
 945
 946func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 947	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 948	if !ok {
 949		return
 950	}
 951	filePath := strings.Trim(r.PathValue("path"), "/")
 952	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 953	if err != nil {
 954		s.notFound(w, r)
 955		return
 956	}
 957	// Serve inert: never let repo content execute in the forge's origin.
 958	// Images get their real type so <img> works under nosniff; SVG script
 959	// is dead on arrival because the instance CSP is script-src 'none'.
 960	ct := "text/plain; charset=utf-8"
 961	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
 962		ct = t
 963	}
 964	w.Header().Set("Content-Type", ct)
 965	w.Header().Set("X-Content-Type-Options", "nosniff")
 966	w.Write(data)
 967}
 968
 969// imageTypes are the formats raw serves with a real content type and blob
 970// pages preview inline.
 971var imageTypes = map[string]string{
 972	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
 973	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
 974	".svg": "image/svg+xml", ".ico": "image/x-icon",
 975}
 976
 977// readmeRank orders competing README files: richer renderers win.
 978var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 979
 980// pickReadme returns the best README-ish blob in a tree listing: any file
 981// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 982// we can render richly.
 983func pickReadme(entries []gitutil.TreeEntry) string {
 984	best, bestRank := "", 1<<30
 985	for _, e := range entries {
 986		if e.Type != "blob" {
 987			continue
 988		}
 989		lower := strings.ToLower(e.Name)
 990		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 991			continue
 992		}
 993		rank, ok := readmeRank[path.Ext(lower)]
 994		if !ok {
 995			rank = 10 // plaintext fallback
 996		}
 997		if rank < bestRank {
 998			best, bestRank = e.Name, rank
 999		}
1000	}
1001	return best
1002}
1003
1004// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1005// task lists) on top of CommonMark, with class-based fence highlighting
1006// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1007// dropped.
1008// Headings carry ids so a README or wiki section can be linked to, the
1009// way org headings already are (#132).
1010var markdown = goldmark.New(
1011	goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1012	goldmark.WithExtensions(extension.GFM,
1013		highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1014
1015// fenceHighlight renders one code block with chroma classes, for org and
1016// anything else outside goldmark. Unknown languages fall back to plain.
1017func fenceHighlight(source, lang string) string {
1018	lexer := lexers.Get(lang)
1019	if lexer == nil {
1020		lexer = lexers.Fallback
1021	}
1022	iterator, err := lexer.Tokenise(nil, source)
1023	if err != nil {
1024		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1025	}
1026	var buf bytes.Buffer
1027	f := html.New(html.WithClasses(true))
1028	if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1029		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1030	}
1031	return buf.String()
1032}
1033
1034// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1035// goldmark's default renderer drops raw HTML, so this is safe as-is.
1036func mdHTML(raw string) template.HTML {
1037	if strings.TrimSpace(raw) == "" {
1038		return ""
1039	}
1040	var buf bytes.Buffer
1041	if markdown.Convert([]byte(raw), &buf) != nil {
1042		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1043	}
1044	return template.HTML(buf.String())
1045}
1046
1047// aboutHTML renders a profile's about text. It has no filename to
1048// dispatch on, so the stored format picks the extension; anything other
1049// than org is markdown.
1050func aboutHTML(p store.Profile) template.HTML {
1051	if strings.TrimSpace(p.About) == "" {
1052		return ""
1053	}
1054	name := "about.md"
1055	if p.AboutFormat == "org" {
1056		name = "about.org"
1057	}
1058	return renderReadme(name, []byte(p.About))
1059}
1060
1061// webResolver answers autolink lookups for one viewer. Cross-repo
1062// references to repositories the viewer cannot read stay plain text, per
1063// the enumeration rule: a link would confirm the repo exists.
1064type webResolver struct {
1065	s      *Server
1066	viewer store.User
1067}
1068
1069func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1070	repo, err := r.s.st.RepoByPath(owner + "/" + name)
1071	if err != nil {
1072		return ""
1073	}
1074	grant := ""
1075	if r.viewer.ID != 0 {
1076		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1077	}
1078	if !policy.CanRead(r.viewer, repo, grant) {
1079		return ""
1080	}
1081	if kind == '#' {
1082		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1083			return ""
1084		}
1085		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1086	}
1087	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1088		return ""
1089	}
1090	return autolink.MRURL(repo.OwnerName, repo.Name, n)
1091}
1092
1093func (r webResolver) UserURL(name string) string {
1094	if _, err := r.s.st.UserByUsername(name); err == nil {
1095		return "/" + name
1096	}
1097	if _, err := r.s.st.OrgByName(name); err == nil {
1098		return "/" + name
1099	}
1100	return ""
1101}
1102
1103// ugcRenderer renders one user-authored body in the format it was written in.
1104// The format travels with the body: it is recorded when the text is written, so
1105// changing a preference later cannot re-interpret prose that already exists.
1106type ugcRenderer func(raw, format string) template.HTML
1107
1108// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1109// so a body stored before formats existed — and any row whose column defaulted —
1110// renders exactly as it did before.
1111//
1112// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1113// about text take, so it inherits that function's include guard and sanitising
1114// rather than growing a second org renderer to keep in step.
1115func ugcHTML(raw, format string) template.HTML {
1116	if format == "org" {
1117		return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1118			return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1119		})
1120	}
1121	return mdHTML(raw)
1122}
1123
1124// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1125// ugcHTML plus cross-reference and mention autolinking for this viewer.
1126func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1127	viewer := store.User{}
1128	if s.cfg.Web.Mode == "accounts" {
1129		viewer = s.viewer(r)
1130	}
1131	res := webResolver{s, viewer}
1132	return func(raw, format string) template.HTML {
1133		h := ugcHTML(raw, format)
1134		if h == "" {
1135			return h
1136		}
1137		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1138	}
1139}
1140
1141// renderedComment pairs a comment with its rendered body for templates.
1142type renderedComment struct {
1143	Author    string
1144	CreatedAt string
1145	Kind      string
1146	BodyHTML  template.HTML
1147}
1148
1149func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1150	var out []renderedComment
1151	for _, c := range cs {
1152		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1153	}
1154	return out
1155}
1156
1157// ugcPolicy sanitizes rendered repo content before it enters the forge's
1158// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1159// output and repo-authored HTML are not. Chroma's highlighting classes
1160// must survive; the pattern admits only short token codes, not the site's
1161// own class names.
1162var ugcPolicy = func() *bluemonday.Policy {
1163	p := bluemonday.UGCPolicy()
1164	p.AllowAttrs("class").
1165		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1166		OnElements("span", "pre", "code", "div")
1167	return p
1168}()
1169
1170// renderReadme renders a README by extension: markdown, org-mode, and
1171// (sanitized) HTML richly; everything else as escaped plaintext.
1172// orgConfig is the go-org configuration for rendering untrusted org.
1173//
1174// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1175// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1176// wiki page, a profile — so both keywords are refused outright: the file is
1177// never opened and the keyword stays the inert text it is. There is no safe
1178// subset to allow instead. An absolute path skips go-org's relative-path join,
1179// a relative one resolves against the daemon's working directory, and a repo
1180// has no directory to scope to anyway because the content came from a git
1181// object rather than a checkout.
1182//
1183// The default logger writes parse warnings to stderr, which would let pushed
1184// content write to the server's log; discard them.
1185func orgConfig() *org.Configuration {
1186	c := org.New()
1187	c.ReadFile = func(string) ([]byte, error) {
1188		return nil, errOrgIncludeDisabled
1189	}
1190	c.Log = log.New(io.Discard, "", 0)
1191	return c
1192}
1193
1194var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1195
1196// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1197// of contents: a README or wiki page is a document and carries one, an issue
1198// comment is a remark and should not sprout one above two headings. `fallback`
1199// supplies the plaintext rendering used when the writer fails.
1200func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1201	c := orgConfig()
1202	if !contents {
1203		// DefaultSettings is a fresh map per org.New(), so this is local.
1204		c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1205	}
1206	doc := c.Parse(bytes.NewReader(raw), name)
1207	writer := org.NewHTMLWriter()
1208	writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1209		if inline {
1210			return "<code>" + template.HTMLEscapeString(source) + "</code>"
1211		}
1212		return fenceHighlight(source, lang)
1213	}
1214	out, err := doc.Write(writer)
1215	if err != nil {
1216		return fallback()
1217	}
1218	return template.HTML(ugcPolicy.Sanitize(out))
1219}
1220
1221// headingTag matches an opening or closing h1..h5 tag, so a rendered
1222// document's headings can move down one level.
1223var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1224
1225// demoteHeadings moves every heading in a rendered document down one
1226// level: the page it sits on already has its h1 (the repository, the
1227// file, the wiki page), so a README's own h1 would be a second top-level
1228// heading in the outline (#133). Ids and anchors are untouched.
1229func demoteHeadings(h template.HTML) template.HTML {
1230	return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1231		sub := headingTag.FindStringSubmatch(m)
1232		return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1233	}))
1234}
1235
1236func renderReadme(name string, raw []byte) template.HTML {
1237	plain := func() template.HTML {
1238		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1239	}
1240	if gitutil.IsBinary(raw) {
1241		return ""
1242	}
1243	switch path.Ext(strings.ToLower(name)) {
1244	case ".md", ".markdown":
1245		var buf bytes.Buffer
1246		if markdown.Convert(raw, &buf) != nil {
1247			return plain()
1248		}
1249		return demoteHeadings(template.HTML(buf.String()))
1250	case ".org":
1251		return demoteHeadings(renderOrg(name, raw, true, plain))
1252	case ".html", ".htm":
1253		return template.HTML(ugcPolicy.Sanitize(string(raw)))
1254	default:
1255		return plain()
1256	}
1257}
1258
1259type diffThread struct {
1260	ID       int64
1261	Resolved string
1262	Stale    bool
1263	// Pending marks a thread in the viewer's own unsubmitted review. Only
1264	// they are shown it, and the page says so, since it looks exactly
1265	// like a posted one otherwise.
1266	Pending    bool
1267	CanResolve bool
1268	Comments   []renderedComment
1269}
1270
1271// reviewRights decides which thread controls a viewer sees. mr resolve
1272// admits the thread author, the MR author, or anyone with write, so the
1273// page needs all three to render the button truthfully.
1274type reviewRights struct {
1275	Viewer   string
1276	MRAuthor string
1277	Write    bool
1278}
1279
1280func (r reviewRights) canResolve(threadAuthor string) bool {
1281	return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1282}
1283
1284// attachThreads injects review threads under their anchored diff lines;
1285// threads whose anchor no longer appears (stale after force-push, or on a
1286// context line outside the current diff) are returned separately.
1287func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1288	type anchor struct {
1289		path string
1290		side string
1291		line int64
1292	}
1293	// Diff-line comments have no stored format yet, so they stay markdown.
1294	// They are the one user-authored body left without the choice; see #51.
1295	threads := map[int64]*diffThread{}
1296	anchors := map[int64]anchor{}
1297	var order []int64
1298	for _, cm := range comments {
1299		if cm.ReplyTo == 0 {
1300			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1301				Pending:    cm.Pending,
1302				CanResolve: rights.canResolve(cm.Author),
1303				Comments:   []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1304			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1305			order = append(order, cm.ID)
1306		} else if th, ok := threads[cm.ReplyTo]; ok {
1307			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1308		}
1309	}
1310	placed := map[int64]bool{}
1311	for f := range files {
1312		lines := files[f].Lines
1313		for i := range lines {
1314			for _, id := range order {
1315				if placed[id] || threads[id].Stale {
1316					continue
1317				}
1318				a := anchors[id]
1319				if lines[i].Path != a.path {
1320					continue
1321				}
1322				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1323					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1324					lines[i].Threads = append(lines[i].Threads, *threads[id])
1325					files[f].Threads++
1326					files[f].Open = true
1327					placed[id] = true
1328				}
1329			}
1330		}
1331	}
1332	var unplaced []diffThread
1333	for _, id := range order {
1334		if !placed[id] {
1335			unplaced = append(unplaced, *threads[id])
1336		}
1337	}
1338	return files, unplaced
1339}
1340
1341// markCompose opens the new-thread form under one diff line. There is no
1342// JavaScript, so "comment on this line" is a plain GET carrying the
1343// anchor and the page renders the form where the reader asked for it.
1344func markCompose(files []diffFile, q url.Values) {
1345	path := q.Get("cpath")
1346	line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1347	if path == "" || line < 1 {
1348		return
1349	}
1350	old := q.Get("cside") == "old"
1351	for f := range files {
1352		for i := range files[f].Lines {
1353			ln := &files[f].Lines[i]
1354			if ln.Path != path {
1355				continue
1356			}
1357			if (old && ln.Class == "del" && ln.OldLine == line) ||
1358				(!old && ln.Class != "del" && ln.NewLine == line) {
1359				ln.Compose = true
1360				files[f].Open = true
1361				return
1362			}
1363		}
1364	}
1365}
1366
1367type sigView struct {
1368	State       string
1369	Signer      string
1370	Fingerprint string
1371}
1372
1373func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1374	raw, err := gitutil.ReadCommit(dir, sha)
1375	if err != nil {
1376		return sigView{State: "unsigned"}, nil
1377	}
1378	parsed, err := sig.ParseCommit(raw)
1379	if err != nil {
1380		return sigView{State: "unsigned"}, nil
1381	}
1382	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1383	if err != nil {
1384		return sigView{State: "unsigned"}, parsed
1385	}
1386	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1387	if res.SignerUserID != 0 {
1388		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1389			v.Signer = u.Username
1390		}
1391	}
1392	return v, parsed
1393}
1394
1395func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1396	ref := r.PathValue("ref")
1397	p, ok := s.repoFor(w, r, ref)
1398	if !ok {
1399		return
1400	}
1401	p.Tab = "log"
1402	const pageSize = 50
1403	// ?path= filters to commits touching one file or directory.
1404	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1405	if filePath == "." {
1406		filePath = ""
1407	}
1408	var shas []string
1409	var err error
1410	if filePath != "" {
1411		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1412	} else {
1413		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1414	}
1415	if err != nil {
1416		s.notFound(w, r)
1417		return
1418	}
1419	next := ""
1420	if len(shas) > pageSize {
1421		next = shas[pageSize]
1422		shas = shas[:pageSize]
1423	}
1424	type row struct {
1425		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1426		Sig                                                               sigView
1427		Check                                                             string // combined status, "" when none ran
1428	}
1429	names := s.authorNames()
1430	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1431	var rows []row
1432	for _, sha := range shas {
1433		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1434		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1435		if parsed != nil {
1436			rw.Subject = parsed.Subject
1437			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1438			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1439			rw.AuthorEmail = parsed.AuthorEmail
1440			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1441		}
1442		rows = append(rows, rw)
1443	}
1444	s.render(w, "log.html", struct {
1445		repoPage
1446		Commits  []row
1447		NextSHA  string
1448		FilePath string
1449	}{p, rows, next, filePath})
1450}
1451
1452func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1453	p, ok := s.repoFor(w, r, "")
1454	if !ok {
1455		return
1456	}
1457	p.Tab = "log"
1458	sha := r.PathValue("sha")
1459	full, err := gitutil.ResolveRef(p.Dir, sha)
1460	if err != nil {
1461		s.notFound(w, r)
1462		return
1463	}
1464	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1465	if parsed == nil {
1466		s.notFound(w, r)
1467		return
1468	}
1469	patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1470	files := parseDiff(patch)
1471	committerEmail := ""
1472	if parsed.CommitterEmail != parsed.AuthorEmail {
1473		committerEmail = parsed.CommitterEmail
1474	}
1475	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1476	commitNames := s.authorNames()
1477	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1478	msg := ""
1479	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1480		msg = string(parsed.Payload[i+2:])
1481	}
1482	s.render(w, "commit.html", struct {
1483		repoPage
1484		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1485		Parents                                                                           []string
1486		Sig                                                                               sigView
1487		Checks                                                                            []store.CommitStatus
1488		DiffFiles                                                                         []diffFile
1489		DiffTruncated                                                                     bool
1490	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1491		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1492		gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1493}
1494
1495// labelPalette provides default label chip colors: mid-tone hues that stay
1496// legible on light and dark backgrounds.
1497var labelPalette = []string{
1498	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1499	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1500}
1501
1502var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1503
1504// clampChip keeps a user-set label colour legible as text on both
1505// grounds. Contrast is defined on relative luminance, so that is what is
1506// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1507// and against the dark ground alike, and where the palette's own colours
1508// sit. The hue is kept; the channels are scaled in linear light (#120).
1509func clampChip(hex string) string {
1510	lin := func(c int64) float64 {
1511		v := float64(c) / 255
1512		if v <= 0.04045 {
1513			return v / 12.92
1514		}
1515		return math.Pow((v+0.055)/1.055, 2.4)
1516	}
1517	r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1518	y := 0.2126*r + 0.7152*g + 0.0722*b
1519	const lo, hi = 0.12, 0.28
1520	if y >= lo && y <= hi {
1521		return strings.ToLower(hex)
1522	}
1523	target := hi
1524	if y < lo {
1525		target = lo
1526	}
1527	if y == 0 {
1528		r, g, b = target, target, target
1529	} else {
1530		k := target / y
1531		r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1532	}
1533	enc := func(v float64) int {
1534		if v <= 0.0031308 {
1535			v *= 12.92
1536		} else {
1537			v = 1.055*math.Pow(v, 1/2.4) - 0.055
1538		}
1539		return int(math.Round(v * 255))
1540	}
1541	return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1542}
1543
1544func hexByte(s string) int64 {
1545	n, _ := strconv.ParseInt(s, 16, 32)
1546	return n
1547}
1548
1549// labelColors returns a complete label-name -> chip color map for a repo:
1550// the stored labels.color when it is a valid hex color, otherwise a
1551// stable default picked from the palette by name hash.
1552func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1553	stored, _ := s.st.LabelColors(repoID)
1554	out := make(map[string]template.CSS, len(stored))
1555	for name, color := range stored {
1556		if !hexColorPat.MatchString(color) {
1557			h := fnv.New32a()
1558			h.Write([]byte(name))
1559			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1560		}
1561		out[name] = template.CSS("--chip:" + clampChip(color))
1562	}
1563	return out
1564}
1565
1566// listPage is how many issues or merge requests a list page shows before
1567// it offers the older ones (#118). Keyset paging on the number, the same
1568// cursor the commands use, so every filter carries across pages.
1569const listPage = 50
1570
1571// olderLink is the current URL with before=<number> set.
1572func olderLink(r *http.Request, before int64) string {
1573	q := r.URL.Query()
1574	q.Set("before", strconv.FormatInt(before, 10))
1575	return "?" + q.Encode()
1576}
1577
1578func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1579	p, ok := s.repoFor(w, r, "")
1580	if !ok {
1581		return
1582	}
1583	p.Tab = "issues"
1584	state := r.URL.Query().Get("state")
1585	if state != "closed" && state != "all" {
1586		state = "open"
1587	}
1588	// The same filters the CLI's issue list takes, as query parameters;
1589	// label chips and author links point here.
1590	qv := r.URL.Query()
1591	f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1592		Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1593		Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1594	f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1595	issues, err := s.st.QueryIssues(p.Repo.ID, f)
1596	if err != nil {
1597		http.Error(w, "internal error", http.StatusInternalServerError)
1598		return
1599	}
1600	older := ""
1601	if len(issues) > listPage {
1602		issues = issues[:listPage]
1603		older = olderLink(r, issues[len(issues)-1].Number)
1604	}
1605	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1606		for i := range issues {
1607			issues[i].Labels = labels[issues[i].ID]
1608		}
1609	}
1610	s.render(w, "issues.html", struct {
1611		repoPage
1612		State       string
1613		Label       string
1614		Query       string
1615		Filters     []listFilter
1616		Issues      []store.Issue
1617		LabelColors map[string]template.CSS
1618		Older       string
1619	}{p, state, f.Label, f.Search,
1620		activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1621		issues, s.labelColors(p.Repo.ID), older})
1622}
1623
1624func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1625	p, ok := s.repoFor(w, r, "")
1626	if !ok {
1627		return
1628	}
1629	p.Tab = "issues"
1630	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1631	if err != nil {
1632		s.notFound(w, r)
1633		return
1634	}
1635	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1636	if err != nil {
1637		s.notFound(w, r)
1638		return
1639	}
1640	comments, err := s.st.ListIssueComments(iss.ID)
1641	if err != nil {
1642		http.Error(w, "internal error", http.StatusInternalServerError)
1643		return
1644	}
1645	md := s.ugcFor(r, p.Repo)
1646	milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1647	s.render(w, "issue.html", struct {
1648		repoPage
1649		Issue       store.Issue
1650		BodyHTML    template.HTML
1651		Comments    []renderedComment
1652		CanEdit     bool
1653		CanWrite    bool
1654		Milestones  []store.Milestone
1655		Notice      string
1656		LabelColors map[string]template.CSS
1657	}{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1658		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1659		milestones, s.takeFlash(w, r), s.labelColors(p.Repo.ID)})
1660}
1661
1662// canEditItem: the author or anyone with write access may edit.
1663// canWriteRepo reports whether the browser session may push to the repo,
1664// which is what gates the review and merge controls.
1665func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1666	if s.cfg.Web.Mode != "accounts" {
1667		return false
1668	}
1669	u := s.viewer(r)
1670	if u.ID == 0 {
1671		return false
1672	}
1673	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1674	return policy.CanWrite(u, repo, grant)
1675}
1676
1677func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1678	if s.cfg.Web.Mode != "accounts" {
1679		return false
1680	}
1681	u := s.viewer(r)
1682	if u.ID == 0 {
1683		return false
1684	}
1685	if u.Username == author {
1686		return true
1687	}
1688	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1689	return policy.CanWrite(u, repo, grant)
1690}
1691
1692func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1693	p, ok := s.repoFor(w, r, "")
1694	if !ok {
1695		return
1696	}
1697	p.Tab = "merge requests"
1698	state := r.URL.Query().Get("state")
1699	if state == "" {
1700		state = "open"
1701	}
1702	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1703	if !valid[state] {
1704		state = "open"
1705	}
1706	qv := r.URL.Query()
1707	mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1708		Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1709	mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1710	mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1711	if err != nil {
1712		http.Error(w, "internal error", http.StatusInternalServerError)
1713		return
1714	}
1715	older := ""
1716	if len(mrs) > listPage {
1717		mrs = mrs[:listPage]
1718		older = olderLink(r, mrs[len(mrs)-1].Number)
1719	}
1720	s.render(w, "mrs.html", struct {
1721		repoPage
1722		State   string
1723		Query   string
1724		Filters []listFilter
1725		MRs     []store.MR
1726		Older   string
1727	}{p, state, mf.Search,
1728		activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs, older})
1729}
1730
1731func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1732	p, ok := s.repoFor(w, r, "")
1733	if !ok {
1734		return
1735	}
1736	p.Tab = "merge requests"
1737	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1738	if err != nil {
1739		s.notFound(w, r)
1740		return
1741	}
1742	m, err := s.st.MRByNumber(p.Repo.ID, n)
1743	if err != nil {
1744		s.notFound(w, r)
1745		return
1746	}
1747	comments, _ := s.st.ListMRComments(m.ID)
1748	reviews, _ := s.st.ListMRReviews(m.ID)
1749	checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1750	// The viewer sees their own unsubmitted review comments and nobody
1751	// else's.
1752	diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
1753
1754	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1755	var files []diffFile
1756	base := m.MergedBase
1757	if base == "" {
1758		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1759			base = b
1760		}
1761	}
1762	var diffTruncated bool
1763	if base != "" {
1764		if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1765			files, diffTruncated = parseDiff(patch), truncated
1766		}
1767	}
1768	md := s.ugcFor(r, p.Repo)
1769	canWrite := s.canWriteRepo(r, p.Repo)
1770	var detachedThreads []diffThread
1771	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1772		reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1773	if p.Viewer != "" {
1774		markCompose(files, r.URL.Query())
1775	}
1776	stat := statOf(files)
1777	// The commits this MR carries: base..head, the same range as the diff.
1778	type commitRow struct {
1779		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1780		Sig                                                  sigView
1781	}
1782	mrNames := s.authorNames()
1783	var commits []commitRow
1784	commitsTotal := 0
1785	if base != "" {
1786		const maxMRCommits = 100
1787		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1788		commitsTotal = len(shas)
1789		if len(shas) > maxMRCommits {
1790			shas = shas[:maxMRCommits]
1791		}
1792		for _, sha := range shas {
1793			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1794			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1795			if parsed != nil {
1796				cr.Subject = parsed.Subject
1797				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1798				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1799				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1800			}
1801			commits = append(commits, cr)
1802		}
1803	}
1804	// The diff is the reason most people open a merge request, so it gets
1805	// its own view rather than a fold at the foot of the conversation.
1806	// A query parameter keeps this working without JavaScript.
1807	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1808	// The revisions this merge request has had. A stale review is the
1809	// moment someone wants to know what moved, so the link to the
1810	// range-diff belongs next to it.
1811	revisions, _ := s.st.MRHeads(m.ID)
1812	branches, _ := gitutil.Refs(p.Dir, "heads")
1813	view := r.URL.Query().Get("view")
1814	if view != "commits" && view != "diff" {
1815		view = "conversation"
1816	}
1817	// The stack around an open merge request, for the header.
1818	var stackedOn *store.MR
1819	var stacked []store.MR
1820	if m.State == "open" {
1821		if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1822			stackedOn = &parent
1823		}
1824		if m.SourceRepoID == p.Repo.ID {
1825			stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1826		}
1827	}
1828	s.render(w, "mr.html", struct {
1829		repoPage
1830		MR              store.MR
1831		View            string
1832		BodyHTML        template.HTML
1833		Checks          []store.Check
1834		Combined        string
1835		Comments        []renderedComment
1836		Reviews         []store.MRReview
1837		DiffFiles       []diffFile
1838		DiffTruncated   bool
1839		Stat            diffStat
1840		Commits         []commitRow
1841		CommitsTotal    int
1842		Branches        []gitutil.Ref
1843		CanEdit         bool
1844		CanWrite        bool
1845		Unresolved      int
1846		Revisions       []store.MRHead
1847		Notice          string
1848		DetachedThreads []diffThread
1849		StackedOn       *store.MR
1850		Stacked         []store.MR
1851	}{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1852		reviews, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1853		canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked})
1854}
1855
1856func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1857	p, ok := s.repoFor(w, r, "")
1858	if !ok {
1859		return
1860	}
1861	p.Tab = "refs"
1862	branches, _ := gitutil.Refs(p.Dir, "heads")
1863	tags, _ := gitutil.Refs(p.Dir, "tags")
1864	s.render(w, "refs.html", struct {
1865		repoPage
1866		Branches, Tags []gitutil.Ref
1867	}{p, branches, tags})
1868}
1869
1870func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1871	p, ok := s.repoFor(w, r, "")
1872	if !ok {
1873		return
1874	}
1875	file := r.PathValue("file")
1876	ref, ok := strings.CutSuffix(file, ".tar.gz")
1877	if !ok {
1878		s.notFound(w, r)
1879		return
1880	}
1881	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1882		s.notFound(w, r)
1883		return
1884	}
1885	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1886	w.Header().Set("Content-Type", "application/gzip")
1887	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1888	gitutil.Archive(p.Dir, ref, prefix, w)
1889}
1890
1891func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1892	return policy.CanAdmin(u, repo, grant)
1893}
1894
1895func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1896	return policy.CanRead(u, repo, grant)
1897}