From 47096673ec10c9ac4af94bd9035411958c5a042f Mon Sep 17 00:00:00 2001 From: ehsan amiri Date: Thu, 3 Sep 2026 23:40:44 +0330 Subject: [PATCH 1/2] Add WhiteNoise-style static_files middleware across bindings. Serve CSS/images from a filesystem root under a URL prefix so templates can reference assets without custom routes. Co-authored-by: Cursor --- .../skills/fusion-bindings-parity/SKILL.md | 1 + .../FusionFramework/BuiltinMiddleware.cs | 6 ++ bindings/csharp/FusionFramework/Middleware.cs | 100 ++++++++++++++++++ bindings/csharp/FusionFramework/README.md | 11 ++ crates/fusion-node/index.d.ts | 8 ++ crates/fusion-node/index.js | 78 ++++++++++++++ .../python/fusion_framework/__init__.py | 2 + .../python/fusion_framework/middleware.py | 86 +++++++++++++++ examples/static_files.cs | 25 +++++ examples/static_files.mjs | 46 ++++++++ examples/static_files.py | 55 ++++++++++ .../FusionFramework.Tests/MiddlewareTests.cs | 33 ++++++ tests/node/unit/bindings.test.js | 22 ++++ tests/python/unit/test_middleware.py | 29 +++++ 14 files changed, 502 insertions(+) create mode 100644 examples/static_files.cs create mode 100644 examples/static_files.mjs create mode 100644 examples/static_files.py diff --git a/.agents/skills/fusion-bindings-parity/SKILL.md b/.agents/skills/fusion-bindings-parity/SKILL.md index 6ef408b..652384b 100644 --- a/.agents/skills/fusion-bindings-parity/SKILL.md +++ b/.agents/skills/fusion-bindings-parity/SKILL.md @@ -44,6 +44,7 @@ New public surface → show usage in **Python + Node + C#**. Prefer the same bas | Convention HTTP | `def get(self)` | `get()` method | `Get()` method | | Custom HTTP | `@http_get("path/[action]")` | `httpGet('path/[action]')(proto.method)` | `[HttpGet("path/[action]")]` | | Middleware | `middleware.py` factories | factories in `index.js` | `Middleware.cs` | +| Static files | `static_files()` | `staticFiles()` | `Middleware.StaticFiles()` | | Permissions | `permissions=` / `require_permissions` | `permissions` / `requirePermissions` | `PermissionTypes` / `RequirePermissions` | | OpenAPI / Swagger | `app.py` + `api_types.rs` | `buildOpenApi` in `index.js` | `Swagger.cs` | | Version navbar | per-version OpenAPI routes | same | same | diff --git a/bindings/csharp/FusionFramework/BuiltinMiddleware.cs b/bindings/csharp/FusionFramework/BuiltinMiddleware.cs index 61644ad..7eb45f3 100644 --- a/bindings/csharp/FusionFramework/BuiltinMiddleware.cs +++ b/bindings/csharp/FusionFramework/BuiltinMiddleware.cs @@ -8,4 +8,10 @@ public static class BuiltinMiddleware public static FusionMiddleware Cors() => Middleware.Cors(); public static FusionMiddleware CacheHeaders() => Middleware.CacheHeaders(); public static FusionMiddleware RequestId() => Middleware.RequestId(); + public static FusionMiddleware StaticFiles( + string root = "static", + string prefix = "/static", + int? maxAge = 3600, + bool? fallthrough = null) => + Middleware.StaticFiles(root, prefix, maxAge, fallthrough); } diff --git a/bindings/csharp/FusionFramework/Middleware.cs b/bindings/csharp/FusionFramework/Middleware.cs index 4a0001a..c7187d6 100644 --- a/bindings/csharp/FusionFramework/Middleware.cs +++ b/bindings/csharp/FusionFramework/Middleware.cs @@ -325,6 +325,106 @@ Dictionary CorsHeaders(string? origin) }; } + static readonly Dictionary StaticMimeTypes = new(StringComparer.OrdinalIgnoreCase) + { + [".css"] = "text/css; charset=utf-8", + [".gif"] = "image/gif", + [".htm"] = "text/html; charset=utf-8", + [".html"] = "text/html; charset=utf-8", + [".ico"] = "image/x-icon", + [".jpeg"] = "image/jpeg", + [".jpg"] = "image/jpeg", + [".js"] = "text/javascript; charset=utf-8", + [".json"] = "application/json", + [".map"] = "application/json", + [".png"] = "image/png", + [".svg"] = "image/svg+xml", + [".txt"] = "text/plain; charset=utf-8", + [".webp"] = "image/webp", + [".woff"] = "font/woff", + [".woff2"] = "font/woff2", + }; + + /// Map a file extension to Content-Type (octet-stream fallback). + static string GuessStaticContentType(string path) + { + var ext = Path.GetExtension(path); + return StaticMimeTypes.TryGetValue(ext, out var mime) ? mime : "application/octet-stream"; + } + + /// + /// Serve files from for URLs under (WhiteNoise-style). + /// Only GET/HEAD; rejects path traversal. Missing files 404 unless is true. + /// + public static FusionMiddleware StaticFiles( + string root = "static", + string prefix = "/static", + int? maxAge = 3600, + bool? fallthrough = null) + { + var rootFull = Path.GetFullPath(root); + var trimmed = (prefix ?? "/static").Trim().Trim('/'); + var normalized = string.IsNullOrEmpty(trimmed) ? "/" : "/" + trimmed; + var allowFallthrough = fallthrough ?? normalized == "/"; + + return (request, callNext) => + { + var method = (request.Method ?? "GET").ToUpperInvariant(); + if (method is not ("GET" or "HEAD")) + return callNext(request); + + var reqPath = string.IsNullOrEmpty(request.Path) ? "/" : request.Path; + string relative; + if (normalized == "/") + { + relative = reqPath.TrimStart('/'); + if (string.IsNullOrEmpty(relative) || relative.EndsWith('/')) + return callNext(request); + } + else + { + if (!(reqPath == normalized || reqPath.StartsWith(normalized + "/", StringComparison.Ordinal))) + return callNext(request); + relative = reqPath[normalized.Length..].TrimStart('/'); + if (string.IsNullOrEmpty(relative)) + return callNext(request); + } + + var candidate = Path.GetFullPath(Path.Combine(rootFull, relative)); + var rootPrefix = rootFull.EndsWith(Path.DirectorySeparatorChar) + ? rootFull + : rootFull + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) && + !string.Equals(candidate, rootFull, StringComparison.OrdinalIgnoreCase)) + { + return Error(403, "Forbidden"); + } + + if (!File.Exists(candidate)) + { + if (allowFallthrough) return callNext(request); + return Error(404, "Not found"); + } + + var info = new FileInfo(candidate); + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["content-type"] = GuessStaticContentType(candidate), + ["content-length"] = info.Length.ToString(), + }; + if (maxAge is int age) + headers["cache-control"] = $"public, max-age={age}"; + + object body = method == "HEAD" ? Array.Empty() : File.ReadAllBytes(candidate); + return new Dictionary + { + ["status"] = 200, + ["body"] = body, + ["headers"] = headers, + }; + }; + } + /// Optional identity middleware — not enabled by default. Add via app.Use(Middleware.FrameworkHeaders()). public static FusionMiddleware FrameworkHeaders() { diff --git a/bindings/csharp/FusionFramework/README.md b/bindings/csharp/FusionFramework/README.md index fae1fc4..508879a 100644 --- a/bindings/csharp/FusionFramework/README.md +++ b/bindings/csharp/FusionFramework/README.md @@ -117,6 +117,17 @@ MIDDLEWARE.Add(Middleware.BearerJwt()); Route.Register(typeof(AdminModule), "/api/admin", permissions: new[] { AdminChecks.IsAdmin }); ``` +## Static files + +Serve CSS/images without custom routes (WhiteNoise-style): + +```csharp +app.Use(Middleware.StaticFiles(root: "static", prefix: "/static")); +// +``` + +Use `prefix: "/"` with `fallthrough: true` when assets share the site root with HTML routes. + ## License BSD 3-Clause diff --git a/crates/fusion-node/index.d.ts b/crates/fusion-node/index.d.ts index 4dddf75..7d200d1 100644 --- a/crates/fusion-node/index.d.ts +++ b/crates/fusion-node/index.d.ts @@ -201,6 +201,14 @@ export function requestId(options?: { incoming?: boolean }): FusionMiddleware +/** Serve files from `root` under URL `prefix` (WhiteNoise-style). */ +export function staticFiles(options?: { + root?: string + prefix?: string + maxAge?: number | null + fallthrough?: boolean +}): FusionMiddleware + export type FusionResponse = | string | { diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index 018f25a..47f2429 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -268,6 +268,83 @@ function cors(options = {}) { } } +const STATIC_MIME_TYPES = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.htm': 'text/html; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json', + '.map': 'application/json', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +} + +/** Guess Content-Type from a file path extension. */ +function guessStaticContentType(filePath) { + const ext = path.extname(String(filePath)).toLowerCase() + return STATIC_MIME_TYPES[ext] || 'application/octet-stream' +} + +/** + * Serve files from `root` for URLs under `prefix` (WhiteNoise-style). + * Only GET/HEAD; path traversal is rejected. Missing files 404 unless fallthrough. + */ +function staticFiles(options = {}) { + const rootDir = path.resolve(String(options.root ?? 'static')) + const rawPrefix = String(options.prefix ?? '/static').trim() + const normalized = rawPrefix.replace(/\/+$/, '') === '' ? '/' : `/${rawPrefix.replace(/^\/+|\/+$/g, '')}` + const maxAge = options.maxAge === undefined ? 3600 : options.maxAge + const allowFallthrough = + options.fallthrough === undefined ? normalized === '/' : !!options.fallthrough + + return (request, callNext) => { + const method = String(request.method || 'GET').toUpperCase() + if (method !== 'GET' && method !== 'HEAD') return callNext(request) + + const reqPath = String(request.path || '/') + let relative = '' + if (normalized === '/') { + relative = reqPath.replace(/^\/+/, '') + if (!relative || relative.endsWith('/')) return callNext(request) + } else { + if (!(reqPath === normalized || reqPath.startsWith(`${normalized}/`))) { + return callNext(request) + } + relative = reqPath.slice(normalized.length).replace(/^\/+/, '') + if (!relative) return callNext(request) + } + + const candidate = path.resolve(rootDir, relative) + const relToRoot = path.relative(rootDir, candidate) + if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot)) { + return { status: 403, body: { detail: 'Forbidden' } } + } + if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) { + if (allowFallthrough) return callNext(request) + return { status: 404, body: { detail: 'Not found' } } + } + + const size = fs.statSync(candidate).size + const headers = { + 'content-type': guessStaticContentType(candidate), + 'content-length': String(size), + } + if (maxAge !== null && maxAge !== undefined) { + headers['cache-control'] = `public, max-age=${Number(maxAge)}` + } + const body = method === 'HEAD' ? Buffer.alloc(0) : fs.readFileSync(candidate) + return { status: 200, body, headers } + } +} + class FusionBaseApi { constructor(request) { this.request = request && typeof request === 'object' ? request : emptyRequest() @@ -1527,6 +1604,7 @@ module.exports = { cors, cacheHeaders, requestId, + staticFiles, runMiddlewareChain, coerceParam, parsePagination, diff --git a/crates/fusion-py/python/fusion_framework/__init__.py b/crates/fusion-py/python/fusion_framework/__init__.py index 62ed66b..0074b93 100644 --- a/crates/fusion-py/python/fusion_framework/__init__.py +++ b/crates/fusion-py/python/fusion_framework/__init__.py @@ -11,6 +11,7 @@ require_permissions, require_roles, security_headers, + static_files, use, ) from fusion_framework.pagination import PaginationParams, paginated_body, parse_pagination @@ -30,6 +31,7 @@ "require_permissions", "require_roles", "security_headers", + "static_files", "use", "PaginationParams", "parse_pagination", diff --git a/crates/fusion-py/python/fusion_framework/middleware.py b/crates/fusion-py/python/fusion_framework/middleware.py index 65222f3..e990ddb 100644 --- a/crates/fusion-py/python/fusion_framework/middleware.py +++ b/crates/fusion-py/python/fusion_framework/middleware.py @@ -5,6 +5,7 @@ import base64 import inspect import json +from pathlib import Path from typing import Any, Awaitable, Callable, Iterable, Union Middleware = Callable[..., Any] @@ -398,6 +399,91 @@ def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> return middleware +_STATIC_MIME_TYPES: dict[str, str] = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".htm": "text/html; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json", + ".map": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".txt": "text/plain; charset=utf-8", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +} + + +def _guess_static_content_type(path: Path) -> str: + """Map a file extension to a Content-Type, defaulting to octet-stream.""" + return _STATIC_MIME_TYPES.get(path.suffix.lower(), "application/octet-stream") + + +def static_files( + root: str | Path = "static", + *, + prefix: str = "/static", + max_age: int | None = 3600, + fallthrough: bool | None = None, +) -> Middleware: + """Serve local files under ``root`` for URLs starting with ``prefix`` (WhiteNoise-style). + + Only ``GET`` / ``HEAD``. Rejects path traversal. Paths outside ``prefix`` call + the next middleware. Missing files under ``prefix`` return 404 unless + ``fallthrough`` is True (defaults to True when ``prefix`` is ``/`` so HTML + routes still work). + """ + root_path = Path(root) + normalized = "/" + str(prefix).strip("/") if str(prefix).strip("/") else "/" + allow_fallthrough = (normalized == "/") if fallthrough is None else bool(fallthrough) + + def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any: + method = str(request.get("method", "GET")).upper() + if method not in ("GET", "HEAD"): + return call_next(request) + + req_path = str(request.get("path") or "/") + if normalized == "/": + relative = req_path.lstrip("/") + if not relative or relative.endswith("/"): + return call_next(request) + else: + if not (req_path == normalized or req_path.startswith(normalized + "/")): + return call_next(request) + relative = req_path[len(normalized) :].lstrip("/") + if not relative: + return call_next(request) + + base = root_path.resolve() + candidate = (base / relative).resolve() + try: + candidate.relative_to(base) + except ValueError: + return {"status": 403, "body": {"detail": "Forbidden"}} + + if not candidate.is_file(): + if allow_fallthrough: + return call_next(request) + return {"status": 404, "body": {"detail": "Not found"}} + + size = candidate.stat().st_size + headers = { + "content-type": _guess_static_content_type(candidate), + "content-length": str(size), + } + if max_age is not None: + headers["cache-control"] = f"public, max-age={int(max_age)}" + body: Any = b"" if method == "HEAD" else candidate.read_bytes() + return {"status": 200, "body": body, "headers": headers} + + return middleware + + def dispatch_route( request: RequestDict, handler: Callable[[RequestDict], Any], diff --git a/examples/static_files.cs b/examples/static_files.cs new file mode 100644 index 0000000..1e911c9 --- /dev/null +++ b/examples/static_files.cs @@ -0,0 +1,25 @@ +// Serve images/CSS with Middleware.StaticFiles (WhiteNoise-style). +// app.Use(Middleware.StaticFiles(root: "static", prefix: "/static")); +// + +using FusionFramework; + +var staticDir = Path.Combine(AppContext.BaseDirectory, "static_files_assets"); +Directory.CreateDirectory(staticDir); +var logo = Path.Combine(staticDir, "logo.png"); +if (!File.Exists(logo)) + File.WriteAllBytes(logo, new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a }); + +var settings = new FusionSettings(); +settings.EnsureLoaded(); +var app = new FusionApp(settings); +app.Use(Middleware.StaticFiles(root: staticDir, prefix: "/static")); + +[Route("/api/ping")] +sealed class PingModule : FusionBaseApi +{ + public object Get() => Response(new { ok = true }); +} + +Route.Register(); +app.Listen(); diff --git a/examples/static_files.mjs b/examples/static_files.mjs new file mode 100644 index 0000000..db6c319 --- /dev/null +++ b/examples/static_files.mjs @@ -0,0 +1,46 @@ +/** + * Serve images/CSS with `staticFiles` (WhiteNoise-style). + * + * app.use(staticFiles({ root: 'static', prefix: '/static' })) + * // + */ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import { + FusionApp, + FusionBaseApi, + getSettings, + route, + staticFiles, + status, +} from 'fusion-framework' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const STATIC_DIR = path.join(__dirname, 'static_files_assets') + +class Ping extends FusionBaseApi { + get() { + return this.response({ ok: true }, status.HTTP_SUCCESS) + } +} + +route('/api/ping')(Ping) + +async function main() { + fs.mkdirSync(STATIC_DIR, { recursive: true }) + const logo = path.join(STATIC_DIR, 'logo.png') + if (!fs.existsSync(logo)) { + fs.writeFileSync(logo, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + } + + await import('./settings.mjs').catch(() => {}) + const app = new FusionApp(getSettings()) + app.use(staticFiles({ root: STATIC_DIR, prefix: '/static' })) + await app.listen() +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/examples/static_files.py b/examples/static_files.py new file mode 100644 index 0000000..8dcda7d --- /dev/null +++ b/examples/static_files.py @@ -0,0 +1,55 @@ +"""Serve images/CSS from disk with ``static_files`` (WhiteNoise-style). + +Place assets under ``static/`` (or any root) and mount the middleware:: + + app.use(static_files(root="static", prefix="/static")) + +Then HTML can use ```` without a custom route. + +For files next to templates with root-relative URLs:: + + app.use(static_files(root="templates/home", prefix="/", fallthrough=True)) +""" + +from pathlib import Path + +from fusion_framework import static_files, status +from fusion_framework.api import FusionBaseApi +from fusion_framework.app import FusionApp +from fusion_framework.config import get_settings, load_settings_module +from fusion_framework.route import route +from fusion_framework.template import FusionBaseTemplate + +STATIC_DIR = Path(__file__).resolve().parent / "static_files_assets" + + +@route("/") +class HomePage(FusionBaseTemplate): + template = "home/index.html" + + def context(self): + return {"title": "static files demo"} + + +@route("/api/ping") +class Ping(FusionBaseApi): + def get(self): + return self.response({"ok": True}, status=status.HTTP_SUCCESS) + + +def main() -> None: + # Demo assets live beside this example; in apps use project ``static/``. + STATIC_DIR.mkdir(exist_ok=True) + logo = STATIC_DIR / "logo.png" + if not logo.is_file(): + # Minimal valid-looking PNG header bytes for local demos. + logo.write_bytes(b"\x89PNG\r\n\x1a\n") + + load_settings_module("settings") + app = FusionApp(get_settings()) + app.use(static_files(root=STATIC_DIR, prefix="/static")) + app.listen() + + +if __name__ == "__main__": + main() diff --git a/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs b/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs index 8002422..97f4d4c 100644 --- a/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs +++ b/tests/csharp/FusionFramework.Tests/MiddlewareTests.cs @@ -73,4 +73,37 @@ public void Cors_answers_options_with_204() var headers = Assert.IsType>(result["headers"]); Assert.False(string.IsNullOrEmpty(headers["Access-Control-Allow-Origin"])); } + + [Fact] + public void StaticFiles_serves_asset_under_prefix() + { + var dir = Directory.CreateTempSubdirectory("fusion-static-"); + try + { + var file = Path.Combine(dir.FullName, "logo.png"); + File.WriteAllBytes(file, new byte[] { 0x89, 0x50, 0x4e, 0x47 }); + + var request = new FusionRequest + { + Method = "GET", + Path = "/static/logo.png", + Headers = new Dictionary(), + }; + + var result = Middleware.RunChain( + request, + new[] { Middleware.StaticFiles(root: dir.FullName, prefix: "/static", maxAge: 60) }, + Handler) as Dictionary; + + Assert.NotNull(result); + Assert.Equal(200, result["status"]); + Assert.IsType(result["body"]); + var headers = Assert.IsType>(result["headers"]); + Assert.Equal("image/png", headers["content-type"]); + } + finally + { + dir.Delete(recursive: true); + } + } } diff --git a/tests/node/unit/bindings.test.js b/tests/node/unit/bindings.test.js index fbdf3e9..34368b2 100644 --- a/tests/node/unit/bindings.test.js +++ b/tests/node/unit/bindings.test.js @@ -16,6 +16,7 @@ const { cors, requireRoles, frameworkHeaders, + staticFiles, resolveRoutePath, apiResourceName, } = fusion @@ -143,4 +144,25 @@ describe('middleware', () => { const result = await runMiddlewareChain(request, [frameworkHeaders()], handler) assert.ok(result.headers['x-powered-by'] || result.headers['X-Powered-By']) }) + + it('staticFiles serves assets under prefix', async () => { + const fs = require('fs') + const os = require('os') + const path = require('path') + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fusion-static-')) + const file = path.join(dir, 'logo.png') + fs.writeFileSync(file, Buffer.from([0x89, 0x50, 0x4e, 0x47])) + const request = { method: 'GET', path: '/static/logo.png', headers: {} } + const result = await runMiddlewareChain( + request, + [staticFiles({ root: dir, prefix: '/static', maxAge: 60 })], + handler, + ) + assert.equal(result.status, 200) + assert.ok(Buffer.isBuffer(result.body) || result.body instanceof Uint8Array) + const headers = Object.fromEntries( + Object.entries(result.headers || {}).map(([k, v]) => [k.toLowerCase(), v]), + ) + assert.equal(headers['content-type'], 'image/png') + }) }) diff --git a/tests/python/unit/test_middleware.py b/tests/python/unit/test_middleware.py index 31086df..9baea8a 100644 --- a/tests/python/unit/test_middleware.py +++ b/tests/python/unit/test_middleware.py @@ -12,6 +12,7 @@ request_id, require_roles, set_active_global, + static_files, ) @@ -111,3 +112,31 @@ def test_sync_middleware_propagates_async_handler_coroutine(): assert inspect.isawaitable(result) resolved = asyncio.run(result) assert resolved["body"]["ok"] is True + + +def test_static_files_serves_png(tmp_path): + """static_files returns file bytes under the URL prefix.""" + asset = tmp_path / "logo.png" + asset.write_bytes(b"\x89PNG\r\n\x1a\nfake") + set_active_global([static_files(root=tmp_path, prefix="/static", max_age=60)]) + request = {"path": "/static/logo.png", "headers": {}, "method": "GET"} + result = dispatch_route(request, _handler, []) + assert result["status"] == 200 + assert result["body"] == asset.read_bytes() + headers = {str(k).lower(): v for k, v in (result.get("headers") or {}).items()} + assert headers["content-type"] == "image/png" + assert "max-age=60" in headers["cache-control"] + + +def test_static_files_missing_under_prefix_is_404(tmp_path): + set_active_global([static_files(root=tmp_path, prefix="/static")]) + request = {"path": "/static/missing.png", "headers": {}, "method": "GET"} + result = dispatch_route(request, _handler, []) + assert result["status"] == 404 + + +def test_static_files_outside_prefix_falls_through(tmp_path): + set_active_global([static_files(root=tmp_path, prefix="/static")]) + request = {"path": "/api/items", "headers": {}, "method": "GET"} + result = dispatch_route(request, _handler, []) + assert result["status"] == 200 From 009b830235abf29045cf6a083b13bd8b04c2b9a6 Mon Sep 17 00:00:00 2001 From: ehsan amiri Date: Thu, 3 Sep 2026 23:49:33 +0330 Subject: [PATCH 2/2] Mount static_files as real routes on listen. Global middleware only runs for matched routes, so unmatched asset URLs 404'd; register GET/HEAD file routes when the app mounts. Co-authored-by: Cursor --- bindings/csharp/FusionFramework/FusionApp.cs | 1 + bindings/csharp/FusionFramework/Middleware.cs | 157 ++++++++++++------ bindings/csharp/FusionFramework/README.md | 11 +- crates/fusion-node/index.js | 109 ++++++++---- .../fusion-py/python/fusion_framework/app.py | 3 + .../python/fusion_framework/middleware.py | 139 +++++++++++----- examples/static_files.py | 12 +- 7 files changed, 295 insertions(+), 137 deletions(-) diff --git a/bindings/csharp/FusionFramework/FusionApp.cs b/bindings/csharp/FusionFramework/FusionApp.cs index 08379c7..e701c07 100644 --- a/bindings/csharp/FusionFramework/FusionApp.cs +++ b/bindings/csharp/FusionFramework/FusionApp.cs @@ -113,6 +113,7 @@ public void Mount() } } + Middleware.MountStaticFiles(this, _middleware); SwaggerDocs.Mount(this, SettingsStore.Current); } diff --git a/bindings/csharp/FusionFramework/Middleware.cs b/bindings/csharp/FusionFramework/Middleware.cs index c7187d6..6978304 100644 --- a/bindings/csharp/FusionFramework/Middleware.cs +++ b/bindings/csharp/FusionFramework/Middleware.cs @@ -354,7 +354,9 @@ static string GuessStaticContentType(string path) /// /// Serve files from for URLs under (WhiteNoise-style). - /// Only GET/HEAD; rejects path traversal. Missing files 404 unless is true. + /// is the folder on disk; is the URL prefix + /// (e.g. root=static, prefix=/static → static/logo.png at /static/logo.png). + /// Files are mounted as GET/HEAD routes on . /// public static FusionMiddleware StaticFiles( string root = "static", @@ -362,69 +364,118 @@ public static FusionMiddleware StaticFiles( int? maxAge = 3600, bool? fallthrough = null) { - var rootFull = Path.GetFullPath(root); + var state = new StaticFilesState + { + Root = Path.GetFullPath(root), + Prefix = NormalizeStaticPrefix(prefix), + MaxAge = maxAge, + Fallthrough = fallthrough ?? NormalizeStaticPrefix(prefix) == "/", + }; + + FusionMiddleware middleware = (request, callNext) => ServeStaticOrNext(state, request, callNext); + StaticFilesStates.Add(middleware, state); + return middleware; + } + + static readonly System.Runtime.CompilerServices.ConditionalWeakTable StaticFilesStates = new(); + + sealed class StaticFilesState + { + public required string Root { get; init; } + public required string Prefix { get; init; } + public int? MaxAge { get; init; } + public bool Fallthrough { get; init; } + } + + static string NormalizeStaticPrefix(string? prefix) + { var trimmed = (prefix ?? "/static").Trim().Trim('/'); - var normalized = string.IsNullOrEmpty(trimmed) ? "/" : "/" + trimmed; - var allowFallthrough = fallthrough ?? normalized == "/"; + return string.IsNullOrEmpty(trimmed) ? "/" : "/" + trimmed; + } - return (request, callNext) => + static object ServeStaticOrNext(StaticFilesState state, FusionRequest request, Func callNext) + { + var method = (request.Method ?? "GET").ToUpperInvariant(); + if (method is not ("GET" or "HEAD")) + return callNext(request)!; + + var reqPath = string.IsNullOrEmpty(request.Path) ? "/" : request.Path; + string relative; + if (state.Prefix == "/") { - var method = (request.Method ?? "GET").ToUpperInvariant(); - if (method is not ("GET" or "HEAD")) - return callNext(request); + relative = reqPath.TrimStart('/'); + if (string.IsNullOrEmpty(relative) || relative.EndsWith('/')) + return callNext(request)!; + } + else + { + if (!(reqPath == state.Prefix || reqPath.StartsWith(state.Prefix + "/", StringComparison.Ordinal))) + return callNext(request)!; + relative = reqPath[state.Prefix.Length..].TrimStart('/'); + if (string.IsNullOrEmpty(relative)) + return callNext(request)!; + } - var reqPath = string.IsNullOrEmpty(request.Path) ? "/" : request.Path; - string relative; - if (normalized == "/") - { - relative = reqPath.TrimStart('/'); - if (string.IsNullOrEmpty(relative) || relative.EndsWith('/')) - return callNext(request); - } - else - { - if (!(reqPath == normalized || reqPath.StartsWith(normalized + "/", StringComparison.Ordinal))) - return callNext(request); - relative = reqPath[normalized.Length..].TrimStart('/'); - if (string.IsNullOrEmpty(relative)) - return callNext(request); - } + var candidate = Path.GetFullPath(Path.Combine(state.Root, relative)); + var rootPrefix = state.Root.EndsWith(Path.DirectorySeparatorChar) + ? state.Root + : state.Root + Path.DirectorySeparatorChar; + if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) && + !string.Equals(candidate, state.Root, StringComparison.OrdinalIgnoreCase)) + { + return Error(403, "Forbidden"); + } - var candidate = Path.GetFullPath(Path.Combine(rootFull, relative)); - var rootPrefix = rootFull.EndsWith(Path.DirectorySeparatorChar) - ? rootFull - : rootFull + Path.DirectorySeparatorChar; - if (!candidate.StartsWith(rootPrefix, StringComparison.OrdinalIgnoreCase) && - !string.Equals(candidate, rootFull, StringComparison.OrdinalIgnoreCase)) - { - return Error(403, "Forbidden"); - } + if (!File.Exists(candidate)) + { + if (state.Fallthrough) return callNext(request)!; + return Error(404, "Not found"); + } - if (!File.Exists(candidate)) - { - if (allowFallthrough) return callNext(request); - return Error(404, "Not found"); - } + return StaticFileResponse(candidate, method, state.MaxAge); + } - var info = new FileInfo(candidate); - var headers = new Dictionary(StringComparer.OrdinalIgnoreCase) - { - ["content-type"] = GuessStaticContentType(candidate), - ["content-length"] = info.Length.ToString(), - }; - if (maxAge is int age) - headers["cache-control"] = $"public, max-age={age}"; + static Dictionary StaticFileResponse(string path, string method, int? maxAge) + { + var info = new FileInfo(path); + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["content-type"] = GuessStaticContentType(path), + ["content-length"] = info.Length.ToString(), + }; + if (maxAge is int age) + headers["cache-control"] = $"public, max-age={age}"; - object body = method == "HEAD" ? Array.Empty() : File.ReadAllBytes(candidate); - return new Dictionary - { - ["status"] = 200, - ["body"] = body, - ["headers"] = headers, - }; + object body = method == "HEAD" ? Array.Empty() : File.ReadAllBytes(path); + return new Dictionary + { + ["status"] = 200, + ["body"] = body, + ["headers"] = headers, }; } + /// Register GET/HEAD routes for each middleware on the app. + internal static void MountStaticFiles(FusionApp app, IEnumerable middlewares) + { + foreach (var mw in middlewares) + { + if (!StaticFilesStates.TryGetValue(mw, out var state)) + continue; + if (!Directory.Exists(state.Root)) + continue; + + foreach (var filePath in Directory.EnumerateFiles(state.Root, "*", SearchOption.AllDirectories)) + { + var rel = Path.GetRelativePath(state.Root, filePath).Replace('\\', '/'); + var url = state.Prefix == "/" ? "/" + rel : state.Prefix + "/" + rel; + var captured = filePath; + app.AddRawRoute("GET", url, () => StaticFileResponse(captured, "GET", state.MaxAge)); + app.AddRawRoute("HEAD", url, () => StaticFileResponse(captured, "HEAD", state.MaxAge)); + } + } + } + /// Optional identity middleware — not enabled by default. Add via app.Use(Middleware.FrameworkHeaders()). public static FusionMiddleware FrameworkHeaders() { diff --git a/bindings/csharp/FusionFramework/README.md b/bindings/csharp/FusionFramework/README.md index 508879a..76b8ac1 100644 --- a/bindings/csharp/FusionFramework/README.md +++ b/bindings/csharp/FusionFramework/README.md @@ -119,14 +119,21 @@ Route.Register(typeof(AdminModule), "/api/admin", permissions: new[] { AdminChec ## Static files -Serve CSS/images without custom routes (WhiteNoise-style): +Serve CSS/images without custom routes (WhiteNoise-style). + +- **root**: folder on disk (e.g. `static`) +- **prefix**: URL prefix (e.g. `/static`) + +So `static/logo.png` is available at `/static/logo.png`: ```csharp app.Use(Middleware.StaticFiles(root: "static", prefix: "/static")); // ``` -Use `prefix: "/"` with `fallthrough: true` when assets share the site root with HTML routes. +Use `prefix: "/"` when files should be served at the site root +(`templates/home/a.png` → `/a.png`). Files are mounted as real routes on +`FusionApp.Mount()` / `Listen()`. ## License diff --git a/crates/fusion-node/index.js b/crates/fusion-node/index.js index 47f2429..ec2f5f3 100644 --- a/crates/fusion-node/index.js +++ b/crates/fusion-node/index.js @@ -295,7 +295,11 @@ function guessStaticContentType(filePath) { /** * Serve files from `root` for URLs under `prefix` (WhiteNoise-style). - * Only GET/HEAD; path traversal is rejected. Missing files 404 unless fallthrough. + * + * - root: folder on disk (e.g. 'static') + * - prefix: URL prefix (e.g. '/static' → static/logo.png at /static/logo.png) + * + * Files are also mounted as real GET/HEAD routes on FusionApp.mount()/listen(). */ function staticFiles(options = {}) { const rootDir = path.resolve(String(options.root ?? 'static')) @@ -304,44 +308,79 @@ function staticFiles(options = {}) { const maxAge = options.maxAge === undefined ? 3600 : options.maxAge const allowFallthrough = options.fallthrough === undefined ? normalized === '/' : !!options.fallthrough + const cfg = { root: rootDir, prefix: normalized, maxAge, fallthrough: allowFallthrough } - return (request, callNext) => { - const method = String(request.method || 'GET').toUpperCase() - if (method !== 'GET' && method !== 'HEAD') return callNext(request) - - const reqPath = String(request.path || '/') - let relative = '' - if (normalized === '/') { - relative = reqPath.replace(/^\/+/, '') - if (!relative || relative.endsWith('/')) return callNext(request) - } else { - if (!(reqPath === normalized || reqPath.startsWith(`${normalized}/`))) { - return callNext(request) - } - relative = reqPath.slice(normalized.length).replace(/^\/+/, '') - if (!relative) return callNext(request) - } + const middleware = (request, callNext) => serveStaticOrNext(cfg, request, callNext) + middleware.__fusionStatic = cfg + return middleware +} - const candidate = path.resolve(rootDir, relative) - const relToRoot = path.relative(rootDir, candidate) - if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot)) { - return { status: 403, body: { detail: 'Forbidden' } } - } - if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) { - if (allowFallthrough) return callNext(request) - return { status: 404, body: { detail: 'Not found' } } - } +/** Build a 200 file response envelope. */ +function staticFileResponse(filePath, method, maxAge) { + const size = fs.statSync(filePath).size + const headers = { + 'content-type': guessStaticContentType(filePath), + 'content-length': String(size), + } + if (maxAge !== null && maxAge !== undefined) { + headers['cache-control'] = `public, max-age=${Number(maxAge)}` + } + const body = String(method).toUpperCase() === 'HEAD' ? Buffer.alloc(0) : fs.readFileSync(filePath) + return { status: 200, body, headers } +} + +/** Try to serve a static file; otherwise callNext. */ +function serveStaticOrNext(cfg, request, callNext) { + const method = String(request.method || 'GET').toUpperCase() + if (method !== 'GET' && method !== 'HEAD') return callNext(request) - const size = fs.statSync(candidate).size - const headers = { - 'content-type': guessStaticContentType(candidate), - 'content-length': String(size), + const reqPath = String(request.path || '/') + const normalized = cfg.prefix + let relative = '' + if (normalized === '/') { + relative = reqPath.replace(/^\/+/, '') + if (!relative || relative.endsWith('/')) return callNext(request) + } else { + if (!(reqPath === normalized || reqPath.startsWith(`${normalized}/`))) { + return callNext(request) } - if (maxAge !== null && maxAge !== undefined) { - headers['cache-control'] = `public, max-age=${Number(maxAge)}` + relative = reqPath.slice(normalized.length).replace(/^\/+/, '') + if (!relative) return callNext(request) + } + + const candidate = path.resolve(cfg.root, relative) + const relToRoot = path.relative(cfg.root, candidate) + if (relToRoot.startsWith('..') || path.isAbsolute(relToRoot)) { + return { status: 403, body: { detail: 'Forbidden' } } + } + if (!fs.existsSync(candidate) || !fs.statSync(candidate).isFile()) { + if (cfg.fallthrough) return callNext(request) + return { status: 404, body: { detail: 'Not found' } } + } + return staticFileResponse(candidate, method, cfg.maxAge) +} + +/** Register GET/HEAD routes for files under each staticFiles() mount. */ +function mountStaticFiles(engine, middlewares) { + for (const mw of middlewares || []) { + const cfg = mw && mw.__fusionStatic + if (!cfg || !fs.existsSync(cfg.root) || !fs.statSync(cfg.root).isDirectory()) continue + const walk = (dir) => { + for (const name of fs.readdirSync(dir)) { + const full = path.join(dir, name) + const st = fs.statSync(full) + if (st.isDirectory()) { + walk(full) + continue + } + if (!st.isFile()) continue + const rel = path.relative(cfg.root, full).split(path.sep).join('/') + const url = cfg.prefix === '/' ? `/${rel}` : `${cfg.prefix}/${rel}` + engine.route('GET', url, () => staticFileResponse(full, 'GET', cfg.maxAge)) + engine.route('HEAD', url, () => staticFileResponse(full, 'HEAD', cfg.maxAge)) + } } - const body = method === 'HEAD' ? Buffer.alloc(0) : fs.readFileSync(candidate) - return { status: 200, body, headers } + walk(cfg.root) } } @@ -1297,6 +1336,8 @@ class FusionApp { } } + mountStaticFiles(this.engine, this._middleware) + const swagger = readSwaggerSettings() if (swagger.enabled) { const prefix = swagger.path diff --git a/crates/fusion-py/python/fusion_framework/app.py b/crates/fusion-py/python/fusion_framework/app.py index 554f064..5a3ba79 100644 --- a/crates/fusion-py/python/fusion_framework/app.py +++ b/crates/fusion-py/python/fusion_framework/app.py @@ -403,6 +403,9 @@ def listen( if not self._mounted: set_active_global(self._middleware) self._engine.mount_routes() + from fusion_framework.middleware import mount_static_files + + mount_static_files(self._engine, self._middleware) swagger = _swagger_settings(self.settings) if swagger.get("enabled"): _mount_swagger(self._engine, swagger) diff --git a/crates/fusion-py/python/fusion_framework/middleware.py b/crates/fusion-py/python/fusion_framework/middleware.py index e990ddb..0aebfbd 100644 --- a/crates/fusion-py/python/fusion_framework/middleware.py +++ b/crates/fusion-py/python/fusion_framework/middleware.py @@ -433,55 +433,112 @@ def static_files( ) -> Middleware: """Serve local files under ``root`` for URLs starting with ``prefix`` (WhiteNoise-style). - Only ``GET`` / ``HEAD``. Rejects path traversal. Paths outside ``prefix`` call - the next middleware. Missing files under ``prefix`` return 404 unless - ``fallthrough`` is True (defaults to True when ``prefix`` is ``/`` so HTML - routes still work). + - ``root``: folder on disk that holds the files (e.g. ``\"static\"`` or + ``Path(__file__).parent / \"templates\" / \"home\"``). + - ``prefix``: URL path prefix browsers request (e.g. ``\"/static\"`` so + ``static/logo.png`` is served at ``/static/logo.png``). Use ``\"/\"`` to + serve files at the site root (``templates/home/a.png`` → ``/a.png``). + + On ``FusionApp.listen()``, matching files are mounted as real GET/HEAD routes + (middleware alone cannot see unmatched paths). Only ``GET`` / ``HEAD``. + Path traversal is rejected. The returned middleware also short-circuits when + invoked on an already-mounted path. """ - root_path = Path(root) + root_path = Path(root).expanduser() normalized = "/" + str(prefix).strip("/") if str(prefix).strip("/") else "/" allow_fallthrough = (normalized == "/") if fallthrough is None else bool(fallthrough) + cfg = { + "root": root_path, + "prefix": normalized, + "max_age": max_age, + "fallthrough": allow_fallthrough, + } def middleware(request: RequestDict, call_next: Callable[[RequestDict], Any]) -> Any: - method = str(request.get("method", "GET")).upper() - if method not in ("GET", "HEAD"): + return _serve_static_or_next(cfg, request, call_next) + + middleware.__fusion_static__ = cfg # type: ignore[attr-defined] + return middleware + + +def _serve_static_or_next( + cfg: dict[str, Any], + request: RequestDict, + call_next: Callable[[RequestDict], Any], +) -> Any: + """Try to serve a file for this request; otherwise call the next handler.""" + method = str(request.get("method", "GET")).upper() + if method not in ("GET", "HEAD"): + return call_next(request) + + req_path = str(request.get("path") or "/") + normalized = str(cfg["prefix"]) + if normalized == "/": + relative = req_path.lstrip("/") + if not relative or relative.endswith("/"): + return call_next(request) + else: + if not (req_path == normalized or req_path.startswith(normalized + "/")): + return call_next(request) + relative = req_path[len(normalized) :].lstrip("/") + if not relative: return call_next(request) - req_path = str(request.get("path") or "/") - if normalized == "/": - relative = req_path.lstrip("/") - if not relative or relative.endswith("/"): - return call_next(request) - else: - if not (req_path == normalized or req_path.startswith(normalized + "/")): - return call_next(request) - relative = req_path[len(normalized) :].lstrip("/") - if not relative: - return call_next(request) - - base = root_path.resolve() - candidate = (base / relative).resolve() - try: - candidate.relative_to(base) - except ValueError: - return {"status": 403, "body": {"detail": "Forbidden"}} - - if not candidate.is_file(): - if allow_fallthrough: - return call_next(request) - return {"status": 404, "body": {"detail": "Not found"}} - - size = candidate.stat().st_size - headers = { - "content-type": _guess_static_content_type(candidate), - "content-length": str(size), - } - if max_age is not None: - headers["cache-control"] = f"public, max-age={int(max_age)}" - body: Any = b"" if method == "HEAD" else candidate.read_bytes() - return {"status": 200, "body": body, "headers": headers} + base = Path(cfg["root"]).resolve() + candidate = (base / relative).resolve() + try: + candidate.relative_to(base) + except ValueError: + return {"status": 403, "body": {"detail": "Forbidden"}} - return middleware + if not candidate.is_file(): + if cfg["fallthrough"]: + return call_next(request) + return {"status": 404, "body": {"detail": "Not found"}} + + return _static_file_response(candidate, method, cfg.get("max_age")) + + +def _static_file_response(path: Path, method: str, max_age: int | None) -> dict[str, Any]: + """Build a 200 response envelope for a file on disk.""" + size = path.stat().st_size + headers = { + "content-type": _guess_static_content_type(path), + "content-length": str(size), + } + if max_age is not None: + headers["cache-control"] = f"public, max-age={int(max_age)}" + body: Any = b"" if method.upper() == "HEAD" else path.read_bytes() + return {"status": 200, "body": body, "headers": headers} + + +def mount_static_files(engine: Any, middlewares: Iterable[Middleware]) -> None: + """Register GET/HEAD routes for every file under each ``static_files`` mount.""" + for middleware in middlewares: + cfg = getattr(middleware, "__fusion_static__", None) + if not isinstance(cfg, dict): + continue + root = Path(cfg["root"]).expanduser().resolve() + if not root.is_dir(): + continue + prefix = str(cfg["prefix"]) + max_age = cfg.get("max_age") + for file_path in root.rglob("*"): + if not file_path.is_file(): + continue + rel = file_path.relative_to(root).as_posix() + url = f"/{rel}" if prefix == "/" else f"{prefix}/{rel}" + path_for_get = file_path + path_for_head = file_path + + def _get(_req: RequestDict, p: Path = path_for_get, age: int | None = max_age) -> dict[str, Any]: + return _static_file_response(p, "GET", age) + + def _head(_req: RequestDict, p: Path = path_for_head, age: int | None = max_age) -> dict[str, Any]: + return _static_file_response(p, "HEAD", age) + + engine.route("GET", url, _get) + engine.route("HEAD", url, _head) def dispatch_route( diff --git a/examples/static_files.py b/examples/static_files.py index 8dcda7d..f89bcd5 100644 --- a/examples/static_files.py +++ b/examples/static_files.py @@ -1,14 +1,12 @@ """Serve images/CSS from disk with ``static_files`` (WhiteNoise-style). -Place assets under ``static/`` (or any root) and mount the middleware:: +``root`` = folder on disk · ``prefix`` = URL prefix the browser requests. - app.use(static_files(root="static", prefix="/static")) + static_files(root="static", prefix="/static") + # static/logo.png → /static/logo.png + # -Then HTML can use ```` without a custom route. - -For files next to templates with root-relative URLs:: - - app.use(static_files(root="templates/home", prefix="/", fallthrough=True)) +Files are registered as real GET/HEAD routes when the app listens. """ from pathlib import Path