internal/httpd/web.go

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

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