internal/httpd/web.go

33f4fa3490944ada57c2a5c57e6650020c1afeab
gitbay/internal/httpd/web.go history · blame · raw

1265 lines · 34639 bytes

   1package httpd
   2
   3import (
   4	"bytes"
   5	"fmt"
   6	"hash/fnv"
   7	"io"
   8	"os"
   9	"path/filepath"
  10
  11	"gitbay.org/gitbay/internal/policy"
  12	"html/template"
  13	"net/http"
  14	"path"
  15	"regexp"
  16	"strconv"
  17	"strings"
  18	"time"
  19
  20	"github.com/alecthomas/chroma/v2/formatters/html"
  21	"github.com/alecthomas/chroma/v2/lexers"
  22	"github.com/alecthomas/chroma/v2/styles"
  23	"github.com/microcosm-cc/bluemonday"
  24	"github.com/niklasfasching/go-org/org"
  25	"github.com/yuin/goldmark"
  26
  27	"gitbay.org/gitbay/internal/autolink"
  28	"gitbay.org/gitbay/internal/control"
  29	"gitbay.org/gitbay/internal/gitutil"
  30	"gitbay.org/gitbay/internal/sig"
  31	"gitbay.org/gitbay/internal/store"
  32	"gitbay.org/gitbay/internal/web"
  33)
  34
  35const maxRenderBytes = 1 << 20 // largest blob rendered inline
  36
  37func (s *Server) render(w http.ResponseWriter, page string, data any) {
  38	var buf bytes.Buffer
  39	if err := web.Render(&buf, page, data); err != nil {
  40		http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
  41		return
  42	}
  43	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  44	buf.WriteTo(w)
  45}
  46
  47func (s *Server) siteName() string {
  48	h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
  49	return strings.TrimSuffix(h, "/")
  50}
  51
  52func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
  53	w.Header().Set("Content-Type", "text/css; charset=utf-8")
  54	w.Write(web.StyleCSS)
  55}
  56
  57func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
  58	w.Header().Set("Content-Type", "image/svg+xml")
  59	w.Write(web.FaviconSVG)
  60}
  61
  62// notFound renders the designed 404 page with a 404 status. Falls back to
  63// the stock plain-text response if the template fails.
  64func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
  65	var buf bytes.Buffer
  66	if err := web.Render(&buf, "404.html", struct {
  67		Site   string
  68		Viewer string
  69	}{s.siteName(), s.viewerName(r)}); err != nil {
  70		http.NotFound(w, r)
  71		return
  72	}
  73	w.Header().Set("Content-Type", "text/html; charset=utf-8")
  74	w.WriteHeader(http.StatusNotFound)
  75	buf.WriteTo(w)
  76}
  77
  78// describedRepo pairs a repo with its description for listings.
  79type describedRepo struct {
  80	store.Repo
  81	Desc string
  82}
  83
  84func (s *Server) describeAll(repos []store.Repo) []describedRepo {
  85	var out []describedRepo
  86	for _, r := range repos {
  87		out = append(out, describedRepo{r, gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name))})
  88	}
  89	return out
  90}
  91
  92// index is the homepage: a dashboard for logged-in users, a landing page
  93// for everyone else. The full public listing lives at /explore.
  94func (s *Server) index(w http.ResponseWriter, r *http.Request) {
  95	if s.cfg.Web.Mode == "accounts" {
  96		if viewer := s.viewer(r); viewer.ID != 0 {
  97			s.dashboard(w, r, viewer)
  98			return
  99		}
 100	}
 101	host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
 102		s.cfg.Server.SiteURL, "https://"), "http://"), "/")
 103	s.render(w, "landing.html", struct {
 104		Site     string
 105		Viewer   string
 106		Host     string
 107		Accounts bool
 108		Signup   bool
 109	}{s.siteName(), "", host, s.cfg.Web.Mode == "accounts",
 110		s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
 111}
 112
 113func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
 114	pinned, _ := s.st.PinnedRepos(viewer.ID)
 115	var visible []store.Repo
 116	for _, rp := range pinned {
 117		grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
 118		if policy.CanRead(viewer, rp, grant) {
 119			visible = append(visible, rp)
 120		}
 121	}
 122	mrs, _ := s.st.DashboardMRs(viewer.ID)
 123	issues, _ := s.st.DashboardIssues(viewer.ID)
 124	s.render(w, "dashboard.html", struct {
 125		Site   string
 126		Viewer string
 127		Pinned []describedRepo
 128		MRs    []store.DashboardItem
 129		Issues []store.DashboardItem
 130	}{s.siteName(), viewer.Username, s.describeAll(visible), mrs, issues})
 131}
 132
 133func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
 134	repos, err := s.st.ListPublicRepos()
 135	if err != nil {
 136		http.Error(w, "internal error", http.StatusInternalServerError)
 137		return
 138	}
 139	var viewer store.User
 140	if s.cfg.Web.Mode == "accounts" {
 141		viewer = s.viewer(r)
 142	}
 143	q := strings.TrimSpace(r.URL.Query().Get("q"))
 144	s.render(w, "explore.html", struct {
 145		Site   string
 146		Viewer string
 147		Query  string
 148		Repos  []describedRepo
 149	}{s.siteName(), viewer.Username, q, s.filterRepos(q, s.describeAll(repos))})
 150}
 151
 152// viewerName returns the logged-in username for header rendering, or "".
 153func (s *Server) viewerName(r *http.Request) string {
 154	if s.cfg.Web.Mode != "accounts" {
 155		return ""
 156	}
 157	return s.viewer(r).Username
 158}
 159
 160// privacy renders the privacy page: what the gitbay software does with
 161// data, plus this instance's operator-provided notes.
 162func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
 163	s.render(w, "privacy.html", struct {
 164		Site   string
 165		Viewer string
 166		Host   string
 167		Notice string
 168	}{s.siteName(), s.viewerName(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
 169}
 170
 171// filterRepos keeps repos whose path, description, or topics contain the
 172// query, case-insensitively. An empty query keeps everything.
 173func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
 174	if q == "" {
 175		return repos
 176	}
 177	q = strings.ToLower(q)
 178	var out []describedRepo
 179	for _, d := range repos {
 180		if strings.Contains(strings.ToLower(d.Path()), q) ||
 181			strings.Contains(strings.ToLower(d.Desc), q) {
 182			out = append(out, d)
 183			continue
 184		}
 185		topics, _ := s.st.ListTopics(d.ID)
 186		for _, t := range topics {
 187			if strings.Contains(t, q) {
 188				out = append(out, d)
 189				break
 190			}
 191		}
 192	}
 193	return out
 194}
 195
 196// repoPage is the shared context for repo-scoped pages.
 197type repoPage struct {
 198	Site     string
 199	Viewer   string
 200	Desc     string
 201	Repo     store.Repo
 202	Ref      string
 203	CloneURL string
 204	Dir      string
 205	Tab      string // active tab in the repo header
 206	Topics   []string
 207}
 208
 209// repoFor resolves the repo for a web request; false means 404 was sent.
 210// Anonymous visitors see public repos only; in accounts mode a logged-in
 211// viewer additionally sees repos their grants allow. Private and missing
 212// repos are indistinguishable either way.
 213func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
 214	var repo store.Repo
 215	var viewer store.User
 216	if s.cfg.Web.Mode == "accounts" {
 217		viewer = s.viewer(r)
 218	}
 219	repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
 220	ok := err == nil
 221	if ok {
 222		grant := ""
 223		if viewer.ID != 0 {
 224			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 225		}
 226		ok = policyCanRead(viewer, repo, grant)
 227	}
 228	if !ok {
 229		s.notFound(w, r)
 230		return repoPage{}, false
 231	}
 232	if ref == "" {
 233		ref = repo.DefaultBranch
 234	}
 235	topics, _ := s.st.ListTopics(repo.ID)
 236	return repoPage{
 237		Site:     s.siteName(),
 238		Viewer:   viewer.Username,
 239		Desc:     gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
 240		Repo:     repo,
 241		Ref:      ref,
 242		CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
 243		Dir:      control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
 244		Topics:   topics,
 245	}, true
 246}
 247
 248type crumb struct {
 249	Name string
 250	URL  string
 251}
 252
 253func crumbs(p repoPage, kind, filePath string) []crumb {
 254	var cs []crumb
 255	base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
 256	acc := ""
 257	for _, part := range strings.Split(filePath, "/") {
 258		if part == "" {
 259			continue
 260		}
 261		acc = path.Join(acc, part)
 262		cs = append(cs, crumb{Name: part, URL: base + acc})
 263	}
 264	return cs
 265}
 266
 267// ownerPage renders /{owner} for users and orgs: the repositories the
 268// viewer may see, org membership either direction. Owner names are not
 269// secret (they are on every commit); repository visibility rules hold.
 270func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
 271	name := r.PathValue("owner")
 272	var viewer store.User
 273	if s.cfg.Web.Mode == "accounts" {
 274		viewer = s.viewer(r)
 275	}
 276
 277	kind := "user"
 278	var ownerID int64
 279	var members []store.OrgMember
 280	var orgs []store.OrgMember
 281	if u, err := s.st.UserByUsername(name); err == nil {
 282		ownerID = u.ID
 283		orgs, _ = s.st.ListOrgsForUser(u.ID)
 284	} else if o, err := s.st.OrgByName(name); err == nil {
 285		kind, ownerID = "org", o.ID
 286		members, _ = s.st.OrgMembers(o.ID)
 287	} else {
 288		s.notFound(w, r)
 289		return
 290	}
 291	profile, _ := s.st.OwnerProfile(kind, ownerID)
 292
 293	all, err := s.st.ListReposForOwner(kind, ownerID)
 294	if err != nil {
 295		http.Error(w, "internal error", http.StatusInternalServerError)
 296		return
 297	}
 298	var visible []store.Repo
 299	for _, repo := range all {
 300		grant := ""
 301		if viewer.ID != 0 {
 302			grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
 303		}
 304		if policy.CanRead(viewer, repo, grant) {
 305			visible = append(visible, repo)
 306		}
 307	}
 308	s.render(w, "owner.html", struct {
 309		Site    string
 310		Viewer  string
 311		Owner   string
 312		Kind    string
 313		Profile store.Profile
 314		Repos   []describedRepo
 315		Members []store.OrgMember
 316		Orgs    []store.OrgMember
 317	}{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs})
 318}
 319
 320func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
 321	p, ok := s.repoFor(w, r, "")
 322	if !ok {
 323		return
 324	}
 325	p.Tab = "files"
 326	s.renderTree(w, r, p, "")
 327}
 328
 329func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
 330	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 331	if !ok {
 332		return
 333	}
 334	p.Tab = "files"
 335	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
 336}
 337
 338func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
 339	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
 340		// Empty repo: render the page with no entries rather than 404.
 341		s.render(w, "tree.html", struct {
 342			repoPage
 343			Crumbs     []crumb
 344			Prefix     string
 345			Entries    []gitutil.TreeEntry
 346			ReadmeName string
 347			ReadmeHTML template.HTML
 348		}{repoPage: p})
 349		return
 350	}
 351	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
 352	if err != nil {
 353		s.notFound(w, r)
 354		return
 355	}
 356	prefix := ""
 357	if dirPath != "" {
 358		prefix = dirPath + "/"
 359	}
 360
 361	var readmeHTML template.HTML
 362	readmeName := pickReadme(entries)
 363	if readmeName != "" {
 364		if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
 365			readmeHTML = renderReadme(readmeName, raw)
 366		}
 367	}
 368
 369	s.render(w, "tree.html", struct {
 370		repoPage
 371		Crumbs     []crumb
 372		Prefix     string
 373		Entries    []gitutil.TreeEntry
 374		ReadmeName string
 375		ReadmeHTML template.HTML
 376	}{p, crumbs(p, "tree", dirPath), prefix, entries, readmeName, readmeHTML})
 377}
 378
 379func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
 380	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 381	if !ok {
 382		return
 383	}
 384	p.Tab = "files"
 385	filePath := strings.Trim(r.PathValue("path"), "/")
 386	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
 387	if err != nil {
 388		s.notFound(w, r)
 389		return
 390	}
 391	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
 392
 393	var codeHTML template.HTML
 394	if !binary {
 395		codeHTML = highlight(filePath, data)
 396	}
 397	cs := crumbs(p, "blob", filePath)
 398	base := ""
 399	if len(cs) > 0 {
 400		base = cs[len(cs)-1].Name
 401		cs = cs[:len(cs)-1]
 402	}
 403	s.render(w, "blob.html", struct {
 404		repoPage
 405		Crumbs   []crumb
 406		Base     string
 407		Path     string
 408		Binary   bool
 409		Size     int
 410		CodeHTML template.HTML
 411	}{p, cs, base, filePath, binary, len(data), codeHTML})
 412}
 413
 414// releases lists tag-anchored releases with notes and assets.
 415func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
 416	p, ok := s.repoFor(w, r, "")
 417	if !ok {
 418		return
 419	}
 420	p.Tab = "releases"
 421	rels, err := s.st.ListReleases(p.Repo.ID)
 422	if err != nil {
 423		http.Error(w, "internal error", http.StatusInternalServerError)
 424		return
 425	}
 426	md := s.ugcFor(r, p.Repo)
 427	type relView struct {
 428		store.Release
 429		NotesHTML template.HTML
 430	}
 431	var views []relView
 432	for _, rel := range rels {
 433		views = append(views, relView{rel, md(rel.Notes)})
 434	}
 435	s.render(w, "releases.html", struct {
 436		repoPage
 437		Releases []relView
 438	}{p, views})
 439}
 440
 441// releaseAsset streams one uploaded asset. Tags containing '/' are not
 442// reachable here (single path segment); SSH download always works.
 443func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
 444	p, ok := s.repoFor(w, r, "")
 445	if !ok {
 446		return
 447	}
 448	rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
 449	if err != nil {
 450		s.notFound(w, r)
 451		return
 452	}
 453	name := r.PathValue("name")
 454	found := false
 455	for _, a := range rel.Assets {
 456		if a.Name == name {
 457			found = true
 458		}
 459	}
 460	if !found {
 461		s.notFound(w, r)
 462		return
 463	}
 464	f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
 465		"gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
 466	if err != nil {
 467		s.notFound(w, r)
 468		return
 469	}
 470	defer f.Close()
 471	w.Header().Set("Content-Type", "application/octet-stream")
 472	w.Header().Set("X-Content-Type-Options", "nosniff")
 473	w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
 474	if fi, err := f.Stat(); err == nil {
 475		w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
 476	}
 477	io.Copy(w, f)
 478}
 479
 480// milestones lists a repo's milestones with progress.
 481func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
 482	p, ok := s.repoFor(w, r, "")
 483	if !ok {
 484		return
 485	}
 486	p.Tab = "issues"
 487	state := r.URL.Query().Get("state")
 488	if state != "closed" && state != "all" {
 489		state = "open"
 490	}
 491	ms, err := s.st.ListMilestones(p.Repo.ID, state)
 492	if err != nil {
 493		http.Error(w, "internal error", http.StatusInternalServerError)
 494		return
 495	}
 496	type msView struct {
 497		store.Milestone
 498		Percent int
 499	}
 500	var views []msView
 501	for _, m := range ms {
 502		v := msView{Milestone: m}
 503		if total := m.OpenItems + m.ClosedItems; total > 0 {
 504			v.Percent = m.ClosedItems * 100 / total
 505		}
 506		views = append(views, v)
 507	}
 508	s.render(w, "milestones.html", struct {
 509		repoPage
 510		State      string
 511		Milestones []msView
 512	}{p, state, views})
 513}
 514
 515// search runs a bounded literal git grep over the repo's default branch.
 516func (s *Server) search(w http.ResponseWriter, r *http.Request) {
 517	p, ok := s.repoFor(w, r, "")
 518	if !ok {
 519		return
 520	}
 521	p.Tab = "search"
 522	q := strings.TrimSpace(r.URL.Query().Get("q"))
 523	type matchView struct {
 524		Path     string
 525		Line     int
 526		TextHTML template.HTML
 527	}
 528	var matches []matchView
 529	var queryErr string
 530	if q != "" {
 531		if len(q) < 2 || len(q) > 200 {
 532			queryErr = "query must be 2 to 200 characters"
 533		} else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
 534			raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
 535			if err != nil {
 536				http.Error(w, "internal error", http.StatusInternalServerError)
 537				return
 538			}
 539			for _, m := range raw {
 540				matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
 541			}
 542		}
 543	}
 544	s.render(w, "search.html", struct {
 545		repoPage
 546		Query    string
 547		QueryErr string
 548		Matches  []matchView
 549		Capped   bool
 550	}{p, q, queryErr, matches, len(matches) == 200})
 551}
 552
 553// markMatch escapes a matched line and wraps case-insensitive occurrences
 554// of the query in <mark>.
 555func markMatch(text, q string) template.HTML {
 556	lower, lq := strings.ToLower(text), strings.ToLower(q)
 557	var b strings.Builder
 558	pos := 0
 559	for {
 560		i := strings.Index(lower[pos:], lq)
 561		if i < 0 {
 562			break
 563		}
 564		i += pos
 565		b.WriteString(template.HTMLEscapeString(text[pos:i]))
 566		b.WriteString("<mark>")
 567		b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
 568		b.WriteString("</mark>")
 569		pos = i + len(q)
 570	}
 571	b.WriteString(template.HTMLEscapeString(text[pos:]))
 572	return template.HTML(b.String())
 573}
 574
 575// blamePageSize caps how many lines one blame page renders; blame is a
 576// per-line subprocess cost, so large files paginate.
 577const blamePageSize = 1000
 578
 579func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
 580	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 581	if !ok {
 582		return
 583	}
 584	p.Tab = "files"
 585	filePath := strings.Trim(r.PathValue("path"), "/")
 586	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 587	if err != nil {
 588		s.notFound(w, r)
 589		return
 590	}
 591	total := bytes.Count(data, []byte("\n"))
 592	if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
 593		total++
 594	}
 595	binary := gitutil.IsBinary(data)
 596
 597	type hunkView struct {
 598		gitutil.BlameHunk
 599		ShortSHA string
 600		Date     string
 601		Sig      sigView
 602		Numbered []numberedLine
 603	}
 604	var hunks []hunkView
 605	page, pages := 1, (total+blamePageSize-1)/blamePageSize
 606	if pages == 0 {
 607		pages = 1
 608	}
 609	if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
 610		page = n
 611	}
 612	if !binary && total > 0 {
 613		start := (page-1)*blamePageSize + 1
 614		end := min(total, page*blamePageSize)
 615		raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
 616		if err != nil {
 617			s.notFound(w, r)
 618			return
 619		}
 620		sigs := map[string]sigView{}
 621		for _, h := range raw {
 622			v, ok := sigs[h.SHA]
 623			if !ok {
 624				v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
 625				sigs[h.SHA] = v
 626			}
 627			hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
 628				Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
 629			for i, l := range h.Lines {
 630				hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
 631			}
 632			hunks = append(hunks, hv)
 633		}
 634	}
 635	cs := crumbs(p, "blame", filePath)
 636	base := ""
 637	if len(cs) > 0 {
 638		base = cs[len(cs)-1].Name
 639		cs = cs[:len(cs)-1]
 640	}
 641	s.render(w, "blame.html", struct {
 642		repoPage
 643		Crumbs      []crumb
 644		Base        string
 645		Path        string
 646		Binary      bool
 647		Hunks       []hunkView
 648		Page, Pages int
 649	}{p, cs, base, filePath, binary, hunks, page, pages})
 650}
 651
 652type numberedLine struct {
 653	N    int
 654	Text string
 655}
 656
 657func highlight(filePath string, data []byte) template.HTML {
 658	lexer := lexers.Match(filePath)
 659	if lexer == nil {
 660		lexer = lexers.Fallback
 661	}
 662	style := styles.Get("friendly")
 663	formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false),
 664		html.WithLinkableLineNumbers(true, "L"))
 665	iterator, err := lexer.Tokenise(nil, string(data))
 666	if err != nil {
 667		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 668	}
 669	var buf bytes.Buffer
 670	if err := formatter.Format(&buf, style, iterator); err != nil {
 671		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
 672	}
 673	return template.HTML(buf.String())
 674}
 675
 676func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
 677	p, ok := s.repoFor(w, r, r.PathValue("ref"))
 678	if !ok {
 679		return
 680	}
 681	filePath := strings.Trim(r.PathValue("path"), "/")
 682	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
 683	if err != nil {
 684		s.notFound(w, r)
 685		return
 686	}
 687	// Serve inert: never let repo content execute in the forge's origin.
 688	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
 689	w.Header().Set("X-Content-Type-Options", "nosniff")
 690	w.Write(data)
 691}
 692
 693// readmeRank orders competing README files: richer renderers win.
 694var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
 695
 696// pickReadme returns the best README-ish blob in a tree listing: any file
 697// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
 698// we can render richly.
 699func pickReadme(entries []gitutil.TreeEntry) string {
 700	best, bestRank := "", 1<<30
 701	for _, e := range entries {
 702		if e.Type != "blob" {
 703			continue
 704		}
 705		lower := strings.ToLower(e.Name)
 706		if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
 707			continue
 708		}
 709		rank, ok := readmeRank[path.Ext(lower)]
 710		if !ok {
 711			rank = 10 // plaintext fallback
 712		}
 713		if rank < bestRank {
 714			best, bestRank = e.Name, rank
 715		}
 716	}
 717	return best
 718}
 719
 720// mdHTML renders user-authored markdown (issue and MR bodies, comments).
 721// goldmark's default renderer drops raw HTML, so this is safe as-is.
 722func mdHTML(raw string) template.HTML {
 723	if strings.TrimSpace(raw) == "" {
 724		return ""
 725	}
 726	var buf bytes.Buffer
 727	if goldmark.Convert([]byte(raw), &buf) != nil {
 728		return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
 729	}
 730	return template.HTML(buf.String())
 731}
 732
 733// webResolver answers autolink lookups for one viewer. Cross-repo
 734// references to repositories the viewer cannot read stay plain text, per
 735// the enumeration rule: a link would confirm the repo exists.
 736type webResolver struct {
 737	s      *Server
 738	viewer store.User
 739}
 740
 741func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
 742	repo, err := r.s.st.RepoByPath(owner + "/" + name)
 743	if err != nil {
 744		return ""
 745	}
 746	grant := ""
 747	if r.viewer.ID != 0 {
 748		grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
 749	}
 750	if !policy.CanRead(r.viewer, repo, grant) {
 751		return ""
 752	}
 753	if kind == '#' {
 754		if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
 755			return ""
 756		}
 757		return autolink.IssueURL(repo.OwnerName, repo.Name, n)
 758	}
 759	if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
 760		return ""
 761	}
 762	return autolink.MRURL(repo.OwnerName, repo.Name, n)
 763}
 764
 765func (r webResolver) UserURL(name string) string {
 766	if _, err := r.s.st.UserByUsername(name); err == nil {
 767		return "/" + name
 768	}
 769	if _, err := r.s.st.OrgByName(name); err == nil {
 770		return "/" + name
 771	}
 772	return ""
 773}
 774
 775// ugcFor returns a renderer for user-authored markdown on one repo's pages:
 776// mdHTML plus cross-reference and mention autolinking for this viewer.
 777func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
 778	viewer := store.User{}
 779	if s.cfg.Web.Mode == "accounts" {
 780		viewer = s.viewer(r)
 781	}
 782	res := webResolver{s, viewer}
 783	return func(raw string) template.HTML {
 784		h := mdHTML(raw)
 785		if h == "" {
 786			return h
 787		}
 788		return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
 789	}
 790}
 791
 792// renderedComment pairs a comment with its rendered body for templates.
 793type renderedComment struct {
 794	Author    string
 795	CreatedAt string
 796	BodyHTML  template.HTML
 797}
 798
 799func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
 800	var out []renderedComment
 801	for _, c := range cs {
 802		out = append(out, renderedComment{c.Author, c.CreatedAt, md(c.Body)})
 803	}
 804	return out
 805}
 806
 807// ugcPolicy sanitizes rendered repo content before it enters the forge's
 808// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
 809// output and repo-authored HTML are not.
 810var ugcPolicy = bluemonday.UGCPolicy()
 811
 812// renderReadme renders a README by extension: markdown, org-mode, and
 813// (sanitized) HTML richly; everything else as escaped plaintext.
 814func renderReadme(name string, raw []byte) template.HTML {
 815	plain := func() template.HTML {
 816		return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
 817	}
 818	if gitutil.IsBinary(raw) {
 819		return ""
 820	}
 821	switch path.Ext(strings.ToLower(name)) {
 822	case ".md", ".markdown":
 823		var buf bytes.Buffer
 824		if goldmark.Convert(raw, &buf) != nil {
 825			return plain()
 826		}
 827		return template.HTML(buf.String())
 828	case ".org":
 829		doc := org.New().Parse(bytes.NewReader(raw), name)
 830		html, err := doc.Write(org.NewHTMLWriter())
 831		if err != nil {
 832			return plain()
 833		}
 834		return template.HTML(ugcPolicy.Sanitize(html))
 835	case ".html", ".htm":
 836		return template.HTML(ugcPolicy.Sanitize(string(raw)))
 837	default:
 838		return plain()
 839	}
 840}
 841
 842type diffLine struct {
 843	Class   string
 844	Text    string
 845	Path    string // file this line belongs to
 846	NewLine int64  // line number in the new file (0 when absent)
 847	OldLine int64  // line number in the old file (0 when absent)
 848	Threads []diffThread
 849}
 850
 851var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
 852
 853// classifyDiff parses a unified diff into rendered lines, tracking the
 854// file and old/new line numbers so review threads can anchor inline.
 855func classifyDiff(patch string) []diffLine {
 856	var lines []diffLine
 857	path := ""
 858	var oldN, newN int64
 859	for _, l := range strings.Split(patch, "\n") {
 860		d := diffLine{Text: l}
 861		switch {
 862		case strings.HasPrefix(l, "+++ "):
 863			d.Class = "meta"
 864			path = strings.TrimPrefix(strings.TrimPrefix(l, "+++ "), "b/")
 865		case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
 866			d.Class = "meta"
 867		case strings.HasPrefix(l, "@@"):
 868			d.Class = "hunk"
 869			if m := hunkPat.FindStringSubmatch(l); m != nil {
 870				oldN, _ = strconv.ParseInt(m[1], 10, 64)
 871				newN, _ = strconv.ParseInt(m[2], 10, 64)
 872			}
 873		case strings.HasPrefix(l, "+"):
 874			d.Class, d.Path, d.NewLine = "add", path, newN
 875			newN++
 876		case strings.HasPrefix(l, "-"):
 877			d.Class, d.Path, d.OldLine = "del", path, oldN
 878			oldN++
 879		default:
 880			d.Path, d.OldLine, d.NewLine = path, oldN, newN
 881			oldN++
 882			newN++
 883		}
 884		lines = append(lines, d)
 885	}
 886	return lines
 887}
 888
 889type diffThread struct {
 890	ID       int64
 891	Resolved string
 892	Stale    bool
 893	Comments []renderedComment
 894}
 895
 896// attachThreads injects review threads under their anchored diff lines;
 897// threads whose anchor no longer appears (stale after force-push, or on a
 898// context line outside the current diff) are returned separately.
 899func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) {
 900	type anchor struct {
 901		path string
 902		side string
 903		line int64
 904	}
 905	threads := map[int64]*diffThread{}
 906	anchors := map[int64]anchor{}
 907	var order []int64
 908	for _, cm := range comments {
 909		if cm.ReplyTo == 0 {
 910			threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
 911				Comments: []renderedComment{{cm.Author, cm.CreatedAt, md(cm.Body)}}}
 912			anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
 913			order = append(order, cm.ID)
 914		} else if th, ok := threads[cm.ReplyTo]; ok {
 915			th.Comments = append(th.Comments, renderedComment{cm.Author, cm.CreatedAt, md(cm.Body)})
 916		}
 917	}
 918	placed := map[int64]bool{}
 919	for i := range lines {
 920		for _, id := range order {
 921			if placed[id] || threads[id].Stale {
 922				continue
 923			}
 924			a := anchors[id]
 925			if lines[i].Path != a.path {
 926				continue
 927			}
 928			if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
 929				(a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
 930				lines[i].Threads = append(lines[i].Threads, *threads[id])
 931				placed[id] = true
 932			}
 933		}
 934	}
 935	var unplaced []diffThread
 936	for _, id := range order {
 937		if !placed[id] {
 938			unplaced = append(unplaced, *threads[id])
 939		}
 940	}
 941	return lines, unplaced
 942}
 943
 944type sigView struct {
 945	State       string
 946	Signer      string
 947	Fingerprint string
 948}
 949
 950func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
 951	raw, err := gitutil.ReadCommit(dir, sha)
 952	if err != nil {
 953		return sigView{State: "unsigned"}, nil
 954	}
 955	parsed, err := sig.ParseCommit(raw)
 956	if err != nil {
 957		return sigView{State: "unsigned"}, nil
 958	}
 959	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
 960	if err != nil {
 961		return sigView{State: "unsigned"}, parsed
 962	}
 963	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
 964	if res.SignerUserID != 0 {
 965		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
 966			v.Signer = u.Username
 967		}
 968	}
 969	return v, parsed
 970}
 971
 972func (s *Server) log(w http.ResponseWriter, r *http.Request) {
 973	ref := r.PathValue("ref")
 974	p, ok := s.repoFor(w, r, ref)
 975	if !ok {
 976		return
 977	}
 978	p.Tab = "log"
 979	const pageSize = 50
 980	shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
 981	if err != nil {
 982		s.notFound(w, r)
 983		return
 984	}
 985	next := ""
 986	if len(shas) > pageSize {
 987		next = shas[pageSize]
 988		shas = shas[:pageSize]
 989	}
 990	type row struct {
 991		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
 992		Sig                                                   sigView
 993	}
 994	var rows []row
 995	for _, sha := range shas {
 996		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
 997		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
 998		if parsed != nil {
 999			rw.Subject = parsed.Subject
1000			rw.AuthorName = parsed.AuthorName
1001			rw.AuthorEmail = parsed.AuthorEmail
1002			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1003		}
1004		rows = append(rows, rw)
1005	}
1006	s.render(w, "log.html", struct {
1007		repoPage
1008		Commits []row
1009		NextSHA string
1010	}{p, rows, next})
1011}
1012
1013func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1014	p, ok := s.repoFor(w, r, "")
1015	if !ok {
1016		return
1017	}
1018	p.Tab = "log"
1019	sha := r.PathValue("sha")
1020	full, err := gitutil.ResolveRef(p.Dir, sha)
1021	if err != nil {
1022		s.notFound(w, r)
1023		return
1024	}
1025	v, parsed := s.sigFor(p.Repo, p.Dir, full)
1026	if parsed == nil {
1027		s.notFound(w, r)
1028		return
1029	}
1030	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1031	lines := classifyDiff(patch)
1032	committerEmail := ""
1033	if parsed.CommitterEmail != parsed.AuthorEmail {
1034		committerEmail = parsed.CommitterEmail
1035	}
1036	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1037	msg := ""
1038	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1039		msg = string(parsed.Payload[i+2:])
1040	}
1041	s.render(w, "commit.html", struct {
1042		repoPage
1043		SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
1044		Sig                                                                   sigView
1045		Checks                                                                []store.CommitStatus
1046		DiffLines                                                             []diffLine
1047	}{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
1048		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, checks, lines})
1049}
1050
1051// labelPalette provides default label chip colors: mid-tone hues that stay
1052// legible on light and dark backgrounds.
1053var labelPalette = []string{
1054	"#0969da", "#1a7f37", "#9a6700", "#cf222e",
1055	"#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1056}
1057
1058var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1059
1060// labelColors returns a complete label-name -> chip color map for a repo:
1061// the stored labels.color when it is a valid hex color, otherwise a
1062// stable default picked from the palette by name hash.
1063func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1064	stored, _ := s.st.LabelColors(repoID)
1065	out := make(map[string]template.CSS, len(stored))
1066	for name, color := range stored {
1067		if !hexColorPat.MatchString(color) {
1068			h := fnv.New32a()
1069			h.Write([]byte(name))
1070			color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1071		}
1072		out[name] = template.CSS("--chip:" + color)
1073	}
1074	return out
1075}
1076
1077func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1078	p, ok := s.repoFor(w, r, "")
1079	if !ok {
1080		return
1081	}
1082	p.Tab = "issues"
1083	state := r.URL.Query().Get("state")
1084	if state != "closed" && state != "all" {
1085		state = "open"
1086	}
1087	issues, err := s.st.ListIssues(p.Repo.ID, state)
1088	if err != nil {
1089		http.Error(w, "internal error", http.StatusInternalServerError)
1090		return
1091	}
1092	if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1093		for i := range issues {
1094			issues[i].Labels = labels[issues[i].ID]
1095		}
1096	}
1097	s.render(w, "issues.html", struct {
1098		repoPage
1099		State       string
1100		Issues      []store.Issue
1101		LabelColors map[string]template.CSS
1102	}{p, state, issues, s.labelColors(p.Repo.ID)})
1103}
1104
1105func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1106	p, ok := s.repoFor(w, r, "")
1107	if !ok {
1108		return
1109	}
1110	p.Tab = "issues"
1111	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1112	if err != nil {
1113		s.notFound(w, r)
1114		return
1115	}
1116	iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1117	if err != nil {
1118		s.notFound(w, r)
1119		return
1120	}
1121	comments, err := s.st.ListIssueComments(iss.ID)
1122	if err != nil {
1123		http.Error(w, "internal error", http.StatusInternalServerError)
1124		return
1125	}
1126	md := s.ugcFor(r, p.Repo)
1127	s.render(w, "issue.html", struct {
1128		repoPage
1129		Issue       store.Issue
1130		BodyHTML    template.HTML
1131		Comments    []renderedComment
1132		LabelColors map[string]template.CSS
1133	}{p, iss, md(iss.Body), renderComments(comments, md), s.labelColors(p.Repo.ID)})
1134}
1135
1136func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1137	p, ok := s.repoFor(w, r, "")
1138	if !ok {
1139		return
1140	}
1141	p.Tab = "merge requests"
1142	state := r.URL.Query().Get("state")
1143	if state == "" {
1144		state = "open"
1145	}
1146	valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1147	if !valid[state] {
1148		state = "open"
1149	}
1150	mrs, err := s.st.ListMRs(p.Repo.ID, state)
1151	if err != nil {
1152		http.Error(w, "internal error", http.StatusInternalServerError)
1153		return
1154	}
1155	s.render(w, "mrs.html", struct {
1156		repoPage
1157		State string
1158		MRs   []store.MR
1159	}{p, state, mrs})
1160}
1161
1162func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1163	p, ok := s.repoFor(w, r, "")
1164	if !ok {
1165		return
1166	}
1167	p.Tab = "merge requests"
1168	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1169	if err != nil {
1170		s.notFound(w, r)
1171		return
1172	}
1173	m, err := s.st.MRByNumber(p.Repo.ID, n)
1174	if err != nil {
1175		s.notFound(w, r)
1176		return
1177	}
1178	comments, _ := s.st.ListMRComments(m.ID)
1179	reviews, _ := s.st.ListMRReviews(m.ID)
1180	checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1181	diffComments, _ := s.st.ListDiffComments(m.ID)
1182
1183	headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1184	var lines []diffLine
1185	base := m.MergedBase
1186	if base == "" {
1187		if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1188			base = b
1189		}
1190	}
1191	if base != "" {
1192		if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1193			lines = classifyDiff(patch)
1194		}
1195	}
1196	md := s.ugcFor(r, p.Repo)
1197	var detachedThreads []diffThread
1198	lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
1199	type diffStat struct{ Files, Adds, Dels int }
1200	var stat diffStat
1201	seenFiles := map[string]bool{}
1202	for _, l := range lines {
1203		switch l.Class {
1204		case "add":
1205			stat.Adds++
1206		case "del":
1207			stat.Dels++
1208		}
1209		if l.Path != "" && !seenFiles[l.Path] {
1210			seenFiles[l.Path] = true
1211			stat.Files++
1212		}
1213	}
1214	s.render(w, "mr.html", struct {
1215		repoPage
1216		MR              store.MR
1217		BodyHTML        template.HTML
1218		Checks          []store.CommitStatus
1219		Combined        string
1220		Comments        []renderedComment
1221		Reviews         []store.MRReview
1222		DiffLines       []diffLine
1223		Stat            diffStat
1224		DetachedThreads []diffThread
1225	}{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md), reviews, lines, stat, detachedThreads})
1226}
1227
1228func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1229	p, ok := s.repoFor(w, r, "")
1230	if !ok {
1231		return
1232	}
1233	p.Tab = "refs"
1234	branches, _ := gitutil.Refs(p.Dir, "heads")
1235	tags, _ := gitutil.Refs(p.Dir, "tags")
1236	s.render(w, "refs.html", struct {
1237		repoPage
1238		Branches, Tags []gitutil.Ref
1239	}{p, branches, tags})
1240}
1241
1242func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1243	p, ok := s.repoFor(w, r, "")
1244	if !ok {
1245		return
1246	}
1247	file := r.PathValue("file")
1248	ref, ok := strings.CutSuffix(file, ".tar.gz")
1249	if !ok {
1250		s.notFound(w, r)
1251		return
1252	}
1253	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1254		s.notFound(w, r)
1255		return
1256	}
1257	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1258	w.Header().Set("Content-Type", "application/gzip")
1259	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1260	gitutil.Archive(p.Dir, ref, prefix, w)
1261}
1262
1263func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1264	return policy.CanRead(u, repo, grant)
1265}