internal/httpd/web.go

81272b0b804d31bba1ee5e540b69cffc895ef3bf
gitbay/internal/httpd/web.go history · blame · raw

2059 lines · 65107 bytes

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