Files
Stalwart/crates/dav/src/calendar/scheduling.rs

439 lines
15 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::{
DavError, DavErrorCondition, DavMethod,
calendar::freebusy::CalendarFreebusyRequestHandler,
common::{
ETag,
lock::{LockRequestHandler, ResourceState},
uri::DavUriResource,
},
};
use calcard::{
Entry, Parser,
icalendar::{
ICalendarComponentType, ICalendarEntry, ICalendarMethod, ICalendarProperty, ICalendarValue,
Uri,
},
};
use common::{Server, auth::AccessToken};
use dav_proto::{
RequestHeaders,
schema::{
property::Rfc1123DateTime,
request::FreeBusyQuery,
response::{CalCondition, Href, ScheduleResponse, ScheduleResponseItem},
},
};
use groupware::{
DestroyArchive, cache::GroupwareCache, calendar::CalendarEventNotification, strip_mailto_scheme,
};
use http_proto::HttpResponse;
use hyper::StatusCode;
use store::{
ValueKey,
write::{AlignedBytes, Archive},
};
use store::{ahash::AHashMap, write::BatchBuilder};
use trc::AddContext;
use types::collection::{Collection, SyncCollection};
use utils::sanitize_email;
pub(crate) trait CalendarEventNotificationHandler: Sync + Send {
fn handle_scheduling_get_request(
&self,
access_token: &AccessToken,
headers: &RequestHeaders<'_>,
is_head: bool,
) -> impl Future<Output = crate::Result<HttpResponse>> + Send;
fn handle_scheduling_delete_request(
&self,
access_token: &AccessToken,
headers: &RequestHeaders<'_>,
) -> impl Future<Output = crate::Result<HttpResponse>> + Send;
fn handle_scheduling_post_request(
&self,
access_token: &AccessToken,
headers: &RequestHeaders<'_>,
bytes: Vec<u8>,
) -> impl Future<Output = crate::Result<HttpResponse>> + Send;
}
impl CalendarEventNotificationHandler for Server {
async fn handle_scheduling_get_request(
&self,
access_token: &AccessToken,
headers: &RequestHeaders<'_>,
is_head: bool,
) -> crate::Result<HttpResponse> {
// Validate URI
let resource_ = self
.validate_uri(access_token, headers.uri)
.await?
.into_owned_uri()?;
let account_id = resource_.account_id;
let resources = self
.fetch_dav_resources(
access_token.account_id(),
account_id,
SyncCollection::CalendarEventNotification,
)
.await
.caused_by(trc::location!())?;
let resource = resources
.by_path(
resource_
.resource
.ok_or(DavError::Code(StatusCode::METHOD_NOT_ALLOWED))?,
)
.ok_or(DavError::Code(StatusCode::NOT_FOUND))?;
if resource.is_container() {
return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED));
}
// Validate ACL
if !access_token.is_member(account_id) {
return Err(DavError::Code(StatusCode::FORBIDDEN));
}
// Fetch event
let event_ = self
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::CalendarEventNotification,
resource.document_id(),
))
.await
.caused_by(trc::location!())?
.ok_or(DavError::Code(StatusCode::NOT_FOUND))?;
let event = event_
.unarchive::<CalendarEventNotification>()
.caused_by(trc::location!())?;
// Validate headers
let etag = event_.etag();
self.validate_headers(
access_token,
headers,
vec![ResourceState {
account_id,
collection: Collection::CalendarEventNotification,
document_id: resource.document_id().into(),
etag: etag.clone().into(),
path: resource_.resource.unwrap(),
..Default::default()
}],
Default::default(),
DavMethod::GET,
)
.await?;
let response = HttpResponse::new(StatusCode::OK)
.with_content_type("text/calendar; charset=utf-8")
.with_etag(etag)
.with_last_modified(Rfc1123DateTime::new(i64::from(event.modified)).to_string());
let ical = event.event.to_string();
if !is_head {
Ok(response.with_binary_body(ical))
} else {
Ok(response.with_content_length(ical.len()))
}
}
async fn handle_scheduling_delete_request(
&self,
access_token: &AccessToken,
headers: &RequestHeaders<'_>,
) -> crate::Result<HttpResponse> {
// Validate URI
let resource = self
.validate_uri(access_token, headers.uri)
.await?
.into_owned_uri()?;
let account_id = resource.account_id;
let delete_path = resource
.resource
.filter(|r| !r.is_empty())
.ok_or(DavError::Code(StatusCode::FORBIDDEN))?;
let resources = self
.fetch_dav_resources(
access_token.account_id(),
account_id,
SyncCollection::CalendarEventNotification,
)
.await
.caused_by(trc::location!())?;
// Check resource type
let resource = resources
.by_path(delete_path)
.ok_or(DavError::Code(StatusCode::NOT_FOUND))?;
if resource.is_container() {
return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED));
}
// Validate ACL
if !access_token.is_member(account_id) {
return Err(DavError::Code(StatusCode::FORBIDDEN));
}
let document_id = resource.document_id();
let event_ = self
.store()
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
account_id,
Collection::CalendarEventNotification,
document_id,
))
.await
.caused_by(trc::location!())?
.ok_or(DavError::Code(StatusCode::NOT_FOUND))?;
// Validate headers
self.validate_headers(
access_token,
headers,
vec![ResourceState {
account_id,
collection: Collection::CalendarEventNotification,
document_id: document_id.into(),
etag: event_.etag().into(),
path: delete_path,
..Default::default()
}],
Default::default(),
DavMethod::DELETE,
)
.await?;
let event = event_
.to_unarchived::<CalendarEventNotification>()
.caused_by(trc::location!())?;
// Delete event
let mut batch = BatchBuilder::new();
DestroyArchive(event)
.delete(
access_token.account_tenant_ids(),
account_id,
document_id,
&mut batch,
)
.caused_by(trc::location!())?;
self.commit_batch(batch).await.caused_by(trc::location!())?;
Ok(HttpResponse::new(StatusCode::NO_CONTENT))
}
async fn handle_scheduling_post_request(
&self,
access_token: &AccessToken,
headers: &RequestHeaders<'_>,
bytes: Vec<u8>,
) -> crate::Result<HttpResponse> {
// Validate URI
let resource = self
.validate_uri(access_token, headers.uri)
.await?
.into_owned_uri()?;
if resource.resource.is_none_or(|r| r != "outbox") {
return Err(DavError::Code(StatusCode::FORBIDDEN));
}
// Parse iTIP message
if bytes.len() > self.core.groupware.max_ical_size {
return Err(DavError::Condition(DavErrorCondition::new(
StatusCode::PRECONDITION_FAILED,
CalCondition::MaxResourceSize(self.core.groupware.max_ical_size as u32),
)));
}
let itip_raw = std::str::from_utf8(&bytes).map_err(|_| {
DavError::Condition(
DavErrorCondition::new(
StatusCode::BAD_REQUEST,
CalCondition::ValidSchedulingMessage,
)
.with_details("Invalid UTF-8 in iCalendar data"),
)
})?;
let itip = match Parser::new(itip_raw).entry() {
Entry::ICalendar(ical) if ical.components.len() > 1 => ical,
_ => {
return Err(DavError::Condition(
DavErrorCondition::new(
StatusCode::BAD_REQUEST,
CalCondition::ValidSchedulingMessage,
)
.with_details("Failed to parse iCalendar data"),
));
}
};
// Parse request
let mut from_date = None;
let mut to_date = None;
let mut organizer = None;
let mut attendees = AHashMap::new();
let mut uid = None;
let tz_resolver = itip.build_tz_resolver();
let mut found_freebusy = false;
for component in &itip.components {
if component.component_type != ICalendarComponentType::VFreebusy {
continue;
} else if !found_freebusy {
found_freebusy = true;
} else {
return Err(DavError::Condition(
DavErrorCondition::new(
StatusCode::BAD_REQUEST,
CalCondition::ValidSchedulingMessage,
)
.with_details("Multiple VFREEBUSY components found"),
));
}
for entry in &component.entries {
let tz_id = entry.tz_id();
match (&entry.name, entry.values.first()) {
(ICalendarProperty::Dtstart, Some(ICalendarValue::PartialDateTime(dt))) => {
from_date = dt.to_date_time_with_tz(tz_resolver.resolve_or_default(tz_id));
}
(ICalendarProperty::Dtend, Some(ICalendarValue::PartialDateTime(dt))) => {
to_date = dt.to_date_time_with_tz(tz_resolver.resolve_or_default(tz_id));
}
(ICalendarProperty::Uid, Some(ICalendarValue::Text(_))) => {
uid = Some(entry);
}
(
ICalendarProperty::Organizer,
Some(ICalendarValue::Text(_) | ICalendarValue::Uri(Uri::Location(_))),
) => {
organizer = Some(entry);
}
(
ICalendarProperty::Attendee,
Some(
ICalendarValue::Text(value) | ICalendarValue::Uri(Uri::Location(value)),
),
) => {
if let Some(email) = sanitize_email(strip_mailto_scheme(value.as_str())) {
attendees.insert(email, entry);
}
}
_ => {}
}
}
}
let (Some(from_date), Some(to_date)) = (from_date, to_date) else {
return Err(DavError::Condition(
DavErrorCondition::new(
StatusCode::BAD_REQUEST,
CalCondition::ValidSchedulingMessage,
)
.with_details("Missing DTSTART or DTEND in VFREEBUSY component"),
));
};
let Some(organizer) = organizer else {
return Err(DavError::Condition(
DavErrorCondition::new(
StatusCode::BAD_REQUEST,
CalCondition::ValidSchedulingMessage,
)
.with_details("Missing ORGANIZER in VFREEBUSY component"),
));
};
if attendees.is_empty() {
return Err(DavError::Condition(
DavErrorCondition::new(
StatusCode::BAD_REQUEST,
CalCondition::ValidSchedulingMessage,
)
.with_details("Missing ATTENDEE in VFREEBUSY component"),
));
}
let mut response = ScheduleResponse::default();
for (email, attendee) in attendees {
if let Some(account_id) = self
.account_id_from_email(&email, true)
.await
.caused_by(trc::location!())?
{
let resources = self
.fetch_dav_resources(
access_token.account_id(),
account_id,
SyncCollection::Calendar,
)
.await
.caused_by(trc::location!())?;
if let Some(resource) = self
.core
.groupware
.default_calendar_name
.as_ref()
.and_then(|name| resources.by_path(name))
{
let mut free_busy = self
.build_freebusy_object(
access_token,
FreeBusyQuery::new(from_date.timestamp(), to_date.timestamp()),
&resources,
account_id,
resource,
)
.await?;
// Add iTIP method
free_busy.components[0].entries.push(ICalendarEntry {
name: ICalendarProperty::Method,
params: vec![],
values: vec![ICalendarValue::Method(ICalendarMethod::Reply)],
});
// Add properties
let component = &mut free_busy.components[1];
component.entries.push(organizer.clone());
component.entries.push(attendee.clone());
if let Some(uid) = uid {
component.entries.push(uid.clone());
}
response.items.0.push(ScheduleResponseItem {
recipient: Href(format!("mailto:{email}")),
request_status: "2.0;Success".into(),
calendar_data: Some(free_busy.to_string()),
});
} else {
response.items.0.push(ScheduleResponseItem {
recipient: Href(format!("mailto:{email}")),
request_status: "3.7;Default calendar not found".into(),
calendar_data: None,
});
}
} else {
response.items.0.push(ScheduleResponseItem {
recipient: Href(format!("mailto:{email}")),
request_status: "3.7;Invalid calendar user or insufficient permissions".into(),
calendar_data: None,
});
}
}
Ok(HttpResponse::new(StatusCode::OK).with_xml_body(response.to_string()))
}
}