internal/httpd/web.go

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