aboutsummaryrefslogtreecommitdiff
path: root/src/web
diff options
context:
space:
mode:
Diffstat (limited to 'src/web')
-rw-r--r--src/web/web_server.rs382
1 files changed, 308 insertions, 74 deletions
diff --git a/src/web/web_server.rs b/src/web/web_server.rs
index 48dcb5b1..9d13b468 100644
--- a/src/web/web_server.rs
+++ b/src/web/web_server.rs
@@ -6,6 +6,7 @@ use tokio::net::{TcpListener, UnixListener};
use tokio::sync::watch;
use hyper::{
+ body::Body,
body::Incoming as IncomingBody,
header::{HeaderValue, HOST},
Method, Request, Response, StatusCode,
@@ -25,11 +26,13 @@ use garage_api_common::cors::{
};
use garage_api_common::generic_server::{server_loop, UnixListenerOn};
use garage_api_common::helpers::*;
+use garage_api_s3::api_server::ResBody;
use garage_api_s3::error::{
CommonErrorDerivative, Error as ApiError, OkOrBadRequest, OkOrInternalError,
};
use garage_api_s3::get::{handle_get_without_ctx, handle_head_without_ctx};
+use garage_model::bucket_table::{self, RoutingRule};
use garage_model::garage::Garage;
use garage_table::*;
@@ -236,45 +239,71 @@ 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, &website_config.routing_rules)?;
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()),
+ (_, Ok((key, code))) => {
+ handle_inner(self.garage.clone(), req, bucket_id, key, code).await
}
- Method::GET => {
- handle_get_without_ctx(
+ };
+
+ // 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,
+ ..
+ },
+ ) => {
+ handle_inner(
self.garage.clone(),
req,
bucket_id,
- &key,
- None,
- Default::default(),
+ redirect_key,
+ *redirect_code,
)
.await
}
- _ => 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())
- }
_ => ret_doc,
};
@@ -300,17 +329,17 @@ impl WebServer {
// We want to return the error document
// Create a fake HTTP request with path = the error document
let req2 = Request::builder()
+ .method("GET")
.uri(format!("http://{}/{}", host, &error_document))
.body(empty_body::<Infallible>())
.unwrap();
- match handle_get_without_ctx(
+ match handle_inner(
self.garage.clone(),
&req2,
bucket_id,
&error_document,
- None,
- Default::default(),
+ error.http_status_code(),
)
.await
{
@@ -325,8 +354,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()) {
@@ -357,6 +384,55 @@ impl WebServer {
}
}
+async fn handle_inner(
+ garage: Arc<Garage>,
+ req: &Request<impl Body>,
+ bucket_id: Uuid,
+ key: &str,
+ status_code: StatusCode,
+) -> Result<Response<ResBody>, ApiError> {
+ if status_code != StatusCode::OK {
+ // If we are returning an error document, discard all headers from
+ // the original request that would have influenced the result:
+ // - Range header, we don't want to return a subrange of the error document
+ // - Caching directives such as If-None-Match, etc, which are not relevant
+ let cleaned_req = Request::builder()
+ .uri(req.uri())
+ .body(empty_body::<Infallible>())
+ .unwrap();
+
+ let mut ret = match req.method() {
+ &Method::HEAD => {
+ handle_head_without_ctx(garage, &cleaned_req, bucket_id, key, None).await?
+ }
+ &Method::GET => {
+ handle_get_without_ctx(
+ garage,
+ &cleaned_req,
+ bucket_id,
+ key,
+ None,
+ Default::default(),
+ )
+ .await?
+ }
+ _ => return Err(ApiError::bad_request("HTTP method not supported")),
+ };
+
+ *ret.status_mut() = status_code;
+
+ Ok(ret)
+ } else {
+ match req.method() {
+ &Method::HEAD => handle_head_without_ctx(garage, req, bucket_id, key, None).await,
+ &Method::GET => {
+ handle_get_without_ctx(garage, req, bucket_id, key, None, Default::default()).await
+ }
+ _ => Err(ApiError::bad_request("HTTP method not supported")),
+ }
+ }
+}
+
fn error_to_res(e: Error) -> Response<BoxBody<Error>> {
// If we are here, it is either that:
// - there was an error before trying to get the requested URL
@@ -373,9 +449,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
@@ -385,33 +496,152 @@ 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(
+ path: &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_bucket_root = base_key.is_empty();
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)),
-
- // "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)),
-
- // "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 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()
+ };
+
+ let mut routing_rules_iter = routing_rules.iter();
+ let key = loop {
+ let Some(routing_rule) = routing_rules_iter.next() else {
+ break key;
+ };
+
+ 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 mut target = compute_redirect_target(&routing_rule.redirect, 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) => {
+ // we need to remove the leading /
+ target.remove(0);
+ 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) => {
+ target.remove(0);
+ return Ok(RoutingResult::LoadOrAlternativeError {
+ key,
+ redirect_key: target,
+ redirect_code: status_code,
+ });
+ }
+ }
+ } else {
+ let target = compute_redirect_target(&routing_rule.redirect, 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}")),
+ // we can't use `path` because key might have changed substentially in case of
+ // routing rules
+ redirect_url: percent_encoding::percent_encode(
+ format!("/{key}/").as_bytes(),
+ PATH_ENCODING_SET,
+ )
+ .to_string(),
+ key,
+ 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'}');
+
+fn compute_redirect_target(redirect: &bucket_table::Redirect, suffix: Option<&str>) -> String {
+ let mut res = String::new();
+ if let Some(hostname) = &redirect.hostname {
+ if let Some(protocol) = &redirect.protocol {
+ res.push_str(protocol);
+ res.push_str("://");
+ } else {
+ res.push_str("//");
+ }
+ res.push_str(hostname);
}
+ res.push('/');
+ if let Some(replace_key_prefix) = &redirect.replace_key_prefix {
+ res.push_str(replace_key_prefix);
+ if let Some(suffix) = suffix {
+ res.push_str(suffix)
+ }
+ } else if let Some(replace_key) = &redirect.replace_key {
+ res.push_str(replace_key)
+ }
+ res
}
#[cfg(test)]
@@ -421,35 +651,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(())
}
}