internal/httpd/web.go

1879 lines · 58707 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	CanResolve bool
1264	Comments   []renderedComment
1265}
1266
1267// reviewRights decides which thread controls a viewer sees. mr resolve
1268// admits the thread author, the MR author, or anyone with write, so the
1269// page needs all three to render the button truthfully.
1270type reviewRights struct {
1271	Viewer   string
1272	MRAuthor string
1273	Write    bool
1274}
1275
1276func (r reviewRights) canResolve(threadAuthor string) bool {
1277	return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1278}
1279
1280// attachThreads injects review threads under their anchored diff lines;
1281// threads whose anchor no longer appears (stale after force-push, or on a
1282// context line outside the current diff) are returned separately.
1283func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1284	type anchor struct {
1285		path string
1286		side string
1287		line int64
1288	}
1289	// Diff-line comments have no stored format yet, so they stay markdown.
1290	// They are the one user-authored body left without the choice; see #51.
1291	threads := map[int64]*diffThread{}
1292	anchors := map[int64]anchor{}
1293	var order []int64
1294	for _, cm := range comments {
1295		if cm.ReplyTo == 0 {
1296			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1297				CanResolve: rights.canResolve(cm.Author),
1298				Comments:   []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1299			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1300			order = append(order, cm.ID)
1301		} else if th, ok := threads[cm.ReplyTo]; ok {
1302			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1303		}
1304	}
1305	placed := map[int64]bool{}
1306	for f := range files {
1307		lines := files[f].Lines
1308		for i := range lines {
1309			for _, id := range order {
1310				if placed[id] || threads[id].Stale {
1311					continue
1312				}
1313				a := anchors[id]
1314				if lines[i].Path != a.path {
1315					continue
1316				}
1317				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1318					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1319					lines[i].Threads = append(lines[i].Threads, *threads[id])
1320					files[f].Threads++
1321					files[f].Open = true
1322					placed[id] = true
1323				}
1324			}
1325		}
1326	}
1327	var unplaced []diffThread
1328	for _, id := range order {
1329		if !placed[id] {
1330			unplaced = append(unplaced, *threads[id])
1331		}
1332	}
1333	return files, unplaced
1334}
1335
1336// markCompose opens the new-thread form under one diff line. There is no
1337// JavaScript, so "comment on this line" is a plain GET carrying the
1338// anchor and the page renders the form where the reader asked for it.
1339func markCompose(files []diffFile, q url.Values) {
1340	path := q.Get("cpath")
1341	line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1342	if path == "" || line < 1 {
1343		return
1344	}
1345	old := q.Get("cside") == "old"
1346	for f := range files {
1347		for i := range files[f].Lines {
1348			ln := &files[f].Lines[i]
1349			if ln.Path != path {
1350				continue
1351			}
1352			if (old && ln.Class == "del" && ln.OldLine == line) ||
1353				(!old && ln.Class != "del" && ln.NewLine == line) {
1354				ln.Compose = true
1355				files[f].Open = true
1356				return
1357			}
1358		}
1359	}
1360}
1361
1362type sigView struct {
1363	State       string
1364	Signer      string
1365	Fingerprint string
1366}
1367
1368func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1369	raw, err := gitutil.ReadCommit(dir, sha)
1370	if err != nil {
1371		return sigView{State: "unsigned"}, nil
1372	}
1373	parsed, err := sig.ParseCommit(raw)
1374	if err != nil {
1375		return sigView{State: "unsigned"}, nil
1376	}
1377	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1378	if err != nil {
1379		return sigView{State: "unsigned"}, parsed
1380	}
1381	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1382	if res.SignerUserID != 0 {
1383		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1384			v.Signer = u.Username
1385		}
1386	}
1387	return v, parsed
1388}
1389
1390func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1391	ref := r.PathValue("ref")
1392	p, ok := s.repoFor(w, r, ref)
1393	if !ok {
1394		return
1395	}
1396	p.Tab = "log"
1397	const pageSize = 50
1398	// ?path= filters to commits touching one file or directory.
1399	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1400	if filePath == "." {
1401		filePath = ""
1402	}
1403	var shas []string
1404	var err error
1405	if filePath != "" {
1406		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1407	} else {
1408		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1409	}
1410	if err != nil {
1411		s.notFound(w, r)
1412		return
1413	}
1414	next := ""
1415	if len(shas) > pageSize {
1416		next = shas[pageSize]
1417		shas = shas[:pageSize]
1418	}
1419	type row struct {
1420		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1421		Sig                                                               sigView
1422		Check                                                             string // combined status, "" when none ran
1423	}
1424	names := s.authorNames()
1425	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1426	var rows []row
1427	for _, sha := range shas {
1428		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1429		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1430		if parsed != nil {
1431			rw.Subject = parsed.Subject
1432			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1433			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1434			rw.AuthorEmail = parsed.AuthorEmail
1435			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1436		}
1437		rows = append(rows, rw)
1438	}
1439	s.render(w, "log.html", struct {
1440		repoPage
1441		Commits  []row
1442		NextSHA  string
1443		FilePath string
1444	}{p, rows, next, filePath})
1445}
1446
1447func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1448	p, ok := s.repoFor(w, r, "")
1449	if !ok {
1450		return
1451	}
1452	p.Tab = "log"
1453	sha := r.PathValue("sha")
1454	full, err := gitutil.ResolveRef(p.Dir, sha)
1455	if err != nil {
1456		s.notFound(w, r)
1457		return
1458	}
1459	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1460	if parsed == nil {
1461		s.notFound(w, r)
1462		return
1463	}
1464	patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1465	files := parseDiff(patch)
1466	committerEmail := ""
1467	if parsed.CommitterEmail != parsed.AuthorEmail {
1468		committerEmail = parsed.CommitterEmail
1469	}
1470	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1471	commitNames := s.authorNames()
1472	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1473	msg := ""
1474	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1475		msg = string(parsed.Payload[i+2:])
1476	}
1477	s.render(w, "commit.html", struct {
1478		repoPage
1479		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1480		Parents                                                                           []string
1481		Sig                                                                               sigView
1482		Checks                                                                            []store.CommitStatus
1483		DiffFiles                                                                         []diffFile
1484		DiffTruncated                                                                     bool
1485	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1486		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1487		gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1488}
1489
1490// labelPalette provides default label chip colors: mid-tone hues that stay
1491// legible on light and dark backgrounds.
1492var labelPalette = []string{
1493	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1494	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1495}
1496
1497var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1498
1499// clampChip keeps a user-set label colour legible as text on both
1500// grounds. Contrast is defined on relative luminance, so that is what is
1501// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1502// and against the dark ground alike, and where the palette's own colours
1503// sit. The hue is kept; the channels are scaled in linear light (#120).
1504func clampChip(hex string) string {
1505	lin := func(c int64) float64 {
1506		v := float64(c) / 255
1507		if v <= 0.04045 {
1508			return v / 12.92
1509		}
1510		return math.Pow((v+0.055)/1.055, 2.4)
1511	}
1512	r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1513	y := 0.2126*r + 0.7152*g + 0.0722*b
1514	const lo, hi = 0.12, 0.28
1515	if y >= lo && y <= hi {
1516		return strings.ToLower(hex)
1517	}
1518	target := hi
1519	if y < lo {
1520		target = lo
1521	}
1522	if y == 0 {
1523		r, g, b = target, target, target
1524	} else {
1525		k := target / y
1526		r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1527	}
1528	enc := func(v float64) int {
1529		if v <= 0.0031308 {
1530			v *= 12.92
1531		} else {
1532			v = 1.055*math.Pow(v, 1/2.4) - 0.055
1533		}
1534		return int(math.Round(v * 255))
1535	}
1536	return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1537}
1538
1539func hexByte(s string) int64 {
1540	n, _ := strconv.ParseInt(s, 16, 32)
1541	return n
1542}
1543
1544// labelColors returns a complete label-name -> chip color map for a repo:
1545// the stored labels.color when it is a valid hex color, otherwise a
1546// stable default picked from the palette by name hash.
1547func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1548	stored, _ := s.st.LabelColors(repoID)
1549	out := make(map[string]template.CSS, len(stored))
1550	for name, color := range stored {
1551		if !hexColorPat.MatchString(color) {
1552			h := fnv.New32a()
1553			h.Write([]byte(name))
1554			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1555		}
1556		out[name] = template.CSS("--chip:" + clampChip(color))
1557	}
1558	return out
1559}
1560
1561// listPage is how many issues or merge requests a list page shows before
1562// it offers the older ones (#118). Keyset paging on the number, the same
1563// cursor the commands use, so every filter carries across pages.
1564const listPage = 50
1565
1566// olderLink is the current URL with before=<number> set.
1567func olderLink(r *http.Request, before int64) string {
1568	q := r.URL.Query()
1569	q.Set("before", strconv.FormatInt(before, 10))
1570	return "?" + q.Encode()
1571}
1572
1573func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1574	p, ok := s.repoFor(w, r, "")
1575	if !ok {
1576		return
1577	}
1578	p.Tab = "issues"
1579	state := r.URL.Query().Get("state")
1580	if state != "closed" && state != "all" {
1581		state = "open"
1582	}
1583	// The same filters the CLI's issue list takes, as query parameters;
1584	// label chips and author links point here.
1585	qv := r.URL.Query()
1586	f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1587		Author: qv.Get("author"), Milestone: qv.Get("milestone"), Limit: listPage + 1}
1588	f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1589	issues, err := s.st.QueryIssues(p.Repo.ID, f)
1590	if err != nil {
1591		http.Error(w, "internal error", http.StatusInternalServerError)
1592		return
1593	}
1594	older := ""
1595	if len(issues) > listPage {
1596		issues = issues[:listPage]
1597		older = olderLink(r, issues[len(issues)-1].Number)
1598	}
1599	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1600		for i := range issues {
1601			issues[i].Labels = labels[issues[i].ID]
1602		}
1603	}
1604	s.render(w, "issues.html", struct {
1605		repoPage
1606		State       string
1607		Label       string
1608		Filters     []listFilter
1609		Issues      []store.Issue
1610		LabelColors map[string]template.CSS
1611		Older       string
1612	}{p, state, f.Label, activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1613		issues, s.labelColors(p.Repo.ID), older})
1614}
1615
1616func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1617	p, ok := s.repoFor(w, r, "")
1618	if !ok {
1619		return
1620	}
1621	p.Tab = "issues"
1622	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1623	if err != nil {
1624		s.notFound(w, r)
1625		return
1626	}
1627	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1628	if err != nil {
1629		s.notFound(w, r)
1630		return
1631	}
1632	comments, err := s.st.ListIssueComments(iss.ID)
1633	if err != nil {
1634		http.Error(w, "internal error", http.StatusInternalServerError)
1635		return
1636	}
1637	md := s.ugcFor(r, p.Repo)
1638	milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1639	s.render(w, "issue.html", struct {
1640		repoPage
1641		Issue       store.Issue
1642		BodyHTML    template.HTML
1643		Comments    []renderedComment
1644		CanEdit     bool
1645		CanWrite    bool
1646		Milestones  []store.Milestone
1647		Notice      string
1648		LabelColors map[string]template.CSS
1649	}{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1650		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1651		milestones, s.takeFlash(w, r), s.labelColors(p.Repo.ID)})
1652}
1653
1654// canEditItem: the author or anyone with write access may edit.
1655// canWriteRepo reports whether the browser session may push to the repo,
1656// which is what gates the review and merge controls.
1657func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1658	if s.cfg.Web.Mode != "accounts" {
1659		return false
1660	}
1661	u := s.viewer(r)
1662	if u.ID == 0 {
1663		return false
1664	}
1665	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1666	return policy.CanWrite(u, repo, grant)
1667}
1668
1669func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1670	if s.cfg.Web.Mode != "accounts" {
1671		return false
1672	}
1673	u := s.viewer(r)
1674	if u.ID == 0 {
1675		return false
1676	}
1677	if u.Username == author {
1678		return true
1679	}
1680	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1681	return policy.CanWrite(u, repo, grant)
1682}
1683
1684func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1685	p, ok := s.repoFor(w, r, "")
1686	if !ok {
1687		return
1688	}
1689	p.Tab = "merge requests"
1690	state := r.URL.Query().Get("state")
1691	if state == "" {
1692		state = "open"
1693	}
1694	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1695	if !valid[state] {
1696		state = "open"
1697	}
1698	qv := r.URL.Query()
1699	mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone"), Limit: listPage + 1}
1700	mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1701	mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1702	if err != nil {
1703		http.Error(w, "internal error", http.StatusInternalServerError)
1704		return
1705	}
1706	older := ""
1707	if len(mrs) > listPage {
1708		mrs = mrs[:listPage]
1709		older = olderLink(r, mrs[len(mrs)-1].Number)
1710	}
1711	s.render(w, "mrs.html", struct {
1712		repoPage
1713		State   string
1714		Filters []listFilter
1715		MRs     []store.MR
1716		Older   string
1717	}{p, state, activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs, older})
1718}
1719
1720func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1721	p, ok := s.repoFor(w, r, "")
1722	if !ok {
1723		return
1724	}
1725	p.Tab = "merge requests"
1726	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1727	if err != nil {
1728		s.notFound(w, r)
1729		return
1730	}
1731	m, err := s.st.MRByNumber(p.Repo.ID, n)
1732	if err != nil {
1733		s.notFound(w, r)
1734		return
1735	}
1736	comments, _ := s.st.ListMRComments(m.ID)
1737	reviews, _ := s.st.ListMRReviews(m.ID)
1738	checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1739	diffComments, _ := s.st.ListDiffComments(m.ID)
1740
1741	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1742	var files []diffFile
1743	base := m.MergedBase
1744	if base == "" {
1745		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1746			base = b
1747		}
1748	}
1749	var diffTruncated bool
1750	if base != "" {
1751		if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1752			files, diffTruncated = parseDiff(patch), truncated
1753		}
1754	}
1755	md := s.ugcFor(r, p.Repo)
1756	canWrite := s.canWriteRepo(r, p.Repo)
1757	var detachedThreads []diffThread
1758	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1759		reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1760	if p.Viewer != "" {
1761		markCompose(files, r.URL.Query())
1762	}
1763	stat := statOf(files)
1764	// The commits this MR carries: base..head, the same range as the diff.
1765	type commitRow struct {
1766		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1767		Sig                                                  sigView
1768	}
1769	mrNames := s.authorNames()
1770	var commits []commitRow
1771	commitsTotal := 0
1772	if base != "" {
1773		const maxMRCommits = 100
1774		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1775		commitsTotal = len(shas)
1776		if len(shas) > maxMRCommits {
1777			shas = shas[:maxMRCommits]
1778		}
1779		for _, sha := range shas {
1780			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1781			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1782			if parsed != nil {
1783				cr.Subject = parsed.Subject
1784				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1785				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1786				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1787			}
1788			commits = append(commits, cr)
1789		}
1790	}
1791	// The diff is the reason most people open a merge request, so it gets
1792	// its own view rather than a fold at the foot of the conversation.
1793	// A query parameter keeps this working without JavaScript.
1794	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1795	branches, _ := gitutil.Refs(p.Dir, "heads")
1796	view := r.URL.Query().Get("view")
1797	if view != "commits" && view != "diff" {
1798		view = "conversation"
1799	}
1800	// The stack around an open merge request, for the header.
1801	var stackedOn *store.MR
1802	var stacked []store.MR
1803	if m.State == "open" {
1804		if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1805			stackedOn = &parent
1806		}
1807		if m.SourceRepoID == p.Repo.ID {
1808			stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1809		}
1810	}
1811	s.render(w, "mr.html", struct {
1812		repoPage
1813		MR              store.MR
1814		View            string
1815		BodyHTML        template.HTML
1816		Checks          []store.Check
1817		Combined        string
1818		Comments        []renderedComment
1819		Reviews         []store.MRReview
1820		DiffFiles       []diffFile
1821		DiffTruncated   bool
1822		Stat            diffStat
1823		Commits         []commitRow
1824		CommitsTotal    int
1825		Branches        []gitutil.Ref
1826		CanEdit         bool
1827		CanWrite        bool
1828		Unresolved      int
1829		Notice          string
1830		DetachedThreads []diffThread
1831		StackedOn       *store.MR
1832		Stacked         []store.MR
1833	}{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1834		reviews, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1835		canWrite, unresolved, s.takeFlash(w, r), detachedThreads, stackedOn, stacked})
1836}
1837
1838func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1839	p, ok := s.repoFor(w, r, "")
1840	if !ok {
1841		return
1842	}
1843	p.Tab = "refs"
1844	branches, _ := gitutil.Refs(p.Dir, "heads")
1845	tags, _ := gitutil.Refs(p.Dir, "tags")
1846	s.render(w, "refs.html", struct {
1847		repoPage
1848		Branches, Tags []gitutil.Ref
1849	}{p, branches, tags})
1850}
1851
1852func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1853	p, ok := s.repoFor(w, r, "")
1854	if !ok {
1855		return
1856	}
1857	file := r.PathValue("file")
1858	ref, ok := strings.CutSuffix(file, ".tar.gz")
1859	if !ok {
1860		s.notFound(w, r)
1861		return
1862	}
1863	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1864		s.notFound(w, r)
1865		return
1866	}
1867	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1868	w.Header().Set("Content-Type", "application/gzip")
1869	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1870	gitutil.Archive(p.Dir, ref, prefix, w)
1871}
1872
1873func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1874	return policy.CanAdmin(u, repo, grant)
1875}
1876
1877func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1878	return policy.CanRead(u, repo, grant)
1879}