aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAlex Auvolat <alex@adnab.me>2022-01-07 16:23:04 +0100
committerAlex Auvolat <alex@adnab.me>2022-01-13 17:27:16 +0100
commitbed3106c6a4cbdc7257eb88060fc04ec84340ee4 (patch)
tree6237fb5a7a49208c3c990b1b7b3955d35124da19
parent60c0033c8bbd3dbc18f8c91f15674a60fd8acdc0 (diff)
downloadgarage-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.md6
-rw-r--r--doc/book/src/working_documents/compatibility_target.md8
-rw-r--r--src/api/api_server.rs6
-rw-r--r--src/api/lib.rs1
-rw-r--r--src/api/s3_cors.rs339
-rw-r--r--src/api/s3_router.rs10
-rw-r--r--src/api/s3_website.rs15
-rw-r--r--src/api/s3_xml.rs6
-rw-r--r--src/model/bucket_table.rs31
-rw-r--r--src/model/migrate.rs3
-rw-r--r--src/util/crdt/lww.rs12
-rw-r--r--src/web/web_server.rs175
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