mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-02 21:04:06 +03:00
perf(app-proxy): preconnect the media origin and revalidate the app shell (#1827)
This commit is contained in:
@@ -7,6 +7,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content, maximum-scale=1, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content, maximum-scale=1, user-scalable=no">
|
||||||
<meta name="description" content="Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.">
|
<meta name="description" content="Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.">
|
||||||
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
|
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
|
||||||
|
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>
|
||||||
|
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png">
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="{{STATIC_CDN_ENDPOINT}}/web/apple-touch-icon.png">
|
<link rel="apple-touch-icon" sizes="180x180" href="{{STATIC_CDN_ENDPOINT}}/web/apple-touch-icon.png">
|
||||||
|
|||||||
@@ -67,17 +67,26 @@ pub fn build_bootstrap_script(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MEDIA_PRECONNECT_TAG: &str = r#"<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">"#;
|
||||||
|
const STATIC_PRECONNECT_TAGS: [&str; 2] = [
|
||||||
|
r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">"#,
|
||||||
|
r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>"#,
|
||||||
|
];
|
||||||
|
|
||||||
pub fn inject_bootstrap(
|
pub fn inject_bootstrap(
|
||||||
html: &str,
|
html: &str,
|
||||||
nonce: &str,
|
nonce: &str,
|
||||||
script_tag: &str,
|
script_tag: &str,
|
||||||
static_cdn_endpoint: &str,
|
static_cdn_endpoint: &str,
|
||||||
|
media_endpoint: &str,
|
||||||
) -> String {
|
) -> 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 = html.replace("{{CSP_NONCE_PLACEHOLDER}}", nonce);
|
||||||
let nonced = nonced.replace(
|
let nonced = apply_static_preconnect(&nonced, static_cdn);
|
||||||
"{{STATIC_CDN_ENDPOINT}}",
|
let nonced = nonced.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn);
|
||||||
static_cdn_endpoint.trim_end_matches('/'),
|
let nonced = apply_media_preconnect(&nonced, media, static_cdn);
|
||||||
);
|
|
||||||
|
|
||||||
if nonced.contains("<!--{{FLUXER_BOOTSTRAP}}-->") {
|
if nonced.contains("<!--{{FLUXER_BOOTSTRAP}}-->") {
|
||||||
return nonced.replace("<!--{{FLUXER_BOOTSTRAP}}-->", script_tag);
|
return nonced.replace("<!--{{FLUXER_BOOTSTRAP}}-->", script_tag);
|
||||||
@@ -111,6 +120,27 @@ pub fn inject_bootstrap(
|
|||||||
nonced
|
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 {
|
fn escape_json_for_script(value: &str) -> String {
|
||||||
value
|
value
|
||||||
.replace("</", "<\\/")
|
.replace("</", "<\\/")
|
||||||
@@ -122,10 +152,58 @@ fn escape_json_for_script(value: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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",
|
||||||
|
"<script>boot</script>",
|
||||||
|
"https://cdn.example.test/",
|
||||||
|
"https://media.example.test/",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shipped_shell_declares_both_static_cdn_socket_pools() {
|
||||||
|
assert!(
|
||||||
|
SHIPPED_APP_SHELL
|
||||||
|
.contains(r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>"#),
|
||||||
|
"fluxer_app/index.html has no anonymous preconnect for its module scripts and fonts"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
SHIPPED_APP_SHELL.contains(r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">"#),
|
||||||
|
"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#"<link rel="preconnect" href="https://cdn.example.test">"#));
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.contains(r#"<link rel="preconnect" href="https://cdn.example.test" crossorigin>"#)
|
||||||
|
);
|
||||||
|
assert!(result.contains(r#"<link rel="preconnect" href="https://media.example.test">"#));
|
||||||
|
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("<script>boot</script>"));
|
||||||
|
assert!(result.contains(r#"nonce="shellnonce""#));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inject_bootstrap_before_head_close() {
|
fn inject_bootstrap_before_head_close() {
|
||||||
let html = "<html><head><title>App</title></head><body></body></html>";
|
let html = "<html><head><title>App</title></head><body></body></html>";
|
||||||
let result = inject_bootstrap(html, "abc123", "<script>boot</script>", "");
|
let result = inject_bootstrap(html, "abc123", "<script>boot</script>", "", "");
|
||||||
assert!(result.contains("<script>boot</script>"));
|
assert!(result.contains("<script>boot</script>"));
|
||||||
assert!(result.contains("<head>"));
|
assert!(result.contains("<head>"));
|
||||||
}
|
}
|
||||||
@@ -133,7 +211,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn inject_bootstrap_fluxer_placeholder() {
|
fn inject_bootstrap_fluxer_placeholder() {
|
||||||
let html = "<html><head>{{FLUXER_BOOTSTRAP}}</head></html>";
|
let html = "<html><head>{{FLUXER_BOOTSTRAP}}</head></html>";
|
||||||
let result = inject_bootstrap(html, "n1", "<script>x</script>", "");
|
let result = inject_bootstrap(html, "n1", "<script>x</script>", "", "");
|
||||||
assert!(result.contains("<script>x</script>"));
|
assert!(result.contains("<script>x</script>"));
|
||||||
assert!(!result.contains("{{FLUXER_BOOTSTRAP}}"));
|
assert!(!result.contains("{{FLUXER_BOOTSTRAP}}"));
|
||||||
}
|
}
|
||||||
@@ -141,7 +219,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn inject_bootstrap_comment_placeholder() {
|
fn inject_bootstrap_comment_placeholder() {
|
||||||
let html = "<html><head><!--{{FLUXER_BOOTSTRAP}}--></head></html>";
|
let html = "<html><head><!--{{FLUXER_BOOTSTRAP}}--></head></html>";
|
||||||
let result = inject_bootstrap(html, "n2", "<script>y</script>", "");
|
let result = inject_bootstrap(html, "n2", "<script>y</script>", "", "");
|
||||||
assert!(result.contains("<script>y</script>"));
|
assert!(result.contains("<script>y</script>"));
|
||||||
assert!(!result.contains("<!--{{FLUXER_BOOTSTRAP}}-->"));
|
assert!(!result.contains("<!--{{FLUXER_BOOTSTRAP}}-->"));
|
||||||
}
|
}
|
||||||
@@ -149,7 +227,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn inject_bootstrap_replaces_csp_nonce_placeholder() {
|
fn inject_bootstrap_replaces_csp_nonce_placeholder() {
|
||||||
let html = r#"<html><head><script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script>{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
let html = r#"<html><head><script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script>{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
||||||
let result = inject_bootstrap(html, "mynonce", "<script>z</script>", "");
|
let result = inject_bootstrap(html, "mynonce", "<script>z</script>", "", "");
|
||||||
assert!(result.contains(r#"nonce="mynonce""#));
|
assert!(result.contains(r#"nonce="mynonce""#));
|
||||||
assert!(!result.contains("{{CSP_NONCE_PLACEHOLDER}}"));
|
assert!(!result.contains("{{CSP_NONCE_PLACEHOLDER}}"));
|
||||||
}
|
}
|
||||||
@@ -162,11 +240,103 @@ mod tests {
|
|||||||
"nonce",
|
"nonce",
|
||||||
"<script>boot</script>",
|
"<script>boot</script>",
|
||||||
"https://cdn.example.test/",
|
"https://cdn.example.test/",
|
||||||
|
"",
|
||||||
);
|
);
|
||||||
assert!(result.contains(r#"href="https://cdn.example.test/web/favicon-32x32.png""#));
|
assert!(result.contains(r#"href="https://cdn.example.test/web/favicon-32x32.png""#));
|
||||||
assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}"));
|
assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inject_bootstrap_replaces_media_endpoint_placeholder() {
|
||||||
|
let html = r#"<html><head><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
|
||||||
|
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
|
||||||
|
{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
||||||
|
let result = inject_bootstrap(
|
||||||
|
html,
|
||||||
|
"nonce",
|
||||||
|
"<script>boot</script>",
|
||||||
|
"https://cdn.example.test/",
|
||||||
|
"https://media.example.test/",
|
||||||
|
);
|
||||||
|
assert!(result.contains(r#"<link rel="preconnect" href="https://media.example.test">"#));
|
||||||
|
assert!(result.contains(r#"<link rel="preconnect" href="https://cdn.example.test">"#));
|
||||||
|
assert!(!result.contains("{{MEDIA_ENDPOINT}}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inject_bootstrap_drops_media_preconnect_when_endpoint_is_empty() {
|
||||||
|
let html = r#"<html><head><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
|
||||||
|
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
|
||||||
|
{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
||||||
|
let result = inject_bootstrap(
|
||||||
|
html,
|
||||||
|
"nonce",
|
||||||
|
"<script>boot</script>",
|
||||||
|
"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#"<html><head><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
|
||||||
|
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
|
||||||
|
{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
||||||
|
let result = inject_bootstrap(
|
||||||
|
html,
|
||||||
|
"nonce",
|
||||||
|
"<script>boot</script>",
|
||||||
|
"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#"<html><head><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
|
||||||
|
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>
|
||||||
|
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
|
||||||
|
{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn static_cdn_keeps_a_credentialed_and_an_anonymous_preconnect() {
|
||||||
|
let result = inject_bootstrap(
|
||||||
|
SHELL_PRECONNECT_HEAD,
|
||||||
|
"nonce",
|
||||||
|
"<script>boot</script>",
|
||||||
|
"https://cdn.example.test/",
|
||||||
|
"https://media.example.test",
|
||||||
|
);
|
||||||
|
assert!(result.contains(r#"<link rel="preconnect" href="https://cdn.example.test">"#));
|
||||||
|
assert!(
|
||||||
|
result
|
||||||
|
.contains(r#"<link rel="preconnect" href="https://cdn.example.test" crossorigin>"#)
|
||||||
|
);
|
||||||
|
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",
|
||||||
|
"<script>boot</script>",
|
||||||
|
"",
|
||||||
|
"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]
|
#[test]
|
||||||
fn escape_json_for_script_escapes_closing_script() {
|
fn escape_json_for_script_escapes_closing_script() {
|
||||||
assert_eq!(escape_json_for_script("</script>"), "<\\/script>");
|
assert_eq!(escape_json_for_script("</script>"), "<\\/script>");
|
||||||
|
|||||||
@@ -11,10 +11,18 @@ use axum::{
|
|||||||
use std::path::Path as FsPath;
|
use std::path::Path as FsPath;
|
||||||
use std::time::Duration;
|
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 ASSET_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||||
const MAX_ASSET_SIZE_BYTES: u64 = 100 * 1024 * 1024;
|
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] = &[
|
const BLOCKED_REQUEST_HEADERS: &[&str] = &[
|
||||||
"accept-encoding",
|
"accept-encoding",
|
||||||
@@ -50,7 +58,12 @@ pub async fn proxy_assets(
|
|||||||
request: axum::extract::Request,
|
request: axum::extract::Request,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let Some(cdn_endpoint) = &state.config.static_cdn_endpoint else {
|
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}");
|
let target_url = format!("{cdn_endpoint}/assets/{path}");
|
||||||
@@ -95,7 +108,8 @@ pub async fn proxy_assets(
|
|||||||
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
|
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();
|
let mut response_headers = axum::http::HeaderMap::new();
|
||||||
|
|
||||||
for (name, value) in upstream_response.headers() {
|
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_known_asset_content_type(&mut response_headers, &path);
|
||||||
set_font_cors(&mut response_headers);
|
set_font_cors(&mut response_headers);
|
||||||
|
set_proxied_cache_control(&mut response_headers, &path, status);
|
||||||
|
|
||||||
let asset_csp = build_asset_csp(
|
let asset_csp = build_asset_csp(
|
||||||
&state.config.csp,
|
&state.config.csp,
|
||||||
@@ -128,13 +143,16 @@ pub async fn proxy_assets(
|
|||||||
|
|
||||||
let body = Body::from_stream(upstream_response.bytes_stream());
|
let body = Body::from_stream(upstream_response.bytes_stream());
|
||||||
let mut response = Response::new(body);
|
let mut response = Response::new(body);
|
||||||
*response.status_mut() =
|
*response.status_mut() = status;
|
||||||
StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
|
|
||||||
*response.headers_mut() = response_headers;
|
*response.headers_mut() = response_headers;
|
||||||
response
|
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 file_path = FsPath::new(static_dir).join(relative_path);
|
||||||
|
|
||||||
let resolved = match tokio::fs::canonicalize(&file_path).await {
|
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();
|
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 {
|
let content = match tokio::fs::read(&resolved).await {
|
||||||
Ok(bytes) => bytes,
|
Ok(bytes) => bytes,
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
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) {
|
if let Ok(value) = HeaderValue::from_str(mime_type) {
|
||||||
response.headers_mut().insert(header::CONTENT_TYPE, value);
|
response.headers_mut().insert(header::CONTENT_TYPE, value);
|
||||||
}
|
}
|
||||||
if is_font_mime(mime_type) {
|
set_local_asset_headers(response.headers_mut(), relative_path, entity_tag.as_deref());
|
||||||
response.headers_mut().insert(
|
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,
|
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||||
HeaderValue::from_static(CORS_ALLOW_ANY_VALUE),
|
HeaderValue::from_static(CORS_ALLOW_ANY_VALUE),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
let cache_control = if is_hashed_asset(relative_path) {
|
if let Some(entity_tag) = entity_tag
|
||||||
"public, max-age=31536000, immutable"
|
&& let Ok(value) = HeaderValue::from_str(entity_tag)
|
||||||
} else {
|
{
|
||||||
"public, max-age=3600, must-revalidate"
|
headers.insert(header::ETAG, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn local_asset_entity_tag(metadata: &std::fs::Metadata) -> Option<String> {
|
||||||
|
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,
|
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) {
|
fn set_font_cors(headers: &mut HeaderMap) {
|
||||||
@@ -210,7 +284,411 @@ fn set_known_asset_content_type(headers: &mut HeaderMap, path: &str) {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::super::spa_static::{
|
||||||
|
LONG_LIVED_ASSET_CACHE_CONTROL, REVALIDATED_ASSET_CACHE_CONTROL, is_hashed_asset,
|
||||||
|
};
|
||||||
use super::*;
|
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<String> {
|
||||||
|
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]
|
#[test]
|
||||||
fn known_js_asset_overrides_upstream_octet_stream() {
|
fn known_js_asset_overrides_upstream_octet_stream() {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use axum::{
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
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 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";
|
const CRITICAL_CH_VALUE: &str = "Sec-CH-DPR, Sec-CH-Width, Save-Data";
|
||||||
@@ -32,22 +32,29 @@ pub async fn spa_catch_all(
|
|||||||
) -> Response {
|
) -> Response {
|
||||||
let request_path = request.uri().path();
|
let request_path = request.uri().path();
|
||||||
|
|
||||||
if is_static_root_file(request_path) {
|
if let Some(cache_control) = static_root_file_cache_control(request_path) {
|
||||||
return serve_static_file(&state.config.static_dir, request_path).await;
|
return serve_static_file(&state.config.static_dir, request_path, cache_control).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
serve_spa_index(&state, &headers, request_path).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
|
STATIC_ROOT_FILES
|
||||||
.iter()
|
.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 file_path = Path::new(static_dir).join(request_path.trim_start_matches('/'));
|
||||||
|
|
||||||
let resolved = match tokio::fs::canonicalize(&file_path).await {
|
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 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();
|
let mut response = content.into_response();
|
||||||
if let Ok(ct) = HeaderValue::from_str(mime_type) {
|
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
|
.static_cdn_endpoint
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or("");
|
.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 csp = build_csp(&state.config.csp, &nonce, &runtime_csp_sources);
|
||||||
let geoip = build_geoip_response(state.geoip.lookup(headers));
|
let geoip = build_geoip_response(state.geoip.lookup(headers));
|
||||||
let script_tag = build_bootstrap_script(&state.config, &discovery, &geoip, &nonce);
|
let script_tag = build_bootstrap_script(&state.config, &discovery, &geoip, &nonce);
|
||||||
|
|
||||||
if let Some(snapshot) = should_serve_frozen(&time_freeze) {
|
if let Some(snapshot) = should_serve_frozen(&time_freeze) {
|
||||||
let frozen_html = String::from_utf8_lossy(&snapshot.index_html);
|
let frozen_html = String::from_utf8_lossy(&snapshot.index_html);
|
||||||
let mut html = inject_bootstrap(&frozen_html, &nonce, &script_tag, static_cdn_endpoint);
|
let dev_buster = should_bust_dev_assets.then(current_dev_asset_cache_buster);
|
||||||
if let Some(meta) = &invite_meta {
|
let html = render_spa_document(
|
||||||
html = inject_invite_meta(&html, meta);
|
&frozen_html,
|
||||||
}
|
&nonce,
|
||||||
if should_bust_dev_assets {
|
&script_tag,
|
||||||
html = append_dev_asset_cache_buster(&html, ¤t_dev_asset_cache_buster());
|
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);
|
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,
|
Err(response) => return response,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut html = inject_bootstrap(&raw_html, &nonce, &script_tag, static_cdn_endpoint);
|
let dev_buster = should_bust_dev_assets.then(current_dev_asset_cache_buster);
|
||||||
if let Some(meta) = &invite_meta {
|
let html = render_spa_document(
|
||||||
html = inject_invite_meta(&html, meta);
|
&raw_html,
|
||||||
}
|
&nonce,
|
||||||
if should_bust_dev_assets {
|
&script_tag,
|
||||||
html = append_dev_asset_cache_buster(&html, ¤t_dev_asset_cache_buster());
|
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)
|
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<DiscoveryResponse> {
|
async fn refresh_discovery_for_spa(state: &AppState) -> Option<DiscoveryResponse> {
|
||||||
state
|
state
|
||||||
.discovery_cache
|
.discovery_cache
|
||||||
@@ -466,8 +495,21 @@ fn append_cache_buster_query(value: &str, buster: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use super::super::spa_static::LONG_LIVED_ASSET_CACHE_CONTROL;
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn dev_asset_cache_buster_rewrites_script_and_link_assets() {
|
fn dev_asset_cache_buster_rewrites_script_and_link_assets() {
|
||||||
let html = r#"<link rel="preconnect" href="https://example.test"><link href="/assets/main.css?abcdef1234567890"><script src="https://example.test/assets/main.abcdef1234567890.js"></script><script src="/assets/unversioned.js"></script><link rel="manifest" href="/manifest.json">"#;
|
let html = r#"<link rel="preconnect" href="https://example.test"><link href="/assets/main.css?abcdef1234567890"><script src="https://example.test/assets/main.abcdef1234567890.js"></script><script src="/assets/unversioned.js"></script><link rel="manifest" href="/manifest.json">"#;
|
||||||
@@ -516,6 +558,654 @@ mod tests {
|
|||||||
assert!(!is_static_root_file("/users/1.2.3"));
|
assert!(!is_static_root_file("/users/1.2.3"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SHELL_WITH_A_NONCE_HOLE: &str = r#"<!doctype html><html><head><title>Fluxer</title><script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script><script src="/assets/app.js"></script></head><body></body></html>"#;
|
||||||
|
|
||||||
|
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",
|
||||||
|
"<script>booted</script>",
|
||||||
|
"https://static.example.test",
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(!rendered.contains("{{CSP_NONCE_PLACEHOLDER}}"));
|
||||||
|
assert!(rendered.contains(r#"nonce="reqnonce""#));
|
||||||
|
assert!(rendered.contains("<script>booted</script>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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",
|
||||||
|
"<script>booted</script>",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
Some(&meta),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let without_meta = render_spa_document(
|
||||||
|
SHELL_WITH_A_NONCE_HOLE,
|
||||||
|
"reqnonce",
|
||||||
|
"<script>booted</script>",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
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",
|
||||||
|
"<script>booted</script>",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
None,
|
||||||
|
Some("9911"),
|
||||||
|
);
|
||||||
|
let untouched = render_spa_document(
|
||||||
|
SHELL_WITH_A_NONCE_HOLE,
|
||||||
|
"reqnonce",
|
||||||
|
"<script>booted</script>",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
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",
|
||||||
|
"<script>frozenboot</script>",
|
||||||
|
"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("<script>frozenboot</script>"),
|
||||||
|
"a frozen-served shell shipped without the bootstrap script"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHELL_WITH_ENDPOINT_HOLES: &str = r#"<!doctype html><html><head><title>Fluxer</title><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
|
||||||
|
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>
|
||||||
|
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png"><link rel="apple-touch-icon" sizes="180x180" href="{{STATIC_CDN_ENDPOINT}}/web/apple-touch-icon.png"><script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script><script src="/assets/app.js"></script></head><body></body></html>"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_static_cdn_argument_resolves_every_hole_the_shell_carries() {
|
||||||
|
let rendered = render_spa_document(
|
||||||
|
SHELL_WITH_ENDPOINT_HOLES,
|
||||||
|
"reqnonce",
|
||||||
|
"<script>booted</script>",
|
||||||
|
"https://cdn.example.test/",
|
||||||
|
"https://media.example.test",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
rendered
|
||||||
|
.contains(r#"<link rel="preconnect" href="https://cdn.example.test" crossorigin>"#),
|
||||||
|
"the static CDN argument never reached the anonymous preconnect"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
rendered.contains(r#"<link rel="preconnect" href="https://cdn.example.test">"#),
|
||||||
|
"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",
|
||||||
|
"<script>booted</script>",
|
||||||
|
"https://cdn.example.test",
|
||||||
|
"https://media.example.test/",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
distinct.contains(r#"<link rel="preconnect" href="https://media.example.test">"#),
|
||||||
|
"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",
|
||||||
|
"<script>booted</script>",
|
||||||
|
"https://cdn.example.test",
|
||||||
|
"https://cdn.example.test",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
shared.contains(r#"<link rel="preconnect" href="https://cdn.example.test">"#),
|
||||||
|
"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<String>,
|
||||||
|
) -> 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#"<script nonce="{granted_nonce}">window.__FLUXER_BOOTSTRAP__"#
|
||||||
|
)),
|
||||||
|
"the live bootstrap was not granted the nonce its own policy header carries"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
served
|
||||||
|
.matches(&format!(r#"nonce="{granted_nonce}""#))
|
||||||
|
.count(),
|
||||||
|
served.matches(r#"nonce=""#).count(),
|
||||||
|
"the live document carries a nonce its own policy header never granted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!served.contains("{{STATIC_CDN_ENDPOINT}}"),
|
||||||
|
"the live branch shipped an unresolved static CDN hole"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!served.contains("{{MEDIA_ENDPOINT}}"),
|
||||||
|
"the live branch shipped an unresolved media hole"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
served.contains("window.__FLUXER_BOOTSTRAP__"),
|
||||||
|
"the live branch shipped without a bootstrap script"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
served
|
||||||
|
.contains(r#"<link rel="preconnect" href="https://cdn.example.test" crossorigin>"#),
|
||||||
|
"the discovered static CDN never reached the served document"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
served.contains(r#"<link rel="preconnect" href="https://media.example.test">"#),
|
||||||
|
"the discovered media endpoint never reached the served document"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "time-freeze")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_frozen_document_is_served_with_its_asset_urls_untouched() {
|
||||||
|
let captured =
|
||||||
|
String::from_utf8_lossy(&crate::frozen_snapshots::STABLE_SNAPSHOT.index_html)
|
||||||
|
.into_owned();
|
||||||
|
assert!(
|
||||||
|
captured.contains(
|
||||||
|
r#"<link rel="stylesheet" href="https://fluxerstatic.com/fonts/ibm-plex.css">"#
|
||||||
|
),
|
||||||
|
"the captured shell no longer carries the asset URL this test proves we leave alone"
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = spa_state_serving(ReleaseChannel::Stable, None).await;
|
||||||
|
|
||||||
|
let response = serve_spa_index(&state, &HeaderMap::new(), "/channels/@me").await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let served = read_document(response).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
served.contains(
|
||||||
|
r#"<link rel="stylesheet" href="https://fluxerstatic.com/fonts/ibm-plex.css">"#
|
||||||
|
),
|
||||||
|
"the frozen document's stylesheet URL was rewritten on the primary hosted path"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
served.contains(r#"<link rel="manifest" href="/manifest.json">"#),
|
||||||
|
"the frozen document's manifest URL was rewritten on the primary hosted path"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!served.contains("?_=") && !served.contains("&_="),
|
||||||
|
"the frozen document was served with a development cache-busting query"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "time-freeze")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_frozen_branch_serves_a_rendered_document_and_not_the_raw_shell() {
|
||||||
|
let captured =
|
||||||
|
String::from_utf8_lossy(&crate::frozen_snapshots::STABLE_SNAPSHOT.index_html)
|
||||||
|
.into_owned();
|
||||||
|
assert!(
|
||||||
|
captured.contains("{{CSP_NONCE_PLACEHOLDER}}"),
|
||||||
|
"the captured shell no longer carries the nonce hole this test proves we fill"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!captured.contains("window.__FLUXER_BOOTSTRAP__"),
|
||||||
|
"the captured shell already carries a bootstrap, so this test can no longer tell a rendered document from a raw shell"
|
||||||
|
);
|
||||||
|
|
||||||
|
let state = spa_state_serving(ReleaseChannel::Stable, None).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 primary hosted path shipped a literal nonce placeholder"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
served
|
||||||
|
.matches(&format!(r#"nonce="{granted_nonce}""#))
|
||||||
|
.count(),
|
||||||
|
served.matches(r#"nonce=""#).count(),
|
||||||
|
"the frozen document carries a nonce its own policy header never granted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
served.contains(&format!(
|
||||||
|
r#"<script nonce="{granted_nonce}">window.__FLUXER_BOOTSTRAP__"#
|
||||||
|
)),
|
||||||
|
"the primary hosted path shipped without a bootstrap script the browser will run"
|
||||||
|
);
|
||||||
|
|
||||||
|
let second = serve_spa_index(&state, &HeaderMap::new(), "/channels/@me").await;
|
||||||
|
assert_ne!(
|
||||||
|
nonce_granted_by(&second),
|
||||||
|
granted_nonce,
|
||||||
|
"two frozen requests were granted the same nonce"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "time-freeze")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn every_branch_announces_which_snapshot_decision_it_took() {
|
||||||
|
let frozen_state = spa_state_serving(ReleaseChannel::Stable, None).await;
|
||||||
|
let frozen = serve_spa_index(&frozen_state, &HeaderMap::new(), "/channels/@me").await;
|
||||||
|
assert_eq!(
|
||||||
|
frozen
|
||||||
|
.headers()
|
||||||
|
.get("x-time-freeze")
|
||||||
|
.expect("the frozen response announced no snapshot decision")
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
format!(
|
||||||
|
"frozen; sha={}",
|
||||||
|
crate::frozen_snapshots::STABLE_SNAPSHOT.sha
|
||||||
|
),
|
||||||
|
"the frozen response named the wrong snapshot"
|
||||||
|
);
|
||||||
|
|
||||||
|
let live_state =
|
||||||
|
spa_state_serving(ReleaseChannel::Canary, Some(SHELL_WITH_ENDPOINT_HOLES)).await;
|
||||||
|
let live = serve_spa_index(&live_state, &HeaderMap::new(), "/channels/@me").await;
|
||||||
|
assert_eq!(
|
||||||
|
live.headers()
|
||||||
|
.get("x-time-freeze")
|
||||||
|
.expect("the live response announced no snapshot decision")
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
"no-snapshot",
|
||||||
|
"a channel with no snapshot claimed one anyway"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "time-freeze")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_frozen_shell_is_never_served_with_the_asset_lifetime() {
|
||||||
|
let state = spa_state_serving(ReleaseChannel::Stable, None).await;
|
||||||
|
|
||||||
|
let response = serve_spa_index(&state, &HeaderMap::new(), "/channels/@me").await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get("x-time-freeze")
|
||||||
|
.expect("the response announced no snapshot decision")
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
format!(
|
||||||
|
"frozen; sha={}",
|
||||||
|
crate::frozen_snapshots::STABLE_SNAPSHOT.sha
|
||||||
|
),
|
||||||
|
"this test never reached the frozen branch, so it proves nothing about it"
|
||||||
|
);
|
||||||
|
let cache_control = response
|
||||||
|
.headers()
|
||||||
|
.get(header::CACHE_CONTROL)
|
||||||
|
.expect("the frozen shell was served without a cache policy at all")
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cache_control, "no-cache",
|
||||||
|
"the frozen document naming the hashed bundle must be revalidated on every load"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
cache_control, LONG_LIVED_ASSET_CACHE_CONTROL,
|
||||||
|
"a frozen shell cached for a year pins every returning visitor to the deployed-over bundle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_crawl_control_document_is_never_served_with_the_asset_lifetime() {
|
||||||
|
let root = std::env::temp_dir().join(format!(
|
||||||
|
"fluxer-app-proxy-crawl-control-{}",
|
||||||
|
std::process::id()
|
||||||
|
));
|
||||||
|
tokio::fs::create_dir_all(&root).await.unwrap();
|
||||||
|
tokio::fs::write(root.join("robots.txt"), "User-agent: *\nDisallow:\n")
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let static_dir = root.to_str().unwrap();
|
||||||
|
|
||||||
|
let policy = static_root_file_cache_control("/robots.txt")
|
||||||
|
.expect("robots.txt is no longer served as a static root file");
|
||||||
|
assert!(
|
||||||
|
static_root_file_cache_control("/channels/@me").is_none(),
|
||||||
|
"an application route was mistaken for a static root file"
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = serve_static_file(static_dir, "/robots.txt", policy).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let cache_control = response
|
||||||
|
.headers()
|
||||||
|
.get(header::CACHE_CONTROL)
|
||||||
|
.expect("the crawl-control document was served without a cache policy at all")
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cache_control, CRAWL_CONTROL_CACHE_CONTROL);
|
||||||
|
assert_ne!(
|
||||||
|
cache_control, LONG_LIVED_ASSET_CACHE_CONTROL,
|
||||||
|
"a crawl rule change cannot reach a crawler that already fetched a year-long robots.txt"
|
||||||
|
);
|
||||||
|
|
||||||
|
tokio::fs::remove_dir_all(&root).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_shell_is_never_served_with_the_asset_lifetime() {
|
||||||
|
let state =
|
||||||
|
spa_state_serving(ReleaseChannel::Canary, Some(SHELL_WITH_ENDPOINT_HOLES)).await;
|
||||||
|
|
||||||
|
let response = serve_spa_index(&state, &HeaderMap::new(), "/channels/@me").await;
|
||||||
|
|
||||||
|
let cache_control = response
|
||||||
|
.headers()
|
||||||
|
.get(header::CACHE_CONTROL)
|
||||||
|
.expect("the shell was served without a cache policy at all")
|
||||||
|
.to_str()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cache_control, "no-cache",
|
||||||
|
"the document naming the hashed bundle must be revalidated on every load"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
cache_control, LONG_LIVED_ASSET_CACHE_CONTROL,
|
||||||
|
"a shell cached for a year pins every returning visitor to the deployed-over bundle"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_index_upstream_replaces_the_snapshot_with_an_unstorable_busted_document() {
|
||||||
|
let index_upstream_url = spawn_local_origin(SHELL_WITH_ENDPOINT_HOLES, "text/html").await;
|
||||||
|
let state = spa_state_reading_its_shell_from(index_upstream_url).await;
|
||||||
|
|
||||||
|
let response = serve_spa_index(&state, &HeaderMap::new(), "/channels/@me").await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get(header::CACHE_CONTROL)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
DEV_NO_STORE_CACHE_CONTROL,
|
||||||
|
"a document fetched from an index upstream was served as cacheable"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
response
|
||||||
|
.headers()
|
||||||
|
.get("cdn-cache-control")
|
||||||
|
.expect("the edge was never told to skip storing this document")
|
||||||
|
.to_str()
|
||||||
|
.unwrap(),
|
||||||
|
"no-store"
|
||||||
|
);
|
||||||
|
let served = read_document(response).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
served.contains(r#"src="/assets/app.js?_="#),
|
||||||
|
"an index upstream served its assets without a cache-busting query"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_configured_static_cdn_stands_in_when_discovery_names_none() {
|
||||||
|
let state =
|
||||||
|
spa_state_without_discovered_endpoints(Some("https://fallbackcdn.example.test")).await;
|
||||||
|
|
||||||
|
let response = serve_spa_index(&state, &HeaderMap::new(), "/channels/@me").await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let served = read_document(response).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
served.contains(
|
||||||
|
r#"<link rel="preconnect" href="https://fallbackcdn.example.test" crossorigin>"#
|
||||||
|
),
|
||||||
|
"the configured static CDN never reached the anonymous preconnect"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
served.contains(r#"<link rel="preconnect" href="https://fallbackcdn.example.test">"#),
|
||||||
|
"the configured static CDN never reached the credentialed preconnect"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
served.contains(r#"href="https://fallbackcdn.example.test/web/favicon-32x32.png""#),
|
||||||
|
"the favicon was not resolved against the configured static CDN"
|
||||||
|
);
|
||||||
|
assert!(!served.contains("{{STATIC_CDN_ENDPOINT}}"));
|
||||||
|
assert_eq!(
|
||||||
|
served.matches("preconnect").count(),
|
||||||
|
2,
|
||||||
|
"a media endpoint nobody named still warmed a socket"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_endpoint_neither_discovered_nor_configured_warms_no_socket_at_all() {
|
||||||
|
let state = spa_state_without_discovered_endpoints(None).await;
|
||||||
|
|
||||||
|
let response = serve_spa_index(&state, &HeaderMap::new(), "/channels/@me").await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let served = read_document(response).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
served.matches("preconnect").count(),
|
||||||
|
0,
|
||||||
|
"an endpoint nobody named still reached the served document"
|
||||||
|
);
|
||||||
|
assert!(!served.contains("{{STATIC_CDN_ENDPOINT}}"));
|
||||||
|
assert!(!served.contains("{{MEDIA_ENDPOINT}}"));
|
||||||
|
assert!(
|
||||||
|
served.contains(r#"href="/web/favicon-32x32.png""#),
|
||||||
|
"an unnamed static CDN left the favicon pointing somewhere other than our own origin"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn font_mime_types_are_cors_enabled() {
|
fn font_mime_types_are_cors_enabled() {
|
||||||
assert!(is_font_mime("font/woff2"));
|
assert!(is_font_mime("font/woff2"));
|
||||||
|
|||||||
@@ -240,13 +240,16 @@ pub fn is_font_mime(mime_type: &str) -> bool {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const LONG_LIVED_ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
|
||||||
|
pub const REVALIDATED_ASSET_CACHE_CONTROL: &str = "public, max-age=3600, must-revalidate";
|
||||||
|
|
||||||
pub fn is_hashed_asset(path: &str) -> bool {
|
pub fn is_hashed_asset(path: &str) -> bool {
|
||||||
let filename = path.rsplit('/').next().unwrap_or(path);
|
let filename = path.rsplit('/').next().unwrap_or(path);
|
||||||
let Some(last_dot) = filename.rfind('.') else {
|
let Some(last_dot) = filename.rfind('.') else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
let stem = &filename[..last_dot];
|
let stem = &filename[..last_dot];
|
||||||
if is_content_hash(stem) {
|
if stem.split('.').next().is_some_and(is_content_hash) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
['.', '-'].iter().any(|sep| {
|
['.', '-'].iter().any(|sep| {
|
||||||
@@ -255,6 +258,14 @@ pub fn is_hashed_asset(path: &str) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn asset_cache_control(path: &str) -> &'static str {
|
||||||
|
if is_hashed_asset(path) {
|
||||||
|
LONG_LIVED_ASSET_CACHE_CONTROL
|
||||||
|
} else {
|
||||||
|
REVALIDATED_ASSET_CACHE_CONTROL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn is_content_hash(value: &str) -> bool {
|
fn is_content_hash(value: &str) -> bool {
|
||||||
value.len() >= 8 && value.chars().all(|c| c.is_ascii_hexdigit())
|
value.len() >= 8 && value.chars().all(|c| c.is_ascii_hexdigit())
|
||||||
}
|
}
|
||||||
@@ -345,10 +356,36 @@ mod tests {
|
|||||||
assert!(is_hashed_asset("/assets/488b87159423ca35.js"));
|
assert!(is_hashed_asset("/assets/488b87159423ca35.js"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hashed_asset_accepts_the_contenthash_worker_bundle_name() {
|
||||||
|
assert!(
|
||||||
|
is_hashed_asset("assets/2d715e4730758083.worker.js"),
|
||||||
|
"rspack emits workers as assets/[contenthash:16].worker.js"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hashed_asset_negative() {
|
fn hashed_asset_negative() {
|
||||||
assert!(!is_hashed_asset("app.js"));
|
assert!(!is_hashed_asset("app.js"));
|
||||||
assert!(!is_hashed_asset("style.css"));
|
assert!(!is_hashed_asset("style.css"));
|
||||||
assert!(!is_hashed_asset("a79f1c3119cd700d/app.js"));
|
assert!(!is_hashed_asset("a79f1c3119cd700d/app.js"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_bundled_font_licences_are_not_treated_as_content_hashed() {
|
||||||
|
assert!(!is_hashed_asset("assets/fonts-NOTICE.txt"));
|
||||||
|
assert!(!is_hashed_asset("assets/fonts-LICENSE-IBM-PLEX.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_content_hashed_asset_is_promised_to_never_change() {
|
||||||
|
assert_eq!(
|
||||||
|
asset_cache_control("assets/469e0b8f10c496a1.css"),
|
||||||
|
LONG_LIVED_ASSET_CACHE_CONTROL
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
asset_cache_control("assets/fonts-NOTICE.txt"),
|
||||||
|
REVALIDATED_ASSET_CACHE_CONTROL
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user