internal/httpd/web.go

13f864d416c4ab328bbb07bdc53867656d3ab678
gitbay/internal/httpd/web.go history · blame · raw

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