internal/httpd/web.go

69d8aa4ff9115d6ca1116e09a232e7a6416e4645
gitbay/internal/httpd/web.go history · blame · raw

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