internal/httpd/web.go

2069 lines · 65598 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// 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 := false
 581	switch path.Ext(strings.ToLower(filePath)) {
 582	case ".md", ".markdown", ".org":
 583		renderable = !binary
 584	}
 585	var renderedHTML template.HTML
 586	rendered := renderable && r.URL.Query().Get("view") != "source"
 587	if rendered {
 588		renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
 589	}
 590	cs := crumbs(p, "blob", filePath)
 591	base := ""
 592	if len(cs) > 0 {
 593		base = cs[len(cs)-1].Name
 594		cs = cs[:len(cs)-1]
 595	}
 596	branches, _ := gitutil.Refs(p.Dir, "heads")
 597	lines := 0
 598	if !binary && !image && len(data) > 0 {
 599		lines = bytes.Count(data, []byte("\n"))
 600		if data[len(data)-1] != '\n' {
 601			lines++
 602		}
 603	}
 604	// The file listing leads with the last commit now, so the facts about
 605	// the file itself are reported here instead.
 606	entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
 607	s.render(w, "blob.html", struct {
 608		repoPage
 609		Crumbs       []crumb
 610		Base         string
 611		Path         string
 612		DirPath      string
 613		RefKind      string
 614		Binary       bool
 615		Image        bool
 616		Size         int
 617		Lines        int
 618		Exec         bool
 619		Symlink      bool
 620		Branches     []gitutil.Ref
 621		CodeHTML     template.HTML
 622		Renderable   bool // markdown or org: the toggle is offered
 623		Rendered     bool // this response shows the rendering
 624		RenderedHTML template.HTML
 625	}{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
 626		entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
 627}
 628
 629// releases lists tag-anchored releases with notes and assets.
 630func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 631	p, ok := s.repoFor(w, r, "")
 632	if !ok {
 633		return
 634	}
 635	p.Tab = "releases"
 636	p.Feed = "/" + p.Repo.Path() + "/releases.atom"
 637	rels, err := s.st.ListReleases(p.Repo.ID)
 638	if err != nil {
 639		http.Error(w, "internal error", http.StatusInternalServerError)
 640		return
 641	}
 642	md := s.ugcFor(r, p.Repo)
 643	type relView struct {
 644		store.Release
 645		NotesHTML template.HTML
 646	}
 647	var views []relView
 648	for _, rel := range rels {
 649		views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
 650	}
 651	// Tags without a release yet are what a create form can offer.
 652	released := map[string]bool{}
 653	for _, rel := range rels {
 654		released[rel.Tag] = true
 655	}
 656	var freeTags []string
 657	if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
 658		gitutil.SortVersions(tags)
 659		for _, tg := range tags {
 660			if !released[tg.Name] {
 661				freeTags = append(freeTags, tg.Name)
 662			}
 663		}
 664	}
 665	s.render(w, "releases.html", struct {
 666		repoPage
 667		Releases []relView
 668		FreeTags []string
 669		CanWrite bool
 670		Notice   string
 671	}{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
 672}
 673
 674// releaseAsset streams one uploaded asset. Tags containing '/' are not
 675// reachable here (single path segment); SSH download always works.
 676func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 677	p, ok := s.repoFor(w, r, "")
 678	if !ok {
 679		return
 680	}
 681	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 682	if err != nil {
 683		s.notFound(w, r)
 684		return
 685	}
 686	name := r.PathValue("name")
 687	found := false
 688	for _, a := range rel.Assets {
 689		if a.Name == name {
 690			found = true
 691		}
 692	}
 693	if !found {
 694		s.notFound(w, r)
 695		return
 696	}
 697	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 698		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 699	if err != nil {
 700		s.notFound(w, r)
 701		return
 702	}
 703	defer f.Close()
 704	w.Header().Set("Content-Type", "application/octet-stream")
 705	w.Header().Set("X-Content-Type-Options", "nosniff")
 706	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 707	if fi, err := f.Stat(); err == nil {
 708		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 709	}
 710	io.Copy(w, f)
 711}
 712
 713// milestones lists a repo's milestones with progress.
 714func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 715	p, ok := s.repoFor(w, r, "")
 716	if !ok {
 717		return
 718	}
 719	p.Tab = "issues"
 720	state := r.URL.Query().Get("state")
 721	if state != "closed" && state != "all" {
 722		state = "open"
 723	}
 724	readable, err := control.ReadableScope(s.st, s.viewer(r), p.Repo)
 725	if err != nil {
 726		http.Error(w, "internal error", http.StatusInternalServerError)
 727		return
 728	}
 729	ms, err := s.st.ListMilestones(p.Repo, state, readable)
 730	if err != nil {
 731		http.Error(w, "internal error", http.StatusInternalServerError)
 732		return
 733	}
 734	type msView struct {
 735		store.Milestone
 736		Percent int
 737	}
 738	var views []msView
 739	for _, m := range ms {
 740		v := msView{Milestone: m}
 741		if total := m.OpenItems + m.ClosedItems; total > 0 {
 742			v.Percent = m.ClosedItems * 100 / total
 743		}
 744		views = append(views, v)
 745	}
 746	s.render(w, "milestones.html", struct {
 747		repoPage
 748		State      string
 749		Milestones []msView
 750	}{p, state, views})
 751}
 752
 753// search runs a bounded literal git grep over the repo's default branch.
 754func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 755	p, ok := s.repoFor(w, r, "")
 756	if !ok {
 757		return
 758	}
 759	p.Tab = "search"
 760	q := strings.TrimSpace(r.URL.Query().Get("q"))
 761	type matchView struct {
 762		Path     string
 763		Line     int
 764		TextHTML template.HTML
 765	}
 766	var matches []matchView
 767	var queryErr string
 768	if q != "" {
 769		if len(q) < 2 || len(q) > 200 {
 770			queryErr = "query must be 2 to 200 characters"
 771		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 772			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 773			if err != nil {
 774				http.Error(w, "internal error", http.StatusInternalServerError)
 775				return
 776			}
 777			for _, m := range raw {
 778				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 779			}
 780		}
 781	}
 782	s.render(w, "search.html", struct {
 783		repoPage
 784		Query    string
 785		QueryErr string
 786		Matches  []matchView
 787		Capped   bool
 788	}{p, q, queryErr, matches, len(matches) == 200})
 789}
 790
 791// markMatch escapes a matched line and wraps case-insensitive occurrences
 792// of the query in <mark>.
 793func markMatch(text, q string) template.HTML {
 794	lower, lq := strings.ToLower(text), strings.ToLower(q)
 795	var b strings.Builder
 796	pos := 0
 797	for {
 798		i := strings.Index(lower[pos:], lq)
 799		if i < 0 {
 800			break
 801		}
 802		i += pos
 803		b.WriteString(template.HTMLEscapeString(text[pos:i]))
 804		b.WriteString("<mark>")
 805		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
 806		b.WriteString("</mark>")
 807		pos = i + len(q)
 808	}
 809	b.WriteString(template.HTMLEscapeString(text[pos:]))
 810	return template.HTML(b.String())
 811}
 812
 813func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 814	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 815	if !ok {
 816		return
 817	}
 818	p.Tab = "files"
 819	filePath := strings.Trim(r.PathValue("path"), "/")
 820
 821	// Blame is a control command; the web renders what it returns rather
 822	// than shelling out to git itself, so all three surfaces agree.
 823	page := 1
 824	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
 825		page = n
 826	}
 827	from := (page-1)*control.BlameSpan + 1
 828
 829	var out struct {
 830		From       int `json:"from"`
 831		To         int `json:"to"`
 832		TotalLines int `json:"total_lines"`
 833		Hunks      []struct {
 834			SHA         string   `json:"sha"`
 835			AuthorName  string   `json:"author_name"`
 836			AuthorEmail string   `json:"author_email"`
 837			Date        string   `json:"date"`
 838			Summary     string   `json:"summary"`
 839			StartLine   int      `json:"start_line"`
 840			Lines       []string `json:"lines"`
 841		} `json:"hunks"`
 842	}
 843	argv := []string{"repo", "blame", p.Repo.Path(), filePath,
 844		"--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
 845	var viewer store.User
 846	if s.cfg.Web.Mode == "accounts" {
 847		viewer = s.viewer(r)
 848	}
 849	msg, ok := s.runControlInto(viewer, argv, &out)
 850
 851	// A binary or empty file is a refusal, not a 404: the page still
 852	// renders and says why there is nothing to attribute.
 853	binary := false
 854	if !ok {
 855		if strings.Contains(msg, "is binary") {
 856			binary = true
 857		} else {
 858			s.notFound(w, r)
 859			return
 860		}
 861	}
 862
 863	type hunkView struct {
 864		gitutil.BlameHunk
 865		ShortSHA string
 866		Date     string
 867		Sig      sigView
 868		Numbered []numberedLine
 869	}
 870	var hunks []hunkView
 871	sigs := map[string]sigView{}
 872	for _, h := range out.Hunks {
 873		v, seen := sigs[h.SHA]
 874		if !seen {
 875			v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 876			sigs[h.SHA] = v
 877		}
 878		date := h.Date
 879		if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
 880			date = t.Format(time.RFC3339)
 881		}
 882		hv := hunkView{
 883			BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
 884				AuthorEmail: h.AuthorEmail, Summary: h.Summary,
 885				StartLine: h.StartLine, Lines: h.Lines},
 886			ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
 887		}
 888		for i, l := range h.Lines {
 889			hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 890		}
 891		hunks = append(hunks, hv)
 892	}
 893
 894	pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
 895	if pages == 0 {
 896		pages = 1
 897	}
 898	if page > pages {
 899		page = pages
 900	}
 901
 902	cs := crumbs(p, "blame", filePath)
 903	base := ""
 904	if len(cs) > 0 {
 905		base = cs[len(cs)-1].Name
 906		cs = cs[:len(cs)-1]
 907	}
 908	s.render(w, "blame.html", struct {
 909		repoPage
 910		Crumbs      []crumb
 911		Base        string
 912		Path        string
 913		Binary      bool
 914		Hunks       []hunkView
 915		Page, Pages int
 916	}{p, cs, base, filePath, binary, hunks, page, pages})
 917}
 918
 919type numberedLine struct {
 920	N    int
 921	Text string
 922}
 923
 924// chromaFormatter emits class-based markup (no inline colors), so the
 925// stylesheet can swap palettes with the color scheme.
 926var chromaFormatter = html.New(html.WithClasses(true),
 927	html.WithLineNumbers(true), html.LineNumbersInTable(false),
 928	html.WithLinkableLineNumbers(true, "L"))
 929
 930// chromaFormatterPlain is chromaFormatter without linkable line numbers,
 931// for a page that highlights more than one file: linkable ids are
 932// per-file line numbers, so several files on one page would repeat
 933// id="L1", id="L2", ...
 934var chromaFormatterPlain = html.New(html.WithClasses(true),
 935	html.WithLineNumbers(true), html.LineNumbersInTable(false))
 936
 937func highlight(filePath string, data []byte) template.HTML {
 938	return highlightWith(chromaFormatter, filePath, data)
 939}
 940
 941func highlightPlain(filePath string, data []byte) template.HTML {
 942	return highlightWith(chromaFormatterPlain, filePath, data)
 943}
 944
 945func highlightWith(formatter *html.Formatter, filePath string, data []byte) template.HTML {
 946	lexer := lexers.Match(filePath)
 947	if lexer == nil {
 948		lexer = lexers.Fallback
 949	}
 950	iterator, err := lexer.Tokenise(nil, string(data))
 951	if err != nil {
 952		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 953	}
 954	var buf bytes.Buffer
 955	if err := formatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
 956		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 957	}
 958	return template.HTML(buf.String())
 959}
 960
 961// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
 962// The light one cannot be left unscoped: the two palettes do not name the
 963// same token set, and every token github-dark omits would keep its
 964// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
 965// Scoped, an unnamed token inherits the wrapper's colour instead, which is
 966// readable in both. The site's --code-bg stays the background either way.
 967// lightStyle and darkStyle are chosen on measured contrast against the
 968// grounds code actually sits on here — page, code block, and the diff
 969// tints. friendly, the chroma default, put 61 token/ground pairs under
 970// 4.5:1; xcode puts one.
 971const (
 972	lightStyle = "xcode"
 973	darkStyle  = "github-dark"
 974)
 975
 976var chromaCSS = func() []byte {
 977	var buf bytes.Buffer
 978	buf.WriteString("@media (prefers-color-scheme: light) {\n")
 979	chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
 980	// xcode's NameAttribute is its one token under 4.5:1 against the diff
 981	// tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
 982	buf.WriteString(".chroma .na { color: #6f5a21 }\n")
 983	buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
 984	chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
 985	buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
 986	// Line numbers take the site's own gutter colour in both schemes. Left
 987	// alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
 988	// chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
 989	// latter is a formatter fallback, not a style entry, so no palette test
 990	// can see it.
 991	buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
 992	return buf.Bytes()
 993}()
 994
 995func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 996	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 997	if !ok {
 998		return
 999	}
1000	filePath := strings.Trim(r.PathValue("path"), "/")
1001	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
1002	if err != nil {
1003		s.notFound(w, r)
1004		return
1005	}
1006	// Serve inert: never let repo content execute in the forge's origin.
1007	// Images get their real type so <img> works under nosniff; SVG script
1008	// is dead on arrival because the instance CSP is script-src 'none'.
1009	ct := "text/plain; charset=utf-8"
1010	if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
1011		ct = t
1012	}
1013	w.Header().Set("Content-Type", ct)
1014	w.Header().Set("X-Content-Type-Options", "nosniff")
1015	w.Write(data)
1016}
1017
1018// imageTypes are the formats raw serves with a real content type and blob
1019// pages preview inline.
1020var imageTypes = map[string]string{
1021	".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
1022	".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
1023	".svg": "image/svg+xml", ".ico": "image/x-icon",
1024}
1025
1026// readmeRank orders competing README files: richer renderers win.
1027var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
1028
1029// pickReadme returns the best README-ish blob in a tree listing: any file
1030// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
1031// we can render richly.
1032func pickReadme(entries []gitutil.TreeEntry) string {
1033	best, bestRank := "", 1<<30
1034	for _, e := range entries {
1035		if e.Type != "blob" {
1036			continue
1037		}
1038		lower := strings.ToLower(e.Name)
1039		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
1040			continue
1041		}
1042		rank, ok := readmeRank[path.Ext(lower)]
1043		if !ok {
1044			rank = 10 // plaintext fallback
1045		}
1046		if rank < bestRank {
1047			best, bestRank = e.Name, rank
1048		}
1049	}
1050	return best
1051}
1052
1053// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1054// task lists) on top of CommonMark, with class-based fence highlighting
1055// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1056// dropped.
1057// Headings carry ids so a README or wiki section can be linked to, the
1058// way org headings already are (#132).
1059var markdown = goldmark.New(
1060	goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1061	goldmark.WithExtensions(extension.GFM,
1062		highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1063
1064// fenceHighlight renders one code block with chroma classes, for org and
1065// anything else outside goldmark. Unknown languages fall back to plain.
1066func fenceHighlight(source, lang string) string {
1067	lexer := lexers.Get(lang)
1068	if lexer == nil {
1069		lexer = lexers.Fallback
1070	}
1071	iterator, err := lexer.Tokenise(nil, source)
1072	if err != nil {
1073		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1074	}
1075	var buf bytes.Buffer
1076	f := html.New(html.WithClasses(true))
1077	if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1078		return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1079	}
1080	return buf.String()
1081}
1082
1083// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1084// goldmark's default renderer drops raw HTML, so this is safe as-is.
1085func mdHTML(raw string) template.HTML {
1086	if strings.TrimSpace(raw) == "" {
1087		return ""
1088	}
1089	var buf bytes.Buffer
1090	if markdown.Convert([]byte(raw), &buf) != nil {
1091		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1092	}
1093	return template.HTML(buf.String())
1094}
1095
1096// aboutHTML renders a profile's about text. It has no filename to
1097// dispatch on, so the stored format picks the extension; anything other
1098// than org is markdown.
1099func aboutHTML(p store.Profile) template.HTML {
1100	if strings.TrimSpace(p.About) == "" {
1101		return ""
1102	}
1103	name := "about.md"
1104	if p.AboutFormat == "org" {
1105		name = "about.org"
1106	}
1107	return renderReadme(name, []byte(p.About))
1108}
1109
1110// webResolver answers autolink lookups for one viewer. Cross-repo
1111// references to repositories the viewer cannot read stay plain text, per
1112// the enumeration rule: a link would confirm the repo exists.
1113type webResolver struct {
1114	s      *Server
1115	viewer store.User
1116}
1117
1118func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1119	repo, err := r.s.st.RepoByPath(owner + "/" + name)
1120	if err != nil {
1121		return ""
1122	}
1123	grant := ""
1124	if r.viewer.ID != 0 {
1125		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1126	}
1127	if !policy.CanRead(r.viewer, repo, grant) {
1128		return ""
1129	}
1130	if kind == '#' {
1131		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1132			return ""
1133		}
1134		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1135	}
1136	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1137		return ""
1138	}
1139	return autolink.MRURL(repo.OwnerName, repo.Name, n)
1140}
1141
1142func (r webResolver) UserURL(name string) string {
1143	if _, err := r.s.st.UserByUsername(name); err == nil {
1144		return "/" + name
1145	}
1146	if _, err := r.s.st.OrgByName(name); err == nil {
1147		return "/" + name
1148	}
1149	return ""
1150}
1151
1152// ugcRenderer renders one user-authored body in the format it was written in.
1153// The format travels with the body: it is recorded when the text is written, so
1154// changing a preference later cannot re-interpret prose that already exists.
1155type ugcRenderer func(raw, format string) template.HTML
1156
1157// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1158// so a body stored before formats existed — and any row whose column defaulted —
1159// renders exactly as it did before.
1160//
1161// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1162// about text take, so it inherits that function's include guard and sanitising
1163// rather than growing a second org renderer to keep in step.
1164func ugcHTML(raw, format string) template.HTML {
1165	if format == "org" {
1166		return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1167			return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1168		})
1169	}
1170	return mdHTML(raw)
1171}
1172
1173// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1174// ugcHTML plus cross-reference and mention autolinking for this viewer.
1175func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1176	viewer := store.User{}
1177	if s.cfg.Web.Mode == "accounts" {
1178		viewer = s.viewer(r)
1179	}
1180	res := webResolver{s, viewer}
1181	return func(raw, format string) template.HTML {
1182		h := ugcHTML(raw, format)
1183		if h == "" {
1184			return h
1185		}
1186		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1187	}
1188}
1189
1190// renderedComment pairs a comment with its rendered body for templates.
1191type renderedComment struct {
1192	Author    string
1193	CreatedAt string
1194	Kind      string
1195	BodyHTML  template.HTML
1196}
1197
1198func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1199	var out []renderedComment
1200	for _, c := range cs {
1201		out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1202	}
1203	return out
1204}
1205
1206// ugcPolicy sanitizes rendered repo content before it enters the forge's
1207// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1208// output and repo-authored HTML are not. Chroma's highlighting classes
1209// must survive; the pattern admits only short token codes, not the site's
1210// own class names.
1211var ugcPolicy = func() *bluemonday.Policy {
1212	p := bluemonday.UGCPolicy()
1213	p.AllowAttrs("class").
1214		Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1215		OnElements("span", "pre", "code", "div")
1216	return p
1217}()
1218
1219// renderReadme renders a README by extension: markdown, org-mode, and
1220// (sanitized) HTML richly; everything else as escaped plaintext.
1221// orgConfig is the go-org configuration for rendering untrusted org.
1222//
1223// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1224// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1225// wiki page, a profile — so both keywords are refused outright: the file is
1226// never opened and the keyword stays the inert text it is. There is no safe
1227// subset to allow instead. An absolute path skips go-org's relative-path join,
1228// a relative one resolves against the daemon's working directory, and a repo
1229// has no directory to scope to anyway because the content came from a git
1230// object rather than a checkout.
1231//
1232// The default logger writes parse warnings to stderr, which would let pushed
1233// content write to the server's log; discard them.
1234func orgConfig() *org.Configuration {
1235	c := org.New()
1236	c.ReadFile = func(string) ([]byte, error) {
1237		return nil, errOrgIncludeDisabled
1238	}
1239	c.Log = log.New(io.Discard, "", 0)
1240	return c
1241}
1242
1243var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1244
1245// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1246// of contents: a README or wiki page is a document and carries one, an issue
1247// comment is a remark and should not sprout one above two headings. `fallback`
1248// supplies the plaintext rendering used when the writer fails.
1249func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1250	c := orgConfig()
1251	if !contents {
1252		// DefaultSettings is a fresh map per org.New(), so this is local.
1253		c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1254	}
1255	doc := c.Parse(bytes.NewReader(raw), name)
1256	writer := org.NewHTMLWriter()
1257	writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1258		if inline {
1259			return "<code>" + template.HTMLEscapeString(source) + "</code>"
1260		}
1261		return fenceHighlight(source, lang)
1262	}
1263	writer.ExtendingWriter = &orgWriter{writer}
1264	out, err := doc.Write(writer)
1265	if err != nil {
1266		return fallback()
1267	}
1268	return template.HTML(ugcPolicy.Sanitize(out))
1269}
1270
1271// orgWriter overrides go-org's autolink rendering. go-org ends a bare URL
1272// at the first character outside RFC 3986's set, and that set includes
1273// `.`, `,` and `)`, so a URL closing a sentence or a parenthesis took the
1274// punctuation with it. Org stops a plain link before trailing punctuation
1275// and keeps a `)` only when a `(` inside the link opened it.
1276type orgWriter struct {
1277	*org.HTMLWriter
1278}
1279
1280func (w *orgWriter) WriteRegularLink(l org.RegularLink) {
1281	if !l.AutoLink {
1282		w.HTMLWriter.WriteRegularLink(l)
1283		return
1284	}
1285	url, rest := splitAutolinkPunctuation(l.URL)
1286	l.URL = url
1287	w.HTMLWriter.WriteRegularLink(l)
1288	if rest != "" {
1289		w.WriteText(org.Text{Content: rest})
1290	}
1291}
1292
1293// splitAutolinkPunctuation returns the URL without trailing sentence
1294// punctuation, and the punctuation it removed.
1295func splitAutolinkPunctuation(url string) (string, string) {
1296	end := len(url)
1297	for end > 0 {
1298		switch url[end-1] {
1299		case '.', ',', ';', ':', '!', '?', '\'', '"':
1300			end--
1301			continue
1302		case ')':
1303			if strings.Count(url[:end], ")") > strings.Count(url[:end], "(") {
1304				end--
1305				continue
1306			}
1307		}
1308		break
1309	}
1310	return url[:end], url[end:]
1311}
1312
1313// headingTag matches an opening or closing h1..h5 tag, so a rendered
1314// document's headings can move down one level.
1315var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1316
1317// demoteHeadings moves every heading in a rendered document down one
1318// level: the page it sits on already has its h1 (the repository, the
1319// file, the wiki page), so a README's own h1 would be a second top-level
1320// heading in the outline (#133). Ids and anchors are untouched.
1321func demoteHeadings(h template.HTML) template.HTML {
1322	return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1323		sub := headingTag.FindStringSubmatch(m)
1324		return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1325	}))
1326}
1327
1328func renderReadme(name string, raw []byte) template.HTML {
1329	plain := func() template.HTML {
1330		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1331	}
1332	if gitutil.IsBinary(raw) {
1333		return ""
1334	}
1335	switch path.Ext(strings.ToLower(name)) {
1336	case ".md", ".markdown":
1337		var buf bytes.Buffer
1338		if markdown.Convert(raw, &buf) != nil {
1339			return plain()
1340		}
1341		return demoteHeadings(template.HTML(buf.String()))
1342	case ".org":
1343		return demoteHeadings(renderOrg(name, raw, true, plain))
1344	case ".html", ".htm":
1345		return template.HTML(ugcPolicy.Sanitize(string(raw)))
1346	default:
1347		return plain()
1348	}
1349}
1350
1351type diffThread struct {
1352	ID       int64
1353	Resolved string
1354	Stale    bool
1355	// Pending marks a thread in the viewer's own unsubmitted review. Only
1356	// they are shown it, and the page says so, since it looks exactly
1357	// like a posted one otherwise.
1358	Pending    bool
1359	CanResolve bool
1360	Comments   []renderedComment
1361}
1362
1363// reviewRights decides which thread controls a viewer sees. mr resolve
1364// admits the thread author, the MR author, or anyone with write, so the
1365// page needs all three to render the button truthfully.
1366type reviewRights struct {
1367	Viewer   string
1368	MRAuthor string
1369	Write    bool
1370}
1371
1372func (r reviewRights) canResolve(threadAuthor string) bool {
1373	return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1374}
1375
1376// attachThreads injects review threads under their anchored diff lines;
1377// threads whose anchor no longer appears (stale after force-push, or on a
1378// context line outside the current diff) are returned separately.
1379func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1380	type anchor struct {
1381		path string
1382		side string
1383		line int64
1384	}
1385	// Diff-line comments have no stored format yet, so they stay markdown.
1386	// They are the one user-authored body left without the choice; see #51.
1387	threads := map[int64]*diffThread{}
1388	anchors := map[int64]anchor{}
1389	var order []int64
1390	for _, cm := range comments {
1391		if cm.ReplyTo == 0 {
1392			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1393				Pending:    cm.Pending,
1394				CanResolve: rights.canResolve(cm.Author),
1395				Comments:   []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1396			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1397			order = append(order, cm.ID)
1398		} else if th, ok := threads[cm.ReplyTo]; ok {
1399			th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1400		}
1401	}
1402	placed := map[int64]bool{}
1403	for f := range files {
1404		lines := files[f].Lines
1405		for i := range lines {
1406			for _, id := range order {
1407				if placed[id] || threads[id].Stale {
1408					continue
1409				}
1410				a := anchors[id]
1411				if lines[i].Path != a.path {
1412					continue
1413				}
1414				if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1415					(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1416					lines[i].Threads = append(lines[i].Threads, *threads[id])
1417					files[f].Threads++
1418					files[f].Open = true
1419					placed[id] = true
1420				}
1421			}
1422		}
1423	}
1424	var unplaced []diffThread
1425	for _, id := range order {
1426		if !placed[id] {
1427			unplaced = append(unplaced, *threads[id])
1428		}
1429	}
1430	return files, unplaced
1431}
1432
1433// markCompose opens the new-thread form under one diff line. There is no
1434// JavaScript, so "comment on this line" is a plain GET carrying the
1435// anchor and the page renders the form where the reader asked for it.
1436func markCompose(files []diffFile, q url.Values) {
1437	path := q.Get("cpath")
1438	line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1439	if path == "" || line < 1 {
1440		return
1441	}
1442	old := q.Get("cside") == "old"
1443	for f := range files {
1444		for i := range files[f].Lines {
1445			ln := &files[f].Lines[i]
1446			if ln.Path != path {
1447				continue
1448			}
1449			if (old && ln.Class == "del" && ln.OldLine == line) ||
1450				(!old && ln.Class != "del" && ln.NewLine == line) {
1451				ln.Compose = true
1452				files[f].Open = true
1453				return
1454			}
1455		}
1456	}
1457}
1458
1459type sigView struct {
1460	State       string
1461	Signer      string
1462	Fingerprint string
1463}
1464
1465func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1466	raw, err := gitutil.ReadCommit(dir, sha)
1467	if err != nil {
1468		return sigView{State: "unsigned"}, nil
1469	}
1470	parsed, err := sig.ParseCommit(raw)
1471	if err != nil {
1472		return sigView{State: "unsigned"}, nil
1473	}
1474	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1475	if err != nil {
1476		return sigView{State: "unsigned"}, parsed
1477	}
1478	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1479	if res.SignerUserID != 0 {
1480		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1481			v.Signer = u.Username
1482		}
1483	}
1484	return v, parsed
1485}
1486
1487func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1488	ref := r.PathValue("ref")
1489	p, ok := s.repoFor(w, r, ref)
1490	if !ok {
1491		return
1492	}
1493	p.Tab = "log"
1494	p.Feed = "/" + p.Repo.Path() + "/log.atom/" + p.Ref
1495	const pageSize = 50
1496	// ?path= filters to commits touching one file or directory.
1497	filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1498	if filePath == "." {
1499		filePath = ""
1500	}
1501	var shas []string
1502	var err error
1503	if filePath != "" {
1504		shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1505	} else {
1506		shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1507	}
1508	if err != nil {
1509		s.notFound(w, r)
1510		return
1511	}
1512	next := ""
1513	if len(shas) > pageSize {
1514		next = shas[pageSize]
1515		shas = shas[:pageSize]
1516	}
1517	type row struct {
1518		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1519		Sig                                                               sigView
1520		Check                                                             string // combined status, "" when none ran
1521	}
1522	names := s.authorNames()
1523	checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1524	var rows []row
1525	for _, sha := range shas {
1526		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1527		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1528		if parsed != nil {
1529			rw.Subject = parsed.Subject
1530			rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1531			rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1532			rw.AuthorEmail = parsed.AuthorEmail
1533			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
1534		}
1535		rows = append(rows, rw)
1536	}
1537	s.render(w, "log.html", struct {
1538		repoPage
1539		Commits  []row
1540		NextSHA  string
1541		FilePath string
1542	}{p, rows, next, filePath})
1543}
1544
1545func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1546	p, ok := s.repoFor(w, r, "")
1547	if !ok {
1548		return
1549	}
1550	p.Tab = "log"
1551	sha := r.PathValue("sha")
1552	full, err := gitutil.ResolveRef(p.Dir, sha)
1553	if err != nil {
1554		s.notFound(w, r)
1555		return
1556	}
1557	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1558	if parsed == nil {
1559		s.notFound(w, r)
1560		return
1561	}
1562	patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1563	files := parseDiff(patch)
1564	committerEmail := ""
1565	if parsed.CommitterEmail != parsed.AuthorEmail {
1566		committerEmail = parsed.CommitterEmail
1567	}
1568	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1569	commitNames := s.authorNames()
1570	commitUser, _ := commitNames.account(parsed.AuthorEmail)
1571	msg := ""
1572	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1573		msg = string(parsed.Payload[i+2:])
1574	}
1575	s.render(w, "commit.html", struct {
1576		repoPage
1577		SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1578		Parents                                                                           []string
1579		Sig                                                                               sigView
1580		Checks                                                                            []store.CommitStatus
1581		DiffFiles                                                                         []diffFile
1582		DiffTruncated                                                                     bool
1583	}{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1584		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1585		gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1586}
1587
1588// labelPalette provides default label chip colors: mid-tone hues that stay
1589// legible on light and dark backgrounds.
1590var labelPalette = []string{
1591	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1592	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1593}
1594
1595var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1596
1597// clampChip keeps a user-set label colour legible as text on both
1598// grounds. Contrast is defined on relative luminance, so that is what is
1599// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1600// and against the dark ground alike, and where the palette's own colours
1601// sit. The hue is kept; the channels are scaled in linear light (#120).
1602func clampChip(hex string) string {
1603	lin := func(c int64) float64 {
1604		v := float64(c) / 255
1605		if v <= 0.04045 {
1606			return v / 12.92
1607		}
1608		return math.Pow((v+0.055)/1.055, 2.4)
1609	}
1610	r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1611	y := 0.2126*r + 0.7152*g + 0.0722*b
1612	const lo, hi = 0.12, 0.28
1613	if y >= lo && y <= hi {
1614		return strings.ToLower(hex)
1615	}
1616	target := hi
1617	if y < lo {
1618		target = lo
1619	}
1620	if y == 0 {
1621		r, g, b = target, target, target
1622	} else {
1623		k := target / y
1624		r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1625	}
1626	enc := func(v float64) int {
1627		if v <= 0.0031308 {
1628			v *= 12.92
1629		} else {
1630			v = 1.055*math.Pow(v, 1/2.4) - 0.055
1631		}
1632		return int(math.Round(v * 255))
1633	}
1634	return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1635}
1636
1637func hexByte(s string) int64 {
1638	n, _ := strconv.ParseInt(s, 16, 32)
1639	return n
1640}
1641
1642// labelColors returns a complete label-name -> chip color map for a repo:
1643// the stored labels.color when it is a valid hex color, otherwise a
1644// stable default picked from the palette by name hash.
1645func (s *Server) labelColors(repo store.Repo) map[string]template.CSS {
1646	stored, _ := s.st.LabelColors(repo)
1647	return colorStyles(stored)
1648}
1649
1650// colorStyles turns a label-name -> stored color map into chip styles: the
1651// stored color when it is a valid hex color, otherwise a stable default
1652// picked from the palette by name hash.
1653func colorStyles(stored map[string]string) map[string]template.CSS {
1654	out := make(map[string]template.CSS, len(stored))
1655	for name, color := range stored {
1656		if !hexColorPat.MatchString(color) {
1657			h := fnv.New32a()
1658			h.Write([]byte(name))
1659			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1660		}
1661		out[name] = template.CSS("--chip:" + clampChip(color))
1662	}
1663	return out
1664}
1665
1666// listPage is how many issues or merge requests a list page shows before
1667// it offers the older ones (#118). Keyset paging on the number, the same
1668// cursor the commands use, so every filter carries across pages.
1669const listPage = 50
1670
1671// olderLink is the current URL with before=<number> set.
1672func olderLink(r *http.Request, before int64) string {
1673	q := r.URL.Query()
1674	q.Set("before", strconv.FormatInt(before, 10))
1675	return "?" + q.Encode()
1676}
1677
1678func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1679	p, ok := s.repoFor(w, r, "")
1680	if !ok {
1681		return
1682	}
1683	p.Tab = "issues"
1684	state := r.URL.Query().Get("state")
1685	if state != "closed" && state != "all" {
1686		state = "open"
1687	}
1688	// The same filters the CLI's issue list takes, as query parameters;
1689	// label chips and author links point here.
1690	qv := r.URL.Query()
1691	f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1692		Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1693		Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1694	f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1695	issues, err := s.st.QueryIssues(p.Repo.ID, f)
1696	if err != nil {
1697		http.Error(w, "internal error", http.StatusInternalServerError)
1698		return
1699	}
1700	older := ""
1701	if len(issues) > listPage {
1702		issues = issues[:listPage]
1703		older = olderLink(r, issues[len(issues)-1].Number)
1704	}
1705	if labels, err := s.st.ListIssueLabels(p.Repo); err == nil {
1706		for i := range issues {
1707			issues[i].Labels = labels[issues[i].ID]
1708		}
1709	}
1710	s.render(w, "issues.html", struct {
1711		repoPage
1712		State       string
1713		Label       string
1714		Query       string
1715		Filters     []listFilter
1716		Issues      []store.Issue
1717		LabelColors map[string]template.CSS
1718		Older       string
1719	}{p, state, f.Label, f.Search,
1720		activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1721		issues, s.labelColors(p.Repo), older})
1722}
1723
1724func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1725	p, ok := s.repoFor(w, r, "")
1726	if !ok {
1727		return
1728	}
1729	p.Tab = "issues"
1730	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1731	if err != nil {
1732		s.notFound(w, r)
1733		return
1734	}
1735	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1736	if err != nil {
1737		s.notFound(w, r)
1738		return
1739	}
1740	comments, err := s.st.ListIssueComments(iss.ID)
1741	if err != nil {
1742		http.Error(w, "internal error", http.StatusInternalServerError)
1743		return
1744	}
1745	md := s.ugcFor(r, p.Repo)
1746	// nil readable: the picker lists titles, never the progress counts.
1747	milestones, _ := s.st.ListMilestones(p.Repo, "open", nil)
1748	s.render(w, "issue.html", struct {
1749		repoPage
1750		Issue       store.Issue
1751		BodyHTML    template.HTML
1752		Comments    []renderedComment
1753		CanEdit     bool
1754		CanWrite    bool
1755		Milestones  []store.Milestone
1756		Notice      string
1757		LabelColors map[string]template.CSS
1758	}{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1759		s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1760		milestones, s.takeFlash(w, r), s.labelColors(p.Repo)})
1761}
1762
1763// canEditItem: the author or anyone with write access may edit.
1764// canWriteRepo reports whether the browser session may push to the repo,
1765// which is what gates the review and merge controls.
1766func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) 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	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1775	return policy.CanWrite(u, repo, grant)
1776}
1777
1778func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1779	if s.cfg.Web.Mode != "accounts" {
1780		return false
1781	}
1782	u := s.viewer(r)
1783	if u.ID == 0 {
1784		return false
1785	}
1786	if u.Username == author {
1787		return true
1788	}
1789	grant, _ := s.st.AccessRole(repo.ID, u.ID)
1790	return policy.CanWrite(u, repo, grant)
1791}
1792
1793func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1794	p, ok := s.repoFor(w, r, "")
1795	if !ok {
1796		return
1797	}
1798	p.Tab = "merge requests"
1799	state := r.URL.Query().Get("state")
1800	if state == "" {
1801		state = "open"
1802	}
1803	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1804	if !valid[state] {
1805		state = "open"
1806	}
1807	qv := r.URL.Query()
1808	mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1809		Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1810	mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1811	mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1812	if err != nil {
1813		http.Error(w, "internal error", http.StatusInternalServerError)
1814		return
1815	}
1816	older := ""
1817	if len(mrs) > listPage {
1818		mrs = mrs[:listPage]
1819		older = olderLink(r, mrs[len(mrs)-1].Number)
1820	}
1821	s.render(w, "mrs.html", struct {
1822		repoPage
1823		State   string
1824		Query   string
1825		Filters []listFilter
1826		MRs     []store.MR
1827		Older   string
1828	}{p, state, mf.Search,
1829		activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs, older})
1830}
1831
1832func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1833	p, ok := s.repoFor(w, r, "")
1834	if !ok {
1835		return
1836	}
1837	p.Tab = "merge requests"
1838	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1839	if err != nil {
1840		s.notFound(w, r)
1841		return
1842	}
1843	m, err := s.st.MRByNumber(p.Repo.ID, n)
1844	if err != nil {
1845		s.notFound(w, r)
1846		return
1847	}
1848	comments, _ := s.st.ListMRComments(m.ID)
1849	reviews, _ := s.st.ListMRReviews(m.ID)
1850	// The same rule the merge gates apply, so the page cannot show an
1851	// approval the gate ignores (#147).
1852	reviewCounts := control.ReviewersWhoCount(s.st, p.Repo, reviews)
1853	reviewRows := make([]reviewRow, 0, len(reviews))
1854	for _, r := range reviews {
1855		reviewRows = append(reviewRows, reviewRow{MRReview: r, Counts: reviewCounts[r.Reviewer]})
1856	}
1857	checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1858	// The viewer sees their own unsubmitted review comments and nobody
1859	// else's.
1860	diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
1861
1862	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1863	// An admin can prune the head ref; the diff is then unavailable, not
1864	// empty, and the page must not read as the latter.
1865	_, headErr := gitutil.ResolveRef(p.Dir, headRef)
1866	headPruned := headErr != nil
1867	var files []diffFile
1868	base := m.MergedBase
1869	if base == "" {
1870		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1871			base = b
1872		}
1873	}
1874	var diffTruncated bool
1875	if base != "" {
1876		if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1877			files, diffTruncated = parseDiff(patch), truncated
1878		}
1879	}
1880	// The head is already reachable from the target, so the diff is empty
1881	// by construction rather than because nothing changed.
1882	headMerged := false
1883	if len(files) == 0 && m.HeadSHA != "" {
1884		if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
1885			if ok, err := gitutil.IsAncestor(p.Dir, m.HeadSHA, targetSHA); err == nil {
1886				headMerged = ok
1887			}
1888		}
1889	}
1890	md := s.ugcFor(r, p.Repo)
1891	canWrite := s.canWriteRepo(r, p.Repo)
1892	var detachedThreads []diffThread
1893	files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1894		reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1895	if p.Viewer != "" {
1896		markCompose(files, r.URL.Query())
1897	}
1898	stat := statOf(files)
1899	// The commits this MR carries: base..head, the same range as the diff.
1900	type commitRow struct {
1901		SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1902		Sig                                                  sigView
1903	}
1904	mrNames := s.authorNames()
1905	var commits []commitRow
1906	commitsTotal := 0
1907	if base != "" {
1908		const maxMRCommits = 100
1909		shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1910		commitsTotal = len(shas)
1911		if len(shas) > maxMRCommits {
1912			shas = shas[:maxMRCommits]
1913		}
1914		for _, sha := range shas {
1915			v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1916			cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1917			if parsed != nil {
1918				cr.Subject = parsed.Subject
1919				cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1920				cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1921				cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339)
1922			}
1923			commits = append(commits, cr)
1924		}
1925	}
1926	// The diff is the reason most people open a merge request, so it gets
1927	// its own view rather than a fold at the foot of the conversation.
1928	// A query parameter keeps this working without JavaScript.
1929	unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1930	// The revisions this merge request has had. A stale review is the
1931	// moment someone wants to know what moved, so the link to the
1932	// range-diff belongs next to it.
1933	revisions, _ := s.st.MRHeads(m.ID)
1934	branches, _ := gitutil.Refs(p.Dir, "heads")
1935	view := r.URL.Query().Get("view")
1936	if view != "commits" && view != "diff" {
1937		view = "conversation"
1938	}
1939	// Where the merge request stands against the gates, the same
1940	// computation mr merge refuses on (#199).
1941	var gates *control.GatesOut
1942	if m.State == "open" || m.State == "source_gone" {
1943		if targetSHA, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.TargetRef); err == nil {
1944			if g, err := control.MergeGates(s.st, p.Repo, m, p.Dir, targetSHA, m.HeadSHA); err == nil {
1945				gates = &g
1946			}
1947		}
1948	}
1949	// The stack around an open merge request, for the header.
1950	var stackedOn *store.MR
1951	var stacked []store.MR
1952	if m.State == "open" {
1953		if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1954			stackedOn = &parent
1955		}
1956		if m.SourceRepoID == p.Repo.ID {
1957			stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1958		}
1959	}
1960	s.render(w, "mr.html", struct {
1961		repoPage
1962		MR              store.MR
1963		View            string
1964		BodyHTML        template.HTML
1965		Checks          []store.Check
1966		Combined        string
1967		Comments        []renderedComment
1968		Reviews         []reviewRow
1969		DiffFiles       []diffFile
1970		DiffTruncated   bool
1971		Stat            diffStat
1972		Commits         []commitRow
1973		CommitsTotal    int
1974		Branches        []gitutil.Ref
1975		CanEdit         bool
1976		CanWrite        bool
1977		Unresolved      int
1978		Revisions       []store.MRHead
1979		Notice          string
1980		DetachedThreads []diffThread
1981		StackedOn       *store.MR
1982		Stacked         []store.MR
1983		Gates           *control.GatesOut
1984		SourceGone      bool
1985		HeadMerged      bool
1986		HeadPruned      bool
1987		Base            string
1988	}{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1989		reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1990		canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, gates,
1991		sourceGone(p, m), headMerged, headPruned, base})
1992}
1993
1994// sourceGone reports whether an MR's source branch no longer exists: the
1995// push hook marks a deleted branch on an open MR, and a merged or closed
1996// one is checked here. A fork's branch lives in another repository and
1997// is left to the recorded state.
1998func sourceGone(p repoPage, m store.MR) bool {
1999	if m.State == "source_gone" {
2000		return true
2001	}
2002	if m.SourceRepoID != p.Repo.ID {
2003		return false
2004	}
2005	_, err := gitutil.ResolveRef(p.Dir, "refs/heads/"+m.SourceRef)
2006	return err != nil
2007}
2008
2009func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
2010	p, ok := s.repoFor(w, r, "")
2011	if !ok {
2012		return
2013	}
2014	p.Tab = "refs"
2015	branches, _ := gitutil.Refs(p.Dir, "heads")
2016	tags, _ := gitutil.Refs(p.Dir, "tags")
2017	gitutil.SortVersions(tags)
2018	s.render(w, "refs.html", struct {
2019		repoPage
2020		Branches, Tags []gitutil.Ref
2021	}{p, branches, tags})
2022}
2023
2024func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
2025	p, ok := s.repoFor(w, r, "")
2026	if !ok {
2027		return
2028	}
2029	file := r.PathValue("file")
2030	ref, ok := strings.CutSuffix(file, ".tar.gz")
2031	if !ok {
2032		s.notFound(w, r)
2033		return
2034	}
2035	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
2036		s.notFound(w, r)
2037		return
2038	}
2039	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
2040	w.Header().Set("Content-Type", "application/gzip")
2041	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
2042	gitutil.Archive(p.Dir, ref, prefix, w)
2043}
2044
2045func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
2046	return policy.CanAdmin(u, repo, grant)
2047}
2048
2049func policyCanRead(u store.User, repo store.Repo, grant string) bool {
2050	return policy.CanRead(u, repo, grant)
2051}
2052
2053// reviewRow is a review with whether the merge gates count it, which
2054// depends on the reviewer's access and so is not a property of the
2055// review row itself.
2056type reviewRow struct {
2057	store.MRReview
2058	Counts bool
2059}
2060
2061// sshCloneURL is the SSH clone URL for a repository, with the port only
2062// when it is not the default.
2063func (s *Server) sshCloneURL(repo store.Repo) string {
2064	host := s.cfg.SiteHost()
2065	if s.cfg.SSH.Port != 22 {
2066		host += ":" + strconv.Itoa(s.cfg.SSH.Port)
2067	}
2068	return "ssh://git@" + host + "/" + repo.Path() + ".git"
2069}