From b99aaa8351db99f1c0d83003f912277bd830816c Mon Sep 17 00:00:00 2001 From: Raito Bezarius Date: Tue, 3 Sep 2024 15:43:08 +0200 Subject: [PATCH] feat(api|web): follow through metadata-driven redirects This is an implementation of the GET handler for the `x-amz-website-redirect-location`. This implements the semantics described in https://docs.aws.amazon.com/AmazonS3/latest/userguide/how-to-page-redirect.html for the S3 API and the web API. Signed-off-by: Raito Bezarius --- Cargo.lock | 12 ++++++++++++ Cargo.nix | 15 ++++++++++++++- src/api/Cargo.toml | 1 + src/api/s3/api_server.rs | 5 ++++- src/api/s3/get.rs | 33 ++++++++++++++++++++++++++++++--- src/garage/tests/s3/website.rs | 33 +++++++++++++++++++++++++++++++++ src/web/web_server.rs | 5 +++++ 7 files changed, 99 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b23da311..51f75afd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -197,6 +197,17 @@ dependencies = [ "zstd-safe", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.48", +] + [[package]] name = "async-stream" version = "0.3.5" @@ -1365,6 +1376,7 @@ dependencies = [ "aes-gcm", "argon2", "async-compression", + "async-recursion", "async-trait", "base64 0.21.7", "bytes", diff --git a/Cargo.nix b/Cargo.nix index 124d3196..e63b92a2 100644 --- a/Cargo.nix +++ b/Cargo.nix @@ -34,7 +34,7 @@ args@{ ignoreLockHash, }: let - nixifiedLockHash = "c0aa85d369b22875a652356862a5810c22838970be9fbec558dd108d5232881d"; + nixifiedLockHash = "fe0e77c1af963cc04accebb4ddd1607df5bbbaa48ca0b7a325f5e4497e21790e"; workspaceSrc = if args.workspaceSrc == null then ./. else args.workspaceSrc; currentLockHash = builtins.hashFile "sha256" (workspaceSrc + /Cargo.lock); lockHashIgnored = if ignoreLockHash @@ -349,6 +349,18 @@ in }; }); + "registry+https://github.com/rust-lang/crates.io-index".async-recursion."1.1.1" = overridableMkRustCrate (profileName: rec { + name = "async-recursion"; + version = "1.1.1"; + registry = "registry+https://github.com/rust-lang/crates.io-index"; + src = fetchCratesIo { inherit name version; sha256 = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"; }; + dependencies = { + proc_macro2 = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".proc-macro2."1.0.78" { inherit profileName; }).out; + quote = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".quote."1.0.35" { inherit profileName; }).out; + syn = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".syn."2.0.48" { inherit profileName; }).out; + }; + }); + "registry+https://github.com/rust-lang/crates.io-index".async-stream."0.3.5" = overridableMkRustCrate (profileName: rec { name = "async-stream"; version = "0.3.5"; @@ -2003,6 +2015,7 @@ in aes_gcm = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".aes-gcm."0.10.3" { inherit profileName; }).out; argon2 = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".argon2."0.5.3" { inherit profileName; }).out; async_compression = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".async-compression."0.4.6" { inherit profileName; }).out; + async_recursion = (buildRustPackages."registry+https://github.com/rust-lang/crates.io-index".async-recursion."1.1.1" { profileName = "__noProfile"; }).out; async_trait = (buildRustPackages."registry+https://github.com/rust-lang/crates.io-index".async-trait."0.1.77" { profileName = "__noProfile"; }).out; base64 = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".base64."0.21.7" { inherit profileName; }).out; bytes = (rustPackages."registry+https://github.com/rust-lang/crates.io-index".bytes."1.5.0" { inherit profileName; }).out; diff --git a/src/api/Cargo.toml b/src/api/Cargo.toml index a5645c26..cccb722f 100644 --- a/src/api/Cargo.toml +++ b/src/api/Cargo.toml @@ -68,6 +68,7 @@ quick-xml.workspace = true opentelemetry.workspace = true opentelemetry-prometheus = { workspace = true, optional = true } prometheus = { workspace = true, optional = true } +async-recursion = "1.1.1" [features] k2v = [ "garage_util/k2v", "garage_model/k2v" ] diff --git a/src/api/s3/api_server.rs b/src/api/s3/api_server.rs index 1737af33..e89d9a5e 100644 --- a/src/api/s3/api_server.rs +++ b/src/api/s3/api_server.rs @@ -201,7 +201,10 @@ impl ApiHandler for S3ApiServer { response_content_type, response_expires, }; - handle_get(ctx, &req, &key, part_number, overrides).await + // Redirects are flattened over the S3 API as per: + // https://docs.aws.amazon.com/AmazonS3/latest/userguide/how-to-page-redirect.html + // > REST endpoint – Amazon S3 doesn't redirect the page request. It returns the requested object. + handle_get(ctx, &req, &key, part_number, overrides, true).await } Endpoint::UploadPart { key, diff --git a/src/api/s3/get.rs b/src/api/s3/get.rs index f5d3cf11..0d8a2dd6 100644 --- a/src/api/s3/get.rs +++ b/src/api/s3/get.rs @@ -280,22 +280,25 @@ pub async fn handle_head_without_ctx( /// Handle GET request pub async fn handle_get( ctx: ReqCtx, - req: &Request, + req: &Request, key: &str, part_number: Option, overrides: GetObjectOverrides, + flatten_redirect: bool ) -> Result, Error> { - handle_get_without_ctx(ctx.garage, req, ctx.bucket_id, key, part_number, overrides).await + handle_get_without_ctx(ctx.garage, req, ctx.bucket_id, key, part_number, overrides, flatten_redirect).await } /// Handle GET request +#[async_recursion::async_recursion] pub async fn handle_get_without_ctx( garage: Arc, - req: &Request, + req: &Request, bucket_id: Uuid, key: &str, part_number: Option, overrides: GetObjectOverrides, + flatten_redirect: bool ) -> Result, Error> { let object = garage .object_table @@ -329,6 +332,30 @@ pub async fn handle_get_without_ctx( let checksum_mode = checksum_mode(&req); + if let Some(redirect_hdr) = headers + .headers + .iter() + .find(|(k, _)| k == "x-amz-website-redirect-location") + .map(|(_, v)| v) + { + if flatten_redirect { + return handle_get_without_ctx(garage, + req, + bucket_id, + redirect_hdr, + part_number, + overrides, + flatten_redirect + ).await; + } else { + return Ok(Response::builder() + .status(StatusCode::FOUND) + .header("Location", redirect_hdr) + .body(empty_body()) + .unwrap()); + } + } + match (part_number, parse_range_header(req, last_v_meta.size)?) { (Some(_), Some(_)) => Err(Error::bad_request( "Cannot specify both partNumber and Range header", diff --git a/src/garage/tests/s3/website.rs b/src/garage/tests/s3/website.rs index 0cadc388..5bb2fb6d 100644 --- a/src/garage/tests/s3/website.rs +++ b/src/garage/tests/s3/website.rs @@ -181,6 +181,16 @@ async fn test_website_s3_api() { .await .unwrap(); + ctx.client + .put_object() + .bucket(&bucket) + .key("site/home-redirected.html") + .website_redirect_location("/site/home.html") + .body(ByteStream::from_static(&[])) + .send() + .await + .unwrap(); + let conf = WebsiteConfiguration::builder() .index_document( IndexDocument::builder() @@ -274,6 +284,29 @@ async fn test_website_s3_api() { ); } + // Test redirection with CORS + { + let req = Request::builder() + .method("GET") + .uri(format!("http://127.0.0.1:{}/site/home-redirected.html", ctx.garage.web_port)) + .header("Host", format!("{}.web.garage", BCKT_NAME)) + .header("Origin", "https://example.com") + .body(Body::new(Bytes::new())) + .unwrap(); + + let resp = client.request(req).await.unwrap(); + + assert!(resp.status().is_redirection()); + assert_eq!( + resp.headers().get("access-control-allow-origin").unwrap(), + "*" + ); + assert_eq!( + resp.headers().get(http::header::LOCATION).unwrap(), + "/site/home.html" + ); + } + // Test ErrorDocument on 404 { let req = Request::builder() diff --git a/src/web/web_server.rs b/src/web/web_server.rs index 69939f65..592f0be3 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -249,6 +249,9 @@ impl WebServer { handle_head_without_ctx(self.garage.clone(), req, bucket_id, &key, None).await } Method::GET => { + // Redirects are not flattened in the web API, as per: + // https://docs.aws.amazon.com/AmazonS3/latest/userguide/how-to-page-redirect.html + // > Region-specific website endpoint – Amazon S3 redirects the page request according to the value of the x-amz-website-redirect-location property. handle_get_without_ctx( self.garage.clone(), req, @@ -256,6 +259,7 @@ impl WebServer { &key, None, Default::default(), + false ) .await } @@ -309,6 +313,7 @@ impl WebServer { &error_document, None, Default::default(), + false ) .await {