internal/httpd/web.go

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

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