internal/httpd/web.go

471902dd1405f81f8414ea60ae8526cda92387cc
gitbay/internal/httpd/web.go history · blame · raw

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