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