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