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");