Skip to content
Open
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
fix(caldav): improved data extraction for all component types
Signed-off-by: SebastianKrupinski <[email protected]>
  • Loading branch information
miaulalala authored and SebastianKrupinski committed Nov 12, 2025
commit 64377b75a325e590199be1bf3b7d0d62755eb5eb
154 changes: 68 additions & 86 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,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 @@ -3082,99 +3080,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;
$components = $vObject->getBaseComponents();
if (count($components) !== 1) {
throw new BadRequest('Invalid calendar object must contain exactly 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;

return $derived;

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
];
}

/**
Expand Down
Loading