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

web: render READMEs by format

Any file named readme or readme.<ext> (case-insensitive) renders in the
tree view: markdown via goldmark, org-mode via go-org, HTML directly —
org output and repo HTML sanitized with bluemonday before entering the
forge origin — and everything else as escaped plaintext. Richer formats
win when several READMEs coexist. Binary content is skipped.
 e2e/web_test.go       | 38 +++++++++++++++++++++++
 go.mod                |  4 +++
 go.sum                |  8 +++++
 internal/httpd/web.go | 86 +++++++++++++++++++++++++++++++++++++++------------
 4 files changed, 117 insertions(+), 19 deletions(-)

diff --git a/e2e/web_test.go b/e2e/web_test.go
index edb0016..e239a0f 100644
--- a/e2e/web_test.go
+++ b/e2e/web_test.go
@@ -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 &lt;text&gt; &amp; 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 {
diff --git a/go.mod b/go.mod
index d0e4b15..17848d2 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 6d35b53..21e4a6d 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/internal/httpd/web.go b/internal/httpd/web.go
index 6e35ed6..4427612 100644
--- a/internal/httpd/web.go
+++ b/internal/httpd/web.go
@@ -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