internal/httpd/web.go

35cb93f6f17f984193c93df7ff9278f5bb662ffa
gitbay/internal/httpd/web.go history · blame · raw

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