diff options
Diffstat (limited to 'apps/files/lib/Service/OwnershipTransferService.php')
-rw-r--r-- | apps/files/lib/Service/OwnershipTransferService.php | 424 |
1 files changed, 268 insertions, 156 deletions
diff --git a/apps/files/lib/Service/OwnershipTransferService.php b/apps/files/lib/Service/OwnershipTransferService.php index 3499d809f2c..84c99f32109 100644 --- a/apps/files/lib/Service/OwnershipTransferService.php +++ b/apps/files/lib/Service/OwnershipTransferService.php @@ -3,47 +3,33 @@ declare(strict_types=1); /** - * @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Julius Härtl <jus@bitgrid.net> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sascha Wiswedel <sascha.wiswedel@nextcloud.com> - * @author Tobia De Koninck <LEDfan@users.noreply.github.com> - * - * @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 <http://www.gnu.org/licenses/>. - * + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files\Service; use Closure; +use Exception; use OC\Files\Filesystem; use OC\Files\View; +use OC\User\NoUserException; +use OCA\Encryption\Util; use OCA\Files\Exception\TransferOwnershipException; +use OCA\Files_External\Config\ConfigAdapter; use OCP\Encryption\IManager as IEncryptionManager; +use OCP\Files\Config\IHomeMountProvider; use OCP\Files\Config\IUserMountCache; +use OCP\Files\File; use OCP\Files\FileInfo; -use OCP\Files\IHomeStorage; use OCP\Files\InvalidPathException; +use OCP\Files\IRootFolder; use OCP\Files\Mount\IMountManager; +use OCP\Files\NotFoundException; use OCP\IUser; use OCP\IUserManager; +use OCP\L10N\IFactory; +use OCP\Server; use OCP\Share\IManager as IShareManager; use OCP\Share\IShare; use Symfony\Component\Console\Helper\ProgressBar; @@ -58,31 +44,15 @@ use function rtrim; class OwnershipTransferService { - /** @var IEncryptionManager */ - private $encryptionManager; - - /** @var IShareManager */ - private $shareManager; - - /** @var IMountManager */ - private $mountManager; - - /** @var IUserMountCache */ - private $userMountCache; - - /** @var IUserManager */ - private $userManager; - - public function __construct(IEncryptionManager $manager, - IShareManager $shareManager, - IMountManager $mountManager, - IUserMountCache $userMountCache, - IUserManager $userManager) { - $this->encryptionManager = $manager; - $this->shareManager = $shareManager; - $this->mountManager = $mountManager; - $this->userMountCache = $userMountCache; - $this->userManager = $userManager; + public function __construct( + private IEncryptionManager $encryptionManager, + private IShareManager $shareManager, + private IMountManager $mountManager, + private IUserMountCache $userMountCache, + private IUserManager $userManager, + private IFactory $l10nFactory, + private IRootFolder $rootFolder, + ) { } /** @@ -93,15 +63,17 @@ class OwnershipTransferService { * @param OutputInterface|null $output * @param bool $move * @throws TransferOwnershipException - * @throws \OC\User\NoUserException + * @throws NoUserException */ - public function transfer(IUser $sourceUser, + public function transfer( + IUser $sourceUser, IUser $destinationUser, string $path, ?OutputInterface $output = null, bool $move = false, bool $firstLogin = false, - bool $transferIncomingShares = false): void { + bool $includeExternalStorage = false, + ): void { $output = $output ?? new NullOutput(); $sourceUid = $sourceUser->getUID(); $destinationUid = $destinationUser->getUID(); @@ -110,15 +82,17 @@ class OwnershipTransferService { // If encryption is on we have to ensure the user has logged in before and that all encryption modules are ready if (($this->encryptionManager->isEnabled() && $destinationUser->getLastLogin() === 0) || !$this->encryptionManager->isReadyForUser($destinationUid)) { - throw new TransferOwnershipException("The target user is not ready to accept files. The user has at least to have logged in once.", 2); + throw new TransferOwnershipException('The target user is not ready to accept files. The user has at least to have logged in once.', 2); } // setup filesystem // Requesting the user folder will set it up if the user hasn't logged in before // We need a setupFS for the full filesystem setup before as otherwise we will just return // a lazy root folder which does not create the destination users folder + \OC_Util::setupFS($sourceUser->getUID()); \OC_Util::setupFS($destinationUser->getUID()); - \OC::$server->getUserFolder($destinationUser->getUID()); + $this->rootFolder->getUserFolder($sourceUser->getUID()); + $this->rootFolder->getUserFolder($destinationUser->getUID()); Filesystem::initMountPoints($sourceUid); Filesystem::initMountPoints($destinationUid); @@ -127,19 +101,15 @@ class OwnershipTransferService { if ($move) { $finalTarget = "$destinationUid/files/"; } else { + $l = $this->l10nFactory->get('files', $this->l10nFactory->getUserLanguage($destinationUser)); $date = date('Y-m-d H-i-s'); - // Remove some characters which are prone to cause errors - $cleanUserName = str_replace(['\\', '/', ':', '.', '?', '#', '\'', '"'], '-', $sourceUser->getDisplayName()); - // Replace multiple dashes with one dash - $cleanUserName = preg_replace('/-{2,}/s', '-', $cleanUserName); - $cleanUserName = $cleanUserName ?: $sourceUid; - - $finalTarget = "$destinationUid/files/transferred from $cleanUserName on $date"; + $cleanUserName = $this->sanitizeFolderName($sourceUser->getDisplayName()) ?: $sourceUid; + $finalTarget = "$destinationUid/files/" . $this->sanitizeFolderName($l->t('Transferred from %1$s on %2$s', [$cleanUserName, $date])); try { $view->verifyPath(dirname($finalTarget), basename($finalTarget)); } catch (InvalidPathException $e) { - $finalTarget = "$destinationUid/files/transferred from $sourceUid on $date"; + $finalTarget = "$destinationUid/files/" . $this->sanitizeFolderName($l->t('Transferred from %1$s on %2$s', [$sourceUid, $date])); } } @@ -153,7 +123,7 @@ class OwnershipTransferService { } if ($move && !$firstLogin && count($view->getDirectoryContent($finalTarget)) > 0) { - throw new TransferOwnershipException("Destination path does not exists or is not empty", 1); + throw new TransferOwnershipException('Destination path does not exists or is not empty', 1); } @@ -174,49 +144,62 @@ class OwnershipTransferService { $sourcePath ); + $sourceSize = $view->getFileInfo($sourcePath)->getSize(); + // transfer the files $this->transferFiles( $sourceUid, $sourcePath, $finalTarget, $view, - $output + $output, + $includeExternalStorage, ); + $sizeDifference = $sourceSize - $view->getFileInfo($finalTarget)->getSize(); + // transfer the incoming shares + $sourceShares = $this->collectIncomingShares( + $sourceUid, + $output, + $sourcePath, + ); + $destinationShares = $this->collectIncomingShares( + $destinationUid, + $output, + null, + ); + $this->transferIncomingShares( + $sourceUid, + $destinationUid, + $sourceShares, + $destinationShares, + $output, + $path, + $finalTarget, + $move + ); + + $destinationPath = $finalTarget . '/' . $path; // restore the shares $this->restoreShares( $sourceUid, $destinationUid, + $destinationPath, $shares, $output ); - - // transfer the incoming shares - if ($transferIncomingShares === true) { - $sourceShares = $this->collectIncomingShares( - $sourceUid, - $output, - $view - ); - $destinationShares = $this->collectIncomingShares( - $destinationUid, - $output, - $view, - true - ); - $this->transferIncomingShares( - $sourceUid, - $destinationUid, - $sourceShares, - $destinationShares, - $output, - $path, - $finalTarget, - $move - ); + if ($sizeDifference !== 0) { + $output->writeln("Transferred folder have a size difference of: $sizeDifference Bytes which means the transfer may be incomplete. Please check the logs if there was any issue during the transfer operation."); } } + private function sanitizeFolderName(string $name): string { + // Remove some characters which are prone to cause errors + $name = str_replace(['\\', '/', ':', '.', '?', '#', '\'', '"'], '-', $name); + // Replace multiple dashes with one dash + return preg_replace('/-{2,}/s', '-', $name); + } + private function walkFiles(View $view, $path, Closure $callBack) { foreach ($view->getDirectoryContent($path) as $fileInfo) { if (!$callBack($fileInfo)) { @@ -231,85 +214,135 @@ class OwnershipTransferService { /** * @param OutputInterface $output * - * @throws \Exception + * @throws TransferOwnershipException */ - protected function analyse(string $sourceUid, + protected function analyse( + string $sourceUid, string $destinationUid, string $sourcePath, View $view, - OutputInterface $output): void { + OutputInterface $output, + bool $includeExternalStorage = false, + ): void { $output->writeln('Validating quota'); - $size = $view->getFileInfo($sourcePath, false)->getSize(false); + $sourceFileInfo = $view->getFileInfo($sourcePath, false); + if ($sourceFileInfo === false) { + throw new TransferOwnershipException("Unknown path provided: $sourcePath", 1); + } + $size = $sourceFileInfo->getSize(false); $freeSpace = $view->free_space($destinationUid . '/files/'); if ($size > $freeSpace && $freeSpace !== FileInfo::SPACE_UNKNOWN) { - $output->writeln('<error>Target user does not have enough free space available.</error>'); - throw new \Exception('Execution terminated.'); + throw new TransferOwnershipException('Target user does not have enough free space available.', 1); } $output->writeln("Analysing files of $sourceUid ..."); $progress = new ProgressBar($output); $progress->start(); + if ($this->encryptionManager->isEnabled()) { + $masterKeyEnabled = Server::get(Util::class)->isMasterKeyEnabled(); + } else { + $masterKeyEnabled = false; + } $encryptedFiles = []; - $this->walkFiles($view, $sourcePath, - function (FileInfo $fileInfo) use ($progress) { - if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) { - // only analyze into folders from main storage, - if (!$fileInfo->getStorage()->instanceOfStorage(IHomeStorage::class)) { - return false; - } - return true; - } - $progress->advance(); - if ($fileInfo->isEncrypted()) { - $encryptedFiles[] = $fileInfo; - } - return true; - }); + if ($sourceFileInfo->getType() === FileInfo::TYPE_FOLDER) { + if ($sourceFileInfo->isEncrypted()) { + /* Encrypted folder means e2ee encrypted */ + $encryptedFiles[] = $sourceFileInfo; + } else { + $this->walkFiles($view, $sourcePath, + function (FileInfo $fileInfo) use ($progress, $masterKeyEnabled, &$encryptedFiles, $includeExternalStorage) { + if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) { + $mount = $fileInfo->getMountPoint(); + // only analyze into folders from main storage, + if ( + $mount->getMountProvider() instanceof IHomeMountProvider + || ($includeExternalStorage && $mount->getMountProvider() instanceof ConfigAdapter) + ) { + if ($fileInfo->isEncrypted()) { + /* Encrypted folder means e2ee encrypted, we cannot transfer it */ + $encryptedFiles[] = $fileInfo; + } + return true; + } else { + return false; + } + } + $progress->advance(); + if ($fileInfo->isEncrypted() && !$masterKeyEnabled) { + /* Encrypted file means SSE, we can only transfer it if master key is enabled */ + $encryptedFiles[] = $fileInfo; + } + return true; + }); + } + } elseif ($sourceFileInfo->isEncrypted() && !$masterKeyEnabled) { + /* Encrypted file means SSE, we can only transfer it if master key is enabled */ + $encryptedFiles[] = $sourceFileInfo; + } $progress->finish(); $output->writeln(''); // no file is allowed to be encrypted if (!empty($encryptedFiles)) { - $output->writeln("<error>Some files are encrypted - please decrypt them first.</error>"); + $output->writeln('<error>Some files are encrypted - please decrypt them first.</error>'); foreach ($encryptedFiles as $encryptedFile) { /** @var FileInfo $encryptedFile */ - $output->writeln(" " . $encryptedFile->getPath()); + $output->writeln(' ' . $encryptedFile->getPath()); } - throw new \Exception('Execution terminated.'); + throw new TransferOwnershipException('Some files are encrypted - please decrypt them first.', 1); } } - private function collectUsersShares(string $sourceUid, + /** + * @return array<array{share: IShare, suffix: string}> + */ + private function collectUsersShares( + string $sourceUid, OutputInterface $output, View $view, - string $path): array { + string $path, + ): array { $output->writeln("Collecting all share information for files and folders of $sourceUid ..."); $shares = []; $progress = new ProgressBar($output); - foreach ([IShare::TYPE_GROUP, IShare::TYPE_USER, IShare::TYPE_LINK, IShare::TYPE_REMOTE, IShare::TYPE_ROOM, IShare::TYPE_EMAIL, IShare::TYPE_CIRCLE, IShare::TYPE_DECK, IShare::TYPE_SCIENCEMESH] as $shareType) { + $normalizedPath = Filesystem::normalizePath($path); + + $supportedShareTypes = [ + IShare::TYPE_GROUP, + IShare::TYPE_USER, + IShare::TYPE_LINK, + IShare::TYPE_REMOTE, + IShare::TYPE_ROOM, + IShare::TYPE_EMAIL, + IShare::TYPE_CIRCLE, + IShare::TYPE_DECK, + IShare::TYPE_SCIENCEMESH, + ]; + + foreach ($supportedShareTypes as $shareType) { $offset = 0; while (true) { - $sharePage = $this->shareManager->getSharesBy($sourceUid, $shareType, null, true, 50, $offset); + $sharePage = $this->shareManager->getSharesBy($sourceUid, $shareType, null, true, 50, $offset, onlyValid: false); $progress->advance(count($sharePage)); if (empty($sharePage)) { break; } if ($path !== "$sourceUid/files") { - $sharePage = array_filter($sharePage, function (IShare $share) use ($view, $path) { + $sharePage = array_filter($sharePage, function (IShare $share) use ($view, $normalizedPath) { try { $relativePath = $view->getPath($share->getNodeId()); - $singleFileTranfer = $view->is_file($path); + $singleFileTranfer = $view->is_file($normalizedPath); if ($singleFileTranfer) { - return Filesystem::normalizePath($relativePath) === Filesystem::normalizePath($path); + return Filesystem::normalizePath($relativePath) === $normalizedPath; } return mb_strpos( Filesystem::normalizePath($relativePath . '/', false), - Filesystem::normalizePath($path . '/', false)) === 0; - } catch (\Exception $e) { + $normalizedPath . '/') === 0; + } catch (Exception $e) { return false; } }); @@ -321,17 +354,32 @@ class OwnershipTransferService { $progress->finish(); $output->writeln(''); - return $shares; + + return array_values(array_filter(array_map(function (IShare $share) use ($view, $normalizedPath, $output, $sourceUid) { + try { + $nodePath = $view->getPath($share->getNodeId()); + } catch (NotFoundException $e) { + $output->writeln("<error>Failed to find path for shared file {$share->getNodeId()} for user $sourceUid, skipping</error>"); + return null; + } + + return [ + 'share' => $share, + 'suffix' => substr(Filesystem::normalizePath($nodePath), strlen($normalizedPath)), + ]; + }, $shares))); } - private function collectIncomingShares(string $sourceUid, + private function collectIncomingShares( + string $sourceUid, OutputInterface $output, - View $view, - bool $addKeys = false): array { + ?string $path, + ): array { $output->writeln("Collecting all incoming share information for files and folders of $sourceUid ..."); $shares = []; $progress = new ProgressBar($output); + $normalizedPath = Filesystem::normalizePath($path); $offset = 0; while (true) { @@ -340,14 +388,19 @@ class OwnershipTransferService { if (empty($sharePage)) { break; } - if ($addKeys) { - foreach ($sharePage as $singleShare) { - $shares[$singleShare->getNodeId()] = $singleShare; - } - } else { - foreach ($sharePage as $singleShare) { - $shares[] = $singleShare; - } + + if ($path !== null && $path !== "$sourceUid/files") { + $sharePage = array_filter($sharePage, static function (IShare $share) use ($sourceUid, $normalizedPath) { + try { + return str_starts_with(Filesystem::normalizePath($sourceUid . '/files' . $share->getTarget() . '/', false), $normalizedPath . '/'); + } catch (Exception) { + return false; + } + }); + } + + foreach ($sharePage as $share) { + $shares[$share->getNodeId()] = $share; } $offset += 50; @@ -362,11 +415,14 @@ class OwnershipTransferService { /** * @throws TransferOwnershipException */ - protected function transferFiles(string $sourceUid, + protected function transferFiles( + string $sourceUid, string $sourcePath, string $finalTarget, View $view, - OutputInterface $output): void { + OutputInterface $output, + bool $includeExternalStorage, + ): void { $output->writeln("Transferring files to $finalTarget ..."); // This change will help user to transfer the folder specified using --path option. @@ -375,26 +431,69 @@ class OwnershipTransferService { $view->mkdir($finalTarget); $finalTarget = $finalTarget . '/' . basename($sourcePath); } - if ($view->rename($sourcePath, $finalTarget) === false) { - throw new TransferOwnershipException("Could not transfer files.", 1); + $sourceInfo = $view->getFileInfo($sourcePath); + + /// handle the external storages mounted at the root, or the admin specifying an external storage with --path + if ($sourceInfo->getInternalPath() === '' && $includeExternalStorage) { + $this->moveMountContents($view, $sourcePath, $finalTarget); + } else { + if ($view->rename($sourcePath, $finalTarget, ['checkSubMounts' => false]) === false) { + throw new TransferOwnershipException('Could not transfer files.', 1); + } + } + + if ($includeExternalStorage) { + $nestedMounts = $this->mountManager->findIn($sourcePath); + foreach ($nestedMounts as $mount) { + if ($mount->getMountProvider() === ConfigAdapter::class) { + $relativePath = substr(trim($mount->getMountPoint(), '/'), strlen($sourcePath)); + $this->moveMountContents($view, $mount->getMountPoint(), $finalTarget . $relativePath); + } + } } + if (!is_dir("$sourceUid/files")) { // because the files folder is moved away we need to recreate it $view->mkdir("$sourceUid/files"); } } - private function restoreShares(string $sourceUid, + private function moveMountContents(View $rootView, string $source, string $target) { + if ($rootView->copy($source, $target)) { + // just doing `rmdir` on the mountpoint would cause it to try and unmount the storage + // we need to empty the contents instead + $content = $rootView->getDirectoryContent($source); + foreach ($content as $item) { + if ($item->getType() === FileInfo::TYPE_FOLDER) { + $rootView->rmdir($item->getPath()); + } else { + $rootView->unlink($item->getPath()); + } + } + } else { + throw new TransferOwnershipException("Could not transfer $source to $target"); + } + } + + /** + * @param string $targetLocation New location of the transfered node + * @param array<array{share: IShare, suffix: string}> $shares previously collected share information + */ + private function restoreShares( + string $sourceUid, string $destinationUid, + string $targetLocation, array $shares, - OutputInterface $output) { - $output->writeln("Restoring shares ..."); + OutputInterface $output, + ):void { + $output->writeln('Restoring shares ...'); $progress = new ProgressBar($output, count($shares)); - foreach ($shares as $share) { + foreach ($shares as ['share' => $share, 'suffix' => $suffix]) { try { - if ($share->getShareType() === IShare::TYPE_USER && - $share->getSharedWith() === $destinationUid) { + $output->writeln('Transfering share ' . $share->getId() . ' of type ' . $share->getShareType(), OutputInterface::VERBOSITY_VERBOSE); + if ($share->getShareType() === IShare::TYPE_USER + && $share->getSharedWith() === $destinationUid) { // Unmount the shares before deleting, so we don't try to get the storage later on. $shareMountPoint = $this->mountManager->find('/' . $destinationUid . '/files' . $share->getTarget()); if ($shareMountPoint) { @@ -409,8 +508,8 @@ class OwnershipTransferService { $share->setSharedBy($destinationUid); } - if ($share->getShareType() === IShare::TYPE_USER && - !$this->userManager->userExists($share->getSharedWith())) { + if ($share->getShareType() === IShare::TYPE_USER + && !$this->userManager->userExists($share->getSharedWith())) { // stray share with deleted user $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted user "' . $share->getSharedWith() . '", deleting</error>'); $this->shareManager->deleteShare($share); @@ -419,12 +518,25 @@ class OwnershipTransferService { // trigger refetching of the node so that the new owner and mountpoint are taken into account // otherwise the checks on the share update will fail due to the original node not being available in the new user scope $this->userMountCache->clear(); - $share->setNodeId($share->getNode()->getId()); - $this->shareManager->updateShare($share); + try { + // Try to get the "old" id. + // Normally the ID is preserved, + // but for transferes between different storages the ID might change + $newNodeId = $share->getNode()->getId(); + } catch (NotFoundException) { + // ID has changed due to transfer between different storages + // Try to get the new ID from the target path and suffix of the share + $node = $this->rootFolder->get(Filesystem::normalizePath($targetLocation . '/' . $suffix)); + $newNodeId = $node->getId(); + $output->writeln('Had to change node id to ' . $newNodeId, OutputInterface::VERBOSITY_VERY_VERBOSE); + } + $share->setNodeId($newNodeId); + + $this->shareManager->updateShare($share, onlyValid: false); } } - } catch (\OCP\Files\NotFoundException $e) { + } catch (NotFoundException $e) { $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted file, skipping</error>'); } catch (\Throwable $e) { $output->writeln('<error>Could not restore share with id ' . $share->getId() . ':' . $e->getMessage() . ' : ' . $e->getTraceAsString() . '</error>'); @@ -443,11 +555,11 @@ class OwnershipTransferService { string $path, string $finalTarget, bool $move): void { - $output->writeln("Restoring incoming shares ..."); + $output->writeln('Restoring incoming shares ...'); $progress = new ProgressBar($output, count($sourceShares)); $prefix = "$destinationUid/files"; $finalShareTarget = ''; - if (substr($finalTarget, 0, strlen($prefix)) === $prefix) { + if (str_starts_with($finalTarget, $prefix)) { $finalShareTarget = substr($finalTarget, strlen($prefix)); } foreach ($sourceShares as $share) { @@ -457,13 +569,13 @@ class OwnershipTransferService { if (trim($path, '/') !== '') { $pathToCheck = '/' . trim($path) . '/'; } - if (substr($share->getTarget(), 0, strlen($pathToCheck)) !== $pathToCheck) { + if (!str_starts_with($share->getTarget(), $pathToCheck)) { continue; } $shareTarget = $share->getTarget(); $shareTarget = $finalShareTarget . $shareTarget; - if ($share->getShareType() === IShare::TYPE_USER && - $share->getSharedBy() === $destinationUid) { + if ($share->getShareType() === IShare::TYPE_USER + && $share->getSharedBy() === $destinationUid) { $this->shareManager->deleteShare($share); } elseif (isset($destinationShares[$share->getNodeId()])) { $destinationShare = $destinationShares[$share->getNodeId()]; @@ -504,7 +616,7 @@ class OwnershipTransferService { $this->shareManager->moveShare($share, $destinationUid); continue; } - } catch (\OCP\Files\NotFoundException $e) { + } catch (NotFoundException $e) { $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted file, skipping</error>'); } catch (\Throwable $e) { $output->writeln('<error>Could not restore share with id ' . $share->getId() . ':' . $e->getTraceAsString() . '</error>'); |