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