internal/httpd/web.go
1569 lines · 46221 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 // github-dark's line numbers are #6e7681, 4.31:1 on the page; lifted to
857 // the same grey its comments use, which clears the floor.
858 buf.WriteString(".chroma .lnt, .chroma .ln { color: #8b949e }\n")
859 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
860 return buf.Bytes()
861}()
862
863func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
864 p, ok := s.repoFor(w, r, r.PathValue("ref"))
865 if !ok {
866 return
867 }
868 filePath := strings.Trim(r.PathValue("path"), "/")
869 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
870 if err != nil {
871 s.notFound(w, r)
872 return
873 }
874 // Serve inert: never let repo content execute in the forge's origin.
875 // Images get their real type so <img> works under nosniff; SVG script
876 // is dead on arrival because the instance CSP is script-src 'none'.
877 ct := "text/plain; charset=utf-8"
878 if t, ok := imageTypes[strings.ToLower(path.Ext(filePath))]; ok {
879 ct = t
880 }
881 w.Header().Set("Content-Type", ct)
882 w.Header().Set("X-Content-Type-Options", "nosniff")
883 w.Write(data)
884}
885
886// imageTypes are the formats raw serves with a real content type and blob
887// pages preview inline.
888var imageTypes = map[string]string{
889 ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
890 ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif",
891 ".svg": "image/svg+xml", ".ico": "image/x-icon",
892}
893
894// readmeRank orders competing README files: richer renderers win.
895var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
896
897// pickReadme returns the best README-ish blob in a tree listing: any file
898// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
899// we can render richly.
900func pickReadme(entries []gitutil.TreeEntry) string {
901 best, bestRank := "", 1<<30
902 for _, e := range entries {
903 if e.Type != "blob" {
904 continue
905 }
906 lower := strings.ToLower(e.Name)
907 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
908 continue
909 }
910 rank, ok := readmeRank[path.Ext(lower)]
911 if !ok {
912 rank = 10 // plaintext fallback
913 }
914 if rank < bestRank {
915 best, bestRank = e.Name, rank
916 }
917 }
918 return best
919}
920
921// markdown is the shared renderer: GFM (tables, strikethrough, autolinks,
922// task lists) on top of CommonMark, with class-based fence highlighting
923// (the palette lives in the stylesheet, per scheme). Raw HTML is still
924// dropped.
925var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM,
926 highlighting.NewHighlighting(highlighting.WithFormatOptions(html.WithClasses(true)))))
927
928// fenceHighlight renders one code block with chroma classes, for org and
929// anything else outside goldmark. Unknown languages fall back to plain.
930func fenceHighlight(source, lang string) string {
931 lexer := lexers.Get(lang)
932 if lexer == nil {
933 lexer = lexers.Fallback
934 }
935 iterator, err := lexer.Tokenise(nil, source)
936 if err != nil {
937 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
938 }
939 var buf bytes.Buffer
940 f := html.New(html.WithClasses(true))
941 if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
942 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
943 }
944 return buf.String()
945}
946
947// mdHTML renders user-authored markdown (issue and MR bodies, comments).
948// goldmark's default renderer drops raw HTML, so this is safe as-is.
949func mdHTML(raw string) template.HTML {
950 if strings.TrimSpace(raw) == "" {
951 return ""
952 }
953 var buf bytes.Buffer
954 if markdown.Convert([]byte(raw), &buf) != nil {
955 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
956 }
957 return template.HTML(buf.String())
958}
959
960// webResolver answers autolink lookups for one viewer. Cross-repo
961// references to repositories the viewer cannot read stay plain text, per
962// the enumeration rule: a link would confirm the repo exists.
963type webResolver struct {
964 s *Server
965 viewer store.User
966}
967
968func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
969 repo, err := r.s.st.RepoByPath(owner + "/" + name)
970 if err != nil {
971 return ""
972 }
973 grant := ""
974 if r.viewer.ID != 0 {
975 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
976 }
977 if !policy.CanRead(r.viewer, repo, grant) {
978 return ""
979 }
980 if kind == '#' {
981 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
982 return ""
983 }
984 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
985 }
986 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
987 return ""
988 }
989 return autolink.MRURL(repo.OwnerName, repo.Name, n)
990}
991
992func (r webResolver) UserURL(name string) string {
993 if _, err := r.s.st.UserByUsername(name); err == nil {
994 return "/" + name
995 }
996 if _, err := r.s.st.OrgByName(name); err == nil {
997 return "/" + name
998 }
999 return ""
1000}
1001
1002// ugcFor returns a renderer for user-authored markdown on one repo's pages:
1003// mdHTML plus cross-reference and mention autolinking for this viewer.
1004func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
1005 viewer := store.User{}
1006 if s.cfg.Web.Mode == "accounts" {
1007 viewer = s.viewer(r)
1008 }
1009 res := webResolver{s, viewer}
1010 return func(raw string) template.HTML {
1011 h := mdHTML(raw)
1012 if h == "" {
1013 return h
1014 }
1015 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
1016 }
1017}
1018
1019// renderedComment pairs a comment with its rendered body for templates.
1020type renderedComment struct {
1021 Author string
1022 CreatedAt string
1023 Kind string
1024 BodyHTML template.HTML
1025}
1026
1027func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
1028 var out []renderedComment
1029 for _, c := range cs {
1030 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
1031 }
1032 return out
1033}
1034
1035// ugcPolicy sanitizes rendered repo content before it enters the forge's
1036// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
1037// output and repo-authored HTML are not. Chroma's highlighting classes
1038// must survive; the pattern admits only short token codes, not the site's
1039// own class names.
1040var ugcPolicy = func() *bluemonday.Policy {
1041 p := bluemonday.UGCPolicy()
1042 p.AllowAttrs("class").
1043 Matching(regexp.MustCompile(`^(chroma|[a-z0-9]{1,3})( (chroma|[a-z0-9]{1,3}))*$`)).
1044 OnElements("span", "pre", "code", "div")
1045 return p
1046}()
1047
1048// renderReadme renders a README by extension: markdown, org-mode, and
1049// (sanitized) HTML richly; everything else as escaped plaintext.
1050func renderReadme(name string, raw []byte) template.HTML {
1051 plain := func() template.HTML {
1052 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
1053 }
1054 if gitutil.IsBinary(raw) {
1055 return ""
1056 }
1057 switch path.Ext(strings.ToLower(name)) {
1058 case ".md", ".markdown":
1059 var buf bytes.Buffer
1060 if markdown.Convert(raw, &buf) != nil {
1061 return plain()
1062 }
1063 return template.HTML(buf.String())
1064 case ".org":
1065 doc := org.New().Parse(bytes.NewReader(raw), name)
1066 writer := org.NewHTMLWriter()
1067 writer.HighlightCodeBlock = func(source, lang string, inline bool, params map[string]string) string {
1068 if inline {
1069 return "<code>" + template.HTMLEscapeString(source) + "</code>"
1070 }
1071 return fenceHighlight(source, lang)
1072 }
1073 out, err := doc.Write(writer)
1074 if err != nil {
1075 return plain()
1076 }
1077 return template.HTML(ugcPolicy.Sanitize(out))
1078 case ".html", ".htm":
1079 return template.HTML(ugcPolicy.Sanitize(string(raw)))
1080 default:
1081 return plain()
1082 }
1083}
1084
1085type diffThread struct {
1086 ID int64
1087 Resolved string
1088 Stale bool
1089 Comments []renderedComment
1090}
1091
1092// attachThreads injects review threads under their anchored diff lines;
1093// threads whose anchor no longer appears (stale after force-push, or on a
1094// context line outside the current diff) are returned separately.
1095func attachThreads(files []diffFile, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffFile, []diffThread) {
1096 type anchor struct {
1097 path string
1098 side string
1099 line int64
1100 }
1101 threads := map[int64]*diffThread{}
1102 anchors := map[int64]anchor{}
1103 var order []int64
1104 for _, cm := range comments {
1105 if cm.ReplyTo == 0 {
1106 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
1107 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
1108 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
1109 order = append(order, cm.ID)
1110 } else if th, ok := threads[cm.ReplyTo]; ok {
1111 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
1112 }
1113 }
1114 placed := map[int64]bool{}
1115 for f := range files {
1116 lines := files[f].Lines
1117 for i := range lines {
1118 for _, id := range order {
1119 if placed[id] || threads[id].Stale {
1120 continue
1121 }
1122 a := anchors[id]
1123 if lines[i].Path != a.path {
1124 continue
1125 }
1126 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1127 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1128 lines[i].Threads = append(lines[i].Threads, *threads[id])
1129 files[f].Threads++
1130 files[f].Open = true
1131 placed[id] = true
1132 }
1133 }
1134 }
1135 }
1136 var unplaced []diffThread
1137 for _, id := range order {
1138 if !placed[id] {
1139 unplaced = append(unplaced, *threads[id])
1140 }
1141 }
1142 return files, unplaced
1143}
1144
1145type sigView struct {
1146 State string
1147 Signer string
1148 Fingerprint string
1149}
1150
1151func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1152 raw, err := gitutil.ReadCommit(dir, sha)
1153 if err != nil {
1154 return sigView{State: "unsigned"}, nil
1155 }
1156 parsed, err := sig.ParseCommit(raw)
1157 if err != nil {
1158 return sigView{State: "unsigned"}, nil
1159 }
1160 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1161 if err != nil {
1162 return sigView{State: "unsigned"}, parsed
1163 }
1164 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1165 if res.SignerUserID != 0 {
1166 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1167 v.Signer = u.Username
1168 }
1169 }
1170 return v, parsed
1171}
1172
1173func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1174 ref := r.PathValue("ref")
1175 p, ok := s.repoFor(w, r, ref)
1176 if !ok {
1177 return
1178 }
1179 p.Tab = "log"
1180 const pageSize = 50
1181 // ?path= filters to commits touching one file or directory.
1182 filePath := strings.Trim(path.Clean("/"+r.URL.Query().Get("path")), "/")
1183 if filePath == "." {
1184 filePath = ""
1185 }
1186 var shas []string
1187 var err error
1188 if filePath != "" {
1189 shas, err = gitutil.RevListPath(p.Dir, p.Ref, filePath, pageSize+1)
1190 } else {
1191 shas, err = gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1192 }
1193 if err != nil {
1194 s.notFound(w, r)
1195 return
1196 }
1197 next := ""
1198 if len(shas) > pageSize {
1199 next = shas[pageSize]
1200 shas = shas[:pageSize]
1201 }
1202 type row struct {
1203 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, AuthorUser, Date string
1204 Sig sigView
1205 Check string // combined status, "" when none ran
1206 }
1207 names := s.authorNames()
1208 checks, _ := s.st.CombinedStatusFor(p.Repo.ID, shas)
1209 var rows []row
1210 for _, sha := range shas {
1211 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1212 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v, Check: checks[sha]}
1213 if parsed != nil {
1214 rw.Subject = parsed.Subject
1215 rw.AuthorName = names.name(parsed.AuthorEmail, parsed.AuthorName)
1216 rw.AuthorUser, _ = names.account(parsed.AuthorEmail)
1217 rw.AuthorEmail = parsed.AuthorEmail
1218 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1219 }
1220 rows = append(rows, rw)
1221 }
1222 s.render(w, "log.html", struct {
1223 repoPage
1224 Commits []row
1225 NextSHA string
1226 FilePath string
1227 }{p, rows, next, filePath})
1228}
1229
1230func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1231 p, ok := s.repoFor(w, r, "")
1232 if !ok {
1233 return
1234 }
1235 p.Tab = "log"
1236 sha := r.PathValue("sha")
1237 full, err := gitutil.ResolveRef(p.Dir, sha)
1238 if err != nil {
1239 s.notFound(w, r)
1240 return
1241 }
1242 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1243 if parsed == nil {
1244 s.notFound(w, r)
1245 return
1246 }
1247 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1248 files := parseDiff(patch)
1249 committerEmail := ""
1250 if parsed.CommitterEmail != parsed.AuthorEmail {
1251 committerEmail = parsed.CommitterEmail
1252 }
1253 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1254 commitNames := s.authorNames()
1255 commitUser, _ := commitNames.account(parsed.AuthorEmail)
1256 msg := ""
1257 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1258 msg = string(parsed.Payload[i+2:])
1259 }
1260 s.render(w, "commit.html", struct {
1261 repoPage
1262 SHA, ShortSHA, AuthorName, AuthorEmail, AuthorUser, CommitterEmail, Date, Message string
1263 Parents []string
1264 Sig sigView
1265 Checks []store.CommitStatus
1266 DiffFiles []diffFile
1267 }{p, full, full[:10], commitNames.name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, commitUser, committerEmail,
1268 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1269 gitutil.Parents(p.Dir, full), v, checks, files})
1270}
1271
1272// labelPalette provides default label chip colors: mid-tone hues that stay
1273// legible on light and dark backgrounds.
1274var labelPalette = []string{
1275 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1276 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1277}
1278
1279var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1280
1281// labelColors returns a complete label-name -> chip color map for a repo:
1282// the stored labels.color when it is a valid hex color, otherwise a
1283// stable default picked from the palette by name hash.
1284func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1285 stored, _ := s.st.LabelColors(repoID)
1286 out := make(map[string]template.CSS, len(stored))
1287 for name, color := range stored {
1288 if !hexColorPat.MatchString(color) {
1289 h := fnv.New32a()
1290 h.Write([]byte(name))
1291 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1292 }
1293 out[name] = template.CSS("--chip:" + color)
1294 }
1295 return out
1296}
1297
1298func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1299 p, ok := s.repoFor(w, r, "")
1300 if !ok {
1301 return
1302 }
1303 p.Tab = "issues"
1304 state := r.URL.Query().Get("state")
1305 if state != "closed" && state != "all" {
1306 state = "open"
1307 }
1308 issues, err := s.st.ListIssues(p.Repo.ID, state)
1309 if err != nil {
1310 http.Error(w, "internal error", http.StatusInternalServerError)
1311 return
1312 }
1313 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1314 for i := range issues {
1315 issues[i].Labels = labels[issues[i].ID]
1316 }
1317 }
1318 // ?label=x narrows to issues carrying that label (chips link here).
1319 labelFilter := r.URL.Query().Get("label")
1320 if labelFilter != "" {
1321 var kept []store.Issue
1322 for _, iss := range issues {
1323 for _, l := range iss.Labels {
1324 if l == labelFilter {
1325 kept = append(kept, iss)
1326 break
1327 }
1328 }
1329 }
1330 issues = kept
1331 }
1332 s.render(w, "issues.html", struct {
1333 repoPage
1334 State string
1335 Label string
1336 Issues []store.Issue
1337 LabelColors map[string]template.CSS
1338 }{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1339}
1340
1341func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1342 p, ok := s.repoFor(w, r, "")
1343 if !ok {
1344 return
1345 }
1346 p.Tab = "issues"
1347 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1348 if err != nil {
1349 s.notFound(w, r)
1350 return
1351 }
1352 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1353 if err != nil {
1354 s.notFound(w, r)
1355 return
1356 }
1357 comments, err := s.st.ListIssueComments(iss.ID)
1358 if err != nil {
1359 http.Error(w, "internal error", http.StatusInternalServerError)
1360 return
1361 }
1362 md := s.ugcFor(r, p.Repo)
1363 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1364 s.render(w, "issue.html", struct {
1365 repoPage
1366 Issue store.Issue
1367 BodyHTML template.HTML
1368 Comments []renderedComment
1369 CanEdit bool
1370 CanWrite bool
1371 Milestones []store.Milestone
1372 Notice string
1373 LabelColors map[string]template.CSS
1374 }{p, iss, md(iss.Body), renderComments(comments, md),
1375 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1376 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1377}
1378
1379// canEditItem: the author or anyone with write access may edit.
1380// canWriteRepo reports whether the browser session may push to the repo,
1381// which is what gates the review and merge controls.
1382func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1383 if s.cfg.Web.Mode != "accounts" {
1384 return false
1385 }
1386 u := s.viewer(r)
1387 if u.ID == 0 {
1388 return false
1389 }
1390 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1391 return policy.CanWrite(u, repo, grant)
1392}
1393
1394func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1395 if s.cfg.Web.Mode != "accounts" {
1396 return false
1397 }
1398 u := s.viewer(r)
1399 if u.ID == 0 {
1400 return false
1401 }
1402 if u.Username == author {
1403 return true
1404 }
1405 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1406 return policy.CanWrite(u, repo, grant)
1407}
1408
1409func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1410 p, ok := s.repoFor(w, r, "")
1411 if !ok {
1412 return
1413 }
1414 p.Tab = "merge requests"
1415 state := r.URL.Query().Get("state")
1416 if state == "" {
1417 state = "open"
1418 }
1419 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1420 if !valid[state] {
1421 state = "open"
1422 }
1423 mrs, err := s.st.ListMRs(p.Repo.ID, state)
1424 if err != nil {
1425 http.Error(w, "internal error", http.StatusInternalServerError)
1426 return
1427 }
1428 s.render(w, "mrs.html", struct {
1429 repoPage
1430 State string
1431 MRs []store.MR
1432 }{p, state, mrs})
1433}
1434
1435func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1436 p, ok := s.repoFor(w, r, "")
1437 if !ok {
1438 return
1439 }
1440 p.Tab = "merge requests"
1441 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1442 if err != nil {
1443 s.notFound(w, r)
1444 return
1445 }
1446 m, err := s.st.MRByNumber(p.Repo.ID, n)
1447 if err != nil {
1448 s.notFound(w, r)
1449 return
1450 }
1451 comments, _ := s.st.ListMRComments(m.ID)
1452 reviews, _ := s.st.ListMRReviews(m.ID)
1453 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1454 diffComments, _ := s.st.ListDiffComments(m.ID)
1455
1456 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1457 var files []diffFile
1458 base := m.MergedBase
1459 if base == "" {
1460 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1461 base = b
1462 }
1463 }
1464 if base != "" {
1465 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1466 files = parseDiff(patch)
1467 }
1468 }
1469 md := s.ugcFor(r, p.Repo)
1470 var detachedThreads []diffThread
1471 files, detachedThreads = attachThreads(files, diffComments, m.HeadSHA, md)
1472 stat := statOf(files)
1473 // The commits this MR carries: base..head, the same range as the diff.
1474 type commitRow struct {
1475 SHA, ShortSHA, Subject, AuthorName, AuthorUser, Date string
1476 Sig sigView
1477 }
1478 mrNames := s.authorNames()
1479 var commits []commitRow
1480 if base != "" {
1481 const maxMRCommits = 100
1482 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1483 if len(shas) > maxMRCommits {
1484 shas = shas[:maxMRCommits]
1485 }
1486 for _, sha := range shas {
1487 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1488 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1489 if parsed != nil {
1490 cr.Subject = parsed.Subject
1491 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1492 cr.AuthorUser, _ = mrNames.account(parsed.AuthorEmail)
1493 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1494 }
1495 commits = append(commits, cr)
1496 }
1497 }
1498 // The diff is the reason most people open a merge request, so it gets
1499 // its own view rather than a fold at the foot of the conversation.
1500 // A query parameter keeps this working without JavaScript.
1501 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1502 view := r.URL.Query().Get("view")
1503 if view != "commits" && view != "diff" {
1504 view = "conversation"
1505 }
1506 s.render(w, "mr.html", struct {
1507 repoPage
1508 MR store.MR
1509 View string
1510 BodyHTML template.HTML
1511 Checks []store.CommitStatus
1512 Combined string
1513 Comments []renderedComment
1514 Reviews []store.MRReview
1515 DiffFiles []diffFile
1516 Stat diffStat
1517 Commits []commitRow
1518 CanEdit bool
1519 CanWrite bool
1520 Unresolved int
1521 Notice string
1522 DetachedThreads []diffThread
1523 }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1524 reviews, files, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1525 s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
1526}
1527
1528func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1529 p, ok := s.repoFor(w, r, "")
1530 if !ok {
1531 return
1532 }
1533 p.Tab = "refs"
1534 branches, _ := gitutil.Refs(p.Dir, "heads")
1535 tags, _ := gitutil.Refs(p.Dir, "tags")
1536 s.render(w, "refs.html", struct {
1537 repoPage
1538 Branches, Tags []gitutil.Ref
1539 }{p, branches, tags})
1540}
1541
1542func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1543 p, ok := s.repoFor(w, r, "")
1544 if !ok {
1545 return
1546 }
1547 file := r.PathValue("file")
1548 ref, ok := strings.CutSuffix(file, ".tar.gz")
1549 if !ok {
1550 s.notFound(w, r)
1551 return
1552 }
1553 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1554 s.notFound(w, r)
1555 return
1556 }
1557 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1558 w.Header().Set("Content-Type", "application/gzip")
1559 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1560 gitutil.Archive(p.Dir, ref, prefix, w)
1561}
1562
1563func policyCanAdmin(u store.User, repo store.Repo, grant string) bool {
1564 return policy.CanAdmin(u, repo, grant)
1565}
1566
1567func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1568 return policy.CanRead(u, repo, grant)
1569}