internal/httpd/web.go

4744c629ed4f0158bdea7d0f1d207fdccb1f7f99
gitbay/internal/httpd/web.go history · blame · raw

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