Skip to content
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

[stable27] Let occ trashbin:restore restore also from groupfolders and add filters #40581

Merged
merged 5 commits into from
Sep 25, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 170 additions & 42 deletions apps/files_trashbin/lib/Command/RestoreAllFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -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,
Expand All @@ -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');
Expand All @@ -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);
Expand All @@ -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();

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;
}
}