aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files_trashbin/lib
diff options
context:
space:
mode:
authorRobin Windey <ro.windey@gmail.com>2023-09-18 12:38:57 +0200
committerGitHub <noreply@github.com>2023-09-18 12:38:57 +0200
commit7afcacdff737c46bd93f23d2c55cfc29570f15cc (patch)
tree5ee979528bb3161a288036f113c1650abe0bf5fe /apps/files_trashbin/lib
parent5d20baec01daec7b7aefe7cb17f8db5611dcd176 (diff)
parentd7a73ffdf1b87d9e56e02446f3e2cd3c725d30a5 (diff)
downloadnextcloud-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.php212
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;
}
}