Commit b09bf9f4c0
Verified · cmc
Layout: unified · split
e2e/web_test.go +38
| @@ -148,6 +148,44 @@ func TestWebUI(t *testing.T) { | ||
| 148 | 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 <text> & stuff") { | |
| 186 | t.Fatalf("txt README not escaped-plaintext:\n%s", body) | |
| 187 | } | |
| 188 | ||
| 151 | 189 | // Private repo pages: 404, indistinguishable from nonexistent. |
| 152 | 190 | for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} { |
| 153 | 191 | if status, _ := inst.get(t, p); status != 404 { |
go.mod +4
| @@ -14,14 +14,18 @@ require ( | ||
| 14 | 14 | ) |
| 15 | 15 | |
| 16 | 16 | require ( |
| 17 | github.com/aymerick/douceur v0.2.0 // indirect | |
| 17 | 18 | github.com/cloudflare/circl v1.6.2 // indirect |
| 18 | 19 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect |
| 19 | 20 | github.com/dlclark/regexp2/v2 v2.2.1 // indirect |
| 20 | 21 | github.com/dustin/go-humanize v1.0.1 // indirect |
| 21 | 22 | github.com/google/uuid v1.6.0 // indirect |
| 23 | github.com/gorilla/css v1.0.1 // indirect | |
| 22 | 24 | github.com/inconshreveable/mousetrap v1.1.0 // indirect |
| 23 | 25 | github.com/mattn/go-isatty v0.0.24 // indirect |
| 26 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect | |
| 24 | 27 | github.com/ncruces/go-strftime v1.0.0 // indirect |
| 28 | github.com/niklasfasching/go-org v1.9.1 // indirect | |
| 25 | 29 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| 26 | 30 | github.com/russross/blackfriday/v2 v2.1.0 // indirect |
| 27 | 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 | ||
| 8 | 8 | github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8= |
| 9 | 9 | github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= |
| 10 | 10 | github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= |
| 11 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= | |
| 12 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= | |
| 11 | 13 | github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ= |
| 12 | 14 | github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= |
| 13 | 15 | 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 | ||
| 20 | 22 | github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= |
| 21 | 23 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| 22 | 24 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| 25 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= | |
| 26 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= | |
| 23 | 27 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| 24 | 28 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| 25 | 29 | 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 | ||
| 28 | 32 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= |
| 29 | 33 | github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= |
| 30 | 34 | github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= |
| 35 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= | |
| 36 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= | |
| 31 | 37 | github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| 32 | 38 | github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| 39 | github.com/niklasfasching/go-org v1.9.1 h1:/3s4uTPOF06pImGa2Yvlp24yKXZoTYM+nsIlMzfpg/0= | |
| 40 | github.com/niklasfasching/go-org v1.9.1/go.mod h1:ZAGFFkWvUQcpazmi/8nHqwvARpr1xpb+Es67oUGX/48= | |
| 33 | 41 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| 34 | 42 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| 35 | 43 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= |
internal/httpd/web.go +67 −19
| @@ -15,6 +15,8 @@ import ( | ||
| 15 | 15 | "github.com/alecthomas/chroma/v2/formatters/html" |
| 16 | 16 | "github.com/alecthomas/chroma/v2/lexers" |
| 17 | 17 | "github.com/alecthomas/chroma/v2/styles" |
| 18 | "github.com/microcosm-cc/bluemonday" | |
| 19 | "github.com/niklasfasching/go-org/org" | |
| 18 | 20 | "github.com/yuin/goldmark" |
| 19 | 21 | |
| 20 | 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 | 182 | var readmeHTML template.HTML |
| 181 | for _, e := range entries { | |
| 182 | if e.Type != "blob" { | |
| 183 | continue | |
| 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 | |
| 183 | if name := pickReadme(entries); name != "" { | |
| 184 | if raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+name, maxRenderBytes); err == nil { | |
| 185 | readmeHTML = renderReadme(name, raw) | |
| 200 | 186 | } |
| 201 | 187 | } |
| 202 | 188 | |
| @@ -278,6 +264,68 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | ||
| 278 | 264 | w.Write(data) |
| 279 | 265 | } |
| 280 | 266 | |
| 267 | // readmeRank orders competing README files: richer renderers win. | |
| 268 | var 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. | |
| 273 | func 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. | |
| 297 | var ugcPolicy = bluemonday.UGCPolicy() | |
| 298 | ||
| 299 | // renderReadme renders a README by extension: markdown, org-mode, and | |
| 300 | // (sanitized) HTML richly; everything else as escaped plaintext. | |
| 301 | func 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 | ||
| 281 | 329 | type diffLine struct { |
| 282 | 330 | Class string |
| 283 | 331 | Text string |