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