From 82eb87bc47c0dca96a04aea0bb42740cc42ca614 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 24 Aug 2026 12:26:21 +0200 Subject: [PATCH] perf(app-proxy): preconnect the media origin and revalidate the app shell (#1827) --- fluxer_app/index.html | 2 + fluxer_app_proxy/src/bootstrap.rs | 186 ++++- fluxer_app_proxy/src/routes/assets_proxy.rs | 508 +++++++++++++- fluxer_app_proxy/src/routes/spa_index.rs | 742 +++++++++++++++++++- fluxer_app_proxy/src/routes/spa_static.rs | 39 +- 5 files changed, 1427 insertions(+), 50 deletions(-) diff --git a/fluxer_app/index.html b/fluxer_app/index.html index 00beb4605..c979f75e0 100644 --- a/fluxer_app/index.html +++ b/fluxer_app/index.html @@ -7,6 +7,8 @@ + + diff --git a/fluxer_app_proxy/src/bootstrap.rs b/fluxer_app_proxy/src/bootstrap.rs index d2631108f..1dd4b6a89 100644 --- a/fluxer_app_proxy/src/bootstrap.rs +++ b/fluxer_app_proxy/src/bootstrap.rs @@ -67,17 +67,26 @@ pub fn build_bootstrap_script( ) } +const MEDIA_PRECONNECT_TAG: &str = r#""#; +const STATIC_PRECONNECT_TAGS: [&str; 2] = [ + r#""#, + r#""#, +]; + pub fn inject_bootstrap( html: &str, nonce: &str, script_tag: &str, static_cdn_endpoint: &str, + media_endpoint: &str, ) -> String { + let static_cdn = static_cdn_endpoint.trim_end_matches('/'); + let media = media_endpoint.trim_end_matches('/'); + let nonced = html.replace("{{CSP_NONCE_PLACEHOLDER}}", nonce); - let nonced = nonced.replace( - "{{STATIC_CDN_ENDPOINT}}", - static_cdn_endpoint.trim_end_matches('/'), - ); + let nonced = apply_static_preconnect(&nonced, static_cdn); + let nonced = nonced.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn); + let nonced = apply_media_preconnect(&nonced, media, static_cdn); if nonced.contains("") { return nonced.replace("", script_tag); @@ -111,6 +120,27 @@ pub fn inject_bootstrap( nonced } +fn apply_static_preconnect(html: &str, static_cdn: &str) -> String { + if !static_cdn.is_empty() { + return html.to_owned(); + } + let mut stripped = html.to_owned(); + for tag in STATIC_PRECONNECT_TAGS { + stripped = stripped.replace(&format!("{tag}\n"), "").replace(tag, ""); + } + stripped +} + +fn apply_media_preconnect(html: &str, media: &str, static_cdn: &str) -> String { + if media.is_empty() || media == static_cdn { + return html + .replace(&format!("{MEDIA_PRECONNECT_TAG}\n"), "") + .replace(MEDIA_PRECONNECT_TAG, "") + .replace("{{MEDIA_ENDPOINT}}", ""); + } + html.replace("{{MEDIA_ENDPOINT}}", media) +} + fn escape_json_for_script(value: &str) -> String { value .replace(" String { mod tests { use super::*; + const SHIPPED_APP_SHELL: &str = include_str!("../../fluxer_app/index.html"); + + fn inject_into_shipped_shell() -> String { + inject_bootstrap( + SHIPPED_APP_SHELL, + "shellnonce", + "", + "https://cdn.example.test/", + "https://media.example.test/", + ) + } + + #[test] + fn shipped_shell_declares_both_static_cdn_socket_pools() { + assert!( + SHIPPED_APP_SHELL + .contains(r#""#), + "fluxer_app/index.html has no anonymous preconnect for its module scripts and fonts" + ); + assert!( + SHIPPED_APP_SHELL.contains(r#""#), + "fluxer_app/index.html lost the credentialed preconnect its stylesheet and icons use" + ); + } + + #[test] + fn shipped_shell_serves_three_distinct_preconnects() { + let result = inject_into_shipped_shell(); + assert!(result.contains(r#""#)); + assert!( + result + .contains(r#""#) + ); + assert!(result.contains(r#""#)); + assert_eq!(result.matches("preconnect").count(), 3); + } + + #[test] + fn shipped_shell_ships_no_unsubstituted_placeholder() { + let result = inject_into_shipped_shell(); + assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}")); + assert!(!result.contains("{{MEDIA_ENDPOINT}}")); + assert!(!result.contains("{{CSP_NONCE_PLACEHOLDER}}")); + assert!(!result.contains("{{FLUXER_BOOTSTRAP}}")); + assert!(result.contains("")); + assert!(result.contains(r#"nonce="shellnonce""#)); + } + #[test] fn inject_bootstrap_before_head_close() { let html = "App"; - let result = inject_bootstrap(html, "abc123", "", ""); + let result = inject_bootstrap(html, "abc123", "", "", ""); assert!(result.contains("")); assert!(result.contains("")); } @@ -133,7 +211,7 @@ mod tests { #[test] fn inject_bootstrap_fluxer_placeholder() { let html = "{{FLUXER_BOOTSTRAP}}"; - let result = inject_bootstrap(html, "n1", "", ""); + let result = inject_bootstrap(html, "n1", "", "", ""); assert!(result.contains("")); assert!(!result.contains("{{FLUXER_BOOTSTRAP}}")); } @@ -141,7 +219,7 @@ mod tests { #[test] fn inject_bootstrap_comment_placeholder() { let html = ""; - let result = inject_bootstrap(html, "n2", "", ""); + let result = inject_bootstrap(html, "n2", "", "", ""); assert!(result.contains("")); assert!(!result.contains("")); } @@ -149,7 +227,7 @@ mod tests { #[test] fn inject_bootstrap_replaces_csp_nonce_placeholder() { let html = r#"{{FLUXER_BOOTSTRAP}}"#; - let result = inject_bootstrap(html, "mynonce", "", ""); + let result = inject_bootstrap(html, "mynonce", "", "", ""); assert!(result.contains(r#"nonce="mynonce""#)); assert!(!result.contains("{{CSP_NONCE_PLACEHOLDER}}")); } @@ -162,11 +240,103 @@ mod tests { "nonce", "", "https://cdn.example.test/", + "", ); assert!(result.contains(r#"href="https://cdn.example.test/web/favicon-32x32.png""#)); assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}")); } + #[test] + fn inject_bootstrap_replaces_media_endpoint_placeholder() { + let html = r#" + +{{FLUXER_BOOTSTRAP}}"#; + let result = inject_bootstrap( + html, + "nonce", + "", + "https://cdn.example.test/", + "https://media.example.test/", + ); + assert!(result.contains(r#""#)); + assert!(result.contains(r#""#)); + assert!(!result.contains("{{MEDIA_ENDPOINT}}")); + } + + #[test] + fn inject_bootstrap_drops_media_preconnect_when_endpoint_is_empty() { + let html = r#" + +{{FLUXER_BOOTSTRAP}}"#; + let result = inject_bootstrap( + html, + "nonce", + "", + "https://cdn.example.test", + "", + ); + assert_eq!(result.matches("preconnect").count(), 1); + assert!(!result.contains("{{MEDIA_ENDPOINT}}")); + assert!(!result.contains(r#"href="">"#)); + } + + #[test] + fn inject_bootstrap_drops_media_preconnect_when_it_matches_the_static_cdn() { + let html = r#" + +{{FLUXER_BOOTSTRAP}}"#; + let result = inject_bootstrap( + html, + "nonce", + "", + "https://cdn.example.test", + "https://cdn.example.test/", + ); + assert_eq!(result.matches("preconnect").count(), 1); + assert!(!result.contains("{{MEDIA_ENDPOINT}}")); + } + + const SHELL_PRECONNECT_HEAD: &str = r#" + + +{{FLUXER_BOOTSTRAP}}"#; + + #[test] + fn static_cdn_keeps_a_credentialed_and_an_anonymous_preconnect() { + let result = inject_bootstrap( + SHELL_PRECONNECT_HEAD, + "nonce", + "", + "https://cdn.example.test/", + "https://media.example.test", + ); + assert!(result.contains(r#""#)); + assert!( + result + .contains(r#""#) + ); + assert_eq!(result.matches("preconnect").count(), 3); + } + + #[test] + fn both_static_preconnects_are_dropped_when_the_endpoint_is_empty() { + let result = inject_bootstrap( + SHELL_PRECONNECT_HEAD, + "nonce", + "", + "", + "https://media.example.test", + ); + assert_eq!(result.matches("preconnect").count(), 1); + assert!(!result.contains(r#"href="""#)); + assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}")); + } + + #[test] + fn media_preconnect_carries_no_crossorigin_attribute() { + assert!(!MEDIA_PRECONNECT_TAG.contains("crossorigin")); + } + #[test] fn escape_json_for_script_escapes_closing_script() { assert_eq!(escape_json_for_script(""), "<\\/script>"); diff --git a/fluxer_app_proxy/src/routes/assets_proxy.rs b/fluxer_app_proxy/src/routes/assets_proxy.rs index 767d05608..7d516d388 100644 --- a/fluxer_app_proxy/src/routes/assets_proxy.rs +++ b/fluxer_app_proxy/src/routes/assets_proxy.rs @@ -11,10 +11,18 @@ use axum::{ use std::path::Path as FsPath; use std::time::Duration; -use super::spa_static::{CORS_ALLOW_ANY_VALUE, guess_mime, is_font_mime, is_hashed_asset}; +use super::spa_static::{CORS_ALLOW_ANY_VALUE, asset_cache_control, guess_mime, is_font_mime}; const ASSET_REQUEST_TIMEOUT: Duration = Duration::from_secs(15); const MAX_ASSET_SIZE_BYTES: u64 = 100 * 1024 * 1024; +const UPSTREAM_FAILURE_CACHE_CONTROL: &str = "no-store"; +const UPSTREAM_FAILURE_STRIPPED_HEADERS: &[&str] = &[ + "cdn-cache-control", + "cloudflare-cdn-cache-control", + "surrogate-control", + "expires", + "age", +]; const BLOCKED_REQUEST_HEADERS: &[&str] = &[ "accept-encoding", @@ -50,7 +58,12 @@ pub async fn proxy_assets( request: axum::extract::Request, ) -> Response { let Some(cdn_endpoint) = &state.config.static_cdn_endpoint else { - return serve_local_asset(&state.config.static_dir, &format!("assets/{path}")).await; + return serve_local_asset( + &state.config.static_dir, + &format!("assets/{path}"), + request.headers(), + ) + .await; }; let target_url = format!("{cdn_endpoint}/assets/{path}"); @@ -95,7 +108,8 @@ pub async fn proxy_assets( return StatusCode::PAYLOAD_TOO_LARGE.into_response(); } - let status = upstream_response.status(); + let status = StatusCode::from_u16(upstream_response.status().as_u16()) + .unwrap_or(StatusCode::BAD_GATEWAY); let mut response_headers = axum::http::HeaderMap::new(); for (name, value) in upstream_response.headers() { @@ -110,6 +124,7 @@ pub async fn proxy_assets( } set_known_asset_content_type(&mut response_headers, &path); set_font_cors(&mut response_headers); + set_proxied_cache_control(&mut response_headers, &path, status); let asset_csp = build_asset_csp( &state.config.csp, @@ -128,13 +143,16 @@ pub async fn proxy_assets( let body = Body::from_stream(upstream_response.bytes_stream()); let mut response = Response::new(body); - *response.status_mut() = - StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + *response.status_mut() = status; *response.headers_mut() = response_headers; response } -async fn serve_local_asset(static_dir: &str, relative_path: &str) -> Response { +async fn serve_local_asset( + static_dir: &str, + relative_path: &str, + request_headers: &HeaderMap, +) -> Response { let file_path = FsPath::new(static_dir).join(relative_path); let resolved = match tokio::fs::canonicalize(&file_path).await { @@ -150,6 +168,19 @@ async fn serve_local_asset(static_dir: &str, relative_path: &str) -> Response { return StatusCode::NOT_FOUND.into_response(); } + let entity_tag = tokio::fs::metadata(&resolved) + .await + .ok() + .and_then(|metadata| local_asset_entity_tag(&metadata)); + + if let Some(entity_tag) = entity_tag.as_deref() + && if_none_match_matches(request_headers, entity_tag) + { + let mut response = StatusCode::NOT_MODIFIED.into_response(); + set_local_asset_headers(response.headers_mut(), relative_path, Some(entity_tag)); + return response; + } + let content = match tokio::fs::read(&resolved).await { Ok(bytes) => bytes, Err(err) if err.kind() == std::io::ErrorKind::NotFound => { @@ -166,22 +197,65 @@ async fn serve_local_asset(static_dir: &str, relative_path: &str) -> Response { if let Ok(value) = HeaderValue::from_str(mime_type) { response.headers_mut().insert(header::CONTENT_TYPE, value); } - if is_font_mime(mime_type) { - response.headers_mut().insert( + set_local_asset_headers(response.headers_mut(), relative_path, entity_tag.as_deref()); + response +} + +fn set_local_asset_headers(headers: &mut HeaderMap, relative_path: &str, entity_tag: Option<&str>) { + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(asset_cache_control(relative_path)), + ); + if is_font_mime(guess_mime(relative_path)) { + headers.insert( header::ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static(CORS_ALLOW_ANY_VALUE), ); } - let cache_control = if is_hashed_asset(relative_path) { - "public, max-age=31536000, immutable" - } else { - "public, max-age=3600, must-revalidate" + if let Some(entity_tag) = entity_tag + && let Ok(value) = HeaderValue::from_str(entity_tag) + { + headers.insert(header::ETAG, value); + } +} + +fn local_asset_entity_tag(metadata: &std::fs::Metadata) -> Option { + let modified = metadata.modified().ok()?; + let nanos = modified + .duration_since(std::time::UNIX_EPOCH) + .ok()? + .as_nanos(); + Some(format!("\"{nanos:x}-{:x}\"", metadata.len())) +} + +fn if_none_match_matches(headers: &HeaderMap, entity_tag: &str) -> bool { + let Some(header_value) = headers + .get(header::IF_NONE_MATCH) + .and_then(|value| value.to_str().ok()) + else { + return false; }; - response.headers_mut().insert( + header_value.split(',').any(|candidate| { + let candidate = candidate.trim(); + candidate == "*" || candidate.trim_start_matches("W/") == entity_tag + }) +} + +fn set_proxied_cache_control(headers: &mut HeaderMap, path: &str, status: StatusCode) { + if status.is_success() || status == StatusCode::NOT_MODIFIED { + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(asset_cache_control(path)), + ); + return; + } + headers.insert( header::CACHE_CONTROL, - HeaderValue::from_static(cache_control), + HeaderValue::from_static(UPSTREAM_FAILURE_CACHE_CONTROL), ); - response + for name in UPSTREAM_FAILURE_STRIPPED_HEADERS { + headers.remove(*name); + } } fn set_font_cors(headers: &mut HeaderMap) { @@ -210,7 +284,411 @@ fn set_known_asset_content_type(headers: &mut HeaderMap, path: &str) { #[cfg(test)] mod tests { + use super::super::spa_static::{ + LONG_LIVED_ASSET_CACHE_CONTROL, REVALIDATED_ASSET_CACHE_CONTROL, is_hashed_asset, + }; use super::*; + use crate::config::AppProxyConfig; + use crate::discovery_cache::DiscoveryCache; + use axum::Router; + use axum::http::Request as HttpRequest; + use axum::http::header::HeaderName; + use fluxer_common::config::GeoipSourceConfig; + use fluxer_common::geoip::{GeoipConfig, GeoipResolver}; + use std::sync::Arc; + + async fn spawn_upstream(status: StatusCode, cache_control: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let router = Router::new().fallback(move || async move { + let mut response = Response::new(Body::from("upstream-bytes")); + *response.status_mut() = status; + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static(cache_control), + ); + response + }); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{addr}") + } + + fn upstream_backed_state(cdn_endpoint: &str) -> AppState { + let mut config = AppProxyConfig::from_env(); + config.static_cdn_endpoint = Some(cdn_endpoint.to_owned()); + AppState { + config: Arc::new(config), + http_client: reqwest::Client::new(), + discovery_cache: Arc::new(DiscoveryCache::new()), + geoip: Arc::new(GeoipResolver::from_config(&GeoipConfig { + geoip_source: GeoipSourceConfig::Filesystem { + maxmind_db_path: None, + }, + geoip_s3_config: None, + trust_client_ip_header: false, + client_ip_header_name: "x-forwarded-for".to_owned(), + })), + invite_meta: None, + index_html: None, + } + } + + async fn proxied_asset( + status: StatusCode, + upstream_cache_control: &'static str, + asset_path: &str, + ) -> Response { + let endpoint = spawn_upstream(status, upstream_cache_control).await; + let state = upstream_backed_state(&endpoint); + let request = HttpRequest::builder() + .uri(format!("/assets/{asset_path}")) + .body(Body::empty()) + .unwrap(); + proxy_assets(State(state), Path(asset_path.to_owned()), request).await + } + + fn cache_control_of(response: &Response) -> Option<&str> { + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()) + } + + #[tokio::test] + async fn a_proxied_asset_overrides_a_shorter_upstream_lifetime() { + let response = proxied_asset( + StatusCode::OK, + "public, max-age=3600, must-revalidate", + "2d715e4730758083.worker.js", + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + cache_control_of(&response), + Some(LONG_LIVED_ASSET_CACHE_CONTROL) + ); + } + + #[tokio::test] + async fn an_asset_without_a_content_hash_is_never_promised_to_never_change() { + let response = proxied_asset( + StatusCode::OK, + "public, max-age=31536000, immutable", + "voice_engine_bg.wasm", + ) + .await; + + assert_eq!( + cache_control_of(&response), + Some(REVALIDATED_ASSET_CACHE_CONTROL), + "a stable filename can be redeployed over, so it must stay revalidatable" + ); + } + + #[tokio::test] + async fn revalidated_hashed_asset_keeps_our_lifetime_on_not_modified() { + let response = proxied_asset( + StatusCode::NOT_MODIFIED, + "public, max-age=60", + "2d715e4730758083.worker.js", + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + assert_eq!( + cache_control_of(&response), + Some(LONG_LIVED_ASSET_CACHE_CONTROL) + ); + } + + #[tokio::test] + async fn an_asset_without_a_content_hash_keeps_our_policy_on_not_modified() { + let response = proxied_asset( + StatusCode::NOT_MODIFIED, + "public, max-age=31536000, immutable", + "voice_engine_bg.wasm", + ) + .await; + + assert_eq!( + cache_control_of(&response), + Some(REVALIDATED_ASSET_CACHE_CONTROL) + ); + } + + #[tokio::test] + async fn upstream_failure_is_never_stamped_with_an_asset_lifetime() { + let response = proxied_asset( + StatusCode::NOT_FOUND, + "no-store", + "2d715e4730758083.worker.js", + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(cache_control_of(&response), Some("no-store")); + } + + #[tokio::test] + async fn a_not_found_carrying_a_long_upstream_lifetime_is_rewritten_to_no_store() { + let response = proxied_asset( + StatusCode::NOT_FOUND, + "public, max-age=31536000, immutable", + "2d715e4730758083.worker.js", + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + cache_control_of(&response), + Some(UPSTREAM_FAILURE_CACHE_CONTROL), + "a cdn or bucket error page with its own year would pin the miss for a year" + ); + } + + #[tokio::test] + async fn a_bad_gateway_carrying_a_long_upstream_lifetime_is_rewritten_to_no_store() { + let response = proxied_asset( + StatusCode::BAD_GATEWAY, + "public, max-age=604800", + "2d715e4730758083.worker.js", + ) + .await; + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + assert_eq!( + cache_control_of(&response), + Some(UPSTREAM_FAILURE_CACHE_CONTROL) + ); + } + + #[tokio::test] + async fn a_server_error_carrying_a_long_upstream_lifetime_is_rewritten_to_no_store() { + let response = proxied_asset( + StatusCode::INTERNAL_SERVER_ERROR, + "public, max-age=86400, immutable", + "voice_engine_bg.wasm", + ) + .await; + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + cache_control_of(&response), + Some(UPSTREAM_FAILURE_CACHE_CONTROL) + ); + } + + #[test] + fn a_failure_drops_the_cdn_lifetimes_a_success_keeps() { + let long_lived = || { + let mut headers = HeaderMap::new(); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + headers.insert( + HeaderName::from_static("cdn-cache-control"), + HeaderValue::from_static("public, max-age=31536000"), + ); + headers.insert( + header::EXPIRES, + HeaderValue::from_static("Thu, 31 Dec 2099 23:59:59 GMT"), + ); + headers + }; + + let mut ok = long_lived(); + set_proxied_cache_control(&mut ok, "2d715e4730758083.worker.js", StatusCode::OK); + assert!( + ok.contains_key("cdn-cache-control"), + "positive control: a real asset still reaches the cdn with its own long lifetime" + ); + assert!(ok.contains_key(header::EXPIRES)); + + let mut failed = long_lived(); + set_proxied_cache_control( + &mut failed, + "2d715e4730758083.worker.js", + StatusCode::NOT_FOUND, + ); + assert_eq!( + failed + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some(UPSTREAM_FAILURE_CACHE_CONTROL) + ); + assert!( + !failed.contains_key("cdn-cache-control"), + "a cdn honours cdn-cache-control over cache-control, so the error would still be pinned" + ); + assert!(!failed.contains_key(header::EXPIRES)); + } + + struct LocalAssetDir { + root: std::path::PathBuf, + } + + impl LocalAssetDir { + fn with_asset(name: &str, bytes: &[u8]) -> Self { + let unique = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let root = std::env::temp_dir().join(format!("fluxer-local-asset-{unique}-{name}")); + std::fs::create_dir_all(root.join("assets")).unwrap(); + std::fs::write(root.join("assets").join(name), bytes).unwrap(); + Self { root } + } + + fn dir(&self) -> &str { + self.root.to_str().unwrap() + } + } + + impl Drop for LocalAssetDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + fn entity_tag_of(response: &Response) -> Option { + response + .headers() + .get(header::ETAG) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned) + } + + fn cors_origin_of(response: &Response) -> Option<&str> { + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|value| value.to_str().ok()) + } + + #[tokio::test] + async fn local_font_revalidation_keeps_cross_origin_access() { + let fixture = LocalAssetDir::with_asset("0018072843a46dc4.woff2", b"wOF2stub"); + + let first = serve_local_asset( + fixture.dir(), + "assets/0018072843a46dc4.woff2", + &HeaderMap::new(), + ) + .await; + assert_eq!(cors_origin_of(&first), Some(CORS_ALLOW_ANY_VALUE)); + let entity_tag = entity_tag_of(&first).expect("first response carries a validator"); + + let mut conditional = HeaderMap::new(); + conditional.insert( + header::IF_NONE_MATCH, + HeaderValue::from_str(&entity_tag).unwrap(), + ); + let second = + serve_local_asset(fixture.dir(), "assets/0018072843a46dc4.woff2", &conditional).await; + + assert_eq!(second.status(), StatusCode::NOT_MODIFIED); + assert_eq!( + cors_origin_of(&second), + Some(CORS_ALLOW_ANY_VALUE), + "a 304 without the CORS header fails the cross-origin font fetch the 200 allowed" + ); + } + + #[tokio::test] + async fn local_hashed_asset_is_served_with_an_entity_tag() { + let fixture = LocalAssetDir::with_asset("356aaade04a117b1.js", b"console.log(1)"); + + let response = serve_local_asset( + fixture.dir(), + "assets/356aaade04a117b1.js", + &HeaderMap::new(), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + cache_control_of(&response), + Some(LONG_LIVED_ASSET_CACHE_CONTROL) + ); + assert!( + entity_tag_of(&response).is_some(), + "a year-long asset with no validator forces a full re-download on any revalidation" + ); + } + + #[tokio::test] + async fn local_asset_revalidation_returns_not_modified() { + let fixture = LocalAssetDir::with_asset("f00dcafe12345678.css", b"body{}"); + + let first = serve_local_asset( + fixture.dir(), + "assets/f00dcafe12345678.css", + &HeaderMap::new(), + ) + .await; + let entity_tag = entity_tag_of(&first).expect("first response carries a validator"); + + let mut conditional = HeaderMap::new(); + conditional.insert( + header::IF_NONE_MATCH, + HeaderValue::from_str(&entity_tag).unwrap(), + ); + let second = + serve_local_asset(fixture.dir(), "assets/f00dcafe12345678.css", &conditional).await; + + assert_eq!(second.status(), StatusCode::NOT_MODIFIED); + assert_eq!(entity_tag_of(&second).as_deref(), Some(entity_tag.as_str())); + assert_eq!( + cache_control_of(&second), + Some(LONG_LIVED_ASSET_CACHE_CONTROL) + ); + } + + #[tokio::test] + async fn local_asset_with_a_stale_entity_tag_is_resent_in_full() { + let fixture = LocalAssetDir::with_asset("voice_engine_bg.wasm", b"\0asm"); + + let mut conditional = HeaderMap::new(); + conditional.insert( + header::IF_NONE_MATCH, + HeaderValue::from_static("\"stale-from-a-previous-build\""), + ); + let response = + serve_local_asset(fixture.dir(), "assets/voice_engine_bg.wasm", &conditional).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + cache_control_of(&response), + Some(REVALIDATED_ASSET_CACHE_CONTROL) + ); + assert!( + entity_tag_of(&response).is_some(), + "a year-long asset with no validator forces a full re-download on any revalidation" + ); + } + + #[tokio::test] + async fn a_local_content_hashed_asset_is_promised_to_never_change() { + let fixture = LocalAssetDir::with_asset("2d715e4730758083.worker.js", b"self.onmessage=0"); + + let response = serve_local_asset( + fixture.dir(), + "assets/2d715e4730758083.worker.js", + &HeaderMap::new(), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + cache_control_of(&response), + Some(LONG_LIVED_ASSET_CACHE_CONTROL) + ); + assert!(is_hashed_asset("assets/2d715e4730758083.worker.js")); + } #[test] fn known_js_asset_overrides_upstream_octet_stream() { diff --git a/fluxer_app_proxy/src/routes/spa_index.rs b/fluxer_app_proxy/src/routes/spa_index.rs index 41b2b75a5..487765422 100644 --- a/fluxer_app_proxy/src/routes/spa_index.rs +++ b/fluxer_app_proxy/src/routes/spa_index.rs @@ -19,7 +19,7 @@ use axum::{ use std::path::Path; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use super::spa_static::{CORS_ALLOW_ANY_VALUE, guess_mime, is_font_mime, is_hashed_asset}; +use super::spa_static::{CORS_ALLOW_ANY_VALUE, guess_mime, is_font_mime}; const ACCEPT_CH_VALUE: &str = "DPR, Sec-CH-DPR, Sec-CH-Width, Save-Data, ECT, Downlink"; const CRITICAL_CH_VALUE: &str = "Sec-CH-DPR, Sec-CH-Width, Save-Data"; @@ -32,22 +32,29 @@ pub async fn spa_catch_all( ) -> Response { let request_path = request.uri().path(); - if is_static_root_file(request_path) { - return serve_static_file(&state.config.static_dir, request_path).await; + if let Some(cache_control) = static_root_file_cache_control(request_path) { + return serve_static_file(&state.config.static_dir, request_path, cache_control).await; } serve_spa_index(&state, &headers, request_path).await } -const STATIC_ROOT_FILES: &[&str] = &["/robots.txt"]; +const CRAWL_CONTROL_CACHE_CONTROL: &str = "public, max-age=300, must-revalidate"; -fn is_static_root_file(request_path: &str) -> bool { +const STATIC_ROOT_FILES: &[(&str, &str)] = &[("/robots.txt", CRAWL_CONTROL_CACHE_CONTROL)]; + +fn static_root_file_cache_control(request_path: &str) -> Option<&'static str> { STATIC_ROOT_FILES .iter() - .any(|candidate| request_path.eq_ignore_ascii_case(candidate)) + .find(|(candidate, _)| request_path.eq_ignore_ascii_case(candidate)) + .map(|(_, cache_control)| *cache_control) } -async fn serve_static_file(static_dir: &str, request_path: &str) -> Response { +async fn serve_static_file( + static_dir: &str, + request_path: &str, + cache_control: &'static str, +) -> Response { let file_path = Path::new(static_dir).join(request_path.trim_start_matches('/')); let resolved = match tokio::fs::canonicalize(&file_path).await { @@ -75,11 +82,6 @@ async fn serve_static_file(static_dir: &str, request_path: &str) -> Response { }; let mime_type = guess_mime(request_path); - let cache_control = if is_hashed_asset(request_path) { - "public, max-age=31536000, immutable" - } else { - "public, max-age=3600, must-revalidate" - }; let mut response = content.into_response(); if let Ok(ct) = HeaderValue::from_str(mime_type) { @@ -118,19 +120,23 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap, request_path: &s .static_cdn_endpoint .as_deref() .unwrap_or(""); + let media_endpoint = runtime_csp_sources.media_endpoint.as_deref().unwrap_or(""); let csp = build_csp(&state.config.csp, &nonce, &runtime_csp_sources); let geoip = build_geoip_response(state.geoip.lookup(headers)); let script_tag = build_bootstrap_script(&state.config, &discovery, &geoip, &nonce); if let Some(snapshot) = should_serve_frozen(&time_freeze) { let frozen_html = String::from_utf8_lossy(&snapshot.index_html); - let mut html = inject_bootstrap(&frozen_html, &nonce, &script_tag, static_cdn_endpoint); - if let Some(meta) = &invite_meta { - html = inject_invite_meta(&html, meta); - } - if should_bust_dev_assets { - html = append_dev_asset_cache_buster(&html, ¤t_dev_asset_cache_buster()); - } + let dev_buster = should_bust_dev_assets.then(current_dev_asset_cache_buster); + let html = render_spa_document( + &frozen_html, + &nonce, + &script_tag, + static_cdn_endpoint, + media_endpoint, + invite_meta.as_ref(), + dev_buster.as_deref(), + ); return build_spa_response(html, &csp, debug_header.as_deref(), should_bust_dev_assets); } @@ -139,16 +145,39 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap, request_path: &s Err(response) => return response, }; - let mut html = inject_bootstrap(&raw_html, &nonce, &script_tag, static_cdn_endpoint); - if let Some(meta) = &invite_meta { - html = inject_invite_meta(&html, meta); - } - if should_bust_dev_assets { - html = append_dev_asset_cache_buster(&html, ¤t_dev_asset_cache_buster()); - } + let dev_buster = should_bust_dev_assets.then(current_dev_asset_cache_buster); + let html = render_spa_document( + &raw_html, + &nonce, + &script_tag, + static_cdn_endpoint, + media_endpoint, + invite_meta.as_ref(), + dev_buster.as_deref(), + ); build_spa_response(html, &csp, debug_header.as_deref(), should_bust_dev_assets) } +fn render_spa_document( + html: &str, + nonce: &str, + script_tag: &str, + static_cdn_endpoint: &str, + media_endpoint: &str, + invite_meta: Option<&InvitePageMeta>, + dev_asset_cache_buster: Option<&str>, +) -> String { + let mut document = + inject_bootstrap(html, nonce, script_tag, static_cdn_endpoint, media_endpoint); + if let Some(meta) = invite_meta { + document = inject_invite_meta(&document, meta); + } + if let Some(buster) = dev_asset_cache_buster { + document = append_dev_asset_cache_buster(&document, buster); + } + document +} + async fn refresh_discovery_for_spa(state: &AppState) -> Option { state .discovery_cache @@ -466,8 +495,21 @@ fn append_cache_buster_query(value: &str, buster: &str) -> String { #[cfg(test)] mod tests { + use super::super::spa_static::LONG_LIVED_ASSET_CACHE_CONTROL; use super::*; + fn is_static_root_file(request_path: &str) -> bool { + static_root_file_cache_control(request_path).is_some() + } + + use crate::config::{AppProxyConfig, ReleaseChannel}; + use crate::discovery_cache::DiscoveryCache; + use axum::Router; + use axum::body::Body; + use fluxer_common::config::GeoipSourceConfig; + use fluxer_common::geoip::{GeoipConfig, GeoipResolver}; + use std::sync::Arc; + #[test] fn dev_asset_cache_buster_rewrites_script_and_link_assets() { let html = r#""#; @@ -516,6 +558,654 @@ mod tests { assert!(!is_static_root_file("/users/1.2.3")); } + const SHELL_WITH_A_NONCE_HOLE: &str = r#"Fluxer"#; + + fn sample_invite_meta() -> InvitePageMeta { + InvitePageMeta { + title: "Join Sample Space".to_owned(), + description: "A sample invite".to_owned(), + image_url: None, + } + } + + #[test] + fn the_rendered_document_always_carries_the_bootstrap_and_a_real_nonce() { + let rendered = render_spa_document( + SHELL_WITH_A_NONCE_HOLE, + "reqnonce", + "", + "https://static.example.test", + "", + None, + None, + ); + + assert!(!rendered.contains("{{CSP_NONCE_PLACEHOLDER}}")); + assert!(rendered.contains(r#"nonce="reqnonce""#)); + assert!(rendered.contains("")); + } + + #[test] + fn invite_metadata_reaches_the_rendered_document_only_when_resolved() { + let meta = sample_invite_meta(); + let with_meta = render_spa_document( + SHELL_WITH_A_NONCE_HOLE, + "reqnonce", + "", + "", + "", + Some(&meta), + None, + ); + let without_meta = render_spa_document( + SHELL_WITH_A_NONCE_HOLE, + "reqnonce", + "", + "", + "", + None, + None, + ); + + assert!(with_meta.contains("Join Sample Space")); + assert!(with_meta.contains("og:title")); + assert!(!without_meta.contains("Join Sample Space")); + assert!(!without_meta.contains("og:title")); + } + + #[test] + fn the_dev_cache_buster_reaches_the_rendered_document_only_when_supplied() { + let busted = render_spa_document( + SHELL_WITH_A_NONCE_HOLE, + "reqnonce", + "", + "", + "", + None, + Some("9911"), + ); + let untouched = render_spa_document( + SHELL_WITH_A_NONCE_HOLE, + "reqnonce", + "", + "", + "", + None, + None, + ); + + assert!(busted.contains(r#"src="/assets/app.js?_=9911""#)); + assert!(untouched.contains(r#"src="/assets/app.js""#)); + assert!(!untouched.contains("_=9911")); + } + + #[cfg(feature = "time-freeze")] + #[test] + fn the_frozen_shell_is_never_served_with_an_unfilled_nonce_or_a_missing_bootstrap() { + let frozen = String::from_utf8_lossy(&crate::frozen_snapshots::STABLE_SNAPSHOT.index_html) + .into_owned(); + assert!( + frozen.contains("{{CSP_NONCE_PLACEHOLDER}}"), + "the captured shell should still carry the hole this test proves we fill" + ); + + let served = render_spa_document( + &frozen, + "frozennonce", + "", + "https://fluxerstatic.com", + "", + None, + None, + ); + + assert!( + !served.contains("{{CSP_NONCE_PLACEHOLDER}}"), + "a frozen-served shell shipped a literal nonce placeholder" + ); + assert!( + served.contains(r#"nonce="frozennonce""#), + "a frozen-served shell lost its per-request nonce" + ); + assert!( + served.contains(""), + "a frozen-served shell shipped without the bootstrap script" + ); + } + + const SHELL_WITH_ENDPOINT_HOLES: &str = r#"Fluxer + + +"#; + + #[test] + fn the_static_cdn_argument_resolves_every_hole_the_shell_carries() { + let rendered = render_spa_document( + SHELL_WITH_ENDPOINT_HOLES, + "reqnonce", + "", + "https://cdn.example.test/", + "https://media.example.test", + None, + None, + ); + + assert!( + rendered + .contains(r#""#), + "the static CDN argument never reached the anonymous preconnect" + ); + assert!( + rendered.contains(r#""#), + "the static CDN argument never reached the credentialed preconnect" + ); + assert!( + rendered.contains(r#"href="https://cdn.example.test/web/favicon-32x32.png""#), + "the favicon href was not resolved against the static CDN argument" + ); + assert!( + rendered.contains(r#"href="https://cdn.example.test/web/apple-touch-icon.png""#), + "the touch-icon href was not resolved against the static CDN argument" + ); + assert!(!rendered.contains("{{STATIC_CDN_ENDPOINT}}")); + assert_eq!(rendered.matches("preconnect").count(), 3); + } + + #[test] + fn the_media_argument_is_resolved_and_weighed_against_the_static_cdn() { + let distinct = render_spa_document( + SHELL_WITH_ENDPOINT_HOLES, + "reqnonce", + "", + "https://cdn.example.test", + "https://media.example.test/", + None, + None, + ); + assert!( + distinct.contains(r#""#), + "the media argument never reached the media preconnect" + ); + assert!(!distinct.contains("{{MEDIA_ENDPOINT}}")); + assert_eq!(distinct.matches("preconnect").count(), 3); + + let shared = render_spa_document( + SHELL_WITH_ENDPOINT_HOLES, + "reqnonce", + "", + "https://cdn.example.test", + "https://cdn.example.test", + None, + None, + ); + assert!( + shared.contains(r#""#), + "the static preconnects must survive a media endpoint that collapses onto them" + ); + assert_eq!( + shared.matches("preconnect").count(), + 2, + "a media endpoint equal to the static CDN must not warm a third socket" + ); + } + + const DISCOVERY_BODY_WITH_BOTH_ENDPOINTS: &str = r#"{"api_code_version":"proxy-test","endpoints":{"static_cdn":"https://cdn.example.test","media":"https://media.example.test"}}"#; + + const DISCOVERY_BODY_WITHOUT_ENDPOINTS: &str = r#"{"api_code_version":"proxy-test"}"#; + + async fn spawn_local_origin(payload: &'static str, content_type: &'static str) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let router = Router::new().fallback(move || async move { + let mut response = Response::new(Body::from(payload)); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + response + }); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + format!("http://{addr}/") + } + + async fn spa_state_serving(channel: ReleaseChannel, cached_shell: Option<&str>) -> AppState { + assemble_spa_state( + channel, + cached_shell, + DISCOVERY_BODY_WITH_BOTH_ENDPOINTS, + None, + None, + ) + .await + } + + async fn spa_state_reading_its_shell_from(index_upstream_url: String) -> AppState { + assemble_spa_state( + ReleaseChannel::Stable, + None, + DISCOVERY_BODY_WITH_BOTH_ENDPOINTS, + None, + Some(index_upstream_url), + ) + .await + } + + async fn spa_state_without_discovered_endpoints(static_cdn_fallback: Option<&str>) -> AppState { + assemble_spa_state( + ReleaseChannel::Canary, + Some(SHELL_WITH_ENDPOINT_HOLES), + DISCOVERY_BODY_WITHOUT_ENDPOINTS, + static_cdn_fallback, + None, + ) + .await + } + + async fn assemble_spa_state( + channel: ReleaseChannel, + cached_shell: Option<&str>, + discovery_body: &'static str, + static_cdn_fallback: Option<&str>, + index_upstream_url: Option, + ) -> AppState { + let discovery_upstream_url = spawn_local_origin(discovery_body, "application/json").await; + let mut config = AppProxyConfig::from_env(); + config.release_channel = channel; + config.time_freeze_enabled = true; + config.index_upstream_url = index_upstream_url; + config.static_cdn_endpoint = static_cdn_fallback.map(ToOwned::to_owned); + config.trust_client_ip_header = false; + config.discovery_upstream_url = discovery_upstream_url; + + AppState { + config: Arc::new(config), + http_client: reqwest::Client::new(), + discovery_cache: Arc::new(DiscoveryCache::new()), + geoip: Arc::new(GeoipResolver::from_config(&GeoipConfig { + geoip_source: GeoipSourceConfig::Filesystem { + maxmind_db_path: None, + }, + geoip_s3_config: None, + trust_client_ip_header: false, + client_ip_header_name: "x-forwarded-for".to_owned(), + })), + invite_meta: None, + index_html: cached_shell.map(Arc::from), + } + } + + fn nonce_granted_by(response: &Response) -> String { + let policy = response + .headers() + .get(header::CONTENT_SECURITY_POLICY) + .expect("the document was served without a content security policy") + .to_str() + .unwrap(); + let opening = policy + .find("'nonce-") + .expect("the content security policy granted no nonce at all"); + let remainder = &policy[opening + "'nonce-".len()..]; + let closing = remainder + .find('\'') + .expect("the content security policy left its nonce source unterminated"); + remainder[..closing].to_owned() + } + + async fn read_document(response: Response) -> String { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + String::from_utf8(bytes.to_vec()).unwrap() + } + + #[tokio::test] + async fn the_live_branch_serves_a_rendered_document_and_not_the_raw_shell() { + let state = + spa_state_serving(ReleaseChannel::Canary, Some(SHELL_WITH_ENDPOINT_HOLES)).await; + + let response = serve_spa_index(&state, &HeaderMap::new(), "/channels/@me").await; + assert_eq!(response.status(), StatusCode::OK); + let granted_nonce = nonce_granted_by(&response); + let served = read_document(response).await; + + assert!( + !served.contains("{{CSP_NONCE_PLACEHOLDER}}"), + "the live branch shipped an unfilled nonce hole" + ); + assert!( + served.contains(&format!( + r#"