internal/httpd/web.go

93b38a063b3b4b161a1baf976e92f53d02b1a221
gitbay/internal/httpd/web.go history · blame · raw

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