internal/httpd/web.go

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

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