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