Skip to content
Closed
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
154 changes: 68 additions & 86 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2994,99 +2992,83 @@
* @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);

Check failure on line 3040 in apps/dav/lib/CalDAV/CalDavBackend.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedClass

apps/dav/lib/CalDAV/CalDavBackend.php:3040:19: UndefinedClass: Class, interface or enum named OCA\DAV\CalDAV\EventReaderRRule does not exist (see https://psalm.dev/019)
$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;
}

/**
Expand Down
19 changes: 19 additions & 0 deletions apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
];
}

Expand Down
29 changes: 29 additions & 0 deletions build/psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,35 @@
<NullableReturnStatement>
<code><![CDATA[null]]></code>
</NullableReturnStatement>
<UndefinedMagicPropertyFetch>
<code><![CDATA[$component->CLASS]]></code>
<code><![CDATA[$component->CLASS]]></code>
<code><![CDATA[$component->CLASS]]></code>
<code><![CDATA[$component->DTEND]]></code>
<code><![CDATA[$component->DTEND]]></code>
<code><![CDATA[$component->DTEND]]></code>
<code><![CDATA[$component->DTSTART]]></code>
<code><![CDATA[$component->DTSTART]]></code>
<code><![CDATA[$component->DTSTART]]></code>
<code><![CDATA[$component->DUE]]></code>
<code><![CDATA[$component->DUE]]></code>
<code><![CDATA[$component->DUE]]></code>
<code><![CDATA[$component->DURATION]]></code>
<code><![CDATA[$component->DURATION]]></code>
<code><![CDATA[$component->DURATION]]></code>
<code><![CDATA[$component->RDATE]]></code>
<code><![CDATA[$component->RDATE]]></code>
<code><![CDATA[$component->RDATE]]></code>
<code><![CDATA[$component->RDATE]]></code>
<code><![CDATA[$component->RDATE]]></code>
<code><![CDATA[$component->RDATE]]></code>
<code><![CDATA[$component->RRULE]]></code>
<code><![CDATA[$component->RRULE]]></code>
<code><![CDATA[$component->RRULE]]></code>
<code><![CDATA[$component->UID]]></code>
<code><![CDATA[$component->UID]]></code>
<code><![CDATA[$component->UID]]></code>
</UndefinedMagicPropertyFetch>
</file>
<file src="apps/dav/lib/CalDAV/CalendarHome.php">
<InvalidNullableReturnType>
Expand Down
Loading