support redirection on s3 endpoint

This commit is contained in:
trinity-1686a
2024-12-14 17:46:27 +01:00
parent 3661a597fa
commit c9b733a4a6
6 changed files with 445 additions and 98 deletions
+251 -61
View File
@@ -28,6 +28,7 @@ use garage_api::s3::error::{
};
use garage_api::s3::get::{handle_get_without_ctx, handle_head_without_ctx};
use garage_model::bucket_table::RoutingRule;
use garage_model::garage::Garage;
use garage_table::*;
@@ -234,26 +235,32 @@ impl WebServer {
// Get path
let path = req.uri().path().to_string();
let index = &website_config.index_document;
let (key, may_redirect) = path_to_keys(&path, index)?;
let routing_result = path_to_keys(&path, index, &[])?;
debug!(
"Selected bucket: \"{}\" {:?}, target key: \"{}\", may redirect to: {:?}",
bucket_name, bucket_id, key, may_redirect
"Selected bucket: \"{}\" {:?}, routing to {:?}",
bucket_name, bucket_id, routing_result,
);
let ret_doc = match *req.method() {
Method::OPTIONS => handle_options_for_bucket(req, &bucket_params)
let ret_doc = match (req.method(), routing_result.main_target()) {
(&Method::OPTIONS, _) => handle_options_for_bucket(req, &bucket_params)
.map_err(ApiError::from)
.map(|res| res.map(|_empty_body: EmptyBody| empty_body())),
Method::HEAD => {
handle_head_without_ctx(self.garage.clone(), req, bucket_id, &key, None).await
(_, Err((url, code))) => Ok(Response::builder()
.status(code)
.header("Location", url)
.body(empty_body())
.unwrap()),
(&Method::HEAD, Ok((key, code))) => {
handle_head_without_ctx(self.garage.clone(), req, bucket_id, key, code, None).await
}
Method::GET => {
(&Method::GET, Ok((key, code))) => {
handle_get_without_ctx(
self.garage.clone(),
req,
bucket_id,
&key,
key,
code,
None,
Default::default(),
)
@@ -262,16 +269,68 @@ impl WebServer {
_ => Err(ApiError::bad_request("HTTP method not supported")),
};
// Try implicit redirect on error
let ret_doc_with_redir = match (&ret_doc, may_redirect) {
(Err(ApiError::NoSuchKey), ImplicitRedirect::To { key, url })
if self.check_key_exists(bucket_id, key.as_str()).await? =>
{
Ok(Response::builder()
.status(StatusCode::FOUND)
.header("Location", url)
.body(empty_body())
.unwrap())
// Try handling errors if bucket configuration provided fallbacks
let ret_doc_with_redir = match (&ret_doc, &routing_result) {
(
Err(ApiError::NoSuchKey),
RoutingResult::LoadOrRedirect {
redirect_if_exists,
redirect_url,
redirect_code,
..
},
) => {
let redirect = if let Some(redirect_key) = redirect_if_exists {
self.check_key_exists(bucket_id, redirect_key.as_str())
.await?
} else {
true
};
if redirect {
Ok(Response::builder()
.status(redirect_code)
.header("Location", redirect_url)
.body(empty_body())
.unwrap())
} else {
ret_doc
}
}
(
Err(ApiError::NoSuchKey),
RoutingResult::LoadOrAlternativeError {
redirect_key,
redirect_code,
..
},
) => {
match *req.method() {
Method::HEAD => {
handle_head_without_ctx(
self.garage.clone(),
req,
bucket_id,
redirect_key,
*redirect_code,
None,
)
.await
}
Method::GET => {
handle_get_without_ctx(
self.garage.clone(),
req,
bucket_id,
redirect_key,
*redirect_code,
None,
Default::default(),
)
.await
}
// we shouldn't ever reach here
_ => Err(ApiError::bad_request("HTTP method not supported")),
}
}
_ => ret_doc,
};
@@ -307,6 +366,7 @@ impl WebServer {
&req2,
bucket_id,
&error_document,
error.http_status_code(),
None,
Default::default(),
)
@@ -323,8 +383,6 @@ impl WebServer {
error
);
*error_doc.status_mut() = error.http_status_code();
// Preserve error message in a special header
for error_line in error.to_string().split('\n') {
if let Ok(v) = HeaderValue::from_bytes(error_line.as_bytes()) {
@@ -371,9 +429,44 @@ fn error_to_res(e: Error) -> Response<BoxBody<Error>> {
}
#[derive(Debug, PartialEq)]
enum ImplicitRedirect {
No,
To { key: String, url: String },
enum RoutingResult {
// Load a key and use `code` as status, or fallback to normal 404 handler if not found
LoadKey {
key: String,
code: StatusCode,
},
// Load a key and use `200` as status, or fallback with a redirection using `redirect_code`
// as status
LoadOrRedirect {
key: String,
redirect_if_exists: Option<String>,
redirect_url: String,
redirect_code: StatusCode,
},
// Load a key and use `200` as status, or fallback by loading a different key and use
// `redirect_code` as status
LoadOrAlternativeError {
key: String,
redirect_key: String,
redirect_code: StatusCode,
},
// Send an http redirect with `code` as status
Redirect {
url: String,
code: StatusCode,
},
}
impl RoutingResult {
// return Ok((key_to_deref, status_code)) or Err((redirect_target, status_code))
fn main_target(&self) -> Result<(&str, StatusCode), (&str, StatusCode)> {
match self {
RoutingResult::LoadKey { key, code } => Ok((key, *code)),
RoutingResult::LoadOrRedirect { key, .. } => Ok((key, StatusCode::OK)),
RoutingResult::LoadOrAlternativeError { key, .. } => Ok((key, StatusCode::OK)),
RoutingResult::Redirect { url, code } => Err((url, *code)),
}
}
}
/// Path to key
@@ -383,35 +476,128 @@ enum ImplicitRedirect {
/// which is also AWS S3 behavior.
///
/// Check: https://docs.aws.amazon.com/AmazonS3/latest/userguide/IndexDocumentSupport.html
fn path_to_keys<'a>(path: &'a str, index: &str) -> Result<(String, ImplicitRedirect), Error> {
fn path_to_keys<'a>(
path: &'a str,
index: &str,
routing_rules: &[RoutingRule],
) -> Result<RoutingResult, Error> {
let path_utf8 = percent_encoding::percent_decode_str(path).decode_utf8()?;
let base_key = match path_utf8.strip_prefix("/") {
Some(bk) => bk,
None => return Err(Error::BadRequest("Path must start with a / (slash)".into())),
};
let is_bucket_root = base_key.len() == 0;
let is_trailing_slash = path_utf8.ends_with("/");
match (is_bucket_root, is_trailing_slash) {
// It is not possible to store something at the root of the bucket (ie. empty key),
// the only option is to fetch the index
(true, _) => Ok((index.to_string(), ImplicitRedirect::No)),
let key = if is_bucket_root || is_trailing_slash {
// we can't store anything at the root, so we need to query the index
// if the key end with a slash, we always query the index
format!("{base_key}{index}")
} else {
// if the key doesn't end with `/`, leave it unmodified
base_key.to_string()
};
// "If you create a folder structure in your bucket, you must have an index document at each level. In each folder, the index document must have the same name, for example, index.html. When a user specifies a URL that resembles a folder lookup, the presence or absence of a trailing slash determines the behavior of the website. For example, the following URL, with a trailing slash, returns the photos/index.html index document."
(false, true) => Ok((format!("{base_key}{index}"), ImplicitRedirect::No)),
let mut routing_rules_iter = routing_rules.iter();
let key = loop {
let Some(routing_rule) = routing_rules_iter.next() else {
break key;
};
// "However, if you exclude the trailing slash from the preceding URL, Amazon S3 first looks for an object photos in the bucket. If the photos object is not found, it searches for an index document, photos/index.html. If that document is found, Amazon S3 returns a 302 Found message and points to the photos/ key. For subsequent requests to photos/, Amazon S3 returns photos/index.html. If the index document is not found, Amazon S3 returns an error."
(false, false) => Ok((
base_key.to_string(),
ImplicitRedirect::To {
key: format!("{base_key}/{index}"),
url: format!("{path}/"),
},
)),
let Ok(status_code) = StatusCode::from_u16(routing_rule.redirect.http_redirect_code) else {
continue;
};
if let Some(condition) = &routing_rule.condition {
let suffix = if let Some(prefix) = &condition.prefix {
let Some(suffix) = key.strip_prefix(prefix) else {
continue;
};
Some(suffix)
} else {
None
};
let target = routing_rule.redirect.compute_target(suffix);
let query_alternative_key =
status_code == StatusCode::OK || status_code == StatusCode::NOT_FOUND;
let redirect_on_error =
condition.http_error_code == Some(StatusCode::NOT_FOUND.as_u16());
match (query_alternative_key, redirect_on_error) {
(false, false) => {
return Ok(RoutingResult::Redirect {
url: target,
code: status_code,
})
}
(true, false) => {
if status_code == StatusCode::OK {
break target;
} else {
return Ok(RoutingResult::LoadKey {
key: target,
code: status_code,
});
}
}
(false, true) => {
return Ok(RoutingResult::LoadOrRedirect {
key,
redirect_if_exists: None,
redirect_url: target,
redirect_code: status_code,
});
}
(true, true) => {
return Ok(RoutingResult::LoadOrAlternativeError {
key,
redirect_key: target,
redirect_code: status_code,
});
}
}
} else {
let target = routing_rule.redirect.compute_target(None);
return Ok(RoutingResult::Redirect {
url: target,
code: status_code,
});
}
};
if is_bucket_root || is_trailing_slash {
Ok(RoutingResult::LoadKey {
key,
code: StatusCode::OK,
})
} else {
Ok(RoutingResult::LoadOrRedirect {
redirect_if_exists: Some(format!("{key}/{index}")),
key,
// we can't use `path` because key might have changed substentially in case of
// routing rules
redirect_url: percent_encoding::percent_encode(
format!("{path}/").as_bytes(),
PATH_ENCODING_SET,
)
.to_string(),
redirect_code: StatusCode::FOUND,
})
}
}
// per https://url.spec.whatwg.org/#path-percent-encode-set
const PATH_ENCODING_SET: &percent_encoding::AsciiSet = &percent_encoding::CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'`')
.add(b'{')
.add(b'}');
#[cfg(test)]
mod tests {
use super::*;
@@ -419,35 +605,39 @@ mod tests {
#[test]
fn path_to_keys_test() -> Result<(), Error> {
assert_eq!(
path_to_keys("/file%20.jpg", "index.html")?,
(
"file .jpg".to_string(),
ImplicitRedirect::To {
key: "file .jpg/index.html".to_string(),
url: "/file%20.jpg/".to_string()
}
)
path_to_keys("/file%20.jpg", "index.html", &[])?,
RoutingResult::LoadOrRedirect {
key: "file .jpg".to_string(),
redirect_url: "/file%20.jpg/".to_string(),
redirect_if_exists: Some("file .jpg/index.html".to_string()),
redirect_code: StatusCode::FOUND,
}
);
assert_eq!(
path_to_keys("/%20t/", "index.html")?,
(" t/index.html".to_string(), ImplicitRedirect::No)
path_to_keys("/%20t/", "index.html", &[])?,
RoutingResult::LoadKey {
key: " t/index.html".to_string(),
code: StatusCode::OK
}
);
assert_eq!(
path_to_keys("/", "index.html")?,
("index.html".to_string(), ImplicitRedirect::No)
path_to_keys("/", "index.html", &[])?,
RoutingResult::LoadKey {
key: "index.html".to_string(),
code: StatusCode::OK
}
);
assert_eq!(
path_to_keys("/hello", "index.html")?,
(
"hello".to_string(),
ImplicitRedirect::To {
key: "hello/index.html".to_string(),
url: "/hello/".to_string()
}
)
path_to_keys("/hello", "index.html", &[])?,
RoutingResult::LoadOrRedirect {
key: "hello".to_string(),
redirect_url: "/hello/".to_string(),
redirect_if_exists: Some("hello/index.html".to_string()),
redirect_code: StatusCode::FOUND,
}
);
assert!(path_to_keys("", "index.html").is_err());
assert!(path_to_keys("i/am/relative", "index.html").is_err());
assert!(path_to_keys("", "index.html", &[]).is_err());
assert!(path_to_keys("i/am/relative", "index.html", &[]).is_err());
Ok(())
}
}