diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php
index 6268693649ca3..9c74014548726 100644
--- a/apps/dav/lib/CalDAV/CalDavBackend.php
+++ b/apps/dav/lib/CalDAV/CalDavBackend.php
@@ -93,12 +93,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;
@@ -2985,99 +2983,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 ae3cf4aa5fe23..d4f2fd67df973 100644
--- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
+++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
@@ -687,6 +687,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 f9b990683efb5..691f71961f4c7 100644
--- a/build/psalm-baseline.xml
+++ b/build/psalm-baseline.xml
@@ -223,6 +223,35 @@
}, $this->db)]]>
+
+ 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]]>
+