From 5fa601207a11e3b8b6b9d2fbf8eb9fd23cf7ae96 Mon Sep 17 00:00:00 2001 From: ThinhHV Date: Wed, 1 Apr 2026 09:05:37 +0000 Subject: [PATCH 1/7] feat(config): add fallback image and PP_TTL config fields --- src/common/config/loader.rs | 73 +++++++++++++++++++++++++++++++++ src/modules/proxy/controller.rs | 6 +++ src/modules/proxy/service.rs | 12 ++++++ 3 files changed, 91 insertions(+) diff --git a/src/common/config/loader.rs b/src/common/config/loader.rs index dff04d8..4c98520 100644 --- a/src/common/config/loader.rs +++ b/src/common/config/loader.rs @@ -59,6 +59,13 @@ pub struct Configuration { pub url_aliases: Option>, // Best format pub best_format: BestFormatConfig, + // Fallback image + pub fallback_image_data: Option, + pub fallback_image_path: Option, + pub fallback_image_url: Option, + pub fallback_image_http_code: u16, + pub fallback_image_ttl: Option, + pub ttl: u64, } fn env_var_opt(name: &str) -> Option { @@ -340,6 +347,14 @@ impl Configuration { &std::env::var("PP_BEST_FORMAT_PREFERRED_FORMATS").unwrap_or_default(), ), }, + fallback_image_data: env_var_opt("PP_FALLBACK_IMAGE_DATA"), + fallback_image_path: env_var_opt("PP_FALLBACK_IMAGE_PATH"), + fallback_image_url: env_var_opt("PP_FALLBACK_IMAGE_URL"), + fallback_image_http_code: env_var_u16("PP_FALLBACK_IMAGE_HTTP_CODE", 200), + fallback_image_ttl: env_var_opt("PP_FALLBACK_IMAGE_TTL") + .and_then(|v| v.parse::().ok()) + .filter(|&v| v > 0), + ttl: env_var_u64("PP_TTL", 86400), }); if cfg.hmac_key.is_none() { tracing::warn!("HMAC_KEY is not set - all requests are unauthenticated"); @@ -463,6 +478,12 @@ impl std::fmt::Debug for Configuration { }), ) .field("best_format", &self.best_format) + .field("fallback_image_data", &self.fallback_image_data.as_ref().map(|_| "[set]")) + .field("fallback_image_path", &self.fallback_image_path) + .field("fallback_image_url", &self.fallback_image_url) + .field("fallback_image_http_code", &self.fallback_image_http_code) + .field("fallback_image_ttl", &self.fallback_image_ttl) + .field("ttl", &self.ttl) .finish() } } @@ -798,4 +819,56 @@ mod tests { unsafe { std::env::remove_var("PP_SOURCE_URL_ENCRYPTION_KEY") }; assert!(result.is_err(), "Expected panic for wrong key length"); } + + #[test] + fn test_fallback_image_defaults() { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var("PP_PORT", "8080"); + std::env::set_var("PP_APP_ENV", "development"); + std::env::remove_var("PP_FALLBACK_IMAGE_DATA"); + std::env::remove_var("PP_FALLBACK_IMAGE_PATH"); + std::env::remove_var("PP_FALLBACK_IMAGE_URL"); + std::env::remove_var("PP_FALLBACK_IMAGE_HTTP_CODE"); + std::env::remove_var("PP_FALLBACK_IMAGE_TTL"); + std::env::remove_var("PP_TTL"); + } + let cfg = super::Configuration::new(); + assert!(cfg.fallback_image_data.is_none()); + assert!(cfg.fallback_image_path.is_none()); + assert!(cfg.fallback_image_url.is_none()); + assert_eq!(cfg.fallback_image_http_code, 200); + assert!(cfg.fallback_image_ttl.is_none()); + assert_eq!(cfg.ttl, 86400); + } + + #[test] + fn test_fallback_image_from_env() { + let _guard = ENV_LOCK.lock().unwrap(); + unsafe { + std::env::set_var("PP_PORT", "8080"); + std::env::set_var("PP_APP_ENV", "development"); + std::env::set_var("PP_FALLBACK_IMAGE_DATA", "aGVsbG8="); + std::env::set_var("PP_FALLBACK_IMAGE_PATH", "/tmp/fallback.png"); + std::env::set_var("PP_FALLBACK_IMAGE_URL", "https://example.com/fallback.png"); + std::env::set_var("PP_FALLBACK_IMAGE_HTTP_CODE", "0"); + std::env::set_var("PP_FALLBACK_IMAGE_TTL", "300"); + std::env::set_var("PP_TTL", "7200"); + } + let cfg = super::Configuration::new(); + unsafe { + std::env::remove_var("PP_FALLBACK_IMAGE_DATA"); + std::env::remove_var("PP_FALLBACK_IMAGE_PATH"); + std::env::remove_var("PP_FALLBACK_IMAGE_URL"); + std::env::remove_var("PP_FALLBACK_IMAGE_HTTP_CODE"); + std::env::remove_var("PP_FALLBACK_IMAGE_TTL"); + std::env::remove_var("PP_TTL"); + } + assert_eq!(cfg.fallback_image_data.as_deref(), Some("aGVsbG8=")); + assert_eq!(cfg.fallback_image_path.as_deref(), Some("/tmp/fallback.png")); + assert_eq!(cfg.fallback_image_url.as_deref(), Some("https://example.com/fallback.png")); + assert_eq!(cfg.fallback_image_http_code, 0); + assert_eq!(cfg.fallback_image_ttl, Some(300)); + assert_eq!(cfg.ttl, 7200); + } } diff --git a/src/modules/proxy/controller.rs b/src/modules/proxy/controller.rs index 236d491..c462a9a 100644 --- a/src/modules/proxy/controller.rs +++ b/src/modules/proxy/controller.rs @@ -270,6 +270,12 @@ mod concurrency_tests { best_format: Default::default(), prometheus_bind: None, prometheus_namespace: String::new(), + fallback_image_data: None, + fallback_image_path: None, + fallback_image_url: None, + fallback_image_http_code: 200, + fallback_image_ttl: None, + ttl: 86400, }); let http = Arc::new( HttpFetcher::new(10, 1_000_000, Arc::new(Allowlist::new(vec![]))) diff --git a/src/modules/proxy/service.rs b/src/modules/proxy/service.rs index 4f9b3f8..a8dc57d 100644 --- a/src/modules/proxy/service.rs +++ b/src/modules/proxy/service.rs @@ -530,6 +530,12 @@ mod tests { best_format: Default::default(), prometheus_bind: None, prometheus_namespace: String::new(), + fallback_image_data: None, + fallback_image_path: None, + fallback_image_url: None, + fallback_image_http_code: 200, + fallback_image_ttl: None, + ttl: 86400, }) } @@ -790,6 +796,12 @@ mod streaming_tests { best_format: Default::default(), prometheus_bind: None, prometheus_namespace: String::new(), + fallback_image_data: None, + fallback_image_path: None, + fallback_image_url: None, + fallback_image_http_code: 200, + fallback_image_ttl: None, + ttl: 86400, }); let http = Arc::new( HttpFetcher::new(10, max_bytes, Arc::new(Allowlist::new(vec![]))) From a8e003cc925092c587f6afdc7d5d805225ccb562 Mon Sep 17 00:00:00 2001 From: ThinhHV Date: Wed, 1 Apr 2026 10:06:38 +0000 Subject: [PATCH 2/7] feat(cli): add fallback image CLI args Add 6 new CLI arguments to support fallback image configuration: - fallback_image_data: Base64-encoded image data - fallback_image_path: Local file path - fallback_image_url: Upstream URL - fallback_image_http_code: HTTP status code (0 = use original) - fallback_image_ttl: Cache TTL in seconds (0 = use PP_TTL) - ttl: General response TTL in seconds All fields have env var mappings (PP_*) and appropriate defaults. Includes apply_to_env() entries and test coverage. --- src/modules/cli/args.rs | 59 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/modules/cli/args.rs b/src/modules/cli/args.rs index 843b087..15be13f 100644 --- a/src/modules/cli/args.rs +++ b/src/modules/cli/args.rs @@ -226,6 +226,30 @@ pub struct Cli { #[arg(long, env = "PP_PROMETHEUS_NAMESPACE", default_value = "")] pub prometheus_namespace: String, + /// Base64-encoded fallback image data [env: PP_FALLBACK_IMAGE_DATA] + #[arg(long, env = "PP_FALLBACK_IMAGE_DATA")] + pub fallback_image_data: Option, + + /// Path to local fallback image file [env: PP_FALLBACK_IMAGE_PATH] + #[arg(long, env = "PP_FALLBACK_IMAGE_PATH", default_value = "")] + pub fallback_image_path: String, + + /// URL of fallback image [env: PP_FALLBACK_IMAGE_URL] + #[arg(long, env = "PP_FALLBACK_IMAGE_URL", default_value = "")] + pub fallback_image_url: String, + + /// HTTP status code for fallback responses; 0 = use original error code [env: PP_FALLBACK_IMAGE_HTTP_CODE] + #[arg(long, env = "PP_FALLBACK_IMAGE_HTTP_CODE", default_value_t = 200u16)] + pub fallback_image_http_code: u16, + + /// TTL in seconds for fallback image responses; 0 = use PP_TTL [env: PP_FALLBACK_IMAGE_TTL] + #[arg(long, env = "PP_FALLBACK_IMAGE_TTL", default_value_t = 0u64)] + pub fallback_image_ttl: u64, + + /// General response TTL in seconds [env: PP_TTL] + #[arg(long, env = "PP_TTL", default_value_t = 86400u64)] + pub ttl: u64, + #[command(subcommand)] pub command: Option, } @@ -328,6 +352,21 @@ impl Cli { ); std::env::set_var("PP_PROMETHEUS_BIND", &self.prometheus_bind); std::env::set_var("PP_PROMETHEUS_NAMESPACE", &self.prometheus_namespace); + std::env::set_var( + "PP_FALLBACK_IMAGE_DATA", + self.fallback_image_data.as_deref().unwrap_or(""), + ); + std::env::set_var("PP_FALLBACK_IMAGE_PATH", &self.fallback_image_path); + std::env::set_var("PP_FALLBACK_IMAGE_URL", &self.fallback_image_url); + std::env::set_var( + "PP_FALLBACK_IMAGE_HTTP_CODE", + self.fallback_image_http_code.to_string(), + ); + std::env::set_var( + "PP_FALLBACK_IMAGE_TTL", + self.fallback_image_ttl.to_string(), + ); + std::env::set_var("PP_TTL", self.ttl.to_string()); } } } @@ -531,4 +570,24 @@ mod tests { "hexkey" ); } + + #[test] + fn test_fallback_image_cli_defaults() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + unsafe { + std::env::remove_var("PP_FALLBACK_IMAGE_DATA"); + std::env::remove_var("PP_FALLBACK_IMAGE_PATH"); + std::env::remove_var("PP_FALLBACK_IMAGE_URL"); + std::env::remove_var("PP_FALLBACK_IMAGE_HTTP_CODE"); + std::env::remove_var("PP_FALLBACK_IMAGE_TTL"); + std::env::remove_var("PP_TTL"); + } + let cli = Cli::parse_from(["previewproxy"]); + assert!(cli.fallback_image_data.is_none()); + assert_eq!(cli.fallback_image_path, ""); + assert_eq!(cli.fallback_image_url, ""); + assert_eq!(cli.fallback_image_http_code, 200u16); + assert_eq!(cli.fallback_image_ttl, 0u64); + assert_eq!(cli.ttl, 86400u64); + } } From 9cb7e68f01d8680d1777770f5dcfb4f84e1f6375 Mon Sep 17 00:00:00 2001 From: ThinhHV Date: Wed, 1 Apr 2026 09:18:00 +0000 Subject: [PATCH 3/7] feat(proxy): add FallbackImage struct with load() from data/path/url --- src/modules/proxy/fallback.rs | 203 ++++++++++++++++++++++++++++++++++ src/modules/proxy/mod.rs | 1 + 2 files changed, 204 insertions(+) create mode 100644 src/modules/proxy/fallback.rs diff --git a/src/modules/proxy/fallback.rs b/src/modules/proxy/fallback.rs new file mode 100644 index 0000000..e704fec --- /dev/null +++ b/src/modules/proxy/fallback.rs @@ -0,0 +1,203 @@ +use base64::Engine; +use bytes::Bytes; +use std::sync::Arc; + +pub struct FallbackImage { + pub bytes: Bytes, + pub content_type: String, +} + +impl FallbackImage { + pub async fn load(cfg: &crate::common::config::Configuration) -> Option> { + let has_data = cfg.fallback_image_data.is_some(); + let has_path = cfg.fallback_image_path.is_some(); + let has_url = cfg.fallback_image_url.is_some(); + + let count = [has_data, has_path, has_url].iter().filter(|&&v| v).count(); + if count == 0 { + return None; + } + if count > 1 { + tracing::warn!( + "Multiple fallback image sources configured; using highest priority: data > path > url" + ); + } + + let (bytes, content_type) = if let Some(data) = &cfg.fallback_image_data { + let raw = base64::engine::general_purpose::STANDARD + .decode(data) + .unwrap_or_else(|e| panic!("PP_FALLBACK_IMAGE_DATA is not valid base64: {e}")); + let ct = detect_content_type(&raw); + (Bytes::from(raw), ct) + } else if let Some(path) = &cfg.fallback_image_path { + let raw = std::fs::read(path) + .unwrap_or_else(|e| panic!("PP_FALLBACK_IMAGE_PATH '{path}' could not be read: {e}")); + let ct = detect_content_type(&raw); + (Bytes::from(raw), ct) + } else { + let url = cfg.fallback_image_url.as_deref().unwrap(); + let resp = reqwest::get(url) + .await + .unwrap_or_else(|e| panic!("PP_FALLBACK_IMAGE_URL '{url}' could not be fetched: {e}")); + let ct = resp + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(';').next().unwrap_or(s).trim().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + let raw = resp + .bytes() + .await + .unwrap_or_else(|e| panic!("PP_FALLBACK_IMAGE_URL '{url}' body read failed: {e}")); + (raw, ct) + }; + + Some(Arc::new(FallbackImage { bytes, content_type })) + } +} + +fn detect_content_type(bytes: &[u8]) -> String { + if bytes.starts_with(b"\x89PNG") { + "image/png".to_string() + } else if bytes.starts_with(b"\xff\xd8\xff") { + "image/jpeg".to_string() + } else if bytes.starts_with(b"GIF8") { + "image/gif".to_string() + } else if bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WEBP" { + "image/webp".to_string() + } else if bytes.len() >= 12 && bytes.get(4..8) == Some(b"ftyp") { + "image/avif".to_string() + } else { + "application/octet-stream".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::config::Configuration; + use base64::Engine; + + fn base_cfg() -> Configuration { + use std::collections::{HashMap, HashSet}; + use std::net::{Ipv4Addr, SocketAddr}; + Configuration { + env: crate::common::config::Environment::Development, + listen_address: SocketAddr::from((Ipv4Addr::UNSPECIFIED, 8080)), + app_port: 8080, + hmac_key: None, + source_url_encryption_key: None, + allowed_hosts: vec![], + fetch_timeout_secs: 10, + max_source_bytes: 1_000_000, + cache_memory_max_mb: 16, + cache_memory_ttl_secs: 60, + cache_dir: "/tmp/test-fallback".to_string(), + cache_disk_ttl_secs: 60, + cache_disk_max_mb: None, + cache_cleanup_interval_secs: 600, + s3_enabled: false, + s3_bucket: None, + s3_region: "us-east-1".to_string(), + s3_access_key_id: None, + s3_secret_access_key: None, + s3_endpoint: None, + local_enabled: false, + local_base_dir: None, + ffmpeg_path: "ffmpeg".to_string(), + ffprobe_path: "ffprobe".to_string(), + cors_allow_origin: vec!["*".to_string()], + cors_max_age_secs: 600, + max_concurrent_requests: 256, + input_disallow: HashSet::new(), + output_disallow: HashSet::new(), + transform_disallow: HashSet::new(), + url_aliases: None, + best_format: Default::default(), + prometheus_bind: None, + prometheus_namespace: String::new(), + fallback_image_data: None, + fallback_image_path: None, + fallback_image_url: None, + fallback_image_http_code: 200, + fallback_image_ttl: None, + ttl: 86400, + } + } + + // 1x1 red PNG in base64 + const PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="; + + fn png_bytes() -> Vec { + base64::engine::general_purpose::STANDARD.decode(PNG_B64).unwrap() + } + + #[tokio::test] + async fn test_load_none_when_no_source() { + let cfg = base_cfg(); + let result = FallbackImage::load(&cfg).await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_load_from_base64_data() { + let mut cfg = base_cfg(); + cfg.fallback_image_data = Some(PNG_B64.to_string()); + let result = FallbackImage::load(&cfg).await.unwrap(); + assert_eq!(result.bytes.as_ref(), png_bytes().as_slice()); + assert_eq!(result.content_type, "image/png"); + } + + #[tokio::test] + async fn test_load_from_path() { + let path = "/tmp/previewproxy-test-fallback.png"; + std::fs::write(path, png_bytes()).unwrap(); + let mut cfg = base_cfg(); + cfg.fallback_image_path = Some(path.to_string()); + let result = FallbackImage::load(&cfg).await.unwrap(); + assert_eq!(result.bytes.as_ref(), png_bytes().as_slice()); + assert_eq!(result.content_type, "image/png"); + } + + #[tokio::test] + async fn test_load_from_url() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(200) + .set_body_bytes(png_bytes()) + .insert_header("content-type", "image/png"), + ) + .mount(&server) + .await; + let mut cfg = base_cfg(); + cfg.fallback_image_url = Some(server.uri()); + let result = FallbackImage::load(&cfg).await.unwrap(); + assert_eq!(result.bytes.as_ref(), png_bytes().as_slice()); + assert_eq!(result.content_type, "image/png"); + } + + #[tokio::test] + async fn test_data_takes_priority_over_path_and_url() { + let mut cfg = base_cfg(); + cfg.fallback_image_data = Some(PNG_B64.to_string()); + cfg.fallback_image_path = Some("/nonexistent/path.png".to_string()); + cfg.fallback_image_url = Some("https://example.com/fallback.png".to_string()); + // Should succeed using data without trying path or url + let result = FallbackImage::load(&cfg).await.unwrap(); + assert!(!result.bytes.is_empty()); + assert_eq!(result.content_type, "image/png"); + } + + #[tokio::test] + async fn test_path_takes_priority_over_url() { + let path = "/tmp/previewproxy-test-fallback2.png"; + std::fs::write(path, png_bytes()).unwrap(); + let mut cfg = base_cfg(); + cfg.fallback_image_path = Some(path.to_string()); + cfg.fallback_image_url = Some("https://example.com/fallback.png".to_string()); + let result = FallbackImage::load(&cfg).await.unwrap(); + assert_eq!(result.bytes.as_ref(), png_bytes().as_slice()); + } +} diff --git a/src/modules/proxy/mod.rs b/src/modules/proxy/mod.rs index d5099aa..318cb1c 100644 --- a/src/modules/proxy/mod.rs +++ b/src/modules/proxy/mod.rs @@ -1,5 +1,6 @@ pub mod controller; pub mod dto; +pub mod fallback; pub mod fetchable; pub mod service; pub mod sources; From 4ca25e3181f64c83617b4e59fd0ffe379c25efcb Mon Sep 17 00:00:00 2001 From: ThinhHV Date: Wed, 1 Apr 2026 10:06:51 +0000 Subject: [PATCH 4/7] feat(app): wire FallbackImage into AppState Add fallback field to AppState struct and load fallback image at startup in app.rs. Update controller tests to initialize fallback field. Add test to verify fallback is None by default when no fallback sources are configured. --- src/app.rs | 4 ++++ src/modules/mod.rs | 2 ++ src/modules/proxy/controller.rs | 7 +++++++ 3 files changed, 13 insertions(+) diff --git a/src/app.rs b/src/app.rs index bb82866..d4d589b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,7 @@ use crate::common::{config::Config, config::Environment, config::telemetry, middlewares}; use crate::modules::AppState; use crate::modules::cache::manager::CacheManager; +use crate::modules::proxy::fallback::FallbackImage; use crate::modules::proxy::fetchable::Fetchable; use crate::modules::proxy::sources::http::HttpFetcher; use crate::modules::proxy::sources::{AliasSource, LocalSource, S3Source, SourceRouter}; @@ -68,6 +69,8 @@ pub async fn router( let concurrency = Arc::new(Semaphore::new(cfg.max_concurrent_requests)); + let fallback = FallbackImage::load(&cfg).await; + let app_state = AppState { cfg, cache, @@ -75,6 +78,7 @@ pub async fn router( http_fetcher, concurrency, metrics, + fallback, }; let trace_layer = telemetry::trace_layer(); diff --git a/src/modules/mod.rs b/src/modules/mod.rs index bbebd1f..dc25870 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -10,6 +10,7 @@ use crate::common::config::Config; use crate::modules::cache::manager::CacheManager; use crate::modules::metrics::Metrics; use crate::modules::proxy::fetchable::Fetchable; +use crate::modules::proxy::fallback::FallbackImage; use axum::Router; use std::sync::Arc; use tokio::sync::Semaphore; @@ -22,6 +23,7 @@ pub struct AppState { pub http_fetcher: Arc, pub concurrency: Arc, pub metrics: Arc, + pub fallback: Option>, } pub fn router(state: AppState) -> Router { diff --git a/src/modules/proxy/controller.rs b/src/modules/proxy/controller.rs index c462a9a..12a5410 100644 --- a/src/modules/proxy/controller.rs +++ b/src/modules/proxy/controller.rs @@ -289,6 +289,7 @@ mod concurrency_tests { concurrency: Arc::new(Semaphore::new(permits)), cfg, metrics, + fallback: None, } } @@ -302,6 +303,12 @@ mod concurrency_tests { } } + #[tokio::test] + async fn test_appstate_has_fallback_none_by_default() { + let state = make_state(1); + assert!(state.fallback.is_none()); + } + #[tokio::test] async fn test_path_encrypted_url_decrypts_and_proxies() { use http_body_util::BodyExt; From 99b03cfb0c1ba9499b70c1792e53350b6918559b Mon Sep 17 00:00:00 2001 From: ThinhHV Date: Wed, 1 Apr 2026 09:24:52 +0000 Subject: [PATCH 5/7] feat(proxy): serve fallback image on upstream fetch errors --- src/modules/proxy/controller.rs | 166 +++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 4 deletions(-) diff --git a/src/modules/proxy/controller.rs b/src/modules/proxy/controller.rs index 12a5410..f5d63e1 100644 --- a/src/modules/proxy/controller.rs +++ b/src/modules/proxy/controller.rs @@ -1,5 +1,6 @@ use crate::common::config::Config; use crate::common::errors::ProxyError; +use crate::modules::proxy::fallback::FallbackImage; use crate::modules::AppState; use crate::modules::cache::manager::CacheHit; use crate::modules::cache::memory::CacheEntry; @@ -134,8 +135,18 @@ async fn handle_query_inner( }; let params = from_query(&query)?; let service = ProxyService::new(&state); - let result = service.process(params, url, permit, queued_at).await?; - Ok(build_response(result, &state.cfg)) + let result = service.process(params, url, permit, queued_at).await; + match result { + Ok(r) => Ok(build_response(r, &state.cfg)), + Err(ref e) if is_upstream_error(e) => { + if let Some(fallback) = &state.fallback { + Ok(build_fallback_response(fallback, e, &state.cfg)) + } else { + Err(result.unwrap_err()) + } + } + Err(e) => Err(e), + } } async fn handle_path_inner( @@ -157,8 +168,49 @@ async fn handle_path_inner( params.merge_from(query_params); } let svc = ProxyService::new(&state); - let result = svc.process(params, url, permit, queued_at).await?; - Ok(build_response(result, &state.cfg)) + let result = svc.process(params, url, permit, queued_at).await; + match result { + Ok(r) => Ok(build_response(r, &state.cfg)), + Err(ref e) if is_upstream_error(e) => { + if let Some(fallback) = &state.fallback { + Ok(build_fallback_response(fallback, e, &state.cfg)) + } else { + Err(result.unwrap_err()) + } + } + Err(e) => Err(e), + } +} + +fn is_upstream_error(e: &ProxyError) -> bool { + matches!( + e, + ProxyError::UpstreamNotFound | ProxyError::UpstreamTimeout | ProxyError::TooManyRedirects + ) +} + +fn build_fallback_response(fallback: &FallbackImage, err: &ProxyError, cfg: &Config) -> Response { + let ttl = cfg.fallback_image_ttl.unwrap_or(cfg.ttl); + let cache_control = format!("public, max-age={ttl}"); + + let status = if cfg.fallback_image_http_code == 0 { + err.clone().into_response().status() + } else { + StatusCode::from_u16(cfg.fallback_image_http_code).unwrap_or(StatusCode::OK) + }; + + let ct: axum::http::HeaderValue = fallback + .content_type + .parse() + .unwrap_or_else(|_| "application/octet-stream".parse().unwrap()); + + let mut headers = HeaderMap::new(); + headers.insert(header::CONTENT_TYPE, ct); + headers.insert(header::CONTENT_LENGTH, fallback.bytes.len().into()); + headers.insert(header::CACHE_CONTROL, cache_control.parse().unwrap()); + headers.insert("x-fallback", "true".parse().unwrap()); + + (status, headers, fallback.bytes.clone()).into_response() } /// Converts a `ProcessResult` into an HTTP response. @@ -223,6 +275,7 @@ fn build_cached_response(entry: CacheEntry, hit: CacheHit, cfg: &Config) -> Resp #[cfg(test)] mod concurrency_tests { + use base64::Engine; use crate::common::config::Configuration; use crate::modules::AppState; use crate::modules::cache::manager::CacheManager; @@ -484,6 +537,111 @@ mod concurrency_tests { ); } + fn make_state_with_fallback(permits: usize, fallback: Option>) -> AppState { + AppState { + fallback, + ..make_state(permits) + } + } + + #[tokio::test] + async fn test_fallback_served_on_upstream_404() { + use http_body_util::BodyExt; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let png_bytes = base64::engine::general_purpose::STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==") + .unwrap(); + let fallback = Some(Arc::new(crate::modules::proxy::fallback::FallbackImage { + bytes: bytes::Bytes::from(png_bytes.clone()), + content_type: "image/png".to_string(), + })); + let state = make_state_with_fallback(256, fallback); + let app = crate::modules::router(state); + + let url = format!("/proxy?url={}", urlencoding::encode(&server.uri())); + let req = axum::http::Request::builder() + .uri(&url) + .body(axum::body::Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::OK); + assert_eq!( + resp.headers().get("x-fallback").and_then(|v| v.to_str().ok()), + Some("true") + ); + let body = resp.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(body.as_ref(), png_bytes.as_slice()); + } + + #[tokio::test] + async fn test_no_fallback_on_invalid_signature() { + let mut cfg = (*make_state(1).cfg).clone(); + cfg.hmac_key = Some("secret".to_string()); + let fallback = Some(Arc::new(crate::modules::proxy::fallback::FallbackImage { + bytes: bytes::Bytes::from(vec![1u8; 10]), + content_type: "image/png".to_string(), + })); + let state = AppState { + cfg: std::sync::Arc::new(cfg), + fallback, + ..make_state(1) + }; + let app = crate::modules::router(state); + let req = axum::http::Request::builder() + .uri("/proxy?url=https://example.com/img.jpg") + .body(axum::body::Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::FORBIDDEN); + assert!(resp.headers().get("x-fallback").is_none()); + } + + #[tokio::test] + async fn test_fallback_http_code_zero_uses_original_error_code() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let png_bytes = base64::engine::general_purpose::STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==") + .unwrap(); + let fallback = Some(Arc::new(crate::modules::proxy::fallback::FallbackImage { + bytes: bytes::Bytes::from(png_bytes), + content_type: "image/png".to_string(), + })); + let mut cfg = (*make_state(1).cfg).clone(); + cfg.fallback_image_http_code = 0; + let state = AppState { + cfg: std::sync::Arc::new(cfg), + fallback, + ..make_state(256) + }; + let app = crate::modules::router(state); + + let url = format!("/proxy?url={}", urlencoding::encode(&server.uri())); + let req = axum::http::Request::builder() + .uri(&url) + .body(axum::body::Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND); + assert_eq!( + resp.headers().get("x-fallback").and_then(|v| v.to_str().ok()), + Some("true") + ); + } + #[tokio::test] async fn test_streaming_x_cache_miss_header() { use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; From 8878c6fc049631247a6c9a83f6a6cd0fb9757eb8 Mon Sep 17 00:00:00 2001 From: ThinhHV Date: Wed, 1 Apr 2026 09:27:13 +0000 Subject: [PATCH 6/7] test(proxy): add TTL resolution tests for fallback image responses --- src/modules/proxy/controller.rs | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/modules/proxy/controller.rs b/src/modules/proxy/controller.rs index f5d63e1..6da267e 100644 --- a/src/modules/proxy/controller.rs +++ b/src/modules/proxy/controller.rs @@ -667,4 +667,84 @@ mod concurrency_tests { Some("MISS") ); } + + #[tokio::test] + async fn test_fallback_ttl_uses_fallback_image_ttl_when_set() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let png_bytes = base64::engine::general_purpose::STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==") + .unwrap(); + let fallback = Some(Arc::new(crate::modules::proxy::fallback::FallbackImage { + bytes: bytes::Bytes::from(png_bytes), + content_type: "image/png".to_string(), + })); + + let mut cfg = (*make_state(1).cfg).clone(); + cfg.fallback_image_ttl = Some(300); + cfg.ttl = 86400; + let state = AppState { + cfg: std::sync::Arc::new(cfg), + fallback, + ..make_state(256) + }; + let app = crate::modules::router(state); + + let url = format!("/proxy?url={}", urlencoding::encode(&server.uri())); + let req = axum::http::Request::builder() + .uri(&url) + .body(axum::body::Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!( + resp.headers().get("cache-control").and_then(|v| v.to_str().ok()), + Some("public, max-age=300") + ); + } + + #[tokio::test] + async fn test_fallback_ttl_falls_back_to_pp_ttl() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(404)) + .mount(&server) + .await; + + let png_bytes = base64::engine::general_purpose::STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg==") + .unwrap(); + let fallback = Some(Arc::new(crate::modules::proxy::fallback::FallbackImage { + bytes: bytes::Bytes::from(png_bytes), + content_type: "image/png".to_string(), + })); + + let mut cfg = (*make_state(1).cfg).clone(); + cfg.fallback_image_ttl = None; + cfg.ttl = 1234; + let state = AppState { + cfg: std::sync::Arc::new(cfg), + fallback, + ..make_state(256) + }; + let app = crate::modules::router(state); + + let url = format!("/proxy?url={}", urlencoding::encode(&server.uri())); + let req = axum::http::Request::builder() + .uri(&url) + .body(axum::body::Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!( + resp.headers().get("cache-control").and_then(|v| v.to_str().ok()), + Some("public, max-age=1234") + ); + } } From 1021b546779bb1396dfe99a28757bf4a1955ca1e Mon Sep 17 00:00:00 2001 From: ThinhHV Date: Wed, 1 Apr 2026 10:05:26 +0000 Subject: [PATCH 7/7] feat(config): add fallback image configuration options to .env.sample and README --- .env.sample | 27 ++++ README.md | 44 +------ src/common/config/loader.rs | 36 +++--- src/modules/cli/args.rs | 136 ++++----------------- src/modules/metrics/prometheus/exporter.rs | 6 +- src/modules/mod.rs | 2 +- src/modules/proxy/controller.rs | 53 ++++++-- src/modules/proxy/fallback.rs | 11 +- src/modules/proxy/service.rs | 108 +++++++++++++--- tests/integration_test.rs | 10 +- 10 files changed, 221 insertions(+), 212 deletions(-) diff --git a/.env.sample b/.env.sample index b3f5ad2..567f869 100644 --- a/.env.sample +++ b/.env.sample @@ -11,6 +11,9 @@ # Max in-flight requests before returning 503 # PP_MAX_CONCURRENT_REQUESTS=256 +# General response TTL (seconds) +# PP_TTL=86400 + # Log level filter (see https://docs.rs/tracing-subscriber) # RUST_LOG=previewproxy=info,tower_http=info @@ -154,6 +157,30 @@ # Blocks matching output format names (comma-separated regex patterns). # PP_OUTPUT_DISALLOW_LIST= +# ============================================================ +# Fallback Image +# ============================================================ + +# Image served when an upstream fetch fails (404, timeout, too many redirects). +# Only one source should be set; priority if multiple are set: data > path > url. + +# Base64-encoded image data. Generate with: base64 fallback.png | tr -d '\n' +# PP_FALLBACK_IMAGE_DATA= + +# Path to a locally stored fallback image file. +# PP_FALLBACK_IMAGE_PATH= + +# URL of the fallback image (fetched once at startup). +# PP_FALLBACK_IMAGE_URL= + +# HTTP status code to use for fallback responses. Set to 0 to use the original +# error's status code instead. Default: 200 +# PP_FALLBACK_IMAGE_HTTP_CODE=200 + +# Cache-Control max-age (seconds) for fallback responses. +# Falls back to PP_TTL when unset or 0. +# PP_FALLBACK_IMAGE_TTL= + # ============================================================ # Monitoring # ============================================================ diff --git a/README.md b/README.md index 97cfccc..4254346 100644 --- a/README.md +++ b/README.md @@ -144,48 +144,8 @@ previewproxy upgrade ### CLI Reference -Configuration is read from environment variables (`.env` file) or CLI flags - CLI flags take precedence. - -| Flag / Env var | Default | Description | -| --------------------------------------------------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- | -| `--port`, `-p`
`PP_PORT` | `8080` | Server port | -| `--env`, `-E`
`PP_APP_ENV` | `development` | `development` or `production` | -| `--max-concurrent-requests`
`PP_MAX_CONCURRENT_REQUESTS` | `256` | Max number of concurrent requests before returning 503 | -| `--rust-log`
`RUST_LOG` | `previewproxy=info,...` | Log level filter | -| `--hmac-key`, `-k`
`PP_HMAC_KEY` | - | HMAC signing key; omit to disable | -| `--allowed-hosts`, `-a`
`PP_ALLOWED_HOSTS` | - | Comma-separated allowed domains; empty = allow all | -| `--source-url-encryption-key`
`PP_SOURCE_URL_ENCRYPTION_KEY` | - | Hex-encoded AES key for source URL encryption (32/48/64 hex chars = AES-128/192/256); omit to disable | -| `--fetch-timeout-secs`, `-t`
`PP_FETCH_TIMEOUT_SECS` | `10` | Upstream fetch timeout (seconds) | -| `--max-source-bytes`, `-s`
`PP_MAX_SOURCE_BYTES` | `20971520` | Max source image size (bytes) | -| `--cache-memory-max-mb`
`PP_CACHE_MEMORY_MAX_MB` | `256` | L1 in-memory cache size (MB) | -| `--cache-memory-ttl-secs`
`PP_CACHE_MEMORY_TTL_SECS` | `3600` | L1 cache TTL (seconds) | -| `--cache-dir`, `-D`
`PP_CACHE_DIR` | `/tmp/previewproxy` | L2 disk cache directory | -| `--cache-disk-ttl-secs`
`PP_CACHE_DISK_TTL_SECS` | `86400` | L2 cache TTL (seconds) | -| `--cache-disk-max-mb`
`PP_CACHE_DISK_MAX_MB` | - | L2 disk cache size limit (MB); empty = unlimited | -| `--cache-cleanup-interval-secs`
`PP_CACHE_CLEANUP_INTERVAL_SECS` | `600` | Background cleanup interval (seconds) | -| `--s3-enabled`
`PP_S3_ENABLED` | `false` | Enable S3 as an image source | -| `--s3-bucket`
`PP_S3_BUCKET` | - | S3 bucket name (required if S3 enabled) | -| `--s3-region`
`PP_S3_REGION` | `us-east-1` | S3 region | -| `--s3-access-key-id`
`PP_S3_ACCESS_KEY_ID` | - | S3 access key ID (required if S3 enabled) | -| `--s3-secret-access-key`
`PP_S3_SECRET_ACCESS_KEY` | - | S3 secret access key (required if S3 enabled) | -| `--s3-endpoint`
`PP_S3_ENDPOINT` | - | Custom S3 endpoint URL (for Cloudflare R2, RustFS, etc.); omit for AWS | -| `--local-enabled`
`PP_LOCAL_ENABLED` | `false` | Enable local filesystem as an image source | -| `--local-base-dir`
`PP_LOCAL_BASE_DIR` | - | Root directory for local file serving (required if local enabled) | -| `--ffmpeg-path`
`PP_FFMPEG_PATH` | `ffmpeg` | Path to the ffmpeg binary | -| `--ffprobe-path`
`PP_FFPROBE_PATH` | `ffprobe` (same dir as ffmpeg) | Path to the ffprobe binary | -| `--cors-allow-origin`
`PP_CORS_ALLOW_ORIGIN` | `*` | Comma-separated allowed CORS origins; `*` = allow all; wildcards (`*.example.com`) match a single subdomain label | -| `--cors-max-age-secs`
`PP_CORS_MAX_AGE_SECS` | `600` | CORS preflight cache duration (seconds) | -| `--input-disallow-list`
`PP_INPUT_DISALLOW_LIST` | - | Comma-separated input formats to block: `jpeg`, `png`, `gif`, `webp`, `avif`, `jxl`, `bmp`, `tiff`, `pdf`, `psd`, `video` | -| `--output-disallow-list`
`PP_OUTPUT_DISALLOW_LIST` | - | Comma-separated output formats to block: `jpeg`, `png`, `gif`, `webp`, `avif`, `jxl`, `bmp`, `tiff`, `ico` | -| `--transform-disallow-list`
`PP_TRANSFORM_DISALLOW_LIST` | - | Comma-separated transforms to block: `resize`, `rotate`, `flip`, `grayscale`, `brightness`, `contrast`, `blur`, `watermark`, `gif_anim` | -| `--url-aliases`
`PP_URL_ALIASES` | - | Comma-separated alias definitions: `name=https://base.url,name2=https://other.url`; enables `name:/path` URL scheme in requests | -| `--best-format-complexity-threshold`
`PP_BEST_FORMAT_COMPLEXITY_THRESHOLD` | `5.5` | Sobel edge density threshold; images below this are treated as low-complexity (lossless candidates included) | -| `--best-format-max-resolution`
`PP_BEST_FORMAT_MAX_RESOLUTION` | - | When set, images with resolution (megapixels) above this skip the multi-encode trial and pick one format | -| `--best-format-by-default`
`PP_BEST_FORMAT_BY_DEFAULT` | `false` | When true and no format is specified, use best-format selection instead of returning source format | -| `--best-format-allow-skips`
`PP_BEST_FORMAT_ALLOW_SKIPS` | `false` | When true, skip re-encoding if best format matches source format and no other transforms are applied | -| `--best-format-preferred-formats`
`PP_BEST_FORMAT_PREFERRED_FORMATS` | `jpeg,webp,png` | Comma-separated formats to trial; add `avif` or `jxl` for better compression at the cost of slower encoding | -| `--prometheus-bind`
`PP_PROMETHEUS_BIND` | - | Address to expose Prometheus metrics (e.g. `:9464` or `0.0.0.0:9464`); omit to disable | -| `--prometheus-namespace`
`PP_PROMETHEUS_NAMESPACE` | - | Prefix for all Prometheus metric names | +Configuration is read from environment variables (`.env` file) or CLI flags - CLI flags take precedence. See [Environment Variables](https://platform.vigrise.com/docs/open-source-software/previewproxy/configuration/environment-variables) for full +reference. --- diff --git a/src/common/config/loader.rs b/src/common/config/loader.rs index 4c98520..5055ff7 100644 --- a/src/common/config/loader.rs +++ b/src/common/config/loader.rs @@ -267,6 +267,7 @@ impl Configuration { env, listen_address, app_port, + ttl: env_var_u64("PP_TTL", 86400), hmac_key: env_var_opt("PP_HMAC_KEY"), source_url_encryption_key: env_var_opt("PP_SOURCE_URL_ENCRYPTION_KEY") .map(|s| parse_hex_key("PP_SOURCE_URL_ENCRYPTION_KEY", &s)), @@ -275,29 +276,25 @@ impl Configuration { max_source_bytes: env_var_u64("PP_MAX_SOURCE_BYTES", 20_971_520), cache_memory_max_mb: env_var_u64("PP_CACHE_MEMORY_MAX_MB", 256), cache_memory_ttl_secs: env_var_u64("PP_CACHE_MEMORY_TTL_SECS", 3600), - cache_dir: std::env::var("PP_CACHE_DIR") - .unwrap_or_else(|_| "/tmp/previewproxy".to_string()), + cache_dir: std::env::var("PP_CACHE_DIR").unwrap_or_else(|_| "/tmp/previewproxy".to_string()), cache_disk_ttl_secs: env_var_u64("PP_CACHE_DISK_TTL_SECS", 86400), cache_disk_max_mb: env_var_opt("PP_CACHE_DISK_MAX_MB").and_then(|v| v.parse().ok()), cache_cleanup_interval_secs: env_var_u64("PP_CACHE_CLEANUP_INTERVAL_SECS", 600), s3_enabled: env_var_bool("PP_S3_ENABLED"), s3_bucket: env_var_opt("PP_S3_BUCKET"), - s3_region: std::env::var("PP_S3_REGION") - .unwrap_or_else(|_| "us-east-1".to_string()), + s3_region: std::env::var("PP_S3_REGION").unwrap_or_else(|_| "us-east-1".to_string()), s3_access_key_id: env_var_opt("PP_S3_ACCESS_KEY_ID"), s3_secret_access_key: env_var_opt("PP_S3_SECRET_ACCESS_KEY"), s3_endpoint: env_var_opt("PP_S3_ENDPOINT"), local_enabled: env_var_bool("PP_LOCAL_ENABLED"), local_base_dir: env_var_opt("PP_LOCAL_BASE_DIR"), - ffmpeg_path: std::env::var("PP_FFMPEG_PATH") - .unwrap_or_else(|_| "ffmpeg".to_string()), + ffmpeg_path: std::env::var("PP_FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()), ffprobe_path: { let explicit = std::env::var("PP_FFPROBE_PATH").unwrap_or_default(); if !explicit.is_empty() { explicit } else { - let ffmpeg = - std::env::var("PP_FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()); + let ffmpeg = std::env::var("PP_FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()); let path = std::path::Path::new(&ffmpeg); match path.parent() { Some(dir) if dir != std::path::Path::new("") => { @@ -331,16 +328,13 @@ impl Configuration { transform_disallow: parse_transform_disallow( &std::env::var("PP_TRANSFORM_DISALLOW_LIST").unwrap_or_default(), ), - url_aliases: parse_url_aliases( - &std::env::var("PP_URL_ALIASES").unwrap_or_default(), - ), + url_aliases: parse_url_aliases(&std::env::var("PP_URL_ALIASES").unwrap_or_default()), best_format: BestFormatConfig { complexity_threshold: std::env::var("PP_BEST_FORMAT_COMPLEXITY_THRESHOLD") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(5.5), - max_resolution: env_var_opt("PP_BEST_FORMAT_MAX_RESOLUTION") - .and_then(|v| v.parse().ok()), + max_resolution: env_var_opt("PP_BEST_FORMAT_MAX_RESOLUTION").and_then(|v| v.parse().ok()), by_default: env_var_bool("PP_BEST_FORMAT_BY_DEFAULT"), allow_skips: env_var_bool("PP_BEST_FORMAT_ALLOW_SKIPS"), preferred_formats: parse_preferred_formats( @@ -354,7 +348,6 @@ impl Configuration { fallback_image_ttl: env_var_opt("PP_FALLBACK_IMAGE_TTL") .and_then(|v| v.parse::().ok()) .filter(|&v| v > 0), - ttl: env_var_u64("PP_TTL", 86400), }); if cfg.hmac_key.is_none() { tracing::warn!("HMAC_KEY is not set - all requests are unauthenticated"); @@ -478,7 +471,10 @@ impl std::fmt::Debug for Configuration { }), ) .field("best_format", &self.best_format) - .field("fallback_image_data", &self.fallback_image_data.as_ref().map(|_| "[set]")) + .field( + "fallback_image_data", + &self.fallback_image_data.as_ref().map(|_| "[set]"), + ) .field("fallback_image_path", &self.fallback_image_path) .field("fallback_image_url", &self.fallback_image_url) .field("fallback_image_http_code", &self.fallback_image_http_code) @@ -865,8 +861,14 @@ mod tests { std::env::remove_var("PP_TTL"); } assert_eq!(cfg.fallback_image_data.as_deref(), Some("aGVsbG8=")); - assert_eq!(cfg.fallback_image_path.as_deref(), Some("/tmp/fallback.png")); - assert_eq!(cfg.fallback_image_url.as_deref(), Some("https://example.com/fallback.png")); + assert_eq!( + cfg.fallback_image_path.as_deref(), + Some("/tmp/fallback.png") + ); + assert_eq!( + cfg.fallback_image_url.as_deref(), + Some("https://example.com/fallback.png") + ); assert_eq!(cfg.fallback_image_http_code, 0); assert_eq!(cfg.fallback_image_ttl, Some(300)); assert_eq!(cfg.ttl, 7200); diff --git a/src/modules/cli/args.rs b/src/modules/cli/args.rs index 15be13f..5fbc499 100644 --- a/src/modules/cli/args.rs +++ b/src/modules/cli/args.rs @@ -14,34 +14,23 @@ pub struct Cli { pub port: u16, /// Environment: development or production [env: PP_APP_ENV] - #[arg( - short = 'E', - long, - env = "PP_APP_ENV", - default_value = "development" - )] + #[arg(short = 'E', long, env = "PP_APP_ENV", default_value = "development")] pub env: String, + /// General response TTL in seconds [env: PP_TTL] + #[arg(long, env = "PP_TTL", default_value_t = 86400u64)] + pub ttl: u64, + /// HMAC signing key (leave empty to disable) [env: PP_HMAC_KEY] #[arg(short = 'k', long, env = "PP_HMAC_KEY")] pub hmac_key: Option, /// Comma-separated allowed upstream hosts (empty = allow all) [env: PP_ALLOWED_HOSTS] - #[arg( - short = 'a', - long, - env = "PP_ALLOWED_HOSTS", - default_value = "" - )] + #[arg(short = 'a', long, env = "PP_ALLOWED_HOSTS", default_value = "")] pub allowed_hosts: String, /// Upstream fetch timeout in seconds [env: PP_FETCH_TIMEOUT_SECS] - #[arg( - short = 't', - long, - env = "PP_FETCH_TIMEOUT_SECS", - default_value = "10" - )] + #[arg(short = 't', long, env = "PP_FETCH_TIMEOUT_SECS", default_value = "10")] pub fetch_timeout_secs: u64, /// Maximum source image size in bytes [env: PP_MAX_SOURCE_BYTES] @@ -58,11 +47,7 @@ pub struct Cli { pub cache_memory_max_mb: u64, /// L1 in-memory cache TTL in seconds [env: PP_CACHE_MEMORY_TTL_SECS] - #[arg( - long, - env = "PP_CACHE_MEMORY_TTL_SECS", - default_value = "3600" - )] + #[arg(long, env = "PP_CACHE_MEMORY_TTL_SECS", default_value = "3600")] pub cache_memory_ttl_secs: u64, /// L2 disk cache directory [env: PP_CACHE_DIR] @@ -75,11 +60,7 @@ pub struct Cli { pub cache_dir: String, /// L2 disk cache TTL in seconds [env: PP_CACHE_DISK_TTL_SECS] - #[arg( - long, - env = "PP_CACHE_DISK_TTL_SECS", - default_value = "86400" - )] + #[arg(long, env = "PP_CACHE_DISK_TTL_SECS", default_value = "86400")] pub cache_disk_ttl_secs: u64, /// L2 disk cache max size in MB (empty = unlimited) [env: PP_CACHE_DISK_MAX_MB] @@ -87,11 +68,7 @@ pub struct Cli { pub cache_disk_max_mb: String, /// Cache cleanup interval in seconds [env: PP_CACHE_CLEANUP_INTERVAL_SECS] - #[arg( - long, - env = "PP_CACHE_CLEANUP_INTERVAL_SECS", - default_value = "600" - )] + #[arg(long, env = "PP_CACHE_CLEANUP_INTERVAL_SECS", default_value = "600")] pub cache_cleanup_interval_secs: u64, /// Path to the ffmpeg binary [env: PP_FFMPEG_PATH] @@ -127,11 +104,7 @@ pub struct Cli { pub url_aliases: String, /// Max in-flight requests before returning 503 [env: PP_MAX_CONCURRENT_REQUESTS] - #[arg( - long, - env = "PP_MAX_CONCURRENT_REQUESTS", - default_value_t = 256 - )] + #[arg(long, env = "PP_MAX_CONCURRENT_REQUESTS", default_value_t = 256)] pub max_concurrent_requests: u32, /// Log level filter (e.g. previewproxy=info,tower_http=info) [env: RUST_LOG] @@ -187,27 +160,15 @@ pub struct Cli { pub best_format_complexity_threshold: f64, /// Max resolution in megapixels before skipping multi-format trial (leave empty to always trial) [env: PP_BEST_FORMAT_MAX_RESOLUTION] - #[arg( - long, - env = "PP_BEST_FORMAT_MAX_RESOLUTION", - default_value = "" - )] + #[arg(long, env = "PP_BEST_FORMAT_MAX_RESOLUTION", default_value = "")] pub best_format_max_resolution: String, /// Apply best-format selection for all requests that don't specify a format [env: PP_BEST_FORMAT_BY_DEFAULT] - #[arg( - long, - env = "PP_BEST_FORMAT_BY_DEFAULT", - default_value_t = false - )] + #[arg(long, env = "PP_BEST_FORMAT_BY_DEFAULT", default_value_t = false)] pub best_format_by_default: bool, /// Skip re-encoding if selected best format matches source format and no transforms applied [env: PP_BEST_FORMAT_ALLOW_SKIPS] - #[arg( - long, - env = "PP_BEST_FORMAT_ALLOW_SKIPS", - default_value_t = false - )] + #[arg(long, env = "PP_BEST_FORMAT_ALLOW_SKIPS", default_value_t = false)] pub best_format_allow_skips: bool, /// Comma-separated formats to trial for best-format selection [env: PP_BEST_FORMAT_PREFERRED_FORMATS] @@ -246,10 +207,6 @@ pub struct Cli { #[arg(long, env = "PP_FALLBACK_IMAGE_TTL", default_value_t = 0u64)] pub fallback_image_ttl: u64, - /// General response TTL in seconds [env: PP_TTL] - #[arg(long, env = "PP_TTL", default_value_t = 86400u64)] - pub ttl: u64, - #[command(subcommand)] pub command: Option, } @@ -259,19 +216,10 @@ impl Cli { unsafe { std::env::set_var("PP_PORT", self.port.to_string()); std::env::set_var("PP_APP_ENV", &self.env); - std::env::set_var( - "PP_HMAC_KEY", - self.hmac_key.as_deref().unwrap_or(""), - ); + std::env::set_var("PP_HMAC_KEY", self.hmac_key.as_deref().unwrap_or("")); std::env::set_var("PP_ALLOWED_HOSTS", &self.allowed_hosts); - std::env::set_var( - "PP_FETCH_TIMEOUT_SECS", - self.fetch_timeout_secs.to_string(), - ); - std::env::set_var( - "PP_MAX_SOURCE_BYTES", - self.max_source_bytes.to_string(), - ); + std::env::set_var("PP_FETCH_TIMEOUT_SECS", self.fetch_timeout_secs.to_string()); + std::env::set_var("PP_MAX_SOURCE_BYTES", self.max_source_bytes.to_string()); std::env::set_var( "PP_CACHE_MEMORY_MAX_MB", self.cache_memory_max_mb.to_string(), @@ -293,22 +241,10 @@ impl Cli { std::env::set_var("PP_FFMPEG_PATH", &self.ffmpeg_path); std::env::set_var("PP_FFPROBE_PATH", &self.ffprobe_path); std::env::set_var("PP_CORS_ALLOW_ORIGIN", &self.cors_allow_origin); - std::env::set_var( - "PP_CORS_MAX_AGE_SECS", - self.cors_max_age_secs.to_string(), - ); - std::env::set_var( - "PP_INPUT_DISALLOW_LIST", - &self.input_disallow_list, - ); - std::env::set_var( - "PP_OUTPUT_DISALLOW_LIST", - &self.output_disallow_list, - ); - std::env::set_var( - "PP_TRANSFORM_DISALLOW_LIST", - &self.transform_disallow_list, - ); + std::env::set_var("PP_CORS_MAX_AGE_SECS", self.cors_max_age_secs.to_string()); + std::env::set_var("PP_INPUT_DISALLOW_LIST", &self.input_disallow_list); + std::env::set_var("PP_OUTPUT_DISALLOW_LIST", &self.output_disallow_list); + std::env::set_var("PP_TRANSFORM_DISALLOW_LIST", &self.transform_disallow_list); std::env::set_var("PP_URL_ALIASES", &self.url_aliases); std::env::set_var( "PP_MAX_CONCURRENT_REQUESTS", @@ -323,10 +259,7 @@ impl Cli { std::env::set_var("PP_S3_BUCKET", &self.s3_bucket); std::env::set_var("PP_S3_REGION", &self.s3_region); std::env::set_var("PP_S3_ACCESS_KEY_ID", &self.s3_access_key_id); - std::env::set_var( - "PP_S3_SECRET_ACCESS_KEY", - &self.s3_secret_access_key, - ); + std::env::set_var("PP_S3_SECRET_ACCESS_KEY", &self.s3_secret_access_key); std::env::set_var("PP_S3_ENDPOINT", &self.s3_endpoint); std::env::set_var("PP_LOCAL_ENABLED", self.local_enabled.to_string()); std::env::set_var("PP_LOCAL_BASE_DIR", &self.local_base_dir); @@ -362,10 +295,7 @@ impl Cli { "PP_FALLBACK_IMAGE_HTTP_CODE", self.fallback_image_http_code.to_string(), ); - std::env::set_var( - "PP_FALLBACK_IMAGE_TTL", - self.fallback_image_ttl.to_string(), - ); + std::env::set_var("PP_FALLBACK_IMAGE_TTL", self.fallback_image_ttl.to_string()); std::env::set_var("PP_TTL", self.ttl.to_string()); } } @@ -543,24 +473,12 @@ mod tests { "hexkey", ]); cli.apply_to_env(); - assert_eq!( - std::env::var("PP_MAX_CONCURRENT_REQUESTS").unwrap(), - "128" - ); + assert_eq!(std::env::var("PP_MAX_CONCURRENT_REQUESTS").unwrap(), "128"); assert_eq!(std::env::var("PP_S3_ENABLED").unwrap(), "true"); - assert_eq!( - std::env::var("PP_S3_BUCKET").unwrap(), - "testbucket" - ); + assert_eq!(std::env::var("PP_S3_BUCKET").unwrap(), "testbucket"); assert_eq!(std::env::var("PP_LOCAL_ENABLED").unwrap(), "true"); - assert_eq!( - std::env::var("PP_LOCAL_BASE_DIR").unwrap(), - "/srv/images" - ); - assert_eq!( - std::env::var("PP_BEST_FORMAT_BY_DEFAULT").unwrap(), - "true" - ); + assert_eq!(std::env::var("PP_LOCAL_BASE_DIR").unwrap(), "/srv/images"); + assert_eq!(std::env::var("PP_BEST_FORMAT_BY_DEFAULT").unwrap(), "true"); assert_eq!( std::env::var("PP_BEST_FORMAT_PREFERRED_FORMATS").unwrap(), "webp,avif" diff --git a/src/modules/metrics/prometheus/exporter.rs b/src/modules/metrics/prometheus/exporter.rs index d0e6c74..67b6c17 100644 --- a/src/modules/metrics/prometheus/exporter.rs +++ b/src/modules/metrics/prometheus/exporter.rs @@ -28,7 +28,6 @@ pub async fn handle_metrics(State(metrics): State>) -> Response { #[cfg(test)] mod tests { - use super::*; use crate::modules::metrics::Metrics; use axum::http::StatusCode; use tower::ServiceExt; @@ -50,7 +49,10 @@ mod tests { .get("content-type") .and_then(|v| v.to_str().ok()) .unwrap_or(""); - assert!(ct.contains("text/plain"), "content-type should be text/plain, got: {ct}"); + assert!( + ct.contains("text/plain"), + "content-type should be text/plain, got: {ct}" + ); } #[tokio::test] diff --git a/src/modules/mod.rs b/src/modules/mod.rs index dc25870..17079f0 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -9,8 +9,8 @@ pub mod transform; use crate::common::config::Config; use crate::modules::cache::manager::CacheManager; use crate::modules::metrics::Metrics; -use crate::modules::proxy::fetchable::Fetchable; use crate::modules::proxy::fallback::FallbackImage; +use crate::modules::proxy::fetchable::Fetchable; use axum::Router; use std::sync::Arc; use tokio::sync::Semaphore; diff --git a/src/modules/proxy/controller.rs b/src/modules/proxy/controller.rs index 6da267e..4704305 100644 --- a/src/modules/proxy/controller.rs +++ b/src/modules/proxy/controller.rs @@ -1,9 +1,9 @@ use crate::common::config::Config; use crate::common::errors::ProxyError; -use crate::modules::proxy::fallback::FallbackImage; use crate::modules::AppState; use crate::modules::cache::manager::CacheHit; use crate::modules::cache::memory::CacheEntry; +use crate::modules::proxy::fallback::FallbackImage; use crate::modules::proxy::{ dto::{ ProcessResult, @@ -67,14 +67,22 @@ async fn handle_query( axum::body::Body::empty(), ) .into_response(); - state.metrics.status_codes_total.with_label_values(&[resp.status().as_str()]).inc(); + state + .metrics + .status_codes_total + .with_label_values(&[resp.status().as_str()]) + .inc(); return resp; } }; let resp = handle_query_inner(state.clone(), query, permit, queued_at) .await .unwrap_or_else(|e| e.into_response()); - state.metrics.status_codes_total.with_label_values(&[resp.status().as_str()]).inc(); + state + .metrics + .status_codes_total + .with_label_values(&[resp.status().as_str()]) + .inc(); resp } @@ -106,14 +114,22 @@ async fn handle_path( axum::body::Body::empty(), ) .into_response(); - state.metrics.status_codes_total.with_label_values(&[resp.status().as_str()]).inc(); + state + .metrics + .status_codes_total + .with_label_values(&[resp.status().as_str()]) + .inc(); return resp; } }; let resp = handle_path_inner(state.clone(), path, query, permit, queued_at) .await .unwrap_or_else(|e| e.into_response()); - state.metrics.status_codes_total.with_label_values(&[resp.status().as_str()]).inc(); + state + .metrics + .status_codes_total + .with_label_values(&[resp.status().as_str()]) + .inc(); resp } @@ -275,13 +291,13 @@ fn build_cached_response(entry: CacheEntry, hit: CacheHit, cfg: &Config) -> Resp #[cfg(test)] mod concurrency_tests { - use base64::Engine; use crate::common::config::Configuration; use crate::modules::AppState; use crate::modules::cache::manager::CacheManager; use crate::modules::proxy::sources::http::HttpFetcher; use crate::modules::security::allowlist::Allowlist; use axum::http::StatusCode; + use base64::Engine; use std::net::{Ipv4Addr, SocketAddr}; use std::sync::Arc; use tokio::sync::Semaphore; @@ -537,7 +553,10 @@ mod concurrency_tests { ); } - fn make_state_with_fallback(permits: usize, fallback: Option>) -> AppState { + fn make_state_with_fallback( + permits: usize, + fallback: Option>, + ) -> AppState { AppState { fallback, ..make_state(permits) @@ -573,7 +592,10 @@ mod concurrency_tests { let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), axum::http::StatusCode::OK); assert_eq!( - resp.headers().get("x-fallback").and_then(|v| v.to_str().ok()), + resp + .headers() + .get("x-fallback") + .and_then(|v| v.to_str().ok()), Some("true") ); let body = resp.into_body().collect().await.unwrap().to_bytes(); @@ -637,7 +659,10 @@ mod concurrency_tests { let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), axum::http::StatusCode::NOT_FOUND); assert_eq!( - resp.headers().get("x-fallback").and_then(|v| v.to_str().ok()), + resp + .headers() + .get("x-fallback") + .and_then(|v| v.to_str().ok()), Some("true") ); } @@ -703,7 +728,10 @@ mod concurrency_tests { .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!( - resp.headers().get("cache-control").and_then(|v| v.to_str().ok()), + resp + .headers() + .get("cache-control") + .and_then(|v| v.to_str().ok()), Some("public, max-age=300") ); } @@ -743,7 +771,10 @@ mod concurrency_tests { .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!( - resp.headers().get("cache-control").and_then(|v| v.to_str().ok()), + resp + .headers() + .get("cache-control") + .and_then(|v| v.to_str().ok()), Some("public, max-age=1234") ); } diff --git a/src/modules/proxy/fallback.rs b/src/modules/proxy/fallback.rs index e704fec..e06f6e2 100644 --- a/src/modules/proxy/fallback.rs +++ b/src/modules/proxy/fallback.rs @@ -52,7 +52,10 @@ impl FallbackImage { (raw, ct) }; - Some(Arc::new(FallbackImage { bytes, content_type })) + Some(Arc::new(FallbackImage { + bytes, + content_type, + })) } } @@ -79,7 +82,7 @@ mod tests { use base64::Engine; fn base_cfg() -> Configuration { - use std::collections::{HashMap, HashSet}; + use std::collections::HashSet; use std::net::{Ipv4Addr, SocketAddr}; Configuration { env: crate::common::config::Environment::Development, @@ -129,7 +132,9 @@ mod tests { const PNG_B64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="; fn png_bytes() -> Vec { - base64::engine::general_purpose::STANDARD.decode(PNG_B64).unwrap() + base64::engine::general_purpose::STANDARD + .decode(PNG_B64) + .unwrap() } #[tokio::test] diff --git a/src/modules/proxy/service.rs b/src/modules/proxy/service.rs index a8dc57d..dded1d8 100644 --- a/src/modules/proxy/service.rs +++ b/src/modules/proxy/service.rs @@ -85,7 +85,8 @@ impl ProxyService { fn drop(&mut self) { self.metrics.requests_in_progress.dec(); self.metrics.update_utilization(); - self.metrics + self + .metrics .request_duration_seconds .observe(self.start.elapsed().as_secs_f64()); } @@ -95,7 +96,8 @@ impl ProxyService { start: Instant::now(), }; - self.metrics + self + .metrics .request_span_duration_seconds .with_label_values(&["queue"]) .observe(queued_at.elapsed().as_secs_f64()); @@ -187,8 +189,16 @@ impl ProxyService { Ok(r) => r, Err(e) => { guard.complete(Err(e.clone())); - let error_type = if matches!(e, ProxyError::UpstreamTimeout) { "timeout" } else { "downloading" }; - self.metrics.errors_total.with_label_values(&[error_type]).inc(); + let error_type = if matches!(e, ProxyError::UpstreamTimeout) { + "timeout" + } else { + "downloading" + }; + self + .metrics + .errors_total + .with_label_values(&[error_type]) + .inc(); return Err(e); } }; @@ -293,7 +303,8 @@ impl ProxyService { tracing::info!(url = image_url.as_str(), "fetch start"); let download_start = Instant::now(); let fetch_result = self.fetcher.fetch(&image_url).await; - self.metrics + self + .metrics .request_span_duration_seconds .with_label_values(&["downloading"]) .observe(download_start.elapsed().as_secs_f64()); @@ -309,12 +320,23 @@ impl ProxyService { } Err(e) => { guard.complete(Err(e.clone())); - let error_type = if matches!(e, ProxyError::UpstreamTimeout) { "timeout" } else { "downloading" }; - self.metrics.errors_total.with_label_values(&[error_type]).inc(); + let error_type = if matches!(e, ProxyError::UpstreamTimeout) { + "timeout" + } else { + "downloading" + }; + self + .metrics + .errors_total + .with_label_values(&[error_type]) + .inc(); return Err(e); } }; - self.metrics.buffer_size_bytes.observe(src_bytes.len() as f64); + self + .metrics + .buffer_size_bytes + .observe(src_bytes.len() as f64); // 8. Video interception (extract first/seeked frame and continue as PNG) let is_video = src_ct @@ -362,13 +384,21 @@ impl ProxyService { } Err(e) => { guard.complete(Err(e.clone())); - self.metrics.errors_total.with_label_values(&["processing"]).inc(); + self + .metrics + .errors_total + .with_label_values(&["processing"]) + .inc(); return Err(e); } }, Err(e) => { guard.complete(Err(e.clone())); - self.metrics.errors_total.with_label_values(&["processing"]).inc(); + self + .metrics + .errors_total + .with_label_values(&["processing"]) + .inc(); return Err(e); } } @@ -417,7 +447,8 @@ impl ProxyService { content_type: ct, }); self.metrics.images_in_progress.dec(); - self.metrics + self + .metrics .request_span_duration_seconds .with_label_values(&["processing"]) .observe(transform_start.elapsed().as_secs_f64()); @@ -441,7 +472,11 @@ impl ProxyService { Ok(e) => e, Err(e) => { guard.complete(Err(e.clone())); - self.metrics.errors_total.with_label_values(&["processing"]).inc(); + self + .metrics + .errors_total + .with_label_values(&["processing"]) + .inc(); return Err(e); } }; @@ -843,7 +878,12 @@ mod streaming_tests { .await; let (svc, _) = make_svc(1_000_000); let result = svc - .process(TransformParams::default(), server.uri(), permit(), std::time::Instant::now()) + .process( + TransformParams::default(), + server.uri(), + permit(), + std::time::Instant::now(), + ) .await .unwrap(); assert!(matches!(result, ProcessResult::Stream { .. })); @@ -862,7 +902,12 @@ mod streaming_tests { .await; let (svc, _) = make_svc(1_000_000); let result = svc - .process(TransformParams::default(), server.uri(), permit(), std::time::Instant::now()) + .process( + TransformParams::default(), + server.uri(), + permit(), + std::time::Instant::now(), + ) .await; assert!(matches!(result, Err(ProxyError::NotAnImage))); } @@ -883,7 +928,12 @@ mod streaming_tests { .await; let (svc, _) = make_svc(1_000_000); let result = svc - .process(TransformParams::default(), server.uri(), permit(), std::time::Instant::now()) + .process( + TransformParams::default(), + server.uri(), + permit(), + std::time::Instant::now(), + ) .await; assert!( matches!(result, Err(ProxyError::VideoDecodeError)), @@ -905,7 +955,12 @@ mod streaming_tests { .await; let (svc, _) = make_svc(1_000_000); let result = svc - .process(TransformParams::default(), server.uri(), permit(), std::time::Instant::now()) + .process( + TransformParams::default(), + server.uri(), + permit(), + std::time::Instant::now(), + ) .await; assert!(matches!(result, Err(ProxyError::PdfRenderError))); } @@ -924,7 +979,12 @@ mod streaming_tests { let (svc, cache) = make_svc(1_000_000); let url = server.uri(); let result = svc - .process(TransformParams::default(), url.clone(), permit(), std::time::Instant::now()) + .process( + TransformParams::default(), + url.clone(), + permit(), + std::time::Instant::now(), + ) .await .unwrap(); if let ProcessResult::Stream { body, .. } = result { @@ -968,7 +1028,12 @@ mod streaming_tests { let (svc, cache) = make_svc(1_000_000); let url = server.uri(); let result = svc - .process(TransformParams::default(), url.clone(), permit(), std::time::Instant::now()) + .process( + TransformParams::default(), + url.clone(), + permit(), + std::time::Instant::now(), + ) .await .unwrap(); if let ProcessResult::Stream { mut body, .. } = result { @@ -1031,7 +1096,12 @@ mod streaming_tests { let (svc, cache) = make_svc(50); let url = server.uri(); let result = svc - .process(TransformParams::default(), url.clone(), permit(), std::time::Instant::now()) + .process( + TransformParams::default(), + url.clone(), + permit(), + std::time::Instant::now(), + ) .await .unwrap(); if let ProcessResult::Stream { mut body, .. } = result { diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 757afc4..d0b267e 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -155,10 +155,7 @@ async fn test_local_source_passthrough() { unsafe { std::env::set_var("PP_PORT", "8081"); std::env::set_var("PP_APP_ENV", "development"); - std::env::set_var( - "PP_CACHE_DIR", - "/tmp/previewproxy-test-local-passthrough", - ); + std::env::set_var("PP_CACHE_DIR", "/tmp/previewproxy-test-local-passthrough"); std::env::set_var("PP_CACHE_MEMORY_MAX_MB", "10"); std::env::remove_var("PP_HMAC_KEY"); std::env::remove_var("PP_ALLOWED_HOSTS"); @@ -196,10 +193,7 @@ async fn test_local_source_with_resize() { unsafe { std::env::set_var("PP_PORT", "8081"); std::env::set_var("PP_APP_ENV", "development"); - std::env::set_var( - "PP_CACHE_DIR", - "/tmp/previewproxy-test-local-resize", - ); + std::env::set_var("PP_CACHE_DIR", "/tmp/previewproxy-test-local-resize"); std::env::set_var("PP_CACHE_MEMORY_MAX_MB", "10"); std::env::remove_var("PP_HMAC_KEY"); std::env::remove_var("PP_ALLOWED_HOSTS");