krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
b09bf9f4c011abba74725b44e1fffaa24655bc67
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-24T02:20:00Z
e2e/web_test.go | 38 +++++++++++++++++++++++ go.mod | 4 +++ go.sum | 8 +++++ internal/httpd/web.go | 86 +++++++++++++++++++++++++++++++++++++++------------ 4 files changed, 117 insertions(+), 19 deletions(-) @@ -148,6 +148,44 @@ func TestWebUI(t *testing.T) { t.Fatal("archive missing content") } + // README formats: org-mode renders, HTML renders sanitized, unknown + // extensions fall back to plaintext, and richer formats win conflicts. + readmeRepo := func(name, file, content string) { + t.Helper() + if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/"+name); code != 0 { + t.Fatalf("repo create %s failed", name) + } + w := t.TempDir() + mustGit(t, w, env, "clone", inst.sshURL("alice/"+name), "r") + d := filepath.Join(w, "r") + os.WriteFile(filepath.Join(d, file), []byte(content), 0o644) + mustGit(t, d, env, "checkout", "-q", "-b", "main") + mustGit(t, d, env, "add", ".") + mustGit(t, d, env, "commit", "-q", "-m", "readme") + mustGit(t, d, env, "push", "-q", "origin", "main") + } + + readmeRepo("orgdoc", "README.org", "* Heading\n\nSome /emphasis/ here.\n") + status, body = inst.get(t, "/alice/orgdoc") + if status != 200 || !strings.Contains(body, "headline-1") || !strings.Contains(body, "<em>emphasis</em>") { + t.Fatalf("org README not rendered:\n%s", body) + } + + readmeRepo("htmldoc", "README.html", "<p id=\"ok\">fine</p><script>alert(1)</script>") + status, body = inst.get(t, "/alice/htmldoc") + if status != 200 || !strings.Contains(body, "fine</p>") { + t.Fatalf("html README not rendered:\n%s", body) + } + if strings.Contains(body, "<script>alert") { + t.Fatal("repo HTML script survived sanitization") + } + + readmeRepo("txtdoc", "README.txt", "plain <text> & stuff\n") + status, body = inst.get(t, "/alice/txtdoc") + if status != 200 || !strings.Contains(body, "plain <text> & stuff") { + t.Fatalf("txt README not escaped-plaintext:\n%s", body) + } + // Private repo pages: 404, indistinguishable from nonexistent. for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} { if status, _ := inst.get(t, p); status != 404 { @@ -14,14 +14,18 @@ require ( ) require ( + github.com/aymerick/douceur v0.2.0 // indirect github.com/cloudflare/circl v1.6.2 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/dlclark/regexp2/v2 v2.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/niklasfasching/go-org v1.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect @@ -8,6 +8,8 @@ github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= @@ -20,6 +22,8 @@ github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFe github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -28,8 +32,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0= +github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -15,6 +15,8 @@ import ( "github.com/alecthomas/chroma/v2/formatters/html" "github.com/alecthomas/chroma/v2/lexers" "github.com/alecthomas/chroma/v2/styles" + "github.com/microcosm-cc/bluemonday" + "github.com/niklasfasching/go-org/org" "github.com/yuin/goldmark" "gitbay.org/gitbay/internal/control" @@ -178,25 +180,9 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, } var readmeHTML template.HTML - for _, e := range entries { - if e.Type != "blob" { - continue - } - lower := strings.ToLower(e.Name) - if lower == "readme" || lower == "readme.md" || lower == "readme.markdown" { - raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+e.Name, maxRenderBytes) - if err == nil { - var buf bytes.Buffer - if strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") { - // goldmark's default renderer drops raw HTML: safe. - if goldmark.Convert(raw, &buf) == nil { - readmeHTML = template.HTML(buf.String()) - } - } else { - readmeHTML = template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>") - } - } - break + if name := pickReadme(entries); name != "" { + if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+name, maxRenderBytes); err == nil { + readmeHTML = renderReadme(name, raw) } } @@ -278,6 +264,68 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { w.Write(data) } +// readmeRank orders competing README files: richer renderers win. +var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3} + +// pickReadme returns the best README-ish blob in a tree listing: any file +// named "readme" or "readme.<ext>" (case-insensitive), preferring formats +// we can render richly. +func pickReadme(entries []gitutil.TreeEntry) string { + best, bestRank := "", 1<<30 + for _, e := range entries { + if e.Type != "blob" { + continue + } + lower := strings.ToLower(e.Name) + if lower != "readme" && !strings.HasPrefix(lower, "readme.") { + continue + } + rank, ok := readmeRank[path.Ext(lower)] + if !ok { + rank = 10 // plaintext fallback + } + if rank < bestRank { + best, bestRank = e.Name, rank + } + } + return best +} + +// ugcPolicy sanitizes rendered repo content before it enters the forge's +// origin: markdown is already safe (goldmark drops raw HTML), but org-mode +// output and repo-authored HTML are not. +var ugcPolicy = bluemonday.UGCPolicy() + +// renderReadme renders a README by extension: markdown, org-mode, and +// (sanitized) HTML richly; everything else as escaped plaintext. +func renderReadme(name string, raw []byte) template.HTML { + plain := func() template.HTML { + return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>") + } + if gitutil.IsBinary(raw) { + return "" + } + switch path.Ext(strings.ToLower(name)) { + case ".md", ".markdown": + var buf bytes.Buffer + if goldmark.Convert(raw, &buf) != nil { + return plain() + } + return template.HTML(buf.String()) + case ".org": + doc := org.New().Parse(bytes.NewReader(raw), name) + html, err := doc.Write(org.NewHTMLWriter()) + if err != nil { + return plain() + } + return template.HTML(ugcPolicy.Sanitize(html)) + case ".html", ".htm": + return template.HTML(ugcPolicy.Sanitize(string(raw))) + default: + return plain() + } +} + type diffLine struct { Class string Text string