krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
repo-descriptions: internal/httpd/web.go · raw
1package httpd
2
3import (
4 "bytes"
5 "fmt"
6
7 "gitbay.org/gitbay/internal/policy"
8 "html/template"
9 "net/http"
10 "path"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/alecthomas/chroma/v2/formatters/html"
16 "github.com/alecthomas/chroma/v2/lexers"
17 "github.com/alecthomas/chroma/v2/styles"
18 "github.com/microcosm-cc/bluemonday"
19 "github.com/niklasfasching/go-org/org"
20 "github.com/yuin/goldmark"
21
22 "gitbay.org/gitbay/internal/control"
23 "gitbay.org/gitbay/internal/gitutil"
24 "gitbay.org/gitbay/internal/sig"
25 "gitbay.org/gitbay/internal/store"
26 "gitbay.org/gitbay/internal/web"
27)
28
29const maxRenderBytes = 1 << 20 // largest blob rendered inline
30
31func (s *Server) render(w http.ResponseWriter, page string, data any) {
32 var buf bytes.Buffer
33 if err := web.Render(&buf, page, data); err != nil {
34 http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
35 return
36 }
37 w.Header().Set("Content-Type", "text/html; charset=utf-8")
38 buf.WriteTo(w)
39}
40
41func (s *Server) siteName() string {
42 h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
43 return strings.TrimSuffix(h, "/")
44}
45
46func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
47 w.Header().Set("Content-Type", "text/css; charset=utf-8")
48 w.Write(web.StyleCSS)
49}
50
51// describedRepo pairs a repo with its description for listings.
52type describedRepo struct {
53 store.Repo
54 Desc string
55}
56
57func (s *Server) describeAll(repos []store.Repo) []describedRepo {
58 var out []describedRepo
59 for _, r := range repos {
60 out = append(out, describedRepo{r, gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, r.OwnerName, r.Name))})
61 }
62 return out
63}
64
65func (s *Server) index(w http.ResponseWriter, r *http.Request) {
66 repos, err := s.st.ListPublicRepos()
67 if err != nil {
68 http.Error(w, "internal error", http.StatusInternalServerError)
69 return
70 }
71 var viewer store.User
72 var mine []store.Repo
73 if s.cfg.Web.Mode == "accounts" {
74 if viewer = s.viewer(r); viewer.ID != 0 {
75 all, err := s.st.ListReposForUser(viewer.ID)
76 if err == nil {
77 for _, rp := range all {
78 if rp.Visibility == "private" {
79 mine = append(mine, rp)
80 }
81 }
82 }
83 }
84 }
85 s.render(w, "index.html", struct {
86 Site string
87 Viewer string
88 Repos []describedRepo
89 Mine []describedRepo
90 }{s.siteName(), viewer.Username, s.describeAll(repos), s.describeAll(mine)})
91}
92
93// repoPage is the shared context for repo-scoped pages.
94type repoPage struct {
95 Site string
96 Viewer string
97 Desc string
98 Repo store.Repo
99 Ref string
100 CloneURL string
101 Dir string
102}
103
104// repoFor resolves the repo for a web request; false means 404 was sent.
105// Anonymous visitors see public repos only; in accounts mode a logged-in
106// viewer additionally sees repos their grants allow. Private and missing
107// repos are indistinguishable either way.
108func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
109 var repo store.Repo
110 var viewer store.User
111 if s.cfg.Web.Mode == "accounts" {
112 viewer = s.viewer(r)
113 }
114 repo, err := s.st.RepoByPath(r.PathValue("owner") + "/" + r.PathValue("repo"))
115 ok := err == nil
116 if ok {
117 grant := ""
118 if viewer.ID != 0 {
119 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
120 }
121 ok = policyCanRead(viewer, repo, grant)
122 }
123 if !ok {
124 http.NotFound(w, r)
125 return repoPage{}, false
126 }
127 if ref == "" {
128 ref = repo.DefaultBranch
129 }
130 return repoPage{
131 Site: s.siteName(),
132 Viewer: viewer.Username,
133 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
134 Repo: repo,
135 Ref: ref,
136 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
137 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
138 }, true
139}
140
141type crumb struct {
142 Name string
143 URL string
144}
145
146func crumbs(p repoPage, kind, filePath string) []crumb {
147 var cs []crumb
148 base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
149 acc := ""
150 for _, part := range strings.Split(filePath, "/") {
151 if part == "" {
152 continue
153 }
154 acc = path.Join(acc, part)
155 cs = append(cs, crumb{Name: part, URL: base + acc})
156 }
157 return cs
158}
159
160// ownerPage renders /{owner} for users and orgs: the repositories the
161// viewer may see, org membership either direction. Owner names are not
162// secret (they are on every commit); repository visibility rules hold.
163func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
164 name := r.PathValue("owner")
165 var viewer store.User
166 if s.cfg.Web.Mode == "accounts" {
167 viewer = s.viewer(r)
168 }
169
170 kind := "user"
171 var ownerID int64
172 var members []store.OrgMember
173 var orgs []store.OrgMember
174 if u, err := s.st.UserByUsername(name); err == nil {
175 ownerID = u.ID
176 orgs, _ = s.st.ListOrgsForUser(u.ID)
177 } else if o, err := s.st.OrgByName(name); err == nil {
178 kind, ownerID = "org", o.ID
179 members, _ = s.st.OrgMembers(o.ID)
180 } else {
181 http.NotFound(w, r)
182 return
183 }
184
185 all, err := s.st.ListReposForOwner(kind, ownerID)
186 if err != nil {
187 http.Error(w, "internal error", http.StatusInternalServerError)
188 return
189 }
190 var visible []store.Repo
191 for _, repo := range all {
192 grant := ""
193 if viewer.ID != 0 {
194 grant, _ = s.st.AccessRole(repo.ID, viewer.ID)
195 }
196 if policy.CanRead(viewer, repo, grant) {
197 visible = append(visible, repo)
198 }
199 }
200 s.render(w, "owner.html", struct {
201 Site string
202 Viewer string
203 Owner string
204 Kind string
205 Repos []describedRepo
206 Members []store.OrgMember
207 Orgs []store.OrgMember
208 }{s.siteName(), viewer.Username, name, kind, s.describeAll(visible), members, orgs})
209}
210
211func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
212 p, ok := s.repoFor(w, r, "")
213 if !ok {
214 return
215 }
216 s.renderTree(w, r, p, "")
217}
218
219func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
220 p, ok := s.repoFor(w, r, r.PathValue("ref"))
221 if !ok {
222 return
223 }
224 s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
225}
226
227func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
228 if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
229 // Empty repo: render the page with no entries rather than 404.
230 s.render(w, "tree.html", struct {
231 repoPage
232 Crumbs []crumb
233 Prefix string
234 Entries []gitutil.TreeEntry
235 ReadmeHTML template.HTML
236 }{repoPage: p})
237 return
238 }
239 entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
240 if err != nil {
241 http.NotFound(w, r)
242 return
243 }
244 prefix := ""
245 if dirPath != "" {
246 prefix = dirPath + "/"
247 }
248
249 var readmeHTML template.HTML
250 if name := pickReadme(entries); name != "" {
251 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+name, maxRenderBytes); err == nil {
252 readmeHTML = renderReadme(name, raw)
253 }
254 }
255
256 s.render(w, "tree.html", struct {
257 repoPage
258 Crumbs []crumb
259 Prefix string
260 Entries []gitutil.TreeEntry
261 ReadmeHTML template.HTML
262 }{p, crumbs(p, "tree", dirPath), prefix, entries, readmeHTML})
263}
264
265func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
266 p, ok := s.repoFor(w, r, r.PathValue("ref"))
267 if !ok {
268 return
269 }
270 filePath := strings.Trim(r.PathValue("path"), "/")
271 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
272 if err != nil {
273 http.NotFound(w, r)
274 return
275 }
276 binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
277
278 var codeHTML template.HTML
279 if !binary {
280 codeHTML = highlight(filePath, data)
281 }
282 cs := crumbs(p, "blob", filePath)
283 base := ""
284 if len(cs) > 0 {
285 base = cs[len(cs)-1].Name
286 cs = cs[:len(cs)-1]
287 }
288 s.render(w, "blob.html", struct {
289 repoPage
290 Crumbs []crumb
291 Base string
292 Path string
293 Binary bool
294 Size int
295 CodeHTML template.HTML
296 }{p, cs, base, filePath, binary, len(data), codeHTML})
297}
298
299func highlight(filePath string, data []byte) template.HTML {
300 lexer := lexers.Match(filePath)
301 if lexer == nil {
302 lexer = lexers.Fallback
303 }
304 style := styles.Get("friendly")
305 formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false))
306 iterator, err := lexer.Tokenise(nil, string(data))
307 if err != nil {
308 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
309 }
310 var buf bytes.Buffer
311 if err := formatter.Format(&buf, style, iterator); err != nil {
312 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
313 }
314 return template.HTML(buf.String())
315}
316
317func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
318 p, ok := s.repoFor(w, r, r.PathValue("ref"))
319 if !ok {
320 return
321 }
322 filePath := strings.Trim(r.PathValue("path"), "/")
323 data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
324 if err != nil {
325 http.NotFound(w, r)
326 return
327 }
328 // Serve inert: never let repo content execute in the forge's origin.
329 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
330 w.Header().Set("X-Content-Type-Options", "nosniff")
331 w.Write(data)
332}
333
334// readmeRank orders competing README files: richer renderers win.
335var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
336
337// pickReadme returns the best README-ish blob in a tree listing: any file
338// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
339// we can render richly.
340func pickReadme(entries []gitutil.TreeEntry) string {
341 best, bestRank := "", 1<<30
342 for _, e := range entries {
343 if e.Type != "blob" {
344 continue
345 }
346 lower := strings.ToLower(e.Name)
347 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
348 continue
349 }
350 rank, ok := readmeRank[path.Ext(lower)]
351 if !ok {
352 rank = 10 // plaintext fallback
353 }
354 if rank < bestRank {
355 best, bestRank = e.Name, rank
356 }
357 }
358 return best
359}
360
361// mdHTML renders user-authored markdown (issue and MR bodies, comments).
362// goldmark's default renderer drops raw HTML, so this is safe as-is.
363func mdHTML(raw string) template.HTML {
364 if strings.TrimSpace(raw) == "" {
365 return ""
366 }
367 var buf bytes.Buffer
368 if goldmark.Convert([]byte(raw), &buf) != nil {
369 return template.HTML("<pre>" + template.HTMLEscapeString(raw) + "</pre>")
370 }
371 return template.HTML(buf.String())
372}
373
374// renderedComment pairs a comment with its rendered body for templates.
375type renderedComment struct {
376 Author string
377 CreatedAt string
378 BodyHTML template.HTML
379}
380
381func renderComments(cs []store.IssueComment) []renderedComment {
382 var out []renderedComment
383 for _, c := range cs {
384 out = append(out, renderedComment{c.Author, c.CreatedAt, mdHTML(c.Body)})
385 }
386 return out
387}
388
389// ugcPolicy sanitizes rendered repo content before it enters the forge's
390// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
391// output and repo-authored HTML are not.
392var ugcPolicy = bluemonday.UGCPolicy()
393
394// renderReadme renders a README by extension: markdown, org-mode, and
395// (sanitized) HTML richly; everything else as escaped plaintext.
396func renderReadme(name string, raw []byte) template.HTML {
397 plain := func() template.HTML {
398 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
399 }
400 if gitutil.IsBinary(raw) {
401 return ""
402 }
403 switch path.Ext(strings.ToLower(name)) {
404 case ".md", ".markdown":
405 var buf bytes.Buffer
406 if goldmark.Convert(raw, &buf) != nil {
407 return plain()
408 }
409 return template.HTML(buf.String())
410 case ".org":
411 doc := org.New().Parse(bytes.NewReader(raw), name)
412 html, err := doc.Write(org.NewHTMLWriter())
413 if err != nil {
414 return plain()
415 }
416 return template.HTML(ugcPolicy.Sanitize(html))
417 case ".html", ".htm":
418 return template.HTML(ugcPolicy.Sanitize(string(raw)))
419 default:
420 return plain()
421 }
422}
423
424type diffLine struct {
425 Class string
426 Text string
427}
428
429func classifyDiff(patch string) []diffLine {
430 var lines []diffLine
431 for _, l := range strings.Split(patch, "\n") {
432 class := ""
433 switch {
434 case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
435 class = "meta"
436 case strings.HasPrefix(l, "@@"):
437 class = "hunk"
438 case strings.HasPrefix(l, "+"):
439 class = "add"
440 case strings.HasPrefix(l, "-"):
441 class = "del"
442 }
443 lines = append(lines, diffLine{class, l})
444 }
445 return lines
446}
447
448type sigView struct {
449 State string
450 Signer string
451 Fingerprint string
452}
453
454func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
455 raw, err := gitutil.ReadCommit(dir, sha)
456 if err != nil {
457 return sigView{State: "unsigned"}, nil
458 }
459 parsed, err := sig.ParseCommit(raw)
460 if err != nil {
461 return sigView{State: "unsigned"}, nil
462 }
463 res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
464 if err != nil {
465 return sigView{State: "unsigned"}, parsed
466 }
467 v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
468 if res.SignerUserID != 0 {
469 if u, err := s.st.UserByID(res.SignerUserID); err == nil {
470 v.Signer = u.Username
471 }
472 }
473 return v, parsed
474}
475
476func (s *Server) log(w http.ResponseWriter, r *http.Request) {
477 ref := r.PathValue("ref")
478 p, ok := s.repoFor(w, r, ref)
479 if !ok {
480 return
481 }
482 const pageSize = 50
483 shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
484 if err != nil {
485 http.NotFound(w, r)
486 return
487 }
488 next := ""
489 if len(shas) > pageSize {
490 next = shas[pageSize]
491 shas = shas[:pageSize]
492 }
493 type row struct {
494 SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
495 Sig sigView
496 }
497 var rows []row
498 for _, sha := range shas {
499 v, parsed := s.sigFor(p.Repo, p.Dir, sha)
500 rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
501 if parsed != nil {
502 rw.Subject = parsed.Subject
503 rw.AuthorName = parsed.AuthorName
504 rw.AuthorEmail = parsed.AuthorEmail
505 rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
506 }
507 rows = append(rows, rw)
508 }
509 s.render(w, "log.html", struct {
510 repoPage
511 Commits []row
512 NextSHA string
513 }{p, rows, next})
514}
515
516func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
517 p, ok := s.repoFor(w, r, "")
518 if !ok {
519 return
520 }
521 sha := r.PathValue("sha")
522 full, err := gitutil.ResolveRef(p.Dir, sha)
523 if err != nil {
524 http.NotFound(w, r)
525 return
526 }
527 v, parsed := s.sigFor(p.Repo, p.Dir, full)
528 if parsed == nil {
529 http.NotFound(w, r)
530 return
531 }
532 patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
533 lines := classifyDiff(patch)
534 committerEmail := ""
535 if parsed.CommitterEmail != parsed.AuthorEmail {
536 committerEmail = parsed.CommitterEmail
537 }
538 msg := ""
539 if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
540 msg = string(parsed.Payload[i+2:])
541 }
542 s.render(w, "commit.html", struct {
543 repoPage
544 SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
545 Sig sigView
546 DiffLines []diffLine
547 }{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
548 time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines})
549}
550
551func (s *Server) issues(w http.ResponseWriter, r *http.Request) {
552 p, ok := s.repoFor(w, r, "")
553 if !ok {
554 return
555 }
556 state := r.URL.Query().Get("state")
557 if state != "closed" && state != "all" {
558 state = "open"
559 }
560 issues, err := s.st.ListIssues(p.Repo.ID, state)
561 if err != nil {
562 http.Error(w, "internal error", http.StatusInternalServerError)
563 return
564 }
565 s.render(w, "issues.html", struct {
566 repoPage
567 State string
568 Issues []store.Issue
569 }{p, state, issues})
570}
571
572func (s *Server) issue(w http.ResponseWriter, r *http.Request) {
573 p, ok := s.repoFor(w, r, "")
574 if !ok {
575 return
576 }
577 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
578 if err != nil {
579 http.NotFound(w, r)
580 return
581 }
582 iss, err := s.st.IssueByNumber(p.Repo.ID, n)
583 if err != nil {
584 http.NotFound(w, r)
585 return
586 }
587 comments, err := s.st.ListIssueComments(iss.ID)
588 if err != nil {
589 http.Error(w, "internal error", http.StatusInternalServerError)
590 return
591 }
592 s.render(w, "issue.html", struct {
593 repoPage
594 Issue store.Issue
595 BodyHTML template.HTML
596 Comments []renderedComment
597 }{p, iss, mdHTML(iss.Body), renderComments(comments)})
598}
599
600func (s *Server) mrs(w http.ResponseWriter, r *http.Request) {
601 p, ok := s.repoFor(w, r, "")
602 if !ok {
603 return
604 }
605 state := r.URL.Query().Get("state")
606 if state == "" {
607 state = "open"
608 }
609 valid := map[string]bool{"open": true, "merged": true, "closed": true, "source_gone": true, "all": true}
610 if !valid[state] {
611 state = "open"
612 }
613 mrs, err := s.st.ListMRs(p.Repo.ID, state)
614 if err != nil {
615 http.Error(w, "internal error", http.StatusInternalServerError)
616 return
617 }
618 s.render(w, "mrs.html", struct {
619 repoPage
620 State string
621 MRs []store.MR
622 }{p, state, mrs})
623}
624
625func (s *Server) mr(w http.ResponseWriter, r *http.Request) {
626 p, ok := s.repoFor(w, r, "")
627 if !ok {
628 return
629 }
630 n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
631 if err != nil {
632 http.NotFound(w, r)
633 return
634 }
635 m, err := s.st.MRByNumber(p.Repo.ID, n)
636 if err != nil {
637 http.NotFound(w, r)
638 return
639 }
640 comments, _ := s.st.ListMRComments(m.ID)
641 reviews, _ := s.st.ListMRReviews(m.ID)
642
643 headRef := fmt.Sprintf("refs/merge-requests/%d/head", m.Number)
644 var lines []diffLine
645 if base, err := gitutil.MergeBase(p.Dir, "refs/heads/"+m.TargetRef, headRef); err == nil {
646 if patch, err := gitutil.Diff(p.Dir, base, headRef, 4<<20); err == nil {
647 lines = classifyDiff(patch)
648 }
649 }
650 s.render(w, "mr.html", struct {
651 repoPage
652 MR store.MR
653 BodyHTML template.HTML
654 Comments []renderedComment
655 Reviews []store.MRReview
656 DiffLines []diffLine
657 }{p, m, mdHTML(m.Body), renderComments(comments), reviews, lines})
658}
659
660func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
661 p, ok := s.repoFor(w, r, "")
662 if !ok {
663 return
664 }
665 branches, _ := gitutil.Refs(p.Dir, "heads")
666 tags, _ := gitutil.Refs(p.Dir, "tags")
667 s.render(w, "refs.html", struct {
668 repoPage
669 Branches, Tags []gitutil.Ref
670 }{p, branches, tags})
671}
672
673func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
674 p, ok := s.repoFor(w, r, "")
675 if !ok {
676 return
677 }
678 file := r.PathValue("file")
679 ref, ok := strings.CutSuffix(file, ".tar.gz")
680 if !ok {
681 http.NotFound(w, r)
682 return
683 }
684 if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
685 http.NotFound(w, r)
686 return
687 }
688 prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
689 w.Header().Set("Content-Type", "application/gzip")
690 w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
691 gitutil.Archive(p.Dir, ref, prefix, w)
692}
693
694func policyCanRead(u store.User, repo store.Repo, grant string) bool {
695 return policy.CanRead(u, repo, grant)
696}