internal/httpd/web.go

1977 lines · 62097 bytes

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