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