internal/httpd/web.go

6f16ad5df0845d8f44ec6530f1d1d6993d8dcb70
gitbay/internal/httpd/web.go history · blame · raw

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