426 lines
17 KiB
Rust
426 lines
17 KiB
Rust
/*
|
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
|
*
|
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
|
*/
|
|
|
|
use crate::{calendar::Availability, calendar_event::CalendarSyntheticId};
|
|
use calcard::{
|
|
common::timezone::Tz,
|
|
icalendar::{
|
|
ArchivedICalendarClassification, ArchivedICalendarParameterValue,
|
|
ArchivedICalendarParticipationStatus, ArchivedICalendarProperty, ArchivedICalendarStatus,
|
|
ArchivedICalendarTransparency, ArchivedICalendarValue, ICalendarParameterName,
|
|
},
|
|
jscalendar::{JSCalendar, JSCalendarProperty, JSCalendarValue},
|
|
};
|
|
use common::{
|
|
Server, TinyCalendarPreferences,
|
|
auth::{AccessToken, BuildAccessToken},
|
|
};
|
|
use groupware::{
|
|
cache::GroupwareCache,
|
|
calendar::{CALENDAR_SUBSCRIBED, CalendarEvent},
|
|
};
|
|
use jmap_proto::{
|
|
method::availability::{
|
|
BusyPeriod, BusyStatus, GetAvailabilityRequest, GetAvailabilityResponse,
|
|
},
|
|
object::calendar::IncludeInAvailability,
|
|
request::IntoValid,
|
|
types::date::UTCDate,
|
|
};
|
|
use jmap_tools::{Key, Map, Value};
|
|
use registry::schema::enums::Permission;
|
|
use std::{collections::hash_map::Entry, future::Future};
|
|
use store::{
|
|
ValueKey,
|
|
ahash::AHashMap,
|
|
write::{AlignedBytes, Archive},
|
|
};
|
|
use trc::AddContext;
|
|
use types::{
|
|
TimeRange,
|
|
acl::Acl,
|
|
collection::{Collection, SyncCollection},
|
|
id::Id,
|
|
};
|
|
use utils::sanitize_email;
|
|
|
|
pub trait PrincipalGetAvailability: Sync + Send {
|
|
fn principal_get_availability(
|
|
&self,
|
|
request: GetAvailabilityRequest,
|
|
access_token: &AccessToken,
|
|
) -> impl Future<Output = trc::Result<GetAvailabilityResponse>> + Send;
|
|
}
|
|
|
|
impl PrincipalGetAvailability for Server {
|
|
async fn principal_get_availability(
|
|
&self,
|
|
request: GetAvailabilityRequest,
|
|
access_token: &AccessToken,
|
|
) -> trc::Result<GetAvailabilityResponse> {
|
|
if !self.core.groupware.allow_directory_query
|
|
&& !access_token.has_permission(Permission::JmapPrincipalGetAvailability)
|
|
{
|
|
return Err(trc::JmapEvent::Forbidden
|
|
.into_err()
|
|
.details("The administrator has disabled directory queries.".to_string()));
|
|
}
|
|
|
|
// Process parameters
|
|
if !request.id.is_valid() {
|
|
return Err(trc::JmapEvent::InvalidArguments
|
|
.into_err()
|
|
.details("Missing principal id"));
|
|
}
|
|
let properties = request
|
|
.event_properties
|
|
.map(|props| props.into_valid().collect::<Vec<_>>())
|
|
.unwrap_or_default();
|
|
if properties
|
|
.iter()
|
|
.any(|p| !matches!(p, JSCalendarProperty::Id | JSCalendarProperty::BaseEventId))
|
|
{
|
|
return Err(trc::JmapEvent::InvalidArguments
|
|
.into_err()
|
|
.details("Only 'id' and 'baseEventId' properties are supported in results"));
|
|
}
|
|
let return_event_details = !properties.is_empty();
|
|
let max_instances = self.core.groupware.max_ical_instances;
|
|
let filter = TimeRange {
|
|
start: request.utc_start.timestamp(),
|
|
end: request.utc_end.timestamp(),
|
|
};
|
|
let principal_id = request.id.document_id();
|
|
let principal = self
|
|
.access_token(principal_id)
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
.build();
|
|
let principal_account = self
|
|
.account_info(principal_id)
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
let mut periods = Vec::new();
|
|
|
|
for account_id in principal.all_ids_by_collection(Collection::Calendar) {
|
|
let resources = self
|
|
.fetch_dav_resources(
|
|
access_token.account_id(),
|
|
account_id,
|
|
SyncCollection::Calendar,
|
|
)
|
|
.await
|
|
.caused_by(trc::location!())?;
|
|
|
|
// Obtain shared ids
|
|
let is_account_owner = principal_id == account_id;
|
|
let shared_ids = if !access_token.is_member(account_id) {
|
|
// Condition: The user has the "mayReadFreeBusy" permission for the calendar.
|
|
let shared_ids = resources.shared_items(
|
|
access_token,
|
|
[Acl::ReadItems, Acl::SchedulingReadFreeBusy],
|
|
true,
|
|
);
|
|
if shared_ids.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
shared_ids.into()
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Condition: The event finishes after the "utcStart" argument and starts before the "utcEnd" argument.
|
|
let mut preferences_cache: AHashMap<u32, Option<&TinyCalendarPreferences>> =
|
|
AHashMap::default();
|
|
'next_event: for resource in resources.resources.iter().filter(|r| {
|
|
r.event_time_range().is_some_and(|(start, end)| {
|
|
shared_ids
|
|
.as_ref()
|
|
.is_none_or(|ids| ids.contains(r.document_id))
|
|
&& filter.is_in_range(false, start, end)
|
|
})
|
|
}) {
|
|
// Obtain calendar settings
|
|
let mut include_in_availability = None;
|
|
let mut default_tz = Tz::UTC;
|
|
let mut is_subscribed = is_account_owner;
|
|
for calendar_id in resource
|
|
.child_names()
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.map(|n| n.parent_id)
|
|
{
|
|
match preferences_cache.entry(calendar_id) {
|
|
Entry::Occupied(e) => {
|
|
if let Some(prefs) = e.get() {
|
|
default_tz = prefs.tz;
|
|
is_subscribed |= prefs.flags & CALENDAR_SUBSCRIBED != 0;
|
|
include_in_availability =
|
|
IncludeInAvailability::from_flags(prefs.flags);
|
|
}
|
|
}
|
|
Entry::Vacant(e) => {
|
|
if let Some(prefs) = resources
|
|
.container_resource_by_id(calendar_id)
|
|
.and_then(|r| r.calendar_preferences(principal_id))
|
|
{
|
|
default_tz = prefs.tz;
|
|
is_subscribed |= prefs.flags & CALENDAR_SUBSCRIBED != 0;
|
|
include_in_availability =
|
|
IncludeInAvailability::from_flags(prefs.flags);
|
|
e.insert(Some(prefs));
|
|
} else {
|
|
e.insert(None);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let include_in_availability = include_in_availability.unwrap_or({
|
|
if is_account_owner {
|
|
IncludeInAvailability::All
|
|
} else {
|
|
IncludeInAvailability::None
|
|
}
|
|
});
|
|
|
|
if !is_subscribed || include_in_availability == IncludeInAvailability::None {
|
|
continue 'next_event;
|
|
}
|
|
|
|
// Fetch event
|
|
let document_id = resource.document_id;
|
|
let Some(archive) = self
|
|
.store()
|
|
.get_value::<Archive<AlignedBytes>>(ValueKey::archive(
|
|
account_id,
|
|
Collection::CalendarEvent,
|
|
document_id,
|
|
))
|
|
.await
|
|
.caused_by(trc::location!())?
|
|
else {
|
|
continue;
|
|
};
|
|
let event = archive
|
|
.unarchive::<CalendarEvent>()
|
|
.caused_by(trc::location!())?;
|
|
|
|
// Find the component ids that match the criteria
|
|
let mut matching_component_ids = AHashMap::new();
|
|
'next_component: for (component_id, component) in
|
|
event.data.event.components.iter().enumerate()
|
|
{
|
|
if !component.component_type.is_event_or_todo() {
|
|
continue 'next_component;
|
|
}
|
|
|
|
let mut is_cancelled = false;
|
|
let mut is_main_event = true;
|
|
let mut busy_status = None;
|
|
|
|
for entry in component.entries.iter() {
|
|
match (&entry.name, entry.values.first()) {
|
|
(
|
|
ArchivedICalendarProperty::Status,
|
|
Some(ArchivedICalendarValue::Status(
|
|
ArchivedICalendarStatus::Cancelled,
|
|
)),
|
|
) => {
|
|
// The "status" property of the event is not "cancelled".
|
|
is_cancelled = true;
|
|
}
|
|
(ArchivedICalendarProperty::RecurrenceId, _) => {
|
|
is_main_event = false;
|
|
}
|
|
(
|
|
ArchivedICalendarProperty::Class,
|
|
Some(ArchivedICalendarValue::Classification(
|
|
ArchivedICalendarClassification::Confidential,
|
|
)),
|
|
) => {
|
|
// Condition: The event's "privacy" property is not "secret".
|
|
continue 'next_component;
|
|
}
|
|
(
|
|
ArchivedICalendarProperty::Transp,
|
|
Some(ArchivedICalendarValue::Transparency(
|
|
ArchivedICalendarTransparency::Transparent,
|
|
)),
|
|
) => {
|
|
// Condition: The "freeBusyStatus" property of the event is "busy" (or omitted, as this is the default).
|
|
continue 'next_component;
|
|
}
|
|
(ArchivedICalendarProperty::Attendee, Some(value))
|
|
if include_in_availability == IncludeInAvailability::Attending =>
|
|
{
|
|
if let Some(attendee) = value.as_text().and_then(|attendee| {
|
|
sanitize_email(
|
|
attendee.strip_prefix("mailto:").unwrap_or(attendee),
|
|
)
|
|
}) {
|
|
// Condition: the Principal is a participant of the event, and has a "participationStatus" of "accepted" or "tentative".
|
|
if principal_account.addresses().contains(&attendee) {
|
|
busy_status = Some(
|
|
entry
|
|
.parameters(&ICalendarParameterName::Partstat)
|
|
.next()
|
|
.map(|v| {
|
|
match v {
|
|
ArchivedICalendarParameterValue::Partstat(
|
|
ArchivedICalendarParticipationStatus::Accepted,
|
|
) => BusyStatus::Confirmed,
|
|
ArchivedICalendarParameterValue::Partstat(
|
|
ArchivedICalendarParticipationStatus::Tentative,
|
|
) => BusyStatus::Tentative,
|
|
ArchivedICalendarParameterValue::Partstat(
|
|
ArchivedICalendarParticipationStatus::Declined,
|
|
) => {
|
|
is_cancelled = true;
|
|
BusyStatus::Unavailable
|
|
}
|
|
_ => BusyStatus::Unavailable,
|
|
}
|
|
})
|
|
.unwrap_or(BusyStatus::Unavailable),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
|
|
if is_cancelled {
|
|
if is_main_event {
|
|
continue 'next_event;
|
|
} else {
|
|
continue 'next_component;
|
|
}
|
|
}
|
|
|
|
let busy_status = if let Some(busy_status) = busy_status {
|
|
busy_status
|
|
} else if include_in_availability == IncludeInAvailability::All {
|
|
BusyStatus::Confirmed
|
|
} else {
|
|
continue 'next_component;
|
|
};
|
|
|
|
matching_component_ids.insert(component_id as u32, busy_status);
|
|
}
|
|
|
|
if matching_component_ids.is_empty() {
|
|
// No events matched the criteria
|
|
continue 'next_event;
|
|
}
|
|
|
|
for expansion in event.data.expand(default_tz, filter).unwrap_or_default() {
|
|
let Some(busy_status) = matching_component_ids.get(&expansion.comp_id) else {
|
|
continue;
|
|
};
|
|
if periods.len() < max_instances {
|
|
periods.push(FreeBusyResult {
|
|
utc_start: expansion.start,
|
|
utc_end: expansion.end,
|
|
busy_status: *busy_status,
|
|
expansion_id: expansion.comp_id,
|
|
document_id,
|
|
});
|
|
} else {
|
|
return Err(trc::JmapEvent::RequestTooLarge
|
|
.into_err()
|
|
.details("The number of expanded instances exceeds the server limit"));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut result = GetAvailabilityResponse {
|
|
list: Vec::with_capacity(periods.len()),
|
|
};
|
|
|
|
if periods.is_empty() {
|
|
return Ok(result);
|
|
}
|
|
|
|
// Sort by busy status and start time
|
|
periods.sort_unstable_by(|a, b| {
|
|
a.busy_status
|
|
.cmp(&b.busy_status)
|
|
.then_with(|| a.utc_start.cmp(&b.utc_start))
|
|
});
|
|
|
|
if return_event_details {
|
|
for period in periods {
|
|
result.list.push(period.into());
|
|
}
|
|
} else {
|
|
// Merge intervals with same busy status
|
|
let mut start_time = periods[0].utc_start;
|
|
let mut end_time = periods[0].utc_end;
|
|
let mut current_status = periods[0].busy_status;
|
|
|
|
for curr in periods.iter().skip(1) {
|
|
if curr.utc_start <= end_time && curr.busy_status == current_status {
|
|
end_time = end_time.max(curr.utc_end);
|
|
} else {
|
|
result.list.push(BusyPeriod {
|
|
utc_start: UTCDate::from_timestamp(start_time),
|
|
utc_end: UTCDate::from_timestamp(end_time),
|
|
busy_status: Some(current_status),
|
|
event: None,
|
|
});
|
|
start_time = curr.utc_start;
|
|
end_time = curr.utc_end;
|
|
current_status = curr.busy_status;
|
|
}
|
|
}
|
|
|
|
result.list.push(BusyPeriod {
|
|
utc_start: UTCDate::from_timestamp(start_time),
|
|
utc_end: UTCDate::from_timestamp(end_time),
|
|
busy_status: Some(current_status),
|
|
event: None,
|
|
});
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
}
|
|
|
|
struct FreeBusyResult {
|
|
utc_start: i64,
|
|
utc_end: i64,
|
|
busy_status: BusyStatus,
|
|
expansion_id: u32,
|
|
document_id: u32,
|
|
}
|
|
|
|
impl From<FreeBusyResult> for BusyPeriod {
|
|
fn from(value: FreeBusyResult) -> Self {
|
|
BusyPeriod {
|
|
utc_start: UTCDate::from_timestamp(value.utc_start),
|
|
utc_end: UTCDate::from_timestamp(value.utc_end),
|
|
busy_status: Some(value.busy_status),
|
|
event: JSCalendar(Value::Object(Map::from(vec![
|
|
(
|
|
Key::Property(JSCalendarProperty::Id),
|
|
Value::Element(JSCalendarValue::Id(<Id as CalendarSyntheticId>::new(
|
|
value.expansion_id,
|
|
value.document_id,
|
|
))),
|
|
),
|
|
(
|
|
Key::Property(JSCalendarProperty::BaseEventId),
|
|
Value::Element(JSCalendarValue::Id(Id::from(value.document_id))),
|
|
),
|
|
])))
|
|
.into(),
|
|
}
|
|
}
|
|
}
|