Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
# ============================================================
Expand Down
44 changes: 2 additions & 42 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`<br>`PP_PORT` | `8080` | Server port |
| `--env`, `-E`<br>`PP_APP_ENV` | `development` | `development` or `production` |
| `--max-concurrent-requests`<br>`PP_MAX_CONCURRENT_REQUESTS` | `256` | Max number of concurrent requests before returning 503 |
| `--rust-log`<br>`RUST_LOG` | `previewproxy=info,...` | Log level filter |
| `--hmac-key`, `-k`<br>`PP_HMAC_KEY` | - | HMAC signing key; omit to disable |
| `--allowed-hosts`, `-a`<br>`PP_ALLOWED_HOSTS` | - | Comma-separated allowed domains; empty = allow all |
| `--source-url-encryption-key`<br>`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`<br>`PP_FETCH_TIMEOUT_SECS` | `10` | Upstream fetch timeout (seconds) |
| `--max-source-bytes`, `-s`<br>`PP_MAX_SOURCE_BYTES` | `20971520` | Max source image size (bytes) |
| `--cache-memory-max-mb`<br>`PP_CACHE_MEMORY_MAX_MB` | `256` | L1 in-memory cache size (MB) |
| `--cache-memory-ttl-secs`<br>`PP_CACHE_MEMORY_TTL_SECS` | `3600` | L1 cache TTL (seconds) |
| `--cache-dir`, `-D`<br>`PP_CACHE_DIR` | `/tmp/previewproxy` | L2 disk cache directory |
| `--cache-disk-ttl-secs`<br>`PP_CACHE_DISK_TTL_SECS` | `86400` | L2 cache TTL (seconds) |
| `--cache-disk-max-mb`<br>`PP_CACHE_DISK_MAX_MB` | - | L2 disk cache size limit (MB); empty = unlimited |
| `--cache-cleanup-interval-secs`<br>`PP_CACHE_CLEANUP_INTERVAL_SECS` | `600` | Background cleanup interval (seconds) |
| `--s3-enabled`<br>`PP_S3_ENABLED` | `false` | Enable S3 as an image source |
| `--s3-bucket`<br>`PP_S3_BUCKET` | - | S3 bucket name (required if S3 enabled) |
| `--s3-region`<br>`PP_S3_REGION` | `us-east-1` | S3 region |
| `--s3-access-key-id`<br>`PP_S3_ACCESS_KEY_ID` | - | S3 access key ID (required if S3 enabled) |
| `--s3-secret-access-key`<br>`PP_S3_SECRET_ACCESS_KEY` | - | S3 secret access key (required if S3 enabled) |
| `--s3-endpoint`<br>`PP_S3_ENDPOINT` | - | Custom S3 endpoint URL (for Cloudflare R2, RustFS, etc.); omit for AWS |
| `--local-enabled`<br>`PP_LOCAL_ENABLED` | `false` | Enable local filesystem as an image source |
| `--local-base-dir`<br>`PP_LOCAL_BASE_DIR` | - | Root directory for local file serving (required if local enabled) |
| `--ffmpeg-path`<br>`PP_FFMPEG_PATH` | `ffmpeg` | Path to the ffmpeg binary |
| `--ffprobe-path`<br>`PP_FFPROBE_PATH` | `ffprobe` (same dir as ffmpeg) | Path to the ffprobe binary |
| `--cors-allow-origin`<br>`PP_CORS_ALLOW_ORIGIN` | `*` | Comma-separated allowed CORS origins; `*` = allow all; wildcards (`*.example.com`) match a single subdomain label |
| `--cors-max-age-secs`<br>`PP_CORS_MAX_AGE_SECS` | `600` | CORS preflight cache duration (seconds) |
| `--input-disallow-list`<br>`PP_INPUT_DISALLOW_LIST` | - | Comma-separated input formats to block: `jpeg`, `png`, `gif`, `webp`, `avif`, `jxl`, `bmp`, `tiff`, `pdf`, `psd`, `video` |
| `--output-disallow-list`<br>`PP_OUTPUT_DISALLOW_LIST` | - | Comma-separated output formats to block: `jpeg`, `png`, `gif`, `webp`, `avif`, `jxl`, `bmp`, `tiff`, `ico` |
| `--transform-disallow-list`<br>`PP_TRANSFORM_DISALLOW_LIST` | - | Comma-separated transforms to block: `resize`, `rotate`, `flip`, `grayscale`, `brightness`, `contrast`, `blur`, `watermark`, `gif_anim` |
| `--url-aliases`<br>`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`<br>`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`<br>`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`<br>`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`<br>`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`<br>`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`<br>`PP_PROMETHEUS_BIND` | - | Address to expose Prometheus metrics (e.g. `:9464` or `0.0.0.0:9464`); omit to disable |
| `--prometheus-namespace`<br>`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.

---

Expand Down
4 changes: 4 additions & 0 deletions src/app.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -68,13 +69,16 @@ 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,
fetcher,
http_fetcher,
concurrency,
metrics,
fallback,
};

let trace_layer = telemetry::trace_layer();
Expand Down
101 changes: 88 additions & 13 deletions src/common/config/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ pub struct Configuration {
pub url_aliases: Option<HashMap<String, String>>,
// Best format
pub best_format: BestFormatConfig,
// Fallback image
pub fallback_image_data: Option<String>,
pub fallback_image_path: Option<String>,
pub fallback_image_url: Option<String>,
pub fallback_image_http_code: u16,
pub fallback_image_ttl: Option<u64>,
pub ttl: u64,
}

fn env_var_opt(name: &str) -> Option<String> {
Expand Down Expand Up @@ -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)),
Expand All @@ -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("") => {
Expand Down Expand Up @@ -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::<u64>().ok())
.filter(|&v| v > 0),
});
if cfg.hmac_key.is_none() {
tracing::warn!("HMAC_KEY is not set - all requests are unauthenticated");
Expand Down Expand Up @@ -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()
}
}
Expand Down Expand Up @@ -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);
}
}
Loading