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