OwnershipTransferService.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OCA\Files\Service;
  8. use Closure;
  9. use OC\Files\Filesystem;
  10. use OC\Files\View;
  11. use OC\User\NoUserException;
  12. use OCA\Encryption\Util;
  13. use OCA\Files\Exception\TransferOwnershipException;
  14. use OCP\Encryption\IManager as IEncryptionManager;
  15. use OCP\Files\Config\IUserMountCache;
  16. use OCP\Files\FileInfo;
  17. use OCP\Files\IHomeStorage;
  18. use OCP\Files\InvalidPathException;
  19. use OCP\Files\IRootFolder;
  20. use OCP\Files\Mount\IMountManager;
  21. use OCP\Files\NotFoundException;
  22. use OCP\IUser;
  23. use OCP\IUserManager;
  24. use OCP\L10N\IFactory;
  25. use OCP\Server;
  26. use OCP\Share\IManager as IShareManager;
  27. use OCP\Share\IShare;
  28. use Symfony\Component\Console\Helper\ProgressBar;
  29. use Symfony\Component\Console\Output\NullOutput;
  30. use Symfony\Component\Console\Output\OutputInterface;
  31. use function array_merge;
  32. use function basename;
  33. use function count;
  34. use function date;
  35. use function is_dir;
  36. use function rtrim;
  37. class OwnershipTransferService {
  38. public function __construct(
  39. private IEncryptionManager $encryptionManager,
  40. private IShareManager $shareManager,
  41. private IMountManager $mountManager,
  42. private IUserMountCache $userMountCache,
  43. private IUserManager $userManager,
  44. private IFactory $l10nFactory,
  45. private IRootFolder $rootFolder,
  46. ) {
  47. }
  48. /**
  49. * @param IUser $sourceUser
  50. * @param IUser $destinationUser
  51. * @param string $path
  52. *
  53. * @param OutputInterface|null $output
  54. * @param bool $move
  55. * @throws TransferOwnershipException
  56. * @throws NoUserException
  57. */
  58. public function transfer(
  59. IUser $sourceUser,
  60. IUser $destinationUser,
  61. string $path,
  62. ?OutputInterface $output = null,
  63. bool $move = false,
  64. bool $firstLogin = false,
  65. bool $transferIncomingShares = false,
  66. ): void {
  67. $output = $output ?? new NullOutput();
  68. $sourceUid = $sourceUser->getUID();
  69. $destinationUid = $destinationUser->getUID();
  70. $sourcePath = rtrim($sourceUid . '/files/' . $path, '/');
  71. // If encryption is on we have to ensure the user has logged in before and that all encryption modules are ready
  72. if (($this->encryptionManager->isEnabled() && $destinationUser->getLastLogin() === 0)
  73. || !$this->encryptionManager->isReadyForUser($destinationUid)) {
  74. throw new TransferOwnershipException('The target user is not ready to accept files. The user has at least to have logged in once.', 2);
  75. }
  76. // setup filesystem
  77. // Requesting the user folder will set it up if the user hasn't logged in before
  78. // We need a setupFS for the full filesystem setup before as otherwise we will just return
  79. // a lazy root folder which does not create the destination users folder
  80. \OC_Util::setupFS($sourceUser->getUID());
  81. \OC_Util::setupFS($destinationUser->getUID());
  82. $this->rootFolder->getUserFolder($sourceUser->getUID());
  83. $this->rootFolder->getUserFolder($destinationUser->getUID());
  84. Filesystem::initMountPoints($sourceUid);
  85. Filesystem::initMountPoints($destinationUid);
  86. $view = new View();
  87. if ($move) {
  88. $finalTarget = "$destinationUid/files/";
  89. } else {
  90. $l = $this->l10nFactory->get('files', $this->l10nFactory->getUserLanguage($destinationUser));
  91. $date = date('Y-m-d H-i-s');
  92. $cleanUserName = $this->sanitizeFolderName($sourceUser->getDisplayName()) ?: $sourceUid;
  93. $finalTarget = "$destinationUid/files/" . $this->sanitizeFolderName($l->t('Transferred from %1$s on %2$s', [$cleanUserName, $date]));
  94. try {
  95. $view->verifyPath(dirname($finalTarget), basename($finalTarget));
  96. } catch (InvalidPathException $e) {
  97. $finalTarget = "$destinationUid/files/" . $this->sanitizeFolderName($l->t('Transferred from %1$s on %2$s', [$sourceUid, $date]));
  98. }
  99. }
  100. if (!($view->is_dir($sourcePath) || $view->is_file($sourcePath))) {
  101. throw new TransferOwnershipException("Unknown path provided: $path", 1);
  102. }
  103. if ($move && !$view->is_dir($finalTarget)) {
  104. // Initialize storage
  105. \OC_Util::setupFS($destinationUser->getUID());
  106. }
  107. if ($move && !$firstLogin && count($view->getDirectoryContent($finalTarget)) > 0) {
  108. throw new TransferOwnershipException('Destination path does not exists or is not empty', 1);
  109. }
  110. // analyse source folder
  111. $this->analyse(
  112. $sourceUid,
  113. $destinationUid,
  114. $sourcePath,
  115. $view,
  116. $output
  117. );
  118. // collect all the shares
  119. $shares = $this->collectUsersShares(
  120. $sourceUid,
  121. $output,
  122. $view,
  123. $sourcePath
  124. );
  125. // transfer the files
  126. $this->transferFiles(
  127. $sourceUid,
  128. $sourcePath,
  129. $finalTarget,
  130. $view,
  131. $output
  132. );
  133. // transfer the incoming shares
  134. if ($transferIncomingShares === true) {
  135. $sourceShares = $this->collectIncomingShares(
  136. $sourceUid,
  137. $output,
  138. $view
  139. );
  140. $destinationShares = $this->collectIncomingShares(
  141. $destinationUid,
  142. $output,
  143. $view,
  144. true
  145. );
  146. $this->transferIncomingShares(
  147. $sourceUid,
  148. $destinationUid,
  149. $sourceShares,
  150. $destinationShares,
  151. $output,
  152. $path,
  153. $finalTarget,
  154. $move
  155. );
  156. }
  157. $destinationPath = $finalTarget . '/' . $path;
  158. // restore the shares
  159. $this->restoreShares(
  160. $sourceUid,
  161. $destinationUid,
  162. $destinationPath,
  163. $shares,
  164. $output
  165. );
  166. }
  167. private function sanitizeFolderName(string $name): string {
  168. // Remove some characters which are prone to cause errors
  169. $name = str_replace(['\\', '/', ':', '.', '?', '#', '\'', '"'], '-', $name);
  170. // Replace multiple dashes with one dash
  171. return preg_replace('/-{2,}/s', '-', $name);
  172. }
  173. private function walkFiles(View $view, $path, Closure $callBack) {
  174. foreach ($view->getDirectoryContent($path) as $fileInfo) {
  175. if (!$callBack($fileInfo)) {
  176. return;
  177. }
  178. if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) {
  179. $this->walkFiles($view, $fileInfo->getPath(), $callBack);
  180. }
  181. }
  182. }
  183. /**
  184. * @param OutputInterface $output
  185. *
  186. * @throws TransferOwnershipException
  187. */
  188. protected function analyse(string $sourceUid,
  189. string $destinationUid,
  190. string $sourcePath,
  191. View $view,
  192. OutputInterface $output): void {
  193. $output->writeln('Validating quota');
  194. $sourceFileInfo = $view->getFileInfo($sourcePath, false);
  195. if ($sourceFileInfo === false) {
  196. throw new TransferOwnershipException("Unknown path provided: $sourcePath", 1);
  197. }
  198. $size = $sourceFileInfo->getSize(false);
  199. $freeSpace = $view->free_space($destinationUid . '/files/');
  200. if ($size > $freeSpace && $freeSpace !== FileInfo::SPACE_UNKNOWN) {
  201. throw new TransferOwnershipException('Target user does not have enough free space available.', 1);
  202. }
  203. $output->writeln("Analysing files of $sourceUid ...");
  204. $progress = new ProgressBar($output);
  205. $progress->start();
  206. if ($this->encryptionManager->isEnabled()) {
  207. $masterKeyEnabled = Server::get(Util::class)->isMasterKeyEnabled();
  208. } else {
  209. $masterKeyEnabled = false;
  210. }
  211. $encryptedFiles = [];
  212. if ($sourceFileInfo->getType() === FileInfo::TYPE_FOLDER) {
  213. if ($sourceFileInfo->isEncrypted()) {
  214. /* Encrypted folder means e2ee encrypted */
  215. $encryptedFiles[] = $sourceFileInfo;
  216. } else {
  217. $this->walkFiles($view, $sourcePath,
  218. function (FileInfo $fileInfo) use ($progress, $masterKeyEnabled, &$encryptedFiles) {
  219. if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) {
  220. // only analyze into folders from main storage,
  221. if (!$fileInfo->getStorage()->instanceOfStorage(IHomeStorage::class)) {
  222. return false;
  223. }
  224. if ($fileInfo->isEncrypted()) {
  225. /* Encrypted folder means e2ee encrypted, we cannot transfer it */
  226. $encryptedFiles[] = $fileInfo;
  227. }
  228. return true;
  229. }
  230. $progress->advance();
  231. if ($fileInfo->isEncrypted() && !$masterKeyEnabled) {
  232. /* Encrypted file means SSE, we can only transfer it if master key is enabled */
  233. $encryptedFiles[] = $fileInfo;
  234. }
  235. return true;
  236. });
  237. }
  238. } elseif ($sourceFileInfo->isEncrypted() && !$masterKeyEnabled) {
  239. /* Encrypted file means SSE, we can only transfer it if master key is enabled */
  240. $encryptedFiles[] = $sourceFileInfo;
  241. }
  242. $progress->finish();
  243. $output->writeln('');
  244. // no file is allowed to be encrypted
  245. if (!empty($encryptedFiles)) {
  246. $output->writeln('<error>Some files are encrypted - please decrypt them first.</error>');
  247. foreach ($encryptedFiles as $encryptedFile) {
  248. /** @var FileInfo $encryptedFile */
  249. $output->writeln(' ' . $encryptedFile->getPath());
  250. }
  251. throw new TransferOwnershipException('Some files are encrypted - please decrypt them first.', 1);
  252. }
  253. }
  254. /**
  255. * @return array<array{share: IShare, suffix: string}>
  256. */
  257. private function collectUsersShares(
  258. string $sourceUid,
  259. OutputInterface $output,
  260. View $view,
  261. string $path,
  262. ): array {
  263. $output->writeln("Collecting all share information for files and folders of $sourceUid ...");
  264. $shares = [];
  265. $progress = new ProgressBar($output);
  266. $normalizedPath = Filesystem::normalizePath($path);
  267. $supportedShareTypes = [
  268. IShare::TYPE_GROUP,
  269. IShare::TYPE_USER,
  270. IShare::TYPE_LINK,
  271. IShare::TYPE_REMOTE,
  272. IShare::TYPE_ROOM,
  273. IShare::TYPE_EMAIL,
  274. IShare::TYPE_CIRCLE,
  275. IShare::TYPE_DECK,
  276. IShare::TYPE_SCIENCEMESH,
  277. ];
  278. foreach ($supportedShareTypes as $shareType) {
  279. $offset = 0;
  280. while (true) {
  281. $sharePage = $this->shareManager->getSharesBy($sourceUid, $shareType, null, true, 50, $offset);
  282. $progress->advance(count($sharePage));
  283. if (empty($sharePage)) {
  284. break;
  285. }
  286. if ($path !== "$sourceUid/files") {
  287. $sharePage = array_filter($sharePage, function (IShare $share) use ($view, $normalizedPath) {
  288. try {
  289. $relativePath = $view->getPath($share->getNodeId());
  290. $singleFileTranfer = $view->is_file($normalizedPath);
  291. if ($singleFileTranfer) {
  292. return Filesystem::normalizePath($relativePath) === $normalizedPath;
  293. }
  294. return mb_strpos(
  295. Filesystem::normalizePath($relativePath . '/', false),
  296. $normalizedPath . '/') === 0;
  297. } catch (\Exception $e) {
  298. return false;
  299. }
  300. });
  301. }
  302. $shares = array_merge($shares, $sharePage);
  303. $offset += 50;
  304. }
  305. }
  306. $progress->finish();
  307. $output->writeln('');
  308. return array_map(fn (IShare $share) => [
  309. 'share' => $share,
  310. 'suffix' => substr(Filesystem::normalizePath($view->getPath($share->getNodeId())), strlen($normalizedPath)),
  311. ], $shares);
  312. }
  313. private function collectIncomingShares(string $sourceUid,
  314. OutputInterface $output,
  315. View $view,
  316. bool $addKeys = false): array {
  317. $output->writeln("Collecting all incoming share information for files and folders of $sourceUid ...");
  318. $shares = [];
  319. $progress = new ProgressBar($output);
  320. $offset = 0;
  321. while (true) {
  322. $sharePage = $this->shareManager->getSharedWith($sourceUid, IShare::TYPE_USER, null, 50, $offset);
  323. $progress->advance(count($sharePage));
  324. if (empty($sharePage)) {
  325. break;
  326. }
  327. if ($addKeys) {
  328. foreach ($sharePage as $singleShare) {
  329. $shares[$singleShare->getNodeId()] = $singleShare;
  330. }
  331. } else {
  332. foreach ($sharePage as $singleShare) {
  333. $shares[] = $singleShare;
  334. }
  335. }
  336. $offset += 50;
  337. }
  338. $progress->finish();
  339. $output->writeln('');
  340. return $shares;
  341. }
  342. /**
  343. * @throws TransferOwnershipException
  344. */
  345. protected function transferFiles(string $sourceUid,
  346. string $sourcePath,
  347. string $finalTarget,
  348. View $view,
  349. OutputInterface $output): void {
  350. $output->writeln("Transferring files to $finalTarget ...");
  351. // This change will help user to transfer the folder specified using --path option.
  352. // Else only the content inside folder is transferred which is not correct.
  353. if ($sourcePath !== "$sourceUid/files") {
  354. $view->mkdir($finalTarget);
  355. $finalTarget = $finalTarget . '/' . basename($sourcePath);
  356. }
  357. if ($view->rename($sourcePath, $finalTarget) === false) {
  358. throw new TransferOwnershipException('Could not transfer files.', 1);
  359. }
  360. if (!is_dir("$sourceUid/files")) {
  361. // because the files folder is moved away we need to recreate it
  362. $view->mkdir("$sourceUid/files");
  363. }
  364. }
  365. /**
  366. * @param string $targetLocation New location of the transfered node
  367. * @param array<array{share: IShare, suffix: string}> $shares previously collected share information
  368. */
  369. private function restoreShares(
  370. string $sourceUid,
  371. string $destinationUid,
  372. string $targetLocation,
  373. array $shares,
  374. OutputInterface $output,
  375. ):void {
  376. $output->writeln('Restoring shares ...');
  377. $progress = new ProgressBar($output, count($shares));
  378. foreach ($shares as ['share' => $share, 'suffix' => $suffix]) {
  379. try {
  380. $output->writeln('Transfering share ' . $share->getId() . ' of type ' . $share->getShareType(), OutputInterface::VERBOSITY_VERBOSE);
  381. if ($share->getShareType() === IShare::TYPE_USER &&
  382. $share->getSharedWith() === $destinationUid) {
  383. // Unmount the shares before deleting, so we don't try to get the storage later on.
  384. $shareMountPoint = $this->mountManager->find('/' . $destinationUid . '/files' . $share->getTarget());
  385. if ($shareMountPoint) {
  386. $this->mountManager->removeMount($shareMountPoint->getMountPoint());
  387. }
  388. $this->shareManager->deleteShare($share);
  389. } else {
  390. if ($share->getShareOwner() === $sourceUid) {
  391. $share->setShareOwner($destinationUid);
  392. }
  393. if ($share->getSharedBy() === $sourceUid) {
  394. $share->setSharedBy($destinationUid);
  395. }
  396. if ($share->getShareType() === IShare::TYPE_USER &&
  397. !$this->userManager->userExists($share->getSharedWith())) {
  398. // stray share with deleted user
  399. $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted user "' . $share->getSharedWith() . '", deleting</error>');
  400. $this->shareManager->deleteShare($share);
  401. continue;
  402. } else {
  403. // trigger refetching of the node so that the new owner and mountpoint are taken into account
  404. // otherwise the checks on the share update will fail due to the original node not being available in the new user scope
  405. $this->userMountCache->clear();
  406. try {
  407. // Try to get the "old" id.
  408. // Normally the ID is preserved,
  409. // but for transferes between different storages the ID might change
  410. $newNodeId = $share->getNode()->getId();
  411. } catch (NotFoundException) {
  412. // ID has changed due to transfer between different storages
  413. // Try to get the new ID from the target path and suffix of the share
  414. $node = $this->rootFolder->get(Filesystem::normalizePath($targetLocation . '/' . $suffix));
  415. $newNodeId = $node->getId();
  416. $output->writeln('Had to change node id to ' . $newNodeId, OutputInterface::VERBOSITY_VERY_VERBOSE);
  417. }
  418. $share->setNodeId($newNodeId);
  419. $this->shareManager->updateShare($share);
  420. }
  421. }
  422. } catch (NotFoundException $e) {
  423. $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted file, skipping</error>');
  424. } catch (\Throwable $e) {
  425. $output->writeln('<error>Could not restore share with id ' . $share->getId() . ':' . $e->getMessage() . ' : ' . $e->getTraceAsString() . '</error>');
  426. }
  427. $progress->advance();
  428. }
  429. $progress->finish();
  430. $output->writeln('');
  431. }
  432. private function transferIncomingShares(string $sourceUid,
  433. string $destinationUid,
  434. array $sourceShares,
  435. array $destinationShares,
  436. OutputInterface $output,
  437. string $path,
  438. string $finalTarget,
  439. bool $move): void {
  440. $output->writeln('Restoring incoming shares ...');
  441. $progress = new ProgressBar($output, count($sourceShares));
  442. $prefix = "$destinationUid/files";
  443. $finalShareTarget = '';
  444. if (str_starts_with($finalTarget, $prefix)) {
  445. $finalShareTarget = substr($finalTarget, strlen($prefix));
  446. }
  447. foreach ($sourceShares as $share) {
  448. try {
  449. // Only restore if share is in given path.
  450. $pathToCheck = '/';
  451. if (trim($path, '/') !== '') {
  452. $pathToCheck = '/' . trim($path) . '/';
  453. }
  454. if (!str_starts_with($share->getTarget(), $pathToCheck)) {
  455. continue;
  456. }
  457. $shareTarget = $share->getTarget();
  458. $shareTarget = $finalShareTarget . $shareTarget;
  459. if ($share->getShareType() === IShare::TYPE_USER &&
  460. $share->getSharedBy() === $destinationUid) {
  461. $this->shareManager->deleteShare($share);
  462. } elseif (isset($destinationShares[$share->getNodeId()])) {
  463. $destinationShare = $destinationShares[$share->getNodeId()];
  464. // Keep the share which has the most permissions and discard the other one.
  465. if ($destinationShare->getPermissions() < $share->getPermissions()) {
  466. $this->shareManager->deleteShare($destinationShare);
  467. $share->setSharedWith($destinationUid);
  468. // trigger refetching of the node so that the new owner and mountpoint are taken into account
  469. // otherwise the checks on the share update will fail due to the original node not being available in the new user scope
  470. $this->userMountCache->clear();
  471. $share->setNodeId($share->getNode()->getId());
  472. $this->shareManager->updateShare($share);
  473. // The share is already transferred.
  474. $progress->advance();
  475. if ($move) {
  476. continue;
  477. }
  478. $share->setTarget($shareTarget);
  479. $this->shareManager->moveShare($share, $destinationUid);
  480. continue;
  481. }
  482. $this->shareManager->deleteShare($share);
  483. } elseif ($share->getShareOwner() === $destinationUid) {
  484. $this->shareManager->deleteShare($share);
  485. } else {
  486. $share->setSharedWith($destinationUid);
  487. $share->setNodeId($share->getNode()->getId());
  488. $this->shareManager->updateShare($share);
  489. // trigger refetching of the node so that the new owner and mountpoint are taken into account
  490. // otherwise the checks on the share update will fail due to the original node not being available in the new user scope
  491. $this->userMountCache->clear();
  492. // The share is already transferred.
  493. $progress->advance();
  494. if ($move) {
  495. continue;
  496. }
  497. $share->setTarget($shareTarget);
  498. $this->shareManager->moveShare($share, $destinationUid);
  499. continue;
  500. }
  501. } catch (NotFoundException $e) {
  502. $output->writeln('<error>Share with id ' . $share->getId() . ' points at deleted file, skipping</error>');
  503. } catch (\Throwable $e) {
  504. $output->writeln('<error>Could not restore share with id ' . $share->getId() . ':' . $e->getTraceAsString() . '</error>');
  505. }
  506. $progress->advance();
  507. }
  508. $progress->finish();
  509. $output->writeln('');
  510. }
  511. }