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 <masterancpp@gmail.com>
This commit is contained in:
Raito Bezarius 2024-09-03 15:43:08 +02:00
parent 647b2dae33
commit b99aaa8351
7 changed files with 99 additions and 5 deletions

12
Cargo.lock generated
View file

@ -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",

View file

@ -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;

View file

@ -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" ]

View file

@ -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,

View file

@ -280,22 +280,25 @@ pub async fn handle_head_without_ctx(
/// Handle GET request
pub async fn handle_get(
ctx: ReqCtx,
req: &Request<impl Body>,
req: &Request<impl Body + std::marker::Sync>,
key: &str,
part_number: Option<u64>,
overrides: GetObjectOverrides,
flatten_redirect: bool
) -> Result<Response<ResBody>, 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<Garage>,
req: &Request<impl Body>,
req: &Request<impl Body + std::marker::Sync>,
bucket_id: Uuid,
key: &str,
part_number: Option<u64>,
overrides: GetObjectOverrides,
flatten_redirect: bool
) -> Result<Response<ResBody>, 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",

View file

@ -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()

View file

@ -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
{