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