use crate::common::config::AppConfig; use crate::common::di::AppState; use axum::Router; use axum::extract::{Request, State}; use axum::http::header::{CACHE_CONTROL, HeaderValue}; use axum::middleware::Next; use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::get_service; use base64::Engine as _; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use std::path::{Path, PathBuf}; use std::sync::Arc; use tower_http::compression::CompressionLayer; use tower_http::services::{ServeDir, ServeFile}; use tower_http::set_header::SetResponseHeaderLayer; /// Resolve the directory the SPA is actually served from. /// /// Prefers the Vite build output (`static-dist/`) sitting next to the configured /// static path, falling back to the configured path itself — the container ships /// the built SPA straight to `OXICLOUD_STATIC_PATH` (default `./static`), so there /// the fallback is what serves. Shared with the CSP layer in `main.rs` so the /// inline-script hashes are computed from exactly the bytes that get served. pub fn resolve_static_path(config: &AppConfig) -> PathBuf { let dist = config .static_path .parent() .unwrap_or(Path::new(".")) .join("static-dist"); if dist.exists() { return dist; } config.static_path.clone() } /// Serves the SvelteKit single-page app. /// /// The frontend is built by Vite into `static-dist/` (repo root). Real files are /// served from disk; any unmatched client route (deep links such as /// `/files/`, `/s/`, `/login`) falls back to the SPA shell /// `index.html`, which boots the client router. /// /// Caching: content-hashed assets under `/_app/immutable` are cached forever; /// everything else — crucially the `index.html` shell — is `no-cache` so a deploy /// can't leave a stale app pinned in browsers. pub fn create_web_routes(app_state: Arc) -> Router> { let config = AppConfig::from_env(); let static_path = resolve_static_path(&config); // SPA fallback: serve the file if it exists, else the app shell. // // `precompressed_*`: if the frontend build emitted a sibling `.br`/`.gz` // (frontend/scripts/precompress.mjs runs at build time), serve those // bytes directly with the right Content-Encoding instead of re-running // Brotli over the same immutable bundle on EVERY request — the // `CompressionLayer` below then skips the already-encoded response and // remains only the fallback for assets without a precompressed sibling // (benches/STATIC-PRECOMPRESSED.md). let spa = ServeDir::new(&static_path) .precompressed_br() .precompressed_gzip() .fallback(ServeFile::new(static_path.join("index.html"))); // Hashed, immutable assets (SvelteKit emits these under /_app/immutable). let app_immutable = ServeDir::new(static_path.join("_app").join("immutable")) .precompressed_br() .precompressed_gzip(); Router::new() .nest_service( "/_app/immutable", get_service(app_immutable).layer(SetResponseHeaderLayer::overriding( CACHE_CONTROL, HeaderValue::from_static("public, max-age=31536000, immutable"), )), ) .fallback_service(spa) // Fallback compression for assets without a precompressed sibling. // Quality 4, NOT the default: the default maps to Brotli q11 — // ~1.3 s of CPU per 700 KiB bundle per request (measured in // benches/STATIC-PRECOMPRESSED.md; the .br siblings above carry the // real q11 bytes, paid once at build time). .layer( CompressionLayer::new() .quality(tower_http::CompressionLevel::Precise(4)) .br(true) .gzip(true), ) // `if_not_present` so the immutable assets above keep their long cache; // the shell itself must always revalidate so a deploy can't pin a stale // app in browsers. .layer(SetResponseHeaderLayer::if_not_present( CACHE_CONTROL, HeaderValue::from_static("no-cache"), )) // Short-circuit `GET /login` to the OIDC authorize endpoint when // the AutoRedirectIfStandaloneOidc policy resolves. Runs BEFORE // the SPA shell is served, so there's no form-then-redirect flash. // The SPA carries the same predicate as belt-and-suspenders for // deep links / browser-cache hits that skip this hop. .layer(axum::middleware::from_fn_with_state( app_state, oidc_standalone_login_redirect, )) } /// Intercept `GET /login` and 302 to `/api/auth/oidc/authorize` when OIDC is /// the only working method (see `AuthApplicationService::auto_redirect_to_oidc`). /// /// Loop-guards mirror the SPA: /// - `?error=…` — the IdP bounced us back; falling through lets the SPA render /// the error rather than looping straight back to the failing IdP. /// - `?oidc_code=…` — the callback landing carries the exchange code; the SPA /// must handle it, not another authorize round-trip. async fn oidc_standalone_login_redirect( State(state): State>, req: Request, next: Next, ) -> Response { if req.method() == axum::http::Method::GET && req.uri().path() == "/login" { let has_loop_guard_param = req .uri() .query() .map(|q| { q.split('&') .any(|p| p.starts_with("error=") || p.starts_with("oidc_code=")) }) .unwrap_or(false); let should_redirect = !has_loop_guard_param && state .auth_service .as_ref() .map(|svc| svc.auth_application_service.auto_redirect_to_oidc()) .unwrap_or(false); if should_redirect { return Redirect::temporary("/api/auth/oidc/authorize").into_response(); } } next.run(req).await } /// Build the `content-security-policy` header value served on every response. /// /// `script-src` stays strict — `'self'` with **no** `'unsafe-inline'` — and /// additionally lists a `'sha256-…'` source for each inline ``, so /// each shell is read verbatim and that slice hashed. Scripts carrying a `src` /// attribute are external (already allowed by `'self'`) and skipped. Only the /// directory root is scanned — the SPA is client-rendered (SSR/prerender off), /// so the only inline-script shell is `index.html`. Returns a deduplicated, /// sorted list; empty when the dir is unreadable /// (e.g. a Vite dev server serving HTML on its own port instead). fn inline_script_csp_hashes(static_path: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(static_path) else { return Vec::new(); }; let mut hashes = BTreeSet::new(); for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("html") { continue; } let Ok(html) = std::fs::read_to_string(&path) else { continue; }; for script in inline_scripts(&html) { hashes.insert(csp_hash(script)); } } hashes.into_iter().collect() } /// The CSP `'sha256-'` source expression for one inline script body. fn csp_hash(script: &str) -> String { let digest = Sha256::digest(script.as_bytes()); let encoded = base64::engine::general_purpose::STANDARD.encode(digest); format!("'sha256-{encoded}'") } /// Text content of every inline ``, and emit the /// wrong hash — the real inline script then fails CSP with `script-src 'self'`. fn inline_scripts(html: &str) -> Vec<&str> { let mut scripts = Vec::new(); let mut cursor = 0; while cursor < html.len() { let tail = &html[cursor..]; // Skip past HTML comments — they may contain the literal // string `") else { break; }; let content_end = content_start + close_rel; if !opening_tag_has_src(open_tag) { scripts.push(&html[content_start..content_end]); } cursor = content_end + "".len(); } scripts } /// Whether a `"; assert_eq!(inline_scripts(html), vec!["\n alert(1);\n"]); } #[test] fn skips_external_src_scripts() { let html = r#""#; assert_eq!(inline_scripts(html), vec!["boot();"]); } #[test] fn keeps_inline_module_skips_module_with_src() { let html = r#""#; assert_eq!(inline_scripts(html), vec!["go();"]); } #[test] fn case_insensitive_tag_matching() { let html = ""; assert_eq!(inline_scripts(html), vec!["run();"]); } #[test] fn empty_inline_script_hash_matches_known_sha256_vector() { // SHA-256 of the empty string, base64 — the canonical empty digest. assert_eq!( csp_hash(""), "'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='" ); } #[test] fn identical_scripts_produce_one_deduplicated_hash() { let html = ""; let mut set = BTreeSet::new(); for s in inline_scripts(html) { set.insert(csp_hash(s)); } assert_eq!(set.len(), 1); } #[test] fn html_comment_mentioning_script_does_not_poison_scanner() { // The SvelteKit shell has an explanatory comment referring to // `\n", "\n", ); let scripts = inline_scripts(html); assert_eq!(scripts, vec!["alert(1);", "boot();"]); } #[test] fn unterminated_comment_bails_out_gracefully() { // Malformed input: `