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