internal/httpd/web.go

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

1569 lines · 46221 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(lightStyle), 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, each scoped to the scheme it is for.
 833// The light one cannot be left unscoped: the two palettes do not name the
 834// same token set, and every token github-dark omits would keep its
 835// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
 836// Scoped, an unnamed token inherits the wrapper's colour instead, which is
 837// readable in both. The site's --code-bg stays the background either way.
 838// lightStyle and darkStyle are chosen on measured contrast against the
 839// grounds code actually sits on here — page, code block, and the diff
 840// tints. friendly, the chroma default, put 61 token/ground pairs under
 841// 4.5:1; xcode puts one.
 842const (
 843	lightStyle = "xcode"
 844	darkStyle  = "github-dark"
 845)
 846
 847var chromaCSS = func() []byte {
 848	var buf bytes.Buffer
 849	buf.WriteString("@media (prefers-color-scheme: light) {\n")
 850	chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
 851	// xcode's NameAttribute is its one token under 4.5:1 against the diff
 852	// tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
 853	buf.WriteString(".chroma .na { color: #6f5a21 }\n")
 854	buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
 855	chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
 856	// github-dark's line numbers are #6e7681, 4.31:1 on the page; lifted to
 857	// the same grey its comments use, which clears the floor.
 858	buf.WriteString(".chroma .lnt, .chroma .ln { color: #8b949e }\n")
 859	buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
 860	return buf.Bytes()
 861}()
 862
 863func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 864	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 865	if !ok {
 866		return
 867	}
 868	filePath := strings.Trim(r.PathValue("path"), "/")
 869	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 870	if err != nil {
 871		s.notFound(w, r)
 872		return
 873	}
 874	// Serve inert: never let repo content execute in the forge's origin.
 875	// Images get their real type so <img> works under nosniff; SVG script
 876	// is dead on arrival because the instance CSP is script-src 'none'.
 877	ct := "text/plain; charset=utf-8"
 878	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
 879		ct = t
 880	}
 881	w.Header().Set("Content-Type", ct)
 882	w.Header().Set("X-Content-Type-Options", "nosniff")
 883	w.Write(data)
 884}
 885
 886// imageTypes are the formats raw serves with a real content type and blob
 887// pages preview inline.
 888var imageTypes = map[string]string{
 889	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
 890	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
 891	".svg": "image/svg+xml", ".ico": "image/x-icon",
 892}
 893
 894// readmeRank orders competing README files: richer renderers win.
 895var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 896
 897// pickReadme returns the best README-ish blob in a tree listing: any file
 898// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 899// we can render richly.
 900func pickReadme(entries []gitutil.TreeEntry) string {
 901	best, bestRank := "", 1<<30
 902	for _, e := range entries {
 903		if e.Type != "blob" {
 904			continue
 905		}
 906		lower := strings.ToLower(e.Name)
 907		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 908			continue
 909		}
 910		rank, ok := readmeRank[path.Ext(lower)]
 911		if !ok {
 912			rank = 10 // plaintext fallback
 913		}
 914		if rank < bestRank {
 915			best, bestRank = e.Name, rank
 916		}
 917	}
 918	return best
 919}
 920
 921// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
 922// task lists) on top of CommonMark, with class-based fence highlighting
 923// (the palette lives in the stylesheet, per scheme). Raw HTML is still
 924// dropped.
 925var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
 926	highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
 927
 928// fenceHighlight renders one code block with chroma classes, for org and
 929// anything else outside goldmark. Unknown languages fall back to plain.
 930func fenceHighlight(source, lang string) string {
 931	lexer := lexers.Get(lang)
 932	if lexer == nil {
 933		lexer = lexers.Fallback
 934	}
 935	iterator, err := lexer.Tokenise(nil, source)
 936	if err != nil {
 937		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 938	}
 939	var buf bytes.Buffer
 940	f := html.New(html.WithClasses(true))
 941	if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
 942		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 943	}
 944	return buf.String()
 945}
 946
 947// mdHTML renders user-authored markdown (issue and MR bodies, comments).
 948// goldmark's default renderer drops raw HTML, so this is safe as-is.
 949func mdHTML(raw string) template.HTML {
 950	if strings.TrimSpace(raw) == "" {
 951		return ""
 952	}
 953	var buf bytes.Buffer
 954	if markdown.Convert([]byte(raw), &buf) != nil {
 955		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
 956	}
 957	return template.HTML(buf.String())
 958}
 959
 960// webResolver answers autolink lookups for one viewer. Cross-repo
 961// references to repositories the viewer cannot read stay plain text, per
 962// the enumeration rule: a link would confirm the repo exists.
 963type webResolver struct {
 964	s      *Server
 965	viewer store.User
 966}
 967
 968func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
 969	repo, err := r.s.st.RepoByPath(owner + "/" + name)
 970	if err != nil {
 971		return ""
 972	}
 973	grant := ""
 974	if r.viewer.ID != 0 {
 975		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
 976	}
 977	if !policy.CanRead(r.viewer, repo, grant) {
 978		return ""
 979	}
 980	if kind == '#' {
 981		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
 982			return ""
 983		}
 984		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
 985	}
 986	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
 987		return ""
 988	}
 989	return autolink.MRURL(repo.OwnerName, repo.Name, n)
 990}
 991
 992func (r webResolver) UserURL(name string) string {
 993	if _, err := r.s.st.UserByUsername(name); err == nil {
 994		return "/" + name
 995	}
 996	if _, err := r.s.st.OrgByName(name); err == nil {
 997		return "/" + name
 998	}
 999	return ""
1000}
1001
1002// ugcFor returns a renderer for user-authored markdown on one repo's pages:
1003// mdHTML plus cross-reference and mention autolinking for this viewer.
1004func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
1005	viewer := store.User{}
1006	if s.cfg.Web.Mode == "accounts" {
1007		viewer = s.viewer(r)
1008	}
1009	res := webResolver{s, viewer}
1010	return func(raw string) template.HTML {
1011		h := mdHTML(raw)
1012		if h == "" {
1013			return h
1014		}
1015		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1016	}
1017}
1018
1019// renderedComment pairs a comment with its rendered body for templates.
1020type renderedComment struct {
1021	Author    string
1022	CreatedAt string
1023	Kind      string
1024	BodyHTML  template.HTML
1025}
1026
1027func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
1028	var out []renderedComment
1029	for _, c := range cs {
1030		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
1031	}
1032	return out
1033}
1034
1035// ugcPolicy sanitizes rendered repo content before it enters the forge's
1036// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1037// output and repo-authored HTML are not. Chroma's highlighting classes
1038// must survive; the pattern admits only short token codes, not the site's
1039// own class names.
1040var ugcPolicy = func() *bluemonday.Policy {
1041	p := bluemonday.UGCPolicy()
1042	p.AllowAttrs("class").
1043		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1044		OnElements("span", "pre", "code", "div")
1045	return p
1046}()
1047
1048// renderReadme renders a README by extension: markdown, org-mode, and
1049// (sanitized) HTML richly; everything else as escaped plaintext.
1050func renderReadme(name string, raw []byte) template.HTML {
1051	plain := func() template.HTML {
1052		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1053	}
1054	if gitutil.IsBinary(raw) {
1055		return ""
1056	}
1057	switch path.Ext(strings.ToLower(name)) {
1058	case ".md", ".markdown":
1059		var buf bytes.Buffer
1060		if markdown.Convert(raw, &buf) != nil {
1061			return plain()
1062		}
1063		return template.HTML(buf.String())
1064	case ".org":
1065		doc := org.New().Parse(bytes.NewReader(raw), name)
1066		writer := org.NewHTMLWriter()
1067		writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1068			if inline {
1069				return "<code>" + template.HTMLEscapeString(source) + "</code>"
1070			}
1071			return fenceHighlight(source, lang)
1072		}
1073		out, err := doc.Write(writer)
1074		if err != nil {
1075			return plain()
1076		}
1077		return template.HTML(ugcPolicy.Sanitize(out))
1078	case ".html", ".htm":
1079		return template.HTML(ugcPolicy.Sanitize(string(raw)))
1080	default:
1081		return plain()
1082	}
1083}
1084
1085type diffThread struct {
1086	ID       int64
1087	Resolved string
1088	Stale    bool
1089	Comments []renderedComment
1090}
1091
1092// attachThreads injects review threads under their anchored diff lines;
1093// threads whose anchor no longer appears (stale after force-push, or on a
1094// context line outside the current diff) are returned separately.
1095func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffFile, []diffThread) {
1096	type anchor struct {
1097		path string
1098		side string
1099		line int64
1100	}
1101	threads := map[int64]*diffThread{}
1102	anchors := map[int64]anchor{}
1103	var order []int64
1104	for _, cm := range comments {
1105		if cm.ReplyTo == 0 {
1106			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1107				Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
1108			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1109			order = append(order, cm.ID)
1110		} else if th, ok := threads[cm.ReplyTo]; ok {
1111			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
1112		}
1113	}
1114	placed := map[int64]bool{}
1115	for f := range files {
1116		lines := files[f].Lines
1117		for i := range lines {
1118			for _, id := range order {
1119				if placed[id] || threads[id].Stale {
1120					continue
1121				}
1122				a := anchors[id]
1123				if lines[i].Path != a.path {
1124					continue
1125				}
1126				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1127					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1128					lines[i].Threads = append(lines[i].Threads, *threads[id])
1129					files[f].Threads++
1130					files[f].Open = true
1131					placed[id] = true
1132				}
1133			}
1134		}
1135	}
1136	var unplaced []diffThread
1137	for _, id := range order {
1138		if !placed[id] {
1139			unplaced = append(unplaced, *threads[id])
1140		}
1141	}
1142	return files, unplaced
1143}
1144
1145type sigView struct {
1146	State       string
1147	Signer      string
1148	Fingerprint string
1149}
1150
1151func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1152	raw, err := gitutil.ReadCommit(dir, sha)
1153	if err != nil {
1154		return sigView{State: "unsigned"}, nil
1155	}
1156	parsed, err := sig.ParseCommit(raw)
1157	if err != nil {
1158		return sigView{State: "unsigned"}, nil
1159	}
1160	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1161	if err != nil {
1162		return sigView{State: "unsigned"}, parsed
1163	}
1164	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1165	if res.SignerUserID != 0 {
1166		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1167			v.Signer = u.Username
1168		}
1169	}
1170	return v, parsed
1171}
1172
1173func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1174	ref := r.PathValue("ref")
1175	p, ok := s.repoFor(w, r, ref)
1176	if !ok {
1177		return
1178	}
1179	p.Tab = "log"
1180	const pageSize = 50
1181	// ?path= filters to commits touching one file or directory.
1182	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1183	if filePath == "." {
1184		filePath = ""
1185	}
1186	var shas []string
1187	var err error
1188	if filePath != "" {
1189		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1190	} else {
1191		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1192	}
1193	if err != nil {
1194		s.notFound(w, r)
1195		return
1196	}
1197	next := ""
1198	if len(shas) > pageSize {
1199		next = shas[pageSize]
1200		shas = shas[:pageSize]
1201	}
1202	type row struct {
1203		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1204		Sig                                                               sigView
1205		Check                                                             string // combined status, "" when none ran
1206	}
1207	names := s.authorNames()
1208	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1209	var rows []row
1210	for _, sha := range shas {
1211		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1212		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1213		if parsed != nil {
1214			rw.Subject = parsed.Subject
1215			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1216			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1217			rw.AuthorEmail = parsed.AuthorEmail
1218			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1219		}
1220		rows = append(rows, rw)
1221	}
1222	s.render(w, "log.html", struct {
1223		repoPage
1224		Commits  []row
1225		NextSHA  string
1226		FilePath string
1227	}{p, rows, next, filePath})
1228}
1229
1230func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1231	p, ok := s.repoFor(w, r, "")
1232	if !ok {
1233		return
1234	}
1235	p.Tab = "log"
1236	sha := r.PathValue("sha")
1237	full, err := gitutil.ResolveRef(p.Dir, sha)
1238	if err != nil {
1239		s.notFound(w, r)
1240		return
1241	}
1242	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1243	if parsed == nil {
1244		s.notFound(w, r)
1245		return
1246	}
1247	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1248	files := parseDiff(patch)
1249	committerEmail := ""
1250	if parsed.CommitterEmail != parsed.AuthorEmail {
1251		committerEmail = parsed.CommitterEmail
1252	}
1253	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1254	commitNames := s.authorNames()
1255	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1256	msg := ""
1257	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1258		msg = string(parsed.Payload[i+2:])
1259	}
1260	s.render(w, "commit.html", struct {
1261		repoPage
1262		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1263		Parents                                                                           []string
1264		Sig                                                                               sigView
1265		Checks                                                                            []store.CommitStatus
1266		DiffFiles                                                                         []diffFile
1267	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1268		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1269		gitutil.Parents(p.Dir, full), v, checks, files})
1270}
1271
1272// labelPalette provides default label chip colors: mid-tone hues that stay
1273// legible on light and dark backgrounds.
1274var labelPalette = []string{
1275	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1276	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1277}
1278
1279var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1280
1281// labelColors returns a complete label-name -> chip color map for a repo:
1282// the stored labels.color when it is a valid hex color, otherwise a
1283// stable default picked from the palette by name hash.
1284func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1285	stored, _ := s.st.LabelColors(repoID)
1286	out := make(map[string]template.CSS, len(stored))
1287	for name, color := range stored {
1288		if !hexColorPat.MatchString(color) {
1289			h := fnv.New32a()
1290			h.Write([]byte(name))
1291			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1292		}
1293		out[name] = template.CSS("--chip:" + color)
1294	}
1295	return out
1296}
1297
1298func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1299	p, ok := s.repoFor(w, r, "")
1300	if !ok {
1301		return
1302	}
1303	p.Tab = "issues"
1304	state := r.URL.Query().Get("state")
1305	if state != "closed" && state != "all" {
1306		state = "open"
1307	}
1308	issues, err := s.st.ListIssues(p.Repo.ID, state)
1309	if err != nil {
1310		http.Error(w, "internal error", http.StatusInternalServerError)
1311		return
1312	}
1313	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1314		for i := range issues {
1315			issues[i].Labels = labels[issues[i].ID]
1316		}
1317	}
1318	// ?label=x narrows to issues carrying that label (chips link here).
1319	labelFilter := r.URL.Query().Get("label")
1320	if labelFilter != "" {
1321		var kept []store.Issue
1322		for _, iss := range issues {
1323			for _, l := range iss.Labels {
1324				if l == labelFilter {
1325					kept = append(kept, iss)
1326					break
1327				}
1328			}
1329		}
1330		issues = kept
1331	}
1332	s.render(w, "issues.html", struct {
1333		repoPage
1334		State       string
1335		Label       string
1336		Issues      []store.Issue
1337		LabelColors map[string]template.CSS
1338	}{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1339}
1340
1341func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1342	p, ok := s.repoFor(w, r, "")
1343	if !ok {
1344		return
1345	}
1346	p.Tab = "issues"
1347	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1348	if err != nil {
1349		s.notFound(w, r)
1350		return
1351	}
1352	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1353	if err != nil {
1354		s.notFound(w, r)
1355		return
1356	}
1357	comments, err := s.st.ListIssueComments(iss.ID)
1358	if err != nil {
1359		http.Error(w, "internal error", http.StatusInternalServerError)
1360		return
1361	}
1362	md := s.ugcFor(r, p.Repo)
1363	milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1364	s.render(w, "issue.html", struct {
1365		repoPage
1366		Issue       store.Issue
1367		BodyHTML    template.HTML
1368		Comments    []renderedComment
1369		CanEdit     bool
1370		CanWrite    bool
1371		Milestones  []store.Milestone
1372		Notice      string
1373		LabelColors map[string]template.CSS
1374	}{p, iss, md(iss.Body), renderComments(comments, md),
1375		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1376		milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1377}
1378
1379// canEditItem: the author or anyone with write access may edit.
1380// canWriteRepo reports whether the browser session may push to the repo,
1381// which is what gates the review and merge controls.
1382func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1383	if s.cfg.Web.Mode != "accounts" {
1384		return false
1385	}
1386	u := s.viewer(r)
1387	if u.ID == 0 {
1388		return false
1389	}
1390	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1391	return policy.CanWrite(u, repo, grant)
1392}
1393
1394func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1395	if s.cfg.Web.Mode != "accounts" {
1396		return false
1397	}
1398	u := s.viewer(r)
1399	if u.ID == 0 {
1400		return false
1401	}
1402	if u.Username == author {
1403		return true
1404	}
1405	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1406	return policy.CanWrite(u, repo, grant)
1407}
1408
1409func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1410	p, ok := s.repoFor(w, r, "")
1411	if !ok {
1412		return
1413	}
1414	p.Tab = "merge requests"
1415	state := r.URL.Query().Get("state")
1416	if state == "" {
1417		state = "open"
1418	}
1419	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1420	if !valid[state] {
1421		state = "open"
1422	}
1423	mrs, err := s.st.ListMRs(p.Repo.ID, state)
1424	if err != nil {
1425		http.Error(w, "internal error", http.StatusInternalServerError)
1426		return
1427	}
1428	s.render(w, "mrs.html", struct {
1429		repoPage
1430		State string
1431		MRs   []store.MR
1432	}{p, state, mrs})
1433}
1434
1435func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1436	p, ok := s.repoFor(w, r, "")
1437	if !ok {
1438		return
1439	}
1440	p.Tab = "merge requests"
1441	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1442	if err != nil {
1443		s.notFound(w, r)
1444		return
1445	}
1446	m, err := s.st.MRByNumber(p.Repo.ID, n)
1447	if err != nil {
1448		s.notFound(w, r)
1449		return
1450	}
1451	comments, _ := s.st.ListMRComments(m.ID)
1452	reviews, _ := s.st.ListMRReviews(m.ID)
1453	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1454	diffComments, _ := s.st.ListDiffComments(m.ID)
1455
1456	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1457	var files []diffFile
1458	base := m.MergedBase
1459	if base == "" {
1460		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1461			base = b
1462		}
1463	}
1464	if base != "" {
1465		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1466			files = parseDiff(patch)
1467		}
1468	}
1469	md := s.ugcFor(r, p.Repo)
1470	var detachedThreads []diffThread
1471	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md)
1472	stat := statOf(files)
1473	// The commits this MR carries: base..head, the same range as the diff.
1474	type commitRow struct {
1475		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1476		Sig                                                  sigView
1477	}
1478	mrNames := s.authorNames()
1479	var commits []commitRow
1480	if base != "" {
1481		const maxMRCommits = 100
1482		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1483		if len(shas) > maxMRCommits {
1484			shas = shas[:maxMRCommits]
1485		}
1486		for _, sha := range shas {
1487			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1488			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1489			if parsed != nil {
1490				cr.Subject = parsed.Subject
1491				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1492				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1493				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1494			}
1495			commits = append(commits, cr)
1496		}
1497	}
1498	// The diff is the reason most people open a merge request, so it gets
1499	// its own view rather than a fold at the foot of the conversation.
1500	// A query parameter keeps this working without JavaScript.
1501	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1502	view := r.URL.Query().Get("view")
1503	if view != "commits" && view != "diff" {
1504		view = "conversation"
1505	}
1506	s.render(w, "mr.html", struct {
1507		repoPage
1508		MR              store.MR
1509		View            string
1510		BodyHTML        template.HTML
1511		Checks          []store.CommitStatus
1512		Combined        string
1513		Comments        []renderedComment
1514		Reviews         []store.MRReview
1515		DiffFiles       []diffFile
1516		Stat            diffStat
1517		Commits         []commitRow
1518		CanEdit         bool
1519		CanWrite        bool
1520		Unresolved      int
1521		Notice          string
1522		DetachedThreads []diffThread
1523	}{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1524		reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1525		s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
1526}
1527
1528func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1529	p, ok := s.repoFor(w, r, "")
1530	if !ok {
1531		return
1532	}
1533	p.Tab = "refs"
1534	branches, _ := gitutil.Refs(p.Dir, "heads")
1535	tags, _ := gitutil.Refs(p.Dir, "tags")
1536	s.render(w, "refs.html", struct {
1537		repoPage
1538		Branches, Tags []gitutil.Ref
1539	}{p, branches, tags})
1540}
1541
1542func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1543	p, ok := s.repoFor(w, r, "")
1544	if !ok {
1545		return
1546	}
1547	file := r.PathValue("file")
1548	ref, ok := strings.CutSuffix(file, ".tar.gz")
1549	if !ok {
1550		s.notFound(w, r)
1551		return
1552	}
1553	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1554		s.notFound(w, r)
1555		return
1556	}
1557	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1558	w.Header().Set("Content-Type", "application/gzip")
1559	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1560	gitutil.Archive(p.Dir, ref, prefix, w)
1561}
1562
1563func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1564	return policy.CanAdmin(u, repo, grant)
1565}
1566
1567func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1568	return policy.CanRead(u, repo, grant)
1569}