internal/httpd/web.go

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

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