internal/httpd/web.go

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

2518 lines · 82317 bytes

132 symbols in this file
   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	"strconv"
  24	"strings"
  25	"time"
  26
  27	"github.com/alecthomas/chroma/v2/formatters/html"
  28	"github.com/alecthomas/chroma/v2/lexers"
  29	"github.com/alecthomas/chroma/v2/styles"
  30	"github.com/microcosm-cc/bluemonday"
  31	"github.com/niklasfasching/go-org/org"
  32	"github.com/yuin/goldmark"
  33	highlighting "github.com/yuin/goldmark-highlighting/v2"
  34	"github.com/yuin/goldmark/extension"
  35	"github.com/yuin/goldmark/parser"
  36
  37	"gitbay.org/gitbay/internal/autolink"
  38	"gitbay.org/gitbay/internal/control"
  39	"gitbay.org/gitbay/internal/gitutil"
  40	"gitbay.org/gitbay/internal/sig"
  41	"gitbay.org/gitbay/internal/store"
  42	"gitbay.org/gitbay/internal/suggest"
  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// stylesheetHash is the hash of what stylesheet serves, computed once. It
  69// is the ETag, so a browser revalidating with If-None-Match gets a 304
  70// until a deploy changes the bytes (#132), and it is the ?v= the layout
  71// stamps on the URL, so a deploy the browser has not fetched yet cannot be
  72// answered from its cache (#239).
  73var stylesheetHash = func() string {
  74	h := sha256.New()
  75	h.Write(styleCSS)
  76	h.Write(chromaCSS)
  77	return hex.EncodeToString(h.Sum(nil))[:16]
  78}()
  79
  80var stylesheetETag = `"` + stylesheetHash + `"`
  81
  82func init() { web.StyleVersion = stylesheetHash }
  83
  84func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
  85	w.Header().Set("ETag", stylesheetETag)
  86	// A URL carrying this build's hash names bytes that cannot change, so
  87	// it never needs revalidating. The bare URL still can, and keeps the
  88	// policy it had.
  89	if r.URL.Query().Get("v") == stylesheetHash {
  90		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
  91	} else {
  92		w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
  93	}
  94	if r.Header.Get("If-None-Match") == stylesheetETag {
  95		w.WriteHeader(http.StatusNotModified)
  96		return
  97	}
  98	w.Header().Set("Content-Type", "text/css; charset=utf-8")
  99	w.Write(styleCSS)
 100	w.Write(chromaCSS)
 101}
 102
 103func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
 104	w.Header().Set("Content-Type", "image/svg+xml")
 105	w.Write(web.FaviconSVG)
 106}
 107
 108// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
 109// so the CSP's default-src 'self' covers it — no font CDN.
 110func (s *Server) font(w http.ResponseWriter, r *http.Request) {
 111	data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
 112	if err != nil {
 113		http.NotFound(w, r)
 114		return
 115	}
 116	w.Header().Set("Content-Type", "font/woff2")
 117	w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
 118	w.Write(data)
 119}
 120
 121var staticTypes = map[string]string{
 122	".gif":  "image/gif",
 123	".webm": "video/webm",
 124	".mp4":  "video/mp4",
 125}
 126
 127// image serves the embedded landing recording with the font cache policy.
 128// ServeContent answers Range, which Safari needs to play video.
 129func (s *Server) image(w http.ResponseWriter, r *http.Request) {
 130	name := "static" + r.URL.Path[len("/static"):]
 131	data, err := web.ImageFS.ReadFile(name)
 132	if err != nil {
 133		http.NotFound(w, r)
 134		return
 135	}
 136	w.Header().Set("Content-Type", staticTypes[path.Ext(name)])
 137	w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
 138	http.ServeContent(w, r, name, time.Time{}, bytes.NewReader(data))
 139}
 140
 141// notFound renders the designed 404 page with a 404 status. Falls back to
 142// the stock plain-text response if the template fails.
 143func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
 144	var buf bytes.Buffer
 145	if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
 146		http.NotFound(w, r)
 147		return
 148	}
 149	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 150	w.WriteHeader(http.StatusNotFound)
 151	buf.WriteTo(w)
 152}
 153
 154// describedRepo pairs a repo with the listing metadata: description,
 155// topics, license, and last-updated date.
 156type describedRepo struct {
 157	store.Repo
 158	Desc    string
 159	Topics  []string
 160	License string
 161	Updated string
 162}
 163
 164// Archived flattens the settings flag so the reporow partial can read the
 165// same field name from a describedRepo and from a profile's repo row.
 166func (d describedRepo) Archived() bool { return d.Settings.Archived }
 167
 168func (s *Server) describeAll(repos []store.Repo) []describedRepo {
 169	var out []describedRepo
 170	for _, r := range repos {
 171		dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
 172		d := describedRepo{
 173			Repo:    r,
 174			Desc:    gitutil.ReadDescription(dir),
 175			License: control.DetectLicense(dir, r.DefaultBranch),
 176			Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
 177		}
 178		d.Topics, _ = s.st.ListTopics(r.ID)
 179		out = append(out, d)
 180	}
 181	return out
 182}
 183
 184// index is the homepage: a dashboard for logged-in users, a landing page
 185// for everyone else. The full public listing lives at /explore.
 186func (s *Server) index(w http.ResponseWriter, r *http.Request) {
 187	if s.cfg.Web.Mode == "accounts" {
 188		if viewer := s.viewer(r); viewer.ID != 0 {
 189			s.dashboard(w, r, viewer)
 190			return
 191		}
 192	}
 193	host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
 194		s.cfg.Server.SiteURL, "https://"), "http://"), "/")
 195	s.render(w, "landing.html", struct {
 196		basePage
 197		Host       string
 198		Accounts   bool
 199		Signup     bool
 200		EmailLogin bool
 201	}{s.anonBase(), host, s.cfg.Web.Mode == "accounts",
 202		s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed",
 203		s.emailLoginEnabled()})
 204}
 205
 206func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
 207	mrs, _ := s.st.DashboardMRs(viewer.ID)
 208	issues, _ := s.st.DashboardIssues(viewer.ID)
 209	reviews, _ := s.st.ReviewQueue(viewer.ID)
 210	assigned, _ := s.st.AssignedIssues(viewer.ID)
 211	events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
 212	queries, _ := control.PinnedQueries(s.st, viewer)
 213	s.render(w, "dashboard.html", struct {
 214		basePage
 215		Tab      string
 216		Pins     []pinnedRow
 217		Reviews  []store.DashboardItem
 218		Assigned []store.DashboardItem
 219		MRs      []store.DashboardItem
 220		Issues   []store.DashboardItem
 221		Queries  []control.DashboardQuery
 222		Feed     []control.FeedLine
 223	}{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, queries, control.FeedLines(events)})
 224}
 225
 226// explorePageSize is how many repositories one page of /explore lists.
 227const explorePageSize = 20
 228
 229// explorePage is /explore: one page of the filtered listing, most recent
 230// activity first. Total counts the whole filtered listing.
 231type explorePage struct {
 232	basePage
 233	Tab    string
 234	Query  string
 235	Facets []facetGroup
 236	Repos  []describedRepo
 237	Total  int
 238	Page   int
 239	Pages  int
 240	Prev   string
 241	Next   string
 242}
 243
 244func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
 245	repos, err := s.st.ListPublicReposByActivity()
 246	if err != nil {
 247		http.Error(w, "internal error", http.StatusInternalServerError)
 248		return
 249	}
 250	var viewer store.User
 251	if s.cfg.Web.Mode == "accounts" {
 252		viewer = s.viewer(r)
 253	}
 254	q := strings.TrimSpace(r.URL.Query().Get("q"))
 255	described := s.describeAll(repos)
 256	p := explorePage{basePage: s.baseFor(viewer), Tab: "explore", Query: q,
 257		Facets: []facetGroup{topicFacets(described, q)}}
 258	p.Repos, p.Total, p.Page, p.Pages = pageOf(s.filterRepos(q, described), r.URL.Query().Get("page"))
 259	if p.Page > 1 {
 260		p.Prev = explorePageURL(q, p.Page-1)
 261	}
 262	if p.Page < p.Pages {
 263		p.Next = explorePageURL(q, p.Page+1)
 264	}
 265	s.render(w, "explore.html", p)
 266}
 267
 268// pageOf returns page n (1-based, clamped) of repos at explorePageSize.
 269func pageOf(repos []describedRepo, n string) (page []describedRepo, total, num, pages int) {
 270	total = len(repos)
 271	pages = max(1, (total+explorePageSize-1)/explorePageSize)
 272	num, _ = strconv.Atoi(n)
 273	num = min(max(num, 1), pages)
 274	lo := (num - 1) * explorePageSize
 275	return repos[lo:min(lo+explorePageSize, total)], total, num, pages
 276}
 277
 278func explorePageURL(q string, page int) string {
 279	v := url.Values{}
 280	if q != "" {
 281		v.Set("q", q)
 282	}
 283	if page > 1 {
 284		v.Set("page", strconv.Itoa(page))
 285	}
 286	if len(v) == 0 {
 287		return "/explore"
 288	}
 289	return "/explore?" + v.Encode()
 290}
 291
 292// privacy renders the privacy page: what the gitbay software does with
 293// data, plus this instance's operator-provided notes.
 294func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
 295	s.render(w, "privacy.html", struct {
 296		basePage
 297		Host   string
 298		Notice string
 299	}{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
 300}
 301
 302// filterRepos keeps repos matching the query by the same rule `repo
 303// search` uses. An empty query keeps everything.
 304func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
 305	if q == "" {
 306		return repos
 307	}
 308	var out []describedRepo
 309	for _, d := range repos {
 310		if control.MatchesRepo(q, d.Path(), d.Desc, d.Topics) {
 311			out = append(out, d)
 312		}
 313	}
 314	return out
 315}
 316
 317// repoPage is the shared context for repo-scoped pages.
 318type repoPage struct {
 319	basePage
 320	Desc     string
 321	Repo     store.Repo
 322	Ref      string
 323	CloneURL string
 324	// SSHCloneURL is the same repository over the SSH transport, which is
 325	// the one a push needs.
 326	SSHCloneURL string
 327	Dir         string
 328	Tab         string // active tab in the repo header
 329	Topics      []string
 330	Pinned      bool   // by the viewer
 331	Marked      bool   // bookmarked by the viewer
 332	Watch       string // the viewer's watch state: watching, muted, or ""
 333	HasWiki     bool
 334	Host        string
 335	Mirrors     []mirrorLine // repo admins only
 336	CanAdmin    bool         // gates the settings tab
 337	Feed        string       // Atom feed for this page, if it has one
 338	// OpenIssues and OpenMRs are the counts on the header tabs.
 339	OpenIssues int
 340	OpenMRs    int
 341	// RepoHome asks the layout for the full header — description, topics,
 342	// website, mirrors. Every other page gets identity and tabs only, so a
 343	// repo describes itself once rather than on all twelve of its pages.
 344	RepoHome bool
 345}
 346
 347// mirrorLine is the admin-only mirror status shown in the repo header.
 348// It carries no credentials: the stored URL is credential-free.
 349type mirrorLine struct {
 350	Direction string
 351	URL       string
 352	Target    string // URL without the scheme, for display
 353	Synced    string
 354	Error     string
 355}
 356
 357// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
 358// readable "2026-08-25 03:39 UTC".
 359func syncedAt(ts string) string {
 360	if len(ts) < 16 {
 361		return ts
 362	}
 363	return ts[:10] + " " + ts[11:16] + " UTC"
 364}
 365
 366// repoFor resolves the repo for a web request; false means 404 was sent.
 367// Anonymous visitors see public repos only; in accounts mode a logged-in
 368// viewer additionally sees repos their grants allow. Private and missing
 369// repos are indistinguishable either way.
 370func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
 371	var repo store.Repo
 372	var viewer store.User
 373	if s.cfg.Web.Mode == "accounts" {
 374		viewer = s.viewer(r)
 375	}
 376	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
 377	ok := err == nil
 378	grant := ""
 379	if ok {
 380		if viewer.ID != 0 {
 381			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 382		}
 383		ok = policyCanRead(viewer, repo, grant)
 384	}
 385	if !ok {
 386		s.notFound(w, r)
 387		return repoPage{}, false
 388	}
 389	if ref == "" {
 390		ref = repo.DefaultBranch
 391	}
 392	topics, _ := s.st.ListTopics(repo.ID)
 393	pinned, marked, watch := false, false, ""
 394	if viewer.ID != 0 {
 395		pinned = s.st.IsPinned(viewer.ID, repo.ID)
 396		marked = s.st.IsBookmarked(viewer.ID, repo.ID)
 397		watch = s.st.RepoWatchState(repo.ID, viewer.ID)
 398	}
 399	canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
 400	var mirrors []mirrorLine
 401	if canAdmin {
 402		ms, _ := s.st.ListMirrors(repo.ID)
 403		for _, m := range ms {
 404			mirrors = append(mirrors, mirrorLine{
 405				Direction: m.Direction,
 406				URL:       m.URL,
 407				Target:    strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
 408				Synced:    syncedAt(m.LastSync),
 409				Error:     m.LastError,
 410			})
 411		}
 412	}
 413	openIssues, openMRs := s.st.OpenCounts(repo.ID)
 414	return repoPage{
 415		basePage:    s.baseFor(viewer),
 416		CanAdmin:    canAdmin,
 417		Mirrors:     mirrors,
 418		Pinned:      pinned,
 419		Marked:      marked,
 420		Watch:       watch,
 421		HasWiki:     s.hasWiki(repo),
 422		Host:        s.cfg.SiteHost(),
 423		Desc:        gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
 424		Repo:        repo,
 425		Ref:         ref,
 426		CloneURL:    s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
 427		SSHCloneURL: s.sshCloneURL(repo),
 428		Dir:         control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
 429		Topics:      topics,
 430		OpenIssues:  openIssues,
 431		OpenMRs:     openMRs,
 432	}, true
 433}
 434
 435type crumb struct {
 436	Name string
 437	URL  string
 438}
 439
 440// crumbs builds one crumb per path component. Every component but the
 441// last is a directory and links to the tree; only the leaf is a page of
 442// the given kind.
 443func crumbs(p repoPage, kind, filePath string) []crumb {
 444	var cs []crumb
 445	parts := strings.Split(strings.Trim(filePath, "/"), "/")
 446	acc := ""
 447	for i, part := range parts {
 448		if part == "" {
 449			continue
 450		}
 451		acc = path.Join(acc, part)
 452		k := "tree"
 453		if i == len(parts)-1 {
 454			k = kind
 455		}
 456		cs = append(cs, crumb{Name: part, URL: "/" + p.Repo.Path() + "/" + k + "/" + p.Ref + "/" + acc})
 457	}
 458	return cs
 459}
 460
 461// profileView is profile show's payload, shaped for the templates. The
 462// repo rows carry the same names the reporow partial reads, so a profile
 463// listing renders identically to explore's.
 464// profileView is profile show's payload with the repository rows wrapped
 465// so the reporow partial can reach them. The fields themselves are the
 466// command's: a field it gains appears here without being re-declared.
 467type profileView struct {
 468	control.ProfileOut
 469	Repos []profileRepoRow `json:"repos"`
 470}
 471
 472// profileRepoRow is one repository row on a profile. The partial asks for
 473// OwnerName, Name and Desc; the payload carries a path and a description.
 474type profileRepoRow struct {
 475	control.ProfileRepo
 476}
 477
 478func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
 479func (p profileRepoRow) Name() string      { _, name, _ := strings.Cut(p.Path, "/"); return name }
 480func (p profileRepoRow) Desc() string      { return p.Description }
 481
 482// ownerPage renders /{owner} for users and orgs: the repositories the
 483// viewer may see, org membership either direction. Owner names are not
 484// secret (they are on every commit); repository visibility rules hold.
 485// profileTab is which section of a profile a URL asks for. The bare
 486// /{owner} is About, the first tab; the rest hang off the /-/ namespace
 487// the labels and milestones pages already use. What #242 asked for is
 488// that the sections be separate pages rather than one stack a long
 489// About pushes the repositories off the bottom of — not that any one of
 490// them be the landing page.
 491func profileTab(path string) string {
 492	switch {
 493	case strings.HasSuffix(path, "/-/repositories"):
 494		return "repos"
 495	case strings.HasSuffix(path, "/-/bookmarks"):
 496		return "bookmarks"
 497	case strings.HasSuffix(path, "/-/snippets"):
 498		return "snippets"
 499	case strings.HasSuffix(path, "/-/people"):
 500		return "people"
 501	}
 502	return "about"
 503}
 504
 505// profileEvents is how many activity lines the About tab lists under the
 506// graph. The graph is a year at a glance; the log is what happened
 507// lately, and a fixed count keeps the page the same length whatever the
 508// account's pace.
 509const profileEvents = 30
 510
 511// ownerFeed is the activity log under the graph on the About tab: the
 512// newest of whatever the graph above it counts, on public repositories
 513// only. That is the actor's own events for a user and the
 514// organization's repositories' events for an org, matching
 515// ActivityByDay and OrgActivityByDay respectively — a log that counted
 516// something else would contradict the total printed over it. Only the
 517// About tab renders it, so no other tab pays for the query.
 518func (s *Server) ownerFeed(tab, kind, name string) []control.FeedLine {
 519	if tab != "about" {
 520		return nil
 521	}
 522	var events []store.FeedEvent
 523	var err error
 524	switch kind {
 525	case "user":
 526		u, uerr := s.st.UserByUsername(name)
 527		if uerr != nil {
 528			return nil
 529		}
 530		events, err = s.st.UserPublicEvents(u.ID, profileEvents)
 531	case "org":
 532		o, oerr := s.st.OrgByName(name)
 533		if oerr != nil {
 534			return nil
 535		}
 536		events, err = s.st.OwnerPublicEvents("org", o.ID, profileEvents)
 537	}
 538	if err != nil {
 539		return nil
 540	}
 541	return control.FeedLines(events)
 542}
 543
 544// ownerPage is what owner.html renders against. It is a named type
 545// because the handler and the tests must agree on it field for field,
 546// and an anonymous struct in two places drifts.
 547type ownerPage struct {
 548	basePage
 549	Owner         string
 550	Kind          string
 551	Tab           string
 552	Profile       store.Profile
 553	AboutHTML     template.HTML
 554	Repos         []profileRepoRow
 555	Members       []control.ProfileMember
 556	Orgs          []control.ProfileMember
 557	Activity      []activityWeek
 558	ActivityTotal int
 559	Log           []control.FeedLine
 560	Bookmarks     []control.BookmarkOut
 561	SnippetRows   []snippetRow
 562	SnippetsAll   bool
 563	Teams         []teamView
 564	CanAdmin      bool
 565	Self          bool
 566	Snippets      int
 567	Notice        string
 568	Reauth        bool // Notice is the stale-session refusal: link to sign in
 569	Feed          string
 570}
 571
 572func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) {
 573	name := r.PathValue("owner")
 574	var viewer store.User
 575	if s.cfg.Web.Mode == "accounts" {
 576		viewer = s.viewer(r)
 577	}
 578
 579	// Everything on this page — membership, the repositories this viewer
 580	// may see, the activity year — comes from profile show, so the page
 581	// and the command cannot report different things.
 582	var d profileView
 583	code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d)
 584	switch {
 585	case code == protocol.ExitNotFound:
 586		s.notFound(w, r)
 587		return
 588	case code != protocol.ExitOK:
 589		log.Printf("profile %s: %s", name, msg)
 590		http.Error(w, "internal error", http.StatusInternalServerError)
 591		return
 592	}
 593
 594	counts := make(map[string]int, len(d.Activity))
 595	for _, day := range d.Activity {
 596		counts[day.Date] = day.Count
 597	}
 598	weeks, activityTotal := activityGrid(counts)
 599
 600	teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
 601	self := d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name)
 602	tab := profileTab(r.URL.Path)
 603	// A tab nobody may open is not a page: the people tab is the
 604	// organization admin panel, bookmarks are the viewer's own and
 605	// nobody else's, and only a user has snippets. Each answers the way
 606	// a missing page does rather than rendering empty.
 607	if (tab == "people" && !canAdmin) || (tab == "bookmarks" && !self) ||
 608		(tab == "snippets" && d.Kind != "user") {
 609		s.notFound(w, r)
 610		return
 611	}
 612
 613	var bookmarks []control.BookmarkOut
 614	if tab == "bookmarks" {
 615		s.runControlInto(viewer, []string{"repo", "bookmarks"}, &bookmarks)
 616	}
 617	var snippets []snippetRow
 618	if tab == "snippets" {
 619		var ok bool
 620		if snippets, ok = s.ownerSnippets(w, r, viewer, name); !ok {
 621			return
 622		}
 623	}
 624	notice := s.takeFlash(w, r)
 625	s.render(w, "owner.html", ownerPage{
 626		basePage:      s.baseFor(viewer),
 627		Owner:         name,
 628		Kind:          d.Kind,
 629		Tab:           tab,
 630		Profile:       store.Profile{Description: d.Description, Website: d.Website, Links: d.Links},
 631		AboutHTML:     aboutHTML(d.About, d.AboutFormat),
 632		Repos:         d.Repos,
 633		Members:       d.Members,
 634		Orgs:          d.Orgs,
 635		Activity:      weeks,
 636		ActivityTotal: activityTotal,
 637		Log:           s.ownerFeed(tab, d.Kind, name),
 638		Bookmarks:     bookmarks,
 639		SnippetRows:   snippets,
 640		SnippetsAll:   self || viewer.IsAdmin,
 641		Teams:         teams,
 642		CanAdmin:      canAdmin,
 643		Self:          self,
 644		Snippets:      d.Snippets,
 645		Notice:        notice,
 646		Reauth:        s.reauthNotice(w, notice, r.URL.Path),
 647		Feed:          "/" + name + "/activity.atom",
 648	})
 649}
 650
 651func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
 652	p, ok := s.repoFor(w, r, "")
 653	if !ok {
 654		return
 655	}
 656	p.Tab = "files"
 657	p.RepoHome = true
 658	s.renderTree(w, r, p, "")
 659}
 660
 661func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
 662	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 663	if !ok {
 664		return
 665	}
 666	p.Tab = "files"
 667	path := strings.Trim(r.PathValue("path"), "/")
 668	// The root of the default branch is the same page as the bare repo
 669	// URL, so its header must match: RepoHome is what picks the h1 over
 670	// the p+link identity, not which route was typed.
 671	p.RepoHome = path == "" && p.Ref == p.Repo.DefaultBranch
 672	s.renderTree(w, r, p, path)
 673}
 674
 675// treePage is shared by the populated and empty-repository renders: two
 676// anonymous structs drifted apart once already.
 677type treePage struct {
 678	repoPage
 679	Crumbs      []crumb
 680	Prefix      string
 681	DirPath     string
 682	RefKind     string
 683	Entries     []gitutil.TreeEntry
 684	Branches    []gitutil.Ref
 685	ReadmeName  string
 686	ReadmeHTML  template.HTML
 687	LastCommits map[string]namedCommit
 688	Tip         namedCommit
 689	Facts       repoFacts
 690	Notice      string
 691}
 692
 693func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
 694	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
 695		// Empty repo: render the page with no entries rather than 404.
 696		s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree", Notice: s.takeFlash(w, r)})
 697		return
 698	}
 699	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
 700	if err != nil {
 701		s.notFound(w, r)
 702		return
 703	}
 704	sortDirsFirst(entries)
 705	prefix := ""
 706	if dirPath != "" {
 707		prefix = dirPath + "/"
 708	}
 709
 710	var readmeHTML template.HTML
 711	readmeName := control.PickReadme(entries)
 712	if readmeName != "" {
 713		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
 714			readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
 715		}
 716	}
 717
 718	branches, _ := gitutil.Refs(p.Dir, "heads")
 719	names := make([]string, 0, len(entries))
 720	for _, e := range entries {
 721		names = append(names, e.Name)
 722	}
 723	// The facts bar is about the repository, not this directory, so it is
 724	// computed once at the root and left off subdirectory listings.
 725	var facts repoFacts
 726	if dirPath == "" {
 727		facts = s.factsFor(p)
 728	}
 729	s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
 730		readmeName, readmeHTML,
 731		s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
 732		s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts, s.takeFlash(w, r)})
 733}
 734
 735func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
 736	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 737	if !ok {
 738		return
 739	}
 740	p.Tab = "files"
 741	filePath := strings.Trim(r.PathValue("path"), "/")
 742	// The file and its symbol links are read from one commit, even if the
 743	// ref moves while the page renders.
 744	commit, err := gitutil.ResolveRef(p.Dir, p.Ref)
 745	if err != nil {
 746		s.notFound(w, r)
 747		return
 748	}
 749	data, err := gitutil.ReadBlob(p.Dir, commit, filePath, maxRenderBytes+1)
 750	if err != nil {
 751		s.notFound(w, r)
 752		return
 753	}
 754	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
 755	_, image := imageTypes[strings.ToLower(path.Ext(filePath))]
 756
 757	var codeHTML template.HTML
 758	if !binary && !image {
 759		codeHTML = highlight(filePath, data)
 760	}
 761	var fileSymbols []store.SymbolRow
 762	if !binary && !image {
 763		codeHTML, fileSymbols = s.blobSymbols(p, commit, filePath, codeHTML)
 764	}
 765	// Markdown and org render like a README, with the source one click
 766	// away; ?view=source shows the text instead.
 767	renderable := markupFile(filePath) && !binary
 768	var renderedHTML template.HTML
 769	rendered := renderable && r.URL.Query().Get("view") != "source"
 770	if rendered {
 771		renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
 772	}
 773	cs := crumbs(p, "blob", filePath)
 774	base := ""
 775	if len(cs) > 0 {
 776		base = cs[len(cs)-1].Name
 777		cs = cs[:len(cs)-1]
 778	}
 779	branches, _ := gitutil.Refs(p.Dir, "heads")
 780	navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath))
 781	nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries)
 782	lines := 0
 783	if !binary && !image && len(data) > 0 {
 784		lines = bytes.Count(data, []byte("\n"))
 785		if data[len(data)-1] != '\n' {
 786			lines++
 787		}
 788	}
 789	// The file listing leads with the last commit now, so the facts about
 790	// the file itself are reported here instead.
 791	entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
 792	s.render(w, "blob.html", struct {
 793		repoPage
 794		Crumbs       []crumb
 795		Base         string
 796		Path         string
 797		DirPath      string
 798		RefKind      string
 799		Binary       bool
 800		Image        bool
 801		Size         int
 802		Lines        int
 803		Exec         bool
 804		Symlink      bool
 805		Branches     []gitutil.Ref
 806		CodeHTML     template.HTML
 807		Renderable   bool // markdown or org: the toggle is offered
 808		Rendered     bool // this response shows the rendering
 809		RenderedHTML template.HTML
 810		Nav          fileNav
 811		Symbols      []store.SymbolRow
 812	}{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
 813		entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML, nav, fileSymbols})
 814}
 815
 816// releases lists tag-anchored releases with notes and assets.
 817func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 818	s.releasesPage(w, r, "")
 819}
 820
 821// releasesPage lists releases. previewForm is "release" when the create
 822// form asked to see its notes, or "release:<tag>" when that release's
 823// edit form did (#235).
 824func (s *Server) releasesPage(w http.ResponseWriter, r *http.Request, previewForm string) {
 825	p, ok := s.repoFor(w, r, "")
 826	if !ok {
 827		return
 828	}
 829	p.Tab = "releases"
 830	p.Feed = "/" + p.Repo.Path() + "/releases.atom"
 831	rels, err := s.st.ListReleases(p.Repo.ID)
 832	if err != nil {
 833		http.Error(w, "internal error", http.StatusInternalServerError)
 834		return
 835	}
 836	md := s.ugcFor(r, p.Repo)
 837	type relView struct {
 838		store.Release
 839		NotesHTML template.HTML
 840	}
 841	var views []relView
 842	for _, rel := range rels {
 843		views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
 844	}
 845	// Tags without a release yet are what a create form can offer.
 846	released := map[string]bool{}
 847	for _, rel := range rels {
 848		released[rel.Tag] = true
 849	}
 850	var freeTags []string
 851	if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
 852		gitutil.SortVersions(tags)
 853		for _, tg := range tags {
 854			if !released[tg.Name] {
 855				freeTags = append(freeTags, tg.Name)
 856			}
 857		}
 858	}
 859	// An edit keeps the release's stored format; a new release has no
 860	// picker and is markdown, as release create stores with no --format.
 861	var d *draft
 862	if previewForm != "" {
 863		format := "md"
 864		if tag, ok := strings.CutPrefix(previewForm, "release:"); ok {
 865			for _, v := range views {
 866				if v.Tag == tag {
 867					format = v.NotesFormat
 868				}
 869			}
 870		}
 871		d = s.draftFor(r, p.Repo, previewForm, "notes", format)
 872	}
 873	s.render(w, "releases.html", struct {
 874		repoPage
 875		Releases []relView
 876		FreeTags []string
 877		CanWrite bool
 878		Notice   string
 879		Draft    *draft
 880	}{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r), d})
 881}
 882
 883// releaseAsset streams one uploaded asset. Tags containing '/' are not
 884// reachable here (single path segment); SSH download always works.
 885func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 886	p, ok := s.repoFor(w, r, "")
 887	if !ok {
 888		return
 889	}
 890	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 891	if err != nil {
 892		s.notFound(w, r)
 893		return
 894	}
 895	name := r.PathValue("name")
 896	found := false
 897	for _, a := range rel.Assets {
 898		if a.Name == name {
 899			found = true
 900		}
 901	}
 902	if !found {
 903		s.notFound(w, r)
 904		return
 905	}
 906	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 907		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 908	if err != nil {
 909		s.notFound(w, r)
 910		return
 911	}
 912	defer f.Close()
 913	w.Header().Set("Content-Type", "application/octet-stream")
 914	w.Header().Set("X-Content-Type-Options", "nosniff")
 915	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 916	if fi, err := f.Stat(); err == nil {
 917		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 918	}
 919	io.Copy(w, f)
 920}
 921
 922// milestones lists a repo's milestones with progress.
 923func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 924	p, ok := s.repoFor(w, r, "")
 925	if !ok {
 926		return
 927	}
 928	p.Tab = "issues"
 929	state := r.URL.Query().Get("state")
 930	if state != "closed" && state != "all" {
 931		state = "open"
 932	}
 933	readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)
 934	if err != nil {
 935		http.Error(w, "internal error", http.StatusInternalServerError)
 936		return
 937	}
 938	ms, err := s.st.ListMilestones(p.Repo, state, readable)
 939	if err != nil {
 940		http.Error(w, "internal error", http.StatusInternalServerError)
 941		return
 942	}
 943	type msView struct {
 944		store.Milestone
 945		Percent int
 946	}
 947	var views []msView
 948	for _, m := range ms {
 949		v := msView{Milestone: m}
 950		if total := m.OpenItems + m.ClosedItems; total > 0 {
 951			v.Percent = m.ClosedItems * 100 / total
 952		}
 953		views = append(views, v)
 954	}
 955	s.render(w, "milestones.html", struct {
 956		repoPage
 957		State      string
 958		Milestones []msView
 959		CanWrite   bool
 960		Notice     string
 961	}{p, state, views, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
 962}
 963
 964// search runs a bounded literal git grep over the repo's default branch.
 965func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 966	p, ok := s.repoFor(w, r, "")
 967	if !ok {
 968		return
 969	}
 970	p.Tab = "search"
 971	q := strings.TrimSpace(r.URL.Query().Get("q"))
 972	type matchView struct {
 973		Path     string
 974		Line     int
 975		TextHTML template.HTML
 976	}
 977	var matches []matchView
 978	var queryErr string
 979	if q != "" {
 980		if len(q) < 2 || len(q) > 200 {
 981			queryErr = "query must be 2 to 200 characters"
 982		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 983			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 984			if err != nil {
 985				http.Error(w, "internal error", http.StatusInternalServerError)
 986				return
 987			}
 988			for _, m := range raw {
 989				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 990			}
 991		}
 992	}
 993	s.render(w, "search.html", struct {
 994		repoPage
 995		Query    string
 996		QueryErr string
 997		Matches  []matchView
 998		Capped   bool
 999	}{p, q, queryErr, matches, len(matches) == 200})
1000}
1001
1002// markMatch escapes a matched line and wraps case-insensitive occurrences
1003// of the query in <mark>.
1004func markMatch(text, q string) template.HTML {
1005	lower, lq := strings.ToLower(text), strings.ToLower(q)
1006	var b strings.Builder
1007	pos := 0
1008	for {
1009		i := strings.Index(lower[pos:], lq)
1010		if i < 0 {
1011			break
1012		}
1013		i += pos
1014		b.WriteString(template.HTMLEscapeString(text[pos:i]))
1015		b.WriteString("<mark>")
1016		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
1017		b.WriteString("</mark>")
1018		pos = i + len(q)
1019	}
1020	b.WriteString(template.HTMLEscapeString(text[pos:]))
1021	return template.HTML(b.String())
1022}
1023
1024func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
1025	p, ok := s.repoFor(w, r, r.PathValue("ref"))
1026	if !ok {
1027		return
1028	}
1029	p.Tab = "files"
1030	filePath := strings.Trim(r.PathValue("path"), "/")
1031
1032	// Blame is a control command; the web renders what it returns rather
1033	// than shelling out to git itself, so all three surfaces agree.
1034	page := 1
1035	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
1036		page = n
1037	}
1038	from := (page-1)*control.BlameSpan + 1
1039
1040	var out struct {
1041		From       int `json:"from"`
1042		To         int `json:"to"`
1043		TotalLines int `json:"total_lines"`
1044		Hunks      []struct {
1045			SHA         string   `json:"sha"`
1046			AuthorName  string   `json:"author_name"`
1047			AuthorEmail string   `json:"author_email"`
1048			Date        string   `json:"date"`
1049			Summary     string   `json:"summary"`
1050			StartLine   int      `json:"start_line"`
1051			Lines       []string `json:"lines"`
1052		} `json:"hunks"`
1053	}
1054	argv := []string{"repo", "blame", p.Repo.Path(), filePath,
1055		"--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
1056	var viewer store.User
1057	if s.cfg.Web.Mode == "accounts" {
1058		viewer = s.viewer(r)
1059	}
1060	msg, ok := s.runControlInto(viewer, argv, &out)
1061
1062	// A binary or empty file is a refusal, not a 404: the page still
1063	// renders and says why there is nothing to attribute.
1064	binary := false
1065	if !ok {
1066		if strings.Contains(msg, "is binary") {
1067			binary = true
1068		} else {
1069			s.notFound(w, r)
1070			return
1071		}
1072	}
1073
1074	type hunkView struct {
1075		gitutil.BlameHunk
1076		ShortSHA string
1077		Date     string
1078		Sig      sigView
1079		Numbered []numberedLine
1080	}
1081	var hunks []hunkView
1082	sigs := map[string]sigView{}
1083	for _, h := range out.Hunks {
1084		v, seen := sigs[h.SHA]
1085		if !seen {
1086			v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
1087			sigs[h.SHA] = v
1088		}
1089		date := h.Date
1090		if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
1091			date = t.Format(time.RFC3339)
1092		}
1093		hv := hunkView{
1094			BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
1095				AuthorEmail: h.AuthorEmail, Summary: h.Summary,
1096				StartLine: h.StartLine, Lines: h.Lines},
1097			ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
1098		}
1099		for i, l := range h.Lines {
1100			hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
1101		}
1102		hunks = append(hunks, hv)
1103	}
1104
1105	pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
1106	if pages == 0 {
1107		pages = 1
1108	}
1109	if page > pages {
1110		page = pages
1111	}
1112
1113	cs := crumbs(p, "blame", filePath)
1114	base := ""
1115	if len(cs) > 0 {
1116		base = cs[len(cs)-1].Name
1117		cs = cs[:len(cs)-1]
1118	}
1119	navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath))
1120	nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries)
1121	s.render(w, "blame.html", struct {
1122		repoPage
1123		Crumbs      []crumb
1124		Base        string
1125		Path        string
1126		Binary      bool
1127		Hunks       []hunkView
1128		Page, Pages int
1129		Nav         fileNav
1130	}{p, cs, base, filePath, binary, hunks, page, pages, nav})
1131}
1132
1133type numberedLine struct {
1134	N    int
1135	Text string
1136}
1137
1138// chromaFormatter emits class-based markup (no inline colors), so the
1139// stylesheet can swap palettes with the color scheme.
1140var chromaFormatter = html.New(html.WithClasses(true),
1141	html.WithLineNumbers(true), html.LineNumbersInTable(false),
1142	html.WithLinkableLineNumbers(true, "L"))
1143
1144// chromaFormatterPlain is chromaFormatter without linkable line numbers,
1145// for a page that highlights more than one file: linkable ids are
1146// per-file line numbers, so several files on one page would repeat
1147// id="L1", id="L2", ...
1148var chromaFormatterPlain = html.New(html.WithClasses(true),
1149	html.WithLineNumbers(true), html.LineNumbersInTable(false))
1150
1151func highlight(filePath string, data []byte) template.HTML {
1152	return highlightWith(chromaFormatter, filePath, data)
1153}
1154
1155func highlightPlain(filePath string, data []byte) template.HTML {
1156	return highlightWith(chromaFormatterPlain, filePath, data)
1157}
1158
1159func highlightWith(formatter *html.Formatter, filePath string, data []byte) template.HTML {
1160	lexer := lexers.Match(filePath)
1161	if lexer == nil {
1162		lexer = lexers.Fallback
1163	}
1164	iterator, err := lexer.Tokenise(nil, string(data))
1165	if err != nil {
1166		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
1167	}
1168	var buf bytes.Buffer
1169	if err := formatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1170		return focusableBlocks(template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>"))
1171	}
1172	return focusableBlocks(template.HTML(buf.String()))
1173}
1174
1175// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
1176// The light one cannot be left unscoped: the two palettes do not name the
1177// same token set, and every token github-dark omits would keep its
1178// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
1179// Scoped, an unnamed token inherits the wrapper's colour instead, which is
1180// readable in both. The site's --code-bg stays the background either way.
1181// lightStyle and darkStyle are chosen on measured contrast against the
1182// grounds code actually sits on here — page, code block, and the diff
1183// tints. friendly, the chroma default, put 61 token/ground pairs under
1184// 4.5:1; xcode puts one.
1185const (
1186	lightStyle = "xcode"
1187	darkStyle  = "github-dark"
1188)
1189
1190var chromaCSS = func() []byte {
1191	var light, dark bytes.Buffer
1192	chromaFormatter.WriteCSS(&light, styles.Get(lightStyle))
1193	// xcode's NameAttribute is its one token under 4.5:1 against the diff
1194	// tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
1195	light.WriteString(".chroma .na { color: #6f5a21 }\n")
1196	chromaFormatter.WriteCSS(&dark, styles.Get(darkStyle))
1197	// Each palette applies under its media query unless the page is
1198	// stamped with the other theme, and again, outside any media query,
1199	// when the page is stamped with its own (#232).
1200	var buf bytes.Buffer
1201	buf.WriteString("@media (prefers-color-scheme: light) {\n")
1202	buf.WriteString(scopeChroma(light.String(), `:root:not([data-theme="dark"])`))
1203	buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
1204	buf.WriteString(scopeChroma(dark.String(), `:root:not([data-theme="light"])`))
1205	buf.WriteString("}\n")
1206	buf.WriteString(scopeChroma(light.String(), `:root[data-theme="light"]`))
1207	buf.WriteString(scopeChroma(dark.String(), `:root[data-theme="dark"]`))
1208	buf.WriteString(".chroma, .bg { background: transparent !important; }\n")
1209	// Line numbers take the site's own gutter colour in both schemes. Left
1210	// alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
1211	// chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
1212	// latter is a formatter fallback, not a style entry, so no palette test
1213	// can see it. !important because the scoped palette rules above outrank
1214	// a bare .chroma .ln.
1215	buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) !important }\n")
1216	return buf.Bytes()
1217}()
1218
1219func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
1220	p, ok := s.repoFor(w, r, r.PathValue("ref"))
1221	if !ok {
1222		return
1223	}
1224	filePath := strings.Trim(r.PathValue("path"), "/")
1225	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
1226	if err != nil {
1227		s.notFound(w, r)
1228		return
1229	}
1230	// Serve inert: never let repo content execute in the forge's origin.
1231	// Images get their real type so <img> works under nosniff; SVG script
1232	// is dead on arrival because the instance CSP is script-src 'none'.
1233	ct := "text/plain; charset=utf-8"
1234	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
1235		ct = t
1236	}
1237	w.Header().Set("Content-Type", ct)
1238	w.Header().Set("X-Content-Type-Options", "nosniff")
1239	w.Write(data)
1240}
1241
1242// imageTypes are the formats raw serves with a real content type and blob
1243// pages preview inline.
1244var imageTypes = map[string]string{
1245	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
1246	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
1247	".svg": "image/svg+xml", ".ico": "image/x-icon",
1248}
1249
1250// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1251// task lists) on top of CommonMark, with class-based fence highlighting
1252// (the palette lives in the stylesheet, per scheme), and TeX math as
1253// MathML (math.go). Raw HTML is still dropped.
1254// Headings carry ids so a README or wiki section can be linked to, the
1255// way org headings already are (#132).
1256var markdown = goldmark.New(
1257	goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1258	goldmark.WithExtensions(extension.GFM, mathExtension{},
1259		highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1260
1261// fenceHighlight renders one code block with chroma classes, for org and
1262// anything else outside goldmark. Unknown languages fall back to plain.
1263func fenceHighlight(source, lang string) string {
1264	lexer := lexers.Get(lang)
1265	if lexer == nil {
1266		lexer = lexers.Fallback
1267	}
1268	iterator, err := lexer.Tokenise(nil, source)
1269	if err != nil {
1270		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1271	}
1272	var buf bytes.Buffer
1273	f := html.New(html.WithClasses(true))
1274	if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1275		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1276	}
1277	return buf.String()
1278}
1279
1280// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1281// goldmark's default renderer drops raw HTML, so this is safe as-is.
1282func mdHTML(raw string) template.HTML {
1283	if strings.TrimSpace(raw) == "" {
1284		return ""
1285	}
1286	var buf bytes.Buffer
1287	if markdown.Convert([]byte(raw), &buf) != nil {
1288		return focusableBlocks(template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>"))
1289	}
1290	return focusableBlocks(template.HTML(buf.String()))
1291}
1292
1293// aboutHTML renders a profile's about text. The format comes from the
1294// file it was read from: org is org, anything else markdown.
1295func aboutHTML(text, format string) template.HTML {
1296	if strings.TrimSpace(text) == "" {
1297		return ""
1298	}
1299	name := "about.md"
1300	if format == "org" {
1301		name = "about.org"
1302	}
1303	return renderReadme(name, []byte(text))
1304}
1305
1306// webResolver answers autolink lookups for one viewer. Cross-repo
1307// references to repositories the viewer cannot read stay plain text, per
1308// the enumeration rule: a link would confirm the repo exists.
1309type webResolver struct {
1310	s      *Server
1311	viewer store.User
1312}
1313
1314func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1315	repo, err := r.s.st.RepoByPath(owner + "/" + name)
1316	if err != nil {
1317		return ""
1318	}
1319	grant := ""
1320	if r.viewer.ID != 0 {
1321		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1322	}
1323	if !policy.CanRead(r.viewer, repo, grant) {
1324		return ""
1325	}
1326	if kind == '#' {
1327		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1328			return ""
1329		}
1330		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1331	}
1332	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1333		return ""
1334	}
1335	return autolink.MRURL(repo.OwnerName, repo.Name, n)
1336}
1337
1338func (r webResolver) UserURL(name string) string {
1339	if _, err := r.s.st.UserByUsername(name); err == nil {
1340		return "/" + name
1341	}
1342	if _, err := r.s.st.OrgByName(name); err == nil {
1343		return "/" + name
1344	}
1345	return ""
1346}
1347
1348// ugcRenderer renders one user-authored body in the format it was written in.
1349// The format travels with the body: it is recorded when the text is written, so
1350// changing a preference later cannot re-interpret prose that already exists.
1351type ugcRenderer func(raw, format string) template.HTML
1352
1353// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1354// so a body stored before formats existed — and any row whose column defaulted —
1355// renders exactly as it did before.
1356//
1357// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1358// about text take, so it inherits that function's include guard and sanitising
1359// rather than growing a second org renderer to keep in step.
1360func ugcHTML(raw, format string) template.HTML {
1361	if format == "org" {
1362		return focusableBlocks(renderOrg("body.org", []byte(raw), false, func() template.HTML {
1363			return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1364		}))
1365	}
1366	return mdHTML(raw)
1367}
1368
1369// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1370// ugcHTML plus cross-reference and mention autolinking for this viewer.
1371func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1372	viewer := store.User{}
1373	if s.cfg.Web.Mode == "accounts" {
1374		viewer = s.viewer(r)
1375	}
1376	res := webResolver{s, viewer}
1377	return func(raw, format string) template.HTML {
1378		h := ugcHTML(raw, format)
1379		if h == "" {
1380			return h
1381		}
1382		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1383	}
1384}
1385
1386// renderedComment pairs a comment with its rendered body for templates.
1387type renderedComment struct {
1388	ID        int64
1389	Author    string
1390	CreatedAt string
1391	Kind      string
1392	BodyHTML  template.HTML
1393}
1394
1395func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1396	var out []renderedComment
1397	for _, c := range cs {
1398		out = append(out, renderedComment{ID: c.ID, Author: c.Author, CreatedAt: c.CreatedAt, Kind: c.Kind, BodyHTML: ugc(c.Body, c.BodyFormat)})
1399	}
1400	return out
1401}
1402
1403// ugcPolicy sanitizes rendered repo content before it enters the forge's
1404// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1405// output and repo-authored HTML are not. Chroma's highlighting classes
1406// must survive; the pattern admits only short token codes, not the site's
1407// own class names.
1408var ugcPolicy = func() *bluemonday.Policy {
1409	p := bluemonday.UGCPolicy()
1410	p.AllowAttrs("class").
1411		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1412		OnElements("span", "pre", "code", "div")
1413	return p
1414}()
1415
1416// renderReadme renders a README by extension: markdown, org-mode, and
1417// (sanitized) HTML richly; everything else as escaped plaintext.
1418// orgConfig is the go-org configuration for rendering untrusted org.
1419//
1420// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1421// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1422// wiki page, a profile — so both keywords are refused outright: the file is
1423// never opened and the keyword stays the inert text it is. There is no safe
1424// subset to allow instead. An absolute path skips go-org's relative-path join,
1425// a relative one resolves against the daemon's working directory, and a repo
1426// has no directory to scope to anyway because the content came from a git
1427// object rather than a checkout.
1428//
1429// The default logger writes parse warnings to stderr, which would let pushed
1430// content write to the server's log; discard them.
1431func orgConfig() *org.Configuration {
1432	c := org.New()
1433	c.ReadFile = func(string) ([]byte, error) {
1434		return nil, errOrgIncludeDisabled
1435	}
1436	c.Log = log.New(io.Discard, "", 0)
1437	return c
1438}
1439
1440var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1441
1442// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1443// of contents: a README or wiki page is a document and carries one, an issue
1444// comment is a remark and should not sprout one above two headings. `fallback`
1445// supplies the plaintext rendering used when the writer fails.
1446func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1447	c := orgConfig()
1448	if !contents {
1449		// DefaultSettings is a fresh map per org.New(), so this is local.
1450		c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1451	}
1452	doc := c.Parse(bytes.NewReader(raw), name)
1453	writer := org.NewHTMLWriter()
1454	writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1455		if inline {
1456			return "<code>" + template.HTMLEscapeString(source) + "</code>"
1457		}
1458		return fenceHighlight(source, lang)
1459	}
1460	ow := &orgWriter{HTMLWriter: writer, math: newMathSlots()}
1461	writer.ExtendingWriter = ow
1462	out, err := doc.Write(writer)
1463	if err != nil {
1464		return fallback()
1465	}
1466	return imageAlt(template.HTML(ow.math.fill(ugcPolicy.Sanitize(out))))
1467}
1468
1469// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL
1470// at the first character outside RFC 3986's set, and that set includes
1471// `.`, `,` and `)`, so a URL closing a sentence or a parenthesis took the
1472// punctuation with it. Org stops a plain link before trailing punctuation
1473// and keeps a `)` only when a `(` inside the link opened it. It also
1474// renders LaTeX fragments and blocks (math.go).
1475type orgWriter struct {
1476	*org.HTMLWriter
1477	math *mathSlots
1478}
1479
1480func (w *orgWriter) WriteRegularLink(l org.RegularLink) {
1481	if !l.AutoLink {
1482		w.HTMLWriter.WriteRegularLink(l)
1483		return
1484	}
1485	url, rest := splitAutolinkPunctuation(l.URL)
1486	l.URL = url
1487	w.HTMLWriter.WriteRegularLink(l)
1488	if rest != "" {
1489		w.WriteText(org.Text{Content: rest})
1490	}
1491}
1492
1493// splitAutolinkPunctuation returns the URL without trailing sentence
1494// punctuation, and the punctuation it removed.
1495func splitAutolinkPunctuation(url string) (string, string) {
1496	end := len(url)
1497	for end > 0 {
1498		switch url[end-1] {
1499		case '.', ',', ';', ':', '!', '?', '\'', '"':
1500			end--
1501			continue
1502		case ')':
1503			if strings.Count(url[:end], ")") > strings.Count(url[:end], "(") {
1504				end--
1505				continue
1506			}
1507		}
1508		break
1509	}
1510	return url[:end], url[end:]
1511}
1512
1513// headingTag matches an opening or closing h1..h5 tag, so a rendered
1514// document's headings can move down one level.
1515var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1516
1517// demoteHeadings moves every heading in a rendered document down one
1518// level: the page it sits on already has its h1 (the repository, the
1519// file, the wiki page), so a README's own h1 would be a second top-level
1520// heading in the outline (#133). Ids and anchors are untouched.
1521func demoteHeadings(h template.HTML) template.HTML {
1522	return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1523		sub := headingTag.FindStringSubmatch(m)
1524		return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1525	}))
1526}
1527
1528func renderReadme(name string, raw []byte) template.HTML {
1529	plain := func() template.HTML {
1530		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1531	}
1532	if gitutil.IsBinary(raw) {
1533		return ""
1534	}
1535	var out template.HTML
1536	switch path.Ext(strings.ToLower(name)) {
1537	case ".md", ".markdown":
1538		var buf bytes.Buffer
1539		if markdown.Convert(raw, &buf) != nil {
1540			return focusableBlocks(plain())
1541		}
1542		out = demoteHeadings(template.HTML(buf.String()))
1543	case ".org":
1544		out = demoteHeadings(renderOrg(name, raw, true, plain))
1545	case ".html", ".htm":
1546		out = template.HTML(ugcPolicy.Sanitize(string(raw)))
1547	default:
1548		out = plain()
1549	}
1550	return focusableBlocks(out)
1551}
1552
1553type diffThread struct {
1554	ID       int64
1555	Resolved string
1556	Stale    bool
1557	// Pending marks a thread in the viewer's own unsubmitted review. Only
1558	// they are shown it, and the page says so, since it looks exactly
1559	// like a posted one otherwise.
1560	Pending    bool
1561	CanResolve bool
1562	Comments   []renderedComment
1563	Suggestion *suggestionView
1564}
1565
1566// suggestionView is a thread's suggestion as the page shows it: the lines
1567// it replaces and the ones it proposes, numbered from Start, and whether
1568// the viewer can apply it here or needs the CLI.
1569type suggestionView struct {
1570	Start    int64
1571	Old, New []suggestionLine
1572	Outdated bool
1573	Reason   string
1574	Local    bool   // the repositories require signed commits: apply from a clone
1575	CanApply bool   // the viewer can push to the source branch of an open MR
1576	Command  string // the CLI command that applies it
1577}
1578
1579type suggestionLine struct {
1580	N    int64
1581	Text string
1582}
1583
1584// newSuggestionView lays out s for the page.
1585func newSuggestionView(s *control.SuggestionOut, canApply bool, command string) *suggestionView {
1586	v := &suggestionView{Start: s.StartLine, Outdated: s.Outdated, Reason: s.Reason,
1587		Local: s.Apply == "local", CanApply: canApply, Command: command}
1588	for i, l := range suggest.FromText(strings.ReplaceAll(s.Original, "\r\n", "\n")) {
1589		v.Old = append(v.Old, suggestionLine{s.StartLine + int64(i), l})
1590	}
1591	for i, l := range suggest.FromText(s.Replacement) {
1592		v.New = append(v.New, suggestionLine{s.StartLine + int64(i), l})
1593	}
1594	return v
1595}
1596
1597// reviewRights decides which thread controls a viewer sees. mr resolve
1598// admits the thread author, the MR author, or anyone with write, so the
1599// page needs all three to render the button truthfully.
1600type reviewRights struct {
1601	Viewer   string
1602	MRAuthor string
1603	Write    bool
1604}
1605
1606func (r reviewRights) canResolve(threadAuthor string) bool {
1607	return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1608}
1609
1610// attachThreads injects review threads under their anchored diff lines;
1611// threads whose anchor no longer appears (stale after force-push, or on a
1612// context line outside the current diff) are returned separately. A
1613// thread root in suggestions renders its suggestion as a diff, and its
1614// body without the block.
1615func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights, suggestions map[int64]*suggestionView) ([]diffFile, []diffThread) {
1616	type anchor struct {
1617		path string
1618		side string
1619		line int64
1620	}
1621	// Diff-line comments have no stored format yet, so they stay markdown.
1622	// They are the one user-authored body left without the choice; see #51.
1623	threads := map[int64]*diffThread{}
1624	anchors := map[int64]anchor{}
1625	var order []int64
1626	for _, cm := range comments {
1627		if cm.ReplyTo == 0 {
1628			body := cm.Body
1629			if suggestions[cm.ID] != nil {
1630				body = suggest.Strip(body)
1631			}
1632			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1633				Pending:    cm.Pending,
1634				CanResolve: rights.canResolve(cm.Author),
1635				Suggestion: suggestions[cm.ID],
1636				Comments:   []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(body, "md")}}}
1637			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1638			order = append(order, cm.ID)
1639		} else if th, ok := threads[cm.ReplyTo]; ok {
1640			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1641		}
1642	}
1643	placed := map[int64]bool{}
1644	for f := range files {
1645		lines := files[f].Lines
1646		for i := range lines {
1647			for _, id := range order {
1648				if placed[id] || threads[id].Stale {
1649					continue
1650				}
1651				a := anchors[id]
1652				if lines[i].Path != a.path {
1653					continue
1654				}
1655				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1656					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1657					lines[i].Threads = append(lines[i].Threads, *threads[id])
1658					files[f].Threads++
1659					files[f].Open = true
1660					placed[id] = true
1661				}
1662			}
1663		}
1664	}
1665	var unplaced []diffThread
1666	for _, id := range order {
1667		if !placed[id] {
1668			unplaced = append(unplaced, *threads[id])
1669		}
1670	}
1671	return files, unplaced
1672}
1673
1674// markCompose opens the new-thread form under one diff line. There is no
1675// JavaScript, so "comment on this line" is a plain GET carrying the
1676// anchor and the page renders the form where the reader asked for it.
1677func markCompose(files []diffFile, q url.Values) {
1678	path := q.Get("cpath")
1679	line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1680	if path == "" || line < 1 {
1681		return
1682	}
1683	old := q.Get("cside") == "old"
1684	for f := range files {
1685		for i := range files[f].Lines {
1686			ln := &files[f].Lines[i]
1687			if ln.Path != path {
1688				continue
1689			}
1690			if (old && ln.Class == "del" && ln.OldLine == line) ||
1691				(!old && ln.Class != "del" && ln.NewLine == line) {
1692				ln.Compose = true
1693				files[f].Open = true
1694				return
1695			}
1696		}
1697	}
1698}
1699
1700type sigView struct {
1701	State       string
1702	Signer      string
1703	Fingerprint string
1704}
1705
1706func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1707	raw, err := gitutil.ReadCommit(dir, sha)
1708	if err != nil {
1709		return sigView{State: "unsigned"}, nil
1710	}
1711	parsed, err := sig.ParseCommit(raw)
1712	if err != nil {
1713		return sigView{State: "unsigned"}, nil
1714	}
1715	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1716	if err != nil {
1717		return sigView{State: "unsigned"}, parsed
1718	}
1719	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1720	if res.SignerUserID != 0 {
1721		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1722			v.Signer = u.Username
1723		}
1724	}
1725	return v, parsed
1726}
1727
1728func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1729	ref := r.PathValue("ref")
1730	p, ok := s.repoFor(w, r, ref)
1731	if !ok {
1732		return
1733	}
1734	p.Tab = "log"
1735	p.Feed = "/" + p.Repo.Path() + "/log.atom/" + p.Ref
1736	const pageSize = 50
1737	// ?path= filters to commits touching one file or directory.
1738	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1739	if filePath == "." {
1740		filePath = ""
1741	}
1742	var shas []string
1743	var err error
1744	if filePath != "" {
1745		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1746	} else {
1747		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1748	}
1749	if err != nil {
1750		s.notFound(w, r)
1751		return
1752	}
1753	next := ""
1754	if len(shas) > pageSize {
1755		next = shas[pageSize]
1756		shas = shas[:pageSize]
1757	}
1758	type row struct {
1759		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1760		Sig                                                               sigView
1761		Check                                                             string // combined status, "" when none ran
1762	}
1763	names := s.authorNames()
1764	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1765	var rows []row
1766	for _, sha := range shas {
1767		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1768		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1769		if parsed != nil {
1770			rw.Subject = parsed.Subject
1771			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1772			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1773			rw.AuthorEmail = parsed.AuthorEmail
1774			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
1775		}
1776		rows = append(rows, rw)
1777	}
1778	s.render(w, "log.html", struct {
1779		repoPage
1780		Commits  []row
1781		NextSHA  string
1782		FilePath string
1783	}{p, rows, next, filePath})
1784}
1785
1786func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1787	p, ok := s.repoFor(w, r, "")
1788	if !ok {
1789		return
1790	}
1791	p.Tab = "log"
1792	sha := r.PathValue("sha")
1793	full, err := gitutil.ResolveRef(p.Dir, sha)
1794	if err != nil {
1795		s.notFound(w, r)
1796		return
1797	}
1798	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1799	if parsed == nil {
1800		s.notFound(w, r)
1801		return
1802	}
1803	patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1804	files := parseDiff(patch)
1805	layout := s.diffLayoutFor(r)
1806	if layout.Split {
1807		splitFiles(files)
1808	}
1809	committerEmail := ""
1810	if parsed.CommitterEmail != parsed.AuthorEmail {
1811		committerEmail = parsed.CommitterEmail
1812	}
1813	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1814	commitNames := s.authorNames()
1815	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1816	msg := ""
1817	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1818		msg = string(parsed.Payload[i+2:])
1819	}
1820	s.render(w, "commit.html", struct {
1821		repoPage
1822		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1823		Parents                                                                           []string
1824		Sig                                                                               sigView
1825		Checks                                                                            []store.CommitStatus
1826		DiffFiles                                                                         []diffFile
1827		DiffTruncated                                                                     bool
1828		Layout                                                                            diffLayout
1829	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1830		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1831		gitutil.Parents(p.Dir, full), v, checks, files, truncated, layout})
1832}
1833
1834// labelPalette provides default label chip colors: mid-tone hues that stay
1835// legible on light and dark backgrounds.
1836var labelPalette = []string{
1837	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1838	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1839}
1840
1841var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1842
1843// The canvases a chip is drawn on, --canvas in each scheme, and the ratio
1844// its text owes them. Chip text is 12px, which WCAG reads as small text at
1845// 4.5:1. TestChipCanvasMatchesStylesheet keeps these in step with the
1846// tokens.
1847const (
1848	chipCanvasLight = "#ffffff"
1849	chipCanvasDark  = "#101114"
1850	chipRatio       = 4.5
1851)
1852
1853// chipTones returns a user-set label colour as it is drawn in each scheme.
1854// The chip's ground is mixed from the colour itself, and the luminance
1855// band that clears 4.5:1 on white ends below the band that clears it on
1856// the dark canvas, so one colour cannot serve both and each label carries
1857// two (#226, replacing the single clamp of #120). The hue is kept — the
1858// channels are scaled in linear light — and only a colour too dark to
1859// brighten any further, a saturated blue, is blended on toward white.
1860func chipTones(hex string) (light, dark string) {
1861	return chipTone(hex, chipCanvasLight, false), chipTone(hex, chipCanvasDark, true)
1862}
1863
1864// chipTone walks the colour along its ramp until it clears the ratio,
1865// stopping at the first tone that does: contrast rises with the distance
1866// travelled, so the bisection finds the tone nearest the one asked for.
1867func chipTone(hex, canvas string, up bool) string {
1868	if chipContrast(strings.ToLower(hex), canvas) >= chipRatio {
1869		return strings.ToLower(hex)
1870	}
1871	lo, hi := 0.0, 1.0
1872	for i := 0; i < 24; i++ {
1873		mid := (lo + hi) / 2
1874		if chipContrast(chipStep(hex, mid, up), canvas) >= chipRatio {
1875			hi = mid
1876		} else {
1877			lo = mid
1878		}
1879	}
1880	return chipStep(hex, hi, up)
1881}
1882
1883// chipStep is the colour s of the way along its ramp: down to black on a
1884// light canvas, and on a dark one up through the brightest tone that
1885// keeps the hue and from there on to white.
1886func chipStep(hex string, s float64, up bool) string {
1887	r, g, b := chipLinear(hex)
1888	switch m := math.Max(r, math.Max(g, b)); {
1889	case !up:
1890		k := 1 - s
1891		r, g, b = r*k, g*k, b*k
1892	case m == 0: // black has no hue to keep
1893		r, g, b = s, s, s
1894	case s <= 0.5:
1895		k := 1 + (s/0.5)*(1/m-1)
1896		r, g, b = r*k, g*k, b*k
1897	default:
1898		k, t := 1/m, (s-0.5)/0.5
1899		r, g, b = r*k, g*k, b*k
1900		r, g, b = r+t*(1-r), g+t*(1-g), b+t*(1-b)
1901	}
1902	return chipHex(r, g, b)
1903}
1904
1905// chipContrast is the WCAG ratio between a chip colour and its own
1906// ground, color-mix(in srgb, chip 10%, canvas).
1907func chipContrast(hex, canvas string) float64 {
1908	y, g := chipLuminance(hex), chipLuminance(chipGround(hex, canvas))
1909	if y < g {
1910		y, g = g, y
1911	}
1912	return (y + 0.05) / (g + 0.05)
1913}
1914
1915// chipGround mixes a tenth of the chip colour into the canvas, the blend
1916// color-mix(in srgb, ...) makes: gamma-encoded channels, not linear ones.
1917func chipGround(hex, canvas string) string {
1918	mix := func(a, b string) string {
1919		return fmt.Sprintf("%02x", int(math.Round(0.1*float64(hexByte(a))+0.9*float64(hexByte(b)))))
1920	}
1921	return "#" + mix(hex[1:3], canvas[1:3]) + mix(hex[3:5], canvas[3:5]) + mix(hex[5:7], canvas[5:7])
1922}
1923
1924// chipLinear is a #rrggbb colour in linear light, chipHex the way back,
1925// and chipLuminance the WCAG relative luminance of one.
1926func chipLinear(hex string) (r, g, b float64) {
1927	lin := func(c int64) float64 {
1928		v := float64(c) / 255
1929		if v <= 0.04045 {
1930			return v / 12.92
1931		}
1932		return math.Pow((v+0.055)/1.055, 2.4)
1933	}
1934	return lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1935}
1936
1937func chipHex(r, g, b float64) string {
1938	enc := func(v float64) int {
1939		v = math.Min(1, math.Max(0, v))
1940		if v <= 0.0031308 {
1941			v *= 12.92
1942		} else {
1943			v = 1.055*math.Pow(v, 1/2.4) - 0.055
1944		}
1945		return int(math.Round(v * 255))
1946	}
1947	return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1948}
1949
1950func chipLuminance(hex string) float64 {
1951	r, g, b := chipLinear(hex)
1952	return 0.2126*r + 0.7152*g + 0.0722*b
1953}
1954
1955func hexByte(s string) int64 {
1956	n, _ := strconv.ParseInt(s, 16, 32)
1957	return n
1958}
1959
1960// labelColors returns a complete label-name -> chip color map for a repo:
1961// the stored labels.color when it is a valid hex color, otherwise a
1962// stable default picked from the palette by name hash.
1963func (s *Server) labelColors(repo store.Repo) map[string]template.CSS {
1964	stored, _ := s.st.LabelColors(repo)
1965	return colorStyles(stored)
1966}
1967
1968// colorStyles turns a label-name -> stored color map into chip styles: the
1969// stored color when it is a valid hex color, otherwise a stable default
1970// picked from the palette by name hash, as a tone per scheme.
1971func colorStyles(stored map[string]string) map[string]template.CSS {
1972	out := make(map[string]template.CSS, len(stored))
1973	for name, color := range stored {
1974		if !hexColorPat.MatchString(color) {
1975			h := fnv.New32a()
1976			h.Write([]byte(name))
1977			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1978		}
1979		light, dark := chipTones(color)
1980		out[name] = template.CSS("--chip-l:" + light + ";--chip-d:" + dark)
1981	}
1982	return out
1983}
1984
1985// listPage is how many issues or merge requests a list page shows before
1986// it offers the older ones (#118). Keyset paging on the number, the same
1987// cursor the commands use, so every filter carries across pages.
1988const listPage = 50
1989
1990// olderLink is the current URL with before=<number> set.
1991func olderLink(r *http.Request, before int64) string {
1992	q := r.URL.Query()
1993	q.Set("before", strconv.FormatInt(before, 10))
1994	return "?" + q.Encode()
1995}
1996
1997func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1998	p, ok := s.repoFor(w, r, "")
1999	if !ok {
2000		return
2001	}
2002	p.Tab = "issues"
2003	state := r.URL.Query().Get("state")
2004	if state != "closed" && state != "all" {
2005		state = "open"
2006	}
2007	// The same filters the CLI's issue list takes, as query parameters;
2008	// label chips and author links point here.
2009	qv := r.URL.Query()
2010	f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
2011		Author: qv.Get("author"), Milestone: qv.Get("milestone"),
2012		Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
2013	f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
2014	issues, err := s.st.QueryIssues(p.Repo.ID, f)
2015	if err != nil {
2016		http.Error(w, "internal error", http.StatusInternalServerError)
2017		return
2018	}
2019	older := ""
2020	if len(issues) > listPage {
2021		issues = issues[:listPage]
2022		older = olderLink(r, issues[len(issues)-1].Number)
2023	}
2024	if labels, err := s.st.ListIssueLabels(p.Repo); err == nil {
2025		for i := range issues {
2026			issues[i].Labels = labels[issues[i].ID]
2027		}
2028	}
2029	base := url.Values{"state": {state}, "label": {f.Label}, "assignee": {f.Assignee}, "author": {f.Author}, "milestone": {f.Milestone}, "q": {f.Search}}
2030	readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
2031	allLabels, _ := s.st.ListLabels(p.Repo, readable)
2032	openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
2033	facets := listFacets(base, []string{"open", "closed", "all"}, state, allLabels, openMS, false)
2034	s.render(w, "issues.html", struct {
2035		repoPage
2036		State       string
2037		Label       string
2038		Query       string
2039		Filters     []listFilter
2040		Facets      []facetGroup
2041		Issues      []store.Issue
2042		LabelColors map[string]template.CSS
2043		Older       string
2044	}{p, state, f.Label, f.Search,
2045		activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
2046		facets, issues, s.labelColors(p.Repo), older})
2047}
2048
2049func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
2050	s.issuePage(w, r, "")
2051}
2052
2053// issuePage renders an issue. previewForm names the form that asked to
2054// see its markup rather than save it — "edit" or "comment", "" for a
2055// plain read — and the page renders that draft above the form it came
2056// from, in the format the write would have stored (#235).
2057func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm string) {
2058	p, ok := s.repoFor(w, r, "")
2059	if !ok {
2060		return
2061	}
2062	p.Tab = "issues"
2063	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
2064	if err != nil {
2065		s.notFound(w, r)
2066		return
2067	}
2068	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
2069	if err != nil {
2070		s.notFound(w, r)
2071		return
2072	}
2073	comments, err := s.st.ListIssueComments(iss.ID)
2074	if err != nil {
2075		http.Error(w, "internal error", http.StatusInternalServerError)
2076		return
2077	}
2078	md := s.ugcFor(r, p.Repo)
2079	// An edit keeps the issue's stored format; a comment has no picker
2080	// and is markdown, which is what issue comment stores with no
2081	// --format.
2082	var d *draft
2083	if previewForm != "" {
2084		format := iss.BodyFormat
2085		if previewForm == "comment" {
2086			format = "md"
2087		}
2088		d = s.draftFor(r, p.Repo, previewForm, "body", format)
2089	}
2090	// nil readable: the picker lists titles, never the progress counts.
2091	milestones, _ := s.st.ListMilestones(p.Repo, "open", nil)
2092	bars := s.reactionBars(r, "issue", iss.ID, comments, fmt.Sprintf("/%s/%s/issues/%d/react", p.Repo.OwnerName, p.Repo.Name, iss.Number))
2093	s.render(w, "issue.html", struct {
2094		repoPage
2095		Issue       store.Issue
2096		BodyHTML    template.HTML
2097		Comments    []renderedComment
2098		CanEdit     bool
2099		CanWrite    bool
2100		Milestones  []store.Milestone
2101		Notice      string
2102		LabelColors map[string]template.CSS
2103		Draft       *draft
2104		Reactions   map[int64]reactionBar
2105	}{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
2106		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
2107		milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d, bars})
2108}
2109
2110// canEditItem: the author or anyone with write access may edit.
2111// canWriteRepo reports whether the browser session may push to the repo,
2112// which is what gates the review and merge controls.
2113func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
2114	if s.cfg.Web.Mode != "accounts" {
2115		return false
2116	}
2117	u := s.viewer(r)
2118	if u.ID == 0 {
2119		return false
2120	}
2121	return s.canWriteRepoAs(u, repo)
2122}
2123
2124// canWriteRepoAs is canWriteRepo for a handler that already has its
2125// viewer as a parameter (behind requireUser) rather than needing to
2126// resolve one from the request's session cookie.
2127func (s *Server) canWriteRepoAs(u store.User, repo store.Repo) bool {
2128	grant, _ := s.st.AccessRole(repo.ID, u.ID)
2129	return policy.CanWrite(u, repo, grant)
2130}
2131
2132func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
2133	if s.cfg.Web.Mode != "accounts" {
2134		return false
2135	}
2136	u := s.viewer(r)
2137	if u.ID == 0 {
2138		return false
2139	}
2140	if u.Username == author {
2141		return true
2142	}
2143	grant, _ := s.st.AccessRole(repo.ID, u.ID)
2144	return policy.CanWrite(u, repo, grant)
2145}
2146
2147// mrRow is one row of the merge request list: the MR plus its head's
2148// combined check state and its comment count. Errors gathering either
2149// fall back to zero values (#230) — the list must still render.
2150type mrRow struct {
2151	store.MR
2152	Check    string
2153	Comments int
2154}
2155
2156func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
2157	p, ok := s.repoFor(w, r, "")
2158	if !ok {
2159		return
2160	}
2161	p.Tab = "merge requests"
2162	canWrite := s.canWriteRepo(r, p.Repo)
2163	state := r.URL.Query().Get("state")
2164	if state == "" {
2165		state = "open"
2166	}
2167	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
2168	if !valid[state] {
2169		state = "open"
2170	}
2171	qv := r.URL.Query()
2172	mf := store.MRFilter{State: state, Label: qv.Get("label"), Author: qv.Get("author"),
2173		Milestone: qv.Get("milestone"), Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
2174	mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
2175	mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
2176	if err != nil {
2177		http.Error(w, "internal error", http.StatusInternalServerError)
2178		return
2179	}
2180	older := ""
2181	if len(mrs) > listPage {
2182		mrs = mrs[:listPage]
2183		older = olderLink(r, mrs[len(mrs)-1].Number)
2184	}
2185	shas := make([]string, len(mrs))
2186	ids := make([]int64, len(mrs))
2187	for i, m := range mrs {
2188		shas[i] = m.HeadSHA
2189		ids[i] = m.ID
2190	}
2191	checks, err := s.st.CombinedStatusFor(p.Repo.ID, shas)
2192	if err != nil {
2193		checks = map[string]string{}
2194	}
2195	comments, err := s.st.MRCommentCounts(p.Repo.ID, ids)
2196	if err != nil {
2197		comments = map[int64]int{}
2198	}
2199	labels, err := s.st.ListMRLabels(p.Repo)
2200	if err != nil {
2201		labels = map[int64][]string{}
2202	}
2203	rows := make([]mrRow, len(mrs))
2204	for i, m := range mrs {
2205		m.Labels = labels[m.ID]
2206		rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]}
2207	}
2208	base := url.Values{"state": {state}, "label": {mf.Label}, "author": {mf.Author}, "milestone": {mf.Milestone}, "q": {mf.Search}}
2209	readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
2210	allLabels, _ := s.st.ListLabels(p.Repo, readable)
2211	openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
2212	facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true)
2213	canOpenMR := canWrite || len(s.writableForks(s.viewer(r), p.Repo)) > 0
2214	s.render(w, "mrs.html", struct {
2215		repoPage
2216		State       string
2217		Query       string
2218		Filters     []listFilter
2219		Facets      []facetGroup
2220		MRs         []mrRow
2221		LabelColors map[string]template.CSS
2222		Older       string
2223		CanOpenMR   bool
2224	}{p, state, mf.Search,
2225		activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}),
2226		facets, rows, s.labelColors(p.Repo), older, canOpenMR})
2227}
2228
2229func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
2230	s.mrPage(w, r, "")
2231}
2232
2233// mrPage renders a merge request. previewForm names the form that asked
2234// to see its markup rather than save it — "edit" or "comment", "" for a
2235// plain read (#235).
2236func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm string) {
2237	p, ok := s.repoFor(w, r, "")
2238	if !ok {
2239		return
2240	}
2241	p.Tab = "merge requests"
2242	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
2243	if err != nil {
2244		s.notFound(w, r)
2245		return
2246	}
2247	m, err := s.st.MRByNumber(p.Repo.ID, n)
2248	if err != nil {
2249		s.notFound(w, r)
2250		return
2251	}
2252	comments, _ := s.st.ListMRComments(m.ID)
2253	reviews, _ := s.st.ListMRReviews(m.ID)
2254	// The same rule the merge gates apply, so the page cannot show an
2255	// approval the gate ignores (#147).
2256	reviewCounts := control.ReviewersWhoCount(s.st, p.Repo, reviews)
2257	reviewRows := make([]reviewRow, 0, len(reviews))
2258	for _, r := range reviews {
2259		reviewRows = append(reviewRows, reviewRow{MRReview: r, Counts: reviewCounts[r.Reviewer]})
2260	}
2261	checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
2262	// The viewer sees their own unsubmitted review comments and nobody
2263	// else's.
2264	diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
2265
2266	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
2267	// An admin can prune the head ref; the diff is then unavailable, not
2268	// empty, and the page must not read as the latter.
2269	_, headErr := gitutil.ResolveRef(p.Dir, headRef)
2270	headPruned := headErr != nil
2271	var files []diffFile
2272	base := m.MergedBase
2273	if base == "" {
2274		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
2275			base = b
2276		}
2277	}
2278	var diffTruncated bool
2279	if base != "" {
2280		if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
2281			files, diffTruncated = parseDiff(patch), truncated
2282		}
2283	}
2284	// The head is already reachable from the target, so the diff is empty
2285	// by construction rather than because nothing changed.
2286	headMerged := false
2287	if len(files) == 0 && m.HeadSHA != "" {
2288		if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
2289			if ok, err := gitutil.IsAncestor(p.Dir, m.HeadSHA, targetSHA); err == nil {
2290				headMerged = ok
2291			}
2292		}
2293	}
2294	md := s.ugcFor(r, p.Repo)
2295	canWrite := s.canWriteRepo(r, p.Repo)
2296	// Applying a suggestion pushes to the source branch, so the button
2297	// follows write on the source repository, which for a fork is not
2298	// the one this page is in.
2299	canApply := false
2300	if p.Viewer != "" && m.State == "open" {
2301		if src, err := s.st.RepoByID(m.SourceRepoID); err == nil {
2302			canApply = s.canWriteRepo(r, src)
2303		}
2304	}
2305	suggestions := map[int64]*suggestionView{}
2306	sgs := control.Suggestions(s.st, s.cfg.Server.Root, p.Repo, m, diffComments)
2307	for _, cm := range diffComments {
2308		if sg := sgs[cm.ID]; sg != nil {
2309			suggestions[cm.ID] = newSuggestionView(sg, canApply && !cm.Pending,
2310				fmt.Sprintf("gitbay mr apply-suggestion %s %d %d", p.Repo.Path(), m.Number, cm.ID))
2311		}
2312	}
2313	var detachedThreads []diffThread
2314	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
2315		reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite}, suggestions)
2316	if p.Viewer != "" {
2317		markCompose(files, r.URL.Query())
2318	}
2319	layout := s.diffLayoutFor(r)
2320	if layout.Split {
2321		splitFiles(files)
2322	}
2323	stat := statOf(files)
2324	// The commits this MR carries: base..head, the same range as the diff.
2325	type commitRow struct {
2326		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
2327		Sig                                                  sigView
2328	}
2329	mrNames := s.authorNames()
2330	var commits []commitRow
2331	commitsTotal := 0
2332	if base != "" {
2333		const maxMRCommits = 100
2334		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
2335		commitsTotal = len(shas)
2336		if len(shas) > maxMRCommits {
2337			shas = shas[:maxMRCommits]
2338		}
2339		for _, sha := range shas {
2340			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
2341			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
2342			if parsed != nil {
2343				cr.Subject = parsed.Subject
2344				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
2345				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
2346				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
2347			}
2348			commits = append(commits, cr)
2349		}
2350	}
2351	// The diff is the reason most people open a merge request, so it gets
2352	// its own view rather than a fold at the foot of the conversation.
2353	// A query parameter keeps this working without JavaScript.
2354	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
2355	// The revisions this merge request has had. A stale review is the
2356	// moment someone wants to know what moved, so the link to the
2357	// range-diff belongs next to it.
2358	revisions, _ := s.st.MRHeads(m.ID)
2359	branches, _ := gitutil.Refs(p.Dir, "heads")
2360	view := r.URL.Query().Get("view")
2361	if view != "commits" && view != "diff" {
2362		view = "conversation"
2363	}
2364	// Where the merge request stands against the gates, the same
2365	// computation mr merge refuses on (#199).
2366	var gates *control.GatesOut
2367	if m.State == "open" || m.State == "source_gone" {
2368		if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
2369			if g, err := control.MergeGates(s.st, p.Repo, m, p.Dir, targetSHA, m.HeadSHA); err == nil {
2370				gates = &g
2371			}
2372		}
2373	}
2374	// The stack around an open merge request, for the header.
2375	var stackedOn *store.MR
2376	var stacked []store.MR
2377	if m.State == "open" {
2378		if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
2379			stackedOn = &parent
2380		}
2381		if m.SourceRepoID == p.Repo.ID {
2382			stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
2383		}
2384	}
2385	// The merge requests this one superseded when it was closed, so the
2386	// page it points to can also say what it supersedes.
2387	supersedes, _ := s.st.MRsSuperseding(p.Repo.ID, m.Number)
2388	// An edit keeps the merge request's stored format; a comment has no
2389	// picker and is markdown, as mr comment stores with no --format.
2390	var d *draft
2391	if previewForm != "" {
2392		format := m.BodyFormat
2393		if previewForm == "comment" {
2394			format = "md"
2395		}
2396		d = s.draftFor(r, p.Repo, previewForm, "body", format)
2397	}
2398	bars := s.reactionBars(r, "mr", m.ID, comments, fmt.Sprintf("/%s/%s/mrs/%d/react", p.Repo.OwnerName, p.Repo.Name, m.Number))
2399	s.render(w, "mr.html", struct {
2400		repoPage
2401		MR              store.MR
2402		View            string
2403		BodyHTML        template.HTML
2404		Checks          []store.Check
2405		Combined        string
2406		Comments        []renderedComment
2407		Reviews         []reviewRow
2408		DiffFiles       []diffFile
2409		DiffTruncated   bool
2410		Stat            diffStat
2411		Commits         []commitRow
2412		CommitsTotal    int
2413		Branches        []gitutil.Ref
2414		CanEdit         bool
2415		CanWrite        bool
2416		Unresolved      int
2417		Revisions       []store.MRHead
2418		Notice          string
2419		DetachedThreads []diffThread
2420		StackedOn       *store.MR
2421		Stacked         []store.MR
2422		Supersedes      []store.MR
2423		Gates           *control.GatesOut
2424		SourceGone      bool
2425		HeadMerged      bool
2426		HeadPruned      bool
2427		Base            string
2428		LabelColors     map[string]template.CSS
2429		Draft           *draft
2430		Layout          diffLayout
2431		Reactions       map[int64]reactionBar
2432	}{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
2433		reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
2434		canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates,
2435		sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d, layout, bars})
2436}
2437
2438// sourceGone reports whether an MR's source branch no longer exists: the
2439// push hook marks a deleted branch on an open MR, and a merged or closed
2440// one is checked here. A fork's branch lives in another repository and
2441// is left to the recorded state.
2442func sourceGone(p repoPage, m store.MR) bool {
2443	if m.State == "source_gone" {
2444		return true
2445	}
2446	if m.SourceRepoID != p.Repo.ID {
2447		return false
2448	}
2449	_, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.SourceRef)
2450	return err != nil
2451}
2452
2453func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
2454	p, ok := s.repoFor(w, r, "")
2455	if !ok {
2456		return
2457	}
2458	p.Tab = "refs"
2459	branches, _ := gitutil.Refs(p.Dir, "heads")
2460	tags, _ := gitutil.Refs(p.Dir, "tags")
2461	gitutil.SortVersions(tags)
2462	s.render(w, "refs.html", struct {
2463		repoPage
2464		Branches, Tags []gitutil.Ref
2465	}{p, branches, tags})
2466}
2467
2468func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
2469	p, ok := s.repoFor(w, r, "")
2470	if !ok {
2471		return
2472	}
2473	file := r.PathValue("file")
2474	ref, ok := strings.CutSuffix(file, ".tar.gz")
2475	if !ok {
2476		s.notFound(w, r)
2477		return
2478	}
2479	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
2480		s.notFound(w, r)
2481		return
2482	}
2483	out, kill, finish, ok := s.packSlot(w, r)
2484	if !ok {
2485		return
2486	}
2487	defer finish()
2488	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
2489	w.Header().Set("Content-Type", "application/gzip")
2490	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
2491	gitutil.ArchiveUntil(p.Dir, ref, prefix, out, kill)
2492}
2493
2494func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
2495	return policy.CanAdmin(u, repo, grant)
2496}
2497
2498func policyCanRead(u store.User, repo store.Repo, grant string) bool {
2499	return policy.CanRead(u, repo, grant)
2500}
2501
2502// reviewRow is a review with whether the merge gates count it, which
2503// depends on the reviewer's access and so is not a property of the
2504// review row itself.
2505type reviewRow struct {
2506	store.MRReview
2507	Counts bool
2508}
2509
2510// sshCloneURL is the SSH clone URL for a repository, with the port only
2511// when it is not the default.
2512func (s *Server) sshCloneURL(repo store.Repo) string {
2513	host := s.cfg.SiteHost()
2514	if s.cfg.SSH.Port != 22 {
2515		host += ":" + strconv.Itoa(s.cfg.SSH.Port)
2516	}
2517	return "ssh://git@" + host + "/" + repo.Path() + ".git"
2518}