internal/httpd/web.go

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

2302 lines · 74847 bytes

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