diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 5de755848aabe..d2593277241e5 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -67,6 +67,9 @@ 'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => $baseDir . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php', 'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php', 'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportService' => $baseDir . '/../lib/CalDAV/Import/ImportService.php', + 'OCA\\DAV\\CalDAV\\Import\\TextImporter' => $baseDir . '/../lib/CalDAV/Import/TextImporter.php', + 'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => $baseDir . '/../lib/CalDAV/Import/XmlImporter.php', 'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => $baseDir . '/../lib/CalDAV/Integration/ExternalCalendar.php', 'OCA\\DAV\\CalDAV\\Integration\\ICalendarProvider' => $baseDir . '/../lib/CalDAV/Integration/ICalendarProvider.php', 'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseServer' => $baseDir . '/../lib/CalDAV/InvitationResponse/InvitationResponseServer.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 5ad69cac7b2ce..a3c06fd392f26 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -82,6 +82,9 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => __DIR__ . '/..' . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php', 'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php', 'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportService' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportService.php', + 'OCA\\DAV\\CalDAV\\Import\\TextImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/TextImporter.php', + 'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/XmlImporter.php', 'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ExternalCalendar.php', 'OCA\\DAV\\CalDAV\\Integration\\ICalendarProvider' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ICalendarProvider.php', 'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseServer' => __DIR__ . '/..' . '/../lib/CalDAV/InvitationResponse/InvitationResponseServer.php', diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index d6270a08c72f5..82d47c5fa03b8 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -982,9 +982,9 @@ public function restoreCalendar(int $id): void { * @param int $calendarType * @return array */ - public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR):array { + public function getLimitedCalendarObjects(int $calendarId, int $calendarType = self::CALENDAR_TYPE_CALENDAR, array $fields = []):array { $query = $this->db->getQueryBuilder(); - $query->select(['id','uid', 'etag', 'uri', 'calendardata']) + $query->select($fields ?: ['id', 'uid', 'etag', 'uri', 'calendardata']) ->from('calendarobjects') ->where($query->expr()->eq('calendarid', $query->createNamedParameter($calendarId))) ->andWhere($query->expr()->eq('calendartype', $query->createNamedParameter($calendarType))) @@ -993,12 +993,7 @@ public function getLimitedCalendarObjects(int $calendarId, int $calendarType = s $result = []; while (($row = $stmt->fetch()) !== false) { - $result[$row['uid']] = [ - 'id' => $row['id'], - 'etag' => $row['etag'], - 'uri' => $row['uri'], - 'calendardata' => $row['calendardata'], - ]; + $result[$row['uid']] = $row; } $stmt->closeCursor(); diff --git a/apps/dav/lib/CalDAV/Import/ImportService.php b/apps/dav/lib/CalDAV/Import/ImportService.php new file mode 100644 index 0000000000000..56fa51049e71b --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/ImportService.php @@ -0,0 +1,186 @@ + + */ + public function importText($source): Generator { + if (!is_resource($source)) { + throw new InvalidArgumentException('Invalid import source must be a file resource'); + } + $importer = new TextImporter($source); + $structure = $importer->structure(); + $sObjectPrefix = $importer::OBJECT_PREFIX; + $sObjectSuffix = $importer::OBJECT_SUFFIX; + // calendar properties + foreach ($structure['VCALENDAR'] as $entry) { + if (!str_ends_with($entry, "\n") || !str_ends_with($entry, "\r\n")) { + $sObjectPrefix .= PHP_EOL; + } + } + // calendar time zones + $timezones = []; + foreach ($structure['VTIMEZONE'] as $tid => $collection) { + $instance = $collection[0]; + $sObjectContents = $importer->extract((int)$instance[2], (int)$instance[3]); + $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix); + $timezones[$tid] = clone $vObject->VTIMEZONE; + } + // calendar components + // for each component type, construct a full calendar object with all components + // that match the same UID and appropriate time zones that are used in the components + foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) { + foreach ($structure[$type] as $cid => $instances) { + /** @var array $instances */ + // extract all instances of component and unserialize to object + $sObjectContents = ''; + foreach ($instances as $instance) { + $sObjectContents .= $importer->extract($instance[2], $instance[3]); + } + /** @var VCalendar $vObject */ + $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix); + // add time zones to object + foreach ($this->findTimeZones($vObject) as $zone) { + if (isset($timezones[$zone])) { + $vObject->add(clone $timezones[$zone]); + } + } + yield $vObject; + } + } + } + + /** + * Generates object stream from a xml formatted source (xcal) + * + * @param resource $source + * + * @return Generator<\Sabre\VObject\Component\VCalendar> + */ + public function importXml($source): Generator { + if (!is_resource($source)) { + throw new InvalidArgumentException('Invalid import source must be a file resource'); + } + $importer = new XmlImporter($source); + $structure = $importer->structure(); + $sObjectPrefix = $importer::OBJECT_PREFIX; + $sObjectSuffix = $importer::OBJECT_SUFFIX; + // calendar time zones + $timezones = []; + foreach ($structure['VTIMEZONE'] as $tid => $collection) { + $instance = $collection[0]; + $sObjectContents = $importer->extract((int)$instance[2], (int)$instance[3]); + $vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix); + $timezones[$tid] = clone $vObject->VTIMEZONE; + } + // calendar components + // for each component type, construct a full calendar object with all components + // that match the same UID and appropriate time zones that are used in the components + foreach (['VEVENT', 'VTODO', 'VJOURNAL'] as $type) { + foreach ($structure[$type] as $cid => $instances) { + /** @var array $instances */ + // extract all instances of component and unserialize to object + $sObjectContents = ''; + foreach ($instances as $instance) { + $sObjectContents .= $importer->extract($instance[2], $instance[3]); + } + /** @var VCalendar $vObject */ + $vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix); + // add time zones to object + foreach ($this->findTimeZones($vObject) as $zone) { + if (isset($timezones[$zone])) { + $vObject->add(clone $timezones[$zone]); + } + } + yield $vObject; + } + } + } + + /** + * Generates object stream from a json formatted source (jcal) + * + * @param resource $source + * + * @return Generator<\Sabre\VObject\Component\VCalendar> + */ + public function importJson($source): Generator { + if (!is_resource($source)) { + throw new InvalidArgumentException('Invalid import source must be a file resource'); + } + /** @var VCALENDAR $importer */ + $importer = Reader::readJson($source); + // calendar time zones + $timezones = []; + foreach ($importer->VTIMEZONE as $timezone) { + $tzid = $timezone->TZID?->getValue(); + if ($tzid !== null) { + $timezones[$tzid] = clone $timezone; + } + } + // calendar components + foreach ($importer->getBaseComponents() as $base) { + $vObject = new VCalendar; + $vObject->VERSION = clone $importer->VERSION; + $vObject->PRODID = clone $importer->PRODID; + // extract all instances of component + foreach ($importer->getByUID($base->UID->getValue()) as $instance) { + $vObject->add(clone $instance); + } + // add time zones to object + foreach ($this->findTimeZones($vObject) as $zone) { + if (isset($timezones[$zone])) { + $vObject->add(clone $timezones[$zone]); + } + } + yield $vObject; + } + } + + /** + * Searches through all component properties looking for defined timezones + * + * @return array + */ + private function findTimeZones(VCalendar $vObject): array { + $timezones = []; + foreach ($vObject->getComponents() as $vComponent) { + if ($vComponent->name !== 'VTIMEZONE') { + foreach (['DTSTART', 'DTEND', 'DUE', 'RDATE', 'EXDATE'] as $property) { + if (isset($vComponent->$property?->parameters['TZID'])) { + $tid = $vComponent->$property->parameters['TZID']->getValue(); + $timezones[$tid] = true; + } + } + } + } + return array_keys($timezones); + } + +} diff --git a/apps/dav/lib/CalDAV/Import/TextImporter.php b/apps/dav/lib/CalDAV/Import/TextImporter.php new file mode 100644 index 0000000000000..7871c783bb8ce --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/TextImporter.php @@ -0,0 +1,156 @@ + [], 'VEVENT' => [], 'VTODO' => [], 'VJOURNAL' => [], 'VTIMEZONE' => []]; + + /** + * @param resource $source + */ + public function __construct( + private $source, + ) { + // Ensure that source is a stream resource + if (!is_resource($source) || get_resource_type($source) !== 'stream') { + throw new Exception('Source must be a stream resource'); + } + } + + /** + * Analyzes the source data and creates a structure of components + */ + private function analyze() { + $componentStart = null; + $componentEnd = null; + $componentId = null; + $componentType = null; + $tagName = null; + $tagValue = null; + + // iterate through the source data line by line + fseek($this->source, 0); + while (!feof($this->source)) { + $data = fgets($this->source); + // skip empty lines + if ($data === false || empty(trim($data))) { + continue; + } + // lines with whitespace at the beginning are continuations of the previous line + if (ctype_space($data[0]) === false) { + // detect the line TAG + // detect the first occurrence of ':' or ';' + $colonPos = strpos($data, ':'); + $semicolonPos = strpos($data, ';'); + if ($colonPos !== false && $semicolonPos !== false) { + $splitPosition = min($colonPos, $semicolonPos); + } elseif ($colonPos !== false) { + $splitPosition = $colonPos; + } elseif ($semicolonPos !== false) { + $splitPosition = $semicolonPos; + } else { + continue; + } + $tagName = strtoupper(trim(substr($data, 0, $splitPosition))); + $tagValue = trim(substr($data, $splitPosition + 1)); + $tagContinuation = false; + } else { + $tagContinuation = true; + $tagValue .= trim($data); + } + + if ($tagContinuation === false) { + // check line for component start, remember the position and determine the type + if ($tagName === 'BEGIN' && in_array($tagValue, self::COMPONENT_TYPES, true)) { + $componentStart = ftell($this->source) - strlen($data); + $componentType = $tagValue; + } + // check line for component end, remember the position + if ($tagName === 'END' && $componentType === $tagValue) { + $componentEnd = ftell($this->source); + } + // check line for component id + if ($componentStart !== null && ($tagName === 'UID' || $tagName === 'TZID')) { + $componentId = $tagValue; + } + } else { + // check line for component id + if ($componentStart !== null && ($tagName === 'UID' || $tagName === 'TZID')) { + $componentId = $tagValue; + } + } + // any line(s) not inside a component are VCALENDAR properties + if ($componentStart === null) { + if ($tagName !== 'BEGIN' && $tagName !== 'END' && $tagValue === 'VCALENDAR') { + $components['VCALENDAR'][] = $data; + } + } + // if component start and end are found, add the component to the structure + if ($componentStart !== null && $componentEnd !== null) { + if ($componentId !== null) { + $this->structure[$componentType][$componentId][] = [ + $componentType, + $componentId, + $componentStart, + $componentEnd + ]; + } else { + $this->structure[$componentType][] = [ + $componentType, + $componentId, + $componentStart, + $componentEnd + ]; + } + $componentId = null; + $componentType = null; + $componentStart = null; + $componentEnd = null; + } + } + } + + /** + * Returns the analyzed structure of the source data + * the analyzed structure is a collection of components organized by type, + * each entry is a collection of instances + * [ + * 'VEVENT' => [ + * '7456f141-b478-4cb9-8efc-1427ba0d6839' => [ + * ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 0, 100 ], + * ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 100, 200 ] + * ] + * ] + * ] + */ + public function structure(): array { + if (!$this->analyzed) { + $this->analyze(); + } + return $this->structure; + } + + /** + * Extracts a string chuck from the source data + * + * @param int $start starting byte position + * @param int $end ending byte position + */ + public function extract(int $start, int $end): string { + fseek($this->source, $start); + return fread($this->source, $end - $start); + } +} diff --git a/apps/dav/lib/CalDAV/Import/XmlImporter.php b/apps/dav/lib/CalDAV/Import/XmlImporter.php new file mode 100644 index 0000000000000..b37e65170ebfd --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/XmlImporter.php @@ -0,0 +1,173 @@ +'; + public const OBJECT_SUFFIX = ''; + private const COMPONENT_TYPES = ['VEVENT', 'VTODO', 'VJOURNAL', 'VTIMEZONE']; + + private bool $analyzed = false; + private array $structure = ['VCALENDAR' => [], 'VEVENT' => [], 'VTODO' => [], 'VJOURNAL' => [], 'VTIMEZONE' => []]; + private int $praseLevel = 0; + private array $prasePath = []; + private ?int $componentStart = null; + private ?int $componentEnd = null; + private int $componentLevel = 0; + private ?string $componentId = null; + private ?string $componentType = null; + private bool $componentIdProperty = false; + + /** + * @param resource $source + */ + public function __construct( + private $source, + ) { + // Ensure that source is a stream resource + if (!is_resource($source) || get_resource_type($source) !== 'stream') { + throw new Exception('Source must be a stream resource'); + } + } + + /** + * Analyzes the source data and creates a structure of components + */ + private function analyze() { + $this->praseLevel = 0; + $this->prasePath = []; + $this->componentStart = null; + $this->componentEnd = null; + $this->componentLevel = 0; + $this->componentId = null; + $this->componentType = null; + $this->componentIdProperty = false; + // Create the parser and assign tag handlers + $parser = xml_parser_create(); + xml_set_object($parser, $this); + xml_set_element_handler($parser, $this->tagStart(...), $this->tagEnd(...)); + xml_set_default_handler($parser, $this->tagContents(...)); + // iterate through the source data chuck by chunk to trigger the handlers + @fseek($this->source, 0); + while ($chunk = fread($this->source, 4096)) { + if (!xml_parse($parser, $chunk, feof($this->source))) { + throw new Exception( + xml_error_string(xml_get_error_code($parser)) + . ' At line: ' + . xml_get_current_line_number($parser) + ); + } + } + //Free up the parser + xml_parser_free($parser); + } + + /** + * Handles start of tag events from the parser for all tags + */ + private function tagStart(XMLParser $parser, string $tag, array $attributes): void { + // add the tag to the path tracker and increment depth the level + $this->praseLevel++; + $this->prasePath[$this->praseLevel] = $tag; + // determine if the tag is a component type and remember the byte position + if (in_array($tag, self::COMPONENT_TYPES, true)) { + $this->componentStart = xml_get_current_byte_index($parser) - (strlen($tag) + 1); + $this->componentType = $tag; + $this->componentLevel = $this->praseLevel; + } + // determine if the tag is a sub tag of the component and an id property + if ($this->componentStart !== null + && ($this->componentLevel + 2) === $this->praseLevel + && ($tag === 'UID' || $tag === 'TZID') + ) { + $this->componentIdProperty = true; + } + } + + /** + * Handles end of tag events from the parser for all tags + */ + private function tagEnd(XMLParser $parser, string $tag): void { + // if the end tag matched the component type or the component id property + // then add the component to the structure + if ($tag === 'UID' || $tag === 'TZID') { + $this->componentIdProperty = false; + } elseif ($this->componentType === $tag) { + $this->componentEnd = xml_get_current_byte_index($parser); + if ($this->componentId !== null) { + $this->structure[$this->componentType][$this->componentId][] = [ + $this->componentType, + $this->componentId, + $this->componentStart, + $this->componentEnd, + implode('/', $this->prasePath) + ]; + } else { + $this->structure[$this->componentType][] = [ + $this->componentType, + $this->componentId, + $this->componentStart, + $this->componentEnd, + implode('/', $this->prasePath) + ]; + } + $this->componentStart = null; + $this->componentEnd = null; + $this->componentId = null; + $this->componentType = null; + $this->componentIdProperty = false; + } + // remove the tag from the path tacker and depth the level + unset($this->prasePath[$this->praseLevel]); + $this->praseLevel--; + } + + /** + * Handles tag contents events from the parser for all tags + */ + private function tagContents(XMLParser $parser, string $data): void { + if ($this->componentIdProperty) { + $this->componentId = $data; + } + } + + /** + * Returns the analyzed structure of the source data + * the analyzed structure is a collection of components organized by type, + * each entry is a collection of instances + * [ + * 'VEVENT' => [ + * '7456f141-b478-4cb9-8efc-1427ba0d6839' => [ + * ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 0, 100 ], + * ['VEVENT', '7456f141-b478-4cb9-8efc-1427ba0d6839', 100, 200 ] + * ] + * ] + * ] + */ + public function structure(): array { + if (!$this->analyzed) { + $this->analyze(); + } + return $this->structure; + } + + /** + * Extracts a string chuck from the source data + * + * @param int $start starting byte position + * @param int $end ending byte position + */ + public function extract(int $start, int $end): string { + fseek($this->source, $start); + return fread($this->source, $end - $start); + } +} diff --git a/apps/dav/lib/CalDAV/WebcalCaching/Connection.php b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php index 3981f7cdb6065..559cff141b6c2 100644 --- a/apps/dav/lib/CalDAV/WebcalCaching/Connection.php +++ b/apps/dav/lib/CalDAV/WebcalCaching/Connection.php @@ -14,7 +14,6 @@ use OCP\Http\Client\LocalServerException; use OCP\IAppConfig; use Psr\Log\LoggerInterface; -use Sabre\VObject\Reader; class Connection { public function __construct( @@ -26,8 +25,10 @@ public function __construct( /** * gets webcal feed from remote server + * + * @return array{data: resource, format: string}|null */ - public function queryWebcalFeed(array $subscription): ?string { + public function queryWebcalFeed(array $subscription): ?array { $subscriptionId = $subscription['id']; $url = $this->cleanURL($subscription['source']); if ($url === null) { @@ -54,6 +55,7 @@ public function queryWebcalFeed(array $subscription): ?string { 'User-Agent' => $uaString, 'Accept' => 'text/calendar, application/calendar+json, application/calendar+xml', ], + 'stream' => true, ]; $user = parse_url($subscription['source'], PHP_URL_USER); @@ -77,42 +79,22 @@ public function queryWebcalFeed(array $subscription): ?string { return null; } - $body = $response->getBody(); - $contentType = $response->getHeader('Content-Type'); $contentType = explode(';', $contentType, 2)[0]; - switch ($contentType) { - case 'application/calendar+json': - try { - $jCalendar = Reader::readJson($body, Reader::OPTION_FORGIVING); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); - return null; - } - return $jCalendar->serialize(); - - case 'application/calendar+xml': - try { - $xCalendar = Reader::readXML($body); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); - return null; - } - return $xCalendar->serialize(); - - case 'text/calendar': - default: - try { - $vCalendar = Reader::read($body); - } catch (Exception $ex) { - // In case of a parsing error return null - $this->logger->warning("Subscription $subscriptionId could not be parsed", ['exception' => $ex]); - return null; - } - return $vCalendar->serialize(); + + $format = match ($contentType) { + 'application/calendar+json' => 'jcal', + 'application/calendar+xml' => 'xcal', + default => 'ical', + }; + + // With 'stream' => true, getBody() returns the underlying stream resource + $stream = $response->getBody(); + if (!is_resource($stream)) { + return null; } + + return ['data' => $stream, 'format' => $format]; } /** diff --git a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php index a0981e6dec16f..5e7c11a7164a7 100644 --- a/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php +++ b/apps/dav/lib/CalDAV/WebcalCaching/RefreshWebcalService.php @@ -9,18 +9,14 @@ namespace OCA\DAV\CalDAV\WebcalCaching; use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Import\ImportService; use OCP\AppFramework\Utility\ITimeFactory; use Psr\Log\LoggerInterface; -use Sabre\DAV\Exception\BadRequest; -use Sabre\DAV\Exception\Forbidden; use Sabre\DAV\PropPatch; use Sabre\VObject\Component; use Sabre\VObject\DateTimeParser; use Sabre\VObject\InvalidDataException; use Sabre\VObject\ParseException; -use Sabre\VObject\Reader; -use Sabre\VObject\Recur\NoInstancesException; -use Sabre\VObject\Splitter\ICalendar; use Sabre\VObject\UUIDUtil; use function count; @@ -36,20 +32,20 @@ public function __construct( private LoggerInterface $logger, private Connection $connection, private ITimeFactory $time, + private ImportService $importService, ) { } public function refreshSubscription(string $principalUri, string $uri) { $subscription = $this->getSubscription($principalUri, $uri); - $mutations = []; if (!$subscription) { return; } // Check the refresh rate if there is any - if (!empty($subscription['{http://apple.com/ns/ical/}refreshrate'])) { - // add the refresh interval to the lastmodified timestamp - $refreshInterval = new \DateInterval($subscription['{http://apple.com/ns/ical/}refreshrate']); + if (!empty($subscription[self::REFRESH_RATE])) { + // add the refresh interval to the last modified timestamp + $refreshInterval = new \DateInterval($subscription[self::REFRESH_RATE]); $updateTime = $this->time->getDateTime(); $updateTime->setTimestamp($subscription['lastmodified'])->add($refreshInterval); if ($updateTime->getTimestamp() > $this->time->getTime()) { @@ -57,109 +53,116 @@ public function refreshSubscription(string $principalUri, string $uri) { } } - - $webcalData = $this->connection->queryWebcalFeed($subscription); - if (!$webcalData) { + $result = $this->connection->queryWebcalFeed($subscription); + if (!$result) { return; } - $localData = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); + $data = $result['data']; + $format = $result['format']; $stripTodos = ($subscription[self::STRIP_TODOS] ?? 1) === 1; $stripAlarms = ($subscription[self::STRIP_ALARMS] ?? 1) === 1; $stripAttachments = ($subscription[self::STRIP_ATTACHMENTS] ?? 1) === 1; try { - $splitter = new ICalendar($webcalData, Reader::OPTION_FORGIVING); - - while ($vObject = $splitter->getNext()) { - /** @var Component $vObject */ - $compName = null; - $uid = null; - - foreach ($vObject->getComponents() as $component) { - if ($component->name === 'VTIMEZONE') { - continue; - } - - $compName = $component->name; + $existingObjects = $this->calDavBackend->getLimitedCalendarObjects((int)$subscription['id'], CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION, ['id', 'uid', 'etag', 'uri']); - if ($stripAlarms) { - unset($component->{'VALARM'}); - } - if ($stripAttachments) { - unset($component->{'ATTACH'}); - } - - $uid = $component->{ 'UID' }->getValue(); - } - - if ($stripTodos && $compName === 'VTODO') { - continue; - } + $generator = match ($format) { + 'xcal' => $this->importService->importXml(...), + 'jcal' => $this->importService->importJson(...), + default => $this->importService->importText(...) + }; - if (!isset($uid)) { - continue; - } + foreach ($generator($data) as $vObject) { + /** @var Component\VCalendar $vObject */ + $vBase = $vObject->getBaseComponent(); - try { - $denormalized = $this->calDavBackend->getDenormalizedData($vObject->serialize()); - } catch (InvalidDataException|Forbidden $ex) { - $this->logger->warning('Unable to denormalize calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]); + if (!$vBase->UID) { continue; } - // Find all identical sets and remove them from the update - if (isset($localData[$uid]) && $denormalized['etag'] === $localData[$uid]['etag']) { - unset($localData[$uid]); + // Some calendar providers (e.g. Google, MS) use very long UIDs + if (strlen($vBase->UID->getValue()) > 512) { + $this->logger->warning('Skipping calendar object with overly long UID from subscription "{subscriptionId}"', [ + 'subscriptionId' => $subscription['id'], + 'uid' => $vBase->UID->getValue(), + ]); continue; } - $vObjectCopy = clone $vObject; - $identical = isset($localData[$uid]) && $this->compareWithoutDtstamp($vObjectCopy, $localData[$uid]); - if ($identical) { - unset($localData[$uid]); + if ($stripTodos && $vBase->name === 'VTODO') { continue; } - // Find all modified sets and update them - if (isset($localData[$uid]) && $denormalized['etag'] !== $localData[$uid]['etag']) { - $this->calDavBackend->updateCalendarObject($subscription['id'], $localData[$uid]['uri'], $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); - unset($localData[$uid]); - continue; + if ($stripAlarms || $stripAttachments) { + foreach ($vObject->getComponents() as $component) { + if ($component->name === 'VTIMEZONE') { + continue; + } + if ($stripAlarms) { + $component->remove('VALARM'); + } + if ($stripAttachments) { + $component->remove('ATTACH'); + } + } } - // Only entirely new events get created here - try { - $objectUri = $this->getRandomCalendarObjectUri(); - $this->calDavBackend->createCalendarObject($subscription['id'], $objectUri, $vObject->serialize(), CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION); - } catch (NoInstancesException|BadRequest $ex) { - $this->logger->warning('Unable to create calendar object from subscription {subscriptionId}', ['exception' => $ex, 'subscriptionId' => $subscription['id'], 'source' => $subscription['source']]); + $sObject = $vObject->serialize(); + $uid = $vBase->UID->getValue(); + $etag = md5($sObject); + + // No existing object with this UID, create it + if (!isset($existingObjects[$uid])) { + try { + $this->calDavBackend->createCalendarObject( + $subscription['id'], + UUIDUtil::getUUID() . '.ics', + $sObject, + CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION + ); + } catch (\Exception $ex) { + $this->logger->warning('Unable to create calendar object from subscription {subscriptionId}', [ + 'exception' => $ex, + 'subscriptionId' => $subscription['id'], + 'source' => $subscription['source'], + ]); + } + } elseif ($existingObjects[$uid]['etag'] !== $etag) { + // Existing object with this UID but different etag, update it + $this->calDavBackend->updateCalendarObject( + $subscription['id'], + $existingObjects[$uid]['uri'], + $sObject, + CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION + ); + unset($existingObjects[$uid]); + } else { + // Existing object with same etag, just remove from tracking + unset($existingObjects[$uid]); } } - $ids = array_map(static function ($dataSet): int { - return (int)$dataSet['id']; - }, $localData); - $uris = array_map(static function ($dataSet): string { - return $dataSet['uri']; - }, $localData); - - if (!empty($ids) && !empty($uris)) { - // Clean up on aisle 5 - // The only events left over in the $localData array should be those that don't exist upstream - // All deleted VObjects from upstream are removed - $this->calDavBackend->purgeCachedEventsForSubscription($subscription['id'], $ids, $uris); + // Clean up objects that no longer exist in the remote feed + // The only events left over should be those not found upstream + if (!empty($existingObjects)) { + $ids = array_map('intval', array_column($existingObjects, 'id')); + $uris = array_column($existingObjects, 'uri'); + $this->calDavBackend->purgeCachedEventsForSubscription((int)$subscription['id'], $ids, $uris); } - $newRefreshRate = $this->checkWebcalDataForRefreshRate($subscription, $webcalData); - if ($newRefreshRate) { - $mutations[self::REFRESH_RATE] = $newRefreshRate; + // Update refresh rate from the last processed object + if (isset($vObject)) { + $this->updateRefreshRate($subscription, $vObject); } - - $this->updateSubscription($subscription, $mutations); } catch (ParseException $ex) { $this->logger->error('Subscription {subscriptionId} could not be refreshed due to a parsing error', ['exception' => $ex, 'subscriptionId' => $subscription['id']]); + } finally { + // Close the data stream to free resources + if (is_resource($data)) { + fclose($data); + } } } @@ -181,84 +184,34 @@ function ($sub) use ($uri) { return $subscriptions[0]; } - /** - * check if: - * - current subscription stores a refreshrate - * - the webcal feed suggests a refreshrate - * - return suggested refreshrate if user didn't set a custom one - * + * Update refresh rate from calendar object if: + * - current subscription does not store a refreshrate + * - the webcal feed suggests a valid refreshrate */ - private function checkWebcalDataForRefreshRate(array $subscription, string $webcalData): ?string { - // if there is no refreshrate stored in the database, check the webcal feed - // whether it suggests any refresh rate and store that in the database - if (isset($subscription[self::REFRESH_RATE]) && $subscription[self::REFRESH_RATE] !== null) { - return null; + private function updateRefreshRate(array $subscription, Component\VCalendar $vCalendar): void { + // if there is already a refreshrate stored in the database, don't override it + if (!empty($subscription[self::REFRESH_RATE])) { + return; } - /** @var Component\VCalendar $vCalendar */ - $vCalendar = Reader::read($webcalData); - - $newRefreshRate = null; - if (isset($vCalendar->{'X-PUBLISHED-TTL'})) { - $newRefreshRate = $vCalendar->{'X-PUBLISHED-TTL'}->getValue(); - } - if (isset($vCalendar->{'REFRESH-INTERVAL'})) { - $newRefreshRate = $vCalendar->{'REFRESH-INTERVAL'}->getValue(); - } + $refreshRate = $vCalendar->{'REFRESH-INTERVAL'}?->getValue() + ?? $vCalendar->{'X-PUBLISHED-TTL'}?->getValue(); - if (!$newRefreshRate) { - return null; + if ($refreshRate === null) { + return; } - // check if new refresh rate is even valid + // check if refresh rate is valid try { - DateTimeParser::parseDuration($newRefreshRate); - } catch (InvalidDataException $ex) { - return null; - } - - return $newRefreshRate; - } - - /** - * update subscription stored in database - * used to set: - * - refreshrate - * - source - * - * @param array $subscription - * @param array $mutations - */ - private function updateSubscription(array $subscription, array $mutations) { - if (empty($mutations)) { + DateTimeParser::parseDuration($refreshRate); + } catch (InvalidDataException) { return; } - $propPatch = new PropPatch($mutations); + $propPatch = new PropPatch([self::REFRESH_RATE => $refreshRate]); $this->calDavBackend->updateSubscription($subscription['id'], $propPatch); $propPatch->commit(); } - /** - * Returns a random uri for a calendar-object - * - * @return string - */ - public function getRandomCalendarObjectUri():string { - return UUIDUtil::getUUID() . '.ics'; - } - - private function compareWithoutDtstamp(Component $vObject, array $calendarObject): bool { - foreach ($vObject->getComponents() as $component) { - unset($component->{'DTSTAMP'}); - } - - $localVobject = Reader::read($calendarObject['calendardata']); - foreach ($localVobject->getComponents() as $component) { - unset($component->{'DTSTAMP'}); - } - - return strcasecmp($localVobject->serialize(), $vObject->serialize()) === 0; - } } diff --git a/apps/dav/tests/unit/CalDAV/WebcalCaching/ConnectionTest.php b/apps/dav/tests/unit/CalDAV/WebcalCaching/ConnectionTest.php index 5e9caaaeb44b9..2fd6893104e51 100644 --- a/apps/dav/tests/unit/CalDAV/WebcalCaching/ConnectionTest.php +++ b/apps/dav/tests/unit/CalDAV/WebcalCaching/ConnectionTest.php @@ -93,11 +93,12 @@ public function testInvalidUrl(): void { } /** - * @param string $result + * @param string $url * @param string $contentType + * @param string $expectedFormat * @dataProvider urlDataProvider */ - public function testConnection(string $url, string $result, string $contentType): void { + public function testConnection(string $url, string $contentType, string $expectedFormat): void { $client = $this->createMock(IClient::class); $response = $this->createMock(IResponse::class); $subscription = [ @@ -126,16 +127,76 @@ public function testConnection(string $url, string $result, string $contentType) ->with('https://foo.bar/bla2') ->willReturn($response); + $response->expects($this->once()) + ->method('getHeader') + ->with('Content-Type') + ->willReturn($contentType); + + // Create a stream resource to simulate streaming response + $stream = fopen('php://temp', 'r+'); + fwrite($stream, 'test calendar data'); + rewind($stream); + $response->expects($this->once()) ->method('getBody') + ->willReturn($stream); + + $output = $this->connection->queryWebcalFeed($subscription); + + $this->assertIsArray($output); + $this->assertArrayHasKey('data', $output); + $this->assertArrayHasKey('format', $output); + $this->assertIsResource($output['data']); + $this->assertEquals($expectedFormat, $output['format']); + + // Cleanup + if (is_resource($output['data'])) { + fclose($output['data']); + } + } + + public function testConnectionReturnsNullWhenBodyIsNotResource(): void { + $client = $this->createMock(IClient::class); + $response = $this->createMock(IResponse::class); + $subscription = [ + 'id' => 42, + 'uri' => 'sub123', + 'refreshreate' => 'P1H', + 'striptodos' => 1, + 'stripalarms' => 1, + 'stripattachments' => 1, + 'source' => 'https://foo.bar/bla2', + 'lastmodified' => 0, + ]; + + $this->clientService->expects($this->once()) + ->method('newClient') ->with() - ->willReturn($result); + ->willReturn($client); + + $this->config->expects($this->once()) + ->method('getValueString') + ->with('dav', 'webcalAllowLocalAccess', 'no') + ->willReturn('no'); + + $client->expects($this->once()) + ->method('get') + ->with('https://foo.bar/bla2') + ->willReturn($response); + $response->expects($this->once()) ->method('getHeader') ->with('Content-Type') - ->willReturn($contentType); + ->willReturn('text/calendar'); - $this->connection->queryWebcalFeed($subscription); + // Return a string instead of a resource + $response->expects($this->once()) + ->method('getBody') + ->willReturn('not a resource'); + + $output = $this->connection->queryWebcalFeed($subscription); + + $this->assertNull($output); } public static function runLocalURLDataProvider(): array { @@ -159,21 +220,9 @@ public static function runLocalURLDataProvider(): array { public static function urlDataProvider(): array { return [ - [ - 'https://foo.bar/bla2', - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", - 'text/calendar;charset=utf8', - ], - [ - 'https://foo.bar/bla2', - '["vcalendar",[["prodid",{},"text","-//Example Corp.//Example Client//EN"],["version",{},"text","2.0"]],[["vtimezone",[["last-modified",{},"date-time","2004-01-10T03:28:45Z"],["tzid",{},"text","US/Eastern"]],[["daylight",[["dtstart",{},"date-time","2000-04-04T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":4}],["tzname",{},"text","EDT"],["tzoffsetfrom",{},"utc-offset","-05:00"],["tzoffsetto",{},"utc-offset","-04:00"]],[]],["standard",[["dtstart",{},"date-time","2000-10-26T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":10}],["tzname",{},"text","EST"],["tzoffsetfrom",{},"utc-offset","-04:00"],["tzoffsetto",{},"utc-offset","-05:00"]],[]]]],["vevent",[["dtstamp",{},"date-time","2006-02-06T00:11:21Z"],["dtstart",{"tzid":"US/Eastern"},"date-time","2006-01-02T14:00:00"],["duration",{},"duration","PT1H"],["recurrence-id",{"tzid":"US/Eastern"},"date-time","2006-01-04T12:00:00"],["summary",{},"text","Event #2"],["uid",{},"text","12345"]],[]]]]', - 'application/calendar+json', - ], - [ - 'https://foo.bar/bla2', - '-//Example Inc.//Example Client//EN2.02006-02-06T00:11:21ZUS/Eastern2006-01-04T14:00:00PT1HUS/Eastern2006-01-04T12:00:00Event #2 bis12345', - 'application/calendar+xml', - ], + ['https://foo.bar/bla2', 'text/calendar;charset=utf8', 'ical'], + ['https://foo.bar/bla2', 'application/calendar+json', 'jcal'], + ['https://foo.bar/bla2', 'application/calendar+xml', 'xcal'], ]; } } diff --git a/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php b/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php index d65a99a15e06e..42f7f8b1e846d 100644 --- a/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/WebcalCaching/RefreshWebcalServiceTest.php @@ -8,6 +8,7 @@ namespace OCA\DAV\Tests\unit\CalDAV\WebcalCaching; use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\Import\ImportService; use OCA\DAV\CalDAV\WebcalCaching\Connection; use OCA\DAV\CalDAV\WebcalCaching\RefreshWebcalService; use OCP\AppFramework\Utility\ITimeFactory; @@ -23,7 +24,8 @@ class RefreshWebcalServiceTest extends TestCase { private CalDavBackend|MockObject $caldavBackend; private Connection|MockObject $connection; private LoggerInterface|MockObject $logger; - private ITimeFactory|MockObject $time; + private ImportService|MockObject $importService; + private ITimeFactory|MockObject $timeFactory; protected function setUp(): void { parent::setUp(); @@ -31,97 +33,208 @@ protected function setUp(): void { $this->caldavBackend = $this->createMock(CalDavBackend::class); $this->connection = $this->createMock(Connection::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->time = $this->createMock(ITimeFactory::class); + $this->importService = $this->createMock(ImportService::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + // Default time factory behavior: current time is far in the future so refresh always happens + $this->timeFactory->method('getTime')->willReturn(PHP_INT_MAX); + $this->timeFactory->method('getDateTime')->willReturn(new \DateTime()); + } + + /** + * Helper to create a resource stream from string content + */ + private function createStreamFromString(string $content) { + $stream = fopen('php://temp', 'r+'); + fwrite($stream, $content); + rewind($stream); + return $stream; } /** * @param string $body - * @param string $contentType + * @param string $format * @param string $result * * @dataProvider runDataProvider */ - public function testRun(string $body, string $contentType, string $result): void { + public function testRun(string $body, string $format, string $result): void { $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) - ->onlyMethods(['getRandomCalendarObjectUri']) - ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) + ->onlyMethods(['getSubscription']) + ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->timeFactory, $this->importService]) ->getMock(); $refreshWebcalService - ->method('getRandomCalendarObjectUri') - ->willReturn('uri-1.ics'); - - $this->caldavBackend->expects(self::once()) - ->method('getSubscriptionsForUser') - ->with('principals/users/testuser') + ->method('getSubscription') ->willReturn([ - [ - 'id' => '99', - 'uri' => 'sub456', - RefreshWebcalService::REFRESH_RATE => 'P1D', - RefreshWebcalService::STRIP_TODOS => '1', - RefreshWebcalService::STRIP_ALARMS => '1', - RefreshWebcalService::STRIP_ATTACHMENTS => '1', - 'source' => 'webcal://foo.bar/bla', - 'lastmodified' => 0, - ], - [ - 'id' => '42', - 'uri' => 'sub123', - RefreshWebcalService::REFRESH_RATE => 'PT1H', - RefreshWebcalService::STRIP_TODOS => '1', - RefreshWebcalService::STRIP_ALARMS => '1', - RefreshWebcalService::STRIP_ATTACHMENTS => '1', - 'source' => 'webcal://foo.bar/bla2', - 'lastmodified' => 0, - ], + 'id' => '42', + 'uri' => 'sub123', + RefreshWebcalService::REFRESH_RATE => 'PT1H', + RefreshWebcalService::STRIP_TODOS => '1', + RefreshWebcalService::STRIP_ALARMS => '1', + RefreshWebcalService::STRIP_ATTACHMENTS => '1', + 'source' => 'webcal://foo.bar/bla2', + 'lastmodified' => 0, ]); + $stream = $this->createStreamFromString($body); + $this->connection->expects(self::once()) ->method('queryWebcalFeed') - ->willReturn($result); + ->willReturn(['data' => $stream, 'format' => $format]); + + $this->caldavBackend->expects(self::once()) + ->method('getLimitedCalendarObjects') + ->willReturn([]); + + // Create a VCalendar object that will be yielded by the import service + $vCalendar = VObject\Reader::read($result); + + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); + $this->caldavBackend->expects(self::once()) ->method('createCalendarObject') - ->with(42, 'uri-1.ics', $result, 1); + ->with( + '42', + self::matchesRegularExpression('/^[a-f0-9-]+\.ics$/'), + $result, + CalDavBackend::CALENDAR_TYPE_SUBSCRIPTION + ); $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); } /** * @param string $body - * @param string $contentType + * @param string $format * @param string $result * * @dataProvider identicalDataProvider */ - public function testRunIdentical(string $uid, array $calendarObject, string $body, string $contentType, string $result): void { + public function testRunIdentical(string $uid, array $calendarObject, string $body, string $format, string $result): void { $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) - ->onlyMethods(['getRandomCalendarObjectUri']) - ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) + ->onlyMethods(['getSubscription']) + ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->timeFactory, $this->importService]) ->getMock(); $refreshWebcalService - ->method('getRandomCalendarObjectUri') - ->willReturn('uri-1.ics'); + ->method('getSubscription') + ->willReturn([ + 'id' => '42', + 'uri' => 'sub123', + RefreshWebcalService::REFRESH_RATE => 'PT1H', + RefreshWebcalService::STRIP_TODOS => '1', + RefreshWebcalService::STRIP_ALARMS => '1', + RefreshWebcalService::STRIP_ATTACHMENTS => '1', + 'source' => 'webcal://foo.bar/bla2', + 'lastmodified' => 0, + ]); + + $stream = $this->createStreamFromString($body); + + $this->connection->expects(self::once()) + ->method('queryWebcalFeed') + ->willReturn(['data' => $stream, 'format' => $format]); + + $this->caldavBackend->expects(self::once()) + ->method('getLimitedCalendarObjects') + ->willReturn($calendarObject); + + // Create a VCalendar object that will be yielded by the import service + $vCalendar = VObject\Reader::read($result); + + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); + + $this->caldavBackend->expects(self::never()) + ->method('createCalendarObject'); + + $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); + } + + public function testSubscriptionNotFound(): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); + + $this->caldavBackend->expects(self::once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/testuser') + ->willReturn([]); + + $this->connection->expects(self::never()) + ->method('queryWebcalFeed'); + + $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); + } + + public function testConnectionReturnsNull(): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); $this->caldavBackend->expects(self::once()) ->method('getSubscriptionsForUser') ->with('principals/users/testuser') ->willReturn([ [ - 'id' => '99', - 'uri' => 'sub456', - RefreshWebcalService::REFRESH_RATE => 'P1D', + 'id' => '42', + 'uri' => 'sub123', RefreshWebcalService::STRIP_TODOS => '1', RefreshWebcalService::STRIP_ALARMS => '1', RefreshWebcalService::STRIP_ATTACHMENTS => '1', - 'source' => 'webcal://foo.bar/bla', + 'source' => 'webcal://foo.bar/bla2', 'lastmodified' => 0, ], + ]); + + $this->connection->expects(self::once()) + ->method('queryWebcalFeed') + ->willReturn(null); + + $this->importService->expects(self::never()) + ->method('importText'); + + $this->caldavBackend->expects(self::never()) + ->method('createCalendarObject'); + + $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); + } + + public function testDeletedObjectsArePurged(): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); + + $this->caldavBackend->expects(self::once()) + ->method('getSubscriptionsForUser') + ->with('principals/users/testuser') + ->willReturn([ [ 'id' => '42', 'uri' => 'sub123', - RefreshWebcalService::REFRESH_RATE => 'PT1H', RefreshWebcalService::STRIP_TODOS => '1', RefreshWebcalService::STRIP_ALARMS => '1', RefreshWebcalService::STRIP_ATTACHMENTS => '1', @@ -130,78 +243,91 @@ public function testRunIdentical(string $uid, array $calendarObject, string $bod ], ]); + $body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:new-event\r\nDTSTAMP:20160218T133704Z\r\nDTSTART:20160218T133704Z\r\nSUMMARY:New Event\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + $stream = $this->createStreamFromString($body); + $this->connection->expects(self::once()) ->method('queryWebcalFeed') - ->willReturn($result); + ->willReturn(['data' => $stream, 'format' => 'ical']); + // Existing objects include one that won't be in the feed $this->caldavBackend->expects(self::once()) ->method('getLimitedCalendarObjects') - ->willReturn($calendarObject); + ->willReturn([ + 'old-deleted-event' => [ + 'id' => 99, + 'uid' => 'old-deleted-event', + 'etag' => 'old-etag', + 'uri' => 'old-event.ics', + ], + ]); - $denormalised = [ - 'etag' => 100, - 'size' => strlen($calendarObject[$uid]['calendardata']), - 'uid' => 'sub456' - ]; + $vCalendar = VObject\Reader::read($body); + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; - $this->caldavBackend->expects(self::once()) - ->method('getDenormalizedData') - ->willReturn($denormalised); + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); - $this->caldavBackend->expects(self::never()) + $this->caldavBackend->expects(self::once()) ->method('createCalendarObject'); - $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub456'); - } + $this->caldavBackend->expects(self::once()) + ->method('purgeCachedEventsForSubscription') + ->with(42, [99], ['old-event.ics']); - public function testRunJustUpdated(): void { - $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) - ->onlyMethods(['getRandomCalendarObjectUri']) - ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) - ->getMock(); + $refreshWebcalService->refreshSubscription('principals/users/testuser', 'sub123'); + } - $refreshWebcalService - ->method('getRandomCalendarObjectUri') - ->willReturn('uri-1.ics'); + public function testLongUidIsSkipped(): void { + $refreshWebcalService = new RefreshWebcalService( + $this->caldavBackend, + $this->logger, + $this->connection, + $this->timeFactory, + $this->importService + ); $this->caldavBackend->expects(self::once()) ->method('getSubscriptionsForUser') ->with('principals/users/testuser') ->willReturn([ - [ - 'id' => '99', - 'uri' => 'sub456', - RefreshWebcalService::REFRESH_RATE => 'P1D', - RefreshWebcalService::STRIP_TODOS => '1', - RefreshWebcalService::STRIP_ALARMS => '1', - RefreshWebcalService::STRIP_ATTACHMENTS => '1', - 'source' => 'webcal://foo.bar/bla', - 'lastmodified' => time(), - ], [ 'id' => '42', 'uri' => 'sub123', - RefreshWebcalService::REFRESH_RATE => 'PT1H', RefreshWebcalService::STRIP_TODOS => '1', RefreshWebcalService::STRIP_ALARMS => '1', RefreshWebcalService::STRIP_ATTACHMENTS => '1', 'source' => 'webcal://foo.bar/bla2', - 'lastmodified' => time(), + 'lastmodified' => 0, ], ]); - $timeMock = $this->createMock(\DateTime::class); - $this->time->expects(self::once()) - ->method('getDateTime') - ->willReturn($timeMock); - $timeMock->expects(self::once()) - ->method('getTimestamp') - ->willReturn(2101724667); - $this->time->expects(self::once()) - ->method('getTime') - ->willReturn(time()); - $this->connection->expects(self::never()) - ->method('queryWebcalFeed'); + // Create a UID that is longer than 512 characters + $longUid = str_repeat('a', 513); + $body = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Test//Test//EN\r\nBEGIN:VEVENT\r\nUID:$longUid\r\nDTSTAMP:20160218T133704Z\r\nDTSTART:20160218T133704Z\r\nSUMMARY:Event with long UID\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + $stream = $this->createStreamFromString($body); + + $this->connection->expects(self::once()) + ->method('queryWebcalFeed') + ->willReturn(['data' => $stream, 'format' => 'ical']); + + $this->caldavBackend->expects(self::once()) + ->method('getLimitedCalendarObjects') + ->willReturn([]); + + $vCalendar = VObject\Reader::read($body); + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); + + // Event with long UID should be skipped, so createCalendarObject should never be called $this->caldavBackend->expects(self::never()) ->method('createCalendarObject'); @@ -217,14 +343,10 @@ public function testRunJustUpdated(): void { */ public function testRunCreateCalendarNoException(string $body, string $contentType, string $result): void { $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) - ->onlyMethods(['getRandomCalendarObjectUri', 'getSubscription',]) - ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) + ->onlyMethods(['getSubscription']) + ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->timeFactory, $this->importService]) ->getMock(); - $refreshWebcalService - ->method('getRandomCalendarObjectUri') - ->willReturn('uri-1.ics'); - $refreshWebcalService ->method('getSubscription') ->willReturn([ @@ -238,13 +360,26 @@ public function testRunCreateCalendarNoException(string $body, string $contentTy 'lastmodified' => 0, ]); + $stream = $this->createStreamFromString($body); + $this->connection->expects(self::once()) ->method('queryWebcalFeed') - ->willReturn($result); + ->willReturn(['data' => $stream, 'format' => 'ical']); $this->caldavBackend->expects(self::once()) - ->method('createCalendarObject') - ->with(42, 'uri-1.ics', $result, 1); + ->method('getLimitedCalendarObjects') + ->willReturn([]); + + // Create a VCalendar object that will be yielded by the import service + $vCalendar = VObject\Reader::read($result); + + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); $noInstanceException = new NoInstancesException("can't add calendar object"); $this->caldavBackend->expects(self::once()) @@ -267,14 +402,10 @@ public function testRunCreateCalendarNoException(string $body, string $contentTy */ public function testRunCreateCalendarBadRequest(string $body, string $contentType, string $result): void { $refreshWebcalService = $this->getMockBuilder(RefreshWebcalService::class) - ->onlyMethods(['getRandomCalendarObjectUri', 'getSubscription']) - ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->time]) + ->onlyMethods(['getSubscription']) + ->setConstructorArgs([$this->caldavBackend, $this->logger, $this->connection, $this->timeFactory, $this->importService]) ->getMock(); - $refreshWebcalService - ->method('getRandomCalendarObjectUri') - ->willReturn('uri-1.ics'); - $refreshWebcalService ->method('getSubscription') ->willReturn([ @@ -288,13 +419,26 @@ public function testRunCreateCalendarBadRequest(string $body, string $contentTyp 'lastmodified' => 0, ]); + $stream = $this->createStreamFromString($body); + $this->connection->expects(self::once()) ->method('queryWebcalFeed') - ->willReturn($result); + ->willReturn(['data' => $stream, 'format' => 'ical']); $this->caldavBackend->expects(self::once()) - ->method('createCalendarObject') - ->with(42, 'uri-1.ics', $result, 1); + ->method('getLimitedCalendarObjects') + ->willReturn([]); + + // Create a VCalendar object that will be yielded by the import service + $vCalendar = VObject\Reader::read($result); + + $generator = function () use ($vCalendar) { + yield $vCalendar; + }; + + $this->importService->expects(self::once()) + ->method('importText') + ->willReturn($generator()); $badRequestException = new BadRequest("can't add reach calendar url"); $this->caldavBackend->expects(self::once()) @@ -312,20 +456,21 @@ public function testRunCreateCalendarBadRequest(string $body, string $contentTyp * @return array */ public static function identicalDataProvider():array { + $icalBody = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + $etag = md5($icalBody); return [ [ '12345', [ '12345' => [ 'id' => 42, - 'etag' => 100, - 'uri' => 'sub456', - 'calendardata' => "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", + 'etag' => $etag, + 'uri' => 'sub456.ics', ], ], "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", - 'text/calendar;charset=utf8', - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20180218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", + 'ical', + $icalBody, ], ]; } @@ -337,19 +482,9 @@ public function runDataProvider():array { return [ [ "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject 4.1.1//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", - 'text/calendar;charset=utf8', + 'ical', "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nUID:12345\r\nDTSTAMP:20160218T133704Z\r\nDTSTART;VALUE=DATE:19000101\r\nDTEND;VALUE=DATE:19000102\r\nRRULE:FREQ=YEARLY\r\nSUMMARY:12345's Birthday (1900)\r\nTRANSP:TRANSPARENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n", ], - [ - '["vcalendar",[["prodid",{},"text","-//Example Corp.//Example Client//EN"],["version",{},"text","2.0"]],[["vtimezone",[["last-modified",{},"date-time","2004-01-10T03:28:45Z"],["tzid",{},"text","US/Eastern"]],[["daylight",[["dtstart",{},"date-time","2000-04-04T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":4}],["tzname",{},"text","EDT"],["tzoffsetfrom",{},"utc-offset","-05:00"],["tzoffsetto",{},"utc-offset","-04:00"]],[]],["standard",[["dtstart",{},"date-time","2000-10-26T02:00:00"],["rrule",{},"recur",{"freq":"YEARLY","byday":"1SU","bymonth":10}],["tzname",{},"text","EST"],["tzoffsetfrom",{},"utc-offset","-04:00"],["tzoffsetto",{},"utc-offset","-05:00"]],[]]]],["vevent",[["dtstamp",{},"date-time","2006-02-06T00:11:21Z"],["dtstart",{"tzid":"US/Eastern"},"date-time","2006-01-02T14:00:00"],["duration",{},"duration","PT1H"],["recurrence-id",{"tzid":"US/Eastern"},"date-time","2006-01-04T12:00:00"],["summary",{},"text","Event #2"],["uid",{},"text","12345"]],[]]]]', - 'application/calendar+json', - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VTIMEZONE\r\nLAST-MODIFIED:20040110T032845Z\r\nTZID:US/Eastern\r\nBEGIN:DAYLIGHT\r\nDTSTART:20000404T020000\r\nRRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=4\r\nTZNAME:EDT\r\nTZOFFSETFROM:-0500\r\nTZOFFSETTO:-0400\r\nEND:DAYLIGHT\r\nBEGIN:STANDARD\r\nDTSTART:20001026T020000\r\nRRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=10\r\nTZNAME:EST\r\nTZOFFSETFROM:-0400\r\nTZOFFSETTO:-0500\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\nBEGIN:VEVENT\r\nDTSTAMP:20060206T001121Z\r\nDTSTART;TZID=US/Eastern:20060102T140000\r\nDURATION:PT1H\r\nRECURRENCE-ID;TZID=US/Eastern:20060104T120000\r\nSUMMARY:Event #2\r\nUID:12345\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" - ], - [ - '-//Example Inc.//Example Client//EN2.02006-02-06T00:11:21ZUS/Eastern2006-01-04T14:00:00PT1HUS/Eastern2006-01-04T12:00:00Event #2 bis12345', - 'application/calendar+xml', - "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Sabre//Sabre VObject " . VObject\Version::VERSION . "//EN\r\nCALSCALE:GREGORIAN\r\nBEGIN:VEVENT\r\nDTSTAMP:20060206T001121Z\r\nDTSTART;TZID=US/Eastern:20060104T140000\r\nDURATION:PT1H\r\nRECURRENCE-ID;TZID=US/Eastern:20060104T120000\r\nSUMMARY:Event #2 bis\r\nUID:12345\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" - ] ]; } } diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml index 78390a1b68e66..245f33782d616 100644 --- a/build/psalm-baseline.xml +++ b/build/psalm-baseline.xml @@ -283,11 +283,6 @@ - - - - - getKey()]]>