diff options
Diffstat (limited to 'apps/files_trashbin/lib')
52 files changed, 4536 insertions, 1542 deletions
diff --git a/apps/files_trashbin/lib/AppInfo/Application.php b/apps/files_trashbin/lib/AppInfo/Application.php new file mode 100644 index 00000000000..76d566f4286 --- /dev/null +++ b/apps/files_trashbin/lib/AppInfo/Application.php @@ -0,0 +1,96 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Trashbin\AppInfo; + +use OCA\DAV\Connector\Sabre\Principal; +use OCA\Files\Event\LoadAdditionalScriptsEvent; +use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; +use OCA\Files_Trashbin\Capabilities; +use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent; +use OCA\Files_Trashbin\Expiration; +use OCA\Files_Trashbin\Listener\EventListener; +use OCA\Files_Trashbin\Listeners\BeforeTemplateRendered; +use OCA\Files_Trashbin\Listeners\LoadAdditionalScripts; +use OCA\Files_Trashbin\Listeners\SyncLivePhotosListener; +use OCA\Files_Trashbin\Trash\ITrashManager; +use OCA\Files_Trashbin\Trash\TrashManager; +use OCA\Files_Trashbin\Trashbin; +use OCA\Files_Trashbin\UserMigration\TrashbinMigrator; +use OCP\App\IAppManager; +use OCP\AppFramework\App; +use OCP\AppFramework\Bootstrap\IBootContext; +use OCP\AppFramework\Bootstrap\IBootstrap; +use OCP\AppFramework\Bootstrap\IRegistrationContext; +use OCP\Files\Events\BeforeFileSystemSetupEvent; +use OCP\Files\Events\Node\BeforeNodeDeletedEvent; +use OCP\Files\Events\Node\NodeWrittenEvent; +use OCP\User\Events\BeforeUserDeletedEvent; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +class Application extends App implements IBootstrap { + public const APP_ID = 'files_trashbin'; + + public function __construct(array $urlParams = []) { + parent::__construct(self::APP_ID, $urlParams); + } + + public function register(IRegistrationContext $context): void { + $context->registerCapability(Capabilities::class); + + $context->registerServiceAlias('Expiration', Expiration::class); + $context->registerServiceAlias(ITrashManager::class, TrashManager::class); + /** Register $principalBackend for the DAV collection */ + $context->registerServiceAlias('principalBackend', Principal::class); + + $context->registerUserMigrator(TrashbinMigrator::class); + + $context->registerEventListener( + LoadAdditionalScriptsEvent::class, + LoadAdditionalScripts::class + ); + + $context->registerEventListener( + BeforeTemplateRenderedEvent::class, + BeforeTemplateRendered::class + ); + + $context->registerEventListener(BeforeNodeRestoredEvent::class, SyncLivePhotosListener::class); + + $context->registerEventListener(NodeWrittenEvent::class, EventListener::class); + $context->registerEventListener(BeforeUserDeletedEvent::class, EventListener::class); + $context->registerEventListener(BeforeFileSystemSetupEvent::class, EventListener::class); + + // pre and post-rename, disable trash logic for the copy+unlink case + $context->registerEventListener(BeforeNodeDeletedEvent::class, Trashbin::class); + } + + public function boot(IBootContext $context): void { + $context->injectFn([$this, 'registerTrashBackends']); + } + + public function registerTrashBackends(ContainerInterface $serverContainer, LoggerInterface $logger, IAppManager $appManager, ITrashManager $trashManager): void { + foreach ($appManager->getEnabledApps() as $app) { + $appInfo = $appManager->getAppInfo($app); + if (isset($appInfo['trash'])) { + $backends = $appInfo['trash']; + foreach ($backends as $backend) { + $class = $backend['@value']; + $for = $backend['@attributes']['for']; + + try { + $backendObject = $serverContainer->get($class); + $trashManager->registerBackend($for, $backendObject); + } catch (\Exception $e) { + $logger->error($e->getMessage(), ['exception' => $e]); + } + } + } + } + } +} diff --git a/apps/files_trashbin/lib/BackgroundJob/ExpireTrash.php b/apps/files_trashbin/lib/BackgroundJob/ExpireTrash.php new file mode 100644 index 00000000000..bb383dab78d --- /dev/null +++ b/apps/files_trashbin/lib/BackgroundJob/ExpireTrash.php @@ -0,0 +1,88 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Trashbin\BackgroundJob; + +use OC\Files\View; +use OCA\Files_Trashbin\Expiration; +use OCA\Files_Trashbin\Helper; +use OCA\Files_Trashbin\Trashbin; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; +use OCP\IAppConfig; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; + +class ExpireTrash extends TimedJob { + public function __construct( + private IAppConfig $appConfig, + private IUserManager $userManager, + private Expiration $expiration, + private LoggerInterface $logger, + ITimeFactory $time, + ) { + parent::__construct($time); + // Run once per 30 minutes + $this->setInterval(60 * 30); + } + + protected function run($argument) { + $backgroundJob = $this->appConfig->getValueString('files_trashbin', 'background_job_expire_trash', 'yes'); + if ($backgroundJob === 'no') { + return; + } + + $maxAge = $this->expiration->getMaxAgeAsTimestamp(); + if (!$maxAge) { + return; + } + + $stopTime = time() + 60 * 30; // Stops after 30 minutes. + $offset = $this->appConfig->getValueInt('files_trashbin', 'background_job_expire_trash_offset', 0); + $users = $this->userManager->getSeenUsers($offset); + + foreach ($users as $user) { + try { + $uid = $user->getUID(); + if (!$this->setupFS($uid)) { + continue; + } + $dirContent = Helper::getTrashFiles('/', $uid, 'mtime'); + Trashbin::deleteExpiredFiles($dirContent, $uid); + } catch (\Throwable $e) { + $this->logger->error('Error while expiring trashbin for user ' . $user->getUID(), ['exception' => $e]); + } + + $offset++; + + if ($stopTime < time()) { + $this->appConfig->setValueInt('files_trashbin', 'background_job_expire_trash_offset', $offset); + \OC_Util::tearDownFS(); + return; + } + } + + $this->appConfig->setValueInt('files_trashbin', 'background_job_expire_trash_offset', 0); + \OC_Util::tearDownFS(); + } + + /** + * Act on behalf on trash item owner + */ + protected function setupFS(string $user): bool { + \OC_Util::tearDownFS(); + \OC_Util::setupFS($user); + + // Check if this user has a trashbin directory + $view = new View('/' . $user); + if (!$view->is_dir('/files_trashbin/files')) { + return false; + } + + return true; + } +} diff --git a/apps/files_trashbin/lib/Capabilities.php b/apps/files_trashbin/lib/Capabilities.php new file mode 100644 index 00000000000..53c17a475ff --- /dev/null +++ b/apps/files_trashbin/lib/Capabilities.php @@ -0,0 +1,38 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Trashbin; + +use OCA\Files_Trashbin\Service\ConfigService; +use OCP\Capabilities\ICapability; + +/** + * Class Capabilities + * + * @package OCA\Files_Trashbin + */ +class Capabilities implements ICapability { + + /** + * Return this classes capabilities + * + * @return array{ + * files: array{ + * undelete: bool, + * delete_from_trash: bool + * } + * } + */ + public function getCapabilities() { + return [ + 'files' => [ + 'undelete' => true, + 'delete_from_trash' => ConfigService::getDeleteFromTrashEnabled(), + ] + ]; + } +} diff --git a/apps/files_trashbin/lib/Command/CleanUp.php b/apps/files_trashbin/lib/Command/CleanUp.php new file mode 100644 index 00000000000..e9b4fa8ae60 --- /dev/null +++ b/apps/files_trashbin/lib/Command/CleanUp.php @@ -0,0 +1,118 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Trashbin\Command; + +use OCP\Files\IRootFolder; +use OCP\IDBConnection; +use OCP\IUserBackend; +use OCP\IUserManager; +use OCP\Util; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\InvalidOptionException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class CleanUp extends Command { + + public function __construct( + protected IRootFolder $rootFolder, + protected IUserManager $userManager, + protected IDBConnection $dbConnection, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('trashbin:cleanup') + ->setDescription('Remove deleted files') + ->addArgument( + 'user_id', + InputArgument::OPTIONAL | InputArgument::IS_ARRAY, + 'remove deleted files of the given user(s)' + ) + ->addOption( + 'all-users', + null, + InputOption::VALUE_NONE, + 'run action on all users' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $users = $input->getArgument('user_id'); + $verbose = $input->getOption('verbose'); + if ((!empty($users)) and ($input->getOption('all-users'))) { + throw new InvalidOptionException('Either specify a user_id or --all-users'); + } elseif (!empty($users)) { + foreach ($users as $user) { + if ($this->userManager->userExists($user)) { + $output->writeln("Remove deleted files of <info>$user</info>"); + $this->removeDeletedFiles($user, $output, $verbose); + } else { + $output->writeln("<error>Unknown user $user</error>"); + return 1; + } + } + } elseif ($input->getOption('all-users')) { + $output->writeln('Remove deleted files for all users'); + foreach ($this->userManager->getBackends() as $backend) { + $name = get_class($backend); + if ($backend instanceof IUserBackend) { + $name = $backend->getBackendName(); + } + $output->writeln("Remove deleted files for users on backend <info>$name</info>"); + $limit = 500; + $offset = 0; + do { + $users = $backend->getUsers('', $limit, $offset); + foreach ($users as $user) { + $output->writeln(" <info>$user</info>"); + $this->removeDeletedFiles($user, $output, $verbose); + } + $offset += $limit; + } while (count($users) >= $limit); + } + } else { + throw new InvalidOptionException('Either specify a user_id or --all-users'); + } + return 0; + } + + /** + * remove deleted files for the given user + */ + protected function removeDeletedFiles(string $uid, OutputInterface $output, bool $verbose): void { + \OC_Util::tearDownFS(); + \OC_Util::setupFS($uid); + $path = '/' . $uid . '/files_trashbin'; + if ($this->rootFolder->nodeExists($path)) { + $node = $this->rootFolder->get($path); + + if ($verbose) { + $output->writeln('Deleting <info>' . Util::humanFileSize($node->getSize()) . "</info> in trash for <info>$uid</info>."); + } + $node->delete(); + if ($this->rootFolder->nodeExists($path)) { + $output->writeln('<error>Trash folder sill exists after attempting to delete it</error>'); + return; + } + $query = $this->dbConnection->getQueryBuilder(); + $query->delete('files_trash') + ->where($query->expr()->eq('user', $query->createParameter('uid'))) + ->setParameter('uid', $uid); + $query->executeStatement(); + } else { + if ($verbose) { + $output->writeln("No trash found for <info>$uid</info>"); + } + } + } +} diff --git a/apps/files_trashbin/lib/Command/Expire.php b/apps/files_trashbin/lib/Command/Expire.php new file mode 100644 index 00000000000..73a42cd4749 --- /dev/null +++ b/apps/files_trashbin/lib/Command/Expire.php @@ -0,0 +1,39 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Trashbin\Command; + +use OC\Command\FileAccess; +use OCA\Files_Trashbin\Trashbin; +use OCP\Command\ICommand; +use OCP\IUserManager; +use OCP\Server; + +class Expire implements ICommand { + use FileAccess; + + /** + * @param string $user + */ + public function __construct( + private $user, + ) { + } + + public function handle() { + $userManager = Server::get(IUserManager::class); + if (!$userManager->userExists($this->user)) { + // User has been deleted already + return; + } + + \OC_Util::tearDownFS(); + \OC_Util::setupFS($this->user); + Trashbin::expire($this->user); + \OC_Util::tearDownFS(); + } +} diff --git a/apps/files_trashbin/lib/Command/ExpireTrash.php b/apps/files_trashbin/lib/Command/ExpireTrash.php new file mode 100644 index 00000000000..422d8379984 --- /dev/null +++ b/apps/files_trashbin/lib/Command/ExpireTrash.php @@ -0,0 +1,111 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2019-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud GmbH. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Trashbin\Command; + +use OC\Files\View; +use OCA\Files_Trashbin\Expiration; +use OCA\Files_Trashbin\Trashbin; +use OCP\IUser; +use OCP\IUserManager; +use Psr\Log\LoggerInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class ExpireTrash extends Command { + + /** + * @param IUserManager|null $userManager + * @param Expiration|null $expiration + */ + public function __construct( + private LoggerInterface $logger, + private ?IUserManager $userManager = null, + private ?Expiration $expiration = null, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('trashbin:expire') + ->setDescription('Expires the users trashbin') + ->addArgument( + 'user_id', + InputArgument::OPTIONAL | InputArgument::IS_ARRAY, + 'expires the trashbin of the given user(s), if no user is given the trash for all users will be expired' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $minAge = $this->expiration->getMinAgeAsTimestamp(); + $maxAge = $this->expiration->getMaxAgeAsTimestamp(); + if ($minAge === false && $maxAge === false) { + $output->writeln('Auto expiration is configured - keeps files and folders in the trash bin for 30 days and automatically deletes anytime after that if space is needed (note: files may not be deleted if space is not needed)'); + return 1; + } + + $users = $input->getArgument('user_id'); + if (!empty($users)) { + foreach ($users as $user) { + if ($this->userManager->userExists($user)) { + $output->writeln("Remove deleted files of <info>$user</info>"); + $userObject = $this->userManager->get($user); + $this->expireTrashForUser($userObject); + } else { + $output->writeln("<error>Unknown user $user</error>"); + return 1; + } + } + } else { + $p = new ProgressBar($output); + $p->start(); + + $users = $this->userManager->getSeenUsers(); + foreach ($users as $user) { + $p->advance(); + $this->expireTrashForUser($user); + } + $p->finish(); + $output->writeln(''); + } + return 0; + } + + public function expireTrashForUser(IUser $user) { + try { + $uid = $user->getUID(); + if (!$this->setupFS($uid)) { + return; + } + Trashbin::expire($uid); + } catch (\Throwable $e) { + $this->logger->error('Error while expiring trashbin for user ' . $user->getUID(), ['exception' => $e]); + } + } + + /** + * Act on behalf on trash item owner + * @param string $user + * @return boolean + */ + protected function setupFS($user) { + \OC_Util::tearDownFS(); + \OC_Util::setupFS($user); + + // Check if this user has a trashbin directory + $view = new View('/' . $user); + if (!$view->is_dir('/files_trashbin/files')) { + return false; + } + + return true; + } +} diff --git a/apps/files_trashbin/lib/Command/RestoreAllFiles.php b/apps/files_trashbin/lib/Command/RestoreAllFiles.php new file mode 100644 index 00000000000..ce31f759c0e --- /dev/null +++ b/apps/files_trashbin/lib/Command/RestoreAllFiles.php @@ -0,0 +1,273 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Trashbin\Command; + +use OC\Core\Command\Base; +use OCA\Files_Trashbin\Trash\ITrashManager; +use OCA\Files_Trashbin\Trash\TrashItem; +use OCP\Files\IRootFolder; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\IUserBackend; +use OCP\IUserManager; +use OCP\L10N\IFactory; +use Symfony\Component\Console\Exception\InvalidOptionException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +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 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 <info>$user</info>"); + $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 <info>$name</info>"); + $limit = 500; + $offset = 0; + do { + $users = $backend->getUsers('', $limit, $offset); + foreach ($users as $user) { + $output->writeln("<info>$user</info>"); + $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("<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 matching the given filters'); + return; + } + $prepMsg = $dryRun ? 'Would restore' : 'Preparing to restore'; + $output->writeln("$prepMsg <info>$trashCount</info> 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 <info>$filename</info> originally deleted at <info>$humanTime</info> to <info>/$location</info>"); + continue; + } + + $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>'); + } + + 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 !== 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; + } +} diff --git a/apps/files_trashbin/lib/Command/Size.php b/apps/files_trashbin/lib/Command/Size.php new file mode 100644 index 00000000000..9c19d4d92b3 --- /dev/null +++ b/apps/files_trashbin/lib/Command/Size.php @@ -0,0 +1,124 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Command; + +use OC\Core\Command\Base; +use OCP\Command\IBus; +use OCP\IConfig; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Util; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Size extends Base { + public function __construct( + private IConfig $config, + private IUserManager $userManager, + private IBus $commandBus, + ) { + parent::__construct(); + } + + protected function configure() { + parent::configure(); + $this + ->setName('trashbin:size') + ->setDescription('Configure the target trashbin size') + ->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'configure the target size for the provided user, if no user is given the default size is configured') + ->addArgument( + 'size', + InputArgument::OPTIONAL, + 'the target size for the trashbin, if not provided the current trashbin size will be returned' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = $input->getOption('user'); + $size = $input->getArgument('size'); + + if ($size) { + $parsedSize = Util::computerFileSize($size); + if ($parsedSize === false) { + $output->writeln('<error>Failed to parse input size</error>'); + return -1; + } + if ($user) { + $this->config->setUserValue($user, 'files_trashbin', 'trashbin_size', (string)$parsedSize); + $this->commandBus->push(new Expire($user)); + } else { + $this->config->setAppValue('files_trashbin', 'trashbin_size', (string)$parsedSize); + $output->writeln('<info>Warning: changing the default trashbin size will automatically trigger cleanup of existing trashbins,</info>'); + $output->writeln('<info>a users trashbin can exceed the configured size until they move a new file to the trashbin.</info>'); + } + } else { + $this->printTrashbinSize($input, $output, $user); + } + + return 0; + } + + private function printTrashbinSize(InputInterface $input, OutputInterface $output, ?string $user) { + $globalSize = (int)$this->config->getAppValue('files_trashbin', 'trashbin_size', '-1'); + if ($globalSize < 0) { + $globalHumanSize = 'default (50% of available space)'; + } else { + $globalHumanSize = Util::humanFileSize($globalSize); + } + + if ($user) { + $userSize = (int)$this->config->getUserValue($user, 'files_trashbin', 'trashbin_size', '-1'); + + if ($userSize < 0) { + $userHumanSize = ($globalSize < 0) ? $globalHumanSize : "default($globalHumanSize)"; + } else { + $userHumanSize = Util::humanFileSize($userSize); + } + + if ($input->getOption('output') == self::OUTPUT_FORMAT_PLAIN) { + $output->writeln($userHumanSize); + } else { + $userValue = ($userSize < 0) ? 'default' : $userSize; + $globalValue = ($globalSize < 0) ? 'default' : $globalSize; + $this->writeArrayInOutputFormat($input, $output, [ + 'user_size' => $userValue, + 'global_size' => $globalValue, + 'effective_size' => ($userSize < 0) ? $globalValue : $userValue, + ]); + } + } else { + $users = []; + $this->userManager->callForSeenUsers(function (IUser $user) use (&$users): void { + $users[] = $user->getUID(); + }); + $userValues = $this->config->getUserValueForUsers('files_trashbin', 'trashbin_size', $users); + + if ($input->getOption('output') == self::OUTPUT_FORMAT_PLAIN) { + $output->writeln("Default size: $globalHumanSize"); + $output->writeln(''); + if (count($userValues)) { + $output->writeln('Per-user sizes:'); + $this->writeArrayInOutputFormat($input, $output, array_map(function ($size) { + return Util::humanFileSize($size); + }, $userValues)); + } else { + $output->writeln('No per-user sizes configured'); + } + } else { + $globalValue = ($globalSize < 0) ? 'default' : $globalSize; + $this->writeArrayInOutputFormat($input, $output, [ + 'global_size' => $globalValue, + 'user_sizes' => $userValues, + ]); + } + } + } +} diff --git a/apps/files_trashbin/lib/Controller/PreviewController.php b/apps/files_trashbin/lib/Controller/PreviewController.php new file mode 100644 index 00000000000..a4e911d88ef --- /dev/null +++ b/apps/files_trashbin/lib/Controller/PreviewController.php @@ -0,0 +1,103 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Controller; + +use OCA\Files_Trashbin\Trash\ITrashManager; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Files\Folder; +use OCP\Files\IMimeTypeDetector; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\IPreview; +use OCP\IRequest; +use OCP\IUserSession; + +#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] +class PreviewController extends Controller { + public function __construct( + string $appName, + IRequest $request, + private IRootFolder $rootFolder, + private ITrashManager $trashManager, + private IUserSession $userSession, + private IMimeTypeDetector $mimeTypeDetector, + private IPreview $previewManager, + private ITimeFactory $time, + ) { + parent::__construct($appName, $request); + } + + /** + * Get the preview for a file + * + * @param int $fileId ID of the file + * @param int $x Width of the preview + * @param int $y Height of the preview + * @param bool $a Whether to not crop the preview + * + * @return Http\FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, list<empty>, array{}> + * + * 200: Preview returned + * 400: Getting preview is not possible + * 404: Preview not found + */ + #[NoAdminRequired] + #[NoCSRFRequired] + public function getPreview( + int $fileId = -1, + int $x = 32, + int $y = 32, + bool $a = false, + ) { + if ($fileId === -1 || $x === 0 || $y === 0) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + try { + $file = $this->trashManager->getTrashNodeById($this->userSession->getUser(), $fileId); + if ($file === null) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } + if ($file instanceof Folder) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + + $pathParts = pathinfo($file->getName()); + $extension = $pathParts['extension'] ?? ''; + $fileName = $pathParts['filename']; + /* + * Files in the root of the trashbin are timetamped. + * So we have to strip that in order to properly detect the mimetype of the file. + */ + if (preg_match('/d\d+/', $extension)) { + $mimeType = $this->mimeTypeDetector->detectPath($fileName); + } else { + $mimeType = $this->mimeTypeDetector->detectPath($file->getName()); + } + + $f = $this->previewManager->getPreview($file, $x, $y, !$a, IPreview::MODE_FILL, $mimeType); + $response = new FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]); + + // Cache previews for 24H + $response->cacheFor(3600 * 24); + return $response; + } catch (NotFoundException $e) { + return new DataResponse([], Http::STATUS_NOT_FOUND); + } catch (\InvalidArgumentException $e) { + return new DataResponse([], Http::STATUS_BAD_REQUEST); + } + } +} diff --git a/apps/files_trashbin/lib/Events/BeforeNodeRestoredEvent.php b/apps/files_trashbin/lib/Events/BeforeNodeRestoredEvent.php new file mode 100644 index 00000000000..0bc6b37c35b --- /dev/null +++ b/apps/files_trashbin/lib/Events/BeforeNodeRestoredEvent.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Events; + +use Exception; +use OCP\Files\Events\Node\AbstractNodesEvent; +use OCP\Files\Node; + +/** + * @since 28.0.0 + */ +class BeforeNodeRestoredEvent extends AbstractNodesEvent { + public function __construct( + Node $source, + Node $target, + private bool &$run, + ) { + parent::__construct($source, $target); + } + + /** + * @return never + */ + public function abortOperation(?\Throwable $ex = null) { + $this->stopPropagation(); + $this->run = false; + if ($ex !== null) { + throw $ex; + } else { + throw new Exception('Operation aborted'); + } + } +} diff --git a/apps/files_trashbin/lib/Events/MoveToTrashEvent.php b/apps/files_trashbin/lib/Events/MoveToTrashEvent.php new file mode 100644 index 00000000000..0d776b606b1 --- /dev/null +++ b/apps/files_trashbin/lib/Events/MoveToTrashEvent.php @@ -0,0 +1,55 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Events; + +use OCP\EventDispatcher\Event; +use OCP\Files\Node; + +/** + * Class MoveToTrashEvent + * + * Event to allow other apps to disable the trash bin for specific files + * + * @package OCA\Files_Trashbin\Events + * @since 28.0.0 Dispatched as a typed event + */ +class MoveToTrashEvent extends Event { + + /** @var bool */ + private $moveToTrashBin; + + public function __construct( + private Node $node, + ) { + $this->moveToTrashBin = true; + } + + /** + * get Node which will be deleted + * + * @return Node + */ + public function getNode() { + return $this->node; + } + + /** + * disable trash bin for this operation + */ + public function disableTrashBin() { + $this->moveToTrashBin = false; + } + + /** + * should the file be moved to the trash bin? + * + * @return bool + */ + public function shouldMoveToTrashBin() { + return $this->moveToTrashBin; + } +} diff --git a/apps/files_trashbin/lib/Events/NodeRestoredEvent.php b/apps/files_trashbin/lib/Events/NodeRestoredEvent.php new file mode 100644 index 00000000000..4278d6cfe95 --- /dev/null +++ b/apps/files_trashbin/lib/Events/NodeRestoredEvent.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Events; + +use OCP\Files\Events\Node\AbstractNodesEvent; +use OCP\Files\Node; + +/** + * @since 28.0.0 + */ +class NodeRestoredEvent extends AbstractNodesEvent { + public function __construct(Node $source, Node $target) { + parent::__construct($source, $target); + } +} diff --git a/apps/files_trashbin/lib/Exceptions/CopyRecursiveException.php b/apps/files_trashbin/lib/Exceptions/CopyRecursiveException.php new file mode 100644 index 00000000000..3ea1293e5d7 --- /dev/null +++ b/apps/files_trashbin/lib/Exceptions/CopyRecursiveException.php @@ -0,0 +1,10 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Trashbin\Exceptions; + +class CopyRecursiveException extends \Exception { +} diff --git a/apps/files_trashbin/lib/expiration.php b/apps/files_trashbin/lib/Expiration.php index e5a37b88b49..0bbe39a9314 100644 --- a/apps/files_trashbin/lib/expiration.php +++ b/apps/files_trashbin/lib/Expiration.php @@ -1,38 +1,20 @@ <?php + /** - * @author Lukas Reschke <lukas@owncloud.com> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\Files_Trashbin; -use \OCP\IConfig; -use \OCP\AppFramework\Utility\ITimeFactory; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; class Expiration { // how long do we keep files in the trash bin if no other value is defined in the config file (unit: days) - const DEFAULT_RETENTION_OBLIGATION = 30; - const NO_OBLIGATION = -1; - - /** @var ITimeFactory */ - private $timeFactory; + public const DEFAULT_RETENTION_OBLIGATION = 30; + public const NO_OBLIGATION = -1; /** @var string */ private $retentionObligation; @@ -46,9 +28,15 @@ class Expiration { /** @var bool */ private $canPurgeToSaveSpace; - public function __construct(IConfig $config,ITimeFactory $timeFactory){ - $this->timeFactory = $timeFactory; - $this->retentionObligation = $config->getSystemValue('trashbin_retention_obligation', 'auto'); + public function __construct( + IConfig $config, + private ITimeFactory $timeFactory, + ) { + $this->setRetentionObligation($config->getSystemValue('trashbin_retention_obligation', 'auto')); + } + + public function setRetentionObligation(string $obligation) { + $this->retentionObligation = $obligation; if ($this->retentionObligation !== 'disabled') { $this->parseRetentionObligation(); @@ -59,7 +47,7 @@ class Expiration { * Is trashbin expiration enabled * @return bool */ - public function isEnabled(){ + public function isEnabled() { return $this->retentionObligation !== 'disabled'; } @@ -69,7 +57,7 @@ class Expiration { * @param bool $quotaExceeded * @return bool */ - public function isExpired($timestamp, $quotaExceeded = false){ + public function isExpired($timestamp, $quotaExceeded = false) { // No expiration if disabled if (!$this->isEnabled()) { return false; @@ -83,7 +71,7 @@ class Expiration { $time = $this->timeFactory->getTime(); // Never expire dates in future e.g. misconfiguration or negative time // adjustment - if ($time<$timestamp) { + if ($time < $timestamp) { return false; } @@ -107,6 +95,20 @@ class Expiration { } /** + * Get minimal retention obligation as a timestamp + * + * @return int|false + */ + public function getMinAgeAsTimestamp() { + $minAge = false; + if ($this->isEnabled() && $this->minAge !== self::NO_OBLIGATION) { + $time = $this->timeFactory->getTime(); + $minAge = $time - ($this->minAge * 86400); + } + return $minAge; + } + + /** * @return bool|int */ public function getMaxAgeAsTimestamp() { @@ -118,7 +120,7 @@ class Expiration { return $maxAge; } - private function parseRetentionObligation(){ + private function parseRetentionObligation() { $splitValues = explode(',', $this->retentionObligation); if (!isset($splitValues[0])) { $minValue = self::DEFAULT_RETENTION_OBLIGATION; @@ -141,13 +143,13 @@ class Expiration { $this->canPurgeToSaveSpace = true; } elseif ($minValue !== 'auto' && $maxValue === 'auto') { // Keep for X days but delete anytime if space needed - $this->minAge = intval($minValue); + $this->minAge = (int)$minValue; $this->maxAge = self::NO_OBLIGATION; $this->canPurgeToSaveSpace = true; } elseif ($minValue === 'auto' && $maxValue !== 'auto') { // Delete anytime if space needed, Delete all older than max automatically $this->minAge = self::NO_OBLIGATION; - $this->maxAge = intval($maxValue); + $this->maxAge = (int)$maxValue; $this->canPurgeToSaveSpace = true; } elseif ($minValue !== 'auto' && $maxValue !== 'auto') { // Delete all older than max OR older than min if space needed @@ -157,8 +159,8 @@ class Expiration { $maxValue = $minValue; } - $this->minAge = intval($minValue); - $this->maxAge = intval($maxValue); + $this->minAge = (int)$minValue; + $this->maxAge = (int)$maxValue; $this->canPurgeToSaveSpace = false; } } diff --git a/apps/files_trashbin/lib/Helper.php b/apps/files_trashbin/lib/Helper.php new file mode 100644 index 00000000000..746832e9280 --- /dev/null +++ b/apps/files_trashbin/lib/Helper.php @@ -0,0 +1,110 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Trashbin; + +use OC\Files\FileInfo; +use OC\Files\View; +use OCP\Constants; +use OCP\Files\Cache\ICacheEntry; +use OCP\Files\IMimeTypeDetector; +use OCP\Server; + +class Helper { + /** + * Retrieves the contents of a trash bin directory. + * + * @param string $dir path to the directory inside the trashbin + * or empty to retrieve the root of the trashbin + * @param string $user + * @param string $sortAttribute attribute to sort on or empty to disable sorting + * @param bool $sortDescending true for descending sort, false otherwise + * @return \OCP\Files\FileInfo[] + */ + public static function getTrashFiles($dir, $user, $sortAttribute = '', $sortDescending = false) { + $result = []; + $timestamp = null; + + $view = new View('/' . $user . '/files_trashbin/files'); + + if (ltrim($dir, '/') !== '' && !$view->is_dir($dir)) { + throw new \Exception('Directory does not exists'); + } + + $mount = $view->getMount($dir); + $storage = $mount->getStorage(); + $absoluteDir = $view->getAbsolutePath($dir); + $internalPath = $mount->getInternalPath($absoluteDir); + + $extraData = Trashbin::getExtraData($user); + $dirContent = $storage->getCache()->getFolderContents($mount->getInternalPath($view->getAbsolutePath($dir))); + foreach ($dirContent as $entry) { + $entryName = $entry->getName(); + $name = $entryName; + if ($dir === '' || $dir === '/') { + $pathparts = pathinfo($entryName); + $timestamp = substr($pathparts['extension'], 1); + $name = $pathparts['filename']; + } elseif ($timestamp === null) { + // for subfolders we need to calculate the timestamp only once + $parts = explode('/', ltrim($dir, '/')); + $timestamp = substr(pathinfo($parts[0], PATHINFO_EXTENSION), 1); + } + $originalPath = ''; + $originalName = substr($entryName, 0, -strlen($timestamp) - 2); + if (isset($extraData[$originalName][$timestamp]['location'])) { + $originalPath = $extraData[$originalName][$timestamp]['location']; + if (substr($originalPath, -1) === '/') { + $originalPath = substr($originalPath, 0, -1); + } + } + $type = $entry->getMimeType() === ICacheEntry::DIRECTORY_MIMETYPE ? 'dir' : 'file'; + $i = [ + 'name' => $name, + 'mtime' => $timestamp, + 'mimetype' => $type === 'dir' ? 'httpd/unix-directory' : Server::get(IMimeTypeDetector::class)->detectPath($name), + 'type' => $type, + 'directory' => ($dir === '/') ? '' : $dir, + 'size' => $entry->getSize(), + 'etag' => '', + 'permissions' => Constants::PERMISSION_ALL - Constants::PERMISSION_SHARE, + 'fileid' => $entry->getId(), + ]; + if ($originalPath) { + if ($originalPath !== '.') { + $i['extraData'] = $originalPath . '/' . $originalName; + } else { + $i['extraData'] = $originalName; + } + } + $i['deletedBy'] = $extraData[$originalName][$timestamp]['deletedBy'] ?? null; + $result[] = new FileInfo($absoluteDir . '/' . $i['name'], $storage, $internalPath . '/' . $i['name'], $i, $mount); + } + + if ($sortAttribute !== '') { + return \OCA\Files\Helper::sortFiles($result, $sortAttribute, $sortDescending); + } + return $result; + } + + /** + * Format file infos for JSON + * + * @param \OCP\Files\FileInfo[] $fileInfos file infos + */ + public static function formatFileInfos($fileInfos) { + $files = []; + foreach ($fileInfos as $i) { + $entry = \OCA\Files\Helper::formatFileInfo($i); + $entry['id'] = $i->getId(); + $entry['etag'] = $entry['mtime']; // add fake etag, it is only needed to identify the preview image + $entry['permissions'] = Constants::PERMISSION_READ; + $files[] = $entry; + } + return $files; + } +} diff --git a/apps/files_trashbin/lib/Listener/EventListener.php b/apps/files_trashbin/lib/Listener/EventListener.php new file mode 100644 index 00000000000..63ecc9c81f7 --- /dev/null +++ b/apps/files_trashbin/lib/Listener/EventListener.php @@ -0,0 +1,44 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +namespace OCA\Files_Trashbin\Listener; + +use OCA\Files_Trashbin\Storage; +use OCA\Files_Trashbin\Trashbin; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\BeforeFileSystemSetupEvent; +use OCP\Files\Events\Node\NodeWrittenEvent; +use OCP\User\Events\BeforeUserDeletedEvent; + +/** @template-implements IEventListener<NodeWrittenEvent|BeforeUserDeletedEvent|BeforeFileSystemSetupEvent> */ +class EventListener implements IEventListener { + public function __construct( + private ?string $userId = null, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof NodeWrittenEvent) { + // Resize trash + if (!empty($this->userId)) { + Trashbin::resizeTrash($this->userId); + } + } + + // Clean up user specific settings if user gets deleted + if ($event instanceof BeforeUserDeletedEvent) { + Trashbin::deleteUser($event->getUser()->getUID()); + } + + if ($event instanceof BeforeFileSystemSetupEvent) { + Storage::setupStorage(); + } + } +} diff --git a/apps/files_trashbin/lib/Listeners/BeforeTemplateRendered.php b/apps/files_trashbin/lib/Listeners/BeforeTemplateRendered.php new file mode 100644 index 00000000000..d62618583f7 --- /dev/null +++ b/apps/files_trashbin/lib/Listeners/BeforeTemplateRendered.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_Trashbin\Listeners; + +use OCA\Files_Sharing\Event\BeforeTemplateRenderedEvent; +use OCA\Files_Trashbin\Service\ConfigService; +use OCP\AppFramework\Services\IInitialState; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** @template-implements IEventListener<BeforeTemplateRenderedEvent> */ +class BeforeTemplateRendered implements IEventListener { + public function __construct( + private IInitialState $initialState, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof BeforeTemplateRenderedEvent)) { + return; + } + + ConfigService::injectInitialState($this->initialState); + } +} diff --git a/apps/files_trashbin/lib/Listeners/LoadAdditionalScripts.php b/apps/files_trashbin/lib/Listeners/LoadAdditionalScripts.php new file mode 100644 index 00000000000..7940b934ace --- /dev/null +++ b/apps/files_trashbin/lib/Listeners/LoadAdditionalScripts.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Listeners; + +use OCA\Files\Event\LoadAdditionalScriptsEvent; +use OCA\Files_Trashbin\AppInfo\Application; +use OCA\Files_Trashbin\Service\ConfigService; +use OCP\AppFramework\Services\IInitialState; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Util; + +/** @template-implements IEventListener<LoadAdditionalScriptsEvent> */ +class LoadAdditionalScripts implements IEventListener { + public function __construct( + private IInitialState $initialState, + ) { + } + + public function handle(Event $event): void { + if (!($event instanceof LoadAdditionalScriptsEvent)) { + return; + } + + Util::addInitScript(Application::APP_ID, 'init'); + + ConfigService::injectInitialState($this->initialState); + } +} diff --git a/apps/files_trashbin/lib/Listeners/SyncLivePhotosListener.php b/apps/files_trashbin/lib/Listeners/SyncLivePhotosListener.php new file mode 100644 index 00000000000..2cb3a94aa1d --- /dev/null +++ b/apps/files_trashbin/lib/Listeners/SyncLivePhotosListener.php @@ -0,0 +1,132 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_Trashbin\Listeners; + +use OCA\Files\Service\LivePhotosService; +use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent; +use OCA\Files_Trashbin\Trash\ITrashItem; +use OCA\Files_Trashbin\Trash\ITrashManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Folder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\IUserSession; + +/** + * @template-implements IEventListener<BeforeNodeRestoredEvent> + */ +class SyncLivePhotosListener implements IEventListener { + /** @var Array<int, bool> */ + private array $pendingRestores = []; + + public function __construct( + private ?IUserSession $userSession, + private ITrashManager $trashManager, + private LivePhotosService $livePhotosService, + ) { + } + + public function handle(Event $event): void { + if ($this->userSession === null) { + return; + } + + /** @var BeforeNodeRestoredEvent $event */ + $peerFileId = $this->livePhotosService->getLivePhotoPeerId($event->getSource()->getId()); + + if ($peerFileId === null) { + return; // Not a live photo. + } + + // Check the user's trashbin. + $user = $this->userSession->getUser(); + if ($user === null) { + return; + } + + $peerFile = $this->trashManager->getTrashNodeById($user, $peerFileId); + + if ($peerFile === null) { + return; // Peer file not found. + } + + $this->handleRestore($event, $peerFile); + } + + /** + * During restore event, we trigger another recursive restore on the peer file. + * Restore operations on the .mov file directly are currently blocked. + * The event listener being singleton, we can store the current state + * of pending restores inside the 'pendingRestores' property, + * to prevent infinite recursivity. + */ + private function handleRestore(BeforeNodeRestoredEvent $event, Node $peerFile): void { + $sourceFile = $event->getSource(); + + if ($sourceFile->getMimetype() === 'video/quicktime') { + if (isset($this->pendingRestores[$peerFile->getId()])) { + unset($this->pendingRestores[$peerFile->getId()]); + return; + } else { + $event->abortOperation(new NotPermittedException('Cannot restore the video part of a live photo')); + } + } else { + $user = $this->userSession?->getUser(); + if ($user === null) { + return; + } + + $peerTrashItem = $this->trashManager->getTrashNodeById($user, $peerFile->getId()); + // Peer file is not in the bin, no need to restore it. + if ($peerTrashItem === null) { + return; + } + + $trashRoot = $this->trashManager->listTrashRoot($user); + $trashItem = $this->getTrashItem($trashRoot, $peerFile->getInternalPath()); + + if ($trashItem === null) { + $event->abortOperation(new NotFoundException("Couldn't find peer file in trashbin")); + } + + $this->pendingRestores[$sourceFile->getId()] = true; + try { + $this->trashManager->restoreItem($trashItem); + } catch (\Throwable $ex) { + $event->abortOperation($ex); + } + } + } + + /** + * There is currently no method to restore a file based on its fileId or path. + * So we have to manually find a ITrashItem from the trash item list. + * TODO: This should be replaced by a proper method in the TrashManager. + */ + private function getTrashItem(array $trashFolder, string $path): ?ITrashItem { + foreach ($trashFolder as $trashItem) { + if (str_starts_with($path, 'files_trashbin/files' . $trashItem->getTrashPath())) { + if ($path === 'files_trashbin/files' . $trashItem->getTrashPath()) { + return $trashItem; + } + + if ($trashItem instanceof Folder) { + $node = $this->getTrashItem($trashItem->getDirectoryListing(), $path); + if ($node !== null) { + return $node; + } + } + } + } + + return null; + } +} diff --git a/apps/files_trashbin/lib/Migration/Version1010Date20200630192639.php b/apps/files_trashbin/lib/Migration/Version1010Date20200630192639.php new file mode 100644 index 00000000000..3de908e2d78 --- /dev/null +++ b/apps/files_trashbin/lib/Migration/Version1010Date20200630192639.php @@ -0,0 +1,69 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1010Date20200630192639 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('files_trash')) { + $table = $schema->createTable('files_trash'); + $table->addColumn('auto_id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->addColumn('id', Types::STRING, [ + 'notnull' => true, + 'length' => 250, + 'default' => '', + ]); + $table->addColumn('user', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + 'default' => '', + ]); + $table->addColumn('timestamp', Types::STRING, [ + 'notnull' => true, + 'length' => 12, + 'default' => '', + ]); + $table->addColumn('location', Types::STRING, [ + 'notnull' => true, + 'length' => 512, + 'default' => '', + ]); + $table->addColumn('type', Types::STRING, [ + 'notnull' => false, + 'length' => 4, + ]); + $table->addColumn('mime', Types::STRING, [ + 'notnull' => false, + 'length' => 255, + ]); + $table->setPrimaryKey(['auto_id']); + $table->addIndex(['id'], 'id_index'); + $table->addIndex(['timestamp'], 'timestamp_index'); + $table->addIndex(['user'], 'user_index'); + } + return $schema; + } +} diff --git a/apps/files_trashbin/lib/Migration/Version1020Date20240403003535.php b/apps/files_trashbin/lib/Migration/Version1020Date20240403003535.php new file mode 100644 index 00000000000..3e85edf40b6 --- /dev/null +++ b/apps/files_trashbin/lib/Migration/Version1020Date20240403003535.php @@ -0,0 +1,42 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_Trashbin\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\Attributes\AddColumn; +use OCP\Migration\Attributes\ColumnType; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +#[AddColumn(table: 'files_trash', name: 'deleted_by', type: ColumnType::STRING)] +class Version1020Date20240403003535 extends SimpleMigrationStep { + + /** + * @param Closure(): ISchemaWrapper $schemaClosure + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if (!$schema->hasTable('files_trash')) { + return null; + } + + $table = $schema->getTable('files_trash'); + $table->addColumn('deleted_by', Types::STRING, [ + 'notnull' => false, + 'length' => 64, + ]); + + return $schema; + } +} diff --git a/apps/files_trashbin/lib/Sabre/AbstractTrash.php b/apps/files_trashbin/lib/Sabre/AbstractTrash.php new file mode 100644 index 00000000000..f032395437b --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/AbstractTrash.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +use OCA\Files_Trashbin\Service\ConfigService; +use OCA\Files_Trashbin\Trash\ITrashItem; +use OCA\Files_Trashbin\Trash\ITrashManager; +use OCP\Files\FileInfo; +use OCP\IUser; +use Sabre\DAV\Exception\Forbidden; + +abstract class AbstractTrash implements ITrash { + public function __construct( + protected ITrashManager $trashManager, + protected ITrashItem $data, + ) { + } + + public function getFilename(): string { + return $this->data->getName(); + } + + public function getDeletionTime(): int { + return $this->data->getDeletedTime(); + } + + public function getFileId(): int { + return $this->data->getId(); + } + + public function getFileInfo(): FileInfo { + return $this->data; + } + + /** + * @psalm-suppress ImplementedReturnTypeMismatch \Sabre\DAV\IFile::getSize signature does not support 32bit + * @return int|float + */ + public function getSize(): int|float { + return $this->data->getSize(); + } + + public function getLastModified(): int { + return $this->data->getMtime(); + } + + public function getContentType(): string { + return $this->data->getMimetype(); + } + + public function getETag(): string { + return $this->data->getEtag(); + } + + public function getName(): string { + return $this->data->getName(); + } + + public function getOriginalLocation(): string { + return $this->data->getOriginalLocation(); + } + + public function getTitle(): string { + return $this->data->getTitle(); + } + + public function getDeletedBy(): ?IUser { + return $this->data->getDeletedBy(); + } + + public function delete() { + if (!ConfigService::getDeleteFromTrashEnabled()) { + throw new Forbidden('Not allowed to delete items from the trash bin'); + } + + $this->trashManager->removeItem($this->data); + } + + public function restore(): bool { + $this->trashManager->restoreItem($this->data); + return true; + } +} diff --git a/apps/files_trashbin/lib/Sabre/AbstractTrashFile.php b/apps/files_trashbin/lib/Sabre/AbstractTrashFile.php new file mode 100644 index 00000000000..03014d23669 --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/AbstractTrashFile.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\IFile; + +abstract class AbstractTrashFile extends AbstractTrash implements IFile, ITrash { + public function put($data) { + throw new Forbidden(); + } + + public function setName($name) { + throw new Forbidden(); + } +} diff --git a/apps/files_trashbin/lib/Sabre/AbstractTrashFolder.php b/apps/files_trashbin/lib/Sabre/AbstractTrashFolder.php new file mode 100644 index 00000000000..9e8f67f4db6 --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/AbstractTrashFolder.php @@ -0,0 +1,63 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +use OCA\Files_Trashbin\Trash\ITrashItem; +use OCP\Files\FileInfo; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; + +abstract class AbstractTrashFolder extends AbstractTrash implements ICollection, ITrash { + public function getChildren(): array { + $entries = $this->trashManager->listTrashFolder($this->data); + + $children = array_map(function (ITrashItem $entry) { + if ($entry->getType() === FileInfo::TYPE_FOLDER) { + return new TrashFolderFolder($this->trashManager, $entry); + } + return new TrashFolderFile($this->trashManager, $entry); + }, $entries); + + return $children; + } + + public function getChild($name): ITrash { + $entries = $this->getChildren(); + + foreach ($entries as $entry) { + if ($entry->getName() === $name) { + return $entry; + } + } + + throw new NotFound(); + } + + public function childExists($name): bool { + try { + $this->getChild($name); + return true; + } catch (NotFound $e) { + return false; + } + } + + public function setName($name) { + throw new Forbidden(); + } + + public function createFile($name, $data = null) { + throw new Forbidden(); + } + + public function createDirectory($name) { + throw new Forbidden(); + } +} diff --git a/apps/files_trashbin/lib/Sabre/ITrash.php b/apps/files_trashbin/lib/Sabre/ITrash.php new file mode 100644 index 00000000000..f37e1ccd9c3 --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/ITrash.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +use OCP\Files\FileInfo; +use OCP\IUser; + +interface ITrash { + public function restore(): bool; + + public function getFilename(): string; + + public function getOriginalLocation(): string; + + public function getTitle(): string; + + public function getDeletionTime(): int; + + public function getDeletedBy(): ?IUser; + + public function getSize(): int|float; + + public function getFileId(): int; + + public function getFileInfo(): FileInfo; +} diff --git a/apps/files_trashbin/lib/Sabre/RestoreFolder.php b/apps/files_trashbin/lib/Sabre/RestoreFolder.php new file mode 100644 index 00000000000..781a28bbc25 --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/RestoreFolder.php @@ -0,0 +1,60 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\ICollection; +use Sabre\DAV\IMoveTarget; +use Sabre\DAV\INode; + +class RestoreFolder implements ICollection, IMoveTarget { + public function createFile($name, $data = null) { + throw new Forbidden(); + } + + public function createDirectory($name) { + throw new Forbidden(); + } + + public function getChild($name) { + return null; + } + + public function delete() { + throw new Forbidden(); + } + + public function getName() { + return 'restore'; + } + + public function setName($name) { + throw new Forbidden(); + } + + public function getLastModified(): int { + return 0; + } + + public function getChildren(): array { + return []; + } + + public function childExists($name): bool { + return false; + } + + public function moveInto($targetName, $sourcePath, INode $sourceNode): bool { + if (!($sourceNode instanceof ITrash)) { + return false; + } + + return $sourceNode->restore(); + } +} diff --git a/apps/files_trashbin/lib/Sabre/RootCollection.php b/apps/files_trashbin/lib/Sabre/RootCollection.php new file mode 100644 index 00000000000..8886dae0895 --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/RootCollection.php @@ -0,0 +1,51 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +use OCA\Files_Trashbin\Trash\ITrashManager; +use OCP\IConfig; +use OCP\IUserSession; +use OCP\Server; +use Sabre\DAV\INode; +use Sabre\DAVACL\AbstractPrincipalCollection; +use Sabre\DAVACL\PrincipalBackend; + +class RootCollection extends AbstractPrincipalCollection { + public function __construct( + private ITrashManager $trashManager, + PrincipalBackend\BackendInterface $principalBackend, + IConfig $config, + ) { + parent::__construct($principalBackend, 'principals/users'); + $this->disableListing = !$config->getSystemValue('debug', false); + } + + /** + * This method returns a node for a principal. + * + * The passed array contains principal information, and is guaranteed to + * at least contain a uri item. Other properties may or may not be + * supplied by the authentication backend. + * + * @param array $principalInfo + * @return INode + */ + public function getChildForPrincipal(array $principalInfo): TrashHome { + [, $name] = \Sabre\Uri\split($principalInfo['uri']); + $user = Server::get(IUserSession::class)->getUser(); + if (is_null($user) || $name !== $user->getUID()) { + throw new \Sabre\DAV\Exception\Forbidden(); + } + return new TrashHome($principalInfo, $this->trashManager, $user); + } + + public function getName(): string { + return 'trashbin'; + } +} diff --git a/apps/files_trashbin/lib/Sabre/TrashFile.php b/apps/files_trashbin/lib/Sabre/TrashFile.php new file mode 100644 index 00000000000..29bcde769d9 --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/TrashFile.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +use OCA\Files_Trashbin\Trashbin; + +class TrashFile extends AbstractTrashFile { + public function get() { + return $this->data->getStorage()->fopen(Trashbin::getTrashFilename($this->data->getInternalPath(), $this->getDeletionTime()), 'rb'); + } + + public function getName(): string { + return Trashbin::getTrashFilename($this->data->getName(), $this->getDeletionTime()); + } +} diff --git a/apps/files_trashbin/lib/Sabre/TrashFolder.php b/apps/files_trashbin/lib/Sabre/TrashFolder.php new file mode 100644 index 00000000000..e1c495bf08e --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/TrashFolder.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +use OCA\Files_Trashbin\Trashbin; + +class TrashFolder extends AbstractTrashFolder { + public function getName(): string { + return Trashbin::getTrashFilename($this->data->getName(), $this->getDeletionTime()); + } +} diff --git a/apps/files_trashbin/lib/Sabre/TrashFolderFile.php b/apps/files_trashbin/lib/Sabre/TrashFolderFile.php new file mode 100644 index 00000000000..37e70b717ae --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/TrashFolderFile.php @@ -0,0 +1,15 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +class TrashFolderFile extends AbstractTrashFile { + public function get() { + return $this->data->getStorage()->fopen($this->data->getInternalPath(), 'rb'); + } +} diff --git a/apps/files_trashbin/lib/Sabre/TrashFolderFolder.php b/apps/files_trashbin/lib/Sabre/TrashFolderFolder.php new file mode 100644 index 00000000000..1a5cb98e114 --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/TrashFolderFolder.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +class TrashFolderFolder extends AbstractTrashFolder { +} diff --git a/apps/files_trashbin/lib/Sabre/TrashHome.php b/apps/files_trashbin/lib/Sabre/TrashHome.php new file mode 100644 index 00000000000..fc291c76f17 --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/TrashHome.php @@ -0,0 +1,71 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +use OCA\Files_Trashbin\Trash\ITrashManager; +use OCP\IUser; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; + +class TrashHome implements ICollection { + public function __construct( + private array $principalInfo, + private ITrashManager $trashManager, + private IUser $user, + ) { + } + + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + [, $name] = \Sabre\Uri\split($this->principalInfo['uri']); + return $name; + } + + public function setName($name) { + throw new Forbidden('Permission denied to rename this trashbin'); + } + + public function createFile($name, $data = null) { + throw new Forbidden('Not allowed to create files in the trashbin'); + } + + public function createDirectory($name) { + throw new Forbidden('Not allowed to create folders in the trashbin'); + } + + public function getChild($name) { + if ($name === 'restore') { + return new RestoreFolder(); + } + if ($name === 'trash') { + return new TrashRoot($this->user, $this->trashManager); + } + + throw new NotFound(); + } + + public function getChildren(): array { + return [ + new RestoreFolder(), + new TrashRoot($this->user, $this->trashManager) + ]; + } + + public function childExists($name): bool { + return $name === 'restore' || $name === 'trash'; + } + + public function getLastModified(): int { + return 0; + } +} diff --git a/apps/files_trashbin/lib/Sabre/TrashRoot.php b/apps/files_trashbin/lib/Sabre/TrashRoot.php new file mode 100644 index 00000000000..dd89583d9a1 --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/TrashRoot.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +use OCA\Files_Trashbin\Service\ConfigService; +use OCA\Files_Trashbin\Trash\ITrashItem; +use OCA\Files_Trashbin\Trash\ITrashManager; +use OCA\Files_Trashbin\Trashbin; +use OCP\Files\FileInfo; +use OCP\IUser; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; + +class TrashRoot implements ICollection { + + public function __construct( + private IUser $user, + private ITrashManager $trashManager, + ) { + } + + public function delete() { + if (!ConfigService::getDeleteFromTrashEnabled()) { + throw new Forbidden('Not allowed to delete items from the trash bin'); + } + + Trashbin::deleteAll(); + foreach ($this->trashManager->listTrashRoot($this->user) as $trashItem) { + $this->trashManager->removeItem($trashItem); + } + } + + public function getName(): string { + return 'trash'; + } + + public function setName($name) { + throw new Forbidden('Permission denied to rename this trashbin'); + } + + public function createFile($name, $data = null) { + throw new Forbidden('Not allowed to create files in the trashbin'); + } + + public function createDirectory($name) { + throw new Forbidden('Not allowed to create folders in the trashbin'); + } + + public function getChildren(): array { + $entries = $this->trashManager->listTrashRoot($this->user); + + $children = array_map(function (ITrashItem $entry) { + if ($entry->getType() === FileInfo::TYPE_FOLDER) { + return new TrashFolder($this->trashManager, $entry); + } + return new TrashFile($this->trashManager, $entry); + }, $entries); + + return $children; + } + + public function getChild($name): ITrash { + $entries = $this->getChildren(); + + foreach ($entries as $entry) { + if ($entry->getName() === $name) { + return $entry; + } + } + + throw new NotFound(); + } + + public function childExists($name): bool { + try { + $this->getChild($name); + return true; + } catch (NotFound $e) { + return false; + } + } + + public function getLastModified(): int { + return 0; + } +} diff --git a/apps/files_trashbin/lib/Sabre/TrashbinPlugin.php b/apps/files_trashbin/lib/Sabre/TrashbinPlugin.php new file mode 100644 index 00000000000..54bb1326966 --- /dev/null +++ b/apps/files_trashbin/lib/Sabre/TrashbinPlugin.php @@ -0,0 +1,180 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Sabre; + +use OC\Files\FileInfo; +use OC\Files\View; +use OCA\DAV\Connector\Sabre\FilesPlugin; +use OCA\Files_Trashbin\Trash\ITrashItem; +use OCP\IPreview; +use Psr\Log\LoggerInterface; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\Server; +use Sabre\DAV\ServerPlugin; +use Sabre\HTTP\RequestInterface; +use Sabre\HTTP\ResponseInterface; + +class TrashbinPlugin extends ServerPlugin { + public const TRASHBIN_FILENAME = '{http://nextcloud.org/ns}trashbin-filename'; + public const TRASHBIN_ORIGINAL_LOCATION = '{http://nextcloud.org/ns}trashbin-original-location'; + public const TRASHBIN_DELETION_TIME = '{http://nextcloud.org/ns}trashbin-deletion-time'; + public const TRASHBIN_TITLE = '{http://nextcloud.org/ns}trashbin-title'; + public const TRASHBIN_DELETED_BY_ID = '{http://nextcloud.org/ns}trashbin-deleted-by-id'; + public const TRASHBIN_DELETED_BY_DISPLAY_NAME = '{http://nextcloud.org/ns}trashbin-deleted-by-display-name'; + public const TRASHBIN_BACKEND = '{http://nextcloud.org/ns}trashbin-backend'; + + /** @var Server */ + private $server; + + public function __construct( + private IPreview $previewManager, + private View $view, + ) { + } + + public function initialize(Server $server) { + $this->server = $server; + + $this->server->on('propFind', [$this, 'propFind']); + $this->server->on('afterMethod:GET', [$this,'httpGet']); + $this->server->on('beforeMove', [$this, 'beforeMove']); + } + + + public function propFind(PropFind $propFind, INode $node) { + if (!($node instanceof ITrash)) { + return; + } + + $propFind->handle(self::TRASHBIN_FILENAME, function () use ($node) { + return $node->getFilename(); + }); + + $propFind->handle(self::TRASHBIN_ORIGINAL_LOCATION, function () use ($node) { + return $node->getOriginalLocation(); + }); + + $propFind->handle(self::TRASHBIN_TITLE, function () use ($node) { + return $node->getTitle(); + }); + + $propFind->handle(self::TRASHBIN_DELETION_TIME, function () use ($node) { + return $node->getDeletionTime(); + }); + + $propFind->handle(self::TRASHBIN_DELETED_BY_ID, function () use ($node) { + return $node->getDeletedBy()?->getUID(); + }); + + $propFind->handle(self::TRASHBIN_DELETED_BY_DISPLAY_NAME, function () use ($node) { + return $node->getDeletedBy()?->getDisplayName(); + }); + + // Pass the real filename as the DAV display name + $propFind->handle(FilesPlugin::DISPLAYNAME_PROPERTYNAME, function () use ($node) { + return $node->getFilename(); + }); + + $propFind->handle(FilesPlugin::SIZE_PROPERTYNAME, function () use ($node) { + return $node->getSize(); + }); + + $propFind->handle(FilesPlugin::FILEID_PROPERTYNAME, function () use ($node) { + return $node->getFileId(); + }); + + $propFind->handle(FilesPlugin::PERMISSIONS_PROPERTYNAME, function () { + return 'GD'; // read + delete + }); + + $propFind->handle(FilesPlugin::GETETAG_PROPERTYNAME, function () use ($node) { + // add fake etag, it is only needed to identify the preview image + return $node->getLastModified(); + }); + + $propFind->handle(FilesPlugin::INTERNAL_FILEID_PROPERTYNAME, function () use ($node) { + // add fake etag, it is only needed to identify the preview image + return $node->getFileId(); + }); + + $propFind->handle(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, function () use ($node): string { + return $this->previewManager->isAvailable($node->getFileInfo()) ? 'true' : 'false'; + }); + + $propFind->handle(FilesPlugin::MOUNT_TYPE_PROPERTYNAME, function () { + return ''; + }); + + $propFind->handle(self::TRASHBIN_BACKEND, function () use ($node) { + $fileInfo = $node->getFileInfo(); + if (!($fileInfo instanceof ITrashItem)) { + return ''; + } + return $fileInfo->getTrashBackend()::class; + }); + } + + /** + * Set real filename on trashbin download + * + * @param RequestInterface $request + * @param ResponseInterface $response + */ + public function httpGet(RequestInterface $request, ResponseInterface $response): void { + $path = $request->getPath(); + $node = $this->server->tree->getNodeForPath($path); + if ($node instanceof ITrash) { + $response->addHeader('Content-Disposition', 'attachment; filename="' . $node->getFilename() . '"'); + } + } + + /** + * Check if a user has available space before attempting to + * restore from trashbin unless they have unlimited quota. + * + * @param string $sourcePath + * @param string $destinationPath + * @return bool + */ + public function beforeMove(string $sourcePath, string $destinationPath): bool { + try { + $node = $this->server->tree->getNodeForPath($sourcePath); + $destinationNodeParent = $this->server->tree->getNodeForPath(dirname($destinationPath)); + } catch (\Sabre\DAV\Exception $e) { + \OCP\Server::get(LoggerInterface::class) + ->error($e->getMessage(), ['app' => 'files_trashbin', 'exception' => $e]); + return true; + } + + // Check if a file is being restored before proceeding + if (!$node instanceof ITrash || !$destinationNodeParent instanceof RestoreFolder) { + return true; + } + + $fileInfo = $node->getFileInfo(); + if (!$fileInfo instanceof ITrashItem) { + return true; + } + $restoreFolder = dirname($fileInfo->getOriginalLocation()); + $freeSpace = $this->view->free_space($restoreFolder); + if ($freeSpace === FileInfo::SPACE_NOT_COMPUTED + || $freeSpace === FileInfo::SPACE_UNKNOWN + || $freeSpace === FileInfo::SPACE_UNLIMITED) { + return true; + } + $filesize = $fileInfo->getSize(); + if ($freeSpace < $filesize) { + $this->server->httpResponse->setStatus(507); + return false; + } + + return true; + } +} diff --git a/apps/files_trashbin/lib/Service/ConfigService.php b/apps/files_trashbin/lib/Service/ConfigService.php new file mode 100644 index 00000000000..9e7826fe580 --- /dev/null +++ b/apps/files_trashbin/lib/Service/ConfigService.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace OCA\Files_Trashbin\Service; + +use OCP\AppFramework\Services\IInitialState; +use OCP\IConfig; +use OCP\Server; + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +class ConfigService { + public static function getDeleteFromTrashEnabled(): bool { + return Server::get(IConfig::class)->getSystemValueBool('files.trash.delete', true); + } + + public static function injectInitialState(IInitialState $initialState): void { + $initialState->provideLazyInitialState('config', function () { + return [ + 'allow_delete' => ConfigService::getDeleteFromTrashEnabled(), + ]; + }); + } +} diff --git a/apps/files_trashbin/lib/Storage.php b/apps/files_trashbin/lib/Storage.php new file mode 100644 index 00000000000..82b7af5a934 --- /dev/null +++ b/apps/files_trashbin/lib/Storage.php @@ -0,0 +1,209 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Trashbin; + +use OC\Files\Filesystem; +use OC\Files\Storage\Wrapper\Wrapper; +use OCA\Files_Trashbin\Events\MoveToTrashEvent; +use OCA\Files_Trashbin\Trash\ITrashManager; +use OCP\App\IAppManager; +use OCP\Encryption\Exceptions\GenericEncryptionException; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\Files\Storage\IStorage; +use OCP\IRequest; +use OCP\IUserManager; +use OCP\Server; +use Psr\Log\LoggerInterface; + +class Storage extends Wrapper { + private string $mountPoint; + private bool $trashEnabled = true; + + /** + * Storage constructor. + * @param array $parameters + */ + public function __construct( + $parameters, + private ?ITrashManager $trashManager = null, + private ?IUserManager $userManager = null, + private ?LoggerInterface $logger = null, + private ?IEventDispatcher $eventDispatcher = null, + private ?IRootFolder $rootFolder = null, + private ?IRequest $request = null, + ) { + $this->mountPoint = $parameters['mountPoint']; + parent::__construct($parameters); + } + + public function unlink(string $path): bool { + if ($this->trashEnabled) { + try { + return $this->doDelete($path, 'unlink'); + } catch (GenericEncryptionException $e) { + // in case of a encryption exception we delete the file right away + $this->logger->info( + "Can't move file " . $path + . ' to the trash bin, therefore it was deleted right away'); + + return $this->storage->unlink($path); + } + } else { + return $this->storage->unlink($path); + } + } + + public function rmdir(string $path): bool { + if ($this->trashEnabled) { + return $this->doDelete($path, 'rmdir'); + } else { + return $this->storage->rmdir($path); + } + } + + /** + * check if it is a file located in data/user/files only files in the + * 'files' directory should be moved to the trash + */ + protected function shouldMoveToTrash(string $path): bool { + $normalized = Filesystem::normalizePath($this->mountPoint . '/' . $path); + $parts = explode('/', $normalized); + if (count($parts) < 4 || strpos($normalized, '/appdata_') === 0) { + return false; + } + + // check if there is a app which want to disable the trash bin for this file + $fileId = $this->storage->getCache()->getId($path); + $owner = $this->storage->getOwner($path); + if ($owner === false || $this->storage->instanceOfStorage(\OCA\Files_Sharing\External\Storage::class)) { + $nodes = $this->rootFolder->getById($fileId); + } else { + $nodes = $this->rootFolder->getUserFolder($owner)->getById($fileId); + } + + foreach ($nodes as $node) { + $event = $this->createMoveToTrashEvent($node); + $this->eventDispatcher->dispatchTyped($event); + $this->eventDispatcher->dispatch('OCA\Files_Trashbin::moveToTrash', $event); + if ($event->shouldMoveToTrashBin() === false) { + return false; + } + } + + if ($parts[2] === 'files' && $this->userManager->userExists($parts[1])) { + return true; + } + + return false; + } + + /** + * get move to trash event + * + * @param Node $node + * @return MoveToTrashEvent + */ + protected function createMoveToTrashEvent(Node $node): MoveToTrashEvent { + return new MoveToTrashEvent($node); + } + + /** + * Run the delete operation with the given method + * + * @param string $path path of file or folder to delete + * @param string $method either "unlink" or "rmdir" + * + * @return bool true if the operation succeeded, false otherwise + */ + private function doDelete(string $path, string $method): bool { + $isTrashbinEnabled = Server::get(IAppManager::class)->isEnabledForUser('files_trashbin'); + $isPartFile = pathinfo($path, PATHINFO_EXTENSION) === 'part'; + $isSkipTrashHeaderSet = $this->request !== null && $this->request->getHeader('X-NC-Skip-Trashbin') === 'true'; + // We keep the shouldMoveToTrash call at the end to prevent emitting unnecessary event. + $shouldMoveToTrash = $isTrashbinEnabled && !$isPartFile && !$isSkipTrashHeaderSet && $this->shouldMoveToTrash($path); + + if ($shouldMoveToTrash) { + // check permissions before we continue, this is especially important for + // shared files + if (!$this->isDeletable($path)) { + return false; + } + + $isMovedToTrash = $this->trashManager->moveToTrash($this, $path); + if ($isMovedToTrash) { + return true; + } + } + + return call_user_func([$this->storage, $method], $path); + } + + /** + * Setup the storage wrapper callback + */ + public static function setupStorage(): void { + $trashManager = Server::get(ITrashManager::class); + $userManager = Server::get(IUserManager::class); + $logger = Server::get(LoggerInterface::class); + $eventDispatcher = Server::get(IEventDispatcher::class); + $rootFolder = Server::get(IRootFolder::class); + $request = Server::get(IRequest::class); + Filesystem::addStorageWrapper( + 'oc_trashbin', + function (string $mountPoint, IStorage $storage) use ($trashManager, $userManager, $logger, $eventDispatcher, $rootFolder, $request) { + return new Storage( + ['storage' => $storage, 'mountPoint' => $mountPoint], + $trashManager, + $userManager, + $logger, + $eventDispatcher, + $rootFolder, + $request, + ); + }, + 1); + } + + public function getMountPoint() { + return $this->mountPoint; + } + + public function moveFromStorage(IStorage $sourceStorage, string $sourceInternalPath, string $targetInternalPath): bool { + $sourceIsTrashbin = $sourceStorage->instanceOfStorage(Storage::class); + try { + // the fallback for moving between storage involves a copy+delete + // we don't want to trigger the trashbin when doing the delete + if ($sourceIsTrashbin) { + /** @var Storage $sourceStorage */ + $sourceStorage->disableTrash(); + } + $result = parent::moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + if ($sourceIsTrashbin) { + /** @var Storage $sourceStorage */ + $sourceStorage->enableTrash(); + } + return $result; + } catch (\Exception $e) { + if ($sourceIsTrashbin) { + /** @var Storage $sourceStorage */ + $sourceStorage->enableTrash(); + } + throw $e; + } + } + + protected function disableTrash(): void { + $this->trashEnabled = false; + } + + protected function enableTrash(): void { + $this->trashEnabled = true; + } +} diff --git a/apps/files_trashbin/lib/Trash/BackendNotFoundException.php b/apps/files_trashbin/lib/Trash/BackendNotFoundException.php new file mode 100644 index 00000000000..292b6ee293c --- /dev/null +++ b/apps/files_trashbin/lib/Trash/BackendNotFoundException.php @@ -0,0 +1,10 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Trash; + +class BackendNotFoundException extends \Exception { +} diff --git a/apps/files_trashbin/lib/Trash/ITrashBackend.php b/apps/files_trashbin/lib/Trash/ITrashBackend.php new file mode 100644 index 00000000000..11b3132bfba --- /dev/null +++ b/apps/files_trashbin/lib/Trash/ITrashBackend.php @@ -0,0 +1,67 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Trash; + +use OCP\Files\Node; +use OCP\Files\Storage\IStorage; +use OCP\IUser; + +/** + * @since 15.0.0 + */ +interface ITrashBackend { + /** + * List all trash items in the root of the trashbin + * + * @param IUser $user + * @return ITrashItem[] + * @since 15.0.0 + */ + public function listTrashRoot(IUser $user): array; + + /** + * List all trash items in a subfolder in the trashbin + * + * @param ITrashItem $folder + * @return ITrashItem[] + * @since 15.0.0 + */ + public function listTrashFolder(ITrashItem $folder): array; + + /** + * Restore a trashbin item + * + * @param ITrashItem $item + * @since 15.0.0 + */ + public function restoreItem(ITrashItem $item); + + /** + * Permanently remove an item from trash + * + * @param ITrashItem $item + * @since 15.0.0 + */ + public function removeItem(ITrashItem $item); + + /** + * Move a file or folder to trash + * + * @param IStorage $storage + * @param string $internalPath + * @return boolean whether or not the file was moved to trash, if false then the file should be deleted normally + * @since 15.0.0 + */ + public function moveToTrash(IStorage $storage, string $internalPath): bool; + + /** + * @param IUser $user + * @param int $fileId + * @return Node|null + */ + public function getTrashNodeById(IUser $user, int $fileId); +} diff --git a/apps/files_trashbin/lib/Trash/ITrashItem.php b/apps/files_trashbin/lib/Trash/ITrashItem.php new file mode 100644 index 00000000000..299cac49a69 --- /dev/null +++ b/apps/files_trashbin/lib/Trash/ITrashItem.php @@ -0,0 +1,70 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Trash; + +use OCP\Files\FileInfo; +use OCP\IUser; + +/** + * @since 15.0.0 + */ +interface ITrashItem extends FileInfo { + /** + * Get the trash backend for this item + * + * @return ITrashBackend + * @since 15.0.0 + */ + public function getTrashBackend(): ITrashBackend; + + /** + * Get the original location for the trash item + * + * @return string + * @since 15.0.0 + */ + public function getOriginalLocation(): string; + + /** + * Get the timestamp that the file was moved to trash + * + * @return int + * @since 15.0.0 + */ + public function getDeletedTime(): int; + + /** + * Get the path of the item relative to the users trashbin + * + * @return string + * @since 15.0.0 + */ + public function getTrashPath(): string; + + /** + * Whether the item is a deleted item in the root of the trash, or a file in a subfolder + * + * @return bool + * @since 15.0.0 + */ + public function isRootItem(): bool; + + /** + * Get the user for which this trash item applies + * + * @return IUser + * @since 15.0.0 + */ + public function getUser(): IUser; + + /** + * @since 30.0.0 + */ + public function getDeletedBy(): ?IUser; + + public function getTitle(): string; +} diff --git a/apps/files_trashbin/lib/Trash/ITrashManager.php b/apps/files_trashbin/lib/Trash/ITrashManager.php new file mode 100644 index 00000000000..743ea01358a --- /dev/null +++ b/apps/files_trashbin/lib/Trash/ITrashManager.php @@ -0,0 +1,41 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Trash; + +use OCP\IUser; + +interface ITrashManager extends ITrashBackend { + /** + * Add a backend for the trashbin + * + * @param string $storageType + * @param ITrashBackend $backend + * @since 15.0.0 + */ + public function registerBackend(string $storageType, ITrashBackend $backend); + + /** + * List all trash items in the root of the trashbin + * + * @param IUser $user + * @return ITrashItem[] + * @since 15.0.0 + */ + public function listTrashRoot(IUser $user): array; + + /** + * Temporally prevent files from being moved to the trash + * + * @since 15.0.0 + */ + public function pauseTrash(); + + /** + * @since 15.0.0 + */ + public function resumeTrash(); +} diff --git a/apps/files_trashbin/lib/Trash/LegacyTrashBackend.php b/apps/files_trashbin/lib/Trash/LegacyTrashBackend.php new file mode 100644 index 00000000000..204defde35c --- /dev/null +++ b/apps/files_trashbin/lib/Trash/LegacyTrashBackend.php @@ -0,0 +1,121 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Trash; + +use OC\Files\Filesystem; +use OC\Files\View; +use OCA\Files_Trashbin\Helper; +use OCA\Files_Trashbin\Storage; +use OCA\Files_Trashbin\Trashbin; +use OCP\Files\FileInfo; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\IStorage; +use OCP\IUser; +use OCP\IUserManager; + +class LegacyTrashBackend implements ITrashBackend { + /** @var array */ + private $deletedFiles = []; + + public function __construct( + private IRootFolder $rootFolder, + private IUserManager $userManager, + ) { + } + + /** + * @param array $items + * @param IUser $user + * @param ITrashItem $parent + * @return ITrashItem[] + */ + private function mapTrashItems(array $items, IUser $user, ?ITrashItem $parent = null): array { + $parentTrashPath = ($parent instanceof ITrashItem) ? $parent->getTrashPath() : ''; + $isRoot = $parent === null; + return array_map(function (FileInfo $file) use ($parent, $parentTrashPath, $isRoot, $user) { + $originalLocation = $isRoot ? $file['extraData'] : $parent->getOriginalLocation() . '/' . $file->getName(); + if (!$originalLocation) { + $originalLocation = $file->getName(); + } + /** @psalm-suppress UndefinedInterfaceMethod */ + $deletedBy = $this->userManager->get($file['deletedBy']) ?? $parent?->getDeletedBy(); + $trashFilename = Trashbin::getTrashFilename($file->getName(), $file->getMtime()); + return new TrashItem( + $this, + $originalLocation, + $file->getMTime(), + $parentTrashPath . '/' . ($isRoot ? $trashFilename : $file->getName()), + $file, + $user, + $deletedBy, + ); + }, $items); + } + + public function listTrashRoot(IUser $user): array { + $entries = Helper::getTrashFiles('/', $user->getUID()); + return $this->mapTrashItems($entries, $user); + } + + public function listTrashFolder(ITrashItem $folder): array { + $user = $folder->getUser(); + $entries = Helper::getTrashFiles($folder->getTrashPath(), $user->getUID()); + return $this->mapTrashItems($entries, $user, $folder); + } + + public function restoreItem(ITrashItem $item) { + Trashbin::restore($item->getTrashPath(), $item->getName(), $item->isRootItem() ? $item->getDeletedTime() : null); + } + + public function removeItem(ITrashItem $item) { + $user = $item->getUser(); + if ($item->isRootItem()) { + $path = substr($item->getTrashPath(), 0, -strlen('.d' . $item->getDeletedTime())); + Trashbin::delete($path, $user->getUID(), $item->getDeletedTime()); + } else { + Trashbin::delete($item->getTrashPath(), $user->getUID(), null); + } + } + + public function moveToTrash(IStorage $storage, string $internalPath): bool { + if (!$storage instanceof Storage) { + return false; + } + $normalized = Filesystem::normalizePath($storage->getMountPoint() . '/' . $internalPath, true, false, true); + $view = Filesystem::getView(); + if (!isset($this->deletedFiles[$normalized]) && $view instanceof View) { + $this->deletedFiles[$normalized] = $normalized; + if ($filesPath = $view->getRelativePath($normalized)) { + $filesPath = trim($filesPath, '/'); + $result = Trashbin::move2trash($filesPath); + } else { + $result = false; + } + unset($this->deletedFiles[$normalized]); + } else { + $result = false; + } + + return $result; + } + + public function getTrashNodeById(IUser $user, int $fileId) { + try { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $trash = $userFolder->getParent()->get('files_trashbin/files'); + if ($trash instanceof Folder) { + return $trash->getFirstNodeById($fileId); + } else { + return null; + } + } catch (NotFoundException $e) { + return null; + } + } +} diff --git a/apps/files_trashbin/lib/Trash/TrashItem.php b/apps/files_trashbin/lib/Trash/TrashItem.php new file mode 100644 index 00000000000..2ae999a2069 --- /dev/null +++ b/apps/files_trashbin/lib/Trash/TrashItem.php @@ -0,0 +1,172 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Trash; + +use OCP\Files\FileInfo; +use OCP\IUser; + +class TrashItem implements ITrashItem { + + public function __construct( + private ITrashBackend $backend, + private string $originalLocation, + private int $deletedTime, + private string $trashPath, + private FileInfo $fileInfo, + private IUser $user, + private ?IUser $deletedBy, + ) { + } + + public function getTrashBackend(): ITrashBackend { + return $this->backend; + } + + public function getOriginalLocation(): string { + return $this->originalLocation; + } + + public function getDeletedTime(): int { + return $this->deletedTime; + } + + public function getTrashPath(): string { + return $this->trashPath; + } + + public function isRootItem(): bool { + return substr_count($this->getTrashPath(), '/') === 1; + } + + public function getUser(): IUser { + return $this->user; + } + + public function getEtag() { + return $this->fileInfo->getEtag(); + } + + public function getSize($includeMounts = true) { + return $this->fileInfo->getSize($includeMounts); + } + + public function getMtime() { + return $this->fileInfo->getMtime(); + } + + public function getName() { + return $this->fileInfo->getName(); + } + + public function getInternalPath() { + return $this->fileInfo->getInternalPath(); + } + + public function getPath() { + return $this->fileInfo->getPath(); + } + + public function getMimetype() { + return $this->fileInfo->getMimetype(); + } + + public function getMimePart() { + return $this->fileInfo->getMimePart(); + } + + public function getStorage() { + return $this->fileInfo->getStorage(); + } + + public function getId() { + return $this->fileInfo->getId(); + } + + public function isEncrypted() { + return $this->fileInfo->isEncrypted(); + } + + public function getPermissions() { + return $this->fileInfo->getPermissions(); + } + + public function getType() { + return $this->fileInfo->getType(); + } + + public function isReadable() { + return $this->fileInfo->isReadable(); + } + + public function isUpdateable() { + return $this->fileInfo->isUpdateable(); + } + + public function isCreatable() { + return $this->fileInfo->isCreatable(); + } + + public function isDeletable() { + return $this->fileInfo->isDeletable(); + } + + public function isShareable() { + return $this->fileInfo->isShareable(); + } + + public function isShared() { + return $this->fileInfo->isShared(); + } + + public function isMounted() { + return $this->fileInfo->isMounted(); + } + + public function getMountPoint() { + return $this->fileInfo->getMountPoint(); + } + + public function getOwner() { + return $this->fileInfo->getOwner(); + } + + public function getChecksum() { + return $this->fileInfo->getChecksum(); + } + + public function getExtension(): string { + return $this->fileInfo->getExtension(); + } + + public function getTitle(): string { + return $this->getOriginalLocation(); + } + + public function getCreationTime(): int { + return $this->fileInfo->getCreationTime(); + } + + public function getUploadTime(): int { + return $this->fileInfo->getUploadTime(); + } + + public function getParentId(): int { + return $this->fileInfo->getParentId(); + } + + public function getDeletedBy(): ?IUser { + return $this->deletedBy; + } + + /** + * @inheritDoc + * @return array<string, int|string|bool|float|string[]|int[]> + */ + public function getMetadata(): array { + return $this->fileInfo->getMetadata(); + } +} diff --git a/apps/files_trashbin/lib/Trash/TrashManager.php b/apps/files_trashbin/lib/Trash/TrashManager.php new file mode 100644 index 00000000000..521a576c00a --- /dev/null +++ b/apps/files_trashbin/lib/Trash/TrashManager.php @@ -0,0 +1,111 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Trashbin\Trash; + +use OCP\Files\Storage\IStorage; +use OCP\IUser; + +class TrashManager implements ITrashManager { + /** @var ITrashBackend[] */ + private $backends = []; + + private $trashPaused = false; + + public function registerBackend(string $storageType, ITrashBackend $backend) { + $this->backends[$storageType] = $backend; + } + + /** + * @return ITrashBackend[] + */ + private function getBackends(): array { + return $this->backends; + } + + public function listTrashRoot(IUser $user): array { + $items = array_reduce($this->getBackends(), function (array $items, ITrashBackend $backend) use ($user) { + return array_merge($items, $backend->listTrashRoot($user)); + }, []); + usort($items, function (ITrashItem $a, ITrashItem $b) { + return $b->getDeletedTime() - $a->getDeletedTime(); + }); + return $items; + } + + private function getBackendForItem(ITrashItem $item) { + return $item->getTrashBackend(); + } + + public function listTrashFolder(ITrashItem $folder): array { + return $this->getBackendForItem($folder)->listTrashFolder($folder); + } + + public function restoreItem(ITrashItem $item) { + return $this->getBackendForItem($item)->restoreItem($item); + } + + public function removeItem(ITrashItem $item) { + $this->getBackendForItem($item)->removeItem($item); + } + + /** + * @param IStorage $storage + * @return ITrashBackend + * @throws BackendNotFoundException + */ + public function getBackendForStorage(IStorage $storage): ITrashBackend { + $fullType = get_class($storage); + $foundType = array_reduce(array_keys($this->backends), function ($type, $registeredType) use ($storage) { + if ( + $storage->instanceOfStorage($registeredType) + && ($type === '' || is_subclass_of($registeredType, $type)) + ) { + return $registeredType; + } else { + return $type; + } + }, ''); + if ($foundType === '') { + throw new BackendNotFoundException("Trash backend for $fullType not found"); + } else { + return $this->backends[$foundType]; + } + } + + public function moveToTrash(IStorage $storage, string $internalPath): bool { + if ($this->trashPaused) { + return false; + } + try { + $backend = $this->getBackendForStorage($storage); + $this->trashPaused = true; + $result = $backend->moveToTrash($storage, $internalPath); + $this->trashPaused = false; + return $result; + } catch (BackendNotFoundException $e) { + return false; + } + } + + public function getTrashNodeById(IUser $user, int $fileId) { + foreach ($this->backends as $backend) { + $item = $backend->getTrashNodeById($user, $fileId); + if ($item !== null) { + return $item; + } + } + return null; + } + + public function pauseTrash() { + $this->trashPaused = true; + } + + public function resumeTrash() { + $this->trashPaused = false; + } +} diff --git a/apps/files_trashbin/lib/Trashbin.php b/apps/files_trashbin/lib/Trashbin.php new file mode 100644 index 00000000000..667066c2fca --- /dev/null +++ b/apps/files_trashbin/lib/Trashbin.php @@ -0,0 +1,1200 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Trashbin; + +use Exception; +use OC\Files\Cache\Cache; +use OC\Files\Cache\CacheEntry; +use OC\Files\Cache\CacheQueryBuilder; +use OC\Files\Filesystem; +use OC\Files\Node\NonExistingFile; +use OC\Files\Node\NonExistingFolder; +use OC\Files\View; +use OC\User\NoUserException; +use OC_User; +use OCA\Files_Trashbin\AppInfo\Application; +use OCA\Files_Trashbin\Command\Expire; +use OCA\Files_Trashbin\Events\BeforeNodeRestoredEvent; +use OCA\Files_Trashbin\Events\NodeRestoredEvent; +use OCA\Files_Trashbin\Exceptions\CopyRecursiveException; +use OCA\Files_Versions\Storage; +use OCP\App\IAppManager; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\Command\IBus; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\Node\BeforeNodeDeletedEvent; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\IMimeTypeLoader; +use OCP\Files\IRootFolder; +use OCP\Files\Node; +use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; +use OCP\Files\Storage\ILockingStorage; +use OCP\Files\Storage\IStorage; +use OCP\FilesMetadata\IFilesMetadataManager; +use OCP\IConfig; +use OCP\IDBConnection; +use OCP\IURLGenerator; +use OCP\IUserManager; +use OCP\Lock\ILockingProvider; +use OCP\Lock\LockedException; +use OCP\Server; +use OCP\Util; +use Psr\Log\LoggerInterface; + +/** @template-implements IEventListener<BeforeNodeDeletedEvent> */ +class Trashbin implements IEventListener { + // unit: percentage; 50% of available disk space/quota + public const DEFAULTMAXSIZE = 50; + + /** + * Ensure we don't need to scan the file during the move to trash + * by triggering the scan in the pre-hook + */ + public static function ensureFileScannedHook(Node $node): void { + try { + self::getUidAndFilename($node->getPath()); + } catch (NotFoundException $e) { + // Nothing to scan for non existing files + } + } + + /** + * get the UID of the owner of the file and the path to the file relative to + * owners files folder + * + * @param string $filename + * @return array + * @throws NoUserException + */ + public static function getUidAndFilename($filename) { + $uid = Filesystem::getOwner($filename); + $userManager = Server::get(IUserManager::class); + // if the user with the UID doesn't exists, e.g. because the UID points + // to a remote user with a federated cloud ID we use the current logged-in + // user. We need a valid local user to move the file to the right trash bin + if (!$userManager->userExists($uid)) { + $uid = OC_User::getUser(); + } + if (!$uid) { + // no owner, usually because of share link from ext storage + return [null, null]; + } + Filesystem::initMountPoints($uid); + if ($uid !== OC_User::getUser()) { + $info = Filesystem::getFileInfo($filename); + $ownerView = new View('/' . $uid . '/files'); + try { + $filename = $ownerView->getPath($info['fileid']); + } catch (NotFoundException $e) { + $filename = null; + } + } + return [$uid, $filename]; + } + + /** + * get original location and deleted by of files for user + * + * @param string $user + * @return array<string, array<string, array{location: string, deletedBy: string}>> + */ + public static function getExtraData($user) { + $query = Server::get(IDBConnection::class)->getQueryBuilder(); + $query->select('id', 'timestamp', 'location', 'deleted_by') + ->from('files_trash') + ->where($query->expr()->eq('user', $query->createNamedParameter($user))); + $result = $query->executeQuery(); + $array = []; + while ($row = $result->fetch()) { + $array[$row['id']][$row['timestamp']] = [ + 'location' => (string)$row['location'], + 'deletedBy' => (string)$row['deleted_by'], + ]; + } + $result->closeCursor(); + return $array; + } + + /** + * get original location of file + * + * @param string $user + * @param string $filename + * @param string $timestamp + * @return string|false original location + */ + public static function getLocation($user, $filename, $timestamp) { + $query = Server::get(IDBConnection::class)->getQueryBuilder(); + $query->select('location') + ->from('files_trash') + ->where($query->expr()->eq('user', $query->createNamedParameter($user))) + ->andWhere($query->expr()->eq('id', $query->createNamedParameter($filename))) + ->andWhere($query->expr()->eq('timestamp', $query->createNamedParameter($timestamp))); + + $result = $query->executeQuery(); + $row = $result->fetch(); + $result->closeCursor(); + + if (isset($row['location'])) { + return $row['location']; + } else { + return false; + } + } + + /** @param string $user */ + private static function setUpTrash($user): void { + $view = new View('/' . $user); + if (!$view->is_dir('files_trashbin')) { + $view->mkdir('files_trashbin'); + } + if (!$view->is_dir('files_trashbin/files')) { + $view->mkdir('files_trashbin/files'); + } + if (!$view->is_dir('files_trashbin/versions')) { + $view->mkdir('files_trashbin/versions'); + } + if (!$view->is_dir('files_trashbin/keys')) { + $view->mkdir('files_trashbin/keys'); + } + } + + + /** + * copy file to owners trash + * + * @param string $sourcePath + * @param string $owner + * @param string $targetPath + * @param string $user + * @param int $timestamp + */ + private static function copyFilesToUser($sourcePath, $owner, $targetPath, $user, $timestamp): void { + self::setUpTrash($owner); + + $targetFilename = basename($targetPath); + $targetLocation = dirname($targetPath); + + $sourceFilename = basename($sourcePath); + + $view = new View('/'); + + $target = $user . '/files_trashbin/files/' . static::getTrashFilename($targetFilename, $timestamp); + $source = $owner . '/files_trashbin/files/' . static::getTrashFilename($sourceFilename, $timestamp); + $free = $view->free_space($target); + $isUnknownOrUnlimitedFreeSpace = $free < 0; + $isEnoughFreeSpaceLeft = $view->filesize($source) < $free; + if ($isUnknownOrUnlimitedFreeSpace || $isEnoughFreeSpaceLeft) { + self::copy_recursive($source, $target, $view); + } + + + if ($view->file_exists($target)) { + $query = Server::get(IDBConnection::class)->getQueryBuilder(); + $query->insert('files_trash') + ->setValue('id', $query->createNamedParameter($targetFilename)) + ->setValue('timestamp', $query->createNamedParameter($timestamp)) + ->setValue('location', $query->createNamedParameter($targetLocation)) + ->setValue('user', $query->createNamedParameter($user)) + ->setValue('deleted_by', $query->createNamedParameter($user)); + $result = $query->executeStatement(); + if (!$result) { + Server::get(LoggerInterface::class)->error('trash bin database couldn\'t be updated for the files owner', ['app' => 'files_trashbin']); + } + } + } + + + /** + * move file to the trash bin + * + * @param string $file_path path to the deleted file/directory relative to the files root directory + * @param bool $ownerOnly delete for owner only (if file gets moved out of a shared folder) + * + * @return bool + */ + public static function move2trash($file_path, $ownerOnly = false) { + // get the user for which the filesystem is setup + $root = Filesystem::getRoot(); + [, $user] = explode('/', $root); + [$owner, $ownerPath] = self::getUidAndFilename($file_path); + + // if no owner found (ex: ext storage + share link), will use the current user's trashbin then + if (is_null($owner)) { + $owner = $user; + $ownerPath = $file_path; + } + + $ownerView = new View('/' . $owner); + + // file has been deleted in between + if (is_null($ownerPath) || $ownerPath === '') { + return true; + } + + $sourceInfo = $ownerView->getFileInfo('/files/' . $ownerPath); + + if ($sourceInfo === false) { + return true; + } + + self::setUpTrash($user); + if ($owner !== $user) { + // also setup for owner + self::setUpTrash($owner); + } + + $path_parts = pathinfo($ownerPath); + + $filename = $path_parts['basename']; + $location = $path_parts['dirname']; + /** @var ITimeFactory $timeFactory */ + $timeFactory = Server::get(ITimeFactory::class); + $timestamp = $timeFactory->getTime(); + + $lockingProvider = Server::get(ILockingProvider::class); + + // disable proxy to prevent recursive calls + $trashPath = '/files_trashbin/files/' . static::getTrashFilename($filename, $timestamp); + $gotLock = false; + + do { + /** @var ILockingStorage & IStorage $trashStorage */ + [$trashStorage, $trashInternalPath] = $ownerView->resolvePath($trashPath); + try { + $trashStorage->acquireLock($trashInternalPath, ILockingProvider::LOCK_EXCLUSIVE, $lockingProvider); + $gotLock = true; + } catch (LockedException $e) { + // a file with the same name is being deleted concurrently + // nudge the timestamp a bit to resolve the conflict + + $timestamp = $timestamp + 1; + + $trashPath = '/files_trashbin/files/' . static::getTrashFilename($filename, $timestamp); + } + } while (!$gotLock); + + $sourceStorage = $sourceInfo->getStorage(); + $sourceInternalPath = $sourceInfo->getInternalPath(); + + if ($trashStorage->file_exists($trashInternalPath)) { + $trashStorage->unlink($trashInternalPath); + } + + $configuredTrashbinSize = static::getConfiguredTrashbinSize($owner); + if ($configuredTrashbinSize >= 0 && $sourceInfo->getSize() >= $configuredTrashbinSize) { + return false; + } + + try { + $moveSuccessful = true; + + $trashStorage->moveFromStorage($sourceStorage, $sourceInternalPath, $trashInternalPath); + if ($sourceStorage->getCache()->inCache($sourceInternalPath)) { + $trashStorage->getUpdater()->renameFromStorage($sourceStorage, $sourceInternalPath, $trashInternalPath); + } + } catch (CopyRecursiveException $e) { + $moveSuccessful = false; + if ($trashStorage->file_exists($trashInternalPath)) { + $trashStorage->unlink($trashInternalPath); + } + Server::get(LoggerInterface::class)->error('Couldn\'t move ' . $file_path . ' to the trash bin', ['app' => 'files_trashbin']); + } + + if ($sourceStorage->file_exists($sourceInternalPath)) { // failed to delete the original file, abort + if ($sourceStorage->is_dir($sourceInternalPath)) { + $sourceStorage->rmdir($sourceInternalPath); + } else { + $sourceStorage->unlink($sourceInternalPath); + } + + if ($sourceStorage->file_exists($sourceInternalPath)) { + // undo the cache move + $sourceStorage->getUpdater()->renameFromStorage($trashStorage, $trashInternalPath, $sourceInternalPath); + } else { + $trashStorage->getUpdater()->remove($trashInternalPath); + } + return false; + } + + if ($moveSuccessful) { + $query = Server::get(IDBConnection::class)->getQueryBuilder(); + $query->insert('files_trash') + ->setValue('id', $query->createNamedParameter($filename)) + ->setValue('timestamp', $query->createNamedParameter($timestamp)) + ->setValue('location', $query->createNamedParameter($location)) + ->setValue('user', $query->createNamedParameter($owner)) + ->setValue('deleted_by', $query->createNamedParameter($user)); + $result = $query->executeStatement(); + if (!$result) { + Server::get(LoggerInterface::class)->error('trash bin database couldn\'t be updated', ['app' => 'files_trashbin']); + } + Util::emitHook('\OCA\Files_Trashbin\Trashbin', 'post_moveToTrash', ['filePath' => Filesystem::normalizePath($file_path), + 'trashPath' => Filesystem::normalizePath(static::getTrashFilename($filename, $timestamp))]); + + self::retainVersions($filename, $owner, $ownerPath, $timestamp); + + // if owner !== user we need to also add a copy to the users trash + if ($user !== $owner && $ownerOnly === false) { + self::copyFilesToUser($ownerPath, $owner, $file_path, $user, $timestamp); + } + } + + $trashStorage->releaseLock($trashInternalPath, ILockingProvider::LOCK_EXCLUSIVE, $lockingProvider); + + self::scheduleExpire($user); + + // if owner !== user we also need to update the owners trash size + if ($owner !== $user) { + self::scheduleExpire($owner); + } + + return $moveSuccessful; + } + + private static function getConfiguredTrashbinSize(string $user): int|float { + $config = Server::get(IConfig::class); + $userTrashbinSize = $config->getUserValue($user, 'files_trashbin', 'trashbin_size', '-1'); + if (is_numeric($userTrashbinSize) && ($userTrashbinSize > -1)) { + return Util::numericToNumber($userTrashbinSize); + } + $systemTrashbinSize = $config->getAppValue('files_trashbin', 'trashbin_size', '-1'); + if (is_numeric($systemTrashbinSize)) { + return Util::numericToNumber($systemTrashbinSize); + } + return -1; + } + + /** + * Move file versions to trash so that they can be restored later + * + * @param string $filename of deleted file + * @param string $owner owner user id + * @param string $ownerPath path relative to the owner's home storage + * @param int $timestamp when the file was deleted + */ + private static function retainVersions($filename, $owner, $ownerPath, $timestamp) { + if (Server::get(IAppManager::class)->isEnabledForUser('files_versions') && !empty($ownerPath)) { + $user = OC_User::getUser(); + $rootView = new View('/'); + + if ($rootView->is_dir($owner . '/files_versions/' . $ownerPath)) { + if ($owner !== $user) { + self::copy_recursive($owner . '/files_versions/' . $ownerPath, $owner . '/files_trashbin/versions/' . static::getTrashFilename(basename($ownerPath), $timestamp), $rootView); + } + self::move($rootView, $owner . '/files_versions/' . $ownerPath, $user . '/files_trashbin/versions/' . static::getTrashFilename($filename, $timestamp)); + } elseif ($versions = Storage::getVersions($owner, $ownerPath)) { + foreach ($versions as $v) { + if ($owner !== $user) { + self::copy($rootView, $owner . '/files_versions' . $v['path'] . '.v' . $v['version'], $owner . '/files_trashbin/versions/' . static::getTrashFilename($v['name'] . '.v' . $v['version'], $timestamp)); + } + self::move($rootView, $owner . '/files_versions' . $v['path'] . '.v' . $v['version'], $user . '/files_trashbin/versions/' . static::getTrashFilename($filename . '.v' . $v['version'], $timestamp)); + } + } + } + } + + /** + * Move a file or folder on storage level + * + * @param View $view + * @param string $source + * @param string $target + * @return bool + */ + private static function move(View $view, $source, $target) { + /** @var \OC\Files\Storage\Storage $sourceStorage */ + [$sourceStorage, $sourceInternalPath] = $view->resolvePath($source); + /** @var \OC\Files\Storage\Storage $targetStorage */ + [$targetStorage, $targetInternalPath] = $view->resolvePath($target); + /** @var \OC\Files\Storage\Storage $ownerTrashStorage */ + + $result = $targetStorage->moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + if ($result) { + $targetStorage->getUpdater()->renameFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + } + return $result; + } + + /** + * Copy a file or folder on storage level + * + * @param View $view + * @param string $source + * @param string $target + * @return bool + */ + private static function copy(View $view, $source, $target) { + /** @var \OC\Files\Storage\Storage $sourceStorage */ + [$sourceStorage, $sourceInternalPath] = $view->resolvePath($source); + /** @var \OC\Files\Storage\Storage $targetStorage */ + [$targetStorage, $targetInternalPath] = $view->resolvePath($target); + /** @var \OC\Files\Storage\Storage $ownerTrashStorage */ + + $result = $targetStorage->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); + if ($result) { + $targetStorage->getUpdater()->update($targetInternalPath); + } + return $result; + } + + /** + * Restore a file or folder from trash bin + * + * @param string $file path to the deleted file/folder relative to "files_trashbin/files/", + * including the timestamp suffix ".d12345678" + * @param string $filename name of the file/folder + * @param int $timestamp time when the file/folder was deleted + * + * @return bool true on success, false otherwise + */ + public static function restore($file, $filename, $timestamp) { + $user = OC_User::getUser(); + $view = new View('/' . $user); + + $location = ''; + if ($timestamp) { + $location = self::getLocation($user, $filename, $timestamp); + if ($location === false) { + Server::get(LoggerInterface::class)->error('trash bin database inconsistent! ($user: ' . $user . ' $filename: ' . $filename . ', $timestamp: ' . $timestamp . ')', ['app' => 'files_trashbin']); + } else { + // if location no longer exists, restore file in the root directory + if ($location !== '/' + && (!$view->is_dir('files/' . $location) + || !$view->isCreatable('files/' . $location)) + ) { + $location = ''; + } + } + } + + // we need a extension in case a file/dir with the same name already exists + $uniqueFilename = self::getUniqueFilename($location, $filename, $view); + + $source = Filesystem::normalizePath('files_trashbin/files/' . $file); + $target = Filesystem::normalizePath('files/' . $location . '/' . $uniqueFilename); + if (!$view->file_exists($source)) { + return false; + } + $mtime = $view->filemtime($source); + + // restore file + if (!$view->isCreatable(dirname($target))) { + throw new NotPermittedException("Can't restore trash item because the target folder is not writable"); + } + + $sourcePath = Filesystem::normalizePath($file); + $targetPath = Filesystem::normalizePath('/' . $location . '/' . $uniqueFilename); + + $sourceNode = self::getNodeForPath($sourcePath); + $targetNode = self::getNodeForPath($targetPath); + $run = true; + $event = new BeforeNodeRestoredEvent($sourceNode, $targetNode, $run); + $dispatcher = Server::get(IEventDispatcher::class); + $dispatcher->dispatchTyped($event); + + if (!$run) { + return false; + } + + $restoreResult = $view->rename($source, $target); + + // handle the restore result + if ($restoreResult) { + $fakeRoot = $view->getRoot(); + $view->chroot('/' . $user . '/files'); + $view->touch('/' . $location . '/' . $uniqueFilename, $mtime); + $view->chroot($fakeRoot); + Util::emitHook('\OCA\Files_Trashbin\Trashbin', 'post_restore', ['filePath' => $targetPath, 'trashPath' => $sourcePath]); + + $sourceNode = self::getNodeForPath($sourcePath); + $targetNode = self::getNodeForPath($targetPath); + $event = new NodeRestoredEvent($sourceNode, $targetNode); + $dispatcher = Server::get(IEventDispatcher::class); + $dispatcher->dispatchTyped($event); + + self::restoreVersions($view, $file, $filename, $uniqueFilename, $location, $timestamp); + + if ($timestamp) { + $query = Server::get(IDBConnection::class)->getQueryBuilder(); + $query->delete('files_trash') + ->where($query->expr()->eq('user', $query->createNamedParameter($user))) + ->andWhere($query->expr()->eq('id', $query->createNamedParameter($filename))) + ->andWhere($query->expr()->eq('timestamp', $query->createNamedParameter($timestamp))); + $query->executeStatement(); + } + + return true; + } + + return false; + } + + /** + * restore versions from trash bin + * + * @param View $view file view + * @param string $file complete path to file + * @param string $filename name of file once it was deleted + * @param string $uniqueFilename new file name to restore the file without overwriting existing files + * @param string $location location if file + * @param int $timestamp deletion time + * @return false|null + */ + private static function restoreVersions(View $view, $file, $filename, $uniqueFilename, $location, $timestamp) { + if (Server::get(IAppManager::class)->isEnabledForUser('files_versions')) { + $user = OC_User::getUser(); + $rootView = new View('/'); + + $target = Filesystem::normalizePath('/' . $location . '/' . $uniqueFilename); + + [$owner, $ownerPath] = self::getUidAndFilename($target); + + // file has been deleted in between + if (empty($ownerPath)) { + return false; + } + + if ($timestamp) { + $versionedFile = $filename; + } else { + $versionedFile = $file; + } + + if ($view->is_dir('/files_trashbin/versions/' . $file)) { + $rootView->rename(Filesystem::normalizePath($user . '/files_trashbin/versions/' . $file), Filesystem::normalizePath($owner . '/files_versions/' . $ownerPath)); + } elseif ($versions = self::getVersionsFromTrash($versionedFile, $timestamp, $user)) { + foreach ($versions as $v) { + if ($timestamp) { + $rootView->rename($user . '/files_trashbin/versions/' . static::getTrashFilename($versionedFile . '.v' . $v, $timestamp), $owner . '/files_versions/' . $ownerPath . '.v' . $v); + } else { + $rootView->rename($user . '/files_trashbin/versions/' . $versionedFile . '.v' . $v, $owner . '/files_versions/' . $ownerPath . '.v' . $v); + } + } + } + } + } + + /** + * delete all files from the trash + */ + public static function deleteAll() { + $user = OC_User::getUser(); + $userRoot = \OC::$server->getUserFolder($user)->getParent(); + $view = new View('/' . $user); + $fileInfos = $view->getDirectoryContent('files_trashbin/files'); + + try { + $trash = $userRoot->get('files_trashbin'); + } catch (NotFoundException $e) { + return false; + } + + // Array to store the relative path in (after the file is deleted, the view won't be able to relativise the path anymore) + $filePaths = []; + foreach ($fileInfos as $fileInfo) { + $filePaths[] = $view->getRelativePath($fileInfo->getPath()); + } + unset($fileInfos); // save memory + + // Bulk PreDelete-Hook + \OC_Hook::emit('\OCP\Trashbin', 'preDeleteAll', ['paths' => $filePaths]); + + // Single-File Hooks + foreach ($filePaths as $path) { + self::emitTrashbinPreDelete($path); + } + + // actual file deletion + $trash->delete(); + + $query = Server::get(IDBConnection::class)->getQueryBuilder(); + $query->delete('files_trash') + ->where($query->expr()->eq('user', $query->createNamedParameter($user))); + $query->executeStatement(); + + // Bulk PostDelete-Hook + \OC_Hook::emit('\OCP\Trashbin', 'deleteAll', ['paths' => $filePaths]); + + // Single-File Hooks + foreach ($filePaths as $path) { + self::emitTrashbinPostDelete($path); + } + + $trash = $userRoot->newFolder('files_trashbin'); + $trash->newFolder('files'); + + return true; + } + + /** + * wrapper function to emit the 'preDelete' hook of \OCP\Trashbin before a file is deleted + * + * @param string $path + */ + protected static function emitTrashbinPreDelete($path) { + \OC_Hook::emit('\OCP\Trashbin', 'preDelete', ['path' => $path]); + } + + /** + * wrapper function to emit the 'delete' hook of \OCP\Trashbin after a file has been deleted + * + * @param string $path + */ + protected static function emitTrashbinPostDelete($path) { + \OC_Hook::emit('\OCP\Trashbin', 'delete', ['path' => $path]); + } + + /** + * delete file from trash bin permanently + * + * @param string $filename path to the file + * @param string $user + * @param int $timestamp of deletion time + * + * @return int|float size of deleted files + */ + public static function delete($filename, $user, $timestamp = null) { + $userRoot = \OC::$server->getUserFolder($user)->getParent(); + $view = new View('/' . $user); + $size = 0; + + if ($timestamp) { + $query = Server::get(IDBConnection::class)->getQueryBuilder(); + $query->delete('files_trash') + ->where($query->expr()->eq('user', $query->createNamedParameter($user))) + ->andWhere($query->expr()->eq('id', $query->createNamedParameter($filename))) + ->andWhere($query->expr()->eq('timestamp', $query->createNamedParameter($timestamp))); + $query->executeStatement(); + + $file = static::getTrashFilename($filename, $timestamp); + } else { + $file = $filename; + } + + $size += self::deleteVersions($view, $file, $filename, $timestamp, $user); + + try { + $node = $userRoot->get('/files_trashbin/files/' . $file); + } catch (NotFoundException $e) { + return $size; + } + + if ($node instanceof Folder) { + $size += self::calculateSize(new View('/' . $user . '/files_trashbin/files/' . $file)); + } elseif ($node instanceof File) { + $size += $view->filesize('/files_trashbin/files/' . $file); + } + + self::emitTrashbinPreDelete('/files_trashbin/files/' . $file); + $node->delete(); + self::emitTrashbinPostDelete('/files_trashbin/files/' . $file); + + return $size; + } + + /** + * @param string $file + * @param string $filename + * @param ?int $timestamp + */ + private static function deleteVersions(View $view, $file, $filename, $timestamp, string $user): int|float { + $size = 0; + if (Server::get(IAppManager::class)->isEnabledForUser('files_versions')) { + if ($view->is_dir('files_trashbin/versions/' . $file)) { + $size += self::calculateSize(new View('/' . $user . '/files_trashbin/versions/' . $file)); + $view->unlink('files_trashbin/versions/' . $file); + } elseif ($versions = self::getVersionsFromTrash($filename, $timestamp, $user)) { + foreach ($versions as $v) { + if ($timestamp) { + $size += $view->filesize('/files_trashbin/versions/' . static::getTrashFilename($filename . '.v' . $v, $timestamp)); + $view->unlink('/files_trashbin/versions/' . static::getTrashFilename($filename . '.v' . $v, $timestamp)); + } else { + $size += $view->filesize('/files_trashbin/versions/' . $filename . '.v' . $v); + $view->unlink('/files_trashbin/versions/' . $filename . '.v' . $v); + } + } + } + } + return $size; + } + + /** + * check to see whether a file exists in trashbin + * + * @param string $filename path to the file + * @param int $timestamp of deletion time + * @return bool true if file exists, otherwise false + */ + public static function file_exists($filename, $timestamp = null) { + $user = OC_User::getUser(); + $view = new View('/' . $user); + + if ($timestamp) { + $filename = static::getTrashFilename($filename, $timestamp); + } + + $target = Filesystem::normalizePath('files_trashbin/files/' . $filename); + return $view->file_exists($target); + } + + /** + * deletes used space for trash bin in db if user was deleted + * + * @param string $uid id of deleted user + * @return bool result of db delete operation + */ + public static function deleteUser($uid) { + $query = Server::get(IDBConnection::class)->getQueryBuilder(); + $query->delete('files_trash') + ->where($query->expr()->eq('user', $query->createNamedParameter($uid))); + return (bool)$query->executeStatement(); + } + + /** + * calculate remaining free space for trash bin + * + * @param int|float $trashbinSize current size of the trash bin + * @param string $user + * @return int|float available free space for trash bin + */ + private static function calculateFreeSpace(int|float $trashbinSize, string $user): int|float { + $configuredTrashbinSize = static::getConfiguredTrashbinSize($user); + if ($configuredTrashbinSize > -1) { + return $configuredTrashbinSize - $trashbinSize; + } + + $userObject = Server::get(IUserManager::class)->get($user); + if (is_null($userObject)) { + return 0; + } + $softQuota = true; + $quota = $userObject->getQuota(); + if ($quota === null || $quota === 'none') { + $quota = Filesystem::free_space('/'); + $softQuota = false; + // inf or unknown free space + if ($quota < 0) { + $quota = PHP_INT_MAX; + } + } else { + $quota = Util::computerFileSize($quota); + // invalid quota + if ($quota === false) { + $quota = PHP_INT_MAX; + } + } + + // calculate available space for trash bin + // subtract size of files and current trash bin size from quota + if ($softQuota) { + $userFolder = \OC::$server->getUserFolder($user); + if (is_null($userFolder)) { + return 0; + } + $free = $quota - $userFolder->getSize(false); // remaining free space for user + if ($free > 0) { + $availableSpace = ($free * self::DEFAULTMAXSIZE / 100) - $trashbinSize; // how much space can be used for versions + } else { + $availableSpace = $free - $trashbinSize; + } + } else { + $availableSpace = $quota; + } + + return Util::numericToNumber($availableSpace); + } + + /** + * resize trash bin if necessary after a new file was added to Nextcloud + * + * @param string $user user id + */ + public static function resizeTrash($user) { + $size = self::getTrashbinSize($user); + + $freeSpace = self::calculateFreeSpace($size, $user); + + if ($freeSpace < 0) { + self::scheduleExpire($user); + } + } + + /** + * clean up the trash bin + * + * @param string $user + */ + public static function expire($user) { + $trashBinSize = self::getTrashbinSize($user); + $availableSpace = self::calculateFreeSpace($trashBinSize, $user); + + $dirContent = Helper::getTrashFiles('/', $user, 'mtime'); + + // delete all files older then $retention_obligation + [$delSize, $count] = self::deleteExpiredFiles($dirContent, $user); + + $availableSpace += $delSize; + + // delete files from trash until we meet the trash bin size limit again + self::deleteFiles(array_slice($dirContent, $count), $user, $availableSpace); + } + + /** + * @param string $user + */ + private static function scheduleExpire($user) { + // let the admin disable auto expire + /** @var Application $application */ + $application = Server::get(Application::class); + $expiration = $application->getContainer()->query('Expiration'); + if ($expiration->isEnabled()) { + Server::get(IBus::class)->push(new Expire($user)); + } + } + + /** + * if the size limit for the trash bin is reached, we delete the oldest + * files in the trash bin until we meet the limit again + * + * @param array $files + * @param string $user + * @param int|float $availableSpace available disc space + * @return int|float size of deleted files + */ + protected static function deleteFiles(array $files, string $user, int|float $availableSpace): int|float { + /** @var Application $application */ + $application = Server::get(Application::class); + $expiration = $application->getContainer()->query('Expiration'); + $size = 0; + + if ($availableSpace < 0) { + foreach ($files as $file) { + if ($availableSpace < 0 && $expiration->isExpired($file['mtime'], true)) { + $tmp = self::delete($file['name'], $user, $file['mtime']); + Server::get(LoggerInterface::class)->info( + 'remove "' . $file['name'] . '" (' . $tmp . 'B) to meet the limit of trash bin size (50% of available quota) for user "{user}"', + [ + 'app' => 'files_trashbin', + 'user' => $user, + ] + ); + $availableSpace += $tmp; + $size += $tmp; + } else { + break; + } + } + } + return $size; + } + + /** + * delete files older then max storage time + * + * @param array $files list of files sorted by mtime + * @param string $user + * @return array{int|float, int} size of deleted files and number of deleted files + */ + public static function deleteExpiredFiles($files, $user) { + /** @var Expiration $expiration */ + $expiration = Server::get(Expiration::class); + $size = 0; + $count = 0; + foreach ($files as $file) { + $timestamp = $file['mtime']; + $filename = $file['name']; + if ($expiration->isExpired($timestamp)) { + try { + $size += self::delete($filename, $user, $timestamp); + $count++; + } catch (NotPermittedException $e) { + Server::get(LoggerInterface::class)->warning('Removing "' . $filename . '" from trashbin failed for user "{user}"', + [ + 'exception' => $e, + 'app' => 'files_trashbin', + 'user' => $user, + ] + ); + } + Server::get(LoggerInterface::class)->info( + 'Remove "' . $filename . '" from trashbin for user "{user}" because it exceeds max retention obligation term.', + [ + 'app' => 'files_trashbin', + 'user' => $user, + ], + ); + } else { + break; + } + } + + return [$size, $count]; + } + + /** + * recursive copy to copy a whole directory + * + * @param string $source source path, relative to the users files directory + * @param string $destination destination path relative to the users root directory + * @param View $view file view for the users root directory + * @return int|float + * @throws Exceptions\CopyRecursiveException + */ + private static function copy_recursive($source, $destination, View $view): int|float { + $size = 0; + if ($view->is_dir($source)) { + $view->mkdir($destination); + $view->touch($destination, $view->filemtime($source)); + foreach ($view->getDirectoryContent($source) as $i) { + $pathDir = $source . '/' . $i['name']; + if ($view->is_dir($pathDir)) { + $size += self::copy_recursive($pathDir, $destination . '/' . $i['name'], $view); + } else { + $size += $view->filesize($pathDir); + $result = $view->copy($pathDir, $destination . '/' . $i['name']); + if (!$result) { + throw new CopyRecursiveException(); + } + $view->touch($destination . '/' . $i['name'], $view->filemtime($pathDir)); + } + } + } else { + $size += $view->filesize($source); + $result = $view->copy($source, $destination); + if (!$result) { + throw new CopyRecursiveException(); + } + $view->touch($destination, $view->filemtime($source)); + } + return $size; + } + + /** + * find all versions which belong to the file we want to restore + * + * @param string $filename name of the file which should be restored + * @param int $timestamp timestamp when the file was deleted + */ + private static function getVersionsFromTrash($filename, $timestamp, string $user): array { + $view = new View('/' . $user . '/files_trashbin/versions'); + $versions = []; + + /** @var \OC\Files\Storage\Storage $storage */ + [$storage,] = $view->resolvePath('/'); + + $pattern = Server::get(IDBConnection::class)->escapeLikeParameter(basename($filename)); + if ($timestamp) { + // fetch for old versions + $escapedTimestamp = Server::get(IDBConnection::class)->escapeLikeParameter((string)$timestamp); + $pattern .= '.v%.d' . $escapedTimestamp; + $offset = -strlen($escapedTimestamp) - 2; + } else { + $pattern .= '.v%'; + } + + // Manually fetch all versions from the file cache to be able to filter them by their parent + $cache = $storage->getCache(''); + $query = new CacheQueryBuilder( + Server::get(IDBConnection::class)->getQueryBuilder(), + Server::get(IFilesMetadataManager::class), + ); + $normalizedParentPath = ltrim(Filesystem::normalizePath(dirname('files_trashbin/versions/' . $filename)), '/'); + $parentId = $cache->getId($normalizedParentPath); + if ($parentId === -1) { + return []; + } + + $query->selectFileCache() + ->whereStorageId($cache->getNumericStorageId()) + ->andWhere($query->expr()->eq('parent', $query->createNamedParameter($parentId))) + ->andWhere($query->expr()->iLike('name', $query->createNamedParameter($pattern))); + + $result = $query->executeQuery(); + $entries = $result->fetchAll(); + $result->closeCursor(); + + /** @var CacheEntry[] $matches */ + $matches = array_map(function (array $data) { + return Cache::cacheEntryFromData($data, Server::get(IMimeTypeLoader::class)); + }, $entries); + + foreach ($matches as $ma) { + if ($timestamp) { + $parts = explode('.v', substr($ma['path'], 0, $offset)); + $versions[] = end($parts); + } else { + $parts = explode('.v', $ma['path']); + $versions[] = end($parts); + } + } + + return $versions; + } + + /** + * find unique extension for restored file if a file with the same name already exists + * + * @param string $location where the file should be restored + * @param string $filename name of the file + * @param View $view filesystem view relative to users root directory + * @return string with unique extension + */ + private static function getUniqueFilename($location, $filename, View $view) { + $ext = pathinfo($filename, PATHINFO_EXTENSION); + $name = pathinfo($filename, PATHINFO_FILENAME); + $l = Util::getL10N('files_trashbin'); + + $location = '/' . trim($location, '/'); + + // if extension is not empty we set a dot in front of it + if ($ext !== '') { + $ext = '.' . $ext; + } + + if ($view->file_exists('files' . $location . '/' . $filename)) { + $i = 2; + $uniqueName = $name . ' (' . $l->t('restored') . ')' . $ext; + while ($view->file_exists('files' . $location . '/' . $uniqueName)) { + $uniqueName = $name . ' (' . $l->t('restored') . ' ' . $i . ')' . $ext; + $i++; + } + + return $uniqueName; + } + + return $filename; + } + + /** + * get the size from a given root folder + * + * @param View $view file view on the root folder + * @return int|float size of the folder + */ + private static function calculateSize(View $view): int|float { + $root = Server::get(IConfig::class)->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . $view->getAbsolutePath(''); + if (!file_exists($root)) { + return 0; + } + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root), \RecursiveIteratorIterator::CHILD_FIRST); + $size = 0; + + /** + * RecursiveDirectoryIterator on an NFS path isn't iterable with foreach + * This bug is fixed in PHP 5.5.9 or before + * See #8376 + */ + $iterator->rewind(); + while ($iterator->valid()) { + $path = $iterator->current(); + $relpath = substr($path, strlen($root) - 1); + if (!$view->is_dir($relpath)) { + $size += $view->filesize($relpath); + } + $iterator->next(); + } + return $size; + } + + /** + * get current size of trash bin from a given user + * + * @param string $user user who owns the trash bin + * @return int|float trash bin size + */ + private static function getTrashbinSize(string $user): int|float { + $view = new View('/' . $user); + $fileInfo = $view->getFileInfo('/files_trashbin'); + return isset($fileInfo['size']) ? $fileInfo['size'] : 0; + } + + /** + * check if trash bin is empty for a given user + * + * @param string $user + * @return bool + */ + public static function isEmpty($user) { + $view = new View('/' . $user . '/files_trashbin'); + if ($view->is_dir('/files') && $dh = $view->opendir('/files')) { + while (($file = readdir($dh)) !== false) { + if (!Filesystem::isIgnoredDir($file)) { + return false; + } + } + } + return true; + } + + /** + * @param $path + * @return string + */ + public static function preview_icon($path) { + return Server::get(IURLGenerator::class)->linkToRoute('core_ajax_trashbin_preview', ['x' => 32, 'y' => 32, 'file' => $path]); + } + + /** + * Return the filename used in the trash bin + */ + public static function getTrashFilename(string $filename, int $timestamp): string { + $trashFilename = $filename . '.d' . $timestamp; + $length = strlen($trashFilename); + // oc_filecache `name` column has a limit of 250 chars + $maxLength = 250; + if ($length > $maxLength) { + $trashFilename = substr_replace( + $trashFilename, + '', + $maxLength / 2, + $length - $maxLength + ); + } + return $trashFilename; + } + + private static function getNodeForPath(string $path): Node { + $user = OC_User::getUser(); + $rootFolder = Server::get(IRootFolder::class); + + if ($user !== false) { + $userFolder = $rootFolder->getUserFolder($user); + /** @var Folder */ + $trashFolder = $userFolder->getParent()->get('files_trashbin/files'); + try { + return $trashFolder->get($path); + } catch (NotFoundException $ex) { + } + } + + $view = Server::get(View::class); + $fsView = Filesystem::getView(); + if ($fsView === null) { + throw new Exception('View should not be null'); + } + + $fullPath = $fsView->getAbsolutePath($path); + + if (Filesystem::is_dir($path)) { + return new NonExistingFolder($rootFolder, $view, $fullPath); + } else { + return new NonExistingFile($rootFolder, $view, $fullPath); + } + } + + public function handle(Event $event): void { + if ($event instanceof BeforeNodeDeletedEvent) { + self::ensureFileScannedHook($event->getNode()); + } + } +} diff --git a/apps/files_trashbin/lib/UserMigration/TrashbinMigrator.php b/apps/files_trashbin/lib/UserMigration/TrashbinMigrator.php new file mode 100644 index 00000000000..baff1ef6032 --- /dev/null +++ b/apps/files_trashbin/lib/UserMigration/TrashbinMigrator.php @@ -0,0 +1,164 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_Trashbin\UserMigration; + +use OCA\Files_Trashbin\AppInfo\Application; +use OCA\Files_Trashbin\Trashbin; +use OCP\Files\Folder; +use OCP\Files\IRootFolder; +use OCP\Files\NotFoundException; +use OCP\IDBConnection; +use OCP\IL10N; +use OCP\IUser; +use OCP\UserMigration\IExportDestination; +use OCP\UserMigration\IImportSource; +use OCP\UserMigration\IMigrator; +use OCP\UserMigration\ISizeEstimationMigrator; +use OCP\UserMigration\TMigratorBasicVersionHandling; +use OCP\UserMigration\UserMigrationException; +use Symfony\Component\Console\Output\OutputInterface; + +class TrashbinMigrator implements IMigrator, ISizeEstimationMigrator { + + use TMigratorBasicVersionHandling; + + protected const PATH_FILES_FOLDER = Application::APP_ID . '/files'; + protected const PATH_LOCATIONS_FILE = Application::APP_ID . '/locations.json'; + + public function __construct( + protected IRootFolder $root, + protected IDBConnection $dbc, + protected IL10N $l10n, + ) { + } + + /** + * {@inheritDoc} + */ + public function getEstimatedExportSize(IUser $user): int|float { + $uid = $user->getUID(); + + try { + $trashbinFolder = $this->root->get('/' . $uid . '/files_trashbin'); + if (!$trashbinFolder instanceof Folder) { + return 0; + } + return ceil($trashbinFolder->getSize() / 1024); + } catch (\Throwable $e) { + return 0; + } + } + + /** + * {@inheritDoc} + */ + public function export(IUser $user, IExportDestination $exportDestination, OutputInterface $output): void { + $output->writeln('Exporting trashbin into ' . Application::APP_ID . '…'); + + $uid = $user->getUID(); + + try { + $trashbinFolder = $this->root->get('/' . $uid . '/files_trashbin'); + if (!$trashbinFolder instanceof Folder) { + throw new UserMigrationException('/' . $uid . '/files_trashbin is not a folder'); + } + $output->writeln('Exporting trashbin files…'); + $exportDestination->copyFolder($trashbinFolder, static::PATH_FILES_FOLDER); + $originalLocations = []; + // TODO Export all extra data and bump migrator to v2 + foreach (Trashbin::getExtraData($uid) as $filename => $extraData) { + $locationData = []; + foreach ($extraData as $timestamp => ['location' => $location]) { + $locationData[$timestamp] = $location; + } + $originalLocations[$filename] = $locationData; + } + $exportDestination->addFileContents(static::PATH_LOCATIONS_FILE, json_encode($originalLocations)); + } catch (NotFoundException $e) { + $output->writeln('No trashbin to export…'); + } catch (\Throwable $e) { + throw new UserMigrationException('Could not export trashbin: ' . $e->getMessage(), 0, $e); + } + } + + /** + * {@inheritDoc} + */ + public function import(IUser $user, IImportSource $importSource, OutputInterface $output): void { + if ($importSource->getMigratorVersion($this->getId()) === null) { + $output->writeln('No version for ' . static::class . ', skipping import…'); + return; + } + + $output->writeln('Importing trashbin from ' . Application::APP_ID . '…'); + + $uid = $user->getUID(); + + if ($importSource->pathExists(static::PATH_FILES_FOLDER)) { + try { + $trashbinFolder = $this->root->get('/' . $uid . '/files_trashbin'); + if (!$trashbinFolder instanceof Folder) { + throw new UserMigrationException('Could not import trashbin, /' . $uid . '/files_trashbin is not a folder'); + } + } catch (NotFoundException $e) { + $trashbinFolder = $this->root->newFolder('/' . $uid . '/files_trashbin'); + } + $output->writeln('Importing trashbin files…'); + try { + $importSource->copyToFolder($trashbinFolder, static::PATH_FILES_FOLDER); + } catch (\Throwable $e) { + throw new UserMigrationException('Could not import trashbin.', 0, $e); + } + $locations = json_decode($importSource->getFileContents(static::PATH_LOCATIONS_FILE), true, 512, JSON_THROW_ON_ERROR); + $qb = $this->dbc->getQueryBuilder(); + $qb->insert('files_trash') + ->values([ + 'id' => $qb->createParameter('id'), + 'timestamp' => $qb->createParameter('timestamp'), + 'location' => $qb->createParameter('location'), + 'user' => $qb->createNamedParameter($uid), + ]); + foreach ($locations as $id => $fileLocations) { + foreach ($fileLocations as $timestamp => $location) { + $qb + ->setParameter('id', $id) + ->setParameter('timestamp', $timestamp) + ->setParameter('location', $location) + ; + + $qb->executeStatement(); + } + } + } else { + $output->writeln('No trashbin to import…'); + } + } + + /** + * {@inheritDoc} + */ + public function getId(): string { + return 'trashbin'; + } + + /** + * {@inheritDoc} + */ + public function getDisplayName(): string { + return $this->l10n->t('Deleted files'); + } + + /** + * {@inheritDoc} + */ + public function getDescription(): string { + return $this->l10n->t('Deleted files and folders in the trash bin (may expire during export if you are low on storage space)'); + } +} diff --git a/apps/files_trashbin/lib/backgroundjob/expiretrash.php b/apps/files_trashbin/lib/backgroundjob/expiretrash.php deleted file mode 100644 index 4ee0658840b..00000000000 --- a/apps/files_trashbin/lib/backgroundjob/expiretrash.php +++ /dev/null @@ -1,134 +0,0 @@ -<?php -/** - * @author Lukas Reschke <lukas@owncloud.com> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\Files_Trashbin\BackgroundJob; - -use OCP\IConfig; -use OCP\IUserManager; -use OCA\Files_Trashbin\AppInfo\Application; -use OCA\Files_Trashbin\Expiration; -use OCA\Files_Trashbin\Helper; -use OCA\Files_Trashbin\Trashbin; - -class ExpireTrash extends \OC\BackgroundJob\TimedJob { - - const ITEMS_PER_SESSION = 1000; - - /** - * @var Expiration - */ - private $expiration; - - /** - * @var IConfig - */ - private $config; - - /** - * @var IUserManager - */ - private $userManager; - - const USERS_PER_SESSION = 1000; - - /** - * @param IConfig|null $config - * @param IUserManager|null $userManager - * @param Expiration|null $expiration - */ - public function __construct(IConfig $config = null, - IUserManager $userManager = null, - Expiration $expiration = null) { - // Run once per 30 minutes - $this->setInterval(60 * 30); - - if (is_null($expiration) || is_null($userManager) || is_null($config)) { - $this->fixDIForJobs(); - } else { - $this->config = $config; - $this->userManager = $userManager; - $this->expiration = $expiration; - } - } - - protected function fixDIForJobs() { - $application = new Application(); - $this->config = \OC::$server->getConfig(); - $this->userManager = \OC::$server->getUserManager(); - $this->expiration = $application->getContainer()->query('Expiration'); - } - - /** - * @param $argument - * @throws \Exception - */ - protected function run($argument) { - $maxAge = $this->expiration->getMaxAgeAsTimestamp(); - if (!$maxAge) { - return; - } - - $offset = $this->config->getAppValue('files_trashbin', 'cronjob_user_offset', 0); - $users = $this->userManager->search('', self::USERS_PER_SESSION, $offset); - if (!count($users)) { - // No users found, reset offset and retry - $offset = 0; - $users = $this->userManager->search('', self::USERS_PER_SESSION); - } - - $offset += self::USERS_PER_SESSION; - $this->config->setAppValue('files_trashbin', 'cronjob_user_offset', $offset); - - foreach ($users as $user) { - $uid = $user->getUID(); - if (!$this->setupFS($uid)) { - continue; - } - $dirContent = Helper::getTrashFiles('/', $uid, 'mtime'); - Trashbin::deleteExpiredFiles($dirContent, $uid); - } - - \OC_Util::tearDownFS(); - } - - /** - * Act on behalf on trash item owner - * @param string $user - * @return boolean - */ - private function setupFS($user){ - if (!$this->userManager->userExists($user)) { - return false; - } - - //Check if this user has a trashbin directory - $view = new \OC\Files\View('/' . $user); - if (!$view->is_dir('/files_trashbin/files')){ - return false; - } - - \OC_Util::tearDownFS(); - \OC_Util::setupFS($user); - - return true; - } -} diff --git a/apps/files_trashbin/lib/capabilities.php b/apps/files_trashbin/lib/capabilities.php deleted file mode 100644 index 43daa8cfa47..00000000000 --- a/apps/files_trashbin/lib/capabilities.php +++ /dev/null @@ -1,48 +0,0 @@ -<?php -/** - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <rullzer@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\Files_Trashbin; - -use OCP\Capabilities\ICapability; - -/** - * Class Capabilities - * - * @package OCA\Files_Trashbin - */ -class Capabilities implements ICapability { - - /** - * Return this classes capabilities - * - * @return array - */ - public function getCapabilities() { - return [ - 'files' => [ - 'undelete' => true - ] - ]; - } - -} diff --git a/apps/files_trashbin/lib/exceptions/copyrecursiveexception.php b/apps/files_trashbin/lib/exceptions/copyrecursiveexception.php deleted file mode 100644 index 8ebc8dfbfe1..00000000000 --- a/apps/files_trashbin/lib/exceptions/copyrecursiveexception.php +++ /dev/null @@ -1,26 +0,0 @@ -<?php -/** - * @author Björn Schießle <schiessle@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\Files_Trashbin\Exceptions; - -class CopyRecursiveException extends \Exception { -} diff --git a/apps/files_trashbin/lib/helper.php b/apps/files_trashbin/lib/helper.php deleted file mode 100644 index f56bbb12a91..00000000000 --- a/apps/files_trashbin/lib/helper.php +++ /dev/null @@ -1,129 +0,0 @@ -<?php -/** - * @author Björn Schießle <schiessle@owncloud.com> - * @author Joas Schilling <nickvergessen@owncloud.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <icewind@owncloud.com> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ -namespace OCA\Files_Trashbin; - -use OC\Files\FileInfo; -use OCP\Constants; - -class Helper -{ - /** - * Retrieves the contents of a trash bin directory. - * - * @param string $dir path to the directory inside the trashbin - * or empty to retrieve the root of the trashbin - * @param string $user - * @param string $sortAttribute attribute to sort on or empty to disable sorting - * @param bool $sortDescending true for descending sort, false otherwise - * @return \OCP\Files\FileInfo[] - */ - public static function getTrashFiles($dir, $user, $sortAttribute = '', $sortDescending = false){ - $result = array(); - $timestamp = null; - - $view = new \OC\Files\View('/' . $user . '/files_trashbin/files'); - - if (ltrim($dir, '/') !== '' && !$view->is_dir($dir)) { - throw new \Exception('Directory does not exists'); - } - - $dirContent = $view->opendir($dir); - if ($dirContent === false) { - return $result; - } - - $mount = $view->getMount($dir); - $storage = $mount->getStorage(); - $absoluteDir = $view->getAbsolutePath($dir); - $internalPath = $mount->getInternalPath($absoluteDir); - - if (is_resource($dirContent)) { - $originalLocations = \OCA\Files_Trashbin\Trashbin::getLocations($user); - while (($entryName = readdir($dirContent)) !== false) { - if (!\OC\Files\Filesystem::isIgnoredDir($entryName)) { - $id = $entryName; - if ($dir === '' || $dir === '/') { - $size = $view->filesize($id); - $pathparts = pathinfo($entryName); - $timestamp = substr($pathparts['extension'], 1); - $id = $pathparts['filename']; - - } else if ($timestamp === null) { - // for subfolders we need to calculate the timestamp only once - $size = $view->filesize($dir . '/' . $id); - $parts = explode('/', ltrim($dir, '/')); - $timestamp = substr(pathinfo($parts[0], PATHINFO_EXTENSION), 1); - } - $originalPath = ''; - if (isset($originalLocations[$id][$timestamp])) { - $originalPath = $originalLocations[$id][$timestamp]; - if (substr($originalPath, -1) === '/') { - $originalPath = substr($originalPath, 0, -1); - } - } - $i = array( - 'name' => $id, - 'mtime' => $timestamp, - 'mimetype' => $view->is_dir($dir . '/' . $entryName) ? 'httpd/unix-directory' : \OC::$server->getMimeTypeDetector()->detectPath($id), - 'type' => $view->is_dir($dir . '/' . $entryName) ? 'dir' : 'file', - 'directory' => ($dir === '/') ? '' : $dir, - 'size' => $size, - 'etag' => '', - 'permissions' => Constants::PERMISSION_ALL - Constants::PERMISSION_SHARE - ); - if ($originalPath) { - $i['extraData'] = $originalPath.'/'.$id; - } - $result[] = new FileInfo($absoluteDir . '/' . $i['name'], $storage, $internalPath . '/' . $i['name'], $i, $mount); - } - } - closedir($dirContent); - } - - if ($sortAttribute !== '') { - return \OCA\Files\Helper::sortFiles($result, $sortAttribute, $sortDescending); - } - return $result; - } - - /** - * Format file infos for JSON - * @param \OCP\Files\FileInfo[] $fileInfos file infos - */ - public static function formatFileInfos($fileInfos) { - $files = array(); - $id = 0; - foreach ($fileInfos as $i) { - $entry = \OCA\Files\Helper::formatFileInfo($i); - $entry['id'] = $id++; - $entry['etag'] = $entry['mtime']; // add fake etag, it is only needed to identify the preview image - $entry['permissions'] = \OCP\Constants::PERMISSION_READ; - $files[] = $entry; - } - return $files; - } -} diff --git a/apps/files_trashbin/lib/hooks.php b/apps/files_trashbin/lib/hooks.php deleted file mode 100644 index 89063bcb33e..00000000000 --- a/apps/files_trashbin/lib/hooks.php +++ /dev/null @@ -1,53 +0,0 @@ -<?php -/** - * @author Björn Schießle <schiessle@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -/** - * This class contains all hooks. - */ - -namespace OCA\Files_Trashbin; - -class Hooks { - - /** - * clean up user specific settings if user gets deleted - * @param array $params array with uid - * - * This function is connected to the pre_deleteUser signal of OC_Users - * to remove the used space for the trash bin stored in the database - */ - public static function deleteUser_hook($params) { - if( \OCP\App::isEnabled('files_trashbin') ) { - $uid = $params['uid']; - Trashbin::deleteUser($uid); - } - } - - public static function post_write_hook($params) { - $user = \OCP\User::getUser(); - if (!empty($user)) { - Trashbin::resizeTrash($user); - } - } -} diff --git a/apps/files_trashbin/lib/storage.php b/apps/files_trashbin/lib/storage.php deleted file mode 100644 index c4c523810ac..00000000000 --- a/apps/files_trashbin/lib/storage.php +++ /dev/null @@ -1,189 +0,0 @@ -<?php -/** - * @author Björn Schießle <schiessle@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <icewind@owncloud.com> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\Files_Trashbin; - -use OC\Files\Filesystem; -use OC\Files\Storage\Wrapper\Wrapper; -use OC\Files\View; -use OCP\IUserManager; - -class Storage extends Wrapper { - - private $mountPoint; - // remember already deleted files to avoid infinite loops if the trash bin - // move files across storages - private $deletedFiles = array(); - - /** - * Disable trash logic - * - * @var bool - */ - private static $disableTrash = false; - - /** @var IUserManager */ - private $userManager; - - function __construct($parameters, IUserManager $userManager = null) { - $this->mountPoint = $parameters['mountPoint']; - $this->userManager = $userManager; - parent::__construct($parameters); - } - - /** - * @internal - */ - public static function preRenameHook($params) { - // in cross-storage cases, a rename is a copy + unlink, - // that last unlink must not go to trash - self::$disableTrash = true; - } - - /** - * @internal - */ - public static function postRenameHook($params) { - self::$disableTrash = false; - } - - /** - * Rename path1 to path2 by calling the wrapped storage. - * - * @param string $path1 first path - * @param string $path2 second path - */ - public function rename($path1, $path2) { - $result = $this->storage->rename($path1, $path2); - if ($result === false) { - // when rename failed, the post_rename hook isn't triggered, - // but we still want to reenable the trash logic - self::$disableTrash = false; - } - return $result; - } - - /** - * Deletes the given file by moving it into the trashbin. - * - * @param string $path path of file or folder to delete - * - * @return bool true if the operation succeeded, false otherwise - */ - public function unlink($path) { - return $this->doDelete($path, 'unlink'); - } - - /** - * Deletes the given folder by moving it into the trashbin. - * - * @param string $path path of folder to delete - * - * @return bool true if the operation succeeded, false otherwise - */ - public function rmdir($path) { - return $this->doDelete($path, 'rmdir'); - } - - /** - * check if it is a file located in data/user/files only files in the - * 'files' directory should be moved to the trash - * - * @param $path - * @return bool - */ - protected function shouldMoveToTrash($path){ - $normalized = Filesystem::normalizePath($this->mountPoint . '/' . $path); - $parts = explode('/', $normalized); - if (count($parts) < 4) { - return false; - } - - if ($this->userManager->userExists($parts[1]) && $parts[2] == 'files') { - return true; - } - - return false; - } - - /** - * Run the delete operation with the given method - * - * @param string $path path of file or folder to delete - * @param string $method either "unlink" or "rmdir" - * - * @return bool true if the operation succeeded, false otherwise - */ - private function doDelete($path, $method) { - if (self::$disableTrash - || !\OC_App::isEnabled('files_trashbin') - || (pathinfo($path, PATHINFO_EXTENSION) === 'part') - || $this->shouldMoveToTrash($path) === false - ) { - return call_user_func_array([$this->storage, $method], [$path]); - } - - // check permissions before we continue, this is especially important for - // shared files - if (!$this->isDeletable($path)) { - return false; - } - - $normalized = Filesystem::normalizePath($this->mountPoint . '/' . $path); - $result = true; - $view = Filesystem::getView(); - if (!isset($this->deletedFiles[$normalized]) && $view instanceof View) { - $this->deletedFiles[$normalized] = $normalized; - if ($filesPath = $view->getRelativePath($normalized)) { - $filesPath = trim($filesPath, '/'); - $result = \OCA\Files_Trashbin\Trashbin::move2trash($filesPath); - // in cross-storage cases the file will be copied - // but not deleted, so we delete it here - if ($result) { - call_user_func_array([$this->storage, $method], [$path]); - } - } else { - $result = call_user_func_array([$this->storage, $method], [$path]); - } - unset($this->deletedFiles[$normalized]); - } else if ($this->storage->file_exists($path)) { - $result = call_user_func_array([$this->storage, $method], [$path]); - } - - return $result; - } - - /** - * Setup the storate wrapper callback - */ - public static function setupStorage() { - \OC\Files\Filesystem::addStorageWrapper('oc_trashbin', function ($mountPoint, $storage) { - return new \OCA\Files_Trashbin\Storage( - array('storage' => $storage, 'mountPoint' => $mountPoint), - \OC::$server->getUserManager() - ); - }, 1); - } - -} diff --git a/apps/files_trashbin/lib/trashbin.php b/apps/files_trashbin/lib/trashbin.php deleted file mode 100644 index 52692950a65..00000000000 --- a/apps/files_trashbin/lib/trashbin.php +++ /dev/null @@ -1,926 +0,0 @@ -<?php -/** - * @author Bart Visscher <bartv@thisnet.nl> - * @author Bastien Ho <bastienho@urbancube.fr> - * @author Björn Schießle <schiessle@owncloud.com> - * @author Florin Peter <github@florin-peter.de> - * @author Georg Ehrke <georg@owncloud.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Qingping Hou <dave2008713@gmail.com> - * @author Robin Appelman <icewind@owncloud.com> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <rullzer@owncloud.com> - * @author Sjors van der Pluijm <sjors@desjors.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\Files_Trashbin; - -use OC\Files\Filesystem; -use OC\Files\View; -use OCA\Files_Trashbin\AppInfo\Application; -use OCA\Files_Trashbin\Command\Expire; -use OCP\Files\NotFoundException; -use OCP\User; - -class Trashbin { - - // unit: percentage; 50% of available disk space/quota - const DEFAULTMAXSIZE = 50; - - /** - * Whether versions have already be rescanned during this PHP request - * - * @var bool - */ - private static $scannedVersions = false; - - /** - * Ensure we don't need to scan the file during the move to trash - * by triggering the scan in the pre-hook - * - * @param array $params - */ - public static function ensureFileScannedHook($params) { - try { - self::getUidAndFilename($params['path']); - } catch (NotFoundException $e) { - // nothing to scan for non existing files - } - } - - /** - * get the UID of the owner of the file and the path to the file relative to - * owners files folder - * - * @param string $filename - * @return array - * @throws \OC\User\NoUserException - */ - public static function getUidAndFilename($filename) { - $uid = Filesystem::getOwner($filename); - $userManager = \OC::$server->getUserManager(); - // if the user with the UID doesn't exists, e.g. because the UID points - // to a remote user with a federated cloud ID we use the current logged-in - // user. We need a valid local user to move the file to the right trash bin - if (!$userManager->userExists($uid)) { - $uid = User::getUser(); - } - Filesystem::initMountPoints($uid); - if ($uid != User::getUser()) { - $info = Filesystem::getFileInfo($filename); - $ownerView = new View('/' . $uid . '/files'); - try { - $filename = $ownerView->getPath($info['fileid']); - } catch (NotFoundException $e) { - $filename = null; - } - } - return [$uid, $filename]; - } - - /** - * get original location of files for user - * - * @param string $user - * @return array (filename => array (timestamp => original location)) - */ - public static function getLocations($user) { - $query = \OC_DB::prepare('SELECT `id`, `timestamp`, `location`' - . ' FROM `*PREFIX*files_trash` WHERE `user`=?'); - $result = $query->execute(array($user)); - $array = array(); - while ($row = $result->fetchRow()) { - if (isset($array[$row['id']])) { - $array[$row['id']][$row['timestamp']] = $row['location']; - } else { - $array[$row['id']] = array($row['timestamp'] => $row['location']); - } - } - return $array; - } - - /** - * get original location of file - * - * @param string $user - * @param string $filename - * @param string $timestamp - * @return string original location - */ - public static function getLocation($user, $filename, $timestamp) { - $query = \OC_DB::prepare('SELECT `location` FROM `*PREFIX*files_trash`' - . ' WHERE `user`=? AND `id`=? AND `timestamp`=?'); - $result = $query->execute(array($user, $filename, $timestamp))->fetchAll(); - if (isset($result[0]['location'])) { - return $result[0]['location']; - } else { - return false; - } - } - - private static function setUpTrash($user) { - $view = new View('/' . $user); - if (!$view->is_dir('files_trashbin')) { - $view->mkdir('files_trashbin'); - } - if (!$view->is_dir('files_trashbin/files')) { - $view->mkdir('files_trashbin/files'); - } - if (!$view->is_dir('files_trashbin/versions')) { - $view->mkdir('files_trashbin/versions'); - } - if (!$view->is_dir('files_trashbin/keys')) { - $view->mkdir('files_trashbin/keys'); - } - } - - - /** - * copy file to owners trash - * - * @param string $sourcePath - * @param string $owner - * @param string $targetPath - * @param $user - * @param integer $timestamp - */ - private static function copyFilesToUser($sourcePath, $owner, $targetPath, $user, $timestamp) { - self::setUpTrash($owner); - - $targetFilename = basename($targetPath); - $targetLocation = dirname($targetPath); - - $sourceFilename = basename($sourcePath); - - $view = new View('/'); - - $target = $user . '/files_trashbin/files/' . $targetFilename . '.d' . $timestamp; - $source = $owner . '/files_trashbin/files/' . $sourceFilename . '.d' . $timestamp; - self::copy_recursive($source, $target, $view); - - - if ($view->file_exists($target)) { - $query = \OC_DB::prepare("INSERT INTO `*PREFIX*files_trash` (`id`,`timestamp`,`location`,`user`) VALUES (?,?,?,?)"); - $result = $query->execute(array($targetFilename, $timestamp, $targetLocation, $user)); - if (!$result) { - \OCP\Util::writeLog('files_trashbin', 'trash bin database couldn\'t be updated for the files owner', \OCP\Util::ERROR); - } - } - } - - - /** - * move file to the trash bin - * - * @param string $file_path path to the deleted file/directory relative to the files root directory - * @return bool - */ - public static function move2trash($file_path) { - // get the user for which the filesystem is setup - $root = Filesystem::getRoot(); - list(, $user) = explode('/', $root); - list($owner, $ownerPath) = self::getUidAndFilename($file_path); - - $ownerView = new View('/' . $owner); - // file has been deleted in between - if (is_null($ownerPath) || $ownerPath === '' || !$ownerView->file_exists('/files/' . $ownerPath)) { - return true; - } - - self::setUpTrash($user); - if ($owner !== $user) { - // also setup for owner - self::setUpTrash($owner); - } - - $path_parts = pathinfo($ownerPath); - - $filename = $path_parts['basename']; - $location = $path_parts['dirname']; - $timestamp = time(); - - // disable proxy to prevent recursive calls - $trashPath = '/files_trashbin/files/' . $filename . '.d' . $timestamp; - - /** @var \OC\Files\Storage\Storage $trashStorage */ - list($trashStorage, $trashInternalPath) = $ownerView->resolvePath($trashPath); - /** @var \OC\Files\Storage\Storage $sourceStorage */ - list($sourceStorage, $sourceInternalPath) = $ownerView->resolvePath('/files/' . $ownerPath); - try { - $moveSuccessful = true; - if ($trashStorage->file_exists($trashInternalPath)) { - $trashStorage->unlink($trashInternalPath); - } - $trashStorage->moveFromStorage($sourceStorage, $sourceInternalPath, $trashInternalPath); - } catch (\OCA\Files_Trashbin\Exceptions\CopyRecursiveException $e) { - $moveSuccessful = false; - if ($trashStorage->file_exists($trashInternalPath)) { - $trashStorage->unlink($trashInternalPath); - } - \OCP\Util::writeLog('files_trashbin', 'Couldn\'t move ' . $file_path . ' to the trash bin', \OCP\Util::ERROR); - } - - if ($sourceStorage->file_exists($sourceInternalPath)) { // failed to delete the original file, abort - $sourceStorage->unlink($sourceInternalPath); - return false; - } - - $trashStorage->getUpdater()->renameFromStorage($sourceStorage, $sourceInternalPath, $trashInternalPath); - - if ($moveSuccessful) { - $query = \OC_DB::prepare("INSERT INTO `*PREFIX*files_trash` (`id`,`timestamp`,`location`,`user`) VALUES (?,?,?,?)"); - $result = $query->execute(array($filename, $timestamp, $location, $owner)); - if (!$result) { - \OCP\Util::writeLog('files_trashbin', 'trash bin database couldn\'t be updated', \OCP\Util::ERROR); - } - \OCP\Util::emitHook('\OCA\Files_Trashbin\Trashbin', 'post_moveToTrash', array('filePath' => Filesystem::normalizePath($file_path), - 'trashPath' => Filesystem::normalizePath($filename . '.d' . $timestamp))); - - self::retainVersions($filename, $owner, $ownerPath, $timestamp); - - // if owner !== user we need to also add a copy to the owners trash - if ($user !== $owner) { - self::copyFilesToUser($ownerPath, $owner, $file_path, $user, $timestamp); - } - } - - self::scheduleExpire($user); - - // if owner !== user we also need to update the owners trash size - if ($owner !== $user) { - self::scheduleExpire($owner); - } - - return $moveSuccessful; - } - - /** - * Move file versions to trash so that they can be restored later - * - * @param string $filename of deleted file - * @param string $owner owner user id - * @param string $ownerPath path relative to the owner's home storage - * @param integer $timestamp when the file was deleted - */ - private static function retainVersions($filename, $owner, $ownerPath, $timestamp) { - if (\OCP\App::isEnabled('files_versions') && !empty($ownerPath)) { - - $user = User::getUser(); - $rootView = new View('/'); - - if ($rootView->is_dir($owner . '/files_versions/' . $ownerPath)) { - if ($owner !== $user) { - self::copy_recursive($owner . '/files_versions/' . $ownerPath, $owner . '/files_trashbin/versions/' . basename($ownerPath) . '.d' . $timestamp, $rootView); - } - self::move($rootView, $owner . '/files_versions/' . $ownerPath, $user . '/files_trashbin/versions/' . $filename . '.d' . $timestamp); - } else if ($versions = \OCA\Files_Versions\Storage::getVersions($owner, $ownerPath)) { - - foreach ($versions as $v) { - if ($owner !== $user) { - self::copy($rootView, $owner . '/files_versions' . $v['path'] . '.v' . $v['version'], $owner . '/files_trashbin/versions/' . $v['name'] . '.v' . $v['version'] . '.d' . $timestamp); - } - self::move($rootView, $owner . '/files_versions' . $v['path'] . '.v' . $v['version'], $user . '/files_trashbin/versions/' . $filename . '.v' . $v['version'] . '.d' . $timestamp); - } - } - } - } - - /** - * Move a file or folder on storage level - * - * @param View $view - * @param string $source - * @param string $target - * @return bool - */ - private static function move(View $view, $source, $target) { - /** @var \OC\Files\Storage\Storage $sourceStorage */ - list($sourceStorage, $sourceInternalPath) = $view->resolvePath($source); - /** @var \OC\Files\Storage\Storage $targetStorage */ - list($targetStorage, $targetInternalPath) = $view->resolvePath($target); - /** @var \OC\Files\Storage\Storage $ownerTrashStorage */ - - $result = $targetStorage->moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); - if ($result) { - $targetStorage->getUpdater()->renameFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); - } - return $result; - } - - /** - * Copy a file or folder on storage level - * - * @param View $view - * @param string $source - * @param string $target - * @return bool - */ - private static function copy(View $view, $source, $target) { - /** @var \OC\Files\Storage\Storage $sourceStorage */ - list($sourceStorage, $sourceInternalPath) = $view->resolvePath($source); - /** @var \OC\Files\Storage\Storage $targetStorage */ - list($targetStorage, $targetInternalPath) = $view->resolvePath($target); - /** @var \OC\Files\Storage\Storage $ownerTrashStorage */ - - $result = $targetStorage->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); - if ($result) { - $targetStorage->getUpdater()->update($targetInternalPath); - } - return $result; - } - - /** - * Restore a file or folder from trash bin - * - * @param string $file path to the deleted file/folder relative to "files_trashbin/files/", - * including the timestamp suffix ".d12345678" - * @param string $filename name of the file/folder - * @param int $timestamp time when the file/folder was deleted - * - * @return bool true on success, false otherwise - */ - public static function restore($file, $filename, $timestamp) { - $user = User::getUser(); - $view = new View('/' . $user); - - $location = ''; - if ($timestamp) { - $location = self::getLocation($user, $filename, $timestamp); - if ($location === false) { - \OCP\Util::writeLog('files_trashbin', 'trash bin database inconsistent!', \OCP\Util::ERROR); - } else { - // if location no longer exists, restore file in the root directory - if ($location !== '/' && - (!$view->is_dir('files/' . $location) || - !$view->isCreatable('files/' . $location)) - ) { - $location = ''; - } - } - } - - // we need a extension in case a file/dir with the same name already exists - $uniqueFilename = self::getUniqueFilename($location, $filename, $view); - - $source = Filesystem::normalizePath('files_trashbin/files/' . $file); - $target = Filesystem::normalizePath('files/' . $location . '/' . $uniqueFilename); - if (!$view->file_exists($source)) { - return false; - } - $mtime = $view->filemtime($source); - - // restore file - $restoreResult = $view->rename($source, $target); - - // handle the restore result - if ($restoreResult) { - $fakeRoot = $view->getRoot(); - $view->chroot('/' . $user . '/files'); - $view->touch('/' . $location . '/' . $uniqueFilename, $mtime); - $view->chroot($fakeRoot); - \OCP\Util::emitHook('\OCA\Files_Trashbin\Trashbin', 'post_restore', array('filePath' => Filesystem::normalizePath('/' . $location . '/' . $uniqueFilename), - 'trashPath' => Filesystem::normalizePath($file))); - - self::restoreVersions($view, $file, $filename, $uniqueFilename, $location, $timestamp); - - if ($timestamp) { - $query = \OC_DB::prepare('DELETE FROM `*PREFIX*files_trash` WHERE `user`=? AND `id`=? AND `timestamp`=?'); - $query->execute(array($user, $filename, $timestamp)); - } - - return true; - } - - return false; - } - - /** - * restore versions from trash bin - * - * @param View $view file view - * @param string $file complete path to file - * @param string $filename name of file once it was deleted - * @param string $uniqueFilename new file name to restore the file without overwriting existing files - * @param string $location location if file - * @param int $timestamp deletion time - * @return false|null - */ - private static function restoreVersions(View $view, $file, $filename, $uniqueFilename, $location, $timestamp) { - - if (\OCP\App::isEnabled('files_versions')) { - - $user = User::getUser(); - $rootView = new View('/'); - - $target = Filesystem::normalizePath('/' . $location . '/' . $uniqueFilename); - - list($owner, $ownerPath) = self::getUidAndFilename($target); - - // file has been deleted in between - if (empty($ownerPath)) { - return false; - } - - if ($timestamp) { - $versionedFile = $filename; - } else { - $versionedFile = $file; - } - - if ($view->is_dir('/files_trashbin/versions/' . $file)) { - $rootView->rename(Filesystem::normalizePath($user . '/files_trashbin/versions/' . $file), Filesystem::normalizePath($owner . '/files_versions/' . $ownerPath)); - } else if ($versions = self::getVersionsFromTrash($versionedFile, $timestamp, $user)) { - foreach ($versions as $v) { - if ($timestamp) { - $rootView->rename($user . '/files_trashbin/versions/' . $versionedFile . '.v' . $v . '.d' . $timestamp, $owner . '/files_versions/' . $ownerPath . '.v' . $v); - } else { - $rootView->rename($user . '/files_trashbin/versions/' . $versionedFile . '.v' . $v, $owner . '/files_versions/' . $ownerPath . '.v' . $v); - } - } - } - } - } - - /** - * delete all files from the trash - */ - public static function deleteAll() { - $user = User::getUser(); - $view = new View('/' . $user); - $view->deleteAll('files_trashbin'); - $query = \OC_DB::prepare('DELETE FROM `*PREFIX*files_trash` WHERE `user`=?'); - $query->execute(array($user)); - $view->mkdir('files_trashbin'); - $view->mkdir('files_trashbin/files'); - - return true; - } - - /** - * delete file from trash bin permanently - * - * @param string $filename path to the file - * @param string $user - * @param int $timestamp of deletion time - * - * @return int size of deleted files - */ - public static function delete($filename, $user, $timestamp = null) { - $view = new View('/' . $user); - $size = 0; - - if ($timestamp) { - $query = \OC_DB::prepare('DELETE FROM `*PREFIX*files_trash` WHERE `user`=? AND `id`=? AND `timestamp`=?'); - $query->execute(array($user, $filename, $timestamp)); - $file = $filename . '.d' . $timestamp; - } else { - $file = $filename; - } - - $size += self::deleteVersions($view, $file, $filename, $timestamp, $user); - - if ($view->is_dir('/files_trashbin/files/' . $file)) { - $size += self::calculateSize(new View('/' . $user . '/files_trashbin/files/' . $file)); - } else { - $size += $view->filesize('/files_trashbin/files/' . $file); - } - \OC_Hook::emit('\OCP\Trashbin', 'preDelete', array('path' => '/files_trashbin/files/' . $file)); - $view->unlink('/files_trashbin/files/' . $file); - \OC_Hook::emit('\OCP\Trashbin', 'delete', array('path' => '/files_trashbin/files/' . $file)); - - return $size; - } - - /** - * @param View $view - * @param string $file - * @param string $filename - * @param integer|null $timestamp - * @param string $user - * @return int - */ - private static function deleteVersions(View $view, $file, $filename, $timestamp, $user) { - $size = 0; - if (\OCP\App::isEnabled('files_versions')) { - if ($view->is_dir('files_trashbin/versions/' . $file)) { - $size += self::calculateSize(new View('/' . $user . '/files_trashbin/versions/' . $file)); - $view->unlink('files_trashbin/versions/' . $file); - } else if ($versions = self::getVersionsFromTrash($filename, $timestamp, $user)) { - foreach ($versions as $v) { - if ($timestamp) { - $size += $view->filesize('/files_trashbin/versions/' . $filename . '.v' . $v . '.d' . $timestamp); - $view->unlink('/files_trashbin/versions/' . $filename . '.v' . $v . '.d' . $timestamp); - } else { - $size += $view->filesize('/files_trashbin/versions/' . $filename . '.v' . $v); - $view->unlink('/files_trashbin/versions/' . $filename . '.v' . $v); - } - } - } - } - return $size; - } - - /** - * check to see whether a file exists in trashbin - * - * @param string $filename path to the file - * @param int $timestamp of deletion time - * @return bool true if file exists, otherwise false - */ - public static function file_exists($filename, $timestamp = null) { - $user = User::getUser(); - $view = new View('/' . $user); - - if ($timestamp) { - $filename = $filename . '.d' . $timestamp; - } else { - $filename = $filename; - } - - $target = Filesystem::normalizePath('files_trashbin/files/' . $filename); - return $view->file_exists($target); - } - - /** - * deletes used space for trash bin in db if user was deleted - * - * @param string $uid id of deleted user - * @return bool result of db delete operation - */ - public static function deleteUser($uid) { - $query = \OC_DB::prepare('DELETE FROM `*PREFIX*files_trash` WHERE `user`=?'); - return $query->execute(array($uid)); - } - - /** - * calculate remaining free space for trash bin - * - * @param integer $trashbinSize current size of the trash bin - * @param string $user - * @return int available free space for trash bin - */ - private static function calculateFreeSpace($trashbinSize, $user) { - $softQuota = true; - $userObject = \OC::$server->getUserManager()->get($user); - if(is_null($userObject)) { - return 0; - } - $quota = $userObject->getQuota(); - if ($quota === null || $quota === 'none') { - $quota = Filesystem::free_space('/'); - $softQuota = false; - // inf or unknown free space - if ($quota < 0) { - $quota = PHP_INT_MAX; - } - } else { - $quota = \OCP\Util::computerFileSize($quota); - } - - // calculate available space for trash bin - // subtract size of files and current trash bin size from quota - if ($softQuota) { - $userFolder = \OC::$server->getUserFolder($user); - if(is_null($userFolder)) { - return 0; - } - $free = $quota - $userFolder->getSize(); // remaining free space for user - if ($free > 0) { - $availableSpace = ($free * self::DEFAULTMAXSIZE / 100) - $trashbinSize; // how much space can be used for versions - } else { - $availableSpace = $free - $trashbinSize; - } - } else { - $availableSpace = $quota; - } - - return $availableSpace; - } - - /** - * resize trash bin if necessary after a new file was added to ownCloud - * - * @param string $user user id - */ - public static function resizeTrash($user) { - - $size = self::getTrashbinSize($user); - - $freeSpace = self::calculateFreeSpace($size, $user); - - if ($freeSpace < 0) { - self::scheduleExpire($user); - } - } - - /** - * clean up the trash bin - * - * @param string $user - */ - public static function expire($user) { - $trashBinSize = self::getTrashbinSize($user); - $availableSpace = self::calculateFreeSpace($trashBinSize, $user); - - $dirContent = Helper::getTrashFiles('/', $user, 'mtime'); - - // delete all files older then $retention_obligation - list($delSize, $count) = self::deleteExpiredFiles($dirContent, $user); - - $availableSpace += $delSize; - - // delete files from trash until we meet the trash bin size limit again - self::deleteFiles(array_slice($dirContent, $count), $user, $availableSpace); - } - - /** - * @param string $user - */ - private static function scheduleExpire($user) { - // let the admin disable auto expire - $application = new Application(); - $expiration = $application->getContainer()->query('Expiration'); - if ($expiration->isEnabled()) { - \OC::$server->getCommandBus()->push(new Expire($user)); - } - } - - /** - * if the size limit for the trash bin is reached, we delete the oldest - * files in the trash bin until we meet the limit again - * - * @param array $files - * @param string $user - * @param int $availableSpace available disc space - * @return int size of deleted files - */ - protected static function deleteFiles($files, $user, $availableSpace) { - $application = new Application(); - $expiration = $application->getContainer()->query('Expiration'); - $size = 0; - - if ($availableSpace < 0) { - foreach ($files as $file) { - if ($availableSpace < 0 && $expiration->isExpired($file['mtime'], true)) { - $tmp = self::delete($file['name'], $user, $file['mtime']); - \OCP\Util::writeLog('files_trashbin', 'remove "' . $file['name'] . '" (' . $tmp . 'B) to meet the limit of trash bin size (50% of available quota)', \OCP\Util::INFO); - $availableSpace += $tmp; - $size += $tmp; - } else { - break; - } - } - } - return $size; - } - - /** - * delete files older then max storage time - * - * @param array $files list of files sorted by mtime - * @param string $user - * @return integer[] size of deleted files and number of deleted files - */ - public static function deleteExpiredFiles($files, $user) { - $application = new Application(); - $expiration = $application->getContainer()->query('Expiration'); - $size = 0; - $count = 0; - foreach ($files as $file) { - $timestamp = $file['mtime']; - $filename = $file['name']; - if ($expiration->isExpired($timestamp)) { - $count++; - $size += self::delete($filename, $user, $timestamp); - \OC::$server->getLogger()->info( - 'Remove "' . $filename . '" from trashbin because it exceeds max retention obligation term.', - ['app' => 'files_trashbin'] - ); - } else { - break; - } - } - - return array($size, $count); - } - - /** - * recursive copy to copy a whole directory - * - * @param string $source source path, relative to the users files directory - * @param string $destination destination path relative to the users root directoy - * @param View $view file view for the users root directory - * @return int - * @throws Exceptions\CopyRecursiveException - */ - private static function copy_recursive($source, $destination, View $view) { - $size = 0; - if ($view->is_dir($source)) { - $view->mkdir($destination); - $view->touch($destination, $view->filemtime($source)); - foreach ($view->getDirectoryContent($source) as $i) { - $pathDir = $source . '/' . $i['name']; - if ($view->is_dir($pathDir)) { - $size += self::copy_recursive($pathDir, $destination . '/' . $i['name'], $view); - } else { - $size += $view->filesize($pathDir); - $result = $view->copy($pathDir, $destination . '/' . $i['name']); - if (!$result) { - throw new \OCA\Files_Trashbin\Exceptions\CopyRecursiveException(); - } - $view->touch($destination . '/' . $i['name'], $view->filemtime($pathDir)); - } - } - } else { - $size += $view->filesize($source); - $result = $view->copy($source, $destination); - if (!$result) { - throw new \OCA\Files_Trashbin\Exceptions\CopyRecursiveException(); - } - $view->touch($destination, $view->filemtime($source)); - } - return $size; - } - - /** - * find all versions which belong to the file we want to restore - * - * @param string $filename name of the file which should be restored - * @param int $timestamp timestamp when the file was deleted - * @return array - */ - private static function getVersionsFromTrash($filename, $timestamp, $user) { - $view = new View('/' . $user . '/files_trashbin/versions'); - $versions = array(); - - //force rescan of versions, local storage may not have updated the cache - if (!self::$scannedVersions) { - /** @var \OC\Files\Storage\Storage $storage */ - list($storage,) = $view->resolvePath('/'); - $storage->getScanner()->scan('files_trashbin/versions'); - self::$scannedVersions = true; - } - - if ($timestamp) { - // fetch for old versions - $matches = $view->searchRaw($filename . '.v%.d' . $timestamp); - $offset = -strlen($timestamp) - 2; - } else { - $matches = $view->searchRaw($filename . '.v%'); - } - - if (is_array($matches)) { - foreach ($matches as $ma) { - if ($timestamp) { - $parts = explode('.v', substr($ma['path'], 0, $offset)); - $versions[] = (end($parts)); - } else { - $parts = explode('.v', $ma); - $versions[] = (end($parts)); - } - } - } - return $versions; - } - - /** - * find unique extension for restored file if a file with the same name already exists - * - * @param string $location where the file should be restored - * @param string $filename name of the file - * @param View $view filesystem view relative to users root directory - * @return string with unique extension - */ - private static function getUniqueFilename($location, $filename, View $view) { - $ext = pathinfo($filename, PATHINFO_EXTENSION); - $name = pathinfo($filename, PATHINFO_FILENAME); - $l = \OC::$server->getL10N('files_trashbin'); - - $location = '/' . trim($location, '/'); - - // if extension is not empty we set a dot in front of it - if ($ext !== '') { - $ext = '.' . $ext; - } - - if ($view->file_exists('files' . $location . '/' . $filename)) { - $i = 2; - $uniqueName = $name . " (" . $l->t("restored") . ")" . $ext; - while ($view->file_exists('files' . $location . '/' . $uniqueName)) { - $uniqueName = $name . " (" . $l->t("restored") . " " . $i . ")" . $ext; - $i++; - } - - return $uniqueName; - } - - return $filename; - } - - /** - * get the size from a given root folder - * - * @param View $view file view on the root folder - * @return integer size of the folder - */ - private static function calculateSize($view) { - $root = \OC::$server->getConfig()->getSystemValue('datadirectory') . $view->getAbsolutePath(''); - if (!file_exists($root)) { - return 0; - } - $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root), \RecursiveIteratorIterator::CHILD_FIRST); - $size = 0; - - /** - * RecursiveDirectoryIterator on an NFS path isn't iterable with foreach - * This bug is fixed in PHP 5.5.9 or before - * See #8376 - */ - $iterator->rewind(); - while ($iterator->valid()) { - $path = $iterator->current(); - $relpath = substr($path, strlen($root) - 1); - if (!$view->is_dir($relpath)) { - $size += $view->filesize($relpath); - } - $iterator->next(); - } - return $size; - } - - /** - * get current size of trash bin from a given user - * - * @param string $user user who owns the trash bin - * @return integer trash bin size - */ - private static function getTrashbinSize($user) { - $view = new View('/' . $user); - $fileInfo = $view->getFileInfo('/files_trashbin'); - return isset($fileInfo['size']) ? $fileInfo['size'] : 0; - } - - /** - * register hooks - */ - public static function registerHooks() { - // create storage wrapper on setup - \OCP\Util::connectHook('OC_Filesystem', 'preSetup', 'OCA\Files_Trashbin\Storage', 'setupStorage'); - //Listen to delete user signal - \OCP\Util::connectHook('OC_User', 'pre_deleteUser', 'OCA\Files_Trashbin\Hooks', 'deleteUser_hook'); - //Listen to post write hook - \OCP\Util::connectHook('OC_Filesystem', 'post_write', 'OCA\Files_Trashbin\Hooks', 'post_write_hook'); - // pre and post-rename, disable trash logic for the copy+unlink case - \OCP\Util::connectHook('OC_Filesystem', 'delete', 'OCA\Files_Trashbin\Trashbin', 'ensureFileScannedHook'); - \OCP\Util::connectHook('OC_Filesystem', 'rename', 'OCA\Files_Trashbin\Storage', 'preRenameHook'); - \OCP\Util::connectHook('OC_Filesystem', 'post_rename', 'OCA\Files_Trashbin\Storage', 'postRenameHook'); - } - - /** - * check if trash bin is empty for a given user - * - * @param string $user - * @return bool - */ - public static function isEmpty($user) { - - $view = new View('/' . $user . '/files_trashbin'); - if ($view->is_dir('/files') && $dh = $view->opendir('/files')) { - while ($file = readdir($dh)) { - if (!Filesystem::isIgnoredDir($file)) { - return false; - } - } - } - return true; - } - - /** - * @param $path - * @return string - */ - public static function preview_icon($path) { - return \OCP\Util::linkToRoute('core_ajax_trashbin_preview', array('x' => 32, 'y' => 32, 'file' => $path)); - } -} |