internal/httpd/web.go
1574 lines · 45359 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]gitutil.EntryCommit
439 Tip gitutil.EntryCommit
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]gitutil.EntryCommit
483 Tip gitutil.EntryCommit
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, 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.AuthorEmail = parsed.AuthorEmail
1216 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1217 }
1218 rows = append(rows, rw)
1219 }
1220 s.render(w, "log.html", struct {
1221 repoPage
1222 Commits []row
1223 NextSHA string
1224 FilePath string
1225 }{p, rows, next, filePath})
1226}
1227
1228func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1229 p, ok := s.repoFor(w, r, "")
1230 if !ok {
1231 return
1232 }
1233 p.Tab = "log"
1234 sha := r.PathValue("sha")
1235 full, err := gitutil.ResolveRef(p.Dir, sha)
1236 if err != nil {
1237 s.notFound(w, r)
1238 return
1239 }
1240 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1241 if parsed == nil {
1242 s.notFound(w, r)
1243 return
1244 }
1245 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1246 lines := classifyDiff(patch)
1247 committerEmail := ""
1248 if parsed.CommitterEmail != parsed.AuthorEmail {
1249 committerEmail = parsed.CommitterEmail
1250 }
1251 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1252 msg := ""
1253 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1254 msg = string(parsed.Payload[i+2:])
1255 }
1256 s.render(w, "commit.html", struct {
1257 repoPage
1258 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
1259 Parents []string
1260 Sig sigView
1261 Checks []store.CommitStatus
1262 DiffLines []diffLine
1263 }{p, full, full[:10], s.authorNames().name(parsed.AuthorEmail, parsed.AuthorName), parsed.AuthorEmail, committerEmail,
1264 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1265 gitutil.Parents(p.Dir, full), v, checks, lines})
1266}
1267
1268// labelPalette provides default label chip colors: mid-tone hues that stay
1269// legible on light and dark backgrounds.
1270var labelPalette = []string{
1271 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1272 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1273}
1274
1275var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1276
1277// labelColors returns a complete label-name -> chip color map for a repo:
1278// the stored labels.color when it is a valid hex color, otherwise a
1279// stable default picked from the palette by name hash.
1280func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1281 stored, _ := s.st.LabelColors(repoID)
1282 out := make(map[string]template.CSS, len(stored))
1283 for name, color := range stored {
1284 if !hexColorPat.MatchString(color) {
1285 h := fnv.New32a()
1286 h.Write([]byte(name))
1287 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1288 }
1289 out[name] = template.CSS("--chip:" + color)
1290 }
1291 return out
1292}
1293
1294func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1295 p, ok := s.repoFor(w, r, "")
1296 if !ok {
1297 return
1298 }
1299 p.Tab = "issues"
1300 state := r.URL.Query().Get("state")
1301 if state != "closed" && state != "all" {
1302 state = "open"
1303 }
1304 issues, err := s.st.ListIssues(p.Repo.ID, state)
1305 if err != nil {
1306 http.Error(w, "internal error", http.StatusInternalServerError)
1307 return
1308 }
1309 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1310 for i := range issues {
1311 issues[i].Labels = labels[issues[i].ID]
1312 }
1313 }
1314 // ?label=x narrows to issues carrying that label (chips link here).
1315 labelFilter := r.URL.Query().Get("label")
1316 if labelFilter != "" {
1317 var kept []store.Issue
1318 for _, iss := range issues {
1319 for _, l := range iss.Labels {
1320 if l == labelFilter {
1321 kept = append(kept, iss)
1322 break
1323 }
1324 }
1325 }
1326 issues = kept
1327 }
1328 s.render(w, "issues.html", struct {
1329 repoPage
1330 State string
1331 Label string
1332 Issues []store.Issue
1333 LabelColors map[string]template.CSS
1334 }{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1335}
1336
1337func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1338 p, ok := s.repoFor(w, r, "")
1339 if !ok {
1340 return
1341 }
1342 p.Tab = "issues"
1343 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1344 if err != nil {
1345 s.notFound(w, r)
1346 return
1347 }
1348 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1349 if err != nil {
1350 s.notFound(w, r)
1351 return
1352 }
1353 comments, err := s.st.ListIssueComments(iss.ID)
1354 if err != nil {
1355 http.Error(w, "internal error", http.StatusInternalServerError)
1356 return
1357 }
1358 md := s.ugcFor(r, p.Repo)
1359 milestones, _ := s.st.ListMilestones(p.Repo.ID, "open")
1360 s.render(w, "issue.html", struct {
1361 repoPage
1362 Issue store.Issue
1363 BodyHTML template.HTML
1364 Comments []renderedComment
1365 CanEdit bool
1366 CanWrite bool
1367 Milestones []store.Milestone
1368 Notice string
1369 LabelColors map[string]template.CSS
1370 }{p, iss, md(iss.Body), renderComments(comments, md),
1371 s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo),
1372 milestones, r.URL.Query().Get("e"), s.labelColors(p.Repo.ID)})
1373}
1374
1375// canEditItem: the author or anyone with write access may edit.
1376// canWriteRepo reports whether the browser session may push to the repo,
1377// which is what gates the review and merge controls.
1378func (s *Server) canWriteRepo(r *http.Request, repo store.Repo) bool {
1379 if s.cfg.Web.Mode != "accounts" {
1380 return false
1381 }
1382 u := s.viewer(r)
1383 if u.ID == 0 {
1384 return false
1385 }
1386 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1387 return policy.CanWrite(u, repo, grant)
1388}
1389
1390func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1391 if s.cfg.Web.Mode != "accounts" {
1392 return false
1393 }
1394 u := s.viewer(r)
1395 if u.ID == 0 {
1396 return false
1397 }
1398 if u.Username == author {
1399 return true
1400 }
1401 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1402 return policy.CanWrite(u, repo, grant)
1403}
1404
1405func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1406 p, ok := s.repoFor(w, r, "")
1407 if !ok {
1408 return
1409 }
1410 p.Tab = "merge requests"
1411 state := r.URL.Query().Get("state")
1412 if state == "" {
1413 state = "open"
1414 }
1415 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1416 if !valid[state] {
1417 state = "open"
1418 }
1419 mrs, err := s.st.ListMRs(p.Repo.ID, state)
1420 if err != nil {
1421 http.Error(w, "internal error", http.StatusInternalServerError)
1422 return
1423 }
1424 s.render(w, "mrs.html", struct {
1425 repoPage
1426 State string
1427 MRs []store.MR
1428 }{p, state, mrs})
1429}
1430
1431func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1432 p, ok := s.repoFor(w, r, "")
1433 if !ok {
1434 return
1435 }
1436 p.Tab = "merge requests"
1437 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1438 if err != nil {
1439 s.notFound(w, r)
1440 return
1441 }
1442 m, err := s.st.MRByNumber(p.Repo.ID, n)
1443 if err != nil {
1444 s.notFound(w, r)
1445 return
1446 }
1447 comments, _ := s.st.ListMRComments(m.ID)
1448 reviews, _ := s.st.ListMRReviews(m.ID)
1449 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1450 diffComments, _ := s.st.ListDiffComments(m.ID)
1451
1452 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1453 var lines []diffLine
1454 base := m.MergedBase
1455 if base == "" {
1456 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1457 base = b
1458 }
1459 }
1460 if base != "" {
1461 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1462 lines = classifyDiff(patch)
1463 }
1464 }
1465 md := s.ugcFor(r, p.Repo)
1466 var detachedThreads []diffThread
1467 lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
1468 type diffStat struct{ Files, Adds, Dels int }
1469 var stat diffStat
1470 seenFiles := map[string]bool{}
1471 for _, l := range lines {
1472 switch l.Class {
1473 case "add":
1474 stat.Adds++
1475 case "del":
1476 stat.Dels++
1477 }
1478 if l.Path != "" && !seenFiles[l.Path] {
1479 seenFiles[l.Path] = true
1480 stat.Files++
1481 }
1482 }
1483 // The commits this MR carries: base..head, the same range as the diff.
1484 type commitRow struct {
1485 SHA, ShortSHA, Subject, AuthorName, Date string
1486 Sig sigView
1487 }
1488 mrNames := s.authorNames()
1489 var commits []commitRow
1490 if base != "" {
1491 const maxMRCommits = 100
1492 shas, _ := gitutil.RevListRange(p.Dir, base, headRef)
1493 if len(shas) > maxMRCommits {
1494 shas = shas[:maxMRCommits]
1495 }
1496 for _, sha := range shas {
1497 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1498 cr := commitRow{SHA: sha, ShortSHA: sha[:10], Sig: v}
1499 if parsed != nil {
1500 cr.Subject = parsed.Subject
1501 cr.AuthorName = mrNames.name(parsed.AuthorEmail, parsed.AuthorName)
1502 cr.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1503 }
1504 commits = append(commits, cr)
1505 }
1506 }
1507 // The diff is the reason most people open a merge request, so it gets
1508 // its own view rather than a fold at the foot of the conversation.
1509 // A query parameter keeps this working without JavaScript.
1510 unresolved, _ := s.st.UnresolvedThreadCount(m.ID)
1511 view := r.URL.Query().Get("view")
1512 if view != "commits" && view != "diff" {
1513 view = "conversation"
1514 }
1515 s.render(w, "mr.html", struct {
1516 repoPage
1517 MR store.MR
1518 View string
1519 BodyHTML template.HTML
1520 Checks []store.CommitStatus
1521 Combined string
1522 Comments []renderedComment
1523 Reviews []store.MRReview
1524 DiffLines []diffLine
1525 Stat diffStat
1526 Commits []commitRow
1527 CanEdit bool
1528 CanWrite bool
1529 Unresolved int
1530 Notice string
1531 DetachedThreads []diffThread
1532 }{p, m, view, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1533 reviews, lines, stat, commits, s.canEditItem(r, p.Repo, m.Author),
1534 s.canWriteRepo(r, p.Repo), unresolved, r.URL.Query().Get("e"), detachedThreads})
1535}
1536
1537func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1538 p, ok := s.repoFor(w, r, "")
1539 if !ok {
1540 return
1541 }
1542 p.Tab = "refs"
1543 branches, _ := gitutil.Refs(p.Dir, "heads")
1544 tags, _ := gitutil.Refs(p.Dir, "tags")
1545 s.render(w, "refs.html", struct {
1546 repoPage
1547 Branches, Tags []gitutil.Ref
1548 }{p, branches, tags})
1549}
1550
1551func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1552 p, ok := s.repoFor(w, r, "")
1553 if !ok {
1554 return
1555 }
1556 file := r.PathValue("file")
1557 ref, ok := strings.CutSuffix(file, ".tar.gz")
1558 if !ok {
1559 s.notFound(w, r)
1560 return
1561 }
1562 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1563 s.notFound(w, r)
1564 return
1565 }
1566 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1567 w.Header().Set("Content-Type", "application/gzip")
1568 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1569 gitutil.Archive(p.Dir, ref, prefix, w)
1570}
1571
1572func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1573 return policy.CanRead(u, repo, grant)
1574}