internal/httpd/web.go

251a71053c93d2621ad97fa7d1ce8efacb119406
gitbay/internal/httpd/web.go history · blame · raw

1899 lines · 59333 bytes

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