From 496acc902d4c007bd627f3b214a317f00beecc83 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Mon, 14 Sep 2026 11:38:37 -0400 Subject: [PATCH] fix(CalDAV): extract data from occurance exemptions Signed-off-by: SebastianKrupinski --- apps/dav/lib/CalDAV/CalDavBackend.php | 171 ++++++++++++------ .../tests/unit/CalDAV/CalDavBackendTest.php | 26 +++ 2 files changed, 137 insertions(+), 60 deletions(-) diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 2f093e1b49ffb..26c27476a4ecc 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -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 $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; } /** diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index 75bd0f118906f..4e37a7d47e2b4 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -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; @@ -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();