RestoreAllFiles.php 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-License-Identifier: AGPL-3.0-only
  5. */
  6. namespace OCA\Files_Trashbin\Command;
  7. use OC\Core\Command\Base;
  8. use OCA\Files_Trashbin\Trash\ITrashManager;
  9. use OCA\Files_Trashbin\Trash\TrashItem;
  10. use OCP\Files\IRootFolder;
  11. use OCP\IDBConnection;
  12. use OCP\IL10N;
  13. use OCP\IUserBackend;
  14. use OCP\IUserManager;
  15. use OCP\L10N\IFactory;
  16. use Symfony\Component\Console\Exception\InvalidOptionException;
  17. use Symfony\Component\Console\Input\InputArgument;
  18. use Symfony\Component\Console\Input\InputInterface;
  19. use Symfony\Component\Console\Input\InputOption;
  20. use Symfony\Component\Console\Output\OutputInterface;
  21. class RestoreAllFiles extends Base {
  22. private const SCOPE_ALL = 0;
  23. private const SCOPE_USER = 1;
  24. private const SCOPE_GROUPFOLDERS = 2;
  25. private static array $SCOPE_MAP = [
  26. 'user' => self::SCOPE_USER,
  27. 'groupfolders' => self::SCOPE_GROUPFOLDERS,
  28. 'all' => self::SCOPE_ALL
  29. ];
  30. /** @var IL10N */
  31. protected $l10n;
  32. /**
  33. * @param IRootFolder $rootFolder
  34. * @param IUserManager $userManager
  35. * @param IDBConnection $dbConnection
  36. * @param ITrashManager $trashManager
  37. * @param IFactory $l10nFactory
  38. */
  39. public function __construct(
  40. protected IRootFolder $rootFolder,
  41. protected IUserManager $userManager,
  42. protected IDBConnection $dbConnection,
  43. protected ITrashManager $trashManager,
  44. IFactory $l10nFactory,
  45. ) {
  46. parent::__construct();
  47. $this->l10n = $l10nFactory->get('files_trashbin');
  48. }
  49. protected function configure(): void {
  50. parent::configure();
  51. $this
  52. ->setName('trashbin:restore')
  53. ->setDescription('Restore all deleted files according to the given filters')
  54. ->addArgument(
  55. 'user_id',
  56. InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
  57. 'restore all deleted files of the given user(s)'
  58. )
  59. ->addOption(
  60. 'all-users',
  61. null,
  62. InputOption::VALUE_NONE,
  63. 'run action on all users'
  64. )
  65. ->addOption(
  66. 'scope',
  67. 's',
  68. InputOption::VALUE_OPTIONAL,
  69. 'Restore files from the given scope. Possible values are "user", "groupfolders" or "all"',
  70. 'user'
  71. )
  72. ->addOption(
  73. 'since',
  74. null,
  75. InputOption::VALUE_OPTIONAL,
  76. 'Only restore files deleted after the given date and time, see https://www.php.net/manual/en/function.strtotime.php for more information on supported formats'
  77. )
  78. ->addOption(
  79. 'until',
  80. null,
  81. InputOption::VALUE_OPTIONAL,
  82. 'Only restore files deleted before the given date and time, see https://www.php.net/manual/en/function.strtotime.php for more information on supported formats'
  83. )
  84. ->addOption(
  85. 'dry-run',
  86. 'd',
  87. InputOption::VALUE_NONE,
  88. 'Only show which files would be restored but do not perform any action'
  89. );
  90. }
  91. protected function execute(InputInterface $input, OutputInterface $output): int {
  92. /** @var string[] $users */
  93. $users = $input->getArgument('user_id');
  94. if ((!empty($users)) && ($input->getOption('all-users'))) {
  95. throw new InvalidOptionException('Either specify a user_id or --all-users');
  96. }
  97. [$scope, $since, $until, $dryRun] = $this->parseArgs($input);
  98. if (!empty($users)) {
  99. foreach ($users as $user) {
  100. $output->writeln("Restoring deleted files for user <info>$user</info>");
  101. $this->restoreDeletedFiles($user, $scope, $since, $until, $dryRun, $output);
  102. }
  103. } elseif ($input->getOption('all-users')) {
  104. $output->writeln('Restoring deleted files for all users');
  105. foreach ($this->userManager->getBackends() as $backend) {
  106. $name = get_class($backend);
  107. if ($backend instanceof IUserBackend) {
  108. $name = $backend->getBackendName();
  109. }
  110. $output->writeln("Restoring deleted files for users on backend <info>$name</info>");
  111. $limit = 500;
  112. $offset = 0;
  113. do {
  114. $users = $backend->getUsers('', $limit, $offset);
  115. foreach ($users as $user) {
  116. $output->writeln("<info>$user</info>");
  117. $this->restoreDeletedFiles($user, $scope, $since, $until, $dryRun, $output);
  118. }
  119. $offset += $limit;
  120. } while (count($users) >= $limit);
  121. }
  122. } else {
  123. throw new InvalidOptionException('Either specify a user_id or --all-users');
  124. }
  125. return 0;
  126. }
  127. /**
  128. * Restore deleted files for the given user according to the given filters
  129. */
  130. protected function restoreDeletedFiles(string $uid, int $scope, ?int $since, ?int $until, bool $dryRun, OutputInterface $output): void {
  131. \OC_Util::tearDownFS();
  132. \OC_Util::setupFS($uid);
  133. \OC_User::setUserId($uid);
  134. $user = $this->userManager->get($uid);
  135. if ($user === null) {
  136. $output->writeln("<error>Unknown user $uid</error>");
  137. return;
  138. }
  139. $userTrashItems = $this->filterTrashItems(
  140. $this->trashManager->listTrashRoot($user),
  141. $scope,
  142. $since,
  143. $until,
  144. $output);
  145. $trashCount = count($userTrashItems);
  146. if ($trashCount == 0) {
  147. $output->writeln('User has no deleted files in the trashbin matching the given filters');
  148. return;
  149. }
  150. $prepMsg = $dryRun ? 'Would restore' : 'Preparing to restore';
  151. $output->writeln("$prepMsg <info>$trashCount</info> files...");
  152. $count = 0;
  153. foreach ($userTrashItems as $trashItem) {
  154. $filename = $trashItem->getName();
  155. $humanTime = $this->l10n->l('datetime', $trashItem->getDeletedTime());
  156. // We use getTitle() here instead of getOriginalLocation() because
  157. // for groupfolders this contains the groupfolder name itself as prefix
  158. // which makes it more human readable
  159. $location = $trashItem->getTitle();
  160. if ($dryRun) {
  161. $output->writeln("Would restore <info>$filename</info> originally deleted at <info>$humanTime</info> to <info>/$location</info>");
  162. continue;
  163. }
  164. $output->write("File <info>$filename</info> originally deleted at <info>$humanTime</info> restoring to <info>/$location</info>:");
  165. try {
  166. $trashItem->getTrashBackend()->restoreItem($trashItem);
  167. } catch (\Throwable $e) {
  168. $output->writeln(' <error>Failed: ' . $e->getMessage() . '</error>');
  169. $output->writeln(' <error>' . $e->getTraceAsString() . '</error>', OutputInterface::VERBOSITY_VERY_VERBOSE);
  170. continue;
  171. }
  172. $count++;
  173. $output->writeln(' <info>success</info>');
  174. }
  175. if (!$dryRun) {
  176. $output->writeln("Successfully restored <info>$count</info> out of <info>$trashCount</info> files.");
  177. }
  178. }
  179. protected function parseArgs(InputInterface $input): array {
  180. $since = $this->parseTimestamp($input->getOption('since'));
  181. $until = $this->parseTimestamp($input->getOption('until'));
  182. if ($since !== null && $until !== null && $since > $until) {
  183. throw new InvalidOptionException('since must be before until');
  184. }
  185. return [
  186. $this->parseScope($input->getOption('scope')),
  187. $since,
  188. $until,
  189. $input->getOption('dry-run')
  190. ];
  191. }
  192. protected function parseScope(string $scope): int {
  193. if (isset(self::$SCOPE_MAP[$scope])) {
  194. return self::$SCOPE_MAP[$scope];
  195. }
  196. throw new InvalidOptionException("Invalid scope '$scope'");
  197. }
  198. protected function parseTimestamp(?string $timestamp): ?int {
  199. if ($timestamp === null) {
  200. return null;
  201. }
  202. $timestamp = strtotime($timestamp);
  203. if ($timestamp === false) {
  204. throw new InvalidOptionException("Invalid timestamp '$timestamp'");
  205. }
  206. return $timestamp;
  207. }
  208. protected function filterTrashItems(array $trashItems, int $scope, ?int $since, ?int $until, OutputInterface $output): array {
  209. $filteredTrashItems = [];
  210. foreach ($trashItems as $trashItem) {
  211. $trashItemClass = get_class($trashItem);
  212. // Check scope with exact class name for locally deleted files
  213. if ($scope === self::SCOPE_USER && $trashItemClass !== TrashItem::class) {
  214. $output->writeln('Skipping <info>' . $trashItem->getName() . '</info> because it is not a user trash item', OutputInterface::VERBOSITY_VERBOSE);
  215. continue;
  216. }
  217. /**
  218. * Check scope for groupfolders by string because the groupfolders app might not be installed.
  219. * That's why PSALM doesn't know the class GroupTrashItem.
  220. * @psalm-suppress RedundantCondition
  221. */
  222. if ($scope === self::SCOPE_GROUPFOLDERS && $trashItemClass !== 'OCA\GroupFolders\Trash\GroupTrashItem') {
  223. $output->writeln('Skipping <info>' . $trashItem->getName() . '</info> because it is not a groupfolders trash item', OutputInterface::VERBOSITY_VERBOSE);
  224. continue;
  225. }
  226. // Check left timestamp boundary
  227. if ($since !== null && $trashItem->getDeletedTime() <= $since) {
  228. $output->writeln('Skipping <info>' . $trashItem->getName() . "</info> because it was deleted before the 'since' timestamp", OutputInterface::VERBOSITY_VERBOSE);
  229. continue;
  230. }
  231. // Check right timestamp boundary
  232. if ($until !== null && $trashItem->getDeletedTime() >= $until) {
  233. $output->writeln('Skipping <info>' . $trashItem->getName() . "</info> because it was deleted after the 'until' timestamp", OutputInterface::VERBOSITY_VERBOSE);
  234. continue;
  235. }
  236. $filteredTrashItems[] = $trashItem;
  237. }
  238. return $filteredTrashItems;
  239. }
  240. }