internal/httpd/web.go

7ad3de789eb7388381be33637ec3854cc4c7885b
gitbay/internal/httpd/web.go history · blame · raw

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