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