internal/httpd/web.go

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