From 11ba4b58725587ee2cd366000a6692bed031aa76 Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Wed, 16 Aug 2023 16:08:14 +0200 Subject: [PATCH 01/36] Create v2 routes Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- appinfo/routes.php | 23 +- lib/AppInfo/Application.php | 3 + lib/Controller/V1/LockingController.php | 191 ++++ lib/Controller/V1/MetaDataController.php | 231 +++++ lib/IMetaDataStorageV1.php | 87 ++ lib/LockManagerV1.php | 155 ++++ lib/MetaDataStorageV1.php | 311 +++++++ lib/RollbackServiceV1.php | 132 +++ .../Controller/MetaDataControllerV1Test.php | 353 ++++++++ tests/Unit/LockManagerV1Test.php | 403 +++++++++ tests/Unit/MetaDataStorageV1Test.php | 813 ++++++++++++++++++ tests/Unit/RollbackServiceV1Test.php | 240 ++++++ 12 files changed, 2935 insertions(+), 7 deletions(-) create mode 100644 lib/Controller/V1/LockingController.php create mode 100644 lib/Controller/V1/MetaDataController.php create mode 100644 lib/IMetaDataStorageV1.php create mode 100644 lib/LockManagerV1.php create mode 100644 lib/MetaDataStorageV1.php create mode 100644 lib/RollbackServiceV1.php create mode 100644 tests/Unit/Controller/MetaDataControllerV1Test.php create mode 100644 tests/Unit/LockManagerV1Test.php create mode 100644 tests/Unit/MetaDataStorageV1Test.php create mode 100644 tests/Unit/RollbackServiceV1Test.php diff --git a/appinfo/routes.php b/appinfo/routes.php index cda92c28..c6d658d4 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -26,6 +26,7 @@ return [ 'ocs' => [ + # v1 ['name' => 'Key#setPrivateKey', 'url' => '/api/v1/private-key', 'verb' => 'POST'], ['name' => 'Key#getPrivateKey', 'url' => '/api/v1/private-key', 'verb' => 'GET'], ['name' => 'Key#deletePrivateKey', 'url' => '/api/v1/private-key', 'verb' => 'DELETE'], @@ -33,15 +34,23 @@ ['name' => 'Key#getPublicKeys', 'url' => '/api/v1/public-key', 'verb' => 'GET'], ['name' => 'Key#deletePublicKey', 'url' => '/api/v1/public-key', 'verb' => 'DELETE'], ['name' => 'Key#getPublicServerKey', 'url' => '/api/v1/server-key', 'verb' => 'GET'], - ['name' => 'MetaData#setMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'POST'], - ['name' => 'MetaData#getMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'GET'], - ['name' => 'MetaData#updateMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'PUT'], - ['name' => 'MetaData#deleteMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'DELETE'], - ['name' => 'MetaData#addMetadataFileDrop', 'url' => '/api/v1/meta-data/{id}/filedrop', 'verb' => 'PUT'], + ['name' => 'V1\MetaData#setMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'POST'], + ['name' => 'V1\MetaData#getMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'GET'], + ['name' => 'V1\MetaData#updateMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'PUT'], + ['name' => 'V1\MetaData#deleteMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'DELETE'], + ['name' => 'V1\MetaData#addMetadataFileDrop', 'url' => '/api/v1/meta-data/{id}/filedrop', 'verb' => 'PUT'], ['name' => 'Encryption#removeEncryptedFolders', 'url' => '/api/v1/encrypted-files', 'verb' => 'DELETE'], ['name' => 'Encryption#setEncryptionFlag', 'url' => '/api/v1/encrypted/{id}', 'verb' => 'PUT'], ['name' => 'Encryption#removeEncryptionFlag', 'url' => '/api/v1/encrypted/{id}', 'verb' => 'DELETE'], - ['name' => 'Locking#lockFolder', 'url' => '/api/v1/lock/{id}', 'verb' => 'POST'], - ['name' => 'Locking#unlockFolder', 'url' => '/api/v1/lock/{id}', 'verb' => 'DELETE'], + ['name' => 'V1\Locking#lockFolder', 'url' => '/api/v1/lock/{id}', 'verb' => 'POST'], + ['name' => 'V1\Locking#unlockFolder', 'url' => '/api/v1/lock/{id}', 'verb' => 'DELETE'], + # v2 + ['name' => 'MetaData#setMetaData', 'url' => '/api/v2/meta-data/{id}', 'verb' => 'POST'], + ['name' => 'MetaData#getMetaData', 'url' => '/api/v2/meta-data/{id}', 'verb' => 'GET'], + ['name' => 'MetaData#updateMetaData', 'url' => '/api/v2/meta-data/{id}', 'verb' => 'PUT'], + ['name' => 'MetaData#deleteMetaData', 'url' => '/api/v2/meta-data/{id}', 'verb' => 'DELETE'], + ['name' => 'MetaData#addMetadataFileDrop', 'url' => '/api/v2/meta-data/{id}/filedrop', 'verb' => 'PUT'], + ['name' => 'Locking#lockFolder', 'url' => '/api/v2/lock/{id}', 'verb' => 'POST'], + ['name' => 'Locking#unlockFolder', 'url' => '/api/v2/lock/{id}', 'verb' => 'DELETE'], ], ]; diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 2aa053b4..41fa5ef6 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -31,9 +31,11 @@ use OCA\EndToEndEncryption\EncryptionManager; use OCA\EndToEndEncryption\IKeyStorage; use OCA\EndToEndEncryption\IMetaDataStorage; +use OCA\EndToEndEncryption\IMetaDataStorageV1; use OCA\EndToEndEncryption\KeyStorage; use OCA\EndToEndEncryption\Listener\UserDeletedListener; use OCA\EndToEndEncryption\MetaDataStorage; +use OCA\EndToEndEncryption\MetaDataStorageV1; use OCA\EndToEndEncryption\Middleware\CanUseAppMiddleware; use OCA\EndToEndEncryption\Middleware\UserAgentCheckMiddleware; use OCA\Files_Trashbin\Events\MoveToTrashEvent; @@ -67,6 +69,7 @@ public function register(IRegistrationContext $context): void { $context->registerMiddleware(UserAgentCheckMiddleware::class); $context->registerMiddleware(CanUseAppMiddleware::class); $context->registerServiceAlias(IKeyStorage::class, KeyStorage::class); + $context->registerServiceAlias(IMetaDataStorageV1::class, MetaDataStorageV1::class); $context->registerServiceAlias(IMetaDataStorage::class, MetaDataStorage::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerPublicShareTemplateProvider(E2EEPublicShareTemplateProvider::class); diff --git a/lib/Controller/V1/LockingController.php b/lib/Controller/V1/LockingController.php new file mode 100644 index 00000000..0d9518a9 --- /dev/null +++ b/lib/Controller/V1/LockingController.php @@ -0,0 +1,191 @@ + + * @copyright Copyright (c) 2020 Georg Ehrke + * + * @author Bjoern Schiessle + * @author Georg Ehrke + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\EndToEndEncryption\Controller\V1; + +use OC\User\NoUserException; +use OCA\EndToEndEncryption\Exceptions\FileLockedException; +use OCA\EndToEndEncryption\Exceptions\FileNotLockedException; +use OCA\EndToEndEncryption\Exceptions\MissingMetaDataException; +use OCA\EndToEndEncryption\FileService; +use OCA\EndToEndEncryption\IMetaDataStorageV1; +use OCA\EndToEndEncryption\LockManagerV1; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\AppFramework\OCSController; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\IL10N; +use OCP\IRequest; +use Psr\Log\LoggerInterface; +use OCP\Share\IManager as ShareManager; +use OCP\AppFramework\OCS\OCSBadRequestException; + +class LockingController extends OCSController { + private ?string $userId; + private IMetaDataStorageV1 $metaDataStorage; + private IRootFolder $rootFolder; + private FileService $fileService; + private LockManagerV1 $lockManager; + private IL10N $l10n; + private LoggerInterface $logger; + private ShareManager $shareManager; + + public function __construct( + string $AppName, + IRequest $request, + ?string $userId, + IMetaDataStorageV1 $metaDataStorage, + LockManagerV1 $lockManager, + IRootFolder $rootFolder, + FileService $fileService, + LoggerInterface $logger, + IL10N $l10n, + ShareManager $shareManager + ) { + parent::__construct($AppName, $request); + $this->userId = $userId; + $this->metaDataStorage = $metaDataStorage; + $this->rootFolder = $rootFolder; + $this->fileService = $fileService; + $this->lockManager = $lockManager; + $this->logger = $logger; + $this->l10n = $l10n; + $this->shareManager = $shareManager; + } + + /** + * Lock folder + * + * @NoAdminRequired + * @E2ERestrictUserAgent + * @PublicPage + * + * @param int $id file ID + * + * @return DataResponse + * @throws OCSForbiddenException + */ + public function lockFolder(int $id, ?string $shareToken = null): DataResponse { + $e2eToken = $this->request->getParam('e2e-token', ''); + + $ownerId = $this->getOwnerId($shareToken); + + try { + $userFolder = $this->rootFolder->getUserFolder($ownerId); + } catch (NoUserException $e) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to create the lock')); + } + + if ($userFolder->getId() === $id) { + $e = new OCSForbiddenException($this->l10n->t('You are not allowed to lock the root')); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw $e; + } + + $nodes = $userFolder->getById($id); + if (!isset($nodes[0]) || !$nodes[0] instanceof Folder) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to create the lock')); + } + + $newToken = $this->lockManager->lockFile($id, $e2eToken, $ownerId); + if ($newToken === null) { + throw new OCSForbiddenException($this->l10n->t('File already locked')); + } + return new DataResponse(['e2e-token' => $newToken]); + } + + + /** + * Unlock folder + * + * @NoAdminRequired + * @E2ERestrictUserAgent + * @PublicPage + * + * @param int $id file ID + * + * @return DataResponse + * @throws OCSForbiddenException + * @throws OCSNotFoundException + */ + public function unlockFolder(int $id, ?string $shareToken = null): DataResponse { + $token = $this->request->getHeader('e2e-token'); + + $ownerId = $this->getOwnerId($shareToken); + + try { + $userFolder = $this->rootFolder->getUserFolder($ownerId); + } catch (NoUserException $e) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to remove the lock')); + } + + $nodes = $userFolder->getById($id); + if (!isset($nodes[0]) || !$nodes[0] instanceof Folder) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to remove the lock')); + } + + $hadChanges = $this->fileService->finalizeChanges($nodes[0]); + + try { + $this->metaDataStorage->saveIntermediateFile($ownerId, $id); + } catch (MissingMetaDataException $ex) { + if ($hadChanges) { + throw $ex; + } + } + + try { + $this->lockManager->unlockFile($id, $token); + } catch (FileLockedException $e) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to remove the lock')); + } catch (FileNotLockedException $e) { + throw new OCSNotFoundException($this->l10n->t('File not locked')); + } + + return new DataResponse(); + } + + private function getOwnerId(?string $shareToken = null): string { + if ($shareToken !== null) { + $share = $this->shareManager->getShareByToken($shareToken); + + if (!($share->getPermissions() & \OCP\Constants::PERMISSION_CREATE)) { + throw new OCSForbiddenException("Can't lock share without create permission"); + } + + return $share->getShareOwner(); + } elseif ($this->userId !== null) { + return $this->userId; + } else { + throw new OCSBadRequestException("Couldn't find the owner of the encrypted folder"); + } + } +} diff --git a/lib/Controller/V1/MetaDataController.php b/lib/Controller/V1/MetaDataController.php new file mode 100644 index 00000000..d262a2b7 --- /dev/null +++ b/lib/Controller/V1/MetaDataController.php @@ -0,0 +1,231 @@ + + * @copyright Copyright (c) 2020 Georg Ehrke + * + * @author Bjoern Schiessle + * @author Georg Ehrke + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\EndToEndEncryption\Controller\V1; + +use OCA\EndToEndEncryption\Exceptions\MetaDataExistsException; +use OCA\EndToEndEncryption\Exceptions\MissingMetaDataException; +use OCA\EndToEndEncryption\IMetaDataStorageV1; +use OCA\EndToEndEncryption\LockManagerV1; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\AppFramework\OCSController; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IL10N; +use OCP\IRequest; +use Psr\Log\LoggerInterface; +use OCP\Share\IManager as ShareManager; + +class MetaDataController extends OCSController { + private ?string $userId; + private IMetaDataStorageV1 $metaDataStorage; + private LoggerInterface $logger; + private LockManagerV1 $lockManager; + private IL10N $l10n; + private ShareManager $shareManager; + + public function __construct( + string $AppName, + IRequest $request, + ?string $userId, + IMetaDataStorageV1 $metaDataStorage, + LockManagerV1 $lockManager, + LoggerInterface $logger, + IL10N $l10n, + ShareManager $shareManager + ) { + parent::__construct($AppName, $request); + $this->userId = $userId; + $this->metaDataStorage = $metaDataStorage; + $this->logger = $logger; + $this->lockManager = $lockManager; + $this->l10n = $l10n; + $this->shareManager = $shareManager; + } + + /** + * Get metadata + * + * @NoAdminRequired + * @E2ERestrictUserAgent + * + * @throws OCSNotFoundException + * @throws OCSBadRequestException + */ + public function getMetaData(int $id, ?string $shareToken = null): DataResponse { + try { + $ownerId = $this->getOwnerId($shareToken); + $metaData = $this->metaDataStorage->getMetaData($ownerId, $id); + } catch (NotFoundException $e) { + throw new OCSNotFoundException($this->l10n->t('Could not find metadata for "%s"', [$id])); + } catch (\Exception $e) { + $this->logger->critical($e->getMessage(), ['exception' => $e, 'app' => $this->appName]); + throw new OCSBadRequestException($this->l10n->t('Cannot read metadata')); + } + return new DataResponse(['meta-data' => $metaData]); + } + + /** + * Set metadata + * + * @NoAdminRequired + * + * @throws OCSNotFoundException + * @throws OCSBadRequestException + */ + public function setMetaData(int $id, string $metaData): DataResponse { + try { + $this->metaDataStorage->setMetaDataIntoIntermediateFile($this->userId, $id, $metaData); + } catch (MetaDataExistsException $e) { + return new DataResponse([], Http::STATUS_CONFLICT); + } catch (NotFoundException $e) { + throw new OCSNotFoundException($e->getMessage()); + } catch (\Exception $e) { + $this->logger->critical($e->getMessage(), ['exception' => $e, 'app' => $this->appName]); + throw new OCSBadRequestException($this->l10n->t('Cannot store metadata')); + } + + return new DataResponse(['meta-data' => $metaData]); + } + + /** + * Update metadata + * + * @NoAdminRequired + * @return DataResponse + * @throws OCSForbiddenException + * @throws OCSBadRequestException + * @throws OCSNotFoundException + */ + public function updateMetaData(int $id, string $metaData): DataResponse { + $e2eToken = $this->request->getParam('e2e-token'); + + if ($this->lockManager->isLocked($id, $e2eToken)) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); + } + + try { + $this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, $metaData); + } catch (MissingMetaDataException $e) { + throw new OCSNotFoundException($this->l10n->t('Metadata-file does not exist')); + } catch (NotFoundException $e) { + throw new OCSNotFoundException($e->getMessage()); + } catch (\Exception $e) { + $this->logger->critical($e->getMessage(), ['exception' => $e, 'app' => $this->appName]); + throw new OCSBadRequestException($this->l10n->t('Cannot store metadata')); + } + + return new DataResponse(['meta-data' => $metaData]); + } + + /** + * Delete metadata + * + * @NoAdminRequired + * + * @param int $id file id + * @return DataResponse + * + * @throws OCSForbiddenException + * @throws OCSNotFoundException + * @throws OCSBadRequestException + */ + public function deleteMetaData(int $id): DataResponse { + try { + $this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, '{}'); + } catch (NotFoundException $e) { + throw new OCSNotFoundException($this->l10n->t('Could not find metadata for "%s"', [$id])); + } catch (NotPermittedException $e) { + throw new OCSForbiddenException($this->l10n->t('Only the owner can delete the metadata-file')); + } catch (\Exception $e) { + $this->logger->critical($e->getMessage(), ['exception' => $e, 'app' => $this->appName]); + throw new OCSBadRequestException($this->l10n->t('Cannot delete metadata')); + } + return new DataResponse(); + } + + + /** + * Append new entries in the filedrop property of a metadata + * + * @PublicPage + * @NoAdminRequired + * @return DataResponse + * @throws OCSForbiddenException + * @throws OCSBadRequestException + * @throws OCSNotFoundException + */ + public function addMetadataFileDrop(int $id, string $fileDrop, ?string $shareToken = null): DataResponse { + $e2eToken = $this->request->getParam('e2e-token'); + $ownerId = $this->getOwnerId($shareToken); + + if ($this->lockManager->isLocked($id, $e2eToken, $ownerId)) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); + } + + try { + $metaData = $this->metaDataStorage->getMetaData($ownerId, $id); + $decodedMetadata = json_decode($metaData, true); + $decodedFileDrop = json_decode($fileDrop, true); + $decodedMetadata['filedrop'] = array_merge($decodedMetadata['filedrop'] ?? [], $decodedFileDrop); + $encodedMetadata = json_encode($decodedMetadata); + + $this->metaDataStorage->updateMetaDataIntoIntermediateFile($ownerId, $id, $encodedMetadata); + } catch (MissingMetaDataException $e) { + throw new OCSNotFoundException($this->l10n->t('Metadata-file does not exist')); + } catch (NotFoundException $e) { + throw new OCSNotFoundException($e->getMessage()); + } catch (\Exception $e) { + $this->logger->critical($e->getMessage(), ['exception' => $e, 'app' => $this->appName]); + throw new OCSBadRequestException($this->l10n->t('Cannot update filedrop')); + } + + return new DataResponse(['meta-data' => $metaData]); + } + + private function getOwnerId(?string $shareToken = null): string { + if ($shareToken !== null) { + $share = $this->shareManager->getShareByToken($shareToken); + + if (!($share->getPermissions() & \OCP\Constants::PERMISSION_CREATE)) { + throw new OCSForbiddenException("Can't lock share without create permission"); + } + + return $share->getShareOwner(); + } elseif ($this->userId !== null) { + return $this->userId; + } else { + throw new OCSBadRequestException("Couldn't find the owner of the encrypted folder"); + } + } +} diff --git a/lib/IMetaDataStorageV1.php b/lib/IMetaDataStorageV1.php new file mode 100644 index 00000000..8e279dfc --- /dev/null +++ b/lib/IMetaDataStorageV1.php @@ -0,0 +1,87 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\EndToEndEncryption; + +use OCA\EndToEndEncryption\Exceptions\MetaDataExistsException; +use OCA\EndToEndEncryption\Exceptions\MissingMetaDataException; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; + +/** + * Interface IMetaDataStorage + * + * @package OCA\EndToEndEncryption + */ +interface IMetaDataStorageV1 { + + /** + * Get meta data file + * + * @throws NotPermittedException + * @throws NotFoundException + */ + public function getMetaData(string $userId, int $id): string; + + /** + * Set meta data file into intermediate file + * + * @throws NotPermittedException + * @throws NotFoundException + * @throws MetaDataExistsException + */ + public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $metaData): void; + + /** + * Update meta data file into intermediate file + * + * @throws NotPermittedException + * @throws NotFoundException + * @throws MissingMetaDataException + */ + public function updateMetaDataIntoIntermediateFile(string $userId, int $id, string $fileKey): void; + + /** + * Moves intermediate metadata file to final file + * + * @throws NotPermittedException + * @throws NotFoundException + * @throws MissingMetaDataException + */ + public function saveIntermediateFile(string $userId, int $id): void; + + /** + * Delete the previously set intermediate file + * + * @throws NotPermittedException + * @throws NotFoundException + */ + public function deleteIntermediateFile(string $userId, int $id): void; + + /** + * Delete meta data file (and backup) + * + * @throws NotPermittedException + * @throws NotFoundException + */ + public function deleteMetaData(string $userId, int $id): void; +} diff --git a/lib/LockManagerV1.php b/lib/LockManagerV1.php new file mode 100644 index 00000000..285ead6c --- /dev/null +++ b/lib/LockManagerV1.php @@ -0,0 +1,155 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + + +namespace OCA\EndToEndEncryption; + +use OCA\EndToEndEncryption\Db\Lock; +use OCA\EndToEndEncryption\Db\LockMapper; +use OCA\EndToEndEncryption\Exceptions\FileLockedException; +use OCA\EndToEndEncryption\Exceptions\FileNotLockedException; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; + +/** + * Handle end-to-end encryption file locking + * + * @package OCA\EndToEndEncryption + */ +class LockManagerV1 { + private LockMapper $lockMapper; + private ISecureRandom $secureRandom; + private IUserSession $userSession; + private IRootFolder $rootFolder; + private ITimeFactory $timeFactory; + + public function __construct(LockMapper $lockMapper, + ISecureRandom $secureRandom, + IRootFolder $rootFolder, + IUserSession $userSession, + ITimeFactory $timeFactory + ) { + $this->lockMapper = $lockMapper; + $this->secureRandom = $secureRandom; + $this->userSession = $userSession; + $this->rootFolder = $rootFolder; + $this->timeFactory = $timeFactory; + } + + /** + * Lock file + */ + public function lockFile(int $id, string $token = '', ?string $ownerId = null): ?string { + if ($this->isLocked($id, $token, $ownerId)) { + return null; + } + + try { + $lock = $this->lockMapper->getByFileId($id); + return $lock->getToken() === $token ? $token : null; + } catch (DoesNotExistException $ex) { + $newToken = $this->getToken(); + $lockEntity = new Lock(); + $lockEntity->setId($id); + $lockEntity->setTimestamp($this->timeFactory->getTime()); + $lockEntity->setToken($newToken); + $this->lockMapper->insert($lockEntity); + return $newToken; + } + } + + /** + * Unlock file + * + * @throws FileLockedException + * @throws FileNotLockedException + */ + public function unlockFile(int $id, string $token): void { + try { + $lock = $this->lockMapper->getByFileId($id); + } catch (DoesNotExistException $ex) { + throw new FileNotLockedException(); + } + + if ($lock->getToken() !== $token) { + throw new FileLockedException(); + } + + $this->lockMapper->delete($lock); + } + + /** + * Check if a file or a parent folder is locked + * + * @throws InvalidPathException + * @throws NotFoundException + * @throws \OCP\Files\NotPermittedException + */ + public function isLocked(int $id, string $token, ?string $ownerId = null): bool { + if ($ownerId === null) { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new NotPermittedException('No active user-session'); + } + $ownerId = $user->getUid(); + } + + $userRoot = $this->rootFolder->getUserFolder($ownerId); + $nodes = $userRoot->getById($id); + foreach ($nodes as $node) { + while ($node->getPath() !== '/') { + try { + $lock = $this->lockMapper->getByFileId($node->getId()); + } catch (DoesNotExistException $ex) { + // If this node is not locked, just check the parent one + $node = $node->getParent(); + continue; + } + + // If it's locked with a different token, return true + if ($lock->getToken() !== $token) { + return true; + } + + // If it's locked with the expected token, check the parent node + $node = $node->getParent(); + } + } + + return false; + } + + + /** + * Generate a new token + */ + private function getToken(): string { + return $this->secureRandom->generate(64, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); + } +} diff --git a/lib/MetaDataStorageV1.php b/lib/MetaDataStorageV1.php new file mode 100644 index 00000000..398fdb71 --- /dev/null +++ b/lib/MetaDataStorageV1.php @@ -0,0 +1,311 @@ + + * @copyright Copyright (c) 2020 Georg Ehrke + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\EndToEndEncryption; + +use OC\User\NoUserException; +use OCA\EndToEndEncryption\Exceptions\MetaDataExistsException; +use OCA\EndToEndEncryption\Exceptions\MissingMetaDataException; +use OCP\Files\IAppData; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\SimpleFS\ISimpleFile; + +/** + * Class MetaDataStorage + * + * @package OCA\EndToEndEncryption + */ +class MetaDataStorageV1 implements IMetaDataStorageV1 { + private IAppData $appData; + private IRootFolder $rootFolder; + private string $metaDataRoot = '/meta-data'; + private string $metaDataFileName = 'meta.data'; + private string $intermediateMetaDataFileName = 'intermediate.meta.data'; + + public function __construct(IAppData $appData, + IRootFolder $rootFolder) { + $this->appData = $appData; + $this->rootFolder = $rootFolder; + } + + /** + * @inheritDoc + */ + public function getMetaData(string $userId, int $id): string { + $this->verifyFolderStructure(); + $this->verifyOwner($userId, $id); + + $legacyFile = $this->getLegacyFile($userId, $id); + if ($legacyFile !== null) { + return $legacyFile->getContent(); + } + + $folderName = $this->getFolderNameForFileId($id); + $folder = $this->appData->getFolder($folderName); + + return $folder + ->getFile($this->metaDataFileName) + ->getContent(); + } + + /** + * @inheritDoc + */ + public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $metaData): void { + $this->verifyFolderStructure(); + $this->verifyOwner($userId, $id); + + $legacyFile = $this->getLegacyFile($userId, $id); + if ($legacyFile !== null) { + throw new MetaDataExistsException('Legacy Meta-data file already exists'); + } + + $folderName = $this->getFolderNameForFileId($id); + try { + $dir = $this->appData->getFolder($folderName); + } catch (NotFoundException $ex) { + $dir = $this->appData->newFolder($folderName); + } + + // Do not override metadata-file + if ($dir->fileExists($this->metaDataFileName)) { + throw new MetaDataExistsException('Meta-data file already exists'); + } + + if ($dir->fileExists($this->intermediateMetaDataFileName)) { + throw new MetaDataExistsException('Intermediate meta-data file already exists'); + } + + $dir->newFile($this->intermediateMetaDataFileName) + ->putContent($metaData); + } + + /** + * @inheritDoc + */ + public function updateMetaDataIntoIntermediateFile(string $userId, int $id, string $fileKey): void { + // ToDo check signature for race condition + $this->verifyFolderStructure(); + $this->verifyOwner($userId, $id); + + $legacyFile = $this->getLegacyFile($userId, $id); + $folderName = $this->getFolderNameForFileId($id); + try { + $dir = $this->appData->getFolder($folderName); + } catch (NotFoundException $ex) { + // No folder and no legacy + if ($legacyFile === null) { + throw new MissingMetaDataException('Meta-data file missing'); + } + + $dir = $this->appData->newFolder($folderName); + } + + if ($legacyFile === null && !$dir->fileExists($this->metaDataFileName)) { + throw new MissingMetaDataException('Meta-data file missing'); + } + + try { + $intermediateMetaDataFile = $dir->getFile($this->intermediateMetaDataFileName); + } catch (NotFoundException $ex) { + $intermediateMetaDataFile = $dir->newFile($this->intermediateMetaDataFileName); + } + + $intermediateMetaDataFile + ->putContent($fileKey); + } + + /** + * @inheritDoc + */ + public function deleteMetaData(string $userId, int $id): void { + $this->verifyFolderStructure(); + $this->verifyOwner($userId, $id); + + $folderName = $this->getFolderNameForFileId($id); + try { + $dir = $this->appData->getFolder($folderName); + } catch (NotFoundException $ex) { + return; + } + + $dir->delete(); + $this->cleanupLegacyFile($userId, $id); + } + + /** + * @inheritDoc + */ + public function saveIntermediateFile(string $userId, int $id): void { + $this->verifyFolderStructure(); + $this->verifyOwner($userId, $id); + + $folderName = $this->getFolderNameForFileId($id); + try { + $dir = $this->appData->getFolder($folderName); + } catch (NotFoundException $ex) { + throw new MissingMetaDataException('Intermediate meta-data file missing'); + } + + if (!$dir->fileExists($this->intermediateMetaDataFileName)) { + throw new MissingMetaDataException('Intermediate meta-data file missing'); + } + + $intermediateMetaDataFile = $dir->getFile($this->intermediateMetaDataFileName); + // If the intermediate file is empty, delete the metadata file + if ($intermediateMetaDataFile->getContent() === '{}') { + $dir->delete(); + } else { + try { + $finalFile = $dir->getFile($this->metaDataFileName); + } catch (NotFoundException $ex) { + $finalFile = $dir->newFile($this->metaDataFileName); + } + + $finalFile->putContent($intermediateMetaDataFile->getContent()); + // After successfully saving, automatically delete the intermediate file + $intermediateMetaDataFile->delete(); + } + + $this->cleanupLegacyFile($userId, $id); + } + + /** + * @inheritDoc + */ + public function deleteIntermediateFile(string $userId, int $id): void { + $this->verifyFolderStructure(); + $this->verifyOwner($userId, $id); + + $folderName = $this->getFolderNameForFileId($id); + try { + $dir = $this->appData->getFolder($folderName); + } catch (NotFoundException $ex) { + return; + } + + if (!$dir->fileExists($this->intermediateMetaDataFileName)) { + return; + } + + $dir->getFile($this->intermediateMetaDataFileName) + ->delete(); + } + + private function getFolderNameForFileId(int $id): string { + return $this->metaDataRoot . '/' . $id; + } + + /** + * Verifies that user has access to file-id + * + * @throws NotFoundException + */ + protected function verifyOwner(string $userId, int $id): void { + try { + $userFolder = $this->rootFolder->getUserFolder($userId); + } catch (NoUserException | NotPermittedException $ex) { + throw new NotFoundException('No user-root for '. $userId); + } + + $ownerNodes = $userFolder->getById($id); + if (!isset($ownerNodes[0])) { + throw new NotFoundException('No file for owner with ID ' . $id); + } + } + + /** + * @throws NotFoundException + * @throws NotPermittedException + */ + protected function verifyFolderStructure(): void { + $appDataRoot = $this->appData->getFolder('/'); + if (!$appDataRoot->fileExists($this->metaDataRoot)) { + $this->appData->newFolder($this->metaDataRoot); + } + } + + /** + * @throws NotPermittedException + */ + protected function getLegacyFile(string $userId, int $id): ?ISimpleFile { + try { + $legacyOwnerPath = $this->getLegacyOwnerPath($userId, $id); + } catch (NotFoundException $e) { + // Just return if file does not exist for user + return null; + } + + try { + $legacyFolder = $this->appData->getFolder($this->metaDataRoot . '/' . $legacyOwnerPath); + return $legacyFolder->getFile($this->metaDataFileName); + } catch (NotFoundException $e) { + // Just return if no legacy file exits + return null; + } + } + + /** + * @throws NotPermittedException + */ + protected function cleanupLegacyFile(string $userId, int $id): void { + try { + $legacyOwnerPath = $this->getLegacyOwnerPath($userId, $id); + } catch (NotFoundException $e) { + // Just return if file does not exist for user + return; + } + + try { + $legacyFolder = $this->appData->getFolder($this->metaDataRoot . '/' . $legacyOwnerPath); + $legacyFolder->delete(); + } catch (NotFoundException | NotPermittedException $e) { + return; + } + } + + /** + * Get path to the file for the file-owner. + * This is needed for the old way of storing metadata-files. + * + * @throws NotFoundException + * @throws NotPermittedException + */ + protected function getLegacyOwnerPath(string $userId, int $id):string { + try { + $userFolder = $this->rootFolder->getUserFolder($userId); + } catch (NoUserException $ex) { + throw new NotFoundException('No user-root for '. $userId); + } + + $ownerNodes = $userFolder->getById($id); + if (!isset($ownerNodes[0])) { + throw new NotFoundException('No file for owner with ID ' . $id); + } + + return $ownerNodes[0]->getPath(); + } +} diff --git a/lib/RollbackServiceV1.php b/lib/RollbackServiceV1.php new file mode 100644 index 00000000..e02c2312 --- /dev/null +++ b/lib/RollbackServiceV1.php @@ -0,0 +1,132 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\EndToEndEncryption; + +use OCP\Files\Config\ICachedMountFileInfo; +use OCP\Files\Config\IUserMountCache; +use OCP\Files\Folder; +use OCA\EndToEndEncryption\AppInfo\Application; +use OCA\EndToEndEncryption\Db\LockMapper; +use OCP\Files\IRootFolder; +use Psr\Log\LoggerInterface; + +/** + * Class RollbackService + * + * @package OCA\EndToEndEncryption + * + * This is the first implementation of a rollback-service, + * which allows restoring a previous state of an e2e folder + * in case a client dies and its token expires. + * + * This implementation is rather simple approach which copies + * a folder on lock. In case of a necessary rollback, it restores + * the state from the copied folder. + * + * A more elaborate approach should keep a journal of modifications + * and only backup files when they are actually being modified / deleted. + */ +class RollbackServiceV1 { + private LockMapper $lockMapper; + private IMetaDataStorageV1 $metaDataStorage; + private FileService $fileService; + private IUserMountCache $userMountCache; + private IRootFolder $rootFolder; + private LoggerInterface $logger; + + public function __construct(LockMapper $lockMapper, + IMetaDataStorageV1 $metaDataStorage, + FileService $fileService, + IUserMountCache $userMountCache, + IRootFolder $rootFolder, + LoggerInterface $logger) { + $this->lockMapper = $lockMapper; + $this->metaDataStorage = $metaDataStorage; + $this->fileService = $fileService; + $this->userMountCache = $userMountCache; + $this->rootFolder = $rootFolder; + $this->logger = $logger; + } + + /** + * Rollback all locks older than given timetstamp + * + * @param int $olderThanTimestamp + * @param int|null $limit + */ + public function rollbackOlderThan(int $olderThanTimestamp, ?int $limit = null): void { + $locks = $this->lockMapper->findAllLocksOlderThan($olderThanTimestamp, $limit); + + foreach ($locks as $lock) { + $mountPoints = $this->userMountCache->getMountsForFileId($lock->getId()); + if (empty($mountPoints)) { + $this->lockMapper->delete($lock); + continue; + } + + /** @var ICachedMountFileInfo $firstMountPoint */ + $firstMountPoint = array_shift($mountPoints); + $userId = $firstMountPoint->getUser()->getUID(); + + try { + $userFolder = $this->rootFolder->getUserFolder($userId); + } catch (\Exception $ex) { + $this->logger->critical($ex->getMessage(), [ + 'exception' => $ex, + 'app' => Application::APP_ID, + ]); + continue; + } + + if (strpos($firstMountPoint->getInternalPath(), 'files_trashbin/files/') === 0) { + $this->lockMapper->delete($lock); + continue; + } + + $nodes = $userFolder->getById($lock->getId()); + if (!isset($nodes[0]) || !$nodes[0] instanceof Folder) { + continue; + } + + $node = $nodes[0]; + // If the time that passed since the node was last modified + // is bigger than the time to live, do nothing + if ($node->getMTime() > $olderThanTimestamp) { + continue; + } + + try { + $this->fileService->revertChanges($node); + $this->metaDataStorage->deleteIntermediateFile($userId, $lock->getId()); + } catch (\Exception $ex) { + $this->logger->critical($ex->getMessage(), [ + 'exception' => $ex, + 'app' => Application::APP_ID, + ]); + continue; + } + + $this->lockMapper->delete($lock); + } + } +} diff --git a/tests/Unit/Controller/MetaDataControllerV1Test.php b/tests/Unit/Controller/MetaDataControllerV1Test.php new file mode 100644 index 00000000..7fdf91cb --- /dev/null +++ b/tests/Unit/Controller/MetaDataControllerV1Test.php @@ -0,0 +1,353 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\EndToEndEncryption\Tests\Controller; + +use OCA\EndToEndEncryption\Controller\V1\MetaDataController; +use OCA\EndToEndEncryption\Exceptions\MetaDataExistsException; +use OCA\EndToEndEncryption\Exceptions\MissingMetaDataException; +use OCA\EndToEndEncryption\IMetaDataStorageV1; +use OCA\EndToEndEncryption\LockManagerV1; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IL10N; +use OCP\IRequest; +use Psr\Log\LoggerInterface; +use Test\TestCase; +use OCP\Share\IManager as ShareManager; + +class MetaDataControllerV1Test extends TestCase { + + + /** @var string */ + private $appName; + + /** @var IRequest|\PHPUnit\Framework\MockObject\MockObject */ + private $request; + + /** @var string */ + private $userId; + + /** @var IMetaDataStorageV1|\PHPUnit\Framework\MockObject\MockObject */ + private $metaDataStorage; + + /** @var LockManagerV1|\PHPUnit\Framework\MockObject\MockObject */ + private $lockManager; + + /** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $logger; + + /** @var IL10N|\PHPUnit\Framework\MockObject\MockObject */ + private $l10n; + + /** @var ShareManager|\PHPUnit\Framework\MockObject\MockObject */ + private $shareManager; + + /** @var MetaDataController */ + private $controller; + + + protected function setUp(): void { + parent::setUp(); + + $this->appName = 'end_to_end_encryption'; + $this->request = $this->createMock(IRequest::class); + $this->userId = 'john.doe'; + $this->metaDataStorage = $this->createMock(IMetaDataStorageV1::class); + $this->lockManager = $this->createMock(LockManagerV1::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->l10n = $this->createMock(IL10N::class); + $this->shareManager = $this->createMock(ShareManager::class); + + $this->controller = new MetaDataController( + $this->appName, + $this->request, + $this->userId, + $this->metaDataStorage, + $this->lockManager, + $this->logger, + $this->l10n, + $this->shareManager + ); + } + + /** + * @param \Exception|null $metaDataStorageException + * @param string|null $expectedException + * @param string|null $expectedExceptionMessage + * @param bool $expectLogger + * + * @dataProvider getMetaDataDataProvider + */ + public function testGetMetaData(?\Exception $metaDataStorageException, + ?string $expectedException, + ?string $expectedExceptionMessage, + bool $expectLogger): void { + $fileId = 42; + $metaData = 'JSON-ENCODED-META-DATA'; + if ($metaDataStorageException) { + $this->metaDataStorage->expects($this->once()) + ->method('getMetaData') + ->with('john.doe', $fileId) + ->willThrowException($metaDataStorageException); + } else { + $this->metaDataStorage->expects($this->once()) + ->method('getMetaData') + ->with('john.doe', $fileId) + ->willReturn($metaData); + } + + $this->l10n->expects($this->any()) + ->method('t') + ->willReturnCallback(static function ($string, $args) { + return vsprintf($string, $args); + }); + + if ($expectLogger) { + $this->logger->expects($this->once()) + ->method('critical') + ->with($metaDataStorageException->getMessage(), ['exception' => $metaDataStorageException, 'app' => $this->appName]); + } + + if ($expectedException) { + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->controller->getMetaData($fileId); + } else { + $response = $this->controller->getMetaData($fileId); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertEquals([ + 'meta-data' => $metaData + ], $response->getData()); + } + } + + public function getMetaDataDataProvider(): array { + return [ + [null, null, null, false], + [new NotFoundException(), OCSNotFoundException::class, 'Could not find metadata for "42"', false], + [new \Exception(), OCSBadRequestException::class, 'Cannot read metadata', true], + ]; + } + + /** + * @param \Exception|null $metaDataStorageException + * @param string|null $expectedException + * @param string|null $expectedExceptionMessage + * @param bool $expectLogger + * @param array|null $expectedResponseData + * @param int|null $expectedResponseCode + * + * @dataProvider setMetaDataDataProvider + */ + public function testSetMetaData(?\Exception $metaDataStorageException, + ?string $expectedException, + ?string $expectedExceptionMessage, + bool $expectLogger, + ?array $expectedResponseData, + ?int $expectedResponseCode): void { + $fileId = 42; + $metaData = 'JSON-ENCODED-META-DATA'; + if ($metaDataStorageException) { + $this->metaDataStorage->expects($this->once()) + ->method('setMetaDataIntoIntermediateFile') + ->with('john.doe', $fileId, $metaData) + ->willThrowException($metaDataStorageException); + } else { + $this->metaDataStorage->expects($this->once()) + ->method('setMetaDataIntoIntermediateFile') + ->with('john.doe', $fileId, $metaData); + } + + $this->l10n->expects($this->any()) + ->method('t') + ->willReturnCallback(static function ($string, $args) { + return vsprintf($string, $args); + }); + + if ($expectLogger) { + $this->logger->expects($this->once()) + ->method('critical') + ->with($metaDataStorageException->getMessage(), ['exception' => $metaDataStorageException, 'app' => $this->appName]); + } + + if ($expectedException) { + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->controller->setMetaData($fileId, $metaData); + } else { + $response = $this->controller->setMetaData($fileId, $metaData); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertEquals($expectedResponseData, $response->getData()); + $this->assertEquals($expectedResponseCode, $response->getStatus()); + } + } + + public function setMetaDataDataProvider(): array { + return [ + [null, null, null, false, ['meta-data' => 'JSON-ENCODED-META-DATA'], 200], + [new MetaDataExistsException(), null, null, false, [], 409], + [new NotFoundException('Exception message'), OCSNotFoundException::class, 'Exception message', false, null, null], + [new \Exception(), OCSBadRequestException::class, 'Cannot store metadata', true, null, null], + ]; + } + + /** + * @param bool $isLocked + * @param \Exception|null $metaDataStorageException + * @param string|null $expectedException + * @param string|null $expectedExceptionMessage + * @param bool $expectLogger + * + * @dataProvider updateMetaDataDataProvider + */ + public function testUpdateMetaData(bool $isLocked, + ?\Exception $metaDataStorageException, + ?string $expectedException, + ?string $expectedExceptionMessage, + bool $expectLogger): void { + $fileId = 42; + $sendToken = 'sendE2EToken'; + $metaData = 'JSON-ENCODED-META-DATA'; + $this->request->expects($this->once()) + ->method('getParam') + ->with('e2e-token') + ->willReturn($sendToken); + + $this->lockManager->expects($this->once()) + ->method('isLocked') + ->with($fileId, $sendToken) + ->willReturn($isLocked); + + if (!$isLocked) { + if ($metaDataStorageException) { + $this->metaDataStorage->expects($this->once()) + ->method('updateMetaDataIntoIntermediateFile') + ->with('john.doe', $fileId, $metaData) + ->willThrowException($metaDataStorageException); + } else { + $this->metaDataStorage->expects($this->once()) + ->method('updateMetaDataIntoIntermediateFile') + ->with('john.doe', $fileId, $metaData); + } + } + + $this->l10n->expects($this->any()) + ->method('t') + ->willReturnCallback(static function ($string, $args) { + return vsprintf($string, $args); + }); + + if ($expectLogger) { + $this->logger->expects($this->once()) + ->method('critical') + ->with($metaDataStorageException->getMessage(), ['exception' => $metaDataStorageException, 'app' => $this->appName]); + } + + if ($expectedException) { + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->controller->updateMetaData($fileId, $metaData); + } else { + $response = $this->controller->updateMetaData($fileId, $metaData); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertEquals([ + 'meta-data' => $metaData, + ], $response->getData()); + } + } + + public function updateMetaDataDataProvider(): array { + return [ + [false, null, null, null, false], + [true, null, OCSForbiddenException::class, 'You are not allowed to edit the file, make sure to first lock it, and then send the right token', false], + [false, new MissingMetaDataException(), OCSNotFoundException::class, 'Metadata-file does not exist', false], + [false, new NotFoundException('Exception Message'), OCSNotFoundException::class, 'Exception Message', false], + [false, new \Exception(), OCSBadRequestException::class, 'Cannot store metadata', true], + ]; + } + + /** + * @param \Exception|null $metaDataStorageException + * @param string|null $expectedException + * @param string|null $expectedExceptionMessage + * @param bool $expectLogger + * + * @dataProvider deleteMetaDataDataProvider + */ + public function testDeleteMetaData(?\Exception $metaDataStorageException, + ?string $expectedException, + ?string $expectedExceptionMessage, + bool $expectLogger): void { + $fileId = 42; + if ($metaDataStorageException) { + $this->metaDataStorage->expects($this->once()) + ->method('updateMetaDataIntoIntermediateFile') + ->with('john.doe', $fileId, '{}') + ->willThrowException($metaDataStorageException); + } else { + $this->metaDataStorage->expects($this->once()) + ->method('updateMetaDataIntoIntermediateFile') + ->with('john.doe', $fileId, '{}'); + } + + $this->l10n->expects($this->any()) + ->method('t') + ->willReturnCallback(static function ($string, $args) { + return vsprintf($string, $args); + }); + + if ($expectLogger) { + $this->logger->expects($this->once()) + ->method('critical') + ->with($metaDataStorageException->getMessage(), ['exception' => $metaDataStorageException, 'app' => $this->appName]); + } + + if ($expectedException) { + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->controller->deleteMetaData($fileId); + } else { + $response = $this->controller->deleteMetaData($fileId); + $this->assertInstanceOf(DataResponse::class, $response); + $this->assertEquals([], $response->getData()); + } + } + + public function deleteMetaDataDataProvider(): array { + return [ + [null, null, null, false], + [new NotFoundException(), OCSNotFoundException::class, 'Could not find metadata for "42"', false], + [new NotPermittedException(), OCSForbiddenException::class, 'Only the owner can delete the metadata-file', false], + [new \Exception(), OCSBadRequestException::class, 'Cannot delete metadata', true], + ]; + } +} diff --git a/tests/Unit/LockManagerV1Test.php b/tests/Unit/LockManagerV1Test.php new file mode 100644 index 00000000..61d858ab --- /dev/null +++ b/tests/Unit/LockManagerV1Test.php @@ -0,0 +1,403 @@ + + * @copyright Copyright (c) 2020 Georg Ehrke + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ +namespace OCA\EndToEndEncryption\Tests\Unit; + +use OCA\EndToEndEncryption\Db\Lock; +use OCA\EndToEndEncryption\Db\LockMapper; +use OCA\EndToEndEncryption\Exceptions\FileLockedException; +use OCA\EndToEndEncryption\Exceptions\FileNotLockedException; +use OCA\EndToEndEncryption\LockManagerV1; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\Files\NotPermittedException; +use OCP\IUser; +use OCP\IUserSession; +use OCP\Security\ISecureRandom; +use Test\TestCase; + +/** + * Class LockManagerV1Test + * + * @group DB + */ +class LockManagerV1Test extends TestCase { + + /** @var LockMapper|\PHPUnit\Framework\MockObject\MockObject */ + private $lockMapper; + + /** @var ISecureRandom|\PHPUnit\Framework\MockObject\MockObject */ + private $secureRandom; + + /** @var IUserSession|\PHPUnit\Framework\MockObject\MockObject */ + private $userSession; + + /** @var IRootFolder|\PHPUnit\Framework\MockObject\MockObject */ + private $rootFolder; + + /** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */ + private $timeFactory; + + /** @var LockManagerV1 */ + private $lockManager; + + protected function setUp(): void { + parent::setUp(); + + $this->lockMapper = $this->createMock(LockMapper::class); + $this->secureRandom = $this->createMock(ISecureRandom::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + $this->timeFactory = $this->createMock(ITimeFactory::class); + + $this->lockManager = new LockManagerV1($this->lockMapper, $this->secureRandom, + $this->rootFolder, $this->userSession, $this->timeFactory); + } + + /** + * @dataProvider lockDataProvider + * + * @param bool $isLocked + * @param bool $lockDoesNotExist + * @param string $token + * @param bool $expectNull + * @param bool $expectNewToken + * @param bool $expectOldToken + */ + public function testLock(bool $isLocked, bool $lockDoesNotExist, string $token, bool $expectNull, bool $expectNewToken, bool $expectOldToken): void { + $lockManager = $this->getMockBuilder(LockManagerV1::class) + ->setMethods(['isLocked']) + ->setConstructorArgs([ + $this->lockMapper, + $this->secureRandom, + $this->rootFolder, + $this->userSession, + $this->timeFactory + ]) + ->getMock(); + + $lockManager->expects($this->once()) + ->method('isLocked') + ->with(42, $token) + ->willReturn($isLocked); + + if (!$isLocked) { + if ($lockDoesNotExist) { + $this->lockMapper->expects($this->once()) + ->method('getByFileId') + ->with(42) + ->willThrowException(new DoesNotExistException('')); + } else { + $fakeLock = new Lock(); + $fakeLock->setToken('correct-token123'); + + $this->lockMapper->expects($this->once()) + ->method('getByFileId') + ->with(42) + ->willReturn($fakeLock); + } + } + + if ($expectNewToken) { + $this->secureRandom->expects($this->once()) + ->method('generate') + ->with(64, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS) + ->willReturn('new-token'); + + $this->timeFactory->expects($this->once()) + ->method('getTime') + ->willReturn(1337); + + $this->lockMapper->expects($this->once()) + ->method('insert') + ->with($this->callback(static function ($lock) { + return ($lock instanceof Lock && + $lock->getId() === 42 && + $lock->getTimestamp() === 1337 && + $lock->getToken() === 'new-token'); + })); + } else { + $this->secureRandom->expects($this->never()) + ->method('generate'); + $this->timeFactory->expects($this->never()) + ->method('getTime'); + } + + $actual = $lockManager->lockFile(42, $token); + + if ($expectNull) { + $this->assertNull($actual); + } + if ($expectOldToken) { + $this->assertEquals($token, $actual); + } + if ($expectNewToken) { + $this->assertEquals('new-token', $actual); + } + } + + public function lockDataProvider(): array { + return [ + [true, false, 'correct-token123', true, false, false], + [false, true, 'correct-token123', false, true, false], + [false, false, 'correct-token123', false, false, true], + [false, false, 'wrong-token456', true, false, false], + ]; + } + + /** + * @dataProvider unlockDataProvider + * + * @param bool $lockDoesNotExist + * @param string $token + * @param bool $expectFileNotLocked + * @param bool $expectFileLocked + * @param bool $expectDelete + */ + public function testUnlock(bool $lockDoesNotExist, string $token, bool $expectFileNotLocked, bool $expectFileLocked, bool $expectDelete): void { + if ($lockDoesNotExist) { + $this->lockMapper->expects($this->once()) + ->method('getByFileId') + ->with(42) + ->willThrowException(new DoesNotExistException('')); + } else { + $fakeLock = new Lock(); + $fakeLock->setToken('correct-token123'); + + $this->lockMapper->expects($this->once()) + ->method('getByFileId') + ->with(42) + ->willReturn($fakeLock); + } + + if ($expectDelete) { + $this->lockMapper->expects($this->once()) + ->method('delete') + ->with($fakeLock); + } else { + $this->lockMapper->expects($this->never()) + ->method('delete'); + } + + if ($expectFileNotLocked) { + $this->expectException(FileNotLockedException::class); + } elseif ($expectFileLocked) { + $this->expectException(FileLockedException::class); + } + + $this->lockManager->unlockFile(42, $token); + } + + public function unlockDataProvider(): array { + return [ + [true, 'correct-token123', true, false, false], + [false, 'correct-token123', false, false, true], + [false, 'wrong-token456', false, true, false], + ]; + } + + public function testIsLockedNoUserSession(): void { + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn(null); + + $this->expectException(NotPermittedException::class); + + $this->lockManager->isLocked(42, 'correct-token123'); + } + + public function testIsLockedRoot(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('jane'); + + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $node = $this->createMock(Node::class); + $node->expects($this->once()) + ->method('getPath') + ->willReturn('/'); + + $userRoot = $this->createMock(Folder::class); + $userRoot->expects($this->once()) + ->method('getById') + ->with(42) + ->willReturn([$node]); + + $this->rootFolder->expects($this->once()) + ->method('getUserFolder') + ->with('jane') + ->willReturn($userRoot); + + $actual = $this->lockManager->isLocked(42, 'wrong-token456'); + $this->assertFalse($actual); + } + + public function testIsLockedNodeDifferentToken(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('jane'); + + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $node = $this->createMock(Node::class); + $node->expects($this->once()) + ->method('getPath') + ->willReturn('/sub/folder/abc'); + $node->expects($this->once()) + ->method('getId') + ->willReturn(1337); + + $userRoot = $this->createMock(Folder::class); + $userRoot->expects($this->once()) + ->method('getById') + ->with(42) + ->willReturn([$node]); + + $this->rootFolder->expects($this->once()) + ->method('getUserFolder') + ->with('jane') + ->willReturn($userRoot); + + $lock = new Lock(); + $lock->setToken('correct-token123'); + + $this->lockMapper->expects($this->once()) + ->method('getByFileId') + ->with(1337) + ->willReturn($lock); + + $actual = $this->lockManager->isLocked(42, 'wrong-token456'); + $this->assertTrue($actual); + } + + public function testIsLockedNodeCorrectToken(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('jane'); + + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $parentNode = $this->createMock(Node::class); + $parentNode->expects($this->once()) + ->method('getPath') + ->willReturn('/'); + + $node = $this->createMock(Node::class); + $node->expects($this->once()) + ->method('getPath') + ->willReturn('/sub/folder/abc'); + $node->expects($this->once()) + ->method('getId') + ->willReturn(1337); + $node->expects($this->once()) + ->method('getParent') + ->willReturn($parentNode); + + $userRoot = $this->createMock(Folder::class); + $userRoot->expects($this->once()) + ->method('getById') + ->with(42) + ->willReturn([$node]); + + $this->rootFolder->expects($this->once()) + ->method('getUserFolder') + ->with('jane') + ->willReturn($userRoot); + + $lock = new Lock(); + $lock->setToken('correct-token123'); + + $this->lockMapper->expects($this->once()) + ->method('getByFileId') + ->with(1337) + ->willReturn($lock); + + $actual = $this->lockManager->isLocked(42, 'correct-token123'); + $this->assertFalse($actual); + } + + public function testIsLockedParent(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('jane'); + + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $parentNode = $this->createMock(Node::class); + $parentNode->expects($this->once()) + ->method('getPath') + ->willReturn('/sub/folder'); + $parentNode->expects($this->once()) + ->method('getId') + ->willReturn(7331); + + $node = $this->createMock(Node::class); + $node->expects($this->once()) + ->method('getPath') + ->willReturn('/sub/folder/abc'); + $node->expects($this->once()) + ->method('getId') + ->willReturn(1337); + $node->expects($this->once()) + ->method('getParent') + ->willReturn($parentNode); + + $userRoot = $this->createMock(Folder::class); + $userRoot->expects($this->once()) + ->method('getById') + ->with(42) + ->willReturn([$node]); + + $this->rootFolder->expects($this->once()) + ->method('getUserFolder') + ->with('jane') + ->willReturn($userRoot); + + $lock = new Lock(); + $lock->setToken('correct-token123'); + + $this->lockMapper->expects($this->exactly(2)) + ->method('getByFileId') + ->withConsecutive([1337], [7331]) + ->willReturnOnConsecutiveCalls( + $this->throwException(new DoesNotExistException('')), + $lock + ); + + $actual = $this->lockManager->isLocked(42, 'wrong-token456'); + $this->assertTrue($actual); + } +} diff --git a/tests/Unit/MetaDataStorageV1Test.php b/tests/Unit/MetaDataStorageV1Test.php new file mode 100644 index 00000000..f6d58ceb --- /dev/null +++ b/tests/Unit/MetaDataStorageV1Test.php @@ -0,0 +1,813 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\EndToEndEncryption\Tests\Unit; + +use OC\User\NoUserException; +use OCA\EndToEndEncryption\Exceptions\MetaDataExistsException; +use OCA\EndToEndEncryption\Exceptions\MissingMetaDataException; +use OCA\EndToEndEncryption\MetaDataStorageV1; +use OCP\Files\Folder; +use OCP\Files\IAppData; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; +use Test\TestCase; +use Exception; + +class MetaDataStorageV1Test extends TestCase { + + /** @var IAppData|\PHPUnit\Framework\MockObject\MockObject */ + private $appData; + + /** @var IRootFolder|\PHPUnit\Framework\MockObject\MockObject */ + private $rootFolder; + + /** @var MetaDataStorageV1 */ + private $metaDataStorage; + + protected function setUp(): void { + parent::setUp(); + + $this->appData = $this->createMock(IAppData::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + + $this->metaDataStorage = new MetaDataStorageV1($this->appData, $this->rootFolder); + } + + /** + * @param bool $hasLegacyFile + * @param string $expectedOutput + * + * @dataProvider getMetaDataDataProvider + */ + public function testGetMetaData(bool $hasLegacyFile, string $expectedOutput): void { + $metaDataStorage = $this->getMockBuilder(MetaDataStorageV1::class) + ->setMethods([ + 'verifyOwner', + 'verifyFolderStructure', + 'getLegacyFile', + ]) + ->setConstructorArgs([ + $this->appData, + $this->rootFolder, + ]) + ->getMock(); + + $metaDataStorage->expects($this->once()) + ->method('verifyOwner') + ->with('userId', 42); + $metaDataStorage->expects($this->once()) + ->method('verifyFolderStructure'); + + if ($hasLegacyFile) { + $legacyMetaDataFile = $this->createMock(ISimpleFile::class); + $legacyMetaDataFile->expects($this->once()) + ->method('getContent') + ->willReturn('legacy-metadata-file-content'); + $metaDataStorage->expects($this->once()) + ->method('getLegacyFile') + ->with('userId', 42) + ->willReturn($legacyMetaDataFile); + + $this->appData->expects($this->never()) + ->method('getFolder') + ->with('/meta-data/42'); + } else { + $metaDataStorage->expects($this->once()) + ->method('getLegacyFile') + ->with('userId', 42) + ->willReturn(null); + + $metaDataFile = $this->createMock(ISimpleFile::class); + $metaDataFile->expects($this->once()) + ->method('getContent') + ->willReturn('metadata-file-content'); + + $metaDataFolder = $this->createMock(ISimpleFolder::class); + $metaDataFolder->expects($this->once()) + ->method('getFile') + ->with('meta.data') + ->willReturn($metaDataFile); + + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/42') + ->willReturn($metaDataFolder); + } + + $actual = $metaDataStorage->getMetaData('userId', 42); + $this->assertEquals($expectedOutput, $actual); + } + + public function getMetaDataDataProvider(): array { + return [ + [true, 'legacy-metadata-file-content'], + [false, 'metadata-file-content'], + ]; + } + + /** + * @dataProvider setMetaDataIntoIntermediateFileDataProvider + * + * @param bool $hasLegacyMetadataFile + * @param bool $folderExists + * @param bool $fileExists + * @param bool $intermediateFileExists + * @param bool $expectsNewFolder + * @param bool $expectsMetaDataExistsException + */ + public function testSetMetaDataIntoIntermediateFile(bool $hasLegacyMetadataFile, bool $folderExists, bool $fileExists, bool $intermediateFileExists, bool $expectsNewFolder, bool $expectsMetaDataExistsException): void { + $metaDataStorage = $this->getMockBuilder(MetaDataStorageV1::class) + ->setMethods([ + 'verifyOwner', + 'verifyFolderStructure', + 'getLegacyFile', + ]) + ->setConstructorArgs([ + $this->appData, + $this->rootFolder, + ]) + ->getMock(); + + $metaDataStorage->expects($this->once()) + ->method('verifyOwner') + ->with('userId', 42); + $metaDataStorage->expects($this->once()) + ->method('verifyFolderStructure'); + + if ($hasLegacyMetadataFile) { + $legacyMetaDataFile = $this->createMock(ISimpleFile::class); + $metaDataStorage->expects($this->once()) + ->method('getLegacyFile') + ->with('userId', 42) + ->willReturn($legacyMetaDataFile); + } else { + $metaDataStorage->expects($this->once()) + ->method('getLegacyFile') + ->with('userId', 42) + ->willReturn(null); + + $metaDataFolder = $this->createMock(ISimpleFolder::class); + if ($folderExists) { + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/42') + ->willReturn($metaDataFolder); + } else { + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/42') + ->willThrowException(new NotFoundException()); + } + + if ($expectsNewFolder) { + $this->appData->expects($this->once()) + ->method('newFolder') + ->with('/meta-data/42') + ->willReturn($metaDataFolder); + } else { + $this->appData->expects($this->never()) + ->method('newFolder'); + } + + + if ($fileExists) { + $metaDataFolder->expects($this->once()) + ->method('fileExists') + ->with('meta.data') + ->willReturn($fileExists); + } else { + $metaDataFolder->expects($this->exactly(2)) + ->method('fileExists') + ->withConsecutive(['meta.data'], ['intermediate.meta.data']) + ->willReturnOnConsecutiveCalls($fileExists, $intermediateFileExists); + } + } + + if ($expectsMetaDataExistsException) { + $this->expectException(MetaDataExistsException::class); + + if ($hasLegacyMetadataFile) { + $this->expectExceptionMessage('Legacy Meta-data file already exists'); + } elseif ($fileExists) { + $this->expectExceptionMessage('Meta-data file already exists'); + } elseif ($intermediateFileExists) { + $this->expectExceptionMessage('Intermediate meta-data file already exists'); + } + } else { + $node = $this->createMock(ISimpleFile::class); + $node->expects($this->once()) + ->method('putContent') + ->with('metadata-file-content'); + + $metaDataFolder->expects($this->once()) + ->method('newFile') + ->with('intermediate.meta.data') + ->willReturn($node); + } + + $metaDataStorage->setMetaDataIntoIntermediateFile('userId', 42, 'metadata-file-content'); + } + + public function setMetaDataIntoIntermediateFileDataProvider(): array { + return [ + [true, false, false, false, false, true], + [false, false, false, false, true, false], + [false, true, false, true, false, true], + [false, true, false, false, false, false], + [false, true, true, true, false, true], + [false, true, true, false, false, true], + ]; + } + + /** + * @dataProvider updateMetaDataIntoIntermediateFileDataProvider + * + * @param bool $hasLegacyMetadataFile + * @param bool $folderExists + * @param bool $fileExists + * @param bool $intermediateFileExists + * @param bool $expectMissingMetaDataException + */ + public function testUpdateMetaDataIntoIntermediateFile(bool $hasLegacyMetadataFile, bool $folderExists, bool $fileExists, bool $intermediateFileExists, bool $expectMissingMetaDataException): void { + $metaDataStorage = $this->getMockBuilder(MetaDataStorageV1::class) + ->setMethods([ + 'verifyOwner', + 'verifyFolderStructure', + 'getLegacyFile', + ]) + ->setConstructorArgs([ + $this->appData, + $this->rootFolder, + ]) + ->getMock(); + + $metaDataStorage->expects($this->once()) + ->method('verifyOwner') + ->with('userId', 42); + $metaDataStorage->expects($this->once()) + ->method('verifyFolderStructure'); + + if ($hasLegacyMetadataFile) { + $metaDataStorage->expects($this->once()) + ->method('getLegacyFile') + ->with('userId', 42) + ->willReturn($this->createMock(ISimpleFile::class)); + } else { + $metaDataStorage->expects($this->once()) + ->method('getLegacyFile') + ->with('userId', 42) + ->willReturn(null); + } + + $metaDataFolder = $this->createMock(ISimpleFolder::class); + if ($folderExists) { + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/42') + ->willReturn($metaDataFolder); + + if (!$hasLegacyMetadataFile) { + $metaDataFolder->expects($this->once()) + ->method('fileExists') + ->with('meta.data') + ->willReturn($fileExists); + } + } else { + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/42') + ->willThrowException(new NotFoundException()); + + if ($hasLegacyMetadataFile) { + $this->appData->expects($this->once()) + ->method('newFolder') + ->with('/meta-data/42') + ->willReturn($metaDataFolder); + } + } + + if ($expectMissingMetaDataException) { + $this->expectException(MissingMetaDataException::class); + $this->expectExceptionMessage('Meta-data file missing'); + } else { + $intermediateFile = $this->createMock(ISimpleFile::class); + $intermediateFile->expects($this->once()) + ->method('putContent') + ->with('metadata-file-content'); + + if ($intermediateFileExists) { + $metaDataFolder->expects($this->once()) + ->method('getFile') + ->with('intermediate.meta.data') + ->willReturn($intermediateFile); + } else { + $metaDataFolder->expects($this->once()) + ->method('getFile') + ->with('intermediate.meta.data') + ->willThrowException(new NotFoundException()); + + $metaDataFolder->expects($this->once()) + ->method('newFile') + ->with('intermediate.meta.data') + ->willReturn($intermediateFile); + } + } + + $metaDataStorage->updateMetaDataIntoIntermediateFile('userId', 42, 'metadata-file-content'); + } + + public function updateMetaDataIntoIntermediateFileDataProvider(): array { + return [ + [false, true, true, true, false], + [false, true, true, false, false], + [false, true, false, false, true], + [false, false, true, false, true], + [true, false, false, false, false], + [true, true, false, true, false], + [true, true, false, false, false], + ]; + } + + /** + * @dataProvider deleteMetaDataDataProvider + * + * @param bool $folderExists + */ + public function testDeleteMetaData(bool $folderExists): void { + $metaDataStorage = $this->getMockBuilder(MetaDataStorageV1::class) + ->setMethods([ + 'verifyOwner', + 'verifyFolderStructure', + 'cleanupLegacyFile', + ]) + ->setConstructorArgs([ + $this->appData, + $this->rootFolder, + ]) + ->getMock(); + + $metaDataStorage->expects($this->once()) + ->method('verifyOwner') + ->with('userId', 42); + $metaDataStorage->expects($this->once()) + ->method('verifyFolderStructure'); + + if ($folderExists) { + $metaDataFolder = $this->createMock(ISimpleFolder::class); + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/42') + ->willReturn($metaDataFolder); + + $metaDataFolder->expects($this->once()) + ->method('delete'); + $metaDataStorage->expects($this->once()) + ->method('cleanupLegacyFile') + ->with('userId', 42); + } else { + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/42') + ->willThrowException(new NotFoundException()); + } + + $metaDataStorage->deleteMetaData('userId', 42); + } + + public function deleteMetaDataDataProvider(): array { + return [ + [true], + [false], + ]; + } + + /** + * @dataProvider saveIntermediateFileDataProvider + * + * @param bool $folderExists + * @param bool $intermediateFileExists + * @param bool $intermediateFileIsEmpty + * @param bool $finalFileExists + * @param bool $expectsException + */ + public function testSaveIntermediateFile(bool $folderExists, bool $intermediateFileExists, bool $intermediateFileIsEmpty, bool $finalFileExists, bool $expectsException): void { + $metaDataStorage = $this->getMockBuilder(MetaDataStorageV1::class) + ->setMethods([ + 'verifyOwner', + 'verifyFolderStructure', + 'cleanupLegacyFile', + ]) + ->setConstructorArgs([ + $this->appData, + $this->rootFolder, + ]) + ->getMock(); + + $metaDataStorage->expects($this->once()) + ->method('verifyOwner') + ->with('userId', 42); + $metaDataStorage->expects($this->once()) + ->method('verifyFolderStructure'); + + if ($folderExists) { + $metaDataFolder = $this->createMock(ISimpleFolder::class); + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/42') + ->willReturn($metaDataFolder); + + $metaDataFolder->expects($this->once()) + ->method('fileExists') + ->with('intermediate.meta.data') + ->willReturn($intermediateFileExists); + + if ($intermediateFileExists) { + $intermediateFile = $this->createMock(ISimpleFile::class); + if ($intermediateFileIsEmpty) { + $intermediateFile->expects($this->once()) + ->method('getContent') + ->willReturn('{}'); + + $metaDataFolder->expects($this->once()) + ->method('getFile') + ->with('intermediate.meta.data') + ->willReturn($intermediateFile); + + $metaDataFolder->expects($this->once()) + ->method('delete'); + } else { + $intermediateFile->expects($this->exactly(2)) + ->method('getContent') + ->willReturn('intermediate-file-content'); + + $finalFile = $this->createMock(ISimpleFile::class); + $finalFile->expects($this->once()) + ->method('putContent') + ->with('intermediate-file-content'); + + if ($finalFileExists) { + $metaDataFolder->expects($this->exactly(2)) + ->method('getFile') + ->withConsecutive(['intermediate.meta.data'], ['meta.data']) + ->willReturn($intermediateFile, $finalFile); + } else { + $metaDataFolder->expects($this->exactly(2)) + ->method('getFile') + ->withConsecutive(['intermediate.meta.data'], ['meta.data']) + ->willReturnOnConsecutiveCalls( + $intermediateFile, + $this->throwException(new NotFoundException()), + ); + + $metaDataFolder->expects($this->once()) + ->method('newFile') + ->with('meta.data') + ->willReturn($finalFile); + } + + $intermediateFile->expects($this->once()) + ->method('delete'); + } + + $metaDataStorage->expects($this->once()) + ->method('cleanupLegacyFile') + ->with('userId', 42); + } + } else { + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/42') + ->willThrowException(new NotFoundException()); + } + + if ($expectsException) { + $this->expectException(MissingMetaDataException::class); + $this->expectExceptionMessage('Intermediate meta-data file missing'); + } + + $metaDataStorage->saveIntermediateFile('userId', 42); + } + + public function saveIntermediateFileDataProvider(): array { + return [ + [false, false, false, false, true], + [true, false, false, false, true], + [true, true, false, true, false], + [true, true, true, true, false], + [true, true, false, false, false], + [true, true, true, false, false], + ]; + } + + /** + * @dataProvider deleteIntermediateFileDataProvider + * + * @param bool $folderExists + * @param bool $fileExists + */ + public function testDeleteIntermediateFile(bool $folderExists, bool $fileExists): void { + $metaDataStorage = $this->getMockBuilder(MetaDataStorageV1::class) + ->setMethods([ + 'verifyOwner', + 'verifyFolderStructure', + ]) + ->setConstructorArgs([ + $this->appData, + $this->rootFolder, + ]) + ->getMock(); + + $metaDataStorage->expects($this->once()) + ->method('verifyOwner') + ->with('userId', 42); + $metaDataStorage->expects($this->once()) + ->method('verifyFolderStructure'); + + if ($folderExists) { + $metaDataFolder = $this->createMock(ISimpleFolder::class); + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/42') + ->willReturn($metaDataFolder); + + $metaDataFolder->expects($this->once()) + ->method('fileExists') + ->with('intermediate.meta.data') + ->willReturn($fileExists); + + if ($fileExists) { + $intermediateFile = $this->createMock(ISimpleFile::class); + $intermediateFile->expects($this->once()) + ->method('delete'); + + $metaDataFolder->expects($this->once()) + ->method('getFile') + ->with('intermediate.meta.data') + ->willReturn($intermediateFile); + } + } + + $metaDataStorage->deleteIntermediateFile('userId', 42); + } + + public function deleteIntermediateFileDataProvider(): array { + return [ + [false, false], + [true, false], + [true, true], + ]; + } + + /** + * @dataProvider verifyOwnerDataProvider + * + * @param bool $noUserException + * @param bool $emptyOwnerRoot + * @param bool $expectsNotFoundEx + * @param string|null $expectedMessage + */ + public function testVerifyOwner(bool $noUserException, bool $emptyOwnerRoot, bool $expectsNotFoundEx, ?string $expectedMessage): void { + if ($noUserException) { + $this->rootFolder->expects($this->once()) + ->method('getUserFolder') + ->with('userId') + ->willThrowException(new NoUserException()); + } else { + $ownerRoot = $this->createMock(Folder::class); + $this->rootFolder->expects($this->once()) + ->method('getUserFolder') + ->with('userId') + ->willReturn($ownerRoot); + + if ($emptyOwnerRoot) { + $ownerRoot->expects($this->once()) + ->method('getById') + ->with(42) + ->willReturn([]); + } else { + $ownerNode = $this->createMock(Node::class); + $ownerRoot->expects($this->once()) + ->method('getById') + ->with(42) + ->willReturn([$ownerNode]); + } + } + + if ($expectsNotFoundEx) { + $this->expectException(NotFoundException::class); + $this->expectExceptionMessage($expectedMessage); + } + + self::invokePrivate($this->metaDataStorage, 'verifyOwner', ['userId', 42]); + } + + public function verifyOwnerDataProvider(): array { + return [ + [true, false, true, 'No user-root for userId'], + [false, true, true, 'No file for owner with ID 42'], + [false, false, false, null], + ]; + } + + /** + * @dataProvider verifyFolderStructureDataProvider + * + * @param bool $exists + * @param bool $expectsNewFolder + */ + public function testVerifyFolderStructure(bool $exists, bool $expectsNewFolder): void { + $appDataRoot = $this->createMock(ISimpleFolder::class); + $appDataRoot->expects($this->once()) + ->method('fileExists') + ->with('/meta-data') + ->willReturn($exists); + + if ($expectsNewFolder) { + $this->appData->expects($this->once()) + ->method('newFolder') + ->with('/meta-data'); + } else { + $this->appData->expects($this->never()) + ->method('newFolder'); + } + + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/') + ->willReturn($appDataRoot); + + self::invokePrivate($this->metaDataStorage, 'verifyFolderStructure'); + } + + public function verifyFolderStructureDataProvider(): array { + return [ + [true, false], + [false, true], + ]; + } + + /** + * @param Exception|null $legacyOwnerException + * @param Exception|null $getFolderException + * @param Exception|null $getFileException + * @param bool $expectsNull + * + * @dataProvider getLegacyFileDataProvider + */ + public function testGetLegacyFile(?Exception $legacyOwnerException, + ?Exception $getFolderException, + ?Exception $getFileException, + bool $expectsNull): void { + $metaDataStorage = $this->getMockBuilder(MetaDataStorageV1::class) + ->setMethods([ + 'getLegacyOwnerPath', + ]) + ->setConstructorArgs([ + $this->appData, + $this->rootFolder, + ]) + ->getMock(); + + $legacyFolder = $this->createMock(ISimpleFolder::class); + $legacyFile = $this->createMock(ISimpleFile::class); + if ($legacyOwnerException) { + $metaDataStorage->expects($this->once()) + ->method('getLegacyOwnerPath') + ->with('john.doe', 42) + ->willThrowException($legacyOwnerException); + } else { + $metaDataStorage->expects($this->once()) + ->method('getLegacyOwnerPath') + ->with('john.doe', 42) + ->willReturn('legacy-path-to-metadata-folder'); + + if ($getFolderException) { + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/legacy-path-to-metadata-folder') + ->willThrowException($getFolderException); + } else { + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/legacy-path-to-metadata-folder') + ->willReturn($legacyFolder); + + if ($getFileException) { + $legacyFolder->expects($this->once()) + ->method('getFile') + ->with('meta.data') + ->willThrowException($getFileException); + } else { + $legacyFolder->expects($this->once()) + ->method('getFile') + ->with('meta.data') + ->willReturn($legacyFile); + } + } + } + + $actual = self::invokePrivate($metaDataStorage, 'getLegacyFile', ['john.doe', 42]); + if ($expectsNull) { + $this->assertNull($actual); + } else { + $this->assertEquals($legacyFile, $actual); + } + } + + public function getLegacyFileDataProvider(): array { + return [ + [new NotFoundException(), null, null, true], + [null, new NotFoundException(), null, true], + [null, null, new NotFoundException(), true], + [null, null, null, false], + ]; + } + + /** + * @param Exception|null $legacyOwnerException + * @param Exception|null $getFolderException + * @param bool $expectsDelete + * + * @dataProvider cleanupLegacyFileDataProvider + */ + public function testCleanupLegacyFile(?Exception $legacyOwnerException, + ?Exception $getFolderException, + bool $expectsDelete): void { + $metaDataStorage = $this->getMockBuilder(MetaDataStorageV1::class) + ->setMethods([ + 'getLegacyOwnerPath', + ]) + ->setConstructorArgs([ + $this->appData, + $this->rootFolder, + ]) + ->getMock(); + + $legacyFolder = $this->createMock(ISimpleFolder::class); + if ($legacyOwnerException) { + $metaDataStorage->expects($this->once()) + ->method('getLegacyOwnerPath') + ->with('john.doe', 42) + ->willThrowException($legacyOwnerException); + } else { + $metaDataStorage->expects($this->once()) + ->method('getLegacyOwnerPath') + ->with('john.doe', 42) + ->willReturn('legacy-path-to-metadata-folder'); + + if ($getFolderException) { + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/legacy-path-to-metadata-folder') + ->willThrowException($getFolderException); + } else { + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/legacy-path-to-metadata-folder') + ->willReturn($legacyFolder); + } + } + + if ($expectsDelete) { + $legacyFolder->expects($this->once()) + ->method('delete'); + } else { + $legacyFolder->expects($this->never()) + ->method('delete'); + } + + self::invokePrivate($metaDataStorage, 'cleanupLegacyFile', ['john.doe', 42]); + } + + public function cleanupLegacyFileDataProvider(): array { + return [ + [new NotFoundException(), null, false], + [null, new NotFoundException(), false], + [null, null, true], + ]; + } +} diff --git a/tests/Unit/RollbackServiceV1Test.php b/tests/Unit/RollbackServiceV1Test.php new file mode 100644 index 00000000..bd59429e --- /dev/null +++ b/tests/Unit/RollbackServiceV1Test.php @@ -0,0 +1,240 @@ + + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\EndToEndEncryption\Tests\Unit; + +use OCA\EndToEndEncryption\Db\Lock; +use OCA\EndToEndEncryption\Db\LockMapper; +use OCA\EndToEndEncryption\FileService; +use OCA\EndToEndEncryption\IMetaDataStorageV1; +use OCA\EndToEndEncryption\RollbackServiceV1; +use OCP\Files\Config\ICachedMountFileInfo; +use OCP\Files\Config\IUserMountCache; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use Psr\Log\LoggerInterface; +use OCP\IUser; +use Test\TestCase; + +class RollbackServiceV1Test extends TestCase { + + /** @var LockMapper|\PHPUnit\Framework\MockObject\MockObject */ + private $lockMapper; + + /** @var IMetaDataStorageV1|\PHPUnit\Framework\MockObject\MockObject */ + private $metaDataStorage; + + /** @var FileService|\PHPUnit\Framework\MockObject\MockObject */ + private $fileService; + + /** @var IUserMountCache|\PHPUnit\Framework\MockObject\MockObject */ + private $userMountCache; + + /** @var IRootFolder|\PHPUnit\Framework\MockObject\MockObject */ + private $rootFolder; + + /** @var LoggerInterface|\PHPUnit\Framework\MockObject\MockObject */ + private $logger; + + /** @var RollbackServiceV1 */ + private $rollbackService; + + protected function setUp(): void { + parent::setUp(); + + $this->lockMapper = $this->createMock(LockMapper::class); + $this->metaDataStorage = $this->createMock(IMetaDataStorageV1::class); + $this->fileService = $this->createMock(FileService::class); + $this->userMountCache = $this->createMock(IUserMountCache::class); + $this->rootFolder = $this->createMock(IRootFolder::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->rollbackService = new RollbackServiceV1($this->lockMapper, + $this->metaDataStorage, + $this->fileService, + $this->userMountCache, + $this->rootFolder, + $this->logger); + } + + public function testRollbackOlderThan(): void { + $locks = $this->getSampleLocks(); + + $this->lockMapper->expects($this->once()) + ->method('findAllLocksOlderThan') + ->with(1337, 10) + ->willReturn($locks); + + $mountFileInfo2 = $this->createMock(ICachedMountFileInfo::class); + $mountFileInfo3 = $this->createMock(ICachedMountFileInfo::class); + $mountFileInfo4 = $this->createMock(ICachedMountFileInfo::class); + $mountFileInfo5 = $this->createMock(ICachedMountFileInfo::class); + $mountFileInfo6 = $this->createMock(ICachedMountFileInfo::class); + $mountFileInfo7 = $this->createMock(ICachedMountFileInfo::class); + + $mountFileInfo7->method('getInternalPath') + ->willReturn('files_trashbin/files/a_deleted_file.jpg.d1682517431'); + + $this->userMountCache->expects($this->exactly(7)) + ->method('getMountsForFileId') + ->willReturnMap([ + [100001, null, []], + [100002, null, [$mountFileInfo2]], + [100003, null, [$mountFileInfo3]], + [100004, null, [$mountFileInfo4]], + [100005, null, [$mountFileInfo5]], + [100006, null, [$mountFileInfo6]], + [100007, null, [$mountFileInfo7]], + ]); + + $user2 = $this->createMock(IUser::class); + $user2->method('getUID')->willReturn('user2'); + $user3 = $this->createMock(IUser::class); + $user3->method('getUID')->willReturn('user3'); + $user4 = $this->createMock(IUser::class); + $user4->method('getUID')->willReturn('user4'); + $user5 = $this->createMock(IUser::class); + $user5->method('getUID')->willReturn('user5'); + $user6 = $this->createMock(IUser::class); + $user6->method('getUID')->willReturn('user6'); + $user7 = $this->createMock(IUser::class); + $user7->method('getUID')->willReturn('user7'); + + $mountFileInfo2->method('getUser')->willReturn($user2); + $mountFileInfo3->method('getUser')->willReturn($user3); + $mountFileInfo4->method('getUser')->willReturn($user4); + $mountFileInfo5->method('getUser')->willReturn($user5); + $mountFileInfo6->method('getUser')->willReturn($user6); + $mountFileInfo7->method('getUser')->willReturn($user7); + + $userFolder3 = $this->createMock(Folder::class); + $userFolder4 = $this->createMock(Folder::class); + $userFolder5 = $this->createMock(Folder::class); + $userFolder6 = $this->createMock(Folder::class); + $userFolder7 = $this->createMock(Folder::class); + + $this->rootFolder->method('getUserFolder') + ->withConsecutive(['user2'], ['user3'], ['user4'], ['user5'], ['user6'], ['user7']) + ->willReturnOnConsecutiveCalls( + $this->throwException(new \Exception('User not found')), + $userFolder3, + $userFolder4, + $userFolder5, + $userFolder6, + $userFolder7 + ); + + $node3 = $this->createMock(Folder::class); + $node3->expects($this->once()) + ->method('getMTime') + ->willReturn(2000); + $node4 = $this->createMock(Folder::class); + $node4->expects($this->once()) + ->method('getMTime') + ->willReturn(1336); + $node5 = $this->createMock(Folder::class); + $node5->expects($this->once()) + ->method('getMTime') + ->willReturn(1335); + $node6 = $this->createMock(Folder::class); + $node6->expects($this->once()) + ->method('getMTime') + ->willReturn(200); + + $userFolder3->expects($this->once()) + ->method('getById') + ->with(100003) + ->willReturn([$node3]); + $userFolder4->expects($this->once()) + ->method('getById') + ->with(100004) + ->willReturn([$node4]); + $userFolder5->expects($this->once()) + ->method('getById') + ->with(100005) + ->willReturn([$node5]); + $userFolder6->expects($this->once()) + ->method('getById') + ->with(100006) + ->willReturn([$node6]); + + $this->fileService->expects($this->exactly(3)) + ->method('revertChanges') + ->withConsecutive([$node4], [$node5], [$node6]) + ->willReturnOnConsecutiveCalls( + $this->throwException(new \Exception('Exception while reverting changes')), + true, + true + ); + + $this->metaDataStorage->expects($this->exactly(2)) + ->method('deleteIntermediateFile') + ->withConsecutive(['user5', 100005], ['user6', 100006]) + ->willReturnOnConsecutiveCalls( + $this->throwException(new \Exception('Exception while deleting intermediate file')), + null + ); + + $this->lockMapper->expects($this->exactly(3)) + ->method('delete') + ->withConsecutive([$locks[0]], [$locks[5]], [$locks[6]]); + + $this->logger->expects($this->exactly(3)) + ->method('critical'); + + $this->rollbackService->rollbackOlderThan(1337, 10); + } + + private function getSampleLocks(): array { + $lock1 = new Lock(); + $lock1->setId(100001); + + $lock2 = new Lock(); + $lock2->setId(100002); + + $lock3 = new Lock(); + $lock3->setId(100003); + + $lock4 = new Lock(); + $lock4->setId(100004); + + $lock5 = new Lock(); + $lock5->setId(100005); + + $lock6 = new Lock(); + $lock6->setId(100006); + + $lock7 = new Lock(); + $lock7->setId(100007); + + return [ + $lock1, + $lock2, + $lock3, + $lock4, + $lock5, + $lock6, + $lock7, + ]; + } +} From a8b9f84db270d021d871d14c6aceffb14ae05a91 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Mon, 20 Mar 2023 19:37:14 +0100 Subject: [PATCH 02/36] Wrap-up changes for updated subfolders When a subfolder is updated, the changes were not saved. Changed folders are now tracked and changes metadata update are done for the all the changed folders instead of the top one. Signed-off-by: Louis Chemineau Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- lib/Controller/LockingController.php | 14 +++--- lib/Controller/MetaDataController.php | 12 +++-- lib/FileService.php | 2 +- lib/IMetaDataStorage.php | 22 ++++++++- lib/LockManager.php | 7 ++- lib/MetaDataStorage.php | 37 ++++++++++++++- lib/RollbackService.php | 3 ++ .../Unit/Controller/LockingControllerTest.php | 7 ++- .../Controller/MetaDataControllerTest.php | 13 +++-- tests/Unit/LockManagerTest.php | 20 ++++++-- tests/Unit/MetaDataStorageTest.php | 47 ++++++++++--------- tests/Unit/RollbackServiceTest.php | 7 +++ 12 files changed, 142 insertions(+), 49 deletions(-) diff --git a/lib/Controller/LockingController.php b/lib/Controller/LockingController.php index bb069163..3696b8c8 100644 --- a/lib/Controller/LockingController.php +++ b/lib/Controller/LockingController.php @@ -32,7 +32,6 @@ use OC\User\NoUserException; use OCA\EndToEndEncryption\Exceptions\FileLockedException; use OCA\EndToEndEncryption\Exceptions\FileNotLockedException; -use OCA\EndToEndEncryption\Exceptions\MissingMetaDataException; use OCA\EndToEndEncryption\FileService; use OCA\EndToEndEncryption\IMetaDataStorage; use OCA\EndToEndEncryption\LockManager; @@ -152,16 +151,15 @@ public function unlockFolder(int $id, ?string $shareToken = null): DataResponse throw new OCSForbiddenException($this->l10n->t('You are not allowed to remove the lock')); } - $hadChanges = $this->fileService->finalizeChanges($nodes[0]); + $touchFoldersIds = $this->metaDataStorage->getTouchedFolders($token); + foreach ($touchFoldersIds as $folderId) { + $this->fileService->finalizeChanges($userFolder->getById($folderId)[0]); - try { - $this->metaDataStorage->saveIntermediateFile($ownerId, $id); - } catch (MissingMetaDataException $ex) { - if ($hadChanges) { - throw $ex; - } + $this->metaDataStorage->saveIntermediateFile($ownerId, $folderId); } + $this->metaDataStorage->clearTouchedFolders($token); + try { $this->lockManager->unlockFile($id, $token); } catch (FileLockedException $e) { diff --git a/lib/Controller/MetaDataController.php b/lib/Controller/MetaDataController.php index 9354a15a..cd6bf4f6 100644 --- a/lib/Controller/MetaDataController.php +++ b/lib/Controller/MetaDataController.php @@ -104,8 +104,10 @@ public function getMetaData(int $id, ?string $shareToken = null): DataResponse { * @throws OCSBadRequestException */ public function setMetaData(int $id, string $metaData): DataResponse { + $e2eToken = $this->request->getHeader('e2e-token'); + try { - $this->metaDataStorage->setMetaDataIntoIntermediateFile($this->userId, $id, $metaData); + $this->metaDataStorage->setMetaDataIntoIntermediateFile($this->userId, $id, $metaData, $e2eToken); } catch (MetaDataExistsException $e) { return new DataResponse([], Http::STATUS_CONFLICT); } catch (NotFoundException $e) { @@ -135,7 +137,7 @@ public function updateMetaData(int $id, string $metaData): DataResponse { } try { - $this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, $metaData); + $this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, $metaData, $e2eToken); } catch (MissingMetaDataException $e) { throw new OCSNotFoundException($this->l10n->t('Metadata-file does not exist')); } catch (NotFoundException $e) { @@ -161,8 +163,10 @@ public function updateMetaData(int $id, string $metaData): DataResponse { * @throws OCSBadRequestException */ public function deleteMetaData(int $id): DataResponse { + $e2eToken = $this->request->getHeader('e2e-token'); + try { - $this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, '{}'); + $this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, '{}', $e2eToken); } catch (NotFoundException $e) { throw new OCSNotFoundException($this->l10n->t('Could not find metadata for "%s"', [$id])); } catch (NotPermittedException $e) { @@ -200,7 +204,7 @@ public function addMetadataFileDrop(int $id, string $fileDrop, ?string $shareTok $decodedMetadata['filedrop'] = array_merge($decodedMetadata['filedrop'] ?? [], $decodedFileDrop); $encodedMetadata = json_encode($decodedMetadata); - $this->metaDataStorage->updateMetaDataIntoIntermediateFile($ownerId, $id, $encodedMetadata); + $this->metaDataStorage->updateMetaDataIntoIntermediateFile($ownerId, $id, $encodedMetadata, $e2eToken); } catch (MissingMetaDataException $e) { throw new OCSNotFoundException($this->l10n->t('Metadata-file does not exist')); } catch (NotFoundException $e) { diff --git a/lib/FileService.php b/lib/FileService.php index ea21df26..266a9980 100644 --- a/lib/FileService.php +++ b/lib/FileService.php @@ -56,7 +56,7 @@ public function revertChanges(Folder $folder): bool { } /** - * @return bool Whether this operation changed any files + * @return bool Move and delete temporary files suffixed by .e2e-to-save and .e2e-to-delete */ public function finalizeChanges(Folder $folder): bool { $intermediateFiles = $this->getIntermediateFiles($folder); diff --git a/lib/IMetaDataStorage.php b/lib/IMetaDataStorage.php index 348c8c81..5dc1e15a 100644 --- a/lib/IMetaDataStorage.php +++ b/lib/IMetaDataStorage.php @@ -49,7 +49,7 @@ public function getMetaData(string $userId, int $id): string; * @throws NotFoundException * @throws MetaDataExistsException */ - public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $metaData): void; + public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $metaData, string $token): void; /** * Update meta data file into intermediate file @@ -58,7 +58,7 @@ public function setMetaDataIntoIntermediateFile(string $userId, int $id, string * @throws NotFoundException * @throws MissingMetaDataException */ - public function updateMetaDataIntoIntermediateFile(string $userId, int $id, string $fileKey): void; + public function updateMetaDataIntoIntermediateFile(string $userId, int $id, string $fileKey, string $token): void; /** * Moves intermediate metadata file to final file @@ -84,4 +84,22 @@ public function deleteIntermediateFile(string $userId, int $id): void; * @throws NotFoundException */ public function deleteMetaData(string $userId, int $id): void; + + /** + * Return the list of folders marked as touched. + * + * @return int[] + * + * @throws NotPermittedException + * @throws NotFoundException + */ + public function getTouchedFolders(string $token): array; + + /** + * Clear the list of touched folder for a token. + * + * @throws NotPermittedException + * @throws NotFoundException + */ + public function clearTouchedFolders(string $token): void; } diff --git a/lib/LockManager.php b/lib/LockManager.php index eb608272..b6f5b3d5 100644 --- a/lib/LockManager.php +++ b/lib/LockManager.php @@ -49,11 +49,13 @@ class LockManager { private IRootFolder $rootFolder; private ITimeFactory $timeFactory; - public function __construct(LockMapper $lockMapper, + public function __construct( + LockMapper $lockMapper, ISecureRandom $secureRandom, IRootFolder $rootFolder, IUserSession $userSession, - ITimeFactory $timeFactory + ITimeFactory $timeFactory, + private IMetaDataStorage $metaDataStorage, ) { $this->lockMapper = $lockMapper; $this->secureRandom = $secureRandom; @@ -101,6 +103,7 @@ public function unlockFile(int $id, string $token): void { throw new FileLockedException(); } + $this->metaDataStorage->clearTouchedFolders($lock->getToken()); $this->lockMapper->delete($lock); } diff --git a/lib/MetaDataStorage.php b/lib/MetaDataStorage.php index a27f872c..9c8362c2 100644 --- a/lib/MetaDataStorage.php +++ b/lib/MetaDataStorage.php @@ -32,6 +32,7 @@ use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\Files\SimpleFS\ISimpleFile; +use OCP\Files\SimpleFS\ISimpleFolder; /** * Class MetaDataStorage @@ -74,7 +75,7 @@ public function getMetaData(string $userId, int $id): string { /** * @inheritDoc */ - public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $metaData): void { + public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $metaData, string $token): void { $this->verifyFolderStructure(); $this->verifyOwner($userId, $id); @@ -101,12 +102,14 @@ public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $dir->newFile($this->intermediateMetaDataFileName) ->putContent($metaData); + + $this->getTokenFolder($token)->newFile("$id", ''); } /** * @inheritDoc */ - public function updateMetaDataIntoIntermediateFile(string $userId, int $id, string $fileKey): void { + public function updateMetaDataIntoIntermediateFile(string $userId, int $id, string $fileKey, string $token): void { // ToDo check signature for race condition $this->verifyFolderStructure(); $this->verifyOwner($userId, $id); @@ -136,6 +139,8 @@ public function updateMetaDataIntoIntermediateFile(string $userId, int $id, stri $intermediateMetaDataFile ->putContent($fileKey); + + $this->getTokenFolder($token)->newFile("$id", ''); } /** @@ -308,4 +313,32 @@ protected function getLegacyOwnerPath(string $userId, int $id):string { return $ownerNodes[0]->getPath(); } + + /** + * @inheritDoc + */ + public function getTouchedFolders(string $token): array { + return array_map( + fn (ISimpleFile $file) => (int)$file->getName(), + $this->getTokenFolder($token)->getDirectoryListing() + ); + } + + /** + * @inheritDoc + */ + public function clearTouchedFolders(string $token): void { + $this->getTokenFolder($token)->delete(); + } + + // To ease the wrap-up process during unlocking, + // we keep track of every folder for which metadata was updated. + // For that we create a file named /tokens/$token/$folderId. + private function getTokenFolder(string $token): ISimpleFolder { + try { + return $this->appData->getFolder("/tokens/$token"); + } catch (NotFoundException $ex) { + return $this->appData->newFolder("/tokens/$token"); + } + } } diff --git a/lib/RollbackService.php b/lib/RollbackService.php index c13afc6c..2b8472de 100644 --- a/lib/RollbackService.php +++ b/lib/RollbackService.php @@ -80,6 +80,7 @@ public function rollbackOlderThan(int $olderThanTimestamp, ?int $limit = null): foreach ($locks as $lock) { $mountPoints = $this->userMountCache->getMountsForFileId($lock->getId()); if (empty($mountPoints)) { + $this->metaDataStorage->clearTouchedFolders($lock->getToken()); $this->lockMapper->delete($lock); continue; } @@ -99,6 +100,7 @@ public function rollbackOlderThan(int $olderThanTimestamp, ?int $limit = null): } if (strpos($firstMountPoint->getInternalPath(), 'files_trashbin/files/') === 0) { + $this->metaDataStorage->clearTouchedFolders($lock->getToken()); $this->lockMapper->delete($lock); continue; } @@ -126,6 +128,7 @@ public function rollbackOlderThan(int $olderThanTimestamp, ?int $limit = null): continue; } + $this->metaDataStorage->clearTouchedFolders($lock->getToken()); $this->lockMapper->delete($lock); } } diff --git a/tests/Unit/Controller/LockingControllerTest.php b/tests/Unit/Controller/LockingControllerTest.php index d2ea7e22..48b09086 100644 --- a/tests/Unit/Controller/LockingControllerTest.php +++ b/tests/Unit/Controller/LockingControllerTest.php @@ -226,11 +226,16 @@ public function testUnlockFolder(bool $getUserFolderThrows, ->willReturn([]); } else { $node = $this->createMock(Folder::class); - $userFolder->expects($this->once()) + $userFolder->expects($this->exactly(2)) ->method('getById') ->with($fileId) ->willReturn([$node]); + $this->metaDataStorage->expects($this->once()) + ->method('getTouchedFolders') + ->with('e2e-token') + ->willReturn([$fileId]); + $this->fileService->expects($this->once()) ->method('finalizeChanges') ->with($node); diff --git a/tests/Unit/Controller/MetaDataControllerTest.php b/tests/Unit/Controller/MetaDataControllerTest.php index 60566012..37a2b3a3 100644 --- a/tests/Unit/Controller/MetaDataControllerTest.php +++ b/tests/Unit/Controller/MetaDataControllerTest.php @@ -249,12 +249,12 @@ public function testUpdateMetaData(bool $isLocked, if ($metaDataStorageException) { $this->metaDataStorage->expects($this->once()) ->method('updateMetaDataIntoIntermediateFile') - ->with('john.doe', $fileId, $metaData) + ->with('john.doe', $fileId, $metaData, $sendToken) ->willThrowException($metaDataStorageException); } else { $this->metaDataStorage->expects($this->once()) ->method('updateMetaDataIntoIntermediateFile') - ->with('john.doe', $fileId, $metaData); + ->with('john.doe', $fileId, $metaData, $sendToken); } } @@ -310,14 +310,19 @@ public function testDeleteMetaData(?\Exception $metaDataStorageException, if ($metaDataStorageException) { $this->metaDataStorage->expects($this->once()) ->method('updateMetaDataIntoIntermediateFile') - ->with('john.doe', $fileId, '{}') + ->with('john.doe', $fileId, '{}', 'e2e-token') ->willThrowException($metaDataStorageException); } else { $this->metaDataStorage->expects($this->once()) ->method('updateMetaDataIntoIntermediateFile') - ->with('john.doe', $fileId, '{}'); + ->with('john.doe', $fileId, '{}', 'e2e-token'); } + $this->request->expects($this->once()) + ->method('getHeader') + ->with('e2e-token') + ->willReturn('e2e-token'); + $this->l10n->expects($this->any()) ->method('t') ->willReturnCallback(static function ($string, $args) { diff --git a/tests/Unit/LockManagerTest.php b/tests/Unit/LockManagerTest.php index 4a531bf4..1d37012d 100644 --- a/tests/Unit/LockManagerTest.php +++ b/tests/Unit/LockManagerTest.php @@ -28,6 +28,7 @@ use OCA\EndToEndEncryption\Db\LockMapper; use OCA\EndToEndEncryption\Exceptions\FileLockedException; use OCA\EndToEndEncryption\Exceptions\FileNotLockedException; +use OCA\EndToEndEncryption\IMetaDataStorage; use OCA\EndToEndEncryption\LockManager; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Utility\ITimeFactory; @@ -62,6 +63,9 @@ class LockManagerTest extends TestCase { /** @var ITimeFactory|\PHPUnit\Framework\MockObject\MockObject */ private $timeFactory; + /** @var IMetaDataStorage|\PHPUnit\Framework\MockObject\MockObject */ + private $metaDataStorage; + /** @var LockManager */ private $lockManager; @@ -73,9 +77,16 @@ protected function setUp(): void { $this->userSession = $this->createMock(IUserSession::class); $this->rootFolder = $this->createMock(IRootFolder::class); $this->timeFactory = $this->createMock(ITimeFactory::class); - - $this->lockManager = new LockManager($this->lockMapper, $this->secureRandom, - $this->rootFolder, $this->userSession, $this->timeFactory); + $this->metaDataStorage = $this->createMock(IMetaDataStorage::class); + + $this->lockManager = new LockManager( + $this->lockMapper, + $this->secureRandom, + $this->rootFolder, + $this->userSession, + $this->timeFactory, + $this->metaDataStorage, + ); } /** @@ -96,7 +107,8 @@ public function testLock(bool $isLocked, bool $lockDoesNotExist, string $token, $this->secureRandom, $this->rootFolder, $this->userSession, - $this->timeFactory + $this->timeFactory, + $this->metaDataStorage, ]) ->getMock(); diff --git a/tests/Unit/MetaDataStorageTest.php b/tests/Unit/MetaDataStorageTest.php index 2eb3e0fb..6daa2bb0 100644 --- a/tests/Unit/MetaDataStorageTest.php +++ b/tests/Unit/MetaDataStorageTest.php @@ -171,29 +171,29 @@ public function testSetMetaDataIntoIntermediateFile(bool $hasLegacyMetadataFile, ->willReturn(null); $metaDataFolder = $this->createMock(ISimpleFolder::class); + $tokenFolder = $this->createMock(ISimpleFolder::class); if ($folderExists) { - $this->appData->expects($this->once()) + $this->appData->expects($this->exactly($expectsMetaDataExistsException ? 1 : 2)) ->method('getFolder') - ->with('/meta-data/42') - ->willReturn($metaDataFolder); + ->willReturnMap([['/meta-data/42', $metaDataFolder], ['/tokens/e2e-token', $tokenFolder]]); } else { - $this->appData->expects($this->once()) + $this->appData->expects($this->exactly($expectsMetaDataExistsException ? 1 : 2)) ->method('getFolder') - ->with('/meta-data/42') + ->withConsecutive(['/meta-data/42'], ['/tokens/e2e-token']) ->willThrowException(new NotFoundException()); } if ($expectsNewFolder) { - $this->appData->expects($this->once()) + $this->appData->expects($this->exactly($expectsMetaDataExistsException || $folderExists ? 1 : 2)) ->method('newFolder') - ->with('/meta-data/42') - ->willReturn($metaDataFolder); + ->willReturnMap([['/meta-data/42', $metaDataFolder], ['/tokens/e2e-token', $tokenFolder]]); } else { - $this->appData->expects($this->never()) - ->method('newFolder'); + $this->appData->expects($this->exactly($expectsMetaDataExistsException || $folderExists ? 0 : 1)) + ->method('newFolder') + ->with('/tokens/e2e-token') + ->willReturn($tokenFolder); } - if ($fileExists) { $metaDataFolder->expects($this->once()) ->method('fileExists') @@ -229,7 +229,7 @@ public function testSetMetaDataIntoIntermediateFile(bool $hasLegacyMetadataFile, ->willReturn($node); } - $metaDataStorage->setMetaDataIntoIntermediateFile('userId', 42, 'metadata-file-content'); + $metaDataStorage->setMetaDataIntoIntermediateFile('userId', 42, 'metadata-file-content', 'e2e-token'); } public function setMetaDataIntoIntermediateFileDataProvider(): array { @@ -284,11 +284,11 @@ public function testUpdateMetaDataIntoIntermediateFile(bool $hasLegacyMetadataFi } $metaDataFolder = $this->createMock(ISimpleFolder::class); + $tokenFolder = $this->createMock(ISimpleFolder::class); if ($folderExists) { - $this->appData->expects($this->once()) + $this->appData->expects($this->exactly($expectMissingMetaDataException ? 1 : 2)) ->method('getFolder') - ->with('/meta-data/42') - ->willReturn($metaDataFolder); + ->willReturnMap([['/meta-data/42', $metaDataFolder], ['/tokens/e2e-token', $tokenFolder]]); if (!$hasLegacyMetadataFile) { $metaDataFolder->expects($this->once()) @@ -297,16 +297,15 @@ public function testUpdateMetaDataIntoIntermediateFile(bool $hasLegacyMetadataFi ->willReturn($fileExists); } } else { - $this->appData->expects($this->once()) + $this->appData->expects($this->exactly($expectMissingMetaDataException ? 1 : 2)) ->method('getFolder') - ->with('/meta-data/42') + ->withConsecutive(['/meta-data/42'], ['/tokens/e2e-token']) ->willThrowException(new NotFoundException()); if ($hasLegacyMetadataFile) { - $this->appData->expects($this->once()) + $this->appData->expects($this->exactly($expectMissingMetaDataException ? 1 : 2)) ->method('newFolder') - ->with('/meta-data/42') - ->willReturn($metaDataFolder); + ->willReturnMap([['/meta-data/42', $metaDataFolder], ['/tokens/e2e-token', $tokenFolder]]); } } @@ -315,6 +314,7 @@ public function testUpdateMetaDataIntoIntermediateFile(bool $hasLegacyMetadataFi $this->expectExceptionMessage('Meta-data file missing'); } else { $intermediateFile = $this->createMock(ISimpleFile::class); + $tokenFile = $this->createMock(ISimpleFile::class); $intermediateFile->expects($this->once()) ->method('putContent') ->with('metadata-file-content'); @@ -335,9 +335,14 @@ public function testUpdateMetaDataIntoIntermediateFile(bool $hasLegacyMetadataFi ->with('intermediate.meta.data') ->willReturn($intermediateFile); } + + $tokenFolder->expects($this->once()) + ->method('newFile') + ->with('42', '') + ->willReturn($tokenFile); } - $metaDataStorage->updateMetaDataIntoIntermediateFile('userId', 42, 'metadata-file-content'); + $metaDataStorage->updateMetaDataIntoIntermediateFile('userId', 42, 'metadata-file-content', 'e2e-token'); } public function updateMetaDataIntoIntermediateFileDataProvider(): array { diff --git a/tests/Unit/RollbackServiceTest.php b/tests/Unit/RollbackServiceTest.php index 3739a7c6..3274b9a8 100644 --- a/tests/Unit/RollbackServiceTest.php +++ b/tests/Unit/RollbackServiceTest.php @@ -208,24 +208,31 @@ public function testRollbackOlderThan(): void { private function getSampleLocks(): array { $lock1 = new Lock(); $lock1->setId(100001); + $lock1->setToken('lock-token-100001'); $lock2 = new Lock(); $lock2->setId(100002); + $lock2->setToken('lock-token-100002'); $lock3 = new Lock(); $lock3->setId(100003); + $lock3->setToken('lock-token-100003'); $lock4 = new Lock(); $lock4->setId(100004); + $lock4->setToken('lock-token-100004'); $lock5 = new Lock(); $lock5->setId(100005); + $lock5->setToken('lock-token-100005'); $lock6 = new Lock(); $lock6->setId(100006); + $lock6->setToken('lock-token-100006'); $lock7 = new Lock(); $lock7->setId(100007); + $lock7->setToken('lock-token-100007'); return [ $lock1, From cfa1c2595a52ec28b82ecb794d4748b75e7cdc51 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 23 May 2023 17:56:22 +0200 Subject: [PATCH 03/36] Use header to provide e2e-token Signed-off-by: Louis Chemineau Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- lib/Controller/MetaDataController.php | 4 ++-- src/services/filedrop.js | 2 +- tests/Unit/Controller/MetaDataControllerTest.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/Controller/MetaDataController.php b/lib/Controller/MetaDataController.php index cd6bf4f6..1beb5fad 100644 --- a/lib/Controller/MetaDataController.php +++ b/lib/Controller/MetaDataController.php @@ -130,7 +130,7 @@ public function setMetaData(int $id, string $metaData): DataResponse { * @throws OCSNotFoundException */ public function updateMetaData(int $id, string $metaData): DataResponse { - $e2eToken = $this->request->getParam('e2e-token'); + $e2eToken = $this->request->getHeader('e2e-token'); if ($this->lockManager->isLocked($id, $e2eToken)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); @@ -190,7 +190,7 @@ public function deleteMetaData(int $id): DataResponse { * @throws OCSNotFoundException */ public function addMetadataFileDrop(int $id, string $fileDrop, ?string $shareToken = null): DataResponse { - $e2eToken = $this->request->getParam('e2e-token'); + $e2eToken = $this->request->getHeader('e2e-token'); $ownerId = $this->getOwnerId($shareToken); if ($this->lockManager->isLocked($id, $e2eToken, $ownerId)) { diff --git a/src/services/filedrop.js b/src/services/filedrop.js index d5f4f99c..69b45f88 100644 --- a/src/services/filedrop.js +++ b/src/services/filedrop.js @@ -103,9 +103,9 @@ export async function uploadFileDrop(folderId, fileDrop, lockToken, shareToken) { headers: { 'x-e2ee-supported': true, + 'e2e-token': lockToken, }, params: { - 'e2e-token': lockToken, shareToken, }, }, diff --git a/tests/Unit/Controller/MetaDataControllerTest.php b/tests/Unit/Controller/MetaDataControllerTest.php index 37a2b3a3..ab473cfb 100644 --- a/tests/Unit/Controller/MetaDataControllerTest.php +++ b/tests/Unit/Controller/MetaDataControllerTest.php @@ -236,7 +236,7 @@ public function testUpdateMetaData(bool $isLocked, $sendToken = 'sendE2EToken'; $metaData = 'JSON-ENCODED-META-DATA'; $this->request->expects($this->once()) - ->method('getParam') + ->method('getHeader') ->with('e2e-token') ->willReturn($sendToken); From 0136ba473e190cef43744ae1009bd537f5f684c3 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 23 May 2023 18:09:56 +0200 Subject: [PATCH 04/36] Use major.minor for api-versions in capabilities Signed-off-by: Louis Chemineau Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- lib/Capabilities.php | 2 +- tests/Unit/CapabilitiesTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Capabilities.php b/lib/Capabilities.php index 73ea702b..f33caa53 100644 --- a/lib/Capabilities.php +++ b/lib/Capabilities.php @@ -51,7 +51,7 @@ public function getCapabilities(): array { $capabilities = ['end-to-end-encryption' => [ 'enabled' => true, - 'api-version' => '1.2', + 'api-version' => '2.0', 'keys-exist' => $keysExist, ] ]; diff --git a/tests/Unit/CapabilitiesTest.php b/tests/Unit/CapabilitiesTest.php index 8e494bff..8405cafe 100644 --- a/tests/Unit/CapabilitiesTest.php +++ b/tests/Unit/CapabilitiesTest.php @@ -75,7 +75,7 @@ public function testGetCapabilities(): void { $this->assertEquals([ 'end-to-end-encryption' => [ 'enabled' => true, - 'api-version' => '1.2', + 'api-version' => '2.0', 'keys-exist' => true ] ], $this->capabilities->getCapabilities()); From 3e650b8a74c8287d34079c0c4930a7bf80556251 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 23 May 2023 18:21:32 +0200 Subject: [PATCH 05/36] Run psalm:fix Signed-off-by: Louis Chemineau Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- lib/Connector/Sabre/RedirectRequestPlugin.php | 1 - lib/E2EEnabledPathCache.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/Connector/Sabre/RedirectRequestPlugin.php b/lib/Connector/Sabre/RedirectRequestPlugin.php index db9a8d52..7624a726 100644 --- a/lib/Connector/Sabre/RedirectRequestPlugin.php +++ b/lib/Connector/Sabre/RedirectRequestPlugin.php @@ -141,7 +141,6 @@ public function httpDelete(RequestInterface $request, ResponseInterface $respons /** * @param RequestInterface $request - * @return bool */ public function httpMkColPut(RequestInterface $request): void { $node = $this->getNode($request->getPath(), $request->getMethod()); diff --git a/lib/E2EEnabledPathCache.php b/lib/E2EEnabledPathCache.php index 11fda4cf..6583349d 100644 --- a/lib/E2EEnabledPathCache.php +++ b/lib/E2EEnabledPathCache.php @@ -59,7 +59,7 @@ public function isE2EEnabledPath(Node $node): bool { /** * Get the encryption state for the path */ - protected function getEncryptedStates(ICache $cache, $node, IStorage $storage): bool { + protected function getEncryptedStates(ICache $cache, Node $node, IStorage $storage): bool { if (!$storage->instanceOfStorage(IHomeStorage::class)) { return false; } From 40fcf73ad64433c6be5baba2a921ebc30fce7a46 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 23 May 2023 20:31:56 +0200 Subject: [PATCH 06/36] Update API doc Signed-off-by: Louis Chemineau Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- doc/api.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/doc/api.md b/doc/api.md index 50d777a0..18b56455 100644 --- a/doc/api.md +++ b/doc/api.md @@ -419,7 +419,13 @@ metaData: content of the encrypted meta-data file **Example curl call:** -`curl -X POST https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/meta-data/ -H "OCS-APIRequest:true"` -d metaData="" +```bash +curl "https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/meta-data/" \ + -X POST \ + -H "OCS-APIRequest:true" \ + -H "e2e-token:" \ + -d metaData="" +``` ## Get meta-data file @@ -491,7 +497,13 @@ the file with the given file-id **Example curl call:** -`curl -X PUT https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/meta-data/ -H "OCS-APIRequest:true"` -d "metaData=&e2e-token=" +```bash +curl "https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/meta-data/" \ + -X PUT \ + -H "OCS-APIRequest:true" \ + -H "e2e-token:" \ + -d metaData="" +``` ## Update filedrop property of meta-data file @@ -564,8 +576,12 @@ DELETE: `/meta-data/` **Example curl call:** -`curl -X DELETE https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/meta-data/ -H "OCS-APIRequest:true"` - +```bash +curl "https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/meta-data/" \ + -X DELETE \ + -H "OCS-APIRequest:true" \ + -H "e2e-token:" +``` ## Get server public key From 6809689a41fc74fe20d7f4ecc13fd9308bc8035b Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 24 May 2023 17:12:14 +0200 Subject: [PATCH 07/36] Prevent access to set and delete endpoints without valid lock token Signed-off-by: Louis Chemineau Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- lib/Controller/MetaDataController.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/Controller/MetaDataController.php b/lib/Controller/MetaDataController.php index 1beb5fad..ba86d7f3 100644 --- a/lib/Controller/MetaDataController.php +++ b/lib/Controller/MetaDataController.php @@ -106,6 +106,10 @@ public function getMetaData(int $id, ?string $shareToken = null): DataResponse { public function setMetaData(int $id, string $metaData): DataResponse { $e2eToken = $this->request->getHeader('e2e-token'); + if ($this->lockManager->isLocked($id, $e2eToken)) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); + } + try { $this->metaDataStorage->setMetaDataIntoIntermediateFile($this->userId, $id, $metaData, $e2eToken); } catch (MetaDataExistsException $e) { @@ -165,6 +169,10 @@ public function updateMetaData(int $id, string $metaData): DataResponse { public function deleteMetaData(int $id): DataResponse { $e2eToken = $this->request->getHeader('e2e-token'); + if ($this->lockManager->isLocked($id, $e2eToken)) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); + } + try { $this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, '{}', $e2eToken); } catch (NotFoundException $e) { From 9184afe05a16fb28a7795ac573e34bbbf64e95fb Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Thu, 1 Jun 2023 12:00:05 +0200 Subject: [PATCH 08/36] Allow to abort changes during unlock Signed-off-by: Louis Chemineau Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- doc/api.md | 65 ++++++++++++------- lib/Controller/LockingController.php | 11 +++- .../Unit/Controller/LockingControllerTest.php | 45 +++++++++---- 3 files changed, 80 insertions(+), 41 deletions(-) diff --git a/doc/api.md b/doc/api.md index 18b56455..d2662ee5 100644 --- a/doc/api.md +++ b/doc/api.md @@ -2,22 +2,24 @@ This are the available OCS API calls for clients to implement end-to-end encryption. A more general documentation how to use the API can be found [here](https://github.com/nextcloud/end_to_end_encryption/blob/master/doc/api-usage.md). -* [List files and folders with encryption status](#list-files-and-folders-with-encryption-status) -* [Store private key](#store-private-key) -* [Get private key](#get-private-key) -* [Delete private key](#delete-private-key) -* [Sign public key](#sign-public-key) -* [Get public keys](#get-public-keys) -* [Delete public keys](#delete-public-keys) -* [Lock file](#lock-file) -* [Unlock file](#unlock-file) -* [Store-meta-data file](#store-meta-data-file) -* [Get meta-data file](#get-meta-data-file) -* [Update meta-data file](#update-meta-data-file) -* [Delete meta-data file](#delete-meta-data-file) -* [Get server public key](#get-server-public-key) -* [Set encryption flag for a folder](#set-encryption-flag-for-a-folder) -* [Remove encryption flag for a folder](#remove-encryption-flag-for-a-folder) +- [End-to-End Encryption API](#end-to-end-encryption-api) +- [Base URL for all API calls](#base-url-for-all-api-calls) + - [List files and folders with encryption status](#list-files-and-folders-with-encryption-status) + - [Store private key](#store-private-key) + - [Get private key](#get-private-key) + - [Delete private key](#delete-private-key) + - [Sign public key](#sign-public-key) + - [Get public keys](#get-public-keys) + - [Delete public keys](#delete-public-keys) + - [Lock file](#lock-file) + - [Unlock file](#unlock-file) + - [Get meta-data file](#get-meta-data-file) + - [Update meta-data file](#update-meta-data-file) + - [Update filedrop property of meta-data file](#update-filedrop-property-of-meta-data-file) + - [Delete meta-data file](#delete-meta-data-file) + - [Get server public key](#get-server-public-key) + - [Set encryption flag for a folder](#set-encryption-flag-for-a-folder) + - [Remove encryption flag for a folder](#remove-encryption-flag-for-a-folder) @@ -31,7 +33,7 @@ PROPFIND: `https:///remote.php/webdav//` **Data:** -xml body: +xml body: ````xml @@ -376,12 +378,25 @@ DELETE: `/lock/` ```` - **Example curl call:** -First try: +Unlock: + +````shell +curl https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/lock/10 \ + -X DELETE \ + -H "OCS-APIRequest:true" \ + -H "e2e-token: +``` + +Unlock and drop pending changes: -`curl -X DELETE https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/lock/10 -H "OCS-APIRequest:true" -H "e2e-token:` +````shell +curl https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/lock/10?abort=true \ + -X DELETE \ + -H "OCS-APIRequest:true" \ + -H "e2e-token: +``` ## Store meta-data file @@ -472,8 +487,8 @@ e2e-token: token to authenticate that you are the client who currently manipulat 200 ok: meta data successfully updated -404 not found: if the meta-data file doesn't exist or if the user can't access -the file with the given file-id +404 not found: if the meta-data file doesn't exist or if the user can't access +the file with the given file-id 403 forbidden: if the file was not locked or the client sends the wrong e2e-token @@ -518,8 +533,8 @@ e2e-token: token to authenticate that you are the client who currently manipulat 200 ok: filedrop successfully updated -404 not found: if the meta-data file doesn't exist or if the user can't access -the file with the given file-id +404 not found: if the meta-data file doesn't exist or if the user can't access +the file with the given file-id 403 forbidden: if the file was not locked or the client sends the wrong e2e-token @@ -585,7 +600,7 @@ curl "https://:@/ocs/v2.php/apps/end_to_end_encryptio ## Get server public key -This is the key, used to sign the users public keys. By retrieving the server's +This is the key, used to sign the users public keys. By retrieving the server's public key the clients can check the signature of the users public keys. GET: `/server-key` diff --git a/lib/Controller/LockingController.php b/lib/Controller/LockingController.php index 3696b8c8..c227ec6e 100644 --- a/lib/Controller/LockingController.php +++ b/lib/Controller/LockingController.php @@ -136,6 +136,7 @@ public function lockFolder(int $id, ?string $shareToken = null): DataResponse { * @throws OCSNotFoundException */ public function unlockFolder(int $id, ?string $shareToken = null): DataResponse { + $abort = $this->request->getParam('abort') === 'true'; $token = $this->request->getHeader('e2e-token'); $ownerId = $this->getOwnerId($shareToken); @@ -153,9 +154,13 @@ public function unlockFolder(int $id, ?string $shareToken = null): DataResponse $touchFoldersIds = $this->metaDataStorage->getTouchedFolders($token); foreach ($touchFoldersIds as $folderId) { - $this->fileService->finalizeChanges($userFolder->getById($folderId)[0]); - - $this->metaDataStorage->saveIntermediateFile($ownerId, $folderId); + if ($abort) { + $this->fileService->revertChanges($userFolder->getById($folderId)[0]); + $this->metaDataStorage->deleteIntermediateFile($ownerId, $folderId); + } else { + $this->fileService->finalizeChanges($userFolder->getById($folderId)[0]); + $this->metaDataStorage->saveIntermediateFile($ownerId, $folderId); + } } $this->metaDataStorage->clearTouchedFolders($token); diff --git a/tests/Unit/Controller/LockingControllerTest.php b/tests/Unit/Controller/LockingControllerTest.php index 48b09086..ab7283f9 100644 --- a/tests/Unit/Controller/LockingControllerTest.php +++ b/tests/Unit/Controller/LockingControllerTest.php @@ -182,17 +182,21 @@ public function testLockFolderException(): void { /** * @param bool $getUserFolderThrows * @param bool $userFolderReturnsNodes + * @param bool $abort * @param \Exception|null $unlockException * @param string|null $expectedExceptionClass * @param string|null $expectedExceptionMessage * * @dataProvider unlockFolderDataProvider */ - public function testUnlockFolder(bool $getUserFolderThrows, + public function testUnlockFolder( + bool $getUserFolderThrows, bool $userFolderReturnsNodes, + bool $abort, ?\Exception $unlockException, ?string $expectedExceptionClass, - ?string $expectedExceptionMessage): void { + ?string $expectedExceptionMessage, + ): void { $fileId = 42; $sendE2E = 'e2e-token'; @@ -207,6 +211,11 @@ public function testUnlockFolder(bool $getUserFolderThrows, ->with('e2e-token') ->willReturn($sendE2E); + $this->request->expects($this->once()) + ->method('getParam') + ->with('abort') + ->willReturn($abort ? 'true' : ''); + if ($getUserFolderThrows) { $this->rootFolder->expects($this->once()) ->method('getUserFolder') @@ -236,12 +245,21 @@ public function testUnlockFolder(bool $getUserFolderThrows, ->with('e2e-token') ->willReturn([$fileId]); - $this->fileService->expects($this->once()) - ->method('finalizeChanges') - ->with($node); - $this->metaDataStorage->expects($this->once()) - ->method('saveIntermediateFile') - ->with('john.doe', $fileId); + if ($abort) { + $this->fileService->expects($this->once()) + ->method('revertChanges') + ->with($node); + $this->metaDataStorage->expects($this->once()) + ->method('deleteIntermediateFile') + ->with('john.doe', $fileId); + } else { + $this->fileService->expects($this->once()) + ->method('finalizeChanges') + ->with($node); + $this->metaDataStorage->expects($this->once()) + ->method('saveIntermediateFile') + ->with('john.doe', $fileId); + } if ($unlockException) { $this->lockManager->expects($this->once()) @@ -270,11 +288,12 @@ public function testUnlockFolder(bool $getUserFolderThrows, public function unlockFolderDataProvider(): array { return [ - [false, true, null, null, null], - [true, false, null, OCSForbiddenException::class, 'You are not allowed to remove the lock'], - [false, false, null, OCSForbiddenException::class, 'You are not allowed to remove the lock'], - [false, true, new FileLockedException(), OCSForbiddenException::class, 'You are not allowed to remove the lock'], - [false, true, new FileNotLockedException(), OCSNotFoundException::class, 'File not locked'] + [false, true, false, null, null, null], + [false, true, true, null, null, null], + [true, false, false, null, OCSForbiddenException::class, 'You are not allowed to remove the lock'], + [false, false, false, null, OCSForbiddenException::class, 'You are not allowed to remove the lock'], + [false, true, false, new FileLockedException(), OCSForbiddenException::class, 'You are not allowed to remove the lock'], + [false, true, false, new FileNotLockedException(), OCSNotFoundException::class, 'File not locked'] ]; } } From 1832289a41465c1810126d86a2d15a1fd52a1b4a Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Thu, 1 Jun 2023 16:30:55 +0200 Subject: [PATCH 09/36] Add support for X-NC-E2EE-COUNTER Signed-off-by: Louis Chemineau Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- lib/Controller/LockingController.php | 3 +- lib/IMetaDataStorage.php | 10 +++ lib/LockManager.php | 13 +++- lib/MetaDataStorage.php | 70 +++++++++++++++++-- .../Unit/Controller/LockingControllerTest.php | 8 +++ tests/Unit/LockManagerTest.php | 56 +++++++++------ 6 files changed, 132 insertions(+), 28 deletions(-) diff --git a/lib/Controller/LockingController.php b/lib/Controller/LockingController.php index c227ec6e..12830266 100644 --- a/lib/Controller/LockingController.php +++ b/lib/Controller/LockingController.php @@ -94,6 +94,7 @@ public function __construct( */ public function lockFolder(int $id, ?string $shareToken = null): DataResponse { $e2eToken = $this->request->getParam('e2e-token', ''); + $e2eCounter = (int)$this->request->getHeader('X-NC-E2EE-COUNTER'); $ownerId = $this->getOwnerId($shareToken); @@ -114,7 +115,7 @@ public function lockFolder(int $id, ?string $shareToken = null): DataResponse { throw new OCSForbiddenException($this->l10n->t('You are not allowed to create the lock')); } - $newToken = $this->lockManager->lockFile($id, $e2eToken, $ownerId); + $newToken = $this->lockManager->lockFile($id, $e2eToken, $e2eCounter, $ownerId); if ($newToken === null) { throw new OCSForbiddenException($this->l10n->t('File already locked')); } diff --git a/lib/IMetaDataStorage.php b/lib/IMetaDataStorage.php index 5dc1e15a..13711b39 100644 --- a/lib/IMetaDataStorage.php +++ b/lib/IMetaDataStorage.php @@ -102,4 +102,14 @@ public function getTouchedFolders(string $token): array; * @throws NotFoundException */ public function clearTouchedFolders(string $token): void; + + /** + * Get the latest received counter. + */ + public function getCounter(int $id): int; + + /** + * Save the latest received counter in an intermediate file. + */ + public function saveIntermediateCounter(int $id, int $counter): void; } diff --git a/lib/LockManager.php b/lib/LockManager.php index b6f5b3d5..e1dfa069 100644 --- a/lib/LockManager.php +++ b/lib/LockManager.php @@ -67,7 +67,7 @@ public function __construct( /** * Lock file */ - public function lockFile(int $id, string $token = '', ?string $ownerId = null): ?string { + public function lockFile(int $id, string $token, int $e2eCounter, string $ownerId): ?string { if ($this->isLocked($id, $token, $ownerId)) { return null; } @@ -76,6 +76,17 @@ public function lockFile(int $id, string $token = '', ?string $ownerId = null): $lock = $this->lockMapper->getByFileId($id); return $lock->getToken() === $token ? $token : null; } catch (DoesNotExistException $ex) { + try { + $storedCounter = $this->metaDataStorage->getCounter($id); + if ($storedCounter >= $e2eCounter) { + throw new NotPermittedException('Received counter is not greater than the stored one'); + } else { + $this->metaDataStorage->saveIntermediateCounter($id, $e2eCounter); + } + } catch (NotFoundException $e) { + // Do not check counter if the metadata do not exists yet. + } + $newToken = $this->getToken(); $lockEntity = new Lock(); $lockEntity->setId($id); diff --git a/lib/MetaDataStorage.php b/lib/MetaDataStorage.php index 9c8362c2..3c92bff5 100644 --- a/lib/MetaDataStorage.php +++ b/lib/MetaDataStorage.php @@ -45,6 +45,10 @@ class MetaDataStorage implements IMetaDataStorage { private string $metaDataRoot = '/meta-data'; private string $metaDataFileName = 'meta.data'; private string $intermediateMetaDataFileName = 'intermediate.meta.data'; + private string $metaDataSignatureFileName = 'meta.data.signature'; + private string $intermediateMetaDataSignatureFileName = 'intermediate.meta.data.signature'; + private string $metaDataCounterFileName = 'meta.data.counter'; + private string $intermediateMetaDataCounterFileName = 'intermediate.meta.data.counter'; public function __construct(IAppData $appData, IRootFolder $rootFolder) { @@ -140,6 +144,11 @@ public function updateMetaDataIntoIntermediateFile(string $userId, int $id, stri $intermediateMetaDataFile ->putContent($fileKey); + // Signature can be empty when deleting the metadata, or during filedrop upload. + if ($signature !== '') { + $this->writeSignature($dir, $this->intermediateMetaDataSignatureFileName, $signature); + } + $this->getTokenFolder($token)->newFile("$id", ''); } @@ -193,6 +202,14 @@ public function saveIntermediateFile(string $userId, int $id): void { $finalFile->putContent($intermediateMetaDataFile->getContent()); // After successfully saving, automatically delete the intermediate file $intermediateMetaDataFile->delete(); + + if ($dir->fileExists($this->intermediateMetaDataSignatureFileName)) { + $intermediateMetaDataSignature = $dir->getFile($this->intermediateMetaDataSignatureFileName); + $this->writeSignature($dir, $this->metaDataSignatureFileName, $intermediateMetaDataSignature->getContent()); + $intermediateMetaDataSignature->delete(); + } + + $this->saveCounter($id); } $this->cleanupLegacyFile($userId, $id); @@ -212,12 +229,15 @@ public function deleteIntermediateFile(string $userId, int $id): void { return; } - if (!$dir->fileExists($this->intermediateMetaDataFileName)) { - return; + if ($dir->fileExists($this->intermediateMetaDataFileName)) { + $dir->getFile($this->intermediateMetaDataFileName) + ->delete(); } - $dir->getFile($this->intermediateMetaDataFileName) - ->delete(); + if ($dir->fileExists($this->intermediateMetaDataCounterFileName)) { + $dir->getFile($this->intermediateMetaDataCounterFileName) + ->delete(); + } } private function getFolderNameForFileId(int $id): string { @@ -341,4 +361,46 @@ private function getTokenFolder(string $token): ISimpleFolder { return $this->appData->newFolder("/tokens/$token"); } } + + /** + * @inheritDoc + */ + public function getCounter(int $id): int { + try { + $metadataFolder = $this->appData->getFolder($this->getFolderNameForFileId($id)); + $counterFile = $metadataFolder->getFile($this->metaDataCounterFileName); + return (int)$counterFile->getContent(); + } catch (NotFoundException $ex) { + return 0; + } + } + + /** + * @inheritDoc + */ + public function saveIntermediateCounter(int $id, int $counter): void { + $metadataFolder = $this->appData->getFolder($this->getFolderNameForFileId($id)); + $metadataFolder->newFile($this->intermediateMetaDataCounterFileName)->putContent($counter); + } + + /** + * Save the latest received counter from the intermediate file. + */ + private function saveCounter(int $id): void { + $metadataFolder = $this->appData->getFolder($this->getFolderNameForFileId($id)); + if (!$metadataFolder->fileExists($this->intermediateMetaDataCounterFileName)) { + return; + } + + $intermediateCounterFile = $metadataFolder->getFile($this->intermediateMetaDataCounterFileName); + + try { + $counterFile = $metadataFolder->getFile($this->metaDataCounterFileName); + } catch (NotFoundException $ex) { + $counterFile = $metadataFolder->newFile($this->metaDataCounterFileName); + } + + $counterFile->putContent($intermediateCounterFile->getContent()); + $intermediateCounterFile->delete(); + } } diff --git a/tests/Unit/Controller/LockingControllerTest.php b/tests/Unit/Controller/LockingControllerTest.php index ab7283f9..826f4b42 100644 --- a/tests/Unit/Controller/LockingControllerTest.php +++ b/tests/Unit/Controller/LockingControllerTest.php @@ -135,6 +135,10 @@ public function testLockFolder(): void { ->method('lockFile') ->with($fileId, $sendE2E) ->willReturn('new-token'); + $this->request->expects($this->once()) + ->method('getHeader') + ->with('X-NC-E2EE-COUNTER') + ->willReturn('1'); $response = $this->controller->lockFolder($fileId); $this->assertInstanceOf(DataResponse::class, $response); @@ -172,6 +176,10 @@ public function testLockFolderException(): void { ->willReturnCallback(static function ($string, $args) { return vsprintf($string, $args); }); + $this->request->expects($this->once()) + ->method('getHeader') + ->with('X-NC-E2EE-COUNTER') + ->willReturn('1'); $this->expectException(OCSForbiddenException::class); $this->expectExceptionMessage('File already locked'); diff --git a/tests/Unit/LockManagerTest.php b/tests/Unit/LockManagerTest.php index 1d37012d..53a6e37a 100644 --- a/tests/Unit/LockManagerTest.php +++ b/tests/Unit/LockManagerTest.php @@ -95,11 +95,12 @@ protected function setUp(): void { * @param bool $isLocked * @param bool $lockDoesNotExist * @param string $token + * @param int $counter * @param bool $expectNull * @param bool $expectNewToken * @param bool $expectOldToken */ - public function testLock(bool $isLocked, bool $lockDoesNotExist, string $token, bool $expectNull, bool $expectNewToken, bool $expectOldToken): void { + public function testLock(bool $isLocked, bool $lockDoesNotExist, int $counter, string $token, bool $expectNull, bool $expectNewToken, bool $expectOldToken): void { $lockManager = $this->getMockBuilder(LockManager::class) ->setMethods(['isLocked']) ->setConstructorArgs([ @@ -114,7 +115,7 @@ public function testLock(bool $isLocked, bool $lockDoesNotExist, string $token, $lockManager->expects($this->once()) ->method('isLocked') - ->with(42, $token) + ->with(42, $token, 'userId') ->willReturn($isLocked); if (!$isLocked) { @@ -135,23 +136,33 @@ public function testLock(bool $isLocked, bool $lockDoesNotExist, string $token, } if ($expectNewToken) { - $this->secureRandom->expects($this->once()) - ->method('generate') - ->with(64, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS) - ->willReturn('new-token'); + $this->metaDataStorage->expects($this->once()) + ->method('getMetaData') + ->with('userId', 42) + ->willReturn('{"counter": 0}'); - $this->timeFactory->expects($this->once()) - ->method('getTime') - ->willReturn(1337); + if ($counter > 0) { + $this->secureRandom->expects($this->once()) + ->method('generate') + ->with(64, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS) + ->willReturn('new-token'); - $this->lockMapper->expects($this->once()) - ->method('insert') - ->with($this->callback(static function ($lock) { - return ($lock instanceof Lock && - $lock->getId() === 42 && - $lock->getTimestamp() === 1337 && - $lock->getToken() === 'new-token'); - })); + $this->timeFactory->expects($this->once()) + ->method('getTime') + ->willReturn(1337); + + $this->lockMapper->expects($this->once()) + ->method('insert') + ->with($this->callback(static function ($lock) { + return ($lock instanceof Lock && + $lock->getId() === 42 && + $lock->getTimestamp() === 1337 && + $lock->getToken() === 'new-token'); + })); + } else { + $this->expectException(NotPermittedException::class); + $this->expectExceptionMessage('Received counter is not greater than the stored one'); + } } else { $this->secureRandom->expects($this->never()) ->method('generate'); @@ -159,7 +170,7 @@ public function testLock(bool $isLocked, bool $lockDoesNotExist, string $token, ->method('getTime'); } - $actual = $lockManager->lockFile(42, $token); + $actual = $lockManager->lockFile(42, $token, $counter, 'userId'); if ($expectNull) { $this->assertNull($actual); @@ -174,10 +185,11 @@ public function testLock(bool $isLocked, bool $lockDoesNotExist, string $token, public function lockDataProvider(): array { return [ - [true, false, 'correct-token123', true, false, false], - [false, true, 'correct-token123', false, true, false], - [false, false, 'correct-token123', false, false, true], - [false, false, 'wrong-token456', true, false, false], + [true, false, 1, 'correct-token123', true, false, false], + [false, true, 1, 'correct-token123', false, true, false], + [false, true, 0, 'correct-token123', false, true, false], + [false, false, 1, 'correct-token123', false, false, true], + [false, false, 1, 'wrong-token456', true, false, false], ]; } From c11386622eb6e390083b4cef5fb9bfee5b8d084c Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Mon, 3 Jul 2023 13:15:35 +0200 Subject: [PATCH 10/36] Store signature on metadata save And send it back in a header on request. Signed-off-by: Louis Chemineau Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- lib/Controller/MetaDataController.php | 14 ++-- lib/IMetaDataStorage.php | 12 +++- lib/MetaDataStorage.php | 26 ++++++- .../Controller/MetaDataControllerTest.php | 19 +++-- tests/Unit/MetaDataStorageTest.php | 70 +++++++++++++------ 5 files changed, 103 insertions(+), 38 deletions(-) diff --git a/lib/Controller/MetaDataController.php b/lib/Controller/MetaDataController.php index ba86d7f3..3c028e33 100644 --- a/lib/Controller/MetaDataController.php +++ b/lib/Controller/MetaDataController.php @@ -92,7 +92,11 @@ public function getMetaData(int $id, ?string $shareToken = null): DataResponse { $this->logger->critical($e->getMessage(), ['exception' => $e, 'app' => $this->appName]); throw new OCSBadRequestException($this->l10n->t('Cannot read metadata')); } - return new DataResponse(['meta-data' => $metaData]); + return new DataResponse( + ['meta-data' => $metaData], + Http::STATUS_OK, + ['X-NC-E2EE-SIGNATURE' => $this->metaDataStorage->readSignature($id)], + ); } /** @@ -105,13 +109,14 @@ public function getMetaData(int $id, ?string $shareToken = null): DataResponse { */ public function setMetaData(int $id, string $metaData): DataResponse { $e2eToken = $this->request->getHeader('e2e-token'); + $signature = $this->request->getHeader('X-NC-E2EE-SIGNATURE'); if ($this->lockManager->isLocked($id, $e2eToken)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } try { - $this->metaDataStorage->setMetaDataIntoIntermediateFile($this->userId, $id, $metaData, $e2eToken); + $this->metaDataStorage->setMetaDataIntoIntermediateFile($this->userId, $id, $metaData, $e2eToken, $signature); } catch (MetaDataExistsException $e) { return new DataResponse([], Http::STATUS_CONFLICT); } catch (NotFoundException $e) { @@ -135,13 +140,14 @@ public function setMetaData(int $id, string $metaData): DataResponse { */ public function updateMetaData(int $id, string $metaData): DataResponse { $e2eToken = $this->request->getHeader('e2e-token'); + $signature = $this->request->getHeader('X-NC-E2EE-SIGNATURE'); if ($this->lockManager->isLocked($id, $e2eToken)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } try { - $this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, $metaData, $e2eToken); + $this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, $metaData, $e2eToken, $signature); } catch (MissingMetaDataException $e) { throw new OCSNotFoundException($this->l10n->t('Metadata-file does not exist')); } catch (NotFoundException $e) { @@ -174,7 +180,7 @@ public function deleteMetaData(int $id): DataResponse { } try { - $this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, '{}', $e2eToken); + $this->metaDataStorage->updateMetaDataIntoIntermediateFile($this->userId, $id, '{}', $e2eToken, ''); } catch (NotFoundException $e) { throw new OCSNotFoundException($this->l10n->t('Could not find metadata for "%s"', [$id])); } catch (NotPermittedException $e) { diff --git a/lib/IMetaDataStorage.php b/lib/IMetaDataStorage.php index 13711b39..21ac2d7c 100644 --- a/lib/IMetaDataStorage.php +++ b/lib/IMetaDataStorage.php @@ -49,7 +49,7 @@ public function getMetaData(string $userId, int $id): string; * @throws NotFoundException * @throws MetaDataExistsException */ - public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $metaData, string $token): void; + public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $metaData, string $token, string $signature): void; /** * Update meta data file into intermediate file @@ -58,7 +58,7 @@ public function setMetaDataIntoIntermediateFile(string $userId, int $id, string * @throws NotFoundException * @throws MissingMetaDataException */ - public function updateMetaDataIntoIntermediateFile(string $userId, int $id, string $fileKey, string $token): void; + public function updateMetaDataIntoIntermediateFile(string $userId, int $id, string $fileKey, string $token, string $signature = ''): void; /** * Moves intermediate metadata file to final file @@ -69,6 +69,14 @@ public function updateMetaDataIntoIntermediateFile(string $userId, int $id, stri */ public function saveIntermediateFile(string $userId, int $id): void; + /** + * Get the stored signature + * + * @throws NotPermittedException + * @throws NotFoundException + */ + public function readSignature(int $id): string; + /** * Delete the previously set intermediate file * diff --git a/lib/MetaDataStorage.php b/lib/MetaDataStorage.php index 3c92bff5..18e9f0ea 100644 --- a/lib/MetaDataStorage.php +++ b/lib/MetaDataStorage.php @@ -79,7 +79,7 @@ public function getMetaData(string $userId, int $id): string { /** * @inheritDoc */ - public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $metaData, string $token): void { + public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $metaData, string $token, string $signature): void { $this->verifyFolderStructure(); $this->verifyOwner($userId, $id); @@ -107,13 +107,16 @@ public function setMetaDataIntoIntermediateFile(string $userId, int $id, string $dir->newFile($this->intermediateMetaDataFileName) ->putContent($metaData); + $dir->newFile($this->intermediateMetaDataSignatureFileName) + ->putContent($signature); + $this->getTokenFolder($token)->newFile("$id", ''); } /** * @inheritDoc */ - public function updateMetaDataIntoIntermediateFile(string $userId, int $id, string $fileKey, string $token): void { + public function updateMetaDataIntoIntermediateFile(string $userId, int $id, string $fileKey, string $token, string $signature = ''): void { // ToDo check signature for race condition $this->verifyFolderStructure(); $this->verifyOwner($userId, $id); @@ -215,6 +218,25 @@ public function saveIntermediateFile(string $userId, int $id): void { $this->cleanupLegacyFile($userId, $id); } + private function writeSignature(ISimpleFolder $dir, string $filename, string $signature): void { + try { + $signatureFile = $dir->getFile($filename); + } catch (NotFoundException $ex) { + $signatureFile = $dir->newFile($filename); + } + + $signatureFile->putContent($signature); + } + + /** + * @inheritDoc + */ + public function readSignature(int $id): string { + $folderName = $this->getFolderNameForFileId($id); + $dir = $this->appData->getFolder($folderName); + return $dir->getFile($this->metaDataSignatureFileName)->getContent(); + } + /** * @inheritDoc */ diff --git a/tests/Unit/Controller/MetaDataControllerTest.php b/tests/Unit/Controller/MetaDataControllerTest.php index ab473cfb..e4e8064c 100644 --- a/tests/Unit/Controller/MetaDataControllerTest.php +++ b/tests/Unit/Controller/MetaDataControllerTest.php @@ -189,6 +189,10 @@ public function testSetMetaData(?\Exception $metaDataStorageException, ->willReturnCallback(static function ($string, $args) { return vsprintf($string, $args); }); + $this->request->expects($this->any()) + ->method('getHeader') + ->withConsecutive(['e2e-token'], ['X-NC-E2EE-SIGNATURE']) + ->willReturn('e2e-token', 'e2eSignature'); if ($expectLogger) { $this->logger->expects($this->once()) @@ -234,11 +238,12 @@ public function testUpdateMetaData(bool $isLocked, bool $expectLogger): void { $fileId = 42; $sendToken = 'sendE2EToken'; + $signature = 'signature'; $metaData = 'JSON-ENCODED-META-DATA'; - $this->request->expects($this->once()) + $this->request->expects($this->exactly(2)) ->method('getHeader') - ->with('e2e-token') - ->willReturn($sendToken); + ->withConsecutive(['e2e-token'], ['X-NC-E2EE-SIGNATURE']) + ->willReturnOnConsecutiveCalls($sendToken, $signature); $this->lockManager->expects($this->once()) ->method('isLocked') @@ -249,12 +254,12 @@ public function testUpdateMetaData(bool $isLocked, if ($metaDataStorageException) { $this->metaDataStorage->expects($this->once()) ->method('updateMetaDataIntoIntermediateFile') - ->with('john.doe', $fileId, $metaData, $sendToken) + ->with('john.doe', $fileId, $metaData, $sendToken, $signature) ->willThrowException($metaDataStorageException); } else { $this->metaDataStorage->expects($this->once()) ->method('updateMetaDataIntoIntermediateFile') - ->with('john.doe', $fileId, $metaData, $sendToken); + ->with('john.doe', $fileId, $metaData, $sendToken, $signature); } } @@ -310,12 +315,12 @@ public function testDeleteMetaData(?\Exception $metaDataStorageException, if ($metaDataStorageException) { $this->metaDataStorage->expects($this->once()) ->method('updateMetaDataIntoIntermediateFile') - ->with('john.doe', $fileId, '{}', 'e2e-token') + ->with('john.doe', $fileId, '{}', 'e2e-token', '') ->willThrowException($metaDataStorageException); } else { $this->metaDataStorage->expects($this->once()) ->method('updateMetaDataIntoIntermediateFile') - ->with('john.doe', $fileId, '{}', 'e2e-token'); + ->with('john.doe', $fileId, '{}', 'e2e-token', ''); } $this->request->expects($this->once()) diff --git a/tests/Unit/MetaDataStorageTest.php b/tests/Unit/MetaDataStorageTest.php index 6daa2bb0..0dda7443 100644 --- a/tests/Unit/MetaDataStorageTest.php +++ b/tests/Unit/MetaDataStorageTest.php @@ -218,18 +218,23 @@ public function testSetMetaDataIntoIntermediateFile(bool $hasLegacyMetadataFile, $this->expectExceptionMessage('Intermediate meta-data file already exists'); } } else { - $node = $this->createMock(ISimpleFile::class); - $node->expects($this->once()) + $intermediateFile = $this->createMock(ISimpleFile::class); + $intermediateSignatureFile = $this->createMock(ISimpleFile::class); + $intermediateFile->expects($this->once()) ->method('putContent') ->with('metadata-file-content'); - $metaDataFolder->expects($this->once()) + $intermediateSignatureFile->expects($this->once()) + ->method('putContent') + ->with('signature'); + + $metaDataFolder->expects($this->exactly(2)) ->method('newFile') - ->with('intermediate.meta.data') - ->willReturn($node); + ->withConsecutive(['intermediate.meta.data'], ['intermediate.meta.data.signature']) + ->willReturnOnConsecutiveCalls($intermediateFile, $intermediateSignatureFile); } - $metaDataStorage->setMetaDataIntoIntermediateFile('userId', 42, 'metadata-file-content', 'e2e-token'); + $metaDataStorage->setMetaDataIntoIntermediateFile('userId', 42, 'metadata-file-content', 'e2e-token', 'signature'); } public function setMetaDataIntoIntermediateFileDataProvider(): array { @@ -314,26 +319,30 @@ public function testUpdateMetaDataIntoIntermediateFile(bool $hasLegacyMetadataFi $this->expectExceptionMessage('Meta-data file missing'); } else { $intermediateFile = $this->createMock(ISimpleFile::class); + $intermediateSignatureFile = $this->createMock(ISimpleFile::class); $tokenFile = $this->createMock(ISimpleFile::class); $intermediateFile->expects($this->once()) ->method('putContent') ->with('metadata-file-content'); + $intermediateSignatureFile->expects($this->once()) + ->method('putContent') + ->with('signature'); if ($intermediateFileExists) { - $metaDataFolder->expects($this->once()) + $metaDataFolder->expects($this->exactly(2)) ->method('getFile') - ->with('intermediate.meta.data') - ->willReturn($intermediateFile); + ->withConsecutive(['intermediate.meta.data'], ['intermediate.meta.data.signature']) + ->willReturnOnConsecutiveCalls($intermediateFile, $intermediateSignatureFile); } else { - $metaDataFolder->expects($this->once()) + $metaDataFolder->expects($this->exactly(2)) ->method('getFile') - ->with('intermediate.meta.data') + ->withConsecutive(['intermediate.meta.data'], ['intermediate.meta.data.signature']) ->willThrowException(new NotFoundException()); - $metaDataFolder->expects($this->once()) + $metaDataFolder->expects($this->exactly(2)) ->method('newFile') - ->with('intermediate.meta.data') - ->willReturn($intermediateFile); + ->withConsecutive(['intermediate.meta.data'], ['intermediate.meta.data.signature']) + ->willReturnOnConsecutiveCalls($intermediateFile, $intermediateSignatureFile); } $tokenFolder->expects($this->once()) @@ -342,7 +351,7 @@ public function testUpdateMetaDataIntoIntermediateFile(bool $hasLegacyMetadataFi ->willReturn($tokenFile); } - $metaDataStorage->updateMetaDataIntoIntermediateFile('userId', 42, 'metadata-file-content', 'e2e-token'); + $metaDataStorage->updateMetaDataIntoIntermediateFile('userId', 42, 'metadata-file-content', 'e2e-token', 'signature'); } public function updateMetaDataIntoIntermediateFileDataProvider(): array { @@ -452,6 +461,7 @@ public function testSaveIntermediateFile(bool $folderExists, bool $intermediateF if ($intermediateFileExists) { $intermediateFile = $this->createMock(ISimpleFile::class); + $intermediateSignatureFile = $this->createMock(ISimpleFile::class); if ($intermediateFileIsEmpty) { $intermediateFile->expects($this->once()) ->method('getContent') @@ -469,33 +479,47 @@ public function testSaveIntermediateFile(bool $folderExists, bool $intermediateF ->method('getContent') ->willReturn('intermediate-file-content'); + $intermediateSignatureFile->expects($this->once()) + ->method('getContent') + ->willReturn('signature'); + $finalFile = $this->createMock(ISimpleFile::class); $finalFile->expects($this->once()) ->method('putContent') ->with('intermediate-file-content'); + $signatureFile = $this->createMock(ISimpleFile::class); + $signatureFile->expects($this->once()) + ->method('putContent') + ->with('signature'); + if ($finalFileExists) { - $metaDataFolder->expects($this->exactly(2)) + $metaDataFolder->expects($this->exactly(4)) ->method('getFile') - ->withConsecutive(['intermediate.meta.data'], ['meta.data']) - ->willReturn($intermediateFile, $finalFile); + ->withConsecutive(['intermediate.meta.data'], ['meta.data'], ['intermediate.meta.data.signature'], ['meta.data.signature']) + ->willReturnOnConsecutiveCalls($intermediateFile, $finalFile, $intermediateSignatureFile, $signatureFile); } else { - $metaDataFolder->expects($this->exactly(2)) + $metaDataFolder->expects($this->exactly(4)) ->method('getFile') - ->withConsecutive(['intermediate.meta.data'], ['meta.data']) + ->withConsecutive(['intermediate.meta.data'], ['meta.data'], ['intermediate.meta.data.signature'], ['meta.data.signature']) ->willReturnOnConsecutiveCalls( $intermediateFile, $this->throwException(new NotFoundException()), + $intermediateSignatureFile, + $this->throwException(new NotFoundException()), ); - $metaDataFolder->expects($this->once()) + $metaDataFolder->expects($this->exactly(2)) ->method('newFile') - ->with('meta.data') - ->willReturn($finalFile); + ->withConsecutive(['meta.data'], ['meta.data.signature']) + ->willReturn($finalFile, $signatureFile); } $intermediateFile->expects($this->once()) ->method('delete'); + + $intermediateSignatureFile->expects($this->once()) + ->method('delete'); } $metaDataStorage->expects($this->once()) From f7cf8cc862a8b5db701113ddc382fb7417f7c974 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 5 Jul 2023 17:37:19 +0200 Subject: [PATCH 11/36] Check if required headers are set Signed-off-by: Louis Chemineau Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- composer.lock | 2 +- lib/Controller/LockingController.php | 10 ++++++++ lib/Controller/MetaDataController.php | 25 +++++++++++++++++++ .../Unit/Controller/LockingControllerTest.php | 8 +++--- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/composer.lock b/composer.lock index 522ec466..86854cea 100644 --- a/composer.lock +++ b/composer.lock @@ -2127,4 +2127,4 @@ "php": "8.0" }, "plugin-api-version": "2.6.0" -} +} \ No newline at end of file diff --git a/lib/Controller/LockingController.php b/lib/Controller/LockingController.php index 12830266..6403338e 100644 --- a/lib/Controller/LockingController.php +++ b/lib/Controller/LockingController.php @@ -46,6 +46,8 @@ use OCP\IRequest; use OCP\Share\IManager as ShareManager; use Psr\Log\LoggerInterface; +use OCP\AppFramework\OCS\OCSPreconditionFailedException; +use OCP\AppFramework\OCS\OCSBadRequestException; class LockingController extends OCSController { private ?string $userId; @@ -96,6 +98,10 @@ public function lockFolder(int $id, ?string $shareToken = null): DataResponse { $e2eToken = $this->request->getParam('e2e-token', ''); $e2eCounter = (int)$this->request->getHeader('X-NC-E2EE-COUNTER'); + if ($e2eCounter === 0) { + throw new OCSPreconditionFailedException($this->l10n->t('X-NC-E2EE-COUNTER')); + } + $ownerId = $this->getOwnerId($shareToken); try { @@ -140,6 +146,10 @@ public function unlockFolder(int $id, ?string $shareToken = null): DataResponse $abort = $this->request->getParam('abort') === 'true'; $token = $this->request->getHeader('e2e-token'); + if ($token === '') { + throw new OCSPreconditionFailedException($this->l10n->t('e2e-token is empty')); + } + $ownerId = $this->getOwnerId($shareToken); try { diff --git a/lib/Controller/MetaDataController.php b/lib/Controller/MetaDataController.php index 3c028e33..57169f25 100644 --- a/lib/Controller/MetaDataController.php +++ b/lib/Controller/MetaDataController.php @@ -35,6 +35,7 @@ use OCA\EndToEndEncryption\LockManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSPreconditionFailedException; use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; @@ -111,6 +112,14 @@ public function setMetaData(int $id, string $metaData): DataResponse { $e2eToken = $this->request->getHeader('e2e-token'); $signature = $this->request->getHeader('X-NC-E2EE-SIGNATURE'); + if ($e2eToken === '') { + throw new OCSPreconditionFailedException($this->l10n->t('e2e-token is empty')); + } + + if ($signature === '') { + throw new OCSPreconditionFailedException($this->l10n->t('X-NC-E2EE-SIGNATURE is empty')); + } + if ($this->lockManager->isLocked($id, $e2eToken)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } @@ -142,6 +151,14 @@ public function updateMetaData(int $id, string $metaData): DataResponse { $e2eToken = $this->request->getHeader('e2e-token'); $signature = $this->request->getHeader('X-NC-E2EE-SIGNATURE'); + if ($e2eToken === '') { + throw new OCSPreconditionFailedException($this->l10n->t('e2e-token is empty')); + } + + if ($signature === '') { + throw new OCSPreconditionFailedException($this->l10n->t('X-NC-E2EE-SIGNATURE is empty')); + } + if ($this->lockManager->isLocked($id, $e2eToken)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } @@ -175,6 +192,10 @@ public function updateMetaData(int $id, string $metaData): DataResponse { public function deleteMetaData(int $id): DataResponse { $e2eToken = $this->request->getHeader('e2e-token'); + if ($e2eToken === '') { + throw new OCSPreconditionFailedException($this->l10n->t('e2e-token is empty')); + } + if ($this->lockManager->isLocked($id, $e2eToken)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } @@ -207,6 +228,10 @@ public function addMetadataFileDrop(int $id, string $fileDrop, ?string $shareTok $e2eToken = $this->request->getHeader('e2e-token'); $ownerId = $this->getOwnerId($shareToken); + if ($e2eToken === '') { + throw new OCSPreconditionFailedException($this->l10n->t('e2e-token is empty')); + } + if ($this->lockManager->isLocked($id, $e2eToken, $ownerId)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } diff --git a/tests/Unit/Controller/LockingControllerTest.php b/tests/Unit/Controller/LockingControllerTest.php index 826f4b42..09536b01 100644 --- a/tests/Unit/Controller/LockingControllerTest.php +++ b/tests/Unit/Controller/LockingControllerTest.php @@ -107,7 +107,7 @@ protected function setUp(): void { public function testLockFolder(): void { $fileId = 42; - $sendE2E = ''; + $sendE2E = 'e2eToken'; $this->l10n->expects($this->any()) ->method('t') @@ -118,7 +118,7 @@ public function testLockFolder(): void { $this->request->expects($this->once()) ->method('getParam') ->with('e2e-token', '') - ->willReturn(''); + ->willReturn($sendE2E); $userFolder = $this->createMock(Folder::class); $this->rootFolder->expects($this->once()) @@ -149,11 +149,11 @@ public function testLockFolder(): void { public function testLockFolderException(): void { $fileId = 42; - $sendE2E = ''; + $sendE2E = 'e2eToken'; $this->request->expects($this->once()) ->method('getParam') ->with('e2e-token', '') - ->willReturn(''); + ->willReturn($sendE2E); $userFolder = $this->createMock(Folder::class); $this->rootFolder->expects($this->once()) From 9a20a3999c7bdd1e7a8f3289a4ec75af62478d74 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Mon, 17 Jul 2023 16:36:09 +0200 Subject: [PATCH 12/36] Update api.md for new headers Signed-off-by: Louis Chemineau Signed-off-by: Benjamin Gaussorgues Signed-off-by: Louis Chemineau --- doc/api.md | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/doc/api.md b/doc/api.md index d2662ee5..970b7d0e 100644 --- a/doc/api.md +++ b/doc/api.md @@ -12,7 +12,6 @@ A more general documentation how to use the API can be found [here](https://gith - [Get public keys](#get-public-keys) - [Delete public keys](#delete-public-keys) - [Lock file](#lock-file) - - [Unlock file](#unlock-file) - [Get meta-data file](#get-meta-data-file) - [Update meta-data file](#update-meta-data-file) - [Update filedrop property of meta-data file](#update-filedrop-property-of-meta-data-file) @@ -345,11 +344,22 @@ e2e-token: if you re-try a previously failed upload, use the token from the firs First try: -`curl -X POST https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/lock/ -H "OCS-APIRequest:true"` +````shell +curl https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/lock/10 \ + -X POST \ + -H "OCS-APIRequest:true" \ + -H "X-NC-E2EE-COUNTER: +``` Retry: `curl -X POST https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/lock/ -H "OCS-APIRequest:true"` -d e2e-token="" +curl https://:@/ocs/v2.php/apps/end_to_end_encryption/api/v1/lock/10 \ + -X POST \ + -H "OCS-APIRequest:true" \ + -d "e2e-token: \ + -H "X-NC-E2EE-COUNTER: +``` ## Unlock file @@ -517,6 +527,7 @@ curl "https://:@/ocs/v2.php/apps/end_to_end_encryptio -X PUT \ -H "OCS-APIRequest:true" \ -H "e2e-token:" \ + -H "X-NC-E2EE-SIGNATURE:" \ -d metaData="" ``` From 787b1c27d470ba4f5f39d6799a9eac8753baf277 Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Thu, 24 Aug 2023 16:13:45 +0200 Subject: [PATCH 13/36] REMOVE ME: temporary fix to allow both routes on same endpoint Signed-off-by: Louis Chemineau --- lib/Controller/V1/MetaDataController.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/Controller/V1/MetaDataController.php b/lib/Controller/V1/MetaDataController.php index d262a2b7..f5de3156 100644 --- a/lib/Controller/V1/MetaDataController.php +++ b/lib/Controller/V1/MetaDataController.php @@ -130,6 +130,12 @@ public function setMetaData(int $id, string $metaData): DataResponse { public function updateMetaData(int $id, string $metaData): DataResponse { $e2eToken = $this->request->getParam('e2e-token'); + // FIXME Temporary fix to handle both routes on single endpoint + if (empty($e2eToken)) { + $e2eToken = $this->request->getHeader('e2e-token'); + } + // End + if ($this->lockManager->isLocked($id, $e2eToken)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } From 7b178474caf1fba8d211ec24ff4358d8c278f8f5 Mon Sep 17 00:00:00 2001 From: Benjamin Gaussorgues Date: Wed, 30 Aug 2023 10:15:04 +0200 Subject: [PATCH 14/36] Fix leave share for root folder Signed-off-by: Louis Chemineau --- lib/Connector/Sabre/LockPlugin.php | 5 +- lib/FileService.php | 15 ++- tests/Unit/FileServiceTest.php | 6 + tests/stub.phpstub | 187 +++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 2 deletions(-) diff --git a/lib/Connector/Sabre/LockPlugin.php b/lib/Connector/Sabre/LockPlugin.php index c0481415..5369c6f3 100644 --- a/lib/Connector/Sabre/LockPlugin.php +++ b/lib/Connector/Sabre/LockPlugin.php @@ -108,7 +108,10 @@ public function checkLock(RequestInterface $request): void { } // Prevent moving or copying stuff from non-encrypted to encrypted folders - if ($this->isE2EEnabledPath($node) xor $this->isE2EEnabledPath($destNode)) { + // if original operation is not a DELETE + if ($this->isE2EEnabledPath($node) !== $this->isE2EEnabledPath($destNode) + && $request->getHeader('X-Nc-Sabre-Original-Method') !== 'DELETE' + ) { throw new Forbidden('Cannot copy or move files from non-encrypted folders to end to end encrypted folders or vice versa.'); } } diff --git a/lib/FileService.php b/lib/FileService.php index 266a9980..c67be587 100644 --- a/lib/FileService.php +++ b/lib/FileService.php @@ -23,6 +23,7 @@ namespace OCA\EndToEndEncryption; use OCA\EndToEndEncryption\Connector\Sabre\RedirectRequestPlugin; +use OCA\Files_Sharing\SharedStorage; use OCP\Files\Folder; use OCP\Files\Node; @@ -72,6 +73,12 @@ public function finalizeChanges(Folder $folder): bool { /** @var Node $intermediateFile */ foreach ($intermediateFiles['to_delete'] as $intermediateFile) { + // If shared to user, try to unshare it first + $storage = $intermediateFile->getStorage(); + if ($storage->instanceOfStorage(SharedStorage::class) && $storage->unshareStorage()) { + continue; + } + // Otherwise delete it $intermediateFile->delete(); } @@ -83,12 +90,18 @@ public function finalizeChanges(Folder $folder): bool { * @return array{to_save: Node[], to_delete: Node[]} */ private function getIntermediateFiles(Folder $folder): array { - $listing = $folder->getDirectoryListing(); $result = [ 'to_save' => [], 'to_delete' => [], ]; + // Special case when root folder is deleted/unshared + if ($this->isIntermediateFileToDelete($folder)) { + $result['to_delete'][] = $folder; + } + + $listing = $folder->getDirectoryListing(); + foreach ($listing as $node) { if ($this->isIntermediateFileToSave($node)) { $result['to_save'][] = $node; diff --git a/tests/Unit/FileServiceTest.php b/tests/Unit/FileServiceTest.php index 9261eca8..4168fd60 100644 --- a/tests/Unit/FileServiceTest.php +++ b/tests/Unit/FileServiceTest.php @@ -26,6 +26,7 @@ use OCA\EndToEndEncryption\FileService; use OCP\Files\Folder; use OCP\Files\Node; +use OCP\Files\Storage\IStorage; use OCP\ILogger; use Test\TestCase; @@ -149,6 +150,7 @@ private function getSampleFolderForEmpty(): array { $file3, $file4, ]); + $folder->method('getName')->willReturn('root'); return [ $folder, @@ -162,6 +164,8 @@ private function getSampleFolderForEmpty(): array { } private function getSampleFolderForNonEmpty(): array { + $storage = $this->createMock(IStorage::class); + $file1 = $this->createMock(Node::class); $file1->method('getName')->willReturn('7215ee9c7d9dc229d2921a40e899ec5f.e2e-to-save'); $file1->method('getPath')->willReturn('/foo/bar/7215ee9c7d9dc229d2921a40e899ec5f.e2e-to-save'); @@ -176,6 +180,7 @@ private function getSampleFolderForNonEmpty(): array { $file4 = $this->createMock(Node::class); $file4->method('getName')->willReturn('a9473ded85aa51851deb4859cdd53f98.e2e-to-delete'); $file4->method('getPath')->willReturn('/foo/bar/a9473ded85aa51851deb4859cdd53f98.e2e-to-delete'); + $file4->method('getStorage')->willReturn($storage); $folder = $this->createMock(Folder::class); $folder->method('getDirectoryListing')->willReturn([ @@ -184,6 +189,7 @@ private function getSampleFolderForNonEmpty(): array { $file3, $file4, ]); + $folder->method('getName')->willReturn('root'); return [ $folder, diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 2045a2e1..ee0992af 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -679,3 +679,190 @@ namespace OC\Files\Storage\Wrapper{ public function getQuota() {} } } + +namespace OCA\Files_Sharing { + interface ISharedStorage extends \OCP\Files\Storage\IStorage + { + } + + /** + * Convert target path to source path and pass the function call to the correct storage provider + */ + class SharedStorage extends \OC\Files\Storage\Wrapper\Jail implements \OCA\Files_Sharing\ISharedStorage, \OCP\Files\Storage\IDisableEncryptionStorage + { + public function __construct($arguments) + { + } + /** + * @inheritdoc + */ + public function instanceOfStorage($class) : bool + { + } + /** + * @return string + */ + public function getShareId() + { + } + /** + * get id of the mount point + * + * @return string + */ + public function getId() : string + { + } + /** + * Get the permissions granted for a shared file + * + * @param string $path Shared target file path + * @return int CRUDS permissions granted + */ + public function getPermissions($path = '') : int + { + } + public function isCreatable($path) : bool + { + } + public function isReadable($path) : bool + { + } + public function isUpdatable($path) : bool + { + } + public function isDeletable($path) : bool + { + } + public function isSharable($path) : bool + { + } + public function fopen($path, $mode) + { + } + /** + * see https://www.php.net/manual/en/function.rename.php + * + * @param string $source + * @param string $target + * @return bool + */ + public function rename($source, $target) : bool + { + } + /** + * return mount point of share, relative to data/user/files + * + * @return string + */ + public function getMountPoint() : string + { + } + /** + * @param string $path + */ + public function setMountPoint($path) : void + { + } + /** + * get the user who shared the file + * + * @return string + */ + public function getSharedFrom() : string + { + } + /** + * @return \OCP\Share\IShare + */ + public function getShare() : \OCP\Share\IShare + { + } + /** + * return share type, can be "file" or "folder" + * + * @return string + */ + public function getItemType() : string + { + } + public function getCache($path = '', $storage = null) + { + } + public function getScanner($path = '', $storage = null) + { + } + public function getOwner($path) : string + { + } + public function getWatcher($path = '', $storage = null) : \OC\Files\Cache\Watcher + { + } + /** + * unshare complete storage, also the grouped shares + * + * @return bool + */ + public function unshareStorage() : bool + { + } + /** + * @param string $path + * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE + * @param \OCP\Lock\ILockingProvider $provider + * @throws \OCP\Lock\LockedException + */ + public function acquireLock($path, $type, \OCP\Lock\ILockingProvider $provider) + { + } + /** + * @param string $path + * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE + * @param \OCP\Lock\ILockingProvider $provider + */ + public function releaseLock($path, $type, \OCP\Lock\ILockingProvider $provider) + { + } + /** + * @param string $path + * @param int $type \OCP\Lock\ILockingProvider::LOCK_SHARED or \OCP\Lock\ILockingProvider::LOCK_EXCLUSIVE + * @param \OCP\Lock\ILockingProvider $provider + */ + public function changeLock($path, $type, \OCP\Lock\ILockingProvider $provider) + { + } + /** + * @return array [ available, last_checked ] + */ + public function getAvailability() + { + } + /** + * @param bool $isAvailable + */ + public function setAvailability($isAvailable) + { + } + public function getSourceStorage() + { + } + public function getWrapperStorage() + { + } + public function file_get_contents($path) + { + } + public function file_put_contents($path, $data) + { + } + /** + * @return void + */ + public function setMountOptions(array $options) + { + } + public function getUnjailedPath($path) + { + } + } +} From 93734bf52d7dda87460bb1126c2a6caf67813865 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Thu, 31 Aug 2023 15:58:04 +0200 Subject: [PATCH 15/36] Adapt filedrop to v2 routes Signed-off-by: Louis Chemineau --- lib/E2EEPublicShareTemplateProvider.php | 34 +++++++++---------------- src/services/filedrop.js | 9 ++++--- src/services/lock.js | 12 ++++++--- src/views/FileDrop.vue | 11 +++++--- 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/lib/E2EEPublicShareTemplateProvider.php b/lib/E2EEPublicShareTemplateProvider.php index ce9edb50..5e354a9e 100644 --- a/lib/E2EEPublicShareTemplateProvider.php +++ b/lib/E2EEPublicShareTemplateProvider.php @@ -19,30 +19,16 @@ use Psr\Log\LoggerInterface; class E2EEPublicShareTemplateProvider implements IPublicShareTemplateProvider { - private IUserManager $userManager; - private IUrlGenerator $urlGenerator; - private IL10N $l10n; - private Defaults $defaults; - private IInitialState $initialState; - private IKeyStorage $keyStorage; - private LoggerInterface $logger; - public function __construct( - IUserManager $userManager, - IUrlGenerator $urlGenerator, - IL10N $l10n, - Defaults $defaults, - IInitialState $initialState, - IKeyStorage $keyStorage, - LoggerInterface $logger + private IUserManager $userManager, + private IUrlGenerator $urlGenerator, + private IL10N $l10n, + private Defaults $defaults, + private IInitialState $initialState, + private IKeyStorage $keyStorage, + private LoggerInterface $logger, + private MetaDataStorage $metadataStorage, ) { - $this->userManager = $userManager; - $this->urlGenerator = $urlGenerator; - $this->l10n = $l10n; - $this->defaults = $defaults; - $this->initialState = $initialState; - $this->keyStorage = $keyStorage; - $this->logger = $logger; } public function shouldRespond(IShare $share): bool { @@ -61,10 +47,14 @@ public function renderPage(IShare $share, string $token, string $path): Template return new TemplateResponse(Application::APP_ID, 'error'); } + $metadata = json_decode($this->metadataStorage->getMetaData($owner->getUID(), $shareNode->getId()), true); + $this->initialState->provideInitialState('publicKey', $publicKey); $this->initialState->provideInitialState('fileId', $shareNode->getId()); $this->initialState->provideInitialState('token', $token); $this->initialState->provideInitialState('fileName', $shareNode->getName()); + $this->initialState->provideInitialState('encryptionVersion', $metadata['version']); + $this->initialState->provideInitialState('counter', $this->metadataStorage->getCounter($shareNode->getId())); // OpenGraph Support: http://ogp.me/ Util::addHeader('meta', ['property' => "og:title", 'content' => $this->l10n->t("Encrypted share")]); diff --git a/src/services/filedrop.js b/src/services/filedrop.js index 69b45f88..843b29a1 100644 --- a/src/services/filedrop.js +++ b/src/services/filedrop.js @@ -82,15 +82,17 @@ export async function getFileDropEntry(file, tag, publicKey) { } /** + * @param {1|2} encryptionVersion - The encrypted version for the folder * @param {string} folderId * @param {EncryptedFileMetadata[]} fileDrop * @param {string} lockToken * @param {string} shareToken */ -export async function uploadFileDrop(folderId, fileDrop, lockToken, shareToken) { +export async function uploadFileDrop(encryptionVersion, folderId, fileDrop, lockToken, shareToken) { const ocsUrl = generateOcsUrl( - 'apps/end_to_end_encryption/api/v1/meta-data/{folderId}', + 'apps/end_to_end_encryption/api/v{encryptionVersion}/meta-data/{folderId}', { + encryptionVersion, folderId, } ) @@ -103,10 +105,11 @@ export async function uploadFileDrop(folderId, fileDrop, lockToken, shareToken) { headers: { 'x-e2ee-supported': true, - 'e2e-token': lockToken, + ...(encryptionVersion === 2 ? { 'e2e-token': lockToken } : {}), }, params: { shareToken, + ...(encryptionVersion === 1 ? { 'e2e-token': lockToken } : {}), }, }, ) diff --git a/src/services/lock.js b/src/services/lock.js index a8e94671..9ae02696 100644 --- a/src/services/lock.js +++ b/src/services/lock.js @@ -5,17 +5,20 @@ import { generateOcsUrl } from '@nextcloud/router' import axios from '@nextcloud/axios' /** + * @param {1|2} encryptionVersion - The encrypted version for the folder + * @param {number} counter - The metadata counter received from the initial state * @param {string} fileId - The file id to lock * @param {?string} shareToken - The optional share token if this is a file drop. * @return {Promise} lockToken */ -export async function lock(fileId, shareToken) { +export async function lock(encryptionVersion, counter, fileId, shareToken) { const { data: { ocs: { meta, data } } } = await axios.post( - generateOcsUrl('apps/end_to_end_encryption/api/v1/lock/{fileId}', { fileId }), + generateOcsUrl('apps/end_to_end_encryption/api/v{encryptionVersion}/lock/{fileId}', { encryptionVersion, fileId }), undefined, { headers: { 'x-e2ee-supported': true, + 'x-nc-e2ee-counter': counter, }, params: { shareToken, @@ -31,13 +34,14 @@ export async function lock(fileId, shareToken) { } /** + * @param {1|2} encryptionVersion - The encrypted version for the folder * @param {string} fileId - The file id to lock * @param {string} lockToken - The optional lock token if the folder was already locked. * @param {?string} shareToken - The optional share token if this is a file drop. */ -export async function unlock(fileId, lockToken, shareToken) { +export async function unlock(encryptionVersion, fileId, lockToken, shareToken) { const { data: { ocs: { meta } } } = await axios.delete( - generateOcsUrl('apps/end_to_end_encryption/api/v1/lock/{fileId}', { fileId }), + generateOcsUrl('apps/end_to_end_encryption/api/v{encryptionVersion}/lock/{fileId}', { encryptionVersion, fileId }), { headers: { 'x-e2ee-supported': true, diff --git a/src/views/FileDrop.vue b/src/views/FileDrop.vue index 13443db0..5a101e38 100644 --- a/src/views/FileDrop.vue +++ b/src/views/FileDrop.vue @@ -97,6 +97,10 @@ export default { publicKey: loadState('end_to_end_encryption', 'publicKey'), /** @type {string} */ fileName: loadState('end_to_end_encryption', 'fileName'), + /** @type {"v1"|"v2"} */ + encryptionVersion: Number.parseInt(loadState('end_to_end_encryption', 'encryptionVersion')), + /** @type {number} */ + counter: Number.parseInt(loadState('end_to_end_encryption', 'counter')), /** @type {{file: File, step: string, error: boolean}[]} */ uploadedFiles: [], loading: false, @@ -149,7 +153,7 @@ export default { try { logger.debug('Locking the folder', { lockToken: this.lockToken, shareToken: this.shareToken }) - lockToken = await lock(this.folderId, this.shareToken) + lockToken = await lock(this.encryptionVersion, this.counter + 1, this.folderId, this.shareToken) } catch (exception) { logger.error('Could not lock the folder', { exception }) showError(t('end_to_end_encryption', 'Could not lock the folder')) @@ -179,7 +183,7 @@ export default { .filter(({ error }) => !error) .reduce((fileDropEntries, { fileDrop }) => ({ ...fileDropEntries, ...fileDrop }), {}) - await uploadFileDrop(this.folderId, fileDrops, lockToken, this.shareToken) + await uploadFileDrop(this.encryptionVersion, this.folderId, fileDrops, lockToken, this.shareToken) } catch (exception) { logger.error('Error while uploading metadata', { exception }) showError(t('end_to_end_encryption', 'Error while uploading metadata')) @@ -190,7 +194,8 @@ export default { progresses .filter(({ error }) => !error) .forEach(progress => { progress.step = UploadStep.UNLOCKING }) - await unlock(this.folderId, lockToken, this.shareToken) + await unlock(this.encryptionVersion, this.folderId, lockToken, this.shareToken) + this.counter++ logger.debug('Unlocking the folder', { lockToken, shareToken: this.shareToken }) progresses .filter(({ error }) => !error) From ac78ddd6903509b6a76cf6a2761ee478be00a472 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Mon, 8 Jan 2024 16:15:38 +0100 Subject: [PATCH 16/36] Adapt filedrop to v2 Signed-off-by: Louis Chemineau --- lib/Controller/MetaDataController.php | 9 +- lib/E2EEPublicShareTemplateProvider.php | 20 +++- src/services/crypto.js | 113 +++++++++---------- src/services/filedrop.js | 143 ++++++++++++++---------- src/services/lock.js | 4 +- src/views/FileDrop.vue | 64 ++++++----- 6 files changed, 192 insertions(+), 161 deletions(-) diff --git a/lib/Controller/MetaDataController.php b/lib/Controller/MetaDataController.php index 57169f25..f11d2c84 100644 --- a/lib/Controller/MetaDataController.php +++ b/lib/Controller/MetaDataController.php @@ -224,7 +224,7 @@ public function deleteMetaData(int $id): DataResponse { * @throws OCSBadRequestException * @throws OCSNotFoundException */ - public function addMetadataFileDrop(int $id, string $fileDrop, ?string $shareToken = null): DataResponse { + public function addMetadataFileDrop(int $id, string $filedrop, ?string $shareToken = null): DataResponse { $e2eToken = $this->request->getHeader('e2e-token'); $ownerId = $this->getOwnerId($shareToken); @@ -239,8 +239,9 @@ public function addMetadataFileDrop(int $id, string $fileDrop, ?string $shareTok try { $metaData = $this->metaDataStorage->getMetaData($ownerId, $id); $decodedMetadata = json_decode($metaData, true); - $decodedFileDrop = json_decode($fileDrop, true); - $decodedMetadata['filedrop'] = array_merge($decodedMetadata['filedrop'] ?? [], $decodedFileDrop); + $fileDropArray = $decodedMetadata['filedrop'] ?? []; + $fileDropArray = array_merge($fileDropArray, json_decode($filedrop, true)); + $decodedMetadata['filedrop'] = $fileDropArray; $encodedMetadata = json_encode($decodedMetadata); $this->metaDataStorage->updateMetaDataIntoIntermediateFile($ownerId, $id, $encodedMetadata, $e2eToken); @@ -253,7 +254,7 @@ public function addMetadataFileDrop(int $id, string $fileDrop, ?string $shareTok throw new OCSBadRequestException($this->l10n->t('Cannot update filedrop')); } - return new DataResponse(['meta-data' => $metaData]); + return new DataResponse(); } private function getOwnerId(?string $shareToken = null): string { diff --git a/lib/E2EEPublicShareTemplateProvider.php b/lib/E2EEPublicShareTemplateProvider.php index 5e354a9e..d77a0515 100644 --- a/lib/E2EEPublicShareTemplateProvider.php +++ b/lib/E2EEPublicShareTemplateProvider.php @@ -39,9 +39,25 @@ public function shouldRespond(IShare $share): bool { public function renderPage(IShare $share, string $token, string $path): TemplateResponse { $shareNode = $share->getNode(); $owner = $this->userManager->get($share->getShareOwner()); + if ($owner === null) { + $e = new NotFoundException("Cannot find folder's owner"); + $this->logger->error($e->getMessage(), ['exception' => $e]); + return new TemplateResponse(Application::APP_ID, 'error'); + } + + $rawMetadata = $this->metadataStorage->getMetaData($owner->getUID(), $shareNode->getId()); + $metadata = json_decode($rawMetadata, true); + $userIds = array_map(fn (array $userEntry): string => $userEntry['userId'], $metadata['users']); try { - $publicKey = $this->keyStorage->getPublicKey($owner->getUID()); + $publicKeys = array_reduce( + $userIds, + function (array $acc, string $userId): array { + $acc[$userId] = $this->keyStorage->getPublicKey($userId); + return $acc; + }, + [] + ); } catch (NotFoundException $e) { $this->logger->error($e->getMessage(), ['exception' => $e]); return new TemplateResponse(Application::APP_ID, 'error'); @@ -49,7 +65,7 @@ public function renderPage(IShare $share, string $token, string $path): Template $metadata = json_decode($this->metadataStorage->getMetaData($owner->getUID(), $shareNode->getId()), true); - $this->initialState->provideInitialState('publicKey', $publicKey); + $this->initialState->provideInitialState('publicKeys', $publicKeys); $this->initialState->provideInitialState('fileId', $shareNode->getId()); $this->initialState->provideInitialState('token', $token); $this->initialState->provideInitialState('fileName', $shareNode->getName()); diff --git a/src/services/crypto.js b/src/services/crypto.js index e4f0db62..d7add8f2 100644 --- a/src/services/crypto.js +++ b/src/services/crypto.js @@ -1,8 +1,9 @@ // SPDX-FileCopyrightText: 2022 Carl Schwan // SPDX-License-Identifier: AGPL-3.0-or-later -import { v4 as uuidv4 } from 'uuid' import * as x509 from '@peculiar/x509' +import { bufferToBase64 } from './filedrop' +import logger from './logger' /** * Gets tag from encrypted data @@ -14,6 +15,9 @@ function getTag(encrypted) { return encrypted.slice(encrypted.byteLength - ((128 + 7) >> 3)) } +/** + * @return {Promise} + */ export async function getRandomAESKey() { return await window.crypto.subtle.generateKey( { @@ -25,10 +29,26 @@ export async function getRandomAESKey() { ) } +/** + * @typedef {object} EncryptionParams + * @property {CryptoKey} key - Encryption key of the file (ex: "jtboLmgGR1OQf2uneqCVHpklQLlIwWL5TXAQ0keK") + * @property {Uint8Array} initializationVector - Mimetype, if unknown use "application/octet-stream" (ex: "plain/text") + */ + +/** + * @return {Promise} + */ +export async function getRandomEncryptionParams() { + return { + key: await getRandomAESKey(), + initializationVector: window.crypto.getRandomValues(new Uint8Array(16)), + } +} + /** * Encrypt file content * - * @param {{key: CryptoKey, initializationVector: Uint8Array}} encryptionData + * @param {EncryptionParams} encryptionData * @param {Uint8Array} content * @return {Promise<{content: ArrayBuffer, tag: ArrayBuffer}>} */ @@ -45,63 +65,37 @@ export async function encryptWithAES({ key, initializationVector }, content) { } } -class EncryptedFile { - - /** - * @param {string} fileName - * @param {string} mimetype - */ - constructor(fileName, mimetype) { - this.encryptedFileName = uuidv4().replaceAll('-', '') - this.initializationVector = window.crypto.getRandomValues(new Uint8Array(16)) - this.fileVersion = 1 - this.metadataKey = 1 - this.originalFileName = fileName - this.mimetype = mimetype - if (this.mimetype === 'inode/directory') { - this.mimetype = 'httpd/unix-directory' - } - this.encryptionKey = null - } - - /** - * Encrypt file content - * - * @param {Uint8Array} content - * @return {Promise<{content: ArrayBuffer, tag: ArrayBuffer}>} - */ - async encrypt(content) { - return encryptWithAES({ key: await this.getEncryptionKey(), initializationVector: this.initializationVector }, content) - } - - /** - * @return {Promise} - */ - async getEncryptionKey() { - if (this.encryptionKey === null) { - this.encryptionKey = await getRandomAESKey() - } +/** + * @typedef {object} FileEncryptionInfo + * @property {string} filename - Original file name (ex: "/foo/test.txt") + * @property {string} mimetype - Mimetype, if unknown use "application/octet-stream" (ex: "plain/text") + * @property {string} key - Encryption key of the file (ex: "jtboLmgGR1OQf2uneqCVHpklQLlIwWL5TXAQ0keK") + * @property {string} nonce - Initialisation vector + * @property {string} authenticationTag - Authentication tag of the file (ex: "LYRaJghbZUzBiNWb51ypWw==") + */ - return this.encryptionKey - } +/** + * Encrypt file content + * + * @param {File} file + * @return {Promise<{encryptedFileContent: ArrayBuffer, encryptionInfo: FileEncryptionInfo}>} + */ +export async function encryptFile(file) { + const blob = await file.arrayBuffer() + const encryptionParams = await getRandomEncryptionParams() + const { content, tag } = await encryptWithAES(encryptionParams, new Uint8Array(blob)) + logger.debug(`[FileDrop] File encrypted: ${file.name}`, { file, content, tag, encryptionParams, rawKey: bufferToBase64(await window.crypto.subtle.exportKey('raw', encryptionParams.key)) }) - /** - * Encrypt file content - * - * @param {Uint8Array} content - * @return {Promise} - */ - async decrypt(content) { - return await window.crypto.subtle.decrypt( - { - name: 'AES-GCM', - iv: this.initializationVector, - }, - await this.getEncryptionKey(), - content - ) + return { + encryptedFileContent: content, + encryptionInfo: { + filename: file.name, + mimetype: file.type, + nonce: bufferToBase64(encryptionParams.initializationVector), + key: bufferToBase64(await window.crypto.subtle.exportKey('raw', encryptionParams.key)), + authenticationTag: bufferToBase64(tag), + }, } - } /** @@ -130,15 +124,10 @@ async function importPublicKey(pem) { * @param {BufferSource} buffer * @return {Promise} */ -async function encryptStringAsymmetric(publicKey, buffer) { +export async function encryptStringAsymmetric(publicKey, buffer) { return await window.crypto.subtle.encrypt( { name: 'RSA-OAEP' }, await importPublicKey(publicKey), buffer ) } - -export { - EncryptedFile, - encryptStringAsymmetric, -} diff --git a/src/services/filedrop.js b/src/services/filedrop.js index 843b29a1..895b877a 100644 --- a/src/services/filedrop.js +++ b/src/services/filedrop.js @@ -1,94 +1,72 @@ import axios from '@nextcloud/axios' import { generateOcsUrl } from '@nextcloud/router' -import { EncryptedFile, encryptStringAsymmetric, encryptWithAES, getRandomAESKey } from './crypto.js' +import { encryptStringAsymmetric, encryptWithAES, getRandomEncryptionParams } from './crypto.js' +import logger from './logger.js' /** - * @typedef {object} EncryptedFileKey - * @property {string} key - Encryption key of the file (ex: "jtboLmgGR1OQf2uneqCVHpklQLlIwWL5TXAQ0keK") - * @property {string} filename - Unencrypted file name (ex: "/foo/test.txt") + * @typedef {object} FileMetadata + * @property {string} filename - Original file name (ex: "/foo/test.txt") * @property {string} mimetype - Mimetype, if unknown use "application/octet-stream" (ex: "plain/text") - * @property {object} version - Which encryption method version was used? For updating in the future. (ex: 1) + * @property {string} key - Encryption key of the file (ex: "jtboLmgGR1OQf2uneqCVHpklQLlIwWL5TXAQ0keK") + * @property {string} nonce - Initialisation vector + * @property {string} authenticationTag - Authentication tag of the file (ex: "LYRaJghbZUzBiNWb51ypWw==") */ /** - * @typedef {object} EncryptedFileMetadata - * @property {string} encrypted - Encrypted JSON payload to the currently used metadata key. Encryption algorithm: AES/GCM/NoPadding (128 bit key size) with metadata key (symmetric) - * @property {string} initializationVector - Initialization vector (ex: "+mHu52HyZq+pAAIN") - * @property {string} authenticationTag - Authentication tag of the file (ex: "LYRaJghbZUzBiNWb51ypWw==") - * @property {string} encryptedKey - Encryption key to decrypt the 'encrypted' property - * @property {string} encryptedInitializationVector - Encryption initialization vector used to decrypt the 'encrypted' property. - * @property {string} encryptedTag - Encryption tag used to decrypt the 'encrypted' property. + * @typedef {object} UserEncryptionInformation + * @property {string } userId + * @property {string } encryptedFiledropKey */ -async function getRandomEncryptionParams() { - return { - key: await getRandomAESKey(), - initializationVector: window.crypto.getRandomValues(new Uint8Array(16)), - } -} +/** + * @typedef {object} FileDropPayload + * @property {string } ciphertext + * @property {string } nonce + * @property {string } authenticationTag + * @property {UserEncryptionInformation[]} users + */ /** * @param {ArrayBuffer} buffer * @return {string} */ -function bufferToBase64(buffer) { +export function bufferToBase64(buffer) { return btoa(String.fromCharCode(...new Uint8Array(buffer))) } /** - * @param {EncryptedFile} file - * @param {Uint8Array} tag - * @param {string} publicKey - * @return {Promise>} + * @param {import('./crypto.js').FileEncryptionInfo} encryptionInfo + * @param {{[userId: string]: string}} publicKeys + * @return {Promise} */ -export async function getFileDropEntry(file, tag, publicKey) { - const rawFileEncryptionKey = await window.crypto.subtle.exportKey('raw', await file.getEncryptionKey()) - - /** @type {EncryptedFileKey} */ - const encryptedPayload = { - key: bufferToBase64(rawFileEncryptionKey), - filename: file.originalFileName, - mimetype: file.mimetype, - version: '1.2', - } - - const encryptedEncryptionParams = await getRandomEncryptionParams() - - const encrypted = await encryptWithAES( - encryptedEncryptionParams, - new TextEncoder().encode(btoa(JSON.stringify(encryptedPayload))) - ) - - const rawKey = await window.crypto.subtle.exportKey('raw', encryptedEncryptionParams.key) - const base64Key1 = bufferToBase64(rawKey) - const base64Key2 = btoa(base64Key1) - const bufferKey = new TextEncoder().encode(base64Key2) +export async function getFileDropEntry(encryptionInfo, publicKeys) { + const compressedEncryptionInfo = await compress(JSON.stringify(encryptionInfo)) + logger.debug(`[FileDrop] Encryption info compressed (${encryptionInfo.filename})`, { encryptionInfo, compressedEncryptionInfo }) - const encryptedKey = await encryptStringAsymmetric( - publicKey, - bufferKey, + const encryptionParams = await getRandomEncryptionParams() + const { content, tag } = await encryptWithAES( + encryptionParams, + new Uint8Array(compressedEncryptionInfo), ) + logger.debug(`[FileDrop] Encryption info encrypted (${encryptionInfo.filename})`, { content, tag, encryptionParams }) + logger.debug(`[FileDrop] Encryption info base64ed (${encryptionInfo.filename})`, { ciphertext: bufferToBase64(content) }) return { - [file.encryptedFileName]: { - encrypted: bufferToBase64(encrypted.content), - initializationVector: bufferToBase64(file.initializationVector), - authenticationTag: bufferToBase64(tag), - encryptedKey: bufferToBase64(encryptedKey), - encryptedTag: bufferToBase64(encrypted.tag), - encryptedInitializationVector: bufferToBase64(encryptedEncryptionParams.initializationVector), - }, + ciphertext: bufferToBase64(content), + nonce: bufferToBase64(encryptionParams.initializationVector), + authenticationTag: bufferToBase64(tag), + users: await encryptRandomKeyForUsers(publicKeys, encryptionParams), } } /** * @param {1|2} encryptionVersion - The encrypted version for the folder - * @param {string} folderId - * @param {EncryptedFileMetadata[]} fileDrop + * @param {number} folderId + * @param {{[uid: string]: FileDropPayload}} fileDrops * @param {string} lockToken * @param {string} shareToken */ -export async function uploadFileDrop(encryptionVersion, folderId, fileDrop, lockToken, shareToken) { +export async function uploadFileDrop(encryptionVersion, folderId, fileDrops, lockToken, shareToken) { const ocsUrl = generateOcsUrl( 'apps/end_to_end_encryption/api/v{encryptionVersion}/meta-data/{folderId}', { @@ -100,7 +78,7 @@ export async function uploadFileDrop(encryptionVersion, folderId, fileDrop, lock const { data: { ocs: { meta } } } = await axios.put( `${ocsUrl}/filedrop`, { - fileDrop: JSON.stringify(fileDrop), + filedrop: JSON.stringify(fileDrops), }, { headers: { @@ -118,3 +96,48 @@ export async function uploadFileDrop(encryptionVersion, folderId, fileDrop, lock throw new Error(`Failed to upload metadata: ${meta.message}`) } } + +/** + * @param {string} str + * @return {Promise} + */ +async function compress(str) { + const stream = new Blob([str]).stream() + const compressedStream = stream.pipeThrough( + new CompressionStream('gzip'), + ) + + const chunks = [] + const reader = compressedStream.getReader() + + while (true) { + const { value } = await reader.read() + if (value === undefined) { + break + } + chunks.push(value) + } + + return new Uint8Array(await new Blob(chunks).arrayBuffer()) +} + +/** + * @param {{[userId: string]: string}} usersPublicKeys + * @param {import('./crypto.js').EncryptionParams} encryptionParams + * @return {Promise} + */ +async function encryptRandomKeyForUsers(usersPublicKeys, encryptionParams) { + return Promise.all(Object.entries(usersPublicKeys).map(async ([userId, publicKey]) => { + const rawKey = await window.crypto.subtle.exportKey('raw', encryptionParams.key) + const base64Key1 = bufferToBase64(rawKey) + const base64Key2 = btoa(base64Key1) + const bufferKey = new TextEncoder().encode(base64Key2) + + const encryptedFileDropKey = await encryptStringAsymmetric( + publicKey, + bufferKey, + ) + + return { userId, encryptedFiledropKey: bufferToBase64(encryptedFileDropKey) } + })) +} diff --git a/src/services/lock.js b/src/services/lock.js index 9ae02696..6cd96433 100644 --- a/src/services/lock.js +++ b/src/services/lock.js @@ -7,7 +7,7 @@ import axios from '@nextcloud/axios' /** * @param {1|2} encryptionVersion - The encrypted version for the folder * @param {number} counter - The metadata counter received from the initial state - * @param {string} fileId - The file id to lock + * @param {number} fileId - The file id to lock * @param {?string} shareToken - The optional share token if this is a file drop. * @return {Promise} lockToken */ @@ -35,7 +35,7 @@ export async function lock(encryptionVersion, counter, fileId, shareToken) { /** * @param {1|2} encryptionVersion - The encrypted version for the folder - * @param {string} fileId - The file id to lock + * @param {number} fileId - The file id to lock * @param {string} lockToken - The optional lock token if the folder was already locked. * @param {?string} shareToken - The optional share token if this is a file drop. */ diff --git a/src/views/FileDrop.vue b/src/views/FileDrop.vue index 5a101e38..a97f5385 100644 --- a/src/views/FileDrop.vue +++ b/src/views/FileDrop.vue @@ -18,9 +18,9 @@ :class="{ loading }"> {{ t('end_to_end_encryption', 'Select or drop files') }} + @change="filesChange($event.target?.files)"> @@ -43,6 +43,7 @@ import Loading from 'vue-material-design-icons/Loading' import Check from 'vue-material-design-icons/Check' import AlertCircle from 'vue-material-design-icons/AlertCircle' +import { v4 as uuidv4 } from 'uuid' import NcContent from '@nextcloud/vue/dist/Components/NcContent.js' import NcAppContent from '@nextcloud/vue/dist/Components/NcAppContent.js' @@ -51,7 +52,7 @@ import { showError } from '@nextcloud/dialogs' import { translate } from '@nextcloud/l10n' import logger from '../services/logger.js' -import { EncryptedFile } from '../services/crypto.js' +import { encryptFile } from '../services/crypto.js' import { uploadFile } from '../services/uploadFile.js' import { lock, unlock } from '../services/lock.js' import { getFileDropEntry, uploadFileDrop } from '../services/filedrop.js' @@ -75,7 +76,7 @@ const UploadStep = { * @property {File} file * @property {UploadStep} step * @property {boolean} error - * @property {Object} fileDrop + * @property {Object} fileDrop */ export default { @@ -93,11 +94,11 @@ export default { shareToken: loadState('end_to_end_encryption', 'token'), /** @type {number} */ folderId: loadState('end_to_end_encryption', 'fileId'), - /** @type {string} */ - publicKey: loadState('end_to_end_encryption', 'publicKey'), + /** @type {{[userId: string]: string}} */ + publicKeys: loadState('end_to_end_encryption', 'publicKeys'), /** @type {string} */ fileName: loadState('end_to_end_encryption', 'fileName'), - /** @type {"v1"|"v2"} */ + /** @type {1|2} */ encryptionVersion: Number.parseInt(loadState('end_to_end_encryption', 'encryptionVersion')), /** @type {number} */ counter: Number.parseInt(loadState('end_to_end_encryption', 'counter')), @@ -114,7 +115,7 @@ export default { * @param {DragEvent} event */ handleDragOver(event) { - if (!event.dataTransfer.types.includes('Files')) { + if (!event.dataTransfer?.types.includes('Files')) { return } @@ -126,7 +127,7 @@ export default { * @param {DragEvent} event */ handleDrop(event) { - if (!event.dataTransfer.types.includes('Files')) { + if (!event.dataTransfer?.types.includes('Files')) { return } @@ -135,10 +136,10 @@ export default { }, /** - * @param {FileList} fileList + * @param {FileList?} fileList */ async filesChange(fileList) { - if (!fileList.length) { + if (!fileList?.length) { return } @@ -152,11 +153,11 @@ export default { let lockToken = null try { - logger.debug('Locking the folder', { lockToken: this.lockToken, shareToken: this.shareToken }) lockToken = await lock(this.encryptionVersion, this.counter + 1, this.folderId, this.shareToken) + logger.debug(`[FileDrop] Folder locked: ${lockToken}`) } catch (exception) { - logger.error('Could not lock the folder', { exception }) - showError(t('end_to_end_encryption', 'Could not lock the folder')) + logger.error('[FileDrop] Could not lock the folder', { exception }) + showError(this.t('end_to_end_encryption', 'Could not lock the folder')) this.loading = false return } @@ -167,14 +168,14 @@ export default { .from(fileList) .map((file) => this.uploadFile(file)) ) + logger.debug('[FileDrop] Files uploaded', { progresses }) } catch (exception) { - logger.error('Error while uploading files', { exception }) - showError(t('end_to_end_encryption', 'Error while uploading files')) + logger.error('[FileDrop] Error while uploading files', { exception }) + showError(this.t('end_to_end_encryption', 'Error while uploading files')) progresses.forEach(progress => { progress.error = true }) } try { - logger.debug('Updating the fileDrop entries', { lockToken, shareToken: this.shareToken }) progresses .filter(({ error }) => !error) .forEach(progress => { progress.step = UploadStep.UPLOADING_METADATA }) @@ -183,10 +184,12 @@ export default { .filter(({ error }) => !error) .reduce((fileDropEntries, { fileDrop }) => ({ ...fileDropEntries, ...fileDrop }), {}) + logger.debug('[FileDrop] FileDrop entries computed', { fileDrops }) + await uploadFileDrop(this.encryptionVersion, this.folderId, fileDrops, lockToken, this.shareToken) } catch (exception) { - logger.error('Error while uploading metadata', { exception }) - showError(t('end_to_end_encryption', 'Error while uploading metadata')) + logger.error('[FileDrop] Error while uploading metadata', { exception }) + showError(this.t('end_to_end_encryption', 'Error while uploading metadata')) progresses.forEach(progress => { progress.error = true }) } @@ -196,13 +199,13 @@ export default { .forEach(progress => { progress.step = UploadStep.UNLOCKING }) await unlock(this.encryptionVersion, this.folderId, lockToken, this.shareToken) this.counter++ - logger.debug('Unlocking the folder', { lockToken, shareToken: this.shareToken }) + logger.debug('[FileDrop] Folder unlocked', { lockToken, shareToken: this.shareToken }) progresses .filter(({ error }) => !error) .forEach(progress => { progress.step = UploadStep.DONE }) } catch (exception) { - logger.error('Error while unlocking the folder', { exception }) - showError(t('end_to_end_encryption', 'Error while unlocking the folder')) + logger.error('[FileDrop] Error while unlocking the folder', { exception }) + showError(this.t('end_to_end_encryption', 'Error while unlocking the folder')) progresses.forEach(progress => { progress.error = true }) } @@ -215,25 +218,24 @@ export default { */ async uploadFile(unencryptedFile) { /** @type {UploadProgress} */ - const progress = { file: unencryptedFile, step: UploadStep.NONE, error: false, fileDrop: undefined } + const progress = { file: unencryptedFile, step: UploadStep.NONE, error: false, fileDrop: {} } this.uploadedFiles.push(progress) try { progress.step = UploadStep.ENCRYPTING - logger.debug('Encrypting the file', { unencryptedFile, shareToken: this.shareToken }) - const file = new EncryptedFile(unencryptedFile.name, unencryptedFile.type) - const blob = await unencryptedFile.arrayBuffer() - const { content, tag } = await file.encrypt(blob) + const { encryptedFileContent, encryptionInfo } = await encryptFile(unencryptedFile) + const encryptedFileName = uuidv4().replaceAll('-', '') - progress.fileDrop = await getFileDropEntry(file, tag, this.publicKey) + progress.fileDrop[encryptedFileName] = await getFileDropEntry(encryptionInfo, this.publicKeys) + logger.debug(`[FileDrop] Filedrop entry computed: ${unencryptedFile.name}`, { fileDropEntry: progress.fileDrop[encryptedFileName] }) progress.step = UploadStep.UPLOADING - logger.debug('Uploading the file', { unencryptedFile, shareToken: this.shareToken }) - await uploadFile('/public.php/webdav/', file.encryptedFileName, content, this.shareToken) + await uploadFile('/public.php/webdav/', encryptedFileName, encryptedFileContent, this.shareToken) progress.step = UploadStep.UPLOADED + logger.debug(`[FileDrop] File uploaded: ${unencryptedFile.name}`, { encryptedFileContent, encryptionInfo, encryptedFileName, shareToken: this.shareToken }) } catch (exception) { progress.error = true - logger.error(`Fail to upload the file (${progress.step})`, { exception }) + logger.error(`[FileDrop] Fail to upload the file (${progress.step})`, { exception }) } return progress From 861ce18e64f70ea7bc2efb0c5bb1fc69cd661e0a Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Thu, 11 Jan 2024 10:18:45 +0100 Subject: [PATCH 17/36] Add default mimetype Signed-off-by: Louis Chemineau --- src/services/crypto.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/crypto.js b/src/services/crypto.js index d7add8f2..45694ffd 100644 --- a/src/services/crypto.js +++ b/src/services/crypto.js @@ -90,7 +90,7 @@ export async function encryptFile(file) { encryptedFileContent: content, encryptionInfo: { filename: file.name, - mimetype: file.type, + mimetype: file.type || 'application/octet-stream', nonce: bufferToBase64(encryptionParams.initializationVector), key: bufferToBase64(await window.crypto.subtle.exportKey('raw', encryptionParams.key)), authenticationTag: bufferToBase64(tag), From ed8e83e0af0a39fd83f4933dbe8359b05c03f233 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Thu, 11 Jan 2024 10:19:01 +0100 Subject: [PATCH 18/36] Remove triple base64 encoding Signed-off-by: Louis Chemineau --- src/services/filedrop.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/services/filedrop.js b/src/services/filedrop.js index 895b877a..b3d02cfc 100644 --- a/src/services/filedrop.js +++ b/src/services/filedrop.js @@ -129,13 +129,10 @@ async function compress(str) { async function encryptRandomKeyForUsers(usersPublicKeys, encryptionParams) { return Promise.all(Object.entries(usersPublicKeys).map(async ([userId, publicKey]) => { const rawKey = await window.crypto.subtle.exportKey('raw', encryptionParams.key) - const base64Key1 = bufferToBase64(rawKey) - const base64Key2 = btoa(base64Key1) - const bufferKey = new TextEncoder().encode(base64Key2) const encryptedFileDropKey = await encryptStringAsymmetric( publicKey, - bufferKey, + rawKey, ) return { userId, encryptedFiledropKey: bufferToBase64(encryptedFileDropKey) } From 3b87088c5c12e3780d188a8155e05f1711a0face Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Mon, 15 Jan 2024 15:48:28 +0100 Subject: [PATCH 19/36] Use top e2ee folder to retrieve users list in public shares Signed-off-by: Louis Chemineau --- lib/E2EEPublicShareTemplateProvider.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/E2EEPublicShareTemplateProvider.php b/lib/E2EEPublicShareTemplateProvider.php index d77a0515..eadc91f4 100644 --- a/lib/E2EEPublicShareTemplateProvider.php +++ b/lib/E2EEPublicShareTemplateProvider.php @@ -45,7 +45,12 @@ public function renderPage(IShare $share, string $token, string $path): Template return new TemplateResponse(Application::APP_ID, 'error'); } - $rawMetadata = $this->metadataStorage->getMetaData($owner->getUID(), $shareNode->getId()); + $topE2eeFolder = $shareNode; + while ($topE2eeFolder->getParent()->isEncrypted()) { + $topE2eeFolder = $shareNode->getParent(); + } + + $rawMetadata = $this->metadataStorage->getMetaData($owner->getUID(), $topE2eeFolder->getId()); $metadata = json_decode($rawMetadata, true); $userIds = array_map(fn (array $userEntry): string => $userEntry['userId'], $metadata['users']); @@ -63,8 +68,6 @@ function (array $acc, string $userId): array { return new TemplateResponse(Application::APP_ID, 'error'); } - $metadata = json_decode($this->metadataStorage->getMetaData($owner->getUID(), $shareNode->getId()), true); - $this->initialState->provideInitialState('publicKeys', $publicKeys); $this->initialState->provideInitialState('fileId', $shareNode->getId()); $this->initialState->provideInitialState('token', $token); From 1d62c0746803ec42c7e0e787c05fc30f72268c8d Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 16 Jan 2024 16:56:14 +0100 Subject: [PATCH 20/36] Correctly validate lock state Signed-off-by: Louis Chemineau --- lib/Connector/Sabre/LockPlugin.php | 2 +- lib/Controller/MetaDataController.php | 8 ++++---- lib/Controller/V1/MetaDataController.php | 4 ++-- lib/LockManager.php | 8 ++++++-- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/lib/Connector/Sabre/LockPlugin.php b/lib/Connector/Sabre/LockPlugin.php index 5369c6f3..2ad1680f 100644 --- a/lib/Connector/Sabre/LockPlugin.php +++ b/lib/Connector/Sabre/LockPlugin.php @@ -162,7 +162,7 @@ protected function verifyTokenOnWriteAccess(INode $node, ?string $token): void { throw new Forbidden('Write access to end-to-end encrypted folder requires token - no token sent'); } - if ($this->lockManager->isLocked($node->getId(), $token)) { + if ($this->lockManager->isLocked($node->getId(), $token, null, true)) { throw new FileLocked('Write access to end-to-end encrypted folder requires token - resource not locked or wrong token sent', Http::STATUS_FORBIDDEN); } } diff --git a/lib/Controller/MetaDataController.php b/lib/Controller/MetaDataController.php index f11d2c84..a5c3d5eb 100644 --- a/lib/Controller/MetaDataController.php +++ b/lib/Controller/MetaDataController.php @@ -120,7 +120,7 @@ public function setMetaData(int $id, string $metaData): DataResponse { throw new OCSPreconditionFailedException($this->l10n->t('X-NC-E2EE-SIGNATURE is empty')); } - if ($this->lockManager->isLocked($id, $e2eToken)) { + if ($this->lockManager->isLocked($id, $e2eToken, null, true)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } @@ -159,7 +159,7 @@ public function updateMetaData(int $id, string $metaData): DataResponse { throw new OCSPreconditionFailedException($this->l10n->t('X-NC-E2EE-SIGNATURE is empty')); } - if ($this->lockManager->isLocked($id, $e2eToken)) { + if ($this->lockManager->isLocked($id, $e2eToken, null, true)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } @@ -196,7 +196,7 @@ public function deleteMetaData(int $id): DataResponse { throw new OCSPreconditionFailedException($this->l10n->t('e2e-token is empty')); } - if ($this->lockManager->isLocked($id, $e2eToken)) { + if ($this->lockManager->isLocked($id, $e2eToken, null, true)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } @@ -232,7 +232,7 @@ public function addMetadataFileDrop(int $id, string $filedrop, ?string $shareTok throw new OCSPreconditionFailedException($this->l10n->t('e2e-token is empty')); } - if ($this->lockManager->isLocked($id, $e2eToken, $ownerId)) { + if ($this->lockManager->isLocked($id, $e2eToken, $ownerId, true)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } diff --git a/lib/Controller/V1/MetaDataController.php b/lib/Controller/V1/MetaDataController.php index f5de3156..6aa499e0 100644 --- a/lib/Controller/V1/MetaDataController.php +++ b/lib/Controller/V1/MetaDataController.php @@ -136,7 +136,7 @@ public function updateMetaData(int $id, string $metaData): DataResponse { } // End - if ($this->lockManager->isLocked($id, $e2eToken)) { + if ($this->lockManager->isLocked($id, $e2eToken, null, true)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } @@ -195,7 +195,7 @@ public function addMetadataFileDrop(int $id, string $fileDrop, ?string $shareTok $e2eToken = $this->request->getParam('e2e-token'); $ownerId = $this->getOwnerId($shareToken); - if ($this->lockManager->isLocked($id, $e2eToken, $ownerId)) { + if ($this->lockManager->isLocked($id, $e2eToken, $ownerId, true)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } diff --git a/lib/LockManager.php b/lib/LockManager.php index e1dfa069..f0c14238 100644 --- a/lib/LockManager.php +++ b/lib/LockManager.php @@ -125,7 +125,7 @@ public function unlockFile(int $id, string $token): void { * @throws NotFoundException * @throws \OCP\Files\NotPermittedException */ - public function isLocked(int $id, string $token, ?string $ownerId = null): bool { + public function isLocked(int $id, string $token, ?string $ownerId = null, bool $requireLock = false): bool { if ($ownerId === null) { $user = $this->userSession->getUser(); if ($user === null) { @@ -134,6 +134,8 @@ public function isLocked(int $id, string $token, ?string $ownerId = null): bool $ownerId = $user->getUid(); } + $lockedByGivenToken = false; + $userRoot = $this->rootFolder->getUserFolder($ownerId); $nodes = $userRoot->getById($id); foreach ($nodes as $node) { @@ -149,6 +151,8 @@ public function isLocked(int $id, string $token, ?string $ownerId = null): bool // If it's locked with a different token, return true if ($lock->getToken() !== $token) { return true; + } else { + $lockedByGivenToken = true; } // If it's locked with the expected token, check the parent node @@ -156,7 +160,7 @@ public function isLocked(int $id, string $token, ?string $ownerId = null): bool } } - return false; + return $requireLock && !$lockedByGivenToken; } From 9463aadb1a4415f51f1362bf4780715a9e9f0fff Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 16 Jan 2024 17:17:23 +0100 Subject: [PATCH 21/36] Run cs:fix Signed-off-by: Louis Chemineau --- lib/Controller/LockingController.php | 4 +-- lib/Controller/MetaDataController.php | 2 +- lib/Controller/V1/LockingController.php | 4 +-- lib/Controller/V1/MetaDataController.php | 2 +- lib/LockManagerV1.php | 8 ++--- lib/MetaDataStorageV1.php | 2 +- lib/RollbackServiceV1.php | 14 ++++---- .../Controller/MetaDataControllerV1Test.php | 32 +++++++++---------- tests/Unit/MetaDataStorageV1Test.php | 12 +++---- tests/Unit/RollbackServiceV1Test.php | 2 +- 10 files changed, 41 insertions(+), 41 deletions(-) diff --git a/lib/Controller/LockingController.php b/lib/Controller/LockingController.php index 6403338e..11f53dec 100644 --- a/lib/Controller/LockingController.php +++ b/lib/Controller/LockingController.php @@ -37,8 +37,10 @@ use OCA\EndToEndEncryption\LockManager; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; +use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\AppFramework\OCS\OCSPreconditionFailedException; use OCP\AppFramework\OCSController; use OCP\Files\Folder; use OCP\Files\IRootFolder; @@ -46,8 +48,6 @@ use OCP\IRequest; use OCP\Share\IManager as ShareManager; use Psr\Log\LoggerInterface; -use OCP\AppFramework\OCS\OCSPreconditionFailedException; -use OCP\AppFramework\OCS\OCSBadRequestException; class LockingController extends OCSController { private ?string $userId; diff --git a/lib/Controller/MetaDataController.php b/lib/Controller/MetaDataController.php index a5c3d5eb..8bde3125 100644 --- a/lib/Controller/MetaDataController.php +++ b/lib/Controller/MetaDataController.php @@ -35,10 +35,10 @@ use OCA\EndToEndEncryption\LockManager; use OCP\AppFramework\Http; use OCP\AppFramework\Http\DataResponse; -use OCP\AppFramework\OCS\OCSPreconditionFailedException; use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\AppFramework\OCS\OCSPreconditionFailedException; use OCP\AppFramework\OCSController; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; diff --git a/lib/Controller/V1/LockingController.php b/lib/Controller/V1/LockingController.php index 0d9518a9..c36c38e8 100644 --- a/lib/Controller/V1/LockingController.php +++ b/lib/Controller/V1/LockingController.php @@ -37,6 +37,7 @@ use OCA\EndToEndEncryption\IMetaDataStorageV1; use OCA\EndToEndEncryption\LockManagerV1; use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; @@ -44,9 +45,8 @@ use OCP\Files\IRootFolder; use OCP\IL10N; use OCP\IRequest; -use Psr\Log\LoggerInterface; use OCP\Share\IManager as ShareManager; -use OCP\AppFramework\OCS\OCSBadRequestException; +use Psr\Log\LoggerInterface; class LockingController extends OCSController { private ?string $userId; diff --git a/lib/Controller/V1/MetaDataController.php b/lib/Controller/V1/MetaDataController.php index 6aa499e0..4d80ddfd 100644 --- a/lib/Controller/V1/MetaDataController.php +++ b/lib/Controller/V1/MetaDataController.php @@ -43,8 +43,8 @@ use OCP\Files\NotPermittedException; use OCP\IL10N; use OCP\IRequest; -use Psr\Log\LoggerInterface; use OCP\Share\IManager as ShareManager; +use Psr\Log\LoggerInterface; class MetaDataController extends OCSController { private ?string $userId; diff --git a/lib/LockManagerV1.php b/lib/LockManagerV1.php index 285ead6c..21bd860b 100644 --- a/lib/LockManagerV1.php +++ b/lib/LockManagerV1.php @@ -50,10 +50,10 @@ class LockManagerV1 { private ITimeFactory $timeFactory; public function __construct(LockMapper $lockMapper, - ISecureRandom $secureRandom, - IRootFolder $rootFolder, - IUserSession $userSession, - ITimeFactory $timeFactory + ISecureRandom $secureRandom, + IRootFolder $rootFolder, + IUserSession $userSession, + ITimeFactory $timeFactory ) { $this->lockMapper = $lockMapper; $this->secureRandom = $secureRandom; diff --git a/lib/MetaDataStorageV1.php b/lib/MetaDataStorageV1.php index 398fdb71..7a99cd5b 100644 --- a/lib/MetaDataStorageV1.php +++ b/lib/MetaDataStorageV1.php @@ -46,7 +46,7 @@ class MetaDataStorageV1 implements IMetaDataStorageV1 { private string $intermediateMetaDataFileName = 'intermediate.meta.data'; public function __construct(IAppData $appData, - IRootFolder $rootFolder) { + IRootFolder $rootFolder) { $this->appData = $appData; $this->rootFolder = $rootFolder; } diff --git a/lib/RollbackServiceV1.php b/lib/RollbackServiceV1.php index e02c2312..ba197745 100644 --- a/lib/RollbackServiceV1.php +++ b/lib/RollbackServiceV1.php @@ -22,11 +22,11 @@ */ namespace OCA\EndToEndEncryption; +use OCA\EndToEndEncryption\AppInfo\Application; +use OCA\EndToEndEncryption\Db\LockMapper; use OCP\Files\Config\ICachedMountFileInfo; use OCP\Files\Config\IUserMountCache; use OCP\Files\Folder; -use OCA\EndToEndEncryption\AppInfo\Application; -use OCA\EndToEndEncryption\Db\LockMapper; use OCP\Files\IRootFolder; use Psr\Log\LoggerInterface; @@ -55,11 +55,11 @@ class RollbackServiceV1 { private LoggerInterface $logger; public function __construct(LockMapper $lockMapper, - IMetaDataStorageV1 $metaDataStorage, - FileService $fileService, - IUserMountCache $userMountCache, - IRootFolder $rootFolder, - LoggerInterface $logger) { + IMetaDataStorageV1 $metaDataStorage, + FileService $fileService, + IUserMountCache $userMountCache, + IRootFolder $rootFolder, + LoggerInterface $logger) { $this->lockMapper = $lockMapper; $this->metaDataStorage = $metaDataStorage; $this->fileService = $fileService; diff --git a/tests/Unit/Controller/MetaDataControllerV1Test.php b/tests/Unit/Controller/MetaDataControllerV1Test.php index 7fdf91cb..fd2515f2 100644 --- a/tests/Unit/Controller/MetaDataControllerV1Test.php +++ b/tests/Unit/Controller/MetaDataControllerV1Test.php @@ -36,9 +36,9 @@ use OCP\Files\NotPermittedException; use OCP\IL10N; use OCP\IRequest; +use OCP\Share\IManager as ShareManager; use Psr\Log\LoggerInterface; use Test\TestCase; -use OCP\Share\IManager as ShareManager; class MetaDataControllerV1Test extends TestCase { @@ -104,9 +104,9 @@ protected function setUp(): void { * @dataProvider getMetaDataDataProvider */ public function testGetMetaData(?\Exception $metaDataStorageException, - ?string $expectedException, - ?string $expectedExceptionMessage, - bool $expectLogger): void { + ?string $expectedException, + ?string $expectedExceptionMessage, + bool $expectLogger): void { $fileId = 42; $metaData = 'JSON-ENCODED-META-DATA'; if ($metaDataStorageException) { @@ -166,11 +166,11 @@ public function getMetaDataDataProvider(): array { * @dataProvider setMetaDataDataProvider */ public function testSetMetaData(?\Exception $metaDataStorageException, - ?string $expectedException, - ?string $expectedExceptionMessage, - bool $expectLogger, - ?array $expectedResponseData, - ?int $expectedResponseCode): void { + ?string $expectedException, + ?string $expectedExceptionMessage, + bool $expectLogger, + ?array $expectedResponseData, + ?int $expectedResponseCode): void { $fileId = 42; $metaData = 'JSON-ENCODED-META-DATA'; if ($metaDataStorageException) { @@ -228,10 +228,10 @@ public function setMetaDataDataProvider(): array { * @dataProvider updateMetaDataDataProvider */ public function testUpdateMetaData(bool $isLocked, - ?\Exception $metaDataStorageException, - ?string $expectedException, - ?string $expectedExceptionMessage, - bool $expectLogger): void { + ?\Exception $metaDataStorageException, + ?string $expectedException, + ?string $expectedExceptionMessage, + bool $expectLogger): void { $fileId = 42; $sendToken = 'sendE2EToken'; $metaData = 'JSON-ENCODED-META-DATA'; @@ -303,9 +303,9 @@ public function updateMetaDataDataProvider(): array { * @dataProvider deleteMetaDataDataProvider */ public function testDeleteMetaData(?\Exception $metaDataStorageException, - ?string $expectedException, - ?string $expectedExceptionMessage, - bool $expectLogger): void { + ?string $expectedException, + ?string $expectedExceptionMessage, + bool $expectLogger): void { $fileId = 42; if ($metaDataStorageException) { $this->metaDataStorage->expects($this->once()) diff --git a/tests/Unit/MetaDataStorageV1Test.php b/tests/Unit/MetaDataStorageV1Test.php index f6d58ceb..b1438f9f 100644 --- a/tests/Unit/MetaDataStorageV1Test.php +++ b/tests/Unit/MetaDataStorageV1Test.php @@ -23,6 +23,7 @@ namespace OCA\EndToEndEncryption\Tests\Unit; +use Exception; use OC\User\NoUserException; use OCA\EndToEndEncryption\Exceptions\MetaDataExistsException; use OCA\EndToEndEncryption\Exceptions\MissingMetaDataException; @@ -35,7 +36,6 @@ use OCP\Files\SimpleFS\ISimpleFile; use OCP\Files\SimpleFS\ISimpleFolder; use Test\TestCase; -use Exception; class MetaDataStorageV1Test extends TestCase { @@ -679,9 +679,9 @@ public function verifyFolderStructureDataProvider(): array { * @dataProvider getLegacyFileDataProvider */ public function testGetLegacyFile(?Exception $legacyOwnerException, - ?Exception $getFolderException, - ?Exception $getFileException, - bool $expectsNull): void { + ?Exception $getFolderException, + ?Exception $getFileException, + bool $expectsNull): void { $metaDataStorage = $this->getMockBuilder(MetaDataStorageV1::class) ->setMethods([ 'getLegacyOwnerPath', @@ -755,8 +755,8 @@ public function getLegacyFileDataProvider(): array { * @dataProvider cleanupLegacyFileDataProvider */ public function testCleanupLegacyFile(?Exception $legacyOwnerException, - ?Exception $getFolderException, - bool $expectsDelete): void { + ?Exception $getFolderException, + bool $expectsDelete): void { $metaDataStorage = $this->getMockBuilder(MetaDataStorageV1::class) ->setMethods([ 'getLegacyOwnerPath', diff --git a/tests/Unit/RollbackServiceV1Test.php b/tests/Unit/RollbackServiceV1Test.php index bd59429e..86a350d0 100644 --- a/tests/Unit/RollbackServiceV1Test.php +++ b/tests/Unit/RollbackServiceV1Test.php @@ -32,8 +32,8 @@ use OCP\Files\Config\IUserMountCache; use OCP\Files\Folder; use OCP\Files\IRootFolder; -use Psr\Log\LoggerInterface; use OCP\IUser; +use Psr\Log\LoggerInterface; use Test\TestCase; class RollbackServiceV1Test extends TestCase { From cd7c4bd3ffb3c163e566a4810ec8d363943252c1 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 16 Jan 2024 17:28:03 +0100 Subject: [PATCH 22/36] Fix psalm errors Signed-off-by: Louis Chemineau --- lib/LockManagerV1.php | 9 +++++++-- lib/MetaDataStorage.php | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/lib/LockManagerV1.php b/lib/LockManagerV1.php index 21bd860b..1100b63a 100644 --- a/lib/LockManagerV1.php +++ b/lib/LockManagerV1.php @@ -111,7 +111,7 @@ public function unlockFile(int $id, string $token): void { * @throws NotFoundException * @throws \OCP\Files\NotPermittedException */ - public function isLocked(int $id, string $token, ?string $ownerId = null): bool { + public function isLocked(int $id, string $token, ?string $ownerId = null, bool $requireLock = false): bool { if ($ownerId === null) { $user = $this->userSession->getUser(); if ($user === null) { @@ -120,6 +120,8 @@ public function isLocked(int $id, string $token, ?string $ownerId = null): bool $ownerId = $user->getUid(); } + $lockedByGivenToken = false; + $userRoot = $this->rootFolder->getUserFolder($ownerId); $nodes = $userRoot->getById($id); foreach ($nodes as $node) { @@ -135,6 +137,8 @@ public function isLocked(int $id, string $token, ?string $ownerId = null): bool // If it's locked with a different token, return true if ($lock->getToken() !== $token) { return true; + } else { + $lockedByGivenToken = true; } // If it's locked with the expected token, check the parent node @@ -142,10 +146,11 @@ public function isLocked(int $id, string $token, ?string $ownerId = null): bool } } - return false; + return $requireLock && !$lockedByGivenToken; } + /** * Generate a new token */ diff --git a/lib/MetaDataStorage.php b/lib/MetaDataStorage.php index 18e9f0ea..b6a234b2 100644 --- a/lib/MetaDataStorage.php +++ b/lib/MetaDataStorage.php @@ -402,7 +402,7 @@ public function getCounter(int $id): int { */ public function saveIntermediateCounter(int $id, int $counter): void { $metadataFolder = $this->appData->getFolder($this->getFolderNameForFileId($id)); - $metadataFolder->newFile($this->intermediateMetaDataCounterFileName)->putContent($counter); + $metadataFolder->newFile($this->intermediateMetaDataCounterFileName)->putContent((string)$counter); } /** From 8f25f615be050eab776b297b4ff361283cf59f9f Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 16 Jan 2024 17:30:23 +0100 Subject: [PATCH 23/36] Fix lint error Signed-off-by: Louis Chemineau --- lib/Controller/LockingController.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/Controller/LockingController.php b/lib/Controller/LockingController.php index 11f53dec..7465dcf5 100644 --- a/lib/Controller/LockingController.php +++ b/lib/Controller/LockingController.php @@ -37,7 +37,6 @@ use OCA\EndToEndEncryption\LockManager; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\OCS\OCSBadRequestException; -use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCS\OCSPreconditionFailedException; From c151738bb055e382d047735107d4778e2e7bb803 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 16 Jan 2024 17:46:21 +0100 Subject: [PATCH 24/36] Fix phpUnit tests Signed-off-by: Louis Chemineau --- tests/Unit/Connector/Sabre/LockPluginTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Connector/Sabre/LockPluginTest.php b/tests/Unit/Connector/Sabre/LockPluginTest.php index 1cc99903..b2d3eb87 100644 --- a/tests/Unit/Connector/Sabre/LockPluginTest.php +++ b/tests/Unit/Connector/Sabre/LockPluginTest.php @@ -469,8 +469,8 @@ public function testCheckLockForWriteCopyMove(string $method, $this->lockManager->method('isLocked') ->willReturnMap([ - [42, $token, null, $isSrcLocked], - [1337, $token, null, $isDestLocked], + [42, $token, null, true, $isSrcLocked], + [1337, $token, null, true, $isDestLocked], ]); $server = $this->createMock(Server::class); From a183add969b7be557ba5c9e764ae73c63662748c Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 17 Jan 2024 10:26:53 +0100 Subject: [PATCH 25/36] Fix eslint error Signed-off-by: Louis Chemineau --- src/services/filedrop.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/services/filedrop.js b/src/services/filedrop.js index b3d02cfc..3b7c8d55 100644 --- a/src/services/filedrop.js +++ b/src/services/filedrop.js @@ -104,6 +104,7 @@ export async function uploadFileDrop(encryptionVersion, folderId, fileDrops, loc async function compress(str) { const stream = new Blob([str]).stream() const compressedStream = stream.pipeThrough( + // eslint-disable-next-line no-undef new CompressionStream('gzip'), ) From 0e06e46db2459f418da3f6695279d4f771389048 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 17 Jan 2024 11:43:37 +0100 Subject: [PATCH 26/36] Fix php unit tests Signed-off-by: Louis Chemineau --- tests/Unit/LockManagerTest.php | 6 +-- tests/Unit/MetaDataStorageTest.php | 74 +++++++++++++++++++++--------- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/tests/Unit/LockManagerTest.php b/tests/Unit/LockManagerTest.php index 53a6e37a..28f1605a 100644 --- a/tests/Unit/LockManagerTest.php +++ b/tests/Unit/LockManagerTest.php @@ -137,9 +137,9 @@ public function testLock(bool $isLocked, bool $lockDoesNotExist, int $counter, s if ($expectNewToken) { $this->metaDataStorage->expects($this->once()) - ->method('getMetaData') - ->with('userId', 42) - ->willReturn('{"counter": 0}'); + ->method('getCounter') + ->with() + ->willReturn(0); if ($counter > 0) { $this->secureRandom->expects($this->once()) diff --git a/tests/Unit/MetaDataStorageTest.php b/tests/Unit/MetaDataStorageTest.php index 0dda7443..af28f10f 100644 --- a/tests/Unit/MetaDataStorageTest.php +++ b/tests/Unit/MetaDataStorageTest.php @@ -449,15 +449,28 @@ public function testSaveIntermediateFile(bool $folderExists, bool $intermediateF if ($folderExists) { $metaDataFolder = $this->createMock(ISimpleFolder::class); - $this->appData->expects($this->once()) - ->method('getFolder') - ->with('/meta-data/42') - ->willReturn($metaDataFolder); - $metaDataFolder->expects($this->once()) - ->method('fileExists') - ->with('intermediate.meta.data') - ->willReturn($intermediateFileExists); + if ($intermediateFileIsEmpty || !$intermediateFileExists) { + $this->appData->expects($this->once()) + ->method('getFolder') + ->with('/meta-data/42') + ->willReturn($metaDataFolder); + + $metaDataFolder->expects($this->once()) + ->method('fileExists') + ->with('intermediate.meta.data') + ->willReturn($intermediateFileExists); + } else { + $this->appData->expects($this->exactly(2)) + ->method('getFolder') + ->with('/meta-data/42') + ->willReturn($metaDataFolder); + + $metaDataFolder->expects($this->exactly(3)) + ->method('fileExists') + ->withConsecutive(['intermediate.meta.data'], ['intermediate.meta.data.signature'], ['intermediate.meta.data.counter']) + ->willReturn($intermediateFileExists); + } if ($intermediateFileExists) { $intermediateFile = $this->createMock(ISimpleFile::class); @@ -493,26 +506,38 @@ public function testSaveIntermediateFile(bool $folderExists, bool $intermediateF ->method('putContent') ->with('signature'); + $intermediateCounterFile = $this->createMock(ISimpleFile::class); + $intermediateCounterFile->expects($this->once()) + ->method('getContent') + ->willReturn('1'); + + $counterFile = $this->createMock(ISimpleFile::class); + $counterFile->expects($this->once()) + ->method('putContent') + ->with('1'); + if ($finalFileExists) { - $metaDataFolder->expects($this->exactly(4)) + $metaDataFolder->expects($this->exactly(6)) ->method('getFile') - ->withConsecutive(['intermediate.meta.data'], ['meta.data'], ['intermediate.meta.data.signature'], ['meta.data.signature']) - ->willReturnOnConsecutiveCalls($intermediateFile, $finalFile, $intermediateSignatureFile, $signatureFile); + ->withConsecutive(['intermediate.meta.data'], ['meta.data'], ['intermediate.meta.data.signature'], ['meta.data.signature'], ['intermediate.meta.data.counter'], ['meta.data.counter']) + ->willReturnOnConsecutiveCalls($intermediateFile, $finalFile, $intermediateSignatureFile, $signatureFile, $intermediateCounterFile, $counterFile); } else { - $metaDataFolder->expects($this->exactly(4)) + $metaDataFolder->expects($this->exactly(6)) ->method('getFile') - ->withConsecutive(['intermediate.meta.data'], ['meta.data'], ['intermediate.meta.data.signature'], ['meta.data.signature']) + ->withConsecutive(['intermediate.meta.data'], ['meta.data'], ['intermediate.meta.data.signature'], ['meta.data.signature'], ['intermediate.meta.data.counter'], ['meta.data.counter']) ->willReturnOnConsecutiveCalls( $intermediateFile, $this->throwException(new NotFoundException()), $intermediateSignatureFile, $this->throwException(new NotFoundException()), + $intermediateCounterFile, + $this->throwException(new NotFoundException()), ); - $metaDataFolder->expects($this->exactly(2)) + $metaDataFolder->expects($this->exactly(3)) ->method('newFile') - ->withConsecutive(['meta.data'], ['meta.data.signature']) - ->willReturn($finalFile, $signatureFile); + ->withConsecutive(['meta.data'], ['meta.data.signature'], ['meta.data.counter']) + ->willReturn($finalFile, $signatureFile, $counterFile); } $intermediateFile->expects($this->once()) @@ -520,6 +545,9 @@ public function testSaveIntermediateFile(bool $folderExists, bool $intermediateF $intermediateSignatureFile->expects($this->once()) ->method('delete'); + + $intermediateCounterFile->expects($this->once()) + ->method('delete'); } $metaDataStorage->expects($this->once()) @@ -583,9 +611,9 @@ public function testDeleteIntermediateFile(bool $folderExists, bool $fileExists) ->with('/meta-data/42') ->willReturn($metaDataFolder); - $metaDataFolder->expects($this->once()) + $metaDataFolder->expects($this->exactly(2)) ->method('fileExists') - ->with('intermediate.meta.data') + ->withConsecutive(['intermediate.meta.data'], ['intermediate.meta.data.counter']) ->willReturn($fileExists); if ($fileExists) { @@ -593,10 +621,14 @@ public function testDeleteIntermediateFile(bool $folderExists, bool $fileExists) $intermediateFile->expects($this->once()) ->method('delete'); - $metaDataFolder->expects($this->once()) + $intermediateCounterFile = $this->createMock(ISimpleFile::class); + $intermediateCounterFile->expects($this->once()) + ->method('delete'); + + $metaDataFolder->expects($this->exactly(2)) ->method('getFile') - ->with('intermediate.meta.data') - ->willReturn($intermediateFile); + ->withConsecutive(['intermediate.meta.data'], ['intermediate.meta.data.counter']) + ->willReturnOnConsecutiveCalls($intermediateFile, $intermediateCounterFile); } } From daa6e6d792e52dc0284fc127c389b286d7a86db4 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 17 Jan 2024 15:10:43 +0100 Subject: [PATCH 27/36] Expose all routes to v2 Signed-off-by: Louis Chemineau --- appinfo/routes.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/appinfo/routes.php b/appinfo/routes.php index c6d658d4..5bb77484 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -27,21 +27,21 @@ return [ 'ocs' => [ # v1 - ['name' => 'Key#setPrivateKey', 'url' => '/api/v1/private-key', 'verb' => 'POST'], - ['name' => 'Key#getPrivateKey', 'url' => '/api/v1/private-key', 'verb' => 'GET'], - ['name' => 'Key#deletePrivateKey', 'url' => '/api/v1/private-key', 'verb' => 'DELETE'], - ['name' => 'Key#createPublicKey', 'url' => '/api/v1/public-key', 'verb' => 'POST'], - ['name' => 'Key#getPublicKeys', 'url' => '/api/v1/public-key', 'verb' => 'GET'], - ['name' => 'Key#deletePublicKey', 'url' => '/api/v1/public-key', 'verb' => 'DELETE'], - ['name' => 'Key#getPublicServerKey', 'url' => '/api/v1/server-key', 'verb' => 'GET'], + ['name' => 'Key#setPrivateKey', 'url' => '/api/v{apiVersion}/private-key', 'verb' => 'POST', 'requirements' => array('apiVersion' => '[1-2]')], + ['name' => 'Key#getPrivateKey', 'url' => '/api/v{apiVersion}/private-key', 'verb' => 'GET', 'requirements' => array('apiVersion' => '[1-2]')], + ['name' => 'Key#deletePrivateKey', 'url' => '/api/v{apiVersion}/private-key', 'verb' => 'DELETE', 'requirements' => array('apiVersion' => '[1-2]')], + ['name' => 'Key#createPublicKey', 'url' => '/api/v{apiVersion}/public-key', 'verb' => 'POST', 'requirements' => array('apiVersion' => '[1-2]')], + ['name' => 'Key#getPublicKeys', 'url' => '/api/v{apiVersion}/public-key', 'verb' => 'GET', 'requirements' => array('apiVersion' => '[1-2]')], + ['name' => 'Key#deletePublicKey', 'url' => '/api/v{apiVersion}/public-key', 'verb' => 'DELETE', 'requirements' => array('apiVersion' => '[1-2]')], + ['name' => 'Key#getPublicServerKey', 'url' => '/api/v{apiVersion}/server-key', 'verb' => 'GET', 'requirements' => array('apiVersion' => '[1-2]')], ['name' => 'V1\MetaData#setMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'POST'], ['name' => 'V1\MetaData#getMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'GET'], ['name' => 'V1\MetaData#updateMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'PUT'], ['name' => 'V1\MetaData#deleteMetaData', 'url' => '/api/v1/meta-data/{id}', 'verb' => 'DELETE'], ['name' => 'V1\MetaData#addMetadataFileDrop', 'url' => '/api/v1/meta-data/{id}/filedrop', 'verb' => 'PUT'], - ['name' => 'Encryption#removeEncryptedFolders', 'url' => '/api/v1/encrypted-files', 'verb' => 'DELETE'], - ['name' => 'Encryption#setEncryptionFlag', 'url' => '/api/v1/encrypted/{id}', 'verb' => 'PUT'], - ['name' => 'Encryption#removeEncryptionFlag', 'url' => '/api/v1/encrypted/{id}', 'verb' => 'DELETE'], + ['name' => 'Encryption#removeEncryptedFolders', 'url' => '/api/v{apiVersion}/encrypted-files', 'verb' => 'DELETE', 'requirements' => array('apiVersion' => '[1-2]')], + ['name' => 'Encryption#setEncryptionFlag', 'url' => '/api/v{apiVersion}/encrypted/{id}', 'verb' => 'PUT', 'requirements' => array('apiVersion' => '[1-2]')], + ['name' => 'Encryption#removeEncryptionFlag', 'url' => '/api/v{apiVersion}/encrypted/{id}', 'verb' => 'DELETE', 'requirements' => array('apiVersion' => '[1-2]')], ['name' => 'V1\Locking#lockFolder', 'url' => '/api/v1/lock/{id}', 'verb' => 'POST'], ['name' => 'V1\Locking#unlockFolder', 'url' => '/api/v1/lock/{id}', 'verb' => 'DELETE'], # v2 From 26001924b6cfbdce2b3c1d57806ddba406befb64 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Thu, 18 Jan 2024 11:24:36 +0100 Subject: [PATCH 28/36] Move folder lock to the backend for filedrops Signed-off-by: Louis Chemineau --- lib/Controller/MetaDataController.php | 34 +++++++++++--- lib/Controller/V1/MetaDataController.php | 32 +++++++++++-- lib/E2EEPublicShareTemplateProvider.php | 1 - lib/LockManager.php | 15 +++--- src/services/filedrop.js | 5 +- src/services/lock.js | 59 ------------------------ src/views/FileDrop.vue | 37 ++------------- 7 files changed, 70 insertions(+), 113 deletions(-) delete mode 100644 src/services/lock.js diff --git a/lib/Controller/MetaDataController.php b/lib/Controller/MetaDataController.php index 8bde3125..049c46af 100644 --- a/lib/Controller/MetaDataController.php +++ b/lib/Controller/MetaDataController.php @@ -29,6 +29,7 @@ namespace OCA\EndToEndEncryption\Controller; +use OC\User\NoUserException; use OCA\EndToEndEncryption\Exceptions\MetaDataExistsException; use OCA\EndToEndEncryption\Exceptions\MissingMetaDataException; use OCA\EndToEndEncryption\IMetaDataStorage; @@ -40,6 +41,8 @@ use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCS\OCSPreconditionFailedException; use OCP\AppFramework\OCSController; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IL10N; @@ -63,7 +66,8 @@ public function __construct( LockManager $lockManager, LoggerInterface $logger, IL10N $l10n, - ShareManager $shareManager + ShareManager $shareManager, + private IRootFolder $rootFolder, ) { parent::__construct($AppName, $request); $this->userId = $userId; @@ -225,15 +229,28 @@ public function deleteMetaData(int $id): DataResponse { * @throws OCSNotFoundException */ public function addMetadataFileDrop(int $id, string $filedrop, ?string $shareToken = null): DataResponse { - $e2eToken = $this->request->getHeader('e2e-token'); $ownerId = $this->getOwnerId($shareToken); - if ($e2eToken === '') { - throw new OCSPreconditionFailedException($this->l10n->t('e2e-token is empty')); + try { + $userFolder = $this->rootFolder->getUserFolder($ownerId); + } catch (NoUserException $e) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to create the lock')); } - if ($this->lockManager->isLocked($id, $e2eToken, $ownerId, true)) { - throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); + if ($userFolder->getId() === $id) { + $e = new OCSForbiddenException($this->l10n->t('You are not allowed to lock the root')); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw $e; + } + + $nodes = $userFolder->getById($id); + if (!isset($nodes[0]) || !$nodes[0] instanceof Folder) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to create the lock')); + } + + $lockToken = $this->lockManager->lockFile($id, 'filedrop-lock', 0, $ownerId, true); + if ($lockToken === null) { + throw new OCSForbiddenException($this->l10n->t('File already locked')); } try { @@ -244,7 +261,8 @@ public function addMetadataFileDrop(int $id, string $filedrop, ?string $shareTok $decodedMetadata['filedrop'] = $fileDropArray; $encodedMetadata = json_encode($decodedMetadata); - $this->metaDataStorage->updateMetaDataIntoIntermediateFile($ownerId, $id, $encodedMetadata, $e2eToken); + $this->metaDataStorage->updateMetaDataIntoIntermediateFile($ownerId, $id, $encodedMetadata, 'filedrop-lock'); + $this->metaDataStorage->saveIntermediateFile($ownerId, $id); } catch (MissingMetaDataException $e) { throw new OCSNotFoundException($this->l10n->t('Metadata-file does not exist')); } catch (NotFoundException $e) { @@ -252,6 +270,8 @@ public function addMetadataFileDrop(int $id, string $filedrop, ?string $shareTok } catch (\Exception $e) { $this->logger->critical($e->getMessage(), ['exception' => $e, 'app' => $this->appName]); throw new OCSBadRequestException($this->l10n->t('Cannot update filedrop')); + } finally { + $this->lockManager->unlockFile($id, $lockToken); } return new DataResponse(); diff --git a/lib/Controller/V1/MetaDataController.php b/lib/Controller/V1/MetaDataController.php index 4d80ddfd..bc114efd 100644 --- a/lib/Controller/V1/MetaDataController.php +++ b/lib/Controller/V1/MetaDataController.php @@ -29,6 +29,7 @@ namespace OCA\EndToEndEncryption\Controller\V1; +use OC\User\NoUserException; use OCA\EndToEndEncryption\Exceptions\MetaDataExistsException; use OCA\EndToEndEncryption\Exceptions\MissingMetaDataException; use OCA\EndToEndEncryption\IMetaDataStorageV1; @@ -39,6 +40,8 @@ use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; use OCP\AppFramework\OCSController; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IL10N; @@ -62,7 +65,8 @@ public function __construct( LockManagerV1 $lockManager, LoggerInterface $logger, IL10N $l10n, - ShareManager $shareManager + ShareManager $shareManager, + private IRootFolder $rootFolder, ) { parent::__construct($AppName, $request); $this->userId = $userId; @@ -192,11 +196,28 @@ public function deleteMetaData(int $id): DataResponse { * @throws OCSNotFoundException */ public function addMetadataFileDrop(int $id, string $fileDrop, ?string $shareToken = null): DataResponse { - $e2eToken = $this->request->getParam('e2e-token'); $ownerId = $this->getOwnerId($shareToken); - if ($this->lockManager->isLocked($id, $e2eToken, $ownerId, true)) { - throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); + try { + $userFolder = $this->rootFolder->getUserFolder($ownerId); + } catch (NoUserException $e) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to create the lock')); + } + + if ($userFolder->getId() === $id) { + $e = new OCSForbiddenException($this->l10n->t('You are not allowed to lock the root')); + $this->logger->error($e->getMessage(), ['exception' => $e]); + throw $e; + } + + $nodes = $userFolder->getById($id); + if (!isset($nodes[0]) || !$nodes[0] instanceof Folder) { + throw new OCSForbiddenException($this->l10n->t('You are not allowed to create the lock')); + } + + $lockToken = $this->lockManager->lockFile($id, 'filedrop-token', $ownerId); + if ($lockToken === null) { + throw new OCSForbiddenException($this->l10n->t('File already locked')); } try { @@ -207,6 +228,7 @@ public function addMetadataFileDrop(int $id, string $fileDrop, ?string $shareTok $encodedMetadata = json_encode($decodedMetadata); $this->metaDataStorage->updateMetaDataIntoIntermediateFile($ownerId, $id, $encodedMetadata); + $this->metaDataStorage->saveIntermediateFile($ownerId, $id); } catch (MissingMetaDataException $e) { throw new OCSNotFoundException($this->l10n->t('Metadata-file does not exist')); } catch (NotFoundException $e) { @@ -214,6 +236,8 @@ public function addMetadataFileDrop(int $id, string $fileDrop, ?string $shareTok } catch (\Exception $e) { $this->logger->critical($e->getMessage(), ['exception' => $e, 'app' => $this->appName]); throw new OCSBadRequestException($this->l10n->t('Cannot update filedrop')); + } finally { + $this->lockManager->unlockFile($id, $lockToken); } return new DataResponse(['meta-data' => $metaData]); diff --git a/lib/E2EEPublicShareTemplateProvider.php b/lib/E2EEPublicShareTemplateProvider.php index eadc91f4..44d4bc42 100644 --- a/lib/E2EEPublicShareTemplateProvider.php +++ b/lib/E2EEPublicShareTemplateProvider.php @@ -73,7 +73,6 @@ function (array $acc, string $userId): array { $this->initialState->provideInitialState('token', $token); $this->initialState->provideInitialState('fileName', $shareNode->getName()); $this->initialState->provideInitialState('encryptionVersion', $metadata['version']); - $this->initialState->provideInitialState('counter', $this->metadataStorage->getCounter($shareNode->getId())); // OpenGraph Support: http://ogp.me/ Util::addHeader('meta', ['property' => "og:title", 'content' => $this->l10n->t("Encrypted share")]); diff --git a/lib/LockManager.php b/lib/LockManager.php index f0c14238..c8ab97ea 100644 --- a/lib/LockManager.php +++ b/lib/LockManager.php @@ -66,8 +66,9 @@ public function __construct( /** * Lock file + * @param bool $noCounterCheck - Needed for filedrop, which updates the metadata without needing to bump the counter */ - public function lockFile(int $id, string $token, int $e2eCounter, string $ownerId): ?string { + public function lockFile(int $id, string $token, int $e2eCounter, string $ownerId, bool $noCounterCheck = false): ?string { if ($this->isLocked($id, $token, $ownerId)) { return null; } @@ -77,11 +78,13 @@ public function lockFile(int $id, string $token, int $e2eCounter, string $ownerI return $lock->getToken() === $token ? $token : null; } catch (DoesNotExistException $ex) { try { - $storedCounter = $this->metaDataStorage->getCounter($id); - if ($storedCounter >= $e2eCounter) { - throw new NotPermittedException('Received counter is not greater than the stored one'); - } else { - $this->metaDataStorage->saveIntermediateCounter($id, $e2eCounter); + if (!$noCounterCheck) { + $storedCounter = $this->metaDataStorage->getCounter($id); + if ($storedCounter >= $e2eCounter) { + throw new NotPermittedException('Received counter is not greater than the stored one'); + } else { + $this->metaDataStorage->saveIntermediateCounter($id, $e2eCounter); + } } } catch (NotFoundException $e) { // Do not check counter if the metadata do not exists yet. diff --git a/src/services/filedrop.js b/src/services/filedrop.js index 3b7c8d55..75f889f4 100644 --- a/src/services/filedrop.js +++ b/src/services/filedrop.js @@ -63,10 +63,9 @@ export async function getFileDropEntry(encryptionInfo, publicKeys) { * @param {1|2} encryptionVersion - The encrypted version for the folder * @param {number} folderId * @param {{[uid: string]: FileDropPayload}} fileDrops - * @param {string} lockToken * @param {string} shareToken */ -export async function uploadFileDrop(encryptionVersion, folderId, fileDrops, lockToken, shareToken) { +export async function uploadFileDrop(encryptionVersion, folderId, fileDrops, shareToken) { const ocsUrl = generateOcsUrl( 'apps/end_to_end_encryption/api/v{encryptionVersion}/meta-data/{folderId}', { @@ -83,11 +82,9 @@ export async function uploadFileDrop(encryptionVersion, folderId, fileDrops, loc { headers: { 'x-e2ee-supported': true, - ...(encryptionVersion === 2 ? { 'e2e-token': lockToken } : {}), }, params: { shareToken, - ...(encryptionVersion === 1 ? { 'e2e-token': lockToken } : {}), }, }, ) diff --git a/src/services/lock.js b/src/services/lock.js deleted file mode 100644 index 6cd96433..00000000 --- a/src/services/lock.js +++ /dev/null @@ -1,59 +0,0 @@ -// SPDX-FileCopyrightText: 2022 Carl Schwan -// SPDX-License-Identifier: AGPL-3.0-or-later - -import { generateOcsUrl } from '@nextcloud/router' -import axios from '@nextcloud/axios' - -/** - * @param {1|2} encryptionVersion - The encrypted version for the folder - * @param {number} counter - The metadata counter received from the initial state - * @param {number} fileId - The file id to lock - * @param {?string} shareToken - The optional share token if this is a file drop. - * @return {Promise} lockToken - */ -export async function lock(encryptionVersion, counter, fileId, shareToken) { - const { data: { ocs: { meta, data } } } = await axios.post( - generateOcsUrl('apps/end_to_end_encryption/api/v{encryptionVersion}/lock/{fileId}', { encryptionVersion, fileId }), - undefined, - { - headers: { - 'x-e2ee-supported': true, - 'x-nc-e2ee-counter': counter, - }, - params: { - shareToken, - }, - } - ) - - if (meta.statuscode !== 200) { - throw new Error(`Failed to lock folder: ${meta.message}`) - } - - return data['e2e-token'] -} - -/** - * @param {1|2} encryptionVersion - The encrypted version for the folder - * @param {number} fileId - The file id to lock - * @param {string} lockToken - The optional lock token if the folder was already locked. - * @param {?string} shareToken - The optional share token if this is a file drop. - */ -export async function unlock(encryptionVersion, fileId, lockToken, shareToken) { - const { data: { ocs: { meta } } } = await axios.delete( - generateOcsUrl('apps/end_to_end_encryption/api/v{encryptionVersion}/lock/{fileId}', { encryptionVersion, fileId }), - { - headers: { - 'x-e2ee-supported': true, - 'e2e-token': lockToken, - }, - params: { - shareToken, - }, - } - ) - - if (meta.statuscode !== 200) { - throw new Error(`Failed to unlock folder: ${meta.message}`) - } -} diff --git a/src/views/FileDrop.vue b/src/views/FileDrop.vue index a97f5385..703fa627 100644 --- a/src/views/FileDrop.vue +++ b/src/views/FileDrop.vue @@ -54,7 +54,6 @@ import { translate } from '@nextcloud/l10n' import logger from '../services/logger.js' import { encryptFile } from '../services/crypto.js' import { uploadFile } from '../services/uploadFile.js' -import { lock, unlock } from '../services/lock.js' import { getFileDropEntry, uploadFileDrop } from '../services/filedrop.js' /** @@ -67,7 +66,6 @@ const UploadStep = { UPLOADING: 'uploading', UPLOADED: 'uploaded', UPLOADING_METADATA: 'uploading_metadata', - UNLOCKING: 'unlocking', DONE: 'done', } @@ -100,8 +98,6 @@ export default { fileName: loadState('end_to_end_encryption', 'fileName'), /** @type {1|2} */ encryptionVersion: Number.parseInt(loadState('end_to_end_encryption', 'encryptionVersion')), - /** @type {number} */ - counter: Number.parseInt(loadState('end_to_end_encryption', 'counter')), /** @type {{file: File, step: string, error: boolean}[]} */ uploadedFiles: [], loading: false, @@ -150,17 +146,6 @@ export default { this.loading = true /** @type {UploadProgress[]} */ let progresses = [] - let lockToken = null - - try { - lockToken = await lock(this.encryptionVersion, this.counter + 1, this.folderId, this.shareToken) - logger.debug(`[FileDrop] Folder locked: ${lockToken}`) - } catch (exception) { - logger.error('[FileDrop] Could not lock the folder', { exception }) - showError(this.t('end_to_end_encryption', 'Could not lock the folder')) - this.loading = false - return - } try { progresses = await Promise.all( @@ -186,28 +171,16 @@ export default { logger.debug('[FileDrop] FileDrop entries computed', { fileDrops }) - await uploadFileDrop(this.encryptionVersion, this.folderId, fileDrops, lockToken, this.shareToken) + await uploadFileDrop(this.encryptionVersion, this.folderId, fileDrops, this.shareToken) } catch (exception) { logger.error('[FileDrop] Error while uploading metadata', { exception }) - showError(this.t('end_to_end_encryption', 'Error while uploading metadata')) + showError(this.t('end_to_end_encryption', 'Error while uploading metadata ({message})', { message: exception.response.data?.ocs?.meta?.message })) progresses.forEach(progress => { progress.error = true }) } - try { - progresses - .filter(({ error }) => !error) - .forEach(progress => { progress.step = UploadStep.UNLOCKING }) - await unlock(this.encryptionVersion, this.folderId, lockToken, this.shareToken) - this.counter++ - logger.debug('[FileDrop] Folder unlocked', { lockToken, shareToken: this.shareToken }) - progresses - .filter(({ error }) => !error) - .forEach(progress => { progress.step = UploadStep.DONE }) - } catch (exception) { - logger.error('[FileDrop] Error while unlocking the folder', { exception }) - showError(this.t('end_to_end_encryption', 'Error while unlocking the folder')) - progresses.forEach(progress => { progress.error = true }) - } + progresses + .filter(({ error }) => !error) + .forEach(progress => { progress.step = UploadStep.DONE }) this.loading = false }, From b04711072926190e6a946251f2de8cc160f2a4d0 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Thu, 18 Jan 2024 12:43:13 +0100 Subject: [PATCH 29/36] Add node workflows Signed-off-by: Louis Chemineau --- .github/workflows/node-when-unrelated.yml | 43 ++++++++++++++ .github/workflows/node.yml | 71 +++++++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 .github/workflows/node-when-unrelated.yml create mode 100644 .github/workflows/node.yml diff --git a/.github/workflows/node-when-unrelated.yml b/.github/workflows/node-when-unrelated.yml new file mode 100644 index 00000000..db32b0db --- /dev/null +++ b/.github/workflows/node-when-unrelated.yml @@ -0,0 +1,43 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# Use node together with node-when-unrelated to make eslint a required check for GitHub actions +# https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/defining-the-mergeability-of-pull-requests/troubleshooting-required-status-checks#handling-skipped-but-required-checks + +name: Node + +on: + pull_request: + paths-ignore: + - '.github/workflows/**' + - 'src/**' + - 'appinfo/info.xml' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.json' + - '**.js' + - '**.ts' + - '**.vue' + push: + branches: + - main + - master + - stable* + +concurrency: + group: node-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + build: + permissions: + contents: none + + runs-on: ubuntu-latest + + name: node + steps: + - name: Skip + run: 'echo "No JS/TS files changed, skipped Node"' diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml new file mode 100644 index 00000000..1774e0b2 --- /dev/null +++ b/.github/workflows/node.yml @@ -0,0 +1,71 @@ +# This workflow is provided via the organization template repository +# +# https://github.com/nextcloud/.github +# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization + +name: Node + +on: + pull_request: + paths: + - '.github/workflows/**' + - 'src/**' + - 'appinfo/info.xml' + - 'package.json' + - 'package-lock.json' + - 'tsconfig.json' + - '**.js' + - '**.ts' + - '**.vue' + push: + branches: + - main + - master + - stable* + +permissions: + contents: read + +concurrency: + group: node-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + + name: node + steps: + - name: Checkout + uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + + - name: Read package.json node and npm engines version + uses: skjnldsv/read-package-engines-version-actions@0ce2ed60f6df073a62a77c0a4958dd0fc68e32e7 # v2.1 + id: versions + with: + fallbackNode: '^16' + fallbackNpm: '^7' + + - name: Set up node ${{ steps.versions.outputs.nodeVersion }} + uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3 + with: + node-version: ${{ steps.versions.outputs.nodeVersion }} + + - name: Set up npm ${{ steps.versions.outputs.npmVersion }} + run: npm i -g npm@"${{ steps.versions.outputs.npmVersion }}" + + - name: Install dependencies & build + run: | + npm ci + npm run build --if-present + + - name: Check webpack build changes + run: | + bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please recompile and commit the assets, see the section \"Show changes on failure\" for details' && exit 1)" + + - name: Show changes on failure + if: failure() + run: | + git status + git --no-pager diff + exit 1 # make it red to grab attention From 4ad7837324cd85f2e8f0521a67b0c7158c7fa72f Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Thu, 18 Jan 2024 12:47:41 +0100 Subject: [PATCH 30/36] Fix phpunit tests Signed-off-by: Louis Chemineau --- tests/Unit/Controller/MetaDataControllerTest.php | 8 +++++++- tests/Unit/Controller/MetaDataControllerV1Test.php | 8 +++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Controller/MetaDataControllerTest.php b/tests/Unit/Controller/MetaDataControllerTest.php index e4e8064c..e0ec9ac0 100644 --- a/tests/Unit/Controller/MetaDataControllerTest.php +++ b/tests/Unit/Controller/MetaDataControllerTest.php @@ -32,6 +32,7 @@ use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IL10N; @@ -70,6 +71,9 @@ class MetaDataControllerTest extends TestCase { /** @var MetaDataController */ private $controller; + /** @var IRootFolder */ + private $rootFolder; + protected function setUp(): void { parent::setUp(); @@ -82,6 +86,7 @@ protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); $this->l10n = $this->createMock(IL10N::class); $this->shareManager = $this->createMock(ShareManager::class); + $this->rootFolder = $this->createMock(IRootFolder::class); $this->controller = new MetaDataController( $this->appName, @@ -91,7 +96,8 @@ protected function setUp(): void { $this->lockManager, $this->logger, $this->l10n, - $this->shareManager + $this->shareManager, + $this->rootFolder, ); } diff --git a/tests/Unit/Controller/MetaDataControllerV1Test.php b/tests/Unit/Controller/MetaDataControllerV1Test.php index fd2515f2..74f9163b 100644 --- a/tests/Unit/Controller/MetaDataControllerV1Test.php +++ b/tests/Unit/Controller/MetaDataControllerV1Test.php @@ -32,6 +32,7 @@ use OCP\AppFramework\OCS\OCSBadRequestException; use OCP\AppFramework\OCS\OCSForbiddenException; use OCP\AppFramework\OCS\OCSNotFoundException; +use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\Files\NotPermittedException; use OCP\IL10N; @@ -70,6 +71,9 @@ class MetaDataControllerV1Test extends TestCase { /** @var MetaDataController */ private $controller; + /** @var IRootFolder */ + private $rootFolder; + protected function setUp(): void { parent::setUp(); @@ -82,6 +86,7 @@ protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); $this->l10n = $this->createMock(IL10N::class); $this->shareManager = $this->createMock(ShareManager::class); + $this->rootFolder = $this->createMock(IRootFolder::class); $this->controller = new MetaDataController( $this->appName, @@ -91,7 +96,8 @@ protected function setUp(): void { $this->lockManager, $this->logger, $this->l10n, - $this->shareManager + $this->shareManager, + $this->rootFolder, ); } From 1b7218b41b989f8ef504eb13712020a7034e2073 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Tue, 23 Jan 2024 17:41:51 +0100 Subject: [PATCH 31/36] Ignore missing signature on v2 endpoint when the metadata is not in v2 Signed-off-by: Louis Chemineau --- lib/MetaDataStorage.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/lib/MetaDataStorage.php b/lib/MetaDataStorage.php index b6a234b2..4bbe7468 100644 --- a/lib/MetaDataStorage.php +++ b/lib/MetaDataStorage.php @@ -234,7 +234,19 @@ private function writeSignature(ISimpleFolder $dir, string $filename, string $si public function readSignature(int $id): string { $folderName = $this->getFolderNameForFileId($id); $dir = $this->appData->getFolder($folderName); - return $dir->getFile($this->metaDataSignatureFileName)->getContent(); + + try { + return $dir->getFile($this->metaDataSignatureFileName)->getContent(); + } catch (NotFoundException $ex) { + $metadata = $dir->getFile($this->metaDataFileName)->getContent(); + $decodedMetadata = json_decode($metadata, true); + + if ($decodedMetadata['metadata']['version'] === "1.2") { + return ""; + } + + throw $ex; + } } /** From a32f9892c96847030fce4444e763c98d02300d17 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Thu, 25 Jan 2024 15:58:41 +0100 Subject: [PATCH 32/36] Return 403 when old client access version 2.0 encrypted folders Signed-off-by: Louis Chemineau --- lib/AppInfo/Application.php | 2 + .../ClientHasCapabilityMiddleware.php | 80 +++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 lib/Middleware/ClientHasCapabilityMiddleware.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 41fa5ef6..70841ef8 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -37,6 +37,7 @@ use OCA\EndToEndEncryption\MetaDataStorage; use OCA\EndToEndEncryption\MetaDataStorageV1; use OCA\EndToEndEncryption\Middleware\CanUseAppMiddleware; +use OCA\EndToEndEncryption\Middleware\ClientHasCapabilityMiddleware; use OCA\EndToEndEncryption\Middleware\UserAgentCheckMiddleware; use OCA\Files_Trashbin\Events\MoveToTrashEvent; use OCA\Files_Versions\Events\CreateVersionEvent; @@ -68,6 +69,7 @@ public function register(IRegistrationContext $context): void { $context->registerCapability(Capabilities::class); $context->registerMiddleware(UserAgentCheckMiddleware::class); $context->registerMiddleware(CanUseAppMiddleware::class); + $context->registerMiddleware(ClientHasCapabilityMiddleware::class); $context->registerServiceAlias(IKeyStorage::class, KeyStorage::class); $context->registerServiceAlias(IMetaDataStorageV1::class, MetaDataStorageV1::class); $context->registerServiceAlias(IMetaDataStorage::class, MetaDataStorage::class); diff --git a/lib/Middleware/ClientHasCapabilityMiddleware.php b/lib/Middleware/ClientHasCapabilityMiddleware.php new file mode 100644 index 00000000..a4e75619 --- /dev/null +++ b/lib/Middleware/ClientHasCapabilityMiddleware.php @@ -0,0 +1,80 @@ + + * + * @author Georg Ehrke + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\EndToEndEncryption\Middleware; + +use OCA\EndToEndEncryption\IMetaDataStorage; +use OCP\AppFramework\Middleware; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\IRequest; + +/** + * Class ClientHasCapabilityMiddleware + * + * @package OCA\EndToEndEncryption\Middleware + */ +class ClientHasCapabilityMiddleware extends Middleware { + public function __construct( + private IRequest $request, + private IMetaDataStorage $metadataStorage, + private ?string $userId, + ) { + } + + /** + * @param \OCP\AppFramework\Controller $controller + * @param string $methodName + * @throws OCSForbiddenException + */ + public function beforeController($controller, $methodName): void { + parent::beforeController($controller, $methodName); + + $pathInfo = $this->request->getPathInfo(); + + if ($pathInfo === false) { + return; + } + + if (!str_contains($pathInfo, '/apps/end_to_end_encryption/api/v1/meta-data/')) { + return; + } + + $fileId = $this->request->getParam('id'); + $metadata = $this->metadataStorage->getMetaData($this->userId ?? '', (int)$fileId); + $decodedMetadata = json_decode($metadata, true); + + if ($decodedMetadata['metadata']['version'] === 1) { + return; + } + + if ($decodedMetadata['metadata']['version'] === "1.2") { + return; + } + + throw new OCSForbiddenException('Client version cannot handle the requested encryption version.'); + } +} From b8b12114e053eabd8326c3b6279be45ddac516cc Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 31 Jan 2024 15:10:30 +0100 Subject: [PATCH 33/36] Improve readability of isLocked return condition Signed-off-by: Louis Chemineau --- lib/LockManager.php | 8 +++++++- lib/LockManagerV1.php | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/LockManager.php b/lib/LockManager.php index c8ab97ea..c33aa00b 100644 --- a/lib/LockManager.php +++ b/lib/LockManager.php @@ -124,6 +124,8 @@ public function unlockFile(int $id, string $token): void { /** * Check if a file or a parent folder is locked * + * @param $requireLock - Specify whether we want to assert that the the folder is locked by the given token. + * * @throws InvalidPathException * @throws NotFoundException * @throws \OCP\Files\NotPermittedException @@ -163,7 +165,11 @@ public function isLocked(int $id, string $token, ?string $ownerId = null, bool $ } } - return $requireLock && !$lockedByGivenToken; + if ($requireLock) { + return !$lockedByGivenToken; + } + + return false; } diff --git a/lib/LockManagerV1.php b/lib/LockManagerV1.php index 1100b63a..9cbcb03d 100644 --- a/lib/LockManagerV1.php +++ b/lib/LockManagerV1.php @@ -107,6 +107,8 @@ public function unlockFile(int $id, string $token): void { /** * Check if a file or a parent folder is locked * + * @param $requireLock - Specify whether we want to assert that the the folder is locked by the given token. + * * @throws InvalidPathException * @throws NotFoundException * @throws \OCP\Files\NotPermittedException @@ -146,9 +148,12 @@ public function isLocked(int $id, string $token, ?string $ownerId = null, bool $ } } - return $requireLock && !$lockedByGivenToken; - } + if ($requireLock) { + return !$lockedByGivenToken; + } + return false; + } /** From 14df4e7c49089a6860f6256cac25a3515a6282b0 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 31 Jan 2024 15:11:00 +0100 Subject: [PATCH 34/36] Remove support for e2e-token in header in PUT v1/meta-data Signed-off-by: Louis Chemineau --- lib/Controller/V1/MetaDataController.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/lib/Controller/V1/MetaDataController.php b/lib/Controller/V1/MetaDataController.php index bc114efd..4566bdc5 100644 --- a/lib/Controller/V1/MetaDataController.php +++ b/lib/Controller/V1/MetaDataController.php @@ -134,12 +134,6 @@ public function setMetaData(int $id, string $metaData): DataResponse { public function updateMetaData(int $id, string $metaData): DataResponse { $e2eToken = $this->request->getParam('e2e-token'); - // FIXME Temporary fix to handle both routes on single endpoint - if (empty($e2eToken)) { - $e2eToken = $this->request->getHeader('e2e-token'); - } - // End - if ($this->lockManager->isLocked($id, $e2eToken, null, true)) { throw new OCSForbiddenException($this->l10n->t('You are not allowed to edit the file, make sure to first lock it, and then send the right token')); } From 89eb51b4f05e73cf04a4bd415831f2e8453128e3 Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 31 Jan 2024 15:15:47 +0100 Subject: [PATCH 35/36] Apply review suggestions Signed-off-by: Louis Chemineau --- src/services/crypto.js | 4 ++-- src/views/FileDrop.vue | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/services/crypto.js b/src/services/crypto.js index 45694ffd..faef2f54 100644 --- a/src/services/crypto.js +++ b/src/services/crypto.js @@ -2,8 +2,8 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import * as x509 from '@peculiar/x509' -import { bufferToBase64 } from './filedrop' -import logger from './logger' +import { bufferToBase64 } from './filedrop.js' +import logger from './logger.js' /** * Gets tag from encrypted data diff --git a/src/views/FileDrop.vue b/src/views/FileDrop.vue index 703fa627..04e405fe 100644 --- a/src/views/FileDrop.vue +++ b/src/views/FileDrop.vue @@ -18,7 +18,7 @@ :class="{ loading }"> {{ t('end_to_end_encryption', 'Select or drop files') }} @@ -174,7 +174,7 @@ export default { await uploadFileDrop(this.encryptionVersion, this.folderId, fileDrops, this.shareToken) } catch (exception) { logger.error('[FileDrop] Error while uploading metadata', { exception }) - showError(this.t('end_to_end_encryption', 'Error while uploading metadata ({message})', { message: exception.response.data?.ocs?.meta?.message })) + showError(this.t('end_to_end_encryption', 'Error while uploading metadata')) progresses.forEach(progress => { progress.error = true }) } @@ -203,7 +203,7 @@ export default { logger.debug(`[FileDrop] Filedrop entry computed: ${unencryptedFile.name}`, { fileDropEntry: progress.fileDrop[encryptedFileName] }) progress.step = UploadStep.UPLOADING - await uploadFile('/public.php/webdav/', encryptedFileName, encryptedFileContent, this.shareToken) + await uploadFile('/public.php/dav/', encryptedFileName, encryptedFileContent, this.shareToken) progress.step = UploadStep.UPLOADED logger.debug(`[FileDrop] File uploaded: ${unencryptedFile.name}`, { encryptedFileContent, encryptionInfo, encryptedFileName, shareToken: this.shareToken }) } catch (exception) { From b1892d426685321a89f57f584a9950117939c60c Mon Sep 17 00:00:00 2001 From: Louis Chemineau Date: Wed, 31 Jan 2024 15:16:16 +0100 Subject: [PATCH 36/36] Compile assets Signed-off-by: Louis Chemineau --- js/end_to_end_encryption-filedrop.js | 4 ++-- js/end_to_end_encryption-filedrop.js.LICENSE.txt | 4 ---- js/end_to_end_encryption-filedrop.js.map | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/js/end_to_end_encryption-filedrop.js b/js/end_to_end_encryption-filedrop.js index db904d7b..393eddb9 100644 --- a/js/end_to_end_encryption-filedrop.js +++ b/js/end_to_end_encryption-filedrop.js @@ -1,3 +1,3 @@ /*! For license information please see end_to_end_encryption-filedrop.js.LICENSE.txt */ -(()=>{var e={2556:(e,t,n)=>{"use strict";n(9070),Object.defineProperty(t,"__esModule",{value:!0}),t.clearAll=function(){[window.sessionStorage,window.localStorage].map((function(e){return a(e)}))},t.clearNonPersistent=function(){[window.sessionStorage,window.localStorage].map((function(e){return a(e,(function(e){return!e.startsWith(o.default.GLOBAL_SCOPE_PERSISTENT)}))}))},t.getBuilder=function(e){return new r.default(e)},n(1249),n(7327),n(1539),n(7941),n(6755);var r=i(n(1957)),o=i(n(8971));function i(e){return e&&e.__esModule?e:{default:e}}function a(e,t){Object.keys(e).filter((function(e){return!t||t(e)})).map(e.removeItem.bind(e))}},8971:(e,t,n)=>{"use strict";function r(e,t){for(var n=0;n{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default=void 0,n(9070);var r,o=(r=n(8971))&&r.__esModule?r:{default:r};function i(e,t){for(var n=0;n0&&void 0!==arguments[0])||arguments[0];return this.persisted=e,this}},{key:"clearOnLogout",value:function(){var e=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];return this.clearedOnLogout=e,this}},{key:"build",value:function(){return new o.default(this.appId,this.persisted?window.localStorage:window.sessionStorage,!this.clearedOnLogout)}}],n&&i(t.prototype,n),r&&i(t,r),Object.defineProperty(t,"prototype",{writable:!1}),e}();t.default=s},7737:(e,t,n)=>{const r=n(5503),{MAX_LENGTH:o,MAX_SAFE_INTEGER:i}=n(5519),{safeRe:a,t:s}=n(8238),l=n(4433),{compareIdentifiers:u}=n(3242);class c{constructor(e,t){if(t=l(t),e instanceof c){if(e.loose===!!t.loose&&e.includePrerelease===!!t.includePrerelease)return e;e=e.version}else if("string"!=typeof e)throw new TypeError(`Invalid version. Must be a string. Got type "${typeof e}".`);if(e.length>o)throw new TypeError(`version is longer than ${o} characters`);r("SemVer",e,t),this.options=t,this.loose=!!t.loose,this.includePrerelease=!!t.includePrerelease;const n=e.trim().match(t.loose?a[s.LOOSE]:a[s.FULL]);if(!n)throw new TypeError(`Invalid Version: ${e}`);if(this.raw=e,this.major=+n[1],this.minor=+n[2],this.patch=+n[3],this.major>i||this.major<0)throw new TypeError("Invalid major version");if(this.minor>i||this.minor<0)throw new TypeError("Invalid minor version");if(this.patch>i||this.patch<0)throw new TypeError("Invalid patch version");n[4]?this.prerelease=n[4].split(".").map((e=>{if(/^[0-9]+$/.test(e)){const t=+e;if(t>=0&&t=0;)"number"==typeof this.prerelease[r]&&(this.prerelease[r]++,r=-2);if(-1===r){if(t===this.prerelease.join(".")&&!1===n)throw new Error("invalid increment argument: identifier already exists");this.prerelease.push(e)}}if(t){let r=[t,e];!1===n&&(r=[t]),0===u(this.prerelease[0],t)?isNaN(this.prerelease[1])&&(this.prerelease=r):this.prerelease=r}break}default:throw new Error(`invalid increment argument: ${e}`)}return this.raw=this.format(),this.build.length&&(this.raw+=`+${this.build.join(".")}`),this}}e.exports=c},2426:(e,t,n)=>{const r=n(7737);e.exports=(e,t)=>new r(e,t).major},7488:(e,t,n)=>{const r=n(7737);e.exports=(e,t,n=!1)=>{if(e instanceof r)return e;try{return new r(e,t)}catch(e){if(!n)return null;throw e}}},7907:(e,t,n)=>{const r=n(7488);e.exports=(e,t)=>{const n=r(e,t);return n?n.version:null}},5519:e=>{const t=Number.MAX_SAFE_INTEGER||9007199254740991;e.exports={MAX_LENGTH:256,MAX_SAFE_COMPONENT_LENGTH:16,MAX_SAFE_BUILD_LENGTH:250,MAX_SAFE_INTEGER:t,RELEASE_TYPES:["major","premajor","minor","preminor","patch","prepatch","prerelease"],SEMVER_SPEC_VERSION:"2.0.0",FLAG_INCLUDE_PRERELEASE:1,FLAG_LOOSE:2}},5503:(e,t,n)=>{var r=n(4155),o=n(5108);const i="object"==typeof r&&r.env&&r.env.NODE_DEBUG&&/\bsemver\b/i.test(r.env.NODE_DEBUG)?(...e)=>o.error("SEMVER",...e):()=>{};e.exports=i},3242:e=>{const t=/^[0-9]+$/,n=(e,n)=>{const r=t.test(e),o=t.test(n);return r&&o&&(e=+e,n=+n),e===n?0:r&&!o?-1:o&&!r?1:en(t,e)}},4433:e=>{const t=Object.freeze({loose:!0}),n=Object.freeze({});e.exports=e=>e?"object"!=typeof e?t:e:n},8238:(e,t,n)=>{const{MAX_SAFE_COMPONENT_LENGTH:r,MAX_SAFE_BUILD_LENGTH:o,MAX_LENGTH:i}=n(5519),a=n(5503),s=(t=e.exports={}).re=[],l=t.safeRe=[],u=t.src=[],c=t.t={};let p=0;const f="[a-zA-Z0-9-]",h=[["\\s",1],["\\d",i],[f,o]],d=(e,t,n)=>{const r=(e=>{for(const[t,n]of h)e=e.split(`${t}*`).join(`${t}{0,${n}}`).split(`${t}+`).join(`${t}{1,${n}}`);return e})(t),o=p++;a(e,o,t),c[e]=o,u[o]=t,s[o]=new RegExp(t,n?"g":void 0),l[o]=new RegExp(r,n?"g":void 0)};d("NUMERICIDENTIFIER","0|[1-9]\\d*"),d("NUMERICIDENTIFIERLOOSE","\\d+"),d("NONNUMERICIDENTIFIER","\\d*[a-zA-Z-][a-zA-Z0-9-]*"),d("MAINVERSION",`(${u[c.NUMERICIDENTIFIER]})\\.(${u[c.NUMERICIDENTIFIER]})\\.(${u[c.NUMERICIDENTIFIER]})`),d("MAINVERSIONLOOSE",`(${u[c.NUMERICIDENTIFIERLOOSE]})\\.(${u[c.NUMERICIDENTIFIERLOOSE]})\\.(${u[c.NUMERICIDENTIFIERLOOSE]})`),d("PRERELEASEIDENTIFIER",`(?:${u[c.NUMERICIDENTIFIER]}|${u[c.NONNUMERICIDENTIFIER]})`),d("PRERELEASEIDENTIFIERLOOSE",`(?:${u[c.NUMERICIDENTIFIERLOOSE]}|${u[c.NONNUMERICIDENTIFIER]})`),d("PRERELEASE",`(?:-(${u[c.PRERELEASEIDENTIFIER]}(?:\\.${u[c.PRERELEASEIDENTIFIER]})*))`),d("PRERELEASELOOSE",`(?:-?(${u[c.PRERELEASEIDENTIFIERLOOSE]}(?:\\.${u[c.PRERELEASEIDENTIFIERLOOSE]})*))`),d("BUILDIDENTIFIER","[a-zA-Z0-9-]+"),d("BUILD",`(?:\\+(${u[c.BUILDIDENTIFIER]}(?:\\.${u[c.BUILDIDENTIFIER]})*))`),d("FULLPLAIN",`v?${u[c.MAINVERSION]}${u[c.PRERELEASE]}?${u[c.BUILD]}?`),d("FULL",`^${u[c.FULLPLAIN]}$`),d("LOOSEPLAIN",`[v=\\s]*${u[c.MAINVERSIONLOOSE]}${u[c.PRERELEASELOOSE]}?${u[c.BUILD]}?`),d("LOOSE",`^${u[c.LOOSEPLAIN]}$`),d("GTLT","((?:<|>)?=?)"),d("XRANGEIDENTIFIERLOOSE",`${u[c.NUMERICIDENTIFIERLOOSE]}|x|X|\\*`),d("XRANGEIDENTIFIER",`${u[c.NUMERICIDENTIFIER]}|x|X|\\*`),d("XRANGEPLAIN",`[v=\\s]*(${u[c.XRANGEIDENTIFIER]})(?:\\.(${u[c.XRANGEIDENTIFIER]})(?:\\.(${u[c.XRANGEIDENTIFIER]})(?:${u[c.PRERELEASE]})?${u[c.BUILD]}?)?)?`),d("XRANGEPLAINLOOSE",`[v=\\s]*(${u[c.XRANGEIDENTIFIERLOOSE]})(?:\\.(${u[c.XRANGEIDENTIFIERLOOSE]})(?:\\.(${u[c.XRANGEIDENTIFIERLOOSE]})(?:${u[c.PRERELEASELOOSE]})?${u[c.BUILD]}?)?)?`),d("XRANGE",`^${u[c.GTLT]}\\s*${u[c.XRANGEPLAIN]}$`),d("XRANGELOOSE",`^${u[c.GTLT]}\\s*${u[c.XRANGEPLAINLOOSE]}$`),d("COERCE",`(^|[^\\d])(\\d{1,${r}})(?:\\.(\\d{1,${r}}))?(?:\\.(\\d{1,${r}}))?(?:$|[^\\d])`),d("COERCERTL",u[c.COERCE],!0),d("LONETILDE","(?:~>?)"),d("TILDETRIM",`(\\s*)${u[c.LONETILDE]}\\s+`,!0),t.tildeTrimReplace="$1~",d("TILDE",`^${u[c.LONETILDE]}${u[c.XRANGEPLAIN]}$`),d("TILDELOOSE",`^${u[c.LONETILDE]}${u[c.XRANGEPLAINLOOSE]}$`),d("LONECARET","(?:\\^)"),d("CARETTRIM",`(\\s*)${u[c.LONECARET]}\\s+`,!0),t.caretTrimReplace="$1^",d("CARET",`^${u[c.LONECARET]}${u[c.XRANGEPLAIN]}$`),d("CARETLOOSE",`^${u[c.LONECARET]}${u[c.XRANGEPLAINLOOSE]}$`),d("COMPARATORLOOSE",`^${u[c.GTLT]}\\s*(${u[c.LOOSEPLAIN]})$|^$`),d("COMPARATOR",`^${u[c.GTLT]}\\s*(${u[c.FULLPLAIN]})$|^$`),d("COMPARATORTRIM",`(\\s*)${u[c.GTLT]}\\s*(${u[c.LOOSEPLAIN]}|${u[c.XRANGEPLAIN]})`,!0),t.comparatorTrimReplace="$1$2$3",d("HYPHENRANGE",`^\\s*(${u[c.XRANGEPLAIN]})\\s+-\\s+(${u[c.XRANGEPLAIN]})\\s*$`),d("HYPHENRANGELOOSE",`^\\s*(${u[c.XRANGEPLAINLOOSE]})\\s+-\\s+(${u[c.XRANGEPLAINLOOSE]})\\s*$`),d("STAR","(<|>)?=?\\s*\\*"),d("GTE0","^\\s*>=\\s*0\\.0\\.0\\s*$"),d("GTE0PRE","^\\s*>=\\s*0\\.0\\.0-0\\s*$")},6453:(e,t,n)=>{"use strict";t.loadState=function(e,t,n){var r=document.querySelector("#initial-state-".concat(e,"-").concat(t));if(null===r){if(void 0!==n)return n;throw new Error("Could not find initial state ".concat(t," of ").concat(e))}try{return JSON.parse(atob(r.value))}catch(n){throw new Error("Could not parse initial state ".concat(t," of ").concat(e))}},n(2222)},9944:(e,t,n)=>{"use strict";var r=n(5108);function o(){return document.documentElement.dataset.locale||"en"}n(9070),t.Iu=function(e,t,n,o,i){if("undefined"==typeof OC)return r.warn("No OC found"),t;return OC.L10N.translate(e,t,n,o,i)},n(4916),n(5306)},1356:(e,t,n)=>{"use strict";var r=n(5108);Object.defineProperty(t,"__esModule",{value:!0}),t.ConsoleLogger=void 0,t.buildConsoleLogger=function(e){return new a(e)},n(9601),n(9070);var o=n(6);function i(e,t){for(var n=0;n{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.LoggerBuilder=void 0,n(9070);var r=n(2341),o=n(6);function i(e,t){for(var n=0;n{"use strict";var r;n(9070),Object.defineProperty(t,"__esModule",{value:!0}),t.LogLevel=void 0,t.LogLevel=r,function(e){e[e.Debug=0]="Debug",e[e.Info=1]="Info",e[e.Warn=2]="Warn",e[e.Error=3]="Error",e[e.Fatal=4]="Fatal"}(r||(t.LogLevel=r={}))},7499:(e,t,n)=>{"use strict";n(9070),t.IY=i;var r=n(1356),o=n(5058);function i(){return new o.LoggerBuilder(r.buildConsoleLogger)}},2341:(e,t,n)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"getRequestToken",{enumerable:!0,get:function(){return r.getRequestToken}}),Object.defineProperty(t,"onRequestTokenUpdate",{enumerable:!0,get:function(){return r.onRequestTokenUpdate}}),Object.defineProperty(t,"getCurrentUser",{enumerable:!0,get:function(){return o.getCurrentUser}});var r=n(9517),o=n(4568)},9517:(e,t,n)=>{"use strict";var r=n(5108);n(9554),Object.defineProperty(t,"__esModule",{value:!0}),t.getRequestToken=function(){return a},t.onRequestTokenUpdate=function(e){s.push(e)};var o=n(8088),i=document.getElementsByTagName("head")[0],a=i?i.getAttribute("data-requesttoken"):null,s=[];(0,o.subscribe)("csrf-token-update",(function(e){a=e.token,s.forEach((function(t){try{t(e.token)}catch(e){r.error("error updating CSRF token observer",e)}}))}))},4568:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.getCurrentUser=function(){if(null===r)return null;return{uid:r,displayName:i,isAdmin:a}};var n=document.getElementsByTagName("head")[0],r=n?n.getAttribute("data-user"):null,o=document.getElementsByTagName("head")[0],i=o?o.getAttribute("data-user-displayname"):null,a="undefined"!=typeof OC&&OC.isUserAdmin()},8088:(e,t,n)=>{"use strict";n.r(t),n.d(t,{emit:()=>Ko,subscribe:()=>qo,unsubscribe:()=>Wo});var r=n(4155),o=n(5108),i="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:void 0!==n.g?n.g:"undefined"!=typeof self?self:{};function a(e){var t={exports:{}};return e(t,t.exports),t.exports}var s=function(e){return e&&e.Math==Math&&e},l=s("object"==typeof globalThis&&globalThis)||s("object"==typeof window&&window)||s("object"==typeof self&&self)||s("object"==typeof i&&i)||function(){return this}()||Function("return this")(),u=function(e){try{return!!e()}catch(e){return!0}},c=!u((function(){return 7!=Object.defineProperty({},1,{get:function(){return 7}})[1]})),p={}.propertyIsEnumerable,f=Object.getOwnPropertyDescriptor,h={f:f&&!p.call({1:2},1)?function(e){var t=f(this,e);return!!t&&t.enumerable}:p},d=function(e,t){return{enumerable:!(1&e),configurable:!(2&e),writable:!(4&e),value:t}},m={}.toString,g=function(e){return m.call(e).slice(8,-1)},v="".split,y=u((function(){return!Object("z").propertyIsEnumerable(0)}))?function(e){return"String"==g(e)?v.call(e,""):Object(e)}:Object,b=function(e){if(null==e)throw TypeError("Can't call method on "+e);return e},w=function(e){return y(b(e))},A=function(e){return"object"==typeof e?null!==e:"function"==typeof e},x=function(e,t){if(!A(e))return e;var n,r;if(t&&"function"==typeof(n=e.toString)&&!A(r=n.call(e)))return r;if("function"==typeof(n=e.valueOf)&&!A(r=n.call(e)))return r;if(!t&&"function"==typeof(n=e.toString)&&!A(r=n.call(e)))return r;throw TypeError("Can't convert object to primitive value")},S=function(e){return Object(b(e))},C={}.hasOwnProperty,E=function(e,t){return C.call(S(e),t)},_=l.document,T=A(_)&&A(_.createElement),k=function(e){return T?_.createElement(e):{}},O=!c&&!u((function(){return 7!=Object.defineProperty(k("div"),"a",{get:function(){return 7}}).a})),N=Object.getOwnPropertyDescriptor,j={f:c?N:function(e,t){if(e=w(e),t=x(t,!0),O)try{return N(e,t)}catch(e){}if(E(e,t))return d(!h.f.call(e,t),e[t])}},P=function(e){if(!A(e))throw TypeError(String(e)+" is not an object");return e},I=Object.defineProperty,L={f:c?I:function(e,t,n){if(P(e),t=x(t,!0),P(n),O)try{return I(e,t,n)}catch(e){}if("get"in n||"set"in n)throw TypeError("Accessors not supported");return"value"in n&&(e[t]=n.value),e}},B=c?function(e,t,n){return L.f(e,t,d(1,n))}:function(e,t,n){return e[t]=n,e},F=function(e,t){try{B(l,e,t)}catch(n){l[e]=t}return t},R="__core-js_shared__",$=l[R]||F(R,{}),D=Function.toString;"function"!=typeof $.inspectSource&&($.inspectSource=function(e){return D.call(e)});var U,z,M,V=$.inspectSource,H=l.WeakMap,G="function"==typeof H&&/native code/.test(V(H)),q=a((function(e){(e.exports=function(e,t){return $[e]||($[e]=void 0!==t?t:{})})("versions",[]).push({version:"3.11.2",mode:"global",copyright:"© 2021 Denis Pushkarev (zloirock.ru)"})})),W=0,K=Math.random(),J=function(e){return"Symbol("+String(void 0===e?"":e)+")_"+(++W+K).toString(36)},Y=q("keys"),Z=function(e){return Y[e]||(Y[e]=J(e))},X={},Q="Object already initialized",ee=l.WeakMap;if(G){var te=$.state||($.state=new ee),ne=te.get,re=te.has,oe=te.set;U=function(e,t){if(re.call(te,e))throw new TypeError(Q);return t.facade=e,oe.call(te,e,t),t},z=function(e){return ne.call(te,e)||{}},M=function(e){return re.call(te,e)}}else{var ie=Z("state");X[ie]=!0,U=function(e,t){if(E(e,ie))throw new TypeError(Q);return t.facade=e,B(e,ie,t),t},z=function(e){return E(e,ie)?e[ie]:{}},M=function(e){return E(e,ie)}}var ae={set:U,get:z,has:M,enforce:function(e){return M(e)?z(e):U(e,{})},getterFor:function(e){return function(t){var n;if(!A(t)||(n=z(t)).type!==e)throw TypeError("Incompatible receiver, "+e+" required");return n}}},se=a((function(e){var t=ae.get,n=ae.enforce,r=String(String).split("String");(e.exports=function(e,t,o,i){var a,s=!!i&&!!i.unsafe,u=!!i&&!!i.enumerable,c=!!i&&!!i.noTargetGet;"function"==typeof o&&("string"!=typeof t||E(o,"name")||B(o,"name",t),(a=n(o)).source||(a.source=r.join("string"==typeof t?t:""))),e!==l?(s?!c&&e[t]&&(u=!0):delete e[t],u?e[t]=o:B(e,t,o)):u?e[t]=o:F(t,o)})(Function.prototype,"toString",(function(){return"function"==typeof this&&t(this).source||V(this)}))})),le=l,ue=function(e){return"function"==typeof e?e:void 0},ce=function(e,t){return arguments.length<2?ue(le[e])||ue(l[e]):le[e]&&le[e][t]||l[e]&&l[e][t]},pe=Math.ceil,fe=Math.floor,he=function(e){return isNaN(e=+e)?0:(e>0?fe:pe)(e)},de=Math.min,me=function(e){return e>0?de(he(e),9007199254740991):0},ge=Math.max,ve=Math.min,ye=function(e){return function(t,n,r){var o,i=w(t),a=me(i.length),s=function(e,t){var n=he(e);return n<0?ge(n+t,0):ve(n,t)}(r,a);if(e&&n!=n){for(;a>s;)if((o=i[s++])!=o)return!0}else for(;a>s;s++)if((e||s in i)&&i[s]===n)return e||s||0;return!e&&-1}},be={includes:ye(!0),indexOf:ye(!1)}.indexOf,we=function(e,t){var n,r=w(e),o=0,i=[];for(n in r)!E(X,n)&&E(r,n)&&i.push(n);for(;t.length>o;)E(r,n=t[o++])&&(~be(i,n)||i.push(n));return i},Ae=["constructor","hasOwnProperty","isPrototypeOf","propertyIsEnumerable","toLocaleString","toString","valueOf"],xe=Ae.concat("length","prototype"),Se={f:Object.getOwnPropertyNames||function(e){return we(e,xe)}},Ce={f:Object.getOwnPropertySymbols},Ee=ce("Reflect","ownKeys")||function(e){var t=Se.f(P(e)),n=Ce.f;return n?t.concat(n(e)):t},_e=function(e,t){for(var n=Ee(t),r=L.f,o=j.f,i=0;ii;)L.f(e,n=r[i++],t[n]);return e},ze=ce("document","documentElement"),Me=Z("IE_PROTO"),Ve=function(){},He=function(e){return"","import mod from \"-!../vue-loader/lib/index.js??vue-loader-options!./ArrowRight.vue?vue&type=script&lang=js\"; export default mod; export * from \"-!../vue-loader/lib/index.js??vue-loader-options!./ArrowRight.vue?vue&type=script&lang=js\"","import { render, staticRenderFns } from \"./ArrowRight.vue?vue&type=template&id=2ee57bcf\"\nimport script from \"./ArrowRight.vue?vue&type=script&lang=js\"\nexport * from \"./ArrowRight.vue?vue&type=script&lang=js\"\n\n\n/* normalize component */\nimport normalizer from \"!../vue-loader/lib/runtime/componentNormalizer.js\"\nvar component = normalizer(\n script,\n render,\n staticRenderFns,\n false,\n null,\n null,\n null\n \n)\n\nexport default component.exports","var render = function render(){var _vm=this,_c=_vm._self._c;return _c('span',_vm._b({staticClass:\"material-design-icon arrow-right-icon\",attrs:{\"aria-hidden\":!_vm.title,\"aria-label\":_vm.title,\"role\":\"img\"},on:{\"click\":function($event){return _vm.$emit('click', $event)}}},'span',_vm.$attrs,false),[_c('svg',{staticClass:\"material-design-icon__svg\",attrs:{\"fill\":_vm.fillColor,\"width\":_vm.size,\"height\":_vm.size,\"viewBox\":\"0 0 24 24\"}},[_c('path',{attrs:{\"d\":\"M4,11V13H16L10.5,18.5L11.92,19.92L19.84,12L11.92,4.08L10.5,5.5L16,11H4Z\"}},[(_vm.title)?_c('title',[_vm._v(_vm._s(_vm.title))]):_vm._e()])])])\n}\nvar staticRenderFns = []\n\nexport { render, staticRenderFns }","/* globals __VUE_SSR_CONTEXT__ */\n\n// IMPORTANT: Do NOT use ES2015 features in this file (except for modules).\n// This module is a runtime utility for cleaner component module output and will\n// be included in the final webpack user bundle.\n\nexport default function normalizeComponent(\n scriptExports,\n render,\n staticRenderFns,\n functionalTemplate,\n injectStyles,\n scopeId,\n moduleIdentifier /* server only */,\n shadowMode /* vue-cli only */\n) {\n // Vue.extend constructor export interop\n var options =\n typeof scriptExports === 'function' ? scriptExports.options : scriptExports\n\n // render functions\n if (render) {\n options.render = render\n options.staticRenderFns = staticRenderFns\n options._compiled = true\n }\n\n // functional template\n if (functionalTemplate) {\n options.functional = true\n }\n\n // scopedId\n if (scopeId) {\n options._scopeId = 'data-v-' + scopeId\n }\n\n var hook\n if (moduleIdentifier) {\n // server build\n hook = function (context) {\n // 2.3 injection\n context =\n context || // cached call\n (this.$vnode && this.$vnode.ssrContext) || // stateful\n (this.parent && this.parent.$vnode && this.parent.$vnode.ssrContext) // functional\n // 2.2 with runInNewContext: true\n if (!context && typeof __VUE_SSR_CONTEXT__ !== 'undefined') {\n context = __VUE_SSR_CONTEXT__\n }\n // inject component styles\n if (injectStyles) {\n injectStyles.call(this, context)\n }\n // register component module identifier for async chunk inferrence\n if (context && context._registeredComponents) {\n context._registeredComponents.add(moduleIdentifier)\n }\n }\n // used by ssr in case component is cached and beforeCreate\n // never gets called\n options._ssrRegister = hook\n } else if (injectStyles) {\n hook = shadowMode\n ? function () {\n injectStyles.call(\n this,\n (options.functional ? this.parent : this).$root.$options.shadowRoot\n )\n }\n : injectStyles\n }\n\n if (hook) {\n if (options.functional) {\n // for template-only hot-reload because in that case the render fn doesn't\n // go through the normalizer\n options._injectStyles = hook\n // register for functional component in vue file\n var originalRender = options.render\n options.render = function renderWithStyleInjection(h, context) {\n hook.call(context)\n return originalRender(h, context)\n }\n } else {\n // inject component registration as beforeCreate hook\n var existing = options.beforeCreate\n options.beforeCreate = existing ? [].concat(existing, hook) : [hook]\n }\n }\n\n return {\n exports: scriptExports,\n options: options\n }\n}\n","if (process.env.NODE_ENV === 'production') {\n module.exports = require('./vue.runtime.common.prod.js')\n} else {\n module.exports = require('./vue.runtime.common.dev.js')\n}\n","/*!\n * Vue.js v2.7.14\n * (c) 2014-2022 Evan You\n * Released under the MIT License.\n */\n/*!\n * Vue.js v2.7.14\n * (c) 2014-2022 Evan You\n * Released under the MIT License.\n */\n\"use strict\";const t=Object.freeze({}),e=Array.isArray;function n(t){return null==t}function o(t){return null!=t}function r(t){return!0===t}function s(t){return\"string\"==typeof t||\"number\"==typeof t||\"symbol\"==typeof t||\"boolean\"==typeof t}function i(t){return\"function\"==typeof t}function c(t){return null!==t&&\"object\"==typeof t}const a=Object.prototype.toString;function l(t){return\"[object Object]\"===a.call(t)}function u(t){const e=parseFloat(String(t));return e>=0&&Math.floor(e)===e&&isFinite(t)}function f(t){return o(t)&&\"function\"==typeof t.then&&\"function\"==typeof t.catch}function d(t){return null==t?\"\":Array.isArray(t)||l(t)&&t.toString===a?JSON.stringify(t,null,2):String(t)}function p(t){const e=parseFloat(t);return isNaN(e)?t:e}function h(t,e){const n=Object.create(null),o=t.split(\",\");for(let t=0;tn[t.toLowerCase()]:t=>n[t]}const m=h(\"key,ref,slot,slot-scope,is\");function _(t,e){const n=t.length;if(n){if(e===t[n-1])return void(t.length=n-1);const o=t.indexOf(e);if(o>-1)return t.splice(o,1)}}const v=Object.prototype.hasOwnProperty;function y(t,e){return v.call(t,e)}function g(t){const e=Object.create(null);return function(n){return e[n]||(e[n]=t(n))}}const b=/-(\\w)/g,$=g((t=>t.replace(b,((t,e)=>e?e.toUpperCase():\"\")))),w=g((t=>t.charAt(0).toUpperCase()+t.slice(1))),C=/\\B([A-Z])/g,x=g((t=>t.replace(C,\"-$1\").toLowerCase()));const k=Function.prototype.bind?function(t,e){return t.bind(e)}:function(t,e){function n(n){const o=arguments.length;return o?o>1?t.apply(e,arguments):t.call(e,n):t.call(e)}return n._length=t.length,n};function O(t,e){e=e||0;let n=t.length-e;const o=new Array(n);for(;n--;)o[n]=t[n+e];return o}function S(t,e){for(const n in e)t[n]=e[n];return t}function j(t){const e={};for(let n=0;n!1,E=t=>t;function P(t,e){if(t===e)return!0;const n=c(t),o=c(e);if(!n||!o)return!n&&!o&&String(t)===String(e);try{const n=Array.isArray(t),o=Array.isArray(e);if(n&&o)return t.length===e.length&&t.every(((t,n)=>P(t,e[n])));if(t instanceof Date&&e instanceof Date)return t.getTime()===e.getTime();if(n||o)return!1;{const n=Object.keys(t),o=Object.keys(e);return n.length===o.length&&n.every((n=>P(t[n],e[n])))}}catch(t){return!1}}function I(t,e){for(let n=0;n0,q=H&&H.indexOf(\"edge/\")>0;H&&H.indexOf(\"android\");const G=H&&/iphone|ipad|ipod|ios/.test(H);H&&/chrome\\/\\d+/.test(H),H&&/phantomjs/.test(H);const Z=H&&H.match(/firefox\\/(\\d+)/),J={}.watch;let X,Q=!1;if(z)try{const t={};Object.defineProperty(t,\"passive\",{get(){Q=!0}}),window.addEventListener(\"test-passive\",null,t)}catch(t){}const Y=()=>(void 0===X&&(X=!z&&\"undefined\"!=typeof global&&(global.process&&\"server\"===global.process.env.VUE_ENV)),X),tt=z&&window.__VUE_DEVTOOLS_GLOBAL_HOOK__;function et(t){return\"function\"==typeof t&&/native code/.test(t.toString())}const nt=\"undefined\"!=typeof Symbol&&et(Symbol)&&\"undefined\"!=typeof Reflect&&et(Reflect.ownKeys);let ot;ot=\"undefined\"!=typeof Set&&et(Set)?Set:class{constructor(){this.set=Object.create(null)}has(t){return!0===this.set[t]}add(t){this.set[t]=!0}clear(){this.set=Object.create(null)}};let rt=null;function st(t=null){t||rt&&rt._scope.off(),rt=t,t&&t._scope.on()}class it{constructor(t,e,n,o,r,s,i,c){this.tag=t,this.data=e,this.children=n,this.text=o,this.elm=r,this.ns=void 0,this.context=s,this.fnContext=void 0,this.fnOptions=void 0,this.fnScopeId=void 0,this.key=e&&e.key,this.componentOptions=i,this.componentInstance=void 0,this.parent=void 0,this.raw=!1,this.isStatic=!1,this.isRootInsert=!0,this.isComment=!1,this.isCloned=!1,this.isOnce=!1,this.asyncFactory=c,this.asyncMeta=void 0,this.isAsyncPlaceholder=!1}get child(){return this.componentInstance}}const ct=(t=\"\")=>{const e=new it;return e.text=t,e.isComment=!0,e};function at(t){return new it(void 0,void 0,void 0,String(t))}function lt(t){const e=new it(t.tag,t.data,t.children&&t.children.slice(),t.text,t.elm,t.context,t.componentOptions,t.asyncFactory);return e.ns=t.ns,e.isStatic=t.isStatic,e.key=t.key,e.isComment=t.isComment,e.fnContext=t.fnContext,e.fnOptions=t.fnOptions,e.fnScopeId=t.fnScopeId,e.asyncMeta=t.asyncMeta,e.isCloned=!0,e}let ut=0;const ft=[];class dt{constructor(){this._pending=!1,this.id=ut++,this.subs=[]}addSub(t){this.subs.push(t)}removeSub(t){this.subs[this.subs.indexOf(t)]=null,this._pending||(this._pending=!0,ft.push(this))}depend(t){dt.target&&dt.target.addDep(this)}notify(t){const e=this.subs.filter((t=>t));for(let t=0,n=e.length;t{const t=e[n];if(Dt(t))return t.value;{const e=t&&t.__ob__;return e&&e.dep.depend(),t}},set:t=>{const o=e[n];Dt(o)&&!Dt(t)?o.value=t:e[n]=t}})}function Rt(t,e,n){const o=t[e];if(Dt(o))return o;const r={get value(){const o=t[e];return void 0===o?n:o},set value(n){t[e]=n}};return U(r,\"__v_isRef\",!0),r}function Lt(t){return Ft(t,!1)}function Ft(t,e){if(!l(t))return t;if(It(t))return t;const n=e?\"__v_rawToShallowReadonly\":\"__v_rawToReadonly\",o=t[n];if(o)return o;const r=Object.create(Object.getPrototypeOf(t));U(t,n,r),U(r,\"__v_isReadonly\",!0),U(r,\"__v_raw\",t),Dt(t)&&U(r,\"__v_isRef\",!0),(e||Pt(t))&&U(r,\"__v_isShallow\",!0);const s=Object.keys(t);for(let n=0;nIe(t,null,n,u,e);let d,p,h=!1,m=!1;if(Dt(n)?(d=()=>n.value,h=Pt(n)):Et(n)?(d=()=>(n.__ob__.dep.depend(),n),s=!0):e(n)?(m=!0,h=n.some((t=>Et(t)||Pt(t))),d=()=>n.map((t=>Dt(t)?t.value:Et(t)?on(t):i(t)?f(t,\"watcher getter\"):void 0))):d=i(n)?o?()=>f(n,\"watcher getter\"):()=>{if(!u||!u._isDestroyed)return p&&p(),f(n,\"watcher\",[_])}:A,o&&s){const t=d;d=()=>on(t())}let _=t=>{p=v.onStop=()=>{f(t,\"watcher cleanup\")}};if(Y())return _=A,o?r&&f(o,\"watcher callback\",[d(),m?[]:void 0,_]):d(),A;const v=new an(rt,d,A,{lazy:!0});v.noRecurse=!o;let y=m?[]:Vt;return v.run=()=>{if(v.active)if(o){const t=v.get();(s||h||(m?t.some(((t,e)=>N(t,y[e]))):N(t,y)))&&(p&&p(),f(o,\"watcher callback\",[t,y===Vt?void 0:y,_]),y=t)}else v.get()},\"sync\"===c?v.update=v.run:\"post\"===c?(v.post=!0,v.update=()=>An(v)):v.update=()=>{if(u&&u===rt&&!u._isMounted){const t=u._preWatchers||(u._preWatchers=[]);t.indexOf(v)<0&&t.push(v)}else An(v)},o?r?v.run():y=v.get():\"post\"===c&&u?u.$once(\"hook:mounted\",(()=>v.get())):v.get(),()=>{v.teardown()}}let Ht;class Wt{constructor(t=!1){this.detached=t,this.active=!0,this.effects=[],this.cleanups=[],this.parent=Ht,!t&&Ht&&(this.index=(Ht.scopes||(Ht.scopes=[])).push(this)-1)}run(t){if(this.active){const e=Ht;try{return Ht=this,t()}finally{Ht=e}}}on(){Ht=this}off(){Ht=this.parent}stop(t){if(this.active){let e,n;for(e=0,n=this.effects.length;e{const e=\"&\"===t.charAt(0),n=\"~\"===(t=e?t.slice(1):t).charAt(0),o=\"!\"===(t=n?t.slice(1):t).charAt(0);return{name:t=o?t.slice(1):t,once:n,capture:o,passive:e}}));function Gt(t,n){function o(){const t=o.fns;if(!e(t))return Ie(t,null,arguments,n,\"v-on handler\");{const e=t.slice();for(let t=0;t0&&(l=te(l,`${i||\"\"}_${a}`),Yt(l[0])&&Yt(f)&&(c[u]=at(f.text+l[0].text),l.shift()),c.push.apply(c,l)):s(l)?Yt(f)?c[u]=at(f.text+l):\"\"!==l&&c.push(at(l)):Yt(l)&&Yt(f)?c[u]=at(f.text+l.text):(r(t._isVList)&&o(l.tag)&&n(l.key)&&o(i)&&(l.key=`__vlist${i}_${a}__`),c.push(l)));return c}function ee(t,n){let r,s,i,a,l=null;if(e(t)||\"string\"==typeof t)for(l=new Array(t.length),r=0,s=t.length;r0,c=n?!!n.$stable:!i,a=n&&n.$key;if(n){if(n._normalized)return n._normalized;if(c&&r&&r!==t&&a===r.$key&&!i&&!r.$hasNormal)return r;s={};for(const t in n)n[t]&&\"$\"!==t[0]&&(s[t]=be(e,o,t,n[t]))}else s={};for(const t in o)t in s||(s[t]=$e(o,t));return n&&Object.isExtensible(n)&&(n._normalized=s),U(s,\"$stable\",c),U(s,\"$key\",a),U(s,\"$hasNormal\",i),s}function be(t,n,o,r){const s=function(){const n=rt;st(t);let o=arguments.length?r.apply(null,arguments):r({});o=o&&\"object\"==typeof o&&!e(o)?[o]:Qt(o);const s=o&&o[0];return st(n),o&&(!s||1===o.length&&s.isComment&&!ye(s))?void 0:o};return r.proxy&&Object.defineProperty(n,o,{get:s,enumerable:!0,configurable:!0}),s}function $e(t,e){return()=>t[e]}function we(e){return{get attrs(){if(!e._attrsProxy){const n=e._attrsProxy={};U(n,\"_v_attr_proxy\",!0),Ce(n,e.$attrs,t,e,\"$attrs\")}return e._attrsProxy},get listeners(){if(!e._listenersProxy){Ce(e._listenersProxy={},e.$listeners,t,e,\"$listeners\")}return e._listenersProxy},get slots(){return function(t){t._slotsProxy||ke(t._slotsProxy={},t.$scopedSlots);return t._slotsProxy}(e)},emit:k(e.$emit,e),expose(t){t&&Object.keys(t).forEach((n=>Mt(e,t,n)))}}}function Ce(t,e,n,o,r){let s=!1;for(const i in e)i in t?e[i]!==n[i]&&(s=!0):(s=!0,xe(t,i,o,r));for(const n in t)n in e||(s=!0,delete t[n]);return s}function xe(t,e,n,o){Object.defineProperty(t,e,{enumerable:!0,configurable:!0,get:()=>n[o][e]})}function ke(t,e){for(const n in e)t[n]=e[n];for(const n in t)n in e||delete t[n]}function Oe(){const t=rt;return t._setupContext||(t._setupContext=we(t))}let Se=null;function je(t,e){return(t.__esModule||nt&&\"Module\"===t[Symbol.toStringTag])&&(t=t.default),c(t)?e.extend(t):t}function Ae(t){if(e(t))for(let e=0;ePe(t,o,r+\" (Promise/async)\"))),s._handled=!0)}catch(t){Pe(t,o,r)}return s}function De(t,e,n){if(L.errorHandler)try{return L.errorHandler.call(null,t,e,n)}catch(e){e!==t&&Ne(e)}Ne(t)}function Ne(t,e,n){if(!z||\"undefined\"==typeof console)throw t;console.error(t)}let Me=!1;const Re=[];let Le,Fe=!1;function Ue(){Fe=!1;const t=Re.slice(0);Re.length=0;for(let e=0;e{t.then(Ue),G&&setTimeout(A)},Me=!0}else if(W||\"undefined\"==typeof MutationObserver||!et(MutationObserver)&&\"[object MutationObserverConstructor]\"!==MutationObserver.toString())Le=\"undefined\"!=typeof setImmediate&&et(setImmediate)?()=>{setImmediate(Ue)}:()=>{setTimeout(Ue,0)};else{let t=1;const e=new MutationObserver(Ue),n=document.createTextNode(String(t));e.observe(n,{characterData:!0}),Le=()=>{t=(t+1)%2,n.data=String(t)},Me=!0}function Be(t,e){let n;if(Re.push((()=>{if(t)try{t.call(e)}catch(t){Pe(t,e,\"nextTick\")}else n&&n(e)})),Fe||(Fe=!0,Le()),!t&&\"undefined\"!=typeof Promise)return new Promise((t=>{n=t}))}function Ve(t){return(e,n=rt)=>{if(n)return function(t,e,n){const o=t.$options;o[e]=zn(o[e],n)}(n,t,e)}}const ze=Ve(\"beforeMount\"),He=Ve(\"mounted\"),We=Ve(\"beforeUpdate\"),Ke=Ve(\"updated\"),qe=Ve(\"beforeDestroy\"),Ge=Ve(\"destroyed\"),Ze=Ve(\"activated\"),Je=Ve(\"deactivated\"),Xe=Ve(\"serverPrefetch\"),Qe=Ve(\"renderTracked\"),Ye=Ve(\"renderTriggered\"),tn=Ve(\"errorCaptured\");var en=Object.freeze({__proto__:null,version:\"2.7.14\",defineComponent:function(t){return t},ref:function(t){return Nt(t,!1)},shallowRef:function(t){return Nt(t,!0)},isRef:Dt,toRef:Rt,toRefs:function(t){const n=e(t)?new Array(t.length):{};for(const e in t)n[e]=Rt(t,e);return n},unref:function(t){return Dt(t)?t.value:t},proxyRefs:function(t){if(Et(t))return t;const e={},n=Object.keys(t);for(let o=0;o{e.depend()}),(()=>{e.notify()})),r={get value(){return n()},set value(t){o(t)}};return U(r,\"__v_isRef\",!0),r},triggerRef:function(t){t.dep&&t.dep.notify()},reactive:function(t){return Tt(t,!1),t},isReactive:Et,isReadonly:It,isShallow:Pt,isProxy:function(t){return Et(t)||It(t)},shallowReactive:At,markRaw:function(t){return Object.isExtensible(t)&&U(t,\"__v_skip\",!0),t},toRaw:function t(e){const n=e&&e.__v_raw;return n?t(n):e},readonly:Lt,shallowReadonly:function(t){return Ft(t,!0)},computed:function(t,e){let n,o;const r=i(t);r?(n=t,o=A):(n=t.get,o=t.set);const s=Y()?null:new an(rt,n,A,{lazy:!0}),c={effect:s,get value(){return s?(s.dirty&&s.evaluate(),dt.target&&s.depend(),s.value):n()},set value(t){o(t)}};return U(c,\"__v_isRef\",!0),U(c,\"__v_isReadonly\",r),c},watch:function(t,e,n){return zt(t,e,n)},watchEffect:function(t,e){return zt(t,null,e)},watchPostEffect:Bt,watchSyncEffect:function(t,e){return zt(t,null,{flush:\"sync\"})},EffectScope:Wt,effectScope:function(t){return new Wt(t)},onScopeDispose:function(t){Ht&&Ht.cleanups.push(t)},getCurrentScope:function(){return Ht},provide:function(t,e){rt&&(Kt(rt)[t]=e)},inject:function(t,e,n=!1){const o=rt;if(o){const r=o.$parent&&o.$parent._provided;if(r&&t in r)return r[t];if(arguments.length>1)return n&&i(e)?e.call(o):e}},h:function(t,e,n){return Te(rt,t,e,n,2,!0)},getCurrentInstance:function(){return rt&&{proxy:rt}},useSlots:function(){return Oe().slots},useAttrs:function(){return Oe().attrs},useListeners:function(){return Oe().listeners},mergeDefaults:function(t,n){const o=e(t)?t.reduce(((t,e)=>(t[e]={},t)),{}):t;for(const t in n){const r=o[t];r?e(r)||i(r)?o[t]={type:r,default:n[t]}:r.default=n[t]:null===r&&(o[t]={default:n[t]})}return o},nextTick:Be,set:Ot,del:St,useCssModule:function(e=\"$style\"){{if(!rt)return t;const n=rt[e];return n||t}},useCssVars:function(t){if(!z)return;const e=rt;e&&Bt((()=>{const n=e.$el,o=t(e,e._setupProxy);if(n&&1===n.nodeType){const t=n.style;for(const e in o)t.setProperty(`--${e}`,o[e])}}))},defineAsyncComponent:function(t){i(t)&&(t={loader:t});const{loader:e,loadingComponent:n,errorComponent:o,delay:r=200,timeout:s,suspensible:c=!1,onError:a}=t;let l=null,u=0;const f=()=>{let t;return l||(t=l=e().catch((t=>{if(t=t instanceof Error?t:new Error(String(t)),a)return new Promise(((e,n)=>{a(t,(()=>e((u++,l=null,f()))),(()=>n(t)),u+1)}));throw t})).then((e=>t!==l&&l?l:(e&&(e.__esModule||\"Module\"===e[Symbol.toStringTag])&&(e=e.default),e))))};return()=>({component:f(),delay:r,timeout:s,error:o,loading:n})},onBeforeMount:ze,onMounted:He,onBeforeUpdate:We,onUpdated:Ke,onBeforeUnmount:qe,onUnmounted:Ge,onActivated:Ze,onDeactivated:Je,onServerPrefetch:Xe,onRenderTracked:Qe,onRenderTriggered:Ye,onErrorCaptured:function(t,e=rt){tn(t,e)}});const nn=new ot;function on(t){return rn(t,nn),nn.clear(),t}function rn(t,n){let o,r;const s=e(t);if(!(!s&&!c(t)||t.__v_skip||Object.isFrozen(t)||t instanceof it)){if(t.__ob__){const e=t.__ob__.dep.id;if(n.has(e))return;n.add(e)}if(s)for(o=t.length;o--;)rn(t[o],n);else if(Dt(t))rn(t.value,n);else for(r=Object.keys(t),o=r.length;o--;)rn(t[r[o]],n)}}let sn,cn=0;class an{constructor(t,e,n,o,r){!function(t,e=Ht){e&&e.active&&e.effects.push(t)}(this,Ht&&!Ht._vm?Ht:t?t._scope:void 0),(this.vm=t)&&r&&(t._watcher=this),o?(this.deep=!!o.deep,this.user=!!o.user,this.lazy=!!o.lazy,this.sync=!!o.sync,this.before=o.before):this.deep=this.user=this.lazy=this.sync=!1,this.cb=n,this.id=++cn,this.active=!0,this.post=!1,this.dirty=this.lazy,this.deps=[],this.newDeps=[],this.depIds=new ot,this.newDepIds=new ot,this.expression=\"\",i(e)?this.getter=e:(this.getter=function(t){if(B.test(t))return;const e=t.split(\".\");return function(t){for(let n=0;n{pn=e}}function mn(t){for(;t&&(t=t.$parent);)if(t._inactive)return!0;return!1}function _n(t,e){if(e){if(t._directInactive=!1,mn(t))return}else if(t._directInactive)return;if(t._inactive||null===t._inactive){t._inactive=!1;for(let e=0;edocument.createEvent(\"Event\").timeStamp&&(On=()=>t.now())}const Sn=(t,e)=>{if(t.post){if(!e.post)return 1}else if(e.post)return-1;return t.id-e.id};function jn(){let t,e;for(kn=On(),Cn=!0,gn.sort(Sn),xn=0;xn{for(let t=0;tt)),e._pending=!1}ft.length=0})(),tt&&L.devtools&&tt.emit(\"flush\")}function An(t){const e=t.id;if(null==$n[e]&&(t!==dt.target||!t.noRecurse)){if($n[e]=!0,Cn){let e=gn.length-1;for(;e>xn&&gn[e].id>t.id;)e--;gn.splice(e+1,0,t)}else gn.push(t);wn||(wn=!0,Be(jn))}}function Tn(t,e){if(t){const n=Object.create(null),o=nt?Reflect.ownKeys(t):Object.keys(t);for(let r=0;r(this.$slots||ge(i,n.scopedSlots,this.$slots=_e(s,i)),this.$slots),Object.defineProperty(this,\"scopedSlots\",{enumerable:!0,get(){return ge(i,n.scopedSlots,this.slots())}}),u&&(this.$options=a,this.$slots=this.slots(),this.$scopedSlots=ge(i,n.scopedSlots,this.$slots)),a._scopeId?this._c=(t,n,o,r)=>{const s=Te(l,t,n,o,r,f);return s&&!e(s)&&(s.fnScopeId=a._scopeId,s.fnContext=i),s}:this._c=(t,e,n,o)=>Te(l,t,e,n,o,f)}function Pn(t,e,n,o,r){const s=lt(t);return s.fnContext=n,s.fnOptions=o,e.slot&&((s.data||(s.data={})).slot=e.slot),s}function In(t,e){for(const n in e)t[$(n)]=e[n]}function Dn(t){return t.name||t.__name||t._componentTag}me(En.prototype);const Nn={init(t,e){if(t.componentInstance&&!t.componentInstance._isDestroyed&&t.data.keepAlive){const e=t;Nn.prepatch(e,e)}else{(t.componentInstance=function(t,e){const n={_isComponent:!0,_parentVnode:t,parent:e},r=t.data.inlineTemplate;o(r)&&(n.render=r.render,n.staticRenderFns=r.staticRenderFns);return new t.componentOptions.Ctor(n)}(t,pn)).$mount(e?t.elm:void 0,e)}},prepatch(e,n){const o=n.componentOptions;!function(e,n,o,r,s){const i=r.data.scopedSlots,c=e.$scopedSlots,a=!!(i&&!i.$stable||c!==t&&!c.$stable||i&&e.$scopedSlots.$key!==i.$key||!i&&e.$scopedSlots.$key);let l=!!(s||e.$options._renderChildren||a);const u=e.$vnode;e.$options._parentVnode=r,e.$vnode=r,e._vnode&&(e._vnode.parent=r),e.$options._renderChildren=s;const f=r.data.attrs||t;e._attrsProxy&&Ce(e._attrsProxy,f,u.data&&u.data.attrs||t,e,\"$attrs\")&&(l=!0),e.$attrs=f,o=o||t;const d=e.$options._parentListeners;if(e._listenersProxy&&Ce(e._listenersProxy,o,d||t,e,\"$listeners\"),e.$listeners=e.$options._parentListeners=o,dn(e,o,d),n&&e.$options.props){$t(!1);const t=e._props,o=e.$options._propKeys||[];for(let r=0;r_(r,s)));const u=t=>{for(let t=0,e=r.length;t{t.resolved=je(n,e),i?r.length=0:u(!0)})),p=D((e=>{o(t.errorComp)&&(t.error=!0,u(!0))})),h=t(d,p);return c(h)&&(f(h)?n(t.resolved)&&h.then(d,p):f(h.component)&&(h.component.then(d,p),o(h.error)&&(t.errorComp=je(h.error,e)),o(h.loading)&&(t.loadingComp=je(h.loading,e),0===h.delay?t.loading=!0:a=setTimeout((()=>{a=null,n(t.resolved)&&n(t.error)&&(t.loading=!0,u(!1))}),h.delay||200)),o(h.timeout)&&(l=setTimeout((()=>{l=null,n(t.resolved)&&p(null)}),h.timeout)))),i=!1,t.loading?t.loadingComp:t.resolved}}(p,d),void 0===s))return function(t,e,n,o,r){const s=ct();return s.asyncFactory=t,s.asyncMeta={data:e,context:n,children:o,tag:r},s}(p,i,a,l,u);i=i||{},ao(s),o(i.model)&&function(t,n){const r=t.model&&t.model.prop||\"value\",s=t.model&&t.model.event||\"input\";(n.attrs||(n.attrs={}))[r]=n.model.value;const i=n.on||(n.on={}),c=i[s],a=n.model.callback;o(c)?(e(c)?-1===c.indexOf(a):c!==a)&&(i[s]=[a].concat(c)):i[s]=a}(s.options,i);const h=function(t,e,r){const s=e.options.props;if(n(s))return;const i={},{attrs:c,props:a}=t;if(o(c)||o(a))for(const t in s){const e=x(t);Xt(i,a,t,e,!0)||Xt(i,c,t,e,!1)}return i}(i,s);if(r(s.options.functional))return function(n,r,s,i,c){const a=n.options,l={},u=a.props;if(o(u))for(const e in u)l[e]=Gn(e,u,r||t);else o(s.attrs)&&In(l,s.attrs),o(s.props)&&In(l,s.props);const f=new En(s,l,c,i,n),d=a.render.call(null,f._c,f);if(d instanceof it)return Pn(d,s,f.parent,a);if(e(d)){const t=Qt(d)||[],e=new Array(t.length);for(let n=0;n{t(n,o),e(n,o)};return n._merged=!0,n}let Fn=A;const Un=L.optionMergeStrategies;function Bn(t,e,n=!0){if(!e)return t;let o,r,s;const i=nt?Reflect.ownKeys(e):Object.keys(e);for(let c=0;c{Un[t]=zn})),M.forEach((function(t){Un[t+\"s\"]=Hn})),Un.watch=function(t,n,o,r){if(t===J&&(t=void 0),n===J&&(n=void 0),!n)return Object.create(t||null);if(!t)return n;const s={};S(s,t);for(const t in n){let o=s[t];const r=n[t];o&&!e(o)&&(o=[o]),s[t]=o?o.concat(r):e(r)?r:[r]}return s},Un.props=Un.methods=Un.inject=Un.computed=function(t,e,n,o){if(!t)return e;const r=Object.create(null);return S(r,t),e&&S(r,e),r},Un.provide=function(t,e){return t?function(){const n=Object.create(null);return Bn(n,i(t)?t.call(this):t),e&&Bn(n,i(e)?e.call(this):e,!1),n}:e};const Wn=function(t,e){return void 0===e?t:e};function Kn(t,n,o){if(i(n)&&(n=n.options),function(t,n){const o=t.props;if(!o)return;const r={};let s,i,c;if(e(o))for(s=o.length;s--;)i=o[s],\"string\"==typeof i&&(c=$(i),r[c]={type:null});else if(l(o))for(const t in o)i=o[t],c=$(t),r[c]=l(i)?i:{type:i};t.props=r}(n),function(t,n){const o=t.inject;if(!o)return;const r=t.inject={};if(e(o))for(let t=0;t-1)if(s&&!y(r,\"default\"))c=!1;else if(\"\"===c||c===x(t)){const t=Qn(String,r.type);(t<0||a-1:\"string\"==typeof t?t.split(\",\").indexOf(n)>-1:(o=t,\"[object RegExp]\"===a.call(o)&&t.test(n));var o}function ho(t,e){const{cache:n,keys:o,_vnode:r}=t;for(const t in n){const s=n[t];if(s){const i=s.name;i&&!e(i)&&mo(n,t,o,r)}}}function mo(t,e,n,o){const r=t[e];!r||o&&r.tag===o.tag||r.componentInstance.$destroy(),t[e]=null,_(n,e)}!function(e){e.prototype._init=function(e){const n=this;n._uid=co++,n._isVue=!0,n.__v_skip=!0,n._scope=new Wt(!0),n._scope._vm=!0,e&&e._isComponent?function(t,e){const n=t.$options=Object.create(t.constructor.options),o=e._parentVnode;n.parent=e.parent,n._parentVnode=o;const r=o.componentOptions;n.propsData=r.propsData,n._parentListeners=r.listeners,n._renderChildren=r.children,n._componentTag=r.tag,e.render&&(n.render=e.render,n.staticRenderFns=e.staticRenderFns)}(n,e):n.$options=Kn(ao(n.constructor),e||{},n),n._renderProxy=n,n._self=n,function(t){const e=t.$options;let n=e.parent;if(n&&!e.abstract){for(;n.$options.abstract&&n.$parent;)n=n.$parent;n.$children.push(t)}t.$parent=n,t.$root=n?n.$root:t,t.$children=[],t.$refs={},t._provided=n?n._provided:Object.create(null),t._watcher=null,t._inactive=null,t._directInactive=!1,t._isMounted=!1,t._isDestroyed=!1,t._isBeingDestroyed=!1}(n),function(t){t._events=Object.create(null),t._hasHookEvent=!1;const e=t.$options._parentListeners;e&&dn(t,e)}(n),function(e){e._vnode=null,e._staticTrees=null;const n=e.$options,o=e.$vnode=n._parentVnode,r=o&&o.context;e.$slots=_e(n._renderChildren,r),e.$scopedSlots=o?ge(e.$parent,o.data.scopedSlots,e.$slots):t,e._c=(t,n,o,r)=>Te(e,t,n,o,r,!1),e.$createElement=(t,n,o,r)=>Te(e,t,n,o,r,!0);const s=o&&o.data;kt(e,\"$attrs\",s&&s.attrs||t,null,!0),kt(e,\"$listeners\",n._parentListeners||t,null,!0)}(n),yn(n,\"beforeCreate\",void 0,!1),function(t){const e=Tn(t.$options.inject,t);e&&($t(!1),Object.keys(e).forEach((n=>{kt(t,n,e[n])})),$t(!0))}(n),eo(n),function(t){const e=t.$options.provide;if(e){const n=i(e)?e.call(t):e;if(!c(n))return;const o=Kt(t),r=nt?Reflect.ownKeys(n):Object.keys(n);for(let t=0;t1?O(n):n;const o=O(arguments,1),r=`event handler for \"${t}\"`;for(let t=0,s=n.length;tparseInt(this.max)&&mo(t,e[0],e,this._vnode),this.vnodeToCache=null}}},created(){this.cache=Object.create(null),this.keys=[]},destroyed(){for(const t in this.cache)mo(this.cache,t,this.keys)},mounted(){this.cacheVNode(),this.$watch(\"include\",(t=>{ho(this,(e=>po(t,e)))})),this.$watch(\"exclude\",(t=>{ho(this,(e=>!po(t,e)))}))},updated(){this.cacheVNode()},render(){const t=this.$slots.default,e=Ae(t),n=e&&e.componentOptions;if(n){const t=fo(n),{include:o,exclude:r}=this;if(o&&(!t||!po(o,t))||r&&t&&po(r,t))return e;const{cache:s,keys:i}=this,c=null==e.key?n.Ctor.cid+(n.tag?`::${n.tag}`:\"\"):e.key;s[c]?(e.componentInstance=s[c].componentInstance,_(i,c),i.push(c)):(this.vnodeToCache=e,this.keyToCache=c),e.data.keepAlive=!0}return e||t&&t[0]}}};!function(t){const e={get:()=>L};Object.defineProperty(t,\"config\",e),t.util={warn:Fn,extend:S,mergeOptions:Kn,defineReactive:kt},t.set=Ot,t.delete=St,t.nextTick=Be,t.observable=t=>(xt(t),t),t.options=Object.create(null),M.forEach((e=>{t.options[e+\"s\"]=Object.create(null)})),t.options._base=t,S(t.options.components,vo),function(t){t.use=function(t){const e=this._installedPlugins||(this._installedPlugins=[]);if(e.indexOf(t)>-1)return this;const n=O(arguments,1);return n.unshift(this),i(t.install)?t.install.apply(t,n):i(t)&&t.apply(null,n),e.push(t),this}}(t),function(t){t.mixin=function(t){return this.options=Kn(this.options,t),this}}(t),uo(t),function(t){M.forEach((e=>{t[e]=function(t,n){return n?(\"component\"===e&&l(n)&&(n.name=n.name||t,n=this.options._base.extend(n)),\"directive\"===e&&i(n)&&(n={bind:n,update:n}),this.options[e+\"s\"][t]=n,n):this.options[e+\"s\"][t]}}))}(t)}(lo),Object.defineProperty(lo.prototype,\"$isServer\",{get:Y}),Object.defineProperty(lo.prototype,\"$ssrContext\",{get(){return this.$vnode&&this.$vnode.ssrContext}}),Object.defineProperty(lo,\"FunctionalRenderContext\",{value:En}),lo.version=\"2.7.14\";const yo=h(\"style,class\"),go=h(\"input,textarea,option,select,progress\"),bo=h(\"contenteditable,draggable,spellcheck\"),$o=h(\"events,caret,typing,plaintext-only\"),wo=h(\"allowfullscreen,async,autofocus,autoplay,checked,compact,controls,declare,default,defaultchecked,defaultmuted,defaultselected,defer,disabled,enabled,formnovalidate,hidden,indeterminate,inert,ismap,itemscope,loop,multiple,muted,nohref,noresize,noshade,novalidate,nowrap,open,pauseonexit,readonly,required,reversed,scoped,seamless,selected,sortable,truespeed,typemustmatch,visible\"),Co=\"http://www.w3.org/1999/xlink\",xo=t=>\":\"===t.charAt(5)&&\"xlink\"===t.slice(0,5),ko=t=>xo(t)?t.slice(6,t.length):\"\",Oo=t=>null==t||!1===t;function So(t){let e=t.data,n=t,r=t;for(;o(r.componentInstance);)r=r.componentInstance._vnode,r&&r.data&&(e=jo(r.data,e));for(;o(n=n.parent);)n&&n.data&&(e=jo(e,n.data));return function(t,e){if(o(t)||o(e))return Ao(t,To(e));return\"\"}(e.staticClass,e.class)}function jo(t,e){return{staticClass:Ao(t.staticClass,e.staticClass),class:o(t.class)?[t.class,e.class]:e.class}}function Ao(t,e){return t?e?t+\" \"+e:t:e||\"\"}function To(t){return Array.isArray(t)?function(t){let e,n=\"\";for(let r=0,s=t.length;rPo(t)||Io(t);const No=Object.create(null);const Mo=h(\"text,number,password,search,email,tel,url\");var Ro=Object.freeze({__proto__:null,createElement:function(t,e){const n=document.createElement(t);return\"select\"!==t||e.data&&e.data.attrs&&void 0!==e.data.attrs.multiple&&n.setAttribute(\"multiple\",\"multiple\"),n},createElementNS:function(t,e){return document.createElementNS(Eo[t],e)},createTextNode:function(t){return document.createTextNode(t)},createComment:function(t){return document.createComment(t)},insertBefore:function(t,e,n){t.insertBefore(e,n)},removeChild:function(t,e){t.removeChild(e)},appendChild:function(t,e){t.appendChild(e)},parentNode:function(t){return t.parentNode},nextSibling:function(t){return t.nextSibling},tagName:function(t){return t.tagName},setTextContent:function(t,e){t.textContent=e},setStyleScope:function(t,e){t.setAttribute(e,\"\")}}),Lo={create(t,e){Fo(e)},update(t,e){t.data.ref!==e.data.ref&&(Fo(t,!0),Fo(e))},destroy(t){Fo(t,!0)}};function Fo(t,n){const r=t.data.ref;if(!o(r))return;const s=t.context,c=t.componentInstance||t.elm,a=n?null:c,l=n?void 0:c;if(i(r))return void Ie(r,s,[a],s,\"template ref function\");const u=t.data.refInFor,f=\"string\"==typeof r||\"number\"==typeof r,d=Dt(r),p=s.$refs;if(f||d)if(u){const t=f?p[r]:r.value;n?e(t)&&_(t,c):e(t)?t.includes(c)||t.push(c):f?(p[r]=[c],Uo(s,r,p[r])):r.value=[c]}else if(f){if(n&&p[r]!==c)return;p[r]=l,Uo(s,r,a)}else if(d){if(n&&r.value!==c)return;r.value=a}}function Uo({_setupState:t},e,n){t&&y(t,e)&&(Dt(t[e])?t[e].value=n:t[e]=n)}const Bo=new it(\"\",{},[]),Vo=[\"create\",\"activate\",\"update\",\"remove\",\"destroy\"];function zo(t,e){return t.key===e.key&&t.asyncFactory===e.asyncFactory&&(t.tag===e.tag&&t.isComment===e.isComment&&o(t.data)===o(e.data)&&function(t,e){if(\"input\"!==t.tag)return!0;let n;const r=o(n=t.data)&&o(n=n.attrs)&&n.type,s=o(n=e.data)&&o(n=n.attrs)&&n.type;return r===s||Mo(r)&&Mo(s)}(t,e)||r(t.isAsyncPlaceholder)&&n(e.asyncFactory.error))}function Ho(t,e,n){let r,s;const i={};for(r=e;r<=n;++r)s=t[r].key,o(s)&&(i[s]=r);return i}var Wo={create:Ko,update:Ko,destroy:function(t){Ko(t,Bo)}};function Ko(t,e){(t.data.directives||e.data.directives)&&function(t,e){const n=t===Bo,o=e===Bo,r=Go(t.data.directives,t.context),s=Go(e.data.directives,e.context),i=[],c=[];let a,l,u;for(a in s)l=r[a],u=s[a],l?(u.oldValue=l.value,u.oldArg=l.arg,Jo(u,\"update\",e,t),u.def&&u.def.componentUpdated&&c.push(u)):(Jo(u,\"bind\",e,t),u.def&&u.def.inserted&&i.push(u));if(i.length){const o=()=>{for(let n=0;n{for(let n=0;n-1?tr(t,e,n):wo(e)?Oo(n)?t.removeAttribute(e):(n=\"allowfullscreen\"===e&&\"EMBED\"===t.tagName?\"true\":e,t.setAttribute(e,n)):bo(e)?t.setAttribute(e,((t,e)=>Oo(e)||\"false\"===e?\"false\":\"contenteditable\"===t&&$o(e)?e:\"true\")(e,n)):xo(e)?Oo(n)?t.removeAttributeNS(Co,ko(e)):t.setAttributeNS(Co,e,n):tr(t,e,n)}function tr(t,e,n){if(Oo(n))t.removeAttribute(e);else{if(W&&!K&&\"TEXTAREA\"===t.tagName&&\"placeholder\"===e&&\"\"!==n&&!t.__ieph){const e=n=>{n.stopImmediatePropagation(),t.removeEventListener(\"input\",e)};t.addEventListener(\"input\",e),t.__ieph=!0}t.setAttribute(e,n)}}var er={create:Qo,update:Qo};function nr(t,e){const r=e.elm,s=e.data,i=t.data;if(n(s.staticClass)&&n(s.class)&&(n(i)||n(i.staticClass)&&n(i.class)))return;let c=So(e);const a=r._transitionClasses;o(a)&&(c=Ao(c,To(a))),c!==r._prevClass&&(r.setAttribute(\"class\",c),r._prevClass=c)}var or={create:nr,update:nr};let rr;function sr(t,e,n){const o=rr;return function r(){const s=e.apply(null,arguments);null!==s&&ar(t,r,n,o)}}const ir=Me&&!(Z&&Number(Z[1])<=53);function cr(t,e,n,o){if(ir){const t=kn,n=e;e=n._wrapper=function(e){if(e.target===e.currentTarget||e.timeStamp>=t||e.timeStamp<=0||e.target.ownerDocument!==document)return n.apply(this,arguments)}}rr.addEventListener(t,e,Q?{capture:n,passive:o}:n)}function ar(t,e,n,o){(o||rr).removeEventListener(t,e._wrapper||e,n)}function lr(t,e){if(n(t.data.on)&&n(e.data.on))return;const r=e.data.on||{},s=t.data.on||{};rr=e.elm||t.elm,function(t){if(o(t.__r)){const e=W?\"change\":\"input\";t[e]=[].concat(t.__r,t[e]||[]),delete t.__r}o(t.__c)&&(t.change=[].concat(t.__c,t.change||[]),delete t.__c)}(r),Zt(r,s,cr,ar,sr,e.context),rr=void 0}var ur={create:lr,update:lr,destroy:t=>lr(t,Bo)};let fr;function dr(t,e){if(n(t.data.domProps)&&n(e.data.domProps))return;let s,i;const c=e.elm,a=t.data.domProps||{};let l=e.data.domProps||{};for(s in(o(l.__ob__)||r(l._v_attr_proxy))&&(l=e.data.domProps=S({},l)),a)s in l||(c[s]=\"\");for(s in l){if(i=l[s],\"textContent\"===s||\"innerHTML\"===s){if(e.children&&(e.children.length=0),i===a[s])continue;1===c.childNodes.length&&c.removeChild(c.childNodes[0])}if(\"value\"===s&&\"PROGRESS\"!==c.tagName){c._value=i;const t=n(i)?\"\":String(i);pr(c,t)&&(c.value=t)}else if(\"innerHTML\"===s&&Io(c.tagName)&&n(c.innerHTML)){fr=fr||document.createElement(\"div\"),fr.innerHTML=`${i}`;const t=fr.firstChild;for(;c.firstChild;)c.removeChild(c.firstChild);for(;t.firstChild;)c.appendChild(t.firstChild)}else if(i!==a[s])try{c[s]=i}catch(t){}}}function pr(t,e){return!t.composing&&(\"OPTION\"===t.tagName||function(t,e){let n=!0;try{n=document.activeElement!==t}catch(t){}return n&&t.value!==e}(t,e)||function(t,e){const n=t.value,r=t._vModifiers;if(o(r)){if(r.number)return p(n)!==p(e);if(r.trim)return n.trim()!==e.trim()}return n!==e}(t,e))}var hr={create:dr,update:dr};const mr=g((function(t){const e={},n=/:(.+)/;return t.split(/;(?![^(]*\\))/g).forEach((function(t){if(t){const o=t.split(n);o.length>1&&(e[o[0].trim()]=o[1].trim())}})),e}));function _r(t){const e=vr(t.style);return t.staticStyle?S(t.staticStyle,e):e}function vr(t){return Array.isArray(t)?j(t):\"string\"==typeof t?mr(t):t}const yr=/^--/,gr=/\\s*!important$/,br=(t,e,n)=>{if(yr.test(e))t.style.setProperty(e,n);else if(gr.test(n))t.style.setProperty(x(e),n.replace(gr,\"\"),\"important\");else{const o=Cr(e);if(Array.isArray(n))for(let e=0,r=n.length;e-1?e.split(Or).forEach((e=>t.classList.add(e))):t.classList.add(e);else{const n=` ${t.getAttribute(\"class\")||\"\"} `;n.indexOf(\" \"+e+\" \")<0&&t.setAttribute(\"class\",(n+e).trim())}}function jr(t,e){if(e&&(e=e.trim()))if(t.classList)e.indexOf(\" \")>-1?e.split(Or).forEach((e=>t.classList.remove(e))):t.classList.remove(e),t.classList.length||t.removeAttribute(\"class\");else{let n=` ${t.getAttribute(\"class\")||\"\"} `;const o=\" \"+e+\" \";for(;n.indexOf(o)>=0;)n=n.replace(o,\" \");n=n.trim(),n?t.setAttribute(\"class\",n):t.removeAttribute(\"class\")}}function Ar(t){if(t){if(\"object\"==typeof t){const e={};return!1!==t.css&&S(e,Tr(t.name||\"v\")),S(e,t),e}return\"string\"==typeof t?Tr(t):void 0}}const Tr=g((t=>({enterClass:`${t}-enter`,enterToClass:`${t}-enter-to`,enterActiveClass:`${t}-enter-active`,leaveClass:`${t}-leave`,leaveToClass:`${t}-leave-to`,leaveActiveClass:`${t}-leave-active`}))),Er=z&&!K;let Pr=\"transition\",Ir=\"transitionend\",Dr=\"animation\",Nr=\"animationend\";Er&&(void 0===window.ontransitionend&&void 0!==window.onwebkittransitionend&&(Pr=\"WebkitTransition\",Ir=\"webkitTransitionEnd\"),void 0===window.onanimationend&&void 0!==window.onwebkitanimationend&&(Dr=\"WebkitAnimation\",Nr=\"webkitAnimationEnd\"));const Mr=z?window.requestAnimationFrame?window.requestAnimationFrame.bind(window):setTimeout:t=>t();function Rr(t){Mr((()=>{Mr(t)}))}function Lr(t,e){const n=t._transitionClasses||(t._transitionClasses=[]);n.indexOf(e)<0&&(n.push(e),Sr(t,e))}function Fr(t,e){t._transitionClasses&&_(t._transitionClasses,e),jr(t,e)}function Ur(t,e,n){const{type:o,timeout:r,propCount:s}=Vr(t,e);if(!o)return n();const i=\"transition\"===o?Ir:Nr;let c=0;const a=()=>{t.removeEventListener(i,l),n()},l=e=>{e.target===t&&++c>=s&&a()};setTimeout((()=>{c0&&(l=\"transition\",u=s,f=r.length):\"animation\"===e?a>0&&(l=\"animation\",u=a,f=c.length):(u=Math.max(s,a),l=u>0?s>a?\"transition\":\"animation\":null,f=l?\"transition\"===l?r.length:c.length:0);return{type:l,timeout:u,propCount:f,hasTransform:\"transition\"===l&&Br.test(n[Pr+\"Property\"])}}function zr(t,e){for(;t.lengthHr(e)+Hr(t[n]))))}function Hr(t){return 1e3*Number(t.slice(0,-1).replace(\",\",\".\"))}function Wr(t,e){const r=t.elm;o(r._leaveCb)&&(r._leaveCb.cancelled=!0,r._leaveCb());const s=Ar(t.data.transition);if(n(s))return;if(o(r._enterCb)||1!==r.nodeType)return;const{css:a,type:l,enterClass:u,enterToClass:f,enterActiveClass:d,appearClass:h,appearToClass:m,appearActiveClass:_,beforeEnter:v,enter:y,afterEnter:g,enterCancelled:b,beforeAppear:$,appear:w,afterAppear:C,appearCancelled:x,duration:k}=s;let O=pn,S=pn.$vnode;for(;S&&S.parent;)O=S.context,S=S.parent;const j=!O._isMounted||!t.isRootInsert;if(j&&!w&&\"\"!==w)return;const A=j&&h?h:u,T=j&&_?_:d,E=j&&m?m:f,P=j&&$||v,I=j&&i(w)?w:y,N=j&&C||g,M=j&&x||b,R=p(c(k)?k.enter:k),L=!1!==a&&!K,F=Gr(I),U=r._enterCb=D((()=>{L&&(Fr(r,E),Fr(r,T)),U.cancelled?(L&&Fr(r,A),M&&M(r)):N&&N(r),r._enterCb=null}));t.data.show||Jt(t,\"insert\",(()=>{const e=r.parentNode,n=e&&e._pending&&e._pending[t.key];n&&n.tag===t.tag&&n.elm._leaveCb&&n.elm._leaveCb(),I&&I(r,U)})),P&&P(r),L&&(Lr(r,A),Lr(r,T),Rr((()=>{Fr(r,A),U.cancelled||(Lr(r,E),F||(qr(R)?setTimeout(U,R):Ur(r,l,U)))}))),t.data.show&&(e&&e(),I&&I(r,U)),L||F||U()}function Kr(t,e){const r=t.elm;o(r._enterCb)&&(r._enterCb.cancelled=!0,r._enterCb());const s=Ar(t.data.transition);if(n(s)||1!==r.nodeType)return e();if(o(r._leaveCb))return;const{css:i,type:a,leaveClass:l,leaveToClass:u,leaveActiveClass:f,beforeLeave:d,leave:h,afterLeave:m,leaveCancelled:_,delayLeave:v,duration:y}=s,g=!1!==i&&!K,b=Gr(h),$=p(c(y)?y.leave:y),w=r._leaveCb=D((()=>{r.parentNode&&r.parentNode._pending&&(r.parentNode._pending[t.key]=null),g&&(Fr(r,u),Fr(r,f)),w.cancelled?(g&&Fr(r,l),_&&_(r)):(e(),m&&m(r)),r._leaveCb=null}));function C(){w.cancelled||(!t.data.show&&r.parentNode&&((r.parentNode._pending||(r.parentNode._pending={}))[t.key]=t),d&&d(r),g&&(Lr(r,l),Lr(r,f),Rr((()=>{Fr(r,l),w.cancelled||(Lr(r,u),b||(qr($)?setTimeout(w,$):Ur(r,a,w)))}))),h&&h(r,w),g||b||w())}v?v(C):C()}function qr(t){return\"number\"==typeof t&&!isNaN(t)}function Gr(t){if(n(t))return!1;const e=t.fns;return o(e)?Gr(Array.isArray(e)?e[0]:e):(t._length||t.length)>1}function Zr(t,e){!0!==e.data.show&&Wr(e)}const Jr=function(t){let i,c;const a={},{modules:l,nodeOps:u}=t;for(i=0;im?(f=n(r[y+1])?null:r[y+1].elm,b(t,f,r,h,y,s)):h>y&&w(e,p,m)}(f,m,_,s,l):o(_)?(o(t.text)&&u.setTextContent(f,\"\"),b(f,null,_,0,_.length-1,s)):o(m)?w(m,0,m.length-1):o(t.text)&&u.setTextContent(f,\"\"):t.text!==e.text&&u.setTextContent(f,e.text),o(h)&&o(p=h.hook)&&o(p=p.postpatch)&&p(t,e)}function O(t,e,n){if(r(n)&&o(t.parent))t.parent.data.pendingInsert=e;else for(let t=0;t{const t=document.activeElement;t&&t.vmodel&&rs(t,\"input\")}));const Xr={inserted(t,e,n,o){\"select\"===n.tag?(o.elm&&!o.elm._vOptions?Jt(n,\"postpatch\",(()=>{Xr.componentUpdated(t,e,n)})):Qr(t,e,n.context),t._vOptions=[].map.call(t.options,es)):(\"textarea\"===n.tag||Mo(t.type))&&(t._vModifiers=e.modifiers,e.modifiers.lazy||(t.addEventListener(\"compositionstart\",ns),t.addEventListener(\"compositionend\",os),t.addEventListener(\"change\",os),K&&(t.vmodel=!0)))},componentUpdated(t,e,n){if(\"select\"===n.tag){Qr(t,e,n.context);const o=t._vOptions,r=t._vOptions=[].map.call(t.options,es);if(r.some(((t,e)=>!P(t,o[e])))){(t.multiple?e.value.some((t=>ts(t,r))):e.value!==e.oldValue&&ts(e.value,r))&&rs(t,\"change\")}}}};function Qr(t,e,n){Yr(t,e),(W||q)&&setTimeout((()=>{Yr(t,e)}),0)}function Yr(t,e,n){const o=e.value,r=t.multiple;if(r&&!Array.isArray(o))return;let s,i;for(let e=0,n=t.options.length;e-1,i.selected!==s&&(i.selected=s);else if(P(es(i),o))return void(t.selectedIndex!==e&&(t.selectedIndex=e));r||(t.selectedIndex=-1)}function ts(t,e){return e.every((e=>!P(e,t)))}function es(t){return\"_value\"in t?t._value:t.value}function ns(t){t.target.composing=!0}function os(t){t.target.composing&&(t.target.composing=!1,rs(t.target,\"input\"))}function rs(t,e){const n=document.createEvent(\"HTMLEvents\");n.initEvent(e,!0,!0),t.dispatchEvent(n)}function ss(t){return!t.componentInstance||t.data&&t.data.transition?t:ss(t.componentInstance._vnode)}var is={bind(t,{value:e},n){const o=(n=ss(n)).data&&n.data.transition,r=t.__vOriginalDisplay=\"none\"===t.style.display?\"\":t.style.display;e&&o?(n.data.show=!0,Wr(n,(()=>{t.style.display=r}))):t.style.display=e?r:\"none\"},update(t,{value:e,oldValue:n},o){if(!e==!n)return;(o=ss(o)).data&&o.data.transition?(o.data.show=!0,e?Wr(o,(()=>{t.style.display=t.__vOriginalDisplay})):Kr(o,(()=>{t.style.display=\"none\"}))):t.style.display=e?t.__vOriginalDisplay:\"none\"},unbind(t,e,n,o,r){r||(t.style.display=t.__vOriginalDisplay)}},cs={model:Xr,show:is};const as={name:String,appear:Boolean,css:Boolean,mode:String,type:String,enterClass:String,leaveClass:String,enterToClass:String,leaveToClass:String,enterActiveClass:String,leaveActiveClass:String,appearClass:String,appearActiveClass:String,appearToClass:String,duration:[Number,String,Object]};function ls(t){const e=t&&t.componentOptions;return e&&e.Ctor.options.abstract?ls(Ae(e.children)):t}function us(t){const e={},n=t.$options;for(const o in n.propsData)e[o]=t[o];const o=n._parentListeners;for(const t in o)e[$(t)]=o[t];return e}function fs(t,e){if(/\\d-keep-alive$/.test(e.tag))return t(\"keep-alive\",{props:e.componentOptions.propsData})}const ds=t=>t.tag||ye(t),ps=t=>\"show\"===t.name;var hs={name:\"transition\",props:as,abstract:!0,render(t){let e=this.$slots.default;if(!e)return;if(e=e.filter(ds),!e.length)return;const n=this.mode,o=e[0];if(function(t){for(;t=t.parent;)if(t.data.transition)return!0}(this.$vnode))return o;const r=ls(o);if(!r)return o;if(this._leaving)return fs(t,o);const i=`__transition-${this._uid}-`;r.key=null==r.key?r.isComment?i+\"comment\":i+r.tag:s(r.key)?0===String(r.key).indexOf(i)?r.key:i+r.key:r.key;const c=(r.data||(r.data={})).transition=us(this),a=this._vnode,l=ls(a);if(r.data.directives&&r.data.directives.some(ps)&&(r.data.show=!0),l&&l.data&&!function(t,e){return e.key===t.key&&e.tag===t.tag}(r,l)&&!ye(l)&&(!l.componentInstance||!l.componentInstance._vnode.isComment)){const e=l.data.transition=S({},c);if(\"out-in\"===n)return this._leaving=!0,Jt(e,\"afterLeave\",(()=>{this._leaving=!1,this.$forceUpdate()})),fs(t,o);if(\"in-out\"===n){if(ye(r))return a;let t;const n=()=>{t()};Jt(c,\"afterEnter\",n),Jt(c,\"enterCancelled\",n),Jt(e,\"delayLeave\",(e=>{t=e}))}}return o}};const ms=S({tag:String,moveClass:String},as);delete ms.mode;var _s={props:ms,beforeMount(){const t=this._update;this._update=(e,n)=>{const o=hn(this);this.__patch__(this._vnode,this.kept,!1,!0),this._vnode=this.kept,o(),t.call(this,e,n)}},render(t){const e=this.tag||this.$vnode.data.tag||\"span\",n=Object.create(null),o=this.prevChildren=this.children,r=this.$slots.default||[],s=this.children=[],i=us(this);for(let t=0;t{if(t.data.moved){const n=t.elm,o=n.style;Lr(n,e),o.transform=o.WebkitTransform=o.transitionDuration=\"\",n.addEventListener(Ir,n._moveCb=function t(o){o&&o.target!==n||o&&!/transform$/.test(o.propertyName)||(n.removeEventListener(Ir,t),n._moveCb=null,Fr(n,e))})}})))},methods:{hasMove(t,e){if(!Er)return!1;if(this._hasMove)return this._hasMove;const n=t.cloneNode();t._transitionClasses&&t._transitionClasses.forEach((t=>{jr(n,t)})),Sr(n,e),n.style.display=\"none\",this.$el.appendChild(n);const o=Vr(n);return this.$el.removeChild(n),this._hasMove=o.hasTransform}}};function vs(t){t.elm._moveCb&&t.elm._moveCb(),t.elm._enterCb&&t.elm._enterCb()}function ys(t){t.data.newPos=t.elm.getBoundingClientRect()}function gs(t){const e=t.data.pos,n=t.data.newPos,o=e.left-n.left,r=e.top-n.top;if(o||r){t.data.moved=!0;const e=t.elm.style;e.transform=e.WebkitTransform=`translate(${o}px,${r}px)`,e.transitionDuration=\"0s\"}}var bs={Transition:hs,TransitionGroup:_s};lo.config.mustUseProp=(t,e,n)=>\"value\"===n&&go(t)&&\"button\"!==e||\"selected\"===n&&\"option\"===t||\"checked\"===n&&\"input\"===t||\"muted\"===n&&\"video\"===t,lo.config.isReservedTag=Do,lo.config.isReservedAttr=yo,lo.config.getTagNamespace=function(t){return Io(t)?\"svg\":\"math\"===t?\"math\":void 0},lo.config.isUnknownElement=function(t){if(!z)return!0;if(Do(t))return!1;if(t=t.toLowerCase(),null!=No[t])return No[t];const e=document.createElement(t);return t.indexOf(\"-\")>-1?No[t]=e.constructor===window.HTMLUnknownElement||e.constructor===window.HTMLElement:No[t]=/HTMLUnknownElement/.test(e.toString())},S(lo.options.directives,cs),S(lo.options.components,bs),lo.prototype.__patch__=z?Jr:A,lo.prototype.$mount=function(t,e){return function(t,e,n){let o;t.$el=e,t.$options.render||(t.$options.render=ct),yn(t,\"beforeMount\"),o=()=>{t._update(t._render(),n)},new an(t,o,A,{before(){t._isMounted&&!t._isDestroyed&&yn(t,\"beforeUpdate\")}},!0),n=!1;const r=t._preWatchers;if(r)for(let t=0;t{L.devtools&&tt&&tt.emit(\"init\",lo)}),0),S(lo,en),module.exports=lo;","/*!\n * Vue.js v2.7.14\n * (c) 2014-2022 Evan You\n * Released under the MIT License.\n */\nvar emptyObject = Object.freeze({});\nvar isArray = Array.isArray;\n// These helpers produce better VM code in JS engines due to their\n// explicitness and function inlining.\nfunction isUndef(v) {\n return v === undefined || v === null;\n}\nfunction isDef(v) {\n return v !== undefined && v !== null;\n}\nfunction isTrue(v) {\n return v === true;\n}\nfunction isFalse(v) {\n return v === false;\n}\n/**\n * Check if value is primitive.\n */\nfunction isPrimitive(value) {\n return (typeof value === 'string' ||\n typeof value === 'number' ||\n // $flow-disable-line\n typeof value === 'symbol' ||\n typeof value === 'boolean');\n}\nfunction isFunction(value) {\n return typeof value === 'function';\n}\n/**\n * Quick object check - this is primarily used to tell\n * objects from primitive values when we know the value\n * is a JSON-compliant type.\n */\nfunction isObject(obj) {\n return obj !== null && typeof obj === 'object';\n}\n/**\n * Get the raw type string of a value, e.g., [object Object].\n */\nvar _toString = Object.prototype.toString;\nfunction toRawType(value) {\n return _toString.call(value).slice(8, -1);\n}\n/**\n * Strict object type check. Only returns true\n * for plain JavaScript objects.\n */\nfunction isPlainObject(obj) {\n return _toString.call(obj) === '[object Object]';\n}\nfunction isRegExp(v) {\n return _toString.call(v) === '[object RegExp]';\n}\n/**\n * Check if val is a valid array index.\n */\nfunction isValidArrayIndex(val) {\n var n = parseFloat(String(val));\n return n >= 0 && Math.floor(n) === n && isFinite(val);\n}\nfunction isPromise(val) {\n return (isDef(val) &&\n typeof val.then === 'function' &&\n typeof val.catch === 'function');\n}\n/**\n * Convert a value to a string that is actually rendered.\n */\nfunction toString(val) {\n return val == null\n ? ''\n : Array.isArray(val) || (isPlainObject(val) && val.toString === _toString)\n ? JSON.stringify(val, null, 2)\n : String(val);\n}\n/**\n * Convert an input value to a number for persistence.\n * If the conversion fails, return original string.\n */\nfunction toNumber(val) {\n var n = parseFloat(val);\n return isNaN(n) ? val : n;\n}\n/**\n * Make a map and return a function for checking if a key\n * is in that map.\n */\nfunction makeMap(str, expectsLowerCase) {\n var map = Object.create(null);\n var list = str.split(',');\n for (var i = 0; i < list.length; i++) {\n map[list[i]] = true;\n }\n return expectsLowerCase ? function (val) { return map[val.toLowerCase()]; } : function (val) { return map[val]; };\n}\n/**\n * Check if a tag is a built-in tag.\n */\nvar isBuiltInTag = makeMap('slot,component', true);\n/**\n * Check if an attribute is a reserved attribute.\n */\nvar isReservedAttribute = makeMap('key,ref,slot,slot-scope,is');\n/**\n * Remove an item from an array.\n */\nfunction remove$2(arr, item) {\n var len = arr.length;\n if (len) {\n // fast path for the only / last item\n if (item === arr[len - 1]) {\n arr.length = len - 1;\n return;\n }\n var index = arr.indexOf(item);\n if (index > -1) {\n return arr.splice(index, 1);\n }\n }\n}\n/**\n * Check whether an object has the property.\n */\nvar hasOwnProperty = Object.prototype.hasOwnProperty;\nfunction hasOwn(obj, key) {\n return hasOwnProperty.call(obj, key);\n}\n/**\n * Create a cached version of a pure function.\n */\nfunction cached(fn) {\n var cache = Object.create(null);\n return function cachedFn(str) {\n var hit = cache[str];\n return hit || (cache[str] = fn(str));\n };\n}\n/**\n * Camelize a hyphen-delimited string.\n */\nvar camelizeRE = /-(\\w)/g;\nvar camelize = cached(function (str) {\n return str.replace(camelizeRE, function (_, c) { return (c ? c.toUpperCase() : ''); });\n});\n/**\n * Capitalize a string.\n */\nvar capitalize = cached(function (str) {\n return str.charAt(0).toUpperCase() + str.slice(1);\n});\n/**\n * Hyphenate a camelCase string.\n */\nvar hyphenateRE = /\\B([A-Z])/g;\nvar hyphenate = cached(function (str) {\n return str.replace(hyphenateRE, '-$1').toLowerCase();\n});\n/**\n * Simple bind polyfill for environments that do not support it,\n * e.g., PhantomJS 1.x. Technically, we don't need this anymore\n * since native bind is now performant enough in most browsers.\n * But removing it would mean breaking code that was able to run in\n * PhantomJS 1.x, so this must be kept for backward compatibility.\n */\n/* istanbul ignore next */\nfunction polyfillBind(fn, ctx) {\n function boundFn(a) {\n var l = arguments.length;\n return l\n ? l > 1\n ? fn.apply(ctx, arguments)\n : fn.call(ctx, a)\n : fn.call(ctx);\n }\n boundFn._length = fn.length;\n return boundFn;\n}\nfunction nativeBind(fn, ctx) {\n return fn.bind(ctx);\n}\n// @ts-expect-error bind cannot be `undefined`\nvar bind = Function.prototype.bind ? nativeBind : polyfillBind;\n/**\n * Convert an Array-like object to a real Array.\n */\nfunction toArray(list, start) {\n start = start || 0;\n var i = list.length - start;\n var ret = new Array(i);\n while (i--) {\n ret[i] = list[i + start];\n }\n return ret;\n}\n/**\n * Mix properties into target object.\n */\nfunction extend(to, _from) {\n for (var key in _from) {\n to[key] = _from[key];\n }\n return to;\n}\n/**\n * Merge an Array of Objects into a single Object.\n */\nfunction toObject(arr) {\n var res = {};\n for (var i = 0; i < arr.length; i++) {\n if (arr[i]) {\n extend(res, arr[i]);\n }\n }\n return res;\n}\n/* eslint-disable no-unused-vars */\n/**\n * Perform no operation.\n * Stubbing args to make Flow happy without leaving useless transpiled code\n * with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/).\n */\nfunction noop(a, b, c) { }\n/**\n * Always return false.\n */\nvar no = function (a, b, c) { return false; };\n/* eslint-enable no-unused-vars */\n/**\n * Return the same value.\n */\nvar identity = function (_) { return _; };\n/**\n * Check if two values are loosely equal - that is,\n * if they are plain objects, do they have the same shape?\n */\nfunction looseEqual(a, b) {\n if (a === b)\n return true;\n var isObjectA = isObject(a);\n var isObjectB = isObject(b);\n if (isObjectA && isObjectB) {\n try {\n var isArrayA = Array.isArray(a);\n var isArrayB = Array.isArray(b);\n if (isArrayA && isArrayB) {\n return (a.length === b.length &&\n a.every(function (e, i) {\n return looseEqual(e, b[i]);\n }));\n }\n else if (a instanceof Date && b instanceof Date) {\n return a.getTime() === b.getTime();\n }\n else if (!isArrayA && !isArrayB) {\n var keysA = Object.keys(a);\n var keysB = Object.keys(b);\n return (keysA.length === keysB.length &&\n keysA.every(function (key) {\n return looseEqual(a[key], b[key]);\n }));\n }\n else {\n /* istanbul ignore next */\n return false;\n }\n }\n catch (e) {\n /* istanbul ignore next */\n return false;\n }\n }\n else if (!isObjectA && !isObjectB) {\n return String(a) === String(b);\n }\n else {\n return false;\n }\n}\n/**\n * Return the first index at which a loosely equal value can be\n * found in the array (if value is a plain object, the array must\n * contain an object of the same shape), or -1 if it is not present.\n */\nfunction looseIndexOf(arr, val) {\n for (var i = 0; i < arr.length; i++) {\n if (looseEqual(arr[i], val))\n return i;\n }\n return -1;\n}\n/**\n * Ensure a function is called only once.\n */\nfunction once(fn) {\n var called = false;\n return function () {\n if (!called) {\n called = true;\n fn.apply(this, arguments);\n }\n };\n}\n// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is#polyfill\nfunction hasChanged(x, y) {\n if (x === y) {\n return x === 0 && 1 / x !== 1 / y;\n }\n else {\n return x === x || y === y;\n }\n}\n\nvar SSR_ATTR = 'data-server-rendered';\nvar ASSET_TYPES = ['component', 'directive', 'filter'];\nvar LIFECYCLE_HOOKS = [\n 'beforeCreate',\n 'created',\n 'beforeMount',\n 'mounted',\n 'beforeUpdate',\n 'updated',\n 'beforeDestroy',\n 'destroyed',\n 'activated',\n 'deactivated',\n 'errorCaptured',\n 'serverPrefetch',\n 'renderTracked',\n 'renderTriggered'\n];\n\nvar config = {\n /**\n * Option merge strategies (used in core/util/options)\n */\n // $flow-disable-line\n optionMergeStrategies: Object.create(null),\n /**\n * Whether to suppress warnings.\n */\n silent: false,\n /**\n * Show production mode tip message on boot?\n */\n productionTip: process.env.NODE_ENV !== 'production',\n /**\n * Whether to enable devtools\n */\n devtools: process.env.NODE_ENV !== 'production',\n /**\n * Whether to record perf\n */\n performance: false,\n /**\n * Error handler for watcher errors\n */\n errorHandler: null,\n /**\n * Warn handler for watcher warns\n */\n warnHandler: null,\n /**\n * Ignore certain custom elements\n */\n ignoredElements: [],\n /**\n * Custom user key aliases for v-on\n */\n // $flow-disable-line\n keyCodes: Object.create(null),\n /**\n * Check if a tag is reserved so that it cannot be registered as a\n * component. This is platform-dependent and may be overwritten.\n */\n isReservedTag: no,\n /**\n * Check if an attribute is reserved so that it cannot be used as a component\n * prop. This is platform-dependent and may be overwritten.\n */\n isReservedAttr: no,\n /**\n * Check if a tag is an unknown element.\n * Platform-dependent.\n */\n isUnknownElement: no,\n /**\n * Get the namespace of an element\n */\n getTagNamespace: noop,\n /**\n * Parse the real tag name for the specific platform.\n */\n parsePlatformTagName: identity,\n /**\n * Check if an attribute must be bound using property, e.g. value\n * Platform-dependent.\n */\n mustUseProp: no,\n /**\n * Perform updates asynchronously. Intended to be used by Vue Test Utils\n * This will significantly reduce performance if set to false.\n */\n async: true,\n /**\n * Exposed for legacy reasons\n */\n _lifecycleHooks: LIFECYCLE_HOOKS\n};\n\n/**\n * unicode letters used for parsing html tags, component names and property paths.\n * using https://www.w3.org/TR/html53/semantics-scripting.html#potentialcustomelementname\n * skipping \\u10000-\\uEFFFF due to it freezing up PhantomJS\n */\nvar unicodeRegExp = /a-zA-Z\\u00B7\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u203F-\\u2040\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD/;\n/**\n * Check if a string starts with $ or _\n */\nfunction isReserved(str) {\n var c = (str + '').charCodeAt(0);\n return c === 0x24 || c === 0x5f;\n}\n/**\n * Define a property.\n */\nfunction def(obj, key, val, enumerable) {\n Object.defineProperty(obj, key, {\n value: val,\n enumerable: !!enumerable,\n writable: true,\n configurable: true\n });\n}\n/**\n * Parse simple path.\n */\nvar bailRE = new RegExp(\"[^\".concat(unicodeRegExp.source, \".$_\\\\d]\"));\nfunction parsePath(path) {\n if (bailRE.test(path)) {\n return;\n }\n var segments = path.split('.');\n return function (obj) {\n for (var i = 0; i < segments.length; i++) {\n if (!obj)\n return;\n obj = obj[segments[i]];\n }\n return obj;\n };\n}\n\n// can we use __proto__?\nvar hasProto = '__proto__' in {};\n// Browser environment sniffing\nvar inBrowser = typeof window !== 'undefined';\nvar UA = inBrowser && window.navigator.userAgent.toLowerCase();\nvar isIE = UA && /msie|trident/.test(UA);\nvar isIE9 = UA && UA.indexOf('msie 9.0') > 0;\nvar isEdge = UA && UA.indexOf('edge/') > 0;\nUA && UA.indexOf('android') > 0;\nvar isIOS = UA && /iphone|ipad|ipod|ios/.test(UA);\nUA && /chrome\\/\\d+/.test(UA) && !isEdge;\nUA && /phantomjs/.test(UA);\nvar isFF = UA && UA.match(/firefox\\/(\\d+)/);\n// Firefox has a \"watch\" function on Object.prototype...\n// @ts-expect-error firebox support\nvar nativeWatch = {}.watch;\nvar supportsPassive = false;\nif (inBrowser) {\n try {\n var opts = {};\n Object.defineProperty(opts, 'passive', {\n get: function () {\n /* istanbul ignore next */\n supportsPassive = true;\n }\n }); // https://github.com/facebook/flow/issues/285\n window.addEventListener('test-passive', null, opts);\n }\n catch (e) { }\n}\n// this needs to be lazy-evaled because vue may be required before\n// vue-server-renderer can set VUE_ENV\nvar _isServer;\nvar isServerRendering = function () {\n if (_isServer === undefined) {\n /* istanbul ignore if */\n if (!inBrowser && typeof global !== 'undefined') {\n // detect presence of vue-server-renderer and avoid\n // Webpack shimming the process\n _isServer =\n global['process'] && global['process'].env.VUE_ENV === 'server';\n }\n else {\n _isServer = false;\n }\n }\n return _isServer;\n};\n// detect devtools\nvar devtools = inBrowser && window.__VUE_DEVTOOLS_GLOBAL_HOOK__;\n/* istanbul ignore next */\nfunction isNative(Ctor) {\n return typeof Ctor === 'function' && /native code/.test(Ctor.toString());\n}\nvar hasSymbol = typeof Symbol !== 'undefined' &&\n isNative(Symbol) &&\n typeof Reflect !== 'undefined' &&\n isNative(Reflect.ownKeys);\nvar _Set; // $flow-disable-line\n/* istanbul ignore if */ if (typeof Set !== 'undefined' && isNative(Set)) {\n // use native Set when available.\n _Set = Set;\n}\nelse {\n // a non-standard Set polyfill that only works with primitive keys.\n _Set = /** @class */ (function () {\n function Set() {\n this.set = Object.create(null);\n }\n Set.prototype.has = function (key) {\n return this.set[key] === true;\n };\n Set.prototype.add = function (key) {\n this.set[key] = true;\n };\n Set.prototype.clear = function () {\n this.set = Object.create(null);\n };\n return Set;\n }());\n}\n\nvar currentInstance = null;\n/**\n * This is exposed for compatibility with v3 (e.g. some functions in VueUse\n * relies on it). Do not use this internally, just use `currentInstance`.\n *\n * @internal this function needs manual type declaration because it relies\n * on previously manually authored types from Vue 2\n */\nfunction getCurrentInstance() {\n return currentInstance && { proxy: currentInstance };\n}\n/**\n * @internal\n */\nfunction setCurrentInstance(vm) {\n if (vm === void 0) { vm = null; }\n if (!vm)\n currentInstance && currentInstance._scope.off();\n currentInstance = vm;\n vm && vm._scope.on();\n}\n\n/**\n * @internal\n */\nvar VNode = /** @class */ (function () {\n function VNode(tag, data, children, text, elm, context, componentOptions, asyncFactory) {\n this.tag = tag;\n this.data = data;\n this.children = children;\n this.text = text;\n this.elm = elm;\n this.ns = undefined;\n this.context = context;\n this.fnContext = undefined;\n this.fnOptions = undefined;\n this.fnScopeId = undefined;\n this.key = data && data.key;\n this.componentOptions = componentOptions;\n this.componentInstance = undefined;\n this.parent = undefined;\n this.raw = false;\n this.isStatic = false;\n this.isRootInsert = true;\n this.isComment = false;\n this.isCloned = false;\n this.isOnce = false;\n this.asyncFactory = asyncFactory;\n this.asyncMeta = undefined;\n this.isAsyncPlaceholder = false;\n }\n Object.defineProperty(VNode.prototype, \"child\", {\n // DEPRECATED: alias for componentInstance for backwards compat.\n /* istanbul ignore next */\n get: function () {\n return this.componentInstance;\n },\n enumerable: false,\n configurable: true\n });\n return VNode;\n}());\nvar createEmptyVNode = function (text) {\n if (text === void 0) { text = ''; }\n var node = new VNode();\n node.text = text;\n node.isComment = true;\n return node;\n};\nfunction createTextVNode(val) {\n return new VNode(undefined, undefined, undefined, String(val));\n}\n// optimized shallow clone\n// used for static nodes and slot nodes because they may be reused across\n// multiple renders, cloning them avoids errors when DOM manipulations rely\n// on their elm reference.\nfunction cloneVNode(vnode) {\n var cloned = new VNode(vnode.tag, vnode.data, \n // #7975\n // clone children array to avoid mutating original in case of cloning\n // a child.\n vnode.children && vnode.children.slice(), vnode.text, vnode.elm, vnode.context, vnode.componentOptions, vnode.asyncFactory);\n cloned.ns = vnode.ns;\n cloned.isStatic = vnode.isStatic;\n cloned.key = vnode.key;\n cloned.isComment = vnode.isComment;\n cloned.fnContext = vnode.fnContext;\n cloned.fnOptions = vnode.fnOptions;\n cloned.fnScopeId = vnode.fnScopeId;\n cloned.asyncMeta = vnode.asyncMeta;\n cloned.isCloned = true;\n return cloned;\n}\n\n/******************************************************************************\r\nCopyright (c) Microsoft Corporation.\r\n\r\nPermission to use, copy, modify, and/or distribute this software for any\r\npurpose with or without fee is hereby granted.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH\r\nREGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY\r\nAND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,\r\nINDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM\r\nLOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR\r\nOTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR\r\nPERFORMANCE OF THIS SOFTWARE.\r\n***************************************************************************** */\r\n\r\nvar __assign = function() {\r\n __assign = Object.assign || function __assign(t) {\r\n for (var s, i = 1, n = arguments.length; i < n; i++) {\r\n s = arguments[i];\r\n for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];\r\n }\r\n return t;\r\n };\r\n return __assign.apply(this, arguments);\r\n};\n\nvar uid$2 = 0;\nvar pendingCleanupDeps = [];\nvar cleanupDeps = function () {\n for (var i = 0; i < pendingCleanupDeps.length; i++) {\n var dep = pendingCleanupDeps[i];\n dep.subs = dep.subs.filter(function (s) { return s; });\n dep._pending = false;\n }\n pendingCleanupDeps.length = 0;\n};\n/**\n * A dep is an observable that can have multiple\n * directives subscribing to it.\n * @internal\n */\nvar Dep = /** @class */ (function () {\n function Dep() {\n // pending subs cleanup\n this._pending = false;\n this.id = uid$2++;\n this.subs = [];\n }\n Dep.prototype.addSub = function (sub) {\n this.subs.push(sub);\n };\n Dep.prototype.removeSub = function (sub) {\n // #12696 deps with massive amount of subscribers are extremely slow to\n // clean up in Chromium\n // to workaround this, we unset the sub for now, and clear them on\n // next scheduler flush.\n this.subs[this.subs.indexOf(sub)] = null;\n if (!this._pending) {\n this._pending = true;\n pendingCleanupDeps.push(this);\n }\n };\n Dep.prototype.depend = function (info) {\n if (Dep.target) {\n Dep.target.addDep(this);\n if (process.env.NODE_ENV !== 'production' && info && Dep.target.onTrack) {\n Dep.target.onTrack(__assign({ effect: Dep.target }, info));\n }\n }\n };\n Dep.prototype.notify = function (info) {\n // stabilize the subscriber list first\n var subs = this.subs.filter(function (s) { return s; });\n if (process.env.NODE_ENV !== 'production' && !config.async) {\n // subs aren't sorted in scheduler if not running async\n // we need to sort them now to make sure they fire in correct\n // order\n subs.sort(function (a, b) { return a.id - b.id; });\n }\n for (var i = 0, l = subs.length; i < l; i++) {\n var sub = subs[i];\n if (process.env.NODE_ENV !== 'production' && info) {\n sub.onTrigger &&\n sub.onTrigger(__assign({ effect: subs[i] }, info));\n }\n sub.update();\n }\n };\n return Dep;\n}());\n// The current target watcher being evaluated.\n// This is globally unique because only one watcher\n// can be evaluated at a time.\nDep.target = null;\nvar targetStack = [];\nfunction pushTarget(target) {\n targetStack.push(target);\n Dep.target = target;\n}\nfunction popTarget() {\n targetStack.pop();\n Dep.target = targetStack[targetStack.length - 1];\n}\n\n/*\n * not type checking this file because flow doesn't play well with\n * dynamically accessing methods on Array prototype\n */\nvar arrayProto = Array.prototype;\nvar arrayMethods = Object.create(arrayProto);\nvar methodsToPatch = [\n 'push',\n 'pop',\n 'shift',\n 'unshift',\n 'splice',\n 'sort',\n 'reverse'\n];\n/**\n * Intercept mutating methods and emit events\n */\nmethodsToPatch.forEach(function (method) {\n // cache original method\n var original = arrayProto[method];\n def(arrayMethods, method, function mutator() {\n var args = [];\n for (var _i = 0; _i < arguments.length; _i++) {\n args[_i] = arguments[_i];\n }\n var result = original.apply(this, args);\n var ob = this.__ob__;\n var inserted;\n switch (method) {\n case 'push':\n case 'unshift':\n inserted = args;\n break;\n case 'splice':\n inserted = args.slice(2);\n break;\n }\n if (inserted)\n ob.observeArray(inserted);\n // notify change\n if (process.env.NODE_ENV !== 'production') {\n ob.dep.notify({\n type: \"array mutation\" /* TriggerOpTypes.ARRAY_MUTATION */,\n target: this,\n key: method\n });\n }\n else {\n ob.dep.notify();\n }\n return result;\n });\n});\n\nvar arrayKeys = Object.getOwnPropertyNames(arrayMethods);\nvar NO_INIITIAL_VALUE = {};\n/**\n * In some cases we may want to disable observation inside a component's\n * update computation.\n */\nvar shouldObserve = true;\nfunction toggleObserving(value) {\n shouldObserve = value;\n}\n// ssr mock dep\nvar mockDep = {\n notify: noop,\n depend: noop,\n addSub: noop,\n removeSub: noop\n};\n/**\n * Observer class that is attached to each observed\n * object. Once attached, the observer converts the target\n * object's property keys into getter/setters that\n * collect dependencies and dispatch updates.\n */\nvar Observer = /** @class */ (function () {\n function Observer(value, shallow, mock) {\n if (shallow === void 0) { shallow = false; }\n if (mock === void 0) { mock = false; }\n this.value = value;\n this.shallow = shallow;\n this.mock = mock;\n // this.value = value\n this.dep = mock ? mockDep : new Dep();\n this.vmCount = 0;\n def(value, '__ob__', this);\n if (isArray(value)) {\n if (!mock) {\n if (hasProto) {\n value.__proto__ = arrayMethods;\n /* eslint-enable no-proto */\n }\n else {\n for (var i = 0, l = arrayKeys.length; i < l; i++) {\n var key = arrayKeys[i];\n def(value, key, arrayMethods[key]);\n }\n }\n }\n if (!shallow) {\n this.observeArray(value);\n }\n }\n else {\n /**\n * Walk through all properties and convert them into\n * getter/setters. This method should only be called when\n * value type is Object.\n */\n var keys = Object.keys(value);\n for (var i = 0; i < keys.length; i++) {\n var key = keys[i];\n defineReactive(value, key, NO_INIITIAL_VALUE, undefined, shallow, mock);\n }\n }\n }\n /**\n * Observe a list of Array items.\n */\n Observer.prototype.observeArray = function (value) {\n for (var i = 0, l = value.length; i < l; i++) {\n observe(value[i], false, this.mock);\n }\n };\n return Observer;\n}());\n// helpers\n/**\n * Attempt to create an observer instance for a value,\n * returns the new observer if successfully observed,\n * or the existing observer if the value already has one.\n */\nfunction observe(value, shallow, ssrMockReactivity) {\n if (value && hasOwn(value, '__ob__') && value.__ob__ instanceof Observer) {\n return value.__ob__;\n }\n if (shouldObserve &&\n (ssrMockReactivity || !isServerRendering()) &&\n (isArray(value) || isPlainObject(value)) &&\n Object.isExtensible(value) &&\n !value.__v_skip /* ReactiveFlags.SKIP */ &&\n !isRef(value) &&\n !(value instanceof VNode)) {\n return new Observer(value, shallow, ssrMockReactivity);\n }\n}\n/**\n * Define a reactive property on an Object.\n */\nfunction defineReactive(obj, key, val, customSetter, shallow, mock) {\n var dep = new Dep();\n var property = Object.getOwnPropertyDescriptor(obj, key);\n if (property && property.configurable === false) {\n return;\n }\n // cater for pre-defined getter/setters\n var getter = property && property.get;\n var setter = property && property.set;\n if ((!getter || setter) &&\n (val === NO_INIITIAL_VALUE || arguments.length === 2)) {\n val = obj[key];\n }\n var childOb = !shallow && observe(val, false, mock);\n Object.defineProperty(obj, key, {\n enumerable: true,\n configurable: true,\n get: function reactiveGetter() {\n var value = getter ? getter.call(obj) : val;\n if (Dep.target) {\n if (process.env.NODE_ENV !== 'production') {\n dep.depend({\n target: obj,\n type: \"get\" /* TrackOpTypes.GET */,\n key: key\n });\n }\n else {\n dep.depend();\n }\n if (childOb) {\n childOb.dep.depend();\n if (isArray(value)) {\n dependArray(value);\n }\n }\n }\n return isRef(value) && !shallow ? value.value : value;\n },\n set: function reactiveSetter(newVal) {\n var value = getter ? getter.call(obj) : val;\n if (!hasChanged(value, newVal)) {\n return;\n }\n if (process.env.NODE_ENV !== 'production' && customSetter) {\n customSetter();\n }\n if (setter) {\n setter.call(obj, newVal);\n }\n else if (getter) {\n // #7981: for accessor properties without setter\n return;\n }\n else if (!shallow && isRef(value) && !isRef(newVal)) {\n value.value = newVal;\n return;\n }\n else {\n val = newVal;\n }\n childOb = !shallow && observe(newVal, false, mock);\n if (process.env.NODE_ENV !== 'production') {\n dep.notify({\n type: \"set\" /* TriggerOpTypes.SET */,\n target: obj,\n key: key,\n newValue: newVal,\n oldValue: value\n });\n }\n else {\n dep.notify();\n }\n }\n });\n return dep;\n}\nfunction set(target, key, val) {\n if (process.env.NODE_ENV !== 'production' && (isUndef(target) || isPrimitive(target))) {\n warn(\"Cannot set reactive property on undefined, null, or primitive value: \".concat(target));\n }\n if (isReadonly(target)) {\n process.env.NODE_ENV !== 'production' && warn(\"Set operation on key \\\"\".concat(key, \"\\\" failed: target is readonly.\"));\n return;\n }\n var ob = target.__ob__;\n if (isArray(target) && isValidArrayIndex(key)) {\n target.length = Math.max(target.length, key);\n target.splice(key, 1, val);\n // when mocking for SSR, array methods are not hijacked\n if (ob && !ob.shallow && ob.mock) {\n observe(val, false, true);\n }\n return val;\n }\n if (key in target && !(key in Object.prototype)) {\n target[key] = val;\n return val;\n }\n if (target._isVue || (ob && ob.vmCount)) {\n process.env.NODE_ENV !== 'production' &&\n warn('Avoid adding reactive properties to a Vue instance or its root $data ' +\n 'at runtime - declare it upfront in the data option.');\n return val;\n }\n if (!ob) {\n target[key] = val;\n return val;\n }\n defineReactive(ob.value, key, val, undefined, ob.shallow, ob.mock);\n if (process.env.NODE_ENV !== 'production') {\n ob.dep.notify({\n type: \"add\" /* TriggerOpTypes.ADD */,\n target: target,\n key: key,\n newValue: val,\n oldValue: undefined\n });\n }\n else {\n ob.dep.notify();\n }\n return val;\n}\nfunction del(target, key) {\n if (process.env.NODE_ENV !== 'production' && (isUndef(target) || isPrimitive(target))) {\n warn(\"Cannot delete reactive property on undefined, null, or primitive value: \".concat(target));\n }\n if (isArray(target) && isValidArrayIndex(key)) {\n target.splice(key, 1);\n return;\n }\n var ob = target.__ob__;\n if (target._isVue || (ob && ob.vmCount)) {\n process.env.NODE_ENV !== 'production' &&\n warn('Avoid deleting properties on a Vue instance or its root $data ' +\n '- just set it to null.');\n return;\n }\n if (isReadonly(target)) {\n process.env.NODE_ENV !== 'production' &&\n warn(\"Delete operation on key \\\"\".concat(key, \"\\\" failed: target is readonly.\"));\n return;\n }\n if (!hasOwn(target, key)) {\n return;\n }\n delete target[key];\n if (!ob) {\n return;\n }\n if (process.env.NODE_ENV !== 'production') {\n ob.dep.notify({\n type: \"delete\" /* TriggerOpTypes.DELETE */,\n target: target,\n key: key\n });\n }\n else {\n ob.dep.notify();\n }\n}\n/**\n * Collect dependencies on array elements when the array is touched, since\n * we cannot intercept array element access like property getters.\n */\nfunction dependArray(value) {\n for (var e = void 0, i = 0, l = value.length; i < l; i++) {\n e = value[i];\n if (e && e.__ob__) {\n e.__ob__.dep.depend();\n }\n if (isArray(e)) {\n dependArray(e);\n }\n }\n}\n\nfunction reactive(target) {\n makeReactive(target, false);\n return target;\n}\n/**\n * Return a shallowly-reactive copy of the original object, where only the root\n * level properties are reactive. It also does not auto-unwrap refs (even at the\n * root level).\n */\nfunction shallowReactive(target) {\n makeReactive(target, true);\n def(target, \"__v_isShallow\" /* ReactiveFlags.IS_SHALLOW */, true);\n return target;\n}\nfunction makeReactive(target, shallow) {\n // if trying to observe a readonly proxy, return the readonly version.\n if (!isReadonly(target)) {\n if (process.env.NODE_ENV !== 'production') {\n if (isArray(target)) {\n warn(\"Avoid using Array as root value for \".concat(shallow ? \"shallowReactive()\" : \"reactive()\", \" as it cannot be tracked in watch() or watchEffect(). Use \").concat(shallow ? \"shallowRef()\" : \"ref()\", \" instead. This is a Vue-2-only limitation.\"));\n }\n var existingOb = target && target.__ob__;\n if (existingOb && existingOb.shallow !== shallow) {\n warn(\"Target is already a \".concat(existingOb.shallow ? \"\" : \"non-\", \"shallow reactive object, and cannot be converted to \").concat(shallow ? \"\" : \"non-\", \"shallow.\"));\n }\n }\n var ob = observe(target, shallow, isServerRendering() /* ssr mock reactivity */);\n if (process.env.NODE_ENV !== 'production' && !ob) {\n if (target == null || isPrimitive(target)) {\n warn(\"value cannot be made reactive: \".concat(String(target)));\n }\n if (isCollectionType(target)) {\n warn(\"Vue 2 does not support reactive collection types such as Map or Set.\");\n }\n }\n }\n}\nfunction isReactive(value) {\n if (isReadonly(value)) {\n return isReactive(value[\"__v_raw\" /* ReactiveFlags.RAW */]);\n }\n return !!(value && value.__ob__);\n}\nfunction isShallow(value) {\n return !!(value && value.__v_isShallow);\n}\nfunction isReadonly(value) {\n return !!(value && value.__v_isReadonly);\n}\nfunction isProxy(value) {\n return isReactive(value) || isReadonly(value);\n}\nfunction toRaw(observed) {\n var raw = observed && observed[\"__v_raw\" /* ReactiveFlags.RAW */];\n return raw ? toRaw(raw) : observed;\n}\nfunction markRaw(value) {\n // non-extensible objects won't be observed anyway\n if (Object.isExtensible(value)) {\n def(value, \"__v_skip\" /* ReactiveFlags.SKIP */, true);\n }\n return value;\n}\n/**\n * @internal\n */\nfunction isCollectionType(value) {\n var type = toRawType(value);\n return (type === 'Map' || type === 'WeakMap' || type === 'Set' || type === 'WeakSet');\n}\n\n/**\n * @internal\n */\nvar RefFlag = \"__v_isRef\";\nfunction isRef(r) {\n return !!(r && r.__v_isRef === true);\n}\nfunction ref$1(value) {\n return createRef(value, false);\n}\nfunction shallowRef(value) {\n return createRef(value, true);\n}\nfunction createRef(rawValue, shallow) {\n if (isRef(rawValue)) {\n return rawValue;\n }\n var ref = {};\n def(ref, RefFlag, true);\n def(ref, \"__v_isShallow\" /* ReactiveFlags.IS_SHALLOW */, shallow);\n def(ref, 'dep', defineReactive(ref, 'value', rawValue, null, shallow, isServerRendering()));\n return ref;\n}\nfunction triggerRef(ref) {\n if (process.env.NODE_ENV !== 'production' && !ref.dep) {\n warn(\"received object is not a triggerable ref.\");\n }\n if (process.env.NODE_ENV !== 'production') {\n ref.dep &&\n ref.dep.notify({\n type: \"set\" /* TriggerOpTypes.SET */,\n target: ref,\n key: 'value'\n });\n }\n else {\n ref.dep && ref.dep.notify();\n }\n}\nfunction unref(ref) {\n return isRef(ref) ? ref.value : ref;\n}\nfunction proxyRefs(objectWithRefs) {\n if (isReactive(objectWithRefs)) {\n return objectWithRefs;\n }\n var proxy = {};\n var keys = Object.keys(objectWithRefs);\n for (var i = 0; i < keys.length; i++) {\n proxyWithRefUnwrap(proxy, objectWithRefs, keys[i]);\n }\n return proxy;\n}\nfunction proxyWithRefUnwrap(target, source, key) {\n Object.defineProperty(target, key, {\n enumerable: true,\n configurable: true,\n get: function () {\n var val = source[key];\n if (isRef(val)) {\n return val.value;\n }\n else {\n var ob = val && val.__ob__;\n if (ob)\n ob.dep.depend();\n return val;\n }\n },\n set: function (value) {\n var oldValue = source[key];\n if (isRef(oldValue) && !isRef(value)) {\n oldValue.value = value;\n }\n else {\n source[key] = value;\n }\n }\n });\n}\nfunction customRef(factory) {\n var dep = new Dep();\n var _a = factory(function () {\n if (process.env.NODE_ENV !== 'production') {\n dep.depend({\n target: ref,\n type: \"get\" /* TrackOpTypes.GET */,\n key: 'value'\n });\n }\n else {\n dep.depend();\n }\n }, function () {\n if (process.env.NODE_ENV !== 'production') {\n dep.notify({\n target: ref,\n type: \"set\" /* TriggerOpTypes.SET */,\n key: 'value'\n });\n }\n else {\n dep.notify();\n }\n }), get = _a.get, set = _a.set;\n var ref = {\n get value() {\n return get();\n },\n set value(newVal) {\n set(newVal);\n }\n };\n def(ref, RefFlag, true);\n return ref;\n}\nfunction toRefs(object) {\n if (process.env.NODE_ENV !== 'production' && !isReactive(object)) {\n warn(\"toRefs() expects a reactive object but received a plain one.\");\n }\n var ret = isArray(object) ? new Array(object.length) : {};\n for (var key in object) {\n ret[key] = toRef(object, key);\n }\n return ret;\n}\nfunction toRef(object, key, defaultValue) {\n var val = object[key];\n if (isRef(val)) {\n return val;\n }\n var ref = {\n get value() {\n var val = object[key];\n return val === undefined ? defaultValue : val;\n },\n set value(newVal) {\n object[key] = newVal;\n }\n };\n def(ref, RefFlag, true);\n return ref;\n}\n\nvar rawToReadonlyFlag = \"__v_rawToReadonly\";\nvar rawToShallowReadonlyFlag = \"__v_rawToShallowReadonly\";\nfunction readonly(target) {\n return createReadonly(target, false);\n}\nfunction createReadonly(target, shallow) {\n if (!isPlainObject(target)) {\n if (process.env.NODE_ENV !== 'production') {\n if (isArray(target)) {\n warn(\"Vue 2 does not support readonly arrays.\");\n }\n else if (isCollectionType(target)) {\n warn(\"Vue 2 does not support readonly collection types such as Map or Set.\");\n }\n else {\n warn(\"value cannot be made readonly: \".concat(typeof target));\n }\n }\n return target;\n }\n if (process.env.NODE_ENV !== 'production' && !Object.isExtensible(target)) {\n warn(\"Vue 2 does not support creating readonly proxy for non-extensible object.\");\n }\n // already a readonly object\n if (isReadonly(target)) {\n return target;\n }\n // already has a readonly proxy\n var existingFlag = shallow ? rawToShallowReadonlyFlag : rawToReadonlyFlag;\n var existingProxy = target[existingFlag];\n if (existingProxy) {\n return existingProxy;\n }\n var proxy = Object.create(Object.getPrototypeOf(target));\n def(target, existingFlag, proxy);\n def(proxy, \"__v_isReadonly\" /* ReactiveFlags.IS_READONLY */, true);\n def(proxy, \"__v_raw\" /* ReactiveFlags.RAW */, target);\n if (isRef(target)) {\n def(proxy, RefFlag, true);\n }\n if (shallow || isShallow(target)) {\n def(proxy, \"__v_isShallow\" /* ReactiveFlags.IS_SHALLOW */, true);\n }\n var keys = Object.keys(target);\n for (var i = 0; i < keys.length; i++) {\n defineReadonlyProperty(proxy, target, keys[i], shallow);\n }\n return proxy;\n}\nfunction defineReadonlyProperty(proxy, target, key, shallow) {\n Object.defineProperty(proxy, key, {\n enumerable: true,\n configurable: true,\n get: function () {\n var val = target[key];\n return shallow || !isPlainObject(val) ? val : readonly(val);\n },\n set: function () {\n process.env.NODE_ENV !== 'production' &&\n warn(\"Set operation on key \\\"\".concat(key, \"\\\" failed: target is readonly.\"));\n }\n });\n}\n/**\n * Returns a reactive-copy of the original object, where only the root level\n * properties are readonly, and does NOT unwrap refs nor recursively convert\n * returned properties.\n * This is used for creating the props proxy object for stateful components.\n */\nfunction shallowReadonly(target) {\n return createReadonly(target, true);\n}\n\nfunction computed(getterOrOptions, debugOptions) {\n var getter;\n var setter;\n var onlyGetter = isFunction(getterOrOptions);\n if (onlyGetter) {\n getter = getterOrOptions;\n setter = process.env.NODE_ENV !== 'production'\n ? function () {\n warn('Write operation failed: computed value is readonly');\n }\n : noop;\n }\n else {\n getter = getterOrOptions.get;\n setter = getterOrOptions.set;\n }\n var watcher = isServerRendering()\n ? null\n : new Watcher(currentInstance, getter, noop, { lazy: true });\n if (process.env.NODE_ENV !== 'production' && watcher && debugOptions) {\n watcher.onTrack = debugOptions.onTrack;\n watcher.onTrigger = debugOptions.onTrigger;\n }\n var ref = {\n // some libs rely on the presence effect for checking computed refs\n // from normal refs, but the implementation doesn't matter\n effect: watcher,\n get value() {\n if (watcher) {\n if (watcher.dirty) {\n watcher.evaluate();\n }\n if (Dep.target) {\n if (process.env.NODE_ENV !== 'production' && Dep.target.onTrack) {\n Dep.target.onTrack({\n effect: Dep.target,\n target: ref,\n type: \"get\" /* TrackOpTypes.GET */,\n key: 'value'\n });\n }\n watcher.depend();\n }\n return watcher.value;\n }\n else {\n return getter();\n }\n },\n set value(newVal) {\n setter(newVal);\n }\n };\n def(ref, RefFlag, true);\n def(ref, \"__v_isReadonly\" /* ReactiveFlags.IS_READONLY */, onlyGetter);\n return ref;\n}\n\nvar WATCHER = \"watcher\";\nvar WATCHER_CB = \"\".concat(WATCHER, \" callback\");\nvar WATCHER_GETTER = \"\".concat(WATCHER, \" getter\");\nvar WATCHER_CLEANUP = \"\".concat(WATCHER, \" cleanup\");\n// Simple effect.\nfunction watchEffect(effect, options) {\n return doWatch(effect, null, options);\n}\nfunction watchPostEffect(effect, options) {\n return doWatch(effect, null, (process.env.NODE_ENV !== 'production'\n ? __assign(__assign({}, options), { flush: 'post' }) : { flush: 'post' }));\n}\nfunction watchSyncEffect(effect, options) {\n return doWatch(effect, null, (process.env.NODE_ENV !== 'production'\n ? __assign(__assign({}, options), { flush: 'sync' }) : { flush: 'sync' }));\n}\n// initial value for watchers to trigger on undefined initial values\nvar INITIAL_WATCHER_VALUE = {};\n// implementation\nfunction watch(source, cb, options) {\n if (process.env.NODE_ENV !== 'production' && typeof cb !== 'function') {\n warn(\"`watch(fn, options?)` signature has been moved to a separate API. \" +\n \"Use `watchEffect(fn, options?)` instead. `watch` now only \" +\n \"supports `watch(source, cb, options?) signature.\");\n }\n return doWatch(source, cb, options);\n}\nfunction doWatch(source, cb, _a) {\n var _b = _a === void 0 ? emptyObject : _a, immediate = _b.immediate, deep = _b.deep, _c = _b.flush, flush = _c === void 0 ? 'pre' : _c, onTrack = _b.onTrack, onTrigger = _b.onTrigger;\n if (process.env.NODE_ENV !== 'production' && !cb) {\n if (immediate !== undefined) {\n warn(\"watch() \\\"immediate\\\" option is only respected when using the \" +\n \"watch(source, callback, options?) signature.\");\n }\n if (deep !== undefined) {\n warn(\"watch() \\\"deep\\\" option is only respected when using the \" +\n \"watch(source, callback, options?) signature.\");\n }\n }\n var warnInvalidSource = function (s) {\n warn(\"Invalid watch source: \".concat(s, \". A watch source can only be a getter/effect \") +\n \"function, a ref, a reactive object, or an array of these types.\");\n };\n var instance = currentInstance;\n var call = function (fn, type, args) {\n if (args === void 0) { args = null; }\n return invokeWithErrorHandling(fn, null, args, instance, type);\n };\n var getter;\n var forceTrigger = false;\n var isMultiSource = false;\n if (isRef(source)) {\n getter = function () { return source.value; };\n forceTrigger = isShallow(source);\n }\n else if (isReactive(source)) {\n getter = function () {\n source.__ob__.dep.depend();\n return source;\n };\n deep = true;\n }\n else if (isArray(source)) {\n isMultiSource = true;\n forceTrigger = source.some(function (s) { return isReactive(s) || isShallow(s); });\n getter = function () {\n return source.map(function (s) {\n if (isRef(s)) {\n return s.value;\n }\n else if (isReactive(s)) {\n return traverse(s);\n }\n else if (isFunction(s)) {\n return call(s, WATCHER_GETTER);\n }\n else {\n process.env.NODE_ENV !== 'production' && warnInvalidSource(s);\n }\n });\n };\n }\n else if (isFunction(source)) {\n if (cb) {\n // getter with cb\n getter = function () { return call(source, WATCHER_GETTER); };\n }\n else {\n // no cb -> simple effect\n getter = function () {\n if (instance && instance._isDestroyed) {\n return;\n }\n if (cleanup) {\n cleanup();\n }\n return call(source, WATCHER, [onCleanup]);\n };\n }\n }\n else {\n getter = noop;\n process.env.NODE_ENV !== 'production' && warnInvalidSource(source);\n }\n if (cb && deep) {\n var baseGetter_1 = getter;\n getter = function () { return traverse(baseGetter_1()); };\n }\n var cleanup;\n var onCleanup = function (fn) {\n cleanup = watcher.onStop = function () {\n call(fn, WATCHER_CLEANUP);\n };\n };\n // in SSR there is no need to setup an actual effect, and it should be noop\n // unless it's eager\n if (isServerRendering()) {\n // we will also not call the invalidate callback (+ runner is not set up)\n onCleanup = noop;\n if (!cb) {\n getter();\n }\n else if (immediate) {\n call(cb, WATCHER_CB, [\n getter(),\n isMultiSource ? [] : undefined,\n onCleanup\n ]);\n }\n return noop;\n }\n var watcher = new Watcher(currentInstance, getter, noop, {\n lazy: true\n });\n watcher.noRecurse = !cb;\n var oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE;\n // overwrite default run\n watcher.run = function () {\n if (!watcher.active) {\n return;\n }\n if (cb) {\n // watch(source, cb)\n var newValue = watcher.get();\n if (deep ||\n forceTrigger ||\n (isMultiSource\n ? newValue.some(function (v, i) {\n return hasChanged(v, oldValue[i]);\n })\n : hasChanged(newValue, oldValue))) {\n // cleanup before running cb again\n if (cleanup) {\n cleanup();\n }\n call(cb, WATCHER_CB, [\n newValue,\n // pass undefined as the old value when it's changed for the first time\n oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,\n onCleanup\n ]);\n oldValue = newValue;\n }\n }\n else {\n // watchEffect\n watcher.get();\n }\n };\n if (flush === 'sync') {\n watcher.update = watcher.run;\n }\n else if (flush === 'post') {\n watcher.post = true;\n watcher.update = function () { return queueWatcher(watcher); };\n }\n else {\n // pre\n watcher.update = function () {\n if (instance && instance === currentInstance && !instance._isMounted) {\n // pre-watcher triggered before\n var buffer = instance._preWatchers || (instance._preWatchers = []);\n if (buffer.indexOf(watcher) < 0)\n buffer.push(watcher);\n }\n else {\n queueWatcher(watcher);\n }\n };\n }\n if (process.env.NODE_ENV !== 'production') {\n watcher.onTrack = onTrack;\n watcher.onTrigger = onTrigger;\n }\n // initial run\n if (cb) {\n if (immediate) {\n watcher.run();\n }\n else {\n oldValue = watcher.get();\n }\n }\n else if (flush === 'post' && instance) {\n instance.$once('hook:mounted', function () { return watcher.get(); });\n }\n else {\n watcher.get();\n }\n return function () {\n watcher.teardown();\n };\n}\n\nvar activeEffectScope;\nvar EffectScope = /** @class */ (function () {\n function EffectScope(detached) {\n if (detached === void 0) { detached = false; }\n this.detached = detached;\n /**\n * @internal\n */\n this.active = true;\n /**\n * @internal\n */\n this.effects = [];\n /**\n * @internal\n */\n this.cleanups = [];\n this.parent = activeEffectScope;\n if (!detached && activeEffectScope) {\n this.index =\n (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(this) - 1;\n }\n }\n EffectScope.prototype.run = function (fn) {\n if (this.active) {\n var currentEffectScope = activeEffectScope;\n try {\n activeEffectScope = this;\n return fn();\n }\n finally {\n activeEffectScope = currentEffectScope;\n }\n }\n else if (process.env.NODE_ENV !== 'production') {\n warn(\"cannot run an inactive effect scope.\");\n }\n };\n /**\n * This should only be called on non-detached scopes\n * @internal\n */\n EffectScope.prototype.on = function () {\n activeEffectScope = this;\n };\n /**\n * This should only be called on non-detached scopes\n * @internal\n */\n EffectScope.prototype.off = function () {\n activeEffectScope = this.parent;\n };\n EffectScope.prototype.stop = function (fromParent) {\n if (this.active) {\n var i = void 0, l = void 0;\n for (i = 0, l = this.effects.length; i < l; i++) {\n this.effects[i].teardown();\n }\n for (i = 0, l = this.cleanups.length; i < l; i++) {\n this.cleanups[i]();\n }\n if (this.scopes) {\n for (i = 0, l = this.scopes.length; i < l; i++) {\n this.scopes[i].stop(true);\n }\n }\n // nested scope, dereference from parent to avoid memory leaks\n if (!this.detached && this.parent && !fromParent) {\n // optimized O(1) removal\n var last = this.parent.scopes.pop();\n if (last && last !== this) {\n this.parent.scopes[this.index] = last;\n last.index = this.index;\n }\n }\n this.parent = undefined;\n this.active = false;\n }\n };\n return EffectScope;\n}());\nfunction effectScope(detached) {\n return new EffectScope(detached);\n}\n/**\n * @internal\n */\nfunction recordEffectScope(effect, scope) {\n if (scope === void 0) { scope = activeEffectScope; }\n if (scope && scope.active) {\n scope.effects.push(effect);\n }\n}\nfunction getCurrentScope() {\n return activeEffectScope;\n}\nfunction onScopeDispose(fn) {\n if (activeEffectScope) {\n activeEffectScope.cleanups.push(fn);\n }\n else if (process.env.NODE_ENV !== 'production') {\n warn(\"onScopeDispose() is called when there is no active effect scope\" +\n \" to be associated with.\");\n }\n}\n\nfunction provide(key, value) {\n if (!currentInstance) {\n if (process.env.NODE_ENV !== 'production') {\n warn(\"provide() can only be used inside setup().\");\n }\n }\n else {\n // TS doesn't allow symbol as index type\n resolveProvided(currentInstance)[key] = value;\n }\n}\nfunction resolveProvided(vm) {\n // by default an instance inherits its parent's provides object\n // but when it needs to provide values of its own, it creates its\n // own provides object using parent provides object as prototype.\n // this way in `inject` we can simply look up injections from direct\n // parent and let the prototype chain do the work.\n var existing = vm._provided;\n var parentProvides = vm.$parent && vm.$parent._provided;\n if (parentProvides === existing) {\n return (vm._provided = Object.create(parentProvides));\n }\n else {\n return existing;\n }\n}\nfunction inject(key, defaultValue, treatDefaultAsFactory) {\n if (treatDefaultAsFactory === void 0) { treatDefaultAsFactory = false; }\n // fallback to `currentRenderingInstance` so that this can be called in\n // a functional component\n var instance = currentInstance;\n if (instance) {\n // #2400\n // to support `app.use` plugins,\n // fallback to appContext's `provides` if the instance is at root\n var provides = instance.$parent && instance.$parent._provided;\n if (provides && key in provides) {\n // TS doesn't allow symbol as index type\n return provides[key];\n }\n else if (arguments.length > 1) {\n return treatDefaultAsFactory && isFunction(defaultValue)\n ? defaultValue.call(instance)\n : defaultValue;\n }\n else if (process.env.NODE_ENV !== 'production') {\n warn(\"injection \\\"\".concat(String(key), \"\\\" not found.\"));\n }\n }\n else if (process.env.NODE_ENV !== 'production') {\n warn(\"inject() can only be used inside setup() or functional components.\");\n }\n}\n\nvar normalizeEvent = cached(function (name) {\n var passive = name.charAt(0) === '&';\n name = passive ? name.slice(1) : name;\n var once = name.charAt(0) === '~'; // Prefixed last, checked first\n name = once ? name.slice(1) : name;\n var capture = name.charAt(0) === '!';\n name = capture ? name.slice(1) : name;\n return {\n name: name,\n once: once,\n capture: capture,\n passive: passive\n };\n});\nfunction createFnInvoker(fns, vm) {\n function invoker() {\n var fns = invoker.fns;\n if (isArray(fns)) {\n var cloned = fns.slice();\n for (var i = 0; i < cloned.length; i++) {\n invokeWithErrorHandling(cloned[i], null, arguments, vm, \"v-on handler\");\n }\n }\n else {\n // return handler return value for single handlers\n return invokeWithErrorHandling(fns, null, arguments, vm, \"v-on handler\");\n }\n }\n invoker.fns = fns;\n return invoker;\n}\nfunction updateListeners(on, oldOn, add, remove, createOnceHandler, vm) {\n var name, cur, old, event;\n for (name in on) {\n cur = on[name];\n old = oldOn[name];\n event = normalizeEvent(name);\n if (isUndef(cur)) {\n process.env.NODE_ENV !== 'production' &&\n warn(\"Invalid handler for event \\\"\".concat(event.name, \"\\\": got \") + String(cur), vm);\n }\n else if (isUndef(old)) {\n if (isUndef(cur.fns)) {\n cur = on[name] = createFnInvoker(cur, vm);\n }\n if (isTrue(event.once)) {\n cur = on[name] = createOnceHandler(event.name, cur, event.capture);\n }\n add(event.name, cur, event.capture, event.passive, event.params);\n }\n else if (cur !== old) {\n old.fns = cur;\n on[name] = old;\n }\n }\n for (name in oldOn) {\n if (isUndef(on[name])) {\n event = normalizeEvent(name);\n remove(event.name, oldOn[name], event.capture);\n }\n }\n}\n\nfunction mergeVNodeHook(def, hookKey, hook) {\n if (def instanceof VNode) {\n def = def.data.hook || (def.data.hook = {});\n }\n var invoker;\n var oldHook = def[hookKey];\n function wrappedHook() {\n hook.apply(this, arguments);\n // important: remove merged hook to ensure it's called only once\n // and prevent memory leak\n remove$2(invoker.fns, wrappedHook);\n }\n if (isUndef(oldHook)) {\n // no existing hook\n invoker = createFnInvoker([wrappedHook]);\n }\n else {\n /* istanbul ignore if */\n if (isDef(oldHook.fns) && isTrue(oldHook.merged)) {\n // already a merged invoker\n invoker = oldHook;\n invoker.fns.push(wrappedHook);\n }\n else {\n // existing plain hook\n invoker = createFnInvoker([oldHook, wrappedHook]);\n }\n }\n invoker.merged = true;\n def[hookKey] = invoker;\n}\n\nfunction extractPropsFromVNodeData(data, Ctor, tag) {\n // we are only extracting raw values here.\n // validation and default values are handled in the child\n // component itself.\n var propOptions = Ctor.options.props;\n if (isUndef(propOptions)) {\n return;\n }\n var res = {};\n var attrs = data.attrs, props = data.props;\n if (isDef(attrs) || isDef(props)) {\n for (var key in propOptions) {\n var altKey = hyphenate(key);\n if (process.env.NODE_ENV !== 'production') {\n var keyInLowerCase = key.toLowerCase();\n if (key !== keyInLowerCase && attrs && hasOwn(attrs, keyInLowerCase)) {\n tip(\"Prop \\\"\".concat(keyInLowerCase, \"\\\" is passed to component \") +\n \"\".concat(formatComponentName(\n // @ts-expect-error tag is string\n tag || Ctor), \", but the declared prop name is\") +\n \" \\\"\".concat(key, \"\\\". \") +\n \"Note that HTML attributes are case-insensitive and camelCased \" +\n \"props need to use their kebab-case equivalents when using in-DOM \" +\n \"templates. You should probably use \\\"\".concat(altKey, \"\\\" instead of \\\"\").concat(key, \"\\\".\"));\n }\n }\n checkProp(res, props, key, altKey, true) ||\n checkProp(res, attrs, key, altKey, false);\n }\n }\n return res;\n}\nfunction checkProp(res, hash, key, altKey, preserve) {\n if (isDef(hash)) {\n if (hasOwn(hash, key)) {\n res[key] = hash[key];\n if (!preserve) {\n delete hash[key];\n }\n return true;\n }\n else if (hasOwn(hash, altKey)) {\n res[key] = hash[altKey];\n if (!preserve) {\n delete hash[altKey];\n }\n return true;\n }\n }\n return false;\n}\n\n// The template compiler attempts to minimize the need for normalization by\n// statically analyzing the template at compile time.\n//\n// For plain HTML markup, normalization can be completely skipped because the\n// generated render function is guaranteed to return Array. There are\n// two cases where extra normalization is needed:\n// 1. When the children contains components - because a functional component\n// may return an Array instead of a single root. In this case, just a simple\n// normalization is needed - if any child is an Array, we flatten the whole\n// thing with Array.prototype.concat. It is guaranteed to be only 1-level deep\n// because functional components already normalize their own children.\nfunction simpleNormalizeChildren(children) {\n for (var i = 0; i < children.length; i++) {\n if (isArray(children[i])) {\n return Array.prototype.concat.apply([], children);\n }\n }\n return children;\n}\n// 2. When the children contains constructs that always generated nested Arrays,\n// e.g.