internal/httpd/web.go

9d96712a6988b5d376162e4f31c9ee3525e1ca9a
gitbay/internal/httpd/web.go history · blame · raw

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