diff --git a/fluxer_app_proxy/src/main.rs b/fluxer_app_proxy/src/main.rs index d66131df3..084198914 100644 --- a/fluxer_app_proxy/src/main.rs +++ b/fluxer_app_proxy/src/main.rs @@ -6,7 +6,7 @@ use fluxer_app_proxy::{ csp::CompiledCspPolicy, discovery_cache::DiscoveryCache, geoip, - routes::build_router, + routes::{build_router, present_local_asset_prefixes}, state::{ AppProxyBudgets, AppState, MAX_SPA_INDEX_BYTES, build_http_client, read_bounded_text_file, }, @@ -67,6 +67,11 @@ fn main() -> anyhow::Result<()> { None }; + let local_asset_prefixes = config + .index_upstream_url + .is_none() + .then(|| present_local_asset_prefixes(&config.static_dir)); + let state = AppState { config, csp, @@ -74,6 +79,7 @@ fn main() -> anyhow::Result<()> { discovery_cache, geoip, index_html, + local_asset_prefixes, budgets: AppProxyBudgets::default(), }; diff --git a/fluxer_app_proxy/src/routes/assets_proxy.rs b/fluxer_app_proxy/src/routes/assets_proxy.rs index d572f2b6d..f3c3efaec 100644 --- a/fluxer_app_proxy/src/routes/assets_proxy.rs +++ b/fluxer_app_proxy/src/routes/assets_proxy.rs @@ -510,6 +510,7 @@ mod tests { client_ip_header_name: "x-forwarded-for".to_owned(), })), index_html: None, + local_asset_prefixes: None, budgets: crate::state::AppProxyBudgets::default(), } } diff --git a/fluxer_app_proxy/src/routes/client_geoip.rs b/fluxer_app_proxy/src/routes/client_geoip.rs index ce7183c52..f3ea1a75c 100644 --- a/fluxer_app_proxy/src/routes/client_geoip.rs +++ b/fluxer_app_proxy/src/routes/client_geoip.rs @@ -54,6 +54,7 @@ mod tests { client_ip_header_name: "x-forwarded-for".to_owned(), })), index_html: Some(Arc::from("")), + local_asset_prefixes: None, budgets: crate::state::AppProxyBudgets::default(), } } diff --git a/fluxer_app_proxy/src/routes/health.rs b/fluxer_app_proxy/src/routes/health.rs index 34b580f6a..31581e682 100644 --- a/fluxer_app_proxy/src/routes/health.rs +++ b/fluxer_app_proxy/src/routes/health.rs @@ -77,6 +77,7 @@ mod tests { client_ip_header_name: "x-forwarded-for".to_owned(), })), index_html: None, + local_asset_prefixes: None, budgets: crate::state::AppProxyBudgets::default(), } } diff --git a/fluxer_app_proxy/src/routes/mod.rs b/fluxer_app_proxy/src/routes/mod.rs index a328ce980..3a119ab6d 100644 --- a/fluxer_app_proxy/src/routes/mod.rs +++ b/fluxer_app_proxy/src/routes/mod.rs @@ -9,6 +9,8 @@ mod health; mod spa_index; mod spa_static; +pub use spa_index::present_local_asset_prefixes; + use crate::state::AppState; use axum::{ Router, diff --git a/fluxer_app_proxy/src/routes/spa_index.rs b/fluxer_app_proxy/src/routes/spa_index.rs index 2b42cb463..023a4f866 100644 --- a/fluxer_app_proxy/src/routes/spa_index.rs +++ b/fluxer_app_proxy/src/routes/spa_index.rs @@ -17,6 +17,7 @@ use axum::{ response::{IntoResponse, Response}, }; use std::path::Path; +use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use super::assets_proxy::serve_local_asset; @@ -45,7 +46,14 @@ pub async fn spa_catch_all( ) .await; } - if is_static_asset_path(request_path) { + if let Some(prefix) = static_asset_prefix(request_path) { + if state + .local_asset_prefixes + .as_ref() + .is_some_and(|present| !present.contains(&prefix)) + { + return StatusCode::NOT_FOUND.into_response(); + } return serve_local_asset( &state.budgets, &state.config.static_dir, @@ -80,10 +88,23 @@ fn static_root_file_cache_control(request_path: &str) -> Option<&'static str> { .map(|(_, cache_control)| *cache_control) } -fn is_static_asset_path(request_path: &str) -> bool { +fn static_asset_prefix(request_path: &str) -> Option<&'static str> { STATIC_ASSET_PREFIXES .iter() - .any(|prefix| request_path.starts_with(prefix)) + .copied() + .find(|prefix| request_path.starts_with(prefix)) +} + +pub fn present_local_asset_prefixes(static_dir: &str) -> Arc<[&'static str]> { + STATIC_ASSET_PREFIXES + .iter() + .copied() + .filter(|prefix| { + Path::new(static_dir) + .join(prefix.trim_matches('/')) + .is_dir() + }) + .collect() } async fn serve_static_file( @@ -609,11 +630,11 @@ mod tests { use crate::config::{AppProxyConfig, ReleaseChannel}; use crate::discovery_cache::DiscoveryCache; + use crate::state::LOCAL_FILE_READS_IN_FLIGHT_MAX; 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() { @@ -823,6 +844,79 @@ mod tests { assert!(served.contains("window.__FLUXER_BOOTSTRAP__")); } + fn static_dir_with(prefix_dirs: &[&str]) -> std::path::PathBuf { + static NEXT_DIR: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let unique = NEXT_DIR.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let root = std::env::temp_dir().join(format!( + "fluxer-static-prefixes-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&root).unwrap(); + for dir in prefix_dirs { + std::fs::create_dir_all(root.join(dir)).unwrap(); + } + root + } + + #[test] + fn only_prefixes_with_a_directory_on_disk_are_present() { + let root = static_dir_with(&["emoji", "web"]); + std::fs::write(root.join("badges"), b"a file, not a directory").unwrap(); + + let present = present_local_asset_prefixes(root.to_str().unwrap()); + + assert_eq!(&*present, &["/emoji/", "/web/"]); + std::fs::remove_dir_all(root).unwrap(); + } + + #[tokio::test] + async fn a_prefix_missing_at_startup_is_refused_without_a_file_read_slot() { + let mut state = spa_state_serving(ReleaseChannel::Stable, Some(SHIPPED_APP_SHELL)).await; + state.local_asset_prefixes = Some(Arc::from([] as [&str; 0])); + let _every_slot = state + .budgets + .local_read_slots + .clone() + .try_acquire_many_owned(LOCAL_FILE_READS_IN_FLIGHT_MAX as u32) + .unwrap(); + + let response = spa_catch_all( + State(state), + HeaderMap::new(), + Request::builder() + .uri("/emoji/1f600.svg") + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn a_prefix_present_at_startup_is_still_served_from_disk() { + let root = static_dir_with(&["emoji"]); + std::fs::write(root.join("emoji").join("1f600.svg"), b"").unwrap(); + let mut state = spa_state_serving(ReleaseChannel::Stable, Some(SHIPPED_APP_SHELL)).await; + let mut config = (*state.config).clone(); + config.static_dir = root.to_str().unwrap().to_owned(); + state.local_asset_prefixes = Some(present_local_asset_prefixes(&config.static_dir)); + state.config = Arc::new(config); + + let response = spa_catch_all( + State(state), + HeaderMap::new(), + Request::builder() + .uri("/emoji/1f600.svg") + .body(Body::empty()) + .unwrap(), + ) + .await; + + assert_eq!(response.status(), StatusCode::OK); + std::fs::remove_dir_all(root).unwrap(); + } + #[tokio::test] async fn the_official_instance_keeps_its_link_preview_metadata() { let state = spa_state_serving(ReleaseChannel::Stable, Some(SHIPPED_APP_SHELL)).await; @@ -923,6 +1017,7 @@ mod tests { client_ip_header_name: "x-forwarded-for".to_owned(), })), index_html: cached_shell.map(Arc::from), + local_asset_prefixes: None, budgets: crate::state::AppProxyBudgets::default(), } } diff --git a/fluxer_app_proxy/src/state.rs b/fluxer_app_proxy/src/state.rs index 54a32bac6..345d1283d 100644 --- a/fluxer_app_proxy/src/state.rs +++ b/fluxer_app_proxy/src/state.rs @@ -44,6 +44,7 @@ pub struct AppState { pub discovery_cache: Arc, pub geoip: Arc, pub index_html: Option>, + pub local_asset_prefixes: Option>, pub budgets: AppProxyBudgets, }