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