internal/httpd/web.go

9df917e73a67d15adecc3f45976690f6fcd4e47a
gitbay/internal/httpd/web.go history · blame · raw

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