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