internal/httpd/web.go

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

1331 lines · 36498 bytes

   1package httpd
   2
   3import (
   4	"bytes"
   5	"fmt"
   6	"hash/fnv"
   7	"io"
   8	"os"
   9	"path/filepath"
  10
  11	"gitbay.org/gitbay/internal/policy"
  12	"html/template"
  13	"net/http"
  14	"path"
  15	"regexp"
  16	"strconv"
  17	"strings"
  18	"time"
  19
  20	"github.com/alecthomas/chroma/v2/formatters/html"
  21	"github.com/alecthomas/chroma/v2/lexers"
  22	"github.com/alecthomas/chroma/v2/styles"
  23	"github.com/microcosm-cc/bluemonday"
  24	"github.com/niklasfasching/go-org/org"
  25	"github.com/yuin/goldmark"
  26
  27	"gitbay.org/gitbay/internal/autolink"
  28	"gitbay.org/gitbay/internal/control"
  29	"gitbay.org/gitbay/internal/gitutil"
  30	"gitbay.org/gitbay/internal/sig"
  31	"gitbay.org/gitbay/internal/store"
  32	"gitbay.org/gitbay/internal/web"
  33)
  34
  35const maxRenderBytes = 1 << 20 // largest blob rendered inline
  36
  37func (s *Server) render(w http.ResponseWriter, page string, data any) {
  38	var buf bytes.Buffer
  39	if err := web.Render(&buf, page, data); err != nil {
  40		http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
  41		return
  42	}
  43	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  44	buf.WriteTo(w)
  45}
  46
  47func (s *Server) siteName() string {
  48	h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
  49	return strings.TrimSuffix(h, "/")
  50}
  51
  52func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
  53	w.Header().Set("Content-Type", "text/css; charset=utf-8")
  54	w.Write(web.StyleCSS)
  55}
  56
  57func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
  58	w.Header().Set("Content-Type", "image/svg+xml")
  59	w.Write(web.FaviconSVG)
  60}
  61
  62// notFound renders the designed 404 page with a 404 status. Falls back to
  63// the stock plain-text response if the template fails.
  64func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
  65	var buf bytes.Buffer
  66	if err := web.Render(&buf, "404.html", struct {
  67		Site   string
  68		Viewer string
  69	}{s.siteName(), s.viewerName(r)}); err != nil {
  70		http.NotFound(w, r)
  71		return
  72	}
  73	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  74	w.WriteHeader(http.StatusNotFound)
  75	buf.WriteTo(w)
  76}
  77
  78// describedRepo pairs a repo with the listing metadata: description,
  79// topics, license, and last-updated date.
  80type describedRepo struct {
  81	store.Repo
  82	Desc    string
  83	Topics  []string
  84	License string
  85	Updated string
  86}
  87
  88func (s *Server) describeAll(repos []store.Repo) []describedRepo {
  89	var out []describedRepo
  90	for _, r := range repos {
  91		dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
  92		d := describedRepo{
  93			Repo:    r,
  94			Desc:    gitutil.ReadDescription(dir),
  95			License: detectLicense(dir, r.DefaultBranch),
  96			Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
  97		}
  98		d.Topics, _ = s.st.ListTopics(r.ID)
  99		out = append(out, d)
 100	}
 101	return out
 102}
 103
 104// index is the homepage: a dashboard for logged-in users, a landing page
 105// for everyone else. The full public listing lives at /explore.
 106func (s *Server) index(w http.ResponseWriter, r *http.Request) {
 107	if s.cfg.Web.Mode == "accounts" {
 108		if viewer := s.viewer(r); viewer.ID != 0 {
 109			s.dashboard(w, r, viewer)
 110			return
 111		}
 112	}
 113	host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
 114		s.cfg.Server.SiteURL, "https://"), "http://"), "/")
 115	s.render(w, "landing.html", struct {
 116		Site     string
 117		Viewer   string
 118		Host     string
 119		Accounts bool
 120		Signup   bool
 121	}{s.siteName(), "", host, s.cfg.Web.Mode == "accounts",
 122		s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
 123}
 124
 125func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
 126	pinned, _ := s.st.PinnedRepos(viewer.ID)
 127	var visible []store.Repo
 128	for _, rp := range pinned {
 129		grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
 130		if policy.CanRead(viewer, rp, grant) {
 131			visible = append(visible, rp)
 132		}
 133	}
 134	mrs, _ := s.st.DashboardMRs(viewer.ID)
 135	issues, _ := s.st.DashboardIssues(viewer.ID)
 136	s.render(w, "dashboard.html", struct {
 137		Site   string
 138		Viewer string
 139		Pinned []describedRepo
 140		MRs    []store.DashboardItem
 141		Issues []store.DashboardItem
 142	}{s.siteName(), viewer.Username, s.describeAll(visible), mrs, issues})
 143}
 144
 145func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
 146	repos, err := s.st.ListPublicRepos()
 147	if err != nil {
 148		http.Error(w, "internal error", http.StatusInternalServerError)
 149		return
 150	}
 151	var viewer store.User
 152	if s.cfg.Web.Mode == "accounts" {
 153		viewer = s.viewer(r)
 154	}
 155	q := strings.TrimSpace(r.URL.Query().Get("q"))
 156	s.render(w, "explore.html", struct {
 157		Site   string
 158		Viewer string
 159		Query  string
 160		Repos  []describedRepo
 161	}{s.siteName(), viewer.Username, q, s.filterRepos(q, s.describeAll(repos))})
 162}
 163
 164// viewerName returns the logged-in username for header rendering, or "".
 165func (s *Server) viewerName(r *http.Request) string {
 166	if s.cfg.Web.Mode != "accounts" {
 167		return ""
 168	}
 169	return s.viewer(r).Username
 170}
 171
 172// privacy renders the privacy page: what the gitbay software does with
 173// data, plus this instance's operator-provided notes.
 174func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
 175	s.render(w, "privacy.html", struct {
 176		Site   string
 177		Viewer string
 178		Host   string
 179		Notice string
 180	}{s.siteName(), s.viewerName(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
 181}
 182
 183// filterRepos keeps repos whose path, description, or topics contain the
 184// query, case-insensitively. An empty query keeps everything.
 185func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
 186	if q == "" {
 187		return repos
 188	}
 189	q = strings.ToLower(q)
 190	var out []describedRepo
 191	for _, d := range repos {
 192		if strings.Contains(strings.ToLower(d.Path()), q) ||
 193			strings.Contains(strings.ToLower(d.Desc), q) {
 194			out = append(out, d)
 195			continue
 196		}
 197		for _, t := range d.Topics {
 198			if strings.Contains(t, q) {
 199				out = append(out, d)
 200				break
 201			}
 202		}
 203	}
 204	return out
 205}
 206
 207// repoPage is the shared context for repo-scoped pages.
 208type repoPage struct {
 209	Site     string
 210	Viewer   string
 211	Desc     string
 212	Repo     store.Repo
 213	Ref      string
 214	CloneURL string
 215	Dir      string
 216	Tab      string // active tab in the repo header
 217	Topics   []string
 218	Pinned   bool // by the viewer
 219}
 220
 221// repoFor resolves the repo for a web request; false means 404 was sent.
 222// Anonymous visitors see public repos only; in accounts mode a logged-in
 223// viewer additionally sees repos their grants allow. Private and missing
 224// repos are indistinguishable either way.
 225func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
 226	var repo store.Repo
 227	var viewer store.User
 228	if s.cfg.Web.Mode == "accounts" {
 229		viewer = s.viewer(r)
 230	}
 231	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
 232	ok := err == nil
 233	if ok {
 234		grant := ""
 235		if viewer.ID != 0 {
 236			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 237		}
 238		ok = policyCanRead(viewer, repo, grant)
 239	}
 240	if !ok {
 241		s.notFound(w, r)
 242		return repoPage{}, false
 243	}
 244	if ref == "" {
 245		ref = repo.DefaultBranch
 246	}
 247	topics, _ := s.st.ListTopics(repo.ID)
 248	pinned := false
 249	if viewer.ID != 0 {
 250		pinned = s.st.IsPinned(viewer.ID, repo.ID)
 251	}
 252	return repoPage{
 253		Site:     s.siteName(),
 254		Viewer:   viewer.Username,
 255		Pinned:   pinned,
 256		Desc:     gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
 257		Repo:     repo,
 258		Ref:      ref,
 259		CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
 260		Dir:      control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
 261		Topics:   topics,
 262	}, true
 263}
 264
 265type crumb struct {
 266	Name string
 267	URL  string
 268}
 269
 270func crumbs(p repoPage, kind, filePath string) []crumb {
 271	var cs []crumb
 272	base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
 273	acc := ""
 274	for _, part := range strings.Split(filePath, "/") {
 275		if part == "" {
 276			continue
 277		}
 278		acc = path.Join(acc, part)
 279		cs = append(cs, crumb{Name: part, URL: base + acc})
 280	}
 281	return cs
 282}
 283
 284// ownerPage renders /{owner} for users and orgs: the repositories the
 285// viewer may see, org membership either direction. Owner names are not
 286// secret (they are on every commit); repository visibility rules hold.
 287func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
 288	name := r.PathValue("owner")
 289	var viewer store.User
 290	if s.cfg.Web.Mode == "accounts" {
 291		viewer = s.viewer(r)
 292	}
 293
 294	kind := "user"
 295	var ownerID int64
 296	var members []store.OrgMember
 297	var orgs []store.OrgMember
 298	if u, err := s.st.UserByUsername(name); err == nil {
 299		ownerID = u.ID
 300		orgs, _ = s.st.ListOrgsForUser(u.ID)
 301	} else if o, err := s.st.OrgByName(name); err == nil {
 302		kind, ownerID = "org", o.ID
 303		members, _ = s.st.OrgMembers(o.ID)
 304	} else {
 305		s.notFound(w, r)
 306		return
 307	}
 308	profile, _ := s.st.OwnerProfile(kind, ownerID)
 309
 310	all, err := s.st.ListReposForOwner(kind, ownerID)
 311	if err != nil {
 312		http.Error(w, "internal error", http.StatusInternalServerError)
 313		return
 314	}
 315	var visible []store.Repo
 316	for _, repo := range all {
 317		grant := ""
 318		if viewer.ID != 0 {
 319			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 320		}
 321		if policy.CanRead(viewer, repo, grant) {
 322			visible = append(visible, repo)
 323		}
 324	}
 325	s.render(w, "owner.html", struct {
 326		Site    string
 327		Viewer  string
 328		Owner   string
 329		Kind    string
 330		Profile store.Profile
 331		Repos   []describedRepo
 332		Members []store.OrgMember
 333		Orgs    []store.OrgMember
 334	}{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs})
 335}
 336
 337func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
 338	p, ok := s.repoFor(w, r, "")
 339	if !ok {
 340		return
 341	}
 342	p.Tab = "files"
 343	s.renderTree(w, r, p, "")
 344}
 345
 346func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
 347	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 348	if !ok {
 349		return
 350	}
 351	p.Tab = "files"
 352	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
 353}
 354
 355func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
 356	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
 357		// Empty repo: render the page with no entries rather than 404.
 358		s.render(w, "tree.html", struct {
 359			repoPage
 360			Crumbs     []crumb
 361			Prefix     string
 362			DirPath    string
 363			RefKind    string
 364			Entries    []gitutil.TreeEntry
 365			Branches   []gitutil.Ref
 366			ReadmeName string
 367			ReadmeHTML template.HTML
 368		}{repoPage: p, RefKind: "tree"})
 369		return
 370	}
 371	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
 372	if err != nil {
 373		s.notFound(w, r)
 374		return
 375	}
 376	prefix := ""
 377	if dirPath != "" {
 378		prefix = dirPath + "/"
 379	}
 380
 381	var readmeHTML template.HTML
 382	readmeName := pickReadme(entries)
 383	if readmeName != "" {
 384		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
 385			readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
 386		}
 387	}
 388
 389	branches, _ := gitutil.Refs(p.Dir, "heads")
 390	s.render(w, "tree.html", struct {
 391		repoPage
 392		Crumbs     []crumb
 393		Prefix     string
 394		DirPath    string
 395		RefKind    string
 396		Entries    []gitutil.TreeEntry
 397		Branches   []gitutil.Ref
 398		ReadmeName string
 399		ReadmeHTML template.HTML
 400	}{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, readmeName, readmeHTML})
 401}
 402
 403func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
 404	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 405	if !ok {
 406		return
 407	}
 408	p.Tab = "files"
 409	filePath := strings.Trim(r.PathValue("path"), "/")
 410	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
 411	if err != nil {
 412		s.notFound(w, r)
 413		return
 414	}
 415	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
 416
 417	var codeHTML template.HTML
 418	if !binary {
 419		codeHTML = highlight(filePath, data)
 420	}
 421	cs := crumbs(p, "blob", filePath)
 422	base := ""
 423	if len(cs) > 0 {
 424		base = cs[len(cs)-1].Name
 425		cs = cs[:len(cs)-1]
 426	}
 427	branches, _ := gitutil.Refs(p.Dir, "heads")
 428	s.render(w, "blob.html", struct {
 429		repoPage
 430		Crumbs   []crumb
 431		Base     string
 432		Path     string
 433		DirPath  string
 434		RefKind  string
 435		Binary   bool
 436		Size     int
 437		Branches []gitutil.Ref
 438		CodeHTML template.HTML
 439	}{p, cs, base, filePath, filePath, "blob", binary, len(data), branches, codeHTML})
 440}
 441
 442// releases lists tag-anchored releases with notes and assets.
 443func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 444	p, ok := s.repoFor(w, r, "")
 445	if !ok {
 446		return
 447	}
 448	p.Tab = "releases"
 449	rels, err := s.st.ListReleases(p.Repo.ID)
 450	if err != nil {
 451		http.Error(w, "internal error", http.StatusInternalServerError)
 452		return
 453	}
 454	md := s.ugcFor(r, p.Repo)
 455	type relView struct {
 456		store.Release
 457		NotesHTML template.HTML
 458	}
 459	var views []relView
 460	for _, rel := range rels {
 461		views = append(views, relView{rel, md(rel.Notes)})
 462	}
 463	s.render(w, "releases.html", struct {
 464		repoPage
 465		Releases []relView
 466	}{p, views})
 467}
 468
 469// releaseAsset streams one uploaded asset. Tags containing '/' are not
 470// reachable here (single path segment); SSH download always works.
 471func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 472	p, ok := s.repoFor(w, r, "")
 473	if !ok {
 474		return
 475	}
 476	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 477	if err != nil {
 478		s.notFound(w, r)
 479		return
 480	}
 481	name := r.PathValue("name")
 482	found := false
 483	for _, a := range rel.Assets {
 484		if a.Name == name {
 485			found = true
 486		}
 487	}
 488	if !found {
 489		s.notFound(w, r)
 490		return
 491	}
 492	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 493		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 494	if err != nil {
 495		s.notFound(w, r)
 496		return
 497	}
 498	defer f.Close()
 499	w.Header().Set("Content-Type", "application/octet-stream")
 500	w.Header().Set("X-Content-Type-Options", "nosniff")
 501	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 502	if fi, err := f.Stat(); err == nil {
 503		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 504	}
 505	io.Copy(w, f)
 506}
 507
 508// milestones lists a repo's milestones with progress.
 509func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 510	p, ok := s.repoFor(w, r, "")
 511	if !ok {
 512		return
 513	}
 514	p.Tab = "issues"
 515	state := r.URL.Query().Get("state")
 516	if state != "closed" && state != "all" {
 517		state = "open"
 518	}
 519	ms, err := s.st.ListMilestones(p.Repo.ID, state)
 520	if err != nil {
 521		http.Error(w, "internal error", http.StatusInternalServerError)
 522		return
 523	}
 524	type msView struct {
 525		store.Milestone
 526		Percent int
 527	}
 528	var views []msView
 529	for _, m := range ms {
 530		v := msView{Milestone: m}
 531		if total := m.OpenItems + m.ClosedItems; total > 0 {
 532			v.Percent = m.ClosedItems * 100 / total
 533		}
 534		views = append(views, v)
 535	}
 536	s.render(w, "milestones.html", struct {
 537		repoPage
 538		State      string
 539		Milestones []msView
 540	}{p, state, views})
 541}
 542
 543// search runs a bounded literal git grep over the repo's default branch.
 544func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 545	p, ok := s.repoFor(w, r, "")
 546	if !ok {
 547		return
 548	}
 549	p.Tab = "search"
 550	q := strings.TrimSpace(r.URL.Query().Get("q"))
 551	type matchView struct {
 552		Path     string
 553		Line     int
 554		TextHTML template.HTML
 555	}
 556	var matches []matchView
 557	var queryErr string
 558	if q != "" {
 559		if len(q) < 2 || len(q) > 200 {
 560			queryErr = "query must be 2 to 200 characters"
 561		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 562			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 563			if err != nil {
 564				http.Error(w, "internal error", http.StatusInternalServerError)
 565				return
 566			}
 567			for _, m := range raw {
 568				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 569			}
 570		}
 571	}
 572	s.render(w, "search.html", struct {
 573		repoPage
 574		Query    string
 575		QueryErr string
 576		Matches  []matchView
 577		Capped   bool
 578	}{p, q, queryErr, matches, len(matches) == 200})
 579}
 580
 581// markMatch escapes a matched line and wraps case-insensitive occurrences
 582// of the query in <mark>.
 583func markMatch(text, q string) template.HTML {
 584	lower, lq := strings.ToLower(text), strings.ToLower(q)
 585	var b strings.Builder
 586	pos := 0
 587	for {
 588		i := strings.Index(lower[pos:], lq)
 589		if i < 0 {
 590			break
 591		}
 592		i += pos
 593		b.WriteString(template.HTMLEscapeString(text[pos:i]))
 594		b.WriteString("<mark>")
 595		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
 596		b.WriteString("</mark>")
 597		pos = i + len(q)
 598	}
 599	b.WriteString(template.HTMLEscapeString(text[pos:]))
 600	return template.HTML(b.String())
 601}
 602
 603// blamePageSize caps how many lines one blame page renders; blame is a
 604// per-line subprocess cost, so large files paginate.
 605const blamePageSize = 1000
 606
 607func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 608	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 609	if !ok {
 610		return
 611	}
 612	p.Tab = "files"
 613	filePath := strings.Trim(r.PathValue("path"), "/")
 614	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 615	if err != nil {
 616		s.notFound(w, r)
 617		return
 618	}
 619	total := bytes.Count(data, []byte("\n"))
 620	if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
 621		total++
 622	}
 623	binary := gitutil.IsBinary(data)
 624
 625	type hunkView struct {
 626		gitutil.BlameHunk
 627		ShortSHA string
 628		Date     string
 629		Sig      sigView
 630		Numbered []numberedLine
 631	}
 632	var hunks []hunkView
 633	page, pages := 1, (total+blamePageSize-1)/blamePageSize
 634	if pages == 0 {
 635		pages = 1
 636	}
 637	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
 638		page = n
 639	}
 640	if !binary && total > 0 {
 641		start := (page-1)*blamePageSize + 1
 642		end := min(total, page*blamePageSize)
 643		raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
 644		if err != nil {
 645			s.notFound(w, r)
 646			return
 647		}
 648		sigs := map[string]sigView{}
 649		for _, h := range raw {
 650			v, ok := sigs[h.SHA]
 651			if !ok {
 652				v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 653				sigs[h.SHA] = v
 654			}
 655			hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
 656				Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
 657			for i, l := range h.Lines {
 658				hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 659			}
 660			hunks = append(hunks, hv)
 661		}
 662	}
 663	cs := crumbs(p, "blame", filePath)
 664	base := ""
 665	if len(cs) > 0 {
 666		base = cs[len(cs)-1].Name
 667		cs = cs[:len(cs)-1]
 668	}
 669	s.render(w, "blame.html", struct {
 670		repoPage
 671		Crumbs      []crumb
 672		Base        string
 673		Path        string
 674		Binary      bool
 675		Hunks       []hunkView
 676		Page, Pages int
 677	}{p, cs, base, filePath, binary, hunks, page, pages})
 678}
 679
 680type numberedLine struct {
 681	N    int
 682	Text string
 683}
 684
 685func highlight(filePath string, data []byte) template.HTML {
 686	lexer := lexers.Match(filePath)
 687	if lexer == nil {
 688		lexer = lexers.Fallback
 689	}
 690	style := styles.Get("friendly")
 691	formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false),
 692		html.WithLinkableLineNumbers(true, "L"))
 693	iterator, err := lexer.Tokenise(nil, string(data))
 694	if err != nil {
 695		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 696	}
 697	var buf bytes.Buffer
 698	if err := formatter.Format(&buf, style, iterator); err != nil {
 699		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 700	}
 701	return template.HTML(buf.String())
 702}
 703
 704func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 705	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 706	if !ok {
 707		return
 708	}
 709	filePath := strings.Trim(r.PathValue("path"), "/")
 710	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 711	if err != nil {
 712		s.notFound(w, r)
 713		return
 714	}
 715	// Serve inert: never let repo content execute in the forge's origin.
 716	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
 717	w.Header().Set("X-Content-Type-Options", "nosniff")
 718	w.Write(data)
 719}
 720
 721// readmeRank orders competing README files: richer renderers win.
 722var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 723
 724// pickReadme returns the best README-ish blob in a tree listing: any file
 725// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 726// we can render richly.
 727func pickReadme(entries []gitutil.TreeEntry) string {
 728	best, bestRank := "", 1<<30
 729	for _, e := range entries {
 730		if e.Type != "blob" {
 731			continue
 732		}
 733		lower := strings.ToLower(e.Name)
 734		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 735			continue
 736		}
 737		rank, ok := readmeRank[path.Ext(lower)]
 738		if !ok {
 739			rank = 10 // plaintext fallback
 740		}
 741		if rank < bestRank {
 742			best, bestRank = e.Name, rank
 743		}
 744	}
 745	return best
 746}
 747
 748// mdHTML renders user-authored markdown (issue and MR bodies, comments).
 749// goldmark's default renderer drops raw HTML, so this is safe as-is.
 750func mdHTML(raw string) template.HTML {
 751	if strings.TrimSpace(raw) == "" {
 752		return ""
 753	}
 754	var buf bytes.Buffer
 755	if goldmark.Convert([]byte(raw), &buf) != nil {
 756		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
 757	}
 758	return template.HTML(buf.String())
 759}
 760
 761// webResolver answers autolink lookups for one viewer. Cross-repo
 762// references to repositories the viewer cannot read stay plain text, per
 763// the enumeration rule: a link would confirm the repo exists.
 764type webResolver struct {
 765	s      *Server
 766	viewer store.User
 767}
 768
 769func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
 770	repo, err := r.s.st.RepoByPath(owner + "/" + name)
 771	if err != nil {
 772		return ""
 773	}
 774	grant := ""
 775	if r.viewer.ID != 0 {
 776		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
 777	}
 778	if !policy.CanRead(r.viewer, repo, grant) {
 779		return ""
 780	}
 781	if kind == '#' {
 782		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
 783			return ""
 784		}
 785		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
 786	}
 787	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
 788		return ""
 789	}
 790	return autolink.MRURL(repo.OwnerName, repo.Name, n)
 791}
 792
 793func (r webResolver) UserURL(name string) string {
 794	if _, err := r.s.st.UserByUsername(name); err == nil {
 795		return "/" + name
 796	}
 797	if _, err := r.s.st.OrgByName(name); err == nil {
 798		return "/" + name
 799	}
 800	return ""
 801}
 802
 803// ugcFor returns a renderer for user-authored markdown on one repo's pages:
 804// mdHTML plus cross-reference and mention autolinking for this viewer.
 805func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
 806	viewer := store.User{}
 807	if s.cfg.Web.Mode == "accounts" {
 808		viewer = s.viewer(r)
 809	}
 810	res := webResolver{s, viewer}
 811	return func(raw string) template.HTML {
 812		h := mdHTML(raw)
 813		if h == "" {
 814			return h
 815		}
 816		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
 817	}
 818}
 819
 820// renderedComment pairs a comment with its rendered body for templates.
 821type renderedComment struct {
 822	Author    string
 823	CreatedAt string
 824	Kind      string
 825	BodyHTML  template.HTML
 826}
 827
 828func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
 829	var out []renderedComment
 830	for _, c := range cs {
 831		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
 832	}
 833	return out
 834}
 835
 836// ugcPolicy sanitizes rendered repo content before it enters the forge's
 837// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
 838// output and repo-authored HTML are not.
 839var ugcPolicy = bluemonday.UGCPolicy()
 840
 841// renderReadme renders a README by extension: markdown, org-mode, and
 842// (sanitized) HTML richly; everything else as escaped plaintext.
 843func renderReadme(name string, raw []byte) template.HTML {
 844	plain := func() template.HTML {
 845		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
 846	}
 847	if gitutil.IsBinary(raw) {
 848		return ""
 849	}
 850	switch path.Ext(strings.ToLower(name)) {
 851	case ".md", ".markdown":
 852		var buf bytes.Buffer
 853		if goldmark.Convert(raw, &buf) != nil {
 854			return plain()
 855		}
 856		return template.HTML(buf.String())
 857	case ".org":
 858		doc := org.New().Parse(bytes.NewReader(raw), name)
 859		html, err := doc.Write(org.NewHTMLWriter())
 860		if err != nil {
 861			return plain()
 862		}
 863		return template.HTML(ugcPolicy.Sanitize(html))
 864	case ".html", ".htm":
 865		return template.HTML(ugcPolicy.Sanitize(string(raw)))
 866	default:
 867		return plain()
 868	}
 869}
 870
 871type diffLine struct {
 872	Class   string
 873	Text    string
 874	Path    string // file this line belongs to
 875	NewLine int64  // line number in the new file (0 when absent)
 876	OldLine int64  // line number in the old file (0 when absent)
 877	Threads []diffThread
 878}
 879
 880var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
 881
 882// classifyDiff parses a unified diff into rendered lines, tracking the
 883// file and old/new line numbers so review threads can anchor inline.
 884func classifyDiff(patch string) []diffLine {
 885	var lines []diffLine
 886	path := ""
 887	var oldN, newN int64
 888	for _, l := range strings.Split(patch, "\n") {
 889		d := diffLine{Text: l}
 890		switch {
 891		case strings.HasPrefix(l, "+++ "):
 892			d.Class = "meta"
 893			path = strings.TrimPrefix(strings.TrimPrefix(l, "+++ "), "b/")
 894		case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
 895			d.Class = "meta"
 896		case strings.HasPrefix(l, "@@"):
 897			d.Class = "hunk"
 898			if m := hunkPat.FindStringSubmatch(l); m != nil {
 899				oldN, _ = strconv.ParseInt(m[1], 10, 64)
 900				newN, _ = strconv.ParseInt(m[2], 10, 64)
 901			}
 902		case strings.HasPrefix(l, "+"):
 903			d.Class, d.Path, d.NewLine = "add", path, newN
 904			newN++
 905		case strings.HasPrefix(l, "-"):
 906			d.Class, d.Path, d.OldLine = "del", path, oldN
 907			oldN++
 908		default:
 909			d.Path, d.OldLine, d.NewLine = path, oldN, newN
 910			oldN++
 911			newN++
 912		}
 913		lines = append(lines, d)
 914	}
 915	return lines
 916}
 917
 918type diffThread struct {
 919	ID       int64
 920	Resolved string
 921	Stale    bool
 922	Comments []renderedComment
 923}
 924
 925// attachThreads injects review threads under their anchored diff lines;
 926// threads whose anchor no longer appears (stale after force-push, or on a
 927// context line outside the current diff) are returned separately.
 928func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) {
 929	type anchor struct {
 930		path string
 931		side string
 932		line int64
 933	}
 934	threads := map[int64]*diffThread{}
 935	anchors := map[int64]anchor{}
 936	var order []int64
 937	for _, cm := range comments {
 938		if cm.ReplyTo == 0 {
 939			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
 940				Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
 941			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
 942			order = append(order, cm.ID)
 943		} else if th, ok := threads[cm.ReplyTo]; ok {
 944			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
 945		}
 946	}
 947	placed := map[int64]bool{}
 948	for i := range lines {
 949		for _, id := range order {
 950			if placed[id] || threads[id].Stale {
 951				continue
 952			}
 953			a := anchors[id]
 954			if lines[i].Path != a.path {
 955				continue
 956			}
 957			if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
 958				(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
 959				lines[i].Threads = append(lines[i].Threads, *threads[id])
 960				placed[id] = true
 961			}
 962		}
 963	}
 964	var unplaced []diffThread
 965	for _, id := range order {
 966		if !placed[id] {
 967			unplaced = append(unplaced, *threads[id])
 968		}
 969	}
 970	return lines, unplaced
 971}
 972
 973type sigView struct {
 974	State       string
 975	Signer      string
 976	Fingerprint string
 977}
 978
 979func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
 980	raw, err := gitutil.ReadCommit(dir, sha)
 981	if err != nil {
 982		return sigView{State: "unsigned"}, nil
 983	}
 984	parsed, err := sig.ParseCommit(raw)
 985	if err != nil {
 986		return sigView{State: "unsigned"}, nil
 987	}
 988	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
 989	if err != nil {
 990		return sigView{State: "unsigned"}, parsed
 991	}
 992	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
 993	if res.SignerUserID != 0 {
 994		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
 995			v.Signer = u.Username
 996		}
 997	}
 998	return v, parsed
 999}
1000
1001func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1002	ref := r.PathValue("ref")
1003	p, ok := s.repoFor(w, r, ref)
1004	if !ok {
1005		return
1006	}
1007	p.Tab = "log"
1008	const pageSize = 50
1009	shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1010	if err != nil {
1011		s.notFound(w, r)
1012		return
1013	}
1014	next := ""
1015	if len(shas) > pageSize {
1016		next = shas[pageSize]
1017		shas = shas[:pageSize]
1018	}
1019	type row struct {
1020		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
1021		Sig                                                   sigView
1022	}
1023	var rows []row
1024	for _, sha := range shas {
1025		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1026		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
1027		if parsed != nil {
1028			rw.Subject = parsed.Subject
1029			rw.AuthorName = parsed.AuthorName
1030			rw.AuthorEmail = parsed.AuthorEmail
1031			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1032		}
1033		rows = append(rows, rw)
1034	}
1035	s.render(w, "log.html", struct {
1036		repoPage
1037		Commits []row
1038		NextSHA string
1039	}{p, rows, next})
1040}
1041
1042func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1043	p, ok := s.repoFor(w, r, "")
1044	if !ok {
1045		return
1046	}
1047	p.Tab = "log"
1048	sha := r.PathValue("sha")
1049	full, err := gitutil.ResolveRef(p.Dir, sha)
1050	if err != nil {
1051		s.notFound(w, r)
1052		return
1053	}
1054	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1055	if parsed == nil {
1056		s.notFound(w, r)
1057		return
1058	}
1059	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1060	lines := classifyDiff(patch)
1061	committerEmail := ""
1062	if parsed.CommitterEmail != parsed.AuthorEmail {
1063		committerEmail = parsed.CommitterEmail
1064	}
1065	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1066	msg := ""
1067	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1068		msg = string(parsed.Payload[i+2:])
1069	}
1070	s.render(w, "commit.html", struct {
1071		repoPage
1072		SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
1073		Parents                                                               []string
1074		Sig                                                                   sigView
1075		Checks                                                                []store.CommitStatus
1076		DiffLines                                                             []diffLine
1077	}{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
1078		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1079		gitutil.Parents(p.Dir, full), v, checks, lines})
1080}
1081
1082// labelPalette provides default label chip colors: mid-tone hues that stay
1083// legible on light and dark backgrounds.
1084var labelPalette = []string{
1085	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1086	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1087}
1088
1089var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1090
1091// labelColors returns a complete label-name -> chip color map for a repo:
1092// the stored labels.color when it is a valid hex color, otherwise a
1093// stable default picked from the palette by name hash.
1094func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1095	stored, _ := s.st.LabelColors(repoID)
1096	out := make(map[string]template.CSS, len(stored))
1097	for name, color := range stored {
1098		if !hexColorPat.MatchString(color) {
1099			h := fnv.New32a()
1100			h.Write([]byte(name))
1101			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1102		}
1103		out[name] = template.CSS("--chip:" + color)
1104	}
1105	return out
1106}
1107
1108func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1109	p, ok := s.repoFor(w, r, "")
1110	if !ok {
1111		return
1112	}
1113	p.Tab = "issues"
1114	state := r.URL.Query().Get("state")
1115	if state != "closed" && state != "all" {
1116		state = "open"
1117	}
1118	issues, err := s.st.ListIssues(p.Repo.ID, state)
1119	if err != nil {
1120		http.Error(w, "internal error", http.StatusInternalServerError)
1121		return
1122	}
1123	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1124		for i := range issues {
1125			issues[i].Labels = labels[issues[i].ID]
1126		}
1127	}
1128	// ?label=x narrows to issues carrying that label (chips link here).
1129	labelFilter := r.URL.Query().Get("label")
1130	if labelFilter != "" {
1131		var kept []store.Issue
1132		for _, iss := range issues {
1133			for _, l := range iss.Labels {
1134				if l == labelFilter {
1135					kept = append(kept, iss)
1136					break
1137				}
1138			}
1139		}
1140		issues = kept
1141	}
1142	s.render(w, "issues.html", struct {
1143		repoPage
1144		State       string
1145		Label       string
1146		Issues      []store.Issue
1147		LabelColors map[string]template.CSS
1148	}{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1149}
1150
1151func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1152	p, ok := s.repoFor(w, r, "")
1153	if !ok {
1154		return
1155	}
1156	p.Tab = "issues"
1157	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1158	if err != nil {
1159		s.notFound(w, r)
1160		return
1161	}
1162	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1163	if err != nil {
1164		s.notFound(w, r)
1165		return
1166	}
1167	comments, err := s.st.ListIssueComments(iss.ID)
1168	if err != nil {
1169		http.Error(w, "internal error", http.StatusInternalServerError)
1170		return
1171	}
1172	md := s.ugcFor(r, p.Repo)
1173	s.render(w, "issue.html", struct {
1174		repoPage
1175		Issue       store.Issue
1176		BodyHTML    template.HTML
1177		Comments    []renderedComment
1178		CanEdit     bool
1179		LabelColors map[string]template.CSS
1180	}{p, iss, md(iss.Body), renderComments(comments, md),
1181		s.canEditItem(r, p.Repo, iss.Author), s.labelColors(p.Repo.ID)})
1182}
1183
1184// canEditItem: the author or anyone with write access may edit.
1185func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1186	if s.cfg.Web.Mode != "accounts" {
1187		return false
1188	}
1189	u := s.viewer(r)
1190	if u.ID == 0 {
1191		return false
1192	}
1193	if u.Username == author {
1194		return true
1195	}
1196	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1197	return policy.CanWrite(u, repo, grant)
1198}
1199
1200func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1201	p, ok := s.repoFor(w, r, "")
1202	if !ok {
1203		return
1204	}
1205	p.Tab = "merge requests"
1206	state := r.URL.Query().Get("state")
1207	if state == "" {
1208		state = "open"
1209	}
1210	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1211	if !valid[state] {
1212		state = "open"
1213	}
1214	mrs, err := s.st.ListMRs(p.Repo.ID, state)
1215	if err != nil {
1216		http.Error(w, "internal error", http.StatusInternalServerError)
1217		return
1218	}
1219	s.render(w, "mrs.html", struct {
1220		repoPage
1221		State string
1222		MRs   []store.MR
1223	}{p, state, mrs})
1224}
1225
1226func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1227	p, ok := s.repoFor(w, r, "")
1228	if !ok {
1229		return
1230	}
1231	p.Tab = "merge requests"
1232	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1233	if err != nil {
1234		s.notFound(w, r)
1235		return
1236	}
1237	m, err := s.st.MRByNumber(p.Repo.ID, n)
1238	if err != nil {
1239		s.notFound(w, r)
1240		return
1241	}
1242	comments, _ := s.st.ListMRComments(m.ID)
1243	reviews, _ := s.st.ListMRReviews(m.ID)
1244	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1245	diffComments, _ := s.st.ListDiffComments(m.ID)
1246
1247	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1248	var lines []diffLine
1249	base := m.MergedBase
1250	if base == "" {
1251		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1252			base = b
1253		}
1254	}
1255	if base != "" {
1256		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1257			lines = classifyDiff(patch)
1258		}
1259	}
1260	md := s.ugcFor(r, p.Repo)
1261	var detachedThreads []diffThread
1262	lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
1263	type diffStat struct{ Files, Adds, Dels int }
1264	var stat diffStat
1265	seenFiles := map[string]bool{}
1266	for _, l := range lines {
1267		switch l.Class {
1268		case "add":
1269			stat.Adds++
1270		case "del":
1271			stat.Dels++
1272		}
1273		if l.Path != "" && !seenFiles[l.Path] {
1274			seenFiles[l.Path] = true
1275			stat.Files++
1276		}
1277	}
1278	s.render(w, "mr.html", struct {
1279		repoPage
1280		MR              store.MR
1281		BodyHTML        template.HTML
1282		Checks          []store.CommitStatus
1283		Combined        string
1284		Comments        []renderedComment
1285		Reviews         []store.MRReview
1286		DiffLines       []diffLine
1287		Stat            diffStat
1288		CanEdit         bool
1289		DetachedThreads []diffThread
1290	}{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1291		reviews, lines, stat, s.canEditItem(r, p.Repo, m.Author), detachedThreads})
1292}
1293
1294func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1295	p, ok := s.repoFor(w, r, "")
1296	if !ok {
1297		return
1298	}
1299	p.Tab = "refs"
1300	branches, _ := gitutil.Refs(p.Dir, "heads")
1301	tags, _ := gitutil.Refs(p.Dir, "tags")
1302	s.render(w, "refs.html", struct {
1303		repoPage
1304		Branches, Tags []gitutil.Ref
1305	}{p, branches, tags})
1306}
1307
1308func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1309	p, ok := s.repoFor(w, r, "")
1310	if !ok {
1311		return
1312	}
1313	file := r.PathValue("file")
1314	ref, ok := strings.CutSuffix(file, ".tar.gz")
1315	if !ok {
1316		s.notFound(w, r)
1317		return
1318	}
1319	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1320		s.notFound(w, r)
1321		return
1322	}
1323	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1324	w.Header().Set("Content-Type", "application/gzip")
1325	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1326	gitutil.Archive(p.Dir, ref, prefix, w)
1327}
1328
1329func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1330	return policy.CanRead(u, repo, grant)
1331}