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
11 changes: 11 additions & 0 deletions bindings/csharp/FusionFramework/BuiltinMiddleware.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace FusionFramework;

/// <summary>Convenience aliases for built-in middleware factories used in scaffolded apps.</summary>
public static class BuiltinMiddleware
{
public static FusionMiddleware FrameworkHeaders() => Middleware.FrameworkHeaders();
public static FusionMiddleware SecurityHeaders() => Middleware.SecurityHeaders();
public static FusionMiddleware Cors() => Middleware.Cors();
public static FusionMiddleware CacheHeaders() => Middleware.CacheHeaders();
public static FusionMiddleware RequestId() => Middleware.RequestId();
}
2 changes: 0 additions & 2 deletions bindings/csharp/FusionFramework/FusionApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ public FusionApp(FusionSettings? settings = null)

settings ??= SettingsStore.Current;
Native.fusion_app_set_settings(_app, settings.Handle);
// Default: advertise Fusion to clients / Wappalyzer-style detectors.
_middleware.Add(Middleware.FrameworkHeaders());
}

public FusionApp Use(FusionMiddleware middleware)
Expand Down
132 changes: 131 additions & 1 deletion bindings/csharp/FusionFramework/Middleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,125 @@ public static FusionMiddleware RequireRoles(
public static FusionMiddleware RequireRoles(params string[] roles) =>
RequireRoles((IEnumerable<string>)roles);

/// <summary>Default identity middleware — advertises Fusion on every response.</summary>
/// <summary>Common security response headers.</summary>
public static FusionMiddleware SecurityHeaders(
string contentTypeOptions = "nosniff",
string frameOptions = "DENY",
string referrerPolicy = "strict-origin-when-cross-origin",
string permissionsPolicy = "camera=(), microphone=(), geolocation=(), payment=()",
string coop = "same-origin",
string corp = "same-origin",
string? csp = null,
string? hsts = null)
{
var extra = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["X-Content-Type-Options"] = contentTypeOptions,
["X-Frame-Options"] = frameOptions,
["Referrer-Policy"] = referrerPolicy,
["Permissions-Policy"] = permissionsPolicy,
["Cross-Origin-Opener-Policy"] = coop,
["Cross-Origin-Resource-Policy"] = corp,
};
if (!string.IsNullOrEmpty(csp)) extra["Content-Security-Policy"] = csp!;
if (!string.IsNullOrEmpty(hsts)) extra["Strict-Transport-Security"] = hsts!;

return (request, callNext) =>
{
var result = ResolveAwaitable(callNext(request));
return MergeResponseHeaders(result, extra);
};
}

/// <summary>Set <c>Cache-Control</c> on responses.</summary>
public static FusionMiddleware CacheHeaders(string defaultValue = "no-store") =>
(request, callNext) =>
{
var result = ResolveAwaitable(callNext(request));
return MergeResponseHeaders(result, new Dictionary<string, string>
{
["Cache-Control"] = defaultValue,
});
};

/// <summary>Echo or generate <c>X-Request-Id</c> on each request.</summary>
public static FusionMiddleware RequestId(string header = "X-Request-Id", bool incoming = true) =>
(request, callNext) =>
{
string? rid = incoming ? GetHeader(request, header) : null;
if (string.IsNullOrEmpty(rid))
rid = Guid.NewGuid().ToString();
EnsureState(request)["request_id"] = rid;
var result = ResolveAwaitable(callNext(request));
return MergeResponseHeaders(result, new Dictionary<string, string> { [header] = rid });
};

/// <summary>CORS middleware; answers <c>OPTIONS</c> preflight with 204.</summary>
public static FusionMiddleware Cors(
IEnumerable<string>? allowOrigins = null,
IEnumerable<string>? allowMethods = null,
IEnumerable<string>? allowHeaders = null,
IEnumerable<string>? exposeHeaders = null,
bool allowCredentials = false,
int maxAge = 600)
{
var origins = (allowOrigins ?? new[] { "*" }).Select(o => o.ToString()).ToList();
var methods = (allowMethods ?? new[]
{
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD",
}).Select(m => m.ToUpperInvariant()).ToList();
var headers = (allowHeaders ?? new[]
{
"Authorization", "Content-Type", "Accept", "Origin", "X-Request-Id",
}).ToList();
var expose = (exposeHeaders ?? new[] { "X-Request-Id" }).ToList();
var allowAll = origins.Contains("*");

Dictionary<string, string> CorsHeaders(string? origin)
{
var chosen = "*";
if (!allowAll)
{
if (!string.IsNullOrEmpty(origin) && origins.Contains(origin))
chosen = origin;
else if (origins.Count > 0)
chosen = origins[0];
}

var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Access-Control-Allow-Origin"] = chosen,
["Access-Control-Allow-Methods"] = string.Join(", ", methods),
["Access-Control-Allow-Headers"] = string.Join(", ", headers),
["Access-Control-Expose-Headers"] = string.Join(", ", expose),
["Access-Control-Max-Age"] = maxAge.ToString(),
["Vary"] = "Origin",
};
if (allowCredentials && chosen != "*")
map["Access-Control-Allow-Credentials"] = "true";
return map;
}

return (request, callNext) =>
{
var origin = GetHeader(request, "Origin");
var extra = CorsHeaders(origin);
if (string.Equals(request.Method, "OPTIONS", StringComparison.OrdinalIgnoreCase))
{
return new Dictionary<string, object?>
{
["status"] = 204,
["body"] = "",
["headers"] = extra,
};
}

var result = ResolveAwaitable(callNext(request));
return MergeResponseHeaders(result, extra);
};
}

/// <summary>Optional identity middleware — not enabled by default. Add via <c>app.Use(Middleware.FrameworkHeaders())</c>.</summary>
public static FusionMiddleware FrameworkHeaders()
{
var extra = Header.Fingerprint();
Expand Down Expand Up @@ -228,6 +346,18 @@ static object Error(int status, string detail) =>
["body"] = new Dictionary<string, object?> { ["detail"] = detail },
};

static string? GetHeader(FusionRequest request, string name)
{
if (request.Headers.TryGetValue(name, out var direct) && !string.IsNullOrEmpty(direct))
return direct;
foreach (var kv in request.Headers)
{
if (string.Equals(kv.Key, name, StringComparison.OrdinalIgnoreCase))
return kv.Value;
}
return null;
}

static byte[] Base64UrlDecode(string input)
{
var s = input.Replace('-', '+').Replace('_', '/');
Expand Down
2 changes: 1 addition & 1 deletion crates/fusion-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ impl App {
let fingerprint = self
.settings
.get_bool("fingerprint.enabled")
.unwrap_or(true);
.unwrap_or(false);
if fingerprint {
server::listen(self.router, addr).await
} else {
Expand Down
4 changes: 2 additions & 2 deletions crates/fusion-core/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ use crate::router::Router;
/// Options for [`listen_with`].
#[derive(Debug, Clone)]
pub struct ListenOptions {
/// Add Fusion identity headers (`X-Powered-By`, …) on every response. Default: true.
/// Add Fusion identity headers (`X-Powered-By`, …) on every response. Default: false.
pub fingerprint: bool,
}

impl Default for ListenOptions {
fn default() -> Self {
Self { fingerprint: true }
Self { fingerprint: false }
}
}

Expand Down
19 changes: 18 additions & 1 deletion crates/fusion-core/src/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ fn collect_templates(
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "html" && ext != "tera" {
if ext != "html" && ext != "tera" && ext != "css" {
continue;
}
let rel = path
Expand Down Expand Up @@ -144,4 +144,21 @@ mod tests {
assert!(html.contains("Go"));
let _ = std::fs::remove_dir_all(&dir);
}

#[test]
fn includes_css_partial() {
clear_template_cache();
let dir = std::env::temp_dir().join("fusion_tpl_css_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("home")).unwrap();
std::fs::write(dir.join("home/style.css"), "body { color: red; }").unwrap();
std::fs::write(
dir.join("home/index.html"),
r#"<style>{% include "home/style.css" %}</style>"#,
)
.unwrap();
let html = render_template("home/index.html", &json!({}), &dir).unwrap();
assert!(html.contains("color: red"));
let _ = std::fs::remove_dir_all(&dir);
}
}
2 changes: 1 addition & 1 deletion crates/fusion-node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const ItemModule = route('/api/[module]/{id}')(
},
)

const MIDDLEWARE = [] // your middleware; Fusion already adds frameworkHeaders() by default
const MIDDLEWARE = [] // add middleware explicitly, e.g. frameworkHeaders()

settings.ensureLoaded()
const app = new FusionApp(getSettings())
Expand Down
27 changes: 27 additions & 0 deletions crates/fusion-node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,33 @@ export type FusionMiddleware = (

export function frameworkHeaders(): FusionMiddleware

export function securityHeaders(options?: {
contentTypeOptions?: string
frameOptions?: string
referrerPolicy?: string
permissionsPolicy?: string
coop?: string
corp?: string
csp?: string
hsts?: string
}): FusionMiddleware

export function cors(options?: {
allowOrigins?: string | string[]
allowMethods?: string[]
allowHeaders?: string[]
exposeHeaders?: string[]
allowCredentials?: boolean
maxAge?: number
}): FusionMiddleware

export function cacheHeaders(options?: { default?: string; value?: string }): FusionMiddleware

export function requestId(options?: {
header?: string
incoming?: boolean
}): FusionMiddleware

export type FusionResponse =
| string
| {
Expand Down
106 changes: 104 additions & 2 deletions crates/fusion-node/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,105 @@ function frameworkHeaders() {
}
}

function getHeader(request, name) {
const headers = request.headers || {}
const target = String(name).toLowerCase()
for (const [key, value] of Object.entries(headers)) {
if (String(key).toLowerCase() === target) return String(value)
}
return null
}

function headerMiddleware(extra) {
return async (request, callNext) => {
const result = await awaitMaybe(callNext(request))
return mergeResponseHeaders(result, extra)
}
}

function securityHeaders(options = {}) {
const extra = {
'X-Content-Type-Options': options.contentTypeOptions ?? 'nosniff',
'X-Frame-Options': options.frameOptions ?? 'DENY',
'Referrer-Policy': options.referrerPolicy ?? 'strict-origin-when-cross-origin',
'Permissions-Policy':
options.permissionsPolicy ?? 'camera=(), microphone=(), geolocation=(), payment=()',
'Cross-Origin-Opener-Policy': options.coop ?? 'same-origin',
'Cross-Origin-Resource-Policy': options.corp ?? 'same-origin',
}
if (options.csp) extra['Content-Security-Policy'] = String(options.csp)
if (options.hsts) extra['Strict-Transport-Security'] = String(options.hsts)
return headerMiddleware(extra)
}

function cacheHeaders(options = {}) {
return headerMiddleware({
'Cache-Control': options.default ?? options.value ?? 'no-store',
})
}

function requestId(options = {}) {
const headerName = options.header ?? 'X-Request-Id'
const incoming = options.incoming !== false
return async (request, callNext) => {
const state = ensureState(request)
let rid = incoming ? getHeader(request, headerName) : null
if (!rid) {
rid =
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
state.request_id = rid
const result = await awaitMaybe(callNext(request))
return mergeResponseHeaders(result, { [headerName]: rid })
}
}

function cors(options = {}) {
const origins = Array.isArray(options.allowOrigins)
? options.allowOrigins.map(String)
: [String(options.allowOrigins ?? '*')]
const methods = (
options.allowMethods ?? ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD']
).map((m) => String(m).toUpperCase())
const allowHeaders = (
options.allowHeaders ?? ['Authorization', 'Content-Type', 'Accept', 'Origin', 'X-Request-Id']
).map(String)
const exposeHeaders = (options.exposeHeaders ?? ['X-Request-Id']).map(String)
const allowCredentials = !!options.allowCredentials
const maxAge = Number(options.maxAge ?? 600)
const allowAll = origins.includes('*')

function corsHeaders(origin) {
let chosen = '*'
if (!allowAll) {
if (origin && origins.includes(origin)) chosen = origin
else if (origins.length) chosen = origins[0]
}
const out = {
'Access-Control-Allow-Origin': chosen,
'Access-Control-Allow-Methods': methods.join(', '),
'Access-Control-Allow-Headers': allowHeaders.join(', '),
'Access-Control-Expose-Headers': exposeHeaders.join(', '),
'Access-Control-Max-Age': String(maxAge),
Vary: 'Origin',
}
if (allowCredentials && chosen !== '*') out['Access-Control-Allow-Credentials'] = 'true'
return out
}

return async (request, callNext) => {
const origin = getHeader(request, 'Origin')
const extra = corsHeaders(origin)
if (String(request.method || 'GET').toUpperCase() === 'OPTIONS') {
return { status: 204, body: '', headers: extra }
}
const result = await awaitMaybe(callNext(request))
return mergeResponseHeaders(result, extra)
}
}

class FusionBaseApi {
constructor(request) {
this.request = request && typeof request === 'object' ? request : emptyRequest()
Expand Down Expand Up @@ -1014,8 +1113,7 @@ class FusionApp {
this.settings = getSettings()
this.engine = new NativeApp()
this.mounted = false
// Default: advertise Fusion to clients / Wappalyzer-style detectors.
this._middleware = [frameworkHeaders()]
this._middleware = []
}

use(middleware) {
Expand Down Expand Up @@ -1162,6 +1260,10 @@ module.exports = {
bearerJwt,
requireRoles,
frameworkHeaders,
securityHeaders,
cors,
cacheHeaders,
requestId,
runMiddlewareChain,
coerceParam,
parsePagination,
Expand Down
Loading
Loading