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