internal/httpd/web.go

2067 lines · 65396 bytes

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