internal/httpd/web.go

4bfcb3893d5ad29d5c6ba0725fd2291d20003e30
gitbay/internal/httpd/web.go history · blame · raw

1789 lines · 55424 bytes

   1package httpd
   2
   3import (
   4	"bytes"
   5	"errors"
   6	"fmt"
   7	"hash/fnv"
   8	"io"
   9	"log"
  10	"os"
  11	"path/filepath"
  12
  13	"gitbay.org/gitbay/internal/policy"
  14	"gitbay.org/gitbay/internal/protocol"
  15	"html/template"
  16	"net/http"
  17	"net/url"
  18	"path"
  19	"regexp"
  20	"sort"
  21	"strconv"
  22	"strings"
  23	"time"
  24
  25	"github.com/alecthomas/chroma/v2/formatters/html"
  26	"github.com/alecthomas/chroma/v2/lexers"
  27	"github.com/alecthomas/chroma/v2/styles"
  28	"github.com/microcosm-cc/bluemonday"
  29	"github.com/niklasfasching/go-org/org"
  30	"github.com/yuin/goldmark"
  31	highlighting "github.com/yuin/goldmark-highlighting/v2"
  32	"github.com/yuin/goldmark/extension"
  33
  34	"gitbay.org/gitbay/internal/autolink"
  35	"gitbay.org/gitbay/internal/control"
  36	"gitbay.org/gitbay/internal/gitutil"
  37	"gitbay.org/gitbay/internal/sig"
  38	"gitbay.org/gitbay/internal/store"
  39	"gitbay.org/gitbay/internal/web"
  40)
  41
  42const maxRenderBytes = 1 << 20 // largest blob rendered inline
  43
  44func (s *Server) render(w http.ResponseWriter, page string, data any) {
  45	var buf bytes.Buffer
  46	if err := web.Render(&buf, page, data); err != nil {
  47		http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
  48		return
  49	}
  50	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  51	buf.WriteTo(w)
  52}
  53
  54// siteName is the instance's display name: the operator's [web] title,
  55// or the site host when they have not set one.
  56func (s *Server) siteName() string {
  57	if t := strings.TrimSpace(s.cfg.Web.Title); t != "" {
  58		return t
  59	}
  60	h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
  61	return strings.TrimSuffix(h, "/")
  62}
  63
  64func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
  65	w.Header().Set("Content-Type", "text/css; charset=utf-8")
  66	w.Write(web.StyleCSS)
  67	w.Write(chromaCSS)
  68}
  69
  70func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
  71	w.Header().Set("Content-Type", "image/svg+xml")
  72	w.Write(web.FaviconSVG)
  73}
  74
  75// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
  76// so the CSP's default-src 'self' covers it — no font CDN.
  77func (s *Server) font(w http.ResponseWriter, r *http.Request) {
  78	data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
  79	if err != nil {
  80		http.NotFound(w, r)
  81		return
  82	}
  83	w.Header().Set("Content-Type", "font/woff2")
  84	w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
  85	w.Write(data)
  86}
  87
  88// notFound renders the designed 404 page with a 404 status. Falls back to
  89// the stock plain-text response if the template fails.
  90func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
  91	var buf bytes.Buffer
  92	if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
  93		http.NotFound(w, r)
  94		return
  95	}
  96	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  97	w.WriteHeader(http.StatusNotFound)
  98	buf.WriteTo(w)
  99}
 100
 101// describedRepo pairs a repo with the listing metadata: description,
 102// topics, license, and last-updated date.
 103type describedRepo struct {
 104	store.Repo
 105	Desc    string
 106	Topics  []string
 107	License string
 108	Updated string
 109}
 110
 111// Archived flattens the settings flag so the reporow partial can read the
 112// same field name from a describedRepo and from a profile's repo row.
 113func (d describedRepo) Archived() bool { return d.Settings.Archived }
 114
 115func (s *Server) describeAll(repos []store.Repo) []describedRepo {
 116	var out []describedRepo
 117	for _, r := range repos {
 118		dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
 119		d := describedRepo{
 120			Repo:    r,
 121			Desc:    gitutil.ReadDescription(dir),
 122			License: control.DetectLicense(dir, r.DefaultBranch),
 123			Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
 124		}
 125		d.Topics, _ = s.st.ListTopics(r.ID)
 126		out = append(out, d)
 127	}
 128	return out
 129}
 130
 131// index is the homepage: a dashboard for logged-in users, a landing page
 132// for everyone else. The full public listing lives at /explore.
 133func (s *Server) index(w http.ResponseWriter, r *http.Request) {
 134	if s.cfg.Web.Mode == "accounts" {
 135		if viewer := s.viewer(r); viewer.ID != 0 {
 136			s.dashboard(w, r, viewer)
 137			return
 138		}
 139	}
 140	host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
 141		s.cfg.Server.SiteURL, "https://"), "http://"), "/")
 142	s.render(w, "landing.html", struct {
 143		basePage
 144		Host     string
 145		Accounts bool
 146		Signup   bool
 147	}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
 148		s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
 149}
 150
 151func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
 152	pinned, _ := s.st.PinnedRepos(viewer.ID)
 153	var visible []store.Repo
 154	for _, rp := range pinned {
 155		grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
 156		if policy.CanRead(viewer, rp, grant) {
 157			visible = append(visible, rp)
 158		}
 159	}
 160	mrs, _ := s.st.DashboardMRs(viewer.ID)
 161	issues, _ := s.st.DashboardIssues(viewer.ID)
 162	reviews, _ := s.st.ReviewQueue(viewer.ID)
 163	assigned, _ := s.st.AssignedIssues(viewer.ID)
 164	events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
 165	s.render(w, "dashboard.html", struct {
 166		basePage
 167		Pinned   []store.Repo
 168		Reviews  []store.DashboardItem
 169		Assigned []store.DashboardItem
 170		MRs      []store.DashboardItem
 171		Issues   []store.DashboardItem
 172		Feed     []feedLine
 173	}{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)})
 174}
 175
 176func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
 177	repos, err := s.st.ListPublicRepos()
 178	if err != nil {
 179		http.Error(w, "internal error", http.StatusInternalServerError)
 180		return
 181	}
 182	var viewer store.User
 183	if s.cfg.Web.Mode == "accounts" {
 184		viewer = s.viewer(r)
 185	}
 186	q := strings.TrimSpace(r.URL.Query().Get("q"))
 187	s.render(w, "explore.html", struct {
 188		basePage
 189		Query string
 190		Repos []describedRepo
 191	}{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
 192}
 193
 194// privacy renders the privacy page: what the gitbay software does with
 195// data, plus this instance's operator-provided notes.
 196func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
 197	s.render(w, "privacy.html", struct {
 198		basePage
 199		Host   string
 200		Notice string
 201	}{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
 202}
 203
 204// filterRepos keeps repos whose path, description, or topics contain the
 205// query, case-insensitively. An empty query keeps everything.
 206func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
 207	if q == "" {
 208		return repos
 209	}
 210	q = strings.ToLower(q)
 211	var out []describedRepo
 212	for _, d := range repos {
 213		if strings.Contains(strings.ToLower(d.Path()), q) ||
 214			strings.Contains(strings.ToLower(d.Desc), q) {
 215			out = append(out, d)
 216			continue
 217		}
 218		for _, t := range d.Topics {
 219			if strings.Contains(t, q) {
 220				out = append(out, d)
 221				break
 222			}
 223		}
 224	}
 225	return out
 226}
 227
 228// repoPage is the shared context for repo-scoped pages.
 229type repoPage struct {
 230	basePage
 231	Desc     string
 232	Repo     store.Repo
 233	Ref      string
 234	CloneURL string
 235	Dir      string
 236	Tab      string // active tab in the repo header
 237	Topics   []string
 238	Pinned   bool // by the viewer
 239	HasWiki  bool
 240	Host     string
 241	Mirrors  []mirrorLine // repo admins only
 242	CanAdmin bool         // gates the settings tab
 243	// OpenIssues and OpenMRs are the counts on the header tabs.
 244	OpenIssues int
 245	OpenMRs    int
 246	// RepoHome asks the layout for the full header — description, topics,
 247	// website, mirrors. Every other page gets identity and tabs only, so a
 248	// repo describes itself once rather than on all twelve of its pages.
 249	RepoHome bool
 250}
 251
 252// mirrorLine is the admin-only mirror status shown in the repo header.
 253// It carries no credentials: the stored URL is credential-free.
 254type mirrorLine struct {
 255	Direction string
 256	URL       string
 257	Target    string // URL without the scheme, for display
 258	Synced    string
 259	Error     string
 260}
 261
 262// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
 263// readable "2026-08-25 03:39 UTC".
 264func syncedAt(ts string) string {
 265	if len(ts) < 16 {
 266		return ts
 267	}
 268	return ts[:10] + " " + ts[11:16] + " UTC"
 269}
 270
 271// repoFor resolves the repo for a web request; false means 404 was sent.
 272// Anonymous visitors see public repos only; in accounts mode a logged-in
 273// viewer additionally sees repos their grants allow. Private and missing
 274// repos are indistinguishable either way.
 275func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
 276	var repo store.Repo
 277	var viewer store.User
 278	if s.cfg.Web.Mode == "accounts" {
 279		viewer = s.viewer(r)
 280	}
 281	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
 282	ok := err == nil
 283	grant := ""
 284	if ok {
 285		if viewer.ID != 0 {
 286			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 287		}
 288		ok = policyCanRead(viewer, repo, grant)
 289	}
 290	if !ok {
 291		s.notFound(w, r)
 292		return repoPage{}, false
 293	}
 294	if ref == "" {
 295		ref = repo.DefaultBranch
 296	}
 297	topics, _ := s.st.ListTopics(repo.ID)
 298	pinned := false
 299	if viewer.ID != 0 {
 300		pinned = s.st.IsPinned(viewer.ID, repo.ID)
 301	}
 302	canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
 303	var mirrors []mirrorLine
 304	if canAdmin {
 305		ms, _ := s.st.ListMirrors(repo.ID)
 306		for _, m := range ms {
 307			mirrors = append(mirrors, mirrorLine{
 308				Direction: m.Direction,
 309				URL:       m.URL,
 310				Target:    strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
 311				Synced:    syncedAt(m.LastSync),
 312				Error:     m.LastError,
 313			})
 314		}
 315	}
 316	openIssues, openMRs := s.st.OpenCounts(repo.ID)
 317	return repoPage{
 318		basePage:   s.baseFor(viewer),
 319		CanAdmin:   canAdmin,
 320		Mirrors:    mirrors,
 321		Pinned:     pinned,
 322		HasWiki:    s.wikiDir(repo.OwnerName, repo.Name) != "",
 323		Host:       s.cfg.SiteHost(),
 324		Desc:       gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
 325		Repo:       repo,
 326		Ref:        ref,
 327		CloneURL:   s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
 328		Dir:        control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
 329		Topics:     topics,
 330		OpenIssues: openIssues,
 331		OpenMRs:    openMRs,
 332	}, true
 333}
 334
 335type crumb struct {
 336	Name string
 337	URL  string
 338}
 339
 340// crumbs builds one crumb per path component. Every component but the
 341// last is a directory and links to the tree; only the leaf is a page of
 342// the given kind.
 343func crumbs(p repoPage, kind, filePath string) []crumb {
 344	var cs []crumb
 345	parts := strings.Split(strings.Trim(filePath, "/"), "/")
 346	acc := ""
 347	for i, part := range parts {
 348		if part == "" {
 349			continue
 350		}
 351		acc = path.Join(acc, part)
 352		k := "tree"
 353		if i == len(parts)-1 {
 354			k = kind
 355		}
 356		cs = append(cs, crumb{Name: part, URL: "/" + p.Repo.Path() + "/" + k + "/" + p.Ref + "/" + acc})
 357	}
 358	return cs
 359}
 360
 361// profileView is profile show's payload, shaped for the templates. The
 362// repo rows carry the same names the reporow partial reads, so a profile
 363// listing renders identically to explore's.
 364type profileView struct {
 365	Name        string              `json:"name"`
 366	Kind        string              `json:"kind"`
 367	Description string              `json:"description"`
 368	Website     string              `json:"website"`
 369	About       string              `json:"about"`
 370	AboutFormat string              `json:"about_format"`
 371	Links       []store.ProfileLink `json:"links"`
 372	Orgs        []profileMember     `json:"orgs"`
 373	Members     []profileMember     `json:"members"`
 374	Repos       []profileRepoRow    `json:"repos"`
 375	Activity    []struct {
 376		Date  string `json:"date"`
 377		Count int    `json:"count"`
 378	} `json:"activity"`
 379}
 380
 381type profileMember struct {
 382	Name string `json:"name"`
 383	Role string `json:"role"`
 384}
 385
 386// profileRepoRow is one repository row on a profile. Path arrives as
 387// owner/name; OwnerName and Name are split out for the partial.
 388type profileRepoRow struct {
 389	Path          string   `json:"path"`
 390	Visibility    string   `json:"visibility"`
 391	Desc          string   `json:"description"`
 392	DefaultBranch string   `json:"default_branch"`
 393	Topics        []string `json:"topics"`
 394	License       string   `json:"license"`
 395	Updated       string   `json:"updated"`
 396	Archived      bool     `json:"archived"`
 397}
 398
 399func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
 400func (p profileRepoRow) Name() string      { _, name, _ := strings.Cut(p.Path, "/"); return name }
 401
 402// ownerPage renders /{owner} for users and orgs: the repositories the
 403// viewer may see, org membership either direction. Owner names are not
 404// secret (they are on every commit); repository visibility rules hold.
 405func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
 406	name := r.PathValue("owner")
 407	var viewer store.User
 408	if s.cfg.Web.Mode == "accounts" {
 409		viewer = s.viewer(r)
 410	}
 411
 412	// Everything on this page — membership, the repositories this viewer
 413	// may see, the activity year — comes from profile show, so the page
 414	// and the command cannot report different things.
 415	var d profileView
 416	code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d)
 417	switch {
 418	case code == protocol.ExitNotFound:
 419		s.notFound(w, r)
 420		return
 421	case code != protocol.ExitOK:
 422		log.Printf("profile %s: %s", name, msg)
 423		http.Error(w, "internal error", http.StatusInternalServerError)
 424		return
 425	}
 426
 427	counts := make(map[string]int, len(d.Activity))
 428	for _, day := range d.Activity {
 429		counts[day.Date] = day.Count
 430	}
 431	weeks, activityTotal := activityGrid(counts)
 432
 433	teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
 434	profile := store.Profile{Description: d.Description, Website: d.Website,
 435		About: d.About, AboutFormat: d.AboutFormat, Links: d.Links}
 436	s.render(w, "owner.html", struct {
 437		basePage
 438		Owner         string
 439		Kind          string
 440		Profile       store.Profile
 441		AboutHTML     template.HTML
 442		Repos         []profileRepoRow
 443		Members       []profileMember
 444		Orgs          []profileMember
 445		Activity      []activityWeek
 446		ActivityTotal int
 447		Teams         []teamView
 448		CanAdmin      bool
 449		Notice        string
 450	}{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
 451		d.Repos, d.Members, d.Orgs,
 452		weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")})
 453}
 454
 455func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
 456	p, ok := s.repoFor(w, r, "")
 457	if !ok {
 458		return
 459	}
 460	p.Tab = "files"
 461	p.RepoHome = true
 462	s.renderTree(w, r, p, "")
 463}
 464
 465func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
 466	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 467	if !ok {
 468		return
 469	}
 470	p.Tab = "files"
 471	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
 472}
 473
 474// treePage is shared by the populated and empty-repository renders: two
 475// anonymous structs drifted apart once already.
 476type treePage struct {
 477	repoPage
 478	Crumbs      []crumb
 479	Prefix      string
 480	DirPath     string
 481	RefKind     string
 482	Entries     []gitutil.TreeEntry
 483	Branches    []gitutil.Ref
 484	ReadmeName  string
 485	ReadmeHTML  template.HTML
 486	LastCommits map[string]namedCommit
 487	Tip         namedCommit
 488	Facts       repoFacts
 489}
 490
 491func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
 492	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
 493		// Empty repo: render the page with no entries rather than 404.
 494		s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"})
 495		return
 496	}
 497	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
 498	if err != nil {
 499		s.notFound(w, r)
 500		return
 501	}
 502	// Directories first. git's tree order interleaves them with files, but
 503	// a listing is scanned by shape before name. Stable, so each group
 504	// keeps the ordering git gave it.
 505	sort.SliceStable(entries, func(i, j int) bool {
 506		return entries[i].Type == "tree" && entries[j].Type != "tree"
 507	})
 508	prefix := ""
 509	if dirPath != "" {
 510		prefix = dirPath + "/"
 511	}
 512
 513	var readmeHTML template.HTML
 514	readmeName := pickReadme(entries)
 515	if readmeName != "" {
 516		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
 517			readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
 518		}
 519	}
 520
 521	branches, _ := gitutil.Refs(p.Dir, "heads")
 522	names := make([]string, 0, len(entries))
 523	for _, e := range entries {
 524		names = append(names, e.Name)
 525	}
 526	// The facts bar is about the repository, not this directory, so it is
 527	// computed once at the root and left off subdirectory listings.
 528	var facts repoFacts
 529	if dirPath == "" {
 530		facts = s.factsFor(p)
 531	}
 532	s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
 533		readmeName, readmeHTML,
 534		s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
 535		s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts})
 536}
 537
 538func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
 539	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 540	if !ok {
 541		return
 542	}
 543	p.Tab = "files"
 544	filePath := strings.Trim(r.PathValue("path"), "/")
 545	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
 546	if err != nil {
 547		s.notFound(w, r)
 548		return
 549	}
 550	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
 551	_, image := imageTypes[strings.ToLower(path.Ext(filePath))]
 552
 553	var codeHTML template.HTML
 554	if !binary && !image {
 555		codeHTML = highlight(filePath, data)
 556	}
 557	// Markdown and org render like a README, with the source one click
 558	// away; ?view=source shows the text instead.
 559	renderable := false
 560	switch path.Ext(strings.ToLower(filePath)) {
 561	case ".md", ".markdown", ".org":
 562		renderable = !binary
 563	}
 564	var renderedHTML template.HTML
 565	rendered := renderable && r.URL.Query().Get("view") != "source"
 566	if rendered {
 567		renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
 568	}
 569	cs := crumbs(p, "blob", filePath)
 570	base := ""
 571	if len(cs) > 0 {
 572		base = cs[len(cs)-1].Name
 573		cs = cs[:len(cs)-1]
 574	}
 575	branches, _ := gitutil.Refs(p.Dir, "heads")
 576	lines := 0
 577	if !binary && !image && len(data) > 0 {
 578		lines = bytes.Count(data, []byte("\n"))
 579		if data[len(data)-1] != '\n' {
 580			lines++
 581		}
 582	}
 583	// The file listing leads with the last commit now, so the facts about
 584	// the file itself are reported here instead.
 585	entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
 586	s.render(w, "blob.html", struct {
 587		repoPage
 588		Crumbs       []crumb
 589		Base         string
 590		Path         string
 591		DirPath      string
 592		RefKind      string
 593		Binary       bool
 594		Image        bool
 595		Size         int
 596		Lines        int
 597		Exec         bool
 598		Symlink      bool
 599		Branches     []gitutil.Ref
 600		CodeHTML     template.HTML
 601		Renderable   bool // markdown or org: the toggle is offered
 602		Rendered     bool // this response shows the rendering
 603		RenderedHTML template.HTML
 604	}{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
 605		entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
 606}
 607
 608// releases lists tag-anchored releases with notes and assets.
 609func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 610	p, ok := s.repoFor(w, r, "")
 611	if !ok {
 612		return
 613	}
 614	p.Tab = "releases"
 615	rels, err := s.st.ListReleases(p.Repo.ID)
 616	if err != nil {
 617		http.Error(w, "internal error", http.StatusInternalServerError)
 618		return
 619	}
 620	md := s.ugcFor(r, p.Repo)
 621	type relView struct {
 622		store.Release
 623		NotesHTML template.HTML
 624	}
 625	var views []relView
 626	for _, rel := range rels {
 627		views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
 628	}
 629	// Tags without a release yet are what a create form can offer.
 630	released := map[string]bool{}
 631	for _, rel := range rels {
 632		released[rel.Tag] = true
 633	}
 634	var freeTags []string
 635	if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
 636		for _, tg := range tags {
 637			if !released[tg.Name] {
 638				freeTags = append(freeTags, tg.Name)
 639			}
 640		}
 641	}
 642	s.render(w, "releases.html", struct {
 643		repoPage
 644		Releases []relView
 645		FreeTags []string
 646		CanWrite bool
 647		Notice   string
 648	}{p, views, freeTags, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")})
 649}
 650
 651// releaseAsset streams one uploaded asset. Tags containing '/' are not
 652// reachable here (single path segment); SSH download always works.
 653func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 654	p, ok := s.repoFor(w, r, "")
 655	if !ok {
 656		return
 657	}
 658	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 659	if err != nil {
 660		s.notFound(w, r)
 661		return
 662	}
 663	name := r.PathValue("name")
 664	found := false
 665	for _, a := range rel.Assets {
 666		if a.Name == name {
 667			found = true
 668		}
 669	}
 670	if !found {
 671		s.notFound(w, r)
 672		return
 673	}
 674	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 675		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 676	if err != nil {
 677		s.notFound(w, r)
 678		return
 679	}
 680	defer f.Close()
 681	w.Header().Set("Content-Type", "application/octet-stream")
 682	w.Header().Set("X-Content-Type-Options", "nosniff")
 683	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 684	if fi, err := f.Stat(); err == nil {
 685		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 686	}
 687	io.Copy(w, f)
 688}
 689
 690// milestones lists a repo's milestones with progress.
 691func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 692	p, ok := s.repoFor(w, r, "")
 693	if !ok {
 694		return
 695	}
 696	p.Tab = "issues"
 697	state := r.URL.Query().Get("state")
 698	if state != "closed" && state != "all" {
 699		state = "open"
 700	}
 701	ms, err := s.st.ListMilestones(p.Repo.ID, state)
 702	if err != nil {
 703		http.Error(w, "internal error", http.StatusInternalServerError)
 704		return
 705	}
 706	type msView struct {
 707		store.Milestone
 708		Percent int
 709	}
 710	var views []msView
 711	for _, m := range ms {
 712		v := msView{Milestone: m}
 713		if total := m.OpenItems + m.ClosedItems; total > 0 {
 714			v.Percent = m.ClosedItems * 100 / total
 715		}
 716		views = append(views, v)
 717	}
 718	s.render(w, "milestones.html", struct {
 719		repoPage
 720		State      string
 721		Milestones []msView
 722	}{p, state, views})
 723}
 724
 725// search runs a bounded literal git grep over the repo's default branch.
 726func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 727	p, ok := s.repoFor(w, r, "")
 728	if !ok {
 729		return
 730	}
 731	p.Tab = "search"
 732	q := strings.TrimSpace(r.URL.Query().Get("q"))
 733	type matchView struct {
 734		Path     string
 735		Line     int
 736		TextHTML template.HTML
 737	}
 738	var matches []matchView
 739	var queryErr string
 740	if q != "" {
 741		if len(q) < 2 || len(q) > 200 {
 742			queryErr = "query must be 2 to 200 characters"
 743		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 744			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 745			if err != nil {
 746				http.Error(w, "internal error", http.StatusInternalServerError)
 747				return
 748			}
 749			for _, m := range raw {
 750				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 751			}
 752		}
 753	}
 754	s.render(w, "search.html", struct {
 755		repoPage
 756		Query    string
 757		QueryErr string
 758		Matches  []matchView
 759		Capped   bool
 760	}{p, q, queryErr, matches, len(matches) == 200})
 761}
 762
 763// markMatch escapes a matched line and wraps case-insensitive occurrences
 764// of the query in <mark>.
 765func markMatch(text, q string) template.HTML {
 766	lower, lq := strings.ToLower(text), strings.ToLower(q)
 767	var b strings.Builder
 768	pos := 0
 769	for {
 770		i := strings.Index(lower[pos:], lq)
 771		if i < 0 {
 772			break
 773		}
 774		i += pos
 775		b.WriteString(template.HTMLEscapeString(text[pos:i]))
 776		b.WriteString("<mark>")
 777		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
 778		b.WriteString("</mark>")
 779		pos = i + len(q)
 780	}
 781	b.WriteString(template.HTMLEscapeString(text[pos:]))
 782	return template.HTML(b.String())
 783}
 784
 785func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 786	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 787	if !ok {
 788		return
 789	}
 790	p.Tab = "files"
 791	filePath := strings.Trim(r.PathValue("path"), "/")
 792
 793	// Blame is a control command; the web renders what it returns rather
 794	// than shelling out to git itself, so all three surfaces agree.
 795	page := 1
 796	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
 797		page = n
 798	}
 799	from := (page-1)*control.BlameSpan + 1
 800
 801	var out struct {
 802		From       int `json:"from"`
 803		To         int `json:"to"`
 804		TotalLines int `json:"total_lines"`
 805		Hunks      []struct {
 806			SHA         string   `json:"sha"`
 807			AuthorName  string   `json:"author_name"`
 808			AuthorEmail string   `json:"author_email"`
 809			Date        string   `json:"date"`
 810			Summary     string   `json:"summary"`
 811			StartLine   int      `json:"start_line"`
 812			Lines       []string `json:"lines"`
 813		} `json:"hunks"`
 814	}
 815	argv := []string{"repo", "blame", p.Repo.Path(), filePath,
 816		"--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
 817	var viewer store.User
 818	if s.cfg.Web.Mode == "accounts" {
 819		viewer = s.viewer(r)
 820	}
 821	msg, ok := s.runControlInto(viewer, argv, &out)
 822
 823	// A binary or empty file is a refusal, not a 404: the page still
 824	// renders and says why there is nothing to attribute.
 825	binary := false
 826	if !ok {
 827		if strings.Contains(msg, "is binary") {
 828			binary = true
 829		} else {
 830			s.notFound(w, r)
 831			return
 832		}
 833	}
 834
 835	type hunkView struct {
 836		gitutil.BlameHunk
 837		ShortSHA string
 838		Date     string
 839		Sig      sigView
 840		Numbered []numberedLine
 841	}
 842	var hunks []hunkView
 843	sigs := map[string]sigView{}
 844	for _, h := range out.Hunks {
 845		v, seen := sigs[h.SHA]
 846		if !seen {
 847			v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 848			sigs[h.SHA] = v
 849		}
 850		date := h.Date
 851		if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
 852			date = t.Format("2006-01-02")
 853		}
 854		hv := hunkView{
 855			BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
 856				AuthorEmail: h.AuthorEmail, Summary: h.Summary,
 857				StartLine: h.StartLine, Lines: h.Lines},
 858			ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
 859		}
 860		for i, l := range h.Lines {
 861			hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 862		}
 863		hunks = append(hunks, hv)
 864	}
 865
 866	pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
 867	if pages == 0 {
 868		pages = 1
 869	}
 870	if page > pages {
 871		page = pages
 872	}
 873
 874	cs := crumbs(p, "blame", filePath)
 875	base := ""
 876	if len(cs) > 0 {
 877		base = cs[len(cs)-1].Name
 878		cs = cs[:len(cs)-1]
 879	}
 880	s.render(w, "blame.html", struct {
 881		repoPage
 882		Crumbs      []crumb
 883		Base        string
 884		Path        string
 885		Binary      bool
 886		Hunks       []hunkView
 887		Page, Pages int
 888	}{p, cs, base, filePath, binary, hunks, page, pages})
 889}
 890
 891type numberedLine struct {
 892	N    int
 893	Text string
 894}
 895
 896// chromaFormatter emits class-based markup (no inline colors), so the
 897// stylesheet can swap palettes with the color scheme.
 898var chromaFormatter = html.New(html.WithClasses(true),
 899	html.WithLineNumbers(true), html.LineNumbersInTable(false),
 900	html.WithLinkableLineNumbers(true, "L"))
 901
 902func highlight(filePath string, data []byte) template.HTML {
 903	lexer := lexers.Match(filePath)
 904	if lexer == nil {
 905		lexer = lexers.Fallback
 906	}
 907	iterator, err := lexer.Tokenise(nil, string(data))
 908	if err != nil {
 909		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 910	}
 911	var buf bytes.Buffer
 912	if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
 913		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 914	}
 915	return template.HTML(buf.String())
 916}
 917
 918// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
 919// The light one cannot be left unscoped: the two palettes do not name the
 920// same token set, and every token github-dark omits would keep its
 921// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
 922// Scoped, an unnamed token inherits the wrapper's colour instead, which is
 923// readable in both. The site's --code-bg stays the background either way.
 924// lightStyle and darkStyle are chosen on measured contrast against the
 925// grounds code actually sits on here — page, code block, and the diff
 926// tints. friendly, the chroma default, put 61 token/ground pairs under
 927// 4.5:1; xcode puts one.
 928const (
 929	lightStyle = "xcode"
 930	darkStyle  = "github-dark"
 931)
 932
 933var chromaCSS = func() []byte {
 934	var buf bytes.Buffer
 935	buf.WriteString("@media (prefers-color-scheme: light) {\n")
 936	chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
 937	// xcode's NameAttribute is its one token under 4.5:1 against the diff
 938	// tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
 939	buf.WriteString(".chroma .na { color: #6f5a21 }\n")
 940	buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
 941	chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
 942	buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
 943	// Line numbers take the site's own gutter colour in both schemes. Left
 944	// alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
 945	// chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
 946	// latter is a formatter fallback, not a style entry, so no palette test
 947	// can see it.
 948	buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
 949	return buf.Bytes()
 950}()
 951
 952func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 953	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 954	if !ok {
 955		return
 956	}
 957	filePath := strings.Trim(r.PathValue("path"), "/")
 958	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 959	if err != nil {
 960		s.notFound(w, r)
 961		return
 962	}
 963	// Serve inert: never let repo content execute in the forge's origin.
 964	// Images get their real type so <img> works under nosniff; SVG script
 965	// is dead on arrival because the instance CSP is script-src 'none'.
 966	ct := "text/plain; charset=utf-8"
 967	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
 968		ct = t
 969	}
 970	w.Header().Set("Content-Type", ct)
 971	w.Header().Set("X-Content-Type-Options", "nosniff")
 972	w.Write(data)
 973}
 974
 975// imageTypes are the formats raw serves with a real content type and blob
 976// pages preview inline.
 977var imageTypes = map[string]string{
 978	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
 979	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
 980	".svg": "image/svg+xml", ".ico": "image/x-icon",
 981}
 982
 983// readmeRank orders competing README files: richer renderers win.
 984var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 985
 986// pickReadme returns the best README-ish blob in a tree listing: any file
 987// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 988// we can render richly.
 989func pickReadme(entries []gitutil.TreeEntry) string {
 990	best, bestRank := "", 1<<30
 991	for _, e := range entries {
 992		if e.Type != "blob" {
 993			continue
 994		}
 995		lower := strings.ToLower(e.Name)
 996		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 997			continue
 998		}
 999		rank, ok := readmeRank[path.Ext(lower)]
1000		if !ok {
1001			rank = 10 // plaintext fallback
1002		}
1003		if rank < bestRank {
1004			best, bestRank = e.Name, rank
1005		}
1006	}
1007	return best
1008}
1009
1010// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1011// task lists) on top of CommonMark, with class-based fence highlighting
1012// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1013// dropped.
1014var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
1015	highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1016
1017// fenceHighlight renders one code block with chroma classes, for org and
1018// anything else outside goldmark. Unknown languages fall back to plain.
1019func fenceHighlight(source, lang string) string {
1020	lexer := lexers.Get(lang)
1021	if lexer == nil {
1022		lexer = lexers.Fallback
1023	}
1024	iterator, err := lexer.Tokenise(nil, source)
1025	if err != nil {
1026		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1027	}
1028	var buf bytes.Buffer
1029	f := html.New(html.WithClasses(true))
1030	if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1031		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1032	}
1033	return buf.String()
1034}
1035
1036// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1037// goldmark's default renderer drops raw HTML, so this is safe as-is.
1038func mdHTML(raw string) template.HTML {
1039	if strings.TrimSpace(raw) == "" {
1040		return ""
1041	}
1042	var buf bytes.Buffer
1043	if markdown.Convert([]byte(raw), &buf) != nil {
1044		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1045	}
1046	return template.HTML(buf.String())
1047}
1048
1049// aboutHTML renders a profile's about text. It has no filename to
1050// dispatch on, so the stored format picks the extension; anything other
1051// than org is markdown.
1052func aboutHTML(p store.Profile) template.HTML {
1053	if strings.TrimSpace(p.About) == "" {
1054		return ""
1055	}
1056	name := "about.md"
1057	if p.AboutFormat == "org" {
1058		name = "about.org"
1059	}
1060	return renderReadme(name, []byte(p.About))
1061}
1062
1063// webResolver answers autolink lookups for one viewer. Cross-repo
1064// references to repositories the viewer cannot read stay plain text, per
1065// the enumeration rule: a link would confirm the repo exists.
1066type webResolver struct {
1067	s      *Server
1068	viewer store.User
1069}
1070
1071func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1072	repo, err := r.s.st.RepoByPath(owner + "/" + name)
1073	if err != nil {
1074		return ""
1075	}
1076	grant := ""
1077	if r.viewer.ID != 0 {
1078		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1079	}
1080	if !policy.CanRead(r.viewer, repo, grant) {
1081		return ""
1082	}
1083	if kind == '#' {
1084		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1085			return ""
1086		}
1087		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1088	}
1089	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1090		return ""
1091	}
1092	return autolink.MRURL(repo.OwnerName, repo.Name, n)
1093}
1094
1095func (r webResolver) UserURL(name string) string {
1096	if _, err := r.s.st.UserByUsername(name); err == nil {
1097		return "/" + name
1098	}
1099	if _, err := r.s.st.OrgByName(name); err == nil {
1100		return "/" + name
1101	}
1102	return ""
1103}
1104
1105// ugcRenderer renders one user-authored body in the format it was written in.
1106// The format travels with the body: it is recorded when the text is written, so
1107// changing a preference later cannot re-interpret prose that already exists.
1108type ugcRenderer func(raw, format string) template.HTML
1109
1110// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1111// so a body stored before formats existed — and any row whose column defaulted —
1112// renders exactly as it did before.
1113//
1114// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1115// about text take, so it inherits that function's include guard and sanitising
1116// rather than growing a second org renderer to keep in step.
1117func ugcHTML(raw, format string) template.HTML {
1118	if format == "org" {
1119		return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1120			return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1121		})
1122	}
1123	return mdHTML(raw)
1124}
1125
1126// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1127// ugcHTML plus cross-reference and mention autolinking for this viewer.
1128func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1129	viewer := store.User{}
1130	if s.cfg.Web.Mode == "accounts" {
1131		viewer = s.viewer(r)
1132	}
1133	res := webResolver{s, viewer}
1134	return func(raw, format string) template.HTML {
1135		h := ugcHTML(raw, format)
1136		if h == "" {
1137			return h
1138		}
1139		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1140	}
1141}
1142
1143// renderedComment pairs a comment with its rendered body for templates.
1144type renderedComment struct {
1145	Author    string
1146	CreatedAt string
1147	Kind      string
1148	BodyHTML  template.HTML
1149}
1150
1151func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1152	var out []renderedComment
1153	for _, c := range cs {
1154		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1155	}
1156	return out
1157}
1158
1159// ugcPolicy sanitizes rendered repo content before it enters the forge's
1160// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1161// output and repo-authored HTML are not. Chroma's highlighting classes
1162// must survive; the pattern admits only short token codes, not the site's
1163// own class names.
1164var ugcPolicy = func() *bluemonday.Policy {
1165	p := bluemonday.UGCPolicy()
1166	p.AllowAttrs("class").
1167		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1168		OnElements("span", "pre", "code", "div")
1169	return p
1170}()
1171
1172// renderReadme renders a README by extension: markdown, org-mode, and
1173// (sanitized) HTML richly; everything else as escaped plaintext.
1174// orgConfig is the go-org configuration for rendering untrusted org.
1175//
1176// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1177// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1178// wiki page, a profile — so both keywords are refused outright: the file is
1179// never opened and the keyword stays the inert text it is. There is no safe
1180// subset to allow instead. An absolute path skips go-org's relative-path join,
1181// a relative one resolves against the daemon's working directory, and a repo
1182// has no directory to scope to anyway because the content came from a git
1183// object rather than a checkout.
1184//
1185// The default logger writes parse warnings to stderr, which would let pushed
1186// content write to the server's log; discard them.
1187func orgConfig() *org.Configuration {
1188	c := org.New()
1189	c.ReadFile = func(string) ([]byte, error) {
1190		return nil, errOrgIncludeDisabled
1191	}
1192	c.Log = log.New(io.Discard, "", 0)
1193	return c
1194}
1195
1196var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1197
1198// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1199// of contents: a README or wiki page is a document and carries one, an issue
1200// comment is a remark and should not sprout one above two headings. `fallback`
1201// supplies the plaintext rendering used when the writer fails.
1202func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1203	c := orgConfig()
1204	if !contents {
1205		// DefaultSettings is a fresh map per org.New(), so this is local.
1206		c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1207	}
1208	doc := c.Parse(bytes.NewReader(raw), name)
1209	writer := org.NewHTMLWriter()
1210	writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1211		if inline {
1212			return "<code>" + template.HTMLEscapeString(source) + "</code>"
1213		}
1214		return fenceHighlight(source, lang)
1215	}
1216	out, err := doc.Write(writer)
1217	if err != nil {
1218		return fallback()
1219	}
1220	return template.HTML(ugcPolicy.Sanitize(out))
1221}
1222
1223func renderReadme(name string, raw []byte) template.HTML {
1224	plain := func() template.HTML {
1225		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1226	}
1227	if gitutil.IsBinary(raw) {
1228		return ""
1229	}
1230	switch path.Ext(strings.ToLower(name)) {
1231	case ".md", ".markdown":
1232		var buf bytes.Buffer
1233		if markdown.Convert(raw, &buf) != nil {
1234			return plain()
1235		}
1236		return template.HTML(buf.String())
1237	case ".org":
1238		return renderOrg(name, raw, true, plain)
1239	case ".html", ".htm":
1240		return template.HTML(ugcPolicy.Sanitize(string(raw)))
1241	default:
1242		return plain()
1243	}
1244}
1245
1246type diffThread struct {
1247	ID         int64
1248	Resolved   string
1249	Stale      bool
1250	CanResolve bool
1251	Comments   []renderedComment
1252}
1253
1254// reviewRights decides which thread controls a viewer sees. mr resolve
1255// admits the thread author, the MR author, or anyone with write, so the
1256// page needs all three to render the button truthfully.
1257type reviewRights struct {
1258	Viewer   string
1259	MRAuthor string
1260	Write    bool
1261}
1262
1263func (r reviewRights) canResolve(threadAuthor string) bool {
1264	return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1265}
1266
1267// attachThreads injects review threads under their anchored diff lines;
1268// threads whose anchor no longer appears (stale after force-push, or on a
1269// context line outside the current diff) are returned separately.
1270func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1271	type anchor struct {
1272		path string
1273		side string
1274		line int64
1275	}
1276	// Diff-line comments have no stored format yet, so they stay markdown.
1277	// They are the one user-authored body left without the choice; see #51.
1278	threads := map[int64]*diffThread{}
1279	anchors := map[int64]anchor{}
1280	var order []int64
1281	for _, cm := range comments {
1282		if cm.ReplyTo == 0 {
1283			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1284				CanResolve: rights.canResolve(cm.Author),
1285				Comments:   []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1286			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1287			order = append(order, cm.ID)
1288		} else if th, ok := threads[cm.ReplyTo]; ok {
1289			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1290		}
1291	}
1292	placed := map[int64]bool{}
1293	for f := range files {
1294		lines := files[f].Lines
1295		for i := range lines {
1296			for _, id := range order {
1297				if placed[id] || threads[id].Stale {
1298					continue
1299				}
1300				a := anchors[id]
1301				if lines[i].Path != a.path {
1302					continue
1303				}
1304				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1305					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1306					lines[i].Threads = append(lines[i].Threads, *threads[id])
1307					files[f].Threads++
1308					files[f].Open = true
1309					placed[id] = true
1310				}
1311			}
1312		}
1313	}
1314	var unplaced []diffThread
1315	for _, id := range order {
1316		if !placed[id] {
1317			unplaced = append(unplaced, *threads[id])
1318		}
1319	}
1320	return files, unplaced
1321}
1322
1323// markCompose opens the new-thread form under one diff line. There is no
1324// JavaScript, so "comment on this line" is a plain GET carrying the
1325// anchor and the page renders the form where the reader asked for it.
1326func markCompose(files []diffFile, q url.Values) {
1327	path := q.Get("cpath")
1328	line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1329	if path == "" || line < 1 {
1330		return
1331	}
1332	old := q.Get("cside") == "old"
1333	for f := range files {
1334		for i := range files[f].Lines {
1335			ln := &files[f].Lines[i]
1336			if ln.Path != path {
1337				continue
1338			}
1339			if (old && ln.Class == "del" && ln.OldLine == line) ||
1340				(!old && ln.Class != "del" && ln.NewLine == line) {
1341				ln.Compose = true
1342				files[f].Open = true
1343				return
1344			}
1345		}
1346	}
1347}
1348
1349type sigView struct {
1350	State       string
1351	Signer      string
1352	Fingerprint string
1353}
1354
1355func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1356	raw, err := gitutil.ReadCommit(dir, sha)
1357	if err != nil {
1358		return sigView{State: "unsigned"}, nil
1359	}
1360	parsed, err := sig.ParseCommit(raw)
1361	if err != nil {
1362		return sigView{State: "unsigned"}, nil
1363	}
1364	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1365	if err != nil {
1366		return sigView{State: "unsigned"}, parsed
1367	}
1368	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1369	if res.SignerUserID != 0 {
1370		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1371			v.Signer = u.Username
1372		}
1373	}
1374	return v, parsed
1375}
1376
1377func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1378	ref := r.PathValue("ref")
1379	p, ok := s.repoFor(w, r, ref)
1380	if !ok {
1381		return
1382	}
1383	p.Tab = "log"
1384	const pageSize = 50
1385	// ?path= filters to commits touching one file or directory.
1386	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1387	if filePath == "." {
1388		filePath = ""
1389	}
1390	var shas []string
1391	var err error
1392	if filePath != "" {
1393		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1394	} else {
1395		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1396	}
1397	if err != nil {
1398		s.notFound(w, r)
1399		return
1400	}
1401	next := ""
1402	if len(shas) > pageSize {
1403		next = shas[pageSize]
1404		shas = shas[:pageSize]
1405	}
1406	type row struct {
1407		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1408		Sig                                                               sigView
1409		Check                                                             string // combined status, "" when none ran
1410	}
1411	names := s.authorNames()
1412	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1413	var rows []row
1414	for _, sha := range shas {
1415		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1416		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1417		if parsed != nil {
1418			rw.Subject = parsed.Subject
1419			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1420			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1421			rw.AuthorEmail = parsed.AuthorEmail
1422			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1423		}
1424		rows = append(rows, rw)
1425	}
1426	s.render(w, "log.html", struct {
1427		repoPage
1428		Commits  []row
1429		NextSHA  string
1430		FilePath string
1431	}{p, rows, next, filePath})
1432}
1433
1434func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1435	p, ok := s.repoFor(w, r, "")
1436	if !ok {
1437		return
1438	}
1439	p.Tab = "log"
1440	sha := r.PathValue("sha")
1441	full, err := gitutil.ResolveRef(p.Dir, sha)
1442	if err != nil {
1443		s.notFound(w, r)
1444		return
1445	}
1446	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1447	if parsed == nil {
1448		s.notFound(w, r)
1449		return
1450	}
1451	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1452	files := parseDiff(patch)
1453	committerEmail := ""
1454	if parsed.CommitterEmail != parsed.AuthorEmail {
1455		committerEmail = parsed.CommitterEmail
1456	}
1457	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1458	commitNames := s.authorNames()
1459	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1460	msg := ""
1461	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1462		msg = string(parsed.Payload[i+2:])
1463	}
1464	s.render(w, "commit.html", struct {
1465		repoPage
1466		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1467		Parents                                                                           []string
1468		Sig                                                                               sigView
1469		Checks                                                                            []store.CommitStatus
1470		DiffFiles                                                                         []diffFile
1471	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1472		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1473		gitutil.Parents(p.Dir, full), v, checks, files})
1474}
1475
1476// labelPalette provides default label chip colors: mid-tone hues that stay
1477// legible on light and dark backgrounds.
1478var labelPalette = []string{
1479	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1480	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1481}
1482
1483var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1484
1485// labelColors returns a complete label-name -> chip color map for a repo:
1486// the stored labels.color when it is a valid hex color, otherwise a
1487// stable default picked from the palette by name hash.
1488func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1489	stored, _ := s.st.LabelColors(repoID)
1490	out := make(map[string]template.CSS, len(stored))
1491	for name, color := range stored {
1492		if !hexColorPat.MatchString(color) {
1493			h := fnv.New32a()
1494			h.Write([]byte(name))
1495			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1496		}
1497		out[name] = template.CSS("--chip:" + color)
1498	}
1499	return out
1500}
1501
1502func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1503	p, ok := s.repoFor(w, r, "")
1504	if !ok {
1505		return
1506	}
1507	p.Tab = "issues"
1508	state := r.URL.Query().Get("state")
1509	if state != "closed" && state != "all" {
1510		state = "open"
1511	}
1512	// The same filters the CLI's issue list takes, as query parameters;
1513	// label chips and author links point here.
1514	qv := r.URL.Query()
1515	f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1516		Author: qv.Get("author"), Milestone: qv.Get("milestone")}
1517	issues, err := s.st.QueryIssues(p.Repo.ID, f)
1518	if err != nil {
1519		http.Error(w, "internal error", http.StatusInternalServerError)
1520		return
1521	}
1522	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1523		for i := range issues {
1524			issues[i].Labels = labels[issues[i].ID]
1525		}
1526	}
1527	s.render(w, "issues.html", struct {
1528		repoPage
1529		State       string
1530		Label       string
1531		Filters     []listFilter
1532		Issues      []store.Issue
1533		LabelColors map[string]template.CSS
1534	}{p, state, f.Label, activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1535		issues, s.labelColors(p.Repo.ID)})
1536}
1537
1538func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1539	p, ok := s.repoFor(w, r, "")
1540	if !ok {
1541		return
1542	}
1543	p.Tab = "issues"
1544	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1545	if err != nil {
1546		s.notFound(w, r)
1547		return
1548	}
1549	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1550	if err != nil {
1551		s.notFound(w, r)
1552		return
1553	}
1554	comments, err := s.st.ListIssueComments(iss.ID)
1555	if err != nil {
1556		http.Error(w, "internal error", http.StatusInternalServerError)
1557		return
1558	}
1559	md := s.ugcFor(r, p.Repo)
1560	milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1561	s.render(w, "issue.html", struct {
1562		repoPage
1563		Issue       store.Issue
1564		BodyHTML    template.HTML
1565		Comments    []renderedComment
1566		CanEdit     bool
1567		CanWrite    bool
1568		Milestones  []store.Milestone
1569		Notice      string
1570		LabelColors map[string]template.CSS
1571	}{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1572		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1573		milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1574}
1575
1576// canEditItem: the author or anyone with write access may edit.
1577// canWriteRepo reports whether the browser session may push to the repo,
1578// which is what gates the review and merge controls.
1579func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1580	if s.cfg.Web.Mode != "accounts" {
1581		return false
1582	}
1583	u := s.viewer(r)
1584	if u.ID == 0 {
1585		return false
1586	}
1587	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1588	return policy.CanWrite(u, repo, grant)
1589}
1590
1591func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1592	if s.cfg.Web.Mode != "accounts" {
1593		return false
1594	}
1595	u := s.viewer(r)
1596	if u.ID == 0 {
1597		return false
1598	}
1599	if u.Username == author {
1600		return true
1601	}
1602	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1603	return policy.CanWrite(u, repo, grant)
1604}
1605
1606func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1607	p, ok := s.repoFor(w, r, "")
1608	if !ok {
1609		return
1610	}
1611	p.Tab = "merge requests"
1612	state := r.URL.Query().Get("state")
1613	if state == "" {
1614		state = "open"
1615	}
1616	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1617	if !valid[state] {
1618		state = "open"
1619	}
1620	qv := r.URL.Query()
1621	mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone")}
1622	mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1623	if err != nil {
1624		http.Error(w, "internal error", http.StatusInternalServerError)
1625		return
1626	}
1627	s.render(w, "mrs.html", struct {
1628		repoPage
1629		State   string
1630		Filters []listFilter
1631		MRs     []store.MR
1632	}{p, state, activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs})
1633}
1634
1635func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1636	p, ok := s.repoFor(w, r, "")
1637	if !ok {
1638		return
1639	}
1640	p.Tab = "merge requests"
1641	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1642	if err != nil {
1643		s.notFound(w, r)
1644		return
1645	}
1646	m, err := s.st.MRByNumber(p.Repo.ID, n)
1647	if err != nil {
1648		s.notFound(w, r)
1649		return
1650	}
1651	comments, _ := s.st.ListMRComments(m.ID)
1652	reviews, _ := s.st.ListMRReviews(m.ID)
1653	checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1654	diffComments, _ := s.st.ListDiffComments(m.ID)
1655
1656	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1657	var files []diffFile
1658	base := m.MergedBase
1659	if base == "" {
1660		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1661			base = b
1662		}
1663	}
1664	if base != "" {
1665		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1666			files = parseDiff(patch)
1667		}
1668	}
1669	md := s.ugcFor(r, p.Repo)
1670	canWrite := s.canWriteRepo(r, p.Repo)
1671	var detachedThreads []diffThread
1672	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1673		reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1674	if p.Viewer != "" {
1675		markCompose(files, r.URL.Query())
1676	}
1677	stat := statOf(files)
1678	// The commits this MR carries: base..head, the same range as the diff.
1679	type commitRow struct {
1680		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1681		Sig                                                  sigView
1682	}
1683	mrNames := s.authorNames()
1684	var commits []commitRow
1685	if base != "" {
1686		const maxMRCommits = 100
1687		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1688		if len(shas) > maxMRCommits {
1689			shas = shas[:maxMRCommits]
1690		}
1691		for _, sha := range shas {
1692			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1693			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1694			if parsed != nil {
1695				cr.Subject = parsed.Subject
1696				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1697				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1698				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1699			}
1700			commits = append(commits, cr)
1701		}
1702	}
1703	// The diff is the reason most people open a merge request, so it gets
1704	// its own view rather than a fold at the foot of the conversation.
1705	// A query parameter keeps this working without JavaScript.
1706	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1707	branches, _ := gitutil.Refs(p.Dir, "heads")
1708	view := r.URL.Query().Get("view")
1709	if view != "commits" && view != "diff" {
1710		view = "conversation"
1711	}
1712	// The stack around an open merge request, for the header.
1713	var stackedOn *store.MR
1714	var stacked []store.MR
1715	if m.State == "open" {
1716		if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1717			stackedOn = &parent
1718		}
1719		if m.SourceRepoID == p.Repo.ID {
1720			stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1721		}
1722	}
1723	s.render(w, "mr.html", struct {
1724		repoPage
1725		MR              store.MR
1726		View            string
1727		BodyHTML        template.HTML
1728		Checks          []store.Check
1729		Combined        string
1730		Comments        []renderedComment
1731		Reviews         []store.MRReview
1732		DiffFiles       []diffFile
1733		Stat            diffStat
1734		Commits         []commitRow
1735		Branches        []gitutil.Ref
1736		CanEdit         bool
1737		CanWrite        bool
1738		Unresolved      int
1739		Notice          string
1740		DetachedThreads []diffThread
1741		StackedOn       *store.MR
1742		Stacked         []store.MR
1743	}{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1744		reviews, files, stat, commits, branches, s.canEditItem(r, p.Repo, m.Author),
1745		canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads, stackedOn, stacked})
1746}
1747
1748func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1749	p, ok := s.repoFor(w, r, "")
1750	if !ok {
1751		return
1752	}
1753	p.Tab = "refs"
1754	branches, _ := gitutil.Refs(p.Dir, "heads")
1755	tags, _ := gitutil.Refs(p.Dir, "tags")
1756	s.render(w, "refs.html", struct {
1757		repoPage
1758		Branches, Tags []gitutil.Ref
1759	}{p, branches, tags})
1760}
1761
1762func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1763	p, ok := s.repoFor(w, r, "")
1764	if !ok {
1765		return
1766	}
1767	file := r.PathValue("file")
1768	ref, ok := strings.CutSuffix(file, ".tar.gz")
1769	if !ok {
1770		s.notFound(w, r)
1771		return
1772	}
1773	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1774		s.notFound(w, r)
1775		return
1776	}
1777	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1778	w.Header().Set("Content-Type", "application/gzip")
1779	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1780	gitutil.Archive(p.Dir, ref, prefix, w)
1781}
1782
1783func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1784	return policy.CanAdmin(u, repo, grant)
1785}
1786
1787func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1788	return policy.CanRead(u, repo, grant)
1789}