internal/httpd/web.go
1916 lines · 60117 bytes
1package httpd
2
3import (
4 "bytes"
5 "crypto/sha256"
6 "encoding/hex"
7 "errors"
8 "fmt"
9 "hash/fnv"
10 "io"
11 "log"
12 "math"
13 "os"
14 "path/filepath"
15
16 "gitbay.org/gitbay/internal/policy"
17 "gitbay.org/gitbay/internal/protocol"
18 "html/template"
19 "net/http"
20 "net/url"
21 "path"
22 "regexp"
23 "sort"
24 "strconv"
25 "strings"
26 "time"
27
28 "github.com/alecthomas/chroma/v2/formatters/html"
29 "github.com/alecthomas/chroma/v2/lexers"
30 "github.com/alecthomas/chroma/v2/styles"
31 "github.com/microcosm-cc/bluemonday"
32 "github.com/niklasfasching/go-org/org"
33 "github.com/yuin/goldmark"
34 highlighting "github.com/yuin/goldmark-highlighting/v2"
35 "github.com/yuin/goldmark/extension"
36 "github.com/yuin/goldmark/parser"
37
38 "gitbay.org/gitbay/internal/autolink"
39 "gitbay.org/gitbay/internal/control"
40 "gitbay.org/gitbay/internal/gitutil"
41 "gitbay.org/gitbay/internal/sig"
42 "gitbay.org/gitbay/internal/store"
43 "gitbay.org/gitbay/internal/web"
44)
45
46const maxRenderBytes = 1 << 20 // largest blob rendered inline
47
48func (s *Server) render(w http.ResponseWriter, page string, data any) {
49 var buf bytes.Buffer
50 if err := web.Render(&buf, page, data); err != nil {
51 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
52 return
53 }
54 w.Header().Set("Content-Type", "text/html; charset=utf-8")
55 buf.WriteTo(w)
56}
57
58// siteName is the instance's display name: the operator's [web] title,
59// or the site host when they have not set one.
60func (s *Server) siteName() string {
61 if t := strings.TrimSpace(s.cfg.Web.Title); t != "" {
62 return t
63 }
64 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
65 return strings.TrimSuffix(h, "/")
66}
67
68// stylesheetETag is the hash of what stylesheet serves, computed once:
69// a browser revalidates with If-None-Match and gets a 304 until a deploy
70// changes the bytes (#132).
71var stylesheetETag = func() string {
72 h := sha256.New()
73 h.Write(web.StyleCSS)
74 h.Write(chromaCSS)
75 return `"` + hex.EncodeToString(h.Sum(nil))[:16] + `"`
76}()
77
78func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
79 w.Header().Set("ETag", stylesheetETag)
80 w.Header().Set("Cache-Control", "public, max-age=86400, must-revalidate")
81 if r.Header.Get("If-None-Match") == stylesheetETag {
82 w.WriteHeader(http.StatusNotModified)
83 return
84 }
85 w.Header().Set("Content-Type", "text/css; charset=utf-8")
86 w.Write(web.StyleCSS)
87 w.Write(chromaCSS)
88}
89
90func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
91 w.Header().Set("Content-Type", "image/svg+xml")
92 w.Write(web.FaviconSVG)
93}
94
95// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
96// so the CSP's default-src 'self' covers it — no font CDN.
97func (s *Server) font(w http.ResponseWriter, r *http.Request) {
98 data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
99 if err != nil {
100 http.NotFound(w, r)
101 return
102 }
103 w.Header().Set("Content-Type", "font/woff2")
104 w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
105 w.Write(data)
106}
107
108// notFound renders the designed 404 page with a 404 status. Falls back to
109// the stock plain-text response if the template fails.
110func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
111 var buf bytes.Buffer
112 if err := web.Render(&buf, "404.html", s.base(r)); err != nil {
113 http.NotFound(w, r)
114 return
115 }
116 w.Header().Set("Content-Type", "text/html; charset=utf-8")
117 w.WriteHeader(http.StatusNotFound)
118 buf.WriteTo(w)
119}
120
121// describedRepo pairs a repo with the listing metadata: description,
122// topics, license, and last-updated date.
123type describedRepo struct {
124 store.Repo
125 Desc string
126 Topics []string
127 License string
128 Updated string
129}
130
131// Archived flattens the settings flag so the reporow partial can read the
132// same field name from a describedRepo and from a profile's repo row.
133func (d describedRepo) Archived() bool { return d.Settings.Archived }
134
135func (s *Server) describeAll(repos []store.Repo) []describedRepo {
136 var out []describedRepo
137 for _, r := range repos {
138 dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
139 d := describedRepo{
140 Repo: r,
141 Desc: gitutil.ReadDescription(dir),
142 License: control.DetectLicense(dir, r.DefaultBranch),
143 Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
144 }
145 d.Topics, _ = s.st.ListTopics(r.ID)
146 out = append(out, d)
147 }
148 return out
149}
150
151// index is the homepage: a dashboard for logged-in users, a landing page
152// for everyone else. The full public listing lives at /explore.
153func (s *Server) index(w http.ResponseWriter, r *http.Request) {
154 if s.cfg.Web.Mode == "accounts" {
155 if viewer := s.viewer(r); viewer.ID != 0 {
156 s.dashboard(w, r, viewer)
157 return
158 }
159 }
160 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
161 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
162 s.render(w, "landing.html", struct {
163 basePage
164 Host string
165 Accounts bool
166 Signup bool
167 }{basePage{Site: s.siteName(), Host: s.cfg.SiteHost()}, host, s.cfg.Web.Mode == "accounts",
168 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
169}
170
171func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
172 pinned, _ := s.st.PinnedRepos(viewer.ID)
173 var visible []store.Repo
174 for _, rp := range pinned {
175 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
176 if policy.CanRead(viewer, rp, grant) {
177 visible = append(visible, rp)
178 }
179 }
180 mrs, _ := s.st.DashboardMRs(viewer.ID)
181 issues, _ := s.st.DashboardIssues(viewer.ID)
182 reviews, _ := s.st.ReviewQueue(viewer.ID)
183 assigned, _ := s.st.AssignedIssues(viewer.ID)
184 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
185 s.render(w, "dashboard.html", struct {
186 basePage
187 Pinned []store.Repo
188 Reviews []store.DashboardItem
189 Assigned []store.DashboardItem
190 MRs []store.DashboardItem
191 Issues []store.DashboardItem
192 Feed []feedLine
193 }{s.baseFor(viewer), visible, reviews, assigned, mrs, issues, feedLines(events)})
194}
195
196func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
197 repos, err := s.st.ListPublicRepos()
198 if err != nil {
199 http.Error(w, "internal error", http.StatusInternalServerError)
200 return
201 }
202 var viewer store.User
203 if s.cfg.Web.Mode == "accounts" {
204 viewer = s.viewer(r)
205 }
206 q := strings.TrimSpace(r.URL.Query().Get("q"))
207 s.render(w, "explore.html", struct {
208 basePage
209 Query string
210 Repos []describedRepo
211 }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
212}
213
214// privacy renders the privacy page: what the gitbay software does with
215// data, plus this instance's operator-provided notes.
216func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
217 s.render(w, "privacy.html", struct {
218 basePage
219 Host string
220 Notice string
221 }{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
222}
223
224// filterRepos keeps repos matching the query by the same rule `repo
225// search` uses. An empty query keeps everything.
226func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
227 if q == "" {
228 return repos
229 }
230 var out []describedRepo
231 for _, d := range repos {
232 if control.MatchesRepo(q, d.Path(), d.Desc, d.Topics) {
233 out = append(out, d)
234 }
235 }
236 return out
237}
238
239// repoPage is the shared context for repo-scoped pages.
240type repoPage struct {
241 basePage
242 Desc string
243 Repo store.Repo
244 Ref string
245 CloneURL string
246 Dir string
247 Tab string // active tab in the repo header
248 Topics []string
249 Pinned bool // by the viewer
250 Watch string // the viewer's watch state: watching, muted, or ""
251 HasWiki bool
252 Host string
253 Mirrors []mirrorLine // repo admins only
254 CanAdmin bool // gates the settings tab
255 // OpenIssues and OpenMRs are the counts on the header tabs.
256 OpenIssues int
257 OpenMRs int
258 // RepoHome asks the layout for the full header — description, topics,
259 // website, mirrors. Every other page gets identity and tabs only, so a
260 // repo describes itself once rather than on all twelve of its pages.
261 RepoHome bool
262}
263
264// mirrorLine is the admin-only mirror status shown in the repo header.
265// It carries no credentials: the stored URL is credential-free.
266type mirrorLine struct {
267 Direction string
268 URL string
269 Target string // URL without the scheme, for display
270 Synced string
271 Error string
272}
273
274// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
275// readable "2026-08-25 03:39 UTC".
276func syncedAt(ts string) string {
277 if len(ts) < 16 {
278 return ts
279 }
280 return ts[:10] + " " + ts[11:16] + " UTC"
281}
282
283// repoFor resolves the repo for a web request; false means 404 was sent.
284// Anonymous visitors see public repos only; in accounts mode a logged-in
285// viewer additionally sees repos their grants allow. Private and missing
286// repos are indistinguishable either way.
287func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
288 var repo store.Repo
289 var viewer store.User
290 if s.cfg.Web.Mode == "accounts" {
291 viewer = s.viewer(r)
292 }
293 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
294 ok := err == nil
295 grant := ""
296 if ok {
297 if viewer.ID != 0 {
298 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
299 }
300 ok = policyCanRead(viewer, repo, grant)
301 }
302 if !ok {
303 s.notFound(w, r)
304 return repoPage{}, false
305 }
306 if ref == "" {
307 ref = repo.DefaultBranch
308 }
309 topics, _ := s.st.ListTopics(repo.ID)
310 pinned, watch := false, ""
311 if viewer.ID != 0 {
312 pinned = s.st.IsPinned(viewer.ID, repo.ID)
313 watch = s.st.RepoWatchState(repo.ID, viewer.ID)
314 }
315 canAdmin := viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant)
316 var mirrors []mirrorLine
317 if canAdmin {
318 ms, _ := s.st.ListMirrors(repo.ID)
319 for _, m := range ms {
320 mirrors = append(mirrors, mirrorLine{
321 Direction: m.Direction,
322 URL: m.URL,
323 Target: strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
324 Synced: syncedAt(m.LastSync),
325 Error: m.LastError,
326 })
327 }
328 }
329 openIssues, openMRs := s.st.OpenCounts(repo.ID)
330 return repoPage{
331 basePage: s.baseFor(viewer),
332 CanAdmin: canAdmin,
333 Mirrors: mirrors,
334 Pinned: pinned,
335 Watch: watch,
336 HasWiki: s.hasWiki(repo),
337 Host: s.cfg.SiteHost(),
338 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
339 Repo: repo,
340 Ref: ref,
341 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
342 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
343 Topics: topics,
344 OpenIssues: openIssues,
345 OpenMRs: openMRs,
346 }, true
347}
348
349type crumb struct {
350 Name string
351 URL string
352}
353
354// crumbs builds one crumb per path component. Every component but the
355// last is a directory and links to the tree; only the leaf is a page of
356// the given kind.
357func crumbs(p repoPage, kind, filePath string) []crumb {
358 var cs []crumb
359 parts := strings.Split(strings.Trim(filePath, "/"), "/")
360 acc := ""
361 for i, part := range parts {
362 if part == "" {
363 continue
364 }
365 acc = path.Join(acc, part)
366 k := "tree"
367 if i == len(parts)-1 {
368 k = kind
369 }
370 cs = append(cs, crumb{Name: part, URL: "/" + p.Repo.Path() + "/" + k + "/" + p.Ref + "/" + acc})
371 }
372 return cs
373}
374
375// profileView is profile show's payload, shaped for the templates. The
376// repo rows carry the same names the reporow partial reads, so a profile
377// listing renders identically to explore's.
378// profileView is profile show's payload with the repository rows wrapped
379// so the reporow partial can reach them. The fields themselves are the
380// command's: a field it gains appears here without being re-declared.
381type profileView struct {
382 control.ProfileOut
383 Repos []profileRepoRow `json:"repos"`
384}
385
386// profileRepoRow is one repository row on a profile. The partial asks for
387// OwnerName, Name and Desc; the payload carries a path and a description.
388type profileRepoRow struct {
389 control.ProfileRepo
390}
391
392func (p profileRepoRow) OwnerName() string { owner, _, _ := strings.Cut(p.Path, "/"); return owner }
393func (p profileRepoRow) Name() string { _, name, _ := strings.Cut(p.Path, "/"); return name }
394func (p profileRepoRow) Desc() string { return p.Description }
395
396// ownerPage renders /{owner} for users and orgs: the repositories the
397// viewer may see, org membership either direction. Owner names are not
398// secret (they are on every commit); repository visibility rules hold.
399func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
400 name := r.PathValue("owner")
401 var viewer store.User
402 if s.cfg.Web.Mode == "accounts" {
403 viewer = s.viewer(r)
404 }
405
406 // Everything on this page — membership, the repositories this viewer
407 // may see, the activity year — comes from profile show, so the page
408 // and the command cannot report different things.
409 var d profileView
410 code, msg := s.runControlIntoCode(viewer, []string{"profile", "show", name}, &d)
411 switch {
412 case code == protocol.ExitNotFound:
413 s.notFound(w, r)
414 return
415 case code != protocol.ExitOK:
416 log.Printf("profile %s: %s", name, msg)
417 http.Error(w, "internal error", http.StatusInternalServerError)
418 return
419 }
420
421 counts := make(map[string]int, len(d.Activity))
422 for _, day := range d.Activity {
423 counts[day.Date] = day.Count
424 }
425 weeks, activityTotal := activityGrid(counts)
426
427 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
428 profile := store.Profile{Description: d.Description, Website: d.Website,
429 About: d.About, AboutFormat: d.AboutFormat, Links: d.Links}
430 s.render(w, "owner.html", struct {
431 basePage
432 Owner string
433 Kind string
434 Profile store.Profile
435 AboutHTML template.HTML
436 Repos []profileRepoRow
437 Members []control.ProfileMember
438 Orgs []control.ProfileMember
439 Activity []activityWeek
440 ActivityTotal int
441 Teams []teamView
442 CanAdmin bool
443 Self bool
444 Notice string
445 }{s.baseFor(viewer), name, d.Kind, profile, aboutHTML(profile),
446 d.Repos, d.Members, d.Orgs,
447 weeks, activityTotal, teams, canAdmin,
448 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
449 s.takeFlash(w, r)})
450}
451
452func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
453 p, ok := s.repoFor(w, r, "")
454 if !ok {
455 return
456 }
457 p.Tab = "files"
458 p.RepoHome = true
459 s.renderTree(w, r, p, "")
460}
461
462func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
463 p, ok := s.repoFor(w, r, r.PathValue("ref"))
464 if !ok {
465 return
466 }
467 p.Tab = "files"
468 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
469}
470
471// treePage is shared by the populated and empty-repository renders: two
472// anonymous structs drifted apart once already.
473type treePage struct {
474 repoPage
475 Crumbs []crumb
476 Prefix string
477 DirPath string
478 RefKind string
479 Entries []gitutil.TreeEntry
480 Branches []gitutil.Ref
481 ReadmeName string
482 ReadmeHTML template.HTML
483 LastCommits map[string]namedCommit
484 Tip namedCommit
485 Facts repoFacts
486 Notice string
487}
488
489func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
490 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
491 // Empty repo: render the page with no entries rather than 404.
492 s.render(w, "tree.html", treePage{repoPage: p, RefKind: "tree", Notice: s.takeFlash(w, r)})
493 return
494 }
495 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
496 if err != nil {
497 s.notFound(w, r)
498 return
499 }
500 // Directories first. git's tree order interleaves them with files, but
501 // a listing is scanned by shape before name. Stable, so each group
502 // keeps the ordering git gave it.
503 sort.SliceStable(entries, func(i, j int) bool {
504 return entries[i].Type == "tree" && entries[j].Type != "tree"
505 })
506 prefix := ""
507 if dirPath != "" {
508 prefix = dirPath + "/"
509 }
510
511 var readmeHTML template.HTML
512 readmeName := pickReadme(entries)
513 if readmeName != "" {
514 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
515 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
516 }
517 }
518
519 branches, _ := gitutil.Refs(p.Dir, "heads")
520 names := make([]string, 0, len(entries))
521 for _, e := range entries {
522 names = append(names, e.Name)
523 }
524 // The facts bar is about the repository, not this directory, so it is
525 // computed once at the root and left off subdirectory listings.
526 var facts repoFacts
527 if dirPath == "" {
528 facts = s.factsFor(p)
529 }
530 s.render(w, "tree.html", treePage{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
531 readmeName, readmeHTML,
532 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
533 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref)), facts, s.takeFlash(w, r)})
534}
535
536func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
537 p, ok := s.repoFor(w, r, r.PathValue("ref"))
538 if !ok {
539 return
540 }
541 p.Tab = "files"
542 filePath := strings.Trim(r.PathValue("path"), "/")
543 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
544 if err != nil {
545 s.notFound(w, r)
546 return
547 }
548 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
549 _, image := imageTypes[strings.ToLower(path.Ext(filePath))]
550
551 var codeHTML template.HTML
552 if !binary && !image {
553 codeHTML = highlight(filePath, data)
554 }
555 // Markdown and org render like a README, with the source one click
556 // away; ?view=source shows the text instead.
557 renderable := false
558 switch path.Ext(strings.ToLower(filePath)) {
559 case ".md", ".markdown", ".org":
560 renderable = !binary
561 }
562 var renderedHTML template.HTML
563 rendered := renderable && r.URL.Query().Get("view") != "source"
564 if rendered {
565 renderedHTML = rewriteRelativeLinks(renderReadme(path.Base(filePath), data), p, path.Dir(filePath))
566 }
567 cs := crumbs(p, "blob", filePath)
568 base := ""
569 if len(cs) > 0 {
570 base = cs[len(cs)-1].Name
571 cs = cs[:len(cs)-1]
572 }
573 branches, _ := gitutil.Refs(p.Dir, "heads")
574 lines := 0
575 if !binary && !image && len(data) > 0 {
576 lines = bytes.Count(data, []byte("\n"))
577 if data[len(data)-1] != '\n' {
578 lines++
579 }
580 }
581 // The file listing leads with the last commit now, so the facts about
582 // the file itself are reported here instead.
583 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
584 s.render(w, "blob.html", struct {
585 repoPage
586 Crumbs []crumb
587 Base string
588 Path string
589 DirPath string
590 RefKind string
591 Binary bool
592 Image bool
593 Size int
594 Lines int
595 Exec bool
596 Symlink bool
597 Branches []gitutil.Ref
598 CodeHTML template.HTML
599 Renderable bool // markdown or org: the toggle is offered
600 Rendered bool // this response shows the rendering
601 RenderedHTML template.HTML
602 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
603 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML, renderable, rendered, renderedHTML})
604}
605
606// releases lists tag-anchored releases with notes and assets.
607func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
608 p, ok := s.repoFor(w, r, "")
609 if !ok {
610 return
611 }
612 p.Tab = "releases"
613 rels, err := s.st.ListReleases(p.Repo.ID)
614 if err != nil {
615 http.Error(w, "internal error", http.StatusInternalServerError)
616 return
617 }
618 md := s.ugcFor(r, p.Repo)
619 type relView struct {
620 store.Release
621 NotesHTML template.HTML
622 }
623 var views []relView
624 for _, rel := range rels {
625 views = append(views, relView{rel, md(rel.Notes, rel.NotesFormat)})
626 }
627 // Tags without a release yet are what a create form can offer.
628 released := map[string]bool{}
629 for _, rel := range rels {
630 released[rel.Tag] = true
631 }
632 var freeTags []string
633 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
634 for _, tg := range tags {
635 if !released[tg.Name] {
636 freeTags = append(freeTags, tg.Name)
637 }
638 }
639 }
640 s.render(w, "releases.html", struct {
641 repoPage
642 Releases []relView
643 FreeTags []string
644 CanWrite bool
645 Notice string
646 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), s.takeFlash(w, r)})
647}
648
649// releaseAsset streams one uploaded asset. Tags containing '/' are not
650// reachable here (single path segment); SSH download always works.
651func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
652 p, ok := s.repoFor(w, r, "")
653 if !ok {
654 return
655 }
656 rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
657 if err != nil {
658 s.notFound(w, r)
659 return
660 }
661 name := r.PathValue("name")
662 found := false
663 for _, a := range rel.Assets {
664 if a.Name == name {
665 found = true
666 }
667 }
668 if !found {
669 s.notFound(w, r)
670 return
671 }
672 f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
673 "gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
674 if err != nil {
675 s.notFound(w, r)
676 return
677 }
678 defer f.Close()
679 w.Header().Set("Content-Type", "application/octet-stream")
680 w.Header().Set("X-Content-Type-Options", "nosniff")
681 w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
682 if fi, err := f.Stat(); err == nil {
683 w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
684 }
685 io.Copy(w, f)
686}
687
688// milestones lists a repo's milestones with progress.
689func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
690 p, ok := s.repoFor(w, r, "")
691 if !ok {
692 return
693 }
694 p.Tab = "issues"
695 state := r.URL.Query().Get("state")
696 if state != "closed" && state != "all" {
697 state = "open"
698 }
699 ms, err := s.st.ListMilestones(p.Repo.ID, state)
700 if err != nil {
701 http.Error(w, "internal error", http.StatusInternalServerError)
702 return
703 }
704 type msView struct {
705 store.Milestone
706 Percent int
707 }
708 var views []msView
709 for _, m := range ms {
710 v := msView{Milestone: m}
711 if total := m.OpenItems + m.ClosedItems; total > 0 {
712 v.Percent = m.ClosedItems * 100 / total
713 }
714 views = append(views, v)
715 }
716 s.render(w, "milestones.html", struct {
717 repoPage
718 State string
719 Milestones []msView
720 }{p, state, views})
721}
722
723// search runs a bounded literal git grep over the repo's default branch.
724func (s *Server) search(w http.ResponseWriter, r *http.Request) {
725 p, ok := s.repoFor(w, r, "")
726 if !ok {
727 return
728 }
729 p.Tab = "search"
730 q := strings.TrimSpace(r.URL.Query().Get("q"))
731 type matchView struct {
732 Path string
733 Line int
734 TextHTML template.HTML
735 }
736 var matches []matchView
737 var queryErr string
738 if q != "" {
739 if len(q) < 2 || len(q) > 200 {
740 queryErr = "query must be 2 to 200 characters"
741 } else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
742 raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
743 if err != nil {
744 http.Error(w, "internal error", http.StatusInternalServerError)
745 return
746 }
747 for _, m := range raw {
748 matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
749 }
750 }
751 }
752 s.render(w, "search.html", struct {
753 repoPage
754 Query string
755 QueryErr string
756 Matches []matchView
757 Capped bool
758 }{p, q, queryErr, matches, len(matches) == 200})
759}
760
761// markMatch escapes a matched line and wraps case-insensitive occurrences
762// of the query in <mark>.
763func markMatch(text, q string) template.HTML {
764 lower, lq := strings.ToLower(text), strings.ToLower(q)
765 var b strings.Builder
766 pos := 0
767 for {
768 i := strings.Index(lower[pos:], lq)
769 if i < 0 {
770 break
771 }
772 i += pos
773 b.WriteString(template.HTMLEscapeString(text[pos:i]))
774 b.WriteString("<mark>")
775 b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
776 b.WriteString("</mark>")
777 pos = i + len(q)
778 }
779 b.WriteString(template.HTMLEscapeString(text[pos:]))
780 return template.HTML(b.String())
781}
782
783func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
784 p, ok := s.repoFor(w, r, r.PathValue("ref"))
785 if !ok {
786 return
787 }
788 p.Tab = "files"
789 filePath := strings.Trim(r.PathValue("path"), "/")
790
791 // Blame is a control command; the web renders what it returns rather
792 // than shelling out to git itself, so all three surfaces agree.
793 page := 1
794 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 {
795 page = n
796 }
797 from := (page-1)*control.BlameSpan + 1
798
799 var out struct {
800 From int `json:"from"`
801 To int `json:"to"`
802 TotalLines int `json:"total_lines"`
803 Hunks []struct {
804 SHA string `json:"sha"`
805 AuthorName string `json:"author_name"`
806 AuthorEmail string `json:"author_email"`
807 Date string `json:"date"`
808 Summary string `json:"summary"`
809 StartLine int `json:"start_line"`
810 Lines []string `json:"lines"`
811 } `json:"hunks"`
812 }
813 argv := []string{"repo", "blame", p.Repo.Path(), filePath,
814 "--ref", p.Ref, "--from", strconv.Itoa(from), "--to", strconv.Itoa(from + control.BlameSpan - 1)}
815 var viewer store.User
816 if s.cfg.Web.Mode == "accounts" {
817 viewer = s.viewer(r)
818 }
819 msg, ok := s.runControlInto(viewer, argv, &out)
820
821 // A binary or empty file is a refusal, not a 404: the page still
822 // renders and says why there is nothing to attribute.
823 binary := false
824 if !ok {
825 if strings.Contains(msg, "is binary") {
826 binary = true
827 } else {
828 s.notFound(w, r)
829 return
830 }
831 }
832
833 type hunkView struct {
834 gitutil.BlameHunk
835 ShortSHA string
836 Date string
837 Sig sigView
838 Numbered []numberedLine
839 }
840 var hunks []hunkView
841 sigs := map[string]sigView{}
842 for _, h := range out.Hunks {
843 v, seen := sigs[h.SHA]
844 if !seen {
845 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
846 sigs[h.SHA] = v
847 }
848 date := h.Date
849 if t, err := time.Parse(time.RFC3339, h.Date); err == nil {
850 date = t.Format("2006-01-02")
851 }
852 hv := hunkView{
853 BlameHunk: gitutil.BlameHunk{SHA: h.SHA, AuthorName: h.AuthorName,
854 AuthorEmail: h.AuthorEmail, Summary: h.Summary,
855 StartLine: h.StartLine, Lines: h.Lines},
856 ShortSHA: h.SHA[:min(10, len(h.SHA))], Date: date, Sig: v,
857 }
858 for i, l := range h.Lines {
859 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
860 }
861 hunks = append(hunks, hv)
862 }
863
864 pages := (out.TotalLines + control.BlameSpan - 1) / control.BlameSpan
865 if pages == 0 {
866 pages = 1
867 }
868 if page > pages {
869 page = pages
870 }
871
872 cs := crumbs(p, "blame", filePath)
873 base := ""
874 if len(cs) > 0 {
875 base = cs[len(cs)-1].Name
876 cs = cs[:len(cs)-1]
877 }
878 s.render(w, "blame.html", struct {
879 repoPage
880 Crumbs []crumb
881 Base string
882 Path string
883 Binary bool
884 Hunks []hunkView
885 Page, Pages int
886 }{p, cs, base, filePath, binary, hunks, page, pages})
887}
888
889type numberedLine struct {
890 N int
891 Text string
892}
893
894// chromaFormatter emits class-based markup (no inline colors), so the
895// stylesheet can swap palettes with the color scheme.
896var chromaFormatter = html.New(html.WithClasses(true),
897 html.WithLineNumbers(true), html.LineNumbersInTable(false),
898 html.WithLinkableLineNumbers(true, "L"))
899
900func highlight(filePath string, data []byte) template.HTML {
901 lexer := lexers.Match(filePath)
902 if lexer == nil {
903 lexer = lexers.Fallback
904 }
905 iterator, err := lexer.Tokenise(nil, string(data))
906 if err != nil {
907 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
908 }
909 var buf bytes.Buffer
910 if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
911 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
912 }
913 return template.HTML(buf.String())
914}
915
916// chromaCSS is both syntax palettes, each scoped to the scheme it is for.
917// The light one cannot be left unscoped: the two palettes do not name the
918// same token set, and every token github-dark omits would keep its
919// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
920// Scoped, an unnamed token inherits the wrapper's colour instead, which is
921// readable in both. The site's --code-bg stays the background either way.
922// lightStyle and darkStyle are chosen on measured contrast against the
923// grounds code actually sits on here — page, code block, and the diff
924// tints. friendly, the chroma default, put 61 token/ground pairs under
925// 4.5:1; xcode puts one.
926const (
927 lightStyle = "xcode"
928 darkStyle = "github-dark"
929)
930
931var chromaCSS = func() []byte {
932 var buf bytes.Buffer
933 buf.WriteString("@media (prefers-color-scheme: light) {\n")
934 chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
935 // xcode's NameAttribute is its one token under 4.5:1 against the diff
936 // tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
937 buf.WriteString(".chroma .na { color: #6f5a21 }\n")
938 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
939 chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
940 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
941 // Line numbers take the site's own gutter colour in both schemes. Left
942 // alone they are github-dark's #6e7681 (4.31:1 on the page) in dark and
943 // chroma's built-in #7f7f7f (3.67:1 on a code block) in light — the
944 // latter is a formatter fallback, not a style entry, so no palette test
945 // can see it.
946 buf.WriteString(".chroma .lnt, .chroma .ln { color: var(--muted) }\n")
947 return buf.Bytes()
948}()
949
950func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
951 p, ok := s.repoFor(w, r, r.PathValue("ref"))
952 if !ok {
953 return
954 }
955 filePath := strings.Trim(r.PathValue("path"), "/")
956 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
957 if err != nil {
958 s.notFound(w, r)
959 return
960 }
961 // Serve inert: never let repo content execute in the forge's origin.
962 // Images get their real type so <img> works under nosniff; SVG script
963 // is dead on arrival because the instance CSP is script-src 'none'.
964 ct := "text/plain; charset=utf-8"
965 if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
966 ct = t
967 }
968 w.Header().Set("Content-Type", ct)
969 w.Header().Set("X-Content-Type-Options", "nosniff")
970 w.Write(data)
971}
972
973// imageTypes are the formats raw serves with a real content type and blob
974// pages preview inline.
975var imageTypes = map[string]string{
976 ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
977 ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
978 ".svg": "image/svg+xml", ".ico": "image/x-icon",
979}
980
981// readmeRank orders competing README files: richer renderers win.
982var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
983
984// pickReadme returns the best README-ish blob in a tree listing: any file
985// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
986// we can render richly.
987func pickReadme(entries []gitutil.TreeEntry) string {
988 best, bestRank := "", 1<<30
989 for _, e := range entries {
990 if e.Type != "blob" {
991 continue
992 }
993 lower := strings.ToLower(e.Name)
994 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
995 continue
996 }
997 rank, ok := readmeRank[path.Ext(lower)]
998 if !ok {
999 rank = 10 // plaintext fallback
1000 }
1001 if rank < bestRank {
1002 best, bestRank = e.Name, rank
1003 }
1004 }
1005 return best
1006}
1007
1008// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
1009// task lists) on top of CommonMark, with class-based fence highlighting
1010// (the palette lives in the stylesheet, per scheme). Raw HTML is still
1011// dropped.
1012// Headings carry ids so a README or wiki section can be linked to, the
1013// way org headings already are (#132).
1014var markdown = goldmark.New(
1015 goldmark.WithParserOptions(parser.WithAutoHeadingID()),
1016 goldmark.WithExtensions(extension.GFM,
1017 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
1018
1019// fenceHighlight renders one code block with chroma classes, for org and
1020// anything else outside goldmark. Unknown languages fall back to plain.
1021func fenceHighlight(source, lang string) string {
1022 lexer := lexers.Get(lang)
1023 if lexer == nil {
1024 lexer = lexers.Fallback
1025 }
1026 iterator, err := lexer.Tokenise(nil, source)
1027 if err != nil {
1028 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1029 }
1030 var buf bytes.Buffer
1031 f := html.New(html.WithClasses(true))
1032 if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
1033 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
1034 }
1035 return buf.String()
1036}
1037
1038// mdHTML renders user-authored markdown (issue and MR bodies, comments).
1039// goldmark's default renderer drops raw HTML, so this is safe as-is.
1040func mdHTML(raw string) template.HTML {
1041 if strings.TrimSpace(raw) == "" {
1042 return ""
1043 }
1044 var buf bytes.Buffer
1045 if markdown.Convert([]byte(raw), &buf) != nil {
1046 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1047 }
1048 return template.HTML(buf.String())
1049}
1050
1051// aboutHTML renders a profile's about text. It has no filename to
1052// dispatch on, so the stored format picks the extension; anything other
1053// than org is markdown.
1054func aboutHTML(p store.Profile) template.HTML {
1055 if strings.TrimSpace(p.About) == "" {
1056 return ""
1057 }
1058 name := "about.md"
1059 if p.AboutFormat == "org" {
1060 name = "about.org"
1061 }
1062 return renderReadme(name, []byte(p.About))
1063}
1064
1065// webResolver answers autolink lookups for one viewer. Cross-repo
1066// references to repositories the viewer cannot read stay plain text, per
1067// the enumeration rule: a link would confirm the repo exists.
1068type webResolver struct {
1069 s *Server
1070 viewer store.User
1071}
1072
1073func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
1074 repo, err := r.s.st.RepoByPath(owner + "/" + name)
1075 if err != nil {
1076 return ""
1077 }
1078 grant := ""
1079 if r.viewer.ID != 0 {
1080 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
1081 }
1082 if !policy.CanRead(r.viewer, repo, grant) {
1083 return ""
1084 }
1085 if kind == '#' {
1086 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
1087 return ""
1088 }
1089 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
1090 }
1091 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
1092 return ""
1093 }
1094 return autolink.MRURL(repo.OwnerName, repo.Name, n)
1095}
1096
1097func (r webResolver) UserURL(name string) string {
1098 if _, err := r.s.st.UserByUsername(name); err == nil {
1099 return "/" + name
1100 }
1101 if _, err := r.s.st.OrgByName(name); err == nil {
1102 return "/" + name
1103 }
1104 return ""
1105}
1106
1107// ugcRenderer renders one user-authored body in the format it was written in.
1108// The format travels with the body: it is recorded when the text is written, so
1109// changing a preference later cannot re-interpret prose that already exists.
1110type ugcRenderer func(raw, format string) template.HTML
1111
1112// ugcHTML renders a user-authored body. Anything other than "org" is markdown,
1113// so a body stored before formats existed — and any row whose column defaulted —
1114// renders exactly as it did before.
1115//
1116// Org goes through renderReadme, the same path READMEs, wiki pages and profile
1117// about text take, so it inherits that function's include guard and sanitising
1118// rather than growing a second org renderer to keep in step.
1119func ugcHTML(raw, format string) template.HTML {
1120 if format == "org" {
1121 return renderOrg("body.org", []byte(raw), false, func() template.HTML {
1122 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
1123 })
1124 }
1125 return mdHTML(raw)
1126}
1127
1128// ugcFor returns a renderer for user-authored bodies on one repo's pages:
1129// ugcHTML plus cross-reference and mention autolinking for this viewer.
1130func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer {
1131 viewer := store.User{}
1132 if s.cfg.Web.Mode == "accounts" {
1133 viewer = s.viewer(r)
1134 }
1135 res := webResolver{s, viewer}
1136 return func(raw, format string) template.HTML {
1137 h := ugcHTML(raw, format)
1138 if h == "" {
1139 return h
1140 }
1141 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1142 }
1143}
1144
1145// renderedComment pairs a comment with its rendered body for templates.
1146type renderedComment struct {
1147 Author string
1148 CreatedAt string
1149 Kind string
1150 BodyHTML template.HTML
1151}
1152
1153func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment {
1154 var out []renderedComment
1155 for _, c := range cs {
1156 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)})
1157 }
1158 return out
1159}
1160
1161// ugcPolicy sanitizes rendered repo content before it enters the forge's
1162// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1163// output and repo-authored HTML are not. Chroma's highlighting classes
1164// must survive; the pattern admits only short token codes, not the site's
1165// own class names.
1166var ugcPolicy = func() *bluemonday.Policy {
1167 p := bluemonday.UGCPolicy()
1168 p.AllowAttrs("class").
1169 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1170 OnElements("span", "pre", "code", "div")
1171 return p
1172}()
1173
1174// renderReadme renders a README by extension: markdown, org-mode, and
1175// (sanitized) HTML richly; everything else as escaped plaintext.
1176// orgConfig is the go-org configuration for rendering untrusted org.
1177//
1178// go-org's default reads #+INCLUDE: and #+SETUPFILE: targets off disk with
1179// os.ReadFile. Everything rendered here is content someone pushed — a README, a
1180// wiki page, a profile — so both keywords are refused outright: the file is
1181// never opened and the keyword stays the inert text it is. There is no safe
1182// subset to allow instead. An absolute path skips go-org's relative-path join,
1183// a relative one resolves against the daemon's working directory, and a repo
1184// has no directory to scope to anyway because the content came from a git
1185// object rather than a checkout.
1186//
1187// The default logger writes parse warnings to stderr, which would let pushed
1188// content write to the server's log; discard them.
1189func orgConfig() *org.Configuration {
1190 c := org.New()
1191 c.ReadFile = func(string) ([]byte, error) {
1192 return nil, errOrgIncludeDisabled
1193 }
1194 c.Log = log.New(io.Discard, "", 0)
1195 return c
1196}
1197
1198var errOrgIncludeDisabled = errors.New("org: #+INCLUDE and #+SETUPFILE are disabled")
1199
1200// renderOrg renders org to sanitized HTML. `contents` asks go-org for its table
1201// of contents: a README or wiki page is a document and carries one, an issue
1202// comment is a remark and should not sprout one above two headings. `fallback`
1203// supplies the plaintext rendering used when the writer fails.
1204func renderOrg(name string, raw []byte, contents bool, fallback func() template.HTML) template.HTML {
1205 c := orgConfig()
1206 if !contents {
1207 // DefaultSettings is a fresh map per org.New(), so this is local.
1208 c.DefaultSettings["OPTIONS"] = strings.ReplaceAll(c.DefaultSettings["OPTIONS"], "toc:t", "toc:nil")
1209 }
1210 doc := c.Parse(bytes.NewReader(raw), name)
1211 writer := org.NewHTMLWriter()
1212 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1213 if inline {
1214 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1215 }
1216 return fenceHighlight(source, lang)
1217 }
1218 out, err := doc.Write(writer)
1219 if err != nil {
1220 return fallback()
1221 }
1222 return template.HTML(ugcPolicy.Sanitize(out))
1223}
1224
1225// headingTag matches an opening or closing h1..h5 tag, so a rendered
1226// document's headings can move down one level.
1227var headingTag = regexp.MustCompile(`<(/?)h([1-5])([\s>])`)
1228
1229// demoteHeadings moves every heading in a rendered document down one
1230// level: the page it sits on already has its h1 (the repository, the
1231// file, the wiki page), so a README's own h1 would be a second top-level
1232// heading in the outline (#133). Ids and anchors are untouched.
1233func demoteHeadings(h template.HTML) template.HTML {
1234 return template.HTML(headingTag.ReplaceAllStringFunc(string(h), func(m string) string {
1235 sub := headingTag.FindStringSubmatch(m)
1236 return "<" + sub[1] + "h" + string(rune(sub[2][0]+1)) + sub[3]
1237 }))
1238}
1239
1240func renderReadme(name string, raw []byte) template.HTML {
1241 plain := func() template.HTML {
1242 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1243 }
1244 if gitutil.IsBinary(raw) {
1245 return ""
1246 }
1247 switch path.Ext(strings.ToLower(name)) {
1248 case ".md", ".markdown":
1249 var buf bytes.Buffer
1250 if markdown.Convert(raw, &buf) != nil {
1251 return plain()
1252 }
1253 return demoteHeadings(template.HTML(buf.String()))
1254 case ".org":
1255 return demoteHeadings(renderOrg(name, raw, true, plain))
1256 case ".html", ".htm":
1257 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1258 default:
1259 return plain()
1260 }
1261}
1262
1263type diffThread struct {
1264 ID int64
1265 Resolved string
1266 Stale bool
1267 // Pending marks a thread in the viewer's own unsubmitted review. Only
1268 // they are shown it, and the page says so, since it looks exactly
1269 // like a posted one otherwise.
1270 Pending bool
1271 CanResolve bool
1272 Comments []renderedComment
1273}
1274
1275// reviewRights decides which thread controls a viewer sees. mr resolve
1276// admits the thread author, the MR author, or anyone with write, so the
1277// page needs all three to render the button truthfully.
1278type reviewRights struct {
1279 Viewer string
1280 MRAuthor string
1281 Write bool
1282}
1283
1284func (r reviewRights) canResolve(threadAuthor string) bool {
1285 return r.Viewer != "" && (r.Write || r.Viewer == r.MRAuthor || r.Viewer == threadAuthor)
1286}
1287
1288// attachThreads injects review threads under their anchored diff lines;
1289// threads whose anchor no longer appears (stale after force-push, or on a
1290// context line outside the current diff) are returned separately.
1291func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md ugcRenderer, rights reviewRights) ([]diffFile, []diffThread) {
1292 type anchor struct {
1293 path string
1294 side string
1295 line int64
1296 }
1297 // Diff-line comments have no stored format yet, so they stay markdown.
1298 // They are the one user-authored body left without the choice; see #51.
1299 threads := map[int64]*diffThread{}
1300 anchors := map[int64]anchor{}
1301 var order []int64
1302 for _, cm := range comments {
1303 if cm.ReplyTo == 0 {
1304 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1305 Pending: cm.Pending,
1306 CanResolve: rights.canResolve(cm.Author),
1307 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")}}}
1308 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1309 order = append(order, cm.ID)
1310 } else if th, ok := threads[cm.ReplyTo]; ok {
1311 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body, "md")})
1312 }
1313 }
1314 placed := map[int64]bool{}
1315 for f := range files {
1316 lines := files[f].Lines
1317 for i := range lines {
1318 for _, id := range order {
1319 if placed[id] || threads[id].Stale {
1320 continue
1321 }
1322 a := anchors[id]
1323 if lines[i].Path != a.path {
1324 continue
1325 }
1326 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1327 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1328 lines[i].Threads = append(lines[i].Threads, *threads[id])
1329 files[f].Threads++
1330 files[f].Open = true
1331 placed[id] = true
1332 }
1333 }
1334 }
1335 }
1336 var unplaced []diffThread
1337 for _, id := range order {
1338 if !placed[id] {
1339 unplaced = append(unplaced, *threads[id])
1340 }
1341 }
1342 return files, unplaced
1343}
1344
1345// markCompose opens the new-thread form under one diff line. There is no
1346// JavaScript, so "comment on this line" is a plain GET carrying the
1347// anchor and the page renders the form where the reader asked for it.
1348func markCompose(files []diffFile, q url.Values) {
1349 path := q.Get("cpath")
1350 line, _ := strconv.ParseInt(q.Get("cline"), 10, 64)
1351 if path == "" || line < 1 {
1352 return
1353 }
1354 old := q.Get("cside") == "old"
1355 for f := range files {
1356 for i := range files[f].Lines {
1357 ln := &files[f].Lines[i]
1358 if ln.Path != path {
1359 continue
1360 }
1361 if (old && ln.Class == "del" && ln.OldLine == line) ||
1362 (!old && ln.Class != "del" && ln.NewLine == line) {
1363 ln.Compose = true
1364 files[f].Open = true
1365 return
1366 }
1367 }
1368 }
1369}
1370
1371type sigView struct {
1372 State string
1373 Signer string
1374 Fingerprint string
1375}
1376
1377func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1378 raw, err := gitutil.ReadCommit(dir, sha)
1379 if err != nil {
1380 return sigView{State: "unsigned"}, nil
1381 }
1382 parsed, err := sig.ParseCommit(raw)
1383 if err != nil {
1384 return sigView{State: "unsigned"}, nil
1385 }
1386 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1387 if err != nil {
1388 return sigView{State: "unsigned"}, parsed
1389 }
1390 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1391 if res.SignerUserID != 0 {
1392 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1393 v.Signer = u.Username
1394 }
1395 }
1396 return v, parsed
1397}
1398
1399func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1400 ref := r.PathValue("ref")
1401 p, ok := s.repoFor(w, r, ref)
1402 if !ok {
1403 return
1404 }
1405 p.Tab = "log"
1406 const pageSize = 50
1407 // ?path= filters to commits touching one file or directory.
1408 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1409 if filePath == "." {
1410 filePath = ""
1411 }
1412 var shas []string
1413 var err error
1414 if filePath != "" {
1415 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1416 } else {
1417 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1418 }
1419 if err != nil {
1420 s.notFound(w, r)
1421 return
1422 }
1423 next := ""
1424 if len(shas) > pageSize {
1425 next = shas[pageSize]
1426 shas = shas[:pageSize]
1427 }
1428 type row struct {
1429 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1430 Sig sigView
1431 Check string // combined status, "" when none ran
1432 }
1433 names := s.authorNames()
1434 checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1435 var rows []row
1436 for _, sha := range shas {
1437 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1438 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1439 if parsed != nil {
1440 rw.Subject = parsed.Subject
1441 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1442 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1443 rw.AuthorEmail = parsed.AuthorEmail
1444 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1445 }
1446 rows = append(rows, rw)
1447 }
1448 s.render(w, "log.html", struct {
1449 repoPage
1450 Commits []row
1451 NextSHA string
1452 FilePath string
1453 }{p, rows, next, filePath})
1454}
1455
1456func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1457 p, ok := s.repoFor(w, r, "")
1458 if !ok {
1459 return
1460 }
1461 p.Tab = "log"
1462 sha := r.PathValue("sha")
1463 full, err := gitutil.ResolveRef(p.Dir, sha)
1464 if err != nil {
1465 s.notFound(w, r)
1466 return
1467 }
1468 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1469 if parsed == nil {
1470 s.notFound(w, r)
1471 return
1472 }
1473 patch, truncated, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1474 files := parseDiff(patch)
1475 committerEmail := ""
1476 if parsed.CommitterEmail != parsed.AuthorEmail {
1477 committerEmail = parsed.CommitterEmail
1478 }
1479 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1480 commitNames := s.authorNames()
1481 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1482 msg := ""
1483 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1484 msg = string(parsed.Payload[i+2:])
1485 }
1486 s.render(w, "commit.html", struct {
1487 repoPage
1488 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1489 Parents []string
1490 Sig sigView
1491 Checks []store.CommitStatus
1492 DiffFiles []diffFile
1493 DiffTruncated bool
1494 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1495 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1496 gitutil.Parents(p.Dir, full), v, checks, files, truncated})
1497}
1498
1499// labelPalette provides default label chip colors: mid-tone hues that stay
1500// legible on light and dark backgrounds.
1501var labelPalette = []string{
1502 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1503 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1504}
1505
1506var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1507
1508// clampChip keeps a user-set label colour legible as text on both
1509// grounds. Contrast is defined on relative luminance, so that is what is
1510// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1511// and against the dark ground alike, and where the palette's own colours
1512// sit. The hue is kept; the channels are scaled in linear light (#120).
1513func clampChip(hex string) string {
1514 lin := func(c int64) float64 {
1515 v := float64(c) / 255
1516 if v <= 0.04045 {
1517 return v / 12.92
1518 }
1519 return math.Pow((v+0.055)/1.055, 2.4)
1520 }
1521 r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1522 y := 0.2126*r + 0.7152*g + 0.0722*b
1523 const lo, hi = 0.12, 0.28
1524 if y >= lo && y <= hi {
1525 return strings.ToLower(hex)
1526 }
1527 target := hi
1528 if y < lo {
1529 target = lo
1530 }
1531 if y == 0 {
1532 r, g, b = target, target, target
1533 } else {
1534 k := target / y
1535 r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1536 }
1537 enc := func(v float64) int {
1538 if v <= 0.0031308 {
1539 v *= 12.92
1540 } else {
1541 v = 1.055*math.Pow(v, 1/2.4) - 0.055
1542 }
1543 return int(math.Round(v * 255))
1544 }
1545 return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1546}
1547
1548func hexByte(s string) int64 {
1549 n, _ := strconv.ParseInt(s, 16, 32)
1550 return n
1551}
1552
1553// labelColors returns a complete label-name -> chip color map for a repo:
1554// the stored labels.color when it is a valid hex color, otherwise a
1555// stable default picked from the palette by name hash.
1556func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1557 stored, _ := s.st.LabelColors(repoID)
1558 out := make(map[string]template.CSS, len(stored))
1559 for name, color := range stored {
1560 if !hexColorPat.MatchString(color) {
1561 h := fnv.New32a()
1562 h.Write([]byte(name))
1563 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1564 }
1565 out[name] = template.CSS("--chip:" + clampChip(color))
1566 }
1567 return out
1568}
1569
1570// listPage is how many issues or merge requests a list page shows before
1571// it offers the older ones (#118). Keyset paging on the number, the same
1572// cursor the commands use, so every filter carries across pages.
1573const listPage = 50
1574
1575// olderLink is the current URL with before=<number> set.
1576func olderLink(r *http.Request, before int64) string {
1577 q := r.URL.Query()
1578 q.Set("before", strconv.FormatInt(before, 10))
1579 return "?" + q.Encode()
1580}
1581
1582func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1583 p, ok := s.repoFor(w, r, "")
1584 if !ok {
1585 return
1586 }
1587 p.Tab = "issues"
1588 state := r.URL.Query().Get("state")
1589 if state != "closed" && state != "all" {
1590 state = "open"
1591 }
1592 // The same filters the CLI's issue list takes, as query parameters;
1593 // label chips and author links point here.
1594 qv := r.URL.Query()
1595 f := store.IssueFilter{State: state, Label: qv.Get("label"), Assignee: qv.Get("assignee"),
1596 Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1597 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1598 f.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1599 issues, err := s.st.QueryIssues(p.Repo.ID, f)
1600 if err != nil {
1601 http.Error(w, "internal error", http.StatusInternalServerError)
1602 return
1603 }
1604 older := ""
1605 if len(issues) > listPage {
1606 issues = issues[:listPage]
1607 older = olderLink(r, issues[len(issues)-1].Number)
1608 }
1609 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1610 for i := range issues {
1611 issues[i].Labels = labels[issues[i].ID]
1612 }
1613 }
1614 s.render(w, "issues.html", struct {
1615 repoPage
1616 State string
1617 Label string
1618 Query string
1619 Filters []listFilter
1620 Issues []store.Issue
1621 LabelColors map[string]template.CSS
1622 Older string
1623 }{p, state, f.Label, f.Search,
1624 activeFilters(state, [][2]string{{"label", f.Label}, {"assignee", f.Assignee}, {"author", f.Author}, {"milestone", f.Milestone}}),
1625 issues, s.labelColors(p.Repo.ID), older})
1626}
1627
1628func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1629 p, ok := s.repoFor(w, r, "")
1630 if !ok {
1631 return
1632 }
1633 p.Tab = "issues"
1634 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1635 if err != nil {
1636 s.notFound(w, r)
1637 return
1638 }
1639 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1640 if err != nil {
1641 s.notFound(w, r)
1642 return
1643 }
1644 comments, err := s.st.ListIssueComments(iss.ID)
1645 if err != nil {
1646 http.Error(w, "internal error", http.StatusInternalServerError)
1647 return
1648 }
1649 md := s.ugcFor(r, p.Repo)
1650 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1651 s.render(w, "issue.html", struct {
1652 repoPage
1653 Issue store.Issue
1654 BodyHTML template.HTML
1655 Comments []renderedComment
1656 CanEdit bool
1657 CanWrite bool
1658 Milestones []store.Milestone
1659 Notice string
1660 LabelColors map[string]template.CSS
1661 }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md),
1662 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1663 milestones, s.takeFlash(w, r), s.labelColors(p.Repo.ID)})
1664}
1665
1666// canEditItem: the author or anyone with write access may edit.
1667// canWriteRepo reports whether the browser session may push to the repo,
1668// which is what gates the review and merge controls.
1669func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1670 if s.cfg.Web.Mode != "accounts" {
1671 return false
1672 }
1673 u := s.viewer(r)
1674 if u.ID == 0 {
1675 return false
1676 }
1677 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1678 return policy.CanWrite(u, repo, grant)
1679}
1680
1681func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1682 if s.cfg.Web.Mode != "accounts" {
1683 return false
1684 }
1685 u := s.viewer(r)
1686 if u.ID == 0 {
1687 return false
1688 }
1689 if u.Username == author {
1690 return true
1691 }
1692 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1693 return policy.CanWrite(u, repo, grant)
1694}
1695
1696func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1697 p, ok := s.repoFor(w, r, "")
1698 if !ok {
1699 return
1700 }
1701 p.Tab = "merge requests"
1702 state := r.URL.Query().Get("state")
1703 if state == "" {
1704 state = "open"
1705 }
1706 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1707 if !valid[state] {
1708 state = "open"
1709 }
1710 qv := r.URL.Query()
1711 mf := store.MRFilter{State: state, Author: qv.Get("author"), Milestone: qv.Get("milestone"),
1712 Search: strings.TrimSpace(qv.Get("q")), Limit: listPage + 1}
1713 mf.Before, _ = strconv.ParseInt(qv.Get("before"), 10, 64)
1714 mrs, err := s.st.QueryMRs(p.Repo.ID, mf)
1715 if err != nil {
1716 http.Error(w, "internal error", http.StatusInternalServerError)
1717 return
1718 }
1719 older := ""
1720 if len(mrs) > listPage {
1721 mrs = mrs[:listPage]
1722 older = olderLink(r, mrs[len(mrs)-1].Number)
1723 }
1724 s.render(w, "mrs.html", struct {
1725 repoPage
1726 State string
1727 Query string
1728 Filters []listFilter
1729 MRs []store.MR
1730 Older string
1731 }{p, state, mf.Search,
1732 activeFilters(state, [][2]string{{"author", mf.Author}, {"milestone", mf.Milestone}}), mrs, older})
1733}
1734
1735func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1736 p, ok := s.repoFor(w, r, "")
1737 if !ok {
1738 return
1739 }
1740 p.Tab = "merge requests"
1741 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1742 if err != nil {
1743 s.notFound(w, r)
1744 return
1745 }
1746 m, err := s.st.MRByNumber(p.Repo.ID, n)
1747 if err != nil {
1748 s.notFound(w, r)
1749 return
1750 }
1751 comments, _ := s.st.ListMRComments(m.ID)
1752 reviews, _ := s.st.ListMRReviews(m.ID)
1753 // The same rule the merge gates apply, so the page cannot show an
1754 // approval the gate ignores (#147).
1755 reviewCounts := control.ReviewersWhoCount(s.st, p.Repo, reviews)
1756 reviewRows := make([]reviewRow, 0, len(reviews))
1757 for _, r := range reviews {
1758 reviewRows = append(reviewRows, reviewRow{MRReview: r, Counts: reviewCounts[r.Reviewer]})
1759 }
1760 checks, combined, _ := s.st.ChecksForCommit(p.Repo.ID, m.HeadSHA)
1761 // The viewer sees their own unsubmitted review comments and nobody
1762 // else's.
1763 diffComments, _ := s.st.ListDiffComments(m.ID, s.webViewer(r).ID)
1764
1765 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1766 var files []diffFile
1767 base := m.MergedBase
1768 if base == "" {
1769 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1770 base = b
1771 }
1772 }
1773 var diffTruncated bool
1774 if base != "" {
1775 if patch, truncated, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1776 files, diffTruncated = parseDiff(patch), truncated
1777 }
1778 }
1779 md := s.ugcFor(r, p.Repo)
1780 canWrite := s.canWriteRepo(r, p.Repo)
1781 var detachedThreads []diffThread
1782 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md,
1783 reviewRights{Viewer: p.Viewer, MRAuthor: m.Author, Write: canWrite})
1784 if p.Viewer != "" {
1785 markCompose(files, r.URL.Query())
1786 }
1787 stat := statOf(files)
1788 // The commits this MR carries: base..head, the same range as the diff.
1789 type commitRow struct {
1790 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1791 Sig sigView
1792 }
1793 mrNames := s.authorNames()
1794 var commits []commitRow
1795 commitsTotal := 0
1796 if base != "" {
1797 const maxMRCommits = 100
1798 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1799 commitsTotal = len(shas)
1800 if len(shas) > maxMRCommits {
1801 shas = shas[:maxMRCommits]
1802 }
1803 for _, sha := range shas {
1804 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1805 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1806 if parsed != nil {
1807 cr.Subject = parsed.Subject
1808 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1809 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1810 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1811 }
1812 commits = append(commits, cr)
1813 }
1814 }
1815 // The diff is the reason most people open a merge request, so it gets
1816 // its own view rather than a fold at the foot of the conversation.
1817 // A query parameter keeps this working without JavaScript.
1818 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1819 // The revisions this merge request has had. A stale review is the
1820 // moment someone wants to know what moved, so the link to the
1821 // range-diff belongs next to it.
1822 revisions, _ := s.st.MRHeads(m.ID)
1823 branches, _ := gitutil.Refs(p.Dir, "heads")
1824 view := r.URL.Query().Get("view")
1825 if view != "commits" && view != "diff" {
1826 view = "conversation"
1827 }
1828 // The stack around an open merge request, for the header.
1829 var stackedOn *store.MR
1830 var stacked []store.MR
1831 if m.State == "open" {
1832 if parent, ok, err := s.st.OpenMRBySource(p.Repo.ID, m.TargetRef); err == nil && ok && parent.ID != m.ID {
1833 stackedOn = &parent
1834 }
1835 if m.SourceRepoID == p.Repo.ID {
1836 stacked, _ = s.st.OpenMRsByTarget(p.Repo.ID, m.SourceRef)
1837 }
1838 }
1839 s.render(w, "mr.html", struct {
1840 repoPage
1841 MR store.MR
1842 View string
1843 BodyHTML template.HTML
1844 Checks []store.Check
1845 Combined string
1846 Comments []renderedComment
1847 Reviews []reviewRow
1848 DiffFiles []diffFile
1849 DiffTruncated bool
1850 Stat diffStat
1851 Commits []commitRow
1852 CommitsTotal int
1853 Branches []gitutil.Ref
1854 CanEdit bool
1855 CanWrite bool
1856 Unresolved int
1857 Revisions []store.MRHead
1858 Notice string
1859 DetachedThreads []diffThread
1860 StackedOn *store.MR
1861 Stacked []store.MR
1862 }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md),
1863 reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author),
1864 canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked})
1865}
1866
1867func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1868 p, ok := s.repoFor(w, r, "")
1869 if !ok {
1870 return
1871 }
1872 p.Tab = "refs"
1873 branches, _ := gitutil.Refs(p.Dir, "heads")
1874 tags, _ := gitutil.Refs(p.Dir, "tags")
1875 s.render(w, "refs.html", struct {
1876 repoPage
1877 Branches, Tags []gitutil.Ref
1878 }{p, branches, tags})
1879}
1880
1881func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1882 p, ok := s.repoFor(w, r, "")
1883 if !ok {
1884 return
1885 }
1886 file := r.PathValue("file")
1887 ref, ok := strings.CutSuffix(file, ".tar.gz")
1888 if !ok {
1889 s.notFound(w, r)
1890 return
1891 }
1892 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1893 s.notFound(w, r)
1894 return
1895 }
1896 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1897 w.Header().Set("Content-Type", "application/gzip")
1898 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1899 gitutil.Archive(p.Dir, ref, prefix, w)
1900}
1901
1902func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1903 return policy.CanAdmin(u, repo, grant)
1904}
1905
1906func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1907 return policy.CanRead(u, repo, grant)
1908}
1909
1910// reviewRow is a review with whether the merge gates count it, which
1911// depends on the reviewer's access and so is not a property of the
1912// review row itself.
1913type reviewRow struct {
1914 store.MRReview
1915 Counts bool
1916}