internal/httpd/web.go

4effb29e572777a68b56b21da1b1733fb6219e89
gitbay/internal/httpd/web.go history · blame · raw

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