diff --git a/crates/e2e_test/src/lib.rs b/crates/e2e_test/src/lib.rs index 71d499a07..ea7fba936 100644 --- a/crates/e2e_test/src/lib.rs +++ b/crates/e2e_test/src/lib.rs @@ -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)] diff --git a/crates/e2e_test/src/website_hosting_test.rs b/crates/e2e_test/src/website_hosting_test.rs new file mode 100644 index 000000000..1290e2fed --- /dev/null +++ b/crates/e2e_test/src/website_hosting_test.rs @@ -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 = Result>; + +#[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("")); + 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("")); + + 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("")); + 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(()) +} diff --git a/docs/operations/static-website-hosting.md b/docs/operations/static-website-hosting.md new file mode 100644 index 000000000..5f39f128e --- /dev/null +++ b/docs/operations/static-website-hosting.md @@ -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. diff --git a/rustfs/src/app/metadata_route.rs b/rustfs/src/app/metadata_route.rs index 6f6cf90c4..4cfd6b601 100644 --- a/rustfs/src/app/metadata_route.rs +++ b/rustfs/src/app/metadata_route.rs @@ -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 { admin: A, host: Option, + website_domains: Vec, + website_scheme: &'static str, + server_ctx: Arc, } -pub(crate) fn with_metadata_route(admin: A, host: Option) -> MetadataRoute { - MetadataRoute { admin, host } +pub(crate) fn with_metadata_route( + admin: A, + host: Option, + website_domains: Vec, + website_scheme: &'static str, + server_ctx: Arc, +) -> MetadataRoute { + 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) -> 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) -> S3Result> { + 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 { + if domains.is_empty() { + return None; + } + let host_header = headers.get(HOST)?.to_str().ok()?; + let authority = host_header.parse::().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, 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, + target: WebsiteTarget, + server_ctx: Arc, + website_scheme: &'static str, +) -> S3Result> { + 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, + server_ctx: &Arc, + bucket: &str, + key: &str, + apply_conditions: bool, +) -> S3Result> { + 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::() + .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, + server_ctx: &Arc, + bucket: &str, + key: &str, +) -> S3Result> { + 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::() + .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, head: bool) -> S3Result> { + 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, + server_ctx: &Arc, + bucket: &str, + error_document: Option<&s3s::dto::ErrorDocument>, + status: http::StatusCode, +) -> S3Result> { + 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 { + let message = status.canonical_reason().unwrap_or("Error"); + let body = format!("{message}"); + 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> { + 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, path: &str, status: StatusCode) -> S3Result> { + 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, + host: &str, + protocol: Option<&str>, + key: &str, + status: StatusCode, + website_scheme: &str, +) -> S3Result> { + 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, + rule: &RoutingRule, + key: &str, + website_scheme: &str, +) -> S3Result> { + 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::().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> { #[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 { + 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!( diff --git a/rustfs/src/app/object/get.rs b/rustfs/src/app/object/get.rs index e24b20c9e..53f6837c4 100644 --- a/rustfs/src/app/object/get.rs +++ b/rustfs/src/app/object/get.rs @@ -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::() + .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::().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::().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() } } diff --git a/rustfs/src/app/object/head.rs b/rustfs/src/app/object/head.rs index 81bde22fe..4d53df5ad 100644 --- a/rustfs/src/app/object/head.rs +++ b/rustfs/src/app/object/head.rs @@ -104,6 +104,7 @@ pub(super) fn odm_head_output(head: SourceHead) -> S3Result { 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()), diff --git a/rustfs/src/app/runtime_sources.rs b/rustfs/src/app/runtime_sources.rs index 2321969e7..ad7c7f949 100644 --- a/rustfs/src/app/runtime_sources.rs +++ b/rustfs/src/app/runtime_sources.rs @@ -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, diff --git a/rustfs/src/on_demand_migration/native_http.rs b/rustfs/src/on_demand_migration/native_http.rs index 4c408fa98..122aa691d 100644 --- a/rustfs/src/on_demand_migration/native_http.rs +++ b/rustfs/src/on_demand_migration/native_http.rs @@ -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, diff --git a/rustfs/src/on_demand_migration/source_client.rs b/rustfs/src/on_demand_migration/source_client.rs index 62adc5b4c..b4518d5ce 100644 --- a/rustfs/src/on_demand_migration/source_client.rs +++ b/rustfs/src/on_demand_migration/source_client.rs @@ -413,6 +413,7 @@ pub struct SourceHead { pub content_language: Option, pub cache_control: Option, pub expires: Option, + pub website_redirect_location: Option, /// `x-amz-meta-*` values keyed without the prefix, matching the stored /// user-metadata shape. pub user_metadata: HashMap, @@ -438,6 +439,7 @@ struct HeadParts { content_language: Option, cache_control: Option, expires: Option, + website_redirect_location: Option, metadata: Option>, version_id: Option, storage_class: Option, @@ -490,6 +492,7 @@ fn source_head(parts: HeadParts) -> Result { 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 Result Result>> { Ok(Some(domains)) } +fn website_host_domains(config: &config::Config) -> Result>> { + 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). diff --git a/rustfs/src/storage/ecfs.rs b/rustfs/src/storage/ecfs.rs index 170b74de6..51fecc893 100644 --- a/rustfs/src/storage/ecfs.rs +++ b/rustfs/src/storage/ecfs.rs @@ -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(input: T, is_owner: bool) -> S3Request { let mut req = S3Request { input, diff --git a/rustfs/src/storage/mod.rs b/rustfs/src/storage/mod.rs index af98b5bb3..10630bb01 100644 --- a/rustfs/src/storage/mod.rs +++ b/rustfs/src/storage/mod.rs @@ -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)] diff --git a/rustfs/src/storage/storage_api.rs b/rustfs/src/storage/storage_api.rs index c0900c56c..445e6968d 100644 --- a/rustfs/src/storage/storage_api.rs +++ b/rustfs/src/storage/storage_api.rs @@ -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 { + 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::().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 diff --git a/rustfs/src/storage_api.rs b/rustfs/src/storage_api.rs index 9b715f92a..7078fc9d6 100644 --- a/rustfs/src/storage_api.rs +++ b/rustfs/src/storage_api.rs @@ -170,11 +170,17 @@ pub(crate) mod server { } pub(crate) mod metadata_route { - pub(crate) fn with_metadata_route(admin: A, host: Option) -> impl s3s::route::S3Route + pub(crate) fn with_metadata_route( + admin: A, + host: Option, + website_domains: Vec, + website_scheme: &'static str, + server_ctx: std::sync::Arc, + ) -> 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) } }