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