diff options
Diffstat (limited to 'apps/files_trashbin/lib')
46 files changed, 3277 insertions, 1028 deletions
diff --git a/apps/files_trashbin/lib/AppInfo/Application.php b/apps/files_trashbin/lib/AppInfo/Application.php index ee1c9481286..76d566f4286 100644 --- a/apps/files_trashbin/lib/AppInfo/Application.php +++ b/apps/files_trashbin/lib/AppInfo/Application.php @@ -1,49 +1,96 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * - * @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\AppInfo; -use OCP\AppFramework\App; +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']; -class Application extends App { - public function __construct (array $urlParams = []) { - parent::__construct('files_trashbin', $urlParams); - - $container = $this->getContainer(); - /* - * Register capabilities - */ - $container->registerCapability('OCA\Files_Trashbin\Capabilities'); - - /* - * Register expiration - */ - $container->registerService('Expiration', function($c) { - return new Expiration( - $c->query('ServerContainer')->getConfig(), - $c->query('OCP\AppFramework\Utility\ITimeFactory') - ); - }); + 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 index e19b7ce604f..bb383dab78d 100644 --- a/apps/files_trashbin/lib/BackgroundJob/ExpireTrash.php +++ b/apps/files_trashbin/lib/BackgroundJob/ExpireTrash.php @@ -1,105 +1,84 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * - * @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: 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 OCP\IUser; -use OCP\IUserManager; -use OCA\Files_Trashbin\AppInfo\Application; +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 \OC\BackgroundJob\TimedJob { - - /** - * @var Expiration - */ - private $expiration; - - /** - * @var IUserManager - */ - private $userManager; - - /** - * @param IUserManager|null $userManager - * @param Expiration|null $expiration - */ - public function __construct(IUserManager $userManager = null, - Expiration $expiration = null) { +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); - - if (is_null($expiration) || is_null($userManager)) { - $this->fixDIForJobs(); - } else { - $this->userManager = $userManager; - $this->expiration = $expiration; - } } - protected function fixDIForJobs() { - $application = new Application(); - $this->userManager = \OC::$server->getUserManager(); - $this->expiration = $application->getContainer()->query('Expiration'); - } - - /** - * @param $argument - * @throws \Exception - */ 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; } - $this->userManager->callForSeenUsers(function(IUser $user) { - $uid = $user->getUID(); - if (!$this->setupFS($uid)) { + $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; } - $dirContent = Helper::getTrashFiles('/', $uid, 'mtime'); - Trashbin::deleteExpiredFiles($dirContent, $uid); - }); - + } + + $this->appConfig->setValueInt('files_trashbin', 'background_job_expire_trash_offset', 0); \OC_Util::tearDownFS(); } /** * Act on behalf on trash item owner - * @param string $user - * @return boolean */ - protected function setupFS($user) { + protected function setupFS(string $user): bool { \OC_Util::tearDownFS(); \OC_Util::setupFS($user); // Check if this user has a trashbin directory - $view = new \OC\Files\View('/' . $user); + $view = new View('/' . $user); if (!$view->is_dir('/files_trashbin/files')) { return false; } diff --git a/apps/files_trashbin/lib/Capabilities.php b/apps/files_trashbin/lib/Capabilities.php index a348a9105fb..53c17a475ff 100644 --- a/apps/files_trashbin/lib/Capabilities.php +++ b/apps/files_trashbin/lib/Capabilities.php @@ -1,29 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @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: 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; /** @@ -36,14 +20,19 @@ class Capabilities implements ICapability { /** * Return this classes capabilities * - * @return array + * @return array{ + * files: array{ + * undelete: bool, + * delete_from_trash: bool + * } + * } */ public function getCapabilities() { return [ 'files' => [ - 'undelete' => true + '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 index b727dd9e133..e9b4fa8ae60 100644 --- a/apps/files_trashbin/lib/Command/CleanUp.php +++ b/apps/files_trashbin/lib/Command/CleanUp.php @@ -1,58 +1,32 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author Joas Schilling <coding@schilljs.com> - * - * @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\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 { - /** @var IUserManager */ - protected $userManager; - - /** @var IRootFolder */ - protected $rootFolder; - - /** @var \OCP\IDBConnection */ - protected $dbConnection; - - /** - * @param IRootFolder $rootFolder - * @param IUserManager $userManager - * @param IDBConnection $dbConnection - */ - function __construct(IRootFolder $rootFolder, IUserManager $userManager, IDBConnection $dbConnection) { + public function __construct( + protected IRootFolder $rootFolder, + protected IUserManager $userManager, + protected IDBConnection $dbConnection, + ) { parent::__construct(); - $this->userManager = $userManager; - $this->rootFolder = $rootFolder; - $this->dbConnection = $dbConnection; } protected function configure() { @@ -62,23 +36,33 @@ class CleanUp extends Command { ->addArgument( 'user_id', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, - 'remove deleted files of the given user(s), if no user is given all deleted files will be removed' + '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) { + protected function execute(InputInterface $input, OutputInterface $output): int { $users = $input->getArgument('user_id'); - if (!empty($users)) { + $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); + $this->removeDeletedFiles($user, $output, $verbose); } else { $output->writeln("<error>Unknown user $user</error>"); + return 1; } } - } else { - $output->writeln('Remove all deleted files'); + } 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) { @@ -91,30 +75,44 @@ class CleanUp extends Command { $users = $backend->getUsers('', $limit, $offset); foreach ($users as $user) { $output->writeln(" <info>$user</info>"); - $this->removeDeletedFiles($user); + $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 - * - * @param string $uid */ - protected function removeDeletedFiles($uid) { + protected function removeDeletedFiles(string $uid, OutputInterface $output, bool $verbose): void { \OC_Util::tearDownFS(); \OC_Util::setupFS($uid); - if ($this->rootFolder->nodeExists('/' . $uid . '/files_trashbin')) { - $this->rootFolder->get('/' . $uid . '/files_trashbin')->delete(); + $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->execute(); + $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 index 0d9bb4a088a..73a42cd4749 100644 --- a/apps/files_trashbin/lib/Command/Expire.php +++ b/apps/files_trashbin/lib/Command/Expire.php @@ -1,52 +1,31 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @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: 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; /** - * @var string - */ - private $user; - - /** * @param string $user */ - function __construct($user) { - $this->user = $user; + public function __construct( + private $user, + ) { } public function handle() { - $userManager = \OC::$server->getUserManager(); + $userManager = Server::get(IUserManager::class); if (!$userManager->userExists($this->user)) { // User has been deleted already return; diff --git a/apps/files_trashbin/lib/Command/ExpireTrash.php b/apps/files_trashbin/lib/Command/ExpireTrash.php index 6b46fd9e626..422d8379984 100644 --- a/apps/files_trashbin/lib/Command/ExpireTrash.php +++ b/apps/files_trashbin/lib/Command/ExpireTrash.php @@ -1,33 +1,18 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud GmbH. - * - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * - * @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: 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 OCP\IUser; -use OCP\IUserManager; +use OC\Files\View; use OCA\Files_Trashbin\Expiration; -use OCA\Files_Trashbin\Helper; 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; @@ -37,25 +22,15 @@ use Symfony\Component\Console\Output\OutputInterface; class ExpireTrash extends Command { /** - * @var Expiration - */ - private $expiration; - - /** - * @var IUserManager - */ - private $userManager; - - /** * @param IUserManager|null $userManager * @param Expiration|null $expiration */ - public function __construct(IUserManager $userManager = null, - Expiration $expiration = null) { + public function __construct( + private LoggerInterface $logger, + private ?IUserManager $userManager = null, + private ?Expiration $expiration = null, + ) { parent::__construct(); - - $this->userManager = $userManager; - $this->expiration = $expiration; } protected function configure() { @@ -69,12 +44,12 @@ class ExpireTrash extends Command { ); } - protected function execute(InputInterface $input, OutputInterface $output) { - + protected function execute(InputInterface $input, OutputInterface $output): int { + $minAge = $this->expiration->getMinAgeAsTimestamp(); $maxAge = $this->expiration->getMaxAgeAsTimestamp(); - if (!$maxAge) { - $output->writeln("No expiry configured."); - return; + 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'); @@ -86,27 +61,34 @@ class ExpireTrash extends Command { $this->expireTrashForUser($userObject); } else { $output->writeln("<error>Unknown user $user</error>"); + return 1; } } } else { $p = new ProgressBar($output); $p->start(); - $this->userManager->callForSeenUsers(function(IUser $user) use ($p) { + + $users = $this->userManager->getSeenUsers(); + foreach ($users as $user) { $p->advance(); $this->expireTrashForUser($user); - }); + } $p->finish(); $output->writeln(''); } + return 0; } - function expireTrashForUser(IUser $user) { - $uid = $user->getUID(); - if (!$this->setupFS($uid)) { - return; + 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]); } - $dirContent = Helper::getTrashFiles('/', $uid, 'mtime'); - Trashbin::deleteExpiredFiles($dirContent, $uid); } /** @@ -119,7 +101,7 @@ class ExpireTrash extends Command { \OC_Util::setupFS($user); // Check if this user has a trashbin directory - $view = new \OC\Files\View('/' . $user); + $view = new View('/' . $user); if (!$view->is_dir('/files_trashbin/files')) { return false; } 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 index 8c3ff292b9f..a4e911d88ef 100644 --- a/apps/files_trashbin/lib/Controller/PreviewController.php +++ b/apps/files_trashbin/lib/Controller/PreviewController.php @@ -1,118 +1,99 @@ <?php + +declare(strict_types=1); + /** - * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * 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 - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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\Files\File; +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 { - - /** @var IRootFolder */ - private $rootFolder; - - /** @var string */ - private $userId; - - /** @var IMimeTypeDetector */ - private $mimeTypeDetector; - - /** @var IPreview */ - private $previewManager; - - /** - * @param string $appName - * @param IRequest $request - * @param IRootFolder $rootFolder - * @param $userId - * @param IMimeTypeDetector $mimeTypeDetector - * @param IPreview $previewManager - */ - public function __construct($appName, - IRequest $request, - IRootFolder $rootFolder, - $userId, - IMimeTypeDetector $mimeTypeDetector, - IPreview $previewManager) { + 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); - - $this->rootFolder = $rootFolder; - $this->userId = $userId; - $this->mimeTypeDetector = $mimeTypeDetector; - $this->previewManager = $previewManager; } /** - * @NoAdminRequired - * @NoCSRFRequired + * 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 * - * @param string $file - * @param int $x - * @param int $y - * @return DataResponse|Http\FileDisplayResponse + * @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( - $file = '', - $x = 44, - $y = 44 + int $fileId = -1, + int $x = 32, + int $y = 32, + bool $a = false, ) { - if ($file === '') { - return new DataResponse([], Http::STATUS_BAD_REQUEST); - } - - if ($x === 0 || $y === 0) { + if ($fileId === -1 || $x === 0 || $y === 0) { return new DataResponse([], Http::STATUS_BAD_REQUEST); } try { - $userFolder = $this->rootFolder->getUserFolder($this->userId); - /** @var Folder $trash */ - $trash = $userFolder->getParent()->get('files_trashbin/files'); - $trashFile = $trash->get($file); - - if ($trashFile instanceof Folder) { + $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); } - /** @var File $trashFile */ - $fileName = $trashFile->getName(); - $i = strrpos($fileName, '.'); - if ($i !== false) { - $fileName = substr($fileName, 0, $i); + $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()); } - $mimeType = $this->mimeTypeDetector->detectPath($fileName); + $f = $this->previewManager->getPreview($file, $x, $y, !$a, IPreview::MODE_FILL, $mimeType); + $response = new FileDisplayResponse($f, Http::STATUS_OK, ['Content-Type' => $f->getMimeType()]); - $f = $this->previewManager->getPreview($trashFile, $x, $y, true, IPreview::MODE_FILL, $mimeType); - return new Http\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) { 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 index 99b42507cec..0d776b606b1 100644 --- a/apps/files_trashbin/lib/Events/MoveToTrashEvent.php +++ b/apps/files_trashbin/lib/Events/MoveToTrashEvent.php @@ -1,32 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2017 Bjoern Schiessle <bjoern@schiessle.org> - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * 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 - * along with this program. If not, see <http://www.gnu.org/licenses/>. - * + * 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; -use Symfony\Component\EventDispatcher\Event; /** * Class MoveToTrashEvent @@ -34,18 +15,17 @@ use Symfony\Component\EventDispatcher\Event; * 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; - /** @var Node */ - private $node; - - public function __construct(Node $node) { + public function __construct( + private Node $node, + ) { $this->moveToTrashBin = true; - $this->node = $node; } /** 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 index 1e639a56868..3ea1293e5d7 100644 --- a/apps/files_trashbin/lib/Exceptions/CopyRecursiveException.php +++ b/apps/files_trashbin/lib/Exceptions/CopyRecursiveException.php @@ -1,26 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author Morris Jobke <hey@morrisjobke.de> - * - * @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: 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 03f126fc415..0bbe39a9314 100644 --- a/apps/files_trashbin/lib/Expiration.php +++ b/apps/files_trashbin/lib/Expiration.php @@ -1,39 +1,20 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * - * @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; @@ -47,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(); @@ -60,7 +47,7 @@ class Expiration { * Is trashbin expiration enabled * @return bool */ - public function isEnabled(){ + public function isEnabled() { return $this->retentionObligation !== 'disabled'; } @@ -70,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; @@ -84,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; } @@ -108,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() { @@ -119,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; @@ -142,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 @@ -158,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 index 65407b8a56e..746832e9280 100644 --- a/apps/files_trashbin/lib/Helper.php +++ b/apps/files_trashbin/lib/Helper.php @@ -1,53 +1,35 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @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: 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 + * 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(); + $result = []; $timestamp = null; - $view = new \OC\Files\View('/' . $user . '/files_trashbin/files'); + $view = new View('/' . $user . '/files_trashbin/files'); if (ltrim($dir, '/') !== '' && !$view->is_dir($dir)) { throw new \Exception('Directory does not exists'); @@ -58,41 +40,40 @@ class Helper { $absoluteDir = $view->getAbsolutePath($dir); $internalPath = $mount->getInternalPath($absoluteDir); - $originalLocations = \OCA\Files_Trashbin\Trashbin::getLocations($user); + $extraData = Trashbin::getExtraData($user); $dirContent = $storage->getCache()->getFolderContents($mount->getInternalPath($view->getAbsolutePath($dir))); foreach ($dirContent as $entry) { $entryName = $entry->getName(); - $id = $entry->getId(); $name = $entryName; if ($dir === '' || $dir === '/') { $pathparts = pathinfo($entryName); $timestamp = substr($pathparts['extension'], 1); $name = $pathparts['filename']; - - } else if ($timestamp === null) { + } 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($originalLocations[$originalName][$timestamp])) { - $originalPath = $originalLocations[$originalName][$timestamp]; + $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 = array( + $i = [ 'name' => $name, 'mtime' => $timestamp, - 'mimetype' => $type === 'dir' ? 'httpd/unix-directory' : \OC::$server->getMimeTypeDetector()->detectPath($name), + '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 - ); + 'permissions' => Constants::PERMISSION_ALL - Constants::PERMISSION_SHARE, + 'fileid' => $entry->getId(), + ]; if ($originalPath) { if ($originalPath !== '.') { $i['extraData'] = $originalPath . '/' . $originalName; @@ -100,6 +81,7 @@ class Helper { $i['extraData'] = $originalName; } } + $i['deletedBy'] = $extraData[$originalName][$timestamp]['deletedBy'] ?? null; $result[] = new FileInfo($absoluteDir . '/' . $i['name'], $storage, $internalPath . '/' . $i['name'], $i, $mount); } @@ -115,13 +97,12 @@ class Helper { * @param \OCP\Files\FileInfo[] $fileInfos file infos */ public static function formatFileInfos($fileInfos) { - $files = array(); - $id = 0; + $files = []; foreach ($fileInfos as $i) { $entry = \OCA\Files\Helper::formatFileInfo($i); - $entry['id'] = $id++; + $entry['id'] = $i->getId(); $entry['etag'] = $entry['mtime']; // add fake etag, it is only needed to identify the preview image - $entry['permissions'] = \OCP\Constants::PERMISSION_READ; + $entry['permissions'] = 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 036294cc144..00000000000 --- a/apps/files_trashbin/lib/Hooks.php +++ /dev/null @@ -1,52 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @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) { - $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/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 index 5eaf502f236..82b7af5a934 100644 --- a/apps/files_trashbin/lib/Storage.php +++ b/apps/files_trashbin/lib/Storage.php @@ -1,242 +1,102 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Joas Schilling <coding@schilljs.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @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: 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 OC\Files\View; 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\ILogger; +use OCP\Files\Storage\IStorage; +use OCP\IRequest; use OCP\IUserManager; -use Symfony\Component\EventDispatcher\EventDispatcher; +use OCP\Server; +use Psr\Log\LoggerInterface; 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; - - /** - * remember which file/folder was moved out of s shared folder - * in this case we want to add a copy to the owners trash bin - * - * @var array - */ - private static $moveOutOfSharedFolder = []; - - /** @var IUserManager */ - private $userManager; - - /** @var ILogger */ - private $logger; - - /** @var EventDispatcher */ - private $eventDispatcher; - - /** @var IRootFolder */ - private $rootFolder; + private string $mountPoint; + private bool $trashEnabled = true; /** * Storage constructor. - * * @param array $parameters - * @param IUserManager|null $userManager - * @param ILogger|null $logger - * @param EventDispatcher|null $eventDispatcher - * @param IRootFolder|null $rootFolder */ - public function __construct($parameters, - IUserManager $userManager = null, - ILogger $logger = null, - EventDispatcher $eventDispatcher = null, - IRootFolder $rootFolder = null) { + 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']; - $this->userManager = $userManager; - $this->logger = $logger; - $this->eventDispatcher = $eventDispatcher; - $this->rootFolder = $rootFolder; 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, only exception: - // if the file was moved from a shared storage to a local folder, - // in this case the owner should get a copy in his trash bin so that - // they can restore the files again - - $oldPath = $params['oldpath']; - $newPath = dirname($params['newpath']); - $currentUser = \OC::$server->getUserSession()->getUser(); - - $fileMovedOutOfSharedFolder = false; - - try { - if ($currentUser) { - $currentUserId = $currentUser->getUID(); - - $view = new View($currentUserId . '/files'); - $fileInfo = $view->getFileInfo($oldPath); - if ($fileInfo) { - $sourceStorage = $fileInfo->getStorage(); - $sourceOwner = $view->getOwner($oldPath); - $targetOwner = $view->getOwner($newPath); - - if ($sourceOwner !== $targetOwner - && $sourceStorage->instanceOfStorage('OCA\Files_Sharing\SharedStorage') - ) { - $fileMovedOutOfSharedFolder = true; - } - } + 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); } - } catch (\Exception $e) { - // do nothing, in this case we just disable the trashbin and continue - $logger = \OC::$server->getLogger(); - $logger->debug('Trashbin storage could not check if a file was moved out of a shared folder: ' . $e->getMessage()); - } - - if($fileMovedOutOfSharedFolder) { - self::$moveOutOfSharedFolder['/' . $currentUserId . '/files' . $oldPath] = true; } else { - 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 - * @return bool - */ - 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 $this->storage->unlink($path); } - 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) { - try { - if (isset(self::$moveOutOfSharedFolder[$this->mountPoint . $path])) { - $result = $this->doDelete($path, 'unlink', true); - unset(self::$moveOutOfSharedFolder[$this->mountPoint . $path]); - } else { - $result = $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"); - - $result = $this->storage->unlink($path); - } - - return $result; - } - - /** - * 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) { - if (isset(self::$moveOutOfSharedFolder[$this->mountPoint . $path])) { - $result = $this->doDelete($path, 'rmdir', true); - unset(self::$moveOutOfSharedFolder[$this->mountPoint . $path]); + public function rmdir(string $path): bool { + if ($this->trashEnabled) { + return $this->doDelete($path, 'rmdir'); } else { - $result = $this->doDelete($path, 'rmdir'); + return $this->storage->rmdir($path); } - - return $result; } /** * 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){ + 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); - $nodes = $this->rootFolder->getById($fileId); + $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; } } - $normalized = Filesystem::normalizePath($this->mountPoint . '/' . $path); - $parts = explode('/', $normalized); - if (count($parts) < 4) { - return false; - } - if ($parts[2] === 'files' && $this->userManager->userExists($parts[1])) { return true; } @@ -250,9 +110,8 @@ class Storage extends Wrapper { * @param Node $node * @return MoveToTrashEvent */ - protected function createMoveToTrashEvent(Node $node) { - $event = new MoveToTrashEvent($node); - return $event; + protected function createMoveToTrashEvent(Node $node): MoveToTrashEvent { + return new MoveToTrashEvent($node); } /** @@ -260,62 +119,91 @@ class Storage extends Wrapper { * * @param string $path path of file or folder to delete * @param string $method either "unlink" or "rmdir" - * @param bool $ownerOnly delete for owner only (if file gets moved out of a shared folder) * * @return bool true if the operation succeeded, false otherwise */ - private function doDelete($path, $method, $ownerOnly = false) { - 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; - } + 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; + } - $normalized = Filesystem::normalizePath($this->mountPoint . '/' . $path, true, false, true); - $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, $ownerOnly); - // 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]); + $isMovedToTrash = $this->trashManager->moveToTrash($this, $path); + if ($isMovedToTrash) { + return true; } - unset($this->deletedFiles[$normalized]); - } else if ($this->storage->file_exists($path)) { - $result = call_user_func_array([$this->storage, $method], [$path]); } - return $result; + return call_user_func([$this->storage, $method], $path); } /** - * Setup the storate wrapper callback + * Setup the storage 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(), - \OC::$server->getLogger(), - \OC::$server->getEventDispatcher(), - \OC::$server->getLazyRootFolder() - ); - }, 1); + 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 index d61881ce3b1..667066c2fca 100644 --- a/apps/files_trashbin/lib/Trashbin.php +++ b/apps/files_trashbin/lib/Trashbin.php @@ -1,78 +1,69 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bart Visscher <bartv@thisnet.nl> - * @author Bastien Ho <bastienho@urbancube.fr> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Florin Peter <github@florin-peter.de> - * @author Georg Ehrke <oc.list@georgehrke.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Juan Pablo Villafáñez <jvillafanez@solidgear.es> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Qingping Hou <dave2008713@gmail.com> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Sjors van der Pluijm <sjors@desjors.nl> - * @author Steven Bühner <buehner@me.com> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @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: 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\User; - -class Trashbin { - +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 - const DEFAULTMAXSIZE = 50; - - /** - * Whether versions have already be rescanned during this PHP request - * - * @var bool - */ - private static $scannedVersions = false; + 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 - * - * @param array $params */ - public static function ensureFileScannedHook($params) { + public static function ensureFileScannedHook(Node $node): void { try { - self::getUidAndFilename($params['path']); + self::getUidAndFilename($node->getPath()); } catch (NotFoundException $e) { - // nothing to scan for non existing files + // Nothing to scan for non existing files } } @@ -82,23 +73,23 @@ class Trashbin { * * @param string $filename * @return array - * @throws \OC\User\NoUserException + * @throws NoUserException */ public static function getUidAndFilename($filename) { $uid = Filesystem::getOwner($filename); - $userManager = \OC::$server->getUserManager(); + $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 = User::getUser(); + $uid = OC_User::getUser(); } if (!$uid) { // no owner, usually because of share link from ext storage return [null, null]; } Filesystem::initMountPoints($uid); - if ($uid !== User::getUser()) { + if ($uid !== OC_User::getUser()) { $info = Filesystem::getFileInfo($filename); $ownerView = new View('/' . $uid . '/files'); try { @@ -111,23 +102,25 @@ class Trashbin { } /** - * get original location of files for user + * get original location and deleted by 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<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; } @@ -137,20 +130,29 @@ class Trashbin { * @param string $user * @param string $filename * @param string $timestamp - * @return string original location + * @return string|false 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']; + $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; } } - private static function setUpTrash($user) { + /** @param string $user */ + private static function setUpTrash($user): void { $view = new View('/' . $user); if (!$view->is_dir('files_trashbin')) { $view->mkdir('files_trashbin'); @@ -173,10 +175,10 @@ class Trashbin { * @param string $sourcePath * @param string $owner * @param string $targetPath - * @param $user - * @param integer $timestamp + * @param string $user + * @param int $timestamp */ - private static function copyFilesToUser($sourcePath, $owner, $targetPath, $user, $timestamp) { + private static function copyFilesToUser($sourcePath, $owner, $targetPath, $user, $timestamp): void { self::setUpTrash($owner); $targetFilename = basename($targetPath); @@ -186,16 +188,27 @@ class Trashbin { $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); + $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 = \OC_DB::prepare("INSERT INTO `*PREFIX*files_trash` (`id`,`timestamp`,`location`,`user`) VALUES (?,?,?,?)"); - $result = $query->execute(array($targetFilename, $timestamp, $targetLocation, $user)); + $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) { - \OCP\Util::writeLog('files_trashbin', 'trash bin database couldn\'t be updated for the files owner', \OCP\Util::ERROR); + Server::get(LoggerInterface::class)->error('trash bin database couldn\'t be updated for the files owner', ['app' => 'files_trashbin']); } } } @@ -212,8 +225,8 @@ class Trashbin { public static function move2trash($file_path, $ownerOnly = false) { // get the user for which the filesystem is setup $root = Filesystem::getRoot(); - list(, $user) = explode('/', $root); - list($owner, $ownerPath) = self::getUidAndFilename($file_path); + [, $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)) { @@ -222,8 +235,15 @@ class Trashbin { } $ownerView = new View('/' . $owner); + // file has been deleted in between - if (is_null($ownerPath) || $ownerPath === '' || !$ownerView->file_exists('/files/' . $ownerPath)) { + if (is_null($ownerPath) || $ownerPath === '') { + return true; + } + + $sourceInfo = $ownerView->getFileInfo('/files/' . $ownerPath); + + if ($sourceInfo === false) { return true; } @@ -237,27 +257,57 @@ class Trashbin { $filename = $path_parts['basename']; $location = $path_parts['dirname']; - $timestamp = time(); + /** @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/' . $filename . '.d' . $timestamp; + $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; + } - /** @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) { + if ($sourceStorage->getCache()->inCache($sourceInternalPath)) { + $trashStorage->getUpdater()->renameFromStorage($sourceStorage, $sourceInternalPath, $trashInternalPath); + } + } catch (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); + 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 @@ -266,19 +316,30 @@ class Trashbin { } 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; } - $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)); + $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) { - \OCP\Util::writeLog('files_trashbin', 'trash bin database couldn\'t be updated', \OCP\Util::ERROR); + Server::get(LoggerInterface::class)->error('trash bin database couldn\'t be updated', ['app' => 'files_trashbin']); } - \OCP\Util::emitHook('\OCA\Files_Trashbin\Trashbin', 'post_moveToTrash', array('filePath' => Filesystem::normalizePath($file_path), - 'trashPath' => Filesystem::normalizePath($filename . '.d' . $timestamp))); + 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); @@ -288,6 +349,8 @@ class Trashbin { } } + $trashStorage->releaseLock($trashInternalPath, ILockingProvider::LOCK_EXCLUSIVE, $lockingProvider); + self::scheduleExpire($user); // if owner !== user we also need to update the owners trash size @@ -298,32 +361,43 @@ class Trashbin { 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 integer $timestamp when the file was deleted + * @param int $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(); + 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/' . basename($ownerPath) . '.d' . $timestamp, $rootView); + 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/' . $filename . '.d' . $timestamp); - } else if ($versions = \OCA\Files_Versions\Storage::getVersions($owner, $ownerPath)) { - + 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/' . $v['name'] . '.v' . $v['version'] . '.d' . $timestamp); + 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/' . $filename . '.v' . $v['version'] . '.d' . $timestamp); + self::move($rootView, $owner . '/files_versions' . $v['path'] . '.v' . $v['version'], $user . '/files_trashbin/versions/' . static::getTrashFilename($filename . '.v' . $v['version'], $timestamp)); } } } @@ -339,9 +413,9 @@ class Trashbin { */ private static function move(View $view, $source, $target) { /** @var \OC\Files\Storage\Storage $sourceStorage */ - list($sourceStorage, $sourceInternalPath) = $view->resolvePath($source); + [$sourceStorage, $sourceInternalPath] = $view->resolvePath($source); /** @var \OC\Files\Storage\Storage $targetStorage */ - list($targetStorage, $targetInternalPath) = $view->resolvePath($target); + [$targetStorage, $targetInternalPath] = $view->resolvePath($target); /** @var \OC\Files\Storage\Storage $ownerTrashStorage */ $result = $targetStorage->moveFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); @@ -361,9 +435,9 @@ class Trashbin { */ private static function copy(View $view, $source, $target) { /** @var \OC\Files\Storage\Storage $sourceStorage */ - list($sourceStorage, $sourceInternalPath) = $view->resolvePath($source); + [$sourceStorage, $sourceInternalPath] = $view->resolvePath($source); /** @var \OC\Files\Storage\Storage $targetStorage */ - list($targetStorage, $targetInternalPath) = $view->resolvePath($target); + [$targetStorage, $targetInternalPath] = $view->resolvePath($target); /** @var \OC\Files\Storage\Storage $ownerTrashStorage */ $result = $targetStorage->copyFromStorage($sourceStorage, $sourceInternalPath, $targetInternalPath); @@ -377,26 +451,26 @@ class Trashbin { * 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" + * 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(); + $user = OC_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! ($user: ' . $user . ' $filename: ' . $filename . ', $timestamp: ' . $timestamp . ')', \OCP\Util::ERROR); + 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)) + if ($location !== '/' + && (!$view->is_dir('files/' . $location) + || !$view->isCreatable('files/' . $location)) ) { $location = ''; } @@ -414,6 +488,24 @@ class Trashbin { $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 @@ -422,14 +514,23 @@ class Trashbin { $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))); + 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 = \OC_DB::prepare('DELETE FROM `*PREFIX*files_trash` WHERE `user`=? AND `id`=? AND `timestamp`=?'); - $query->execute(array($user, $filename, $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; @@ -450,15 +551,13 @@ class Trashbin { * @return false|null */ private static function restoreVersions(View $view, $file, $filename, $uniqueFilename, $location, $timestamp) { - - if (\OCP\App::isEnabled('files_versions')) { - - $user = User::getUser(); + if (Server::get(IAppManager::class)->isEnabledForUser('files_versions')) { + $user = OC_User::getUser(); $rootView = new View('/'); $target = Filesystem::normalizePath('/' . $location . '/' . $uniqueFilename); - list($owner, $ownerPath) = self::getUidAndFilename($target); + [$owner, $ownerPath] = self::getUidAndFilename($target); // file has been deleted in between if (empty($ownerPath)) { @@ -473,10 +572,10 @@ class Trashbin { 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)) { + } elseif ($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); + $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); } @@ -489,7 +588,7 @@ class Trashbin { * delete all files from the trash */ public static function deleteAll() { - $user = User::getUser(); + $user = OC_User::getUser(); $userRoot = \OC::$server->getUserFolder($user)->getParent(); $view = new View('/' . $user); $fileInfos = $view->getDirectoryContent('files_trashbin/files'); @@ -501,30 +600,33 @@ class Trashbin { } // Array to store the relative path in (after the file is deleted, the view won't be able to relativise the path anymore) - $filePaths = array(); - foreach($fileInfos as $fileInfo){ + $filePaths = []; + foreach ($fileInfos as $fileInfo) { $filePaths[] = $view->getRelativePath($fileInfo->getPath()); } unset($fileInfos); // save memory // Bulk PreDelete-Hook - \OC_Hook::emit('\OCP\Trashbin', 'preDeleteAll', array('paths' => $filePaths)); + \OC_Hook::emit('\OCP\Trashbin', 'preDeleteAll', ['paths' => $filePaths]); // Single-File Hooks - foreach($filePaths as $path){ + foreach ($filePaths as $path) { self::emitTrashbinPreDelete($path); } // actual file deletion $trash->delete(); - $query = \OC_DB::prepare('DELETE FROM `*PREFIX*files_trash` WHERE `user`=?'); - $query->execute(array($user)); + + $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', array('paths' => $filePaths)); + \OC_Hook::emit('\OCP\Trashbin', 'deleteAll', ['paths' => $filePaths]); // Single-File Hooks - foreach($filePaths as $path){ + foreach ($filePaths as $path) { self::emitTrashbinPostDelete($path); } @@ -536,18 +638,20 @@ class Trashbin { /** * 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', array('path' => $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', array('path' => $path)); + protected static function emitTrashbinPostDelete($path) { + \OC_Hook::emit('\OCP\Trashbin', 'delete', ['path' => $path]); } /** @@ -557,7 +661,7 @@ class Trashbin { * @param string $user * @param int $timestamp of deletion time * - * @return int size of deleted files + * @return int|float size of deleted files */ public static function delete($filename, $user, $timestamp = null) { $userRoot = \OC::$server->getUserFolder($user)->getParent(); @@ -565,9 +669,14 @@ class Trashbin { $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; + $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; } @@ -582,7 +691,7 @@ class Trashbin { if ($node instanceof Folder) { $size += self::calculateSize(new View('/' . $user . '/files_trashbin/files/' . $file)); - } else if ($node instanceof File) { + } elseif ($node instanceof File) { $size += $view->filesize('/files_trashbin/files/' . $file); } @@ -594,24 +703,21 @@ class Trashbin { } /** - * @param View $view * @param string $file * @param string $filename - * @param integer|null $timestamp - * @param string $user - * @return int + * @param ?int $timestamp */ - private static function deleteVersions(View $view, $file, $filename, $timestamp, $user) { + private static function deleteVersions(View $view, $file, $filename, $timestamp, string $user): int|float { $size = 0; - if (\OCP\App::isEnabled('files_versions')) { + 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); - } else if ($versions = self::getVersionsFromTrash($filename, $timestamp, $user)) { + } elseif ($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); + $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); @@ -630,13 +736,11 @@ class Trashbin { * @return bool true if file exists, otherwise false */ public static function file_exists($filename, $timestamp = null) { - $user = User::getUser(); + $user = OC_User::getUser(); $view = new View('/' . $user); if ($timestamp) { - $filename = $filename . '.d' . $timestamp; - } else { - $filename = $filename; + $filename = static::getTrashFilename($filename, $timestamp); } $target = Filesystem::normalizePath('files_trashbin/files/' . $filename); @@ -650,23 +754,30 @@ class Trashbin { * @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)); + $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 integer $trashbinSize current size of the trash bin + * @param int|float $trashbinSize current size of the trash bin * @param string $user - * @return int available free space for trash bin + * @return int|float available free space for trash bin */ - private static function calculateFreeSpace($trashbinSize, $user) { - $softQuota = true; - $userObject = \OC::$server->getUserManager()->get($user); - if(is_null($userObject)) { + 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('/'); @@ -676,17 +787,21 @@ class Trashbin { $quota = PHP_INT_MAX; } } else { - $quota = \OCP\Util::computerFileSize($quota); + $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)) { + if (is_null($userFolder)) { return 0; } - $free = $quota - $userFolder->getSize(); // remaining free space for user + $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 { @@ -696,7 +811,7 @@ class Trashbin { $availableSpace = $quota; } - return $availableSpace; + return Util::numericToNumber($availableSpace); } /** @@ -705,7 +820,6 @@ class Trashbin { * @param string $user user id */ public static function resizeTrash($user) { - $size = self::getTrashbinSize($user); $freeSpace = self::calculateFreeSpace($size, $user); @@ -727,7 +841,7 @@ class Trashbin { $dirContent = Helper::getTrashFiles('/', $user, 'mtime'); // delete all files older then $retention_obligation - list($delSize, $count) = self::deleteExpiredFiles($dirContent, $user); + [$delSize, $count] = self::deleteExpiredFiles($dirContent, $user); $availableSpace += $delSize; @@ -740,10 +854,11 @@ class Trashbin { */ private static function scheduleExpire($user) { // let the admin disable auto expire - $application = new Application(); + /** @var Application $application */ + $application = Server::get(Application::class); $expiration = $application->getContainer()->query('Expiration'); if ($expiration->isEnabled()) { - \OC::$server->getCommandBus()->push(new Expire($user)); + Server::get(IBus::class)->push(new Expire($user)); } } @@ -753,11 +868,12 @@ class Trashbin { * * @param array $files * @param string $user - * @param int $availableSpace available disc space - * @return int size of deleted files + * @param int|float $availableSpace available disc space + * @return int|float size of deleted files */ - protected static function deleteFiles($files, $user, $availableSpace) { - $application = new Application(); + 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; @@ -765,7 +881,13 @@ class Trashbin { 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); + 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 { @@ -781,41 +903,54 @@ class Trashbin { * * @param array $files list of files sorted by mtime * @param string $user - * @return integer[] size of deleted files and number of deleted files + * @return array{int|float, int} size of deleted files and number of deleted files */ public static function deleteExpiredFiles($files, $user) { - $application = new Application(); - $expiration = $application->getContainer()->query('Expiration'); + /** @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)) { - $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'] + 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 array($size, $count); + 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 directoy + * @param string $destination destination path relative to the users root directory * @param View $view file view for the users root directory - * @return int + * @return int|float * @throws Exceptions\CopyRecursiveException */ - private static function copy_recursive($source, $destination, View $view) { + private static function copy_recursive($source, $destination, View $view): int|float { $size = 0; if ($view->is_dir($source)) { $view->mkdir($destination); @@ -828,7 +963,7 @@ class Trashbin { $size += $view->filesize($pathDir); $result = $view->copy($pathDir, $destination . '/' . $i['name']); if (!$result) { - throw new \OCA\Files_Trashbin\Exceptions\CopyRecursiveException(); + throw new CopyRecursiveException(); } $view->touch($destination . '/' . $i['name'], $view->filemtime($pathDir)); } @@ -837,7 +972,7 @@ class Trashbin { $size += $view->filesize($source); $result = $view->copy($source, $destination); if (!$result) { - throw new \OCA\Files_Trashbin\Exceptions\CopyRecursiveException(); + throw new CopyRecursiveException(); } $view->touch($destination, $view->filemtime($source)); } @@ -849,39 +984,60 @@ class Trashbin { * * @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) { + private static function getVersionsFromTrash($filename, $timestamp, string $user): array { $view = new View('/' . $user . '/files_trashbin/versions'); - $versions = array(); + $versions = []; - //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; - } + /** @var \OC\Files\Storage\Storage $storage */ + [$storage,] = $view->resolvePath('/'); + $pattern = Server::get(IDBConnection::class)->escapeLikeParameter(basename($filename)); if ($timestamp) { // fetch for old versions - $matches = $view->searchRaw($filename . '.v%.d' . $timestamp); - $offset = -strlen($timestamp) - 2; + $escapedTimestamp = Server::get(IDBConnection::class)->escapeLikeParameter((string)$timestamp); + $pattern .= '.v%.d' . $escapedTimestamp; + $offset = -strlen($escapedTimestamp) - 2; } else { - $matches = $view->searchRaw($filename . '.v%'); + $pattern .= '.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)); - } + // 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; } @@ -896,7 +1052,7 @@ class Trashbin { private static function getUniqueFilename($location, $filename, View $view) { $ext = pathinfo($filename, PATHINFO_EXTENSION); $name = pathinfo($filename, PATHINFO_FILENAME); - $l = \OC::$server->getL10N('files_trashbin'); + $l = Util::getL10N('files_trashbin'); $location = '/' . trim($location, '/'); @@ -907,9 +1063,9 @@ class Trashbin { if ($view->file_exists('files' . $location . '/' . $filename)) { $i = 2; - $uniqueName = $name . " (" . $l->t("restored") . ")" . $ext; + $uniqueName = $name . ' (' . $l->t('restored') . ')' . $ext; while ($view->file_exists('files' . $location . '/' . $uniqueName)) { - $uniqueName = $name . " (" . $l->t("restored") . " " . $i . ")" . $ext; + $uniqueName = $name . ' (' . $l->t('restored') . ' ' . $i . ')' . $ext; $i++; } @@ -923,10 +1079,10 @@ class Trashbin { * get the size from a given root folder * * @param View $view file view on the root folder - * @return integer size of the folder + * @return int|float size of the folder */ - private static function calculateSize($view) { - $root = \OC::$server->getConfig()->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') . $view->getAbsolutePath(''); + 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; } @@ -954,41 +1110,24 @@ class Trashbin { * get current size of trash bin from a given user * * @param string $user user who owns the trash bin - * @return integer trash bin size + * @return int|float trash bin size */ - private static function getTrashbinSize($user) { + private static function getTrashbinSize(string $user): int|float { $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)) { + while (($file = readdir($dh)) !== false) { if (!Filesystem::isIgnoredDir($file)) { return false; } @@ -1002,6 +1141,60 @@ class Trashbin { * @return string */ public static function preview_icon($path) { - return \OCP\Util::linkToRoute('core_ajax_trashbin_preview', array('x' => 32, 'y' => 32, 'file' => $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)'); + } +} |