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("", "<\\/")
@@ -122,10 +152,58 @@ fn escape_json_for_script(value: &str) -> 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#"