internal/httpd/web.go

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