self::SCOPE_USER, 'groupfolders' => self::SCOPE_GROUPFOLDERS, 'all' => self::SCOPE_ALL ]; /** @var IL10N */ protected $l10n; /** * @param IRootFolder $rootFolder * @param IUserManager $userManager * @param IDBConnection $dbConnection * @param ITrashManager $trashManager * @param IFactory $l10nFactory */ public function __construct( protected IRootFolder $rootFolder, protected IUserManager $userManager, protected IDBConnection $dbConnection, protected ITrashManager $trashManager, IFactory $l10nFactory, ) { parent::__construct(); $this->l10n = $l10nFactory->get('files_trashbin'); } protected function configure(): void { parent::configure(); $this ->setName('trashbin:restore') ->setDescription('Restore all deleted files according to the given filters') ->addArgument( 'user_id', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'restore all deleted files of the given user(s)' ) ->addOption( 'all-users', 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 date and time, see https://www.php.net/manual/en/function.strtotime.php for more information on supported formats' ) ->addOption( 'until', null, InputOption::VALUE_OPTIONAL, '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' ) ->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)) && ($input->getOption('all-users'))) { throw new InvalidOptionException('Either specify a user_id or --all-users'); } [$scope, $since, $until, $dryRun] = $this->parseArgs($input); if (!empty($users)) { foreach ($users as $user) { $output->writeln("Restoring deleted files for user $user"); $this->restoreDeletedFiles($user, $scope, $since, $until, $dryRun, $output); } } elseif ($input->getOption('all-users')) { $output->writeln('Restoring deleted files for all users'); foreach ($this->userManager->getBackends() as $backend) { $name = get_class($backend); if ($backend instanceof IUserBackend) { $name = $backend->getBackendName(); } $output->writeln("Restoring deleted files for users on backend $name"); $limit = 500; $offset = 0; do { $users = $backend->getUsers('', $limit, $offset); foreach ($users as $user) { $output->writeln("$user"); $this->restoreDeletedFiles($user, $scope, $since, $until, $dryRun, $output); } $offset += $limit; } while (count($users) >= $limit); } } else { throw new InvalidOptionException('Either specify a user_id or --all-users'); } return 0; } /** * Restore deleted files for the given user according to the given filters */ 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); $user = $this->userManager->get($uid); if ($user === null) { $output->writeln("Unknown user $uid"); 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 matching the given filters'); return; } $prepMsg = $dryRun ? 'Would restore' : 'Preparing to restore'; $output->writeln("$prepMsg $trashCount files..."); $count = 0; 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 $filename originally deleted at $humanTime to /$location"); continue; } $output->write("File $filename originally deleted at $humanTime restoring to /$location:"); try { $trashItem->getTrashBackend()->restoreItem($trashItem); } catch (\Throwable $e) { $output->writeln(' Failed: ' . $e->getMessage() . ''); $output->writeln(' ' . $e->getTraceAsString() . '', OutputInterface::VERBOSITY_VERY_VERBOSE); continue; } $count++; $output->writeln(' success'); } if (!$dryRun) { $output->writeln("Successfully restored $count out of $trashCount 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 !== TrashItem::class) { $output->writeln('Skipping ' . $trashItem->getName() . ' 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 ' . $trashItem->getName() . ' 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 ' . $trashItem->getName() . " 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 ' . $trashItem->getName() . " because it was deleted after the 'until' timestamp", OutputInterface::VERBOSITY_VERBOSE); continue; } $filteredTrashItems[] = $trashItem; } return $filteredTrashItems; } }