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