internal/httpd/web.go

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

1782 lines · 55188 bytes

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