From cd60a287b513e2fd04a5bea51c4f172621cc6129 Mon Sep 17 00:00:00 2001 From: Anna Larch Date: Tue, 17 Sep 2024 12:59:55 +0200 Subject: [PATCH] fix(caldav): improved data extraction for all component types Signed-off-by: SebastianKrupinski --- apps/dav/lib/CalDAV/CalDavBackend.php | 154 ++++++++---------- .../tests/unit/CalDAV/CalDavBackendTest.php | 19 +++ build/psalm-baseline.xml | 29 ++++ 3 files changed, 116 insertions(+), 86 deletions(-) diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 3fac670e12288..00ea027ef5ca6 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -92,12 +92,10 @@ use Sabre\VObject\Component; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VTimeZone; -use Sabre\VObject\DateTimeParser; use Sabre\VObject\InvalidDataException; use Sabre\VObject\ParseException; use Sabre\VObject\Property; use Sabre\VObject\Reader; -use Sabre\VObject\Recur\EventIterator; use Sabre\VObject\Recur\MaxInstancesExceededException; use Sabre\VObject\Recur\NoInstancesException; use function array_column; @@ -2994,99 +2992,83 @@ public function restoreChanges(int $calendarId, int $calendarType = self::CALEND * @return array */ public function getDenormalizedData(string $calendarData): array { + + $derived = [ + 'etag' => md5($calendarData), + 'size' => strlen($calendarData), + ]; + // validate data and extract base component + /** @var VCalendar $vObject */ $vObject = Reader::read($calendarData); - $vEvents = []; - $componentType = null; - $component = null; - $firstOccurrence = null; - $lastOccurrence = null; - $uid = null; - $classification = self::CLASSIFICATION_PUBLIC; - $hasDTSTART = false; - foreach ($vObject->getComponents() as $component) { - if ($component->name !== 'VTIMEZONE') { - // Finding all VEVENTs, and track them - if ($component->name === 'VEVENT') { - $vEvents[] = $component; - if ($component->DTSTART) { - $hasDTSTART = true; + /** @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; + } + } } } - // Track first component type and uid - if ($uid === null) { - $componentType = $component->name; - $uid = (string)$component->UID; - } - } - } - if (!$componentType) { - throw new BadRequest('Calendar objects must have a VJOURNAL, VEVENT or VTODO component'); - } - - if ($hasDTSTART) { - $component = $vEvents[0]; - - // Finding the last occurrence is a bit harder - if (!isset($component->RRULE) && count($vEvents) === 1) { - $firstOccurrence = $component->DTSTART->getDateTime()->getTimeStamp(); - if (isset($component->DTEND)) { - $lastOccurrence = $component->DTEND->getDateTime()->getTimeStamp(); - } elseif (isset($component->DURATION)) { - $endDate = clone $component->DTSTART->getDateTime(); - $endDate->add(DateTimeParser::parse($component->DURATION->getValue())); - $lastOccurrence = $endDate->getTimeStamp(); - } elseif (!$component->DTSTART->hasTime()) { - $endDate = clone $component->DTSTART->getDateTime(); - $endDate->modify('+1 day'); - $lastOccurrence = $endDate->getTimeStamp(); - } else { - $lastOccurrence = $firstOccurrence; + // 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()); + } } + // Singleton } else { - try { - $it = new EventIterator($vEvents); - } 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()); - } - $maxDate = new DateTime(self::MAX_DATE); - $firstOccurrence = $it->getDtStart()->getTimestamp(); - if ($it->isInfinite()) { - $lastOccurrence = $maxDate->getTimestamp(); - } else { - $end = $it->getDtEnd(); - while ($it->valid() && $end < $maxDate) { - $end = $it->getDtEnd(); - $it->next(); - } - $lastOccurrence = $end->getTimestamp(); + 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'); } } } + // convert dates to timestamp and prevent negative values + $derived['firstOccurence'] = $startDate ? max(0, $startDate->getTimestamp()) : 0; + $derived['lastOccurence'] = $endDate ? max(0, $endDate->getTimestamp()) : 0; - if ($component->CLASS) { - $classification = CalDavBackend::CLASSIFICATION_PRIVATE; - switch ($component->CLASS->getValue()) { - case 'PUBLIC': - $classification = CalDavBackend::CLASSIFICATION_PUBLIC; - break; - case 'CONFIDENTIAL': - $classification = CalDavBackend::CLASSIFICATION_CONFIDENTIAL; - break; - } - } - return [ - 'etag' => md5($calendarData), - 'size' => strlen($calendarData), - 'componentType' => $componentType, - 'firstOccurence' => is_null($firstOccurrence) ? null : max(0, $firstOccurrence), - 'lastOccurence' => is_null($lastOccurrence) ? null : max(0, $lastOccurrence), - 'uid' => $uid, - 'classification' => $classification - ]; + return $derived; } /** diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index 1295b558b71cb..36bc566ea0b21 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -697,6 +697,25 @@ public function providesCalDataForGetDenormalizedData(): array { 'no class set -> public' => [CalDavBackend::CLASSIFICATION_PUBLIC, 'classification', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//dmfs.org//mimedir.icalendar//EN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nX-LIC-LOCATION:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nTZNAME:CEST\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nTZNAME:CET\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nDTSTART;TZID=Europe/Berlin:20160419T130000\r\nSUMMARY:Test\r\nTRANSP:OPAQUE\r\nDTEND;TZID=Europe/Berlin:20160419T140000\r\nLAST-MODIFIED:20160419T074202Z\r\nDTSTAMP:20160419T074202Z\r\nCREATED:20160419T074202Z\r\nUID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310\r\nEND:VEVENT\r\nEND:VCALENDAR"], 'unknown class -> private' => [CalDavBackend::CLASSIFICATION_PRIVATE, 'classification', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//dmfs.org//mimedir.icalendar//EN\r\nBEGIN:VTIMEZONE\r\nTZID:Europe/Berlin\r\nX-LIC-LOCATION:Europe/Berlin\r\nBEGIN:DAYLIGHT\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nTZNAME:CEST\r\nDTSTART:19700329T020000\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nTZNAME:CET\r\nDTSTART:19701025T030000\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nDTSTART;TZID=Europe/Berlin:20160419T130000\r\nSUMMARY:Test\r\nCLASS:VERTRAULICH\r\nTRANSP:OPAQUE\r\nSTATUS:CONFIRMED\r\nDTEND;TZID=Europe/Berlin:20160419T140000\r\nLAST-MODIFIED:20160419T074202Z\r\nDTSTAMP:20160419T074202Z\r\nCREATED:20160419T074202Z\r\nUID:2e468c48-7860-492e-bc52-92fa0daeeccf.1461051722310\r\nEND:VEVENT\r\nEND:VCALENDAR"], + + // VTODO component type + 'VTODO uid' => ['20070313T123432Z-456553@example.com', 'uid', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTODO\r\nUID:20070313T123432Z-456553@example.com\r\nDTSTAMP:20070313T123432Z\r\nDUE;VALUE=DATE:20070501\r\nSUMMARY:Submit Quebec Income Tax Return for 2006\r\nCLASS:CONFIDENTIAL\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"], + 'VTODO componentType' => ['VTODO', 'componentType', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTODO\r\nUID:20070313T123432Z-456553@example.com\r\nDTSTAMP:20070313T123432Z\r\nDUE;VALUE=DATE:20070501\r\nSUMMARY:Submit Quebec Income Tax Return for 2006\r\nCLASS:CONFIDENTIAL\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"], + 'VTODO classification' => [CalDavBackend::CLASSIFICATION_CONFIDENTIAL, 'classification', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTODO\r\nUID:20070313T123432Z-456553@example.com\r\nDTSTAMP:20070313T123432Z\r\nDUE;VALUE=DATE:20070501\r\nSUMMARY:Submit Quebec Income Tax Return for 2006\r\nCLASS:CONFIDENTIAL\r\nSTATUS:NEEDS-ACTION\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"], + 'VTODO with DTSTART and DUE takes DUE as last occurrence' => [(new DateTime('2024-03-01T17:00:00Z'))->getTimestamp(), 'lastOccurence', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTODO\r\nUID:vtodo-with-due@example.com\r\nDTSTAMP:20240301T080000Z\r\nDTSTART:20240301T090000Z\r\nDUE:20240301T170000Z\r\nSUMMARY:Task with start and due\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"], + 'VTODO with only DUE and no DTSTART has no occurrences' => [0, 'lastOccurence', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTODO\r\nUID:vtodo-due-only@example.com\r\nDTSTAMP:20240301T080000Z\r\nDUE:20240301T170000Z\r\nSUMMARY:Task with due only\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"], + + // VJOURNAL component type + 'VJOURNAL uid' => ['19970901T130000Z-123405@example.com', 'uid', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VJOURNAL\r\nUID:19970901T130000Z-123405@example.com\r\nDTSTAMP:19970901T130000Z\r\nDTSTART;VALUE=DATE:19970317\r\nSUMMARY:Staff meeting minutes\r\nEND:VJOURNAL\r\nEND:VCALENDAR\r\n"], + 'VJOURNAL componentType' => ['VJOURNAL', 'componentType', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VJOURNAL\r\nUID:19970901T130000Z-123405@example.com\r\nDTSTAMP:19970901T130000Z\r\nDTSTART;VALUE=DATE:19970317\r\nSUMMARY:Staff meeting minutes\r\nEND:VJOURNAL\r\nEND:VCALENDAR\r\n"], + 'VJOURNAL without CLASS -> public' => [CalDavBackend::CLASSIFICATION_PUBLIC, 'classification', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VJOURNAL\r\nUID:19970901T130000Z-123405@example.com\r\nDTSTAMP:19970901T130000Z\r\nDTSTART;VALUE=DATE:19970317\r\nSUMMARY:Staff meeting minutes\r\nEND:VJOURNAL\r\nEND:VCALENDAR\r\n"], + 'VJOURNAL last occurrence equals first occurrence (not a VEVENT)' => [(new DateTime('1997-03-17'))->getTimestamp(), 'lastOccurence', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//SabreDAV//SabreDAV//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VJOURNAL\r\nUID:19970901T130000Z-123405@example.com\r\nDTSTAMP:19970901T130000Z\r\nDTSTART;VALUE=DATE:19970317\r\nSUMMARY:Staff meeting minutes\r\nEND:VJOURNAL\r\nEND:VCALENDAR\r\n"], + + // Additional VEVENT branches + '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"], + '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"], ]; } diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index 3efb9c3089fed..1cb04c690a37f 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -122,6 +122,35 @@ + + CLASS]]> + CLASS]]> + CLASS]]> + DTEND]]> + DTEND]]> + DTEND]]> + DTSTART]]> + DTSTART]]> + DTSTART]]> + DUE]]> + DUE]]> + DUE]]> + DURATION]]> + DURATION]]> + DURATION]]> + RDATE]]> + RDATE]]> + RDATE]]> + RDATE]]> + RDATE]]> + RDATE]]> + RRULE]]> + RRULE]]> + RRULE]]> + UID]]> + UID]]> + UID]]> +