internal/httpd/web.go

7b5be134cce1a087ae1096772cbd6f76716d307f
gitbay/internal/httpd/web.go history · blame · raw

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