internal/httpd/web.go

0db33b1ad160bda8a1fd2900e84c654ba25f1218
gitbay/internal/httpd/web.go history · blame · raw

1550 lines · 45194 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	teams, canAdmin := s.orgAdminView(viewer, kind, name)
 396	s.render(w, "owner.html", struct {
 397		basePage
 398		Owner         string
 399		Kind          string
 400		Profile       store.Profile
 401		Repos         []describedRepo
 402		Members       []store.OrgMember
 403		Orgs          []store.OrgMember
 404		Activity      []activityWeek
 405		ActivityTotal int
 406		Teams         []teamView
 407		CanAdmin      bool
 408		Notice        string
 409	}{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs,
 410		weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")})
 411}
 412
 413func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
 414	p, ok := s.repoFor(w, r, "")
 415	if !ok {
 416		return
 417	}
 418	p.Tab = "files"
 419	p.RepoHome = true
 420	s.renderTree(w, r, p, "")
 421}
 422
 423func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
 424	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 425	if !ok {
 426		return
 427	}
 428	p.Tab = "files"
 429	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
 430}
 431
 432// treePage is shared by the populated and empty-repository renders: two
 433// anonymous structs drifted apart once already.
 434type treePage struct {
 435	repoPage
 436	Crumbs      []crumb
 437	Prefix      string
 438	DirPath     string
 439	RefKind     string
 440	Entries     []gitutil.TreeEntry
 441	Branches    []gitutil.Ref
 442	ReadmeName  string
 443	ReadmeHTML  template.HTML
 444	LastCommits map[string]namedCommit
 445	Tip         namedCommit
 446	Facts       repoFacts
 447}
 448
 449func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
 450	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
 451		// Empty repo: render the page with no entries rather than 404.
 452		s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"})
 453		return
 454	}
 455	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
 456	if err != nil {
 457		s.notFound(w, r)
 458		return
 459	}
 460	// Directories first. git's tree order interleaves them with files, but
 461	// a listing is scanned by shape before name. Stable, so each group
 462	// keeps the ordering git gave it.
 463	sort.SliceStable(entries, func(i, j int) bool {
 464		return entries[i].Type == "tree" && entries[j].Type != "tree"
 465	})
 466	prefix := ""
 467	if dirPath != "" {
 468		prefix = dirPath + "/"
 469	}
 470
 471	var readmeHTML template.HTML
 472	readmeName := pickReadme(entries)
 473	if readmeName != "" {
 474		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
 475			readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
 476		}
 477	}
 478
 479	branches, _ := gitutil.Refs(p.Dir, "heads")
 480	names := make([]string, 0, len(entries))
 481	for _, e := range entries {
 482		names = append(names, e.Name)
 483	}
 484	// The facts bar is about the repository, not this directory, so it is
 485	// computed once at the root and left off subdirectory listings.
 486	var facts repoFacts
 487	if dirPath == "" {
 488		facts = s.factsFor(p)
 489	}
 490	s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
 491		readmeName, readmeHTML,
 492		s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
 493		s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts})
 494}
 495
 496func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
 497	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 498	if !ok {
 499		return
 500	}
 501	p.Tab = "files"
 502	filePath := strings.Trim(r.PathValue("path"), "/")
 503	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
 504	if err != nil {
 505		s.notFound(w, r)
 506		return
 507	}
 508	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
 509	_, image := imageTypes[strings.ToLower(path.Ext(filePath))]
 510
 511	var codeHTML template.HTML
 512	if !binary && !image {
 513		codeHTML = highlight(filePath, data)
 514	}
 515	cs := crumbs(p, "blob", filePath)
 516	base := ""
 517	if len(cs) > 0 {
 518		base = cs[len(cs)-1].Name
 519		cs = cs[:len(cs)-1]
 520	}
 521	branches, _ := gitutil.Refs(p.Dir, "heads")
 522	lines := 0
 523	if !binary && !image && len(data) > 0 {
 524		lines = bytes.Count(data, []byte("\n"))
 525		if data[len(data)-1] != '\n' {
 526			lines++
 527		}
 528	}
 529	// The file listing leads with the last commit now, so the facts about
 530	// the file itself are reported here instead.
 531	entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
 532	s.render(w, "blob.html", struct {
 533		repoPage
 534		Crumbs   []crumb
 535		Base     string
 536		Path     string
 537		DirPath  string
 538		RefKind  string
 539		Binary   bool
 540		Image    bool
 541		Size     int
 542		Lines    int
 543		Exec     bool
 544		Symlink  bool
 545		Branches []gitutil.Ref
 546		CodeHTML template.HTML
 547	}{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
 548		entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML})
 549}
 550
 551// releases lists tag-anchored releases with notes and assets.
 552func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 553	p, ok := s.repoFor(w, r, "")
 554	if !ok {
 555		return
 556	}
 557	p.Tab = "releases"
 558	rels, err := s.st.ListReleases(p.Repo.ID)
 559	if err != nil {
 560		http.Error(w, "internal error", http.StatusInternalServerError)
 561		return
 562	}
 563	md := s.ugcFor(r, p.Repo)
 564	type relView struct {
 565		store.Release
 566		NotesHTML template.HTML
 567	}
 568	var views []relView
 569	for _, rel := range rels {
 570		views = append(views, relView{rel, md(rel.Notes)})
 571	}
 572	// Tags without a release yet are what a create form can offer.
 573	released := map[string]bool{}
 574	for _, rel := range rels {
 575		released[rel.Tag] = true
 576	}
 577	var freeTags []string
 578	if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
 579		for _, tg := range tags {
 580			if !released[tg.Name] {
 581				freeTags = append(freeTags, tg.Name)
 582			}
 583		}
 584	}
 585	s.render(w, "releases.html", struct {
 586		repoPage
 587		Releases []relView
 588		FreeTags []string
 589		CanWrite bool
 590		Notice   string
 591	}{p, views, freeTags, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")})
 592}
 593
 594// releaseAsset streams one uploaded asset. Tags containing '/' are not
 595// reachable here (single path segment); SSH download always works.
 596func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 597	p, ok := s.repoFor(w, r, "")
 598	if !ok {
 599		return
 600	}
 601	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 602	if err != nil {
 603		s.notFound(w, r)
 604		return
 605	}
 606	name := r.PathValue("name")
 607	found := false
 608	for _, a := range rel.Assets {
 609		if a.Name == name {
 610			found = true
 611		}
 612	}
 613	if !found {
 614		s.notFound(w, r)
 615		return
 616	}
 617	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 618		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 619	if err != nil {
 620		s.notFound(w, r)
 621		return
 622	}
 623	defer f.Close()
 624	w.Header().Set("Content-Type", "application/octet-stream")
 625	w.Header().Set("X-Content-Type-Options", "nosniff")
 626	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 627	if fi, err := f.Stat(); err == nil {
 628		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 629	}
 630	io.Copy(w, f)
 631}
 632
 633// milestones lists a repo's milestones with progress.
 634func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 635	p, ok := s.repoFor(w, r, "")
 636	if !ok {
 637		return
 638	}
 639	p.Tab = "issues"
 640	state := r.URL.Query().Get("state")
 641	if state != "closed" && state != "all" {
 642		state = "open"
 643	}
 644	ms, err := s.st.ListMilestones(p.Repo.ID, state)
 645	if err != nil {
 646		http.Error(w, "internal error", http.StatusInternalServerError)
 647		return
 648	}
 649	type msView struct {
 650		store.Milestone
 651		Percent int
 652	}
 653	var views []msView
 654	for _, m := range ms {
 655		v := msView{Milestone: m}
 656		if total := m.OpenItems + m.ClosedItems; total > 0 {
 657			v.Percent = m.ClosedItems * 100 / total
 658		}
 659		views = append(views, v)
 660	}
 661	s.render(w, "milestones.html", struct {
 662		repoPage
 663		State      string
 664		Milestones []msView
 665	}{p, state, views})
 666}
 667
 668// search runs a bounded literal git grep over the repo's default branch.
 669func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 670	p, ok := s.repoFor(w, r, "")
 671	if !ok {
 672		return
 673	}
 674	p.Tab = "search"
 675	q := strings.TrimSpace(r.URL.Query().Get("q"))
 676	type matchView struct {
 677		Path     string
 678		Line     int
 679		TextHTML template.HTML
 680	}
 681	var matches []matchView
 682	var queryErr string
 683	if q != "" {
 684		if len(q) < 2 || len(q) > 200 {
 685			queryErr = "query must be 2 to 200 characters"
 686		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 687			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 688			if err != nil {
 689				http.Error(w, "internal error", http.StatusInternalServerError)
 690				return
 691			}
 692			for _, m := range raw {
 693				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 694			}
 695		}
 696	}
 697	s.render(w, "search.html", struct {
 698		repoPage
 699		Query    string
 700		QueryErr string
 701		Matches  []matchView
 702		Capped   bool
 703	}{p, q, queryErr, matches, len(matches) == 200})
 704}
 705
 706// markMatch escapes a matched line and wraps case-insensitive occurrences
 707// of the query in <mark>.
 708func markMatch(text, q string) template.HTML {
 709	lower, lq := strings.ToLower(text), strings.ToLower(q)
 710	var b strings.Builder
 711	pos := 0
 712	for {
 713		i := strings.Index(lower[pos:], lq)
 714		if i < 0 {
 715			break
 716		}
 717		i += pos
 718		b.WriteString(template.HTMLEscapeString(text[pos:i]))
 719		b.WriteString("<mark>")
 720		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
 721		b.WriteString("</mark>")
 722		pos = i + len(q)
 723	}
 724	b.WriteString(template.HTMLEscapeString(text[pos:]))
 725	return template.HTML(b.String())
 726}
 727
 728// blamePageSize caps how many lines one blame page renders; blame is a
 729// per-line subprocess cost, so large files paginate.
 730const blamePageSize = 1000
 731
 732func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 733	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 734	if !ok {
 735		return
 736	}
 737	p.Tab = "files"
 738	filePath := strings.Trim(r.PathValue("path"), "/")
 739	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 740	if err != nil {
 741		s.notFound(w, r)
 742		return
 743	}
 744	total := bytes.Count(data, []byte("\n"))
 745	if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
 746		total++
 747	}
 748	binary := gitutil.IsBinary(data)
 749
 750	type hunkView struct {
 751		gitutil.BlameHunk
 752		ShortSHA string
 753		Date     string
 754		Sig      sigView
 755		Numbered []numberedLine
 756	}
 757	var hunks []hunkView
 758	page, pages := 1, (total+blamePageSize-1)/blamePageSize
 759	if pages == 0 {
 760		pages = 1
 761	}
 762	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
 763		page = n
 764	}
 765	if !binary && total > 0 {
 766		start := (page-1)*blamePageSize + 1
 767		end := min(total, page*blamePageSize)
 768		raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
 769		if err != nil {
 770			s.notFound(w, r)
 771			return
 772		}
 773		sigs := map[string]sigView{}
 774		for _, h := range raw {
 775			v, ok := sigs[h.SHA]
 776			if !ok {
 777				v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 778				sigs[h.SHA] = v
 779			}
 780			hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
 781				Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
 782			for i, l := range h.Lines {
 783				hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 784			}
 785			hunks = append(hunks, hv)
 786		}
 787	}
 788	cs := crumbs(p, "blame", filePath)
 789	base := ""
 790	if len(cs) > 0 {
 791		base = cs[len(cs)-1].Name
 792		cs = cs[:len(cs)-1]
 793	}
 794	s.render(w, "blame.html", struct {
 795		repoPage
 796		Crumbs      []crumb
 797		Base        string
 798		Path        string
 799		Binary      bool
 800		Hunks       []hunkView
 801		Page, Pages int
 802	}{p, cs, base, filePath, binary, hunks, page, pages})
 803}
 804
 805type numberedLine struct {
 806	N    int
 807	Text string
 808}
 809
 810// chromaFormatter emits class-based markup (no inline colors), so the
 811// stylesheet can swap palettes with the color scheme.
 812var chromaFormatter = html.New(html.WithClasses(true),
 813	html.WithLineNumbers(true), html.LineNumbersInTable(false),
 814	html.WithLinkableLineNumbers(true, "L"))
 815
 816func highlight(filePath string, data []byte) template.HTML {
 817	lexer := lexers.Match(filePath)
 818	if lexer == nil {
 819		lexer = lexers.Fallback
 820	}
 821	iterator, err := lexer.Tokenise(nil, string(data))
 822	if err != nil {
 823		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 824	}
 825	var buf bytes.Buffer
 826	if err := chromaFormatter.Format(&buf, styles.Get("friendly"), iterator); err != nil {
 827		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 828	}
 829	return template.HTML(buf.String())
 830}
 831
 832// chromaCSS is both syntax palettes: light by default, dark under the same
 833// media query the rest of the stylesheet uses. The site's --code-bg stays
 834// the background either way.
 835var chromaCSS = func() []byte {
 836	var buf bytes.Buffer
 837	chromaFormatter.WriteCSS(&buf, styles.Get("friendly"))
 838	buf.WriteString("\n@media (prefers-color-scheme: dark) {\n")
 839	chromaFormatter.WriteCSS(&buf, styles.Get("github-dark"))
 840	buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
 841	return buf.Bytes()
 842}()
 843
 844func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 845	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 846	if !ok {
 847		return
 848	}
 849	filePath := strings.Trim(r.PathValue("path"), "/")
 850	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 851	if err != nil {
 852		s.notFound(w, r)
 853		return
 854	}
 855	// Serve inert: never let repo content execute in the forge's origin.
 856	// Images get their real type so <img> works under nosniff; SVG script
 857	// is dead on arrival because the instance CSP is script-src 'none'.
 858	ct := "text/plain; charset=utf-8"
 859	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
 860		ct = t
 861	}
 862	w.Header().Set("Content-Type", ct)
 863	w.Header().Set("X-Content-Type-Options", "nosniff")
 864	w.Write(data)
 865}
 866
 867// imageTypes are the formats raw serves with a real content type and blob
 868// pages preview inline.
 869var imageTypes = map[string]string{
 870	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
 871	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
 872	".svg": "image/svg+xml", ".ico": "image/x-icon",
 873}
 874
 875// readmeRank orders competing README files: richer renderers win.
 876var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 877
 878// pickReadme returns the best README-ish blob in a tree listing: any file
 879// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 880// we can render richly.
 881func pickReadme(entries []gitutil.TreeEntry) string {
 882	best, bestRank := "", 1<<30
 883	for _, e := range entries {
 884		if e.Type != "blob" {
 885			continue
 886		}
 887		lower := strings.ToLower(e.Name)
 888		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 889			continue
 890		}
 891		rank, ok := readmeRank[path.Ext(lower)]
 892		if !ok {
 893			rank = 10 // plaintext fallback
 894		}
 895		if rank < bestRank {
 896			best, bestRank = e.Name, rank
 897		}
 898	}
 899	return best
 900}
 901
 902// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
 903// task lists) on top of CommonMark, with class-based fence highlighting
 904// (the palette lives in the stylesheet, per scheme). Raw HTML is still
 905// dropped.
 906var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
 907	highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
 908
 909// fenceHighlight renders one code block with chroma classes, for org and
 910// anything else outside goldmark. Unknown languages fall back to plain.
 911func fenceHighlight(source, lang string) string {
 912	lexer := lexers.Get(lang)
 913	if lexer == nil {
 914		lexer = lexers.Fallback
 915	}
 916	iterator, err := lexer.Tokenise(nil, source)
 917	if err != nil {
 918		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 919	}
 920	var buf bytes.Buffer
 921	f := html.New(html.WithClasses(true))
 922	if err := f.Format(&buf, styles.Get("friendly"), iterator); err != nil {
 923		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 924	}
 925	return buf.String()
 926}
 927
 928// mdHTML renders user-authored markdown (issue and MR bodies, comments).
 929// goldmark's default renderer drops raw HTML, so this is safe as-is.
 930func mdHTML(raw string) template.HTML {
 931	if strings.TrimSpace(raw) == "" {
 932		return ""
 933	}
 934	var buf bytes.Buffer
 935	if markdown.Convert([]byte(raw), &buf) != nil {
 936		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
 937	}
 938	return template.HTML(buf.String())
 939}
 940
 941// webResolver answers autolink lookups for one viewer. Cross-repo
 942// references to repositories the viewer cannot read stay plain text, per
 943// the enumeration rule: a link would confirm the repo exists.
 944type webResolver struct {
 945	s      *Server
 946	viewer store.User
 947}
 948
 949func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
 950	repo, err := r.s.st.RepoByPath(owner + "/" + name)
 951	if err != nil {
 952		return ""
 953	}
 954	grant := ""
 955	if r.viewer.ID != 0 {
 956		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
 957	}
 958	if !policy.CanRead(r.viewer, repo, grant) {
 959		return ""
 960	}
 961	if kind == '#' {
 962		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
 963			return ""
 964		}
 965		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
 966	}
 967	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
 968		return ""
 969	}
 970	return autolink.MRURL(repo.OwnerName, repo.Name, n)
 971}
 972
 973func (r webResolver) UserURL(name string) string {
 974	if _, err := r.s.st.UserByUsername(name); err == nil {
 975		return "/" + name
 976	}
 977	if _, err := r.s.st.OrgByName(name); err == nil {
 978		return "/" + name
 979	}
 980	return ""
 981}
 982
 983// ugcFor returns a renderer for user-authored markdown on one repo's pages:
 984// mdHTML plus cross-reference and mention autolinking for this viewer.
 985func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
 986	viewer := store.User{}
 987	if s.cfg.Web.Mode == "accounts" {
 988		viewer = s.viewer(r)
 989	}
 990	res := webResolver{s, viewer}
 991	return func(raw string) template.HTML {
 992		h := mdHTML(raw)
 993		if h == "" {
 994			return h
 995		}
 996		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
 997	}
 998}
 999
1000// renderedComment pairs a comment with its rendered body for templates.
1001type renderedComment struct {
1002	Author    string
1003	CreatedAt string
1004	Kind      string
1005	BodyHTML  template.HTML
1006}
1007
1008func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
1009	var out []renderedComment
1010	for _, c := range cs {
1011		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
1012	}
1013	return out
1014}
1015
1016// ugcPolicy sanitizes rendered repo content before it enters the forge's
1017// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1018// output and repo-authored HTML are not. Chroma's highlighting classes
1019// must survive; the pattern admits only short token codes, not the site's
1020// own class names.
1021var ugcPolicy = func() *bluemonday.Policy {
1022	p := bluemonday.UGCPolicy()
1023	p.AllowAttrs("class").
1024		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1025		OnElements("span", "pre", "code", "div")
1026	return p
1027}()
1028
1029// renderReadme renders a README by extension: markdown, org-mode, and
1030// (sanitized) HTML richly; everything else as escaped plaintext.
1031func renderReadme(name string, raw []byte) template.HTML {
1032	plain := func() template.HTML {
1033		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1034	}
1035	if gitutil.IsBinary(raw) {
1036		return ""
1037	}
1038	switch path.Ext(strings.ToLower(name)) {
1039	case ".md", ".markdown":
1040		var buf bytes.Buffer
1041		if markdown.Convert(raw, &buf) != nil {
1042			return plain()
1043		}
1044		return template.HTML(buf.String())
1045	case ".org":
1046		doc := org.New().Parse(bytes.NewReader(raw), name)
1047		writer := org.NewHTMLWriter()
1048		writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1049			if inline {
1050				return "<code>" + template.HTMLEscapeString(source) + "</code>"
1051			}
1052			return fenceHighlight(source, lang)
1053		}
1054		out, err := doc.Write(writer)
1055		if err != nil {
1056			return plain()
1057		}
1058		return template.HTML(ugcPolicy.Sanitize(out))
1059	case ".html", ".htm":
1060		return template.HTML(ugcPolicy.Sanitize(string(raw)))
1061	default:
1062		return plain()
1063	}
1064}
1065
1066type diffThread struct {
1067	ID       int64
1068	Resolved string
1069	Stale    bool
1070	Comments []renderedComment
1071}
1072
1073// attachThreads injects review threads under their anchored diff lines;
1074// threads whose anchor no longer appears (stale after force-push, or on a
1075// context line outside the current diff) are returned separately.
1076func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffFile, []diffThread) {
1077	type anchor struct {
1078		path string
1079		side string
1080		line int64
1081	}
1082	threads := map[int64]*diffThread{}
1083	anchors := map[int64]anchor{}
1084	var order []int64
1085	for _, cm := range comments {
1086		if cm.ReplyTo == 0 {
1087			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1088				Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
1089			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1090			order = append(order, cm.ID)
1091		} else if th, ok := threads[cm.ReplyTo]; ok {
1092			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
1093		}
1094	}
1095	placed := map[int64]bool{}
1096	for f := range files {
1097		lines := files[f].Lines
1098		for i := range lines {
1099			for _, id := range order {
1100				if placed[id] || threads[id].Stale {
1101					continue
1102				}
1103				a := anchors[id]
1104				if lines[i].Path != a.path {
1105					continue
1106				}
1107				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1108					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1109					lines[i].Threads = append(lines[i].Threads, *threads[id])
1110					files[f].Threads++
1111					files[f].Open = true
1112					placed[id] = true
1113				}
1114			}
1115		}
1116	}
1117	var unplaced []diffThread
1118	for _, id := range order {
1119		if !placed[id] {
1120			unplaced = append(unplaced, *threads[id])
1121		}
1122	}
1123	return files, unplaced
1124}
1125
1126type sigView struct {
1127	State       string
1128	Signer      string
1129	Fingerprint string
1130}
1131
1132func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1133	raw, err := gitutil.ReadCommit(dir, sha)
1134	if err != nil {
1135		return sigView{State: "unsigned"}, nil
1136	}
1137	parsed, err := sig.ParseCommit(raw)
1138	if err != nil {
1139		return sigView{State: "unsigned"}, nil
1140	}
1141	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1142	if err != nil {
1143		return sigView{State: "unsigned"}, parsed
1144	}
1145	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1146	if res.SignerUserID != 0 {
1147		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1148			v.Signer = u.Username
1149		}
1150	}
1151	return v, parsed
1152}
1153
1154func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1155	ref := r.PathValue("ref")
1156	p, ok := s.repoFor(w, r, ref)
1157	if !ok {
1158		return
1159	}
1160	p.Tab = "log"
1161	const pageSize = 50
1162	// ?path= filters to commits touching one file or directory.
1163	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1164	if filePath == "." {
1165		filePath = ""
1166	}
1167	var shas []string
1168	var err error
1169	if filePath != "" {
1170		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1171	} else {
1172		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1173	}
1174	if err != nil {
1175		s.notFound(w, r)
1176		return
1177	}
1178	next := ""
1179	if len(shas) > pageSize {
1180		next = shas[pageSize]
1181		shas = shas[:pageSize]
1182	}
1183	type row struct {
1184		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1185		Sig                                                               sigView
1186		Check                                                             string // combined status, "" when none ran
1187	}
1188	names := s.authorNames()
1189	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1190	var rows []row
1191	for _, sha := range shas {
1192		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1193		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1194		if parsed != nil {
1195			rw.Subject = parsed.Subject
1196			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1197			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1198			rw.AuthorEmail = parsed.AuthorEmail
1199			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1200		}
1201		rows = append(rows, rw)
1202	}
1203	s.render(w, "log.html", struct {
1204		repoPage
1205		Commits  []row
1206		NextSHA  string
1207		FilePath string
1208	}{p, rows, next, filePath})
1209}
1210
1211func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1212	p, ok := s.repoFor(w, r, "")
1213	if !ok {
1214		return
1215	}
1216	p.Tab = "log"
1217	sha := r.PathValue("sha")
1218	full, err := gitutil.ResolveRef(p.Dir, sha)
1219	if err != nil {
1220		s.notFound(w, r)
1221		return
1222	}
1223	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1224	if parsed == nil {
1225		s.notFound(w, r)
1226		return
1227	}
1228	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1229	files := parseDiff(patch)
1230	committerEmail := ""
1231	if parsed.CommitterEmail != parsed.AuthorEmail {
1232		committerEmail = parsed.CommitterEmail
1233	}
1234	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1235	commitNames := s.authorNames()
1236	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1237	msg := ""
1238	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1239		msg = string(parsed.Payload[i+2:])
1240	}
1241	s.render(w, "commit.html", struct {
1242		repoPage
1243		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1244		Parents                                                                           []string
1245		Sig                                                                               sigView
1246		Checks                                                                            []store.CommitStatus
1247		DiffFiles                                                                         []diffFile
1248	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1249		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1250		gitutil.Parents(p.Dir, full), v, checks, files})
1251}
1252
1253// labelPalette provides default label chip colors: mid-tone hues that stay
1254// legible on light and dark backgrounds.
1255var labelPalette = []string{
1256	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1257	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1258}
1259
1260var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1261
1262// labelColors returns a complete label-name -> chip color map for a repo:
1263// the stored labels.color when it is a valid hex color, otherwise a
1264// stable default picked from the palette by name hash.
1265func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1266	stored, _ := s.st.LabelColors(repoID)
1267	out := make(map[string]template.CSS, len(stored))
1268	for name, color := range stored {
1269		if !hexColorPat.MatchString(color) {
1270			h := fnv.New32a()
1271			h.Write([]byte(name))
1272			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1273		}
1274		out[name] = template.CSS("--chip:" + color)
1275	}
1276	return out
1277}
1278
1279func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1280	p, ok := s.repoFor(w, r, "")
1281	if !ok {
1282		return
1283	}
1284	p.Tab = "issues"
1285	state := r.URL.Query().Get("state")
1286	if state != "closed" && state != "all" {
1287		state = "open"
1288	}
1289	issues, err := s.st.ListIssues(p.Repo.ID, state)
1290	if err != nil {
1291		http.Error(w, "internal error", http.StatusInternalServerError)
1292		return
1293	}
1294	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1295		for i := range issues {
1296			issues[i].Labels = labels[issues[i].ID]
1297		}
1298	}
1299	// ?label=x narrows to issues carrying that label (chips link here).
1300	labelFilter := r.URL.Query().Get("label")
1301	if labelFilter != "" {
1302		var kept []store.Issue
1303		for _, iss := range issues {
1304			for _, l := range iss.Labels {
1305				if l == labelFilter {
1306					kept = append(kept, iss)
1307					break
1308				}
1309			}
1310		}
1311		issues = kept
1312	}
1313	s.render(w, "issues.html", struct {
1314		repoPage
1315		State       string
1316		Label       string
1317		Issues      []store.Issue
1318		LabelColors map[string]template.CSS
1319	}{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1320}
1321
1322func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1323	p, ok := s.repoFor(w, r, "")
1324	if !ok {
1325		return
1326	}
1327	p.Tab = "issues"
1328	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1329	if err != nil {
1330		s.notFound(w, r)
1331		return
1332	}
1333	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1334	if err != nil {
1335		s.notFound(w, r)
1336		return
1337	}
1338	comments, err := s.st.ListIssueComments(iss.ID)
1339	if err != nil {
1340		http.Error(w, "internal error", http.StatusInternalServerError)
1341		return
1342	}
1343	md := s.ugcFor(r, p.Repo)
1344	milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1345	s.render(w, "issue.html", struct {
1346		repoPage
1347		Issue       store.Issue
1348		BodyHTML    template.HTML
1349		Comments    []renderedComment
1350		CanEdit     bool
1351		CanWrite    bool
1352		Milestones  []store.Milestone
1353		Notice      string
1354		LabelColors map[string]template.CSS
1355	}{p, iss, md(iss.Body), renderComments(comments, md),
1356		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1357		milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1358}
1359
1360// canEditItem: the author or anyone with write access may edit.
1361// canWriteRepo reports whether the browser session may push to the repo,
1362// which is what gates the review and merge controls.
1363func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1364	if s.cfg.Web.Mode != "accounts" {
1365		return false
1366	}
1367	u := s.viewer(r)
1368	if u.ID == 0 {
1369		return false
1370	}
1371	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1372	return policy.CanWrite(u, repo, grant)
1373}
1374
1375func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1376	if s.cfg.Web.Mode != "accounts" {
1377		return false
1378	}
1379	u := s.viewer(r)
1380	if u.ID == 0 {
1381		return false
1382	}
1383	if u.Username == author {
1384		return true
1385	}
1386	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1387	return policy.CanWrite(u, repo, grant)
1388}
1389
1390func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1391	p, ok := s.repoFor(w, r, "")
1392	if !ok {
1393		return
1394	}
1395	p.Tab = "merge requests"
1396	state := r.URL.Query().Get("state")
1397	if state == "" {
1398		state = "open"
1399	}
1400	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1401	if !valid[state] {
1402		state = "open"
1403	}
1404	mrs, err := s.st.ListMRs(p.Repo.ID, state)
1405	if err != nil {
1406		http.Error(w, "internal error", http.StatusInternalServerError)
1407		return
1408	}
1409	s.render(w, "mrs.html", struct {
1410		repoPage
1411		State string
1412		MRs   []store.MR
1413	}{p, state, mrs})
1414}
1415
1416func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1417	p, ok := s.repoFor(w, r, "")
1418	if !ok {
1419		return
1420	}
1421	p.Tab = "merge requests"
1422	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1423	if err != nil {
1424		s.notFound(w, r)
1425		return
1426	}
1427	m, err := s.st.MRByNumber(p.Repo.ID, n)
1428	if err != nil {
1429		s.notFound(w, r)
1430		return
1431	}
1432	comments, _ := s.st.ListMRComments(m.ID)
1433	reviews, _ := s.st.ListMRReviews(m.ID)
1434	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1435	diffComments, _ := s.st.ListDiffComments(m.ID)
1436
1437	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1438	var files []diffFile
1439	base := m.MergedBase
1440	if base == "" {
1441		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1442			base = b
1443		}
1444	}
1445	if base != "" {
1446		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1447			files = parseDiff(patch)
1448		}
1449	}
1450	md := s.ugcFor(r, p.Repo)
1451	var detachedThreads []diffThread
1452	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md)
1453	stat := statOf(files)
1454	// The commits this MR carries: base..head, the same range as the diff.
1455	type commitRow struct {
1456		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1457		Sig                                                  sigView
1458	}
1459	mrNames := s.authorNames()
1460	var commits []commitRow
1461	if base != "" {
1462		const maxMRCommits = 100
1463		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1464		if len(shas) > maxMRCommits {
1465			shas = shas[:maxMRCommits]
1466		}
1467		for _, sha := range shas {
1468			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1469			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1470			if parsed != nil {
1471				cr.Subject = parsed.Subject
1472				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1473				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1474				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1475			}
1476			commits = append(commits, cr)
1477		}
1478	}
1479	// The diff is the reason most people open a merge request, so it gets
1480	// its own view rather than a fold at the foot of the conversation.
1481	// A query parameter keeps this working without JavaScript.
1482	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1483	view := r.URL.Query().Get("view")
1484	if view != "commits" && view != "diff" {
1485		view = "conversation"
1486	}
1487	s.render(w, "mr.html", struct {
1488		repoPage
1489		MR              store.MR
1490		View            string
1491		BodyHTML        template.HTML
1492		Checks          []store.CommitStatus
1493		Combined        string
1494		Comments        []renderedComment
1495		Reviews         []store.MRReview
1496		DiffFiles       []diffFile
1497		Stat            diffStat
1498		Commits         []commitRow
1499		CanEdit         bool
1500		CanWrite        bool
1501		Unresolved      int
1502		Notice          string
1503		DetachedThreads []diffThread
1504	}{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1505		reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1506		s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
1507}
1508
1509func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1510	p, ok := s.repoFor(w, r, "")
1511	if !ok {
1512		return
1513	}
1514	p.Tab = "refs"
1515	branches, _ := gitutil.Refs(p.Dir, "heads")
1516	tags, _ := gitutil.Refs(p.Dir, "tags")
1517	s.render(w, "refs.html", struct {
1518		repoPage
1519		Branches, Tags []gitutil.Ref
1520	}{p, branches, tags})
1521}
1522
1523func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1524	p, ok := s.repoFor(w, r, "")
1525	if !ok {
1526		return
1527	}
1528	file := r.PathValue("file")
1529	ref, ok := strings.CutSuffix(file, ".tar.gz")
1530	if !ok {
1531		s.notFound(w, r)
1532		return
1533	}
1534	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1535		s.notFound(w, r)
1536		return
1537	}
1538	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1539	w.Header().Set("Content-Type", "application/gzip")
1540	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1541	gitutil.Archive(p.Dir, ref, prefix, w)
1542}
1543
1544func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1545	return policy.CanAdmin(u, repo, grant)
1546}
1547
1548func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1549	return policy.CanRead(u, repo, grant)
1550}