Skip to content
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();
Comment on lines +192 to +195
Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Member Author

Choose a reason for hiding this comment

The 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 :-)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nextcloud/files Ideas?

Copy link
Contributor

Choose a reason for hiding this comment

The 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();

Copy link
Member Author

Choose a reason for hiding this comment

The 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 OCP\Files\NotFoundException at the line $userFolder->get($trashItem->getOriginalLocation())->getMountPoint()->getInternalPath($trashItem->getOriginalLocation());

I did some additional research and the only two implementations of ITrashItem->getTitle() I found are:

The first one is for locally deleted files and returns getOriginalLocation() directly (which is exactly what has been returned before the changes of this PR). The second one is the specific override for the GroupTrashItem which (in my opinion) returns exactly what we need: the full path to the groupfolders file including the groupfolders mountpoint, which is basically the name of the groupfolder itself. @icewind1991 you've been the last one working on that LOC, maybe you could give some insights here?


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