internal/httpd/web.go

2473 lines · 81148 bytes

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