internal/httpd/web.go

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