mirror of
https://github.com/rustfs/rustfs.git
synced 2026-10-12 07:39:05 +00:00
fix(s3): serve bucket websites on dedicated domains
Merge the reviewed fix from pull request #8183.
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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!(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user