internal/httpd/web.go

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

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