Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/fusion-bindings-parity/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions bindings/csharp/FusionFramework/BuiltinMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
1 change: 1 addition & 0 deletions bindings/csharp/FusionFramework/FusionApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ public void Mount()
}
}

Middleware.MountStaticFiles(this, _middleware);
SwaggerDocs.Mount(this, SettingsStore.Current);
}

Expand Down
151 changes: 151 additions & 0 deletions bindings/csharp/FusionFramework/Middleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,157 @@ Dictionary<string, string> CorsHeaders(string? origin)
};
}

static readonly Dictionary<string, string> 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",
};

/// <summary>Map a file extension to Content-Type (octet-stream fallback).</summary>
static string GuessStaticContentType(string path)
{
var ext = Path.GetExtension(path);
return StaticMimeTypes.TryGetValue(ext, out var mime) ? mime : "application/octet-stream";
}

/// <summary>
/// Serve files from <paramref name="root"/> for URLs under <paramref name="prefix"/> (WhiteNoise-style).
/// <paramref name="root"/> is the folder on disk; <paramref name="prefix"/> 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 <see cref="FusionApp.Mount"/>.
/// </summary>
public static FusionMiddleware StaticFiles(
string root = "static",
string prefix = "/static",
int? maxAge = 3600,
bool? fallthrough = null)
{
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<Delegate, StaticFilesState> 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('/');
return string.IsNullOrEmpty(trimmed) ? "/" : "/" + trimmed;
}

static object ServeStaticOrNext(StaticFilesState state, FusionRequest request, Func<FusionRequest, object?> 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 == "/")
{
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 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");
}

if (!File.Exists(candidate))
{
if (state.Fallthrough) return callNext(request)!;
return Error(404, "Not found");
}

return StaticFileResponse(candidate, method, state.MaxAge);
}

static Dictionary<string, object?> StaticFileResponse(string path, string method, int? maxAge)
{
var info = new FileInfo(path);
var headers = new Dictionary<string, string>(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<byte>() : File.ReadAllBytes(path);
return new Dictionary<string, object?>
{
["status"] = 200,
["body"] = body,
["headers"] = headers,
};
}

/// <summary>Register GET/HEAD routes for each <see cref="StaticFiles"/> middleware on the app.</summary>
internal static void MountStaticFiles(FusionApp app, IEnumerable<FusionMiddleware> 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));
}
}
}

/// <summary>Optional identity middleware — not enabled by default. Add via <c>app.Use(Middleware.FrameworkHeaders())</c>.</summary>
public static FusionMiddleware FrameworkHeaders()
{
Expand Down
18 changes: 18 additions & 0 deletions bindings/csharp/FusionFramework/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,24 @@ MIDDLEWARE.Add(Middleware.BearerJwt());
Route.Register(typeof(AdminModule), "/api/admin", permissions: new[] { AdminChecks.IsAdmin });
```

## Static files

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"));
// <img src="/static/logo.png">
```

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

BSD 3-Clause
8 changes: 8 additions & 0 deletions crates/fusion-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
| {
Expand Down
119 changes: 119 additions & 0 deletions crates/fusion-node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,122 @@ 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).
*
* - 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'))
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
const cfg = { root: rootDir, prefix: normalized, maxAge, fallthrough: allowFallthrough }

const middleware = (request, callNext) => serveStaticOrNext(cfg, request, callNext)
middleware.__fusionStatic = cfg
return middleware
}

/** 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 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)
}
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))
}
}
walk(cfg.root)
}
}

class FusionBaseApi {
constructor(request) {
this.request = request && typeof request === 'object' ? request : emptyRequest()
Expand Down Expand Up @@ -1220,6 +1336,8 @@ class FusionApp {
}
}

mountStaticFiles(this.engine, this._middleware)

const swagger = readSwaggerSettings()
if (swagger.enabled) {
const prefix = swagger.path
Expand Down Expand Up @@ -1527,6 +1645,7 @@ module.exports = {
cors,
cacheHeaders,
requestId,
staticFiles,
runMiddlewareChain,
coerceParam,
parsePagination,
Expand Down
2 changes: 2 additions & 0 deletions crates/fusion-py/python/fusion_framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
require_permissions,
require_roles,
security_headers,
static_files,
use,
)
from fusion_framework.pagination import PaginationParams, paginated_body, parse_pagination
Expand All @@ -30,6 +31,7 @@
"require_permissions",
"require_roles",
"security_headers",
"static_files",
"use",
"PaginationParams",
"parse_pagination",
Expand Down
3 changes: 3 additions & 0 deletions crates/fusion-py/python/fusion_framework/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading