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