fix(s3): serve bucket websites on dedicated domains

Merge the reviewed fix from pull request #8183.
This commit is contained in:
cxymds
2026-09-28 18:48:21 +08:00
committed by GitHub
parent e0a973bf4e
commit 4a46f5ff3d
14 changed files with 1167 additions and 24 deletions
+2
View File
@@ -272,6 +272,8 @@ mod storage_class_capability_test;
// S3 dummy-compat bucket API tests
#[cfg(test)]
mod bucket_logging_test;
#[cfg(test)]
mod website_hosting_test;
// Multipart control API auth regression tests
#[cfg(test)]
+271
View File
@@ -0,0 +1,271 @@
// Copyright 2024 RustFS Team
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use crate::common::{RustFSTestEnvironment, init_logging};
use aws_sdk_s3::presigning::PresigningConfig;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::{
BucketVersioningStatus, Condition, ErrorDocument, IndexDocument, Redirect, RedirectAllRequestsTo, RoutingRule,
VersioningConfiguration, WebsiteConfiguration,
};
use reqwest::header::HOST;
use std::time::Duration;
type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
#[tokio::test]
async fn website_host_serves_pages_without_changing_s3_reads() -> TestResult {
init_logging();
let mut env = RustFSTestEnvironment::new().await?;
env.start_rustfs_server_with_env(vec![], &[("RUSTFS_WEBSITE_DOMAINS", "website.test")])
.await?;
let s3 = env.create_s3_client();
let bucket = "website-hosting";
s3.create_bucket().bucket(bucket).send().await?;
for (key, body) in [
("index.html", "home"),
("404.html", "error page"),
("docs", "ordinary object"),
("section/index.html", "section page"),
("private.txt", "private"),
] {
s3.put_object()
.bucket(bucket)
.key(key)
.body(ByteStream::from(body.as_bytes().to_vec()))
.send()
.await?;
}
s3.put_object()
.bucket(bucket)
.key("move")
.website_redirect_location("/new-page")
.body(ByteStream::from_static(b"ordinary redirect object"))
.send()
.await?;
s3.put_bucket_website()
.bucket(bucket)
.website_configuration(
WebsiteConfiguration::builder()
.index_document(IndexDocument::builder().suffix("index.html").build()?)
.error_document(ErrorDocument::builder().key("404.html").build()?)
.build(),
)
.send()
.await?;
let policy = serde_json::json!({
"Version": "2012-10-17",
"Statement": [
{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":format!("arn:aws:s3:::{bucket}/*")},
{"Effect":"Deny","Principal":"*","Action":"s3:GetObject","Resource":format!("arn:aws:s3:::{bucket}/private.txt")}
]
})
.to_string();
s3.put_bucket_policy().bucket(bucket).policy(policy).send().await?;
let http = reqwest::Client::builder()
.no_proxy()
.redirect(reqwest::redirect::Policy::none())
.build()?;
let website = |path: &str| format!("{}{path}", env.url);
let host = format!("{bucket}.website.test");
let base_domain = http
.get(website("/website-hosting/index.html"))
.header(HOST, "website.test")
.send()
.await?;
assert_eq!(base_domain.status(), 404);
assert!(!base_domain.text().await?.contains("<Error>"));
let home = http.get(website("/")).header(HOST, &host).send().await?;
assert_eq!(home.status(), 200);
assert_eq!(home.text().await?, "home");
let head = http.head(website("/")).header(HOST, &host).send().await?;
assert_eq!(head.status(), 200);
assert_eq!(head.text().await?, "");
let missing = http.get(website("/missing")).header(HOST, &host).send().await?;
assert_eq!(missing.status(), 404);
assert_eq!(missing.text().await?, "error page");
let denied = http.get(website("/private.txt")).header(HOST, &host).send().await?;
assert_eq!(denied.status(), 403);
assert_eq!(denied.text().await?, "error page");
let collision = http.get(website("/docs")).header(HOST, &host).send().await?;
assert_eq!(collision.status(), 200);
assert_eq!(collision.text().await?, "ordinary object");
let directory = http.get(website("/section")).header(HOST, &host).send().await?;
assert_eq!(directory.status(), 302);
assert_eq!(directory.headers()[reqwest::header::LOCATION], "/section/");
let section = http.get(website("/section/")).header(HOST, &host).send().await?;
assert_eq!(section.status(), 200);
assert_eq!(section.text().await?, "section page");
let redirect = http.get(website("/move")).header(HOST, &host).send().await?;
assert_eq!(redirect.status(), 301);
assert_eq!(redirect.headers()[reqwest::header::LOCATION], "/new-page");
let redirect_with_condition = http
.get(website("/move"))
.header(HOST, &host)
.header(reqwest::header::IF_NONE_MATCH, "*")
.send()
.await?;
assert_eq!(redirect_with_condition.status(), 301);
let redirect_with_range = http
.get(website("/move"))
.header(HOST, &host)
.header(reqwest::header::RANGE, "bytes=999999-")
.send()
.await?;
assert_eq!(redirect_with_range.status(), 301);
let plain_range_error = http
.get(website("/index.html"))
.header(HOST, &host)
.header(reqwest::header::RANGE, "bytes=999999-")
.send()
.await?;
assert_eq!(plain_range_error.status(), 416);
assert!(!plain_range_error.text().await?.contains("<Error>"));
let ordinary = s3.get_object().bucket(bucket).key("move").send().await?;
assert_eq!(ordinary.body.collect().await?.into_bytes(), "ordinary redirect object");
let ranged = s3.get_object().bucket(bucket).key("move").range("bytes=0-7").send().await?;
assert_eq!(ranged.body.collect().await?.into_bytes(), "ordinary");
let head_object = s3.head_object().bucket(bucket).key("move").send().await?;
let etag = head_object.e_tag().expect("object ETag");
let unchanged = s3.get_object().bucket(bucket).key("move").if_none_match(etag).send().await;
assert!(unchanged.is_err(), "ordinary S3 conditional GET should return NotModified");
let presigned = s3
.get_object()
.bucket(bucket)
.key("move")
.presigned(PresigningConfig::expires_in(Duration::from_secs(300))?)
.await?;
let presigned_read = http.get(presigned.uri().to_string()).send().await?;
assert_eq!(presigned_read.status(), 200);
assert_eq!(presigned_read.text().await?, "ordinary redirect object");
let listed = s3.list_objects_v2().bucket(bucket).send().await?;
assert!(listed.contents().iter().any(|object| object.key() == Some("index.html")));
let s3_missing = http.get(format!("{}/{bucket}/missing", env.url)).send().await?;
assert!(s3_missing.status().is_client_error());
assert!(s3_missing.text().await?.contains("<Error>"));
s3.put_bucket_versioning()
.bucket(bucket)
.versioning_configuration(
VersioningConfiguration::builder()
.status(BucketVersioningStatus::Enabled)
.build(),
)
.send()
.await?;
let first = s3
.put_object()
.bucket(bucket)
.key("versioned.txt")
.body(ByteStream::from_static(b"first"))
.send()
.await?;
s3.put_object()
.bucket(bucket)
.key("versioned.txt")
.body(ByteStream::from_static(b"second"))
.send()
.await?;
let versioned = s3
.get_object()
.bucket(bucket)
.key("versioned.txt")
.version_id(first.version_id().expect("first object version"))
.send()
.await?;
assert_eq!(versioned.body.collect().await?.into_bytes(), "first");
s3.put_bucket_website()
.bucket(bucket)
.website_configuration(
WebsiteConfiguration::builder()
.index_document(IndexDocument::builder().suffix("index.html").build()?)
.routing_rules(
RoutingRule::builder()
.condition(Condition::builder().http_error_code_returned_equals("404").build())
.redirect(Redirect::builder().replace_key_with("missing-page").build())
.build(),
)
.routing_rules(
RoutingRule::builder()
.condition(Condition::builder().key_prefix_equals("docs/").build())
.redirect(Redirect::builder().replace_key_with("docs-page").build())
.build(),
)
.routing_rules(
RoutingRule::builder()
.condition(Condition::builder().http_error_code_returned_equals("500").build())
.redirect(Redirect::builder().replace_key_with("wrong-status").build())
.build(),
)
.routing_rules(
RoutingRule::builder()
.condition(Condition::builder().http_error_code_returned_equals("416").build())
.redirect(Redirect::builder().replace_key_with("range-error").build())
.build(),
)
.build(),
)
.send()
.await?;
let rule = http.get(website("/docs/missing")).header(HOST, &host).send().await?;
assert_eq!(rule.status(), 301);
assert_eq!(
rule.headers()[reqwest::header::LOCATION],
"http://website-hosting.website.test/missing-page"
);
let ranged_error = http
.get(website("/index.html"))
.header(HOST, &host)
.header(reqwest::header::RANGE, "bytes=999999-")
.send()
.await?;
assert_eq!(ranged_error.status(), 301);
assert_eq!(
ranged_error.headers()[reqwest::header::LOCATION],
"http://website-hosting.website.test/range-error"
);
s3.put_bucket_website()
.bucket(bucket)
.website_configuration(
WebsiteConfiguration::builder()
.redirect_all_requests_to(RedirectAllRequestsTo::builder().host_name("destination.test").build()?)
.build(),
)
.send()
.await?;
let all = http.get(website("/a%20b?x=1")).header(HOST, &host).send().await?;
assert_eq!(all.status(), 301);
assert_eq!(all.headers()[reqwest::header::LOCATION], "http://destination.test/a%20b?x=1");
let dotted = "my.website.bucket";
s3.create_bucket().bucket(dotted).send().await?;
s3.put_bucket_website()
.bucket(dotted)
.website_configuration(
WebsiteConfiguration::builder()
.redirect_all_requests_to(RedirectAllRequestsTo::builder().host_name("destination.test").build()?)
.build(),
)
.send()
.await?;
let dotted_host = format!("{dotted}.website.test");
let dotted_response = http.get(website("/key")).header(HOST, dotted_host).send().await?;
assert_eq!(dotted_response.status(), 301);
assert_eq!(dotted_response.headers()[reqwest::header::LOCATION], "http://destination.test/key");
env.stop_server();
Ok(())
}
+11
View File
@@ -0,0 +1,11 @@
# Static website hosting
Set `RUSTFS_WEBSITE_DOMAINS` to one or more comma-separated base domains on the S3 listener. For example, `RUSTFS_WEBSITE_DOMAINS=website.example.com` serves bucket `photos` at `photos.website.example.com`. Configure wildcard DNS and a TLS certificate covering the bucket hostnames. The website domain must not overlap `RUSTFS_SERVER_DOMAINS`; startup rejects overlapping values.
Website routing is enabled only when the environment variable is set. Keep the public website hostname in the reverse proxy's `Host` header. The S3 API hostname and path-style endpoints continue to serve ordinary S3 requests, including object GET/HEAD, listing, version selection, and XML errors. A website hostname accepts GET and HEAD only.
Configure the bucket with `PutBucketWebsite`. An index document is required unless `RedirectAllRequestsTo` is configured. A trailing slash resolves to the index document; a missing slash redirects to the trailing-slash URL when an index exists. The website endpoint uses the configured error document for 403 and 404 responses and evaluates routing rules in configuration order. Object metadata `x-amz-website-redirect-location` redirects only on the website endpoint.
Website requests are anonymous. Grant `s3:GetObject` through the bucket policy for every page to be served, including index and error documents. Explicit deny and public-access restrictions still apply. A private error document is not exposed as a fallback. Use the S3 endpoint for authenticated reads and writes.
For HTTPS deployments, set the `Protocol` field explicitly in website redirect rules and `RedirectAllRequestsTo` when the proxy sends HTTP to RustFS. Otherwise, redirects use the protocol visible to the S3 listener.
+655 -10
View File
@@ -15,27 +15,50 @@
//! MinIO-compatible metadata listing extension routes.
use super::bucket_usecase::DefaultBucketUsecase;
use super::object_usecase::DefaultObjectUsecase;
use crate::app::runtime_sources::ServerContextSlot;
use crate::auth::{check_key_valid, get_session_token};
use crate::storage::access::{ReqInfo, authorize_request, req_info_mut};
use crate::storage::{get_bucket_website_config_for_store, validate_website_configuration};
use async_trait::async_trait;
use http::header::CONTENT_TYPE;
use http::header::HOST;
use http::{Extensions, HeaderMap, HeaderValue, Method, Uri};
use http::{Extensions, HeaderMap, HeaderValue, Method, StatusCode, Uri};
use rustfs_policy::policy::action::{Action, S3Action};
use s3s::dto::{EncodingType, ListObjectVersionsInput, ListObjectsV2Input};
use s3s::access::S3Access;
use s3s::dto::{
ETagCondition, EncodingType, GetObjectInput, GetObjectOutput, HeadObjectInput, ListObjectVersionsInput, ListObjectsV2Input,
Range, RoutingRule, Timestamp, TimestampFormat,
};
use s3s::host::{MultiDomain, S3Host};
use s3s::route::S3Route;
use s3s::xml;
use s3s::{Body, S3Error, S3Request, S3Response, S3Result, s3_error};
use s3s::{Body, S3Error, S3ErrorCode, S3Request, S3Response, S3Result, s3_error};
use std::sync::Arc;
use url::form_urlencoded;
pub(crate) struct MetadataRoute<A> {
admin: A,
host: Option<MultiDomain>,
website_domains: Vec<String>,
website_scheme: &'static str,
server_ctx: Arc<ServerContextSlot>,
}
pub(crate) fn with_metadata_route<A>(admin: A, host: Option<MultiDomain>) -> MetadataRoute<A> {
MetadataRoute { admin, host }
pub(crate) fn with_metadata_route<A>(
admin: A,
host: Option<MultiDomain>,
website_domains: Vec<String>,
website_scheme: &'static str,
server_ctx: Arc<ServerContextSlot>,
) -> MetadataRoute<A> {
MetadataRoute {
admin,
host,
website_domains,
website_scheme,
server_ctx,
}
}
#[async_trait]
@@ -44,12 +67,16 @@ where
A: S3Route,
{
fn is_match(&self, method: &Method, uri: &Uri, headers: &HeaderMap, extensions: &mut Extensions) -> bool {
metadata_operation(method, uri, headers, self.host.as_ref()).is_some()
website_target(uri, headers, &self.website_domains).is_some()
|| metadata_operation(method, uri, headers, self.host.as_ref()).is_some()
|| self.admin.is_match(method, uri, headers, extensions)
}
async fn check_access(&self, req: &mut S3Request<Body>) -> S3Result<()> {
if let Some(target) = metadata_operation(&req.method, &req.uri, &req.headers, self.host.as_ref()) {
if let Some(target) = website_target(&req.uri, &req.headers, &self.website_domains) {
req.extensions.insert(Arc::clone(&self.server_ctx));
check_website_access(req, &target).await
} else if let Some(target) = metadata_operation(&req.method, &req.uri, &req.headers, self.host.as_ref()) {
check_metadata_access(req, target).await
} else {
self.admin.check_access(req).await
@@ -57,6 +84,12 @@ where
}
async fn call(&self, req: S3Request<Body>) -> S3Result<S3Response<Body>> {
if let Some(target) = website_target(&req.uri, &req.headers, &self.website_domains) {
let head = req.method == Method::HEAD;
return call_website(req, target, Arc::clone(&self.server_ctx), self.website_scheme)
.await
.or_else(|err: S3Error| Ok(website_error(err.status_code().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), head)));
}
match metadata_operation(&req.method, &req.uri, &req.headers, self.host.as_ref()) {
Some(BucketTarget {
operation: MetadataOperation::ListObjectVersions,
@@ -71,6 +104,511 @@ where
}
}
#[derive(Clone, Debug)]
struct WebsiteTarget {
bucket: String,
key: String,
trailing_slash: bool,
invalid_path: bool,
}
fn website_target(uri: &Uri, headers: &HeaderMap, domains: &[String]) -> Option<WebsiteTarget> {
if domains.is_empty() {
return None;
}
let host_header = headers.get(HOST)?.to_str().ok()?;
let authority = host_header.parse::<http::uri::Authority>().ok()?;
let request_host = authority.host().trim_end_matches('.').to_ascii_lowercase();
let bucket = domains.iter().find_map(|domain| {
if request_host == *domain {
return Some(String::new());
}
request_host.strip_suffix(&format!(".{domain}")).map(str::to_owned)
})?;
let raw_path = uri.path();
let decoded = urlencoding::decode(raw_path);
let invalid_path = decoded.is_err();
let decoded = decoded.unwrap_or_default();
let path = decoded.trim_start_matches('/');
Some(WebsiteTarget {
bucket,
key: path.to_owned(),
trailing_slash: path.is_empty() || path.ends_with('/'),
invalid_path,
})
}
async fn check_website_access(req: &mut S3Request<Body>, target: &WebsiteTarget) -> S3Result<()> {
if req_info_mut(req).is_err() {
req.extensions.insert(ReqInfo::default());
}
let req_info = req_info_mut(req)?;
req_info.cred = None;
req_info.is_owner = false;
req_info.bucket = Some(target.bucket.clone());
req_info.object = Some(target.key.clone());
req_info.version_id = None;
Ok(())
}
async fn call_website(
req: S3Request<Body>,
target: WebsiteTarget,
server_ctx: Arc<ServerContextSlot>,
website_scheme: &'static str,
) -> S3Result<S3Response<Body>> {
if !matches!(req.method, Method::GET | Method::HEAD) {
return Ok(website_error(StatusCode::METHOD_NOT_ALLOWED, req.method == Method::HEAD));
}
if target.invalid_path {
return Ok(website_error(StatusCode::BAD_REQUEST, req.method == Method::HEAD));
}
if target.bucket.is_empty() || target.bucket.starts_with('.') || target.bucket.ends_with('.') || target.bucket.contains("..")
{
return Ok(website_error(StatusCode::NOT_FOUND, req.method == Method::HEAD));
}
let store = server_ctx
.installed_object_store()
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "website object store unavailable"))?;
let config = match get_bucket_website_config_for_store(&store, &target.bucket).await {
Ok(config) => config,
Err(crate::storage::StorageError::ConfigNotFound) => {
return Ok(website_error(StatusCode::NOT_FOUND, req.method == Method::HEAD));
}
Err(crate::storage::StorageError::BucketNotFound(_)) => {
return Ok(website_error(StatusCode::NOT_FOUND, req.method == Method::HEAD));
}
Err(err) => return Err(crate::error::ApiError::from(err).into()),
};
if validate_website_configuration(&config).is_err() {
return Ok(website_error(StatusCode::INTERNAL_SERVER_ERROR, req.method == Method::HEAD));
}
if let Some(redirect) = config.redirect_all_requests_to.as_ref() {
return website_redirect(
&req,
redirect.host_name.as_str(),
redirect.protocol.as_ref().map(|protocol| protocol.as_str()),
&target.key,
StatusCode::MOVED_PERMANENTLY,
website_scheme,
);
}
let key = if target.key.is_empty() || target.trailing_slash {
let suffix = config
.index_document
.as_ref()
.map(|doc| doc.suffix.as_str())
.unwrap_or_default();
format!("{}{}", target.key, suffix)
} else {
target.key.clone()
};
let rules = config.routing_rules.as_deref().unwrap_or_default();
// A rule requiring a status cannot be evaluated until the object lookup completes.
for rule in rules {
if rule
.condition
.as_ref()
.and_then(|condition| condition.http_error_code_returned_equals.as_ref())
.is_some()
{
break;
}
if website_rule_matches(rule, &target.key, StatusCode::OK) {
return website_rule_redirect(&req, rule, &target.key, website_scheme);
}
}
let response = website_get_object(&req, &server_ctx, &target.bucket, &key, true).await;
if let Err(err) = &response
&& matches!(
err.code(),
S3ErrorCode::NotModified | S3ErrorCode::PreconditionFailed | S3ErrorCode::InvalidRange
)
&& let Ok(Some(location)) = website_head_redirect(&req, &server_ctx, &target.bucket, &key).await
{
return website_location(location, StatusCode::MOVED_PERMANENTLY);
}
let status = match &response {
Ok(_) => StatusCode::OK,
Err(err) => err.status_code().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
};
if status == StatusCode::NOT_FOUND && !target.trailing_slash && !target.key.is_empty() {
let index_key = format!(
"{}/{}",
target.key,
config
.index_document
.as_ref()
.map(|doc| doc.suffix.as_str())
.unwrap_or_default()
);
match website_get_object(&req, &server_ctx, &target.bucket, &index_key, false).await {
Ok(_) => return website_relative_redirect(&req, &format!("/{}/", target.key), StatusCode::FOUND),
Err(err)
if matches!(
err.code(),
S3ErrorCode::NoSuchKey | S3ErrorCode::NoSuchVersion | S3ErrorCode::AccessDenied
) => {}
Err(err) => return Err(err),
}
}
if let Some(rule) = rules.iter().find(|rule| website_rule_matches(rule, &target.key, status)) {
return website_rule_redirect(&req, rule, &target.key, website_scheme);
}
match response {
Ok(response) => {
if let Some(location) = response.output.website_redirect_location.clone() {
return website_location(location, StatusCode::MOVED_PERMANENTLY);
}
response_to_website(response, req.method == Method::HEAD)
}
Err(err) if matches!(err.code(), S3ErrorCode::NoSuchKey | S3ErrorCode::NoSuchVersion) => {
website_error_with_document(&req, &server_ctx, &target.bucket, config.error_document.as_ref(), StatusCode::NOT_FOUND)
.await
}
Err(err) if err.code() == &S3ErrorCode::NotModified => Ok(website_error(StatusCode::NOT_MODIFIED, true)),
Err(_) if status.is_client_error() => {
website_error_with_document(&req, &server_ctx, &target.bucket, config.error_document.as_ref(), status).await
}
Err(err) => Err(err),
}
}
async fn website_get_object(
original: &S3Request<Body>,
server_ctx: &Arc<ServerContextSlot>,
bucket: &str,
key: &str,
apply_conditions: bool,
) -> S3Result<S3Response<GetObjectOutput>> {
let header = |name: http::header::HeaderName| {
original
.headers
.get(name)
.map(|value| {
value
.to_str()
.map(str::to_owned)
.map_err(|_| S3Error::new(S3ErrorCode::InvalidArgument))
})
.transpose()
};
let input = if apply_conditions {
GetObjectInput {
bucket: bucket.to_owned(),
key: key.to_owned(),
range: header(http::header::RANGE)?
.map(|value| Range::parse(&value).map_err(|_| S3Error::new(S3ErrorCode::InvalidRange)))
.transpose()?,
if_match: original
.headers
.get(http::header::IF_MATCH)
.map(|value| {
ETagCondition::parse_http_header(value.as_bytes()).map_err(|_| S3Error::new(S3ErrorCode::InvalidArgument))
})
.transpose()?,
if_none_match: original
.headers
.get(http::header::IF_NONE_MATCH)
.map(|value| {
ETagCondition::parse_http_header(value.as_bytes()).map_err(|_| S3Error::new(S3ErrorCode::InvalidArgument))
})
.transpose()?,
if_modified_since: header(http::header::IF_MODIFIED_SINCE)?
.map(|value| {
Timestamp::parse(TimestampFormat::HttpDate, &value).map_err(|_| S3Error::new(S3ErrorCode::InvalidArgument))
})
.transpose()?,
if_unmodified_since: header(http::header::IF_UNMODIFIED_SINCE)?
.map(|value| {
Timestamp::parse(TimestampFormat::HttpDate, &value).map_err(|_| S3Error::new(S3ErrorCode::InvalidArgument))
})
.transpose()?,
..Default::default()
}
} else {
GetObjectInput {
bucket: bucket.to_owned(),
key: key.to_owned(),
..Default::default()
}
};
let mut request = S3Request {
input,
method: Method::GET,
uri: original.uri.clone(),
headers: if apply_conditions {
original.headers.clone()
} else {
let mut headers = original.headers.clone();
for name in [
http::header::RANGE,
http::header::IF_MATCH,
http::header::IF_NONE_MATCH,
http::header::IF_MODIFIED_SINCE,
http::header::IF_UNMODIFIED_SINCE,
] {
headers.remove(name);
}
headers
},
extensions: original.extensions.clone(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
request.extensions.insert(Arc::clone(server_ctx));
let req_info = request
.extensions
.get_mut::<ReqInfo>()
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "website request info missing"))?;
req_info.bucket = Some(bucket.to_owned());
req_info.object = Some(key.to_owned());
req_info.cred = None;
req_info.is_owner = false;
request.extensions.insert(WebsiteRead);
S3Access::get_object(&crate::storage::ecfs::FS::with_server_ctx(Arc::clone(server_ctx)), &mut request).await?;
DefaultObjectUsecase::with_context(server_ctx.installed_app_context())
.execute_get_object(request)
.await
}
async fn website_head_redirect(
original: &S3Request<Body>,
server_ctx: &Arc<ServerContextSlot>,
bucket: &str,
key: &str,
) -> S3Result<Option<String>> {
let mut headers = original.headers.clone();
for name in [
http::header::RANGE,
http::header::IF_MATCH,
http::header::IF_NONE_MATCH,
http::header::IF_MODIFIED_SINCE,
http::header::IF_UNMODIFIED_SINCE,
] {
headers.remove(name);
}
let mut request = S3Request {
input: HeadObjectInput {
bucket: bucket.to_owned(),
key: key.to_owned(),
..Default::default()
},
method: Method::HEAD,
uri: original.uri.clone(),
headers,
extensions: original.extensions.clone(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
request.extensions.insert(Arc::clone(server_ctx));
let req_info = request
.extensions
.get_mut::<ReqInfo>()
.ok_or_else(|| S3Error::with_message(S3ErrorCode::InternalError, "website request info missing"))?;
req_info.bucket = Some(bucket.to_owned());
req_info.object = Some(key.to_owned());
req_info.cred = None;
req_info.is_owner = false;
S3Access::head_object(&crate::storage::ecfs::FS::with_server_ctx(Arc::clone(server_ctx)), &mut request).await?;
let response = DefaultObjectUsecase::with_context(server_ctx.installed_app_context())
.execute_head_object(request)
.await?;
Ok(response.output.website_redirect_location)
}
#[derive(Clone)]
pub(crate) struct WebsiteRead;
fn response_to_website(response: S3Response<GetObjectOutput>, head: bool) -> S3Result<S3Response<Body>> {
let output = response.output;
let mut headers = response.headers;
insert_website_header(&mut headers, http::header::CONTENT_TYPE, output.content_type.as_deref())?;
insert_website_header(
&mut headers,
http::header::CONTENT_LENGTH,
output.content_length.map(|length| length.to_string()).as_deref(),
)?;
insert_website_header(&mut headers, http::header::CONTENT_RANGE, output.content_range.as_deref())?;
if let Some(tag) = output.e_tag.as_ref() {
headers.insert(http::header::ETAG, tag.to_http_header().map_err(S3Error::internal_error)?);
}
insert_website_header(&mut headers, http::header::CACHE_CONTROL, output.cache_control.as_deref())?;
insert_website_header(&mut headers, http::header::CONTENT_ENCODING, output.content_encoding.as_deref())?;
insert_website_header(&mut headers, http::header::CONTENT_LANGUAGE, output.content_language.as_deref())?;
insert_website_header(&mut headers, http::header::CONTENT_DISPOSITION, output.content_disposition.as_deref())?;
if let Some(last_modified) = output.last_modified {
let mut formatted = Vec::new();
last_modified
.format(TimestampFormat::HttpDate, &mut formatted)
.map_err(S3Error::internal_error)?;
headers.insert(
http::header::LAST_MODIFIED,
HeaderValue::from_bytes(&formatted).map_err(S3Error::internal_error)?,
);
}
Ok(S3Response {
output: if head {
Body::empty()
} else {
output.body.map(Body::from).unwrap_or_default()
},
status: Some(if output.content_range.is_some() {
StatusCode::PARTIAL_CONTENT
} else {
StatusCode::OK
}),
headers,
extensions: response.extensions,
})
}
fn insert_website_header(headers: &mut HeaderMap, name: http::header::HeaderName, value: Option<&str>) -> S3Result<()> {
if let Some(value) = value {
headers.insert(name, HeaderValue::from_str(value).map_err(S3Error::internal_error)?);
}
Ok(())
}
async fn website_error_with_document(
req: &S3Request<Body>,
server_ctx: &Arc<ServerContextSlot>,
bucket: &str,
error_document: Option<&s3s::dto::ErrorDocument>,
status: http::StatusCode,
) -> S3Result<S3Response<Body>> {
let Some(error_document) = error_document else {
return Ok(website_error(status, req.method == Method::HEAD));
};
match website_get_object(req, server_ctx, bucket, error_document.key.as_str(), false).await {
Ok(response) => {
let mut output = response_to_website(response, req.method == Method::HEAD)?;
output.status = Some(status);
Ok(output)
}
Err(err)
if matches!(
err.code(),
S3ErrorCode::NoSuchKey | S3ErrorCode::NoSuchVersion | S3ErrorCode::AccessDenied
) =>
{
Ok(website_error(status, req.method == Method::HEAD))
}
Err(err) => Err(err),
}
}
fn website_error(status: StatusCode, head: bool) -> S3Response<Body> {
let message = status.canonical_reason().unwrap_or("Error");
let body = format!("<html><body>{message}</body></html>");
let mut headers = HeaderMap::new();
headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8"));
S3Response {
output: if head { Body::empty() } else { Body::from(body.into_bytes()) },
status: Some(status),
headers,
extensions: Extensions::new(),
}
}
fn website_location(location: String, status: StatusCode) -> S3Result<S3Response<Body>> {
let mut response = S3Response::new(Body::empty());
response.status = Some(status);
response.headers.insert(
http::header::LOCATION,
HeaderValue::try_from(location)
.map_err(|_| S3Error::with_message(S3ErrorCode::InternalError, "invalid website redirect location"))?,
);
Ok(response)
}
fn website_relative_redirect(req: &S3Request<Body>, path: &str, status: StatusCode) -> S3Result<S3Response<Body>> {
let mut url = url::Url::parse("http://localhost/").map_err(S3Error::internal_error)?;
url.set_path(path);
url.set_query(req.uri.query());
let location = match url.query() {
Some(query) => format!("{}?{query}", url.path()),
None => url.path().to_owned(),
};
website_location(location, status)
}
fn website_redirect(
req: &S3Request<Body>,
host: &str,
protocol: Option<&str>,
key: &str,
status: StatusCode,
website_scheme: &str,
) -> S3Result<S3Response<Body>> {
let scheme = protocol.unwrap_or(website_scheme);
let host = if host.is_empty() {
req.headers.get(HOST).and_then(|h| h.to_str().ok()).unwrap_or_default()
} else {
host
};
let mut url = url::Url::parse(&format!("{scheme}://{host}/")).map_err(S3Error::internal_error)?;
url.set_path(&format!("/{key}"));
url.set_query(req.uri.query());
website_location(url.into(), status)
}
fn website_rule_matches(rule: &RoutingRule, key: &str, status: StatusCode) -> bool {
let Some(condition) = rule.condition.as_ref() else { return true };
condition
.key_prefix_equals
.as_ref()
.is_none_or(|prefix| key.starts_with(prefix))
&& condition
.http_error_code_returned_equals
.as_ref()
.is_none_or(|code| code == status.as_str())
}
fn website_rule_redirect(
req: &S3Request<Body>,
rule: &RoutingRule,
key: &str,
website_scheme: &str,
) -> S3Result<S3Response<Body>> {
let redirect = &rule.redirect;
let key = if let Some(replace) = &redirect.replace_key_with {
replace.clone()
} else if let Some(replace) = &redirect.replace_key_prefix_with {
let prefix = rule
.condition
.as_ref()
.and_then(|condition| condition.key_prefix_equals.as_deref())
.unwrap_or_default();
format!("{replace}{}", key.strip_prefix(prefix).unwrap_or(key))
} else {
key.to_owned()
};
let status = redirect
.http_redirect_code
.as_deref()
.and_then(|code| code.parse::<u16>().ok())
.and_then(|code| StatusCode::from_u16(code).ok())
.unwrap_or(StatusCode::MOVED_PERMANENTLY);
website_redirect(
req,
redirect.host_name.as_deref().unwrap_or_default(),
redirect.protocol.as_ref().map(|protocol| protocol.as_str()),
&key,
status,
website_scheme,
)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum MetadataOperation {
ListObjectVersions,
@@ -288,16 +826,123 @@ fn header_value(headers: &HeaderMap, name: &str) -> S3Result<Option<String>> {
#[cfg(test)]
mod tests {
use super::{MetadataOperation, list_object_versions_input, list_objects_v2_input, metadata_operation};
use super::{
MetadataOperation, list_object_versions_input, list_objects_v2_input, metadata_operation, website_redirect,
website_relative_redirect, website_rule_matches, website_target,
};
use http::header::HOST;
use http::{HeaderMap, Method, Uri};
use s3s::dto::EncodingType;
use http::{Extensions, HeaderMap, Method, StatusCode, Uri};
use s3s::dto::{Condition, EncodingType, Redirect, RoutingRule};
use s3s::host::MultiDomain;
use s3s::{Body, S3Request};
fn uri(value: &str) -> Uri {
value.parse().expect("test URI should parse")
}
fn website_request(value: &str) -> S3Request<Body> {
let mut headers = HeaderMap::new();
headers.insert(HOST, "site.example.com".parse().expect("valid host"));
S3Request {
input: Body::empty(),
method: Method::GET,
uri: uri(value),
headers,
extensions: Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
}
}
#[test]
fn website_host_is_isolated_from_s3_host_and_keeps_directory_suffix() {
let domains = vec!["example.com".to_owned()];
let mut headers = HeaderMap::new();
headers.insert(HOST, "share.example.com:9000".parse().expect("valid host"));
let target = website_target(&uri("/docs/"), &headers, &domains).expect("website host matches");
assert_eq!(target.bucket, "share");
assert_eq!(target.key, "docs/");
assert!(target.trailing_slash);
headers.insert(HOST, "s3.other.test".parse().expect("valid host"));
assert!(website_target(&uri("/share/docs/"), &headers, &domains).is_none());
headers.insert(HOST, "my.bucket.example.com".parse().expect("valid host"));
assert_eq!(
website_target(&uri("/"), &headers, &domains)
.expect("dotted bucket host matches")
.bucket,
"my.bucket"
);
headers.insert(HOST, "example.com".parse().expect("valid host"));
assert_eq!(
website_target(&uri("/share/key"), &headers, &domains)
.expect("base domain is reserved")
.bucket,
""
);
headers.insert(HOST, "share.example.com".parse().expect("valid host"));
assert!(
website_target(&uri("/%FF"), &headers, &domains)
.expect("invalid path stays in website route")
.invalid_path
);
}
#[test]
fn website_redirect_encodes_path_and_preserves_query() {
let req = website_request("/old?download=1");
let redirect = website_redirect(
&req,
"new.example.com",
Some("https"),
"new path/#one",
StatusCode::MOVED_PERMANENTLY,
"http",
)
.expect("valid redirect");
assert_eq!(
redirect.headers[http::header::LOCATION],
"https://new.example.com/new%20path/%23one?download=1"
);
let directory = website_relative_redirect(&req, "/new path/", StatusCode::FOUND).expect("valid directory redirect");
assert_eq!(directory.headers[http::header::LOCATION], "/new%20path/?download=1");
let secure = website_redirect(&req, "new.example.com", None, "key", StatusCode::MOVED_PERMANENTLY, "https")
.expect("TLS listener default scheme");
assert_eq!(secure.headers[http::header::LOCATION], "https://new.example.com/key?download=1");
}
#[test]
fn website_rules_choose_first_match_after_object_status_is_known() {
let error_rule = RoutingRule {
condition: Some(Condition {
http_error_code_returned_equals: Some("404".to_owned()),
key_prefix_equals: None,
}),
redirect: Redirect::default(),
};
let prefix_rule = RoutingRule {
condition: Some(Condition {
http_error_code_returned_equals: None,
key_prefix_equals: Some("docs/".to_owned()),
}),
redirect: Redirect::default(),
};
let rules = [error_rule, prefix_rule];
assert_eq!(
rules
.iter()
.position(|rule| website_rule_matches(rule, "docs/missing", StatusCode::NOT_FOUND)),
Some(0)
);
assert_eq!(
rules
.iter()
.position(|rule| website_rule_matches(rule, "docs/existing", StatusCode::OK)),
Some(1)
);
}
#[test]
fn metadata_operation_matches_path_style_extensions_only() {
assert_eq!(
+14 -2
View File
@@ -3732,6 +3732,10 @@ impl DefaultObjectUsecase {
content_range,
e_tag: info.etag.map(|etag| to_s3s_etag(&etag)),
metadata,
website_redirect_location: req
.extensions
.get::<crate::app::metadata_route::WebsiteRead>()
.and_then(|_| info.user_defined.get(AMZ_WEBSITE_REDIRECT_LOCATION).cloned()),
server_side_encryption,
sse_customer_algorithm,
sse_customer_key_md5,
@@ -3843,6 +3847,7 @@ impl DefaultObjectUsecase {
storage_class: remote.storage_class.map(|sc| StorageClass::from(sc.as_str().to_string())),
expiration: remote.expiration,
restore: remote.restore,
website_redirect_location: remote.website_redirect_location,
checksum_crc32: remote.checksum_crc32,
checksum_crc32c: remote.checksum_crc32_c,
checksum_crc64nvme: remote.checksum_crc64_nvme,
@@ -4070,7 +4075,10 @@ impl DefaultObjectUsecase {
{
// Active-active replication lag window: proxy the GET to a
// replication target (backlog#1675 P1-5).
if let Some(output) = Self::proxy_get_object_to_replication_targets(&req, &bucket, &key, &opts).await {
if let Some(mut output) = Self::proxy_get_object_to_replication_targets(&req, &bucket, &key, &opts).await {
if req.extensions.get::<crate::app::metadata_route::WebsiteRead>().is_none() {
output.website_redirect_location = None;
}
lifecycle.finish_ok();
let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, output).await;
inject_accept_ranges_header(&mut response.headers);
@@ -4086,7 +4094,10 @@ impl DefaultObjectUsecase {
.await
{
None => {}
Some(OdmGetOutcome::Respond(Ok(output))) => {
Some(OdmGetOutcome::Respond(Ok(mut output))) => {
if req.extensions.get::<crate::app::metadata_route::WebsiteRead>().is_none() {
output.website_redirect_location = None;
}
lifecycle.finish_ok();
let mut response = wrap_response_with_cors(&bucket, &req.method, &req.headers, *output).await;
inject_accept_ranges_header(&mut response.headers);
@@ -4628,6 +4639,7 @@ fn odm_get_output(head: &SourceHead, content_length: i64, content_range: Option<
e_tag: head.etag.as_deref().map(to_s3s_etag),
last_modified: head.last_modified.map(OffsetDateTime::from).map(Timestamp::from),
metadata: (!head.user_metadata.is_empty()).then(|| head.user_metadata.clone()),
website_redirect_location: head.website_redirect_location.clone(),
..Default::default()
}
}
+3
View File
@@ -104,6 +104,7 @@ pub(super) fn odm_head_output(head: SourceHead) -> S3Result<HeadObjectOutput> {
e_tag: head.etag.as_deref().map(to_s3s_etag),
last_modified: head.last_modified.map(OffsetDateTime::from).map(Timestamp::from),
metadata: (!head.user_metadata.is_empty()).then_some(head.user_metadata),
website_redirect_location: head.website_redirect_location,
..Default::default()
})
}
@@ -286,6 +287,7 @@ impl DefaultObjectUsecase {
storage_class: remote.storage_class.map(|sc| StorageClass::from(sc.as_str().to_string())),
expiration: remote.expiration,
restore: remote.restore,
website_redirect_location: remote.website_redirect_location,
checksum_crc32: remote.checksum_crc32,
checksum_crc32c: remote.checksum_crc32_c,
checksum_crc64nvme: remote.checksum_crc64_nvme,
@@ -777,6 +779,7 @@ mod tests {
content_language: Some("en".to_string()),
cache_control: Some("max-age=60".to_string()),
expires: Some("Thu, 01 Jan 2026 00:00:00 GMT".to_string()),
website_redirect_location: None,
user_metadata: HashMap::from([("owner".to_string(), "alice".to_string())]),
version_id: Some("v1".to_string()),
storage_class: Some("STANDARD_IA".to_string()),
-1
View File
@@ -16,7 +16,6 @@ use crate::app::object_data_cache::ObjectDataCacheAdapter;
#[cfg(test)]
use crate::app::storage_api::runtime_sources::TierConfigMgr;
use crate::runtime_sources as root_runtime_sources;
#[cfg(test)]
pub(crate) use crate::runtime_sources::ServerContextSlot;
pub(crate) use crate::runtime_sources::{
AppContext, current_encryption_service, current_endpoints_handle, current_notification_system,
@@ -309,6 +309,7 @@ pub(super) fn native_source_head(
content_language: header_string(headers, "content-language"),
cache_control: header_string(headers, "cache-control"),
expires: header_string(headers, "expires"),
website_redirect_location: header_string(headers, "x-amz-website-redirect-location"),
user_metadata,
version_id: fields.version_id,
storage_class: fields.storage_class,
@@ -413,6 +413,7 @@ pub struct SourceHead {
pub content_language: Option<String>,
pub cache_control: Option<String>,
pub expires: Option<String>,
pub website_redirect_location: Option<String>,
/// `x-amz-meta-*` values keyed without the prefix, matching the stored
/// user-metadata shape.
pub user_metadata: HashMap<String, String>,
@@ -438,6 +439,7 @@ struct HeadParts {
content_language: Option<String>,
cache_control: Option<String>,
expires: Option<String>,
website_redirect_location: Option<String>,
metadata: Option<HashMap<String, String>>,
version_id: Option<String>,
storage_class: Option<String>,
@@ -490,6 +492,7 @@ fn source_head(parts: HeadParts) -> Result<SourceHead, SourceError> {
content_language: parts.content_language,
cache_control: parts.cache_control,
expires: parts.expires,
website_redirect_location: parts.website_redirect_location,
user_metadata: parts.metadata.unwrap_or_default(),
version_id: parts.version_id,
storage_class: parts.storage_class,
@@ -510,6 +513,7 @@ fn source_head_from_head_output(output: HeadObjectOutput) -> Result<SourceHead,
content_language: output.content_language,
cache_control: output.cache_control,
expires: output.expires_string,
website_redirect_location: output.website_redirect_location,
metadata: output.metadata,
version_id: output.version_id,
storage_class: output.storage_class.map(|class| class.as_str().to_string()),
@@ -549,6 +553,7 @@ fn source_get_from_output(output: GetObjectOutput) -> Result<SourceGet, SourceEr
content_language: output.content_language,
cache_control: output.cache_control,
expires: output.expires_string,
website_redirect_location: output.website_redirect_location,
metadata: output.metadata,
version_id: output.version_id,
storage_class: output.storage_class.map(|class| class.as_str().to_string()),
+38
View File
@@ -191,6 +191,39 @@ fn s3_host_domains(config: &config::Config) -> Result<Option<Vec<String>>> {
Ok(Some(domains))
}
fn website_host_domains(config: &config::Config) -> Result<Option<Vec<String>>> {
if config.console_enable {
return Ok(None);
}
let raw = std::env::var("RUSTFS_WEBSITE_DOMAINS").unwrap_or_default();
let mut domains = Vec::new();
let mut seen = std::collections::HashSet::new();
for domain in raw.split(',').map(str::trim).filter(|domain| !domain.is_empty()) {
if domain.contains(':') || domain.ends_with('.') {
return Err(Error::other(format!("invalid RUSTFS_WEBSITE_DOMAINS entry: {domain}")));
}
let normalized = domain.to_ascii_lowercase();
if !seen.insert(normalized.clone()) {
return Err(Error::other(format!("duplicate RUSTFS_WEBSITE_DOMAINS entry: {domain}")));
}
if config
.server_domains
.iter()
.map(|s| strip_valid_port_suffix(s).to_ascii_lowercase())
.any(|s| normalized == s || normalized.ends_with(&format!(".{s}")) || s.ends_with(&format!(".{normalized}")))
{
return Err(Error::other(format!("website domain overlaps RUSTFS_SERVER_DOMAINS: {domain}")));
}
domains.push(normalized);
}
if domains.is_empty() {
Ok(None)
} else {
MultiDomain::new(&domains).map_err(|err| Error::other(format!("invalid RUSTFS_WEBSITE_DOMAINS {:?}: {err}", domains)))?;
Ok(Some(domains))
}
}
const LOG_COMPONENT_SERVER: &str = "server";
const LOG_SUBSYSTEM_HTTP: &str = "http";
const LOG_SUBSYSTEM_TRANSPORT: &str = "transport";
@@ -1358,12 +1391,17 @@ pub async fn start_http_server(
.map(MultiDomain::new)
.transpose()
.map_err(Error::other)?;
let website_host_sets = website_host_domains(config)?;
let website_route_domains = website_host_sets.unwrap_or_default();
b.set_auth(IAMAuth::with_server_context(access_key, secret_key, server_ctx.clone()));
b.set_access(store);
b.set_route(storage::metadata_route::with_metadata_route(
admin::make_admin_route(config.console_enable, admin_server_ctx)?,
metadata_route_host,
website_route_domains,
protocol,
Arc::clone(&server_ctx),
));
// Normalize leading/duplicate forward slashes in object keys (MinIO parity).
+40 -1
View File
@@ -35,6 +35,7 @@ use crate::storage::storage_api::ecfs_consumer::object_lock::{
parse_object_lock_legal_hold, parse_object_lock_retention, validate_bucket_object_lock_enabled,
};
use crate::storage::storage_api::runtime_sources_consumer::{ECStore, runtime_sources};
use crate::storage::validate_website_configuration;
use crate::table_catalog;
use http::StatusCode;
use metrics::{counter, histogram};
@@ -1512,6 +1513,8 @@ impl S3 for FS {
.await
.map_err(crate::error::ApiError::from)?;
validate_website_configuration(&req.input.website_configuration)?;
let website_config = serialize(&req.input.website_configuration)
.map_err(|err| S3Error::with_message(S3ErrorCode::MalformedXML, format!("{err}")))?;
update_bucket_metadata_config_if_incarnation(
@@ -2038,12 +2041,48 @@ mod tests {
FS, SITE_REPLICATION_GATE_FORCE_DISABLED, SITE_REPLICATION_GATE_FORCE_ENABLED, SITE_REPLICATION_GATE_TEST_OVERRIDE,
};
use crate::storage::access::ReqInfo;
use crate::storage::validate_website_configuration;
use http::Method;
use http::StatusCode;
use s3s::dto::{DeleteBucketReplicationInput, PutBucketReplicationInput, ReplicationConfiguration};
use s3s::dto::{
Condition, DeleteBucketReplicationInput, IndexDocument, PutBucketReplicationInput, Redirect, ReplicationConfiguration,
RoutingRule, WebsiteConfiguration,
};
use s3s::{S3, S3Error, S3ErrorCode, S3Request};
use std::sync::atomic::Ordering;
#[test]
fn website_config_rejects_missing_index_and_invalid_redirect_rules() {
assert!(validate_website_configuration(&WebsiteConfiguration::default()).is_err());
let config = WebsiteConfiguration {
index_document: Some(IndexDocument {
suffix: "index.html".to_owned(),
}),
..Default::default()
};
assert!(validate_website_configuration(&config).is_ok());
let mut bad = config;
bad.routing_rules = Some(vec![RoutingRule {
condition: Some(Condition::default()),
redirect: Redirect {
host_name: Some("other.test".to_owned()),
..Default::default()
},
}]);
assert!(validate_website_configuration(&bad).is_err());
bad.routing_rules = Some(vec![RoutingRule {
condition: Some(Condition {
key_prefix_equals: Some("old/".to_owned()),
..Default::default()
}),
redirect: Redirect {
protocol: Some("ftp".to_owned().into()),
..Default::default()
},
}]);
assert!(validate_website_configuration(&bad).is_err());
}
fn replication_config_edit_request<T>(input: T, is_owner: bool) -> S3Request<T> {
let mut req = S3Request {
input,
+8 -8
View File
@@ -66,14 +66,14 @@ pub(crate) use storage_api::{
ecstore_rebalance, ecstore_rio, ecstore_rpc, ecstore_set_disk, ecstore_storage, ecstore_tier, encode_tags,
find_local_disk_by_ref, get_bucket_accelerate_config, get_bucket_cors_config, get_bucket_logging_config, get_bucket_metadata,
get_bucket_notification_config, get_bucket_object_lock_config, get_bucket_request_payment_config, get_bucket_sse_config,
get_bucket_website_config, get_local_server_property, get_lock_acquire_timeout, head_prefix_consumer, helper_consumer,
init_background_replication, init_bucket_metadata_sys, init_ecstore_config, init_local_disks_with_instance_ctx,
init_lock_clients, is_err_bucket_not_found, is_err_object_not_found, is_err_version_not_found, is_valid_storage_class,
options_consumer, prewarm_local_disk_id_map_with_instance_ctx, read_config, record_replication_proxy, rpc_consumer,
runtime_sources_consumer, s3_api_consumer, serialize, table_catalog_path_hash, to_s3s_etag,
topology_snapshot_from_endpoint_pools_with_capabilities, try_migrate_bucket_metadata, try_migrate_iam_config,
try_migrate_server_config, update_bucket_metadata_config, update_bucket_metadata_config_if_incarnation, verify_rpc_signature,
wrap_reader,
get_bucket_website_config, get_bucket_website_config_for_store, get_local_server_property, get_lock_acquire_timeout,
head_prefix_consumer, helper_consumer, init_background_replication, init_bucket_metadata_sys, init_ecstore_config,
init_local_disks_with_instance_ctx, init_lock_clients, is_err_bucket_not_found, is_err_object_not_found,
is_err_version_not_found, is_valid_storage_class, options_consumer, prewarm_local_disk_id_map_with_instance_ctx, read_config,
record_replication_proxy, rpc_consumer, runtime_sources_consumer, s3_api_consumer, serialize, table_catalog_path_hash,
to_s3s_etag, topology_snapshot_from_endpoint_pools_with_capabilities, try_migrate_bucket_metadata, try_migrate_iam_config,
try_migrate_server_config, update_bucket_metadata_config, update_bucket_metadata_config_if_incarnation,
validate_website_configuration, verify_rpc_signature, wrap_reader,
};
#[cfg(test)]
+111
View File
@@ -1787,6 +1787,117 @@ pub(crate) async fn get_bucket_website_config(bucket: &str) -> Result<(s3s::dto:
ecstore_bucket::metadata_sys::get_website_config(bucket).await
}
pub(crate) async fn get_bucket_website_config_for_store(store: &ECStore, bucket: &str) -> Result<s3s::dto::WebsiteConfiguration> {
let metadata = store.get_bucket_metadata(bucket).await?;
match ecstore_bucket::metadata::ConfigState::of(&metadata.website_config_xml, &metadata.website_config)
.require(bucket, ecstore_bucket::metadata::BUCKET_WEBSITE_CONFIG)?
{
Some(config) => Ok(config.clone()),
None => Err(StorageError::ConfigNotFound),
}
}
pub(crate) fn validate_website_configuration(config: &s3s::dto::WebsiteConfiguration) -> s3s::S3Result<()> {
if config.redirect_all_requests_to.is_some()
&& (config.index_document.is_some() || config.error_document.is_some() || config.routing_rules.is_some())
{
return Err(website_config_error(
"RedirectAllRequestsTo cannot be combined with other website settings",
));
}
if config.redirect_all_requests_to.is_none() && config.index_document.is_none() {
return Err(website_config_error(
"IndexDocument is required unless RedirectAllRequestsTo is configured",
));
}
if let Some(index) = &config.index_document
&& (index.suffix.is_empty() || index.suffix.contains('/'))
{
return Err(website_config_error("IndexDocument suffix must be a single nonempty name"));
}
if let Some(error) = &config.error_document
&& error.key.is_empty()
{
return Err(website_config_error("ErrorDocument key cannot be empty"));
}
if let Some(rules) = &config.routing_rules {
if rules.len() > 50 {
return Err(website_config_error("RoutingRules cannot contain more than 50 rules"));
}
if rules.is_empty() {
return Err(website_config_error("RoutingRules cannot be empty"));
}
for rule in rules {
if let Some(condition) = &rule.condition
&& condition.key_prefix_equals.is_none()
&& condition.http_error_code_returned_equals.is_none()
{
return Err(website_config_error("RoutingRule Condition cannot be empty"));
}
if rule.redirect.host_name.is_none()
&& rule.redirect.protocol.is_none()
&& rule.redirect.replace_key_prefix_with.is_none()
&& rule.redirect.replace_key_with.is_none()
&& rule.redirect.http_redirect_code.is_none()
{
return Err(website_config_error("RoutingRule Redirect cannot be empty"));
}
if rule.redirect.replace_key_prefix_with.is_some() && rule.redirect.replace_key_with.is_some() {
return Err(website_config_error("RoutingRule redirect key replacements are mutually exclusive"));
}
if let Some(protocol) = rule.redirect.protocol.as_ref().map(|protocol| protocol.as_str())
&& !matches!(protocol, "http" | "https")
{
return Err(website_config_error("RoutingRule protocol must be http or https"));
}
if let Some(code) = rule.redirect.http_redirect_code.as_deref()
&& !matches!(code, "301" | "302" | "303" | "307" | "308")
{
return Err(website_config_error("RoutingRule redirect code is invalid"));
}
if let Some(host) = rule.redirect.host_name.as_deref() {
validate_website_redirect_host(host)?;
}
if let Some(code) = rule
.condition
.as_ref()
.and_then(|condition| condition.http_error_code_returned_equals.as_deref())
&& (code.parse::<u16>().ok().is_none_or(|value| !(400..=599).contains(&value)))
{
return Err(website_config_error("RoutingRule error code is invalid"));
}
}
}
if let Some(redirect) = &config.redirect_all_requests_to {
validate_website_redirect_host(&redirect.host_name)?;
if let Some(protocol) = redirect.protocol.as_ref().map(|protocol| protocol.as_str())
&& !matches!(protocol, "http" | "https")
{
return Err(website_config_error("RedirectAllRequestsTo protocol must be http or https"));
}
}
Ok(())
}
fn validate_website_redirect_host(host: &str) -> s3s::S3Result<()> {
let url =
url::Url::parse(&format!("http://{host}/")).map_err(|_| website_config_error("website redirect host is invalid"))?;
if url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.path() != "/"
|| url.query().is_some()
|| url.fragment().is_some()
{
return Err(website_config_error("website redirect host is invalid"));
}
Ok(())
}
fn website_config_error(message: &'static str) -> s3s::S3Error {
s3s::S3Error::with_message(s3s::S3ErrorCode::MalformedXML, message)
}
#[cfg(test)]
pub(crate) async fn set_bucket_metadata(bucket: String, bm: BucketMetadata) -> Result<()> {
ecstore_bucket::metadata_sys::set_bucket_metadata(bucket, bm).await
+8 -2
View File
@@ -170,11 +170,17 @@ pub(crate) mod server {
}
pub(crate) mod metadata_route {
pub(crate) fn with_metadata_route<A>(admin: A, host: Option<s3s::host::MultiDomain>) -> impl s3s::route::S3Route
pub(crate) fn with_metadata_route<A>(
admin: A,
host: Option<s3s::host::MultiDomain>,
website_domains: Vec<String>,
website_scheme: &'static str,
server_ctx: std::sync::Arc<super::ServerContextSlot>,
) -> impl s3s::route::S3Route
where
A: s3s::route::S3Route,
{
crate::app::metadata_route::with_metadata_route(admin, host)
crate::app::metadata_route::with_metadata_route(admin, host, website_domains, website_scheme, server_ctx)
}
}