diff --git a/server/src/api/responses.rs b/server/src/api/responses.rs index f73e802..f7e1af9 100644 --- a/server/src/api/responses.rs +++ b/server/src/api/responses.rs @@ -1,6 +1,98 @@ use serde::*; +use crate::infra::image_manipulation; + #[derive(Serialize)] pub struct Standard { pub message: String, } + +#[derive(Serialize, Deserialize, Eq, PartialEq, Debug)] +pub struct MetadataResponse { + pub source: Source, + pub operations: Vec, +} + +impl MetadataResponse { + pub fn build(url: &str, ops: &image_manipulation::Operations) -> Self { + let operations = ops + .0 + .iter() + .map(|op| match *op { + image_manipulation::Operation::Resize { width, height } => Operation { + r#type: "resize".to_string(), + width: Some(width), + height: Some(height), + }, + image_manipulation::Operation::FlipHorizontally => Operation { + r#type: "flip_horizontally".to_string(), + width: None, + height: None, + }, + image_manipulation::Operation::FlipVertically => Operation { + r#type: "flip_vertically".to_string(), + width: None, + height: None, + }, + }) + .collect(); + + MetadataResponse { + source: Source { + url: url.to_string(), + }, + operations, + } + } +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Debug)] +pub struct Source { + pub url: String, +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Debug)] +pub struct Operation { + pub r#type: String, + pub width: Option, + pub height: Option, +} + +#[cfg(test)] +mod tests { + use crate::infra::image_caching::ImageResize; + + use super::*; + + #[test] + fn test_metadata_response_build() { + let domain = image_manipulation::Operations::build(&Some(ImageResize { + target_width: -100, + target_height: -300, + })); + let result = MetadataResponse::build("http://beachape.com/images/lol.png", &domain); + let expected = MetadataResponse { + source: Source { + url: "http://beachape.com/images/lol.png".to_string(), + }, + operations: vec![ + Operation { + r#type: "resize".to_string(), + width: Some(100), + height: Some(300), + }, + Operation { + r#type: "flip_horizontally".to_string(), + width: None, + height: None, + }, + Operation { + r#type: "flip_vertically".to_string(), + width: None, + height: None, + }, + ], + }; + assert_eq!(expected, result) + } +} diff --git a/server/src/api/routing/handlers.rs b/server/src/api/routing/handlers.rs index 904945e..80d248b 100644 --- a/server/src/api/routing/handlers.rs +++ b/server/src/api/routing/handlers.rs @@ -21,7 +21,7 @@ use responses::Standard; use tower_http::catch_panic::CatchPanicLayer; use crate::api::requests::{ImageResizePathParam, Signature}; -use crate::api::responses; +use crate::api::responses::{self, MetadataResponse}; use crate::infra::components::AppComponents; use crate::infra::config::AuthenticationSettings; use crate::infra::errors::AppError; @@ -29,6 +29,7 @@ use crate::infra::image_caching::{ ImageCacher, ImageFetchRequest, ImageFetchedCacheRequest, ImageResizeRequest, ImageResizedCacheRequest, }; +use crate::infra::image_manipulation::{Operations, OperationsRunner, SingletonOperationsRunner}; use miniaturs_shared::signature::{ensure_signature_is_valid_for_path_and_query, SignatureError}; @@ -39,6 +40,7 @@ pub fn create_router(app_components: AppComponents) -> Router { .route("/", get(root)) .route("/health", get(health_check)) .route("/:signature/:resized_image/*image_url", get(resize)) + .route("/:signature/meta/:resized_image/*image_url", get(metadata)) .fallback(handle_404) .layer(CatchPanicLayer::custom(handle_panic)) .with_state(app_components) @@ -60,10 +62,11 @@ async fn resize( &uri, signature, )?; + let operations = Operations::build(&Some(resized_image.into())); let processed_image_request = { ImageResizeRequest { requested_image_url: image_url.clone(), - resize_target: resized_image.into(), + operations, } }; let maybe_cached_resized_image = app_components @@ -137,23 +140,16 @@ async fn resize( let format = reader_with_format .format() .ok_or(AppError::UnableToDetermineFormat)?; - let mut dynamic_image = reader_with_format.decode()?; - dynamic_image = dynamic_image.resize( - resized_image.target_width as u32, - resized_image.target_height as u32, - image::imageops::FilterType::Gaussian, - ); - - if resized_image.target_width < 0 { - dynamic_image = dynamic_image.fliph(); - } - if resized_image.target_height < 0 { - dynamic_image = dynamic_image.flipv(); - } + let image = SingletonOperationsRunner + .run( + reader_with_format.decode()?, + &processed_image_request.operations, + ) + .await; let mut cursor = Cursor::new(Vec::new()); - dynamic_image.write_to(&mut cursor, format)?; + image.write_to(&mut cursor, format)?; let written_bytes = cursor.into_inner(); let cache_image_req = ImageResizedCacheRequest { @@ -181,6 +177,24 @@ async fn resize( } } +async fn metadata( + State(app_components): State, + uri: Uri, + Path((signature, resized_image, image_url)): Path<(Signature, ImageResizePathParam, String)>, +) -> Result { + ensure_signature_is_valid( + &app_components.config.authentication_settings, + &uri, + signature, + )?; + + let operations = Operations::build(&Some(resized_image.into())); + let metadata = MetadataResponse::build(&image_url, &operations); + let mut response_headers = HeaderMap::new(); + response_headers.insert(CACHE_CONTROL, CACHE_CONTROL_HEADER_VALUE); + Ok((StatusCode::OK, response_headers, Json(metadata)).into_response()) +} + /// Example on how to return status codes and data from an Axum function async fn health_check() -> (StatusCode, Json) { let health = true; diff --git a/server/src/infra/image_caching.rs b/server/src/infra/image_caching.rs index d3f8bbf..b92a591 100644 --- a/server/src/infra/image_caching.rs +++ b/server/src/infra/image_caching.rs @@ -11,10 +11,12 @@ use sha256; use crate::api::requests::ImageResizePathParam; +use super::image_manipulation::Operations; + #[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone)] pub struct ImageResizeRequest { pub requested_image_url: String, - pub resize_target: ImageResize, + pub operations: Operations, } #[derive(Serialize, Deserialize, Eq, PartialEq, Debug, Clone, Copy)] @@ -274,10 +276,10 @@ mod tests { fn test_cache_key() -> TestResult { let req = ImageResizeRequest { requested_image_url: "https://beachape.com/images/something.png".to_string(), - resize_target: ImageResize { + operations: Operations::build(&Some(ImageResize { target_width: 100, target_height: 100, - }, + })), }; let key = req.cache_key(); assert!(key.0.len() < 1024); @@ -289,10 +291,10 @@ mod tests { let req = ImageResizedCacheRequest { request: ImageResizeRequest { requested_image_url: "https://beachape.com/images/something.png".to_string(), - resize_target: ImageResize { + operations: Operations::build(&Some(ImageResize { target_width: 100, target_height: 200, - }, + })), }, content_type: "image/png".to_string(), }; @@ -317,10 +319,10 @@ mod tests { let req = ImageResizeRequest { requested_image_url: "https://beachape.com/images/something_that_does_not_exist.png" .to_string(), - resize_target: ImageResize { + operations: Operations::build(&Some(ImageResize { target_width: 100, target_height: 100, - }, + })), }; let retrieved = s3_image_cacher.get(&req).await; assert!(retrieved.is_none()); @@ -338,10 +340,10 @@ mod tests { let req = ImageResizeRequest { requested_image_url: "https://beachape.com/images/something.png".to_string(), - resize_target: ImageResize { + operations: Operations::build(&Some(ImageResize { target_width: 100, target_height: 100, - }, + })), }; let content = b"testcontent"; let image_set_req = ImageResizedCacheRequest { @@ -368,11 +370,10 @@ mod tests { }; let req = ImageResizeRequest { requested_image_url: "https://beachape.com/images/something_else.png".to_string(), - - resize_target: ImageResize { + operations: Operations::build(&Some(ImageResize { target_width: 300, target_height: 500, - }, + })), }; let content = b"testcontent"; let image_set_req = ImageResizedCacheRequest { diff --git a/server/src/infra/image_manipulation.rs b/server/src/infra/image_manipulation.rs index beeea3f..70c228d 100644 --- a/server/src/infra/image_manipulation.rs +++ b/server/src/infra/image_manipulation.rs @@ -3,14 +3,14 @@ use serde::{Deserialize, Serialize}; use super::image_caching::ImageResize; -#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)] pub enum Operation { Resize { width: u32, height: u32 }, FlipHorizontally, FlipVertically, } -#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)] +#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)] pub struct Operations(pub Vec); impl Operations { diff --git a/server/src/main.rs b/server/src/main.rs index b738d1e..cb243ca 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -47,10 +47,12 @@ mod tests { use http_body_util::BodyExt; use image::{ImageFormat, ImageReader}; use lambda_http::tower::ServiceExt; + use miniaturs_server::api::responses::MetadataResponse; use miniaturs_server::infra::config::{ AuthenticationSettings, AwsSettings, ImageCacheSettings, }; use miniaturs_server::infra::image_caching::*; + use miniaturs_server::infra::image_manipulation::Operations; use miniaturs_shared::signature::make_url_safe_base64_hash; use reqwest::{header::CONTENT_TYPE, StatusCode}; use testcontainers::{runners::AsyncRunner, ContainerAsync, ImageExt}; @@ -117,6 +119,42 @@ mod tests { .await } + #[tokio::test] + async fn test_metadata_response() -> TestResult<()> { + test_metadata( + &PNG_URL_1, + ImageResize { + target_width: 100, + target_height: 80, + }, + ) + .await?; + test_metadata( + &PNG_URL_1, + ImageResize { + target_width: -100, + target_height: 80, + }, + ) + .await?; + test_metadata( + &JPG_URL_1, + ImageResize { + target_width: 100, + target_height: -80, + }, + ) + .await?; + test_metadata( + &PNG_URL_1, + ImageResize { + target_width: -100, + target_height: -80, + }, + ) + .await + } + async fn test_resize( image_url: &str, expected_image_format: ImageFormat, @@ -226,6 +264,45 @@ mod tests { Ok(()) } + async fn test_metadata(image_url: &str, resize: ImageResize) -> TestResult<()> { + let signed_path = + signed_metadata_path(&config().await.authentication_settings, resize, image_url)?; + let response = app() + .await? + .oneshot(Request::builder().uri(signed_path).body(Body::empty())?) + .await?; + assert_eq!(StatusCode::OK, response.status()); + + let mut body_as_metadata: MetadataResponse = + serde_json::from_slice(response.into_body().collect().await?.to_bytes().as_ref())?; + + assert_eq!(image_url, body_as_metadata.source.url); + + // So we can pop easily + body_as_metadata.operations.reverse(); + + let mut op = body_as_metadata.operations.pop().unwrap(); + assert_eq!("resize", op.r#type); + assert_eq!(resize.target_width.unsigned_abs(), op.width.unwrap()); + assert_eq!(resize.target_height.unsigned_abs(), op.height.unwrap()); + + if resize.target_width.is_negative() { + op = body_as_metadata.operations.pop().unwrap(); + assert_eq!("flip_horizontally", op.r#type); + assert_eq!(None, op.width); + assert_eq!(None, op.height); + } + + if resize.target_height.is_negative() { + op = body_as_metadata.operations.pop().unwrap(); + assert_eq!("flip_vertically", op.r#type); + assert_eq!(None, op.width); + assert_eq!(None, op.height); + } + + Ok(()) + } + async fn retrieve_unprocessed_cached( image_url: &str, ) -> Option> { @@ -248,7 +325,7 @@ mod tests { let app_components = AppComponents::create(config.clone()).ok()?; let processed_cache_retrieve_req = ImageResizeRequest { requested_image_url: image_url.to_string(), - resize_target, + operations: Operations::build(&Some(resize_target)), }; app_components .processed_images_cacher @@ -269,6 +346,19 @@ mod tests { Ok(format!("/{hash}/{path}")) } + fn signed_metadata_path( + auth_settings: &AuthenticationSettings, + resize_target: ImageResize, + + url: &str, + ) -> TestResult { + let target_width = resize_target.target_width; + let target_height = resize_target.target_height; + let path = format!("meta/{target_width}x{target_height}/{url}"); + let hash = make_url_safe_base64_hash(&auth_settings.shared_secret, &path)?; + Ok(format!("/{hash}/{path}")) + } + async fn app() -> Result> { let config = config().await; let app_components = AppComponents::create(config.clone())?;