internal/httpd/web.go

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

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