From 76f4477b2d7a4a8008750f301bc2c3907c172bce Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Thu, 18 Sep 2025 15:26:48 -0400 Subject: [PATCH 1/3] fix: Calendar Import Signed-off-by: SebastianKrupinski --- .../composer/composer/autoload_classmap.php | 5 + .../dav/composer/composer/autoload_static.php | 5 + .../lib/CalDAV/Import/ImportCountEvent.php | 37 ++ .../lib/CalDAV/Import/ImportDisposition.php | 16 + .../lib/CalDAV/Import/ImportObjectEvent.php | 42 ++ apps/dav/lib/CalDAV/Import/ImportService.php | 137 ++++-- apps/dav/lib/Command/ImportCalendar.php | 113 +++-- .../Controller/CalendarExportController.php | 107 +++++ .../Controller/CalendarImportController.php | 149 +++++++ .../lib/UserMigration/CalendarMigrator.php | 56 +-- apps/dav/openapi.json | 413 ++++++++++++++++++ .../unit/CalDAV/Import/ImportServiceTest.php | 25 +- lib/composer/composer/autoload_classmap.php | 1 + lib/composer/composer/autoload_static.php | 1 + .../Http/StreamGeneratorResponse.php | 63 +++ lib/public/Calendar/CalendarImportOptions.php | 19 + openapi.json | 413 ++++++++++++++++++ .../Http/StreamGeneratorResponseTest.php | 46 ++ 18 files changed, 1521 insertions(+), 127 deletions(-) create mode 100644 apps/dav/lib/CalDAV/Import/ImportCountEvent.php create mode 100644 apps/dav/lib/CalDAV/Import/ImportDisposition.php create mode 100644 apps/dav/lib/CalDAV/Import/ImportObjectEvent.php create mode 100644 apps/dav/lib/Controller/CalendarExportController.php create mode 100644 apps/dav/lib/Controller/CalendarImportController.php create mode 100644 lib/public/AppFramework/Http/StreamGeneratorResponse.php create mode 100644 tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 622cf0cdd8b03..9e2afc8520703 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -89,6 +89,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\\ImportCountEvent' => $baseDir . '/../lib/CalDAV/Import/ImportCountEvent.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportDisposition' => $baseDir . '/../lib/CalDAV/Import/ImportDisposition.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportObjectEvent' => $baseDir . '/../lib/CalDAV/Import/ImportObjectEvent.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', @@ -264,6 +267,8 @@ 'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => $baseDir . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => $baseDir . '/../lib/Connector/Sabre/ZipFolderPlugin.php', 'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php', + 'OCA\\DAV\\Controller\\CalendarExportController' => $baseDir . '/../lib/Controller/CalendarExportController.php', + 'OCA\\DAV\\Controller\\CalendarImportController' => $baseDir . '/../lib/Controller/CalendarImportController.php', 'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php', 'OCA\\DAV\\Controller\\ExampleContentController' => $baseDir . '/../lib/Controller/ExampleContentController.php', 'OCA\\DAV\\Controller\\FederatedCalendarController' => $baseDir . '/../lib/Controller/FederatedCalendarController.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 15b1b85370f92..5e5163b9e1bc3 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -104,6 +104,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\\ImportCountEvent' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportCountEvent.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportDisposition' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportDisposition.php', + 'OCA\\DAV\\CalDAV\\Import\\ImportObjectEvent' => __DIR__ . '/..' . '/../lib/CalDAV/Import/ImportObjectEvent.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', @@ -279,6 +282,8 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ZipFolderPlugin.php', 'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php', + 'OCA\\DAV\\Controller\\CalendarExportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarExportController.php', + 'OCA\\DAV\\Controller\\CalendarImportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarImportController.php', 'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php', 'OCA\\DAV\\Controller\\ExampleContentController' => __DIR__ . '/..' . '/../lib/Controller/ExampleContentController.php', 'OCA\\DAV\\Controller\\FederatedCalendarController' => __DIR__ . '/..' . '/../lib/Controller/FederatedCalendarController.php', diff --git a/apps/dav/lib/CalDAV/Import/ImportCountEvent.php b/apps/dav/lib/CalDAV/Import/ImportCountEvent.php new file mode 100644 index 0000000000000..14676f82192fe --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/ImportCountEvent.php @@ -0,0 +1,37 @@ +vevent + $this->vtodo + $this->vjournal; + } + + /** + * @return array{type: 'count', vevent: int, vtodo: int, vjournal: int} + */ + #[\Override] + public function jsonSerialize(): array { + return [ + 'type' => 'count', + 'vevent' => $this->vevent, + 'vtodo' => $this->vtodo, + 'vjournal' => $this->vjournal, + ]; + } +} diff --git a/apps/dav/lib/CalDAV/Import/ImportDisposition.php b/apps/dav/lib/CalDAV/Import/ImportDisposition.php new file mode 100644 index 0000000000000..4de18877e8f2c --- /dev/null +++ b/apps/dav/lib/CalDAV/Import/ImportDisposition.php @@ -0,0 +1,16 @@ + $errors + */ + public function __construct( + public ?string $identifier, + public ImportDisposition $disposition, + public array $errors = [], + ) { + } + + public function isError(): bool { + return $this->disposition === ImportDisposition::Error; + } + + /** + * @return array{type: 'object', identifier: ?string, disposition: string, errors: list} + */ + #[\Override] + public function jsonSerialize(): array { + $result = [ + 'type' => 'object', + 'identifier' => $this->identifier, + 'disposition' => $this->disposition->value, + 'errors' => $this->errors, + ]; + + return $result; + } +} diff --git a/apps/dav/lib/CalDAV/Import/ImportService.php b/apps/dav/lib/CalDAV/Import/ImportService.php index 052451ef19281..67c25f2d2d1c4 100644 --- a/apps/dav/lib/CalDAV/Import/ImportService.php +++ b/apps/dav/lib/CalDAV/Import/ImportService.php @@ -33,27 +33,20 @@ public function __construct( * * @param resource $source * - * @return array>> + * @return Generator * * @throws \InvalidArgumentException */ - public function import($source, CalendarImpl $calendar, CalendarImportOptions $options): array { + public function import($source, CalendarImpl $calendar, CalendarImportOptions $options): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } - switch ($options->getFormat()) { - case 'ical': - return $this->importProcess($source, $calendar, $options, $this->importText(...)); - break; - case 'jcal': - return $this->importProcess($source, $calendar, $options, $this->importJson(...)); - break; - case 'xcal': - return $this->importProcess($source, $calendar, $options, $this->importXml(...)); - break; - default: - throw new InvalidArgumentException('Invalid import format'); - } + return match ($options->getFormat()) { + 'ical' => $this->importProcess($source, $calendar, $options, $this->importText(...)), + 'jcal' => $this->importProcess($source, $calendar, $options, $this->importJson(...)), + 'xcal' => $this->importProcess($source, $calendar, $options, $this->importXml(...)), + default => throw new InvalidArgumentException('Invalid import format'), + }; } /** @@ -61,9 +54,9 @@ public function import($source, CalendarImpl $calendar, CalendarImportOptions $o * * @param resource $source * - * @return Generator<\Sabre\VObject\Component\VCalendar> + * @return Generator */ - public function importText($source): Generator { + public function importText($source, ?CalendarImportOptions $options = null): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } @@ -85,6 +78,14 @@ public function importText($source): Generator { $vObject = Reader::read($sObjectPrefix . $sObjectContents . $sObjectSuffix); $timezones[$tid] = clone $vObject->VTIMEZONE; } + // object counts before streaming if requested + if ($options?->getCounts()) { + yield 'counts' => [ + 'VEVENT' => count($structure['VEVENT']), + 'VTODO' => count($structure['VTODO']), + 'VJOURNAL' => count($structure['VJOURNAL']), + ]; + } // 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 @@ -114,9 +115,9 @@ public function importText($source): Generator { * * @param resource $source * - * @return Generator<\Sabre\VObject\Component\VCalendar> + * @return Generator */ - public function importXml($source): Generator { + public function importXml($source, ?CalendarImportOptions $options = null): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } @@ -132,6 +133,14 @@ public function importXml($source): Generator { $vObject = Reader::readXml($sObjectPrefix . $sObjectContents . $sObjectSuffix); $timezones[$tid] = clone $vObject->VTIMEZONE; } + // object counts before streaming if requested + if ($options?->getCounts()) { + yield 'counts' => [ + 'VEVENT' => count($structure['VEVENT']), + 'VTODO' => count($structure['VTODO']), + 'VJOURNAL' => count($structure['VJOURNAL']), + ]; + } // 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 @@ -160,10 +169,11 @@ public function importXml($source): Generator { * Generates object stream from a json formatted source (jcal) * * @param resource $source + * @param CalendarImportOptions|null $options * - * @return Generator<\Sabre\VObject\Component\VCalendar> + * @return Generator */ - public function importJson($source): Generator { + public function importJson($source, ?CalendarImportOptions $options = null): Generator { if (!is_resource($source)) { throw new InvalidArgumentException('Invalid import source must be a file resource'); } @@ -178,7 +188,27 @@ public function importJson($source): Generator { } } // calendar components - foreach ($importer->getBaseComponents() as $base) { + $baseComponents = $importer->getBaseComponents(); + // object counts before streaming if requested + if ($options?->getCounts()) { + /** @var array{VEVENT: int, VTODO: int, VJOURNAL: int} $counts */ + $counts = ['VEVENT' => 0, 'VTODO' => 0, 'VJOURNAL' => 0]; + foreach ($baseComponents as $component) { + switch ($component->name) { + case 'VEVENT': + $counts['VEVENT']++; + break; + case 'VTODO': + $counts['VTODO']++; + break; + case 'VJOURNAL': + $counts['VJOURNAL']++; + break; + } + } + yield 'counts' => $counts; + } + foreach ($baseComponents as $base) { $vObject = new VCalendar; $vObject->VERSION = clone $importer->VERSION; $vObject->PRODID = clone $importer->PRODID; @@ -225,14 +255,22 @@ private function findTimeZones(VCalendar $vObject): array { * @param CalendarImportOptions $options * @param callable $generator: Generator<\Sabre\VObject\Component\VCalendar> * - * @return array>> + * @return Generator */ - public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): array { + public function importProcess($source, CalendarImpl $calendar, CalendarImportOptions $options, callable $generator): Generator { $calendarId = $calendar->getKey(); $calendarUri = $calendar->getUri(); $principalUri = $calendar->getPrincipalUri(); - $outcome = []; - foreach ($generator($source) as $vObject) { + foreach ($generator($source, $options) as $key => $value) { + if ($key === 'counts') { + yield new ImportCountEvent( + vevent: $value['VEVENT'] ?? 0, + vtodo: $value['VTODO'] ?? 0, + vjournal: $value['VJOURNAL'] ?? 0, + ); + continue; + } + $vObject = $value; $components = $vObject->getBaseComponents(); // determine if the object has no base component types if (count($components) === 0) { @@ -240,7 +278,11 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt if ($options->getErrors() === $options::ERROR_FAIL) { throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage); } - $outcome['nbct'] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: null, + errors: [$errorMessage] + ); continue; } // determine if the object has more than one base component type @@ -254,7 +296,11 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt if ($options->getErrors() === $options::ERROR_FAIL) { throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage); } - $outcome['mbct'] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: null, + errors: [$errorMessage] + ); continue 2; } } @@ -265,7 +311,11 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt if ($options->getErrors() === $options::ERROR_FAIL) { throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage); } - $outcome['noid'] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: null, + errors: [$errorMessage] + ); continue; } $uid = $components[0]->UID->getValue(); @@ -273,7 +323,11 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt if ($options->getValidate() !== $options::VALIDATE_NONE) { $issues = $this->componentValidate($vObject, true, 3); if ($options->getValidate() === $options::VALIDATE_SKIP && $issues !== []) { - $outcome[$uid] = ['outcome' => 'error', 'errors' => $issues]; + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: $uid, + errors: $issues + ); continue; } elseif ($options->getValidate() === $options::VALIDATE_FAIL && $issues !== []) { throw new InvalidArgumentException('Error importing calendar data: UID <' . $uid . '> - ' . $issues[0]); @@ -290,7 +344,10 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt $objectId, $objectData ); - $outcome[$uid] = ['outcome' => 'created']; + yield new ImportObjectEvent( + disposition: ImportDisposition::Created, + identifier: $uid, + ); } else { [$cid, $oid] = explode('/', $objectId); if ($options->getSupersede()) { @@ -299,9 +356,15 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt $oid, $objectData ); - $outcome[$uid] = ['outcome' => 'updated']; + yield new ImportObjectEvent( + disposition: ImportDisposition::Updated, + identifier: $uid, + ); } else { - $outcome[$uid] = ['outcome' => 'exists']; + yield new ImportObjectEvent( + disposition: ImportDisposition::Exists, + identifier: $uid, + ); } } } catch (Exception $e) { @@ -309,11 +372,13 @@ public function importProcess($source, CalendarImpl $calendar, CalendarImportOpt if ($options->getErrors() === $options::ERROR_FAIL) { throw new Exception('Error importing calendar data: UID <' . $uid . '> - ' . $errorMessage, 0, $e); } - $outcome[$uid] = ['outcome' => 'error', 'errors' => [$errorMessage]]; + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: $uid, + errors: [$errorMessage] + ); } } - - return $outcome; } /** diff --git a/apps/dav/lib/Command/ImportCalendar.php b/apps/dav/lib/Command/ImportCalendar.php index 74f8e8901eae7..def396d19869f 100644 --- a/apps/dav/lib/Command/ImportCalendar.php +++ b/apps/dav/lib/Command/ImportCalendar.php @@ -9,6 +9,9 @@ use InvalidArgumentException; use OCA\DAV\CalDAV\CalendarImpl; +use OCA\DAV\CalDAV\Import\ImportCountEvent; +use OCA\DAV\CalDAV\Import\ImportDisposition; +use OCA\DAV\CalDAV\Import\ImportObjectEvent; use OCA\DAV\CalDAV\Import\ImportService; use OCP\Calendar\CalendarImportOptions; use OCP\Calendar\IManager; @@ -100,80 +103,76 @@ protected function execute(InputInterface $input, OutputInterface $output): int $options->setValidate($validation); } $options->setFormat($format); + $options->setCounts(true); // evaluate if a valid location was given and is usable otherwise default to stdin - $timeStarted = microtime(true); if ($location !== null) { - $input = fopen($location, 'r'); - if ($input === false) { + $stream = fopen($location, 'r'); + if ($stream === false) { throw new InvalidArgumentException("Location <$location> is not valid. Cannot open location for read operation."); } - try { - $outcome = $this->importService->import($input, $calendar, $options); - } finally { - fclose($input); - } } else { - $input = fopen('php://stdin', 'r'); - if ($input === false) { + $stdin = fopen('php://stdin', 'r'); + if ($stdin === false) { throw new InvalidArgumentException('Cannot open stdin for read operation.'); } - try { - $tempPath = $this->tempManager->getTemporaryFile(); - $tempFile = fopen($tempPath, 'w+'); - while (!feof($input)) { - fwrite($tempFile, fread($input, 8192)); - } - fseek($tempFile, 0); - $outcome = $this->importService->import($tempFile, $calendar, $options); - } finally { - fclose($input); - fclose($tempFile); + $tempPath = $this->tempManager->getTemporaryFile(); + $stream = fopen($tempPath, 'w+'); + while (!feof($stdin)) { + fwrite($stream, fread($stdin, 8192)); } + fclose($stdin); + fseek($stream, 0); } - $timeFinished = microtime(true); - - // summarize the outcome + $timeStarted = microtime(true); $totalCreated = 0; $totalUpdated = 0; $totalSkipped = 0; $totalErrors = 0; - - if ($outcome !== []) { - if ($showCreated || $showUpdated || $showSkipped || $showErrors) { - $output->writeln(''); - } - foreach ($outcome as $id => $result) { - if (isset($result['outcome'])) { - switch ($result['outcome']) { - case 'created': - $totalCreated++; - if ($showCreated) { - $output->writeln(['created: ' . $id]); - } - break; - case 'updated': - $totalUpdated++; - if ($showUpdated) { - $output->writeln(['updated: ' . $id]); - } - break; - case 'exists': - $totalSkipped++; - if ($showSkipped) { - $output->writeln(['skipped: ' . $id]); - } - break; - case 'error': - $totalErrors++; - if ($showErrors) { - $output->writeln(['errors: ' . $id]); - $output->writeln($result['errors']); - } - break; + try { + foreach ($this->importService->import($stream, $calendar, $options) as $event) { + if ($event instanceof ImportCountEvent) { + $output->writeln('Total objects to import: ' . $event->total()); + if ($showCreated || $showUpdated || $showSkipped || $showErrors) { + $output->writeln(''); } + continue; + } + if (!$event instanceof ImportObjectEvent) { + continue; + } + + switch ($event->disposition) { + case ImportDisposition::Created: + $totalCreated++; + if ($showCreated) { + $output->writeln(['created: ' . ($event->identifier ?? 'unknown')]); + } + break; + case ImportDisposition::Updated: + $totalUpdated++; + if ($showUpdated) { + $output->writeln(['updated: ' . ($event->identifier ?? 'unknown')]); + } + break; + case ImportDisposition::Exists: + $totalSkipped++; + if ($showSkipped) { + $output->writeln(['skipped: ' . ($event->identifier ?? 'unknown')]); + } + break; + case ImportDisposition::Error: + $totalErrors++; + if ($showErrors) { + $output->writeln(['errors: ' . ($event->identifier ?? 'unknown')]); + $output->writeln($event->errors); + } + break; } } + } finally { + fclose($stream); } + $timeFinished = microtime(true); $output->writeln([ '', 'Import Completed', diff --git a/apps/dav/lib/Controller/CalendarExportController.php b/apps/dav/lib/Controller/CalendarExportController.php new file mode 100644 index 0000000000000..0acacbe1f147b --- /dev/null +++ b/apps/dav/lib/Controller/CalendarExportController.php @@ -0,0 +1,107 @@ + | DataResponse + * + * 200: data in requested format + * 400: invalid parameters + * 401: user not authorized + */ + #[ApiRoute(verb: 'POST', url: '/export', root: '/calendar')] + #[UserRateLimit(limit: 1, period: 60)] + #[NoAdminRequired] + public function export(string $target, ?string $type = null, ?array $options = null, ?string $user = null) { + $calendarId = $target; + $format = $type ?? 'ical'; + $rangeStart = isset($options['rangeStart']) ? (string)$options['rangeStart'] : null; + $rangeCount = isset($options['rangeCount']) ? (int)$options['rangeCount'] : null; + // evaluate if user is logged in and has permissions + if (!$this->userSession->isLoggedIn()) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if ($user !== null) { + if ($this->userSession->getUser()->getUID() !== $user + && $this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if (!$this->userManager->userExists($user)) { + return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); + } + } else { + $userId = $this->userSession->getUser()->getUID(); + } + // retrieve calendar and evaluate if export is supported + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + return new DataResponse(['error' => 'calendar not found'], Http::STATUS_BAD_REQUEST); + } + $calendar = $calendars[0]; + if (!$calendar instanceof ICalendarExport) { + return new DataResponse(['error' => 'calendar export not supported'], Http::STATUS_BAD_REQUEST); + } + // construct options object + $options = new CalendarExportOptions(); + $options->setRangeStart($rangeStart); + $options->setRangeCount($rangeCount); + // evaluate if provided format is supported + if (!in_array($format, ExportService::FORMATS, true)) { + return new DataResponse(['error' => "Format <$format> is not valid."], Http::STATUS_BAD_REQUEST); + } + $options->setFormat($format); + // construct response + $contentType = match (strtolower($options->getFormat())) { + 'jcal' => 'application/calendar+json; charset=UTF-8', + 'xcal' => 'application/calendar+xml; charset=UTF-8', + default => 'text/calendar; charset=UTF-8' + }; + $response = new StreamGeneratorResponse($this->exportService->export($calendar, $options), $contentType, Http::STATUS_OK); + $response->cacheFor(0); + + return $response; + } +} diff --git a/apps/dav/lib/Controller/CalendarImportController.php b/apps/dav/lib/Controller/CalendarImportController.php new file mode 100644 index 0000000000000..b840ebc77a0cb --- /dev/null +++ b/apps/dav/lib/Controller/CalendarImportController.php @@ -0,0 +1,149 @@ +, total: non-negative-int} + */ +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\CalendarImpl; +use OCA\DAV\CalDAV\Import\ImportService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\StreamGeneratorResponse; +use OCP\AppFramework\OCSController; +use OCP\Calendar\CalendarImportOptions; +use OCP\Calendar\IManager; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\ITempManager; +use OCP\IUserManager; +use OCP\IUserSession; + +class CalendarImportController extends OCSController { + + public function __construct( + IRequest $request, + private IUserSession $userSession, + private IUserManager $userManager, + private IGroupManager $groupManager, + private ITempManager $tempManager, + private IManager $calendarManager, + private ImportService $importService, + ) { + parent::__construct(Application::APP_ID, $request); + } + + /** + * Import calendar data + * + * @param string $transaction client generated transaction id + * @param string $target calendar id + * @param array{format?:string, validation?:0|1|2, errors?:0|1, supersede?:bool, showCreated?:bool, showUpdated?:bool, showSkipped?:bool, showErrors?:bool} $options configuration options + * @param string $data calendar data + * @param string|null $user system user id + * + * @return StreamGeneratorResponse | DataResponse + * + * 200: NDJSON stream of import event objects + * 400: invalid parameters + * 401: user not authorized + */ + #[ApiRoute(verb: 'POST', url: '/import', root: '/calendar')] + #[UserRateLimit(limit: 10, period: 3600)] + #[NoAdminRequired] + public function import(string $transaction, string $target, array $options, string $data, ?string $user = null): DataResponse|StreamGeneratorResponse { + $calendarId = $target; + $format = isset($options['format']) ? $options['format'] : null; + $validation = isset($options['validation']) ? (int)$options['validation'] : null; + $errors = isset($options['errors']) ? (int)$options['errors'] : null; + $supersede = $options['supersede'] ?? false; + // evaluate if user is logged in and has permissions + if (!$this->userSession->isLoggedIn()) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if ($user !== null) { + if ($this->userSession->getUser()->getUID() !== $user + && $this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if (!$this->userManager->userExists($user)) { + return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); + } + $userId = $user; + } else { + $userId = $this->userSession->getUser()->getUID(); + } + // retrieve calendar and evaluate if import is supported and writeable + $calendars = $this->calendarManager->getCalendarsForPrincipal('principals/users/' . $userId, [$calendarId]); + if ($calendars === []) { + return new DataResponse(['error' => "Calendar <$calendarId> not found"], Http::STATUS_BAD_REQUEST); + } + $calendar = $calendars[0]; + if (!$calendar instanceof CalendarImpl) { + return new DataResponse(['error' => "Calendar <$calendarId> does not support this function"], Http::STATUS_BAD_REQUEST); + } + if (!$calendar->isWritable()) { + return new DataResponse(['error' => "Calendar <$calendarId> is not writeable"], Http::STATUS_BAD_REQUEST); + } + if ($calendar->isDeleted()) { + return new DataResponse(['error' => "Calendar <$calendarId> is deleted"], Http::STATUS_BAD_REQUEST); + } + // construct options object + $options = new CalendarImportOptions(); + $options->setSupersede($supersede); + if ($errors !== null) { + try { + $options->setErrors($errors); + } catch (InvalidArgumentException) { + return new DataResponse(['error' => 'Invalid errors option specified'], Http::STATUS_BAD_REQUEST); + } + } + if ($validation !== null) { + try { + $options->setValidate($validation); + } catch (InvalidArgumentException) { + return new DataResponse(['error' => 'Invalid validation option specified'], Http::STATUS_BAD_REQUEST); + } + } + try { + $options->setFormat($format ?? 'ical'); + } catch (InvalidArgumentException) { + return new DataResponse(['error' => 'Invalid format option specified'], Http::STATUS_BAD_REQUEST); + } + $options->setCounts(true); + // process the data + $tempPath = $this->tempManager->getTemporaryFile(); + $tempFile = fopen($tempPath, 'w+'); + fwrite($tempFile, $data); + unset($data); + fseek($tempFile, 0); + + $importGenerator = $this->importService->import($tempFile, $calendar, $options); + $stream = (function () use ($importGenerator, $tempFile, $transaction): \Generator { + yield json_encode(['type' => 'control', 'transaction' => $transaction, 'disposition' => 'start']) . "\n"; + try { + foreach ($importGenerator as $result) { + $data = $result->jsonSerialize(); + $data['transaction'] = $transaction; + yield json_encode($data) . PHP_EOL; + } + } finally { + yield json_encode(['type' => 'control', 'transaction' => $transaction, 'disposition' => 'end']) . "\n"; + fclose($tempFile); + } + })(); + + return new StreamGeneratorResponse($stream, 'application/x-ndjson'); + } +} diff --git a/apps/dav/lib/UserMigration/CalendarMigrator.php b/apps/dav/lib/UserMigration/CalendarMigrator.php index 8dd94d6d4cf25..84154272c8df1 100644 --- a/apps/dav/lib/UserMigration/CalendarMigrator.php +++ b/apps/dav/lib/UserMigration/CalendarMigrator.php @@ -13,6 +13,8 @@ use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\CalendarImpl; use OCA\DAV\CalDAV\Export\ExportService; +use OCA\DAV\CalDAV\Import\ImportDisposition; +use OCA\DAV\CalDAV\Import\ImportObjectEvent; use OCA\DAV\CalDAV\Import\ImportService; use OCP\App\IAppManager; use OCP\Calendar\CalendarExportOptions; @@ -331,24 +333,22 @@ public function importCalendarsV2(IUser $user, IImportSource $importSource, Outp rewind($tempFile); // import calendar data + $options = new CalendarImportOptions(); + $options->setFormat($calendarMeta['format'] ?? 'ical'); + $options->setErrors(0); + $options->setValidate(1); + $options->setSupersede(true); + try { - $options = new CalendarImportOptions(); - $options->setFormat($calendarMeta['format'] ?? 'ical'); - $options->setErrors(0); - $options->setValidate(1); - $options->setSupersede(true); - - $outcome = $this->importService->import( - $tempFile, - $calendar, - $options + $this->importSummary( + $calendarMeta['label'] ?? $calendarMeta['uri'], + $this->importService->import($tempFile, $calendar, $options), + $output ); } finally { fclose($tempFile); } - $this->importSummary($calendarMeta['label'] ?? $calendarMeta['uri'], $outcome, $output); - $importCount++; } catch (Throwable $e) { $output->writeln('Failed to import calendar "' . ($calendarMeta['uri'] ?? 'unknown') . '", skipping…'); @@ -442,16 +442,14 @@ public function importCalendarsV1(IUser $user, IImportSource $importSource, Outp $options->setSupersede(true); try { - $outcome = $this->importService->import( - $tempFile, - $calendar, - $options + $this->importSummary( + $calendarName ?? $calendarUri, + $this->importService->import($tempFile, $calendar, $options), + $output ); } finally { fclose($tempFile); } - - $this->importSummary($calendarName ?? $calendarUri, $outcome, $output); } catch (Throwable $e) { $output->writeln("Failed to import calendar \"$filename\", skipping…"); continue; @@ -515,19 +513,25 @@ public function importSubscriptions(IUser $user, IImportSource $importSource, Ou } } - private function importSummary(string $label, array $outcome, OutputInterface $output): void { + /** + * @param iterable $stream + */ + private function importSummary(string $label, iterable $stream, OutputInterface $output): void { $created = 0; $updated = 0; $skipped = 0; $errors = 0; - foreach ($outcome as $result) { - match ($result['outcome'] ?? null) { - 'created' => $created++, - 'updated' => $updated++, - 'exists' => $skipped++, - 'error' => $errors++, - default => null, + foreach ($stream as $event) { + if (!$event instanceof ImportObjectEvent) { + continue; + } + + match ($event->disposition) { + ImportDisposition::Created => $created++, + ImportDisposition::Updated => $updated++, + ImportDisposition::Exists => $skipped++, + ImportDisposition::Error => $errors++, }; } diff --git a/apps/dav/openapi.json b/apps/dav/openapi.json index e69d5be01d9a0..c5fdbb539b6ed 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -1576,6 +1576,419 @@ } } } + }, + "/ocs/v2.php/calendar/export": { + "post": { + "operationId": "calendar_export-export", + "summary": "Export calendar data", + "tags": [ + "calendar_export" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "target" + ], + "properties": { + "target": { + "type": "string", + "description": "calendar id" + }, + "type": { + "type": "string", + "nullable": true, + "default": null, + "description": "data format" + }, + "options": { + "type": "object", + "default": null, + "description": "configuration options", + "required": [ + "rangeStart", + "rangeCount" + ], + "properties": { + "rangeStart": { + "type": "string" + }, + "rangeCount": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + }, + "user": { + "type": "string", + "nullable": true, + "default": null, + "description": "system user id" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "data in requested format", + "content": { + "text/calendar; charset=UTF-8": { + "schema": { + "anyOf": [] + } + }, + "application/calendar+json; charset=UTF-8": { + "schema": { + "anyOf": [] + } + }, + "application/calendar+xml; charset=UTF-8": { + "schema": { + "anyOf": [] + } + } + } + }, + "400": { + "description": "invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "user not authorized", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/calendar/import": { + "post": { + "operationId": "calendar_import-import", + "summary": "Import calendar data", + "tags": [ + "calendar_import" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "transaction", + "target", + "options", + "data" + ], + "properties": { + "transaction": { + "type": "string", + "description": "client generated transaction id" + }, + "target": { + "type": "string", + "description": "calendar id" + }, + "options": { + "type": "object", + "description": "configuration options", + "properties": { + "format": { + "type": "string" + }, + "validation": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ] + }, + "errors": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1 + ] + }, + "supersede": { + "type": "boolean" + }, + "showCreated": { + "type": "boolean" + }, + "showUpdated": { + "type": "boolean" + }, + "showSkipped": { + "type": "boolean" + }, + "showErrors": { + "type": "boolean" + } + } + }, + "data": { + "type": "string", + "description": "calendar data" + }, + "user": { + "type": "string", + "nullable": true, + "default": null, + "description": "system user id" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "NDJSON stream of import event objects", + "content": { + "application/x-ndjson": { + "schema": { + "anyOf": [] + } + } + } + }, + "400": { + "description": "invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "user not authorized", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + } + } + } } }, "tags": [] diff --git a/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php index 0cfafae22bcdd..066d77ddf3e70 100644 --- a/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php +++ b/apps/dav/tests/unit/CalDAV/Import/ImportServiceTest.php @@ -8,6 +8,9 @@ use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\CalendarImpl; +use OCA\DAV\CalDAV\Import\ImportCountEvent; +use OCA\DAV\CalDAV\Import\ImportDisposition; +use OCA\DAV\CalDAV\Import\ImportObjectEvent; use OCA\DAV\CalDAV\Import\ImportService; use OCP\Calendar\CalendarImportOptions; use PHPUnit\Framework\MockObject\MockObject; @@ -19,7 +22,6 @@ class ImportServiceTest extends \Test\TestCase { private ImportService $service; private CalendarImpl|MockObject $calendar; private CalDavBackend|MockObject $backend; - private array $importResults = []; protected function setUp(): void { parent::setUp(); @@ -55,6 +57,7 @@ public function testImport(): void { // construct import options $options = new CalendarImportOptions(); $options->setFormat('ical'); + $options->setCounts(true); // Mock calendar methods $this->calendar->expects($this->once()) @@ -79,13 +82,18 @@ public function testImport(): void { ); // Act - $result = $this->service->import($stream, $this->calendar, $options); + $result = iterator_to_array($this->service->import($stream, $this->calendar, $options), false); // Assert $this->assertIsArray($result); - $this->assertCount(1, $result, 'Import result should contain one item'); - $this->assertArrayHasKey('96a0e6b1-d886-4a55-a60d-152b31401dcc', $result); - $this->assertEquals('created', $result['96a0e6b1-d886-4a55-a60d-152b31401dcc']['outcome']); + $this->assertCount(2, $result, 'Import result should contain counts and one item'); + $this->assertInstanceOf(ImportCountEvent::class, $result[0]); + $this->assertSame(1, $result[0]->vevent); + $this->assertSame(0, $result[0]->vtodo); + $this->assertSame(0, $result[0]->vjournal); + $this->assertInstanceOf(ImportObjectEvent::class, $result[1]); + $this->assertSame('96a0e6b1-d886-4a55-a60d-152b31401dcc', $result[1]->identifier); + $this->assertSame(ImportDisposition::Created, $result[1]->disposition); } public function testImportWithMultiLineUID(): void { @@ -139,12 +147,13 @@ public function testImportWithMultiLineUID(): void { ); // Act - $result = $this->service->import($stream, $this->calendar, $options); + $result = iterator_to_array($this->service->import($stream, $this->calendar, $options), false); // Assert $this->assertIsArray($result); $this->assertCount(1, $result, 'Import result should contain one item'); - $this->assertArrayHasKey($longUID, $result); - $this->assertEquals('created', $result[$longUID]['outcome']); + $this->assertInstanceOf(ImportObjectEvent::class, $result[0]); + $this->assertSame($longUID, $result[0]->identifier); + $this->assertSame(ImportDisposition::Created, $result[0]->disposition); } } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 198c691981d62..370bbccee81de 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -127,6 +127,7 @@ 'OCP\\AppFramework\\Http\\RedirectToDefaultAppResponse' => $baseDir . '/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php', 'OCP\\AppFramework\\Http\\Response' => $baseDir . '/lib/public/AppFramework/Http/Response.php', 'OCP\\AppFramework\\Http\\StandaloneTemplateResponse' => $baseDir . '/lib/public/AppFramework/Http/StandaloneTemplateResponse.php', + 'OCP\\AppFramework\\Http\\StreamGeneratorResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamGeneratorResponse.php', 'OCP\\AppFramework\\Http\\StreamResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamResponse.php', 'OCP\\AppFramework\\Http\\StreamTraversableResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamTraversableResponse.php', 'OCP\\AppFramework\\Http\\TemplateResponse' => $baseDir . '/lib/public/AppFramework/Http/TemplateResponse.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 36960602bd5e3..77ac18af6ec89 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -168,6 +168,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\AppFramework\\Http\\RedirectToDefaultAppResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php', 'OCP\\AppFramework\\Http\\Response' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Response.php', 'OCP\\AppFramework\\Http\\StandaloneTemplateResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StandaloneTemplateResponse.php', + 'OCP\\AppFramework\\Http\\StreamGeneratorResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamGeneratorResponse.php', 'OCP\\AppFramework\\Http\\StreamResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamResponse.php', 'OCP\\AppFramework\\Http\\StreamTraversableResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamTraversableResponse.php', 'OCP\\AppFramework\\Http\\TemplateResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/TemplateResponse.php', diff --git a/lib/public/AppFramework/Http/StreamGeneratorResponse.php b/lib/public/AppFramework/Http/StreamGeneratorResponse.php new file mode 100644 index 0000000000000..16a7c582aa309 --- /dev/null +++ b/lib/public/AppFramework/Http/StreamGeneratorResponse.php @@ -0,0 +1,63 @@ + + * @template-extends Response> + */ +class StreamGeneratorResponse extends Response implements ICallbackResponse { + protected $generator; + + /** + * @since 35.0.0 + * + * @param Generator $generator the function to call to generate the response + * @param string $contentType http response content type e.g. 'application/json; charset=UTF-8' + * @param S $status http response status + * @param array|null $headers additional headers + */ + public function __construct(Generator $generator, string $contentType, int $status = Http::STATUS_OK, ?array $headers = []) { + parent::__construct(); + + $this->generator = $generator; + + $this->setStatus($status); + $this->addHeader('Content-Type', $contentType); + + foreach ($headers as $key => $value) { + $this->addHeader($key, $value); + } + } + + /** + * Streams content directly to client + * + * @since 35.0.0 + * + * @param IOutput $output a small wrapper that handles output + */ + #[\Override] + public function callback(IOutput $output): void { + + foreach ($this->generator as $chunk) { + print($chunk); + flush(); + } + + } + +} diff --git a/lib/public/Calendar/CalendarImportOptions.php b/lib/public/Calendar/CalendarImportOptions.php index f5f330a425497..c302d916de717 100644 --- a/lib/public/Calendar/CalendarImportOptions.php +++ b/lib/public/Calendar/CalendarImportOptions.php @@ -62,6 +62,7 @@ final class CalendarImportOptions { private bool $supersede = false; private int $errors = self::ERROR_FAIL; private int $validate = self::VALIDATE_SKIP; + private bool $counts = false; /** * Gets the import format @@ -154,4 +155,22 @@ public function setValidate(int $value): void { $this->validate = $value; } + /** + * Gets whether to include object counts as the first yielded value + * + * @since 35.0.0 + */ + public function getCounts(): bool { + return $this->counts; + } + + /** + * Sets whether to include object counts as the first yielded value + * + * @since 35.0.0 + */ + public function setCounts(bool $counts): void { + $this->counts = $counts; + } + } diff --git a/openapi.json b/openapi.json index a1f49c6d3d39a..7e4146d54500b 100644 --- a/openapi.json +++ b/openapi.json @@ -20446,6 +20446,419 @@ } } }, + "/ocs/v2.php/calendar/export": { + "post": { + "operationId": "dav-calendar_export-export", + "summary": "Export calendar data", + "tags": [ + "dav/calendar_export" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "target" + ], + "properties": { + "target": { + "type": "string", + "description": "calendar id" + }, + "type": { + "type": "string", + "nullable": true, + "default": null, + "description": "data format" + }, + "options": { + "type": "object", + "default": null, + "description": "configuration options", + "required": [ + "rangeStart", + "rangeCount" + ], + "properties": { + "rangeStart": { + "type": "string" + }, + "rangeCount": { + "type": "integer", + "format": "int64", + "minimum": 1 + } + } + }, + "user": { + "type": "string", + "nullable": true, + "default": null, + "description": "system user id" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "data in requested format", + "content": { + "text/calendar; charset=UTF-8": { + "schema": { + "anyOf": [] + } + }, + "application/calendar+json; charset=UTF-8": { + "schema": { + "anyOf": [] + } + }, + "application/calendar+xml; charset=UTF-8": { + "schema": { + "anyOf": [] + } + } + } + }, + "400": { + "description": "invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "user not authorized", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/calendar/import": { + "post": { + "operationId": "dav-calendar_import-import", + "summary": "Import calendar data", + "tags": [ + "dav/calendar_import" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "transaction", + "target", + "options", + "data" + ], + "properties": { + "transaction": { + "type": "string", + "description": "client generated transaction id" + }, + "target": { + "type": "string", + "description": "calendar id" + }, + "options": { + "type": "object", + "description": "configuration options", + "properties": { + "format": { + "type": "string" + }, + "validation": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ] + }, + "errors": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1 + ] + }, + "supersede": { + "type": "boolean" + }, + "showCreated": { + "type": "boolean" + }, + "showUpdated": { + "type": "boolean" + }, + "showSkipped": { + "type": "boolean" + }, + "showErrors": { + "type": "boolean" + } + } + }, + "data": { + "type": "string", + "description": "calendar data" + }, + "user": { + "type": "string", + "nullable": true, + "default": null, + "description": "system user id" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "NDJSON stream of import event objects", + "content": { + "application/x-ndjson": { + "schema": { + "anyOf": [] + } + } + } + }, + "400": { + "description": "invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "user not authorized", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + } + } + } + }, "/index.php/apps/federatedfilesharing/createFederatedShare": { "post": { "operationId": "federatedfilesharing-mount_public_link-create-federated-share", diff --git a/tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php b/tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php new file mode 100644 index 0000000000000..d327c257391db --- /dev/null +++ b/tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php @@ -0,0 +1,46 @@ +getHeaders(); + $this->assertEquals('text/plain', $headers['Content-Type']); + $this->assertEquals(200, $response->getStatus()); + } + + public function testCallback() { + $count = 0; + $generator = function () use (&$count) { + $count++; + yield 'chunk1'; + $count++; + yield 'chunk2'; + }; + $response = new StreamGeneratorResponse($generator(), 'text/plain'); + $output = $this->createMock(\OCP\AppFramework\Http\IOutput::class); + + $response->callback($output); + $this->assertEquals($count, 2); + } + +} From 2bf6d6a33326d1c9857d352d13ff0fb7ed3350c5 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Thu, 13 Aug 2026 21:21:58 -0400 Subject: [PATCH 2/3] fix: contacts import ocs Signed-off-by: SebastianKrupinski --- .../composer/composer/autoload_classmap.php | 7 + .../dav/composer/composer/autoload_static.php | 7 + apps/dav/lib/CardDAV/AddressBookImpl.php | 24 ++- .../lib/CardDAV/Import/ImportCountEvent.php | 31 +++ .../lib/CardDAV/Import/ImportDisposition.php | 16 ++ apps/dav/lib/CardDAV/Import/ImportEvent.php | 14 ++ .../lib/CardDAV/Import/ImportObjectEvent.php | 40 ++++ apps/dav/lib/CardDAV/Import/ImportService.php | 204 ++++++++++++++++++ apps/dav/lib/CardDAV/Import/TextImporter.php | 159 ++++++++++++++ .../Controller/ContactsImportController.php | 146 +++++++++++++ apps/dav/openapi.json | 203 +++++++++++++++++ lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 14 +- lib/private/ContactsManager.php | 16 ++ lib/public/Contacts/ContactsImportOptions.php | 177 +++++++++++++++ lib/public/Contacts/IManager.php | 9 + lib/public/IAddressBookWritable.php | 26 +++ openapi.json | 203 +++++++++++++++++ 18 files changed, 1291 insertions(+), 7 deletions(-) create mode 100644 apps/dav/lib/CardDAV/Import/ImportCountEvent.php create mode 100644 apps/dav/lib/CardDAV/Import/ImportDisposition.php create mode 100644 apps/dav/lib/CardDAV/Import/ImportEvent.php create mode 100644 apps/dav/lib/CardDAV/Import/ImportObjectEvent.php create mode 100644 apps/dav/lib/CardDAV/Import/ImportService.php create mode 100644 apps/dav/lib/CardDAV/Import/TextImporter.php create mode 100644 apps/dav/lib/Controller/ContactsImportController.php create mode 100644 lib/public/Contacts/ContactsImportOptions.php create mode 100644 lib/public/IAddressBookWritable.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 9e2afc8520703..b97f6ceb9b72b 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -173,6 +173,12 @@ 'OCA\\DAV\\CardDAV\\Converter' => $baseDir . '/../lib/CardDAV/Converter.php', 'OCA\\DAV\\CardDAV\\HasPhotoPlugin' => $baseDir . '/../lib/CardDAV/HasPhotoPlugin.php', 'OCA\\DAV\\CardDAV\\ImageExportPlugin' => $baseDir . '/../lib/CardDAV/ImageExportPlugin.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportCountEvent' => $baseDir . '/../lib/CardDAV/Import/ImportCountEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportDisposition' => $baseDir . '/../lib/CardDAV/Import/ImportDisposition.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportEvent' => $baseDir . '/../lib/CardDAV/Import/ImportEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportObjectEvent' => $baseDir . '/../lib/CardDAV/Import/ImportObjectEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportService' => $baseDir . '/../lib/CardDAV/Import/ImportService.php', + 'OCA\\DAV\\CardDAV\\Import\\TextImporter' => $baseDir . '/../lib/CardDAV/Import/TextImporter.php', 'OCA\\DAV\\CardDAV\\Integration\\ExternalAddressBook' => $baseDir . '/../lib/CardDAV/Integration/ExternalAddressBook.php', 'OCA\\DAV\\CardDAV\\Integration\\IAddressBookProvider' => $baseDir . '/../lib/CardDAV/Integration/IAddressBookProvider.php', 'OCA\\DAV\\CardDAV\\MultiGetExportPlugin' => $baseDir . '/../lib/CardDAV/MultiGetExportPlugin.php', @@ -269,6 +275,7 @@ 'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php', 'OCA\\DAV\\Controller\\CalendarExportController' => $baseDir . '/../lib/Controller/CalendarExportController.php', 'OCA\\DAV\\Controller\\CalendarImportController' => $baseDir . '/../lib/Controller/CalendarImportController.php', + 'OCA\\DAV\\Controller\\ContactsImportController' => $baseDir . '/../lib/Controller/ContactsImportController.php', 'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php', 'OCA\\DAV\\Controller\\ExampleContentController' => $baseDir . '/../lib/Controller/ExampleContentController.php', 'OCA\\DAV\\Controller\\FederatedCalendarController' => $baseDir . '/../lib/Controller/FederatedCalendarController.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 5e5163b9e1bc3..da47db57dcce5 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -188,6 +188,12 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CardDAV\\Converter' => __DIR__ . '/..' . '/../lib/CardDAV/Converter.php', 'OCA\\DAV\\CardDAV\\HasPhotoPlugin' => __DIR__ . '/..' . '/../lib/CardDAV/HasPhotoPlugin.php', 'OCA\\DAV\\CardDAV\\ImageExportPlugin' => __DIR__ . '/..' . '/../lib/CardDAV/ImageExportPlugin.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportCountEvent' => __DIR__ . '/..' . '/../lib/CardDAV/Import/ImportCountEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportDisposition' => __DIR__ . '/..' . '/../lib/CardDAV/Import/ImportDisposition.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportEvent' => __DIR__ . '/..' . '/../lib/CardDAV/Import/ImportEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportObjectEvent' => __DIR__ . '/..' . '/../lib/CardDAV/Import/ImportObjectEvent.php', + 'OCA\\DAV\\CardDAV\\Import\\ImportService' => __DIR__ . '/..' . '/../lib/CardDAV/Import/ImportService.php', + 'OCA\\DAV\\CardDAV\\Import\\TextImporter' => __DIR__ . '/..' . '/../lib/CardDAV/Import/TextImporter.php', 'OCA\\DAV\\CardDAV\\Integration\\ExternalAddressBook' => __DIR__ . '/..' . '/../lib/CardDAV/Integration/ExternalAddressBook.php', 'OCA\\DAV\\CardDAV\\Integration\\IAddressBookProvider' => __DIR__ . '/..' . '/../lib/CardDAV/Integration/IAddressBookProvider.php', 'OCA\\DAV\\CardDAV\\MultiGetExportPlugin' => __DIR__ . '/..' . '/../lib/CardDAV/MultiGetExportPlugin.php', @@ -284,6 +290,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php', 'OCA\\DAV\\Controller\\CalendarExportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarExportController.php', 'OCA\\DAV\\Controller\\CalendarImportController' => __DIR__ . '/..' . '/../lib/Controller/CalendarImportController.php', + 'OCA\\DAV\\Controller\\ContactsImportController' => __DIR__ . '/..' . '/../lib/Controller/ContactsImportController.php', 'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php', 'OCA\\DAV\\Controller\\ExampleContentController' => __DIR__ . '/..' . '/../lib/Controller/ExampleContentController.php', 'OCA\\DAV\\Controller\\FederatedCalendarController' => __DIR__ . '/..' . '/../lib/Controller/FederatedCalendarController.php', diff --git a/apps/dav/lib/CardDAV/AddressBookImpl.php b/apps/dav/lib/CardDAV/AddressBookImpl.php index f22e38a41b47f..6563dd27a3343 100644 --- a/apps/dav/lib/CardDAV/AddressBookImpl.php +++ b/apps/dav/lib/CardDAV/AddressBookImpl.php @@ -10,6 +10,7 @@ use OCA\DAV\Db\PropertyMapper; use OCP\Constants; use OCP\IAddressBookEnabled; +use OCP\IAddressBookWritable; use OCP\ICreateContactFromString; use OCP\IURLGenerator; use Sabre\VObject\Component\VCard; @@ -17,7 +18,7 @@ use Sabre\VObject\Reader; use Sabre\VObject\UUIDUtil; -class AddressBookImpl implements IAddressBookEnabled, ICreateContactFromString { +class AddressBookImpl implements ICreateContactFromString, IAddressBookEnabled, IAddressBookWritable { /** * AddressBookImpl constructor. @@ -55,6 +56,14 @@ public function getUri(): string { return $this->addressBookInfo['uri']; } + /** + * @return string the principal URI of the address book owner + * @since 35.0.0 + */ + public function getPrincipalUri(): string { + return $this->addressBookInfo['principaluri']; + } + /** * In comparison to getKey() this function returns a human readable (maybe translated) name * @@ -348,6 +357,19 @@ public function isEnabled(): bool { return true; } + #[\Override] + public function isWritable(): bool { + if (!$this->userId) { + return true; + } + + if ($this->isSystemAddressBook()) { + return false; + } + + return $this->getPermissions() & Constants::PERMISSION_UPDATE; + } + #[\Override] public function createFromString(string $name, string $vcfData): void { $this->backend->createCard($this->getKey(), $name, $vcfData); diff --git a/apps/dav/lib/CardDAV/Import/ImportCountEvent.php b/apps/dav/lib/CardDAV/Import/ImportCountEvent.php new file mode 100644 index 0000000000000..54f00914d75fe --- /dev/null +++ b/apps/dav/lib/CardDAV/Import/ImportCountEvent.php @@ -0,0 +1,31 @@ +vcard; + } + + /** + * @return array{type: 'count', vcard: int} + */ + #[\Override] + public function jsonSerialize(): array { + return [ + 'type' => 'count', + 'vcard' => $this->total(), + ]; + } +} diff --git a/apps/dav/lib/CardDAV/Import/ImportDisposition.php b/apps/dav/lib/CardDAV/Import/ImportDisposition.php new file mode 100644 index 0000000000000..a7400da402b3f --- /dev/null +++ b/apps/dav/lib/CardDAV/Import/ImportDisposition.php @@ -0,0 +1,16 @@ + $errors + */ + public function __construct( + public ?string $identifier, + public ImportDisposition $disposition, + public array $errors = [], + ) { + } + + public function isError(): bool { + return $this->disposition === ImportDisposition::Error; + } + + /** + * @return array{type: 'object', identifier: ?string, disposition: string, errors: list} + */ + #[\Override] + public function jsonSerialize(): array { + $result = [ + 'type' => 'object', + 'identifier' => $this->identifier, + 'disposition' => $this->disposition->value, + 'errors' => $this->errors, + ]; + + return $result; + } +} diff --git a/apps/dav/lib/CardDAV/Import/ImportService.php b/apps/dav/lib/CardDAV/Import/ImportService.php new file mode 100644 index 0000000000000..6e8a073851452 --- /dev/null +++ b/apps/dav/lib/CardDAV/Import/ImportService.php @@ -0,0 +1,204 @@ + + * + * @throws \InvalidArgumentException + */ + public function import($source, AddressBookImpl $addressBook, ContactsImportOptions $options): Generator { + if (!is_resource($source)) { + throw new InvalidArgumentException('Invalid import source must be a file resource'); + } + return match ($options->getFormat()) { + 'vcf' => $this->importProcess($source, $addressBook, $options, $this->importText(...)), + default => throw new InvalidArgumentException('Invalid import format'), + }; + } + + /** + * Generates object stream from a text formatted source (vcf) + * + * @param resource $source + * + * @return Generator + */ + public function importText($source, ?ContactsImportOptions $options = null): Generator { + if (!is_resource($source)) { + throw new InvalidArgumentException('Invalid import source must be a file resource'); + } + $importer = new TextImporter($source); + $structure = $importer->structure(); + // object counts before streaming if requested + if ($options?->getCounts()) { + yield 'counts' => [ + 'VCARD' => count($structure['VCARD']) + ]; + } + // card components + foreach ($structure['VCARD'] as $cid => $instances) { + /** @var array $instances */ + // extract all instances of component and unserialize to object + $instance = $instances[0]; + $sObjectContents = $importer->extract($instance[2], $instance[3]); + /** @var VCard $vObject */ + $vObject = Reader::read($sObjectContents); + yield $vObject; + } + } + + /** + * Import objects + * + * @since 32.0.0 + * + * @param resource $source + * @param ContactsImportOptions $options + * @param callable $generator: Generator + * + * @return Generator + */ + public function importProcess($source, AddressBookImpl $addressBook, ContactsImportOptions $options, callable $generator): Generator { + $addressBookId = (int)$addressBook->getKey(); + foreach ($generator($source, $options) as $key => $value) { + if ($key === 'counts') { + yield new ImportCountEvent( + vcard: $value['VCARD'] ?? 0, + ); + continue; + } + $vObject = $value; + // determine if the object has a uid + if (!isset($vObject->UID)) { + $errorMessage = 'One or more objects discovered without a UID'; + if ($options->getErrors() === $options::ERROR_FAIL) { + throw new InvalidArgumentException('Error importing calendar data: ' . $errorMessage); + } + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: null, + errors: [$errorMessage] + ); + continue; + } + $uid = $vObject->UID->getValue(); + // validate object + if ($options->getValidate() !== $options::VALIDATE_NONE) { + $issues = $this->componentValidate($vObject, true, 3); + if ($options->getValidate() === $options::VALIDATE_SKIP && $issues !== []) { + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: $uid, + errors: $issues + ); + continue; + } elseif ($options->getValidate() === $options::VALIDATE_FAIL && $issues !== []) { + throw new InvalidArgumentException('Error importing calendar data: UID <' . $uid . '> - ' . $issues[0]); + } + } + // create or update object in the data store + $objectEntry = $this->backend->getCardByUID($addressBookId, $uid); + $objectData = $vObject->serialize(); + try { + if ($objectEntry === null) { + $objectUri = UUIDUtil::getUUID(); + $this->backend->createCard( + $addressBookId, + $objectUri, + $objectData + ); + yield new ImportObjectEvent( + disposition: ImportDisposition::Created, + identifier: $uid, + ); + } else { + $objectUri = $objectEntry['uri']; + if ($options->getSupersede()) { + $this->backend->updateCard( + $addressBookId, + $objectUri, + $objectData + ); + yield new ImportObjectEvent( + disposition: ImportDisposition::Updated, + identifier: $uid, + ); + } else { + yield new ImportObjectEvent( + disposition: ImportDisposition::Exists, + identifier: $uid, + ); + } + } + } catch (Exception $e) { + $errorMessage = $e->getMessage(); + if ($options->getErrors() === $options::ERROR_FAIL) { + throw new Exception('Error importing calendar data: UID <' . $uid . '> - ' . $errorMessage, 0, $e); + } + yield new ImportObjectEvent( + disposition: ImportDisposition::Error, + identifier: $uid, + errors: [$errorMessage] + ); + } + } + } + + /** + * Validate a component + * + * @param VCard $vObject + * @param bool $repair attempt to repair the component + * @param int $level minimum level of issues to return + * @return list + */ + private function componentValidate(VCard $vObject, bool $repair, int $level): array { + // validate component(S) + $issues = $vObject->validate(Node::PROFILE_CALDAV); + // attempt to repair + if ($repair && count($issues) > 0) { + $issues = $vObject->validate(Node::REPAIR); + } + // filter out messages based on level + $result = []; + foreach ($issues as $key => $issue) { + if (isset($issue['level']) && $issue['level'] >= $level) { + $result[] = $issue['message']; + } + } + + return $result; + } +} diff --git a/apps/dav/lib/CardDAV/Import/TextImporter.php b/apps/dav/lib/CardDAV/Import/TextImporter.php new file mode 100644 index 0000000000000..2fc9a8dc3080f --- /dev/null +++ b/apps/dav/lib/CardDAV/Import/TextImporter.php @@ -0,0 +1,159 @@ + []]; + + /** + * @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); + $data = fread($this->source, $end - $start); + if ($data === false) { + throw new Exception('Failed to read from source stream'); + } + return $data; + } +} diff --git a/apps/dav/lib/Controller/ContactsImportController.php b/apps/dav/lib/Controller/ContactsImportController.php new file mode 100644 index 0000000000000..177639622a0c0 --- /dev/null +++ b/apps/dav/lib/Controller/ContactsImportController.php @@ -0,0 +1,146 @@ +, total: non-negative-int} + */ +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CardDAV\Import\ImportService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\UserRateLimit; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\StreamGeneratorResponse; +use OCP\AppFramework\OCSController; +use OCP\Contacts\ContactsImportOptions; +use OCP\Contacts\IManager; +use OCP\IAddressBookWritable; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\ITempManager; +use OCP\IUserManager; +use OCP\IUserSession; + +class ContactsImportController extends OCSController { + + public function __construct( + IRequest $request, + private IUserSession $userSession, + private IUserManager $userManager, + private IGroupManager $groupManager, + private ITempManager $tempManager, + private IManager $contactsManager, + private ImportService $importService, + ) { + parent::__construct(Application::APP_ID, $request); + } + + /** + * Import contacts data + * + * @param string $transaction client generated transaction id + * @param string $target address book id + * @param array{format?:string, validation?:0|1|2, errors?:0|1, supersede?:bool} $options configuration options + * @param string $data contacts data + * @param string|null $user system user id + * + * @return StreamGeneratorResponse | DataResponse + * + * 200: NDJSON stream of import event objects + * 400: invalid parameters + * 401: user not authorized + */ + #[ApiRoute(verb: 'POST', url: '/import', root: '/contacts')] + #[UserRateLimit(limit: 10, period: 3600)] + #[NoAdminRequired] + public function import(string $transaction, string $target, array $options, string $data, ?string $user = null): DataResponse|StreamGeneratorResponse { + $addressBookId = $target; + $format = isset($options['format']) ? $options['format'] : null; + $validation = isset($options['validation']) ? (int)$options['validation'] : null; + $errors = isset($options['errors']) ? (int)$options['errors'] : null; + $supersede = $options['supersede'] ?? false; + // evaluate if user is logged in and has permissions + if (!$this->userSession->isLoggedIn()) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if ($user !== null) { + if ($this->userSession->getUser()->getUID() !== $user + && $this->groupManager->isAdmin($this->userSession->getUser()->getUID()) === false) { + return new DataResponse([], Http::STATUS_UNAUTHORIZED); + } + if (!$this->userManager->userExists($user)) { + return new DataResponse(['error' => 'user not found'], Http::STATUS_BAD_REQUEST); + } + $userId = $user; + } else { + $userId = $this->userSession->getUser()->getUID(); + } + // retrieve address book and evaluate if import is supported and writeable + $addressBooks = $this->contactsManager->getAddressBooksForPrincipal('principals/users/' . $userId, [$addressBookId]); + if ($addressBooks === []) { + return new DataResponse(['error' => "Address book <$addressBookId> not found"], Http::STATUS_BAD_REQUEST); + } + $addressBook = $addressBooks[0]; + if (!$addressBook instanceof IAddressBookWritable) { + return new DataResponse(['error' => "Address book <$addressBookId> does not support this function"], Http::STATUS_BAD_REQUEST); + } + if (!$addressBook->isWritable()) { + return new DataResponse(['error' => "Address book <$addressBookId> is not writeable"], Http::STATUS_BAD_REQUEST); + } + // construct options object + $options = new ContactsImportOptions(); + $options->setSupersede($supersede); + if ($errors !== null) { + try { + $options->setErrors($errors); + } catch (InvalidArgumentException) { + return new DataResponse(['error' => 'Invalid errors option specified'], Http::STATUS_BAD_REQUEST); + } + } + if ($validation !== null) { + try { + $options->setValidate($validation); + } catch (InvalidArgumentException) { + return new DataResponse(['error' => 'Invalid validation option specified'], Http::STATUS_BAD_REQUEST); + } + } + try { + $options->setFormat($format ?? 'ical'); + } catch (InvalidArgumentException) { + return new DataResponse(['error' => 'Invalid format option specified'], Http::STATUS_BAD_REQUEST); + } + $options->setCounts(true); + // process the data + $tempPath = $this->tempManager->getTemporaryFile(); + $tempFile = fopen($tempPath, 'w+'); + fwrite($tempFile, $data); + unset($data); + fseek($tempFile, 0); + + $importGenerator = $this->importService->import($tempFile, $addressBook, $options); + $stream = (function () use ($importGenerator, $tempFile, $transaction): \Generator { + yield json_encode(['type' => 'control', 'transaction' => $transaction, 'disposition' => 'start']) . "\n"; + try { + foreach ($importGenerator as $result) { + $data = $result->jsonSerialize(); + $data['transaction'] = $transaction; + yield json_encode($data) . PHP_EOL; + } + } finally { + yield json_encode(['type' => 'control', 'transaction' => $transaction, 'disposition' => 'end']) . "\n"; + fclose($tempFile); + } + })(); + + return new StreamGeneratorResponse($stream, 'application/x-ndjson'); + } +} diff --git a/apps/dav/openapi.json b/apps/dav/openapi.json index c5fdbb539b6ed..dad136c0472e0 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -1989,6 +1989,209 @@ } } } + }, + "/ocs/v2.php/contacts/import": { + "post": { + "operationId": "contacts_import-import", + "summary": "Import contacts data", + "tags": [ + "contacts_import" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "transaction", + "target", + "options", + "data" + ], + "properties": { + "transaction": { + "type": "string", + "description": "client generated transaction id" + }, + "target": { + "type": "string", + "description": "address book id" + }, + "options": { + "type": "object", + "description": "configuration options", + "properties": { + "format": { + "type": "string" + }, + "validation": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1, + 2 + ] + }, + "errors": { + "type": "integer", + "format": "int64", + "enum": [ + 0, + 1 + ] + }, + "supersede": { + "type": "boolean" + } + } + }, + "data": { + "type": "string", + "description": "contacts data" + }, + "user": { + "type": "string", + "nullable": true, + "default": null, + "description": "system user id" + } + } + } + } + } + }, + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "NDJSON stream of import event objects", + "content": { + "application/x-ndjson": { + "schema": { + "anyOf": [] + } + } + } + }, + "400": { + "description": "invalid parameters", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "user not authorized", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "object", + "properties": { + "error": { + "type": "string", + "minLength": 1 + } + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + } + } + } } }, "tags": [] diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 370bbccee81de..461d67022130d 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -305,6 +305,7 @@ 'OCP\\Console\\ConsoleEvent' => $baseDir . '/lib/public/Console/ConsoleEvent.php', 'OCP\\Console\\ReservedOptions' => $baseDir . '/lib/public/Console/ReservedOptions.php', 'OCP\\Constants' => $baseDir . '/lib/public/Constants.php', + 'OCP\\Contacts\\ContactsImportOptions' => $baseDir . '/lib/public/Contacts/ContactsImportOptions.php', 'OCP\\Contacts\\ContactsMenu\\IAction' => $baseDir . '/lib/public/Contacts/ContactsMenu/IAction.php', 'OCP\\Contacts\\ContactsMenu\\IActionFactory' => $baseDir . '/lib/public/Contacts/ContactsMenu/IActionFactory.php', 'OCP\\Contacts\\ContactsMenu\\IBulkProvider' => $baseDir . '/lib/public/Contacts/ContactsMenu/IBulkProvider.php', @@ -623,6 +624,7 @@ 'OCP\\Http\\WellKnown\\JrdResponse' => $baseDir . '/lib/public/Http/WellKnown/JrdResponse.php', 'OCP\\IAddressBook' => $baseDir . '/lib/public/IAddressBook.php', 'OCP\\IAddressBookEnabled' => $baseDir . '/lib/public/IAddressBookEnabled.php', + 'OCP\\IAddressBookWritable' => $baseDir . '/lib/public/IAddressBookWritable.php', 'OCP\\IAppConfig' => $baseDir . '/lib/public/IAppConfig.php', 'OCP\\IAvatar' => $baseDir . '/lib/public/IAvatar.php', 'OCP\\IAvatarManager' => $baseDir . '/lib/public/IAvatarManager.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 77ac18af6ec89..5e5ffcc9a7544 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -11,32 +11,32 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 ); public static $prefixLengthsPsr4 = array ( - 'O' => + 'O' => array ( 'OC\\Core\\' => 8, 'OC\\' => 3, 'OCP\\' => 4, ), - 'N' => + 'N' => array ( 'NCU\\' => 4, ), ); public static $prefixDirsPsr4 = array ( - 'OC\\Core\\' => + 'OC\\Core\\' => array ( 0 => __DIR__ . '/../../..' . '/core', ), - 'OC\\' => + 'OC\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/private', ), - 'OCP\\' => + 'OCP\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/public', ), - 'NCU\\' => + 'NCU\\' => array ( 0 => __DIR__ . '/../../..' . '/lib/unstable', ), @@ -346,6 +346,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Console\\ConsoleEvent' => __DIR__ . '/../../..' . '/lib/public/Console/ConsoleEvent.php', 'OCP\\Console\\ReservedOptions' => __DIR__ . '/../../..' . '/lib/public/Console/ReservedOptions.php', 'OCP\\Constants' => __DIR__ . '/../../..' . '/lib/public/Constants.php', + 'OCP\\Contacts\\ContactsImportOptions' => __DIR__ . '/../../..' . '/lib/public/Contacts/ContactsImportOptions.php', 'OCP\\Contacts\\ContactsMenu\\IAction' => __DIR__ . '/../../..' . '/lib/public/Contacts/ContactsMenu/IAction.php', 'OCP\\Contacts\\ContactsMenu\\IActionFactory' => __DIR__ . '/../../..' . '/lib/public/Contacts/ContactsMenu/IActionFactory.php', 'OCP\\Contacts\\ContactsMenu\\IBulkProvider' => __DIR__ . '/../../..' . '/lib/public/Contacts/ContactsMenu/IBulkProvider.php', @@ -664,6 +665,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Http\\WellKnown\\JrdResponse' => __DIR__ . '/../../..' . '/lib/public/Http/WellKnown/JrdResponse.php', 'OCP\\IAddressBook' => __DIR__ . '/../../..' . '/lib/public/IAddressBook.php', 'OCP\\IAddressBookEnabled' => __DIR__ . '/../../..' . '/lib/public/IAddressBookEnabled.php', + 'OCP\\IAddressBookWritable' => __DIR__ . '/../../..' . '/lib/public/IAddressBookWritable.php', 'OCP\\IAppConfig' => __DIR__ . '/../../..' . '/lib/public/IAppConfig.php', 'OCP\\IAvatar' => __DIR__ . '/../../..' . '/lib/public/IAvatar.php', 'OCP\\IAvatarManager' => __DIR__ . '/../../..' . '/lib/public/IAvatarManager.php', diff --git a/lib/private/ContactsManager.php b/lib/private/ContactsManager.php index e19efb243b8f3..cb724ed68aa8a 100644 --- a/lib/private/ContactsManager.php +++ b/lib/private/ContactsManager.php @@ -153,6 +153,22 @@ public function getUserAddressBooks(): array { return $this->addressBooks; } + #[\Override] + public function getAddressBooksForPrincipal(string $principalUri, array $addressBookUris = []): array { + $this->loadAddressBooks(); + $result = []; + foreach ($this->addressBooks as $addressBook) { + if (!method_exists($addressBook, 'getPrincipalUri')) { + continue; + } + + if ($addressBook->getPrincipalUri() === $principalUri && in_array($addressBook->getUri(), $addressBookUris, true)) { + $result[] = $addressBook; + } + } + return $result; + } + /** * removes all registered address book instances */ diff --git a/lib/public/Contacts/ContactsImportOptions.php b/lib/public/Contacts/ContactsImportOptions.php new file mode 100644 index 0000000000000..1543a4dad1df0 --- /dev/null +++ b/lib/public/Contacts/ContactsImportOptions.php @@ -0,0 +1,177 @@ +format; + } + + /** + * Sets the import format + * + * @param 'vcf'|'jcf'|'xcf' $value + * @since 35.0.0 + */ + public function setFormat(string $value): void { + if (!in_array($value, self::FORMATS, true)) { + throw new InvalidArgumentException('Format is not valid.'); + } + $this->format = $value; + } + + /** + * Gets whether to supersede existing objects + * + * @since 35.0.0 + */ + public function getSupersede(): bool { + return $this->supersede; + } + + /** + * Sets whether to supersede existing objects + * + * @since 35.0.0 + */ + public function setSupersede(bool $supersede): void { + $this->supersede = $supersede; + } + + /** + * Gets how to handle object errors + * + * @return int 0 - continue, 1 - fail + * @since 35.0.0 + */ + public function getErrors(): int { + return $this->errors; + } + + /** + * Sets how to handle object errors + * + * @param int $value 0 - continue, 1 - fail + * + * @template $value of self::ERROR_* + * @since 35.0.0 + */ + public function setErrors(int $value): void { + if (!in_array($value, self::ERROR_OPTIONS, true)) { + throw new InvalidArgumentException('Invalid errors option specified'); + } + $this->errors = $value; + } + + /** + * Gets how to handle object validation + * + * @return int 0 - no validation, 1 - validate and skip on issue, 2 - validate and fail on issue + * @since 35.0.0 + */ + public function getValidate(): int { + return $this->validate; + } + + /** + * Sets how to handle object validation + * + * @param int $value 0 - no validation, 1 - validate and skip on issue, 2 - validate and fail on issue + * + * @template $value of self::VALIDATE_* + * @since 35.0.0 + */ + public function setValidate(int $value): void { + if (!in_array($value, self::VALIDATE_OPTIONS, true)) { + throw new InvalidArgumentException('Invalid validation option specified'); + } + $this->validate = $value; + } + + /** + * Gets whether to include object counts as the first yielded value + * + * @since 35.0.0 + */ + public function getCounts(): bool { + return $this->counts; + } + + /** + * Sets whether to include object counts as the first yielded value + * + * @since 35.0.0 + */ + public function setCounts(bool $counts): void { + $this->counts = $counts; + } + +} diff --git a/lib/public/Contacts/IManager.php b/lib/public/Contacts/IManager.php index 60abb18b38217..0c0677405631e 100644 --- a/lib/public/Contacts/IManager.php +++ b/lib/public/Contacts/IManager.php @@ -146,6 +146,15 @@ public function register(\Closure $callable); */ public function getUserAddressBooks(); + /** + * @param string $principalUri URI of the principal + * @param string[] $addressBookUris optionally specify which address books to load, or all if this array is empty + * + * @return \OCP\IAddressBook[] + * @since 23.0.0 + */ + public function getAddressBooksForPrincipal(string $principalUri, array $addressBookUris = []): array; + /** * removes all registered address book instances * diff --git a/lib/public/IAddressBookWritable.php b/lib/public/IAddressBookWritable.php new file mode 100644 index 0000000000000..35f44f15a054f --- /dev/null +++ b/lib/public/IAddressBookWritable.php @@ -0,0 +1,26 @@ + Date: Tue, 18 Aug 2026 11:03:43 -0400 Subject: [PATCH 3/3] fix: combine StreamGeneratorResponse with StreamTraversableResponse Signed-off-by: SebastianKrupinski --- .../Controller/CalendarExportController.php | 6 +- .../Controller/CalendarImportController.php | 8 +-- .../Controller/ContactsImportController.php | 8 +-- apps/dav/openapi.json | 15 +++-- lib/composer/composer/autoload_classmap.php | 1 - lib/composer/composer/autoload_static.php | 1 - .../Http/StreamGeneratorResponse.php | 63 ------------------- openapi.json | 15 +++-- ....php => StreamTraversableResponseTest.php} | 16 +++-- 9 files changed, 42 insertions(+), 91 deletions(-) delete mode 100644 lib/public/AppFramework/Http/StreamGeneratorResponse.php rename tests/lib/AppFramework/Http/{StreamGeneratorResponseTest.php => StreamTraversableResponseTest.php} (57%) diff --git a/apps/dav/lib/Controller/CalendarExportController.php b/apps/dav/lib/Controller/CalendarExportController.php index 0acacbe1f147b..cd66c139f89ed 100644 --- a/apps/dav/lib/Controller/CalendarExportController.php +++ b/apps/dav/lib/Controller/CalendarExportController.php @@ -15,7 +15,7 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; -use OCP\AppFramework\Http\StreamGeneratorResponse; +use OCP\AppFramework\Http\StreamTraversableResponse; use OCP\AppFramework\OCSController; use OCP\Calendar\CalendarExportOptions; use OCP\Calendar\ICalendarExport; @@ -46,7 +46,7 @@ public function __construct( * @param array{rangeStart:string,rangeCount:positive-int} $options configuration options * @param string|null $user system user id * - * @return StreamGeneratorResponse | DataResponse + * @return StreamTraversableResponse | DataResponse * * 200: data in requested format * 400: invalid parameters @@ -99,7 +99,7 @@ public function export(string $target, ?string $type = null, ?array $options = n 'xcal' => 'application/calendar+xml; charset=UTF-8', default => 'text/calendar; charset=UTF-8' }; - $response = new StreamGeneratorResponse($this->exportService->export($calendar, $options), $contentType, Http::STATUS_OK); + $response = new StreamTraversableResponse($this->exportService->export($calendar, $options), Http::STATUS_OK, ['Content-Type' => $contentType]); $response->cacheFor(0); return $response; diff --git a/apps/dav/lib/Controller/CalendarImportController.php b/apps/dav/lib/Controller/CalendarImportController.php index b840ebc77a0cb..83c40fa5651b4 100644 --- a/apps/dav/lib/Controller/CalendarImportController.php +++ b/apps/dav/lib/Controller/CalendarImportController.php @@ -20,7 +20,7 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; -use OCP\AppFramework\Http\StreamGeneratorResponse; +use OCP\AppFramework\Http\StreamTraversableResponse; use OCP\AppFramework\OCSController; use OCP\Calendar\CalendarImportOptions; use OCP\Calendar\IManager; @@ -53,7 +53,7 @@ public function __construct( * @param string $data calendar data * @param string|null $user system user id * - * @return StreamGeneratorResponse | DataResponse + * @return StreamTraversableResponse | DataResponse * * 200: NDJSON stream of import event objects * 400: invalid parameters @@ -62,7 +62,7 @@ public function __construct( #[ApiRoute(verb: 'POST', url: '/import', root: '/calendar')] #[UserRateLimit(limit: 10, period: 3600)] #[NoAdminRequired] - public function import(string $transaction, string $target, array $options, string $data, ?string $user = null): DataResponse|StreamGeneratorResponse { + public function import(string $transaction, string $target, array $options, string $data, ?string $user = null): DataResponse|StreamTraversableResponse { $calendarId = $target; $format = isset($options['format']) ? $options['format'] : null; $validation = isset($options['validation']) ? (int)$options['validation'] : null; @@ -144,6 +144,6 @@ public function import(string $transaction, string $target, array $options, stri } })(); - return new StreamGeneratorResponse($stream, 'application/x-ndjson'); + return new StreamTraversableResponse($stream, Http::STATUS_OK, ['Content-Type' => 'application/x-ndjson']); } } diff --git a/apps/dav/lib/Controller/ContactsImportController.php b/apps/dav/lib/Controller/ContactsImportController.php index 177639622a0c0..fe1264a5f3b1d 100644 --- a/apps/dav/lib/Controller/ContactsImportController.php +++ b/apps/dav/lib/Controller/ContactsImportController.php @@ -19,7 +19,7 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\UserRateLimit; use OCP\AppFramework\Http\DataResponse; -use OCP\AppFramework\Http\StreamGeneratorResponse; +use OCP\AppFramework\Http\StreamTraversableResponse; use OCP\AppFramework\OCSController; use OCP\Contacts\ContactsImportOptions; use OCP\Contacts\IManager; @@ -53,7 +53,7 @@ public function __construct( * @param string $data contacts data * @param string|null $user system user id * - * @return StreamGeneratorResponse | DataResponse + * @return StreamTraversableResponse | DataResponse * * 200: NDJSON stream of import event objects * 400: invalid parameters @@ -62,7 +62,7 @@ public function __construct( #[ApiRoute(verb: 'POST', url: '/import', root: '/contacts')] #[UserRateLimit(limit: 10, period: 3600)] #[NoAdminRequired] - public function import(string $transaction, string $target, array $options, string $data, ?string $user = null): DataResponse|StreamGeneratorResponse { + public function import(string $transaction, string $target, array $options, string $data, ?string $user = null): DataResponse|StreamTraversableResponse { $addressBookId = $target; $format = isset($options['format']) ? $options['format'] : null; $validation = isset($options['validation']) ? (int)$options['validation'] : null; @@ -141,6 +141,6 @@ public function import(string $transaction, string $target, array $options, stri } })(); - return new StreamGeneratorResponse($stream, 'application/x-ndjson'); + return new StreamTraversableResponse($stream, Http::STATUS_OK, ['Content-Type' => 'application/x-ndjson']); } } diff --git a/apps/dav/openapi.json b/apps/dav/openapi.json index dad136c0472e0..f8346e47d5fdf 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -1660,17 +1660,20 @@ "content": { "text/calendar; charset=UTF-8": { "schema": { - "anyOf": [] + "type": "string", + "format": "binary" } }, "application/calendar+json; charset=UTF-8": { "schema": { - "anyOf": [] + "type": "string", + "format": "binary" } }, "application/calendar+xml; charset=UTF-8": { "schema": { - "anyOf": [] + "type": "string", + "format": "binary" } } } @@ -1885,7 +1888,8 @@ "content": { "application/x-ndjson": { "schema": { - "anyOf": [] + "type": "string", + "format": "binary" } } } @@ -2088,7 +2092,8 @@ "content": { "application/x-ndjson": { "schema": { - "anyOf": [] + "type": "string", + "format": "binary" } } } diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index 461d67022130d..c3fd3f1b11052 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -127,7 +127,6 @@ 'OCP\\AppFramework\\Http\\RedirectToDefaultAppResponse' => $baseDir . '/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php', 'OCP\\AppFramework\\Http\\Response' => $baseDir . '/lib/public/AppFramework/Http/Response.php', 'OCP\\AppFramework\\Http\\StandaloneTemplateResponse' => $baseDir . '/lib/public/AppFramework/Http/StandaloneTemplateResponse.php', - 'OCP\\AppFramework\\Http\\StreamGeneratorResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamGeneratorResponse.php', 'OCP\\AppFramework\\Http\\StreamResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamResponse.php', 'OCP\\AppFramework\\Http\\StreamTraversableResponse' => $baseDir . '/lib/public/AppFramework/Http/StreamTraversableResponse.php', 'OCP\\AppFramework\\Http\\TemplateResponse' => $baseDir . '/lib/public/AppFramework/Http/TemplateResponse.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 5e5ffcc9a7544..e024f2f707230 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -168,7 +168,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\AppFramework\\Http\\RedirectToDefaultAppResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/RedirectToDefaultAppResponse.php', 'OCP\\AppFramework\\Http\\Response' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/Response.php', 'OCP\\AppFramework\\Http\\StandaloneTemplateResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StandaloneTemplateResponse.php', - 'OCP\\AppFramework\\Http\\StreamGeneratorResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamGeneratorResponse.php', 'OCP\\AppFramework\\Http\\StreamResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamResponse.php', 'OCP\\AppFramework\\Http\\StreamTraversableResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/StreamTraversableResponse.php', 'OCP\\AppFramework\\Http\\TemplateResponse' => __DIR__ . '/../../..' . '/lib/public/AppFramework/Http/TemplateResponse.php', diff --git a/lib/public/AppFramework/Http/StreamGeneratorResponse.php b/lib/public/AppFramework/Http/StreamGeneratorResponse.php deleted file mode 100644 index 16a7c582aa309..0000000000000 --- a/lib/public/AppFramework/Http/StreamGeneratorResponse.php +++ /dev/null @@ -1,63 +0,0 @@ - - * @template-extends Response> - */ -class StreamGeneratorResponse extends Response implements ICallbackResponse { - protected $generator; - - /** - * @since 35.0.0 - * - * @param Generator $generator the function to call to generate the response - * @param string $contentType http response content type e.g. 'application/json; charset=UTF-8' - * @param S $status http response status - * @param array|null $headers additional headers - */ - public function __construct(Generator $generator, string $contentType, int $status = Http::STATUS_OK, ?array $headers = []) { - parent::__construct(); - - $this->generator = $generator; - - $this->setStatus($status); - $this->addHeader('Content-Type', $contentType); - - foreach ($headers as $key => $value) { - $this->addHeader($key, $value); - } - } - - /** - * Streams content directly to client - * - * @since 35.0.0 - * - * @param IOutput $output a small wrapper that handles output - */ - #[\Override] - public function callback(IOutput $output): void { - - foreach ($this->generator as $chunk) { - print($chunk); - flush(); - } - - } - -} diff --git a/openapi.json b/openapi.json index 740ed4dd72f22..27a1c84461320 100644 --- a/openapi.json +++ b/openapi.json @@ -20529,17 +20529,20 @@ "content": { "text/calendar; charset=UTF-8": { "schema": { - "anyOf": [] + "type": "string", + "format": "binary" } }, "application/calendar+json; charset=UTF-8": { "schema": { - "anyOf": [] + "type": "string", + "format": "binary" } }, "application/calendar+xml; charset=UTF-8": { "schema": { - "anyOf": [] + "type": "string", + "format": "binary" } } } @@ -20754,7 +20757,8 @@ "content": { "application/x-ndjson": { "schema": { - "anyOf": [] + "type": "string", + "format": "binary" } } } @@ -20957,7 +20961,8 @@ "content": { "application/x-ndjson": { "schema": { - "anyOf": [] + "type": "string", + "format": "binary" } } } diff --git a/tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php b/tests/lib/AppFramework/Http/StreamTraversableResponseTest.php similarity index 57% rename from tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php rename to tests/lib/AppFramework/Http/StreamTraversableResponseTest.php index d327c257391db..81c6001183e2a 100644 --- a/tests/lib/AppFramework/Http/StreamGeneratorResponseTest.php +++ b/tests/lib/AppFramework/Http/StreamTraversableResponseTest.php @@ -8,9 +8,10 @@ namespace Test\AppFramework\Http; -use OCP\AppFramework\Http\StreamGeneratorResponse; +use OCP\AppFramework\Http\IOutput; +use OCP\AppFramework\Http\StreamTraversableResponse; -class StreamGeneratorResponseTest extends \Test\TestCase { +class StreamTraversableResponseTest extends \Test\TestCase { protected function setUp(): void { parent::setUp(); @@ -21,7 +22,7 @@ public function testConstructor() { yield 'chunk1'; yield 'chunk2'; }; - $response = new StreamGeneratorResponse($generator(), 'text/plain'); + $response = new StreamTraversableResponse($generator(), 200, ['Content-Type' => 'text/plain']); $headers = $response->getHeaders(); $this->assertEquals('text/plain', $headers['Content-Type']); @@ -36,8 +37,13 @@ public function testCallback() { $count++; yield 'chunk2'; }; - $response = new StreamGeneratorResponse($generator(), 'text/plain'); - $output = $this->createMock(\OCP\AppFramework\Http\IOutput::class); + $response = new StreamTraversableResponse($generator(), 200, ['Content-Type' => 'text/plain']); + $output = $this->createMock(IOutput::class); + $output->expects($this->exactly(2)) + ->method('setOutput') + ->with($this->callback(function ($chunk) { + return in_array($chunk, ['chunk1', 'chunk2'], true); + })); $response->callback($output); $this->assertEquals($count, 2);