Make use of bucket website configuration #182

Merged
lx merged 4 commits from improve-website-config into main 2022-01-17 09:47:58 +00:00
9 changed files with 172 additions and 55 deletions

View file

@ -36,7 +36,7 @@ All APIs that are not mentionned are not implemented and will return a 501 Not I
| DeleteObjects | Implemented | | DeleteObjects | Implemented |
| GetBucketLocation | Implemented | | GetBucketLocation | Implemented |
| GetBucketVersioning | Stub (see below) | | GetBucketVersioning | Stub (see below) |
| GetBucketWebsite | Unsupported | | GetBucketWebsite | Implemented |
| GetObject | Implemented | | GetObject | Implemented |
| HeadBucket | Implemented | | HeadBucket | Implemented |
| HeadObject | Implemented | | HeadObject | Implemented |
@ -56,6 +56,5 @@ All APIs that are not mentionned are not implemented and will return a 501 Not I
- **ListObjects:** Implemented, but there isn't a very good specification of what `encoding-type=url` covers so there might be some encoding bugs. In our implementation the url-encoded fields are in the same in ListObjects as they are in ListObjectsV2. - **ListObjects:** Implemented, but there isn't a very good specification of what `encoding-type=url` covers so there might be some encoding bugs. In our implementation the url-encoded fields are in the same in ListObjects as they are in ListObjectsV2.
- **PutBucketWebsite:** Implemented, but only store if website is enabled, not more complexe informations. - **PutBucketWebsite:** Implemented, but only stores the index document suffix and the error document path. Redirects are not supported.
- **GetBucketWebsite:** Not implemented yet, will be when PubBucketWebsite store more informations.

View file

@ -31,8 +31,8 @@ your motivations for doing so in the PR message.
| | [*PutBucketCors*](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) | | | [*DeleteBucketCors*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/138) |
| | UploadPartCopy | | | UploadPartCopy |
| | [*GetBucketWebsite*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/77) | | | GetBucketWebsite |
| | [*PutBucketWebsite*](https://git.deuxfleurs.fr/Deuxfleurs/garage/issues/77) | | | PutBucketWebsite |
| | DeleteBucketWebsite | | | DeleteBucketWebsite |
| ~~~~~~~~~~~~~~~~~~~~~~~~~~ | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | | ~~~~~~~~~~~~~~~~~~~~~~~~~~ | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ |
| **B-tier** | | | **B-tier** | |

View file

@ -305,6 +305,7 @@ 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::PutBucketWebsite { .. } => { Endpoint::PutBucketWebsite { .. } => {
handle_put_website(garage, bucket_id, req, content_sha256).await handle_put_website(garage, bucket_id, req, content_sha256).await
} }

View file

@ -2,7 +2,7 @@
#[macro_use] #[macro_use]
extern crate log; extern crate log;
mod error; pub mod error;
pub use error::Error; pub use error::Error;
mod encoding; mod encoding;

View file

@ -11,9 +11,46 @@ use crate::signature::verify_signed_content;
use garage_model::bucket_table::*; use garage_model::bucket_table::*;
use garage_model::garage::Garage; use garage_model::garage::Garage;
use garage_table::*; use garage_table::*;
use garage_util::crdt;
use garage_util::data::*; use garage_util::data::*;
pub async fn handle_get_website(
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(website) = param.website_config.get() {
let wc = WebsiteConfiguration {
xmlns: (),
error_document: website.error_document.as_ref().map(|v| Key {
key: Value(v.to_string()),
}),
index_document: Some(Suffix {
suffix: Value(website.index_document.to_string()),
}),
redirect_all_requests_to: None,
routing_rules: None,
};
let xml = quick_xml::se::to_string(&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_website( pub async fn handle_delete_website(
garage: Arc<Garage>, garage: Arc<Garage>,
bucket_id: Uuid, bucket_id: Uuid,
@ -24,17 +61,16 @@ pub async fn handle_delete_website(
.await? .await?
.ok_or(Error::NoSuchBucket)?; .ok_or(Error::NoSuchBucket)?;
if let crdt::Deletable::Present(param) = &mut bucket.state { let param = bucket
param.website_config.update(None); .params_mut()
garage.bucket_table.insert(&bucket).await?; .ok_or_internal_error("Bucket should not be deleted at this point")?;
} else {
unreachable!(); param.website_config.update(None);
} garage.bucket_table.insert(&bucket).await?;
Ok(Response::builder() Ok(Response::builder()
.status(StatusCode::NO_CONTENT) .status(StatusCode::NO_CONTENT)
.body(Body::from(vec![])) .body(Body::empty())?)
.unwrap())
} }
pub async fn handle_put_website( pub async fn handle_put_website(
@ -52,22 +88,21 @@ pub async fn handle_put_website(
.await? .await?
.ok_or(Error::NoSuchBucket)?; .ok_or(Error::NoSuchBucket)?;
let param = bucket
.params_mut()
.ok_or_internal_error("Bucket should not be deleted at this point")?;
let conf: WebsiteConfiguration = from_reader(&body as &[u8])?; let conf: WebsiteConfiguration = from_reader(&body as &[u8])?;
conf.validate()?; conf.validate()?;
if let crdt::Deletable::Present(param) = &mut bucket.state { param
param .website_config
.website_config .update(Some(conf.into_garage_website_config()?));
.update(Some(conf.into_garage_website_config()?)); garage.bucket_table.insert(&bucket).await?;
garage.bucket_table.insert(&bucket).await?;
} else {
unreachable!();
}
Ok(Response::builder() Ok(Response::builder()
.status(StatusCode::OK) .status(StatusCode::OK)
.body(Body::from(vec![])) .body(Body::empty())?)
.unwrap())
} }
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] #[derive(Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]

View file

@ -404,8 +404,8 @@ impl AdminRpcHandler {
let website = if query.allow { let website = if query.allow {
Some(WebsiteConfig { Some(WebsiteConfig {
index_document: "index.html".into(), index_document: query.index_document.clone(),
error_document: None, error_document: query.error_document.clone(),
}) })
} else { } else {
None None

View file

@ -188,6 +188,14 @@ pub struct WebsiteOpt {
/// Bucket name /// Bucket name
pub bucket: String, pub bucket: String,
/// Index document: the suffix appended to request paths ending by /
#[structopt(short = "i", long = "index-document", default_value = "index.html")]
pub index_document: String,
/// Error document: the optionnal document returned when an error occurs
#[structopt(short = "e", long = "error-document")]
pub error_document: Option<String>,
} }
#[derive(Serialize, Deserialize, StructOpt, Debug)] #[derive(Serialize, Deserialize, StructOpt, Debug)]

View file

@ -87,8 +87,6 @@ pub struct WebConfig {
pub bind_addr: SocketAddr, pub bind_addr: SocketAddr,
/// Suffix to remove from domain name to find bucket /// Suffix to remove from domain name to find bucket
pub root_domain: String, pub root_domain: String,
/// Suffix to add when user-agent request path end with "/"
pub index: String,
} }
fn default_sled_cache_capacity() -> u64 { fn default_sled_cache_capacity() -> u64 {

View file

@ -3,7 +3,7 @@ use std::{borrow::Cow, convert::Infallible, net::SocketAddr, sync::Arc};
use futures::future::Future; use futures::future::Future;
use hyper::{ use hyper::{
header::HOST, header::{HeaderValue, HOST},
server::conn::AddrStream, server::conn::AddrStream,
service::{make_service_fn, service_fn}, service::{make_service_fn, service_fn},
Body, Method, Request, Response, Server, Body, Method, Request, Response, Server,
@ -11,10 +11,10 @@ use hyper::{
use crate::error::*; use crate::error::*;
use garage_api::error::{Error as ApiError, OkOrBadRequest};
use garage_api::helpers::{authority_to_host, host_to_bucket}; use garage_api::helpers::{authority_to_host, host_to_bucket};
use garage_api::s3_get::{handle_get, handle_head}; use garage_api::s3_get::{handle_get, handle_head};
use garage_model::bucket_table::Bucket;
use garage_model::garage::Garage; use garage_model::garage::Garage;
use garage_table::*; use garage_table::*;
@ -52,29 +52,45 @@ async fn handle_request(
addr: SocketAddr, addr: SocketAddr,
) -> Result<Response<Body>, Infallible> { ) -> Result<Response<Body>, Infallible> {
info!("{} {} {}", addr, req.method(), req.uri()); info!("{} {} {}", addr, req.method(), req.uri());
let res = serve_file(garage, req).await; match serve_file(garage, &req).await {
match &res { Ok(res) => {
Ok(r) => debug!("{} {:?}", r.status(), r.headers()), debug!("{} {} {}", req.method(), req.uri(), res.status());
Err(e) => warn!("Response: error {}, {}", e.http_status_code(), e), Ok(res)
}
Err(error) => {
info!(
"{} {} {} {}",
req.method(),
req.uri(),
error.http_status_code(),
error
);
Ok(error_to_res(error))
}
} }
Ok(res.unwrap_or_else(error_to_res))
} }
fn error_to_res(e: Error) -> Response<Body> { fn error_to_res(e: Error) -> Response<Body> {
let body: Body = Body::from(format!("{}\n", e)); // If we are here, it is either that:
// - there was an error before trying to get the requested URL
// from the bucket (e.g. bucket not found)
// - there was an error processing the request and (the request
// was a HEAD request or we couldn't get the error document)
// We do NOT enter this code path when returning the bucket's
// error document (this is handled in serve_file)
let body = Body::from(format!("{}\n", e));
let mut http_error = Response::new(body); let mut http_error = Response::new(body);
*http_error.status_mut() = e.http_status_code(); *http_error.status_mut() = e.http_status_code();
e.add_headers(http_error.headers_mut()); e.add_headers(http_error.headers_mut());
http_error http_error
} }
async fn serve_file(garage: Arc<Garage>, req: Request<Body>) -> Result<Response<Body>, Error> { async fn serve_file(garage: Arc<Garage>, req: &Request<Body>) -> Result<Response<Body>, Error> {
// Get http authority string (eg. [::1]:3902 or garage.tld:80) // Get http authority string (eg. [::1]:3902 or garage.tld:80)
let authority = req let authority = req
.headers() .headers()
.get(HOST) .get(HOST)
.ok_or_else(|| Error::BadRequest("HOST header required".to_owned()))? .ok_or_bad_request("HOST header required")?
.to_str()?; .to_str()?;
// Get bucket // Get bucket
@ -91,35 +107,95 @@ async fn serve_file(garage: Arc<Garage>, req: Request<Body>) -> Result<Response<
.ok_or(Error::NotFound)?; .ok_or(Error::NotFound)?;
// Check bucket isn't deleted and has website access enabled // Check bucket isn't deleted and has website access enabled
let _: Bucket = garage let bucket = garage
.bucket_table .bucket_table
.get(&EmptyKey, &bucket_id) .get(&EmptyKey, &bucket_id)
.await? .await?
.filter(|b| { .ok_or(Error::NotFound)?;
b.state
.as_option() let website_config = bucket
.map(|x| x.website_config.get().is_some()) .params()
.unwrap_or(false) .ok_or(Error::NotFound)?
}) .website_config
.get()
.as_ref()
.ok_or(Error::NotFound)?; .ok_or(Error::NotFound)?;
// Get path // Get path
let path = req.uri().path().to_string(); let path = req.uri().path().to_string();
let index = &garage.config.s3_web.index; let index = &website_config.index_document;
let key = path_to_key(&path, index)?; let key = path_to_key(&path, index)?;
info!( debug!(
"Selected bucket: \"{}\" {:?}, selected key: \"{}\"", "Selected bucket: \"{}\" {:?}, selected key: \"{}\"",
bucket_name, bucket_id, key bucket_name, bucket_id, key
); );
let res = match *req.method() { let ret_doc = match *req.method() {
Method::HEAD => handle_head(garage, &req, bucket_id, &key).await?, Method::HEAD => handle_head(garage.clone(), req, bucket_id, &key).await,
Method::GET => handle_get(garage, &req, bucket_id, &key).await?, Method::GET => handle_get(garage.clone(), req, bucket_id, &key).await,
_ => return Err(Error::BadRequest("HTTP method not supported".to_string())), _ => Err(ApiError::BadRequest("HTTP method not supported".into())),
}; }
.map_err(Error::from);
Ok(res) 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);
}
// 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);
}
}
Ok(error_doc)
}
Err(error_doc_error) => {
warn!(
"Couldn't get error document {} for bucket {:?}: {}",
error_document, bucket_id, error_doc_error
);
Err(error)
}
}
} else {
ret_doc
}
} }
/// Path to key /// Path to key