internal/httpd/web.go

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

1171 lines · 32167 bytes

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