internal/httpd/web.go

235ad4973643a1d36743915dea455c6695399e50
gitbay/internal/httpd/web.go history · blame · raw

1530 lines · 43637 bytes

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