internal/httpd/web.go

0f51fba689b235cc0a456b938f3fa4f210f603f5
gitbay/internal/httpd/web.go history · blame · raw

1915 lines · 60051 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.hasWiki(repo),
 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		Self          bool
 444		Notice        string
 445	}{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
 446		d.Repos, d.Members, d.Orgs,
 447		weeks, activityTotal, teams, canAdmin,
 448		d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
 449		s.takeFlash(w, r)})
 450}
 451
 452func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
 453	p, ok := s.repoFor(w, r, "")
 454	if !ok {
 455		return
 456	}
 457	p.Tab = "files"
 458	p.RepoHome = true
 459	s.renderTree(w, r, p, "")
 460}
 461
 462func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
 463	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 464	if !ok {
 465		return
 466	}
 467	p.Tab = "files"
 468	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
 469}
 470
 471// treePage is shared by the populated and empty-repository renders: two
 472// anonymous structs drifted apart once already.
 473type treePage struct {
 474	repoPage
 475	Crumbs      []crumb
 476	Prefix      string
 477	DirPath     string
 478	RefKind     string
 479	Entries     []gitutil.TreeEntry
 480	Branches    []gitutil.Ref
 481	ReadmeName  string
 482	ReadmeHTML  template.HTML
 483	LastCommits map[string]namedCommit
 484	Tip         namedCommit
 485	Facts       repoFacts
 486}
 487
 488func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
 489	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
 490		// Empty repo: render the page with no entries rather than 404.
 491		s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"})
 492		return
 493	}
 494	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
 495	if err != nil {
 496		s.notFound(w, r)
 497		return
 498	}
 499	// Directories first. git's tree order interleaves them with files, but
 500	// a listing is scanned by shape before name. Stable, so each group
 501	// keeps the ordering git gave it.
 502	sort.SliceStable(entries, func(i, j int) bool {
 503		return entries[i].Type == "tree" && entries[j].Type != "tree"
 504	})
 505	prefix := ""
 506	if dirPath != "" {
 507		prefix = dirPath + "/"
 508	}
 509
 510	var readmeHTML template.HTML
 511	readmeName := pickReadme(entries)
 512	if readmeName != "" {
 513		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
 514			readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
 515		}
 516	}
 517
 518	branches, _ := gitutil.Refs(p.Dir, "heads")
 519	names := make([]string, 0, len(entries))
 520	for _, e := range entries {
 521		names = append(names, e.Name)
 522	}
 523	// The facts bar is about the repository, not this directory, so it is
 524	// computed once at the root and left off subdirectory listings.
 525	var facts repoFacts
 526	if dirPath == "" {
 527		facts = s.factsFor(p)
 528	}
 529	s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
 530		readmeName, readmeHTML,
 531		s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
 532		s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts})
 533}
 534
 535func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
 536	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 537	if !ok {
 538		return
 539	}
 540	p.Tab = "files"
 541	filePath := strings.Trim(r.PathValue("path"), "/")
 542	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
 543	if err != nil {
 544		s.notFound(w, r)
 545		return
 546	}
 547	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
 548	_, image := imageTypes[strings.ToLower(path.Ext(filePath))]
 549
 550	var codeHTML template.HTML
 551	if !binary && !image {
 552		codeHTML = highlight(filePath, data)
 553	}
 554	// Markdown and org render like a README, with the source one click
 555	// away; ?view=source shows the text instead.
 556	renderable := false
 557	switch path.Ext(strings.ToLower(filePath)) {
 558	case ".md", ".markdown", ".org":
 559		renderable = !binary
 560	}
 561	var renderedHTML template.HTML
 562	rendered := renderable && r.URL.Query().Get("view") != "source"
 563	if rendered {
 564		renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
 565	}
 566	cs := crumbs(p, "blob", filePath)
 567	base := ""
 568	if len(cs) > 0 {
 569		base = cs[len(cs)-1].Name
 570		cs = cs[:len(cs)-1]
 571	}
 572	branches, _ := gitutil.Refs(p.Dir, "heads")
 573	lines := 0
 574	if !binary && !image && len(data) > 0 {
 575		lines = bytes.Count(data, []byte("\n"))
 576		if data[len(data)-1] != '\n' {
 577			lines++
 578		}
 579	}
 580	// The file listing leads with the last commit now, so the facts about
 581	// the file itself are reported here instead.
 582	entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
 583	s.render(w, "blob.html", struct {
 584		repoPage
 585		Crumbs       []crumb
 586		Base         string
 587		Path         string
 588		DirPath      string
 589		RefKind      string
 590		Binary       bool
 591		Image        bool
 592		Size         int
 593		Lines        int
 594		Exec         bool
 595		Symlink      bool
 596		Branches     []gitutil.Ref
 597		CodeHTML     template.HTML
 598		Renderable   bool // markdown or org: the toggle is offered
 599		Rendered     bool // this response shows the rendering
 600		RenderedHTML template.HTML
 601	}{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
 602		entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
 603}
 604
 605// releases lists tag-anchored releases with notes and assets.
 606func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 607	p, ok := s.repoFor(w, r, "")
 608	if !ok {
 609		return
 610	}
 611	p.Tab = "releases"
 612	rels, err := s.st.ListReleases(p.Repo.ID)
 613	if err != nil {
 614		http.Error(w, "internal error", http.StatusInternalServerError)
 615		return
 616	}
 617	md := s.ugcFor(r, p.Repo)
 618	type relView struct {
 619		store.Release
 620		NotesHTML template.HTML
 621	}
 622	var views []relView
 623	for _, rel := range rels {
 624		views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
 625	}
 626	// Tags without a release yet are what a create form can offer.
 627	released := map[string]bool{}
 628	for _, rel := range rels {
 629		released[rel.Tag] = true
 630	}
 631	var freeTags []string
 632	if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
 633		for _, tg := range tags {
 634			if !released[tg.Name] {
 635				freeTags = append(freeTags, tg.Name)
 636			}
 637		}
 638	}
 639	s.render(w, "releases.html", struct {
 640		repoPage
 641		Releases []relView
 642		FreeTags []string
 643		CanWrite bool
 644		Notice   string
 645	}{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
 646}
 647
 648// releaseAsset streams one uploaded asset. Tags containing '/' are not
 649// reachable here (single path segment); SSH download always works.
 650func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 651	p, ok := s.repoFor(w, r, "")
 652	if !ok {
 653		return
 654	}
 655	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 656	if err != nil {
 657		s.notFound(w, r)
 658		return
 659	}
 660	name := r.PathValue("name")
 661	found := false
 662	for _, a := range rel.Assets {
 663		if a.Name == name {
 664			found = true
 665		}
 666	}
 667	if !found {
 668		s.notFound(w, r)
 669		return
 670	}
 671	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 672		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 673	if err != nil {
 674		s.notFound(w, r)
 675		return
 676	}
 677	defer f.Close()
 678	w.Header().Set("Content-Type", "application/octet-stream")
 679	w.Header().Set("X-Content-Type-Options", "nosniff")
 680	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 681	if fi, err := f.Stat(); err == nil {
 682		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 683	}
 684	io.Copy(w, f)
 685}
 686
 687// milestones lists a repo's milestones with progress.
 688func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 689	p, ok := s.repoFor(w, r, "")
 690	if !ok {
 691		return
 692	}
 693	p.Tab = "issues"
 694	state := r.URL.Query().Get("state")
 695	if state != "closed" && state != "all" {
 696		state = "open"
 697	}
 698	ms, err := s.st.ListMilestones(p.Repo.ID, state)
 699	if err != nil {
 700		http.Error(w, "internal error", http.StatusInternalServerError)
 701		return
 702	}
 703	type msView struct {
 704		store.Milestone
 705		Percent int
 706	}
 707	var views []msView
 708	for _, m := range ms {
 709		v := msView{Milestone: m}
 710		if total := m.OpenItems + m.ClosedItems; total > 0 {
 711			v.Percent = m.ClosedItems * 100 / total
 712		}
 713		views = append(views, v)
 714	}
 715	s.render(w, "milestones.html", struct {
 716		repoPage
 717		State      string
 718		Milestones []msView
 719	}{p, state, views})
 720}
 721
 722// search runs a bounded literal git grep over the repo's default branch.
 723func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 724	p, ok := s.repoFor(w, r, "")
 725	if !ok {
 726		return
 727	}
 728	p.Tab = "search"
 729	q := strings.TrimSpace(r.URL.Query().Get("q"))
 730	type matchView struct {
 731		Path     string
 732		Line     int
 733		TextHTML template.HTML
 734	}
 735	var matches []matchView
 736	var queryErr string
 737	if q != "" {
 738		if len(q) < 2 || len(q) > 200 {
 739			queryErr = "query must be 2 to 200 characters"
 740		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 741			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 742			if err != nil {
 743				http.Error(w, "internal error", http.StatusInternalServerError)
 744				return
 745			}
 746			for _, m := range raw {
 747				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 748			}
 749		}
 750	}
 751	s.render(w, "search.html", struct {
 752		repoPage
 753		Query    string
 754		QueryErr string
 755		Matches  []matchView
 756		Capped   bool
 757	}{p, q, queryErr, matches, len(matches) == 200})
 758}
 759
 760// markMatch escapes a matched line and wraps case-insensitive occurrences
 761// of the query in <mark>.
 762func markMatch(text, q string) template.HTML {
 763	lower, lq := strings.ToLower(text), strings.ToLower(q)
 764	var b strings.Builder
 765	pos := 0
 766	for {
 767		i := strings.Index(lower[pos:], lq)
 768		if i < 0 {
 769			break
 770		}
 771		i += pos
 772		b.WriteString(template.HTMLEscapeString(text[pos:i]))
 773		b.WriteString("<mark>")
 774		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
 775		b.WriteString("</mark>")
 776		pos = i + len(q)
 777	}
 778	b.WriteString(template.HTMLEscapeString(text[pos:]))
 779	return template.HTML(b.String())
 780}
 781
 782func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 783	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 784	if !ok {
 785		return
 786	}
 787	p.Tab = "files"
 788	filePath := strings.Trim(r.PathValue("path"), "/")
 789
 790	// Blame is a control command; the web renders what it returns rather
 791	// than shelling out to git itself, so all three surfaces agree.
 792	page := 1
 793	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
 794		page = n
 795	}
 796	from := (page-1)*control.BlameSpan + 1
 797
 798	var out struct {
 799		From       int `json:"from"`
 800		To         int `json:"to"`
 801		TotalLines int `json:"total_lines"`
 802		Hunks      []struct {
 803			SHA         string   `json:"sha"`
 804			AuthorName  string   `json:"author_name"`
 805			AuthorEmail string   `json:"author_email"`
 806			Date        string   `json:"date"`
 807			Summary     string   `json:"summary"`
 808			StartLine   int      `json:"start_line"`
 809			Lines       []string `json:"lines"`
 810		} `json:"hunks"`
 811	}
 812	argv := []string{"repo", "blame", p.Repo.Path(), filePath,
 813		"--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
 814	var viewer store.User
 815	if s.cfg.Web.Mode == "accounts" {
 816		viewer = s.viewer(r)
 817	}
 818	msg, ok := s.runControlInto(viewer, argv, &out)
 819
 820	// A binary or empty file is a refusal, not a 404: the page still
 821	// renders and says why there is nothing to attribute.
 822	binary := false
 823	if !ok {
 824		if strings.Contains(msg, "is binary") {
 825			binary = true
 826		} else {
 827			s.notFound(w, r)
 828			return
 829		}
 830	}
 831
 832	type hunkView struct {
 833		gitutil.BlameHunk
 834		ShortSHA string
 835		Date     string
 836		Sig      sigView
 837		Numbered []numberedLine
 838	}
 839	var hunks []hunkView
 840	sigs := map[string]sigView{}
 841	for _, h := range out.Hunks {
 842		v, seen := sigs[h.SHA]
 843		if !seen {
 844			v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 845			sigs[h.SHA] = v
 846		}
 847		date := h.Date
 848		if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
 849			date = t.Format("2006-01-02")
 850		}
 851		hv := hunkView{
 852			BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
 853				AuthorEmail: h.AuthorEmail, Summary: h.Summary,
 854				StartLine: h.StartLine, Lines: h.Lines},
 855			ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
 856		}
 857		for i, l := range h.Lines {
 858			hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 859		}
 860		hunks = append(hunks, hv)
 861	}
 862
 863	pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
 864	if pages == 0 {
 865		pages = 1
 866	}
 867	if page > pages {
 868		page = pages
 869	}
 870
 871	cs := crumbs(p, "blame", filePath)
 872	base := ""
 873	if len(cs) > 0 {
 874		base = cs[len(cs)-1].Name
 875		cs = cs[:len(cs)-1]
 876	}
 877	s.render(w, "blame.html", struct {
 878		repoPage
 879		Crumbs      []crumb
 880		Base        string
 881		Path        string
 882		Binary      bool
 883		Hunks       []hunkView
 884		Page, Pages int
 885	}{p, cs, base, filePath, binary, hunks, page, pages})
 886}
 887
 888type numberedLine struct {
 889	N    int
 890	Text string
 891}
 892
 893// chromaFormatter emits class-based markup (no inline colors), so the
 894// stylesheet can swap palettes with the color scheme.
 895var chromaFormatter = html.New(html.WithClasses(true),
 896	html.WithLineNumbers(true), html.LineNumbersInTable(false),
 897	html.WithLinkableLineNumbers(true, "L"))
 898
 899func highlight(filePath string, data []byte) template.HTML {
 900	lexer := lexers.Match(filePath)
 901	if lexer == nil {
 902		lexer = lexers.Fallback
 903	}
 904	iterator, err := lexer.Tokenise(nil, string(data))
 905	if err != nil {
 906		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 907	}
 908	var buf bytes.Buffer
 909	if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
 910		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 911	}
 912	return template.HTML(buf.String())
 913}
 914
 915// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
 916// The light one cannot be left unscoped: the two palettes do not name the
 917// same token set, and every token github-dark omits would keep its
 918// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
 919// Scoped, an unnamed token inherits the wrapper's colour instead, which is
 920// readable in both. The site's --code-bg stays the background either way.
 921// lightStyle and darkStyle are chosen on measured contrast against the
 922// grounds code actually sits on here — page, code block, and the diff
 923// tints. friendly, the chroma default, put 61 token/ground pairs under
 924// 4.5:1; xcode puts one.
 925const (
 926	lightStyle = "xcode"
 927	darkStyle  = "github-dark"
 928)
 929
 930var chromaCSS = func() []byte {
 931	var buf bytes.Buffer
 932	buf.WriteString("@media (prefers-color-scheme: light) {\n")
 933	chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
 934	// xcode's NameAttribute is its one token under 4.5:1 against the diff
 935	// tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
 936	buf.WriteString(".chroma .na { color: #6f5a21 }\n")
 937	buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
 938	chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
 939	buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
 940	// Line numbers take the site's own gutter colour in both schemes. Left
 941	// alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
 942	// chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
 943	// latter is a formatter fallback, not a style entry, so no palette test
 944	// can see it.
 945	buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
 946	return buf.Bytes()
 947}()
 948
 949func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 950	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 951	if !ok {
 952		return
 953	}
 954	filePath := strings.Trim(r.PathValue("path"), "/")
 955	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 956	if err != nil {
 957		s.notFound(w, r)
 958		return
 959	}
 960	// Serve inert: never let repo content execute in the forge's origin.
 961	// Images get their real type so <img> works under nosniff; SVG script
 962	// is dead on arrival because the instance CSP is script-src 'none'.
 963	ct := "text/plain; charset=utf-8"
 964	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
 965		ct = t
 966	}
 967	w.Header().Set("Content-Type", ct)
 968	w.Header().Set("X-Content-Type-Options", "nosniff")
 969	w.Write(data)
 970}
 971
 972// imageTypes are the formats raw serves with a real content type and blob
 973// pages preview inline.
 974var imageTypes = map[string]string{
 975	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
 976	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
 977	".svg": "image/svg+xml", ".ico": "image/x-icon",
 978}
 979
 980// readmeRank orders competing README files: richer renderers win.
 981var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 982
 983// pickReadme returns the best README-ish blob in a tree listing: any file
 984// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 985// we can render richly.
 986func pickReadme(entries []gitutil.TreeEntry) string {
 987	best, bestRank := "", 1<<30
 988	for _, e := range entries {
 989		if e.Type != "blob" {
 990			continue
 991		}
 992		lower := strings.ToLower(e.Name)
 993		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 994			continue
 995		}
 996		rank, ok := readmeRank[path.Ext(lower)]
 997		if !ok {
 998			rank = 10 // plaintext fallback
 999		}
1000		if rank < bestRank {
1001			best, bestRank = e.Name, rank
1002		}
1003	}
1004	return best
1005}
1006
1007// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1008// task lists) on top of CommonMark, with class-based fence highlighting
1009// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1010// dropped.
1011// Headings carry ids so a README or wiki section can be linked to, the
1012// way org headings already are (#132).
1013var markdown = goldmark.New(
1014	goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1015	goldmark.WithExtensions(extension.GFM,
1016		highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1017
1018// fenceHighlight renders one code block with chroma classes, for org and
1019// anything else outside goldmark. Unknown languages fall back to plain.
1020func fenceHighlight(source, lang string) string {
1021	lexer := lexers.Get(lang)
1022	if lexer == nil {
1023		lexer = lexers.Fallback
1024	}
1025	iterator, err := lexer.Tokenise(nil, source)
1026	if err != nil {
1027		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1028	}
1029	var buf bytes.Buffer
1030	f := html.New(html.WithClasses(true))
1031	if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1032		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1033	}
1034	return buf.String()
1035}
1036
1037// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1038// goldmark's default renderer drops raw HTML, so this is safe as-is.
1039func mdHTML(raw string) template.HTML {
1040	if strings.TrimSpace(raw) == "" {
1041		return ""
1042	}
1043	var buf bytes.Buffer
1044	if markdown.Convert([]byte(raw), &buf) != nil {
1045		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1046	}
1047	return template.HTML(buf.String())
1048}
1049
1050// aboutHTML renders a profile's about text. It has no filename to
1051// dispatch on, so the stored format picks the extension; anything other
1052// than org is markdown.
1053func aboutHTML(p store.Profile) template.HTML {
1054	if strings.TrimSpace(p.About) == "" {
1055		return ""
1056	}
1057	name := "about.md"
1058	if p.AboutFormat == "org" {
1059		name = "about.org"
1060	}
1061	return renderReadme(name, []byte(p.About))
1062}
1063
1064// webResolver answers autolink lookups for one viewer. Cross-repo
1065// references to repositories the viewer cannot read stay plain text, per
1066// the enumeration rule: a link would confirm the repo exists.
1067type webResolver struct {
1068	s      *Server
1069	viewer store.User
1070}
1071
1072func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1073	repo, err := r.s.st.RepoByPath(owner + "/" + name)
1074	if err != nil {
1075		return ""
1076	}
1077	grant := ""
1078	if r.viewer.ID != 0 {
1079		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1080	}
1081	if !policy.CanRead(r.viewer, repo, grant) {
1082		return ""
1083	}
1084	if kind == '#' {
1085		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1086			return ""
1087		}
1088		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1089	}
1090	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1091		return ""
1092	}
1093	return autolink.MRURL(repo.OwnerName, repo.Name, n)
1094}
1095
1096func (r webResolver) UserURL(name string) string {
1097	if _, err := r.s.st.UserByUsername(name); err == nil {
1098		return "/" + name
1099	}
1100	if _, err := r.s.st.OrgByName(name); err == nil {
1101		return "/" + name
1102	}
1103	return ""
1104}
1105
1106// ugcRenderer renders one user-authored body in the format it was written in.
1107// The format travels with the body: it is recorded when the text is written, so
1108// changing a preference later cannot re-interpret prose that already exists.
1109type ugcRenderer func(raw, format string) template.HTML
1110
1111// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1112// so a body stored before formats existed — and any row whose column defaulted —
1113// renders exactly as it did before.
1114//
1115// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1116// about text take, so it inherits that function's include guard and sanitising
1117// rather than growing a second org renderer to keep in step.
1118func ugcHTML(raw, format string) template.HTML {
1119	if format == "org" {
1120		return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1121			return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1122		})
1123	}
1124	return mdHTML(raw)
1125}
1126
1127// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1128// ugcHTML plus cross-reference and mention autolinking for this viewer.
1129func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1130	viewer := store.User{}
1131	if s.cfg.Web.Mode == "accounts" {
1132		viewer = s.viewer(r)
1133	}
1134	res := webResolver{s, viewer}
1135	return func(raw, format string) template.HTML {
1136		h := ugcHTML(raw, format)
1137		if h == "" {
1138			return h
1139		}
1140		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1141	}
1142}
1143
1144// renderedComment pairs a comment with its rendered body for templates.
1145type renderedComment struct {
1146	Author    string
1147	CreatedAt string
1148	Kind      string
1149	BodyHTML  template.HTML
1150}
1151
1152func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1153	var out []renderedComment
1154	for _, c := range cs {
1155		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1156	}
1157	return out
1158}
1159
1160// ugcPolicy sanitizes rendered repo content before it enters the forge's
1161// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1162// output and repo-authored HTML are not. Chroma's highlighting classes
1163// must survive; the pattern admits only short token codes, not the site's
1164// own class names.
1165var ugcPolicy = func() *bluemonday.Policy {
1166	p := bluemonday.UGCPolicy()
1167	p.AllowAttrs("class").
1168		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1169		OnElements("span", "pre", "code", "div")
1170	return p
1171}()
1172
1173// renderReadme renders a README by extension: markdown, org-mode, and
1174// (sanitized) HTML richly; everything else as escaped plaintext.
1175// orgConfig is the go-org configuration for rendering untrusted org.
1176//
1177// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1178// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1179// wiki page, a profile — so both keywords are refused outright: the file is
1180// never opened and the keyword stays the inert text it is. There is no safe
1181// subset to allow instead. An absolute path skips go-org's relative-path join,
1182// a relative one resolves against the daemon's working directory, and a repo
1183// has no directory to scope to anyway because the content came from a git
1184// object rather than a checkout.
1185//
1186// The default logger writes parse warnings to stderr, which would let pushed
1187// content write to the server's log; discard them.
1188func orgConfig() *org.Configuration {
1189	c := org.New()
1190	c.ReadFile = func(string) ([]byte, error) {
1191		return nil, errOrgIncludeDisabled
1192	}
1193	c.Log = log.New(io.Discard, "", 0)
1194	return c
1195}
1196
1197var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1198
1199// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1200// of contents: a README or wiki page is a document and carries one, an issue
1201// comment is a remark and should not sprout one above two headings. `fallback`
1202// supplies the plaintext rendering used when the writer fails.
1203func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1204	c := orgConfig()
1205	if !contents {
1206		// DefaultSettings is a fresh map per org.New(), so this is local.
1207		c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1208	}
1209	doc := c.Parse(bytes.NewReader(raw), name)
1210	writer := org.NewHTMLWriter()
1211	writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1212		if inline {
1213			return "<code>" + template.HTMLEscapeString(source) + "</code>"
1214		}
1215		return fenceHighlight(source, lang)
1216	}
1217	out, err := doc.Write(writer)
1218	if err != nil {
1219		return fallback()
1220	}
1221	return template.HTML(ugcPolicy.Sanitize(out))
1222}
1223
1224// headingTag matches an opening or closing h1..h5 tag, so a rendered
1225// document's headings can move down one level.
1226var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1227
1228// demoteHeadings moves every heading in a rendered document down one
1229// level: the page it sits on already has its h1 (the repository, the
1230// file, the wiki page), so a README's own h1 would be a second top-level
1231// heading in the outline (#133). Ids and anchors are untouched.
1232func demoteHeadings(h template.HTML) template.HTML {
1233	return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1234		sub := headingTag.FindStringSubmatch(m)
1235		return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1236	}))
1237}
1238
1239func renderReadme(name string, raw []byte) template.HTML {
1240	plain := func() template.HTML {
1241		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1242	}
1243	if gitutil.IsBinary(raw) {
1244		return ""
1245	}
1246	switch path.Ext(strings.ToLower(name)) {
1247	case ".md", ".markdown":
1248		var buf bytes.Buffer
1249		if markdown.Convert(raw, &buf) != nil {
1250			return plain()
1251		}
1252		return demoteHeadings(template.HTML(buf.String()))
1253	case ".org":
1254		return demoteHeadings(renderOrg(name, raw, true, plain))
1255	case ".html", ".htm":
1256		return template.HTML(ugcPolicy.Sanitize(string(raw)))
1257	default:
1258		return plain()
1259	}
1260}
1261
1262type diffThread struct {
1263	ID       int64
1264	Resolved string
1265	Stale    bool
1266	// Pending marks a thread in the viewer's own unsubmitted review. Only
1267	// they are shown it, and the page says so, since it looks exactly
1268	// like a posted one otherwise.
1269	Pending    bool
1270	CanResolve bool
1271	Comments   []renderedComment
1272}
1273
1274// reviewRights decides which thread controls a viewer sees. mr resolve
1275// admits the thread author, the MR author, or anyone with write, so the
1276// page needs all three to render the button truthfully.
1277type reviewRights struct {
1278	Viewer   string
1279	MRAuthor string
1280	Write    bool
1281}
1282
1283func (r reviewRights) canResolve(threadAuthor string) bool {
1284	return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1285}
1286
1287// attachThreads injects review threads under their anchored diff lines;
1288// threads whose anchor no longer appears (stale after force-push, or on a
1289// context line outside the current diff) are returned separately.
1290func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1291	type anchor struct {
1292		path string
1293		side string
1294		line int64
1295	}
1296	// Diff-line comments have no stored format yet, so they stay markdown.
1297	// They are the one user-authored body left without the choice; see #51.
1298	threads := map[int64]*diffThread{}
1299	anchors := map[int64]anchor{}
1300	var order []int64
1301	for _, cm := range comments {
1302		if cm.ReplyTo == 0 {
1303			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1304				Pending:    cm.Pending,
1305				CanResolve: rights.canResolve(cm.Author),
1306				Comments:   []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1307			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1308			order = append(order, cm.ID)
1309		} else if th, ok := threads[cm.ReplyTo]; ok {
1310			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1311		}
1312	}
1313	placed := map[int64]bool{}
1314	for f := range files {
1315		lines := files[f].Lines
1316		for i := range lines {
1317			for _, id := range order {
1318				if placed[id] || threads[id].Stale {
1319					continue
1320				}
1321				a := anchors[id]
1322				if lines[i].Path != a.path {
1323					continue
1324				}
1325				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1326					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1327					lines[i].Threads = append(lines[i].Threads, *threads[id])
1328					files[f].Threads++
1329					files[f].Open = true
1330					placed[id] = true
1331				}
1332			}
1333		}
1334	}
1335	var unplaced []diffThread
1336	for _, id := range order {
1337		if !placed[id] {
1338			unplaced = append(unplaced, *threads[id])
1339		}
1340	}
1341	return files, unplaced
1342}
1343
1344// markCompose opens the new-thread form under one diff line. There is no
1345// JavaScript, so "comment on this line" is a plain GET carrying the
1346// anchor and the page renders the form where the reader asked for it.
1347func markCompose(files []diffFile, q url.Values) {
1348	path := q.Get("cpath")
1349	line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1350	if path == "" || line < 1 {
1351		return
1352	}
1353	old := q.Get("cside") == "old"
1354	for f := range files {
1355		for i := range files[f].Lines {
1356			ln := &files[f].Lines[i]
1357			if ln.Path != path {
1358				continue
1359			}
1360			if (old && ln.Class == "del" && ln.OldLine == line) ||
1361				(!old && ln.Class != "del" && ln.NewLine == line) {
1362				ln.Compose = true
1363				files[f].Open = true
1364				return
1365			}
1366		}
1367	}
1368}
1369
1370type sigView struct {
1371	State       string
1372	Signer      string
1373	Fingerprint string
1374}
1375
1376func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1377	raw, err := gitutil.ReadCommit(dir, sha)
1378	if err != nil {
1379		return sigView{State: "unsigned"}, nil
1380	}
1381	parsed, err := sig.ParseCommit(raw)
1382	if err != nil {
1383		return sigView{State: "unsigned"}, nil
1384	}
1385	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1386	if err != nil {
1387		return sigView{State: "unsigned"}, parsed
1388	}
1389	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1390	if res.SignerUserID != 0 {
1391		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1392			v.Signer = u.Username
1393		}
1394	}
1395	return v, parsed
1396}
1397
1398func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1399	ref := r.PathValue("ref")
1400	p, ok := s.repoFor(w, r, ref)
1401	if !ok {
1402		return
1403	}
1404	p.Tab = "log"
1405	const pageSize = 50
1406	// ?path= filters to commits touching one file or directory.
1407	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1408	if filePath == "." {
1409		filePath = ""
1410	}
1411	var shas []string
1412	var err error
1413	if filePath != "" {
1414		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1415	} else {
1416		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1417	}
1418	if err != nil {
1419		s.notFound(w, r)
1420		return
1421	}
1422	next := ""
1423	if len(shas) > pageSize {
1424		next = shas[pageSize]
1425		shas = shas[:pageSize]
1426	}
1427	type row struct {
1428		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1429		Sig                                                               sigView
1430		Check                                                             string // combined status, "" when none ran
1431	}
1432	names := s.authorNames()
1433	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1434	var rows []row
1435	for _, sha := range shas {
1436		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1437		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1438		if parsed != nil {
1439			rw.Subject = parsed.Subject
1440			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1441			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1442			rw.AuthorEmail = parsed.AuthorEmail
1443			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1444		}
1445		rows = append(rows, rw)
1446	}
1447	s.render(w, "log.html", struct {
1448		repoPage
1449		Commits  []row
1450		NextSHA  string
1451		FilePath string
1452	}{p, rows, next, filePath})
1453}
1454
1455func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1456	p, ok := s.repoFor(w, r, "")
1457	if !ok {
1458		return
1459	}
1460	p.Tab = "log"
1461	sha := r.PathValue("sha")
1462	full, err := gitutil.ResolveRef(p.Dir, sha)
1463	if err != nil {
1464		s.notFound(w, r)
1465		return
1466	}
1467	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1468	if parsed == nil {
1469		s.notFound(w, r)
1470		return
1471	}
1472	patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1473	files := parseDiff(patch)
1474	committerEmail := ""
1475	if parsed.CommitterEmail != parsed.AuthorEmail {
1476		committerEmail = parsed.CommitterEmail
1477	}
1478	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1479	commitNames := s.authorNames()
1480	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1481	msg := ""
1482	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1483		msg = string(parsed.Payload[i+2:])
1484	}
1485	s.render(w, "commit.html", struct {
1486		repoPage
1487		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1488		Parents                                                                           []string
1489		Sig                                                                               sigView
1490		Checks                                                                            []store.CommitStatus
1491		DiffFiles                                                                         []diffFile
1492		DiffTruncated                                                                     bool
1493	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1494		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1495		gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1496}
1497
1498// labelPalette provides default label chip colors: mid-tone hues that stay
1499// legible on light and dark backgrounds.
1500var labelPalette = []string{
1501	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1502	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1503}
1504
1505var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1506
1507// clampChip keeps a user-set label colour legible as text on both
1508// grounds. Contrast is defined on relative luminance, so that is what is
1509// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1510// and against the dark ground alike, and where the palette's own colours
1511// sit. The hue is kept; the channels are scaled in linear light (#120).
1512func clampChip(hex string) string {
1513	lin := func(c int64) float64 {
1514		v := float64(c) / 255
1515		if v <= 0.04045 {
1516			return v / 12.92
1517		}
1518		return math.Pow((v+0.055)/1.055, 2.4)
1519	}
1520	r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1521	y := 0.2126*r + 0.7152*g + 0.0722*b
1522	const lo, hi = 0.12, 0.28
1523	if y >= lo && y <= hi {
1524		return strings.ToLower(hex)
1525	}
1526	target := hi
1527	if y < lo {
1528		target = lo
1529	}
1530	if y == 0 {
1531		r, g, b = target, target, target
1532	} else {
1533		k := target / y
1534		r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1535	}
1536	enc := func(v float64) int {
1537		if v <= 0.0031308 {
1538			v *= 12.92
1539		} else {
1540			v = 1.055*math.Pow(v, 1/2.4) - 0.055
1541		}
1542		return int(math.Round(v * 255))
1543	}
1544	return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1545}
1546
1547func hexByte(s string) int64 {
1548	n, _ := strconv.ParseInt(s, 16, 32)
1549	return n
1550}
1551
1552// labelColors returns a complete label-name -> chip color map for a repo:
1553// the stored labels.color when it is a valid hex color, otherwise a
1554// stable default picked from the palette by name hash.
1555func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1556	stored, _ := s.st.LabelColors(repoID)
1557	out := make(map[string]template.CSS, len(stored))
1558	for name, color := range stored {
1559		if !hexColorPat.MatchString(color) {
1560			h := fnv.New32a()
1561			h.Write([]byte(name))
1562			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1563		}
1564		out[name] = template.CSS("--chip:" + clampChip(color))
1565	}
1566	return out
1567}
1568
1569// listPage is how many issues or merge requests a list page shows before
1570// it offers the older ones (#118). Keyset paging on the number, the same
1571// cursor the commands use, so every filter carries across pages.
1572const listPage = 50
1573
1574// olderLink is the current URL with before=<number> set.
1575func olderLink(r *http.Request, before int64) string {
1576	q := r.URL.Query()
1577	q.Set("before", strconv.FormatInt(before, 10))
1578	return "?" + q.Encode()
1579}
1580
1581func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1582	p, ok := s.repoFor(w, r, "")
1583	if !ok {
1584		return
1585	}
1586	p.Tab = "issues"
1587	state := r.URL.Query().Get("state")
1588	if state != "closed" && state != "all" {
1589		state = "open"
1590	}
1591	// The same filters the CLI's issue list takes, as query parameters;
1592	// label chips and author links point here.
1593	qv := r.URL.Query()
1594	f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1595		Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1596		Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1597	f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1598	issues, err := s.st.QueryIssues(p.Repo.ID, f)
1599	if err != nil {
1600		http.Error(w, "internal error", http.StatusInternalServerError)
1601		return
1602	}
1603	older := ""
1604	if len(issues) > listPage {
1605		issues = issues[:listPage]
1606		older = olderLink(r, issues[len(issues)-1].Number)
1607	}
1608	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1609		for i := range issues {
1610			issues[i].Labels = labels[issues[i].ID]
1611		}
1612	}
1613	s.render(w, "issues.html", struct {
1614		repoPage
1615		State       string
1616		Label       string
1617		Query       string
1618		Filters     []listFilter
1619		Issues      []store.Issue
1620		LabelColors map[string]template.CSS
1621		Older       string
1622	}{p, state, f.Label, f.Search,
1623		activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1624		issues, s.labelColors(p.Repo.ID), older})
1625}
1626
1627func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1628	p, ok := s.repoFor(w, r, "")
1629	if !ok {
1630		return
1631	}
1632	p.Tab = "issues"
1633	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1634	if err != nil {
1635		s.notFound(w, r)
1636		return
1637	}
1638	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1639	if err != nil {
1640		s.notFound(w, r)
1641		return
1642	}
1643	comments, err := s.st.ListIssueComments(iss.ID)
1644	if err != nil {
1645		http.Error(w, "internal error", http.StatusInternalServerError)
1646		return
1647	}
1648	md := s.ugcFor(r, p.Repo)
1649	milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1650	s.render(w, "issue.html", struct {
1651		repoPage
1652		Issue       store.Issue
1653		BodyHTML    template.HTML
1654		Comments    []renderedComment
1655		CanEdit     bool
1656		CanWrite    bool
1657		Milestones  []store.Milestone
1658		Notice      string
1659		LabelColors map[string]template.CSS
1660	}{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1661		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1662		milestones, s.takeFlash(w, r), s.labelColors(p.Repo.ID)})
1663}
1664
1665// canEditItem: the author or anyone with write access may edit.
1666// canWriteRepo reports whether the browser session may push to the repo,
1667// which is what gates the review and merge controls.
1668func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1669	if s.cfg.Web.Mode != "accounts" {
1670		return false
1671	}
1672	u := s.viewer(r)
1673	if u.ID == 0 {
1674		return false
1675	}
1676	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1677	return policy.CanWrite(u, repo, grant)
1678}
1679
1680func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1681	if s.cfg.Web.Mode != "accounts" {
1682		return false
1683	}
1684	u := s.viewer(r)
1685	if u.ID == 0 {
1686		return false
1687	}
1688	if u.Username == author {
1689		return true
1690	}
1691	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1692	return policy.CanWrite(u, repo, grant)
1693}
1694
1695func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1696	p, ok := s.repoFor(w, r, "")
1697	if !ok {
1698		return
1699	}
1700	p.Tab = "merge requests"
1701	state := r.URL.Query().Get("state")
1702	if state == "" {
1703		state = "open"
1704	}
1705	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1706	if !valid[state] {
1707		state = "open"
1708	}
1709	qv := r.URL.Query()
1710	mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1711		Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1712	mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1713	mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1714	if err != nil {
1715		http.Error(w, "internal error", http.StatusInternalServerError)
1716		return
1717	}
1718	older := ""
1719	if len(mrs) > listPage {
1720		mrs = mrs[:listPage]
1721		older = olderLink(r, mrs[len(mrs)-1].Number)
1722	}
1723	s.render(w, "mrs.html", struct {
1724		repoPage
1725		State   string
1726		Query   string
1727		Filters []listFilter
1728		MRs     []store.MR
1729		Older   string
1730	}{p, state, mf.Search,
1731		activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs, older})
1732}
1733
1734func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1735	p, ok := s.repoFor(w, r, "")
1736	if !ok {
1737		return
1738	}
1739	p.Tab = "merge requests"
1740	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1741	if err != nil {
1742		s.notFound(w, r)
1743		return
1744	}
1745	m, err := s.st.MRByNumber(p.Repo.ID, n)
1746	if err != nil {
1747		s.notFound(w, r)
1748		return
1749	}
1750	comments, _ := s.st.ListMRComments(m.ID)
1751	reviews, _ := s.st.ListMRReviews(m.ID)
1752	// The same rule the merge gates apply, so the page cannot show an
1753	// approval the gate ignores (#147).
1754	reviewCounts := control.ReviewersWhoCount(s.st, p.Repo, reviews)
1755	reviewRows := make([]reviewRow, 0, len(reviews))
1756	for _, r := range reviews {
1757		reviewRows = append(reviewRows, reviewRow{MRReview: r, Counts: reviewCounts[r.Reviewer]})
1758	}
1759	checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1760	// The viewer sees their own unsubmitted review comments and nobody
1761	// else's.
1762	diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
1763
1764	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1765	var files []diffFile
1766	base := m.MergedBase
1767	if base == "" {
1768		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1769			base = b
1770		}
1771	}
1772	var diffTruncated bool
1773	if base != "" {
1774		if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1775			files, diffTruncated = parseDiff(patch), truncated
1776		}
1777	}
1778	md := s.ugcFor(r, p.Repo)
1779	canWrite := s.canWriteRepo(r, p.Repo)
1780	var detachedThreads []diffThread
1781	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1782		reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1783	if p.Viewer != "" {
1784		markCompose(files, r.URL.Query())
1785	}
1786	stat := statOf(files)
1787	// The commits this MR carries: base..head, the same range as the diff.
1788	type commitRow struct {
1789		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1790		Sig                                                  sigView
1791	}
1792	mrNames := s.authorNames()
1793	var commits []commitRow
1794	commitsTotal := 0
1795	if base != "" {
1796		const maxMRCommits = 100
1797		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1798		commitsTotal = len(shas)
1799		if len(shas) > maxMRCommits {
1800			shas = shas[:maxMRCommits]
1801		}
1802		for _, sha := range shas {
1803			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1804			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1805			if parsed != nil {
1806				cr.Subject = parsed.Subject
1807				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1808				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1809				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1810			}
1811			commits = append(commits, cr)
1812		}
1813	}
1814	// The diff is the reason most people open a merge request, so it gets
1815	// its own view rather than a fold at the foot of the conversation.
1816	// A query parameter keeps this working without JavaScript.
1817	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1818	// The revisions this merge request has had. A stale review is the
1819	// moment someone wants to know what moved, so the link to the
1820	// range-diff belongs next to it.
1821	revisions, _ := s.st.MRHeads(m.ID)
1822	branches, _ := gitutil.Refs(p.Dir, "heads")
1823	view := r.URL.Query().Get("view")
1824	if view != "commits" && view != "diff" {
1825		view = "conversation"
1826	}
1827	// The stack around an open merge request, for the header.
1828	var stackedOn *store.MR
1829	var stacked []store.MR
1830	if m.State == "open" {
1831		if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1832			stackedOn = &parent
1833		}
1834		if m.SourceRepoID == p.Repo.ID {
1835			stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1836		}
1837	}
1838	s.render(w, "mr.html", struct {
1839		repoPage
1840		MR              store.MR
1841		View            string
1842		BodyHTML        template.HTML
1843		Checks          []store.Check
1844		Combined        string
1845		Comments        []renderedComment
1846		Reviews         []reviewRow
1847		DiffFiles       []diffFile
1848		DiffTruncated   bool
1849		Stat            diffStat
1850		Commits         []commitRow
1851		CommitsTotal    int
1852		Branches        []gitutil.Ref
1853		CanEdit         bool
1854		CanWrite        bool
1855		Unresolved      int
1856		Revisions       []store.MRHead
1857		Notice          string
1858		DetachedThreads []diffThread
1859		StackedOn       *store.MR
1860		Stacked         []store.MR
1861	}{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1862		reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1863		canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked})
1864}
1865
1866func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1867	p, ok := s.repoFor(w, r, "")
1868	if !ok {
1869		return
1870	}
1871	p.Tab = "refs"
1872	branches, _ := gitutil.Refs(p.Dir, "heads")
1873	tags, _ := gitutil.Refs(p.Dir, "tags")
1874	s.render(w, "refs.html", struct {
1875		repoPage
1876		Branches, Tags []gitutil.Ref
1877	}{p, branches, tags})
1878}
1879
1880func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1881	p, ok := s.repoFor(w, r, "")
1882	if !ok {
1883		return
1884	}
1885	file := r.PathValue("file")
1886	ref, ok := strings.CutSuffix(file, ".tar.gz")
1887	if !ok {
1888		s.notFound(w, r)
1889		return
1890	}
1891	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1892		s.notFound(w, r)
1893		return
1894	}
1895	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1896	w.Header().Set("Content-Type", "application/gzip")
1897	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1898	gitutil.Archive(p.Dir, ref, prefix, w)
1899}
1900
1901func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1902	return policy.CanAdmin(u, repo, grant)
1903}
1904
1905func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1906	return policy.CanRead(u, repo, grant)
1907}
1908
1909// reviewRow is a review with whether the merge gates count it, which
1910// depends on the reviewer's access and so is not a property of the
1911// review row itself.
1912type reviewRow struct {
1913	store.MRReview
1914	Counts bool
1915}