Implement CORS #183

Merged
quentin merged 2 commits from bucket-cors into main 2022-01-24 17:04:07 +00:00
12 changed files with 566 additions and 86 deletions

View file

@ -9,7 +9,8 @@ Implemented:
- putting and getting objects in buckets - putting and getting objects in buckets
- multipart uploads - multipart uploads
- listing objects - 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: Not implemented:
@ -31,9 +32,11 @@ All APIs that are not mentionned are not implemented and will return a 501 Not I
| CreateBucket | Implemented | | CreateBucket | Implemented |
| CreateMultipartUpload | Implemented | | CreateMultipartUpload | Implemented |
| DeleteBucket | Implemented | | DeleteBucket | Implemented |
| DeleteBucketCors | Implemented |
| DeleteBucketWebsite | Implemented | | DeleteBucketWebsite | Implemented |
| DeleteObject | Implemented | | DeleteObject | Implemented |
| DeleteObjects | Implemented | | DeleteObjects | Implemented |
| GetBucketCors | Implemented |
| GetBucketLocation | Implemented | | GetBucketLocation | Implemented |
| GetBucketVersioning | Stub (see below) | | GetBucketVersioning | Stub (see below) |
| GetBucketWebsite | Implemented | | GetBucketWebsite | Implemented |
@ -46,6 +49,7 @@ All APIs that are not mentionned are not implemented and will return a 501 Not I
| ListMultipartUpload | Implemented | | ListMultipartUpload | Implemented |
| ListParts | Implemented | | ListParts | Implemented |
| PutObject | Implemented | | PutObject | Implemented |
| PutBucketCors | Implemented |
| PutBucketWebsite | Partially implemented (see below)| | PutBucketWebsite | Partially implemented (see below)|
| UploadPart | Implemented | | UploadPart | Implemented |
| UploadPartCopy | Implemented | | UploadPartCopy | Implemented |

View file

@ -26,10 +26,10 @@ your motivations for doing so in the PR message.
| | UploadPart | | | UploadPart |
| | [*ListMultipartUploads*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/103) | | | [*ListMultipartUploads*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/103) |
| | [*ListParts*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/103) | | | [*ListParts*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/103) |
| **A-tier** (will implement) | | | **A-tier** | |
| | [*GetBucketCors*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/138) | | | GetBucketCors |
| | [*PutBucketCors*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/138) | | | PutBucketCors |
| | [*DeleteBucketCors*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/138) | | | DeleteBucketCors |
| | UploadPartCopy | | | UploadPartCopy |
| | GetBucketWebsite | | | GetBucketWebsite |
| | PutBucketWebsite | | | PutBucketWebsite |

View file

@ -5,7 +5,7 @@ use futures::future::Future;
use hyper::header; use hyper::header;
use hyper::server::conn::AddrStream; use hyper::server::conn::AddrStream;
use hyper::service::{make_service_fn, service_fn}; 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::data::*;
use garage_util::error::Error as GarageError; 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::garage::Garage;
use garage_model::key_table::Key; use garage_model::key_table::Key;
use garage_table::util::*;
use crate::error::*; use crate::error::*;
use crate::signature::payload::check_payload_signature; use crate::signature::payload::check_payload_signature;
use crate::helpers::*; use crate::helpers::*;
use crate::s3_bucket::*; use crate::s3_bucket::*;
use crate::s3_copy::*; use crate::s3_copy::*;
use crate::s3_cors::*;
use crate::s3_delete::*; use crate::s3_delete::*;
use crate::s3_get::*; use crate::s3_get::*;
use crate::s3_list::*; 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 host = authority_to_host(authority)?;
let bucket = garage let bucket_name = garage
.config .config
.s3_api .s3_api
.root_domain .root_domain
.as_ref() .as_ref()
.and_then(|root_domain| host_to_bucket(&host, root_domain)); .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); 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, None => return handle_request_without_bucket(garage, req, api_key, endpoint).await,
Some(bucket) => bucket.to_string(), 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_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() { let allowed = match endpoint.authorization_type() {
Authorization::Read => api_key.allow_read(&bucket_id), 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(&req, &bucket).await,
Endpoint::HeadObject { key, .. } => handle_head(garage, &req, bucket_id, &key).await, Endpoint::HeadObject { key, .. } => handle_head(garage, &req, bucket_id, &key).await,
Endpoint::GetObject { key, .. } => handle_get(garage, &req, bucket_id, &key).await, Endpoint::GetObject { key, .. } => handle_get(garage, &req, bucket_id, &key).await,
Endpoint::UploadPart { Endpoint::UploadPart {
@ -315,13 +334,26 @@ async fn handler_inner(garage: Arc<Garage>, req: Request<Body>) -> Result<Respon
Endpoint::DeleteObjects {} => { Endpoint::DeleteObjects {} => {
handle_delete_objects(garage, bucket_id, req, content_sha256).await handle_delete_objects(garage, bucket_id, req, content_sha256).await
} }
Endpoint::GetBucketWebsite {} => handle_get_website(garage, bucket_id).await, Endpoint::GetBucketWebsite {} => handle_get_website(&bucket).await,
Endpoint::PutBucketWebsite {} => { Endpoint::PutBucketWebsite {} => {
handle_put_website(garage, bucket_id, req, content_sha256).await handle_put_website(garage, bucket_id, req, content_sha256).await
} }
Endpoint::DeleteBucketWebsite {} => handle_delete_website(garage, bucket_id).await, Endpoint::DeleteBucketWebsite {} => handle_delete_website(garage, bucket_id).await,
Endpoint::GetBucketCors {} => handle_get_cors(&bucket).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())), 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( async fn handle_request_without_bucket(

View file

@ -15,6 +15,7 @@ mod signature;
pub mod helpers; pub mod helpers;
mod s3_bucket; mod s3_bucket;
mod s3_copy; mod s3_copy;
pub mod s3_cors;
mod s3_delete; mod s3_delete;
pub mod s3_get; pub mod s3_get;
mod s3_list; mod s3_list;

391
src/api/s3_cors.rs Normal file
View file

@ -0,0 +1,391 @@
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(bucket: &Bucket) -> Result<Response<Body>, Error> {
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(req: &Request<Body>, bucket: &Bucket) -> 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())?;
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(())
}
}

View file

@ -327,6 +327,7 @@ pub enum Endpoint {
part_number_marker: Option<u64>, part_number_marker: Option<u64>,
upload_id: String, upload_id: String,
}, },
Options,
PutBucketAccelerateConfiguration { PutBucketAccelerateConfiguration {
}, },
PutBucketAcl { PutBucketAcl {
@ -434,6 +435,10 @@ impl Endpoint {
.unwrap_or((path.to_owned(), "")) .unwrap_or((path.to_owned(), ""))
}; };
if *req.method() == Method::OPTIONS {
return Ok((Self::Options, Some(bucket)));
}
let key = percent_encoding::percent_decode_str(key) let key = percent_encoding::percent_decode_str(key)
.decode_utf8()? .decode_utf8()?
.into_owned(); .into_owned();
@ -665,7 +670,6 @@ impl Endpoint {
GetBucketAccelerateConfiguration, GetBucketAccelerateConfiguration,
GetBucketAcl, GetBucketAcl,
GetBucketAnalyticsConfiguration, GetBucketAnalyticsConfiguration,
GetBucketCors,
GetBucketEncryption, GetBucketEncryption,
GetBucketIntelligentTieringConfiguration, GetBucketIntelligentTieringConfiguration,
GetBucketInventoryConfiguration, GetBucketInventoryConfiguration,
@ -711,6 +715,9 @@ impl Endpoint {
GetBucketWebsite, GetBucketWebsite,
PutBucketWebsite, PutBucketWebsite,
DeleteBucketWebsite, DeleteBucketWebsite,
GetBucketCors,
PutBucketCors,
DeleteBucketCors,
] ]
}; };
if readonly { if readonly {
@ -1027,7 +1034,7 @@ mod tests {
OWNER_DELETE "/" => DeleteBucket OWNER_DELETE "/" => DeleteBucket
DELETE "/?analytics&id=list1" => DeleteBucketAnalyticsConfiguration DELETE "/?analytics&id=list1" => DeleteBucketAnalyticsConfiguration
DELETE "/?analytics&id=Id" => DeleteBucketAnalyticsConfiguration DELETE "/?analytics&id=Id" => DeleteBucketAnalyticsConfiguration
DELETE "/?cors" => DeleteBucketCors OWNER_DELETE "/?cors" => DeleteBucketCors
DELETE "/?encryption" => DeleteBucketEncryption DELETE "/?encryption" => DeleteBucketEncryption
DELETE "/?intelligent-tiering&id=Id" => DeleteBucketIntelligentTieringConfiguration DELETE "/?intelligent-tiering&id=Id" => DeleteBucketIntelligentTieringConfiguration
DELETE "/?inventory&id=list1" => DeleteBucketInventoryConfiguration DELETE "/?inventory&id=list1" => DeleteBucketInventoryConfiguration
@ -1050,7 +1057,7 @@ mod tests {
GET "/?accelerate" => GetBucketAccelerateConfiguration GET "/?accelerate" => GetBucketAccelerateConfiguration
GET "/?acl" => GetBucketAcl GET "/?acl" => GetBucketAcl
GET "/?analytics&id=Id" => GetBucketAnalyticsConfiguration GET "/?analytics&id=Id" => GetBucketAnalyticsConfiguration
GET "/?cors" => GetBucketCors OWNER_GET "/?cors" => GetBucketCors
GET "/?encryption" => GetBucketEncryption GET "/?encryption" => GetBucketEncryption
GET "/?intelligent-tiering&id=Id" => GetBucketIntelligentTieringConfiguration GET "/?intelligent-tiering&id=Id" => GetBucketIntelligentTieringConfiguration
GET "/?inventory&id=list1" => GetBucketInventoryConfiguration GET "/?inventory&id=list1" => GetBucketInventoryConfiguration
@ -1126,7 +1133,7 @@ mod tests {
PUT "/?acl" => PutBucketAcl PUT "/?acl" => PutBucketAcl
PUT "/?analytics&id=report1" => PutBucketAnalyticsConfiguration PUT "/?analytics&id=report1" => PutBucketAnalyticsConfiguration
PUT "/?analytics&id=Id" => PutBucketAnalyticsConfiguration PUT "/?analytics&id=Id" => PutBucketAnalyticsConfiguration
PUT "/?cors" => PutBucketCors OWNER_PUT "/?cors" => PutBucketCors
PUT "/?encryption" => PutBucketEncryption PUT "/?encryption" => PutBucketEncryption
PUT "/?intelligent-tiering&id=Id" => PutBucketIntelligentTieringConfiguration PUT "/?intelligent-tiering&id=Id" => PutBucketIntelligentTieringConfiguration
PUT "/?inventory&id=report1" => PutBucketInventoryConfiguration PUT "/?inventory&id=report1" => PutBucketInventoryConfiguration

View file

@ -5,7 +5,7 @@ use hyper::{Body, Request, Response, StatusCode};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::error::*; 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 crate::signature::verify_signed_content;
use garage_model::bucket_table::*; use garage_model::bucket_table::*;
@ -13,16 +13,7 @@ use garage_model::garage::Garage;
use garage_table::*; use garage_table::*;
use garage_util::data::*; use garage_util::data::*;
pub async fn handle_get_website( pub async fn handle_get_website(bucket: &Bucket) -> Result<Response<Body>, Error> {
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 let param = bucket
.params() .params()
.ok_or_internal_error("Bucket should not be deleted at this point")?; .ok_or_internal_error("Bucket should not be deleted at this point")?;
@ -39,7 +30,7 @@ pub async fn handle_get_website(
redirect_all_requests_to: None, redirect_all_requests_to: None,
routing_rules: None, routing_rules: None,
}; };
let xml = quick_xml::se::to_string(&wc)?; let xml = to_xml_with_header(&wc)?;
Ok(Response::builder() Ok(Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.header(http::header::CONTENT_TYPE, "application/xml") .header(http::header::CONTENT_TYPE, "application/xml")
@ -306,7 +297,7 @@ mod tests {
use quick_xml::de::from_str; use quick_xml::de::from_str;
#[test] #[test]
fn test_deserialize() { fn test_deserialize() -> Result<(), Error> {
let message = r#"<?xml version="1.0" encoding="UTF-8"?> let message = r#"<?xml version="1.0" encoding="UTF-8"?>
<WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/"> <WebsiteConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
<ErrorDocument> <ErrorDocument>
@ -368,7 +359,12 @@ mod tests {
ref_value, ref_value,
conf 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(())
} }
} }

View file

@ -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)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct Value(#[serde(rename = "$value")] pub String); 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)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct IntValue(#[serde(rename = "$value")] pub i64); pub struct IntValue(#[serde(rename = "$value")] pub i64);

View file

@ -27,10 +27,7 @@ pub struct BucketParams {
pub creation_date: u64, pub creation_date: u64,
/// Map of key with access to the bucket, and what kind of access they give /// Map of key with access to the bucket, and what kind of access they give
pub authorized_keys: crdt::Map<String, BucketKeyPerm>, 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 /// Map of aliases that are or have been given to this bucket
/// in the global namespace /// in the global namespace
/// (not authoritative: this is just used as an indication to /// (not authoritative: this is just used as an indication to
@ -40,6 +37,13 @@ pub struct BucketParams {
/// in namespaces local to keys /// in namespaces local to keys
/// key = (access key id, alias name) /// key = (access key id, alias name)
pub local_aliases: crdt::LwwMap<(String, String), bool>, 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)] #[derive(PartialEq, Eq, Clone, Debug, Serialize, Deserialize)]
@ -48,15 +52,26 @@ pub struct WebsiteConfig {
pub error_document: Option<String>, 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 { impl BucketParams {
/// Create an empty BucketParams with no authorized keys and no website accesss /// Create an empty BucketParams with no authorized keys and no website accesss
pub fn new() -> Self { pub fn new() -> Self {
BucketParams { BucketParams {
creation_date: now_msec(), creation_date: now_msec(),
authorized_keys: crdt::Map::new(), authorized_keys: crdt::Map::new(),
website_config: crdt::Lww::new(None),
aliases: crdt::LwwMap::new(), aliases: crdt::LwwMap::new(),
local_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) { fn merge(&mut self, o: &Self) {
self.creation_date = std::cmp::min(self.creation_date, o.creation_date); self.creation_date = std::cmp::min(self.creation_date, o.creation_date);
self.authorized_keys.merge(&o.authorized_keys); self.authorized_keys.merge(&o.authorized_keys);
self.website_config.merge(&o.website_config);
self.aliases.merge(&o.aliases); self.aliases.merge(&o.aliases);
self.local_aliases.merge(&o.local_aliases); self.local_aliases.merge(&o.local_aliases);
self.website_config.merge(&o.website_config);
self.cors_config.merge(&o.cors_config);
} }
} }

View file

@ -69,9 +69,10 @@ impl Migrate {
state: Deletable::Present(BucketParams { state: Deletable::Present(BucketParams {
creation_date: now_msec(), creation_date: now_msec(),
authorized_keys: Map::new(), authorized_keys: Map::new(),
website_config: Lww::new(website),
aliases: LwwMap::new(), aliases: LwwMap::new(),
local_aliases: LwwMap::new(), local_aliases: LwwMap::new(),
website_config: Lww::new(website),
cors_config: Lww::new(None),
}), }),
}) })
.await?; .await?;

View file

@ -125,3 +125,15 @@ where
} }
} }
} }
impl<T> Default for Lww<T>
where
T: Default,
{
fn default() -> Self {
Self {
ts: 0,
v: T::default(),
}
}
}

View file

@ -11,8 +11,9 @@ use hyper::{
use crate::error::*; 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::helpers::{authority_to_host, host_to_bucket};
use garage_api::s3_cors::{add_cors_headers, find_matching_cors_rule, handle_options};
use garage_api::s3_get::{handle_get, handle_head}; use garage_api::s3_get::{handle_get, handle_head};
use garage_model::garage::Garage; use garage_model::garage::Garage;
@ -132,23 +133,27 @@ async fn serve_file(garage: Arc<Garage>, req: &Request<Body>) -> Result<Response
); );
let ret_doc = match *req.method() { let ret_doc = match *req.method() {
Method::OPTIONS => handle_options(req, &bucket).await,
Method::HEAD => handle_head(garage.clone(), req, bucket_id, &key).await, Method::HEAD => handle_head(garage.clone(), req, bucket_id, &key).await,
Method::GET => handle_get(garage.clone(), req, bucket_id, &key).await, Method::GET => handle_get(garage.clone(), req, bucket_id, &key).await,
_ => Err(ApiError::BadRequest("HTTP method not supported".into())), _ => Err(ApiError::BadRequest("HTTP method not supported".into())),
} }
.map_err(Error::from); .map_err(Error::from);
if let Err(error) = ret_doc { match ret_doc {
if *req.method() == Method::HEAD || !error.http_status_code().is_client_error() { Err(error) => {
// Do not return the error document in the following cases: // For a HEAD or OPTIONS method, and for non-4xx errors,
// - the error is not a 4xx error code // we don't return the error document as content,
// - the request is a HEAD method // we return above and just return the error message
// 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 when we return an Err.
// by relying on err_to_res that is called above when we return an Err. if *req.method() == Method::HEAD
|| *req.method() == Method::OPTIONS
|| !error.http_status_code().is_client_error()
{
return Err(error); return Err(error);
} }
// Same if no error document is set: just return the error directly // If no error document is set: just return the error directly
let error_document = match &website_config.error_document { let error_document = match &website_config.error_document {
Some(ed) => ed.trim_start_matches('/').to_owned(), Some(ed) => ed.trim_start_matches('/').to_owned(),
None => return Err(error), None => return Err(error),
@ -193,8 +198,15 @@ async fn serve_file(garage: Arc<Garage>, req: &Request<Body>) -> Result<Response
Err(error) Err(error)
} }
} }
} else { }
ret_doc 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)
}
} }
} }