internal/httpd/web.go

353f68a2e57ac692964b73ae1f9acd91fcdcae20
gitbay/internal/httpd/web.go history · blame · raw

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