internal/httpd/web.go

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

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