internal/httpd/web.go

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

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