internal/httpd/web.go
1601 lines · 46341 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 diffLine struct {
1064 Class string
1065 Text string
1066 Path string // file this line belongs to
1067 NewLine int64 // line number in the new file (0 when absent)
1068 OldLine int64 // line number in the old file (0 when absent)
1069 Threads []diffThread
1070}
1071
1072var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
1073
1074// classifyDiff parses a unified diff into rendered lines, tracking the
1075// file and old/new line numbers so review threads can anchor inline.
1076func classifyDiff(patch string) []diffLine {
1077 var lines []diffLine
1078 path := ""
1079 var oldN, newN int64
1080 for _, l := range strings.Split(patch, "\n") {
1081 d := diffLine{Text: l}
1082 switch {
1083 case strings.HasPrefix(l, "+++ "):
1084 d.Class = "meta"
1085 path = strings.TrimPrefix(strings.TrimPrefix(l, "+++ "), "b/")
1086 case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
1087 d.Class = "meta"
1088 case strings.HasPrefix(l, "@@"):
1089 d.Class = "hunk"
1090 if m := hunkPat.FindStringSubmatch(l); m != nil {
1091 oldN, _ = strconv.ParseInt(m[1], 10, 64)
1092 newN, _ = strconv.ParseInt(m[2], 10, 64)
1093 }
1094 case strings.HasPrefix(l, "+"):
1095 d.Class, d.Path, d.NewLine = "add", path, newN
1096 newN++
1097 case strings.HasPrefix(l, "-"):
1098 d.Class, d.Path, d.OldLine = "del", path, oldN
1099 oldN++
1100 default:
1101 d.Path, d.OldLine, d.NewLine = path, oldN, newN
1102 oldN++
1103 newN++
1104 }
1105 lines = append(lines, d)
1106 }
1107 return lines
1108}
1109
1110type diffThread struct {
1111 ID int64
1112 Resolved string
1113 Stale bool
1114 Comments []renderedComment
1115}
1116
1117// attachThreads injects review threads under their anchored diff lines;
1118// threads whose anchor no longer appears (stale after force-push, or on a
1119// context line outside the current diff) are returned separately.
1120func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) {
1121 type anchor struct {
1122 path string
1123 side string
1124 line int64
1125 }
1126 threads := map[int64]*diffThread{}
1127 anchors := map[int64]anchor{}
1128 var order []int64
1129 for _, cm := range comments {
1130 if cm.ReplyTo == 0 {
1131 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1132 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
1133 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1134 order = append(order, cm.ID)
1135 } else if th, ok := threads[cm.ReplyTo]; ok {
1136 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
1137 }
1138 }
1139 placed := map[int64]bool{}
1140 for i := range lines {
1141 for _, id := range order {
1142 if placed[id] || threads[id].Stale {
1143 continue
1144 }
1145 a := anchors[id]
1146 if lines[i].Path != a.path {
1147 continue
1148 }
1149 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1150 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1151 lines[i].Threads = append(lines[i].Threads, *threads[id])
1152 placed[id] = true
1153 }
1154 }
1155 }
1156 var unplaced []diffThread
1157 for _, id := range order {
1158 if !placed[id] {
1159 unplaced = append(unplaced, *threads[id])
1160 }
1161 }
1162 return lines, unplaced
1163}
1164
1165type sigView struct {
1166 State string
1167 Signer string
1168 Fingerprint string
1169}
1170
1171func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1172 raw, err := gitutil.ReadCommit(dir, sha)
1173 if err != nil {
1174 return sigView{State: "unsigned"}, nil
1175 }
1176 parsed, err := sig.ParseCommit(raw)
1177 if err != nil {
1178 return sigView{State: "unsigned"}, nil
1179 }
1180 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1181 if err != nil {
1182 return sigView{State: "unsigned"}, parsed
1183 }
1184 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1185 if res.SignerUserID != 0 {
1186 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1187 v.Signer = u.Username
1188 }
1189 }
1190 return v, parsed
1191}
1192
1193func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1194 ref := r.PathValue("ref")
1195 p, ok := s.repoFor(w, r, ref)
1196 if !ok {
1197 return
1198 }
1199 p.Tab = "log"
1200 const pageSize = 50
1201 // ?path= filters to commits touching one file or directory.
1202 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1203 if filePath == "." {
1204 filePath = ""
1205 }
1206 var shas []string
1207 var err error
1208 if filePath != "" {
1209 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1210 } else {
1211 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1212 }
1213 if err != nil {
1214 s.notFound(w, r)
1215 return
1216 }
1217 next := ""
1218 if len(shas) > pageSize {
1219 next = shas[pageSize]
1220 shas = shas[:pageSize]
1221 }
1222 type row struct {
1223 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1224 Sig sigView
1225 }
1226 names := s.authorNames()
1227 var rows []row
1228 for _, sha := range shas {
1229 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1230 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
1231 if parsed != nil {
1232 rw.Subject = parsed.Subject
1233 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1234 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1235 rw.AuthorEmail = parsed.AuthorEmail
1236 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1237 }
1238 rows = append(rows, rw)
1239 }
1240 s.render(w, "log.html", struct {
1241 repoPage
1242 Commits []row
1243 NextSHA string
1244 FilePath string
1245 }{p, rows, next, filePath})
1246}
1247
1248func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1249 p, ok := s.repoFor(w, r, "")
1250 if !ok {
1251 return
1252 }
1253 p.Tab = "log"
1254 sha := r.PathValue("sha")
1255 full, err := gitutil.ResolveRef(p.Dir, sha)
1256 if err != nil {
1257 s.notFound(w, r)
1258 return
1259 }
1260 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1261 if parsed == nil {
1262 s.notFound(w, r)
1263 return
1264 }
1265 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1266 lines := classifyDiff(patch)
1267 committerEmail := ""
1268 if parsed.CommitterEmail != parsed.AuthorEmail {
1269 committerEmail = parsed.CommitterEmail
1270 }
1271 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1272 commitNames := s.authorNames()
1273 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1274 msg := ""
1275 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1276 msg = string(parsed.Payload[i+2:])
1277 }
1278 s.render(w, "commit.html", struct {
1279 repoPage
1280 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1281 Parents []string
1282 Sig sigView
1283 Checks []store.CommitStatus
1284 DiffLines []diffLine
1285 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1286 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1287 gitutil.Parents(p.Dir, full), v, checks, lines})
1288}
1289
1290// labelPalette provides default label chip colors: mid-tone hues that stay
1291// legible on light and dark backgrounds.
1292var labelPalette = []string{
1293 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1294 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1295}
1296
1297var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1298
1299// labelColors returns a complete label-name -> chip color map for a repo:
1300// the stored labels.color when it is a valid hex color, otherwise a
1301// stable default picked from the palette by name hash.
1302func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1303 stored, _ := s.st.LabelColors(repoID)
1304 out := make(map[string]template.CSS, len(stored))
1305 for name, color := range stored {
1306 if !hexColorPat.MatchString(color) {
1307 h := fnv.New32a()
1308 h.Write([]byte(name))
1309 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1310 }
1311 out[name] = template.CSS("--chip:" + color)
1312 }
1313 return out
1314}
1315
1316func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1317 p, ok := s.repoFor(w, r, "")
1318 if !ok {
1319 return
1320 }
1321 p.Tab = "issues"
1322 state := r.URL.Query().Get("state")
1323 if state != "closed" && state != "all" {
1324 state = "open"
1325 }
1326 issues, err := s.st.ListIssues(p.Repo.ID, state)
1327 if err != nil {
1328 http.Error(w, "internal error", http.StatusInternalServerError)
1329 return
1330 }
1331 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1332 for i := range issues {
1333 issues[i].Labels = labels[issues[i].ID]
1334 }
1335 }
1336 // ?label=x narrows to issues carrying that label (chips link here).
1337 labelFilter := r.URL.Query().Get("label")
1338 if labelFilter != "" {
1339 var kept []store.Issue
1340 for _, iss := range issues {
1341 for _, l := range iss.Labels {
1342 if l == labelFilter {
1343 kept = append(kept, iss)
1344 break
1345 }
1346 }
1347 }
1348 issues = kept
1349 }
1350 s.render(w, "issues.html", struct {
1351 repoPage
1352 State string
1353 Label string
1354 Issues []store.Issue
1355 LabelColors map[string]template.CSS
1356 }{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1357}
1358
1359func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1360 p, ok := s.repoFor(w, r, "")
1361 if !ok {
1362 return
1363 }
1364 p.Tab = "issues"
1365 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1366 if err != nil {
1367 s.notFound(w, r)
1368 return
1369 }
1370 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1371 if err != nil {
1372 s.notFound(w, r)
1373 return
1374 }
1375 comments, err := s.st.ListIssueComments(iss.ID)
1376 if err != nil {
1377 http.Error(w, "internal error", http.StatusInternalServerError)
1378 return
1379 }
1380 md := s.ugcFor(r, p.Repo)
1381 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1382 s.render(w, "issue.html", struct {
1383 repoPage
1384 Issue store.Issue
1385 BodyHTML template.HTML
1386 Comments []renderedComment
1387 CanEdit bool
1388 CanWrite bool
1389 Milestones []store.Milestone
1390 Notice string
1391 LabelColors map[string]template.CSS
1392 }{p, iss, md(iss.Body), renderComments(comments, md),
1393 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1394 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1395}
1396
1397// canEditItem: the author or anyone with write access may edit.
1398// canWriteRepo reports whether the browser session may push to the repo,
1399// which is what gates the review and merge controls.
1400func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1401 if s.cfg.Web.Mode != "accounts" {
1402 return false
1403 }
1404 u := s.viewer(r)
1405 if u.ID == 0 {
1406 return false
1407 }
1408 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1409 return policy.CanWrite(u, repo, grant)
1410}
1411
1412func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1413 if s.cfg.Web.Mode != "accounts" {
1414 return false
1415 }
1416 u := s.viewer(r)
1417 if u.ID == 0 {
1418 return false
1419 }
1420 if u.Username == author {
1421 return true
1422 }
1423 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1424 return policy.CanWrite(u, repo, grant)
1425}
1426
1427func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1428 p, ok := s.repoFor(w, r, "")
1429 if !ok {
1430 return
1431 }
1432 p.Tab = "merge requests"
1433 state := r.URL.Query().Get("state")
1434 if state == "" {
1435 state = "open"
1436 }
1437 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1438 if !valid[state] {
1439 state = "open"
1440 }
1441 mrs, err := s.st.ListMRs(p.Repo.ID, state)
1442 if err != nil {
1443 http.Error(w, "internal error", http.StatusInternalServerError)
1444 return
1445 }
1446 s.render(w, "mrs.html", struct {
1447 repoPage
1448 State string
1449 MRs []store.MR
1450 }{p, state, mrs})
1451}
1452
1453func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1454 p, ok := s.repoFor(w, r, "")
1455 if !ok {
1456 return
1457 }
1458 p.Tab = "merge requests"
1459 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1460 if err != nil {
1461 s.notFound(w, r)
1462 return
1463 }
1464 m, err := s.st.MRByNumber(p.Repo.ID, n)
1465 if err != nil {
1466 s.notFound(w, r)
1467 return
1468 }
1469 comments, _ := s.st.ListMRComments(m.ID)
1470 reviews, _ := s.st.ListMRReviews(m.ID)
1471 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1472 diffComments, _ := s.st.ListDiffComments(m.ID)
1473
1474 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1475 var lines []diffLine
1476 base := m.MergedBase
1477 if base == "" {
1478 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1479 base = b
1480 }
1481 }
1482 if base != "" {
1483 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1484 lines = classifyDiff(patch)
1485 }
1486 }
1487 md := s.ugcFor(r, p.Repo)
1488 var detachedThreads []diffThread
1489 lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
1490 type diffStat struct{ Files, Adds, Dels int }
1491 var stat diffStat
1492 seenFiles := map[string]bool{}
1493 for _, l := range lines {
1494 switch l.Class {
1495 case "add":
1496 stat.Adds++
1497 case "del":
1498 stat.Dels++
1499 }
1500 if l.Path != "" && !seenFiles[l.Path] {
1501 seenFiles[l.Path] = true
1502 stat.Files++
1503 }
1504 }
1505 // The commits this MR carries: base..head, the same range as the diff.
1506 type commitRow struct {
1507 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1508 Sig sigView
1509 }
1510 mrNames := s.authorNames()
1511 var commits []commitRow
1512 if base != "" {
1513 const maxMRCommits = 100
1514 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1515 if len(shas) > maxMRCommits {
1516 shas = shas[:maxMRCommits]
1517 }
1518 for _, sha := range shas {
1519 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1520 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1521 if parsed != nil {
1522 cr.Subject = parsed.Subject
1523 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1524 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1525 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1526 }
1527 commits = append(commits, cr)
1528 }
1529 }
1530 // The diff is the reason most people open a merge request, so it gets
1531 // its own view rather than a fold at the foot of the conversation.
1532 // A query parameter keeps this working without JavaScript.
1533 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1534 view := r.URL.Query().Get("view")
1535 if view != "commits" && view != "diff" {
1536 view = "conversation"
1537 }
1538 s.render(w, "mr.html", struct {
1539 repoPage
1540 MR store.MR
1541 View string
1542 BodyHTML template.HTML
1543 Checks []store.CommitStatus
1544 Combined string
1545 Comments []renderedComment
1546 Reviews []store.MRReview
1547 DiffLines []diffLine
1548 Stat diffStat
1549 Commits []commitRow
1550 CanEdit bool
1551 CanWrite bool
1552 Unresolved int
1553 Notice string
1554 DetachedThreads []diffThread
1555 }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1556 reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1557 s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
1558}
1559
1560func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1561 p, ok := s.repoFor(w, r, "")
1562 if !ok {
1563 return
1564 }
1565 p.Tab = "refs"
1566 branches, _ := gitutil.Refs(p.Dir, "heads")
1567 tags, _ := gitutil.Refs(p.Dir, "tags")
1568 s.render(w, "refs.html", struct {
1569 repoPage
1570 Branches, Tags []gitutil.Ref
1571 }{p, branches, tags})
1572}
1573
1574func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1575 p, ok := s.repoFor(w, r, "")
1576 if !ok {
1577 return
1578 }
1579 file := r.PathValue("file")
1580 ref, ok := strings.CutSuffix(file, ".tar.gz")
1581 if !ok {
1582 s.notFound(w, r)
1583 return
1584 }
1585 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1586 s.notFound(w, r)
1587 return
1588 }
1589 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1590 w.Header().Set("Content-Type", "application/gzip")
1591 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1592 gitutil.Archive(p.Dir, ref, prefix, w)
1593}
1594
1595func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1596 return policy.CanAdmin(u, repo, grant)
1597}
1598
1599func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1600 return policy.CanRead(u, repo, grant)
1601}