Commit b09bf9f4c0

b09bf9f4c011abba74725b44e1fffaa24655bc67

parent: c0d45bd130

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-24 02:20 UTC

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.

Layout: unified · split

e2e/web_test.go +38
@@ -148,6 +148,44 @@ func TestWebUI(t *testing.T) {
148 t.Fatal("archive missing content") 148 t.Fatal("archive missing content")
149 } 149 }
150 150
151 // README formats: org-mode renders, HTML renders sanitized, unknown
152 // extensions fall back to plaintext, and richer formats win conflicts.
153 readmeRepo := func(name, file, content string) {
154 t.Helper()
155 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/"+name); code != 0 {
156 t.Fatalf("repo create %s failed", name)
157 }
158 w := t.TempDir()
159 mustGit(t, w, env, "clone", inst.sshURL("alice/"+name), "r")
160 d := filepath.Join(w, "r")
161 os.WriteFile(filepath.Join(d, file), []byte(content), 0o644)
162 mustGit(t, d, env, "checkout", "-q", "-b", "main")
163 mustGit(t, d, env, "add", ".")
164 mustGit(t, d, env, "commit", "-q", "-m", "readme")
165 mustGit(t, d, env, "push", "-q", "origin", "main")
166 }
167
168 readmeRepo("orgdoc", "README.org", "* Heading\n\nSome /emphasis/ here.\n")
169 status, body = inst.get(t, "/alice/orgdoc")
170 if status != 200 || !strings.Contains(body, "headline-1") || !strings.Contains(body, "<em>emphasis</em>") {
171 t.Fatalf("org README not rendered:\n%s", body)
172 }
173
174 readmeRepo("htmldoc", "README.html", "<p id=\"ok\">fine</p><script>alert(1)</script>")
175 status, body = inst.get(t, "/alice/htmldoc")
176 if status != 200 || !strings.Contains(body, "fine</p>") {
177 t.Fatalf("html README not rendered:\n%s", body)
178 }
179 if strings.Contains(body, "<script>alert") {
180 t.Fatal("repo HTML script survived sanitization")
181 }
182
183 readmeRepo("txtdoc", "README.txt", "plain <text> & stuff\n")
184 status, body = inst.get(t, "/alice/txtdoc")
185 if status != 200 || !strings.Contains(body, "plain &lt;text&gt; &amp; stuff") {
186 t.Fatalf("txt README not escaped-plaintext:\n%s", body)
187 }
188
151 // Private repo pages: 404, indistinguishable from nonexistent. 189 // Private repo pages: 404, indistinguishable from nonexistent.
152 for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} { 190 for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} {
153 if status, _ := inst.get(t, p); status != 404 { 191 if status, _ := inst.get(t, p); status != 404 {
go.mod +4
@@ -14,14 +14,18 @@ require (
14) 14)
15 15
16require ( 16require (
17 github.com/aymerick/douceur v0.2.0 // indirect
17 github.com/cloudflare/circl v1.6.2 // indirect 18 github.com/cloudflare/circl v1.6.2 // indirect
18 github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 19 github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
19 github.com/dlclark/regexp2/v2 v2.2.1 // indirect 20 github.com/dlclark/regexp2/v2 v2.2.1 // indirect
20 github.com/dustin/go-humanize v1.0.1 // indirect 21 github.com/dustin/go-humanize v1.0.1 // indirect
21 github.com/google/uuid v1.6.0 // indirect 22 github.com/google/uuid v1.6.0 // indirect
23 github.com/gorilla/css v1.0.1 // indirect
22 github.com/inconshreveable/mousetrap v1.1.0 // indirect 24 github.com/inconshreveable/mousetrap v1.1.0 // indirect
23 github.com/mattn/go-isatty v0.0.24 // indirect 25 github.com/mattn/go-isatty v0.0.24 // indirect
26 github.com/microcosm-cc/bluemonday v1.0.27 // indirect
24 github.com/ncruces/go-strftime v1.0.0 // indirect 27 github.com/ncruces/go-strftime v1.0.0 // indirect
28 github.com/niklasfasching/go-org v1.9.1 // indirect
25 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 29 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
26 github.com/russross/blackfriday/v2 v2.1.0 // indirect 30 github.com/russross/blackfriday/v2 v2.1.0 // indirect
27 github.com/spf13/pflag v1.0.9 // indirect 31 github.com/spf13/pflag v1.0.9 // indirect
go.sum +8
@@ -8,6 +8,8 @@ github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r
8github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= 8github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
9github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= 9github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
10github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 10github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
11github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
12github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
11github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= 13github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
12github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= 14github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
13github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= 15github.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
20github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= 22github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
21github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 23github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
22github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 24github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
25github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
26github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
23github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 27github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
24github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 28github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
25github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 29github.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
28github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 32github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
29github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= 33github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
30github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= 34github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
35github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
36github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
31github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 37github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
32github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 38github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
39github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0=
40github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48=
33github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 41github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
34github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 42github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
35github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 43github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
internal/httpd/web.go +67 −19
@@ -15,6 +15,8 @@ import (
15 "github.com/alecthomas/chroma/v2/formatters/html" 15 "github.com/alecthomas/chroma/v2/formatters/html"
16 "github.com/alecthomas/chroma/v2/lexers" 16 "github.com/alecthomas/chroma/v2/lexers"
17 "github.com/alecthomas/chroma/v2/styles" 17 "github.com/alecthomas/chroma/v2/styles"
18 "github.com/microcosm-cc/bluemonday"
19 "github.com/niklasfasching/go-org/org"
18 "github.com/yuin/goldmark" 20 "github.com/yuin/goldmark"
19 21
20 "gitbay.org/gitbay/internal/control" 22 "gitbay.org/gitbay/internal/control"
@@ -178,25 +180,9 @@ func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage,
178 } 180 }
179 181
180 var readmeHTML template.HTML 182 var readmeHTML template.HTML
181 for _, e := range entries { 183 if name := pickReadme(entries); name != "" {
182 if e.Type != "blob" { 184 if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+name, maxRenderBytes); err == nil {
183 continue 185 readmeHTML = renderReadme(name, raw)
184 }
185 lower := strings.ToLower(e.Name)
186 if lower == "readme" || lower == "readme.md" || lower == "readme.markdown" {
187 raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+e.Name, maxRenderBytes)
188 if err == nil {
189 var buf bytes.Buffer
190 if strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") {
191 // goldmark's default renderer drops raw HTML: safe.
192 if goldmark.Convert(raw, &buf) == nil {
193 readmeHTML = template.HTML(buf.String())
194 }
195 } else {
196 readmeHTML = template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
197 }
198 }
199 break
200 } 186 }
201 } 187 }
202 188
@@ -278,6 +264,68 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
278 w.Write(data) 264 w.Write(data)
279} 265}
280 266
267// readmeRank orders competing README files: richer renderers win.
268var readmeRank = map[string]int{".md": 1, ".markdown": 1, ".org": 2, ".html": 3, ".htm": 3}
269
270// pickReadme returns the best README-ish blob in a tree listing: any file
271// named "readme" or "readme.<ext>" (case-insensitive), preferring formats
272// we can render richly.
273func pickReadme(entries []gitutil.TreeEntry) string {
274 best, bestRank := "", 1<<30
275 for _, e := range entries {
276 if e.Type != "blob" {
277 continue
278 }
279 lower := strings.ToLower(e.Name)
280 if lower != "readme" && !strings.HasPrefix(lower, "readme.") {
281 continue
282 }
283 rank, ok := readmeRank[path.Ext(lower)]
284 if !ok {
285 rank = 10 // plaintext fallback
286 }
287 if rank < bestRank {
288 best, bestRank = e.Name, rank
289 }
290 }
291 return best
292}
293
294// ugcPolicy sanitizes rendered repo content before it enters the forge's
295// origin: markdown is already safe (goldmark drops raw HTML), but org-mode
296// output and repo-authored HTML are not.
297var ugcPolicy = bluemonday.UGCPolicy()
298
299// renderReadme renders a README by extension: markdown, org-mode, and
300// (sanitized) HTML richly; everything else as escaped plaintext.
301func renderReadme(name string, raw []byte) template.HTML {
302 plain := func() template.HTML {
303 return template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
304 }
305 if gitutil.IsBinary(raw) {
306 return ""
307 }
308 switch path.Ext(strings.ToLower(name)) {
309 case ".md", ".markdown":
310 var buf bytes.Buffer
311 if goldmark.Convert(raw, &buf) != nil {
312 return plain()
313 }
314 return template.HTML(buf.String())
315 case ".org":
316 doc := org.New().Parse(bytes.NewReader(raw), name)
317 html, err := doc.Write(org.NewHTMLWriter())
318 if err != nil {
319 return plain()
320 }
321 return template.HTML(ugcPolicy.Sanitize(html))
322 case ".html", ".htm":
323 return template.HTML(ugcPolicy.Sanitize(string(raw)))
324 default:
325 return plain()
326 }
327}
328
281type diffLine struct { 329type diffLine struct {
282 Class string 330 Class string
283 Text string 331 Text string