-
-
Notifications
You must be signed in to change notification settings - Fork 4.7k
Let occ trashbin:restore restore also from groupfolders and add filters
#39818
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
98dd02b
ee66518
52d77eb
acf0b4c
d7a73ff
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 @@ | |||||||
|
|
||||||||
| 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,27 +53,32 @@ class RestoreAllFiles extends Base { | |||||||
| /** @var \OCP\IDBConnection */ | ||||||||
| protected $dbConnection; | ||||||||
|
|
||||||||
| protected ITrashManager $trashManager; | ||||||||
|
|
||||||||
| /** @var IL10N */ | ||||||||
| protected $l10n; | ||||||||
|
|
||||||||
| /** | ||||||||
| * @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'); | ||||||||
| } | ||||||||
|
|
||||||||
| protected function configure(): void { | ||||||||
| 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 @@ protected function configure(): void { | |||||||
| 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 @@ protected function execute(InputInterface $input, OutputInterface $output): int | |||||||
| $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 @@ protected function execute(InputInterface $input, OutputInterface $output): int | |||||||
| } | ||||||||
|
|
||||||||
| /** | ||||||||
| * 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(); | ||||||||
|
Comment on lines
+192
to
+195
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Feels a bit hackish since there is no guarantee from the interface this will actually return a location.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agree but I didn't find another way of printing the full path in case of a Groupfolder Trashitem. I'm open for suggestions here :-)
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @nextcloud/files Ideas?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The goal is to display the groupfolder name ? Could the following work? $userFolder = $this->rootFolder->getUserFolder($user->getUID());
$userFolder->get($trashItem->getOriginalLocation())->getMountPoint()->getInternalPath();
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @artonge yes that's correct. I wanted to display a "human readable" representation for groupfolders. Unforunately your code suggestion does not work since the file is deleted at the time trying to access it so I get a I did some additional research and the only two implementations of
The first one is for locally deleted files and returns |
||||||||
|
|
||||||||
| 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 { | ||||||||
R0Wi marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||
| 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') { | ||||||||
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Show fixed
Hide fixed
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Show fixed
Hide fixed
|
||||||||
| $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; | ||||||||
| } | ||||||||
| } | ||||||||
Uh oh!
There was an error while loading. Please reload this page.