diff options
-rw-r--r-- | doc/book/src/reference_manual/s3_compatibility.md | 6 | ||||
-rw-r--r-- | doc/book/src/working_documents/compatibility_target.md | 8 | ||||
-rw-r--r-- | src/api/api_server.rs | 42 | ||||
-rw-r--r-- | src/api/lib.rs | 1 | ||||
-rw-r--r-- | src/api/s3_cors.rs | 409 | ||||
-rw-r--r-- | src/api/s3_router.rs | 15 | ||||
-rw-r--r-- | src/api/s3_website.rs | 15 | ||||
-rw-r--r-- | src/api/s3_xml.rs | 6 | ||||
-rw-r--r-- | src/model/bucket_table.rs | 30 | ||||
-rw-r--r-- | src/model/migrate.rs | 3 | ||||
-rw-r--r-- | src/util/crdt/lww.rs | 12 | ||||
-rw-r--r-- | src/web/web_server.rs | 112 |
12 files changed, 581 insertions, 78 deletions
diff --git a/doc/book/src/reference_manual/s3_compatibility.md b/doc/book/src/reference_manual/s3_compatibility.md index 4c4c6457..e6f32f6a 100644 --- a/doc/book/src/reference_manual/s3_compatibility.md +++ b/doc/book/src/reference_manual/s3_compatibility.md @@ -9,7 +9,8 @@ Implemented: - putting and getting objects in buckets - multipart uploads - listing objects -- access control on a per-key-per-bucket basis +- access control on a per-access-key-per-bucket basis +- CORS headers on web endpoint Not implemented: @@ -31,9 +32,11 @@ All APIs that are not mentionned are not implemented and will return a 501 Not I | CreateBucket | Implemented | | CreateMultipartUpload | Implemented | | DeleteBucket | Implemented | +| DeleteBucketCors | Implemented | | DeleteBucketWebsite | Implemented | | DeleteObject | Implemented | | DeleteObjects | Implemented | +| GetBucketCors | Implemented | | GetBucketLocation | Implemented | | GetBucketVersioning | Stub (see below) | | GetBucketWebsite | Implemented | @@ -46,6 +49,7 @@ All APIs that are not mentionned are not implemented and will return a 501 Not I | ListMultipartUpload | Implemented | | ListParts | Implemented | | PutObject | Implemented | +| PutBucketCors | Implemented | | PutBucketWebsite | Partially implemented (see below)| | UploadPart | Implemented | | UploadPartCopy | Implemented | diff --git a/doc/book/src/working_documents/compatibility_target.md b/doc/book/src/working_documents/compatibility_target.md index 8830101f..3f121e47 100644 --- a/doc/book/src/working_documents/compatibility_target.md +++ b/doc/book/src/working_documents/compatibility_target.md @@ -26,10 +26,10 @@ your motivations for doing so in the PR message. | | UploadPart | | | [*ListMultipartUploads*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/103) | | | [*ListParts*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/103) | -| **A-tier** (will implement) | | -| | [*GetBucketCors*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/138) | -| | [*PutBucketCors*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/138) | -| | [*DeleteBucketCors*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/138) | +| **A-tier** | | +| | GetBucketCors | +| | PutBucketCors | +| | DeleteBucketCors | | | UploadPartCopy | | | GetBucketWebsite | | | PutBucketWebsite | diff --git a/src/api/api_server.rs b/src/api/api_server.rs index 11daae13..c28e8832 100644 --- a/src/api/api_server.rs +++ b/src/api/api_server.rs @@ -5,7 +5,7 @@ use futures::future::Future; use hyper::header; use hyper::server::conn::AddrStream; use hyper::service::{make_service_fn, service_fn}; -use hyper::{Body, Request, Response, Server}; +use hyper::{Body, Method, Request, Response, Server}; use garage_util::data::*; use garage_util::error::Error as GarageError; @@ -13,12 +13,15 @@ use garage_util::error::Error as GarageError; use garage_model::garage::Garage; use garage_model::key_table::Key; +use garage_table::util::*; + use crate::error::*; use crate::signature::payload::check_payload_signature; use crate::helpers::*; use crate::s3_bucket::*; use crate::s3_copy::*; +use crate::s3_cors::*; use crate::s3_delete::*; use crate::s3_get::*; use crate::s3_list::*; @@ -102,17 +105,17 @@ async fn handler_inner(garage: Arc<Garage>, req: Request<Body>) -> Result<Respon let host = authority_to_host(authority)?; - let bucket = garage + let bucket_name = garage .config .s3_api .root_domain .as_ref() .and_then(|root_domain| host_to_bucket(&host, root_domain)); - let (endpoint, bucket) = Endpoint::from_request(&req, bucket.map(ToOwned::to_owned))?; + let (endpoint, bucket_name) = Endpoint::from_request(&req, bucket_name.map(ToOwned::to_owned))?; debug!("Endpoint: {:?}", endpoint); - let bucket_name = match bucket { + let bucket_name = match bucket_name { None => return handle_request_without_bucket(garage, req, api_key, endpoint).await, Some(bucket) => bucket.to_string(), }; @@ -123,6 +126,12 @@ async fn handler_inner(garage: Arc<Garage>, req: Request<Body>) -> Result<Respon } let bucket_id = resolve_bucket(&garage, &bucket_name, &api_key).await?; + let bucket = garage + .bucket_table + .get(&EmptyKey, &bucket_id) + .await? + .filter(|b| !b.state.is_deleted()) + .ok_or(Error::NoSuchBucket)?; let allowed = match endpoint.authorization_type() { Authorization::Read => api_key.allow_read(&bucket_id), @@ -137,7 +146,17 @@ async fn handler_inner(garage: Arc<Garage>, req: Request<Body>) -> Result<Respon )); } - match endpoint { + // Look up what CORS rule might apply to response. + // Requests for methods different than GET, HEAD or POST + // are always preflighted, i.e. the browser should make + // an OPTIONS call before to check it is allowed + let matching_cors_rule = match *req.method() { + Method::GET | Method::HEAD | Method::POST => find_matching_cors_rule(&bucket, &req)?, + _ => None, + }; + + let resp = match endpoint { + Endpoint::Options => handle_options(garage, &req, bucket_id).await, Endpoint::HeadObject { key, .. } => handle_head(garage, &req, bucket_id, &key).await, Endpoint::GetObject { key, .. } => handle_get(garage, &req, bucket_id, &key).await, Endpoint::UploadPart { @@ -320,8 +339,21 @@ async fn handler_inner(garage: Arc<Garage>, req: Request<Body>) -> Result<Respon handle_put_website(garage, bucket_id, req, content_sha256).await } Endpoint::DeleteBucketWebsite {} => handle_delete_website(garage, bucket_id).await, + Endpoint::GetBucketCors {} => handle_get_cors(garage, bucket_id).await, + Endpoint::PutBucketCors {} => handle_put_cors(garage, bucket_id, req, content_sha256).await, + Endpoint::DeleteBucketCors {} => handle_delete_cors(garage, bucket_id).await, endpoint => Err(Error::NotImplemented(endpoint.name().to_owned())), + }; + + // If request was a success and we have a CORS rule that applies to it, + // add the corresponding CORS headers to the response + let mut resp_ok = resp?; + if let Some(rule) = matching_cors_rule { + add_cors_headers(&mut resp_ok, rule) + .ok_or_internal_error("Invalid bucket CORS configuration")?; } + + Ok(resp_ok) } async fn handle_request_without_bucket( diff --git a/src/api/lib.rs b/src/api/lib.rs index 725cd9d1..bb5a8265 100644 --- a/src/api/lib.rs +++ b/src/api/lib.rs @@ -15,6 +15,7 @@ mod signature; pub mod helpers; mod s3_bucket; mod s3_copy; +pub mod s3_cors; mod s3_delete; pub mod s3_get; mod s3_list; diff --git a/src/api/s3_cors.rs b/src/api/s3_cors.rs new file mode 100644 index 00000000..d23bf48d --- /dev/null +++ b/src/api/s3_cors.rs @@ -0,0 +1,409 @@ +use quick_xml::de::from_reader; +use std::sync::Arc; + +use http::header::{ + ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, ACCESS_CONTROL_ALLOW_ORIGIN, + ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, +}; +use hyper::{header::HeaderName, Body, Method, Request, Response, StatusCode}; + +use serde::{Deserialize, Serialize}; + +use crate::error::*; +use crate::s3_xml::{to_xml_with_header, xmlns_tag, IntValue, Value}; +use crate::signature::verify_signed_content; + +use garage_model::bucket_table::{Bucket, CorsRule as GarageCorsRule}; +use garage_model::garage::Garage; +use garage_table::*; +use garage_util::data::*; + +pub async fn handle_get_cors( + garage: Arc<Garage>, + bucket_id: Uuid, +) -> Result<Response<Body>, Error> { + let bucket = garage + .bucket_table + .get(&EmptyKey, &bucket_id) + .await? + .ok_or(Error::NoSuchBucket)?; + + let param = bucket + .params() + .ok_or_internal_error("Bucket should not be deleted at this point")?; + + if let Some(cors) = param.cors_config.get() { + let wc = CorsConfiguration { + xmlns: (), + cors_rules: cors + .iter() + .map(CorsRule::from_garage_cors_rule) + .collect::<Vec<_>>(), + }; + let xml = to_xml_with_header(&wc)?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(http::header::CONTENT_TYPE, "application/xml") + .body(Body::from(xml))?) + } else { + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty())?) + } +} + +pub async fn handle_delete_cors( + garage: Arc<Garage>, + bucket_id: Uuid, +) -> Result<Response<Body>, Error> { + let mut bucket = garage + .bucket_table + .get(&EmptyKey, &bucket_id) + .await? + .ok_or(Error::NoSuchBucket)?; + + let param = bucket + .params_mut() + .ok_or_internal_error("Bucket should not be deleted at this point")?; + + param.cors_config.update(None); + garage.bucket_table.insert(&bucket).await?; + + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty())?) +} + +pub async fn handle_put_cors( + garage: Arc<Garage>, + bucket_id: Uuid, + req: Request<Body>, + content_sha256: Option<Hash>, +) -> Result<Response<Body>, Error> { + let body = hyper::body::to_bytes(req.into_body()).await?; + + if let Some(content_sha256) = content_sha256 { + verify_signed_content(content_sha256, &body[..])?; + } + + let mut bucket = garage + .bucket_table + .get(&EmptyKey, &bucket_id) + .await? + .ok_or(Error::NoSuchBucket)?; + + let param = bucket + .params_mut() + .ok_or_internal_error("Bucket should not be deleted at this point")?; + + let conf: CorsConfiguration = from_reader(&body as &[u8])?; + conf.validate()?; + + param + .cors_config + .update(Some(conf.into_garage_cors_config()?)); + garage.bucket_table.insert(&bucket).await?; + + Ok(Response::builder() + .status(StatusCode::OK) + .body(Body::empty())?) +} + +pub async fn handle_options( + garage: Arc<Garage>, + req: &Request<Body>, + bucket_id: Uuid, +) -> Result<Response<Body>, Error> { + let bucket = garage + .bucket_table + .get(&EmptyKey, &bucket_id) + .await? + .ok_or(Error::NoSuchBucket)?; + let origin = req + .headers() + .get("Origin") + .ok_or_bad_request("Missing Origin header")? + .to_str()?; + let request_method = req + .headers() + .get(ACCESS_CONTROL_REQUEST_METHOD) + .ok_or_bad_request("Missing Access-Control-Request-Method header")? + .to_str()?; + let request_headers = match req.headers().get(ACCESS_CONTROL_REQUEST_HEADERS) { + Some(h) => h.to_str()?.split(',').map(|h| h.trim()).collect::<Vec<_>>(), + None => vec![], + }; + + if let Some(cors_config) = bucket.params().unwrap().cors_config.get() { + let matching_rule = cors_config + .iter() + .find(|rule| cors_rule_matches(rule, origin, request_method, request_headers.iter())); + if let Some(rule) = matching_rule { + let mut resp = Response::builder() + .status(StatusCode::OK) + .body(Body::empty())?; + add_cors_headers(&mut resp, rule).ok_or_internal_error("Invalid CORS configuration")?; + return Ok(resp); + } + } + + Err(Error::Forbidden("This CORS request is not allowed.".into())) +} + +pub fn find_matching_cors_rule<'a>( + bucket: &'a Bucket, + req: &Request<Body>, +) -> Result<Option<&'a GarageCorsRule>, Error> { + if let Some(cors_config) = bucket.params().unwrap().cors_config.get() { + if let Some(origin) = req.headers().get("Origin") { + let origin = origin.to_str()?; + let request_headers = match req.headers().get(ACCESS_CONTROL_REQUEST_HEADERS) { + Some(h) => h.to_str()?.split(',').map(|h| h.trim()).collect::<Vec<_>>(), + None => vec![], + }; + return Ok(cors_config.iter().find(|rule| { + cors_rule_matches( + rule, + origin, + &req.method().to_string(), + request_headers.iter(), + ) + })); + } + } + Ok(None) +} + +fn cors_rule_matches<'a, HI, S>( + rule: &GarageCorsRule, + origin: &'a str, + method: &'a str, + mut request_headers: HI, +) -> bool +where + HI: Iterator<Item = S>, + S: AsRef<str>, +{ + rule.allow_origins.iter().any(|x| x == "*" || x == origin) + && rule.allow_methods.iter().any(|x| x == "*" || x == method) + && request_headers.all(|h| { + rule.allow_headers + .iter() + .any(|x| x == "*" || x == h.as_ref()) + }) +} + +pub fn add_cors_headers( + resp: &mut Response<Body>, + rule: &GarageCorsRule, +) -> Result<(), http::header::InvalidHeaderValue> { + let h = resp.headers_mut(); + h.insert( + ACCESS_CONTROL_ALLOW_ORIGIN, + rule.allow_origins.join(", ").parse()?, + ); + h.insert( + ACCESS_CONTROL_ALLOW_METHODS, + rule.allow_methods.join(", ").parse()?, + ); + h.insert( + ACCESS_CONTROL_ALLOW_HEADERS, + rule.allow_headers.join(", ").parse()?, + ); + h.insert( + ACCESS_CONTROL_EXPOSE_HEADERS, + rule.expose_headers.join(", ").parse()?, + ); + Ok(()) +} + +// ---- SERIALIZATION AND DESERIALIZATION TO/FROM S3 XML ---- + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename = "CORSConfiguration")] +pub struct CorsConfiguration { + #[serde(serialize_with = "xmlns_tag", skip_deserializing)] + pub xmlns: (), + #[serde(rename = "CORSRule")] + pub cors_rules: Vec<CorsRule>, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct CorsRule { + #[serde(rename = "ID")] + pub id: Option<Value>, + #[serde(rename = "MaxAgeSeconds")] + pub max_age_seconds: Option<IntValue>, + #[serde(rename = "AllowedOrigin")] + pub allowed_origins: Vec<Value>, + #[serde(rename = "AllowedMethod")] + pub allowed_methods: Vec<Value>, + #[serde(rename = "AllowedHeader", default)] + pub allowed_headers: Vec<Value>, + #[serde(rename = "ExposeHeader", default)] + pub expose_headers: Vec<Value>, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct AllowedMethod { + #[serde(rename = "AllowedMethod")] + pub allowed_method: Value, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct AllowedHeader { + #[serde(rename = "AllowedHeader")] + pub allowed_header: Value, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct ExposeHeader { + #[serde(rename = "ExposeHeader")] + pub expose_header: Value, +} + +impl CorsConfiguration { + pub fn validate(&self) -> Result<(), Error> { + for r in self.cors_rules.iter() { + r.validate()?; + } + Ok(()) + } + + pub fn into_garage_cors_config(self) -> Result<Vec<GarageCorsRule>, Error> { + Ok(self + .cors_rules + .iter() + .map(CorsRule::to_garage_cors_rule) + .collect()) + } +} + +impl CorsRule { + pub fn validate(&self) -> Result<(), Error> { + for method in self.allowed_methods.iter() { + method + .0 + .parse::<Method>() + .ok_or_bad_request("Invalid CORSRule method")?; + } + for header in self + .allowed_headers + .iter() + .chain(self.expose_headers.iter()) + { + header + .0 + .parse::<HeaderName>() + .ok_or_bad_request("Invalid HTTP header name")?; + } + Ok(()) + } + + pub fn to_garage_cors_rule(&self) -> GarageCorsRule { + let convert_vec = + |vval: &[Value]| vval.iter().map(|x| x.0.to_owned()).collect::<Vec<String>>(); + GarageCorsRule { + id: self.id.as_ref().map(|x| x.0.to_owned()), + max_age_seconds: self.max_age_seconds.as_ref().map(|x| x.0 as u64), + allow_origins: convert_vec(&self.allowed_origins), + allow_methods: convert_vec(&self.allowed_methods), + allow_headers: convert_vec(&self.allowed_headers), + expose_headers: convert_vec(&self.expose_headers), + } + } + + pub fn from_garage_cors_rule(rule: &GarageCorsRule) -> Self { + let convert_vec = |vval: &[String]| { + vval.iter() + .map(|x| Value(x.clone())) + .collect::<Vec<Value>>() + }; + Self { + id: rule.id.as_ref().map(|x| Value(x.clone())), + max_age_seconds: rule.max_age_seconds.map(|x| IntValue(x as i64)), + allowed_origins: convert_vec(&rule.allow_origins), + allowed_methods: convert_vec(&rule.allow_methods), + allowed_headers: convert_vec(&rule.allow_headers), + expose_headers: convert_vec(&rule.expose_headers), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use quick_xml::de::from_str; + + #[test] + fn test_deserialize() -> Result<(), Error> { + let message = r#"<?xml version="1.0" encoding="UTF-8"?> +<CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> + <CORSRule> + <AllowedOrigin>http://www.example.com</AllowedOrigin> + + <AllowedMethod>PUT</AllowedMethod> + <AllowedMethod>POST</AllowedMethod> + <AllowedMethod>DELETE</AllowedMethod> + + <AllowedHeader>*</AllowedHeader> + </CORSRule> + <CORSRule> + <AllowedOrigin>*</AllowedOrigin> + <AllowedMethod>GET</AllowedMethod> + </CORSRule> + <CORSRule> + <ID>qsdfjklm</ID> + <MaxAgeSeconds>12345</MaxAgeSeconds> + <AllowedOrigin>https://perdu.com</AllowedOrigin> + + <AllowedMethod>GET</AllowedMethod> + <AllowedMethod>DELETE</AllowedMethod> + <AllowedHeader>*</AllowedHeader> + <ExposeHeader>*</ExposeHeader> + </CORSRule> +</CORSConfiguration>"#; + let conf: CorsConfiguration = from_str(message).unwrap(); + let ref_value = CorsConfiguration { + xmlns: (), + cors_rules: vec![ + CorsRule { + id: None, + max_age_seconds: None, + allowed_origins: vec!["http://www.example.com".into()], + allowed_methods: vec!["PUT".into(), "POST".into(), "DELETE".into()], + allowed_headers: vec!["*".into()], + expose_headers: vec![], + }, + CorsRule { + id: None, + max_age_seconds: None, + allowed_origins: vec!["*".into()], + allowed_methods: vec!["GET".into()], + allowed_headers: vec![], + expose_headers: vec![], + }, + CorsRule { + id: Some("qsdfjklm".into()), + max_age_seconds: Some(IntValue(12345)), + allowed_origins: vec!["https://perdu.com".into()], + allowed_methods: vec!["GET".into(), "DELETE".into()], + allowed_headers: vec!["*".into()], + expose_headers: vec!["*".into()], + }, + ], + }; + assert_eq! { + ref_value, + conf + }; + + let message2 = to_xml_with_header(&ref_value)?; + + let cleanup = |c: &str| c.replace(char::is_whitespace, ""); + assert_eq!(cleanup(message), cleanup(&message2)); + + Ok(()) + } +} diff --git a/src/api/s3_router.rs b/src/api/s3_router.rs index 9add5e4a..51020a81 100644 --- a/src/api/s3_router.rs +++ b/src/api/s3_router.rs @@ -327,6 +327,7 @@ pub enum Endpoint { part_number_marker: Option<u64>, upload_id: String, }, + Options, PutBucketAccelerateConfiguration { }, PutBucketAcl { @@ -434,6 +435,10 @@ impl Endpoint { .unwrap_or((path.to_owned(), "")) }; + if *req.method() == Method::OPTIONS { + return Ok((Self::Options, Some(bucket))); + } + let key = percent_encoding::percent_decode_str(key) .decode_utf8()? .into_owned(); @@ -665,7 +670,6 @@ impl Endpoint { GetBucketAccelerateConfiguration, GetBucketAcl, GetBucketAnalyticsConfiguration, - GetBucketCors, GetBucketEncryption, GetBucketIntelligentTieringConfiguration, GetBucketInventoryConfiguration, @@ -711,6 +715,9 @@ impl Endpoint { GetBucketWebsite, PutBucketWebsite, DeleteBucketWebsite, + GetBucketCors, + PutBucketCors, + DeleteBucketCors, ] }; if readonly { @@ -1027,7 +1034,7 @@ mod tests { OWNER_DELETE "/" => DeleteBucket DELETE "/?analytics&id=list1" => DeleteBucketAnalyticsConfiguration DELETE "/?analytics&id=Id" => DeleteBucketAnalyticsConfiguration - DELETE "/?cors" => DeleteBucketCors + OWNER_DELETE "/?cors" => DeleteBucketCors DELETE "/?encryption" => DeleteBucketEncryption DELETE "/?intelligent-tiering&id=Id" => DeleteBucketIntelligentTieringConfiguration DELETE "/?inventory&id=list1" => DeleteBucketInventoryConfiguration @@ -1050,7 +1057,7 @@ mod tests { GET "/?accelerate" => GetBucketAccelerateConfiguration GET "/?acl" => GetBucketAcl GET "/?analytics&id=Id" => GetBucketAnalyticsConfiguration - GET "/?cors" => GetBucketCors + OWNER_GET "/?cors" => GetBucketCors GET "/?encryption" => GetBucketEncryption GET "/?intelligent-tiering&id=Id" => GetBucketIntelligentTieringConfiguration GET "/?inventory&id=list1" => GetBucketInventoryConfiguration @@ -1126,7 +1133,7 @@ mod tests { PUT "/?acl" => PutBucketAcl PUT "/?analytics&id=report1" => PutBucketAnalyticsConfiguration PUT "/?analytics&id=Id" => PutBucketAnalyticsConfiguration - PUT "/?cors" => PutBucketCors + OWNER_PUT "/?cors" => PutBucketCors PUT "/?encryption" => PutBucketEncryption PUT "/?intelligent-tiering&id=Id" => PutBucketIntelligentTieringConfiguration PUT "/?inventory&id=report1" => PutBucketInventoryConfiguration diff --git a/src/api/s3_website.rs b/src/api/s3_website.rs index c4a43e2c..d5864fc8 100644 --- a/src/api/s3_website.rs +++ b/src/api/s3_website.rs @@ -5,7 +5,7 @@ use hyper::{Body, Request, Response, StatusCode}; use serde::{Deserialize, Serialize}; use crate::error::*; -use crate::s3_xml::{xmlns_tag, IntValue, Value}; +use crate::s3_xml::{to_xml_with_header, xmlns_tag, IntValue, Value}; use crate::signature::verify_signed_content; use garage_model::bucket_table::*; @@ -39,7 +39,7 @@ pub async fn handle_get_website( redirect_all_requests_to: None, routing_rules: None, }; - let xml = quick_xml::se::to_string(&wc)?; + let xml = to_xml_with_header(&wc)?; Ok(Response::builder() .status(StatusCode::OK) .header(http::header::CONTENT_TYPE, "application/xml") @@ -306,7 +306,7 @@ mod tests { use quick_xml::de::from_str; #[test] - fn test_deserialize() { + fn test_deserialize() -> Result<(), Error> { let message = r#"<?xml version="1.0" encoding="UTF-8"?> <WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> <ErrorDocument> @@ -368,7 +368,12 @@ mod tests { ref_value, conf } - // TODO verify result is ok - // TODO cycle back and verify if ok + + let message2 = to_xml_with_header(&ref_value)?; + + let cleanup = |c: &str| c.replace(char::is_whitespace, ""); + assert_eq!(cleanup(message), cleanup(&message2)); + + Ok(()) } } diff --git a/src/api/s3_xml.rs b/src/api/s3_xml.rs index 7bbfa083..8a0dcee0 100644 --- a/src/api/s3_xml.rs +++ b/src/api/s3_xml.rs @@ -16,6 +16,12 @@ pub fn xmlns_tag<S: Serializer>(_v: &(), s: S) -> Result<S::Ok, S::Error> { #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct Value(#[serde(rename = "$value")] pub String); +impl From<&str> for Value { + fn from(s: &str) -> Value { + Value(s.to_string()) + } +} + #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] pub struct IntValue(#[serde(rename = "$value")] pub i64); diff --git a/src/model/bucket_table.rs b/src/model/bucket_table.rs index db7cec18..7c7b9f30 100644 --- a/src/model/bucket_table.rs +++ b/src/model/bucket_table.rs @@ -27,10 +27,7 @@ pub struct BucketParams { pub creation_date: u64, /// Map of key with access to the bucket, and what kind of access they give pub authorized_keys: crdt::Map<String, BucketKeyPerm>, - /// Whether this bucket is allowed for website access - /// (under all of its global alias names), - /// and if so, the website configuration XML document - pub website_config: crdt::Lww<Option<WebsiteConfig>>, + /// Map of aliases that are or have been given to this bucket /// in the global namespace /// (not authoritative: this is just used as an indication to @@ -40,6 +37,13 @@ pub struct BucketParams { /// in namespaces local to keys /// key = (access key id, alias name) pub local_aliases: crdt::LwwMap<(String, String), bool>, + + /// Whether this bucket is allowed for website access + /// (under all of its global alias names), + /// and if so, the website configuration XML document + pub website_config: crdt::Lww<Option<WebsiteConfig>>, + /// CORS rules + pub cors_config: crdt::Lww<Option<Vec<CorsRule>>>, } #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] @@ -48,15 +52,26 @@ pub struct WebsiteConfig { pub error_document: Option<String>, } +#[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] +pub struct CorsRule { + pub id: Option<String>, + pub max_age_seconds: Option<u64>, + pub allow_origins: Vec<String>, + pub allow_methods: Vec<String>, + pub allow_headers: Vec<String>, + pub expose_headers: Vec<String>, +} + impl BucketParams { /// Create an empty BucketParams with no authorized keys and no website accesss pub fn new() -> Self { BucketParams { creation_date: now_msec(), authorized_keys: crdt::Map::new(), - website_config: crdt::Lww::new(None), aliases: crdt::LwwMap::new(), local_aliases: crdt::LwwMap::new(), + website_config: crdt::Lww::new(None), + cors_config: crdt::Lww::new(None), } } } @@ -65,9 +80,12 @@ impl Crdt for BucketParams { fn merge(&mut self, o: &Self) { self.creation_date = std::cmp::min(self.creation_date, o.creation_date); self.authorized_keys.merge(&o.authorized_keys); - self.website_config.merge(&o.website_config); + self.aliases.merge(&o.aliases); self.local_aliases.merge(&o.local_aliases); + + self.website_config.merge(&o.website_config); + self.cors_config.merge(&o.cors_config); } } diff --git a/src/model/migrate.rs b/src/model/migrate.rs index 65140c4b..7e61957a 100644 --- a/src/model/migrate.rs +++ b/src/model/migrate.rs @@ -69,9 +69,10 @@ impl Migrate { state: Deletable::Present(BucketParams { creation_date: now_msec(), authorized_keys: Map::new(), - website_config: Lww::new(website), aliases: LwwMap::new(), local_aliases: LwwMap::new(), + website_config: Lww::new(website), + cors_config: Lww::new(None), }), }) .await?; diff --git a/src/util/crdt/lww.rs b/src/util/crdt/lww.rs index adb07711..254abe8e 100644 --- a/src/util/crdt/lww.rs +++ b/src/util/crdt/lww.rs @@ -125,3 +125,15 @@ where } } } + +impl<T> Default for Lww<T> +where + T: Default, +{ + fn default() -> Self { + Self { + ts: 0, + v: T::default(), + } + } +} diff --git a/src/web/web_server.rs b/src/web/web_server.rs index 834f31e8..491ffdd3 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -11,8 +11,9 @@ use hyper::{ use crate::error::*; -use garage_api::error::{Error as ApiError, OkOrBadRequest}; +use garage_api::error::{Error as ApiError, OkOrBadRequest, OkOrInternalError}; use garage_api::helpers::{authority_to_host, host_to_bucket}; +use garage_api::s3_cors::{add_cors_headers, find_matching_cors_rule}; use garage_api::s3_get::{handle_get, handle_head}; use garage_model::garage::Garage; @@ -138,63 +139,70 @@ async fn serve_file(garage: Arc<Garage>, req: &Request<Body>) -> Result<Response } .map_err(Error::from); - if let Err(error) = ret_doc { - if *req.method() == Method::HEAD || !error.http_status_code().is_client_error() { - // Do not return the error document in the following cases: - // - the error is not a 4xx error code - // - the request is a HEAD method - // In this case we just return the error code and the error message in the body, - // by relying on err_to_res that is called above when we return an Err. - return Err(error); - } + match ret_doc { + Err(error) => { + // For a HEAD method, and for non-4xx errors, + // we don't return the error document as content, + // we return above and just return the error message + // by relying on err_to_res that is called when we return an Err. + if *req.method() == Method::HEAD || !error.http_status_code().is_client_error() { + return Err(error); + } - // Same if no error document is set: just return the error directly - let error_document = match &website_config.error_document { - Some(ed) => ed.trim_start_matches('/').to_owned(), - None => return Err(error), - }; - - // We want to return the error document - // Create a fake HTTP request with path = the error document - let req2 = Request::builder() - .uri(format!("http://{}/{}", host, &error_document)) - .body(Body::empty()) - .unwrap(); - - match handle_get(garage, &req2, bucket_id, &error_document).await { - Ok(mut error_doc) => { - // The error won't be logged back in handle_request, - // so log it here - info!( - "{} {} {} {}", - req.method(), - req.uri(), - error.http_status_code(), - error - ); - - *error_doc.status_mut() = error.http_status_code(); - error.add_headers(error_doc.headers_mut()); - - // Preserve error message in a special header - for error_line in error.to_string().split('\n') { - if let Ok(v) = HeaderValue::from_bytes(error_line.as_bytes()) { - error_doc.headers_mut().append("X-Garage-Error", v); + // If no error document is set: just return the error directly + let error_document = match &website_config.error_document { + Some(ed) => ed.trim_start_matches('/').to_owned(), + None => return Err(error), + }; + + // We want to return the error document + // Create a fake HTTP request with path = the error document + let req2 = Request::builder() + .uri(format!("http://{}/{}", host, &error_document)) + .body(Body::empty()) + .unwrap(); + + match handle_get(garage, &req2, bucket_id, &error_document).await { + Ok(mut error_doc) => { + // The error won't be logged back in handle_request, + // so log it here + info!( + "{} {} {} {}", + req.method(), + req.uri(), + error.http_status_code(), + error + ); + + *error_doc.status_mut() = error.http_status_code(); + error.add_headers(error_doc.headers_mut()); + + // Preserve error message in a special header + for error_line in error.to_string().split('\n') { + if let Ok(v) = HeaderValue::from_bytes(error_line.as_bytes()) { + error_doc.headers_mut().append("X-Garage-Error", v); + } } - } - Ok(error_doc) + Ok(error_doc) + } + Err(error_doc_error) => { + warn!( + "Couldn't get error document {} for bucket {:?}: {}", + error_document, bucket_id, error_doc_error + ); + Err(error) + } } - Err(error_doc_error) => { - warn!( - "Couldn't get error document {} for bucket {:?}: {}", - error_document, bucket_id, error_doc_error - ); - Err(error) + } + Ok(mut resp) => { + // Maybe add CORS headers + if let Some(rule) = find_matching_cors_rule(&bucket, req)? { + add_cors_headers(&mut resp, rule) + .ok_or_internal_error("Invalid bucket CORS configuration")?; } + Ok(resp) } - } else { - ret_doc } } |