2024-04-18 14:08:10 +00:00
|
|
|
use anyhow::Result;
|
2024-05-16 15:38:34 +00:00
|
|
|
use futures::stream::{StreamExt, TryStreamExt};
|
2024-05-25 17:30:59 +00:00
|
|
|
use http_body_util::combinators::UnsyncBoxBody;
|
2024-04-23 08:35:43 +00:00
|
|
|
use http_body_util::BodyStream;
|
2024-04-23 13:20:29 +00:00
|
|
|
use http_body_util::StreamBody;
|
|
|
|
use hyper::body::Frame;
|
2024-05-16 15:38:34 +00:00
|
|
|
use hyper::body::Incoming;
|
|
|
|
use hyper::{body::Bytes, Request, Response};
|
2024-04-18 14:08:10 +00:00
|
|
|
|
|
|
|
use aero_collections::user::User;
|
|
|
|
use aero_dav::caltypes as cal;
|
2024-05-27 16:16:53 +00:00
|
|
|
use aero_dav::realization::{self, All};
|
2024-05-16 15:38:34 +00:00
|
|
|
use aero_dav::types as dav;
|
2024-05-27 16:16:53 +00:00
|
|
|
use aero_dav::versioningtypes as vers;
|
2024-05-25 17:30:59 +00:00
|
|
|
use aero_ical::query::is_component_match;
|
2024-04-18 14:08:10 +00:00
|
|
|
|
2024-05-16 15:38:34 +00:00
|
|
|
use crate::dav::codec;
|
|
|
|
use crate::dav::codec::{depth, deserialize, serialize, text_body};
|
2024-05-25 17:30:59 +00:00
|
|
|
use crate::dav::node::DavNode;
|
2024-04-18 14:08:10 +00:00
|
|
|
use crate::dav::resource::RootNode;
|
|
|
|
|
2024-04-23 13:20:29 +00:00
|
|
|
pub(super) type ArcUser = std::sync::Arc<User>;
|
|
|
|
pub(super) type HttpResponse = Response<UnsyncBoxBody<Bytes, std::io::Error>>;
|
2024-04-18 14:08:10 +00:00
|
|
|
|
|
|
|
const ALLPROP: [dav::PropertyRequest<All>; 10] = [
|
|
|
|
dav::PropertyRequest::CreationDate,
|
|
|
|
dav::PropertyRequest::DisplayName,
|
|
|
|
dav::PropertyRequest::GetContentLanguage,
|
|
|
|
dav::PropertyRequest::GetContentLength,
|
|
|
|
dav::PropertyRequest::GetContentType,
|
|
|
|
dav::PropertyRequest::GetEtag,
|
|
|
|
dav::PropertyRequest::GetLastModified,
|
|
|
|
dav::PropertyRequest::LockDiscovery,
|
|
|
|
dav::PropertyRequest::ResourceType,
|
|
|
|
dav::PropertyRequest::SupportedLock,
|
|
|
|
];
|
|
|
|
|
|
|
|
pub(crate) struct Controller {
|
|
|
|
node: Box<dyn DavNode>,
|
|
|
|
user: std::sync::Arc<User>,
|
|
|
|
req: Request<Incoming>,
|
|
|
|
}
|
|
|
|
impl Controller {
|
2024-05-16 15:38:34 +00:00
|
|
|
pub(crate) async fn route(
|
|
|
|
user: std::sync::Arc<User>,
|
|
|
|
req: Request<Incoming>,
|
|
|
|
) -> Result<HttpResponse> {
|
2024-04-18 14:08:10 +00:00
|
|
|
let path = req.uri().path().to_string();
|
|
|
|
let path_segments: Vec<_> = path.split("/").filter(|s| *s != "").collect();
|
|
|
|
let method = req.method().as_str().to_uppercase();
|
|
|
|
|
2024-04-20 18:01:07 +00:00
|
|
|
let can_create = matches!(method.as_str(), "PUT" | "MKCOL" | "MKCALENDAR");
|
2024-05-16 15:38:34 +00:00
|
|
|
let node = match (RootNode {}).fetch(&user, &path_segments, can_create).await {
|
2024-04-18 14:08:10 +00:00
|
|
|
Ok(v) => v,
|
|
|
|
Err(e) => {
|
|
|
|
tracing::warn!(err=?e, "dav node fetch failed");
|
|
|
|
return Ok(Response::builder()
|
|
|
|
.status(404)
|
2024-05-16 15:38:34 +00:00
|
|
|
.body(codec::text_body("Resource not found"))?);
|
2024-04-18 14:08:10 +00:00
|
|
|
}
|
|
|
|
};
|
2024-04-20 18:01:07 +00:00
|
|
|
|
2024-04-18 14:08:10 +00:00
|
|
|
let ctrl = Self { node, user, req };
|
|
|
|
|
|
|
|
match method.as_str() {
|
|
|
|
"OPTIONS" => Ok(Response::builder()
|
|
|
|
.status(200)
|
|
|
|
.header("DAV", "1")
|
|
|
|
.header("Allow", "HEAD,GET,PUT,OPTIONS,DELETE,PROPFIND,PROPPATCH,MKCOL,COPY,MOVE,LOCK,UNLOCK,MKCALENDAR,REPORT")
|
|
|
|
.body(codec::text_body(""))?),
|
2024-04-23 13:20:29 +00:00
|
|
|
"HEAD" => {
|
|
|
|
tracing::warn!("HEAD not correctly implemented");
|
2024-04-18 14:08:10 +00:00
|
|
|
Ok(Response::builder()
|
|
|
|
.status(404)
|
|
|
|
.body(codec::text_body(""))?)
|
|
|
|
},
|
2024-04-23 13:20:29 +00:00
|
|
|
"GET" => ctrl.get().await,
|
2024-04-23 08:35:43 +00:00
|
|
|
"PUT" => ctrl.put().await,
|
2024-04-24 15:35:00 +00:00
|
|
|
"DELETE" => ctrl.delete().await,
|
2024-04-18 14:08:10 +00:00
|
|
|
"PROPFIND" => ctrl.propfind().await,
|
|
|
|
"REPORT" => ctrl.report().await,
|
|
|
|
_ => Ok(Response::builder()
|
|
|
|
.status(501)
|
|
|
|
.body(codec::text_body("HTTP Method not implemented"))?),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-04-20 18:01:07 +00:00
|
|
|
// --- Per-method functions ---
|
2024-04-18 14:08:10 +00:00
|
|
|
|
|
|
|
/// REPORT has been first described in the "Versioning Extension" of WebDAV
|
|
|
|
/// It allows more complex queries compared to PROPFIND
|
|
|
|
///
|
|
|
|
/// Note: current implementation is not generic at all, it is heavily tied to CalDAV.
|
|
|
|
/// A rewrite would be required to make it more generic (with the extension system that has
|
|
|
|
/// been introduced in aero-dav)
|
2024-05-16 15:38:34 +00:00
|
|
|
async fn report(self) -> Result<HttpResponse> {
|
2024-04-18 14:08:10 +00:00
|
|
|
let status = hyper::StatusCode::from_u16(207)?;
|
|
|
|
|
2024-05-27 16:16:53 +00:00
|
|
|
let cal_report = match deserialize::<vers::Report<All>>(self.req).await {
|
2024-04-18 14:08:10 +00:00
|
|
|
Ok(v) => v,
|
|
|
|
Err(e) => {
|
|
|
|
tracing::error!(err=?e, "unable to decode REPORT body");
|
|
|
|
return Ok(Response::builder()
|
2024-05-16 15:38:34 +00:00
|
|
|
.status(400)
|
|
|
|
.body(text_body("Bad request"))?);
|
2024-04-18 14:08:10 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2024-05-16 15:38:34 +00:00
|
|
|
// Internal representation that will handle processed request
|
2024-04-18 14:08:10 +00:00
|
|
|
let (mut ok_node, mut not_found) = (Vec::new(), Vec::new());
|
2024-05-16 15:38:34 +00:00
|
|
|
let calprop: Option<cal::CalendarSelector<All>>;
|
|
|
|
|
|
|
|
// Extracting request information
|
2024-05-27 16:16:53 +00:00
|
|
|
match cal_report {
|
|
|
|
vers::Report::Extension(realization::ReportType::Cal(cal::ReportType::Multiget(m))) => {
|
2024-05-16 15:38:34 +00:00
|
|
|
// Multiget is really like a propfind where Depth: 0|1|Infinity is replaced by an arbitrary
|
|
|
|
// list of URLs
|
|
|
|
// Getting the list of nodes
|
|
|
|
for h in m.href.into_iter() {
|
|
|
|
let maybe_collected_node = match Path::new(h.0.as_str()) {
|
|
|
|
Ok(Path::Abs(p)) => RootNode {}
|
|
|
|
.fetch(&self.user, p.as_slice(), false)
|
|
|
|
.await
|
|
|
|
.or(Err(h)),
|
|
|
|
Ok(Path::Rel(p)) => self
|
|
|
|
.node
|
|
|
|
.fetch(&self.user, p.as_slice(), false)
|
|
|
|
.await
|
|
|
|
.or(Err(h)),
|
|
|
|
Err(_) => Err(h),
|
|
|
|
};
|
|
|
|
|
|
|
|
match maybe_collected_node {
|
|
|
|
Ok(v) => ok_node.push(v),
|
|
|
|
Err(h) => not_found.push(h),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
calprop = m.selector;
|
|
|
|
}
|
2024-05-27 16:16:53 +00:00
|
|
|
vers::Report::Extension(realization::ReportType::Cal(cal::ReportType::Query(q))) => {
|
2024-05-16 15:38:34 +00:00
|
|
|
calprop = q.selector;
|
2024-05-16 19:47:21 +00:00
|
|
|
ok_node = apply_filter(self.node.children(&self.user).await, &q.filter)
|
2024-05-16 15:38:34 +00:00
|
|
|
.try_collect()
|
|
|
|
.await?;
|
|
|
|
}
|
2024-05-27 16:16:53 +00:00
|
|
|
vers::Report::Extension(realization::ReportType::Sync(_sync_col)) => {
|
|
|
|
todo!()
|
|
|
|
}
|
|
|
|
_ => {
|
2024-05-16 15:38:34 +00:00
|
|
|
return Ok(Response::builder()
|
|
|
|
.status(501)
|
|
|
|
.body(text_body("Not implemented"))?)
|
|
|
|
}
|
|
|
|
};
|
2024-04-18 14:08:10 +00:00
|
|
|
|
|
|
|
// Getting props
|
2024-05-16 15:38:34 +00:00
|
|
|
let props = match calprop {
|
2024-04-18 14:08:10 +00:00
|
|
|
None | Some(cal::CalendarSelector::AllProp) => Some(dav::PropName(ALLPROP.to_vec())),
|
|
|
|
Some(cal::CalendarSelector::PropName) => None,
|
|
|
|
Some(cal::CalendarSelector::Prop(inner)) => Some(inner),
|
|
|
|
};
|
|
|
|
|
2024-05-16 15:38:34 +00:00
|
|
|
serialize(
|
|
|
|
status,
|
|
|
|
Self::multistatus(&self.user, ok_node, not_found, props).await,
|
|
|
|
)
|
2024-04-18 14:08:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/// PROPFIND is the standard way to fetch WebDAV properties
|
2024-05-16 15:38:34 +00:00
|
|
|
async fn propfind(self) -> Result<HttpResponse> {
|
2024-04-18 14:08:10 +00:00
|
|
|
let depth = depth(&self.req);
|
|
|
|
if matches!(depth, dav::Depth::Infinity) {
|
|
|
|
return Ok(Response::builder()
|
|
|
|
.status(501)
|
2024-05-16 15:38:34 +00:00
|
|
|
.body(text_body("Depth: Infinity not implemented"))?);
|
2024-04-18 14:08:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
let status = hyper::StatusCode::from_u16(207)?;
|
|
|
|
|
|
|
|
// A client may choose not to submit a request body. An empty PROPFIND
|
|
|
|
// request body MUST be treated as if it were an 'allprop' request.
|
|
|
|
// @FIXME here we handle any invalid data as an allprop, an empty request is thus correctly
|
|
|
|
// handled, but corrupted requests are also silently handled as allprop.
|
2024-05-16 15:38:34 +00:00
|
|
|
let propfind = deserialize::<dav::PropFind<All>>(self.req)
|
|
|
|
.await
|
|
|
|
.unwrap_or_else(|_| dav::PropFind::<All>::AllProp(None));
|
2024-04-18 14:08:10 +00:00
|
|
|
tracing::debug!(recv=?propfind, "inferred propfind request");
|
|
|
|
|
|
|
|
// Collect nodes as PROPFIND is not limited to the targeted node
|
|
|
|
let mut nodes = vec![];
|
|
|
|
if matches!(depth, dav::Depth::One | dav::Depth::Infinity) {
|
|
|
|
nodes.extend(self.node.children(&self.user).await);
|
|
|
|
}
|
|
|
|
nodes.push(self.node);
|
|
|
|
|
|
|
|
// Expand properties request
|
|
|
|
let propname = match propfind {
|
|
|
|
dav::PropFind::PropName => None,
|
|
|
|
dav::PropFind::AllProp(None) => Some(dav::PropName(ALLPROP.to_vec())),
|
|
|
|
dav::PropFind::AllProp(Some(dav::Include(mut include))) => {
|
|
|
|
include.extend_from_slice(&ALLPROP);
|
|
|
|
Some(dav::PropName(include))
|
2024-05-16 15:38:34 +00:00
|
|
|
}
|
2024-04-18 14:08:10 +00:00
|
|
|
dav::PropFind::Prop(inner) => Some(inner),
|
|
|
|
};
|
|
|
|
|
|
|
|
// Not Found is currently impossible considering the way we designed this function
|
|
|
|
let not_found = vec![];
|
2024-05-16 15:38:34 +00:00
|
|
|
serialize(
|
|
|
|
status,
|
|
|
|
Self::multistatus(&self.user, nodes, not_found, propname).await,
|
|
|
|
)
|
2024-04-18 14:08:10 +00:00
|
|
|
}
|
|
|
|
|
2024-05-16 15:38:34 +00:00
|
|
|
async fn put(self) -> Result<HttpResponse> {
|
2024-04-24 09:43:57 +00:00
|
|
|
let put_policy = codec::put_policy(&self.req)?;
|
2024-04-23 08:35:43 +00:00
|
|
|
|
|
|
|
let stream_of_frames = BodyStream::new(self.req.into_body());
|
|
|
|
let stream_of_bytes = stream_of_frames
|
2024-05-16 15:38:34 +00:00
|
|
|
.map_ok(|frame| frame.into_data())
|
|
|
|
.map(|obj| match obj {
|
|
|
|
Ok(Ok(v)) => Ok(v),
|
|
|
|
Ok(Err(_)) => Err(std::io::Error::new(
|
|
|
|
std::io::ErrorKind::Other,
|
|
|
|
"conversion error",
|
|
|
|
)),
|
|
|
|
Err(err) => Err(std::io::Error::new(std::io::ErrorKind::Other, err)),
|
|
|
|
})
|
|
|
|
.boxed();
|
2024-04-23 08:35:43 +00:00
|
|
|
|
2024-05-22 21:22:03 +00:00
|
|
|
let etag = match self.node.put(put_policy, stream_of_bytes).await {
|
|
|
|
Ok(etag) => etag,
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
|
|
|
tracing::warn!("put pre-condition failed");
|
|
|
|
let response = Response::builder().status(412).body(text_body(""))?;
|
|
|
|
return Ok(response);
|
|
|
|
}
|
|
|
|
Err(e) => Err(e)?,
|
|
|
|
};
|
2024-05-16 15:38:34 +00:00
|
|
|
|
2024-04-23 08:35:43 +00:00
|
|
|
let response = Response::builder()
|
|
|
|
.status(201)
|
|
|
|
.header("ETag", etag)
|
|
|
|
//.header("content-type", "application/xml; charset=\"utf-8\"")
|
|
|
|
.body(text_body(""))?;
|
|
|
|
|
|
|
|
Ok(response)
|
2024-04-20 18:01:07 +00:00
|
|
|
}
|
|
|
|
|
2024-05-16 15:38:34 +00:00
|
|
|
async fn get(self) -> Result<HttpResponse> {
|
2024-04-23 16:07:00 +00:00
|
|
|
let stream_body = StreamBody::new(self.node.content().map_ok(|v| Frame::data(v)));
|
2024-04-23 13:20:29 +00:00
|
|
|
let boxed_body = UnsyncBoxBody::new(stream_body);
|
2024-04-20 18:01:07 +00:00
|
|
|
|
2024-04-24 15:35:00 +00:00
|
|
|
let mut builder = Response::builder().status(200);
|
|
|
|
builder = builder.header("content-type", self.node.content_type());
|
|
|
|
if let Some(etag) = self.node.etag().await {
|
|
|
|
builder = builder.header("etag", etag);
|
|
|
|
}
|
|
|
|
let response = builder.body(boxed_body)?;
|
2024-04-20 18:01:07 +00:00
|
|
|
|
2024-04-23 13:20:29 +00:00
|
|
|
Ok(response)
|
2024-04-24 15:35:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
async fn delete(self) -> Result<HttpResponse> {
|
|
|
|
self.node.delete().await?;
|
|
|
|
let response = Response::builder()
|
|
|
|
.status(204)
|
|
|
|
//.header("content-type", "application/xml; charset=\"utf-8\"")
|
|
|
|
.body(text_body(""))?;
|
|
|
|
Ok(response)
|
2024-04-20 18:01:07 +00:00
|
|
|
}
|
|
|
|
|
2024-04-23 13:20:29 +00:00
|
|
|
// --- Common utility functions ---
|
2024-04-20 18:01:07 +00:00
|
|
|
/// Build a multistatus response from a list of DavNodes
|
2024-05-16 15:38:34 +00:00
|
|
|
async fn multistatus(
|
|
|
|
user: &ArcUser,
|
|
|
|
nodes: Vec<Box<dyn DavNode>>,
|
|
|
|
not_found: Vec<dav::Href>,
|
|
|
|
props: Option<dav::PropName<All>>,
|
|
|
|
) -> dav::Multistatus<All> {
|
2024-04-18 14:08:10 +00:00
|
|
|
// Collect properties on existing objects
|
|
|
|
let mut responses: Vec<dav::Response<All>> = match props {
|
2024-05-16 15:38:34 +00:00
|
|
|
Some(props) => {
|
|
|
|
futures::stream::iter(nodes)
|
|
|
|
.then(|n| n.response_props(user, props.clone()))
|
|
|
|
.collect()
|
|
|
|
.await
|
|
|
|
}
|
|
|
|
None => nodes
|
|
|
|
.into_iter()
|
|
|
|
.map(|n| n.response_propname(user))
|
|
|
|
.collect(),
|
2024-04-18 14:08:10 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
// Register not found objects only if relevant
|
|
|
|
if !not_found.is_empty() {
|
|
|
|
responses.push(dav::Response {
|
2024-05-16 15:38:34 +00:00
|
|
|
status_or_propstat: dav::StatusOrPropstat::Status(
|
|
|
|
not_found,
|
|
|
|
dav::Status(hyper::StatusCode::NOT_FOUND),
|
|
|
|
),
|
2024-04-18 14:08:10 +00:00
|
|
|
error: None,
|
|
|
|
location: None,
|
|
|
|
responsedescription: None,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
// Build response
|
|
|
|
dav::Multistatus::<All> {
|
|
|
|
responses,
|
|
|
|
responsedescription: None,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/// Path is a voluntarily feature limited
|
|
|
|
/// compared to the expressiveness of a UNIX path
|
|
|
|
/// For example getting parent with ../ is not supported, scheme is not supported, etc.
|
|
|
|
/// More complex support could be added later if needed by clients
|
|
|
|
enum Path<'a> {
|
|
|
|
Abs(Vec<&'a str>),
|
|
|
|
Rel(Vec<&'a str>),
|
|
|
|
}
|
|
|
|
impl<'a> Path<'a> {
|
|
|
|
fn new(path: &'a str) -> Result<Self> {
|
|
|
|
// This check is naive, it does not aim at detecting all fully qualified
|
|
|
|
// URL or protect from any attack, its only goal is to help debugging.
|
|
|
|
if path.starts_with("http://") || path.starts_with("https://") {
|
|
|
|
anyhow::bail!("Full URL are not supported")
|
|
|
|
}
|
|
|
|
|
|
|
|
let path_segments: Vec<_> = path.split("/").filter(|s| *s != "" && *s != ".").collect();
|
|
|
|
if path.starts_with("/") {
|
2024-05-16 15:38:34 +00:00
|
|
|
return Ok(Path::Abs(path_segments));
|
2024-04-18 14:08:10 +00:00
|
|
|
}
|
|
|
|
Ok(Path::Rel(path_segments))
|
|
|
|
}
|
|
|
|
}
|
2024-05-16 15:38:34 +00:00
|
|
|
|
|
|
|
//@FIXME naive implementation, must be refactored later
|
|
|
|
use futures::stream::Stream;
|
2024-05-16 19:47:21 +00:00
|
|
|
fn apply_filter<'a>(
|
2024-05-16 15:38:34 +00:00
|
|
|
nodes: Vec<Box<dyn DavNode>>,
|
2024-05-16 19:47:21 +00:00
|
|
|
filter: &'a cal::Filter,
|
|
|
|
) -> impl Stream<Item = std::result::Result<Box<dyn DavNode>, std::io::Error>> + 'a {
|
|
|
|
futures::stream::iter(nodes).filter_map(move |single_node| async move {
|
2024-05-16 15:38:34 +00:00
|
|
|
// Get ICS
|
|
|
|
let chunks: Vec<_> = match single_node.content().try_collect().await {
|
|
|
|
Ok(v) => v,
|
|
|
|
Err(e) => return Some(Err(e)),
|
|
|
|
};
|
|
|
|
let raw_ics = chunks.iter().fold(String::new(), |mut acc, single_chunk| {
|
|
|
|
let str_fragment = std::str::from_utf8(single_chunk.as_ref());
|
|
|
|
acc.extend(str_fragment);
|
|
|
|
acc
|
|
|
|
});
|
|
|
|
|
|
|
|
// Parse ICS
|
2024-05-16 19:47:21 +00:00
|
|
|
let ics = match icalendar::parser::read_calendar(&raw_ics) {
|
|
|
|
Ok(v) => v,
|
|
|
|
Err(e) => {
|
|
|
|
tracing::warn!(err=?e, "Unable to parse ICS in calendar-query");
|
|
|
|
return Some(Err(std::io::Error::from(std::io::ErrorKind::InvalidData)));
|
|
|
|
}
|
|
|
|
};
|
2024-05-16 15:38:34 +00:00
|
|
|
|
|
|
|
// Do checks
|
2024-05-22 13:02:53 +00:00
|
|
|
// @FIXME: icalendar does not consider VCALENDAR as a component
|
|
|
|
// but WebDAV does...
|
|
|
|
// Build a fake VCALENDAR component for icalendar compatibility, it's a hack
|
2024-05-16 19:47:21 +00:00
|
|
|
let root_filter = &filter.0;
|
2024-05-22 13:02:53 +00:00
|
|
|
let fake_vcal_component = icalendar::parser::Component {
|
|
|
|
name: cal::Component::VCalendar.as_str().into(),
|
|
|
|
properties: ics.properties,
|
|
|
|
components: ics.components,
|
2024-05-22 08:05:52 +00:00
|
|
|
};
|
2024-05-22 13:02:53 +00:00
|
|
|
tracing::debug!(filter=?root_filter, "calendar-query filter");
|
2024-05-16 15:38:34 +00:00
|
|
|
|
2024-05-22 08:05:52 +00:00
|
|
|
// Adjust return value according to filter
|
2024-05-26 08:33:04 +00:00
|
|
|
match is_component_match(
|
|
|
|
&fake_vcal_component,
|
|
|
|
&[fake_vcal_component.clone()],
|
|
|
|
root_filter,
|
|
|
|
) {
|
2024-05-22 08:05:52 +00:00
|
|
|
true => Some(Ok(single_node)),
|
|
|
|
_ => None,
|
|
|
|
}
|
2024-05-16 15:38:34 +00:00
|
|
|
})
|
|
|
|
}
|