internal/httpd/web.go

d4aaf96d88e92486a458b150375b2386ac72fe34
gitbay/internal/httpd/web.go history · blame · raw

1679 lines · 50557 bytes

   1package httpd
   2
   3import (
   4	"bytes"
   5	"errors"
   6	"fmt"
   7	"hash/fnv"
   8	"io"
   9	"log"
  10	"os"
  11	"path/filepath"
  12
  13	"gitbay.org/gitbay/internal/policy"
  14	"html/template"
  15	"net/http"
  16	"path"
  17	"regexp"
  18	"sort"
  19	"strconv"
  20	"strings"
  21	"time"
  22
  23	"github.com/alecthomas/chroma/v2/formatters/html"
  24	"github.com/alecthomas/chroma/v2/lexers"
  25	"github.com/alecthomas/chroma/v2/styles"
  26	"github.com/microcosm-cc/bluemonday"
  27	"github.com/niklasfasching/go-org/org"
  28	"github.com/yuin/goldmark"
  29	highlighting "github.com/yuin/goldmark-highlighting/v2"
  30	"github.com/yuin/goldmark/extension"
  31
  32	"gitbay.org/gitbay/internal/autolink"
  33	"gitbay.org/gitbay/internal/control"
  34	"gitbay.org/gitbay/internal/gitutil"
  35	"gitbay.org/gitbay/internal/sig"
  36	"gitbay.org/gitbay/internal/store"
  37	"gitbay.org/gitbay/internal/web"
  38)
  39
  40const maxRenderBytes = 1 << 20 // largest blob rendered inline
  41
  42func (s *Server) render(w http.ResponseWriter, page string, data any) {
  43	var buf bytes.Buffer
  44	if err := web.Render(&buf, page, data); err != nil {
  45		http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
  46		return
  47	}
  48	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  49	buf.WriteTo(w)
  50}
  51
  52// siteName is the instance's display name: the operator's [web] title,
  53// or the site host when they have not set one.
  54func (s *Server) siteName() string {
  55	if t := strings.TrimSpace(s.cfg.Web.Title); t != "" {
  56		return t
  57	}
  58	h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
  59	return strings.TrimSuffix(h, "/")
  60}
  61
  62func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
  63	w.Header().Set("Content-Type", "text/css; charset=utf-8")
  64	w.Write(web.StyleCSS)
  65	w.Write(chromaCSS)
  66}
  67
  68func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
  69	w.Header().Set("Content-Type", "image/svg+xml")
  70	w.Write(web.FaviconSVG)
  71}
  72
  73// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
  74// so the CSP's default-src 'self' covers it — no font CDN.
  75func (s *Server) font(w http.ResponseWriter, r *http.Request) {
  76	data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
  77	if err != nil {
  78		http.NotFound(w, r)
  79		return
  80	}
  81	w.Header().Set("Content-Type", "font/woff2")
  82	w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
  83	w.Write(data)
  84}
  85
  86// notFound renders the designed 404 page with a 404 status. Falls back to
  87// the stock plain-text response if the template fails.
  88func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
  89	var buf bytes.Buffer
  90	if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
  91		http.NotFound(w, r)
  92		return
  93	}
  94	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  95	w.WriteHeader(http.StatusNotFound)
  96	buf.WriteTo(w)
  97}
  98
  99// describedRepo pairs a repo with the listing metadata: description,
 100// topics, license, and last-updated date.
 101type describedRepo struct {
 102	store.Repo
 103	Desc    string
 104	Topics  []string
 105	License string
 106	Updated string
 107}
 108
 109func (s *Server) describeAll(repos []store.Repo) []describedRepo {
 110	var out []describedRepo
 111	for _, r := range repos {
 112		dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
 113		d := describedRepo{
 114			Repo:    r,
 115			Desc:    gitutil.ReadDescription(dir),
 116			License: detectLicense(dir, r.DefaultBranch),
 117			Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
 118		}
 119		d.Topics, _ = s.st.ListTopics(r.ID)
 120		out = append(out, d)
 121	}
 122	return out
 123}
 124
 125// index is the homepage: a dashboard for logged-in users, a landing page
 126// for everyone else. The full public listing lives at /explore.
 127func (s *Server) index(w http.ResponseWriter, r *http.Request) {
 128	if s.cfg.Web.Mode == "accounts" {
 129		if viewer := s.viewer(r); viewer.ID != 0 {
 130			s.dashboard(w, r, viewer)
 131			return
 132		}
 133	}
 134	host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
 135		s.cfg.Server.SiteURL, "https://"), "http://"), "/")
 136	s.render(w, "landing.html", struct {
 137		basePage
 138		Host     string
 139		Accounts bool
 140		Signup   bool
 141	}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
 142		s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
 143}
 144
 145func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
 146	pinned, _ := s.st.PinnedRepos(viewer.ID)
 147	var visible []store.Repo
 148	for _, rp := range pinned {
 149		grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
 150		if policy.CanRead(viewer, rp, grant) {
 151			visible = append(visible, rp)
 152		}
 153	}
 154	mrs, _ := s.st.DashboardMRs(viewer.ID)
 155	issues, _ := s.st.DashboardIssues(viewer.ID)
 156	reviews, _ := s.st.ReviewQueue(viewer.ID)
 157	assigned, _ := s.st.AssignedIssues(viewer.ID)
 158	events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
 159	s.render(w, "dashboard.html", struct {
 160		basePage
 161		Pinned   []store.Repo
 162		Reviews  []store.DashboardItem
 163		Assigned []store.DashboardItem
 164		MRs      []store.DashboardItem
 165		Issues   []store.DashboardItem
 166		Feed     []feedLine
 167	}{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)})
 168}
 169
 170func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
 171	repos, err := s.st.ListPublicRepos()
 172	if err != nil {
 173		http.Error(w, "internal error", http.StatusInternalServerError)
 174		return
 175	}
 176	var viewer store.User
 177	if s.cfg.Web.Mode == "accounts" {
 178		viewer = s.viewer(r)
 179	}
 180	q := strings.TrimSpace(r.URL.Query().Get("q"))
 181	s.render(w, "explore.html", struct {
 182		basePage
 183		Query string
 184		Repos []describedRepo
 185	}{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
 186}
 187
 188// privacy renders the privacy page: what the gitbay software does with
 189// data, plus this instance's operator-provided notes.
 190func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
 191	s.render(w, "privacy.html", struct {
 192		basePage
 193		Host   string
 194		Notice string
 195	}{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
 196}
 197
 198// filterRepos keeps repos whose path, description, or topics contain the
 199// query, case-insensitively. An empty query keeps everything.
 200func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
 201	if q == "" {
 202		return repos
 203	}
 204	q = strings.ToLower(q)
 205	var out []describedRepo
 206	for _, d := range repos {
 207		if strings.Contains(strings.ToLower(d.Path()), q) ||
 208			strings.Contains(strings.ToLower(d.Desc), q) {
 209			out = append(out, d)
 210			continue
 211		}
 212		for _, t := range d.Topics {
 213			if strings.Contains(t, q) {
 214				out = append(out, d)
 215				break
 216			}
 217		}
 218	}
 219	return out
 220}
 221
 222// repoPage is the shared context for repo-scoped pages.
 223type repoPage struct {
 224	basePage
 225	Desc     string
 226	Repo     store.Repo
 227	Ref      string
 228	CloneURL string
 229	Dir      string
 230	Tab      string // active tab in the repo header
 231	Topics   []string
 232	Pinned   bool // by the viewer
 233	HasWiki  bool
 234	Host     string
 235	Mirrors  []mirrorLine // repo admins only
 236	CanAdmin bool         // gates the settings tab
 237	// OpenIssues and OpenMRs are the counts on the header tabs.
 238	OpenIssues int
 239	OpenMRs    int
 240	// RepoHome asks the layout for the full header — description, topics,
 241	// website, mirrors. Every other page gets identity and tabs only, so a
 242	// repo describes itself once rather than on all twelve of its pages.
 243	RepoHome bool
 244}
 245
 246// mirrorLine is the admin-only mirror status shown in the repo header.
 247// It carries no credentials: the stored URL is credential-free.
 248type mirrorLine struct {
 249	Direction string
 250	URL       string
 251	Target    string // URL without the scheme, for display
 252	Synced    string
 253	Error     string
 254}
 255
 256// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
 257// readable "2026-08-25 03:39 UTC".
 258func syncedAt(ts string) string {
 259	if len(ts) < 16 {
 260		return ts
 261	}
 262	return ts[:10] + " " + ts[11:16] + " UTC"
 263}
 264
 265// repoFor resolves the repo for a web request; false means 404 was sent.
 266// Anonymous visitors see public repos only; in accounts mode a logged-in
 267// viewer additionally sees repos their grants allow. Private and missing
 268// repos are indistinguishable either way.
 269func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
 270	var repo store.Repo
 271	var viewer store.User
 272	if s.cfg.Web.Mode == "accounts" {
 273		viewer = s.viewer(r)
 274	}
 275	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
 276	ok := err == nil
 277	grant := ""
 278	if ok {
 279		if viewer.ID != 0 {
 280			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 281		}
 282		ok = policyCanRead(viewer, repo, grant)
 283	}
 284	if !ok {
 285		s.notFound(w, r)
 286		return repoPage{}, false
 287	}
 288	if ref == "" {
 289		ref = repo.DefaultBranch
 290	}
 291	topics, _ := s.st.ListTopics(repo.ID)
 292	pinned := false
 293	if viewer.ID != 0 {
 294		pinned = s.st.IsPinned(viewer.ID, repo.ID)
 295	}
 296	canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
 297	var mirrors []mirrorLine
 298	if canAdmin {
 299		ms, _ := s.st.ListMirrors(repo.ID)
 300		for _, m := range ms {
 301			mirrors = append(mirrors, mirrorLine{
 302				Direction: m.Direction,
 303				URL:       m.URL,
 304				Target:    strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
 305				Synced:    syncedAt(m.LastSync),
 306				Error:     m.LastError,
 307			})
 308		}
 309	}
 310	openIssues, openMRs := s.st.OpenCounts(repo.ID)
 311	return repoPage{
 312		basePage:   s.baseFor(viewer),
 313		CanAdmin:   canAdmin,
 314		Mirrors:    mirrors,
 315		Pinned:     pinned,
 316		HasWiki:    s.wikiDir(repo.OwnerName, repo.Name) != "",
 317		Host:       s.cfg.SiteHost(),
 318		Desc:       gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
 319		Repo:       repo,
 320		Ref:        ref,
 321		CloneURL:   s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
 322		Dir:        control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
 323		Topics:     topics,
 324		OpenIssues: openIssues,
 325		OpenMRs:    openMRs,
 326	}, true
 327}
 328
 329type crumb struct {
 330	Name string
 331	URL  string
 332}
 333
 334func crumbs(p repoPage, kind, filePath string) []crumb {
 335	var cs []crumb
 336	base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
 337	acc := ""
 338	for _, part := range strings.Split(filePath, "/") {
 339		if part == "" {
 340			continue
 341		}
 342		acc = path.Join(acc, part)
 343		cs = append(cs, crumb{Name: part, URL: base + acc})
 344	}
 345	return cs
 346}
 347
 348// ownerPage renders /{owner} for users and orgs: the repositories the
 349// viewer may see, org membership either direction. Owner names are not
 350// secret (they are on every commit); repository visibility rules hold.
 351func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
 352	name := r.PathValue("owner")
 353	var viewer store.User
 354	if s.cfg.Web.Mode == "accounts" {
 355		viewer = s.viewer(r)
 356	}
 357
 358	kind := "user"
 359	var ownerID int64
 360	var members []store.OrgMember
 361	var orgs []store.OrgMember
 362	if u, err := s.st.UserByUsername(name); err == nil {
 363		ownerID = u.ID
 364		orgs, _ = s.st.ListOrgsForUser(u.ID)
 365	} else if o, err := s.st.OrgByName(name); err == nil {
 366		kind, ownerID = "org", o.ID
 367		members, _ = s.st.OrgMembers(o.ID)
 368	} else {
 369		s.notFound(w, r)
 370		return
 371	}
 372	profile, _ := s.st.OwnerProfile(kind, ownerID)
 373
 374	all, err := s.st.ListReposForOwner(kind, ownerID)
 375	if err != nil {
 376		http.Error(w, "internal error", http.StatusInternalServerError)
 377		return
 378	}
 379	var visible []store.Repo
 380	for _, repo := range all {
 381		grant := ""
 382		if viewer.ID != 0 {
 383			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 384		}
 385		if policy.CanRead(viewer, repo, grant) {
 386			visible = append(visible, repo)
 387		}
 388	}
 389	var counts map[string]int
 390	if kind == "user" {
 391		counts, _ = s.st.ActivityByDay(ownerID, activitySince())
 392	} else {
 393		counts, _ = s.st.OrgActivityByDay(ownerID, activitySince())
 394	}
 395	weeks, activityTotal := activityGrid(counts)
 396
 397	teams, canAdmin := s.orgAdminView(viewer, kind, name)
 398	s.render(w, "owner.html", struct {
 399		basePage
 400		Owner         string
 401		Kind          string
 402		Profile       store.Profile
 403		AboutHTML     template.HTML
 404		Repos         []describedRepo
 405		Members       []store.OrgMember
 406		Orgs          []store.OrgMember
 407		Activity      []activityWeek
 408		ActivityTotal int
 409		Teams         []teamView
 410		CanAdmin      bool
 411		Notice        string
 412	}{s.baseFor(viewer), name, kind, profile, aboutHTML(profile),
 413		s.describeAll(visible), members, orgs,
 414		weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")})
 415}
 416
 417func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
 418	p, ok := s.repoFor(w, r, "")
 419	if !ok {
 420		return
 421	}
 422	p.Tab = "files"
 423	p.RepoHome = true
 424	s.renderTree(w, r, p, "")
 425}
 426
 427func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
 428	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 429	if !ok {
 430		return
 431	}
 432	p.Tab = "files"
 433	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
 434}
 435
 436// treePage is shared by the populated and empty-repository renders: two
 437// anonymous structs drifted apart once already.
 438type treePage struct {
 439	repoPage
 440	Crumbs      []crumb
 441	Prefix      string
 442	DirPath     string
 443	RefKind     string
 444	Entries     []gitutil.TreeEntry
 445	Branches    []gitutil.Ref
 446	ReadmeName  string
 447	ReadmeHTML  template.HTML
 448	LastCommits map[string]namedCommit
 449	Tip         namedCommit
 450	Facts       repoFacts
 451}
 452
 453func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
 454	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
 455		// Empty repo: render the page with no entries rather than 404.
 456		s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree"})
 457		return
 458	}
 459	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
 460	if err != nil {
 461		s.notFound(w, r)
 462		return
 463	}
 464	// Directories first. git's tree order interleaves them with files, but
 465	// a listing is scanned by shape before name. Stable, so each group
 466	// keeps the ordering git gave it.
 467	sort.SliceStable(entries, func(i, j int) bool {
 468		return entries[i].Type == "tree" && entries[j].Type != "tree"
 469	})
 470	prefix := ""
 471	if dirPath != "" {
 472		prefix = dirPath + "/"
 473	}
 474
 475	var readmeHTML template.HTML
 476	readmeName := pickReadme(entries)
 477	if readmeName != "" {
 478		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
 479			readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
 480		}
 481	}
 482
 483	branches, _ := gitutil.Refs(p.Dir, "heads")
 484	names := make([]string, 0, len(entries))
 485	for _, e := range entries {
 486		names = append(names, e.Name)
 487	}
 488	// The facts bar is about the repository, not this directory, so it is
 489	// computed once at the root and left off subdirectory listings.
 490	var facts repoFacts
 491	if dirPath == "" {
 492		facts = s.factsFor(p)
 493	}
 494	s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
 495		readmeName, readmeHTML,
 496		s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
 497		s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts})
 498}
 499
 500func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
 501	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 502	if !ok {
 503		return
 504	}
 505	p.Tab = "files"
 506	filePath := strings.Trim(r.PathValue("path"), "/")
 507	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
 508	if err != nil {
 509		s.notFound(w, r)
 510		return
 511	}
 512	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
 513	_, image := imageTypes[strings.ToLower(path.Ext(filePath))]
 514
 515	var codeHTML template.HTML
 516	if !binary && !image {
 517		codeHTML = highlight(filePath, data)
 518	}
 519	cs := crumbs(p, "blob", filePath)
 520	base := ""
 521	if len(cs) > 0 {
 522		base = cs[len(cs)-1].Name
 523		cs = cs[:len(cs)-1]
 524	}
 525	branches, _ := gitutil.Refs(p.Dir, "heads")
 526	lines := 0
 527	if !binary && !image && len(data) > 0 {
 528		lines = bytes.Count(data, []byte("\n"))
 529		if data[len(data)-1] != '\n' {
 530			lines++
 531		}
 532	}
 533	// The file listing leads with the last commit now, so the facts about
 534	// the file itself are reported here instead.
 535	entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
 536	s.render(w, "blob.html", struct {
 537		repoPage
 538		Crumbs   []crumb
 539		Base     string
 540		Path     string
 541		DirPath  string
 542		RefKind  string
 543		Binary   bool
 544		Image    bool
 545		Size     int
 546		Lines    int
 547		Exec     bool
 548		Symlink  bool
 549		Branches []gitutil.Ref
 550		CodeHTML template.HTML
 551	}{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
 552		entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML})
 553}
 554
 555// releases lists tag-anchored releases with notes and assets.
 556func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 557	p, ok := s.repoFor(w, r, "")
 558	if !ok {
 559		return
 560	}
 561	p.Tab = "releases"
 562	rels, err := s.st.ListReleases(p.Repo.ID)
 563	if err != nil {
 564		http.Error(w, "internal error", http.StatusInternalServerError)
 565		return
 566	}
 567	md := s.ugcFor(r, p.Repo)
 568	type relView struct {
 569		store.Release
 570		NotesHTML template.HTML
 571	}
 572	var views []relView
 573	for _, rel := range rels {
 574		views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
 575	}
 576	// Tags without a release yet are what a create form can offer.
 577	released := map[string]bool{}
 578	for _, rel := range rels {
 579		released[rel.Tag] = true
 580	}
 581	var freeTags []string
 582	if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
 583		for _, tg := range tags {
 584			if !released[tg.Name] {
 585				freeTags = append(freeTags, tg.Name)
 586			}
 587		}
 588	}
 589	s.render(w, "releases.html", struct {
 590		repoPage
 591		Releases []relView
 592		FreeTags []string
 593		CanWrite bool
 594		Notice   string
 595	}{p, views, freeTags, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")})
 596}
 597
 598// releaseAsset streams one uploaded asset. Tags containing '/' are not
 599// reachable here (single path segment); SSH download always works.
 600func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 601	p, ok := s.repoFor(w, r, "")
 602	if !ok {
 603		return
 604	}
 605	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 606	if err != nil {
 607		s.notFound(w, r)
 608		return
 609	}
 610	name := r.PathValue("name")
 611	found := false
 612	for _, a := range rel.Assets {
 613		if a.Name == name {
 614			found = true
 615		}
 616	}
 617	if !found {
 618		s.notFound(w, r)
 619		return
 620	}
 621	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 622		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 623	if err != nil {
 624		s.notFound(w, r)
 625		return
 626	}
 627	defer f.Close()
 628	w.Header().Set("Content-Type", "application/octet-stream")
 629	w.Header().Set("X-Content-Type-Options", "nosniff")
 630	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 631	if fi, err := f.Stat(); err == nil {
 632		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 633	}
 634	io.Copy(w, f)
 635}
 636
 637// milestones lists a repo's milestones with progress.
 638func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 639	p, ok := s.repoFor(w, r, "")
 640	if !ok {
 641		return
 642	}
 643	p.Tab = "issues"
 644	state := r.URL.Query().Get("state")
 645	if state != "closed" && state != "all" {
 646		state = "open"
 647	}
 648	ms, err := s.st.ListMilestones(p.Repo.ID, state)
 649	if err != nil {
 650		http.Error(w, "internal error", http.StatusInternalServerError)
 651		return
 652	}
 653	type msView struct {
 654		store.Milestone
 655		Percent int
 656	}
 657	var views []msView
 658	for _, m := range ms {
 659		v := msView{Milestone: m}
 660		if total := m.OpenItems + m.ClosedItems; total > 0 {
 661			v.Percent = m.ClosedItems * 100 / total
 662		}
 663		views = append(views, v)
 664	}
 665	s.render(w, "milestones.html", struct {
 666		repoPage
 667		State      string
 668		Milestones []msView
 669	}{p, state, views})
 670}
 671
 672// search runs a bounded literal git grep over the repo's default branch.
 673func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 674	p, ok := s.repoFor(w, r, "")
 675	if !ok {
 676		return
 677	}
 678	p.Tab = "search"
 679	q := strings.TrimSpace(r.URL.Query().Get("q"))
 680	type matchView struct {
 681		Path     string
 682		Line     int
 683		TextHTML template.HTML
 684	}
 685	var matches []matchView
 686	var queryErr string
 687	if q != "" {
 688		if len(q) < 2 || len(q) > 200 {
 689			queryErr = "query must be 2 to 200 characters"
 690		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 691			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 692			if err != nil {
 693				http.Error(w, "internal error", http.StatusInternalServerError)
 694				return
 695			}
 696			for _, m := range raw {
 697				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 698			}
 699		}
 700	}
 701	s.render(w, "search.html", struct {
 702		repoPage
 703		Query    string
 704		QueryErr string
 705		Matches  []matchView
 706		Capped   bool
 707	}{p, q, queryErr, matches, len(matches) == 200})
 708}
 709
 710// markMatch escapes a matched line and wraps case-insensitive occurrences
 711// of the query in <mark>.
 712func markMatch(text, q string) template.HTML {
 713	lower, lq := strings.ToLower(text), strings.ToLower(q)
 714	var b strings.Builder
 715	pos := 0
 716	for {
 717		i := strings.Index(lower[pos:], lq)
 718		if i < 0 {
 719			break
 720		}
 721		i += pos
 722		b.WriteString(template.HTMLEscapeString(text[pos:i]))
 723		b.WriteString("<mark>")
 724		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
 725		b.WriteString("</mark>")
 726		pos = i + len(q)
 727	}
 728	b.WriteString(template.HTMLEscapeString(text[pos:]))
 729	return template.HTML(b.String())
 730}
 731
 732func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 733	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 734	if !ok {
 735		return
 736	}
 737	p.Tab = "files"
 738	filePath := strings.Trim(r.PathValue("path"), "/")
 739
 740	// Blame is a control command; the web renders what it returns rather
 741	// than shelling out to git itself, so all three surfaces agree.
 742	page := 1
 743	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
 744		page = n
 745	}
 746	from := (page-1)*control.BlameSpan + 1
 747
 748	var out struct {
 749		From       int `json:"from"`
 750		To         int `json:"to"`
 751		TotalLines int `json:"total_lines"`
 752		Hunks      []struct {
 753			SHA         string   `json:"sha"`
 754			AuthorName  string   `json:"author_name"`
 755			AuthorEmail string   `json:"author_email"`
 756			Date        string   `json:"date"`
 757			Summary     string   `json:"summary"`
 758			StartLine   int      `json:"start_line"`
 759			Lines       []string `json:"lines"`
 760		} `json:"hunks"`
 761	}
 762	argv := []string{"repo", "blame", p.Repo.Path(), filePath,
 763		"--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
 764	var viewer store.User
 765	if s.cfg.Web.Mode == "accounts" {
 766		viewer = s.viewer(r)
 767	}
 768	msg, ok := s.runControlInto(viewer, argv, &out)
 769
 770	// A binary or empty file is a refusal, not a 404: the page still
 771	// renders and says why there is nothing to attribute.
 772	binary := false
 773	if !ok {
 774		if strings.Contains(msg, "is binary") {
 775			binary = true
 776		} else {
 777			s.notFound(w, r)
 778			return
 779		}
 780	}
 781
 782	type hunkView struct {
 783		gitutil.BlameHunk
 784		ShortSHA string
 785		Date     string
 786		Sig      sigView
 787		Numbered []numberedLine
 788	}
 789	var hunks []hunkView
 790	sigs := map[string]sigView{}
 791	for _, h := range out.Hunks {
 792		v, seen := sigs[h.SHA]
 793		if !seen {
 794			v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 795			sigs[h.SHA] = v
 796		}
 797		date := h.Date
 798		if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
 799			date = t.Format("2006-01-02")
 800		}
 801		hv := hunkView{
 802			BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
 803				AuthorEmail: h.AuthorEmail, Summary: h.Summary,
 804				StartLine: h.StartLine, Lines: h.Lines},
 805			ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
 806		}
 807		for i, l := range h.Lines {
 808			hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 809		}
 810		hunks = append(hunks, hv)
 811	}
 812
 813	pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
 814	if pages == 0 {
 815		pages = 1
 816	}
 817	if page > pages {
 818		page = pages
 819	}
 820
 821	cs := crumbs(p, "blame", filePath)
 822	base := ""
 823	if len(cs) > 0 {
 824		base = cs[len(cs)-1].Name
 825		cs = cs[:len(cs)-1]
 826	}
 827	s.render(w, "blame.html", struct {
 828		repoPage
 829		Crumbs      []crumb
 830		Base        string
 831		Path        string
 832		Binary      bool
 833		Hunks       []hunkView
 834		Page, Pages int
 835	}{p, cs, base, filePath, binary, hunks, page, pages})
 836}
 837
 838type numberedLine struct {
 839	N    int
 840	Text string
 841}
 842
 843// chromaFormatter emits class-based markup (no inline colors), so the
 844// stylesheet can swap palettes with the color scheme.
 845var chromaFormatter = html.New(html.WithClasses(true),
 846	html.WithLineNumbers(true), html.LineNumbersInTable(false),
 847	html.WithLinkableLineNumbers(true, "L"))
 848
 849func highlight(filePath string, data []byte) template.HTML {
 850	lexer := lexers.Match(filePath)
 851	if lexer == nil {
 852		lexer = lexers.Fallback
 853	}
 854	iterator, err := lexer.Tokenise(nil, string(data))
 855	if err != nil {
 856		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 857	}
 858	var buf bytes.Buffer
 859	if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
 860		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 861	}
 862	return template.HTML(buf.String())
 863}
 864
 865// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
 866// The light one cannot be left unscoped: the two palettes do not name the
 867// same token set, and every token github-dark omits would keep its
 868// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
 869// Scoped, an unnamed token inherits the wrapper's colour instead, which is
 870// readable in both. The site's --code-bg stays the background either way.
 871// lightStyle and darkStyle are chosen on measured contrast against the
 872// grounds code actually sits on here — page, code block, and the diff
 873// tints. friendly, the chroma default, put 61 token/ground pairs under
 874// 4.5:1; xcode puts one.
 875const (
 876	lightStyle = "xcode"
 877	darkStyle  = "github-dark"
 878)
 879
 880var chromaCSS = func() []byte {
 881	var buf bytes.Buffer
 882	buf.WriteString("@media (prefers-color-scheme: light) {\n")
 883	chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
 884	// xcode's NameAttribute is its one token under 4.5:1 against the diff
 885	// tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
 886	buf.WriteString(".chroma .na { color: #6f5a21 }\n")
 887	buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
 888	chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
 889	buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
 890	// Line numbers take the site's own gutter colour in both schemes. Left
 891	// alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
 892	// chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
 893	// latter is a formatter fallback, not a style entry, so no palette test
 894	// can see it.
 895	buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
 896	return buf.Bytes()
 897}()
 898
 899func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 900	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 901	if !ok {
 902		return
 903	}
 904	filePath := strings.Trim(r.PathValue("path"), "/")
 905	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 906	if err != nil {
 907		s.notFound(w, r)
 908		return
 909	}
 910	// Serve inert: never let repo content execute in the forge's origin.
 911	// Images get their real type so <img> works under nosniff; SVG script
 912	// is dead on arrival because the instance CSP is script-src 'none'.
 913	ct := "text/plain; charset=utf-8"
 914	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
 915		ct = t
 916	}
 917	w.Header().Set("Content-Type", ct)
 918	w.Header().Set("X-Content-Type-Options", "nosniff")
 919	w.Write(data)
 920}
 921
 922// imageTypes are the formats raw serves with a real content type and blob
 923// pages preview inline.
 924var imageTypes = map[string]string{
 925	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
 926	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
 927	".svg": "image/svg+xml", ".ico": "image/x-icon",
 928}
 929
 930// readmeRank orders competing README files: richer renderers win.
 931var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 932
 933// pickReadme returns the best README-ish blob in a tree listing: any file
 934// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 935// we can render richly.
 936func pickReadme(entries []gitutil.TreeEntry) string {
 937	best, bestRank := "", 1<<30
 938	for _, e := range entries {
 939		if e.Type != "blob" {
 940			continue
 941		}
 942		lower := strings.ToLower(e.Name)
 943		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 944			continue
 945		}
 946		rank, ok := readmeRank[path.Ext(lower)]
 947		if !ok {
 948			rank = 10 // plaintext fallback
 949		}
 950		if rank < bestRank {
 951			best, bestRank = e.Name, rank
 952		}
 953	}
 954	return best
 955}
 956
 957// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
 958// task lists) on top of CommonMark, with class-based fence highlighting
 959// (the palette lives in the stylesheet, per scheme). Raw HTML is still
 960// dropped.
 961var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
 962	highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
 963
 964// fenceHighlight renders one code block with chroma classes, for org and
 965// anything else outside goldmark. Unknown languages fall back to plain.
 966func fenceHighlight(source, lang string) string {
 967	lexer := lexers.Get(lang)
 968	if lexer == nil {
 969		lexer = lexers.Fallback
 970	}
 971	iterator, err := lexer.Tokenise(nil, source)
 972	if err != nil {
 973		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 974	}
 975	var buf bytes.Buffer
 976	f := html.New(html.WithClasses(true))
 977	if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
 978		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
 979	}
 980	return buf.String()
 981}
 982
 983// mdHTML renders user-authored markdown (issue and MR bodies, comments).
 984// goldmark's default renderer drops raw HTML, so this is safe as-is.
 985func mdHTML(raw string) template.HTML {
 986	if strings.TrimSpace(raw) == "" {
 987		return ""
 988	}
 989	var buf bytes.Buffer
 990	if markdown.Convert([]byte(raw), &buf) != nil {
 991		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
 992	}
 993	return template.HTML(buf.String())
 994}
 995
 996// aboutHTML renders a profile's about text. It has no filename to
 997// dispatch on, so the stored format picks the extension; anything other
 998// than org is markdown.
 999func aboutHTML(p store.Profile) template.HTML {
1000	if strings.TrimSpace(p.About) == "" {
1001		return ""
1002	}
1003	name := "about.md"
1004	if p.AboutFormat == "org" {
1005		name = "about.org"
1006	}
1007	return renderReadme(name, []byte(p.About))
1008}
1009
1010// webResolver answers autolink lookups for one viewer. Cross-repo
1011// references to repositories the viewer cannot read stay plain text, per
1012// the enumeration rule: a link would confirm the repo exists.
1013type webResolver struct {
1014	s      *Server
1015	viewer store.User
1016}
1017
1018func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1019	repo, err := r.s.st.RepoByPath(owner + "/" + name)
1020	if err != nil {
1021		return ""
1022	}
1023	grant := ""
1024	if r.viewer.ID != 0 {
1025		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1026	}
1027	if !policy.CanRead(r.viewer, repo, grant) {
1028		return ""
1029	}
1030	if kind == '#' {
1031		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1032			return ""
1033		}
1034		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1035	}
1036	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1037		return ""
1038	}
1039	return autolink.MRURL(repo.OwnerName, repo.Name, n)
1040}
1041
1042func (r webResolver) UserURL(name string) string {
1043	if _, err := r.s.st.UserByUsername(name); err == nil {
1044		return "/" + name
1045	}
1046	if _, err := r.s.st.OrgByName(name); err == nil {
1047		return "/" + name
1048	}
1049	return ""
1050}
1051
1052// ugcRenderer renders one user-authored body in the format it was written in.
1053// The format travels with the body: it is recorded when the text is written, so
1054// changing a preference later cannot re-interpret prose that already exists.
1055type ugcRenderer func(raw, format string) template.HTML
1056
1057// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1058// so a body stored before formats existed — and any row whose column defaulted —
1059// renders exactly as it did before.
1060//
1061// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1062// about text take, so it inherits that function's include guard and sanitising
1063// rather than growing a second org renderer to keep in step.
1064func ugcHTML(raw, format string) template.HTML {
1065	if format == "org" {
1066		return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1067			return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1068		})
1069	}
1070	return mdHTML(raw)
1071}
1072
1073// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1074// ugcHTML plus cross-reference and mention autolinking for this viewer.
1075func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1076	viewer := store.User{}
1077	if s.cfg.Web.Mode == "accounts" {
1078		viewer = s.viewer(r)
1079	}
1080	res := webResolver{s, viewer}
1081	return func(raw, format string) template.HTML {
1082		h := ugcHTML(raw, format)
1083		if h == "" {
1084			return h
1085		}
1086		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1087	}
1088}
1089
1090// renderedComment pairs a comment with its rendered body for templates.
1091type renderedComment struct {
1092	Author    string
1093	CreatedAt string
1094	Kind      string
1095	BodyHTML  template.HTML
1096}
1097
1098func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1099	var out []renderedComment
1100	for _, c := range cs {
1101		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1102	}
1103	return out
1104}
1105
1106// ugcPolicy sanitizes rendered repo content before it enters the forge's
1107// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1108// output and repo-authored HTML are not. Chroma's highlighting classes
1109// must survive; the pattern admits only short token codes, not the site's
1110// own class names.
1111var ugcPolicy = func() *bluemonday.Policy {
1112	p := bluemonday.UGCPolicy()
1113	p.AllowAttrs("class").
1114		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1115		OnElements("span", "pre", "code", "div")
1116	return p
1117}()
1118
1119// renderReadme renders a README by extension: markdown, org-mode, and
1120// (sanitized) HTML richly; everything else as escaped plaintext.
1121// orgConfig is the go-org configuration for rendering untrusted org.
1122//
1123// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1124// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1125// wiki page, a profile — so both keywords are refused outright: the file is
1126// never opened and the keyword stays the inert text it is. There is no safe
1127// subset to allow instead. An absolute path skips go-org's relative-path join,
1128// a relative one resolves against the daemon's working directory, and a repo
1129// has no directory to scope to anyway because the content came from a git
1130// object rather than a checkout.
1131//
1132// The default logger writes parse warnings to stderr, which would let pushed
1133// content write to the server's log; discard them.
1134func orgConfig() *org.Configuration {
1135	c := org.New()
1136	c.ReadFile = func(string) ([]byte, error) {
1137		return nil, errOrgIncludeDisabled
1138	}
1139	c.Log = log.New(io.Discard, "", 0)
1140	return c
1141}
1142
1143var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1144
1145// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1146// of contents: a README or wiki page is a document and carries one, an issue
1147// comment is a remark and should not sprout one above two headings. `fallback`
1148// supplies the plaintext rendering used when the writer fails.
1149func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1150	c := orgConfig()
1151	if !contents {
1152		// DefaultSettings is a fresh map per org.New(), so this is local.
1153		c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1154	}
1155	doc := c.Parse(bytes.NewReader(raw), name)
1156	writer := org.NewHTMLWriter()
1157	writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1158		if inline {
1159			return "<code>" + template.HTMLEscapeString(source) + "</code>"
1160		}
1161		return fenceHighlight(source, lang)
1162	}
1163	out, err := doc.Write(writer)
1164	if err != nil {
1165		return fallback()
1166	}
1167	return template.HTML(ugcPolicy.Sanitize(out))
1168}
1169
1170func renderReadme(name string, raw []byte) template.HTML {
1171	plain := func() template.HTML {
1172		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1173	}
1174	if gitutil.IsBinary(raw) {
1175		return ""
1176	}
1177	switch path.Ext(strings.ToLower(name)) {
1178	case ".md", ".markdown":
1179		var buf bytes.Buffer
1180		if markdown.Convert(raw, &buf) != nil {
1181			return plain()
1182		}
1183		return template.HTML(buf.String())
1184	case ".org":
1185		return renderOrg(name, raw, true, plain)
1186	case ".html", ".htm":
1187		return template.HTML(ugcPolicy.Sanitize(string(raw)))
1188	default:
1189		return plain()
1190	}
1191}
1192
1193type diffThread struct {
1194	ID       int64
1195	Resolved string
1196	Stale    bool
1197	Comments []renderedComment
1198}
1199
1200// attachThreads injects review threads under their anchored diff lines;
1201// threads whose anchor no longer appears (stale after force-push, or on a
1202// context line outside the current diff) are returned separately.
1203func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer) ([]diffFile, []diffThread) {
1204	type anchor struct {
1205		path string
1206		side string
1207		line int64
1208	}
1209	// Diff-line comments have no stored format yet, so they stay markdown.
1210	// They are the one user-authored body left without the choice; see #51.
1211	threads := map[int64]*diffThread{}
1212	anchors := map[int64]anchor{}
1213	var order []int64
1214	for _, cm := range comments {
1215		if cm.ReplyTo == 0 {
1216			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1217				Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1218			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1219			order = append(order, cm.ID)
1220		} else if th, ok := threads[cm.ReplyTo]; ok {
1221			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1222		}
1223	}
1224	placed := map[int64]bool{}
1225	for f := range files {
1226		lines := files[f].Lines
1227		for i := range lines {
1228			for _, id := range order {
1229				if placed[id] || threads[id].Stale {
1230					continue
1231				}
1232				a := anchors[id]
1233				if lines[i].Path != a.path {
1234					continue
1235				}
1236				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1237					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1238					lines[i].Threads = append(lines[i].Threads, *threads[id])
1239					files[f].Threads++
1240					files[f].Open = true
1241					placed[id] = true
1242				}
1243			}
1244		}
1245	}
1246	var unplaced []diffThread
1247	for _, id := range order {
1248		if !placed[id] {
1249			unplaced = append(unplaced, *threads[id])
1250		}
1251	}
1252	return files, unplaced
1253}
1254
1255type sigView struct {
1256	State       string
1257	Signer      string
1258	Fingerprint string
1259}
1260
1261func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1262	raw, err := gitutil.ReadCommit(dir, sha)
1263	if err != nil {
1264		return sigView{State: "unsigned"}, nil
1265	}
1266	parsed, err := sig.ParseCommit(raw)
1267	if err != nil {
1268		return sigView{State: "unsigned"}, nil
1269	}
1270	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1271	if err != nil {
1272		return sigView{State: "unsigned"}, parsed
1273	}
1274	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1275	if res.SignerUserID != 0 {
1276		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1277			v.Signer = u.Username
1278		}
1279	}
1280	return v, parsed
1281}
1282
1283func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1284	ref := r.PathValue("ref")
1285	p, ok := s.repoFor(w, r, ref)
1286	if !ok {
1287		return
1288	}
1289	p.Tab = "log"
1290	const pageSize = 50
1291	// ?path= filters to commits touching one file or directory.
1292	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1293	if filePath == "." {
1294		filePath = ""
1295	}
1296	var shas []string
1297	var err error
1298	if filePath != "" {
1299		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1300	} else {
1301		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1302	}
1303	if err != nil {
1304		s.notFound(w, r)
1305		return
1306	}
1307	next := ""
1308	if len(shas) > pageSize {
1309		next = shas[pageSize]
1310		shas = shas[:pageSize]
1311	}
1312	type row struct {
1313		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1314		Sig                                                               sigView
1315		Check                                                             string // combined status, "" when none ran
1316	}
1317	names := s.authorNames()
1318	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1319	var rows []row
1320	for _, sha := range shas {
1321		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1322		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1323		if parsed != nil {
1324			rw.Subject = parsed.Subject
1325			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1326			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1327			rw.AuthorEmail = parsed.AuthorEmail
1328			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1329		}
1330		rows = append(rows, rw)
1331	}
1332	s.render(w, "log.html", struct {
1333		repoPage
1334		Commits  []row
1335		NextSHA  string
1336		FilePath string
1337	}{p, rows, next, filePath})
1338}
1339
1340func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1341	p, ok := s.repoFor(w, r, "")
1342	if !ok {
1343		return
1344	}
1345	p.Tab = "log"
1346	sha := r.PathValue("sha")
1347	full, err := gitutil.ResolveRef(p.Dir, sha)
1348	if err != nil {
1349		s.notFound(w, r)
1350		return
1351	}
1352	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1353	if parsed == nil {
1354		s.notFound(w, r)
1355		return
1356	}
1357	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1358	files := parseDiff(patch)
1359	committerEmail := ""
1360	if parsed.CommitterEmail != parsed.AuthorEmail {
1361		committerEmail = parsed.CommitterEmail
1362	}
1363	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1364	commitNames := s.authorNames()
1365	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1366	msg := ""
1367	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1368		msg = string(parsed.Payload[i+2:])
1369	}
1370	s.render(w, "commit.html", struct {
1371		repoPage
1372		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1373		Parents                                                                           []string
1374		Sig                                                                               sigView
1375		Checks                                                                            []store.CommitStatus
1376		DiffFiles                                                                         []diffFile
1377	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1378		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1379		gitutil.Parents(p.Dir, full), v, checks, files})
1380}
1381
1382// labelPalette provides default label chip colors: mid-tone hues that stay
1383// legible on light and dark backgrounds.
1384var labelPalette = []string{
1385	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1386	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1387}
1388
1389var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1390
1391// labelColors returns a complete label-name -> chip color map for a repo:
1392// the stored labels.color when it is a valid hex color, otherwise a
1393// stable default picked from the palette by name hash.
1394func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1395	stored, _ := s.st.LabelColors(repoID)
1396	out := make(map[string]template.CSS, len(stored))
1397	for name, color := range stored {
1398		if !hexColorPat.MatchString(color) {
1399			h := fnv.New32a()
1400			h.Write([]byte(name))
1401			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1402		}
1403		out[name] = template.CSS("--chip:" + color)
1404	}
1405	return out
1406}
1407
1408func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1409	p, ok := s.repoFor(w, r, "")
1410	if !ok {
1411		return
1412	}
1413	p.Tab = "issues"
1414	state := r.URL.Query().Get("state")
1415	if state != "closed" && state != "all" {
1416		state = "open"
1417	}
1418	issues, err := s.st.ListIssues(p.Repo.ID, state, 0, 0)
1419	if err != nil {
1420		http.Error(w, "internal error", http.StatusInternalServerError)
1421		return
1422	}
1423	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1424		for i := range issues {
1425			issues[i].Labels = labels[issues[i].ID]
1426		}
1427	}
1428	// ?label=x narrows to issues carrying that label (chips link here).
1429	labelFilter := r.URL.Query().Get("label")
1430	if labelFilter != "" {
1431		var kept []store.Issue
1432		for _, iss := range issues {
1433			for _, l := range iss.Labels {
1434				if l == labelFilter {
1435					kept = append(kept, iss)
1436					break
1437				}
1438			}
1439		}
1440		issues = kept
1441	}
1442	s.render(w, "issues.html", struct {
1443		repoPage
1444		State       string
1445		Label       string
1446		Issues      []store.Issue
1447		LabelColors map[string]template.CSS
1448	}{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1449}
1450
1451func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1452	p, ok := s.repoFor(w, r, "")
1453	if !ok {
1454		return
1455	}
1456	p.Tab = "issues"
1457	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1458	if err != nil {
1459		s.notFound(w, r)
1460		return
1461	}
1462	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1463	if err != nil {
1464		s.notFound(w, r)
1465		return
1466	}
1467	comments, err := s.st.ListIssueComments(iss.ID)
1468	if err != nil {
1469		http.Error(w, "internal error", http.StatusInternalServerError)
1470		return
1471	}
1472	md := s.ugcFor(r, p.Repo)
1473	milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1474	s.render(w, "issue.html", struct {
1475		repoPage
1476		Issue       store.Issue
1477		BodyHTML    template.HTML
1478		Comments    []renderedComment
1479		CanEdit     bool
1480		CanWrite    bool
1481		Milestones  []store.Milestone
1482		Notice      string
1483		LabelColors map[string]template.CSS
1484	}{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1485		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1486		milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1487}
1488
1489// canEditItem: the author or anyone with write access may edit.
1490// canWriteRepo reports whether the browser session may push to the repo,
1491// which is what gates the review and merge controls.
1492func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1493	if s.cfg.Web.Mode != "accounts" {
1494		return false
1495	}
1496	u := s.viewer(r)
1497	if u.ID == 0 {
1498		return false
1499	}
1500	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1501	return policy.CanWrite(u, repo, grant)
1502}
1503
1504func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1505	if s.cfg.Web.Mode != "accounts" {
1506		return false
1507	}
1508	u := s.viewer(r)
1509	if u.ID == 0 {
1510		return false
1511	}
1512	if u.Username == author {
1513		return true
1514	}
1515	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1516	return policy.CanWrite(u, repo, grant)
1517}
1518
1519func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1520	p, ok := s.repoFor(w, r, "")
1521	if !ok {
1522		return
1523	}
1524	p.Tab = "merge requests"
1525	state := r.URL.Query().Get("state")
1526	if state == "" {
1527		state = "open"
1528	}
1529	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1530	if !valid[state] {
1531		state = "open"
1532	}
1533	mrs, err := s.st.ListMRs(p.Repo.ID, state, 0, 0)
1534	if err != nil {
1535		http.Error(w, "internal error", http.StatusInternalServerError)
1536		return
1537	}
1538	s.render(w, "mrs.html", struct {
1539		repoPage
1540		State string
1541		MRs   []store.MR
1542	}{p, state, mrs})
1543}
1544
1545func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1546	p, ok := s.repoFor(w, r, "")
1547	if !ok {
1548		return
1549	}
1550	p.Tab = "merge requests"
1551	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1552	if err != nil {
1553		s.notFound(w, r)
1554		return
1555	}
1556	m, err := s.st.MRByNumber(p.Repo.ID, n)
1557	if err != nil {
1558		s.notFound(w, r)
1559		return
1560	}
1561	comments, _ := s.st.ListMRComments(m.ID)
1562	reviews, _ := s.st.ListMRReviews(m.ID)
1563	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1564	diffComments, _ := s.st.ListDiffComments(m.ID)
1565
1566	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1567	var files []diffFile
1568	base := m.MergedBase
1569	if base == "" {
1570		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1571			base = b
1572		}
1573	}
1574	if base != "" {
1575		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1576			files = parseDiff(patch)
1577		}
1578	}
1579	md := s.ugcFor(r, p.Repo)
1580	var detachedThreads []diffThread
1581	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md)
1582	stat := statOf(files)
1583	// The commits this MR carries: base..head, the same range as the diff.
1584	type commitRow struct {
1585		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1586		Sig                                                  sigView
1587	}
1588	mrNames := s.authorNames()
1589	var commits []commitRow
1590	if base != "" {
1591		const maxMRCommits = 100
1592		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1593		if len(shas) > maxMRCommits {
1594			shas = shas[:maxMRCommits]
1595		}
1596		for _, sha := range shas {
1597			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1598			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1599			if parsed != nil {
1600				cr.Subject = parsed.Subject
1601				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1602				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1603				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1604			}
1605			commits = append(commits, cr)
1606		}
1607	}
1608	// The diff is the reason most people open a merge request, so it gets
1609	// its own view rather than a fold at the foot of the conversation.
1610	// A query parameter keeps this working without JavaScript.
1611	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1612	view := r.URL.Query().Get("view")
1613	if view != "commits" && view != "diff" {
1614		view = "conversation"
1615	}
1616	s.render(w, "mr.html", struct {
1617		repoPage
1618		MR              store.MR
1619		View            string
1620		BodyHTML        template.HTML
1621		Checks          []store.CommitStatus
1622		Combined        string
1623		Comments        []renderedComment
1624		Reviews         []store.MRReview
1625		DiffFiles       []diffFile
1626		Stat            diffStat
1627		Commits         []commitRow
1628		CanEdit         bool
1629		CanWrite        bool
1630		Unresolved      int
1631		Notice          string
1632		DetachedThreads []diffThread
1633	}{p, m, view, md(m.Body, m.BodyFormat), checks, store.CombinedStatus(checks), renderComments(comments, md),
1634		reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1635		s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
1636}
1637
1638func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1639	p, ok := s.repoFor(w, r, "")
1640	if !ok {
1641		return
1642	}
1643	p.Tab = "refs"
1644	branches, _ := gitutil.Refs(p.Dir, "heads")
1645	tags, _ := gitutil.Refs(p.Dir, "tags")
1646	s.render(w, "refs.html", struct {
1647		repoPage
1648		Branches, Tags []gitutil.Ref
1649	}{p, branches, tags})
1650}
1651
1652func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1653	p, ok := s.repoFor(w, r, "")
1654	if !ok {
1655		return
1656	}
1657	file := r.PathValue("file")
1658	ref, ok := strings.CutSuffix(file, ".tar.gz")
1659	if !ok {
1660		s.notFound(w, r)
1661		return
1662	}
1663	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1664		s.notFound(w, r)
1665		return
1666	}
1667	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1668	w.Header().Set("Content-Type", "application/gzip")
1669	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1670	gitutil.Archive(p.Dir, ref, prefix, w)
1671}
1672
1673func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1674	return policy.CanAdmin(u, repo, grant)
1675}
1676
1677func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1678	return policy.CanRead(u, repo, grant)
1679}