diff options
Diffstat (limited to 'apps/dav/lib/UserMigration')
6 files changed, 939 insertions, 0 deletions
diff --git a/apps/dav/lib/UserMigration/CalendarMigrator.php b/apps/dav/lib/UserMigration/CalendarMigrator.php new file mode 100644 index 00000000000..73e9c375490 --- /dev/null +++ b/apps/dav/lib/UserMigration/CalendarMigrator.php @@ -0,0 +1,482 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\UserMigration; + +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CalDAV\CalDavBackend; +use OCA\DAV\CalDAV\ICSExportPlugin\ICSExportPlugin; +use OCA\DAV\CalDAV\Plugin as CalDAVPlugin; +use OCA\DAV\Connector\Sabre\CachingTree; +use OCA\DAV\Connector\Sabre\Server as SabreDavServer; +use OCA\DAV\RootCollection; +use OCP\Calendar\ICalendar; +use OCP\Calendar\IManager as ICalendarManager; +use OCP\Defaults; +use OCP\IL10N; +use OCP\IUser; +use OCP\UserMigration\IExportDestination; +use OCP\UserMigration\IImportSource; +use OCP\UserMigration\IMigrator; +use OCP\UserMigration\ISizeEstimationMigrator; +use OCP\UserMigration\TMigratorBasicVersionHandling; +use Sabre\VObject\Component as VObjectComponent; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VTimeZone; +use Sabre\VObject\Property\ICalendar\DateTime; +use Sabre\VObject\Reader as VObjectReader; +use Sabre\VObject\UUIDUtil; +use Symfony\Component\Console\Output\NullOutput; +use Symfony\Component\Console\Output\OutputInterface; +use Throwable; +use function substr; + +class CalendarMigrator implements IMigrator, ISizeEstimationMigrator { + + use TMigratorBasicVersionHandling; + + private SabreDavServer $sabreDavServer; + + private const USERS_URI_ROOT = 'principals/users/'; + + private const FILENAME_EXT = '.ics'; + + private const MIGRATED_URI_PREFIX = 'migrated-'; + + private const EXPORT_ROOT = Application::APP_ID . '/calendars/'; + + public function __construct( + private CalDavBackend $calDavBackend, + private ICalendarManager $calendarManager, + private ICSExportPlugin $icsExportPlugin, + private Defaults $defaults, + private IL10N $l10n, + ) { + $root = new RootCollection(); + $this->sabreDavServer = new SabreDavServer(new CachingTree($root)); + $this->sabreDavServer->addPlugin(new CalDAVPlugin()); + } + + private function getPrincipalUri(IUser $user): string { + return CalendarMigrator::USERS_URI_ROOT . $user->getUID(); + } + + /** + * @return array{name: string, vCalendar: VCalendar} + * + * @throws CalendarMigratorException + * @throws InvalidCalendarException + */ + private function getCalendarExportData(IUser $user, ICalendar $calendar, OutputInterface $output): array { + $userId = $user->getUID(); + $uri = $calendar->getUri(); + $path = CalDAVPlugin::CALENDAR_ROOT . "/$userId/$uri"; + + /** + * @see \Sabre\CalDAV\ICSExportPlugin::httpGet() implementation reference + */ + + $properties = $this->sabreDavServer->getProperties($path, [ + '{DAV:}resourcetype', + '{DAV:}displayname', + '{http://sabredav.org/ns}sync-token', + '{DAV:}sync-token', + '{http://apple.com/ns/ical/}calendar-color', + ]); + + // Filter out invalid (e.g. deleted) calendars + if (!isset($properties['{DAV:}resourcetype']) || !$properties['{DAV:}resourcetype']->is('{' . CalDAVPlugin::NS_CALDAV . '}calendar')) { + throw new InvalidCalendarException(); + } + + /** + * @see \Sabre\CalDAV\ICSExportPlugin::generateResponse() implementation reference + */ + + $calDataProp = '{' . CalDAVPlugin::NS_CALDAV . '}calendar-data'; + $calendarNode = $this->sabreDavServer->tree->getNodeForPath($path); + $nodes = $this->sabreDavServer->getPropertiesIteratorForPath($path, [$calDataProp], 1); + + $blobs = []; + foreach ($nodes as $node) { + if (isset($node[200][$calDataProp])) { + $blobs[$node['href']] = $node[200][$calDataProp]; + } + } + + $mergedCalendar = $this->icsExportPlugin->mergeObjects( + $properties, + $blobs, + ); + + $problems = $mergedCalendar->validate(); + if (!empty($problems)) { + $output->writeln('Skipping calendar "' . $properties['{DAV:}displayname'] . '" containing invalid calendar data'); + throw new InvalidCalendarException(); + } + + return [ + 'name' => $calendarNode->getName(), + 'vCalendar' => $mergedCalendar, + ]; + } + + /** + * @return array<int, array{name: string, vCalendar: VCalendar}> + * + * @throws CalendarMigratorException + */ + private function getCalendarExports(IUser $user, OutputInterface $output): array { + $principalUri = $this->getPrincipalUri($user); + + return array_values(array_filter(array_map( + function (ICalendar $calendar) use ($user, $output) { + try { + return $this->getCalendarExportData($user, $calendar, $output); + } catch (InvalidCalendarException $e) { + // Allow this exception as invalid (e.g. deleted) calendars are not to be exported + return null; + } + }, + $this->calendarManager->getCalendarsForPrincipal($principalUri), + ))); + } + + /** + * @throws InvalidCalendarException + */ + private function getUniqueCalendarUri(IUser $user, string $initialCalendarUri): string { + $principalUri = $this->getPrincipalUri($user); + + $initialCalendarUri = substr($initialCalendarUri, 0, strlen(CalendarMigrator::MIGRATED_URI_PREFIX)) === CalendarMigrator::MIGRATED_URI_PREFIX + ? $initialCalendarUri + : CalendarMigrator::MIGRATED_URI_PREFIX . $initialCalendarUri; + + if ($initialCalendarUri === '') { + throw new InvalidCalendarException(); + } + + $existingCalendarUris = array_map( + fn (ICalendar $calendar) => $calendar->getUri(), + $this->calendarManager->getCalendarsForPrincipal($principalUri), + ); + + $calendarUri = $initialCalendarUri; + $acc = 1; + while (in_array($calendarUri, $existingCalendarUris, true)) { + $calendarUri = $initialCalendarUri . "-$acc"; + ++$acc; + } + + return $calendarUri; + } + + /** + * {@inheritDoc} + */ + public function getEstimatedExportSize(IUser $user): int|float { + $calendarExports = $this->getCalendarExports($user, new NullOutput()); + $calendarCount = count($calendarExports); + + // 150B for top-level properties + $size = ($calendarCount * 150) / 1024; + + $componentCount = array_sum(array_map( + function (array $data): int { + /** @var VCalendar $vCalendar */ + $vCalendar = $data['vCalendar']; + return count($vCalendar->getComponents()); + }, + $calendarExports, + )); + + // 450B for each component (events, todos, alarms, etc.) + $size += ($componentCount * 450) / 1024; + + return ceil($size); + } + + /** + * {@inheritDoc} + */ + public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln('Exporting calendars into ' . CalendarMigrator::EXPORT_ROOT . '…'); + + $calendarExports = $this->getCalendarExports($user, $output); + + if (empty($calendarExports)) { + $output->writeln('No calendars to export…'); + } + + try { + /** + * @var string $name + * @var VCalendar $vCalendar + */ + foreach ($calendarExports as ['name' => $name, 'vCalendar' => $vCalendar]) { + // Set filename to sanitized calendar name + $filename = preg_replace('/[^a-z0-9-_]/iu', '', $name) . CalendarMigrator::FILENAME_EXT; + $exportPath = CalendarMigrator::EXPORT_ROOT . $filename; + + $exportDestination->addFileContents($exportPath, $vCalendar->serialize()); + } + } catch (Throwable $e) { + throw new CalendarMigratorException('Could not export calendars', 0, $e); + } + } + + /** + * @return array<string, VTimeZone> + */ + private function getCalendarTimezones(VCalendar $vCalendar): array { + /** @var VTimeZone[] $calendarTimezones */ + $calendarTimezones = array_filter( + $vCalendar->getComponents(), + fn ($component) => $component->name === 'VTIMEZONE', + ); + + /** @var array<string, VTimeZone> $calendarTimezoneMap */ + $calendarTimezoneMap = []; + foreach ($calendarTimezones as $vTimeZone) { + $calendarTimezoneMap[$vTimeZone->getTimeZone()->getName()] = $vTimeZone; + } + + return $calendarTimezoneMap; + } + + /** + * @return VTimeZone[] + */ + private function getTimezonesForComponent(VCalendar $vCalendar, VObjectComponent $component): array { + $componentTimezoneIds = []; + + foreach ($component->children() as $child) { + if ($child instanceof DateTime && isset($child->parameters['TZID'])) { + $timezoneId = $child->parameters['TZID']->getValue(); + if (!in_array($timezoneId, $componentTimezoneIds, true)) { + $componentTimezoneIds[] = $timezoneId; + } + } + } + + $calendarTimezoneMap = $this->getCalendarTimezones($vCalendar); + + return array_values(array_filter(array_map( + fn (string $timezoneId) => $calendarTimezoneMap[$timezoneId], + $componentTimezoneIds, + ))); + } + + private function sanitizeComponent(VObjectComponent $component): VObjectComponent { + // Operate on the component clone to prevent mutation of the original + $component = clone $component; + + // Remove RSVP parameters to prevent automatically sending invitation emails to attendees on import + foreach ($component->children() as $child) { + if ( + $child->name === 'ATTENDEE' + && isset($child->parameters['RSVP']) + ) { + unset($child->parameters['RSVP']); + } + } + + return $component; + } + + /** + * @return VObjectComponent[] + */ + private function getRequiredImportComponents(VCalendar $vCalendar, VObjectComponent $component): array { + $component = $this->sanitizeComponent($component); + /** @var array<int, VTimeZone> $timezoneComponents */ + $timezoneComponents = $this->getTimezonesForComponent($vCalendar, $component); + return [ + ...$timezoneComponents, + $component, + ]; + } + + private function initCalendarObject(): VCalendar { + $vCalendarObject = new VCalendar(); + $vCalendarObject->PRODID = '-//IDN nextcloud.com//Migrated calendar//EN'; + return $vCalendarObject; + } + + /** + * @throws InvalidCalendarException + */ + private function importCalendarObject(int $calendarId, VCalendar $vCalendarObject, string $filename, OutputInterface $output): void { + try { + $this->calDavBackend->createCalendarObject( + $calendarId, + UUIDUtil::getUUID() . CalendarMigrator::FILENAME_EXT, + $vCalendarObject->serialize(), + CalDavBackend::CALENDAR_TYPE_CALENDAR, + ); + } catch (Throwable $e) { + $output->writeln("Error creating calendar object, rolling back creation of \"$filename\" calendar…"); + $this->calDavBackend->deleteCalendar($calendarId, true); + throw new InvalidCalendarException(); + } + } + + /** + * @throws InvalidCalendarException + */ + private function importCalendar(IUser $user, string $filename, string $initialCalendarUri, VCalendar $vCalendar, OutputInterface $output): void { + $principalUri = $this->getPrincipalUri($user); + $calendarUri = $this->getUniqueCalendarUri($user, $initialCalendarUri); + + $calendarId = $this->calDavBackend->createCalendar($principalUri, $calendarUri, [ + '{DAV:}displayname' => isset($vCalendar->{'X-WR-CALNAME'}) ? $vCalendar->{'X-WR-CALNAME'}->getValue() : $this->l10n->t('Migrated calendar (%1$s)', [$filename]), + '{http://apple.com/ns/ical/}calendar-color' => isset($vCalendar->{'X-APPLE-CALENDAR-COLOR'}) ? $vCalendar->{'X-APPLE-CALENDAR-COLOR'}->getValue() : $this->defaults->getColorPrimary(), + 'components' => implode( + ',', + array_reduce( + $vCalendar->getComponents(), + function (array $componentNames, VObjectComponent $component) { + /** @var array<int, string> $componentNames */ + return !in_array($component->name, $componentNames, true) + ? [...$componentNames, $component->name] + : $componentNames; + }, + [], + ) + ), + ]); + + /** @var VObjectComponent[] $calendarComponents */ + $calendarComponents = array_values(array_filter( + $vCalendar->getComponents(), + // VTIMEZONE components are handled separately and added to the calendar object only if depended on by the component + fn (VObjectComponent $component) => $component->name !== 'VTIMEZONE', + )); + + /** @var array<string, VObjectComponent[]> $groupedCalendarComponents */ + $groupedCalendarComponents = []; + /** @var VObjectComponent[] $ungroupedCalendarComponents */ + $ungroupedCalendarComponents = []; + + foreach ($calendarComponents as $component) { + if (isset($component->UID)) { + $uid = $component->UID->getValue(); + // Components with the same UID (e.g. recurring events) are grouped together into a single calendar object + if (isset($groupedCalendarComponents[$uid])) { + $groupedCalendarComponents[$uid][] = $component; + } else { + $groupedCalendarComponents[$uid] = [$component]; + } + } else { + $ungroupedCalendarComponents[] = $component; + } + } + + foreach ($groupedCalendarComponents as $uid => $components) { + // Construct and import a calendar object containing all components of a group + $vCalendarObject = $this->initCalendarObject(); + foreach ($components as $component) { + foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) { + $vCalendarObject->add($component); + } + } + $this->importCalendarObject($calendarId, $vCalendarObject, $filename, $output); + } + + foreach ($ungroupedCalendarComponents as $component) { + // Construct and import a calendar object for a single component + $vCalendarObject = $this->initCalendarObject(); + foreach ($this->getRequiredImportComponents($vCalendar, $component) as $component) { + $vCalendarObject->add($component); + } + $this->importCalendarObject($calendarId, $vCalendarObject, $filename, $output); + } + } + + /** + * {@inheritDoc} + * + * @throws CalendarMigratorException + */ + public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void { + if ($importSource->getMigratorVersion($this->getId()) === null) { + $output->writeln('No version for ' . static::class . ', skipping import…'); + return; + } + + $output->writeln('Importing calendars from ' . CalendarMigrator::EXPORT_ROOT . '…'); + + $calendarImports = $importSource->getFolderListing(CalendarMigrator::EXPORT_ROOT); + if (empty($calendarImports)) { + $output->writeln('No calendars to import…'); + } + + foreach ($calendarImports as $filename) { + $importPath = CalendarMigrator::EXPORT_ROOT . $filename; + try { + /** @var VCalendar $vCalendar */ + $vCalendar = VObjectReader::read( + $importSource->getFileAsStream($importPath), + VObjectReader::OPTION_FORGIVING, + ); + } catch (Throwable $e) { + $output->writeln("Failed to read file \"$importPath\", skipping…"); + continue; + } + + $problems = $vCalendar->validate(); + if (!empty($problems)) { + $output->writeln("Invalid calendar data contained in \"$importPath\", skipping…"); + continue; + } + + $splitFilename = explode('.', $filename, 2); + if (count($splitFilename) !== 2) { + $output->writeln("Invalid filename \"$filename\", expected filename of the format \"<calendar_name>" . CalendarMigrator::FILENAME_EXT . '", skipping…'); + continue; + } + [$initialCalendarUri, $ext] = $splitFilename; + + try { + $this->importCalendar( + $user, + $filename, + $initialCalendarUri, + $vCalendar, + $output, + ); + } catch (InvalidCalendarException $e) { + // Allow this exception to skip a failed import + } finally { + $vCalendar->destroy(); + } + } + } + + /** + * {@inheritDoc} + */ + public function getId(): string { + return 'calendar'; + } + + /** + * {@inheritDoc} + */ + public function getDisplayName(): string { + return $this->l10n->t('Calendar'); + } + + /** + * {@inheritDoc} + */ + public function getDescription(): string { + return $this->l10n->t('Calendars including events, details and attendees'); + } +} diff --git a/apps/dav/lib/UserMigration/CalendarMigratorException.php b/apps/dav/lib/UserMigration/CalendarMigratorException.php new file mode 100644 index 00000000000..f3754809b44 --- /dev/null +++ b/apps/dav/lib/UserMigration/CalendarMigratorException.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\UserMigration; + +use OCP\UserMigration\UserMigrationException; + +class CalendarMigratorException extends UserMigrationException { +} diff --git a/apps/dav/lib/UserMigration/ContactsMigrator.php b/apps/dav/lib/UserMigration/ContactsMigrator.php new file mode 100644 index 00000000000..96d623938a3 --- /dev/null +++ b/apps/dav/lib/UserMigration/ContactsMigrator.php @@ -0,0 +1,397 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\UserMigration; + +use OCA\DAV\AppInfo\Application; +use OCA\DAV\CardDAV\CardDavBackend; +use OCA\DAV\CardDAV\Plugin as CardDAVPlugin; +use OCA\DAV\Connector\Sabre\CachingTree; +use OCA\DAV\Connector\Sabre\Server as SabreDavServer; +use OCA\DAV\RootCollection; +use OCP\IL10N; +use OCP\IUser; +use OCP\UserMigration\IExportDestination; +use OCP\UserMigration\IImportSource; +use OCP\UserMigration\IMigrator; +use OCP\UserMigration\ISizeEstimationMigrator; +use OCP\UserMigration\TMigratorBasicVersionHandling; +use Sabre\VObject\Component\VCard; +use Sabre\VObject\Parser\Parser as VObjectParser; +use Sabre\VObject\Reader as VObjectReader; +use Sabre\VObject\Splitter\VCard as VCardSplitter; +use Sabre\VObject\UUIDUtil; +use Symfony\Component\Console\Output\NullOutput; +use Symfony\Component\Console\Output\OutputInterface; +use Throwable; +use function sort; +use function substr; + +class ContactsMigrator implements IMigrator, ISizeEstimationMigrator { + + use TMigratorBasicVersionHandling; + + private SabreDavServer $sabreDavServer; + + private const USERS_URI_ROOT = 'principals/users/'; + + private const FILENAME_EXT = 'vcf'; + + private const METADATA_EXT = 'json'; + + private const MIGRATED_URI_PREFIX = 'migrated-'; + + private const PATH_ROOT = Application::APP_ID . '/address_books/'; + + public function __construct( + private CardDavBackend $cardDavBackend, + private IL10N $l10n, + ) { + $root = new RootCollection(); + $this->sabreDavServer = new SabreDavServer(new CachingTree($root)); + $this->sabreDavServer->addPlugin(new CardDAVPlugin()); + } + + private function getPrincipalUri(IUser $user): string { + return ContactsMigrator::USERS_URI_ROOT . $user->getUID(); + } + + /** + * @return array{name: string, displayName: string, description: ?string, vCards: VCard[]} + * + * @throws InvalidAddressBookException + */ + private function getAddressBookExportData(IUser $user, array $addressBookInfo, OutputInterface $output): array { + $userId = $user->getUID(); + + if (!isset($addressBookInfo['uri'])) { + throw new InvalidAddressBookException(); + } + + $uri = $addressBookInfo['uri']; + + $path = CardDAVPlugin::ADDRESSBOOK_ROOT . "/users/$userId/$uri"; + + /** + * @see \Sabre\CardDAV\VCFExportPlugin::httpGet() implementation reference + */ + + $addressBookDataProp = '{' . CardDAVPlugin::NS_CARDDAV . '}address-data'; + $addressBookNode = $this->sabreDavServer->tree->getNodeForPath($path); + $nodes = $this->sabreDavServer->getPropertiesIteratorForPath($path, [$addressBookDataProp], 1); + + /** + * @see \Sabre\CardDAV\VCFExportPlugin::generateVCF() implementation reference + */ + + /** @var VCard[] $vCards */ + $vCards = []; + foreach ($nodes as $node) { + if (isset($node[200][$addressBookDataProp])) { + $vCard = VObjectReader::read($node[200][$addressBookDataProp]); + + $problems = $vCard->validate(); + if (!empty($problems)) { + $output->writeln('Skipping contact "' . ($vCard->FN ?? 'null') . '" containing invalid contact data'); + continue; + } + $vCards[] = $vCard; + } + } + + if (count($vCards) === 0) { + throw new InvalidAddressBookException(); + } + + return [ + 'name' => $addressBookNode->getName(), + 'displayName' => $addressBookInfo['{DAV:}displayname'], + 'description' => $addressBookInfo['{' . CardDAVPlugin::NS_CARDDAV . '}addressbook-description'], + 'vCards' => $vCards, + ]; + } + + /** + * @return array<int, array{name: string, displayName: string, description: ?string, vCards: VCard[]}> + */ + private function getAddressBookExports(IUser $user, OutputInterface $output): array { + $principalUri = $this->getPrincipalUri($user); + + return array_values(array_filter(array_map( + function (array $addressBookInfo) use ($user, $output) { + try { + return $this->getAddressBookExportData($user, $addressBookInfo, $output); + } catch (InvalidAddressBookException $e) { + // Allow this exception as invalid address books are not to be exported + return null; + } + }, + $this->cardDavBackend->getAddressBooksForUser($principalUri), + ))); + } + + /** + * @throws InvalidAddressBookException + */ + private function getUniqueAddressBookUri(IUser $user, string $initialAddressBookUri): string { + $principalUri = $this->getPrincipalUri($user); + + $initialAddressBookUri = substr($initialAddressBookUri, 0, strlen(ContactsMigrator::MIGRATED_URI_PREFIX)) === ContactsMigrator::MIGRATED_URI_PREFIX + ? $initialAddressBookUri + : ContactsMigrator::MIGRATED_URI_PREFIX . $initialAddressBookUri; + + if ($initialAddressBookUri === '') { + throw new InvalidAddressBookException(); + } + + $existingAddressBookUris = array_map( + fn (array $addressBookInfo): string => $addressBookInfo['uri'], + $this->cardDavBackend->getAddressBooksForUser($principalUri), + ); + + $addressBookUri = $initialAddressBookUri; + $acc = 1; + while (in_array($addressBookUri, $existingAddressBookUris, true)) { + $addressBookUri = $initialAddressBookUri . "-$acc"; + ++$acc; + } + + return $addressBookUri; + } + + /** + * @param VCard[] $vCards + */ + private function serializeCards(array $vCards): string { + return array_reduce( + $vCards, + fn (string $addressBookBlob, VCard $vCard) => $addressBookBlob . $vCard->serialize(), + '', + ); + } + + /** + * {@inheritDoc} + */ + public function getEstimatedExportSize(IUser $user): int|float { + $addressBookExports = $this->getAddressBookExports($user, new NullOutput()); + $addressBookCount = count($addressBookExports); + + // 50B for each metadata JSON + $size = ($addressBookCount * 50) / 1024; + + $contactsCount = array_sum(array_map( + fn (array $data): int => count($data['vCards']), + $addressBookExports, + )); + + // 350B for each contact + $size += ($contactsCount * 350) / 1024; + + return ceil($size); + } + + /** + * {@inheritDoc} + */ + public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln('Exporting contacts into ' . ContactsMigrator::PATH_ROOT . '…'); + + $addressBookExports = $this->getAddressBookExports($user, $output); + + if (empty($addressBookExports)) { + $output->writeln('No contacts to export…'); + } + + try { + /** + * @var string $name + * @var string $displayName + * @var ?string $description + * @var VCard[] $vCards + */ + foreach ($addressBookExports as ['name' => $name, 'displayName' => $displayName, 'description' => $description, 'vCards' => $vCards]) { + // Set filename to sanitized address book name + $basename = preg_replace('/[^a-z0-9-_]/iu', '', $name); + $exportPath = ContactsMigrator::PATH_ROOT . $basename . '.' . ContactsMigrator::FILENAME_EXT; + $metadataExportPath = ContactsMigrator::PATH_ROOT . $basename . '.' . ContactsMigrator::METADATA_EXT; + + $exportDestination->addFileContents($exportPath, $this->serializeCards($vCards)); + + $metadata = array_filter(['displayName' => $displayName, 'description' => $description]); + $exportDestination->addFileContents($metadataExportPath, json_encode($metadata, JSON_THROW_ON_ERROR)); + } + } catch (Throwable $e) { + throw new CalendarMigratorException('Could not export address book', 0, $e); + } + } + + private function importContact(int $addressBookId, VCard $vCard, string $filename, OutputInterface $output): void { + // Operate on clone to prevent mutation of the original + $vCard = clone $vCard; + $vCard->PRODID = '-//IDN nextcloud.com//Migrated contact//EN'; + + try { + $this->cardDavBackend->createCard( + $addressBookId, + UUIDUtil::getUUID() . '.' . ContactsMigrator::FILENAME_EXT, + $vCard->serialize(), + ); + } catch (Throwable $e) { + $output->writeln('Error creating contact "' . ($vCard->FN ?? 'null') . "\" from \"$filename\", skipping…"); + } + } + + /** + * @param array{displayName: string, description?: string} $metadata + * @param VCard[] $vCards + * + * @throws InvalidAddressBookException + */ + private function importAddressBook(IUser $user, string $filename, string $initialAddressBookUri, array $metadata, array $vCards, OutputInterface $output): void { + $principalUri = $this->getPrincipalUri($user); + $addressBookUri = $this->getUniqueAddressBookUri($user, $initialAddressBookUri); + + $addressBookId = $this->cardDavBackend->createAddressBook($principalUri, $addressBookUri, array_filter([ + '{DAV:}displayname' => $metadata['displayName'], + '{' . CardDAVPlugin::NS_CARDDAV . '}addressbook-description' => $metadata['description'] ?? null, + ])); + + foreach ($vCards as $vCard) { + $this->importContact($addressBookId, $vCard, $filename, $output); + } + } + + /** + * @return array<int, array{addressBook: string, metadata: string}> + */ + private function getAddressBookImports(array $importFiles): array { + $addressBookImports = array_filter( + $importFiles, + fn (string $filename) => pathinfo($filename, PATHINFO_EXTENSION) === ContactsMigrator::FILENAME_EXT, + ); + + $metadataImports = array_filter( + $importFiles, + fn (string $filename) => pathinfo($filename, PATHINFO_EXTENSION) === ContactsMigrator::METADATA_EXT, + ); + + $addressBookSort = sort($addressBookImports); + $metadataSort = sort($metadataImports); + if ($addressBookSort === false || $metadataSort === false) { + throw new ContactsMigratorException('Failed to sort address book files in ' . ContactsMigrator::PATH_ROOT); + } + + if (count($addressBookImports) !== count($metadataImports)) { + throw new ContactsMigratorException('Each ' . ContactsMigrator::FILENAME_EXT . ' file must have a corresponding ' . ContactsMigrator::METADATA_EXT . ' file'); + } + + for ($i = 0; $i < count($addressBookImports); ++$i) { + if (pathinfo($addressBookImports[$i], PATHINFO_FILENAME) !== pathinfo($metadataImports[$i], PATHINFO_FILENAME)) { + throw new ContactsMigratorException('Each ' . ContactsMigrator::FILENAME_EXT . ' file must have a corresponding ' . ContactsMigrator::METADATA_EXT . ' file'); + } + } + + return array_map( + fn (string $addressBookFilename, string $metadataFilename) => ['addressBook' => $addressBookFilename, 'metadata' => $metadataFilename], + $addressBookImports, + $metadataImports, + ); + } + + /** + * {@inheritDoc} + * + * @throws ContactsMigratorException + */ + public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void { + if ($importSource->getMigratorVersion($this->getId()) === null) { + $output->writeln('No version for ' . static::class . ', skipping import…'); + return; + } + + $output->writeln('Importing contacts from ' . ContactsMigrator::PATH_ROOT . '…'); + + $importFiles = $importSource->getFolderListing(ContactsMigrator::PATH_ROOT); + + if (empty($importFiles)) { + $output->writeln('No contacts to import…'); + } + + foreach ($this->getAddressBookImports($importFiles) as ['addressBook' => $addressBookFilename, 'metadata' => $metadataFilename]) { + $addressBookImportPath = ContactsMigrator::PATH_ROOT . $addressBookFilename; + $metadataImportPath = ContactsMigrator::PATH_ROOT . $metadataFilename; + + $vCardSplitter = new VCardSplitter( + $importSource->getFileAsStream($addressBookImportPath), + VObjectParser::OPTION_FORGIVING, + ); + + /** @var VCard[] $vCards */ + $vCards = []; + /** @var ?VCard $vCard */ + while ($vCard = $vCardSplitter->getNext()) { + $problems = $vCard->validate(); + if (!empty($problems)) { + $output->writeln('Skipping contact "' . ($vCard->FN ?? 'null') . '" containing invalid contact data'); + continue; + } + $vCards[] = $vCard; + } + + $splitFilename = explode('.', $addressBookFilename, 2); + if (count($splitFilename) !== 2) { + $output->writeln("Invalid filename \"$addressBookFilename\", expected filename of the format \"<address_book_name>." . ContactsMigrator::FILENAME_EXT . '", skipping…'); + continue; + } + [$initialAddressBookUri, $ext] = $splitFilename; + + /** @var array{displayName: string, description?: string} $metadata */ + $metadata = json_decode($importSource->getFileContents($metadataImportPath), true, 512, JSON_THROW_ON_ERROR); + + try { + $this->importAddressBook( + $user, + $addressBookFilename, + $initialAddressBookUri, + $metadata, + $vCards, + $output, + ); + } catch (InvalidAddressBookException $e) { + // Allow this exception to skip a failed import + } finally { + foreach ($vCards as $vCard) { + $vCard->destroy(); + } + } + } + } + + /** + * {@inheritDoc} + */ + public function getId(): string { + return 'contacts'; + } + + /** + * {@inheritDoc} + */ + public function getDisplayName(): string { + return $this->l10n->t('Contacts'); + } + + /** + * {@inheritDoc} + */ + public function getDescription(): string { + return $this->l10n->t('Contacts and groups'); + } +} diff --git a/apps/dav/lib/UserMigration/ContactsMigratorException.php b/apps/dav/lib/UserMigration/ContactsMigratorException.php new file mode 100644 index 00000000000..8d64d3d4428 --- /dev/null +++ b/apps/dav/lib/UserMigration/ContactsMigratorException.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\UserMigration; + +use OCP\UserMigration\UserMigrationException; + +class ContactsMigratorException extends UserMigrationException { +} diff --git a/apps/dav/lib/UserMigration/InvalidAddressBookException.php b/apps/dav/lib/UserMigration/InvalidAddressBookException.php new file mode 100644 index 00000000000..d904dd9d4dd --- /dev/null +++ b/apps/dav/lib/UserMigration/InvalidAddressBookException.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\UserMigration; + +use Exception; + +class InvalidAddressBookException extends Exception { +} diff --git a/apps/dav/lib/UserMigration/InvalidCalendarException.php b/apps/dav/lib/UserMigration/InvalidCalendarException.php new file mode 100644 index 00000000000..664989de8b1 --- /dev/null +++ b/apps/dav/lib/UserMigration/InvalidCalendarException.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\DAV\UserMigration; + +use Exception; + +class InvalidCalendarException extends Exception { +} |