Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 111 additions & 60 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -3423,75 +3423,126 @@ public function getDenormalizedData(string $calendarData): array {
// validate data and extract base component
/** @var VCalendar $vObject */
$vObject = Reader::read($calendarData);
/** @var \Sabre\VObject\Component\VEvent[]|\Sabre\VObject\Component\VTodo[]|\Sabre\VObject\Component\VJournal[] $components */
$components = $vObject->getBaseComponents();
if (count($components) !== 1) {
throw new BadRequest('A valid calendar object must contain at least one VJOURNAL, VEVENT, or VTODO component type');
}
$component = $components[0];
// extract basic information
$derived['componentType'] = $component->name;
$derived['uid'] = $component->UID ? $component->UID->getValue() : null;
$derived['classification'] = $component->CLASS ? match ($component->CLASS->getValue()) {
'PUBLIC' => self::CLASSIFICATION_PUBLIC,
'CONFIDENTIAL' => self::CLASSIFICATION_CONFIDENTIAL,
default => self::CLASSIFICATION_PRIVATE,
} : self::CLASSIFICATION_PUBLIC;
// extract start and end dates
// VTODO components can have no start date
/** @var */
$startDate = $component->DTSTART instanceof \Sabre\VObject\Property\ICalendar\DateTime ? $component->DTSTART->getDateTime() : null;
$endDate = $startDate ? clone $startDate : null;
if ($startDate) {
// Recurring
if ($component->RRULE || $component->RDATE) {
// RDATE can have both instances and multiple values
// RDATE;TZID=America/Toronto:20250701T000000,20260701T000000
// RDATE;TZID=America/Toronto:20270701T000000
if ($component->RDATE) {
foreach ($component->RDATE as $instance) {
foreach ($instance->getDateTimes() as $entry) {
if ($entry > $endDate) {
$endDate = $entry;

// Extracts componentType, uid, classification, firstOccurence and lastOccurence from a single event/todo/journal component.
// RECURRENCE-ID is irrelevant here: it plays no part in this computation, so it works just as well on a recurrence exception as
// it does on a series master or a non-recurring component.
$extract = function (Component $component): array {
$data = [];
$data['componentType'] = $component->name;
$data['uid'] = $component->UID ? $component->UID->getValue() : null;
$data['classification'] = $component->CLASS ? match ($component->CLASS->getValue()) {
'PUBLIC' => self::CLASSIFICATION_PUBLIC,
'CONFIDENTIAL' => self::CLASSIFICATION_CONFIDENTIAL,
default => self::CLASSIFICATION_PRIVATE,
} : self::CLASSIFICATION_PUBLIC;
// extract start and end dates
// VTODO components can have no start date
$startDate = $component->DTSTART instanceof \Sabre\VObject\Property\ICalendar\DateTime ? $component->DTSTART->getDateTime() : null;
$endDate = $startDate ? clone $startDate : null;
if ($startDate) {
// Recurring
if ($component->RRULE || $component->RDATE) {
// RDATE can have both instances and multiple values
// RDATE;TZID=America/Toronto:20250701T000000,20260701T000000
// RDATE;TZID=America/Toronto:20270701T000000
if ($component->RDATE) {
foreach ($component->RDATE as $instance) {
foreach ($instance->getDateTimes() as $entry) {
if ($entry > $endDate) {
$endDate = $entry;
}
}
}
}
}
// RRULE can be infinate or limited by a UNTIL or COUNT
if ($component->RRULE) {
try {
$rule = new EventReaderRRule($component->RRULE->getValue(), $startDate);
$endDate = $rule->isInfinite() ? new DateTime(self::MAX_DATE) : $rule->concludes();
} catch (NoInstancesException $e) {
$this->logger->debug('Caught no instance exception for calendar data. This usually indicates invalid calendar data.', [
'app' => 'dav',
'exception' => $e,
]);
throw new Forbidden($e->getMessage());
// RRULE can be infinate or limited by a UNTIL or COUNT
$isInfinite = false;
if ($component->RRULE) {
try {
$rule = new EventReaderRRule($component->RRULE->getValue(), $startDate);
$isInfinite = $rule->isInfinite();
$endDate = $isInfinite ? new DateTime(self::MAX_DATE) : $rule->concludes();
} catch (NoInstancesException $e) {
$this->logger->debug('Caught no instance exception for calendar data. This usually indicates invalid calendar data.', [
'app' => 'dav',
'exception' => $e,
]);
throw new Forbidden($e->getMessage());
}
}
// $endDate is still just the start of the last occurrence at this point.
// Add the duration of a single occurrence so time-range searches keep
// matching this object for as long as that last occurrence is ongoing.
// Skip this for an infinite RRULE, since $endDate is already the sentinel MAX_DATE.
if (!$isInfinite) {
if ($component->DTEND instanceof \Sabre\VObject\Property\ICalendar\DateTime) {
$endDate = $endDate->add($startDate->diff($component->DTEND->getDateTime()));
} elseif ($component->DURATION instanceof \Sabre\VObject\Property\ICalendar\Duration) {
$endDate = $endDate->add($component->DURATION->getDateInterval());
} elseif ($component->DUE instanceof \Sabre\VObject\Property\ICalendar\DateTime) {
$endDate = $endDate->add($startDate->diff($component->DUE->getDateTime()));
} elseif ($component->name === 'VEVENT' && !$component->DTSTART->hasTime()) {
$endDate = $endDate->modify('+1 day');
}
}
// Singleton
} else {
if ($component->DTEND instanceof \Sabre\VObject\Property\ICalendar\DateTime) {
// VEVENT component types
$endDate = $component->DTEND->getDateTime();
} elseif ($component->DURATION instanceof \Sabre\VObject\Property\ICalendar\Duration) {
// VEVENT / VTODO component types
$endDate = $startDate->add($component->DURATION->getDateInterval());
} elseif ($component->DUE instanceof \Sabre\VObject\Property\ICalendar\DateTime) {
// VTODO component types
$endDate = $component->DUE->getDateTime();
} elseif ($component->name === 'VEVENT' && !$component->DTSTART->hasTime()) {
// VEVENT component type without time is automatically one day
$endDate = (clone $startDate)->modify('+1 day');
}
}
// Singleton
}
// convert dates to timestamp and prevent negative values
$data['firstOccurence'] = $startDate ? max(0, $startDate->getTimestamp()) : 0;
$data['lastOccurence'] = $endDate ? max(0, $endDate->getTimestamp()) : 0;

return $data;
};

// $extract() only understands these component types; getBaseComponent() itself is not
// restricted to them and may also return e.g. a VFREEBUSY or VAVAILABILITY component.
$supportedComponentTypes = ['VEVENT', 'VTODO', 'VJOURNAL'];

// If there is exactly one base component of a supported type, extract its data directly.
$baseComponent = $vObject->getBaseComponent();
if ($baseComponent !== null && in_array($baseComponent->name, $supportedComponentTypes, true)) {
return $derived + $extract($baseComponent);
}

// No supported base component is present, e.g. a scheduling object made up solely of recurrence exceptions without an
// accompanying series master (for example, an attendee that was only added to specific occurrences of a recurring event).
// Derive the denormalized data by combining the occurrence range of every exception present.
/** @var list<Component> $exceptionComponents */
$exceptionComponents = array_values(array_filter(
$vObject->getComponents(),
static fn (Component $component): bool => in_array($component->name, $supportedComponentTypes, true)
));
if (empty($exceptionComponents)) {
throw new BadRequest('A valid calendar object must contain at least one VJOURNAL, VEVENT, or VTODO component type');
}

$combined = null;
foreach ($exceptionComponents as $component) {
$exceptionData = $extract($component);
if ($combined === null) {
$combined = $exceptionData;
} else {
if ($component->DTEND instanceof \Sabre\VObject\Property\ICalendar\DateTime) {
// VEVENT component types
$endDate = $component->DTEND->getDateTime();
} elseif ($component->DURATION instanceof \Sabre\VObject\Property\ICalendar\Duration) {
// VEVENT / VTODO component types
$endDate = $startDate->add($component->DURATION->getDateInterval());
} elseif ($component->DUE instanceof \Sabre\VObject\Property\ICalendar\DateTime) {
// VTODO component types
$endDate = $component->DUE->getDateTime();
} elseif ($component->name === 'VEVENT' && !$component->DTSTART->hasTime()) {
// VEVENT component type without time is automatically one day
$endDate = (clone $startDate)->modify('+1 day');
}
$combined['firstOccurence'] = min($combined['firstOccurence'], $exceptionData['firstOccurence']);
$combined['lastOccurence'] = max($combined['lastOccurence'], $exceptionData['lastOccurence']);
}
}
// convert dates to timestamp and prevent negative values
$derived['firstOccurence'] = $startDate ? max(0, $startDate->getTimestamp()) : 0;
$derived['lastOccurence'] = $endDate ? max(0, $endDate->getTimestamp()) : 0;

return $derived;
return $derived + $combined;
}

/**
Expand Down
26 changes: 26 additions & 0 deletions apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use OCP\IConfig;
use OCP\IL10N;
use Psr\Log\NullLogger;
use Sabre\DAV\Exception\BadRequest;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\PropPatch;
use Sabre\DAV\Xml\Property\Href;
Expand Down Expand Up @@ -871,10 +872,35 @@ public static function providesCalDataForGetDenormalizedData(): array {
'VEVENT with DURATION instead of DTEND' => [(new DateTime('2024-03-01T11:00:00Z'))->getTimestamp(), 'lastOccurence', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:vevent-duration@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nDURATION:PT2H\r\nSUMMARY:Event with duration\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],
'all-day VEVENT without DTEND defaults to one day' => [(new DateTime('2024-03-02'))->getTimestamp(), 'lastOccurence', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:vevent-allday@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART;VALUE=DATE:20240301\r\nSUMMARY:All day event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],
'VEVENT with RDATE only (no RRULE) uses latest RDATE as last occurrence' => [(new DateTime('2024-03-10T09:00:00Z'))->getTimestamp(), 'lastOccurence', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:vevent-rdate-only@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nRDATE:20240305T090000Z,20240310T090000Z\r\nSUMMARY:Event with RDATE only\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],
'VEVENT with RDATE only and a DURATION includes duration of the last occurrence' => [(new DateTime('2024-03-10T09:30:00Z'))->getTimestamp(), 'lastOccurence', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:vevent-rdate-duration@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nDURATION:PT30M\r\nRDATE:20240305T090000Z,20240310T090000Z\r\nSUMMARY:Event with RDATE and duration\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],
'VEVENT with RRULE COUNT and DTEND includes duration of the last occurrence' => [(new DateTime('2024-03-03T10:00:00Z'))->getTimestamp(), 'lastOccurence', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:vevent-rrule-count-dtend@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nDTEND:20240301T100000Z\r\nRRULE:FREQ=DAILY;COUNT=3\r\nSUMMARY:Event with RRULE count and DTEND\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],
'component without UID' => [null, 'uid', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nSUMMARY:Event without UID\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],

// Recurrence exception without an accompanying master, e.g. an iTip
// REQUEST for an attendee that was only added to a single occurrence
'exception without master resolves componentType from the exception' => ['VEVENT', 'componentType', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:exception-only@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nDTEND:20240301T100000Z\r\nRECURRENCE-ID:20240301T090000Z\r\nSUMMARY:Occurrence exception without a master\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],
'exception without master keeps its own uid' => ['exception-only@example.com', 'uid', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:exception-only@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nDTEND:20240301T100000Z\r\nRECURRENCE-ID:20240301T090000Z\r\nSUMMARY:Occurrence exception without a master\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],
'exception without master uses its own DTSTART/DTEND for occurrence range' => [(new DateTime('2024-03-01T10:00:00Z'))->getTimestamp(), 'lastOccurence', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:exception-only@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nDTEND:20240301T100000Z\r\nRECURRENCE-ID:20240301T090000Z\r\nSUMMARY:Occurrence exception without a master\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],

// Multiple recurrence exceptions without a master, e.g. an attendee
// added to several specific occurrences of a recurring event at once
'multiple exceptions without master use earliest DTSTART as firstOccurence' => [(new DateTime('2024-03-01T09:00:00Z'))->getTimestamp(), 'firstOccurence', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:multi-exception@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nDTEND:20240301T150000Z\r\nRECURRENCE-ID:20240301T090000Z\r\nSUMMARY:Long occurrence\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:multi-exception@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240305T090000Z\r\nDTEND:20240305T093000Z\r\nRECURRENCE-ID:20240305T090000Z\r\nSUMMARY:Short later occurrence\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],
'multiple exceptions without master use latest DTEND as lastOccurence regardless of which exception is earliest' => [(new DateTime('2024-03-05T09:30:00Z'))->getTimestamp(), 'lastOccurence', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:multi-exception@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nDTEND:20240301T150000Z\r\nRECURRENCE-ID:20240301T090000Z\r\nSUMMARY:Long occurrence\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:multi-exception@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240305T090000Z\r\nDTEND:20240305T093000Z\r\nRECURRENCE-ID:20240305T090000Z\r\nSUMMARY:Short later occurrence\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],
'multiple exceptions without master resolve uid' => ['multi-exception@example.com', 'uid', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:multi-exception@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nDTEND:20240301T150000Z\r\nRECURRENCE-ID:20240301T090000Z\r\nSUMMARY:Long occurrence\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nUID:multi-exception@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240305T090000Z\r\nDTEND:20240305T093000Z\r\nRECURRENCE-ID:20240305T090000Z\r\nSUMMARY:Short later occurrence\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"],
];
}

public function testGetDenormalizedDataRejectsObjectWithOnlyUnsupportedBaseComponent(): void {
// A VFREEBUSY is a valid getBaseComponent() candidate (it's neither VTIMEZONE
// nor carries RECURRENCE-ID), but it's not a type the extraction logic supports.
$calData = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VFREEBUSY\r\nUID:freebusy-only@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nDTEND:20240301T100000Z\r\nEND:VFREEBUSY\r\nEND:VCALENDAR\r\n";

$this->expectException(BadRequest::class);
$this->expectExceptionMessage('A valid calendar object must contain at least one VJOURNAL, VEVENT, or VTODO component type');

$this->backend->getDenormalizedData($calData);
}

public function testCalendarSearch(): void {
$calendarId = $this->createTestCalendar();

Expand Down
Loading