internal/httpd/web.go

4f3bd4893bc9923a02b86ae2cbae1fb17e019d2c
gitbay/internal/httpd/web.go history · blame · raw

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