internal/httpd/web.go

d122636fc915dc95728bc74a3990da1630aa36d3
gitbay/internal/httpd/web.go history · blame · raw

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