123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533 |
- <?php
- declare(strict_types=1);
- /**
- * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
- * SPDX-License-Identifier: AGPL-3.0-or-later
- */
- namespace OCA\Files\Service;
- use Closure;
- use OC\Encryption\Manager as EncryptionManager;
- use OC\Files\Filesystem;
- use OC\Files\View;
- use OCA\Files\Exception\TransferOwnershipException;
- use OCP\Encryption\IManager as IEncryptionManager;
- use OCP\Files\Config\IUserMountCache;
- use OCP\Files\FileInfo;
- use OCP\Files\IHomeStorage;
- use OCP\Files\InvalidPathException;
- use OCP\Files\IRootFolder;
- use OCP\Files\Mount\IMountManager;
- use OCP\IUser;
- use OCP\IUserManager;
- use OCP\L10N\IFactory;
- use OCP\Share\IManager as IShareManager;
- use OCP\Share\IShare;
- use Symfony\Component\Console\Helper\ProgressBar;
- use Symfony\Component\Console\Output\NullOutput;
- use Symfony\Component\Console\Output\OutputInterface;
- use function array_merge;
- use function basename;
- use function count;
- use function date;
- use function is_dir;
- use function rtrim;
- class OwnershipTransferService {
- private IEncryptionManager|EncryptionManager $encryptionManager;
- public function __construct(
- IEncryptionManager $encryptionManager,
- private IShareManager $shareManager,
- private IMountManager $mountManager,
- private IUserMountCache $userMountCache,
- private IUserManager $userManager,
- private IFactory $l10nFactory,
- ) {
- $this->encryptionManager = $encryptionManager;
- }
- /**
- * @param IUser $sourceUser
- * @param IUser $destinationUser
- * @param string $path
- *
- * @param OutputInterface|null $output
- * @param bool $move
- * @throws TransferOwnershipException
- * @throws \OC\User\NoUserException
- */
- public function transfer(
- IUser $sourceUser,
- IUser $destinationUser,
- string $path,
- ?OutputInterface $output = null,
- bool $move = false,
- bool $firstLogin = false,
- bool $transferIncomingShares = false,
- ): void {
- $output = $output ?? new NullOutput();
- $sourceUid = $sourceUser->getUID();
- $destinationUid = $destinationUser->getUID();
- $sourcePath = rtrim($sourceUid . '/files/' . $path, '/');
- // 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);
- }
- // 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($destinationUser->getUID());
- \OC::$server->getUserFolder($destinationUser->getUID());
- Filesystem::initMountPoints($sourceUid);
- Filesystem::initMountPoints($destinationUid);
- $view = new View();
- if ($move) {
- $finalTarget = "$destinationUid/files/";
- } else {
- $l = $this->l10nFactory->get('files', $this->l10nFactory->getUserLanguage($destinationUser));
- $date = date('Y-m-d H-i-s');
- $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/" . $this->sanitizeFolderName($l->t('Transferred from %1$s on %2$s', [$sourceUid, $date]));
- }
- }
- if (!($view->is_dir($sourcePath) || $view->is_file($sourcePath))) {
- throw new TransferOwnershipException("Unknown path provided: $path", 1);
- }
- if ($move && !$view->is_dir($finalTarget)) {
- // Initialize storage
- \OC_Util::setupFS($destinationUser->getUID());
- }
- if ($move && !$firstLogin && count($view->getDirectoryContent($finalTarget)) > 0) {
- throw new TransferOwnershipException("Destination path does not exists or is not empty", 1);
- }
- // analyse source folder
- $this->analyse(
- $sourceUid,
- $destinationUid,
- $sourcePath,
- $view,
- $output
- );
- // collect all the shares
- $shares = $this->collectUsersShares(
- $sourceUid,
- $output,
- $view,
- $sourcePath
- );
- // transfer the files
- $this->transferFiles(
- $sourceUid,
- $sourcePath,
- $finalTarget,
- $view,
- $output
- );
- $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
- );
- }
- }
- 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)) {
- return;
- }
- if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) {
- $this->walkFiles($view, $fileInfo->getPath(), $callBack);
- }
- }
- }
- /**
- * @param OutputInterface $output
- *
- * @throws \Exception
- */
- protected function analyse(string $sourceUid,
- string $destinationUid,
- string $sourcePath,
- View $view,
- OutputInterface $output): void {
- $output->writeln('Validating quota');
- $size = $view->getFileInfo($sourcePath, false)->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.');
- }
- $output->writeln("Analysing files of $sourceUid ...");
- $progress = new ProgressBar($output);
- $progress->start();
- $encryptedFiles = [];
- $this->walkFiles($view, $sourcePath,
- function (FileInfo $fileInfo) use ($progress, &$encryptedFiles) {
- 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;
- });
- $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>");
- foreach ($encryptedFiles as $encryptedFile) {
- /** @var FileInfo $encryptedFile */
- $output->writeln(" " . $encryptedFile->getPath());
- }
- throw new \Exception('Execution terminated.');
- }
- }
- /**
- * @return array<array{share: IShare, suffix: string}>
- */
- private function collectUsersShares(
- string $sourceUid,
- OutputInterface $output,
- View $view,
- string $path,
- ): array {
- $output->writeln("Collecting all share information for files and folders of $sourceUid ...");
- $shares = [];
- $progress = new ProgressBar($output);
- $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);
- $progress->advance(count($sharePage));
- if (empty($sharePage)) {
- break;
- }
- if ($path !== "$sourceUid/files") {
- $sharePage = array_filter($sharePage, function (IShare $share) use ($view, $normalizedPath) {
- try {
- $relativePath = $view->getPath($share->getNodeId());
- $singleFileTranfer = $view->is_file($normalizedPath);
- if ($singleFileTranfer) {
- return Filesystem::normalizePath($relativePath) === $normalizedPath;
- }
- return mb_strpos(
- Filesystem::normalizePath($relativePath . '/', false),
- $normalizedPath . '/') === 0;
- } catch (\Exception $e) {
- return false;
- }
- });
- }
- $shares = array_merge($shares, $sharePage);
- $offset += 50;
- }
- }
- $progress->finish();
- $output->writeln('');
- return array_map(fn (IShare $share) => [
- 'share' => $share,
- 'suffix' => substr(Filesystem::normalizePath($view->getPath($share->getNodeId())), strlen($normalizedPath)),
- ], $shares);
- }
- private function collectIncomingShares(string $sourceUid,
- OutputInterface $output,
- View $view,
- bool $addKeys = false): array {
- $output->writeln("Collecting all incoming share information for files and folders of $sourceUid ...");
- $shares = [];
- $progress = new ProgressBar($output);
- $offset = 0;
- while (true) {
- $sharePage = $this->shareManager->getSharedWith($sourceUid, IShare::TYPE_USER, null, 50, $offset);
- $progress->advance(count($sharePage));
- if (empty($sharePage)) {
- break;
- }
- if ($addKeys) {
- foreach ($sharePage as $singleShare) {
- $shares[$singleShare->getNodeId()] = $singleShare;
- }
- } else {
- foreach ($sharePage as $singleShare) {
- $shares[] = $singleShare;
- }
- }
- $offset += 50;
- }
- $progress->finish();
- $output->writeln('');
- return $shares;
- }
- /**
- * @throws TransferOwnershipException
- */
- protected function transferFiles(string $sourceUid,
- string $sourcePath,
- string $finalTarget,
- View $view,
- OutputInterface $output): void {
- $output->writeln("Transferring files to $finalTarget ...");
- // This change will help user to transfer the folder specified using --path option.
- // Else only the content inside folder is transferred which is not correct.
- if ($sourcePath !== "$sourceUid/files") {
- $view->mkdir($finalTarget);
- $finalTarget = $finalTarget . '/' . basename($sourcePath);
- }
- if ($view->rename($sourcePath, $finalTarget) === false) {
- throw new TransferOwnershipException("Could not transfer files.", 1);
- }
- if (!is_dir("$sourceUid/files")) {
- // because the files folder is moved away we need to recreate it
- $view->mkdir("$sourceUid/files");
- }
- }
- /**
- * @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,
- ):void {
- $output->writeln("Restoring shares ...");
- $progress = new ProgressBar($output, count($shares));
- $rootFolder = \OCP\Server::get(IRootFolder::class);
- foreach ($shares as ['share' => $share, 'suffix' => $suffix]) {
- try {
- 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) {
- $this->mountManager->removeMount($shareMountPoint->getMountPoint());
- }
- $this->shareManager->deleteShare($share);
- } else {
- if ($share->getShareOwner() === $sourceUid) {
- $share->setShareOwner($destinationUid);
- }
- if ($share->getSharedBy() === $sourceUid) {
- $share->setSharedBy($destinationUid);
- }
- 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);
- continue;
- } else {
- // 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();
- 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 (\OCP\Files\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 = $rootFolder->get(Filesystem::normalizePath($targetLocation . '/' . $suffix));
- $newNodeId = $node->getId();
- }
- $share->setNodeId($newNodeId);
- $this->shareManager->updateShare($share);
- }
- }
- } catch (\OCP\Files\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>');
- }
- $progress->advance();
- }
- $progress->finish();
- $output->writeln('');
- }
- private function transferIncomingShares(string $sourceUid,
- string $destinationUid,
- array $sourceShares,
- array $destinationShares,
- OutputInterface $output,
- string $path,
- string $finalTarget,
- bool $move): void {
- $output->writeln("Restoring incoming shares ...");
- $progress = new ProgressBar($output, count($sourceShares));
- $prefix = "$destinationUid/files";
- $finalShareTarget = '';
- if (str_starts_with($finalTarget, $prefix)) {
- $finalShareTarget = substr($finalTarget, strlen($prefix));
- }
- foreach ($sourceShares as $share) {
- try {
- // Only restore if share is in given path.
- $pathToCheck = '/';
- if (trim($path, '/') !== '') {
- $pathToCheck = '/' . trim($path) . '/';
- }
- if (!str_starts_with($share->getTarget(), $pathToCheck)) {
- continue;
- }
- $shareTarget = $share->getTarget();
- $shareTarget = $finalShareTarget . $shareTarget;
- if ($share->getShareType() === IShare::TYPE_USER &&
- $share->getSharedBy() === $destinationUid) {
- $this->shareManager->deleteShare($share);
- } elseif (isset($destinationShares[$share->getNodeId()])) {
- $destinationShare = $destinationShares[$share->getNodeId()];
- // Keep the share which has the most permissions and discard the other one.
- if ($destinationShare->getPermissions() < $share->getPermissions()) {
- $this->shareManager->deleteShare($destinationShare);
- $share->setSharedWith($destinationUid);
- // 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);
- // The share is already transferred.
- $progress->advance();
- if ($move) {
- continue;
- }
- $share->setTarget($shareTarget);
- $this->shareManager->moveShare($share, $destinationUid);
- continue;
- }
- $this->shareManager->deleteShare($share);
- } elseif ($share->getShareOwner() === $destinationUid) {
- $this->shareManager->deleteShare($share);
- } else {
- $share->setSharedWith($destinationUid);
- $share->setNodeId($share->getNode()->getId());
- $this->shareManager->updateShare($share);
- // 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();
- // The share is already transferred.
- $progress->advance();
- if ($move) {
- continue;
- }
- $share->setTarget($shareTarget);
- $this->shareManager->moveShare($share, $destinationUid);
- continue;
- }
- } catch (\OCP\Files\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>');
- }
- $progress->advance();
- }
- $progress->finish();
- $output->writeln('');
- }
- }
|