diff options
author | Robin Windey <ro.windey@gmail.com> | 2023-09-18 12:38:57 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-18 12:38:57 +0200 |
commit | 7afcacdff737c46bd93f23d2c55cfc29570f15cc (patch) | |
tree | 5ee979528bb3161a288036f113c1650abe0bf5fe /apps/files_trashbin/lib | |
parent | 5d20baec01daec7b7aefe7cb17f8db5611dcd176 (diff) | |
parent | d7a73ffdf1b87d9e56e02446f3e2cd3c725d30a5 (diff) | |
download | nextcloud-server-7afcacdff737c46bd93f23d2c55cfc29570f15cc.tar.gz nextcloud-server-7afcacdff737c46bd93f23d2c55cfc29570f15cc.zip |
Merge pull request #39818 from nextcloud/feature/trashbin-restore-improvements#39724
Let `occ trashbin:restore` restore also from groupfolders and add filters
Diffstat (limited to 'apps/files_trashbin/lib')
-rw-r--r-- | apps/files_trashbin/lib/Command/RestoreAllFiles.php | 212 |
1 files changed, 170 insertions, 42 deletions
diff --git a/apps/files_trashbin/lib/Command/RestoreAllFiles.php b/apps/files_trashbin/lib/Command/RestoreAllFiles.php index bff116c4ec4..cd79f1e8def 100644 --- a/apps/files_trashbin/lib/Command/RestoreAllFiles.php +++ b/apps/files_trashbin/lib/Command/RestoreAllFiles.php @@ -19,12 +19,11 @@ namespace OCA\Files_Trashbin\Command; use OC\Core\Command\Base; +use OCA\Files_Trashbin\Trash\ITrashManager; use OCP\Files\IRootFolder; use OCP\IDBConnection; use OCP\IL10N; use OCP\IUserBackend; -use OCA\Files_Trashbin\Trashbin; -use OCA\Files_Trashbin\Helper; use OCP\IUserManager; use OCP\L10N\IFactory; use Symfony\Component\Console\Exception\InvalidOptionException; @@ -35,6 +34,16 @@ use Symfony\Component\Console\Output\OutputInterface; class RestoreAllFiles extends Base { + private const SCOPE_ALL = 0; + private const SCOPE_USER = 1; + private const SCOPE_GROUPFOLDERS = 2; + + private static array $SCOPE_MAP = [ + 'user' => self::SCOPE_USER, + 'groupfolders' => self::SCOPE_GROUPFOLDERS, + 'all' => self::SCOPE_ALL + ]; + /** @var IUserManager */ protected $userManager; @@ -44,6 +53,8 @@ class RestoreAllFiles extends Base { /** @var \OCP\IDBConnection */ protected $dbConnection; + protected ITrashManager $trashManager; + /** @var IL10N */ protected $l10n; @@ -51,12 +62,15 @@ class RestoreAllFiles extends Base { * @param IRootFolder $rootFolder * @param IUserManager $userManager * @param IDBConnection $dbConnection + * @param ITrashManager $trashManager + * @param IFactory $l10nFactory */ - public function __construct(IRootFolder $rootFolder, IUserManager $userManager, IDBConnection $dbConnection, IFactory $l10nFactory) { + public function __construct(IRootFolder $rootFolder, IUserManager $userManager, IDBConnection $dbConnection, ITrashManager $trashManager, IFactory $l10nFactory) { parent::__construct(); $this->userManager = $userManager; $this->rootFolder = $rootFolder; $this->dbConnection = $dbConnection; + $this->trashManager = $trashManager; $this->l10n = $l10nFactory->get('files_trashbin'); } @@ -64,7 +78,7 @@ class RestoreAllFiles extends Base { parent::configure(); $this ->setName('trashbin:restore') - ->setDescription('Restore all deleted files') + ->setDescription('Restore all deleted files according to the given filters') ->addArgument( 'user_id', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, @@ -75,23 +89,47 @@ class RestoreAllFiles extends Base { null, InputOption::VALUE_NONE, 'run action on all users' + ) + ->addOption( + 'scope', + 's', + InputOption::VALUE_OPTIONAL, + 'Restore files from the given scope. Possible values are "user", "groupfolders" or "all"', + 'user' + ) + ->addOption( + 'since', + null, + InputOption::VALUE_OPTIONAL, + 'Only restore files deleted after the given timestamp' + ) + ->addOption( + 'until', + null, + InputOption::VALUE_OPTIONAL, + 'Only restore files deleted before the given timestamp' + ) + ->addOption( + 'dry-run', + 'd', + InputOption::VALUE_NONE, + 'Only show which files would be restored but do not perform any action' ); } protected function execute(InputInterface $input, OutputInterface $output): int { /** @var string[] $users */ $users = $input->getArgument('user_id'); - if ((!empty($users)) and ($input->getOption('all-users'))) { + if ((!empty($users)) && ($input->getOption('all-users'))) { throw new InvalidOptionException('Either specify a user_id or --all-users'); - } elseif (!empty($users)) { + } + + [$scope, $since, $until, $dryRun] = $this->parseArgs($input); + + if (!empty($users)) { foreach ($users as $user) { - if ($this->userManager->userExists($user)) { - $output->writeln("Restoring deleted files for user <info>$user</info>"); - $this->restoreDeletedFiles($user, $output); - } else { - $output->writeln("<error>Unknown user $user</error>"); - return 1; - } + $output->writeln("Restoring deleted files for user <info>$user</info>"); + $this->restoreDeletedFiles($user, $scope, $since, $until, $dryRun, $output); } } elseif ($input->getOption('all-users')) { $output->writeln('Restoring deleted files for all users'); @@ -107,7 +145,7 @@ class RestoreAllFiles extends Base { $users = $backend->getUsers('', $limit, $offset); foreach ($users as $user) { $output->writeln("<info>$user</info>"); - $this->restoreDeletedFiles($user, $output); + $this->restoreDeletedFiles($user, $scope, $since, $until, $dryRun, $output); } $offset += $limit; } while (count($users) >= $limit); @@ -119,47 +157,137 @@ class RestoreAllFiles extends Base { } /** - * Restore deleted files for the given user - * - * @param string $uid - * @param OutputInterface $output + * Restore deleted files for the given user according to the given filters */ - protected function restoreDeletedFiles(string $uid, OutputInterface $output): void { + protected function restoreDeletedFiles(string $uid, int $scope, ?int $since, ?int $until, bool $dryRun, OutputInterface $output): void { \OC_Util::tearDownFS(); \OC_Util::setupFS($uid); \OC_User::setUserId($uid); - // Sort by most recently deleted first - // (Restoring in order of most recently deleted preserves nested file paths. - // See https://github.com/nextcloud/server/issues/31200#issuecomment-1130358549) - $filesInTrash = Helper::getTrashFiles('/', $uid, 'mtime', true); + $user = $this->userManager->get($uid); - $trashCount = count($filesInTrash); + if ($user === null) { + $output->writeln("<error>Unknown user $uid</error>"); + return; + } + + $userTrashItems = $this->filterTrashItems( + $this->trashManager->listTrashRoot($user), + $scope, + $since, + $until, + $output); + + $trashCount = count($userTrashItems); if ($trashCount == 0) { - $output->writeln("User has no deleted files in the trashbin"); + $output->writeln("User has no deleted files in the trashbin matching the given filters"); return; } - $output->writeln("Preparing to restore <info>$trashCount</info> files..."); + $prepMsg = $dryRun ? 'Would restore' : 'Preparing to restore'; + $output->writeln("$prepMsg <info>$trashCount</info> files..."); $count = 0; - foreach ($filesInTrash as $trashFile) { - $filename = $trashFile->getName(); - $timestamp = $trashFile->getMtime(); - $humanTime = $this->l10n->l('datetime', $timestamp); - $output->write("File <info>$filename</info> originally deleted at <info>$humanTime</info> "); - $file = Trashbin::getTrashFilename($filename, $timestamp); - $location = Trashbin::getLocation($uid, $filename, (string) $timestamp); - if ($location === '.') { - $location = ''; + foreach($userTrashItems as $trashItem) { + $filename = $trashItem->getName(); + $humanTime = $this->l10n->l('datetime', $trashItem->getDeletedTime()); + // We use getTitle() here instead of getOriginalLocation() because + // for groupfolders this contains the groupfolder name itself as prefix + // which makes it more human readable + $location = $trashItem->getTitle(); + + if ($dryRun) { + $output->writeln("Would restore <info>$filename</info> originally deleted at <info>$humanTime</info> to <info>/$location</info>"); + continue; } - $output->write("restoring to <info>/$location</info>:"); - if (Trashbin::restore($file, $filename, $timestamp)) { - $count = $count + 1; - $output->writeln(" <info>success</info>"); - } else { - $output->writeln(" <error>failed</error>"); + + $output->write("File <info>$filename</info> originally deleted at <info>$humanTime</info> restoring to <info>/$location</info>:"); + + try { + $trashItem->getTrashBackend()->restoreItem($trashItem); + } catch (\Throwable $e) { + $output->writeln(" <error>Failed: " . $e->getMessage() . "</error>"); + $output->writeln(" <error>" . $e->getTraceAsString() . "</error>", OutputInterface::VERBOSITY_VERY_VERBOSE); + continue; } + + $count++; + $output->writeln(" <info>success</info>"); } - $output->writeln("Successfully restored <info>$count</info> out of <info>$trashCount</info> files."); + if (!$dryRun) { + $output->writeln("Successfully restored <info>$count</info> out of <info>$trashCount</info> files."); + } + } + + protected function parseArgs(InputInterface $input): array { + $since = $this->parseTimestamp($input->getOption('since')); + $until = $this->parseTimestamp($input->getOption('until')); + + if ($since !== null && $until !== null && $since > $until) { + throw new InvalidOptionException('since must be before until'); + } + + return [ + $this->parseScope($input->getOption('scope')), + $since, + $until, + $input->getOption('dry-run') + ]; + } + + protected function parseScope(string $scope): int { + if (isset(self::$SCOPE_MAP[$scope])) { + return self::$SCOPE_MAP[$scope]; + } + + throw new InvalidOptionException("Invalid scope '$scope'"); + } + + protected function parseTimestamp(?string $timestamp): ?int { + if ($timestamp === null) { + return null; + } + $timestamp = strtotime($timestamp); + if ($timestamp === false) { + throw new InvalidOptionException("Invalid timestamp '$timestamp'"); + } + return $timestamp; + } + + protected function filterTrashItems(array $trashItems, int $scope, ?int $since, ?int $until, OutputInterface $output): array { + $filteredTrashItems = []; + foreach ($trashItems as $trashItem) { + $trashItemClass = get_class($trashItem); + + // Check scope with exact class name for locally deleted files + if ($scope === self::SCOPE_USER && $trashItemClass !== \OCA\Files_Trashbin\Trash\TrashItem::class) { + $output->writeln("Skipping <info>" . $trashItem->getName() . "</info> because it is not a user trash item", OutputInterface::VERBOSITY_VERBOSE); + continue; + } + + /** + * Check scope for groupfolders by string because the groupfolders app might not be installed. + * That's why PSALM doesn't know the class GroupTrashItem. + * @psalm-suppress RedundantCondition + */ + if ($scope === self::SCOPE_GROUPFOLDERS && $trashItemClass !== 'OCA\GroupFolders\Trash\GroupTrashItem') { + $output->writeln("Skipping <info>" . $trashItem->getName() . "</info> because it is not a groupfolders trash item", OutputInterface::VERBOSITY_VERBOSE); + continue; + } + + // Check left timestamp boundary + if ($since !== null && $trashItem->getDeletedTime() <= $since) { + $output->writeln("Skipping <info>" . $trashItem->getName() . "</info> because it was deleted before the 'since' timestamp", OutputInterface::VERBOSITY_VERBOSE); + continue; + } + + // Check right timestamp boundary + if ($until !== null && $trashItem->getDeletedTime() >= $until) { + $output->writeln("Skipping <info>" . $trashItem->getName() . "</info> because it was deleted after the 'until' timestamp", OutputInterface::VERBOSITY_VERBOSE); + continue; + } + + $filteredTrashItems[] = $trashItem; + } + return $filteredTrashItems; } } |