internal/httpd/web.go

32a583340a8db2b242ed452180dd239fd118bc26
gitbay/internal/httpd/web.go history · blame · raw

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