internal/httpd/web.go

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

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