internal/httpd/web.go

0a69a8facd64e67e42ec04fa11dd4a0304e40d79
gitbay/internal/httpd/web.go history · blame · raw

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