internal/httpd/web.go

2383 lines · 77306 bytes

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