internal/httpd/web.go

8bae67052c75bf6469522c4b6501791f820de5e3
gitbay/internal/httpd/web.go history · blame · raw

1864 lines · 57766 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, r.URL.Query().Get("e")})
 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), r.URL.Query().Get("e")})
 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
1247func renderReadme(name string, raw []byte) template.HTML {
1248	plain := func() template.HTML {
1249		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1250	}
1251	if gitutil.IsBinary(raw) {
1252		return ""
1253	}
1254	switch path.Ext(strings.ToLower(name)) {
1255	case ".md", ".markdown":
1256		var buf bytes.Buffer
1257		if markdown.Convert(raw, &buf) != nil {
1258			return plain()
1259		}
1260		return template.HTML(buf.String())
1261	case ".org":
1262		return renderOrg(name, raw, true, plain)
1263	case ".html", ".htm":
1264		return template.HTML(ugcPolicy.Sanitize(string(raw)))
1265	default:
1266		return plain()
1267	}
1268}
1269
1270type diffThread struct {
1271	ID         int64
1272	Resolved   string
1273	Stale      bool
1274	CanResolve bool
1275	Comments   []renderedComment
1276}
1277
1278// reviewRights decides which thread controls a viewer sees. mr resolve
1279// admits the thread author, the MR author, or anyone with write, so the
1280// page needs all three to render the button truthfully.
1281type reviewRights struct {
1282	Viewer   string
1283	MRAuthor string
1284	Write    bool
1285}
1286
1287func (r reviewRights) canResolve(threadAuthor string) bool {
1288	return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1289}
1290
1291// attachThreads injects review threads under their anchored diff lines;
1292// threads whose anchor no longer appears (stale after force-push, or on a
1293// context line outside the current diff) are returned separately.
1294func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1295	type anchor struct {
1296		path string
1297		side string
1298		line int64
1299	}
1300	// Diff-line comments have no stored format yet, so they stay markdown.
1301	// They are the one user-authored body left without the choice; see #51.
1302	threads := map[int64]*diffThread{}
1303	anchors := map[int64]anchor{}
1304	var order []int64
1305	for _, cm := range comments {
1306		if cm.ReplyTo == 0 {
1307			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1308				CanResolve: rights.canResolve(cm.Author),
1309				Comments:   []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1310			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1311			order = append(order, cm.ID)
1312		} else if th, ok := threads[cm.ReplyTo]; ok {
1313			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1314		}
1315	}
1316	placed := map[int64]bool{}
1317	for f := range files {
1318		lines := files[f].Lines
1319		for i := range lines {
1320			for _, id := range order {
1321				if placed[id] || threads[id].Stale {
1322					continue
1323				}
1324				a := anchors[id]
1325				if lines[i].Path != a.path {
1326					continue
1327				}
1328				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1329					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1330					lines[i].Threads = append(lines[i].Threads, *threads[id])
1331					files[f].Threads++
1332					files[f].Open = true
1333					placed[id] = true
1334				}
1335			}
1336		}
1337	}
1338	var unplaced []diffThread
1339	for _, id := range order {
1340		if !placed[id] {
1341			unplaced = append(unplaced, *threads[id])
1342		}
1343	}
1344	return files, unplaced
1345}
1346
1347// markCompose opens the new-thread form under one diff line. There is no
1348// JavaScript, so "comment on this line" is a plain GET carrying the
1349// anchor and the page renders the form where the reader asked for it.
1350func markCompose(files []diffFile, q url.Values) {
1351	path := q.Get("cpath")
1352	line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1353	if path == "" || line < 1 {
1354		return
1355	}
1356	old := q.Get("cside") == "old"
1357	for f := range files {
1358		for i := range files[f].Lines {
1359			ln := &files[f].Lines[i]
1360			if ln.Path != path {
1361				continue
1362			}
1363			if (old && ln.Class == "del" && ln.OldLine == line) ||
1364				(!old && ln.Class != "del" && ln.NewLine == line) {
1365				ln.Compose = true
1366				files[f].Open = true
1367				return
1368			}
1369		}
1370	}
1371}
1372
1373type sigView struct {
1374	State       string
1375	Signer      string
1376	Fingerprint string
1377}
1378
1379func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1380	raw, err := gitutil.ReadCommit(dir, sha)
1381	if err != nil {
1382		return sigView{State: "unsigned"}, nil
1383	}
1384	parsed, err := sig.ParseCommit(raw)
1385	if err != nil {
1386		return sigView{State: "unsigned"}, nil
1387	}
1388	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1389	if err != nil {
1390		return sigView{State: "unsigned"}, parsed
1391	}
1392	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1393	if res.SignerUserID != 0 {
1394		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1395			v.Signer = u.Username
1396		}
1397	}
1398	return v, parsed
1399}
1400
1401func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1402	ref := r.PathValue("ref")
1403	p, ok := s.repoFor(w, r, ref)
1404	if !ok {
1405		return
1406	}
1407	p.Tab = "log"
1408	const pageSize = 50
1409	// ?path= filters to commits touching one file or directory.
1410	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1411	if filePath == "." {
1412		filePath = ""
1413	}
1414	var shas []string
1415	var err error
1416	if filePath != "" {
1417		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1418	} else {
1419		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1420	}
1421	if err != nil {
1422		s.notFound(w, r)
1423		return
1424	}
1425	next := ""
1426	if len(shas) > pageSize {
1427		next = shas[pageSize]
1428		shas = shas[:pageSize]
1429	}
1430	type row struct {
1431		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1432		Sig                                                               sigView
1433		Check                                                             string // combined status, "" when none ran
1434	}
1435	names := s.authorNames()
1436	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1437	var rows []row
1438	for _, sha := range shas {
1439		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1440		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1441		if parsed != nil {
1442			rw.Subject = parsed.Subject
1443			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1444			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1445			rw.AuthorEmail = parsed.AuthorEmail
1446			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1447		}
1448		rows = append(rows, rw)
1449	}
1450	s.render(w, "log.html", struct {
1451		repoPage
1452		Commits  []row
1453		NextSHA  string
1454		FilePath string
1455	}{p, rows, next, filePath})
1456}
1457
1458func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1459	p, ok := s.repoFor(w, r, "")
1460	if !ok {
1461		return
1462	}
1463	p.Tab = "log"
1464	sha := r.PathValue("sha")
1465	full, err := gitutil.ResolveRef(p.Dir, sha)
1466	if err != nil {
1467		s.notFound(w, r)
1468		return
1469	}
1470	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1471	if parsed == nil {
1472		s.notFound(w, r)
1473		return
1474	}
1475	patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1476	files := parseDiff(patch)
1477	committerEmail := ""
1478	if parsed.CommitterEmail != parsed.AuthorEmail {
1479		committerEmail = parsed.CommitterEmail
1480	}
1481	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1482	commitNames := s.authorNames()
1483	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1484	msg := ""
1485	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1486		msg = string(parsed.Payload[i+2:])
1487	}
1488	s.render(w, "commit.html", struct {
1489		repoPage
1490		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1491		Parents                                                                           []string
1492		Sig                                                                               sigView
1493		Checks                                                                            []store.CommitStatus
1494		DiffFiles                                                                         []diffFile
1495		DiffTruncated                                                                     bool
1496	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1497		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1498		gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1499}
1500
1501// labelPalette provides default label chip colors: mid-tone hues that stay
1502// legible on light and dark backgrounds.
1503var labelPalette = []string{
1504	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1505	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1506}
1507
1508var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1509
1510// clampChip keeps a user-set label colour legible as text on both
1511// grounds. Contrast is defined on relative luminance, so that is what is
1512// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1513// and against the dark ground alike, and where the palette's own colours
1514// sit. The hue is kept; the channels are scaled in linear light (#120).
1515func clampChip(hex string) string {
1516	lin := func(c int64) float64 {
1517		v := float64(c) / 255
1518		if v <= 0.04045 {
1519			return v / 12.92
1520		}
1521		return math.Pow((v+0.055)/1.055, 2.4)
1522	}
1523	r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1524	y := 0.2126*r + 0.7152*g + 0.0722*b
1525	const lo, hi = 0.12, 0.28
1526	if y >= lo && y <= hi {
1527		return strings.ToLower(hex)
1528	}
1529	target := hi
1530	if y < lo {
1531		target = lo
1532	}
1533	if y == 0 {
1534		r, g, b = target, target, target
1535	} else {
1536		k := target / y
1537		r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1538	}
1539	enc := func(v float64) int {
1540		if v <= 0.0031308 {
1541			v *= 12.92
1542		} else {
1543			v = 1.055*math.Pow(v, 1/2.4) - 0.055
1544		}
1545		return int(math.Round(v * 255))
1546	}
1547	return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1548}
1549
1550func hexByte(s string) int64 {
1551	n, _ := strconv.ParseInt(s, 16, 32)
1552	return n
1553}
1554
1555// labelColors returns a complete label-name -> chip color map for a repo:
1556// the stored labels.color when it is a valid hex color, otherwise a
1557// stable default picked from the palette by name hash.
1558func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1559	stored, _ := s.st.LabelColors(repoID)
1560	out := make(map[string]template.CSS, len(stored))
1561	for name, color := range stored {
1562		if !hexColorPat.MatchString(color) {
1563			h := fnv.New32a()
1564			h.Write([]byte(name))
1565			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1566		}
1567		out[name] = template.CSS("--chip:" + clampChip(color))
1568	}
1569	return out
1570}
1571
1572func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1573	p, ok := s.repoFor(w, r, "")
1574	if !ok {
1575		return
1576	}
1577	p.Tab = "issues"
1578	state := r.URL.Query().Get("state")
1579	if state != "closed" && state != "all" {
1580		state = "open"
1581	}
1582	// The same filters the CLI's issue list takes, as query parameters;
1583	// label chips and author links point here.
1584	qv := r.URL.Query()
1585	f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1586		Author: qv.Get("author"), Milestone: qv.Get("milestone")}
1587	issues, err := s.st.QueryIssues(p.Repo.ID, f)
1588	if err != nil {
1589		http.Error(w, "internal error", http.StatusInternalServerError)
1590		return
1591	}
1592	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1593		for i := range issues {
1594			issues[i].Labels = labels[issues[i].ID]
1595		}
1596	}
1597	s.render(w, "issues.html", struct {
1598		repoPage
1599		State       string
1600		Label       string
1601		Filters     []listFilter
1602		Issues      []store.Issue
1603		LabelColors map[string]template.CSS
1604	}{p, state, f.Label, activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1605		issues, s.labelColors(p.Repo.ID)})
1606}
1607
1608func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1609	p, ok := s.repoFor(w, r, "")
1610	if !ok {
1611		return
1612	}
1613	p.Tab = "issues"
1614	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1615	if err != nil {
1616		s.notFound(w, r)
1617		return
1618	}
1619	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1620	if err != nil {
1621		s.notFound(w, r)
1622		return
1623	}
1624	comments, err := s.st.ListIssueComments(iss.ID)
1625	if err != nil {
1626		http.Error(w, "internal error", http.StatusInternalServerError)
1627		return
1628	}
1629	md := s.ugcFor(r, p.Repo)
1630	milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1631	s.render(w, "issue.html", struct {
1632		repoPage
1633		Issue       store.Issue
1634		BodyHTML    template.HTML
1635		Comments    []renderedComment
1636		CanEdit     bool
1637		CanWrite    bool
1638		Milestones  []store.Milestone
1639		Notice      string
1640		LabelColors map[string]template.CSS
1641	}{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1642		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1643		milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1644}
1645
1646// canEditItem: the author or anyone with write access may edit.
1647// canWriteRepo reports whether the browser session may push to the repo,
1648// which is what gates the review and merge controls.
1649func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1650	if s.cfg.Web.Mode != "accounts" {
1651		return false
1652	}
1653	u := s.viewer(r)
1654	if u.ID == 0 {
1655		return false
1656	}
1657	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1658	return policy.CanWrite(u, repo, grant)
1659}
1660
1661func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1662	if s.cfg.Web.Mode != "accounts" {
1663		return false
1664	}
1665	u := s.viewer(r)
1666	if u.ID == 0 {
1667		return false
1668	}
1669	if u.Username == author {
1670		return true
1671	}
1672	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1673	return policy.CanWrite(u, repo, grant)
1674}
1675
1676func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1677	p, ok := s.repoFor(w, r, "")
1678	if !ok {
1679		return
1680	}
1681	p.Tab = "merge requests"
1682	state := r.URL.Query().Get("state")
1683	if state == "" {
1684		state = "open"
1685	}
1686	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1687	if !valid[state] {
1688		state = "open"
1689	}
1690	qv := r.URL.Query()
1691	mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone")}
1692	mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1693	if err != nil {
1694		http.Error(w, "internal error", http.StatusInternalServerError)
1695		return
1696	}
1697	s.render(w, "mrs.html", struct {
1698		repoPage
1699		State   string
1700		Filters []listFilter
1701		MRs     []store.MR
1702	}{p, state, activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs})
1703}
1704
1705func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1706	p, ok := s.repoFor(w, r, "")
1707	if !ok {
1708		return
1709	}
1710	p.Tab = "merge requests"
1711	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1712	if err != nil {
1713		s.notFound(w, r)
1714		return
1715	}
1716	m, err := s.st.MRByNumber(p.Repo.ID, n)
1717	if err != nil {
1718		s.notFound(w, r)
1719		return
1720	}
1721	comments, _ := s.st.ListMRComments(m.ID)
1722	reviews, _ := s.st.ListMRReviews(m.ID)
1723	checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1724	diffComments, _ := s.st.ListDiffComments(m.ID)
1725
1726	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1727	var files []diffFile
1728	base := m.MergedBase
1729	if base == "" {
1730		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1731			base = b
1732		}
1733	}
1734	var diffTruncated bool
1735	if base != "" {
1736		if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1737			files, diffTruncated = parseDiff(patch), truncated
1738		}
1739	}
1740	md := s.ugcFor(r, p.Repo)
1741	canWrite := s.canWriteRepo(r, p.Repo)
1742	var detachedThreads []diffThread
1743	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1744		reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1745	if p.Viewer != "" {
1746		markCompose(files, r.URL.Query())
1747	}
1748	stat := statOf(files)
1749	// The commits this MR carries: base..head, the same range as the diff.
1750	type commitRow struct {
1751		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1752		Sig                                                  sigView
1753	}
1754	mrNames := s.authorNames()
1755	var commits []commitRow
1756	commitsTotal := 0
1757	if base != "" {
1758		const maxMRCommits = 100
1759		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1760		commitsTotal = len(shas)
1761		if len(shas) > maxMRCommits {
1762			shas = shas[:maxMRCommits]
1763		}
1764		for _, sha := range shas {
1765			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1766			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1767			if parsed != nil {
1768				cr.Subject = parsed.Subject
1769				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1770				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1771				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1772			}
1773			commits = append(commits, cr)
1774		}
1775	}
1776	// The diff is the reason most people open a merge request, so it gets
1777	// its own view rather than a fold at the foot of the conversation.
1778	// A query parameter keeps this working without JavaScript.
1779	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1780	branches, _ := gitutil.Refs(p.Dir, "heads")
1781	view := r.URL.Query().Get("view")
1782	if view != "commits" && view != "diff" {
1783		view = "conversation"
1784	}
1785	// The stack around an open merge request, for the header.
1786	var stackedOn *store.MR
1787	var stacked []store.MR
1788	if m.State == "open" {
1789		if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1790			stackedOn = &parent
1791		}
1792		if m.SourceRepoID == p.Repo.ID {
1793			stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1794		}
1795	}
1796	s.render(w, "mr.html", struct {
1797		repoPage
1798		MR              store.MR
1799		View            string
1800		BodyHTML        template.HTML
1801		Checks          []store.Check
1802		Combined        string
1803		Comments        []renderedComment
1804		Reviews         []store.MRReview
1805		DiffFiles       []diffFile
1806		DiffTruncated   bool
1807		Stat            diffStat
1808		Commits         []commitRow
1809		CommitsTotal    int
1810		Branches        []gitutil.Ref
1811		CanEdit         bool
1812		CanWrite        bool
1813		Unresolved      int
1814		Notice          string
1815		DetachedThreads []diffThread
1816		StackedOn       *store.MR
1817		Stacked         []store.MR
1818	}{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1819		reviews, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1820		canWrite, unresolved, r.URL.Query().Get("e"), detachedThreads, stackedOn, stacked})
1821}
1822
1823func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1824	p, ok := s.repoFor(w, r, "")
1825	if !ok {
1826		return
1827	}
1828	p.Tab = "refs"
1829	branches, _ := gitutil.Refs(p.Dir, "heads")
1830	tags, _ := gitutil.Refs(p.Dir, "tags")
1831	s.render(w, "refs.html", struct {
1832		repoPage
1833		Branches, Tags []gitutil.Ref
1834	}{p, branches, tags})
1835}
1836
1837func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1838	p, ok := s.repoFor(w, r, "")
1839	if !ok {
1840		return
1841	}
1842	file := r.PathValue("file")
1843	ref, ok := strings.CutSuffix(file, ".tar.gz")
1844	if !ok {
1845		s.notFound(w, r)
1846		return
1847	}
1848	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1849		s.notFound(w, r)
1850		return
1851	}
1852	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1853	w.Header().Set("Content-Type", "application/gzip")
1854	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1855	gitutil.Archive(p.Dir, ref, prefix, w)
1856}
1857
1858func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1859	return policy.CanAdmin(u, repo, grant)
1860}
1861
1862func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1863	return policy.CanRead(u, repo, grant)
1864}