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/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/common/config/loader.rs b/src/common/config/loader.rs index dff04d8..5055ff7 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 { @@ -260,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)), @@ -268,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("") => { @@ -324,22 +328,26 @@ 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( &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), }); if cfg.hmac_key.is_none() { tracing::warn!("HMAC_KEY is not set - all requests are unauthenticated"); @@ -463,6 +471,15 @@ 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 +815,62 @@ 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/cli/args.rs b/src/modules/cli/args.rs index 843b087..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] @@ -226,6 +187,26 @@ 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, + #[command(subcommand)] pub command: Option, } @@ -235,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(), @@ -269,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", @@ -299,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); @@ -328,6 +285,18 @@ 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()); } } } @@ -504,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" @@ -531,4 +488,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); + } } 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 bbebd1f..17079f0 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -9,6 +9,7 @@ pub mod transform; use crate::common::config::Config; use crate::modules::cache::manager::CacheManager; use crate::modules::metrics::Metrics; +use crate::modules::proxy::fallback::FallbackImage; use crate::modules::proxy::fetchable::Fetchable; use axum::Router; use std::sync::Arc; @@ -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 236d491..4704305 100644 --- a/src/modules/proxy/controller.rs +++ b/src/modules/proxy/controller.rs @@ -3,6 +3,7 @@ use crate::common::errors::ProxyError; 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, @@ -66,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 } @@ -105,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 } @@ -134,8 +151,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 +184,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. @@ -229,6 +297,7 @@ mod concurrency_tests { 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; @@ -270,6 +339,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![]))) @@ -283,6 +358,7 @@ mod concurrency_tests { concurrency: Arc::new(Semaphore::new(permits)), cfg, metrics, + fallback: None, } } @@ -296,6 +372,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; @@ -471,6 +553,120 @@ 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}; @@ -496,4 +692,90 @@ 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") + ); + } } diff --git a/src/modules/proxy/fallback.rs b/src/modules/proxy/fallback.rs new file mode 100644 index 0000000..e06f6e2 --- /dev/null +++ b/src/modules/proxy/fallback.rs @@ -0,0 +1,208 @@ +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::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; diff --git a/src/modules/proxy/service.rs b/src/modules/proxy/service.rs index 4f9b3f8..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); } }; @@ -530,6 +565,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 +831,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![]))) @@ -831,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 { .. })); @@ -850,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))); } @@ -871,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)), @@ -893,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))); } @@ -912,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 { @@ -956,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 { @@ -1019,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");