internal/httpd/web.go
1380 lines · 37952 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 "strconv"
17 "strings"
18 "time"
19
20 "github.com/alecthomas/chroma/v2/formatters/html"
21 "github.com/alecthomas/chroma/v2/lexers"
22 "github.com/alecthomas/chroma/v2/styles"
23 "github.com/microcosm-cc/bluemonday"
24 "github.com/niklasfasching/go-org/org"
25 "github.com/yuin/goldmark"
26
27 "gitbay.org/gitbay/internal/autolink"
28 "gitbay.org/gitbay/internal/control"
29 "gitbay.org/gitbay/internal/gitutil"
30 "gitbay.org/gitbay/internal/sig"
31 "gitbay.org/gitbay/internal/store"
32 "gitbay.org/gitbay/internal/web"
33)
34
35const maxRenderBytes = 1 << 20 // largest blob rendered inline
36
37func (s *Server) render(w http.ResponseWriter, page string, data any) {
38 var buf bytes.Buffer
39 if err := web.Render(&buf, page, data); err != nil {
40 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
41 return
42 }
43 w.Header().Set("Content-Type", "text/html; charset=utf-8")
44 buf.WriteTo(w)
45}
46
47func (s *Server) siteName() string {
48 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
49 return strings.TrimSuffix(h, "/")
50}
51
52func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
53 w.Header().Set("Content-Type", "text/css; charset=utf-8")
54 w.Write(web.StyleCSS)
55}
56
57func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
58 w.Header().Set("Content-Type", "image/svg+xml")
59 w.Write(web.FaviconSVG)
60}
61
62// notFound renders the designed 404 page with a 404 status. Falls back to
63// the stock plain-text response if the template fails.
64func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
65 var buf bytes.Buffer
66 if err := web.Render(&buf, "404.html", struct {
67 Site string
68 Viewer string
69 }{s.siteName(), s.viewerName(r)}); err != nil {
70 http.NotFound(w, r)
71 return
72 }
73 w.Header().Set("Content-Type", "text/html; charset=utf-8")
74 w.WriteHeader(http.StatusNotFound)
75 buf.WriteTo(w)
76}
77
78// describedRepo pairs a repo with the listing metadata: description,
79// topics, license, and last-updated date.
80type describedRepo struct {
81 store.Repo
82 Desc string
83 Topics []string
84 License string
85 Updated string
86}
87
88func (s *Server) describeAll(repos []store.Repo) []describedRepo {
89 var out []describedRepo
90 for _, r := range repos {
91 dir := control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name)
92 d := describedRepo{
93 Repo: r,
94 Desc: gitutil.ReadDescription(dir),
95 License: detectLicense(dir, r.DefaultBranch),
96 Updated: gitutil.LastCommitDate(dir, r.DefaultBranch),
97 }
98 d.Topics, _ = s.st.ListTopics(r.ID)
99 out = append(out, d)
100 }
101 return out
102}
103
104// index is the homepage: a dashboard for logged-in users, a landing page
105// for everyone else. The full public listing lives at /explore.
106func (s *Server) index(w http.ResponseWriter, r *http.Request) {
107 if s.cfg.Web.Mode == "accounts" {
108 if viewer := s.viewer(r); viewer.ID != 0 {
109 s.dashboard(w, r, viewer)
110 return
111 }
112 }
113 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
114 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
115 s.render(w, "landing.html", struct {
116 Site string
117 Viewer string
118 Host string
119 Accounts bool
120 Signup bool
121 }{s.siteName(), "", host, s.cfg.Web.Mode == "accounts",
122 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
123}
124
125func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.User) {
126 pinned, _ := s.st.PinnedRepos(viewer.ID)
127 var visible []store.Repo
128 for _, rp := range pinned {
129 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
130 if policy.CanRead(viewer, rp, grant) {
131 visible = append(visible, rp)
132 }
133 }
134 mrs, _ := s.st.DashboardMRs(viewer.ID)
135 issues, _ := s.st.DashboardIssues(viewer.ID)
136 s.render(w, "dashboard.html", struct {
137 Site string
138 Viewer string
139 Pinned []describedRepo
140 MRs []store.DashboardItem
141 Issues []store.DashboardItem
142 }{s.siteName(), viewer.Username, s.describeAll(visible), mrs, issues})
143}
144
145func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
146 repos, err := s.st.ListPublicRepos()
147 if err != nil {
148 http.Error(w, "internal error", http.StatusInternalServerError)
149 return
150 }
151 var viewer store.User
152 if s.cfg.Web.Mode == "accounts" {
153 viewer = s.viewer(r)
154 }
155 q := strings.TrimSpace(r.URL.Query().Get("q"))
156 s.render(w, "explore.html", struct {
157 Site string
158 Viewer string
159 Query string
160 Repos []describedRepo
161 }{s.siteName(), viewer.Username, q, s.filterRepos(q, s.describeAll(repos))})
162}
163
164// viewerName returns the logged-in username for header rendering, or "".
165func (s *Server) viewerName(r *http.Request) string {
166 if s.cfg.Web.Mode != "accounts" {
167 return ""
168 }
169 return s.viewer(r).Username
170}
171
172// privacy renders the privacy page: what the gitbay software does with
173// data, plus this instance's operator-provided notes.
174func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
175 s.render(w, "privacy.html", struct {
176 Site string
177 Viewer string
178 Host string
179 Notice string
180 }{s.siteName(), s.viewerName(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
181}
182
183// filterRepos keeps repos whose path, description, or topics contain the
184// query, case-insensitively. An empty query keeps everything.
185func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
186 if q == "" {
187 return repos
188 }
189 q = strings.ToLower(q)
190 var out []describedRepo
191 for _, d := range repos {
192 if strings.Contains(strings.ToLower(d.Path()), q) ||
193 strings.Contains(strings.ToLower(d.Desc), q) {
194 out = append(out, d)
195 continue
196 }
197 for _, t := range d.Topics {
198 if strings.Contains(t, q) {
199 out = append(out, d)
200 break
201 }
202 }
203 }
204 return out
205}
206
207// repoPage is the shared context for repo-scoped pages.
208type repoPage struct {
209 Site string
210 Viewer string
211 Desc string
212 Repo store.Repo
213 Ref string
214 CloneURL string
215 Dir string
216 Tab string // active tab in the repo header
217 Topics []string
218 Pinned bool // by the viewer
219 HasWiki bool
220 Host string
221 Mirrors []mirrorLine // repo admins only
222}
223
224// mirrorLine is the admin-only mirror status shown in the repo header.
225// It carries no credentials: the stored URL is credential-free.
226type mirrorLine struct {
227 Direction string
228 URL string
229 Target string // URL without the scheme, for display
230 Synced string
231 Error string
232}
233
234// syncedAt trims a stored sync timestamp (2026-08-25T03:39:19.994Z) to a
235// readable "2026-08-25 03:39 UTC".
236func syncedAt(ts string) string {
237 if len(ts) < 16 {
238 return ts
239 }
240 return ts[:10] + " " + ts[11:16] + " UTC"
241}
242
243// repoFor resolves the repo for a web request; false means 404 was sent.
244// Anonymous visitors see public repos only; in accounts mode a logged-in
245// viewer additionally sees repos their grants allow. Private and missing
246// repos are indistinguishable either way.
247func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
248 var repo store.Repo
249 var viewer store.User
250 if s.cfg.Web.Mode == "accounts" {
251 viewer = s.viewer(r)
252 }
253 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
254 ok := err == nil
255 grant := ""
256 if ok {
257 if viewer.ID != 0 {
258 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
259 }
260 ok = policyCanRead(viewer, repo, grant)
261 }
262 if !ok {
263 s.notFound(w, r)
264 return repoPage{}, false
265 }
266 if ref == "" {
267 ref = repo.DefaultBranch
268 }
269 topics, _ := s.st.ListTopics(repo.ID)
270 pinned := false
271 if viewer.ID != 0 {
272 pinned = s.st.IsPinned(viewer.ID, repo.ID)
273 }
274 var mirrors []mirrorLine
275 if viewer.ID != 0 && policy.CanAdmin(viewer, repo, grant) {
276 ms, _ := s.st.ListMirrors(repo.ID)
277 for _, m := range ms {
278 mirrors = append(mirrors, mirrorLine{
279 Direction: m.Direction,
280 URL: m.URL,
281 Target: strings.TrimPrefix(strings.TrimPrefix(m.URL, "https://"), "http://"),
282 Synced: syncedAt(m.LastSync),
283 Error: m.LastError,
284 })
285 }
286 }
287 return repoPage{
288 Mirrors: mirrors,
289 Site: s.siteName(),
290 Viewer: viewer.Username,
291 Pinned: pinned,
292 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "",
293 Host: s.cfg.SiteHost(),
294 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
295 Repo: repo,
296 Ref: ref,
297 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
298 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
299 Topics: topics,
300 }, true
301}
302
303type crumb struct {
304 Name string
305 URL string
306}
307
308func crumbs(p repoPage, kind, filePath string) []crumb {
309 var cs []crumb
310 base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
311 acc := ""
312 for _, part := range strings.Split(filePath, "/") {
313 if part == "" {
314 continue
315 }
316 acc = path.Join(acc, part)
317 cs = append(cs, crumb{Name: part, URL: base + acc})
318 }
319 return cs
320}
321
322// ownerPage renders /{owner} for users and orgs: the repositories the
323// viewer may see, org membership either direction. Owner names are not
324// secret (they are on every commit); repository visibility rules hold.
325func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
326 name := r.PathValue("owner")
327 var viewer store.User
328 if s.cfg.Web.Mode == "accounts" {
329 viewer = s.viewer(r)
330 }
331
332 kind := "user"
333 var ownerID int64
334 var members []store.OrgMember
335 var orgs []store.OrgMember
336 if u, err := s.st.UserByUsername(name); err == nil {
337 ownerID = u.ID
338 orgs, _ = s.st.ListOrgsForUser(u.ID)
339 } else if o, err := s.st.OrgByName(name); err == nil {
340 kind, ownerID = "org", o.ID
341 members, _ = s.st.OrgMembers(o.ID)
342 } else {
343 s.notFound(w, r)
344 return
345 }
346 profile, _ := s.st.OwnerProfile(kind, ownerID)
347
348 all, err := s.st.ListReposForOwner(kind, ownerID)
349 if err != nil {
350 http.Error(w, "internal error", http.StatusInternalServerError)
351 return
352 }
353 var visible []store.Repo
354 for _, repo := range all {
355 grant := ""
356 if viewer.ID != 0 {
357 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
358 }
359 if policy.CanRead(viewer, repo, grant) {
360 visible = append(visible, repo)
361 }
362 }
363 var counts map[string]int
364 if kind == "user" {
365 counts, _ = s.st.ActivityByDay(ownerID, activitySince())
366 } else {
367 counts, _ = s.st.OrgActivityByDay(ownerID, activitySince())
368 }
369 weeks, activityTotal := activityGrid(counts)
370
371 s.render(w, "owner.html", struct {
372 Site string
373 Viewer string
374 Owner string
375 Kind string
376 Profile store.Profile
377 Repos []describedRepo
378 Members []store.OrgMember
379 Orgs []store.OrgMember
380 Activity []activityWeek
381 ActivityTotal int
382 }{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs,
383 weeks, activityTotal})
384}
385
386func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
387 p, ok := s.repoFor(w, r, "")
388 if !ok {
389 return
390 }
391 p.Tab = "files"
392 s.renderTree(w, r, p, "")
393}
394
395func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
396 p, ok := s.repoFor(w, r, r.PathValue("ref"))
397 if !ok {
398 return
399 }
400 p.Tab = "files"
401 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
402}
403
404func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
405 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
406 // Empty repo: render the page with no entries rather than 404.
407 s.render(w, "tree.html", struct {
408 repoPage
409 Crumbs []crumb
410 Prefix string
411 DirPath string
412 RefKind string
413 Entries []gitutil.TreeEntry
414 Branches []gitutil.Ref
415 ReadmeName string
416 ReadmeHTML template.HTML
417 }{repoPage: p, RefKind: "tree"})
418 return
419 }
420 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
421 if err != nil {
422 s.notFound(w, r)
423 return
424 }
425 prefix := ""
426 if dirPath != "" {
427 prefix = dirPath + "/"
428 }
429
430 var readmeHTML template.HTML
431 readmeName := pickReadme(entries)
432 if readmeName != "" {
433 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+readmeName, maxRenderBytes); err == nil {
434 readmeHTML = rewriteRelativeLinks(renderReadme(readmeName, raw), p, dirPath)
435 }
436 }
437
438 branches, _ := gitutil.Refs(p.Dir, "heads")
439 s.render(w, "tree.html", struct {
440 repoPage
441 Crumbs []crumb
442 Prefix string
443 DirPath string
444 RefKind string
445 Entries []gitutil.TreeEntry
446 Branches []gitutil.Ref
447 ReadmeName string
448 ReadmeHTML template.HTML
449 }{p, crumbs(p, "tree", dirPath), prefix, dirPath, "tree", entries, branches, readmeName, readmeHTML})
450}
451
452func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
453 p, ok := s.repoFor(w, r, r.PathValue("ref"))
454 if !ok {
455 return
456 }
457 p.Tab = "files"
458 filePath := strings.Trim(r.PathValue("path"), "/")
459 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
460 if err != nil {
461 s.notFound(w, r)
462 return
463 }
464 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
465
466 var codeHTML template.HTML
467 if !binary {
468 codeHTML = highlight(filePath, data)
469 }
470 cs := crumbs(p, "blob", filePath)
471 base := ""
472 if len(cs) > 0 {
473 base = cs[len(cs)-1].Name
474 cs = cs[:len(cs)-1]
475 }
476 branches, _ := gitutil.Refs(p.Dir, "heads")
477 s.render(w, "blob.html", struct {
478 repoPage
479 Crumbs []crumb
480 Base string
481 Path string
482 DirPath string
483 RefKind string
484 Binary bool
485 Size int
486 Branches []gitutil.Ref
487 CodeHTML template.HTML
488 }{p, cs, base, filePath, filePath, "blob", binary, len(data), branches, codeHTML})
489}
490
491// releases lists tag-anchored releases with notes and assets.
492func (s *Server) releases(w http.ResponseWriter, r *http.Request) {
493 p, ok := s.repoFor(w, r, "")
494 if !ok {
495 return
496 }
497 p.Tab = "releases"
498 rels, err := s.st.ListReleases(p.Repo.ID)
499 if err != nil {
500 http.Error(w, "internal error", http.StatusInternalServerError)
501 return
502 }
503 md := s.ugcFor(r, p.Repo)
504 type relView struct {
505 store.Release
506 NotesHTML template.HTML
507 }
508 var views []relView
509 for _, rel := range rels {
510 views = append(views, relView{rel, md(rel.Notes)})
511 }
512 s.render(w, "releases.html", struct {
513 repoPage
514 Releases []relView
515 }{p, views})
516}
517
518// releaseAsset streams one uploaded asset. Tags containing '/' are not
519// reachable here (single path segment); SSH download always works.
520func (s *Server) releaseAsset(w http.ResponseWriter, r *http.Request) {
521 p, ok := s.repoFor(w, r, "")
522 if !ok {
523 return
524 }
525 rel, err := s.st.ReleaseByTag(p.Repo.ID, r.PathValue("tag"))
526 if err != nil {
527 s.notFound(w, r)
528 return
529 }
530 name := r.PathValue("name")
531 found := false
532 for _, a := range rel.Assets {
533 if a.Name == name {
534 found = true
535 }
536 }
537 if !found {
538 s.notFound(w, r)
539 return
540 }
541 f, err := os.Open(filepath.Join(control.RepoDir(s.cfg.Server.Root, p.Repo.OwnerName, p.Repo.Name),
542 "gitbay-releases", strconv.FormatInt(rel.ID, 10), name))
543 if err != nil {
544 s.notFound(w, r)
545 return
546 }
547 defer f.Close()
548 w.Header().Set("Content-Type", "application/octet-stream")
549 w.Header().Set("X-Content-Type-Options", "nosniff")
550 w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
551 if fi, err := f.Stat(); err == nil {
552 w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
553 }
554 io.Copy(w, f)
555}
556
557// milestones lists a repo's milestones with progress.
558func (s *Server) milestones(w http.ResponseWriter, r *http.Request) {
559 p, ok := s.repoFor(w, r, "")
560 if !ok {
561 return
562 }
563 p.Tab = "issues"
564 state := r.URL.Query().Get("state")
565 if state != "closed" && state != "all" {
566 state = "open"
567 }
568 ms, err := s.st.ListMilestones(p.Repo.ID, state)
569 if err != nil {
570 http.Error(w, "internal error", http.StatusInternalServerError)
571 return
572 }
573 type msView struct {
574 store.Milestone
575 Percent int
576 }
577 var views []msView
578 for _, m := range ms {
579 v := msView{Milestone: m}
580 if total := m.OpenItems + m.ClosedItems; total > 0 {
581 v.Percent = m.ClosedItems * 100 / total
582 }
583 views = append(views, v)
584 }
585 s.render(w, "milestones.html", struct {
586 repoPage
587 State string
588 Milestones []msView
589 }{p, state, views})
590}
591
592// search runs a bounded literal git grep over the repo's default branch.
593func (s *Server) search(w http.ResponseWriter, r *http.Request) {
594 p, ok := s.repoFor(w, r, "")
595 if !ok {
596 return
597 }
598 p.Tab = "search"
599 q := strings.TrimSpace(r.URL.Query().Get("q"))
600 type matchView struct {
601 Path string
602 Line int
603 TextHTML template.HTML
604 }
605 var matches []matchView
606 var queryErr string
607 if q != "" {
608 if len(q) < 2 || len(q) > 200 {
609 queryErr = "query must be 2 to 200 characters"
610 } else if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err == nil {
611 raw, err := gitutil.Grep(p.Dir, p.Ref, q, 200)
612 if err != nil {
613 http.Error(w, "internal error", http.StatusInternalServerError)
614 return
615 }
616 for _, m := range raw {
617 matches = append(matches, matchView{m.Path, m.Line, markMatch(m.Text, q)})
618 }
619 }
620 }
621 s.render(w, "search.html", struct {
622 repoPage
623 Query string
624 QueryErr string
625 Matches []matchView
626 Capped bool
627 }{p, q, queryErr, matches, len(matches) == 200})
628}
629
630// markMatch escapes a matched line and wraps case-insensitive occurrences
631// of the query in <mark>.
632func markMatch(text, q string) template.HTML {
633 lower, lq := strings.ToLower(text), strings.ToLower(q)
634 var b strings.Builder
635 pos := 0
636 for {
637 i := strings.Index(lower[pos:], lq)
638 if i < 0 {
639 break
640 }
641 i += pos
642 b.WriteString(template.HTMLEscapeString(text[pos:i]))
643 b.WriteString("<mark>")
644 b.WriteString(template.HTMLEscapeString(text[i : i+len(q)]))
645 b.WriteString("</mark>")
646 pos = i + len(q)
647 }
648 b.WriteString(template.HTMLEscapeString(text[pos:]))
649 return template.HTML(b.String())
650}
651
652// blamePageSize caps how many lines one blame page renders; blame is a
653// per-line subprocess cost, so large files paginate.
654const blamePageSize = 1000
655
656func (s *Server) blame(w http.ResponseWriter, r *http.Request) {
657 p, ok := s.repoFor(w, r, r.PathValue("ref"))
658 if !ok {
659 return
660 }
661 p.Tab = "files"
662 filePath := strings.Trim(r.PathValue("path"), "/")
663 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
664 if err != nil {
665 s.notFound(w, r)
666 return
667 }
668 total := bytes.Count(data, []byte("\n"))
669 if len(data) > 0 && !bytes.HasSuffix(data, []byte("\n")) {
670 total++
671 }
672 binary := gitutil.IsBinary(data)
673
674 type hunkView struct {
675 gitutil.BlameHunk
676 ShortSHA string
677 Date string
678 Sig sigView
679 Numbered []numberedLine
680 }
681 var hunks []hunkView
682 page, pages := 1, (total+blamePageSize-1)/blamePageSize
683 if pages == 0 {
684 pages = 1
685 }
686 if n, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && n >= 1 && n <= pages {
687 page = n
688 }
689 if !binary && total > 0 {
690 start := (page-1)*blamePageSize + 1
691 end := min(total, page*blamePageSize)
692 raw, err := gitutil.Blame(p.Dir, p.Ref, filePath, start, end)
693 if err != nil {
694 s.notFound(w, r)
695 return
696 }
697 sigs := map[string]sigView{}
698 for _, h := range raw {
699 v, ok := sigs[h.SHA]
700 if !ok {
701 v, _ = s.sigFor(p.Repo, p.Dir, h.SHA)
702 sigs[h.SHA] = v
703 }
704 hv := hunkView{BlameHunk: h, ShortSHA: h.SHA[:10],
705 Date: time.Unix(h.AuthorUnix, 0).UTC().Format("2006-01-02"), Sig: v}
706 for i, l := range h.Lines {
707 hv.Numbered = append(hv.Numbered, numberedLine{h.StartLine + i, l})
708 }
709 hunks = append(hunks, hv)
710 }
711 }
712 cs := crumbs(p, "blame", filePath)
713 base := ""
714 if len(cs) > 0 {
715 base = cs[len(cs)-1].Name
716 cs = cs[:len(cs)-1]
717 }
718 s.render(w, "blame.html", struct {
719 repoPage
720 Crumbs []crumb
721 Base string
722 Path string
723 Binary bool
724 Hunks []hunkView
725 Page, Pages int
726 }{p, cs, base, filePath, binary, hunks, page, pages})
727}
728
729type numberedLine struct {
730 N int
731 Text string
732}
733
734func highlight(filePath string, data []byte) template.HTML {
735 lexer := lexers.Match(filePath)
736 if lexer == nil {
737 lexer = lexers.Fallback
738 }
739 style := styles.Get("friendly")
740 formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false),
741 html.WithLinkableLineNumbers(true, "L"))
742 iterator, err := lexer.Tokenise(nil, string(data))
743 if err != nil {
744 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
745 }
746 var buf bytes.Buffer
747 if err := formatter.Format(&buf, style, iterator); err != nil {
748 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
749 }
750 return template.HTML(buf.String())
751}
752
753func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
754 p, ok := s.repoFor(w, r, r.PathValue("ref"))
755 if !ok {
756 return
757 }
758 filePath := strings.Trim(r.PathValue("path"), "/")
759 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
760 if err != nil {
761 s.notFound(w, r)
762 return
763 }
764 // Serve inert: never let repo content execute in the forge's origin.
765 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
766 w.Header().Set("X-Content-Type-Options", "nosniff")
767 w.Write(data)
768}
769
770// readmeRank orders competing README files: richer renderers win.
771var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
772
773// pickReadme returns the best README-ish blob in a tree listing: any file
774// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
775// we can render richly.
776func pickReadme(entries []gitutil.TreeEntry) string {
777 best, bestRank := "", 1<<30
778 for _, e := range entries {
779 if e.Type != "blob" {
780 continue
781 }
782 lower := strings.ToLower(e.Name)
783 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
784 continue
785 }
786 rank, ok := readmeRank[path.Ext(lower)]
787 if !ok {
788 rank = 10 // plaintext fallback
789 }
790 if rank < bestRank {
791 best, bestRank = e.Name, rank
792 }
793 }
794 return best
795}
796
797// mdHTML renders user-authored markdown (issue and MR bodies, comments).
798// goldmark's default renderer drops raw HTML, so this is safe as-is.
799func mdHTML(raw string) template.HTML {
800 if strings.TrimSpace(raw) == "" {
801 return ""
802 }
803 var buf bytes.Buffer
804 if goldmark.Convert([]byte(raw), &buf) != nil {
805 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
806 }
807 return template.HTML(buf.String())
808}
809
810// webResolver answers autolink lookups for one viewer. Cross-repo
811// references to repositories the viewer cannot read stay plain text, per
812// the enumeration rule: a link would confirm the repo exists.
813type webResolver struct {
814 s *Server
815 viewer store.User
816}
817
818func (r webResolver) RefURL(owner, name string, kind byte, n int64) string {
819 repo, err := r.s.st.RepoByPath(owner + "/" + name)
820 if err != nil {
821 return ""
822 }
823 grant := ""
824 if r.viewer.ID != 0 {
825 grant, _ = r.s.st.AccessRole(repo.ID, r.viewer.ID)
826 }
827 if !policy.CanRead(r.viewer, repo, grant) {
828 return ""
829 }
830 if kind == '#' {
831 if _, err := r.s.st.IssueByNumber(repo.ID, n); err != nil {
832 return ""
833 }
834 return autolink.IssueURL(repo.OwnerName, repo.Name, n)
835 }
836 if _, err := r.s.st.MRByNumber(repo.ID, n); err != nil {
837 return ""
838 }
839 return autolink.MRURL(repo.OwnerName, repo.Name, n)
840}
841
842func (r webResolver) UserURL(name string) string {
843 if _, err := r.s.st.UserByUsername(name); err == nil {
844 return "/" + name
845 }
846 if _, err := r.s.st.OrgByName(name); err == nil {
847 return "/" + name
848 }
849 return ""
850}
851
852// ugcFor returns a renderer for user-authored markdown on one repo's pages:
853// mdHTML plus cross-reference and mention autolinking for this viewer.
854func (s *Server) ugcFor(r *http.Request, repo store.Repo) func(string) template.HTML {
855 viewer := store.User{}
856 if s.cfg.Web.Mode == "accounts" {
857 viewer = s.viewer(r)
858 }
859 res := webResolver{s, viewer}
860 return func(raw string) template.HTML {
861 h := mdHTML(raw)
862 if h == "" {
863 return h
864 }
865 return template.HTML(autolink.Rewrite(string(h), repo.OwnerName, repo.Name, res))
866 }
867}
868
869// renderedComment pairs a comment with its rendered body for templates.
870type renderedComment struct {
871 Author string
872 CreatedAt string
873 Kind string
874 BodyHTML template.HTML
875}
876
877func renderComments(cs []store.IssueComment, md func(string) template.HTML) []renderedComment {
878 var out []renderedComment
879 for _, c := range cs {
880 out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, md(c.Body)})
881 }
882 return out
883}
884
885// ugcPolicy sanitizes rendered repo content before it enters the forge's
886// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
887// output and repo-authored HTML are not.
888var ugcPolicy = bluemonday.UGCPolicy()
889
890// renderReadme renders a README by extension: markdown, org-mode, and
891// (sanitized) HTML richly; everything else as escaped plaintext.
892func renderReadme(name string, raw []byte) template.HTML {
893 plain := func() template.HTML {
894 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
895 }
896 if gitutil.IsBinary(raw) {
897 return ""
898 }
899 switch path.Ext(strings.ToLower(name)) {
900 case ".md", ".markdown":
901 var buf bytes.Buffer
902 if goldmark.Convert(raw, &buf) != nil {
903 return plain()
904 }
905 return template.HTML(buf.String())
906 case ".org":
907 doc := org.New().Parse(bytes.NewReader(raw), name)
908 html, err := doc.Write(org.NewHTMLWriter())
909 if err != nil {
910 return plain()
911 }
912 return template.HTML(ugcPolicy.Sanitize(html))
913 case ".html", ".htm":
914 return template.HTML(ugcPolicy.Sanitize(string(raw)))
915 default:
916 return plain()
917 }
918}
919
920type diffLine struct {
921 Class string
922 Text string
923 Path string // file this line belongs to
924 NewLine int64 // line number in the new file (0 when absent)
925 OldLine int64 // line number in the old file (0 when absent)
926 Threads []diffThread
927}
928
929var hunkPat = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
930
931// classifyDiff parses a unified diff into rendered lines, tracking the
932// file and old/new line numbers so review threads can anchor inline.
933func classifyDiff(patch string) []diffLine {
934 var lines []diffLine
935 path := ""
936 var oldN, newN int64
937 for _, l := range strings.Split(patch, "\n") {
938 d := diffLine{Text: l}
939 switch {
940 case strings.HasPrefix(l, "+++ "):
941 d.Class = "meta"
942 path = strings.TrimPrefix(strings.TrimPrefix(l, "+++ "), "b/")
943 case strings.HasPrefix(l, "--- "), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
944 d.Class = "meta"
945 case strings.HasPrefix(l, "@@"):
946 d.Class = "hunk"
947 if m := hunkPat.FindStringSubmatch(l); m != nil {
948 oldN, _ = strconv.ParseInt(m[1], 10, 64)
949 newN, _ = strconv.ParseInt(m[2], 10, 64)
950 }
951 case strings.HasPrefix(l, "+"):
952 d.Class, d.Path, d.NewLine = "add", path, newN
953 newN++
954 case strings.HasPrefix(l, "-"):
955 d.Class, d.Path, d.OldLine = "del", path, oldN
956 oldN++
957 default:
958 d.Path, d.OldLine, d.NewLine = path, oldN, newN
959 oldN++
960 newN++
961 }
962 lines = append(lines, d)
963 }
964 return lines
965}
966
967type diffThread struct {
968 ID int64
969 Resolved string
970 Stale bool
971 Comments []renderedComment
972}
973
974// attachThreads injects review threads under their anchored diff lines;
975// threads whose anchor no longer appears (stale after force-push, or on a
976// context line outside the current diff) are returned separately.
977func attachThreads(lines []diffLine, comments []store.DiffComment, headSHA string, md func(string) template.HTML) ([]diffLine, []diffThread) {
978 type anchor struct {
979 path string
980 side string
981 line int64
982 }
983 threads := map[int64]*diffThread{}
984 anchors := map[int64]anchor{}
985 var order []int64
986 for _, cm := range comments {
987 if cm.ReplyTo == 0 {
988 threads[cm.ID] = &diffThread{ID: cm.ID, Resolved: cm.ResolvedBy, Stale: cm.HeadSHA != headSHA,
989 Comments: []renderedComment{{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)}}}
990 anchors[cm.ID] = anchor{cm.Path, cm.Side, cm.Line}
991 order = append(order, cm.ID)
992 } else if th, ok := threads[cm.ReplyTo]; ok {
993 th.Comments = append(th.Comments, renderedComment{Author: cm.Author, CreatedAt: cm.CreatedAt, BodyHTML: md(cm.Body)})
994 }
995 }
996 placed := map[int64]bool{}
997 for i := range lines {
998 for _, id := range order {
999 if placed[id] || threads[id].Stale {
1000 continue
1001 }
1002 a := anchors[id]
1003 if lines[i].Path != a.path {
1004 continue
1005 }
1006 if (a.side == "new" && lines[i].NewLine == a.line && lines[i].Class != "del") ||
1007 (a.side == "old" && lines[i].OldLine == a.line && lines[i].Class == "del") {
1008 lines[i].Threads = append(lines[i].Threads, *threads[id])
1009 placed[id] = true
1010 }
1011 }
1012 }
1013 var unplaced []diffThread
1014 for _, id := range order {
1015 if !placed[id] {
1016 unplaced = append(unplaced, *threads[id])
1017 }
1018 }
1019 return lines, unplaced
1020}
1021
1022type sigView struct {
1023 State string
1024 Signer string
1025 Fingerprint string
1026}
1027
1028func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
1029 raw, err := gitutil.ReadCommit(dir, sha)
1030 if err != nil {
1031 return sigView{State: "unsigned"}, nil
1032 }
1033 parsed, err := sig.ParseCommit(raw)
1034 if err != nil {
1035 return sigView{State: "unsigned"}, nil
1036 }
1037 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
1038 if err != nil {
1039 return sigView{State: "unsigned"}, parsed
1040 }
1041 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
1042 if res.SignerUserID != 0 {
1043 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
1044 v.Signer = u.Username
1045 }
1046 }
1047 return v, parsed
1048}
1049
1050func (s *Server) log(w http.ResponseWriter, r *http.Request) {
1051 ref := r.PathValue("ref")
1052 p, ok := s.repoFor(w, r, ref)
1053 if !ok {
1054 return
1055 }
1056 p.Tab = "log"
1057 const pageSize = 50
1058 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
1059 if err != nil {
1060 s.notFound(w, r)
1061 return
1062 }
1063 next := ""
1064 if len(shas) > pageSize {
1065 next = shas[pageSize]
1066 shas = shas[:pageSize]
1067 }
1068 type row struct {
1069 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
1070 Sig sigView
1071 }
1072 var rows []row
1073 for _, sha := range shas {
1074 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
1075 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
1076 if parsed != nil {
1077 rw.Subject = parsed.Subject
1078 rw.AuthorName = parsed.AuthorName
1079 rw.AuthorEmail = parsed.AuthorEmail
1080 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
1081 }
1082 rows = append(rows, rw)
1083 }
1084 s.render(w, "log.html", struct {
1085 repoPage
1086 Commits []row
1087 NextSHA string
1088 }{p, rows, next})
1089}
1090
1091func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
1092 p, ok := s.repoFor(w, r, "")
1093 if !ok {
1094 return
1095 }
1096 p.Tab = "log"
1097 sha := r.PathValue("sha")
1098 full, err := gitutil.ResolveRef(p.Dir, sha)
1099 if err != nil {
1100 s.notFound(w, r)
1101 return
1102 }
1103 v, parsed := s.sigFor(p.Repo, p.Dir, full)
1104 if parsed == nil {
1105 s.notFound(w, r)
1106 return
1107 }
1108 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
1109 lines := classifyDiff(patch)
1110 committerEmail := ""
1111 if parsed.CommitterEmail != parsed.AuthorEmail {
1112 committerEmail = parsed.CommitterEmail
1113 }
1114 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, full)
1115 msg := ""
1116 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
1117 msg = string(parsed.Payload[i+2:])
1118 }
1119 s.render(w, "commit.html", struct {
1120 repoPage
1121 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
1122 Parents []string
1123 Sig sigView
1124 Checks []store.CommitStatus
1125 DiffLines []diffLine
1126 }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
1127 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg,
1128 gitutil.Parents(p.Dir, full), v, checks, lines})
1129}
1130
1131// labelPalette provides default label chip colors: mid-tone hues that stay
1132// legible on light and dark backgrounds.
1133var labelPalette = []string{
1134 "#0969da", "#1a7f37", "#9a6700", "#cf222e",
1135 "#8250df", "#b93a86", "#0b6c80", "#bf5b16",
1136}
1137
1138var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1139
1140// labelColors returns a complete label-name -> chip color map for a repo:
1141// the stored labels.color when it is a valid hex color, otherwise a
1142// stable default picked from the palette by name hash.
1143func (s *Server) labelColors(repoID int64) map[string]template.CSS {
1144 stored, _ := s.st.LabelColors(repoID)
1145 out := make(map[string]template.CSS, len(stored))
1146 for name, color := range stored {
1147 if !hexColorPat.MatchString(color) {
1148 h := fnv.New32a()
1149 h.Write([]byte(name))
1150 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1151 }
1152 out[name] = template.CSS("--chip:" + color)
1153 }
1154 return out
1155}
1156
1157func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
1158 p, ok := s.repoFor(w, r, "")
1159 if !ok {
1160 return
1161 }
1162 p.Tab = "issues"
1163 state := r.URL.Query().Get("state")
1164 if state != "closed" && state != "all" {
1165 state = "open"
1166 }
1167 issues, err := s.st.ListIssues(p.Repo.ID, state)
1168 if err != nil {
1169 http.Error(w, "internal error", http.StatusInternalServerError)
1170 return
1171 }
1172 if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil {
1173 for i := range issues {
1174 issues[i].Labels = labels[issues[i].ID]
1175 }
1176 }
1177 // ?label=x narrows to issues carrying that label (chips link here).
1178 labelFilter := r.URL.Query().Get("label")
1179 if labelFilter != "" {
1180 var kept []store.Issue
1181 for _, iss := range issues {
1182 for _, l := range iss.Labels {
1183 if l == labelFilter {
1184 kept = append(kept, iss)
1185 break
1186 }
1187 }
1188 }
1189 issues = kept
1190 }
1191 s.render(w, "issues.html", struct {
1192 repoPage
1193 State string
1194 Label string
1195 Issues []store.Issue
1196 LabelColors map[string]template.CSS
1197 }{p, state, labelFilter, issues, s.labelColors(p.Repo.ID)})
1198}
1199
1200func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
1201 p, ok := s.repoFor(w, r, "")
1202 if !ok {
1203 return
1204 }
1205 p.Tab = "issues"
1206 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1207 if err != nil {
1208 s.notFound(w, r)
1209 return
1210 }
1211 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
1212 if err != nil {
1213 s.notFound(w, r)
1214 return
1215 }
1216 comments, err := s.st.ListIssueComments(iss.ID)
1217 if err != nil {
1218 http.Error(w, "internal error", http.StatusInternalServerError)
1219 return
1220 }
1221 md := s.ugcFor(r, p.Repo)
1222 s.render(w, "issue.html", struct {
1223 repoPage
1224 Issue store.Issue
1225 BodyHTML template.HTML
1226 Comments []renderedComment
1227 CanEdit bool
1228 LabelColors map[string]template.CSS
1229 }{p, iss, md(iss.Body), renderComments(comments, md),
1230 s.canEditItem(r, p.Repo, iss.Author), s.labelColors(p.Repo.ID)})
1231}
1232
1233// canEditItem: the author or anyone with write access may edit.
1234func (s *Server) canEditItem(r *http.Request, repo store.Repo, author string) bool {
1235 if s.cfg.Web.Mode != "accounts" {
1236 return false
1237 }
1238 u := s.viewer(r)
1239 if u.ID == 0 {
1240 return false
1241 }
1242 if u.Username == author {
1243 return true
1244 }
1245 grant, _ := s.st.AccessRole(repo.ID, u.ID)
1246 return policy.CanWrite(u, repo, grant)
1247}
1248
1249func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
1250 p, ok := s.repoFor(w, r, "")
1251 if !ok {
1252 return
1253 }
1254 p.Tab = "merge requests"
1255 state := r.URL.Query().Get("state")
1256 if state == "" {
1257 state = "open"
1258 }
1259 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
1260 if !valid[state] {
1261 state = "open"
1262 }
1263 mrs, err := s.st.ListMRs(p.Repo.ID, state)
1264 if err != nil {
1265 http.Error(w, "internal error", http.StatusInternalServerError)
1266 return
1267 }
1268 s.render(w, "mrs.html", struct {
1269 repoPage
1270 State string
1271 MRs []store.MR
1272 }{p, state, mrs})
1273}
1274
1275func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
1276 p, ok := s.repoFor(w, r, "")
1277 if !ok {
1278 return
1279 }
1280 p.Tab = "merge requests"
1281 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1282 if err != nil {
1283 s.notFound(w, r)
1284 return
1285 }
1286 m, err := s.st.MRByNumber(p.Repo.ID, n)
1287 if err != nil {
1288 s.notFound(w, r)
1289 return
1290 }
1291 comments, _ := s.st.ListMRComments(m.ID)
1292 reviews, _ := s.st.ListMRReviews(m.ID)
1293 checks, _ := s.st.ListCommitStatuses(p.Repo.ID, m.HeadSHA)
1294 diffComments, _ := s.st.ListDiffComments(m.ID)
1295
1296 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
1297 var lines []diffLine
1298 base := m.MergedBase
1299 if base == "" {
1300 if b, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
1301 base = b
1302 }
1303 }
1304 if base != "" {
1305 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
1306 lines = classifyDiff(patch)
1307 }
1308 }
1309 md := s.ugcFor(r, p.Repo)
1310 var detachedThreads []diffThread
1311 lines, detachedThreads = attachThreads(lines, diffComments, m.HeadSHA, md)
1312 type diffStat struct{ Files, Adds, Dels int }
1313 var stat diffStat
1314 seenFiles := map[string]bool{}
1315 for _, l := range lines {
1316 switch l.Class {
1317 case "add":
1318 stat.Adds++
1319 case "del":
1320 stat.Dels++
1321 }
1322 if l.Path != "" && !seenFiles[l.Path] {
1323 seenFiles[l.Path] = true
1324 stat.Files++
1325 }
1326 }
1327 s.render(w, "mr.html", struct {
1328 repoPage
1329 MR store.MR
1330 BodyHTML template.HTML
1331 Checks []store.CommitStatus
1332 Combined string
1333 Comments []renderedComment
1334 Reviews []store.MRReview
1335 DiffLines []diffLine
1336 Stat diffStat
1337 CanEdit bool
1338 DetachedThreads []diffThread
1339 }{p, m, md(m.Body), checks, store.CombinedStatus(checks), renderComments(comments, md),
1340 reviews, lines, stat, s.canEditItem(r, p.Repo, m.Author), detachedThreads})
1341}
1342
1343func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
1344 p, ok := s.repoFor(w, r, "")
1345 if !ok {
1346 return
1347 }
1348 p.Tab = "refs"
1349 branches, _ := gitutil.Refs(p.Dir, "heads")
1350 tags, _ := gitutil.Refs(p.Dir, "tags")
1351 s.render(w, "refs.html", struct {
1352 repoPage
1353 Branches, Tags []gitutil.Ref
1354 }{p, branches, tags})
1355}
1356
1357func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
1358 p, ok := s.repoFor(w, r, "")
1359 if !ok {
1360 return
1361 }
1362 file := r.PathValue("file")
1363 ref, ok := strings.CutSuffix(file, ".tar.gz")
1364 if !ok {
1365 s.notFound(w, r)
1366 return
1367 }
1368 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
1369 s.notFound(w, r)
1370 return
1371 }
1372 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
1373 w.Header().Set("Content-Type", "application/gzip")
1374 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
1375 gitutil.Archive(p.Dir, ref, prefix, w)
1376}
1377
1378func policyCanRead(u store.User, repo store.Repo, grant string) bool {
1379 return policy.CanRead(u, repo, grant)
1380}