internal/httpd/web.go

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

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