internal/httpd/web.go

2176 lines · 69687 bytes

   1package httpd
   2
   3import (
   4	"bytes"
   5	"crypto/sha256"
   6	"encoding/hex"
   7	"errors"
   8	"fmt"
   9	"hash/fnv"
  10	"io"
  11	"log"
  12	"math"
  13	"os"
  14	"path/filepath"
  15
  16	"gitbay.org/gitbay/internal/policy"
  17	"gitbay.org/gitbay/internal/protocol"
  18	"html/template"
  19	"net/http"
  20	"net/url"
  21	"path"
  22	"regexp"
  23	"sort"
  24	"strconv"
  25	"strings"
  26	"time"
  27
  28	"github.com/alecthomas/chroma/v2/formatters/html"
  29	"github.com/alecthomas/chroma/v2/lexers"
  30	"github.com/alecthomas/chroma/v2/styles"
  31	"github.com/microcosm-cc/bluemonday"
  32	"github.com/niklasfasching/go-org/org"
  33	"github.com/yuin/goldmark"
  34	highlighting "github.com/yuin/goldmark-highlighting/v2"
  35	"github.com/yuin/goldmark/extension"
  36	"github.com/yuin/goldmark/parser"
  37
  38	"gitbay.org/gitbay/internal/autolink"
  39	"gitbay.org/gitbay/internal/control"
  40	"gitbay.org/gitbay/internal/gitutil"
  41	"gitbay.org/gitbay/internal/sig"
  42	"gitbay.org/gitbay/internal/store"
  43	"gitbay.org/gitbay/internal/web"
  44)
  45
  46const maxRenderBytes = 1 << 20 // largest blob rendered inline
  47
  48func (s *Server) render(w http.ResponseWriter, page string, data any) {
  49	var buf bytes.Buffer
  50	if err := web.Render(&buf, page, data); err != nil {
  51		http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
  52		return
  53	}
  54	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  55	buf.WriteTo(w)
  56}
  57
  58// siteName is the instance's display name: the operator's [web] title,
  59// or the site host when they have not set one.
  60func (s *Server) siteName() string {
  61	if t := strings.TrimSpace(s.cfg.Web.Title); t != "" {
  62		return t
  63	}
  64	h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
  65	return strings.TrimSuffix(h, "/")
  66}
  67
  68// stylesheetETag is the hash of what stylesheet serves, computed once:
  69// a browser revalidates with If-None-Match and gets a 304 until a deploy
  70// changes the bytes (#132).
  71var stylesheetETag = func() string {
  72	h := sha256.New()
  73	h.Write(styleCSS)
  74	h.Write(chromaCSS)
  75	return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
  76}()
  77
  78func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
  79	w.Header().Set("ETag", stylesheetETag)
  80	w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
  81	if r.Header.Get("If-None-Match") == stylesheetETag {
  82		w.WriteHeader(http.StatusNotModified)
  83		return
  84	}
  85	w.Header().Set("Content-Type", "text/css; charset=utf-8")
  86	w.Write(styleCSS)
  87	w.Write(chromaCSS)
  88}
  89
  90func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
  91	w.Header().Set("Content-Type", "image/svg+xml")
  92	w.Write(web.FaviconSVG)
  93}
  94
  95// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
  96// so the CSP's default-src 'self' covers it — no font CDN.
  97func (s *Server) font(w http.ResponseWriter, r *http.Request) {
  98	data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
  99	if err != nil {
 100		http.NotFound(w, r)
 101		return
 102	}
 103	w.Header().Set("Content-Type", "font/woff2")
 104	w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
 105	w.Write(data)
 106}
 107
 108// image serves the embedded landing pictures with the font cache policy.
 109func (s *Server) image(w http.ResponseWriter, r *http.Request) {
 110	data, err := web.ImageFS.ReadFile("static" + r.URL.Path[len("/static"):])
 111	if err != nil {
 112		http.NotFound(w, r)
 113		return
 114	}
 115	w.Header().Set("Content-Type", "image/png")
 116	w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
 117	w.Write(data)
 118}
 119
 120// notFound renders the designed 404 page with a 404 status. Falls back to
 121// the stock plain-text response if the template fails.
 122func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
 123	var buf bytes.Buffer
 124	if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
 125		http.NotFound(w, r)
 126		return
 127	}
 128	w.Header().Set("Content-Type", "text/html; charset=utf-8")
 129	w.WriteHeader(http.StatusNotFound)
 130	buf.WriteTo(w)
 131}
 132
 133// describedRepo pairs a repo with the listing metadata: description,
 134// topics, license, and last-updated date.
 135type describedRepo struct {
 136	store.Repo
 137	Desc    string
 138	Topics  []string
 139	License string
 140	Updated string
 141}
 142
 143// Archived flattens the settings flag so the reporow partial can read the
 144// same field name from a describedRepo and from a profile's repo row.
 145func (d describedRepo) Archived() bool { return d.Settings.Archived }
 146
 147func (s *Server) describeAll(repos []store.Repo) []describedRepo {
 148	var out []describedRepo
 149	for _, r := range repos {
 150		dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
 151		d := describedRepo{
 152			Repo:    r,
 153			Desc:    gitutil.ReadDescription(dir),
 154			License: control.DetectLicense(dir, r.DefaultBranch),
 155			Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
 156		}
 157		d.Topics, _ = s.st.ListTopics(r.ID)
 158		out = append(out, d)
 159	}
 160	return out
 161}
 162
 163// index is the homepage: a dashboard for logged-in users, a landing page
 164// for everyone else. The full public listing lives at /explore.
 165func (s *Server) index(w http.ResponseWriter, r *http.Request) {
 166	if s.cfg.Web.Mode == "accounts" {
 167		if viewer := s.viewer(r); viewer.ID != 0 {
 168			s.dashboard(w, r, viewer)
 169			return
 170		}
 171	}
 172	host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
 173		s.cfg.Server.SiteURL, "https://"), "http://"), "/")
 174	s.render(w, "landing.html", struct {
 175		basePage
 176		Host       string
 177		Accounts   bool
 178		Signup     bool
 179		EmailLogin bool
 180	}{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
 181		s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed",
 182		s.emailLoginEnabled()})
 183}
 184
 185func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
 186	mrs, _ := s.st.DashboardMRs(viewer.ID)
 187	issues, _ := s.st.DashboardIssues(viewer.ID)
 188	reviews, _ := s.st.ReviewQueue(viewer.ID)
 189	assigned, _ := s.st.AssignedIssues(viewer.ID)
 190	events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
 191	s.render(w, "dashboard.html", struct {
 192		basePage
 193		Tab      string
 194		Reviews  []store.DashboardItem
 195		Assigned []store.DashboardItem
 196		MRs      []store.DashboardItem
 197		Issues   []store.DashboardItem
 198		Feed     []feedLine
 199	}{s.baseFor(viewer), "dashboard", reviews, assigned, mrs, issues, feedLines(events)})
 200}
 201
 202func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
 203	repos, err := s.st.ListPublicRepos()
 204	if err != nil {
 205		http.Error(w, "internal error", http.StatusInternalServerError)
 206		return
 207	}
 208	var viewer store.User
 209	if s.cfg.Web.Mode == "accounts" {
 210		viewer = s.viewer(r)
 211	}
 212	q := strings.TrimSpace(r.URL.Query().Get("q"))
 213	s.render(w, "explore.html", struct {
 214		basePage
 215		Tab   string
 216		Query string
 217		Repos []describedRepo
 218	}{s.baseFor(viewer), "explore", q, s.filterRepos(q, s.describeAll(repos))})
 219}
 220
 221// privacy renders the privacy page: what the gitbay software does with
 222// data, plus this instance's operator-provided notes.
 223func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
 224	s.render(w, "privacy.html", struct {
 225		basePage
 226		Host   string
 227		Notice string
 228	}{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
 229}
 230
 231// filterRepos keeps repos matching the query by the same rule `repo
 232// search` uses. An empty query keeps everything.
 233func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
 234	if q == "" {
 235		return repos
 236	}
 237	var out []describedRepo
 238	for _, d := range repos {
 239		if control.MatchesRepo(q, d.Path(), d.Desc, d.Topics) {
 240			out = append(out, d)
 241		}
 242	}
 243	return out
 244}
 245
 246// repoPage is the shared context for repo-scoped pages.
 247type repoPage struct {
 248	basePage
 249	Desc     string
 250	Repo     store.Repo
 251	Ref      string
 252	CloneURL string
 253	// SSHCloneURL is the same repository over the SSH transport, which is
 254	// the one a push needs.
 255	SSHCloneURL string
 256	Dir         string
 257	Tab         string // active tab in the repo header
 258	Topics      []string
 259	Pinned      bool   // by the viewer
 260	Marked      bool   // bookmarked by the viewer
 261	Watch       string // the viewer's watch state: watching, muted, or ""
 262	HasWiki     bool
 263	Host        string
 264	Mirrors     []mirrorLine // repo admins only
 265	CanAdmin    bool         // gates the settings tab
 266	Feed        string       // Atom feed for this page, if it has one
 267	// OpenIssues and OpenMRs are the counts on the header tabs.
 268	OpenIssues int
 269	OpenMRs    int
 270	// RepoHome asks the layout for the full header — description, topics,
 271	// website, mirrors. Every other page gets identity and tabs only, so a
 272	// repo describes itself once rather than on all twelve of its pages.
 273	RepoHome bool
 274}
 275
 276// mirrorLine is the admin-only mirror status shown in the repo header.
 277// It carries no credentials: the stored URL is credential-free.
 278type mirrorLine struct {
 279	Direction string
 280	URL       string
 281	Target    string // URL without the scheme, for display
 282	Synced    string
 283	Error     string
 284}
 285
 286// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
 287// readable "2026-08-25 03:39 UTC".
 288func syncedAt(ts string) string {
 289	if len(ts) < 16 {
 290		return ts
 291	}
 292	return ts[:10] + " " + ts[11:16] + " UTC"
 293}
 294
 295// repoFor resolves the repo for a web request; false means 404 was sent.
 296// Anonymous visitors see public repos only; in accounts mode a logged-in
 297// viewer additionally sees repos their grants allow. Private and missing
 298// repos are indistinguishable either way.
 299func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
 300	var repo store.Repo
 301	var viewer store.User
 302	if s.cfg.Web.Mode == "accounts" {
 303		viewer = s.viewer(r)
 304	}
 305	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
 306	ok := err == nil
 307	grant := ""
 308	if ok {
 309		if viewer.ID != 0 {
 310			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 311		}
 312		ok = policyCanRead(viewer, repo, grant)
 313	}
 314	if !ok {
 315		s.notFound(w, r)
 316		return repoPage{}, false
 317	}
 318	if ref == "" {
 319		ref = repo.DefaultBranch
 320	}
 321	topics, _ := s.st.ListTopics(repo.ID)
 322	pinned, marked, watch := false, false, ""
 323	if viewer.ID != 0 {
 324		pinned = s.st.IsPinned(viewer.ID, repo.ID)
 325		marked = s.st.IsBookmarked(viewer.ID, repo.ID)
 326		watch = s.st.RepoWatchState(repo.ID, viewer.ID)
 327	}
 328	canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
 329	var mirrors []mirrorLine
 330	if canAdmin {
 331		ms, _ := s.st.ListMirrors(repo.ID)
 332		for _, m := range ms {
 333			mirrors = append(mirrors, mirrorLine{
 334				Direction: m.Direction,
 335				URL:       m.URL,
 336				Target:    strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
 337				Synced:    syncedAt(m.LastSync),
 338				Error:     m.LastError,
 339			})
 340		}
 341	}
 342	openIssues, openMRs := s.st.OpenCounts(repo.ID)
 343	return repoPage{
 344		basePage:    s.baseFor(viewer),
 345		CanAdmin:    canAdmin,
 346		Mirrors:     mirrors,
 347		Pinned:      pinned,
 348		Marked:      marked,
 349		Watch:       watch,
 350		HasWiki:     s.hasWiki(repo),
 351		Host:        s.cfg.SiteHost(),
 352		Desc:        gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
 353		Repo:        repo,
 354		Ref:         ref,
 355		CloneURL:    s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
 356		SSHCloneURL: s.sshCloneURL(repo),
 357		Dir:         control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
 358		Topics:      topics,
 359		OpenIssues:  openIssues,
 360		OpenMRs:     openMRs,
 361	}, true
 362}
 363
 364type crumb struct {
 365	Name string
 366	URL  string
 367}
 368
 369// crumbs builds one crumb per path component. Every component but the
 370// last is a directory and links to the tree; only the leaf is a page of
 371// the given kind.
 372func crumbs(p repoPage, kind, filePath string) []crumb {
 373	var cs []crumb
 374	parts := strings.Split(strings.Trim(filePath, "/"), "/")
 375	acc := ""
 376	for i, part := range parts {
 377		if part == "" {
 378			continue
 379		}
 380		acc = path.Join(acc, part)
 381		k := "tree"
 382		if i == len(parts)-1 {
 383			k = kind
 384		}
 385		cs = append(cs, crumb{Name: part, URL: "/" + p.Repo.Path() + "/" + k + "/" + p.Ref + "/" + acc})
 386	}
 387	return cs
 388}
 389
 390// profileView is profile show's payload, shaped for the templates. The
 391// repo rows carry the same names the reporow partial reads, so a profile
 392// listing renders identically to explore's.
 393// profileView is profile show's payload with the repository rows wrapped
 394// so the reporow partial can reach them. The fields themselves are the
 395// command's: a field it gains appears here without being re-declared.
 396type profileView struct {
 397	control.ProfileOut
 398	Repos []profileRepoRow `json:"repos"`
 399}
 400
 401// profileRepoRow is one repository row on a profile. The partial asks for
 402// OwnerName, Name and Desc; the payload carries a path and a description.
 403type profileRepoRow struct {
 404	control.ProfileRepo
 405}
 406
 407func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
 408func (p profileRepoRow) Name() string      { _, name, _ := strings.Cut(p.Path, "/"); return name }
 409func (p profileRepoRow) Desc() string      { return p.Description }
 410
 411// ownerPage renders /{owner} for users and orgs: the repositories the
 412// viewer may see, org membership either direction. Owner names are not
 413// secret (they are on every commit); repository visibility rules hold.
 414func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
 415	name := r.PathValue("owner")
 416	var viewer store.User
 417	if s.cfg.Web.Mode == "accounts" {
 418		viewer = s.viewer(r)
 419	}
 420
 421	// Everything on this page — membership, the repositories this viewer
 422	// may see, the activity year — comes from profile show, so the page
 423	// and the command cannot report different things.
 424	var d profileView
 425	code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d)
 426	switch {
 427	case code == protocol.ExitNotFound:
 428		s.notFound(w, r)
 429		return
 430	case code != protocol.ExitOK:
 431		log.Printf("profile %s: %s", name, msg)
 432		http.Error(w, "internal error", http.StatusInternalServerError)
 433		return
 434	}
 435
 436	counts := make(map[string]int, len(d.Activity))
 437	for _, day := range d.Activity {
 438		counts[day.Date] = day.Count
 439	}
 440	weeks, activityTotal := activityGrid(counts)
 441
 442	teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
 443	profile := store.Profile{Description: d.Description, Website: d.Website,
 444		About: d.About, AboutFormat: d.AboutFormat, Links: d.Links}
 445	s.render(w, "owner.html", struct {
 446		basePage
 447		Owner         string
 448		Kind          string
 449		Profile       store.Profile
 450		AboutHTML     template.HTML
 451		Repos         []profileRepoRow
 452		Members       []control.ProfileMember
 453		Orgs          []control.ProfileMember
 454		Activity      []activityWeek
 455		ActivityTotal int
 456		Teams         []teamView
 457		CanAdmin      bool
 458		Self          bool
 459		Snippets      int
 460		Notice        string
 461		Feed          string
 462	}{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
 463		d.Repos, d.Members, d.Orgs,
 464		weeks, activityTotal, teams, canAdmin,
 465		d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
 466		d.Snippets,
 467		s.takeFlash(w, r), "/" + name + "/activity.atom"})
 468}
 469
 470func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
 471	p, ok := s.repoFor(w, r, "")
 472	if !ok {
 473		return
 474	}
 475	p.Tab = "files"
 476	p.RepoHome = true
 477	s.renderTree(w, r, p, "")
 478}
 479
 480func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
 481	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 482	if !ok {
 483		return
 484	}
 485	p.Tab = "files"
 486	path := strings.Trim(r.PathValue("path"), "/")
 487	// The root of the default branch is the same page as the bare repo
 488	// URL, so its header must match: RepoHome is what picks the h1 over
 489	// the p+link identity, not which route was typed.
 490	p.RepoHome = path == "" && p.Ref == p.Repo.DefaultBranch
 491	s.renderTree(w, r, p, path)
 492}
 493
 494// treePage is shared by the populated and empty-repository renders: two
 495// anonymous structs drifted apart once already.
 496type treePage struct {
 497	repoPage
 498	Crumbs      []crumb
 499	Prefix      string
 500	DirPath     string
 501	RefKind     string
 502	Entries     []gitutil.TreeEntry
 503	Branches    []gitutil.Ref
 504	ReadmeName  string
 505	ReadmeHTML  template.HTML
 506	LastCommits map[string]namedCommit
 507	Tip         namedCommit
 508	Facts       repoFacts
 509	Notice      string
 510}
 511
 512func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
 513	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
 514		// Empty repo: render the page with no entries rather than 404.
 515		s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree", Notice: s.takeFlash(w, r)})
 516		return
 517	}
 518	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
 519	if err != nil {
 520		s.notFound(w, r)
 521		return
 522	}
 523	// Directories first. git's tree order interleaves them with files, but
 524	// a listing is scanned by shape before name. Stable, so each group
 525	// keeps the ordering git gave it.
 526	sort.SliceStable(entries, func(i, j int) bool {
 527		return entries[i].Type == "tree" && entries[j].Type != "tree"
 528	})
 529	prefix := ""
 530	if dirPath != "" {
 531		prefix = dirPath + "/"
 532	}
 533
 534	var readmeHTML template.HTML
 535	readmeName := pickReadme(entries)
 536	if readmeName != "" {
 537		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
 538			readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
 539		}
 540	}
 541
 542	branches, _ := gitutil.Refs(p.Dir, "heads")
 543	names := make([]string, 0, len(entries))
 544	for _, e := range entries {
 545		names = append(names, e.Name)
 546	}
 547	// The facts bar is about the repository, not this directory, so it is
 548	// computed once at the root and left off subdirectory listings.
 549	var facts repoFacts
 550	if dirPath == "" {
 551		facts = s.factsFor(p)
 552	}
 553	s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
 554		readmeName, readmeHTML,
 555		s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
 556		s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts, s.takeFlash(w, r)})
 557}
 558
 559func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
 560	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 561	if !ok {
 562		return
 563	}
 564	p.Tab = "files"
 565	filePath := strings.Trim(r.PathValue("path"), "/")
 566	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
 567	if err != nil {
 568		s.notFound(w, r)
 569		return
 570	}
 571	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
 572	_, image := imageTypes[strings.ToLower(path.Ext(filePath))]
 573
 574	var codeHTML template.HTML
 575	if !binary && !image {
 576		codeHTML = highlight(filePath, data)
 577	}
 578	// Markdown and org render like a README, with the source one click
 579	// away; ?view=source shows the text instead.
 580	renderable := markupFile(filePath) && !binary
 581	var renderedHTML template.HTML
 582	rendered := renderable && r.URL.Query().Get("view") != "source"
 583	if rendered {
 584		renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
 585	}
 586	cs := crumbs(p, "blob", filePath)
 587	base := ""
 588	if len(cs) > 0 {
 589		base = cs[len(cs)-1].Name
 590		cs = cs[:len(cs)-1]
 591	}
 592	branches, _ := gitutil.Refs(p.Dir, "heads")
 593	lines := 0
 594	if !binary && !image && len(data) > 0 {
 595		lines = bytes.Count(data, []byte("\n"))
 596		if data[len(data)-1] != '\n' {
 597			lines++
 598		}
 599	}
 600	// The file listing leads with the last commit now, so the facts about
 601	// the file itself are reported here instead.
 602	entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
 603	s.render(w, "blob.html", struct {
 604		repoPage
 605		Crumbs       []crumb
 606		Base         string
 607		Path         string
 608		DirPath      string
 609		RefKind      string
 610		Binary       bool
 611		Image        bool
 612		Size         int
 613		Lines        int
 614		Exec         bool
 615		Symlink      bool
 616		Branches     []gitutil.Ref
 617		CodeHTML     template.HTML
 618		Renderable   bool // markdown or org: the toggle is offered
 619		Rendered     bool // this response shows the rendering
 620		RenderedHTML template.HTML
 621	}{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
 622		entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
 623}
 624
 625// releases lists tag-anchored releases with notes and assets.
 626func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 627	s.releasesPage(w, r, "")
 628}
 629
 630// releasesPage lists releases. previewForm is "release" when the create
 631// form asked to see its notes, or "release:<tag>" when that release's
 632// edit form did (#235).
 633func (s *Server) releasesPage(w http.ResponseWriter, r *http.Request, previewForm string) {
 634	p, ok := s.repoFor(w, r, "")
 635	if !ok {
 636		return
 637	}
 638	p.Tab = "releases"
 639	p.Feed = "/" + p.Repo.Path() + "/releases.atom"
 640	rels, err := s.st.ListReleases(p.Repo.ID)
 641	if err != nil {
 642		http.Error(w, "internal error", http.StatusInternalServerError)
 643		return
 644	}
 645	md := s.ugcFor(r, p.Repo)
 646	type relView struct {
 647		store.Release
 648		NotesHTML template.HTML
 649	}
 650	var views []relView
 651	for _, rel := range rels {
 652		views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
 653	}
 654	// Tags without a release yet are what a create form can offer.
 655	released := map[string]bool{}
 656	for _, rel := range rels {
 657		released[rel.Tag] = true
 658	}
 659	var freeTags []string
 660	if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
 661		gitutil.SortVersions(tags)
 662		for _, tg := range tags {
 663			if !released[tg.Name] {
 664				freeTags = append(freeTags, tg.Name)
 665			}
 666		}
 667	}
 668	// An edit keeps the release's stored format; a new release has no
 669	// picker and is markdown, as release create stores with no --format.
 670	var d *draft
 671	if previewForm != "" {
 672		format := "md"
 673		if tag, ok := strings.CutPrefix(previewForm, "release:"); ok {
 674			for _, v := range views {
 675				if v.Tag == tag {
 676					format = v.NotesFormat
 677				}
 678			}
 679		}
 680		d = s.draftFor(r, p.Repo, previewForm, "notes", format)
 681	}
 682	s.render(w, "releases.html", struct {
 683		repoPage
 684		Releases []relView
 685		FreeTags []string
 686		CanWrite bool
 687		Notice   string
 688		Draft    *draft
 689	}{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r), d})
 690}
 691
 692// releaseAsset streams one uploaded asset. Tags containing '/' are not
 693// reachable here (single path segment); SSH download always works.
 694func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 695	p, ok := s.repoFor(w, r, "")
 696	if !ok {
 697		return
 698	}
 699	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 700	if err != nil {
 701		s.notFound(w, r)
 702		return
 703	}
 704	name := r.PathValue("name")
 705	found := false
 706	for _, a := range rel.Assets {
 707		if a.Name == name {
 708			found = true
 709		}
 710	}
 711	if !found {
 712		s.notFound(w, r)
 713		return
 714	}
 715	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 716		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 717	if err != nil {
 718		s.notFound(w, r)
 719		return
 720	}
 721	defer f.Close()
 722	w.Header().Set("Content-Type", "application/octet-stream")
 723	w.Header().Set("X-Content-Type-Options", "nosniff")
 724	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 725	if fi, err := f.Stat(); err == nil {
 726		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 727	}
 728	io.Copy(w, f)
 729}
 730
 731// milestones lists a repo's milestones with progress.
 732func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 733	p, ok := s.repoFor(w, r, "")
 734	if !ok {
 735		return
 736	}
 737	p.Tab = "issues"
 738	state := r.URL.Query().Get("state")
 739	if state != "closed" && state != "all" {
 740		state = "open"
 741	}
 742	readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)
 743	if err != nil {
 744		http.Error(w, "internal error", http.StatusInternalServerError)
 745		return
 746	}
 747	ms, err := s.st.ListMilestones(p.Repo, state, readable)
 748	if err != nil {
 749		http.Error(w, "internal error", http.StatusInternalServerError)
 750		return
 751	}
 752	type msView struct {
 753		store.Milestone
 754		Percent int
 755	}
 756	var views []msView
 757	for _, m := range ms {
 758		v := msView{Milestone: m}
 759		if total := m.OpenItems + m.ClosedItems; total > 0 {
 760			v.Percent = m.ClosedItems * 100 / total
 761		}
 762		views = append(views, v)
 763	}
 764	s.render(w, "milestones.html", struct {
 765		repoPage
 766		State      string
 767		Milestones []msView
 768	}{p, state, views})
 769}
 770
 771// search runs a bounded literal git grep over the repo's default branch.
 772func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 773	p, ok := s.repoFor(w, r, "")
 774	if !ok {
 775		return
 776	}
 777	p.Tab = "search"
 778	q := strings.TrimSpace(r.URL.Query().Get("q"))
 779	type matchView struct {
 780		Path     string
 781		Line     int
 782		TextHTML template.HTML
 783	}
 784	var matches []matchView
 785	var queryErr string
 786	if q != "" {
 787		if len(q) < 2 || len(q) > 200 {
 788			queryErr = "query must be 2 to 200 characters"
 789		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 790			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 791			if err != nil {
 792				http.Error(w, "internal error", http.StatusInternalServerError)
 793				return
 794			}
 795			for _, m := range raw {
 796				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 797			}
 798		}
 799	}
 800	s.render(w, "search.html", struct {
 801		repoPage
 802		Query    string
 803		QueryErr string
 804		Matches  []matchView
 805		Capped   bool
 806	}{p, q, queryErr, matches, len(matches) == 200})
 807}
 808
 809// markMatch escapes a matched line and wraps case-insensitive occurrences
 810// of the query in <mark>.
 811func markMatch(text, q string) template.HTML {
 812	lower, lq := strings.ToLower(text), strings.ToLower(q)
 813	var b strings.Builder
 814	pos := 0
 815	for {
 816		i := strings.Index(lower[pos:], lq)
 817		if i < 0 {
 818			break
 819		}
 820		i += pos
 821		b.WriteString(template.HTMLEscapeString(text[pos:i]))
 822		b.WriteString("<mark>")
 823		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
 824		b.WriteString("</mark>")
 825		pos = i + len(q)
 826	}
 827	b.WriteString(template.HTMLEscapeString(text[pos:]))
 828	return template.HTML(b.String())
 829}
 830
 831func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 832	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 833	if !ok {
 834		return
 835	}
 836	p.Tab = "files"
 837	filePath := strings.Trim(r.PathValue("path"), "/")
 838
 839	// Blame is a control command; the web renders what it returns rather
 840	// than shelling out to git itself, so all three surfaces agree.
 841	page := 1
 842	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
 843		page = n
 844	}
 845	from := (page-1)*control.BlameSpan + 1
 846
 847	var out struct {
 848		From       int `json:"from"`
 849		To         int `json:"to"`
 850		TotalLines int `json:"total_lines"`
 851		Hunks      []struct {
 852			SHA         string   `json:"sha"`
 853			AuthorName  string   `json:"author_name"`
 854			AuthorEmail string   `json:"author_email"`
 855			Date        string   `json:"date"`
 856			Summary     string   `json:"summary"`
 857			StartLine   int      `json:"start_line"`
 858			Lines       []string `json:"lines"`
 859		} `json:"hunks"`
 860	}
 861	argv := []string{"repo", "blame", p.Repo.Path(), filePath,
 862		"--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
 863	var viewer store.User
 864	if s.cfg.Web.Mode == "accounts" {
 865		viewer = s.viewer(r)
 866	}
 867	msg, ok := s.runControlInto(viewer, argv, &out)
 868
 869	// A binary or empty file is a refusal, not a 404: the page still
 870	// renders and says why there is nothing to attribute.
 871	binary := false
 872	if !ok {
 873		if strings.Contains(msg, "is binary") {
 874			binary = true
 875		} else {
 876			s.notFound(w, r)
 877			return
 878		}
 879	}
 880
 881	type hunkView struct {
 882		gitutil.BlameHunk
 883		ShortSHA string
 884		Date     string
 885		Sig      sigView
 886		Numbered []numberedLine
 887	}
 888	var hunks []hunkView
 889	sigs := map[string]sigView{}
 890	for _, h := range out.Hunks {
 891		v, seen := sigs[h.SHA]
 892		if !seen {
 893			v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 894			sigs[h.SHA] = v
 895		}
 896		date := h.Date
 897		if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
 898			date = t.Format(time.RFC3339)
 899		}
 900		hv := hunkView{
 901			BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
 902				AuthorEmail: h.AuthorEmail, Summary: h.Summary,
 903				StartLine: h.StartLine, Lines: h.Lines},
 904			ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
 905		}
 906		for i, l := range h.Lines {
 907			hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 908		}
 909		hunks = append(hunks, hv)
 910	}
 911
 912	pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
 913	if pages == 0 {
 914		pages = 1
 915	}
 916	if page > pages {
 917		page = pages
 918	}
 919
 920	cs := crumbs(p, "blame", filePath)
 921	base := ""
 922	if len(cs) > 0 {
 923		base = cs[len(cs)-1].Name
 924		cs = cs[:len(cs)-1]
 925	}
 926	s.render(w, "blame.html", struct {
 927		repoPage
 928		Crumbs      []crumb
 929		Base        string
 930		Path        string
 931		Binary      bool
 932		Hunks       []hunkView
 933		Page, Pages int
 934	}{p, cs, base, filePath, binary, hunks, page, pages})
 935}
 936
 937type numberedLine struct {
 938	N    int
 939	Text string
 940}
 941
 942// chromaFormatter emits class-based markup (no inline colors), so the
 943// stylesheet can swap palettes with the color scheme.
 944var chromaFormatter = html.New(html.WithClasses(true),
 945	html.WithLineNumbers(true), html.LineNumbersInTable(false),
 946	html.WithLinkableLineNumbers(true, "L"))
 947
 948// chromaFormatterPlain is chromaFormatter without linkable line numbers,
 949// for a page that highlights more than one file: linkable ids are
 950// per-file line numbers, so several files on one page would repeat
 951// id="L1", id="L2", ...
 952var chromaFormatterPlain = html.New(html.WithClasses(true),
 953	html.WithLineNumbers(true), html.LineNumbersInTable(false))
 954
 955func highlight(filePath string, data []byte) template.HTML {
 956	return highlightWith(chromaFormatter, filePath, data)
 957}
 958
 959func highlightPlain(filePath string, data []byte) template.HTML {
 960	return highlightWith(chromaFormatterPlain, filePath, data)
 961}
 962
 963func highlightWith(formatter *html.Formatter, filePath string, data []byte) template.HTML {
 964	lexer := lexers.Match(filePath)
 965	if lexer == nil {
 966		lexer = lexers.Fallback
 967	}
 968	iterator, err := lexer.Tokenise(nil, string(data))
 969	if err != nil {
 970		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 971	}
 972	var buf bytes.Buffer
 973	if err := formatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
 974		return focusableBlocks(template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>"))
 975	}
 976	return focusableBlocks(template.HTML(buf.String()))
 977}
 978
 979// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
 980// The light one cannot be left unscoped: the two palettes do not name the
 981// same token set, and every token github-dark omits would keep its
 982// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
 983// Scoped, an unnamed token inherits the wrapper's colour instead, which is
 984// readable in both. The site's --code-bg stays the background either way.
 985// lightStyle and darkStyle are chosen on measured contrast against the
 986// grounds code actually sits on here — page, code block, and the diff
 987// tints. friendly, the chroma default, put 61 token/ground pairs under
 988// 4.5:1; xcode puts one.
 989const (
 990	lightStyle = "xcode"
 991	darkStyle  = "github-dark"
 992)
 993
 994var chromaCSS = func() []byte {
 995	var light, dark bytes.Buffer
 996	chromaFormatter.WriteCSS(&light, styles.Get(lightStyle))
 997	// xcode's NameAttribute is its one token under 4.5:1 against the diff
 998	// tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
 999	light.WriteString(".chroma .na { color: #6f5a21 }\n")
1000	chromaFormatter.WriteCSS(&dark, styles.Get(darkStyle))
1001	// Each palette applies under its media query unless the page is
1002	// stamped with the other theme, and again, outside any media query,
1003	// when the page is stamped with its own (#232).
1004	var buf bytes.Buffer
1005	buf.WriteString("@media (prefers-color-scheme: light) {\n")
1006	buf.WriteString(scopeChroma(light.String(), `:root:not([data-theme="dark"])`))
1007	buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
1008	buf.WriteString(scopeChroma(dark.String(), `:root:not([data-theme="light"])`))
1009	buf.WriteString("}\n")
1010	buf.WriteString(scopeChroma(light.String(), `:root[data-theme="light"]`))
1011	buf.WriteString(scopeChroma(dark.String(), `:root[data-theme="dark"]`))
1012	buf.WriteString(".chroma, .bg { background: transparent !important; }\n")
1013	// Line numbers take the site's own gutter colour in both schemes. Left
1014	// alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
1015	// chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
1016	// latter is a formatter fallback, not a style entry, so no palette test
1017	// can see it. !important because the scoped palette rules above outrank
1018	// a bare .chroma .ln.
1019	buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) !important }\n")
1020	return buf.Bytes()
1021}()
1022
1023func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
1024	p, ok := s.repoFor(w, r, r.PathValue("ref"))
1025	if !ok {
1026		return
1027	}
1028	filePath := strings.Trim(r.PathValue("path"), "/")
1029	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
1030	if err != nil {
1031		s.notFound(w, r)
1032		return
1033	}
1034	// Serve inert: never let repo content execute in the forge's origin.
1035	// Images get their real type so <img> works under nosniff; SVG script
1036	// is dead on arrival because the instance CSP is script-src 'none'.
1037	ct := "text/plain; charset=utf-8"
1038	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
1039		ct = t
1040	}
1041	w.Header().Set("Content-Type", ct)
1042	w.Header().Set("X-Content-Type-Options", "nosniff")
1043	w.Write(data)
1044}
1045
1046// imageTypes are the formats raw serves with a real content type and blob
1047// pages preview inline.
1048var imageTypes = map[string]string{
1049	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
1050	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
1051	".svg": "image/svg+xml", ".ico": "image/x-icon",
1052}
1053
1054// readmeRank orders competing README files: richer renderers win.
1055var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
1056
1057// pickReadme returns the best README-ish blob in a tree listing: any file
1058// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
1059// we can render richly.
1060func pickReadme(entries []gitutil.TreeEntry) string {
1061	best, bestRank := "", 1<<30
1062	for _, e := range entries {
1063		if e.Type != "blob" {
1064			continue
1065		}
1066		lower := strings.ToLower(e.Name)
1067		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
1068			continue
1069		}
1070		rank, ok := readmeRank[path.Ext(lower)]
1071		if !ok {
1072			rank = 10 // plaintext fallback
1073		}
1074		if rank < bestRank {
1075			best, bestRank = e.Name, rank
1076		}
1077	}
1078	return best
1079}
1080
1081// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1082// task lists) on top of CommonMark, with class-based fence highlighting
1083// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1084// dropped.
1085// Headings carry ids so a README or wiki section can be linked to, the
1086// way org headings already are (#132).
1087var markdown = goldmark.New(
1088	goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1089	goldmark.WithExtensions(extension.GFM,
1090		highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1091
1092// fenceHighlight renders one code block with chroma classes, for org and
1093// anything else outside goldmark. Unknown languages fall back to plain.
1094func fenceHighlight(source, lang string) string {
1095	lexer := lexers.Get(lang)
1096	if lexer == nil {
1097		lexer = lexers.Fallback
1098	}
1099	iterator, err := lexer.Tokenise(nil, source)
1100	if err != nil {
1101		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1102	}
1103	var buf bytes.Buffer
1104	f := html.New(html.WithClasses(true))
1105	if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1106		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1107	}
1108	return buf.String()
1109}
1110
1111// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1112// goldmark's default renderer drops raw HTML, so this is safe as-is.
1113func mdHTML(raw string) template.HTML {
1114	if strings.TrimSpace(raw) == "" {
1115		return ""
1116	}
1117	var buf bytes.Buffer
1118	if markdown.Convert([]byte(raw), &buf) != nil {
1119		return focusableBlocks(template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>"))
1120	}
1121	return focusableBlocks(template.HTML(buf.String()))
1122}
1123
1124// aboutHTML renders a profile's about text. It has no filename to
1125// dispatch on, so the stored format picks the extension; anything other
1126// than org is markdown.
1127func aboutHTML(p store.Profile) template.HTML {
1128	if strings.TrimSpace(p.About) == "" {
1129		return ""
1130	}
1131	name := "about.md"
1132	if p.AboutFormat == "org" {
1133		name = "about.org"
1134	}
1135	return renderReadme(name, []byte(p.About))
1136}
1137
1138// webResolver answers autolink lookups for one viewer. Cross-repo
1139// references to repositories the viewer cannot read stay plain text, per
1140// the enumeration rule: a link would confirm the repo exists.
1141type webResolver struct {
1142	s      *Server
1143	viewer store.User
1144}
1145
1146func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1147	repo, err := r.s.st.RepoByPath(owner + "/" + name)
1148	if err != nil {
1149		return ""
1150	}
1151	grant := ""
1152	if r.viewer.ID != 0 {
1153		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1154	}
1155	if !policy.CanRead(r.viewer, repo, grant) {
1156		return ""
1157	}
1158	if kind == '#' {
1159		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1160			return ""
1161		}
1162		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1163	}
1164	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1165		return ""
1166	}
1167	return autolink.MRURL(repo.OwnerName, repo.Name, n)
1168}
1169
1170func (r webResolver) UserURL(name string) string {
1171	if _, err := r.s.st.UserByUsername(name); err == nil {
1172		return "/" + name
1173	}
1174	if _, err := r.s.st.OrgByName(name); err == nil {
1175		return "/" + name
1176	}
1177	return ""
1178}
1179
1180// ugcRenderer renders one user-authored body in the format it was written in.
1181// The format travels with the body: it is recorded when the text is written, so
1182// changing a preference later cannot re-interpret prose that already exists.
1183type ugcRenderer func(raw, format string) template.HTML
1184
1185// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1186// so a body stored before formats existed — and any row whose column defaulted —
1187// renders exactly as it did before.
1188//
1189// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1190// about text take, so it inherits that function's include guard and sanitising
1191// rather than growing a second org renderer to keep in step.
1192func ugcHTML(raw, format string) template.HTML {
1193	if format == "org" {
1194		return focusableBlocks(renderOrg("body.org", []byte(raw), false, func() template.HTML {
1195			return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1196		}))
1197	}
1198	return mdHTML(raw)
1199}
1200
1201// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1202// ugcHTML plus cross-reference and mention autolinking for this viewer.
1203func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1204	viewer := store.User{}
1205	if s.cfg.Web.Mode == "accounts" {
1206		viewer = s.viewer(r)
1207	}
1208	res := webResolver{s, viewer}
1209	return func(raw, format string) template.HTML {
1210		h := ugcHTML(raw, format)
1211		if h == "" {
1212			return h
1213		}
1214		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1215	}
1216}
1217
1218// renderedComment pairs a comment with its rendered body for templates.
1219type renderedComment struct {
1220	Author    string
1221	CreatedAt string
1222	Kind      string
1223	BodyHTML  template.HTML
1224}
1225
1226func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1227	var out []renderedComment
1228	for _, c := range cs {
1229		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1230	}
1231	return out
1232}
1233
1234// ugcPolicy sanitizes rendered repo content before it enters the forge's
1235// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1236// output and repo-authored HTML are not. Chroma's highlighting classes
1237// must survive; the pattern admits only short token codes, not the site's
1238// own class names.
1239var ugcPolicy = func() *bluemonday.Policy {
1240	p := bluemonday.UGCPolicy()
1241	p.AllowAttrs("class").
1242		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1243		OnElements("span", "pre", "code", "div")
1244	return p
1245}()
1246
1247// renderReadme renders a README by extension: markdown, org-mode, and
1248// (sanitized) HTML richly; everything else as escaped plaintext.
1249// orgConfig is the go-org configuration for rendering untrusted org.
1250//
1251// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1252// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1253// wiki page, a profile — so both keywords are refused outright: the file is
1254// never opened and the keyword stays the inert text it is. There is no safe
1255// subset to allow instead. An absolute path skips go-org's relative-path join,
1256// a relative one resolves against the daemon's working directory, and a repo
1257// has no directory to scope to anyway because the content came from a git
1258// object rather than a checkout.
1259//
1260// The default logger writes parse warnings to stderr, which would let pushed
1261// content write to the server's log; discard them.
1262func orgConfig() *org.Configuration {
1263	c := org.New()
1264	c.ReadFile = func(string) ([]byte, error) {
1265		return nil, errOrgIncludeDisabled
1266	}
1267	c.Log = log.New(io.Discard, "", 0)
1268	return c
1269}
1270
1271var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1272
1273// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1274// of contents: a README or wiki page is a document and carries one, an issue
1275// comment is a remark and should not sprout one above two headings. `fallback`
1276// supplies the plaintext rendering used when the writer fails.
1277func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1278	c := orgConfig()
1279	if !contents {
1280		// DefaultSettings is a fresh map per org.New(), so this is local.
1281		c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1282	}
1283	doc := c.Parse(bytes.NewReader(raw), name)
1284	writer := org.NewHTMLWriter()
1285	writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1286		if inline {
1287			return "<code>" + template.HTMLEscapeString(source) + "</code>"
1288		}
1289		return fenceHighlight(source, lang)
1290	}
1291	writer.ExtendingWriter = &orgWriter{writer}
1292	out, err := doc.Write(writer)
1293	if err != nil {
1294		return fallback()
1295	}
1296	return imageAlt(template.HTML(ugcPolicy.Sanitize(out)))
1297}
1298
1299// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL
1300// at the first character outside RFC 3986's set, and that set includes
1301// `.`, `,` and `)`, so a URL closing a sentence or a parenthesis took the
1302// punctuation with it. Org stops a plain link before trailing punctuation
1303// and keeps a `)` only when a `(` inside the link opened it.
1304type orgWriter struct {
1305	*org.HTMLWriter
1306}
1307
1308func (w *orgWriter) WriteRegularLink(l org.RegularLink) {
1309	if !l.AutoLink {
1310		w.HTMLWriter.WriteRegularLink(l)
1311		return
1312	}
1313	url, rest := splitAutolinkPunctuation(l.URL)
1314	l.URL = url
1315	w.HTMLWriter.WriteRegularLink(l)
1316	if rest != "" {
1317		w.WriteText(org.Text{Content: rest})
1318	}
1319}
1320
1321// splitAutolinkPunctuation returns the URL without trailing sentence
1322// punctuation, and the punctuation it removed.
1323func splitAutolinkPunctuation(url string) (string, string) {
1324	end := len(url)
1325	for end > 0 {
1326		switch url[end-1] {
1327		case '.', ',', ';', ':', '!', '?', '\'', '"':
1328			end--
1329			continue
1330		case ')':
1331			if strings.Count(url[:end], ")") > strings.Count(url[:end], "(") {
1332				end--
1333				continue
1334			}
1335		}
1336		break
1337	}
1338	return url[:end], url[end:]
1339}
1340
1341// headingTag matches an opening or closing h1..h5 tag, so a rendered
1342// document's headings can move down one level.
1343var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1344
1345// demoteHeadings moves every heading in a rendered document down one
1346// level: the page it sits on already has its h1 (the repository, the
1347// file, the wiki page), so a README's own h1 would be a second top-level
1348// heading in the outline (#133). Ids and anchors are untouched.
1349func demoteHeadings(h template.HTML) template.HTML {
1350	return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1351		sub := headingTag.FindStringSubmatch(m)
1352		return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1353	}))
1354}
1355
1356func renderReadme(name string, raw []byte) template.HTML {
1357	plain := func() template.HTML {
1358		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1359	}
1360	if gitutil.IsBinary(raw) {
1361		return ""
1362	}
1363	var out template.HTML
1364	switch path.Ext(strings.ToLower(name)) {
1365	case ".md", ".markdown":
1366		var buf bytes.Buffer
1367		if markdown.Convert(raw, &buf) != nil {
1368			return focusableBlocks(plain())
1369		}
1370		out = demoteHeadings(template.HTML(buf.String()))
1371	case ".org":
1372		out = demoteHeadings(renderOrg(name, raw, true, plain))
1373	case ".html", ".htm":
1374		out = template.HTML(ugcPolicy.Sanitize(string(raw)))
1375	default:
1376		out = plain()
1377	}
1378	return focusableBlocks(out)
1379}
1380
1381type diffThread struct {
1382	ID       int64
1383	Resolved string
1384	Stale    bool
1385	// Pending marks a thread in the viewer's own unsubmitted review. Only
1386	// they are shown it, and the page says so, since it looks exactly
1387	// like a posted one otherwise.
1388	Pending    bool
1389	CanResolve bool
1390	Comments   []renderedComment
1391}
1392
1393// reviewRights decides which thread controls a viewer sees. mr resolve
1394// admits the thread author, the MR author, or anyone with write, so the
1395// page needs all three to render the button truthfully.
1396type reviewRights struct {
1397	Viewer   string
1398	MRAuthor string
1399	Write    bool
1400}
1401
1402func (r reviewRights) canResolve(threadAuthor string) bool {
1403	return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1404}
1405
1406// attachThreads injects review threads under their anchored diff lines;
1407// threads whose anchor no longer appears (stale after force-push, or on a
1408// context line outside the current diff) are returned separately.
1409func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1410	type anchor struct {
1411		path string
1412		side string
1413		line int64
1414	}
1415	// Diff-line comments have no stored format yet, so they stay markdown.
1416	// They are the one user-authored body left without the choice; see #51.
1417	threads := map[int64]*diffThread{}
1418	anchors := map[int64]anchor{}
1419	var order []int64
1420	for _, cm := range comments {
1421		if cm.ReplyTo == 0 {
1422			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1423				Pending:    cm.Pending,
1424				CanResolve: rights.canResolve(cm.Author),
1425				Comments:   []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1426			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1427			order = append(order, cm.ID)
1428		} else if th, ok := threads[cm.ReplyTo]; ok {
1429			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1430		}
1431	}
1432	placed := map[int64]bool{}
1433	for f := range files {
1434		lines := files[f].Lines
1435		for i := range lines {
1436			for _, id := range order {
1437				if placed[id] || threads[id].Stale {
1438					continue
1439				}
1440				a := anchors[id]
1441				if lines[i].Path != a.path {
1442					continue
1443				}
1444				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1445					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1446					lines[i].Threads = append(lines[i].Threads, *threads[id])
1447					files[f].Threads++
1448					files[f].Open = true
1449					placed[id] = true
1450				}
1451			}
1452		}
1453	}
1454	var unplaced []diffThread
1455	for _, id := range order {
1456		if !placed[id] {
1457			unplaced = append(unplaced, *threads[id])
1458		}
1459	}
1460	return files, unplaced
1461}
1462
1463// markCompose opens the new-thread form under one diff line. There is no
1464// JavaScript, so "comment on this line" is a plain GET carrying the
1465// anchor and the page renders the form where the reader asked for it.
1466func markCompose(files []diffFile, q url.Values) {
1467	path := q.Get("cpath")
1468	line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1469	if path == "" || line < 1 {
1470		return
1471	}
1472	old := q.Get("cside") == "old"
1473	for f := range files {
1474		for i := range files[f].Lines {
1475			ln := &files[f].Lines[i]
1476			if ln.Path != path {
1477				continue
1478			}
1479			if (old && ln.Class == "del" && ln.OldLine == line) ||
1480				(!old && ln.Class != "del" && ln.NewLine == line) {
1481				ln.Compose = true
1482				files[f].Open = true
1483				return
1484			}
1485		}
1486	}
1487}
1488
1489type sigView struct {
1490	State       string
1491	Signer      string
1492	Fingerprint string
1493}
1494
1495func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1496	raw, err := gitutil.ReadCommit(dir, sha)
1497	if err != nil {
1498		return sigView{State: "unsigned"}, nil
1499	}
1500	parsed, err := sig.ParseCommit(raw)
1501	if err != nil {
1502		return sigView{State: "unsigned"}, nil
1503	}
1504	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1505	if err != nil {
1506		return sigView{State: "unsigned"}, parsed
1507	}
1508	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1509	if res.SignerUserID != 0 {
1510		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1511			v.Signer = u.Username
1512		}
1513	}
1514	return v, parsed
1515}
1516
1517func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1518	ref := r.PathValue("ref")
1519	p, ok := s.repoFor(w, r, ref)
1520	if !ok {
1521		return
1522	}
1523	p.Tab = "log"
1524	p.Feed = "/" + p.Repo.Path() + "/log.atom/" + p.Ref
1525	const pageSize = 50
1526	// ?path= filters to commits touching one file or directory.
1527	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1528	if filePath == "." {
1529		filePath = ""
1530	}
1531	var shas []string
1532	var err error
1533	if filePath != "" {
1534		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1535	} else {
1536		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1537	}
1538	if err != nil {
1539		s.notFound(w, r)
1540		return
1541	}
1542	next := ""
1543	if len(shas) > pageSize {
1544		next = shas[pageSize]
1545		shas = shas[:pageSize]
1546	}
1547	type row struct {
1548		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1549		Sig                                                               sigView
1550		Check                                                             string // combined status, "" when none ran
1551	}
1552	names := s.authorNames()
1553	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1554	var rows []row
1555	for _, sha := range shas {
1556		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1557		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1558		if parsed != nil {
1559			rw.Subject = parsed.Subject
1560			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1561			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1562			rw.AuthorEmail = parsed.AuthorEmail
1563			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
1564		}
1565		rows = append(rows, rw)
1566	}
1567	s.render(w, "log.html", struct {
1568		repoPage
1569		Commits  []row
1570		NextSHA  string
1571		FilePath string
1572	}{p, rows, next, filePath})
1573}
1574
1575func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1576	p, ok := s.repoFor(w, r, "")
1577	if !ok {
1578		return
1579	}
1580	p.Tab = "log"
1581	sha := r.PathValue("sha")
1582	full, err := gitutil.ResolveRef(p.Dir, sha)
1583	if err != nil {
1584		s.notFound(w, r)
1585		return
1586	}
1587	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1588	if parsed == nil {
1589		s.notFound(w, r)
1590		return
1591	}
1592	patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1593	files := parseDiff(patch)
1594	committerEmail := ""
1595	if parsed.CommitterEmail != parsed.AuthorEmail {
1596		committerEmail = parsed.CommitterEmail
1597	}
1598	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1599	commitNames := s.authorNames()
1600	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1601	msg := ""
1602	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1603		msg = string(parsed.Payload[i+2:])
1604	}
1605	s.render(w, "commit.html", struct {
1606		repoPage
1607		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1608		Parents                                                                           []string
1609		Sig                                                                               sigView
1610		Checks                                                                            []store.CommitStatus
1611		DiffFiles                                                                         []diffFile
1612		DiffTruncated                                                                     bool
1613	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1614		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1615		gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1616}
1617
1618// labelPalette provides default label chip colors: mid-tone hues that stay
1619// legible on light and dark backgrounds.
1620var labelPalette = []string{
1621	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1622	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1623}
1624
1625var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1626
1627// clampChip keeps a user-set label colour legible as text on both
1628// grounds. Contrast is defined on relative luminance, so that is what is
1629// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1630// and against the dark ground alike, and where the palette's own colours
1631// sit. The hue is kept; the channels are scaled in linear light (#120).
1632func clampChip(hex string) string {
1633	lin := func(c int64) float64 {
1634		v := float64(c) / 255
1635		if v <= 0.04045 {
1636			return v / 12.92
1637		}
1638		return math.Pow((v+0.055)/1.055, 2.4)
1639	}
1640	r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1641	y := 0.2126*r + 0.7152*g + 0.0722*b
1642	const lo, hi = 0.12, 0.28
1643	if y >= lo && y <= hi {
1644		return strings.ToLower(hex)
1645	}
1646	target := hi
1647	if y < lo {
1648		target = lo
1649	}
1650	if y == 0 {
1651		r, g, b = target, target, target
1652	} else {
1653		k := target / y
1654		r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1655	}
1656	enc := func(v float64) int {
1657		if v <= 0.0031308 {
1658			v *= 12.92
1659		} else {
1660			v = 1.055*math.Pow(v, 1/2.4) - 0.055
1661		}
1662		return int(math.Round(v * 255))
1663	}
1664	return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1665}
1666
1667func hexByte(s string) int64 {
1668	n, _ := strconv.ParseInt(s, 16, 32)
1669	return n
1670}
1671
1672// labelColors returns a complete label-name -> chip color map for a repo:
1673// the stored labels.color when it is a valid hex color, otherwise a
1674// stable default picked from the palette by name hash.
1675func (s *Server) labelColors(repo store.Repo) map[string]template.CSS {
1676	stored, _ := s.st.LabelColors(repo)
1677	return colorStyles(stored)
1678}
1679
1680// colorStyles turns a label-name -> stored color map into chip styles: the
1681// stored color when it is a valid hex color, otherwise a stable default
1682// picked from the palette by name hash.
1683func colorStyles(stored map[string]string) map[string]template.CSS {
1684	out := make(map[string]template.CSS, len(stored))
1685	for name, color := range stored {
1686		if !hexColorPat.MatchString(color) {
1687			h := fnv.New32a()
1688			h.Write([]byte(name))
1689			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1690		}
1691		out[name] = template.CSS("--chip:" + clampChip(color))
1692	}
1693	return out
1694}
1695
1696// listPage is how many issues or merge requests a list page shows before
1697// it offers the older ones (#118). Keyset paging on the number, the same
1698// cursor the commands use, so every filter carries across pages.
1699const listPage = 50
1700
1701// olderLink is the current URL with before=<number> set.
1702func olderLink(r *http.Request, before int64) string {
1703	q := r.URL.Query()
1704	q.Set("before", strconv.FormatInt(before, 10))
1705	return "?" + q.Encode()
1706}
1707
1708func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1709	p, ok := s.repoFor(w, r, "")
1710	if !ok {
1711		return
1712	}
1713	p.Tab = "issues"
1714	state := r.URL.Query().Get("state")
1715	if state != "closed" && state != "all" {
1716		state = "open"
1717	}
1718	// The same filters the CLI's issue list takes, as query parameters;
1719	// label chips and author links point here.
1720	qv := r.URL.Query()
1721	f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1722		Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1723		Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1724	f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1725	issues, err := s.st.QueryIssues(p.Repo.ID, f)
1726	if err != nil {
1727		http.Error(w, "internal error", http.StatusInternalServerError)
1728		return
1729	}
1730	older := ""
1731	if len(issues) > listPage {
1732		issues = issues[:listPage]
1733		older = olderLink(r, issues[len(issues)-1].Number)
1734	}
1735	if labels, err := s.st.ListIssueLabels(p.Repo); err == nil {
1736		for i := range issues {
1737			issues[i].Labels = labels[issues[i].ID]
1738		}
1739	}
1740	s.render(w, "issues.html", struct {
1741		repoPage
1742		State       string
1743		Label       string
1744		Query       string
1745		Filters     []listFilter
1746		Issues      []store.Issue
1747		LabelColors map[string]template.CSS
1748		Older       string
1749	}{p, state, f.Label, f.Search,
1750		activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1751		issues, s.labelColors(p.Repo), older})
1752}
1753
1754func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1755	s.issuePage(w, r, "")
1756}
1757
1758// issuePage renders an issue. previewForm names the form that asked to
1759// see its markup rather than save it — "edit" or "comment", "" for a
1760// plain read — and the page renders that draft above the form it came
1761// from, in the format the write would have stored (#235).
1762func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm string) {
1763	p, ok := s.repoFor(w, r, "")
1764	if !ok {
1765		return
1766	}
1767	p.Tab = "issues"
1768	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1769	if err != nil {
1770		s.notFound(w, r)
1771		return
1772	}
1773	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1774	if err != nil {
1775		s.notFound(w, r)
1776		return
1777	}
1778	comments, err := s.st.ListIssueComments(iss.ID)
1779	if err != nil {
1780		http.Error(w, "internal error", http.StatusInternalServerError)
1781		return
1782	}
1783	md := s.ugcFor(r, p.Repo)
1784	// An edit keeps the issue's stored format; a comment has no picker
1785	// and is markdown, which is what issue comment stores with no
1786	// --format.
1787	var d *draft
1788	if previewForm != "" {
1789		format := iss.BodyFormat
1790		if previewForm == "comment" {
1791			format = "md"
1792		}
1793		d = s.draftFor(r, p.Repo, previewForm, "body", format)
1794	}
1795	// nil readable: the picker lists titles, never the progress counts.
1796	milestones, _ := s.st.ListMilestones(p.Repo, "open", nil)
1797	s.render(w, "issue.html", struct {
1798		repoPage
1799		Issue       store.Issue
1800		BodyHTML    template.HTML
1801		Comments    []renderedComment
1802		CanEdit     bool
1803		CanWrite    bool
1804		Milestones  []store.Milestone
1805		Notice      string
1806		LabelColors map[string]template.CSS
1807		Draft       *draft
1808	}{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1809		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1810		milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d})
1811}
1812
1813// canEditItem: the author or anyone with write access may edit.
1814// canWriteRepo reports whether the browser session may push to the repo,
1815// which is what gates the review and merge controls.
1816func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1817	if s.cfg.Web.Mode != "accounts" {
1818		return false
1819	}
1820	u := s.viewer(r)
1821	if u.ID == 0 {
1822		return false
1823	}
1824	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1825	return policy.CanWrite(u, repo, grant)
1826}
1827
1828func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1829	if s.cfg.Web.Mode != "accounts" {
1830		return false
1831	}
1832	u := s.viewer(r)
1833	if u.ID == 0 {
1834		return false
1835	}
1836	if u.Username == author {
1837		return true
1838	}
1839	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1840	return policy.CanWrite(u, repo, grant)
1841}
1842
1843// mrRow is one row of the merge request list: the MR plus its head's
1844// combined check state and its comment count. Errors gathering either
1845// fall back to zero values (#230) — the list must still render.
1846type mrRow struct {
1847	store.MR
1848	Check    string
1849	Comments int
1850}
1851
1852func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1853	p, ok := s.repoFor(w, r, "")
1854	if !ok {
1855		return
1856	}
1857	p.Tab = "merge requests"
1858	state := r.URL.Query().Get("state")
1859	if state == "" {
1860		state = "open"
1861	}
1862	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1863	if !valid[state] {
1864		state = "open"
1865	}
1866	qv := r.URL.Query()
1867	mf := store.MRFilter{State: state, Label: qv.Get("label"), Author: qv.Get("author"),
1868		Milestone: qv.Get("milestone"), Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1869	mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1870	mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1871	if err != nil {
1872		http.Error(w, "internal error", http.StatusInternalServerError)
1873		return
1874	}
1875	older := ""
1876	if len(mrs) > listPage {
1877		mrs = mrs[:listPage]
1878		older = olderLink(r, mrs[len(mrs)-1].Number)
1879	}
1880	shas := make([]string, len(mrs))
1881	ids := make([]int64, len(mrs))
1882	for i, m := range mrs {
1883		shas[i] = m.HeadSHA
1884		ids[i] = m.ID
1885	}
1886	checks, err := s.st.CombinedStatusFor(p.Repo.ID, shas)
1887	if err != nil {
1888		checks = map[string]string{}
1889	}
1890	comments, err := s.st.MRCommentCounts(p.Repo.ID, ids)
1891	if err != nil {
1892		comments = map[int64]int{}
1893	}
1894	labels, err := s.st.ListMRLabels(p.Repo)
1895	if err != nil {
1896		labels = map[int64][]string{}
1897	}
1898	rows := make([]mrRow, len(mrs))
1899	for i, m := range mrs {
1900		m.Labels = labels[m.ID]
1901		rows[i] = mrRow{MR: m, Check: checks[m.HeadSHA], Comments: comments[m.ID]}
1902	}
1903	s.render(w, "mrs.html", struct {
1904		repoPage
1905		State       string
1906		Query       string
1907		Filters     []listFilter
1908		MRs         []mrRow
1909		LabelColors map[string]template.CSS
1910		Older       string
1911	}{p, state, mf.Search,
1912		activeFilters(state, [][2]string{{"label", mf.Label}, {"author", mf.Author}, {"milestone", mf.Milestone}}),
1913		rows, s.labelColors(p.Repo), older})
1914}
1915
1916func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1917	s.mrPage(w, r, "")
1918}
1919
1920// mrPage renders a merge request. previewForm names the form that asked
1921// to see its markup rather than save it — "edit" or "comment", "" for a
1922// plain read (#235).
1923func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm string) {
1924	p, ok := s.repoFor(w, r, "")
1925	if !ok {
1926		return
1927	}
1928	p.Tab = "merge requests"
1929	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1930	if err != nil {
1931		s.notFound(w, r)
1932		return
1933	}
1934	m, err := s.st.MRByNumber(p.Repo.ID, n)
1935	if err != nil {
1936		s.notFound(w, r)
1937		return
1938	}
1939	comments, _ := s.st.ListMRComments(m.ID)
1940	reviews, _ := s.st.ListMRReviews(m.ID)
1941	// The same rule the merge gates apply, so the page cannot show an
1942	// approval the gate ignores (#147).
1943	reviewCounts := control.ReviewersWhoCount(s.st, p.Repo, reviews)
1944	reviewRows := make([]reviewRow, 0, len(reviews))
1945	for _, r := range reviews {
1946		reviewRows = append(reviewRows, reviewRow{MRReview: r, Counts: reviewCounts[r.Reviewer]})
1947	}
1948	checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1949	// The viewer sees their own unsubmitted review comments and nobody
1950	// else's.
1951	diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
1952
1953	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1954	// An admin can prune the head ref; the diff is then unavailable, not
1955	// empty, and the page must not read as the latter.
1956	_, headErr := gitutil.ResolveRef(p.Dir, headRef)
1957	headPruned := headErr != nil
1958	var files []diffFile
1959	base := m.MergedBase
1960	if base == "" {
1961		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1962			base = b
1963		}
1964	}
1965	var diffTruncated bool
1966	if base != "" {
1967		if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1968			files, diffTruncated = parseDiff(patch), truncated
1969		}
1970	}
1971	// The head is already reachable from the target, so the diff is empty
1972	// by construction rather than because nothing changed.
1973	headMerged := false
1974	if len(files) == 0 && m.HeadSHA != "" {
1975		if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
1976			if ok, err := gitutil.IsAncestor(p.Dir, m.HeadSHA, targetSHA); err == nil {
1977				headMerged = ok
1978			}
1979		}
1980	}
1981	md := s.ugcFor(r, p.Repo)
1982	canWrite := s.canWriteRepo(r, p.Repo)
1983	var detachedThreads []diffThread
1984	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1985		reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1986	if p.Viewer != "" {
1987		markCompose(files, r.URL.Query())
1988	}
1989	stat := statOf(files)
1990	// The commits this MR carries: base..head, the same range as the diff.
1991	type commitRow struct {
1992		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1993		Sig                                                  sigView
1994	}
1995	mrNames := s.authorNames()
1996	var commits []commitRow
1997	commitsTotal := 0
1998	if base != "" {
1999		const maxMRCommits = 100
2000		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
2001		commitsTotal = len(shas)
2002		if len(shas) > maxMRCommits {
2003			shas = shas[:maxMRCommits]
2004		}
2005		for _, sha := range shas {
2006			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
2007			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
2008			if parsed != nil {
2009				cr.Subject = parsed.Subject
2010				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
2011				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
2012				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
2013			}
2014			commits = append(commits, cr)
2015		}
2016	}
2017	// The diff is the reason most people open a merge request, so it gets
2018	// its own view rather than a fold at the foot of the conversation.
2019	// A query parameter keeps this working without JavaScript.
2020	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
2021	// The revisions this merge request has had. A stale review is the
2022	// moment someone wants to know what moved, so the link to the
2023	// range-diff belongs next to it.
2024	revisions, _ := s.st.MRHeads(m.ID)
2025	branches, _ := gitutil.Refs(p.Dir, "heads")
2026	view := r.URL.Query().Get("view")
2027	if view != "commits" && view != "diff" {
2028		view = "conversation"
2029	}
2030	// Where the merge request stands against the gates, the same
2031	// computation mr merge refuses on (#199).
2032	var gates *control.GatesOut
2033	if m.State == "open" || m.State == "source_gone" {
2034		if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
2035			if g, err := control.MergeGates(s.st, p.Repo, m, p.Dir, targetSHA, m.HeadSHA); err == nil {
2036				gates = &g
2037			}
2038		}
2039	}
2040	// The stack around an open merge request, for the header.
2041	var stackedOn *store.MR
2042	var stacked []store.MR
2043	if m.State == "open" {
2044		if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
2045			stackedOn = &parent
2046		}
2047		if m.SourceRepoID == p.Repo.ID {
2048			stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
2049		}
2050	}
2051	// The merge requests this one superseded when it was closed, so the
2052	// page it points to can also say what it supersedes.
2053	supersedes, _ := s.st.MRsSuperseding(p.Repo.ID, m.Number)
2054	// An edit keeps the merge request's stored format; a comment has no
2055	// picker and is markdown, as mr comment stores with no --format.
2056	var d *draft
2057	if previewForm != "" {
2058		format := m.BodyFormat
2059		if previewForm == "comment" {
2060			format = "md"
2061		}
2062		d = s.draftFor(r, p.Repo, previewForm, "body", format)
2063	}
2064	s.render(w, "mr.html", struct {
2065		repoPage
2066		MR              store.MR
2067		View            string
2068		BodyHTML        template.HTML
2069		Checks          []store.Check
2070		Combined        string
2071		Comments        []renderedComment
2072		Reviews         []reviewRow
2073		DiffFiles       []diffFile
2074		DiffTruncated   bool
2075		Stat            diffStat
2076		Commits         []commitRow
2077		CommitsTotal    int
2078		Branches        []gitutil.Ref
2079		CanEdit         bool
2080		CanWrite        bool
2081		Unresolved      int
2082		Revisions       []store.MRHead
2083		Notice          string
2084		DetachedThreads []diffThread
2085		StackedOn       *store.MR
2086		Stacked         []store.MR
2087		Supersedes      []store.MR
2088		Gates           *control.GatesOut
2089		SourceGone      bool
2090		HeadMerged      bool
2091		HeadPruned      bool
2092		Base            string
2093		LabelColors     map[string]template.CSS
2094		Draft           *draft
2095	}{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
2096		reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
2097		canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates,
2098		sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d})
2099}
2100
2101// sourceGone reports whether an MR's source branch no longer exists: the
2102// push hook marks a deleted branch on an open MR, and a merged or closed
2103// one is checked here. A fork's branch lives in another repository and
2104// is left to the recorded state.
2105func sourceGone(p repoPage, m store.MR) bool {
2106	if m.State == "source_gone" {
2107		return true
2108	}
2109	if m.SourceRepoID != p.Repo.ID {
2110		return false
2111	}
2112	_, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.SourceRef)
2113	return err != nil
2114}
2115
2116func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
2117	p, ok := s.repoFor(w, r, "")
2118	if !ok {
2119		return
2120	}
2121	p.Tab = "refs"
2122	branches, _ := gitutil.Refs(p.Dir, "heads")
2123	tags, _ := gitutil.Refs(p.Dir, "tags")
2124	gitutil.SortVersions(tags)
2125	s.render(w, "refs.html", struct {
2126		repoPage
2127		Branches, Tags []gitutil.Ref
2128	}{p, branches, tags})
2129}
2130
2131func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
2132	p, ok := s.repoFor(w, r, "")
2133	if !ok {
2134		return
2135	}
2136	file := r.PathValue("file")
2137	ref, ok := strings.CutSuffix(file, ".tar.gz")
2138	if !ok {
2139		s.notFound(w, r)
2140		return
2141	}
2142	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
2143		s.notFound(w, r)
2144		return
2145	}
2146	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
2147	w.Header().Set("Content-Type", "application/gzip")
2148	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
2149	gitutil.Archive(p.Dir, ref, prefix, w)
2150}
2151
2152func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
2153	return policy.CanAdmin(u, repo, grant)
2154}
2155
2156func policyCanRead(u store.User, repo store.Repo, grant string) bool {
2157	return policy.CanRead(u, repo, grant)
2158}
2159
2160// reviewRow is a review with whether the merge gates count it, which
2161// depends on the reviewer's access and so is not a property of the
2162// review row itself.
2163type reviewRow struct {
2164	store.MRReview
2165	Counts bool
2166}
2167
2168// sshCloneURL is the SSH clone URL for a repository, with the port only
2169// when it is not the default.
2170func (s *Server) sshCloneURL(repo store.Repo) string {
2171	host := s.cfg.SiteHost()
2172	if s.cfg.SSH.Port != 22 {
2173		host += ":" + strconv.Itoa(s.cfg.SSH.Port)
2174	}
2175	return "ssh://git@" + host + "/" + repo.Path() + ".git"
2176}