internal/httpd/web.go
1545 lines · 44756 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
428func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
429 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
430 // Empty repo: render the page with no entries rather than 404.
431 s.render(w, "tree.html", struct {
432 repoPage
433 Crumbs []crumb
434 Prefix string
435 DirPath string
436 RefKind string
437 Entries []gitutil.TreeEntry
438 Branches []gitutil.Ref
439 ReadmeName string
440 ReadmeHTML template.HTML
441 LastCommits map[string]namedCommit
442 Tip namedCommit
443 }{repoPage: p, RefKind: "tree"})
444 return
445 }
446 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
447 if err != nil {
448 s.notFound(w, r)
449 return
450 }
451 // Directories first. git's tree order interleaves them with files, but
452 // a listing is scanned by shape before name. Stable, so each group
453 // keeps the ordering git gave it.
454 sort.SliceStable(entries, func(i, j int) bool {
455 return entries[i].Type == "tree" && entries[j].Type != "tree"
456 })
457 prefix := ""
458 if dirPath != "" {
459 prefix = dirPath + "/"
460 }
461
462 var readmeHTML template.HTML
463 readmeName := pickReadme(entries)
464 if readmeName != "" {
465 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
466 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
467 }
468 }
469
470 branches, _ := gitutil.Refs(p.Dir, "heads")
471 names := make([]string, 0, len(entries))
472 for _, e := range entries {
473 names = append(names, e.Name)
474 }
475 s.render(w, "tree.html", struct {
476 repoPage
477 Crumbs []crumb
478 Prefix string
479 DirPath string
480 RefKind string
481 Entries []gitutil.TreeEntry
482 Branches []gitutil.Ref
483 ReadmeName string
484 ReadmeHTML template.HTML
485 LastCommits map[string]namedCommit
486 Tip namedCommit
487 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches,
488 readmeName, readmeHTML,
489 s.namedCommits(gitutil.LastCommits(p.Dir, p.Ref, dirPath, names)),
490 s.namedTip(gitutil.TipCommit(p.Dir, p.Ref))})
491}
492
493func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
494 p, ok := s.repoFor(w, r, r.PathValue("ref"))
495 if !ok {
496 return
497 }
498 p.Tab = "files"
499 filePath := strings.Trim(r.PathValue("path"), "/")
500 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
501 if err != nil {
502 s.notFound(w, r)
503 return
504 }
505 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
506 _, image := imageTypes[strings.ToLower(path.Ext(filePath))]
507
508 var codeHTML template.HTML
509 if !binary && !image {
510 codeHTML = highlight(filePath, data)
511 }
512 cs := crumbs(p, "blob", filePath)
513 base := ""
514 if len(cs) > 0 {
515 base = cs[len(cs)-1].Name
516 cs = cs[:len(cs)-1]
517 }
518 branches, _ := gitutil.Refs(p.Dir, "heads")
519 lines := 0
520 if !binary && !image && len(data) > 0 {
521 lines = bytes.Count(data, []byte("\n"))
522 if data[len(data)-1] != '\n' {
523 lines++
524 }
525 }
526 // The file listing leads with the last commit now, so the facts about
527 // the file itself are reported here instead.
528 entry, _ := gitutil.StatPath(p.Dir, p.Ref, filePath)
529 s.render(w, "blob.html", struct {
530 repoPage
531 Crumbs []crumb
532 Base string
533 Path string
534 DirPath string
535 RefKind string
536 Binary bool
537 Image bool
538 Size int
539 Lines int
540 Exec bool
541 Symlink bool
542 Branches []gitutil.Ref
543 CodeHTML template.HTML
544 }{p, cs, base, filePath, filePath, "blob", binary, image, len(data), lines,
545 entry.Mode == "100755", entry.Mode == "120000", branches, codeHTML})
546}
547
548// releases lists tag-anchored releases with notes and assets.
549func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
550 p, ok := s.repoFor(w, r, "")
551 if !ok {
552 return
553 }
554 p.Tab = "releases"
555 rels, err := s.st.ListReleases(p.Repo.ID)
556 if err != nil {
557 http.Error(w, "internal error", http.StatusInternalServerError)
558 return
559 }
560 md := s.ugcFor(r, p.Repo)
561 type relView struct {
562 store.Release
563 NotesHTML template.HTML
564 }
565 var views []relView
566 for _, rel := range rels {
567 views = append(views, relView{rel, md(rel.Notes)})
568 }
569 // Tags without a release yet are what a create form can offer.
570 released := map[string]bool{}
571 for _, rel := range rels {
572 released[rel.Tag] = true
573 }
574 var freeTags []string
575 if tags, err := gitutil.Refs(p.Dir, "tags"); err == nil {
576 for _, tg := range tags {
577 if !released[tg.Name] {
578 freeTags = append(freeTags, tg.Name)
579 }
580 }
581 }
582 s.render(w, "releases.html", struct {
583 repoPage
584 Releases []relView
585 FreeTags []string
586 CanWrite bool
587 Notice string
588 }{p, views, freeTags, s.canWriteRepo(r, p.Repo), r.URL.Query().Get("e")})
589}
590
591// releaseAsset streams one uploaded asset. Tags containing '/' are not
592// reachable here (single path segment); SSH download always works.
593func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
594 p, ok := s.repoFor(w, r, "")
595 if !ok {
596 return
597 }
598 rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
599 if err != nil {
600 s.notFound(w, r)
601 return
602 }
603 name := r.PathValue("name")
604 found := false
605 for _, a := range rel.Assets {
606 if a.Name == name {
607 found = true
608 }
609 }
610 if !found {
611 s.notFound(w, r)
612 return
613 }
614 f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
615 "gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
616 if err != nil {
617 s.notFound(w, r)
618 return
619 }
620 defer f.Close()
621 w.Header().Set("Content-Type", "application/octet-stream")
622 w.Header().Set("X-Content-Type-Options", "nosniff")
623 w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
624 if fi, err := f.Stat(); err == nil {
625 w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
626 }
627 io.Copy(w, f)
628}
629
630// milestones lists a repo's milestones with progress.
631func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
632 p, ok := s.repoFor(w, r, "")
633 if !ok {
634 return
635 }
636 p.Tab = "issues"
637 state := r.URL.Query().Get("state")
638 if state != "closed" && state != "all" {
639 state = "open"
640 }
641 ms, err := s.st.ListMilestones(p.Repo.ID, state)
642 if err != nil {
643 http.Error(w, "internal error", http.StatusInternalServerError)
644 return
645 }
646 type msView struct {
647 store.Milestone
648 Percent int
649 }
650 var views []msView
651 for _, m := range ms {
652 v := msView{Milestone: m}
653 if total := m.OpenItems + m.ClosedItems; total > 0 {
654 v.Percent = m.ClosedItems * 100 / total
655 }
656 views = append(views, v)
657 }
658 s.render(w, "milestones.html", struct {
659 repoPage
660 State string
661 Milestones []msView
662 }{p, state, views})
663}
664
665// search runs a bounded literal git grep over the repo's default branch.
666func (s *Server) search(w http.ResponseWriter, r *http.Request) {
667 p, ok := s.repoFor(w, r, "")
668 if !ok {
669 return
670 }
671 p.Tab = "search"
672 q := strings.TrimSpace(r.URL.Query().Get("q"))
673 type matchView struct {
674 Path string
675 Line int
676 TextHTML template.HTML
677 }
678 var matches []matchView
679 var queryErr string
680 if q != "" {
681 if len(q) < 2 || len(q) > 200 {
682 queryErr = "query must be 2 to 200 characters"
683 } else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
684 raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
685 if err != nil {
686 http.Error(w, "internal error", http.StatusInternalServerError)
687 return
688 }
689 for _, m := range raw {
690 matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
691 }
692 }
693 }
694 s.render(w, "search.html", struct {
695 repoPage
696 Query string
697 QueryErr string
698 Matches []matchView
699 Capped bool
700 }{p, q, queryErr, matches, len(matches) == 200})
701}
702
703// markMatch escapes a matched line and wraps case-insensitive occurrences
704// of the query in <mark>.
705func markMatch(text, q string) template.HTML {
706 lower, lq := strings.ToLower(text), strings.ToLower(q)
707 var b strings.Builder
708 pos := 0
709 for {
710 i := strings.Index(lower[pos:], lq)
711 if i < 0 {
712 break
713 }
714 i += pos
715 b.WriteString(template.HTMLEscapeString(text[pos:i]))
716 b.WriteString("<mark>")
717 b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
718 b.WriteString("</mark>")
719 pos = i + len(q)
720 }
721 b.WriteString(template.HTMLEscapeString(text[pos:]))
722 return template.HTML(b.String())
723}
724
725// blamePageSize caps how many lines one blame page renders; blame is a
726// per-line subprocess cost, so large files paginate.
727const blamePageSize = 1000
728
729func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
730 p, ok := s.repoFor(w, r, r.PathValue("ref"))
731 if !ok {
732 return
733 }
734 p.Tab = "files"
735 filePath := strings.Trim(r.PathValue("path"), "/")
736 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
737 if err != nil {
738 s.notFound(w, r)
739 return
740 }
741 total := bytes.Count(data, []byte("\n"))
742 if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
743 total++
744 }
745 binary := gitutil.IsBinary(data)
746
747 type hunkView struct {
748 gitutil.BlameHunk
749 ShortSHA string
750 Date string
751 Sig sigView
752 Numbered []numberedLine
753 }
754 var hunks []hunkView
755 page, pages := 1, (total+blamePageSize-1)/blamePageSize
756 if pages == 0 {
757 pages = 1
758 }
759 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
760 page = n
761 }
762 if !binary && total > 0 {
763 start := (page-1)*blamePageSize + 1
764 end := min(total, page*blamePageSize)
765 raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
766 if err != nil {
767 s.notFound(w, r)
768 return
769 }
770 sigs := map[string]sigView{}
771 for _, h := range raw {
772 v, ok := sigs[h.SHA]
773 if !ok {
774 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
775 sigs[h.SHA] = v
776 }
777 hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
778 Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
779 for i, l := range h.Lines {
780 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
781 }
782 hunks = append(hunks, hv)
783 }
784 }
785 cs := crumbs(p, "blame", filePath)
786 base := ""
787 if len(cs) > 0 {
788 base = cs[len(cs)-1].Name
789 cs = cs[:len(cs)-1]
790 }
791 s.render(w, "blame.html", struct {
792 repoPage
793 Crumbs []crumb
794 Base string
795 Path string
796 Binary bool
797 Hunks []hunkView
798 Page, Pages int
799 }{p, cs, base, filePath, binary, hunks, page, pages})
800}
801
802type numberedLine struct {
803 N int
804 Text string
805}
806
807// chromaFormatter emits class-based markup (no inline colors), so the
808// stylesheet can swap palettes with the color scheme.
809var chromaFormatter = html.New(html.WithClasses(true),
810 html.WithLineNumbers(true), html.LineNumbersInTable(false),
811 html.WithLinkableLineNumbers(true, "L"))
812
813func highlight(filePath string, data []byte) template.HTML {
814 lexer := lexers.Match(filePath)
815 if lexer == nil {
816 lexer = lexers.Fallback
817 }
818 iterator, err := lexer.Tokenise(nil, string(data))
819 if err != nil {
820 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
821 }
822 var buf bytes.Buffer
823 if err := chromaFormatter.Format(&buf, styles.Get("friendly"), iterator); err != nil {
824 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
825 }
826 return template.HTML(buf.String())
827}
828
829// chromaCSS is both syntax palettes: light by default, dark under the same
830// media query the rest of the stylesheet uses. The site's --code-bg stays
831// the background either way.
832var chromaCSS = func() []byte {
833 var buf bytes.Buffer
834 chromaFormatter.WriteCSS(&buf, styles.Get("friendly"))
835 buf.WriteString("\n@media (prefers-color-scheme: dark) {\n")
836 chromaFormatter.WriteCSS(&buf, styles.Get("github-dark"))
837 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
838 return buf.Bytes()
839}()
840
841func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
842 p, ok := s.repoFor(w, r, r.PathValue("ref"))
843 if !ok {
844 return
845 }
846 filePath := strings.Trim(r.PathValue("path"), "/")
847 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
848 if err != nil {
849 s.notFound(w, r)
850 return
851 }
852 // Serve inert: never let repo content execute in the forge's origin.
853 // Images get their real type so <img> works under nosniff; SVG script
854 // is dead on arrival because the instance CSP is script-src 'none'.
855 ct := "text/plain; charset=utf-8"
856 if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
857 ct = t
858 }
859 w.Header().Set("Content-Type", ct)
860 w.Header().Set("X-Content-Type-Options", "nosniff")
861 w.Write(data)
862}
863
864// imageTypes are the formats raw serves with a real content type and blob
865// pages preview inline.
866var imageTypes = map[string]string{
867 ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
868 ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
869 ".svg": "image/svg+xml", ".ico": "image/x-icon",
870}
871
872// readmeRank orders competing README files: richer renderers win.
873var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
874
875// pickReadme returns the best README-ish blob in a tree listing: any file
876// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
877// we can render richly.
878func pickReadme(entries []gitutil.TreeEntry) string {
879 best, bestRank := "", 1<<30
880 for _, e := range entries {
881 if e.Type != "blob" {
882 continue
883 }
884 lower := strings.ToLower(e.Name)
885 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
886 continue
887 }
888 rank, ok := readmeRank[path.Ext(lower)]
889 if !ok {
890 rank = 10 // plaintext fallback
891 }
892 if rank < bestRank {
893 best, bestRank = e.Name, rank
894 }
895 }
896 return best
897}
898
899// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
900// task lists) on top of CommonMark, with class-based fence highlighting
901// (the palette lives in the stylesheet, per scheme). Raw HTML is still
902// dropped.
903var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
904 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
905
906// fenceHighlight renders one code block with chroma classes, for org and
907// anything else outside goldmark. Unknown languages fall back to plain.
908func fenceHighlight(source, lang string) string {
909 lexer := lexers.Get(lang)
910 if lexer == nil {
911 lexer = lexers.Fallback
912 }
913 iterator, err := lexer.Tokenise(nil, source)
914 if err != nil {
915 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
916 }
917 var buf bytes.Buffer
918 f := html.New(html.WithClasses(true))
919 if err := f.Format(&buf, styles.Get("friendly"), iterator); err != nil {
920 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
921 }
922 return buf.String()
923}
924
925// mdHTML renders user-authored markdown (issue and MR bodies, comments).
926// goldmark's default renderer drops raw HTML, so this is safe as-is.
927func mdHTML(raw string) template.HTML {
928 if strings.TrimSpace(raw) == "" {
929 return ""
930 }
931 var buf bytes.Buffer
932 if markdown.Convert([]byte(raw), &buf) != nil {
933 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
934 }
935 return template.HTML(buf.String())
936}
937
938// webResolver answers autolink lookups for one viewer. Cross-repo
939// references to repositories the viewer cannot read stay plain text, per
940// the enumeration rule: a link would confirm the repo exists.
941type webResolver struct {
942 s *Server
943 viewer store.User
944}
945
946func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
947 repo, err := r.s.st.RepoByPath(owner + "/" + name)
948 if err != nil {
949 return ""
950 }
951 grant := ""
952 if r.viewer.ID != 0 {
953 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
954 }
955 if !policy.CanRead(r.viewer, repo, grant) {
956 return ""
957 }
958 if kind == '#' {
959 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
960 return ""
961 }
962 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
963 }
964 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
965 return ""
966 }
967 return autolink.MRURL(repo.OwnerName, repo.Name, n)
968}
969
970func (r webResolver) UserURL(name string) string {
971 if _, err := r.s.st.UserByUsername(name); err == nil {
972 return "/" + name
973 }
974 if _, err := r.s.st.OrgByName(name); err == nil {
975 return "/" + name
976 }
977 return ""
978}
979
980// ugcFor returns a renderer for user-authored markdown on one repo's pages:
981// mdHTML plus cross-reference and mention autolinking for this viewer.
982func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
983 viewer := store.User{}
984 if s.cfg.Web.Mode == "accounts" {
985 viewer = s.viewer(r)
986 }
987 res := webResolver{s, viewer}
988 return func(raw string) template.HTML {
989 h := mdHTML(raw)
990 if h == "" {
991 return h
992 }
993 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
994 }
995}
996
997// renderedComment pairs a comment with its rendered body for templates.
998type renderedComment struct {
999 Author string
1000 CreatedAt string
1001 Kind string
1002 BodyHTML template.HTML
1003}
1004
1005func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
1006 var out []renderedComment
1007 for _, c := range cs {
1008 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
1009 }
1010 return out
1011}
1012
1013// ugcPolicy sanitizes rendered repo content before it enters the forge's
1014// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1015// output and repo-authored HTML are not. Chroma's highlighting classes
1016// must survive; the pattern admits only short token codes, not the site's
1017// own class names.
1018var ugcPolicy = func() *bluemonday.Policy {
1019 p := bluemonday.UGCPolicy()
1020 p.AllowAttrs("class").
1021 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1022 OnElements("span", "pre", "code", "div")
1023 return p
1024}()
1025
1026// renderReadme renders a README by extension: markdown, org-mode, and
1027// (sanitized) HTML richly; everything else as escaped plaintext.
1028func renderReadme(name string, raw []byte) template.HTML {
1029 plain := func() template.HTML {
1030 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1031 }
1032 if gitutil.IsBinary(raw) {
1033 return ""
1034 }
1035 switch path.Ext(strings.ToLower(name)) {
1036 case ".md", ".markdown":
1037 var buf bytes.Buffer
1038 if markdown.Convert(raw, &buf) != nil {
1039 return plain()
1040 }
1041 return template.HTML(buf.String())
1042 case ".org":
1043 doc := org.New().Parse(bytes.NewReader(raw), name)
1044 writer := org.NewHTMLWriter()
1045 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1046 if inline {
1047 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1048 }
1049 return fenceHighlight(source, lang)
1050 }
1051 out, err := doc.Write(writer)
1052 if err != nil {
1053 return plain()
1054 }
1055 return template.HTML(ugcPolicy.Sanitize(out))
1056 case ".html", ".htm":
1057 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1058 default:
1059 return plain()
1060 }
1061}
1062
1063type diffThread struct {
1064 ID int64
1065 Resolved string
1066 Stale bool
1067 Comments []renderedComment
1068}
1069
1070// attachThreads injects review threads under their anchored diff lines;
1071// threads whose anchor no longer appears (stale after force-push, or on a
1072// context line outside the current diff) are returned separately.
1073func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffFile, []diffThread) {
1074 type anchor struct {
1075 path string
1076 side string
1077 line int64
1078 }
1079 threads := map[int64]*diffThread{}
1080 anchors := map[int64]anchor{}
1081 var order []int64
1082 for _, cm := range comments {
1083 if cm.ReplyTo == 0 {
1084 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1085 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
1086 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1087 order = append(order, cm.ID)
1088 } else if th, ok := threads[cm.ReplyTo]; ok {
1089 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
1090 }
1091 }
1092 placed := map[int64]bool{}
1093 for f := range files {
1094 lines := files[f].Lines
1095 for i := range lines {
1096 for _, id := range order {
1097 if placed[id] || threads[id].Stale {
1098 continue
1099 }
1100 a := anchors[id]
1101 if lines[i].Path != a.path {
1102 continue
1103 }
1104 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1105 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1106 lines[i].Threads = append(lines[i].Threads, *threads[id])
1107 files[f].Threads++
1108 files[f].Open = true
1109 placed[id] = true
1110 }
1111 }
1112 }
1113 }
1114 var unplaced []diffThread
1115 for _, id := range order {
1116 if !placed[id] {
1117 unplaced = append(unplaced, *threads[id])
1118 }
1119 }
1120 return files, unplaced
1121}
1122
1123type sigView struct {
1124 State string
1125 Signer string
1126 Fingerprint string
1127}
1128
1129func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1130 raw, err := gitutil.ReadCommit(dir, sha)
1131 if err != nil {
1132 return sigView{State: "unsigned"}, nil
1133 }
1134 parsed, err := sig.ParseCommit(raw)
1135 if err != nil {
1136 return sigView{State: "unsigned"}, nil
1137 }
1138 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1139 if err != nil {
1140 return sigView{State: "unsigned"}, parsed
1141 }
1142 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1143 if res.SignerUserID != 0 {
1144 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1145 v.Signer = u.Username
1146 }
1147 }
1148 return v, parsed
1149}
1150
1151func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1152 ref := r.PathValue("ref")
1153 p, ok := s.repoFor(w, r, ref)
1154 if !ok {
1155 return
1156 }
1157 p.Tab = "log"
1158 const pageSize = 50
1159 // ?path= filters to commits touching one file or directory.
1160 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1161 if filePath == "." {
1162 filePath = ""
1163 }
1164 var shas []string
1165 var err error
1166 if filePath != "" {
1167 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1168 } else {
1169 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1170 }
1171 if err != nil {
1172 s.notFound(w, r)
1173 return
1174 }
1175 next := ""
1176 if len(shas) > pageSize {
1177 next = shas[pageSize]
1178 shas = shas[:pageSize]
1179 }
1180 type row struct {
1181 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1182 Sig sigView
1183 }
1184 names := s.authorNames()
1185 var rows []row
1186 for _, sha := range shas {
1187 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1188 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
1189 if parsed != nil {
1190 rw.Subject = parsed.Subject
1191 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1192 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1193 rw.AuthorEmail = parsed.AuthorEmail
1194 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1195 }
1196 rows = append(rows, rw)
1197 }
1198 s.render(w, "log.html", struct {
1199 repoPage
1200 Commits []row
1201 NextSHA string
1202 FilePath string
1203 }{p, rows, next, filePath})
1204}
1205
1206func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1207 p, ok := s.repoFor(w, r, "")
1208 if !ok {
1209 return
1210 }
1211 p.Tab = "log"
1212 sha := r.PathValue("sha")
1213 full, err := gitutil.ResolveRef(p.Dir, sha)
1214 if err != nil {
1215 s.notFound(w, r)
1216 return
1217 }
1218 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1219 if parsed == nil {
1220 s.notFound(w, r)
1221 return
1222 }
1223 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1224 files := parseDiff(patch)
1225 committerEmail := ""
1226 if parsed.CommitterEmail != parsed.AuthorEmail {
1227 committerEmail = parsed.CommitterEmail
1228 }
1229 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1230 commitNames := s.authorNames()
1231 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1232 msg := ""
1233 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1234 msg = string(parsed.Payload[i+2:])
1235 }
1236 s.render(w, "commit.html", struct {
1237 repoPage
1238 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1239 Parents []string
1240 Sig sigView
1241 Checks []store.CommitStatus
1242 DiffFiles []diffFile
1243 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1244 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1245 gitutil.Parents(p.Dir, full), v, checks, files})
1246}
1247
1248// labelPalette provides default label chip colors: mid-tone hues that stay
1249// legible on light and dark backgrounds.
1250var labelPalette = []string{
1251 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1252 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1253}
1254
1255var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1256
1257// labelColors returns a complete label-name -> chip color map for a repo:
1258// the stored labels.color when it is a valid hex color, otherwise a
1259// stable default picked from the palette by name hash.
1260func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1261 stored, _ := s.st.LabelColors(repoID)
1262 out := make(map[string]template.CSS, len(stored))
1263 for name, color := range stored {
1264 if !hexColorPat.MatchString(color) {
1265 h := fnv.New32a()
1266 h.Write([]byte(name))
1267 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1268 }
1269 out[name] = template.CSS("--chip:" + color)
1270 }
1271 return out
1272}
1273
1274func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1275 p, ok := s.repoFor(w, r, "")
1276 if !ok {
1277 return
1278 }
1279 p.Tab = "issues"
1280 state := r.URL.Query().Get("state")
1281 if state != "closed" && state != "all" {
1282 state = "open"
1283 }
1284 issues, err := s.st.ListIssues(p.Repo.ID, state)
1285 if err != nil {
1286 http.Error(w, "internal error", http.StatusInternalServerError)
1287 return
1288 }
1289 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1290 for i := range issues {
1291 issues[i].Labels = labels[issues[i].ID]
1292 }
1293 }
1294 // ?label=x narrows to issues carrying that label (chips link here).
1295 labelFilter := r.URL.Query().Get("label")
1296 if labelFilter != "" {
1297 var kept []store.Issue
1298 for _, iss := range issues {
1299 for _, l := range iss.Labels {
1300 if l == labelFilter {
1301 kept = append(kept, iss)
1302 break
1303 }
1304 }
1305 }
1306 issues = kept
1307 }
1308 s.render(w, "issues.html", struct {
1309 repoPage
1310 State string
1311 Label string
1312 Issues []store.Issue
1313 LabelColors map[string]template.CSS
1314 }{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1315}
1316
1317func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1318 p, ok := s.repoFor(w, r, "")
1319 if !ok {
1320 return
1321 }
1322 p.Tab = "issues"
1323 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1324 if err != nil {
1325 s.notFound(w, r)
1326 return
1327 }
1328 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1329 if err != nil {
1330 s.notFound(w, r)
1331 return
1332 }
1333 comments, err := s.st.ListIssueComments(iss.ID)
1334 if err != nil {
1335 http.Error(w, "internal error", http.StatusInternalServerError)
1336 return
1337 }
1338 md := s.ugcFor(r, p.Repo)
1339 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1340 s.render(w, "issue.html", struct {
1341 repoPage
1342 Issue store.Issue
1343 BodyHTML template.HTML
1344 Comments []renderedComment
1345 CanEdit bool
1346 CanWrite bool
1347 Milestones []store.Milestone
1348 Notice string
1349 LabelColors map[string]template.CSS
1350 }{p, iss, md(iss.Body), renderComments(comments, md),
1351 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1352 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1353}
1354
1355// canEditItem: the author or anyone with write access may edit.
1356// canWriteRepo reports whether the browser session may push to the repo,
1357// which is what gates the review and merge controls.
1358func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1359 if s.cfg.Web.Mode != "accounts" {
1360 return false
1361 }
1362 u := s.viewer(r)
1363 if u.ID == 0 {
1364 return false
1365 }
1366 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1367 return policy.CanWrite(u, repo, grant)
1368}
1369
1370func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1371 if s.cfg.Web.Mode != "accounts" {
1372 return false
1373 }
1374 u := s.viewer(r)
1375 if u.ID == 0 {
1376 return false
1377 }
1378 if u.Username == author {
1379 return true
1380 }
1381 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1382 return policy.CanWrite(u, repo, grant)
1383}
1384
1385func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1386 p, ok := s.repoFor(w, r, "")
1387 if !ok {
1388 return
1389 }
1390 p.Tab = "merge requests"
1391 state := r.URL.Query().Get("state")
1392 if state == "" {
1393 state = "open"
1394 }
1395 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1396 if !valid[state] {
1397 state = "open"
1398 }
1399 mrs, err := s.st.ListMRs(p.Repo.ID, state)
1400 if err != nil {
1401 http.Error(w, "internal error", http.StatusInternalServerError)
1402 return
1403 }
1404 s.render(w, "mrs.html", struct {
1405 repoPage
1406 State string
1407 MRs []store.MR
1408 }{p, state, mrs})
1409}
1410
1411func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1412 p, ok := s.repoFor(w, r, "")
1413 if !ok {
1414 return
1415 }
1416 p.Tab = "merge requests"
1417 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1418 if err != nil {
1419 s.notFound(w, r)
1420 return
1421 }
1422 m, err := s.st.MRByNumber(p.Repo.ID, n)
1423 if err != nil {
1424 s.notFound(w, r)
1425 return
1426 }
1427 comments, _ := s.st.ListMRComments(m.ID)
1428 reviews, _ := s.st.ListMRReviews(m.ID)
1429 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1430 diffComments, _ := s.st.ListDiffComments(m.ID)
1431
1432 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1433 var files []diffFile
1434 base := m.MergedBase
1435 if base == "" {
1436 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1437 base = b
1438 }
1439 }
1440 if base != "" {
1441 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1442 files = parseDiff(patch)
1443 }
1444 }
1445 md := s.ugcFor(r, p.Repo)
1446 var detachedThreads []diffThread
1447 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md)
1448 stat := statOf(files)
1449 // The commits this MR carries: base..head, the same range as the diff.
1450 type commitRow struct {
1451 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1452 Sig sigView
1453 }
1454 mrNames := s.authorNames()
1455 var commits []commitRow
1456 if base != "" {
1457 const maxMRCommits = 100
1458 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1459 if len(shas) > maxMRCommits {
1460 shas = shas[:maxMRCommits]
1461 }
1462 for _, sha := range shas {
1463 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1464 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1465 if parsed != nil {
1466 cr.Subject = parsed.Subject
1467 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1468 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1469 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1470 }
1471 commits = append(commits, cr)
1472 }
1473 }
1474 // The diff is the reason most people open a merge request, so it gets
1475 // its own view rather than a fold at the foot of the conversation.
1476 // A query parameter keeps this working without JavaScript.
1477 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1478 view := r.URL.Query().Get("view")
1479 if view != "commits" && view != "diff" {
1480 view = "conversation"
1481 }
1482 s.render(w, "mr.html", struct {
1483 repoPage
1484 MR store.MR
1485 View string
1486 BodyHTML template.HTML
1487 Checks []store.CommitStatus
1488 Combined string
1489 Comments []renderedComment
1490 Reviews []store.MRReview
1491 DiffFiles []diffFile
1492 Stat diffStat
1493 Commits []commitRow
1494 CanEdit bool
1495 CanWrite bool
1496 Unresolved int
1497 Notice string
1498 DetachedThreads []diffThread
1499 }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1500 reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1501 s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
1502}
1503
1504func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1505 p, ok := s.repoFor(w, r, "")
1506 if !ok {
1507 return
1508 }
1509 p.Tab = "refs"
1510 branches, _ := gitutil.Refs(p.Dir, "heads")
1511 tags, _ := gitutil.Refs(p.Dir, "tags")
1512 s.render(w, "refs.html", struct {
1513 repoPage
1514 Branches, Tags []gitutil.Ref
1515 }{p, branches, tags})
1516}
1517
1518func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1519 p, ok := s.repoFor(w, r, "")
1520 if !ok {
1521 return
1522 }
1523 file := r.PathValue("file")
1524 ref, ok := strings.CutSuffix(file, ".tar.gz")
1525 if !ok {
1526 s.notFound(w, r)
1527 return
1528 }
1529 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1530 s.notFound(w, r)
1531 return
1532 }
1533 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1534 w.Header().Set("Content-Type", "application/gzip")
1535 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1536 gitutil.Archive(p.Dir, ref, prefix, w)
1537}
1538
1539func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1540 return policy.CanAdmin(u, repo, grant)
1541}
1542
1543func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1544 return policy.CanRead(u, repo, grant)
1545}