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