internal/httpd/web.go

e2dec5d9ff2cd5dd54f68adec4190d8bafeaf302
gitbay/internal/httpd/web.go history · blame · raw

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