internal/httpd/web.go

399281c14bab9829ff98203d7ccb5e16d42dc7f3
gitbay/internal/httpd/web.go history · blame · raw

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