diff --git a/src/app.rs b/src/app.rs index 74cc642..5505463 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,10 +3,10 @@ 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::retry::RetryFetcher; +use crate::modules::proxy::router::SourceRouter; use crate::modules::proxy::sources::http::HttpFetcher; -use crate::modules::proxy::sources::{ - AliasSource, LocalSource, RetryFetcher, S3Source, SourceRouter, -}; +use crate::modules::proxy::sources::{AliasSource, LocalSource, S3Source}; use crate::modules::security::allowlist::Allowlist; use axum::Router; use std::sync::Arc; @@ -62,7 +62,14 @@ pub async fn router( ) .with_private_ip_check(check_private), ); - Arc::new(AliasSource::new(aliases.clone(), alias_http)) + let alias_s3 = s3.clone().map(|x| x as Arc); + let alias_local = local.clone().map(|x| x as Arc); + Arc::new(AliasSource::new( + aliases.clone(), + alias_http, + alias_s3, + alias_local, + )) }); let fetcher: Arc = Arc::new(RetryFetcher::new( diff --git a/src/common/config/loader.rs b/src/common/config/loader.rs index ca6cb13..5960bb0 100644 --- a/src/common/config/loader.rs +++ b/src/common/config/loader.rs @@ -192,9 +192,13 @@ fn parse_url_aliases(s: &str) -> Option> { tracing::warn!("URL_ALIASES: skipping entry {:?} with empty base URL", name); return None; } - if !base.starts_with("http://") && !base.starts_with("https://") { + let valid_scheme = base.starts_with("http://") + || base.starts_with("https://") + || base.starts_with("s3:/") + || base.starts_with("local:/"); + if !valid_scheme { tracing::warn!( - "URL_ALIASES: skipping entry {:?} - base URL must be http:// or https://, got {:?}", + "URL_ALIASES: skipping entry {:?} - base must start with http://, https://, s3:/, or local:/, got {:?}", name, base ); return None; @@ -701,6 +705,40 @@ mod tests { assert!(map.contains_key("ok")); } + #[test] + fn test_url_aliases_accepts_s3_base() { + 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_URL_ALIASES", "thumbs=s3:/my-bucket/thumbnails"); + } + let cfg = super::Configuration::new(); + unsafe { std::env::remove_var("PP_URL_ALIASES") }; + let map = cfg.url_aliases.clone().unwrap(); + assert_eq!( + map.get("thumbs").map(String::as_str), + Some("s3:/my-bucket/thumbnails") + ); + } + + #[test] + fn test_url_aliases_accepts_local_base() { + 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_URL_ALIASES", "assets=local:/var/www/static"); + } + let cfg = super::Configuration::new(); + unsafe { std::env::remove_var("PP_URL_ALIASES") }; + let map = cfg.url_aliases.clone().unwrap(); + assert_eq!( + map.get("assets").map(String::as_str), + Some("local:/var/www/static") + ); + } + #[test] fn test_url_aliases_all_invalid_is_none() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/src/modules/proxy/mod.rs b/src/modules/proxy/mod.rs index 318cb1c..98cf0c7 100644 --- a/src/modules/proxy/mod.rs +++ b/src/modules/proxy/mod.rs @@ -2,5 +2,8 @@ pub mod controller; pub mod dto; pub mod fallback; pub mod fetchable; +pub mod retry; +pub mod router; pub mod service; pub mod sources; +pub mod video; diff --git a/src/modules/proxy/sources/retry.rs b/src/modules/proxy/retry.rs similarity index 100% rename from src/modules/proxy/sources/retry.rs rename to src/modules/proxy/retry.rs diff --git a/src/modules/proxy/sources/router.rs b/src/modules/proxy/router.rs similarity index 99% rename from src/modules/proxy/sources/router.rs rename to src/modules/proxy/router.rs index 3b82d04..9ddcca4 100644 --- a/src/modules/proxy/sources/router.rs +++ b/src/modules/proxy/router.rs @@ -200,7 +200,7 @@ mod tests { ); let mut map = std::collections::HashMap::new(); map.insert("mycdn".to_string(), server_uri.to_string()); - Arc::new(AliasSource::new(map, http)) + Arc::new(AliasSource::new(map, http, None, None)) } #[tokio::test] diff --git a/src/modules/proxy/service.rs b/src/modules/proxy/service.rs index 1b2439c..d65c4c2 100644 --- a/src/modules/proxy/service.rs +++ b/src/modules/proxy/service.rs @@ -357,7 +357,7 @@ impl ProxyService { let is_video = src_ct .as_deref() .map(|ct| ct.starts_with("video/")) - .unwrap_or_else(|| crate::modules::proxy::sources::video::is_video_magic(&src_bytes)); + .unwrap_or_else(|| crate::modules::proxy::video::is_video_magic(&src_bytes)); if is_video && self @@ -370,7 +370,7 @@ impl ProxyService { if is_video { use crate::modules::proxy::dto::params::SeekMode; - use crate::modules::proxy::sources::video::{extract_frame, probe_duration}; + use crate::modules::proxy::video::{extract_frame, probe_duration}; let t_secs = match ¶ms.seek { None => 0.0, @@ -392,7 +392,7 @@ impl ProxyService { }; match extract_frame(&src_bytes, t_secs, &self.ffmpeg_path).await { - Ok(frame) => match crate::modules::proxy::sources::video::frame_to_png_bytes(frame) { + Ok(frame) => match crate::modules::proxy::video::frame_to_png_bytes(frame) { Ok(png_bytes) => { src_bytes = png_bytes; src_ct = Some("image/png".to_string()); diff --git a/src/modules/proxy/sources/alias.rs b/src/modules/proxy/sources/alias.rs index 18f2fa4..e78b4a4 100644 --- a/src/modules/proxy/sources/alias.rs +++ b/src/modules/proxy/sources/alias.rs @@ -7,11 +7,23 @@ use std::sync::Arc; pub struct AliasSource { aliases: HashMap, http: Arc, + s3: Option>, + local: Option>, } impl AliasSource { - pub fn new(aliases: HashMap, http: Arc) -> Self { - Self { aliases, http } + pub fn new( + aliases: HashMap, + http: Arc, + s3: Option>, + local: Option>, + ) -> Self { + Self { + aliases, + http, + s3, + local, + } } fn resolve(&self, url: &str) -> Result { @@ -36,7 +48,23 @@ impl Fetchable for AliasSource { resolved_url = resolved.as_str(), "alias resolved" ); - self.http.fetch(&resolved).await + if resolved.starts_with("s3:/") { + self + .s3 + .as_ref() + .ok_or_else(|| ProxyError::InvalidParams("S3 source not configured".to_string()))? + .fetch(&resolved) + .await + } else if resolved.starts_with("local:/") { + self + .local + .as_ref() + .ok_or_else(|| ProxyError::InvalidParams("local source not configured".to_string()))? + .fetch(&resolved) + .await + } else { + self.http.fetch(&resolved).await + } } } @@ -56,7 +84,35 @@ mod tests { .into_iter() .map(|(k, v)| (k.to_string(), v.to_string())) .collect(); - AliasSource::new(map, http) + AliasSource::new(map, http, None, None) + } + + struct StubFetcher { + response: Vec, + content_type: Option, + } + + #[async_trait::async_trait] + impl Fetchable for StubFetcher { + async fn fetch(&self, _url: &str) -> Result<(Vec, Option), ProxyError> { + Ok((self.response.clone(), self.content_type.clone())) + } + } + + fn make_alias_source_with_backends( + aliases: Vec<(&str, &str)>, + s3: Option>, + local: Option>, + ) -> AliasSource { + let http = Arc::new( + HttpFetcher::new(10, 1_000_000, Arc::new(Allowlist::new(vec![]))) + .with_private_ip_check(false), + ); + let map = aliases + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + AliasSource::new(map, http, s3, local) } #[test] @@ -117,6 +173,55 @@ mod tests { ); } + #[tokio::test] + async fn test_fetch_dispatches_to_s3_when_resolved_url_starts_with_s3() { + let s3_stub: Arc = Arc::new(StubFetcher { + response: b"s3data".to_vec(), + content_type: Some("image/png".to_string()), + }); + let source = + make_alias_source_with_backends(vec![("mybucket", "s3:/bucket-prefix")], Some(s3_stub), None); + let (bytes, ct) = source.fetch("mybucket:/img.png").await.unwrap(); + assert_eq!(bytes, b"s3data"); + assert_eq!(ct, Some("image/png".to_string())); + } + + #[tokio::test] + async fn test_fetch_dispatches_to_local_when_resolved_url_starts_with_local() { + let local_stub: Arc = Arc::new(StubFetcher { + response: b"localdata".to_vec(), + content_type: None, + }); + let source = + make_alias_source_with_backends(vec![("assets", "local:/uploads")], None, Some(local_stub)); + let (bytes, ct) = source.fetch("assets:/photo.jpg").await.unwrap(); + assert_eq!(bytes, b"localdata"); + assert!(ct.is_none()); + } + + #[tokio::test] + async fn test_fetch_s3_alias_without_s3_configured_returns_error() { + let source = + make_alias_source_with_backends(vec![("mybucket", "s3:/bucket-prefix")], None, None); + let err = source.fetch("mybucket:/img.png").await.unwrap_err(); + assert!( + matches!(&err, ProxyError::InvalidParams(m) if m.contains("S3")), + "unexpected: {:?}", + err + ); + } + + #[tokio::test] + async fn test_fetch_local_alias_without_local_configured_returns_error() { + let source = make_alias_source_with_backends(vec![("assets", "local:/uploads")], None, None); + let err = source.fetch("assets:/photo.jpg").await.unwrap_err(); + assert!( + matches!(&err, ProxyError::InvalidParams(m) if m.contains("local")), + "unexpected: {:?}", + err + ); + } + #[tokio::test] async fn test_fetch_resolves_and_fetches() { let server = MockServer::start().await; diff --git a/src/modules/proxy/sources/mod.rs b/src/modules/proxy/sources/mod.rs index d901b3e..75b7697 100644 --- a/src/modules/proxy/sources/mod.rs +++ b/src/modules/proxy/sources/mod.rs @@ -1,14 +1,9 @@ pub mod alias; pub mod http; pub mod local; -pub mod retry; -pub mod router; pub mod s3; -pub mod video; pub use alias::AliasSource; pub use http::HttpFetcher; pub use local::LocalSource; -pub use retry::RetryFetcher; -pub use router::SourceRouter; pub use s3::S3Source; diff --git a/src/modules/proxy/sources/video.rs b/src/modules/proxy/video.rs similarity index 100% rename from src/modules/proxy/sources/video.rs rename to src/modules/proxy/video.rs