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