diff options
author | Alex Auvolat <alex@adnab.me> | 2022-01-07 16:23:04 +0100 |
---|---|---|
committer | Alex Auvolat <alex@adnab.me> | 2022-01-13 17:27:16 +0100 |
commit | bed3106c6a4cbdc7257eb88060fc04ec84340ee4 (patch) | |
tree | 6237fb5a7a49208c3c990b1b7b3955d35124da19 | |
parent | 60c0033c8bbd3dbc18f8c91f15674a60fd8acdc0 (diff) | |
download | garage-bed3106c6a4cbdc7257eb88060fc04ec84340ee4.tar.gz garage-bed3106c6a4cbdc7257eb88060fc04ec84340ee4.zip |
Implement {Put,Get,Delete}BucketCors and CORS in web server
-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 | 6 | ||||
-rw-r--r-- | src/api/lib.rs | 1 | ||||
-rw-r--r-- | src/api/s3_cors.rs | 339 | ||||
-rw-r--r-- | src/api/s3_router.rs | 10 | ||||
-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 | 31 | ||||
-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 | 175 |
12 files changed, 537 insertions, 75 deletions
diff --git a/doc/book/src/reference_manual/s3_compatibility.md b/doc/book/src/reference_manual/s3_compatibility.md index 5c2e6315..b303a306 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 | Missing | | 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 ea1990d9..2563d375 100644 --- a/src/api/api_server.rs +++ b/src/api/api_server.rs @@ -20,6 +20,7 @@ use crate::signature::check_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::*; @@ -310,6 +311,11 @@ 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())), } } 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..c3329085 --- /dev/null +++ b/src/api/s3_cors.rs @@ -0,0 +1,339 @@ +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, +}; +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::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?; + 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())?) +} + +#[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), + } + } +} + +pub 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(()) +} + +#[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 a8ac0086..24a3fbfd 100644 --- a/src/api/s3_router.rs +++ b/src/api/s3_router.rs @@ -773,7 +773,6 @@ impl Endpoint { GetBucketAccelerateConfiguration, GetBucketAcl, GetBucketAnalyticsConfiguration, - GetBucketCors, GetBucketEncryption, GetBucketIntelligentTieringConfiguration, GetBucketInventoryConfiguration, @@ -821,6 +820,9 @@ impl Endpoint { GetBucketWebsite, PutBucketWebsite, DeleteBucketWebsite, + GetBucketCors, + PutBucketCors, + DeleteBucketCors, ] } .is_some(); @@ -1134,7 +1136,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 @@ -1157,7 +1159,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 @@ -1233,7 +1235,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 fcf8cba3..6dcb59c8 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") @@ -303,7 +303,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> @@ -365,7 +365,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 1df4ed60..0532f3d1 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..123ed16b 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,14 @@ 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 + #[serde(default)] + pub cors_config: crdt::Lww<Option<Vec<CorsRule>>>, } #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)] @@ -48,15 +53,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 +81,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..bf330e85 100644 --- a/src/web/web_server.rs +++ b/src/web/web_server.rs @@ -2,19 +2,22 @@ use std::{borrow::Cow, convert::Infallible, net::SocketAddr, sync::Arc}; use futures::future::Future; +use http::header::{ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD}; use hyper::{ header::{HeaderValue, HOST}, server::conn::AddrStream, service::{make_service_fn, service_fn}, - Body, Method, Request, Response, Server, + Body, Method, Request, Response, Server, StatusCode, }; 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, cors_rule_matches}; use garage_api::s3_get::{handle_get, handle_head}; +use garage_model::bucket_table::Bucket; use garage_model::garage::Garage; use garage_table::*; @@ -132,72 +135,136 @@ async fn serve_file(garage: Arc<Garage>, req: &Request<Body>) -> Result<Response ); let ret_doc = match *req.method() { - Method::HEAD => handle_head(garage.clone(), req, bucket_id, &key).await, + Method::OPTIONS => return handle_options(&bucket, req), + Method::HEAD => { + return handle_head(garage.clone(), req, bucket_id, &key) + .await + .map_err(Error::from) + } Method::GET => handle_get(garage.clone(), req, bucket_id, &key).await, _ => Err(ApiError::BadRequest("HTTP method not supported".into())), } .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 or OPTIONS method, 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. + assert!(*req.method() != Method::HEAD && *req.method() != Method::OPTIONS); + + if !error.http_status_code().is_client_error() { + // Do not return the error document if it is not a 4xx error code. + 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(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![], + }; + let matching_rule = cors_config.iter().find(|rule| { + cors_rule_matches( + rule, + origin, + &req.method().to_string(), + request_headers.iter(), + ) + }); + if let Some(rule) = matching_rule { + add_cors_headers(&mut resp, rule) + .ok_or_internal_error("Invalid CORS configuration")?; + } + } } + Ok(resp) } - } else { - ret_doc } } +fn handle_options(bucket: &Bucket, req: &Request<Body>) -> Result<Response<Body>, Error> { + 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()) + .map_err(ApiError::from)?; + add_cors_headers(&mut resp, rule).ok_or_internal_error("Invalid CORS configuration")?; + return Ok(resp); + } + } + + Err(ApiError::Forbidden("No matching CORS rule".into()).into()) +} + /// Path to key /// /// Convert the provided path to the internal key |