internal/httpd/web.go

027899f05d625769c881aeac38b7ef6bbfe4fa4f
gitbay/internal/httpd/web.go history · blame · raw

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