diff options
Diffstat (limited to 'apps/files_versions/lib')
43 files changed, 2294 insertions, 1140 deletions
diff --git a/apps/files_versions/lib/AppInfo/Application.php b/apps/files_versions/lib/AppInfo/Application.php index c994cd2f54a..29158276415 100644 --- a/apps/files_versions/lib/AppInfo/Application.php +++ b/apps/files_versions/lib/AppInfo/Application.php @@ -1,29 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @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 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files_Versions\AppInfo; @@ -33,22 +13,39 @@ use OCA\DAV\Connector\Sabre\Principal; use OCA\Files\Event\LoadAdditionalScriptsEvent; use OCA\Files\Event\LoadSidebar; use OCA\Files_Versions\Capabilities; -use OCA\Files_Versions\Hooks; +use OCA\Files_Versions\Events\VersionRestoredEvent; +use OCA\Files_Versions\Listener\FileEventsListener; +use OCA\Files_Versions\Listener\LegacyRollbackListener; use OCA\Files_Versions\Listener\LoadAdditionalListener; use OCA\Files_Versions\Listener\LoadSidebarListener; +use OCA\Files_Versions\Listener\VersionAuthorListener; +use OCA\Files_Versions\Listener\VersionStorageMoveListener; use OCA\Files_Versions\Versions\IVersionManager; use OCA\Files_Versions\Versions\VersionManager; +use OCP\Accounts\IAccountManager; 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\Node\BeforeNodeCopiedEvent; +use OCP\Files\Events\Node\BeforeNodeDeletedEvent; +use OCP\Files\Events\Node\BeforeNodeRenamedEvent; +use OCP\Files\Events\Node\BeforeNodeTouchedEvent; +use OCP\Files\Events\Node\BeforeNodeWrittenEvent; +use OCP\Files\Events\Node\NodeCopiedEvent; +use OCP\Files\Events\Node\NodeCreatedEvent; +use OCP\Files\Events\Node\NodeDeletedEvent; +use OCP\Files\Events\Node\NodeRenamedEvent; +use OCP\Files\Events\Node\NodeTouchedEvent; +use OCP\Files\Events\Node\NodeWrittenEvent; use OCP\IConfig; use OCP\IGroupManager; use OCP\IServerContainer; use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory; +use OCP\Server; use OCP\Share\IManager as IShareManager; use Psr\Container\ContainerInterface; use Psr\Log\LoggerInterface; @@ -75,6 +72,7 @@ class Application extends App implements IBootstrap { return new Principal( $server->get(IUserManager::class), $server->get(IGroupManager::class), + Server::get(IAccountManager::class), $server->get(IShareManager::class), $server->get(IUserSession::class), $server->get(IAppManager::class), @@ -85,28 +83,43 @@ class Application extends App implements IBootstrap { ); }); - $context->registerService(IVersionManager::class, function () { - return new VersionManager(); - }); + $context->registerServiceAlias(IVersionManager::class, VersionManager::class); /** * Register Events */ $context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class); $context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class); + + $context->registerEventListener(BeforeNodeRenamedEvent::class, VersionStorageMoveListener::class); + $context->registerEventListener(NodeRenamedEvent::class, VersionStorageMoveListener::class); + $context->registerEventListener(BeforeNodeCopiedEvent::class, VersionStorageMoveListener::class); + $context->registerEventListener(NodeCopiedEvent::class, VersionStorageMoveListener::class); + + $context->registerEventListener(NodeCreatedEvent::class, FileEventsListener::class); + $context->registerEventListener(BeforeNodeTouchedEvent::class, FileEventsListener::class); + $context->registerEventListener(NodeTouchedEvent::class, FileEventsListener::class); + $context->registerEventListener(BeforeNodeWrittenEvent::class, FileEventsListener::class); + $context->registerEventListener(NodeWrittenEvent::class, FileEventsListener::class); + $context->registerEventListener(BeforeNodeDeletedEvent::class, FileEventsListener::class); + $context->registerEventListener(NodeDeletedEvent::class, FileEventsListener::class); + $context->registerEventListener(NodeRenamedEvent::class, FileEventsListener::class); + $context->registerEventListener(NodeCopiedEvent::class, FileEventsListener::class); + $context->registerEventListener(BeforeNodeRenamedEvent::class, FileEventsListener::class); + $context->registerEventListener(BeforeNodeCopiedEvent::class, FileEventsListener::class); + + // we add the version author listener with lower priority to make sure new versions already are created by FileEventsListener + $context->registerEventListener(NodeWrittenEvent::class, VersionAuthorListener::class, -1); + + $context->registerEventListener(VersionRestoredEvent::class, LegacyRollbackListener::class); } public function boot(IBootContext $context): void { $context->injectFn(\Closure::fromCallable([$this, 'registerVersionBackends'])); - - /** - * Register hooks - */ - Hooks::connectHooks(); } public function registerVersionBackends(ContainerInterface $container, IAppManager $appManager, LoggerInterface $logger): void { - foreach ($appManager->getInstalledApps() as $app) { + foreach ($appManager->getEnabledApps() as $app) { $appInfo = $appManager->getAppInfo($app); if (isset($appInfo['versions'])) { $backends = $appInfo['versions']; diff --git a/apps/files_versions/lib/BackgroundJob/ExpireVersions.php b/apps/files_versions/lib/BackgroundJob/ExpireVersions.php index a8a311f0a05..794cbc5b882 100644 --- a/apps/files_versions/lib/BackgroundJob/ExpireVersions.php +++ b/apps/files_versions/lib/BackgroundJob/ExpireVersions.php @@ -1,59 +1,33 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files_Versions\BackgroundJob; +use OC\Files\View; use OCA\Files_Versions\Expiration; use OCA\Files_Versions\Storage; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\TimedJob; use OCP\IConfig; use OCP\IUser; use OCP\IUserManager; -class ExpireVersions extends \OC\BackgroundJob\TimedJob { +class ExpireVersions extends TimedJob { public const ITEMS_PER_SESSION = 1000; - /** @var IConfig */ - private $config; - - /** - * @var Expiration - */ - private $expiration; - - /** - * @var IUserManager - */ - private $userManager; - - public function __construct(IConfig $config, IUserManager $userManager, Expiration $expiration) { + public function __construct( + private IConfig $config, + private IUserManager $userManager, + private Expiration $expiration, + ITimeFactory $time, + ) { + parent::__construct($time); // Run once per 30 minutes $this->setInterval(60 * 30); - - $this->config = $config; - $this->expiration = $expiration; - $this->userManager = $userManager; } public function run($argument) { @@ -67,7 +41,7 @@ class ExpireVersions extends \OC\BackgroundJob\TimedJob { return; } - $this->userManager->callForSeenUsers(function (IUser $user) { + $this->userManager->callForSeenUsers(function (IUser $user): void { $uid = $user->getUID(); if (!$this->setupFS($uid)) { return; @@ -78,15 +52,13 @@ class ExpireVersions extends \OC\BackgroundJob\TimedJob { /** * 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 versions directory - $view = new \OC\Files\View('/' . $user); + $view = new View('/' . $user); if (!$view->is_dir('/files_versions')) { return false; } diff --git a/apps/files_versions/lib/Capabilities.php b/apps/files_versions/lib/Capabilities.php index b8602540ec8..cb6394f0a36 100644 --- a/apps/files_versions/lib/Capabilities.php +++ b/apps/files_versions/lib/Capabilities.php @@ -1,42 +1,34 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Tom Needham <tom@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 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files_Versions; +use OCP\App\IAppManager; use OCP\Capabilities\ICapability; +use OCP\IConfig; class Capabilities implements ICapability { - + public function __construct( + private IConfig $config, + private IAppManager $appManager, + ) { + } + /** * Return this classes capabilities * - * @return array + * @return array{files: array{versioning: bool, version_labeling: bool, version_deletion: bool}} */ public function getCapabilities() { return [ 'files' => [ - 'versioning' => true + 'versioning' => true, + 'version_labeling' => $this->config->getSystemValueBool('enable_version_labeling', true), + 'version_deletion' => $this->config->getSystemValueBool('enable_version_deletion', true), ] ]; } diff --git a/apps/files_versions/lib/Command/CleanUp.php b/apps/files_versions/lib/Command/CleanUp.php index d7bb4caa483..e8c46afef16 100644 --- a/apps/files_versions/lib/Command/CleanUp.php +++ b/apps/files_versions/lib/Command/CleanUp.php @@ -1,29 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Daniel Rudolf <nextcloud.com@daniel-rudolf.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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files_Versions\Command; +use OCA\Files_Versions\Db\VersionsMapper; use OCP\Files\IRootFolder; use OCP\IUserBackend; use OCP\IUserManager; @@ -34,24 +18,15 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; class CleanUp extends Command { - - /** @var IUserManager */ - protected $userManager; - - /** @var IRootFolder */ - protected $rootFolder; - - /** - * @param IRootFolder $rootFolder - * @param IUserManager $userManager - */ - public function __construct(IRootFolder $rootFolder, IUserManager $userManager) { + public function __construct( + protected IRootFolder $rootFolder, + protected IUserManager $userManager, + protected VersionsMapper $versionMapper, + ) { parent::__construct(); - $this->userManager = $userManager; - $this->rootFolder = $rootFolder; } - protected function configure() { + protected function configure(): void { $this ->setName('versions:cleanup') ->setDescription('Delete versions') @@ -75,8 +50,8 @@ class CleanUp extends Command { $path = $input->getOption('path'); if ($path) { if (!preg_match('#^/([^/]+)/files(/.*)?$#', $path, $pathMatches)) { - $output->writeln("<error>Invalid path given</error>"); - return 1; + $output->writeln('<error>Invalid path given</error>'); + return self::FAILURE; } $users = [ $pathMatches[1] ]; @@ -85,51 +60,53 @@ class CleanUp extends Command { if (!empty($users)) { foreach ($users as $user) { - if ($this->userManager->userExists($user)) { - $output->writeln("Delete versions of <info>$user</info>"); - $this->deleteVersions($user, $path); - } else { + if (!$this->userManager->userExists($user)) { $output->writeln("<error>Unknown user $user</error>"); - return 1; + return self::FAILURE; } + + $output->writeln("Delete versions of <info>$user</info>"); + $this->deleteVersions($user, $path); } - } else { - $output->writeln('Delete all versions'); - foreach ($this->userManager->getBackends() as $backend) { - $name = get_class($backend); + return self::SUCCESS; + } - if ($backend instanceof IUserBackend) { - $name = $backend->getBackendName(); - } + $output->writeln('Delete all versions'); + foreach ($this->userManager->getBackends() as $backend) { + $name = get_class($backend); - $output->writeln("Delete versions 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->deleteVersions($user); - } - $offset += $limit; - } while (count($users) >= $limit); + if ($backend instanceof IUserBackend) { + $name = $backend->getBackendName(); } + + $output->writeln("Delete versions 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->deleteVersions($user); + } + $offset += $limit; + } while (count($users) >= $limit); } - return 0; + + return self::SUCCESS; } /** * delete versions for the given user - * - * @param string $user - * @param string|null $path */ - protected function deleteVersions(string $user, string $path = null): void { + protected function deleteVersions(string $user, ?string $path = null): void { \OC_Util::tearDownFS(); \OC_Util::setupFS($user); + $userHomeStorageId = $this->rootFolder->getUserFolder($user)->getStorage()->getCache()->getNumericStorageId(); + $this->versionMapper->deleteAllVersionsForUser($userHomeStorageId, $path); + $fullPath = '/' . $user . '/files_versions' . ($path ? '/' . $path : ''); if ($this->rootFolder->nodeExists($fullPath)) { $this->rootFolder->get($fullPath)->delete(); diff --git a/apps/files_versions/lib/Command/Expire.php b/apps/files_versions/lib/Command/Expire.php index 62b2343a5e0..a30e623c347 100644 --- a/apps/files_versions/lib/Command/Expire.php +++ b/apps/files_versions/lib/Command/Expire.php @@ -1,25 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Robin Appelman <robin@icewind.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: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files_Versions\Command; @@ -28,29 +12,21 @@ use OCA\Files_Versions\Storage; use OCP\Command\ICommand; use OCP\Files\StorageNotAvailableException; use OCP\IUserManager; +use OCP\Server; use Psr\Log\LoggerInterface; class Expire implements ICommand { use FileAccess; - /** - * @var string - */ - private $fileName; - - /** - * @var string - */ - private $user; - - public function __construct(string $user, string $fileName) { - $this->user = $user; - $this->fileName = $fileName; + public function __construct( + private string $user, + private string $fileName, + ) { } - public function handle() { + public function handle(): void { /** @var IUserManager $userManager */ - $userManager = \OC::$server->get(IUserManager::class); + $userManager = Server::get(IUserManager::class); if (!$userManager->userExists($this->user)) { // User has been deleted already return; @@ -62,8 +38,7 @@ class Expire implements ICommand { // In case of external storage and session credentials, the expiration // fails because the command does not have those credentials - /** @var LoggerInterface */ - $logger = \OC::$server->get(LoggerInterface::class); + $logger = Server::get(LoggerInterface::class); $logger->warning($e->getMessage(), [ 'exception' => $e, 'uid' => $this->user, diff --git a/apps/files_versions/lib/Command/ExpireVersions.php b/apps/files_versions/lib/Command/ExpireVersions.php index 43068e21451..d3f341a21d2 100644 --- a/apps/files_versions/lib/Command/ExpireVersions.php +++ b/apps/files_versions/lib/Command/ExpireVersions.php @@ -1,30 +1,13 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud GmbH. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud GmbH. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files_Versions\Command; +use OC\Files\View; use OCA\Files_Versions\Expiration; use OCA\Files_Versions\Storage; use OCP\IUser; @@ -36,73 +19,58 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; class ExpireVersions extends Command { - - /** - * @var Expiration - */ - private $expiration; - - /** - * @var IUserManager - */ - private $userManager; - - /** - * @param IUserManager $userManager - * @param Expiration $expiration - */ - public function __construct(IUserManager $userManager, - Expiration $expiration) { + public function __construct( + private IUserManager $userManager, + private Expiration $expiration, + ) { parent::__construct(); - - $this->userManager = $userManager; - $this->expiration = $expiration; } - protected function configure() { + protected function configure(): void { $this ->setName('versions:expire') ->setDescription('Expires the users file versions') ->addArgument( 'user_id', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, - 'expire file versions of the given user(s), if no user is given file versions for all users will be expired.' + 'expire file versions of the given account(s), if no account is given file versions for all accounts will be expired.' ); } protected function execute(InputInterface $input, OutputInterface $output): int { $maxAge = $this->expiration->getMaxAgeAsTimestamp(); if (!$maxAge) { - $output->writeln("Auto expiration is configured - expiration will be handled automatically according to the expiration patterns detailed at the following link https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/file_versioning.html."); - return 1; + $output->writeln('Auto expiration is configured - expiration will be handled automatically according to the expiration patterns detailed at the following link https://docs.nextcloud.com/server/latest/admin_manual/configuration_files/file_versioning.html.'); + return self::FAILURE; } $users = $input->getArgument('user_id'); if (!empty($users)) { foreach ($users as $user) { - if ($this->userManager->userExists($user)) { - $output->writeln("Remove deleted files of <info>$user</info>"); - $userObject = $this->userManager->get($user); - $this->expireVersionsForUser($userObject); - } else { - $output->writeln("<error>Unknown user $user</error>"); - return 1; + if (!$this->userManager->userExists($user)) { + $output->writeln("<error>Unknown account $user</error>"); + return self::FAILURE; } + + $output->writeln("Remove deleted files of <info>$user</info>"); + $userObject = $this->userManager->get($user); + $this->expireVersionsForUser($userObject); } - } else { - $p = new ProgressBar($output); - $p->start(); - $this->userManager->callForSeenUsers(function (IUser $user) use ($p) { - $p->advance(); - $this->expireVersionsForUser($user); - }); - $p->finish(); - $output->writeln(''); + return self::SUCCESS; } - return 0; + + $p = new ProgressBar($output); + $p->start(); + $this->userManager->callForSeenUsers(function (IUser $user) use ($p): void { + $p->advance(); + $this->expireVersionsForUser($user); + }); + $p->finish(); + $output->writeln(''); + return self::SUCCESS; } - public function expireVersionsForUser(IUser $user) { + public function expireVersionsForUser(IUser $user): void { $uid = $user->getUID(); if (!$this->setupFS($uid)) { return; @@ -112,15 +80,13 @@ class ExpireVersions extends Command { /** * Act on behalf on versions 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 version directory - $view = new \OC\Files\View('/' . $user); + $view = new View('/' . $user); if (!$view->is_dir('/files_versions')) { return false; } diff --git a/apps/files_versions/lib/Controller/PreviewController.php b/apps/files_versions/lib/Controller/PreviewController.php index 0e625dc2139..2c3ff8da70d 100644 --- a/apps/files_versions/lib/Controller/PreviewController.php +++ b/apps/files_versions/lib/Controller/PreviewController.php @@ -1,98 +1,89 @@ <?php + /** - * @copyright Copyright (c) 2016, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @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_Versions\Controller; use OCA\Files_Versions\Versions\IVersionManager; use OCP\AppFramework\Controller; use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\Attribute\NoCSRFRequired; +use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\DataResponse; use OCP\AppFramework\Http\FileDisplayResponse; +use OCP\AppFramework\Http\RedirectResponse; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; use OCP\IPreview; use OCP\IRequest; use OCP\IUserSession; +use OCP\Preview\IMimeIconProvider; +#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] class PreviewController extends Controller { - /** @var IRootFolder */ - private $rootFolder; - - /** @var IUserSession */ - private $userSession; - - /** @var IVersionManager */ - private $versionManager; - - /** @var IPreview */ - private $previewManager; - public function __construct( string $appName, IRequest $request, - IRootFolder $rootFolder, - IUserSession $userSession, - IVersionManager $versionManager, - IPreview $previewManager + private IRootFolder $rootFolder, + private IUserSession $userSession, + private IVersionManager $versionManager, + private IPreview $previewManager, + private IMimeIconProvider $mimeIconProvider, ) { parent::__construct($appName, $request); - - $this->rootFolder = $rootFolder; - $this->userSession = $userSession; - $this->versionManager = $versionManager; - $this->previewManager = $previewManager; } /** - * @NoAdminRequired - * @NoCSRFRequired + * Get the preview for a file version * - * @param string $file - * @param int $x - * @param int $y - * @param string $version - * @return DataResponse|FileDisplayResponse + * @param string $file Path of the file + * @param int $x Width of the preview + * @param int $y Height of the preview + * @param string $version Version of the file to get the preview for + * @param bool $mimeFallback Whether to fallback to the mime icon if no preview is available + * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, list<empty>, array{}>|RedirectResponse<Http::STATUS_SEE_OTHER, array{}> + * + * 200: Preview returned + * 303: Redirect to the mime icon url if mimeFallback is true + * 400: Getting preview is not possible + * 404: Preview not found */ + #[NoAdminRequired] + #[NoCSRFRequired] public function getPreview( string $file = '', int $x = 44, int $y = 44, - string $version = '' + string $version = '', + bool $mimeFallback = false, ) { if ($file === '' || $version === '' || $x === 0 || $y === 0) { return new DataResponse([], Http::STATUS_BAD_REQUEST); } + $versionFile = null; try { $user = $this->userSession->getUser(); $userFolder = $this->rootFolder->getUserFolder($user->getUID()); $file = $userFolder->get($file); $versionFile = $this->versionManager->getVersionFile($user, $file, $version); $preview = $this->previewManager->getPreview($versionFile, $x, $y, true, IPreview::MODE_FILL, $versionFile->getMimetype()); - return new FileDisplayResponse($preview, Http::STATUS_OK, ['Content-Type' => $preview->getMimeType()]); + $response = new FileDisplayResponse($preview, Http::STATUS_OK, ['Content-Type' => $preview->getMimeType()]); + $response->cacheFor(3600 * 24, false, true); + return $response; } catch (NotFoundException $e) { + // If we have no preview enabled, we can redirect to the mime icon if any + if ($mimeFallback && $versionFile !== null) { + $url = $this->mimeIconProvider->getMimeIconUrl($versionFile->getMimeType()); + if ($url !== null) { + return new RedirectResponse($url); + } + } + return new DataResponse([], Http::STATUS_NOT_FOUND); } catch (\InvalidArgumentException $e) { return new DataResponse([], Http::STATUS_BAD_REQUEST); diff --git a/apps/files_versions/lib/Db/VersionEntity.php b/apps/files_versions/lib/Db/VersionEntity.php new file mode 100644 index 00000000000..10f1dc8cbba --- /dev/null +++ b/apps/files_versions/lib/Db/VersionEntity.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_Versions\Db; + +use JsonSerializable; + +use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; + +/** + * @method int getFileId() + * @method void setFileId(int $fileId) + * @method int getTimestamp() + * @method void setTimestamp(int $timestamp) + * @method int|float getSize() + * @method void setSize(int|float $size) + * @method int getMimetype() + * @method void setMimetype(int $mimetype) + * @method array|null getMetadata() + * @method void setMetadata(array $metadata) + */ +class VersionEntity extends Entity implements JsonSerializable { + protected ?int $fileId = null; + protected ?int $timestamp = null; + protected ?int $size = null; + protected ?int $mimetype = null; + protected ?array $metadata = null; + + public function __construct() { + $this->addType('id', Types::INTEGER); + $this->addType('file_id', Types::INTEGER); + $this->addType('timestamp', Types::INTEGER); + $this->addType('size', Types::INTEGER); + $this->addType('mimetype', Types::INTEGER); + $this->addType('metadata', Types::JSON); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'file_id' => $this->fileId, + 'timestamp' => $this->timestamp, + 'size' => $this->size, + 'mimetype' => $this->mimetype, + 'metadata' => $this->metadata, + ]; + } + + /** + * @abstract given a key, return the value associated with the key in the metadata column + * if nothing is found, we return an empty string + * @param string $key key associated with the value + */ + public function getMetadataValue(string $key): ?string { + return $this->metadata[$key] ?? null; + } + + /** + * @abstract sets a key value pair in the metadata column + * @param string $key key associated with the value + * @param string $value value associated with the key + */ + public function setMetadataValue(string $key, string $value): void { + $this->metadata[$key] = $value; + $this->markFieldUpdated('metadata'); + } +} diff --git a/apps/files_versions/lib/Db/VersionsMapper.php b/apps/files_versions/lib/Db/VersionsMapper.php new file mode 100644 index 00000000000..318dd8f0d82 --- /dev/null +++ b/apps/files_versions/lib/Db/VersionsMapper.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_Versions\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * @extends QBMapper<VersionEntity> + */ +class VersionsMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'files_versions', VersionEntity::class); + } + + /** + * @return VersionEntity[] + */ + public function findAllVersionsForFileId(int $fileId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))); + + return $this->findEntities($qb); + } + + /** + * @return VersionEntity + */ + public function findCurrentVersionForFileId(int $fileId): VersionEntity { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))) + ->orderBy('timestamp', 'DESC') + ->setMaxResults(1); + + return $this->findEntity($qb); + } + + public function findVersionForFileId(int $fileId, int $timestamp): VersionEntity { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))) + ->andWhere($qb->expr()->eq('timestamp', $qb->createNamedParameter($timestamp))); + + return $this->findEntity($qb); + } + + public function deleteAllVersionsForFileId(int $fileId): int { + $qb = $this->db->getQueryBuilder(); + + return $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))) + ->executeStatement(); + } + + public function deleteAllVersionsForUser(int $storageId, ?string $path = null): void { + $fileIdsGenerator = $this->getFileIdsGenerator($storageId, $path); + + $versionEntitiesDeleteQuery = $this->db->getQueryBuilder(); + $versionEntitiesDeleteQuery->delete($this->getTableName()) + ->where($versionEntitiesDeleteQuery->expr()->in('file_id', $versionEntitiesDeleteQuery->createParameter('file_ids'))); + + foreach ($fileIdsGenerator as $fileIds) { + $versionEntitiesDeleteQuery->setParameter('file_ids', $fileIds, IQueryBuilder::PARAM_INT_ARRAY); + $versionEntitiesDeleteQuery->executeStatement(); + } + } + + private function getFileIdsGenerator(int $storageId, ?string $path): \Generator { + $offset = 0; + do { + $filesIdsSelect = $this->db->getQueryBuilder(); + $filesIdsSelect->select('fileid') + ->from('filecache') + ->where($filesIdsSelect->expr()->eq('storage', $filesIdsSelect->createNamedParameter($storageId, IQueryBuilder::PARAM_STR))) + ->andWhere($filesIdsSelect->expr()->like('path', $filesIdsSelect->createNamedParameter('files' . ($path ? '/' . $this->db->escapeLikeParameter($path) : '') . '/%', IQueryBuilder::PARAM_STR))) + ->andWhere($filesIdsSelect->expr()->gt('fileid', $filesIdsSelect->createParameter('offset'))) + ->setMaxResults(1000) + ->orderBy('fileid', 'ASC'); + + $filesIdsSelect->setParameter('offset', $offset, IQueryBuilder::PARAM_INT); + $result = $filesIdsSelect->executeQuery(); + $fileIds = $result->fetchAll(\PDO::FETCH_COLUMN); + $offset = end($fileIds); + + yield $fileIds; + } while (!empty($fileIds)); + } +} diff --git a/apps/files_versions/lib/Events/CreateVersionEvent.php b/apps/files_versions/lib/Events/CreateVersionEvent.php index d1a5bb00c35..92ed26b2dd6 100644 --- a/apps/files_versions/lib/Events/CreateVersionEvent.php +++ b/apps/files_versions/lib/Events/CreateVersionEvent.php @@ -1,26 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2017 Bjoern Schiessle <bjoern@schiessle.org> - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @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: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Events; @@ -40,17 +22,15 @@ class CreateVersionEvent extends Event { /** @var bool */ private $createVersion; - /** @var Node */ - private $node; - /** * CreateVersionEvent constructor. * * @param Node $node */ - public function __construct(Node $node) { + public function __construct( + private Node $node, + ) { $this->createVersion = true; - $this->node = $node; } /** diff --git a/apps/files_versions/lib/Events/VersionCreatedEvent.php b/apps/files_versions/lib/Events/VersionCreatedEvent.php new file mode 100644 index 00000000000..4dc7a7cb505 --- /dev/null +++ b/apps/files_versions/lib/Events/VersionCreatedEvent.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Events; + +use OCA\Files_Versions\Versions\IVersion; +use OCP\EventDispatcher\Event; +use OCP\Files\Node; + +/** + * Event dispatched after a successful creation of a version + */ +class VersionCreatedEvent extends Event { + public function __construct( + private Node $node, + private IVersion $version, + ) { + parent::__construct(); + } + + /** + * Node of the file that has been versioned + */ + public function getNode(): Node { + return $this->node; + } + + /** + * Version of the file that was created + */ + public function getVersion(): IVersion { + return $this->version; + } +} diff --git a/apps/files_versions/lib/Events/VersionRestoredEvent.php b/apps/files_versions/lib/Events/VersionRestoredEvent.php new file mode 100644 index 00000000000..12e91bd258d --- /dev/null +++ b/apps/files_versions/lib/Events/VersionRestoredEvent.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Events; + +use OCA\Files_Versions\Versions\IVersion; +use OCP\EventDispatcher\Event; + +/** + * Class VersionRestoredEvent + * + * Event that is called after a successful restore of a previous version + * + * @package OCA\Files_Versions + */ +class VersionRestoredEvent extends Event { + public function __construct( + private IVersion $version, + ) { + } + + /** + * Version that was restored + */ + public function getVersion(): IVersion { + return $this->version; + } +} diff --git a/apps/files_versions/lib/Expiration.php b/apps/files_versions/lib/Expiration.php index 40768e90af7..1e04d93379f 100644 --- a/apps/files_versions/lib/Expiration.php +++ b/apps/files_versions/lib/Expiration.php @@ -1,26 +1,9 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Morris Jobke <hey@morrisjobke.de> - * @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 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files_Versions; @@ -33,9 +16,6 @@ class Expiration { // how long do we keep files a version if no other value is defined in the config file (unit: days) public const NO_OBLIGATION = -1; - /** @var ITimeFactory */ - private $timeFactory; - /** @var string */ private $retentionObligation; @@ -48,12 +28,11 @@ class Expiration { /** @var bool */ private $canPurgeToSaveSpace; - /** @var LoggerInterface */ - private $logger; - - public function __construct(IConfig $config, ITimeFactory $timeFactory, LoggerInterface $logger) { - $this->timeFactory = $timeFactory; - $this->logger = $logger; + public function __construct( + IConfig $config, + private ITimeFactory $timeFactory, + private LoggerInterface $logger, + ) { $this->retentionObligation = $config->getSystemValue('versions_retention_obligation', 'auto'); if ($this->retentionObligation !== 'disabled') { @@ -121,6 +100,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; + } + + /** * Get maximal retention obligation as a timestamp * * @return int|false @@ -157,16 +150,16 @@ class Expiration { if (!ctype_digit($minValue) && $minValue !== 'auto') { $isValid = false; $this->logger->warning( - $minValue . ' is not a valid value for minimal versions retention obligation. Check versions_retention_obligation in your config.php. Falling back to auto.', - ['app' => 'files_versions'] + $minValue . ' is not a valid value for minimal versions retention obligation. Check versions_retention_obligation in your config.php. Falling back to auto.', + ['app' => 'files_versions'] ); } if (!ctype_digit($maxValue) && $maxValue !== 'auto') { $isValid = false; $this->logger->warning( - $maxValue . ' is not a valid value for maximal versions retention obligation. Check versions_retention_obligation in your config.php. Falling back to auto.', - ['app' => 'files_versions'] + $maxValue . ' is not a valid value for maximal versions retention obligation. Check versions_retention_obligation in your config.php. Falling back to auto.', + ['app' => 'files_versions'] ); } diff --git a/apps/files_versions/lib/Hooks.php b/apps/files_versions/lib/Hooks.php deleted file mode 100644 index 02f562b1874..00000000000 --- a/apps/files_versions/lib/Hooks.php +++ /dev/null @@ -1,142 +0,0 @@ -<?php -/** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Sam Tuke <mail@samtuke.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/> - * - */ -namespace OCA\Files_Versions; - -use OC\Files\Filesystem; -use OC\Files\Mount\MoveableMount; -use OC\Files\View; -use OCP\Util; - -class Hooks { - public static function connectHooks() { - // Listen to write signals - Util::connectHook('OC_Filesystem', 'write', Hooks::class, 'write_hook'); - // Listen to delete and rename signals - Util::connectHook('OC_Filesystem', 'post_delete', Hooks::class, 'remove_hook'); - Util::connectHook('OC_Filesystem', 'delete', Hooks::class, 'pre_remove_hook'); - Util::connectHook('OC_Filesystem', 'post_rename', Hooks::class, 'rename_hook'); - Util::connectHook('OC_Filesystem', 'post_copy', Hooks::class, 'copy_hook'); - Util::connectHook('OC_Filesystem', 'rename', Hooks::class, 'pre_renameOrCopy_hook'); - Util::connectHook('OC_Filesystem', 'copy', Hooks::class, 'pre_renameOrCopy_hook'); - } - - /** - * listen to write event. - */ - public static function write_hook(array $params): void { - $path = $params[Filesystem::signal_param_path]; - if ($path !== '') { - Storage::store($path); - } - } - - - /** - * Erase versions of deleted file - * @param array $params - * - * This function is connected to the delete signal of OC_Filesystem - * cleanup the versions directory if the actual file gets deleted - */ - public static function remove_hook(array $params): void { - $path = $params[Filesystem::signal_param_path]; - if ($path !== '') { - Storage::delete($path); - } - } - - /** - * mark file as "deleted" so that we can clean up the versions if the file is gone - * @param array $params - */ - public static function pre_remove_hook(array $params): void { - $path = $params[Filesystem::signal_param_path]; - if ($path !== '') { - Storage::markDeletedFile($path); - } - } - - /** - * rename/move versions of renamed/moved files - * @param array $params array with oldpath and newpath - * - * This function is connected to the rename signal of OC_Filesystem and adjust the name and location - * of the stored versions along the actual file - */ - public static function rename_hook(array $params): void { - $oldpath = $params['oldpath']; - $newpath = $params['newpath']; - if ($oldpath !== '' && $newpath !== '') { - Storage::renameOrCopy($oldpath, $newpath, 'rename'); - } - } - - /** - * copy versions of copied files - * @param array $params array with oldpath and newpath - * - * This function is connected to the copy signal of OC_Filesystem and copies the - * the stored versions to the new location - */ - public static function copy_hook(array $params): void { - $oldpath = $params['oldpath']; - $newpath = $params['newpath']; - if ($oldpath !== '' && $newpath !== '') { - Storage::renameOrCopy($oldpath, $newpath, 'copy'); - } - } - - /** - * Remember owner and the owner path of the source file. - * If the file already exists, then it was a upload of a existing file - * over the web interface and we call Storage::store() directly - * - * @param array $params array with oldpath and newpath - * - */ - public static function pre_renameOrCopy_hook(array $params): void { - // if we rename a movable mount point, then the versions don't have - // to be renamed - $absOldPath = Filesystem::normalizePath('/' . \OC_User::getUser() . '/files' . $params['oldpath']); - $manager = Filesystem::getMountManager(); - $mount = $manager->find($absOldPath); - $internalPath = $mount->getInternalPath($absOldPath); - if ($internalPath === '' and $mount instanceof MoveableMount) { - return; - } - - $view = new View(\OC_User::getUser() . '/files'); - if ($view->file_exists($params['newpath'])) { - Storage::store($params['newpath']); - } else { - Storage::setSourcePathAndUser($params['oldpath']); - } - } -} diff --git a/apps/files_versions/lib/Listener/FileEventsListener.php b/apps/files_versions/lib/Listener/FileEventsListener.php new file mode 100644 index 00000000000..969ca4ded45 --- /dev/null +++ b/apps/files_versions/lib/Listener/FileEventsListener.php @@ -0,0 +1,472 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OCA\Files_Versions\Listener; + +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use OC\DB\Exceptions\DbalException; +use OC\Files\Filesystem; +use OC\Files\Mount\MoveableMount; +use OC\Files\Node\NonExistingFile; +use OC\Files\Node\NonExistingFolder; +use OC\Files\View; +use OCA\Files_Versions\Storage; +use OCA\Files_Versions\Versions\INeedSyncVersionBackend; +use OCA\Files_Versions\Versions\IVersionManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\DB\Exception; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\Node\BeforeNodeCopiedEvent; +use OCP\Files\Events\Node\BeforeNodeDeletedEvent; +use OCP\Files\Events\Node\BeforeNodeRenamedEvent; +use OCP\Files\Events\Node\BeforeNodeTouchedEvent; +use OCP\Files\Events\Node\BeforeNodeWrittenEvent; +use OCP\Files\Events\Node\NodeCopiedEvent; +use OCP\Files\Events\Node\NodeCreatedEvent; +use OCP\Files\Events\Node\NodeDeletedEvent; +use OCP\Files\Events\Node\NodeRenamedEvent; +use OCP\Files\Events\Node\NodeTouchedEvent; +use OCP\Files\Events\Node\NodeWrittenEvent; +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\IUserSession; +use Psr\Log\LoggerInterface; + +/** @template-implements IEventListener<BeforeNodeCopiedEvent|BeforeNodeDeletedEvent|BeforeNodeRenamedEvent|BeforeNodeTouchedEvent|BeforeNodeWrittenEvent|NodeCopiedEvent|NodeCreatedEvent|NodeDeletedEvent|NodeRenamedEvent|NodeTouchedEvent|NodeWrittenEvent> */ +class FileEventsListener implements IEventListener { + /** + * @var array<int, array> + */ + private array $writeHookInfo = []; + /** + * @var array<int, Node> + */ + private array $nodesTouched = []; + /** + * @var array<string, Node> + */ + private array $versionsDeleted = []; + + public function __construct( + private IRootFolder $rootFolder, + private IVersionManager $versionManager, + private IMimeTypeLoader $mimeTypeLoader, + private IUserSession $userSession, + private LoggerInterface $logger, + ) { + } + + public function handle(Event $event): void { + if ($event instanceof NodeCreatedEvent) { + $this->created($event->getNode()); + } + + if ($event instanceof BeforeNodeTouchedEvent) { + $this->pre_touch_hook($event->getNode()); + } + + if ($event instanceof NodeTouchedEvent) { + $this->touch_hook($event->getNode()); + } + + if ($event instanceof BeforeNodeWrittenEvent) { + $this->write_hook($event->getNode()); + } + + if ($event instanceof NodeWrittenEvent) { + $this->post_write_hook($event->getNode()); + } + + if ($event instanceof BeforeNodeDeletedEvent) { + $this->pre_remove_hook($event->getNode()); + } + + if ($event instanceof NodeDeletedEvent) { + $this->remove_hook($event->getNode()); + } + + if ($event instanceof NodeRenamedEvent) { + $this->rename_hook($event->getSource(), $event->getTarget()); + } + + if ($event instanceof NodeCopiedEvent) { + $this->copy_hook($event->getSource(), $event->getTarget()); + } + + if ($event instanceof BeforeNodeRenamedEvent) { + $this->pre_renameOrCopy_hook($event->getSource(), $event->getTarget()); + } + + if ($event instanceof BeforeNodeCopiedEvent) { + $this->pre_renameOrCopy_hook($event->getSource(), $event->getTarget()); + } + } + + public function pre_touch_hook(Node $node): void { + // Do not handle folders. + if ($node instanceof Folder) { + return; + } + + // $node is a non-existing on file creation. + if ($node instanceof NonExistingFile) { + return; + } + + $this->nodesTouched[$node->getId()] = $node; + } + + public function touch_hook(Node $node): void { + // Do not handle folders. + if ($node instanceof Folder) { + return; + } + + if ($node instanceof NonExistingFile) { + $this->logger->error( + 'Failed to create or update version for {path}, node does not exist', + [ + 'path' => $node->getPath(), + ] + ); + + return; + } + + $previousNode = $this->nodesTouched[$node->getId()] ?? null; + + if ($previousNode === null) { + return; + } + + unset($this->nodesTouched[$node->getId()]); + + try { + if ($node instanceof File && $this->versionManager instanceof INeedSyncVersionBackend) { + $revision = $this->versionManager->getRevision($previousNode); + + // We update the timestamp of the version entity associated with the previousNode. + $this->versionManager->updateVersionEntity($node, $revision, ['timestamp' => $node->getMTime()]); + } + } catch (DbalException $ex) { + // Ignore UniqueConstraintViolationException, as we are probably in the middle of a rollback + // Where the previous node would temporary have the mtime of the old version, so the rollback touches it to fix it. + if (!($ex->getPrevious() instanceof UniqueConstraintViolationException)) { + throw $ex; + } + } catch (DoesNotExistException $ex) { + // Ignore DoesNotExistException, as we are probably in the middle of a rollback + // Where the previous node would temporary have a wrong mtime, so the rollback touches it to fix it. + } + } + + public function created(Node $node): void { + // Do not handle folders. + if (!($node instanceof File)) { + return; + } + + if ($node instanceof NonExistingFile) { + $this->logger->error( + 'Failed to create version for {path}, node does not exist', + [ + 'path' => $node->getPath(), + ] + ); + + return; + } + + if ($this->versionManager instanceof INeedSyncVersionBackend) { + $this->versionManager->createVersionEntity($node); + } + } + + /** + * listen to write event. + */ + public function write_hook(Node $node): void { + // Do not handle folders. + if ($node instanceof Folder) { + return; + } + + // $node is a non-existing on file creation. + if ($node instanceof NonExistingFile) { + return; + } + + $path = $this->getPathForNode($node); + $result = Storage::store($path); + + // Store the result of the version creation so it can be used in post_write_hook. + $this->writeHookInfo[$node->getId()] = [ + 'previousNode' => $node, + 'versionCreated' => $result !== false + ]; + } + + /** + * listen to post_write event. + */ + public function post_write_hook(Node $node): void { + // Do not handle folders. + if ($node instanceof Folder) { + return; + } + + if ($node instanceof NonExistingFile) { + $this->logger->error( + 'Failed to create or update version for {path}, node does not exist', + [ + 'path' => $node->getPath(), + ] + ); + + return; + } + + $writeHookInfo = $this->writeHookInfo[$node->getId()] ?? null; + + if ($writeHookInfo === null) { + return; + } + + if ( + $writeHookInfo['versionCreated'] + && $node->getMTime() !== $writeHookInfo['previousNode']->getMTime() + ) { + // If a new version was created, insert a version in the DB for the current content. + // If both versions have the same mtime, it means the latest version file simply got overrode, + // so no need to create a new version. + $this->created($node); + } else { + try { + // If no new version was stored in the FS, no new version should be added in the DB. + // So we simply update the associated version. + if ($node instanceof File && $this->versionManager instanceof INeedSyncVersionBackend) { + $revision = $this->versionManager->getRevision($writeHookInfo['previousNode']); + + $this->versionManager->updateVersionEntity( + $node, + $revision, + [ + 'timestamp' => $node->getMTime(), + 'size' => $node->getSize(), + 'mimetype' => $this->mimeTypeLoader->getId($node->getMimetype()), + ], + ); + } + } catch (DoesNotExistException $e) { + // This happens if the versions app was not enabled while the file was created or updated the last time. + // meaning there is no such revision and we need to create this file. + if ($writeHookInfo['versionCreated']) { + $this->created($node); + } else { + // Normally this should not happen so we re-throw the exception to not hide any potential issues. + throw $e; + } + } catch (Exception $e) { + $this->logger->error('Failed to update existing version for ' . $node->getPath(), [ + 'exception' => $e, + 'versionCreated' => $writeHookInfo['versionCreated'], + 'previousNode' => [ + 'size' => $writeHookInfo['previousNode']->getSize(), + 'mtime' => $writeHookInfo['previousNode']->getMTime(), + ], + 'node' => [ + 'size' => $node->getSize(), + 'mtime' => $node->getMTime(), + ] + ]); + throw $e; + } + } + + unset($this->writeHookInfo[$node->getId()]); + } + + /** + * Erase versions of deleted file + * + * This function is connected to the NodeDeletedEvent event + * cleanup the versions directory if the actual file gets deleted + */ + public function remove_hook(Node $node): void { + // Need to normalize the path as there is an issue with path concatenation in View.php::getAbsolutePath. + $path = Filesystem::normalizePath($node->getPath()); + if (!array_key_exists($path, $this->versionsDeleted)) { + return; + } + $node = $this->versionsDeleted[$path]; + $relativePath = $this->getPathForNode($node); + unset($this->versionsDeleted[$path]); + Storage::delete($relativePath); + // If no new version was stored in the FS, no new version should be added in the DB. + // So we simply update the associated version. + if ($node instanceof File && $this->versionManager instanceof INeedSyncVersionBackend) { + $this->versionManager->deleteVersionsEntity($node); + } + } + + /** + * mark file as "deleted" so that we can clean up the versions if the file is gone + */ + public function pre_remove_hook(Node $node): void { + $path = $this->getPathForNode($node); + Storage::markDeletedFile($path); + $this->versionsDeleted[$node->getPath()] = $node; + } + + /** + * rename/move versions of renamed/moved files + * + * This function is connected to the NodeRenamedEvent event and adjust the name and location + * of the stored versions along the actual file + */ + public function rename_hook(Node $source, Node $target): void { + $sourceBackend = $this->versionManager->getBackendForStorage($source->getParent()->getStorage()); + $targetBackend = $this->versionManager->getBackendForStorage($target->getStorage()); + // If different backends, do nothing. + if ($sourceBackend !== $targetBackend) { + return; + } + + $oldPath = $this->getPathForNode($source); + $newPath = $this->getPathForNode($target); + Storage::renameOrCopy($oldPath, $newPath, 'rename'); + } + + /** + * copy versions of copied files + * + * This function is connected to the NodeCopiedEvent event and copies the + * the stored versions to the new location + */ + public function copy_hook(Node $source, Node $target): void { + $sourceBackend = $this->versionManager->getBackendForStorage($source->getParent()->getStorage()); + $targetBackend = $this->versionManager->getBackendForStorage($target->getStorage()); + // If different backends, do nothing. + if ($sourceBackend !== $targetBackend) { + return; + } + + $oldPath = $this->getPathForNode($source); + $newPath = $this->getPathForNode($target); + Storage::renameOrCopy($oldPath, $newPath, 'copy'); + } + + /** + * Remember owner and the owner path of the source file. + * If the file already exists, then it was a upload of a existing file + * over the web interface and we call Storage::store() directly + * + * + */ + public function pre_renameOrCopy_hook(Node $source, Node $target): void { + $sourceBackend = $this->versionManager->getBackendForStorage($source->getStorage()); + $targetBackend = $this->versionManager->getBackendForStorage($target->getParent()->getStorage()); + // If different backends, do nothing. + if ($sourceBackend !== $targetBackend) { + return; + } + + // if we rename a movable mount point, then the versions don't have to be renamed + $oldPath = $this->getPathForNode($source); + $newPath = $this->getPathForNode($target); + if ($oldPath === null || $newPath === null) { + return; + } + + $user = $this->userSession->getUser()?->getUID(); + if ($user === null) { + return; + } + + $absOldPath = Filesystem::normalizePath('/' . $user . '/files' . $oldPath); + $manager = Filesystem::getMountManager(); + $mount = $manager->find($absOldPath); + $internalPath = $mount->getInternalPath($absOldPath); + if ($internalPath === '' and $mount instanceof MoveableMount) { + return; + } + + $view = new View($user . '/files'); + if ($view->file_exists($newPath)) { + Storage::store($newPath); + } else { + Storage::setSourcePathAndUser($oldPath); + } + } + + /** + * Retrieve the path relative to the current user root folder. + * If no user is connected, try to use the node's owner. + */ + private function getPathForNode(Node $node): ?string { + $user = $this->userSession->getUser()?->getUID(); + if ($user) { + $path = $this->rootFolder + ->getUserFolder($user) + ->getRelativePath($node->getPath()); + + if ($path !== null) { + return $path; + } + } + + try { + $owner = $node->getOwner()?->getUid(); + } catch (NotFoundException) { + $owner = null; + } + + // If no owner, extract it from the path. + // e.g. /user/files/foobar.txt + if (!$owner) { + $parts = explode('/', $node->getPath(), 4); + if (count($parts) === 4) { + $owner = $parts[1]; + } + } + + if ($owner) { + $path = $this->rootFolder + ->getUserFolder($owner) + ->getRelativePath($node->getPath()); + + if ($path !== null) { + return $path; + } + } + + if (!($node instanceof NonExistingFile) && !($node instanceof NonExistingFolder)) { + $this->logger->debug('Failed to compute path for node', [ + 'node' => [ + 'path' => $node->getPath(), + 'owner' => $owner, + 'fileid' => $node->getId(), + 'size' => $node->getSize(), + 'mtime' => $node->getMTime(), + ] + ]); + } else { + $this->logger->debug('Failed to compute path for node', [ + 'node' => [ + 'path' => $node->getPath(), + 'owner' => $owner, + ] + ]); + } + return null; + } +} diff --git a/apps/files_versions/lib/Listener/LegacyRollbackListener.php b/apps/files_versions/lib/Listener/LegacyRollbackListener.php new file mode 100644 index 00000000000..072c1511caa --- /dev/null +++ b/apps/files_versions/lib/Listener/LegacyRollbackListener.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Listener; + +use OCA\Files_Versions\Events\VersionRestoredEvent; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; + +/** + * This listener is designed to be compatible with third-party code + * that can still use a hook. This listener will be removed in + * the next version and the rollback hook will stop working. + * + * @deprecated 32.0.0 + * @template-implements IEventListener<VersionRestoredEvent> + */ +class LegacyRollbackListener implements IEventListener { + public function handle(Event $event): void { + if (!($event instanceof VersionRestoredEvent)) { + return; + } + $version = $event->getVersion(); + \OC_Hook::emit('\OCP\Versions', 'rollback', [ + 'path' => $version->getVersionPath(), + 'revision' => $version->getRevisionId(), + 'node' => $version->getSourceFile(), + ]); + } +} diff --git a/apps/files_versions/lib/Listener/LoadAdditionalListener.php b/apps/files_versions/lib/Listener/LoadAdditionalListener.php index aeea8756515..cb955629c0f 100644 --- a/apps/files_versions/lib/Listener/LoadAdditionalListener.php +++ b/apps/files_versions/lib/Listener/LoadAdditionalListener.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Listener; @@ -33,6 +14,7 @@ use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Util; +/** @template-implements IEventListener<LoadAdditionalScriptsEvent> */ class LoadAdditionalListener implements IEventListener { public function handle(Event $event): void { if (!($event instanceof LoadAdditionalScriptsEvent)) { diff --git a/apps/files_versions/lib/Listener/LoadSidebarListener.php b/apps/files_versions/lib/Listener/LoadSidebarListener.php index 27261fbc3d2..b8d13fa4810 100644 --- a/apps/files_versions/lib/Listener/LoadSidebarListener.php +++ b/apps/files_versions/lib/Listener/LoadSidebarListener.php @@ -3,27 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author John Molakvoæ <skjnldsv@protonmail.com> - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Listener; @@ -33,6 +14,7 @@ use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\Util; +/** @template-implements IEventListener<LoadSidebar> */ class LoadSidebarListener implements IEventListener { public function handle(Event $event): void { if (!($event instanceof LoadSidebar)) { diff --git a/apps/files_versions/lib/Listener/VersionAuthorListener.php b/apps/files_versions/lib/Listener/VersionAuthorListener.php new file mode 100644 index 00000000000..9b93b1f888b --- /dev/null +++ b/apps/files_versions/lib/Listener/VersionAuthorListener.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Listener; + +use OC\Files\Node\Folder; +use OCA\Files_Versions\Sabre\Plugin; +use OCA\Files_Versions\Versions\IMetadataVersionBackend; +use OCA\Files_Versions\Versions\IVersionManager; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\Node\NodeWrittenEvent; +use OCP\Files\Node; +use OCP\IUserSession; + +/** @template-implements IEventListener<NodeWrittenEvent> */ +class VersionAuthorListener implements IEventListener { + public function __construct( + private IVersionManager $versionManager, + private IUserSession $userSession, + ) { + } + + /** + * @abstract handles events from a nodes version being changed + * @param Event $event the event that triggered this listener to activate + */ + public function handle(Event $event): void { + if ($event instanceof NodeWrittenEvent) { + $this->post_write_hook($event->getNode()); + } + } + + /** + * @abstract handles the NodeWrittenEvent, and sets the metadata for the associated node + * @param Node $node the node that is currently being written + */ + public function post_write_hook(Node $node): void { + $user = $this->userSession->getUser(); + // Do not handle folders or users that we cannot get metadata from + if ($node instanceof Folder || is_null($user)) { + return; + } + // check if our version manager supports setting the metadata + if ($this->versionManager instanceof IMetadataVersionBackend) { + $revision = $this->versionManager->getRevision($node); + $author = $user->getUID(); + $this->versionManager->setMetadataValue($node, $revision, Plugin::AUTHOR, $author); + } + } +} diff --git a/apps/files_versions/lib/Listener/VersionStorageMoveListener.php b/apps/files_versions/lib/Listener/VersionStorageMoveListener.php new file mode 100644 index 00000000000..d0a0bcf4a92 --- /dev/null +++ b/apps/files_versions/lib/Listener/VersionStorageMoveListener.php @@ -0,0 +1,140 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_Versions\Listener; + +use Exception; +use OC\Files\Node\NonExistingFile; +use OC\Files\Node\NonExistingFolder; +use OCA\Files_Versions\Versions\IVersionBackend; +use OCA\Files_Versions\Versions\IVersionManager; +use OCA\Files_Versions\Versions\IVersionsImporterBackend; +use OCP\EventDispatcher\Event; +use OCP\EventDispatcher\IEventListener; +use OCP\Files\Events\Node\AbstractNodesEvent; +use OCP\Files\Events\Node\BeforeNodeRenamedEvent; +use OCP\Files\Events\Node\NodeCopiedEvent; +use OCP\Files\Events\Node\NodeRenamedEvent; +use OCP\Files\File; +use OCP\Files\Folder; +use OCP\Files\Node; +use OCP\Files\Storage\IStorage; +use OCP\IUser; +use OCP\IUserSession; + +/** @template-implements IEventListener<Event> */ +class VersionStorageMoveListener implements IEventListener { + /** @var File[] */ + private array $movedNodes = []; + + public function __construct( + private IVersionManager $versionManager, + private IUserSession $userSession, + ) { + } + + /** + * @abstract Moves version across storages if necessary. + * @throws Exception No user in session + */ + public function handle(Event $event): void { + if (!($event instanceof AbstractNodesEvent)) { + return; + } + + $source = $event->getSource(); + $target = $event->getTarget(); + + $sourceStorage = $this->getNodeStorage($source); + $targetStorage = $this->getNodeStorage($target); + + $sourceBackend = $this->versionManager->getBackendForStorage($sourceStorage); + $targetBackend = $this->versionManager->getBackendForStorage($targetStorage); + + // If same backend, nothing to do. + if ($sourceBackend === $targetBackend) { + return; + } + + $user = $this->userSession->getUser() ?? $source->getOwner(); + + if ($user === null) { + throw new Exception('Cannot move versions across storages without a user.'); + } + + if ($event instanceof BeforeNodeRenamedEvent) { + $this->recursivelyPrepareMove($source); + } elseif ($event instanceof NodeRenamedEvent || $event instanceof NodeCopiedEvent) { + $this->recursivelyHandleMoveOrCopy($event, $user, $source, $target, $sourceBackend, $targetBackend); + } + } + + /** + * Store all sub files in this->movedNodes so their info can be used after the operation. + */ + private function recursivelyPrepareMove(Node $source): void { + if ($source instanceof File) { + $this->movedNodes[$source->getId()] = $source; + } elseif ($source instanceof Folder) { + foreach ($source->getDirectoryListing() as $child) { + $this->recursivelyPrepareMove($child); + } + } + } + + /** + * Call handleMoveOrCopy on each sub files + * @param NodeRenamedEvent|NodeCopiedEvent $event + */ + private function recursivelyHandleMoveOrCopy(Event $event, IUser $user, ?Node $source, Node $target, IVersionBackend $sourceBackend, IVersionBackend $targetBackend): void { + if ($target instanceof File) { + if ($event instanceof NodeRenamedEvent) { + $source = $this->movedNodes[$target->getId()]; + } + + /** @var File $source */ + $this->handleMoveOrCopy($event, $user, $source, $target, $sourceBackend, $targetBackend); + } elseif ($target instanceof Folder) { + /** @var Folder $source */ + foreach ($target->getDirectoryListing() as $targetChild) { + if ($event instanceof NodeCopiedEvent) { + $sourceChild = $source->get($targetChild->getName()); + } else { + $sourceChild = null; + } + + $this->recursivelyHandleMoveOrCopy($event, $user, $sourceChild, $targetChild, $sourceBackend, $targetBackend); + } + } + } + + /** + * Called only during NodeRenamedEvent or NodeCopiedEvent + * Will send the source node versions to the new backend, and then delete them from the old backend. + * @param NodeRenamedEvent|NodeCopiedEvent $event + */ + private function handleMoveOrCopy(Event $event, IUser $user, File $source, File $target, IVersionBackend $sourceBackend, IVersionBackend $targetBackend): void { + if ($targetBackend instanceof IVersionsImporterBackend) { + $versions = $sourceBackend->getVersionsForFile($user, $source); + $targetBackend->importVersionsForFile($user, $source, $target, $versions); + } + + if ($event instanceof NodeRenamedEvent && $sourceBackend instanceof IVersionsImporterBackend) { + $sourceBackend->clearVersionsForFile($user, $source, $target); + } + } + + private function getNodeStorage(Node $node): IStorage { + if ($node instanceof NonExistingFile || $node instanceof NonExistingFolder) { + return $node->getParent()->getStorage(); + } else { + return $node->getStorage(); + } + } +} diff --git a/apps/files_versions/lib/Migration/Version1020Date20221114144058.php b/apps/files_versions/lib/Migration/Version1020Date20221114144058.php new file mode 100644 index 00000000000..77c8c2201f3 --- /dev/null +++ b/apps/files_versions/lib/Migration/Version1020Date20221114144058.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OCA\Files_Versions\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Auto-generated migration step: Please modify to your needs! + */ +class Version1020Date20221114144058 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): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if ($schema->hasTable('files_versions')) { + return null; + } + + $table = $schema->createTable('files_versions'); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('file_id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('timestamp', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('size', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('mimetype', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('metadata', Types::JSON, [ + 'notnull' => true, + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['file_id', 'timestamp'], 'files_versions_uniq_index'); + + return $schema; + } +} diff --git a/apps/files_versions/lib/Sabre/Plugin.php b/apps/files_versions/lib/Sabre/Plugin.php index 5a127b4251d..984c4a36e5b 100644 --- a/apps/files_versions/lib/Sabre/Plugin.php +++ b/apps/files_versions/lib/Sabre/Plugin.php @@ -3,45 +3,39 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2019, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @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: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Sabre; use OC\AppFramework\Http\Request; +use OCA\DAV\Connector\Sabre\FilesPlugin; +use OCP\IPreview; use OCP\IRequest; use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\INode; +use Sabre\DAV\PropFind; +use Sabre\DAV\PropPatch; use Sabre\DAV\Server; use Sabre\DAV\ServerPlugin; use Sabre\HTTP\RequestInterface; use Sabre\HTTP\ResponseInterface; class Plugin extends ServerPlugin { + private Server $server; - /** @var Server */ - private $server; - /** @var IRequest */ - private $request; + public const LABEL = 'label'; - public function __construct(IRequest $request) { + public const AUTHOR = 'author'; + + public const VERSION_LABEL = '{http://nextcloud.org/ns}version-label'; + + public const VERSION_AUTHOR = '{http://nextcloud.org/ns}version-author'; // dav property for author + + public function __construct( + private IRequest $request, + private IPreview $previewManager, + ) { $this->request = $request; } @@ -49,6 +43,8 @@ class Plugin extends ServerPlugin { $this->server = $server; $server->on('afterMethod:GET', [$this, 'afterGet']); + $server->on('propFind', [$this, 'propFind']); + $server->on('propPatch', [$this, 'propPatch']); } public function afterGet(RequestInterface $request, ResponseInterface $response) { @@ -81,4 +77,23 @@ class Plugin extends ServerPlugin { . '; filename="' . rawurlencode($filename) . '"'); } } + + public function propFind(PropFind $propFind, INode $node): void { + if ($node instanceof VersionFile) { + $propFind->handle(self::VERSION_LABEL, fn () => $node->getMetadataValue(self::LABEL)); + $propFind->handle(self::VERSION_AUTHOR, fn () => $node->getMetadataValue(self::AUTHOR)); + $propFind->handle( + FilesPlugin::HAS_PREVIEW_PROPERTYNAME, + fn (): string => $this->previewManager->isMimeSupported($node->getContentType()) ? 'true' : 'false', + ); + } + } + + public function propPatch($path, PropPatch $propPatch): void { + $node = $this->server->tree->getNodeForPath($path); + + if ($node instanceof VersionFile) { + $propPatch->handle(self::VERSION_LABEL, fn (string $label) => $node->setMetadataValue(self::LABEL, $label)); + } + } } diff --git a/apps/files_versions/lib/Sabre/RestoreFolder.php b/apps/files_versions/lib/Sabre/RestoreFolder.php index 31d87cc7112..7904b098a4f 100644 --- a/apps/files_versions/lib/Sabre/RestoreFolder.php +++ b/apps/files_versions/lib/Sabre/RestoreFolder.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Sabre; diff --git a/apps/files_versions/lib/Sabre/RootCollection.php b/apps/files_versions/lib/Sabre/RootCollection.php index 835df5930bc..1e7129f23da 100644 --- a/apps/files_versions/lib/Sabre/RootCollection.php +++ b/apps/files_versions/lib/Sabre/RootCollection.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Sabre; @@ -34,33 +17,16 @@ use Sabre\DAVACL\PrincipalBackend; class RootCollection extends AbstractPrincipalCollection { - /** @var IRootFolder */ - private $rootFolder; - - /** @var IUserManager */ - private $userManager; - - /** @var IVersionManager */ - private $versionManager; - - /** @var IUserSession */ - private $userSession; - public function __construct( PrincipalBackend\BackendInterface $principalBackend, - IRootFolder $rootFolder, + private IRootFolder $rootFolder, IConfig $config, - IUserManager $userManager, - IVersionManager $versionManager, - IUserSession $userSession + private IUserManager $userManager, + private IVersionManager $versionManager, + private IUserSession $userSession, ) { parent::__construct($principalBackend, 'principals/users'); - $this->rootFolder = $rootFolder; - $this->userManager = $userManager; - $this->versionManager = $versionManager; - $this->userSession = $userSession; - $this->disableListing = !$config->getSystemValue('debug', false); } diff --git a/apps/files_versions/lib/Sabre/VersionCollection.php b/apps/files_versions/lib/Sabre/VersionCollection.php index 946ac9baad7..375d5cf99f2 100644 --- a/apps/files_versions/lib/Sabre/VersionCollection.php +++ b/apps/files_versions/lib/Sabre/VersionCollection.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Sabre; @@ -36,19 +18,11 @@ use Sabre\DAV\ICollection; class VersionCollection implements ICollection { - /** @var File */ - private $file; - - /** @var IUser */ - private $user; - - /** @var IVersionManager */ - private $versionManager; - - public function __construct(File $file, IUser $user, IVersionManager $versionManager) { - $this->file = $file; - $this->user = $user; - $this->versionManager = $versionManager; + public function __construct( + private File $file, + private IUser $user, + private IVersionManager $versionManager, + ) { } public function createFile($name, $data = null) { diff --git a/apps/files_versions/lib/Sabre/VersionFile.php b/apps/files_versions/lib/Sabre/VersionFile.php index b7c7e6db1a6..faa03473648 100644 --- a/apps/files_versions/lib/Sabre/VersionFile.php +++ b/apps/files_versions/lib/Sabre/VersionFile.php @@ -3,29 +3,16 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Sabre; +use OCA\Files_Versions\Versions\IDeletableVersionBackend; +use OCA\Files_Versions\Versions\IMetadataVersion; +use OCA\Files_Versions\Versions\IMetadataVersionBackend; +use OCA\Files_Versions\Versions\INameableVersion; +use OCA\Files_Versions\Versions\INameableVersionBackend; use OCA\Files_Versions\Versions\IVersion; use OCA\Files_Versions\Versions\IVersionManager; use OCP\Files\NotFoundException; @@ -34,15 +21,10 @@ use Sabre\DAV\Exception\NotFound; use Sabre\DAV\IFile; class VersionFile implements IFile { - /** @var IVersion */ - private $version; - - /** @var IVersionManager */ - private $versionManager; - - public function __construct(IVersion $version, IVersionManager $versionManager) { - $this->version = $version; - $this->versionManager = $versionManager; + public function __construct( + private IVersion $version, + private IVersionManager $versionManager, + ) { } public function put($data) { @@ -65,12 +47,20 @@ class VersionFile implements IFile { return (string)$this->version->getRevisionId(); } - public function getSize(): int { + /** + * @psalm-suppress ImplementedReturnTypeMismatch \Sabre\DAV\IFile::getSize signature does not support 32bit + * @return int|float + */ + public function getSize(): int|float { return $this->version->getSize(); } public function delete() { - throw new Forbidden(); + if ($this->versionManager instanceof IDeletableVersionBackend) { + $this->versionManager->deleteVersion($this->version); + } else { + throw new Forbidden(); + } } public function getName(): string { @@ -81,6 +71,29 @@ class VersionFile implements IFile { throw new Forbidden(); } + public function setMetadataValue(string $key, string $value): bool { + $backend = $this->version->getBackend(); + + if ($backend instanceof IMetadataVersionBackend) { + $backend->setMetadataValue($this->version->getSourceFile(), $this->version->getTimestamp(), $key, $value); + return true; + } elseif ($key === 'label' && $backend instanceof INameableVersionBackend) { + $backend->setVersionLabel($this->version, $value); + return true; + } else { + return false; + } + } + + public function getMetadataValue(string $key): ?string { + if ($this->version instanceof IMetadataVersion) { + return $this->version->getMetadataValue($key); + } elseif ($key === 'label' && $this->version instanceof INameableVersion) { + return $this->version->getLabel(); + } + return null; + } + public function getLastModified(): int { return $this->version->getTimestamp(); } diff --git a/apps/files_versions/lib/Sabre/VersionHome.php b/apps/files_versions/lib/Sabre/VersionHome.php index 13505d9a96c..07ac491f2a1 100644 --- a/apps/files_versions/lib/Sabre/VersionHome.php +++ b/apps/files_versions/lib/Sabre/VersionHome.php @@ -1,25 +1,8 @@ <?php + /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Sabre; @@ -32,23 +15,12 @@ use Sabre\DAV\ICollection; class VersionHome implements ICollection { - /** @var array */ - private $principalInfo; - - /** @var IRootFolder */ - private $rootFolder; - - /** @var IUserManager */ - private $userManager; - - /** @var IVersionManager */ - private $versionManager; - - public function __construct(array $principalInfo, IRootFolder $rootFolder, IUserManager $userManager, IVersionManager $versionManager) { - $this->principalInfo = $principalInfo; - $this->rootFolder = $rootFolder; - $this->userManager = $userManager; - $this->versionManager = $versionManager; + public function __construct( + private array $principalInfo, + private IRootFolder $rootFolder, + private IUserManager $userManager, + private IVersionManager $versionManager, + ) { } private function getUser() { diff --git a/apps/files_versions/lib/Sabre/VersionRoot.php b/apps/files_versions/lib/Sabre/VersionRoot.php index 69ac12ed8e9..7f7014fbee3 100644 --- a/apps/files_versions/lib/Sabre/VersionRoot.php +++ b/apps/files_versions/lib/Sabre/VersionRoot.php @@ -3,26 +3,8 @@ declare(strict_types=1); /** - * @copyright 2018, Roeland Jago Douma <roeland@famdouma.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Sabre; @@ -36,19 +18,11 @@ use Sabre\DAV\ICollection; class VersionRoot implements ICollection { - /** @var IUser */ - private $user; - - /** @var IRootFolder */ - private $rootFolder; - - /** @var IVersionManager */ - private $versionManager; - - public function __construct(IUser $user, IRootFolder $rootFolder, IVersionManager $versionManager) { - $this->user = $user; - $this->rootFolder = $rootFolder; - $this->versionManager = $versionManager; + public function __construct( + private IUser $user, + private IRootFolder $rootFolder, + private IVersionManager $versionManager, + ) { } public function delete() { @@ -75,14 +49,12 @@ class VersionRoot implements ICollection { $userFolder = $this->rootFolder->getUserFolder($this->user->getUID()); $fileId = (int)$name; - $nodes = $userFolder->getById($fileId); + $node = $userFolder->getFirstNodeById($fileId); - if ($nodes === []) { + if (!$node) { throw new NotFound(); } - $node = array_pop($nodes); - if (!$node instanceof File) { throw new NotFound(); } diff --git a/apps/files_versions/lib/Storage.php b/apps/files_versions/lib/Storage.php index 3f91e32ef95..6d53a19a518 100644 --- a/apps/files_versions/lib/Storage.php +++ b/apps/files_versions/lib/Storage.php @@ -1,70 +1,49 @@ <?php + /** - * @copyright Copyright (c) 2016, ownCloud, Inc. - * - * @author Arthur Schiwon <blizzz@arthur-schiwon.de> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Bjoern Schiessle <bjoern@schiessle.org> - * @author Björn Schießle <bjoern@schiessle.org> - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Felix Moeller <mail@felixmoeller.de> - * @author Felix Nieuwenhuizen <felix@tdlrali.com> - * @author Joas Schilling <coding@schilljs.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Julius Härtl <jus@bitgrid.net> - * @author Liam JACK <liamjack@users.noreply.github.com> - * @author Lukas Reschke <lukas@statuscode.ch> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <robin@icewind.nl> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Roeland Jago Douma <roeland@famdouma.nl> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * @author Vincent Petry <vincent@nextcloud.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 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ namespace OCA\Files_Versions; +use OC\Files\Filesystem; +use OC\Files\ObjectStore\ObjectStoreStorage; use OC\Files\Search\SearchBinaryOperator; use OC\Files\Search\SearchComparison; use OC\Files\Search\SearchQuery; -use OC_User; -use OC\Files\Filesystem; use OC\Files\View; +use OC\User\NoUserException; +use OC_User; +use OCA\Files_Sharing\SharedMount; use OCA\Files_Versions\AppInfo\Application; use OCA\Files_Versions\Command\Expire; +use OCA\Files_Versions\Db\VersionsMapper; use OCA\Files_Versions\Events\CreateVersionEvent; use OCA\Files_Versions\Versions\IVersionManager; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\Command\IBus; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\Files; use OCP\Files\FileInfo; use OCP\Files\Folder; +use OCP\Files\IMimeTypeDetector; use OCP\Files\IRootFolder; use OCP\Files\Node; -use OCP\Command\IBus; -use OCP\EventDispatcher\IEventDispatcher; -use OCP\Files\IMimeTypeDetector; use OCP\Files\NotFoundException; +use OCP\Files\NotPermittedException; use OCP\Files\Search\ISearchBinaryOperator; use OCP\Files\Search\ISearchComparison; +use OCP\Files\Storage\IWriteStreamStorage; +use OCP\Files\StorageInvalidException; use OCP\Files\StorageNotAvailableException; use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; use OCP\Lock\ILockingProvider; +use OCP\Server; +use OCP\Util; use Psr\Log\LoggerInterface; class Storage { @@ -96,7 +75,7 @@ class Storage { 6 => ['intervalEndsAfter' => -1, 'step' => 604800], ]; - /** @var \OCA\Files_Versions\AppInfo\Application */ + /** @var Application */ private static $application; /** @@ -105,11 +84,11 @@ class Storage { * * @param string $filename * @return array - * @throws \OC\User\NoUserException + * @throws NoUserException */ public static function getUidAndFilename($filename) { $uid = Filesystem::getOwner($filename); - $userManager = \OC::$server->get(IUserManager::class); + $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 create the versions @@ -119,7 +98,7 @@ class Storage { Filesystem::initMountPoints($uid); if ($uid !== OC_User::getUser()) { $info = Filesystem::getFileInfo($filename); - $ownerView = new View('/'.$uid.'/files'); + $ownerView = new View('/' . $uid . '/files'); try { $filename = $ownerView->getPath($info['fileid']); // make sure that the file name doesn't end with a trailing slash @@ -175,7 +154,6 @@ class Storage { * store a new version of a file. */ public static function store($filename) { - // if the file gets streamed we need to remove the .part extension // to get the right target $ext = pathinfo($filename, PATHINFO_EXTENSION); @@ -188,33 +166,54 @@ class Storage { return false; } - [$uid, $filename] = self::getUidAndFilename($filename); + // since hook paths are always relative to the "default filesystem view" + // we always use the owner from there to get the full node + $uid = Filesystem::getView()->getOwner(''); - $files_view = new View('/'.$uid .'/files'); + /** @var IRootFolder $rootFolder */ + $rootFolder = Server::get(IRootFolder::class); + $userFolder = $rootFolder->getUserFolder($uid); - $eventDispatcher = \OC::$server->get(IEventDispatcher::class); - $fileInfo = $files_view->getFileInfo($filename); - $id = $fileInfo->getId(); - $nodes = \OC::$server->get(IRootFolder::class)->getUserFolder($uid)->getById($id); - foreach ($nodes as $node) { - $event = new CreateVersionEvent($node); - $eventDispatcher->dispatch('OCA\Files_Versions::createVersion', $event); - if ($event->shouldCreateVersion() === false) { - return false; + $eventDispatcher = Server::get(IEventDispatcher::class); + try { + $file = $userFolder->get($filename); + } catch (NotFoundException $e) { + return false; + } + + $mount = $file->getMountPoint(); + if ($mount instanceof SharedMount) { + $ownerFolder = $rootFolder->getUserFolder($mount->getShare()->getShareOwner()); + $ownerNode = $ownerFolder->getFirstNodeById($file->getId()); + if ($ownerNode) { + $file = $ownerNode; + $uid = $mount->getShare()->getShareOwner(); } } + /** @var IUserManager $userManager */ + $userManager = Server::get(IUserManager::class); + $user = $userManager->get($uid); + + if (!$user) { + return false; + } + // no use making versions for empty files - if ($fileInfo->getSize() === 0) { + if ($file->getSize() === 0) { + return false; + } + + $event = new CreateVersionEvent($file); + $eventDispatcher->dispatch('OCA\Files_Versions::createVersion', $event); + if ($event->shouldCreateVersion() === false) { return false; } /** @var IVersionManager $versionManager */ - $versionManager = \OC::$server->get(IVersionManager::class); - $userManager = \OC::$server->get(IUserManager::class); - $user = $userManager->get($uid); + $versionManager = Server::get(IVersionManager::class); - $versionManager->createVersion($user, $fileInfo); + $versionManager->createVersion($user, $file); } @@ -270,12 +269,23 @@ class Storage { } /** + * Delete a version of a file + */ + public static function deleteRevision(string $path, int $revision): void { + [$uid, $filename] = self::getUidAndFilename($path); + $view = new View('/' . $uid . '/files_versions'); + \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]); + self::deleteVersion($view, $filename . '.v' . $revision); + \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]); + } + + /** * Rename or copy versions of a file of the given paths * * @param string $sourcePath source path of the file to move, relative to - * the currently logged in user's "files" folder + * the currently logged in user's "files" folder * @param string $targetPath target path of the file to move, relative to - * the currently logged in user's "files" folder + * the currently logged in user's "files" folder * @param string $operation can be 'copy' or 'rename' */ public static function renameOrCopy($sourcePath, $targetPath, $operation) { @@ -300,7 +310,7 @@ class Storage { // does the directory exists for versions too ? if ($rootView->is_dir('/' . $sourceOwner . '/files_versions/' . $sourcePath)) { // create missing dirs if necessary - self::createMissingDirectories($targetPath, new View('/'. $targetOwner)); + self::createMissingDirectories($targetPath, new View('/' . $targetOwner)); // move the directory containing the versions $rootView->$operation( @@ -310,13 +320,13 @@ class Storage { } } elseif ($versions = Storage::getVersions($sourceOwner, '/' . $sourcePath)) { // create missing dirs if necessary - self::createMissingDirectories($targetPath, new View('/'. $targetOwner)); + self::createMissingDirectories($targetPath, new View('/' . $targetOwner)); foreach ($versions as $v) { // move each version one by one to the target directory $rootView->$operation( - '/' . $sourceOwner . '/files_versions/' . $sourcePath.'.v' . $v['version'], - '/' . $targetOwner . '/files_versions/' . $targetPath.'.v'.$v['version'] + '/' . $sourceOwner . '/files_versions/' . $sourcePath . '.v' . $v['version'], + '/' . $targetOwner . '/files_versions/' . $targetPath . '.v' . $v['version'] ); } } @@ -335,16 +345,15 @@ class Storage { * @return bool */ public static function rollback(string $file, int $revision, IUser $user) { - // add expected leading slash $filename = '/' . ltrim($file, '/'); // Fetch the userfolder to trigger view hooks - $root = \OC::$server->get(IRootFolder::class); + $root = Server::get(IRootFolder::class); $userFolder = $root->getUserFolder($user->getUID()); - $users_view = new View('/'.$user->getUID()); - $files_view = new View('/'. $user->getUID().'/files'); + $users_view = new View('/' . $user->getUID()); + $files_view = new View('/' . $user->getUID() . '/files'); $versionCreated = false; @@ -356,9 +365,9 @@ class Storage { } //first create a new version - $version = 'files_versions'.$filename.'.v'.$users_view->filemtime('files'.$filename); + $version = 'files_versions' . $filename . '.v' . $users_view->filemtime('files' . $filename); if (!$users_view->file_exists($version)) { - $users_view->copy('files'.$filename, 'files_versions'.$filename.'.v'.$users_view->filemtime('files'.$filename)); + $users_view->copy('files' . $filename, 'files_versions' . $filename . '.v' . $users_view->filemtime('files' . $filename)); $versionCreated = true; } @@ -373,7 +382,8 @@ class Storage { $fileInfo->getId(), [ 'encrypted' => $oldVersion, 'encryptedVersion' => $oldVersion, - 'size' => $oldFileInfo->getSize() + 'size' => $oldFileInfo->getData()['size'], + 'unencrypted_size' => $oldFileInfo->getData()['unencrypted_size'], ] ); @@ -382,14 +392,6 @@ class Storage { $files_view->touch($file, $revision); Storage::scheduleExpire($user->getUID(), $file); - $node = $userFolder->get($file); - - // TODO: move away from those legacy hooks! - \OC_Hook::emit('\OCP\Versions', 'rollback', [ - 'path' => $filename, - 'revision' => $revision, - 'node' => $node, - ]); return true; } elseif ($versionCreated) { self::deleteVersion($users_view, $version); @@ -416,24 +418,45 @@ class Storage { $view->lockFile($path1, ILockingProvider::LOCK_EXCLUSIVE); $view->lockFile($path2, ILockingProvider::LOCK_EXCLUSIVE); - // TODO add a proper way of overwriting a file while maintaining file ids - if ($storage1->instanceOfStorage('\OC\Files\ObjectStore\ObjectStoreStorage') || $storage2->instanceOfStorage('\OC\Files\ObjectStore\ObjectStoreStorage')) { - $source = $storage1->fopen($internalPath1, 'r'); - $target = $storage2->fopen($internalPath2, 'w'); - [, $result] = \OC_Helper::streamCopy($source, $target); - fclose($source); - fclose($target); + try { + // TODO add a proper way of overwriting a file while maintaining file ids + if ($storage1->instanceOfStorage(ObjectStoreStorage::class) + || $storage2->instanceOfStorage(ObjectStoreStorage::class) + ) { + $source = $storage1->fopen($internalPath1, 'r'); + $result = $source !== false; + if ($result) { + if ($storage2->instanceOfStorage(IWriteStreamStorage::class)) { + /** @var IWriteStreamStorage $storage2 */ + $storage2->writeStream($internalPath2, $source); + } else { + $target = $storage2->fopen($internalPath2, 'w'); + $result = $target !== false; + if ($result) { + [, $result] = Files::streamCopy($source, $target, true); + } + // explicit check as S3 library closes streams already + if (is_resource($target)) { + fclose($target); + } + } + } + // explicit check as S3 library closes streams already + if (is_resource($source)) { + fclose($source); + } - if ($result !== false) { - $storage1->unlink($internalPath1); + if ($result !== false) { + $storage1->unlink($internalPath1); + } + } else { + $result = $storage2->moveFromStorage($storage1, $internalPath1, $internalPath2); } - } else { - $result = $storage2->moveFromStorage($storage1, $internalPath1, $internalPath2); + } finally { + $view->unlockFile($path1, ILockingProvider::LOCK_EXCLUSIVE); + $view->unlockFile($path2, ILockingProvider::LOCK_EXCLUSIVE); } - $view->unlockFile($path1, ILockingProvider::LOCK_EXCLUSIVE); - $view->unlockFile($path2, ILockingProvider::LOCK_EXCLUSIVE); - return ($result !== false); } @@ -473,23 +496,33 @@ class Storage { $filename = $pathparts['filename']; if ($filename === $versionedFile) { $pathparts = pathinfo($entryName); - $timestamp = substr($pathparts['extension'], 1); + $timestamp = substr($pathparts['extension'] ?? '', 1); + if (!is_numeric($timestamp)) { + Server::get(LoggerInterface::class)->error( + 'Version file {path} has incorrect name format', + [ + 'path' => $entryName, + 'app' => 'files_versions', + ] + ); + continue; + } $filename = $pathparts['filename']; $key = $timestamp . '#' . $filename; $versions[$key]['version'] = $timestamp; - $versions[$key]['humanReadableTimestamp'] = self::getHumanReadableTimestamp($timestamp); + $versions[$key]['humanReadableTimestamp'] = self::getHumanReadableTimestamp((int)$timestamp); if (empty($userFullPath)) { $versions[$key]['preview'] = ''; } else { /** @var IURLGenerator $urlGenerator */ - $urlGenerator = \OC::$server->get(IURLGenerator::class); + $urlGenerator = Server::get(IURLGenerator::class); $versions[$key]['preview'] = $urlGenerator->linkToRoute('files_version.Preview.getPreview', ['file' => $userFullPath, 'version' => $timestamp]); } $versions[$key]['path'] = Filesystem::normalizePath($pathinfo['dirname'] . '/' . $filename); $versions[$key]['name'] = $versionedFile; $versions[$key]['size'] = $view->filesize($dir . '/' . $entryName); - $versions[$key]['mimetype'] = \OC::$server->get(IMimeTypeDetector::class)->detectPath($versionedFile); + $versions[$key]['mimetype'] = Server::get(IMimeTypeDetector::class)->detectPath($versionedFile); } } } @@ -509,7 +542,7 @@ class Storage { */ public static function expireOlderThanMaxForUser($uid) { /** @var IRootFolder $root */ - $root = \OC::$server->get(IRootFolder::class); + $root = Server::get(IRootFolder::class); try { /** @var Folder $versionsRoot */ $versionsRoot = $root->get('/' . $uid . '/files_versions'); @@ -532,21 +565,66 @@ class Storage { [] )); + /** @var VersionsMapper $versionsMapper */ + $versionsMapper = Server::get(VersionsMapper::class); + $userFolder = $root->getUserFolder($uid); + $versionEntities = []; + /** @var Node[] $versions */ - $versions = array_filter($allVersions, function (Node $info) use ($threshold) { + $versions = array_filter($allVersions, function (Node $info) use ($threshold, $userFolder, $versionsMapper, $versionsRoot, &$versionEntities) { + // Check that the file match '*.v*' $versionsBegin = strrpos($info->getName(), '.v'); if ($versionsBegin === false) { return false; } + $version = (int)substr($info->getName(), $versionsBegin + 2); + + // Check that the version does not have a label. + $path = $versionsRoot->getRelativePath($info->getPath()); + if ($path === null) { + throw new DoesNotExistException('Could not find relative path of (' . $info->getPath() . ')'); + } + + try { + $node = $userFolder->get(substr($path, 0, -strlen('.v' . $version))); + $versionEntity = $versionsMapper->findVersionForFileId($node->getId(), $version); + $versionEntities[$info->getId()] = $versionEntity; + + if ($versionEntity->getMetadataValue('label') !== null && $versionEntity->getMetadataValue('label') !== '') { + return false; + } + } catch (NotFoundException $e) { + // Original node not found, delete the version + return true; + } catch (StorageNotAvailableException|StorageInvalidException $e) { + // Storage can't be used, but it might only be temporary so we can't always delete the version + // since we can't determine if the version is named we take the safe route and don't expire + return false; + } catch (DoesNotExistException $ex) { + // Version on FS can have no equivalent in the DB if they were created before the version naming feature. + // So we ignore DoesNotExistException. + } + + // Check that the version's timestamp is lower than $threshold return $version < $threshold; }); foreach ($versions as $version) { $internalPath = $version->getInternalPath(); \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]); - $version->delete(); - \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]); + + $versionEntity = isset($versionEntities[$version->getId()]) ? $versionEntities[$version->getId()] : null; + if (!is_null($versionEntity)) { + $versionsMapper->delete($versionEntity); + } + + try { + $version->delete(); + \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]); + } catch (NotPermittedException $e) { + Server::get(LoggerInterface::class)->error("Missing permissions to delete version: {$internalPath}", ['app' => 'files_versions', 'exception' => $e]); + } } } @@ -556,23 +634,23 @@ class Storage { * @param int $timestamp * @return string for example "5 days ago" */ - private static function getHumanReadableTimestamp($timestamp) { + private static function getHumanReadableTimestamp(int $timestamp): string { $diff = time() - $timestamp; if ($diff < 60) { // first minute - return $diff . " seconds ago"; + return $diff . ' seconds ago'; } elseif ($diff < 3600) { //first hour - return round($diff / 60) . " minutes ago"; + return round($diff / 60) . ' minutes ago'; } elseif ($diff < 86400) { // first day - return round($diff / 3600) . " hours ago"; + return round($diff / 3600) . ' hours ago'; } elseif ($diff < 604800) { //first week - return round($diff / 86400) . " days ago"; + return round($diff / 86400) . ' days ago'; } elseif ($diff < 2419200) { //first month - return round($diff / 604800) . " weeks ago"; + return round($diff / 604800) . ' weeks ago'; } elseif ($diff < 29030400) { // first year - return round($diff / 2419200) . " months ago"; + return round($diff / 2419200) . ' months ago'; } else { - return round($diff / 29030400) . " years ago"; + return round($diff / 29030400) . ' years ago'; } } @@ -615,7 +693,7 @@ class Storage { ]; foreach ($versions as $key => $value) { - $size = $view->filesize(self::VERSIONS_ROOT.'/'.$value['path'].'.v'.$value['timestamp']); + $size = $view->filesize(self::VERSIONS_ROOT . '/' . $value['path'] . '.v' . $value['timestamp']); $filename = $value['path']; $result['all'][$key]['version'] = $value['timestamp']; @@ -641,14 +719,28 @@ class Storage { $expiration = self::getExpiration(); if ($expiration->shouldAutoExpire()) { - [$toDelete, $size] = self::getAutoExpireList($time, $versions); + // Exclude versions that are newer than the minimum age from the auto expiration logic. + $minAge = $expiration->getMinAgeAsTimestamp(); + if ($minAge !== false) { + $versionsToAutoExpire = array_filter($versions, fn ($version) => $version['version'] < $minAge); + } else { + $versionsToAutoExpire = $versions; + } + + [$toDelete, $size] = self::getAutoExpireList($time, $versionsToAutoExpire); } else { $size = 0; $toDelete = []; // versions we want to delete } foreach ($versions as $key => $version) { - if ($expiration->isExpired($version['version'], $quotaExceeded) && !isset($toDelete[$key])) { + if (!is_numeric($version['version'])) { + Server::get(LoggerInterface::class)->error( + 'Found a non-numeric timestamp version: ' . json_encode($version), + ['app' => 'files_versions']); + continue; + } + if ($expiration->isExpired((int)($version['version']), $quotaExceeded) && !isset($toDelete[$key])) { $size += $version['size']; $toDelete[$key] = $version['path'] . '.v' . $version['version']; } @@ -694,7 +786,7 @@ class Storage { //distance between two version too small, mark to delete $toDelete[$key] = $version['path'] . '.v' . $version['version']; $size += $version['size']; - \OC::$server->get(LoggerInterface::class)->info('Mark to expire '. $version['path'] .' next version should be ' . $nextVersion . " or smaller. (prevTimestamp: " . $prevTimestamp . "; step: " . $step, ['app' => 'files_versions']); + Server::get(LoggerInterface::class)->info('Mark to expire ' . $version['path'] . ' next version should be ' . $nextVersion . ' or smaller. (prevTimestamp: ' . $prevTimestamp . '; step: ' . $step, ['app' => 'files_versions']); } else { $nextVersion = $version['version'] - $step; $prevTimestamp = $version['version']; @@ -729,7 +821,7 @@ class Storage { if ($expiration->isEnabled()) { $command = new Expire($uid, $fileName); /** @var IBus $bus */ - $bus = \OC::$server->get(IBus::class); + $bus = Server::get(IBus::class); $bus->push($command); } } @@ -748,14 +840,14 @@ class Storage { $expiration = self::getExpiration(); /** @var LoggerInterface $logger */ - $logger = \OC::$server->get(LoggerInterface::class); + $logger = Server::get(LoggerInterface::class); if ($expiration->isEnabled()) { // get available disk space for user - $user = \OC::$server->get(IUserManager::class)->get($uid); + $user = Server::get(IUserManager::class)->get($uid); if (is_null($user)) { $logger->error('Backends provided no user object for ' . $uid, ['app' => 'files_versions']); - throw new \OC\User\NoUserException('Backends provided no user object for ' . $uid); + throw new NoUserException('Backends provided no user object for ' . $uid); } \OC_Util::setupFS($uid); @@ -774,7 +866,7 @@ class Storage { // file maybe renamed or deleted return false; } - $versionsFileview = new View('/'.$uid.'/files_versions'); + $versionsFileview = new View('/' . $uid . '/files_versions'); $softQuota = true; $quota = $user->getQuota(); @@ -782,7 +874,7 @@ class Storage { $quota = Filesystem::free_space('/'); $softQuota = false; } else { - $quota = \OCP\Util::computerFileSize($quota); + $quota = Util::computerFileSize($quota); } // make sure that we have the current size of the version history @@ -792,7 +884,7 @@ class Storage { // subtract size of files and current versions size from quota if ($quota >= 0) { if ($softQuota) { - $root = \OC::$server->get(IRootFolder::class); + $root = Server::get(IRootFolder::class); $userFolder = $root->getUserFolder($uid); if (is_null($userFolder)) { $availableSpace = 0; @@ -834,6 +926,21 @@ class Storage { } foreach ($toDelete as $key => $path) { + // Make sure to cleanup version table relations as expire does not pass deleteVersion + try { + /** @var VersionsMapper $versionsMapper */ + $versionsMapper = Server::get(VersionsMapper::class); + $file = Server::get(IRootFolder::class)->getUserFolder($uid)->get($filename); + $pathparts = pathinfo($path); + $timestamp = (int)substr($pathparts['extension'] ?? '', 1); + $versionEntity = $versionsMapper->findVersionForFileId($file->getId(), $timestamp); + if ($versionEntity->getMetadataValue('label') !== null && $versionEntity->getMetadataValue('label') !== '') { + continue; + } + $versionsMapper->delete($versionEntity); + } catch (DoesNotExistException $e) { + } + \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]); self::deleteVersion($versionsFileview, $path); \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]); @@ -851,10 +958,10 @@ class Storage { reset($allVersions); while ($availableSpace < 0 && $i < $numOfVersions) { $version = current($allVersions); - \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $version['path'].'.v'.$version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]); + \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $version['path'] . '.v' . $version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]); self::deleteVersion($versionsFileview, $version['path'] . '.v' . $version['version']); - \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $version['path'].'.v'.$version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]); - $logger->info('running out of space! Delete oldest version: ' . $version['path'].'.v'.$version['version'], ['app' => 'files_versions']); + \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $version['path'] . '.v' . $version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED]); + $logger->info('running out of space! Delete oldest version: ' . $version['path'] . '.v' . $version['version'], ['app' => 'files_versions']); $versionsSize -= $version['size']; $availableSpace += $version['size']; next($allVersions); @@ -872,13 +979,13 @@ class Storage { * that match the given path to a file. * * @param string $filename $path to a file, relative to the user's - * "files" folder + * "files" folder * @param View $view view on data/user/ */ public static function createMissingDirectories($filename, $view) { $dirname = Filesystem::normalizePath(dirname($filename)); $dirParts = explode('/', $dirname); - $dir = "/files_versions"; + $dir = '/files_versions'; foreach ($dirParts as $part) { $dir = $dir . '/' . $part; if (!$view->file_exists($dir)) { @@ -893,7 +1000,7 @@ class Storage { */ protected static function getExpiration() { if (self::$application === null) { - self::$application = \OC::$server->get(Application::class); + self::$application = Server::get(Application::class); } return self::$application->getContainer()->get(Expiration::class); } diff --git a/apps/files_versions/lib/Versions/BackendNotFoundException.php b/apps/files_versions/lib/Versions/BackendNotFoundException.php index 151957c116a..f1fbecb852a 100644 --- a/apps/files_versions/lib/Versions/BackendNotFoundException.php +++ b/apps/files_versions/lib/Versions/BackendNotFoundException.php @@ -1,24 +1,8 @@ <?php + /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Versions; diff --git a/apps/files_versions/lib/Versions/IDeletableVersionBackend.php b/apps/files_versions/lib/Versions/IDeletableVersionBackend.php new file mode 100644 index 00000000000..fefc038864f --- /dev/null +++ b/apps/files_versions/lib/Versions/IDeletableVersionBackend.php @@ -0,0 +1,21 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Versions; + +/** + * @since 26.0.0 + */ +interface IDeletableVersionBackend { + /** + * Delete a version. + * + * @since 26.0.0 + */ + public function deleteVersion(IVersion $version): void; +} diff --git a/apps/files_versions/lib/Versions/IMetadataVersion.php b/apps/files_versions/lib/Versions/IMetadataVersion.php new file mode 100644 index 00000000000..bc4cd77138b --- /dev/null +++ b/apps/files_versions/lib/Versions/IMetadataVersion.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Versions; + +/** + * This interface allows for just direct accessing of the metadata column JSON + * @since 29.0.0 + */ +interface IMetadataVersion { + /** + * retrieves the all the metadata + * + * @return string[] + * @since 29.0.0 + */ + public function getMetadata(): array; + + /** + * retrieves the metadata value from our $key param + * + * @param string $key the key for the json value of the metadata column + * @since 29.0.0 + */ + public function getMetadataValue(string $key): ?string; +} diff --git a/apps/files_versions/lib/Versions/IMetadataVersionBackend.php b/apps/files_versions/lib/Versions/IMetadataVersionBackend.php new file mode 100644 index 00000000000..79db85e460b --- /dev/null +++ b/apps/files_versions/lib/Versions/IMetadataVersionBackend.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Versions; + +use OCP\Files\Node; + +/** + * This interface edits the metadata column of a node. + * Each column of the metadata has a key => value mapping. + * @since 29.0.0 + */ +interface IMetadataVersionBackend { + /** + * Sets a key value pair in the metadata column corresponding to the node's version. + * + * @param Node $node the node that triggered the Metadata event listener, aka, the file version + * @param int $revision the key for the json value of the metadata column + * @param string $key the key for the json value of the metadata column + * @param string $value the value that corresponds to the key in the metadata column + * @since 29.0.0 + */ + public function setMetadataValue(Node $node, int $revision, string $key, string $value): void; +} diff --git a/apps/files_versions/lib/Versions/INameableVersion.php b/apps/files_versions/lib/Versions/INameableVersion.php new file mode 100644 index 00000000000..a470239f128 --- /dev/null +++ b/apps/files_versions/lib/Versions/INameableVersion.php @@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Versions; + +/** + * @deprecated 29.0.0 + * @since 26.0.0 + */ +interface INameableVersion { + /** + * Get the user created label + * @deprecated 29.0.0 + * @return string + * @since 26.0.0 + */ + public function getLabel(): string; +} diff --git a/apps/files_versions/lib/Versions/INameableVersionBackend.php b/apps/files_versions/lib/Versions/INameableVersionBackend.php new file mode 100644 index 00000000000..d2ab7ed8135 --- /dev/null +++ b/apps/files_versions/lib/Versions/INameableVersionBackend.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Versions; + +/** + * @deprecated 29.0.0 + * @since 26.0.0 + */ +interface INameableVersionBackend { + /** + * Set the label for a version. + * @deprecated 29.0.0 + * @since 26.0.0 + */ + public function setVersionLabel(IVersion $version, string $label): void; +} diff --git a/apps/files_versions/lib/Versions/INeedSyncVersionBackend.php b/apps/files_versions/lib/Versions/INeedSyncVersionBackend.php new file mode 100644 index 00000000000..e52e2f8e8bc --- /dev/null +++ b/apps/files_versions/lib/Versions/INeedSyncVersionBackend.php @@ -0,0 +1,25 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Versions; + +use OCA\Files_Versions\Db\VersionEntity; +use OCP\Files\File; + +/** + * @since 28.0.0 + */ +interface INeedSyncVersionBackend { + /** + * TODO: Convert return type to strong type once all implementations are fixed. + * @return null|VersionEntity + */ + public function createVersionEntity(File $file); + public function updateVersionEntity(File $sourceFile, int $revision, array $properties): void; + public function deleteVersionsEntity(File $file): void; +} diff --git a/apps/files_versions/lib/Versions/IVersion.php b/apps/files_versions/lib/Versions/IVersion.php index 8ab3357b1e2..e5fd53d0157 100644 --- a/apps/files_versions/lib/Versions/IVersion.php +++ b/apps/files_versions/lib/Versions/IVersion.php @@ -3,25 +3,8 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Versions; @@ -65,10 +48,10 @@ interface IVersion { /** * Get the size of this version * - * @return int + * @return int|float * @since 15.0.0 */ - public function getSize(): int; + public function getSize(): int|float; /** * Get the name of the source file at the time of making this version diff --git a/apps/files_versions/lib/Versions/IVersionBackend.php b/apps/files_versions/lib/Versions/IVersionBackend.php index c06e395e4c1..18f8c17f0ac 100644 --- a/apps/files_versions/lib/Versions/IVersionBackend.php +++ b/apps/files_versions/lib/Versions/IVersionBackend.php @@ -3,29 +3,12 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Christoph Wurst <christoph@winzerhof-wurst.at> - * @author Robin Appelman <robin@icewind.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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Versions; +use OC\Files\Node\Node; use OCP\Files\File; use OCP\Files\FileInfo; use OCP\Files\NotFoundException; @@ -78,7 +61,7 @@ interface IVersionBackend { * Open the file for reading * * @param IVersion $version - * @return resource + * @return resource|false * @throws NotFoundException * @since 15.0.0 */ @@ -96,4 +79,11 @@ interface IVersionBackend { * @since 15.0.0 */ public function getVersionFile(IUser $user, FileInfo $sourceFile, $revision): File; + + /** + * Get the revision for a node + * + * @since 32.0.0 + */ + public function getRevision(Node $node): int; } diff --git a/apps/files_versions/lib/Versions/IVersionManager.php b/apps/files_versions/lib/Versions/IVersionManager.php index afc3046fa48..ecd424d0cc1 100644 --- a/apps/files_versions/lib/Versions/IVersionManager.php +++ b/apps/files_versions/lib/Versions/IVersionManager.php @@ -3,28 +3,13 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Versions; +use OCP\Files\Storage\IStorage; + /** * @since 15.0.0 */ @@ -37,4 +22,10 @@ interface IVersionManager extends IVersionBackend { * @since 15.0.0 */ public function registerBackend(string $storageType, IVersionBackend $backend); + + /** + * @throws BackendNotFoundException + * @since 29.0.0 + */ + public function getBackendForStorage(IStorage $storage): IVersionBackend; } diff --git a/apps/files_versions/lib/Versions/IVersionsImporterBackend.php b/apps/files_versions/lib/Versions/IVersionsImporterBackend.php new file mode 100644 index 00000000000..db9349328e9 --- /dev/null +++ b/apps/files_versions/lib/Versions/IVersionsImporterBackend.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Versions; + +use OCP\Files\Node; +use OCP\IUser; + +/** + * @since 29.0.0 + */ +interface IVersionsImporterBackend { + /** + * Import the given versions for the target file. + * + * @param Node $source - The source might not exist anymore. + * @param IVersion[] $versions + * @since 29.0.0 + */ + public function importVersionsForFile(IUser $user, Node $source, Node $target, array $versions): void; + + /** + * Clear all versions for a file + * + * @since 29.0.0 + */ + public function clearVersionsForFile(IUser $user, Node $source, Node $target): void; +} diff --git a/apps/files_versions/lib/Versions/LegacyVersionsBackend.php b/apps/files_versions/lib/Versions/LegacyVersionsBackend.php index 4ea0985e113..48d69d31629 100644 --- a/apps/files_versions/lib/Versions/LegacyVersionsBackend.php +++ b/apps/files_versions/lib/Versions/LegacyVersionsBackend.php @@ -3,50 +3,42 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.nl> - * @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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\Files_Versions\Versions; +use Exception; use OC\Files\View; -use OCA\Files_Sharing\SharedStorage; +use OCA\DAV\Connector\Sabre\Exception\Forbidden; +use OCA\Files_Versions\Db\VersionEntity; +use OCA\Files_Versions\Db\VersionsMapper; use OCA\Files_Versions\Storage; +use OCP\Constants; use OCP\Files\File; use OCP\Files\FileInfo; use OCP\Files\Folder; +use OCP\Files\IMimeTypeLoader; use OCP\Files\IRootFolder; +use OCP\Files\Node; use OCP\Files\NotFoundException; +use OCP\Files\Storage\ISharedStorage; use OCP\Files\Storage\IStorage; use OCP\IUser; use OCP\IUserManager; +use OCP\IUserSession; +use Psr\Log\LoggerInterface; -class LegacyVersionsBackend implements IVersionBackend { - /** @var IRootFolder */ - private $rootFolder; - /** @var IUserManager */ - private $userManager; - - public function __construct(IRootFolder $rootFolder, IUserManager $userManager) { - $this->rootFolder = $rootFolder; - $this->userManager = $userManager; +class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend, INeedSyncVersionBackend, IMetadataVersionBackend, IVersionsImporterBackend { + public function __construct( + private IRootFolder $rootFolder, + private IUserManager $userManager, + private VersionsMapper $versionsMapper, + private IMimeTypeLoader $mimeTypeLoader, + private IUserSession $userSession, + private LoggerInterface $logger, + ) { } public function useBackendForStorage(IStorage $storage): bool { @@ -55,29 +47,104 @@ class LegacyVersionsBackend implements IVersionBackend { public function getVersionsForFile(IUser $user, FileInfo $file): array { $storage = $file->getStorage(); - if ($storage->instanceOfStorage(SharedStorage::class)) { + + if ($storage->instanceOfStorage(ISharedStorage::class)) { $owner = $storage->getOwner(''); + if ($owner === false) { + throw new NotFoundException('No owner for ' . $file->getPath()); + } + $user = $this->userManager->get($owner); + + $fileId = $file->getId(); + if ($fileId === null) { + throw new NotFoundException("File not found ($fileId)"); + } + + if ($user === null) { + throw new NotFoundException("User $owner not found for $fileId"); + } + + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + + $file = $userFolder->getFirstNodeById($fileId); + + if (!$file) { + throw new NotFoundException('version file not found for share owner'); + } + } else { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); } - $userFolder = $this->rootFolder->getUserFolder($user->getUID()); - $nodes = $userFolder->getById($file->getId()); - $file2 = array_pop($nodes); - $versions = Storage::getVersions($user->getUID(), $userFolder->getRelativePath($file2->getPath())); - - return array_map(function (array $data) use ($file, $user) { - return new Version( - (int)$data['version'], - (int)$data['version'], - $data['name'], - (int)$data['size'], - $data['mimetype'], - $data['path'], + $fileId = $file->getId(); + if ($fileId === null) { + throw new NotFoundException("File not found ($fileId)"); + } + + // Insert entries in the DB for existing versions. + $relativePath = $userFolder->getRelativePath($file->getPath()); + if ($relativePath === null) { + throw new NotFoundException("Relative path not found for file $fileId (" . $file->getPath() . ')'); + } + + $currentVersion = [ + 'version' => (string)$file->getMtime(), + 'size' => $file->getSize(), + 'mimetype' => $file->getMimetype(), + ]; + + $versionsInDB = $this->versionsMapper->findAllVersionsForFileId($file->getId()); + /** @var array<int, array> */ + $versionsInFS = array_values(Storage::getVersions($user->getUID(), $relativePath)); + + /** @var array<int, array{db: ?VersionEntity, fs: ?mixed}> */ + $groupedVersions = []; + $davVersions = []; + + foreach ($versionsInDB as $version) { + $revisionId = $version->getTimestamp(); + $groupedVersions[$revisionId] = $groupedVersions[$revisionId] ?? []; + $groupedVersions[$revisionId]['db'] = $version; + } + + foreach ([$currentVersion, ...$versionsInFS] as $version) { + $revisionId = $version['version']; + $groupedVersions[$revisionId] = $groupedVersions[$revisionId] ?? []; + $groupedVersions[$revisionId]['fs'] = $version; + } + + /** @var array<string, array{db: ?VersionEntity, fs: ?mixed}> $groupedVersions */ + foreach ($groupedVersions as $versions) { + if (empty($versions['db']) && !empty($versions['fs'])) { + $versions['db'] = new VersionEntity(); + $versions['db']->setFileId($fileId); + $versions['db']->setTimestamp((int)$versions['fs']['version']); + $versions['db']->setSize((int)$versions['fs']['size']); + $versions['db']->setMimetype($this->mimeTypeLoader->getId($versions['fs']['mimetype'])); + $versions['db']->setMetadata([]); + $this->versionsMapper->insert($versions['db']); + } elseif (!empty($versions['db']) && empty($versions['fs'])) { + $this->versionsMapper->delete($versions['db']); + continue; + } + + $version = new Version( + $versions['db']->getTimestamp(), + $versions['db']->getTimestamp(), + $file->getName(), + $versions['db']->getSize(), + $this->mimeTypeLoader->getMimetypeById($versions['db']->getMimetype()), + $userFolder->getRelativePath($file->getPath()), $file, $this, - $user + $user, + $versions['db']->getMetadata() ?? [], ); - }, $versions); + + array_push($davVersions, $version); + } + + return $davVersions; } public function createVersion(IUser $user, FileInfo $file) { @@ -96,6 +163,10 @@ class LegacyVersionsBackend implements IVersionBackend { } public function rollback(IVersion $version) { + if (!$this->currentUserHasPermissions($version->getSourceFile(), Constants::PERMISSION_UPDATE)) { + throw new Forbidden('You cannot restore this version because you do not have update permissions on the source file.'); + } + return Storage::rollback($version->getVersionPath(), $version->getRevisionId(), $version->getUser()); } @@ -120,9 +191,205 @@ class LegacyVersionsBackend implements IVersionBackend { public function getVersionFile(IUser $user, FileInfo $sourceFile, $revision): File { $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $owner = $sourceFile->getOwner(); + $storage = $sourceFile->getStorage(); + + // Shared files have their versions in the owners root folder so we need to obtain them from there + if ($storage->instanceOfStorage(ISharedStorage::class) && $owner) { + /** @var ISharedStorage $storage */ + $userFolder = $this->rootFolder->getUserFolder($owner->getUID()); + $user = $owner; + $ownerPathInStorage = $sourceFile->getInternalPath(); + $sourceFile = $storage->getShare()->getNode(); + if ($sourceFile instanceof Folder) { + $sourceFile = $sourceFile->get($ownerPathInStorage); + } + } + $versionFolder = $this->getVersionFolder($user); /** @var File $file */ $file = $versionFolder->get($userFolder->getRelativePath($sourceFile->getPath()) . '.v' . $revision); return $file; } + + public function getRevision(Node $node): int { + return $node->getMTime(); + } + + public function deleteVersion(IVersion $version): void { + if (!$this->currentUserHasPermissions($version->getSourceFile(), Constants::PERMISSION_DELETE)) { + throw new Forbidden('You cannot delete this version because you do not have delete permissions on the source file.'); + } + + Storage::deleteRevision($version->getVersionPath(), $version->getRevisionId()); + $versionEntity = $this->versionsMapper->findVersionForFileId( + $version->getSourceFile()->getId(), + $version->getTimestamp(), + ); + $this->versionsMapper->delete($versionEntity); + } + + public function createVersionEntity(File $file): ?VersionEntity { + $versionEntity = new VersionEntity(); + $versionEntity->setFileId($file->getId()); + $versionEntity->setTimestamp($file->getMTime()); + $versionEntity->setSize($file->getSize()); + $versionEntity->setMimetype($this->mimeTypeLoader->getId($file->getMimetype())); + $versionEntity->setMetadata([]); + + $tries = 1; + while ($tries < 5) { + try { + $this->versionsMapper->insert($versionEntity); + return $versionEntity; + } catch (\OCP\DB\Exception $e) { + if (!in_array($e->getReason(), [ + \OCP\DB\Exception::REASON_CONSTRAINT_VIOLATION, + \OCP\DB\Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION, + ]) + ) { + throw $e; + } + /* Conflict with another version, increase mtime and try again */ + $versionEntity->setTimestamp($versionEntity->getTimestamp() + 1); + $tries++; + $this->logger->warning('Constraint violation while inserting version, retrying with increased timestamp', ['exception' => $e]); + } + } + + return null; + } + + public function updateVersionEntity(File $sourceFile, int $revision, array $properties): void { + $versionEntity = $this->versionsMapper->findVersionForFileId($sourceFile->getId(), $revision); + + if (isset($properties['timestamp'])) { + $versionEntity->setTimestamp($properties['timestamp']); + } + + if (isset($properties['size'])) { + $versionEntity->setSize($properties['size']); + } + + if (isset($properties['mimetype'])) { + $versionEntity->setMimetype($properties['mimetype']); + } + + $this->versionsMapper->update($versionEntity); + } + + public function deleteVersionsEntity(File $file): void { + $this->versionsMapper->deleteAllVersionsForFileId($file->getId()); + } + + private function currentUserHasPermissions(FileInfo $sourceFile, int $permissions): bool { + $currentUserId = $this->userSession->getUser()?->getUID(); + + if ($currentUserId === null) { + throw new NotFoundException('No user logged in'); + } + + if ($sourceFile->getOwner()?->getUID() === $currentUserId) { + return ($sourceFile->getPermissions() & $permissions) === $permissions; + } + + $nodes = $this->rootFolder->getUserFolder($currentUserId)->getById($sourceFile->getId()); + + if (count($nodes) === 0) { + throw new NotFoundException('Version file not accessible by current user'); + } + + foreach ($nodes as $node) { + if (($node->getPermissions() & $permissions) === $permissions) { + return true; + } + } + + return false; + } + + public function setMetadataValue(Node $node, int $revision, string $key, string $value): void { + if (!$this->currentUserHasPermissions($node, Constants::PERMISSION_UPDATE)) { + throw new Forbidden('You cannot update the version\'s metadata because you do not have update permissions on the source file.'); + } + + $versionEntity = $this->versionsMapper->findVersionForFileId($node->getId(), $revision); + + $versionEntity->setMetadataValue($key, $value); + $this->versionsMapper->update($versionEntity); + } + + + /** + * @inheritdoc + */ + public function importVersionsForFile(IUser $user, Node $source, Node $target, array $versions): void { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $relativePath = $userFolder->getRelativePath($target->getPath()); + + if ($relativePath === null) { + throw new \Exception('Target does not have a relative path' . $target->getPath()); + } + + $userView = new View('/' . $user->getUID()); + // create all parent folders + Storage::createMissingDirectories($relativePath, $userView); + Storage::scheduleExpire($user->getUID(), $relativePath); + + foreach ($versions as $version) { + // 1. Import the file in its new location. + // Nothing to do for the current version. + if ($version->getTimestamp() !== $source->getMTime()) { + $backend = $version->getBackend(); + $versionFile = $backend->getVersionFile($user, $source, $version->getRevisionId()); + $newVersionPath = 'files_versions/' . $relativePath . '.v' . $version->getTimestamp(); + + $versionContent = $versionFile->fopen('r'); + if ($versionContent === false) { + $this->logger->warning('Fail to open version file.', ['source' => $source, 'version' => $version, 'versionFile' => $versionFile]); + continue; + } + + $userView->file_put_contents($newVersionPath, $versionContent); + // ensure the file is scanned + $userView->getFileInfo($newVersionPath); + } + + // 2. Create the entity in the database + $versionEntity = new VersionEntity(); + $versionEntity->setFileId($target->getId()); + $versionEntity->setTimestamp($version->getTimestamp()); + $versionEntity->setSize($version->getSize()); + $versionEntity->setMimetype($this->mimeTypeLoader->getId($version->getMimetype())); + if ($version instanceof IMetadataVersion) { + $versionEntity->setMetadata($version->getMetadata()); + } + $this->versionsMapper->insert($versionEntity); + } + } + + /** + * @inheritdoc + */ + public function clearVersionsForFile(IUser $user, Node $source, Node $target): void { + $userId = $user->getUID(); + $userFolder = $this->rootFolder->getUserFolder($userId); + + $relativePath = $userFolder->getRelativePath($source->getPath()); + if ($relativePath === null) { + throw new Exception('Relative path not found for node with path: ' . $source->getPath()); + } + + $versionFolder = $this->rootFolder->get($userId . '/files_versions'); + if (!$versionFolder instanceof Folder) { + throw new Exception('User versions folder does not exist'); + } + + $versions = Storage::getVersions($userId, $relativePath); + foreach ($versions as $version) { + $versionFolder->get($version['path'] . '.v' . (int)$version['version'])->delete(); + } + + $this->versionsMapper->deleteAllVersionsForFileId($target->getId()); + } } diff --git a/apps/files_versions/lib/Versions/Version.php b/apps/files_versions/lib/Versions/Version.php index 9979933ebc5..e202a69b7d7 100644 --- a/apps/files_versions/lib/Versions/Version.php +++ b/apps/files_versions/lib/Versions/Version.php @@ -3,79 +3,27 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Versions; use OCP\Files\FileInfo; use OCP\IUser; -class Version implements IVersion { - /** @var int */ - private $timestamp; - - /** @var int|string */ - private $revisionId; - - /** @var string */ - private $name; - - /** @var int */ - private $size; - - /** @var string */ - private $mimetype; - - /** @var string */ - private $path; - - /** @var FileInfo */ - private $sourceFileInfo; - - /** @var IVersionBackend */ - private $backend; - - /** @var IUser */ - private $user; - +class Version implements IVersion, IMetadataVersion { public function __construct( - int $timestamp, - $revisionId, - string $name, - int $size, - string $mimetype, - string $path, - FileInfo $sourceFileInfo, - IVersionBackend $backend, - IUser $user + private int $timestamp, + private int|string $revisionId, + private string $name, + private int|float $size, + private string $mimetype, + private string $path, + private FileInfo $sourceFileInfo, + private IVersionBackend $backend, + private IUser $user, + private array $metadata = [], ) { - $this->timestamp = $timestamp; - $this->revisionId = $revisionId; - $this->name = $name; - $this->size = $size; - $this->mimetype = $mimetype; - $this->path = $path; - $this->sourceFileInfo = $sourceFileInfo; - $this->backend = $backend; - $this->user = $user; } public function getBackend(): IVersionBackend { @@ -94,7 +42,7 @@ class Version implements IVersion { return $this->timestamp; } - public function getSize(): int { + public function getSize(): int|float { return $this->size; } @@ -113,4 +61,12 @@ class Version implements IVersion { public function getUser(): IUser { return $this->user; } + + public function getMetadata(): array { + return $this->metadata; + } + + public function getMetadataValue(string $key): ?string { + return $this->metadata[$key] ?? null; + } } diff --git a/apps/files_versions/lib/Versions/VersionManager.php b/apps/files_versions/lib/Versions/VersionManager.php index 4700f1b208b..9acea8c6513 100644 --- a/apps/files_versions/lib/Versions/VersionManager.php +++ b/apps/files_versions/lib/Versions/VersionManager.php @@ -3,37 +3,37 @@ declare(strict_types=1); /** - * @copyright Copyright (c) 2018 Robin Appelman <robin@icewind.nl> - * - * @author Robin Appelman <robin@icewind.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: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Files_Versions\Versions; +use OCA\Files_Versions\Db\VersionEntity; +use OCA\Files_Versions\Events\VersionCreatedEvent; +use OCA\Files_Versions\Events\VersionRestoredEvent; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\File; use OCP\Files\FileInfo; +use OCP\Files\IRootFolder; +use OCP\Files\Lock\ILock; +use OCP\Files\Lock\ILockManager; +use OCP\Files\Lock\LockContext; +use OCP\Files\Node; use OCP\Files\Storage\IStorage; use OCP\IUser; +use OCP\Lock\ManuallyLockedException; +use OCP\Server; + +class VersionManager implements IVersionManager, IDeletableVersionBackend, INeedSyncVersionBackend, IMetadataVersionBackend { -class VersionManager implements IVersionManager { /** @var (IVersionBackend[])[] */ private $backends = []; + public function __construct( + private IEventDispatcher $dispatcher, + ) { + } + public function registerBackend(string $storageType, IVersionBackend $backend) { if (!isset($this->backends[$storageType])) { $this->backends[$storageType] = []; @@ -62,8 +62,8 @@ class VersionManager implements IVersionManager { foreach ($backends as $type => $backendsForType) { if ( - $storage->instanceOfStorage($type) && - ($foundType === '' || is_subclass_of($type, $foundType)) + $storage->instanceOfStorage($type) + && ($foundType === '' || is_subclass_of($type, $foundType)) ) { foreach ($backendsForType as $backend) { /** @var IVersionBackend $backend */ @@ -94,7 +94,12 @@ class VersionManager implements IVersionManager { public function rollback(IVersion $version) { $backend = $version->getBackend(); - return $backend->rollback($version); + $result = self::handleAppLocks(fn (): ?bool => $backend->rollback($version)); + // rollback doesn't have a return type yet and some implementations don't return anything + if ($result === null || $result === true) { + $this->dispatcher->dispatchTyped(new VersionRestoredEvent($version)); + } + return $result; } public function read(IVersion $version) { @@ -107,7 +112,99 @@ class VersionManager implements IVersionManager { return $backend->getVersionFile($user, $sourceFile, $revision); } + public function getRevision(Node $node): int { + $backend = $this->getBackendForStorage($node->getStorage()); + return $backend->getRevision($node); + } + public function useBackendForStorage(IStorage $storage): bool { return false; } + + public function deleteVersion(IVersion $version): void { + $backend = $version->getBackend(); + if ($backend instanceof IDeletableVersionBackend) { + $backend->deleteVersion($version); + } + } + + public function createVersionEntity(File $file): void { + $backend = $this->getBackendForStorage($file->getStorage()); + if ($backend instanceof INeedSyncVersionBackend) { + $versionEntity = $backend->createVersionEntity($file); + + if ($versionEntity instanceof VersionEntity) { + foreach ($backend->getVersionsForFile($file->getOwner(), $file) as $version) { + if ($version->getRevisionId() === $versionEntity->getTimestamp()) { + $this->dispatcher->dispatchTyped(new VersionCreatedEvent($file, $version)); + break; + } + } + } + } + } + + public function updateVersionEntity(File $sourceFile, int $revision, array $properties): void { + $backend = $this->getBackendForStorage($sourceFile->getStorage()); + if ($backend instanceof INeedSyncVersionBackend) { + $backend->updateVersionEntity($sourceFile, $revision, $properties); + } + } + + public function deleteVersionsEntity(File $file): void { + $backend = $this->getBackendForStorage($file->getStorage()); + if ($backend instanceof INeedSyncVersionBackend) { + $backend->deleteVersionsEntity($file); + } + } + + public function setMetadataValue(Node $node, int $revision, string $key, string $value): void { + $backend = $this->getBackendForStorage($node->getStorage()); + if ($backend instanceof IMetadataVersionBackend) { + $backend->setMetadataValue($node, $revision, $key, $value); + } + } + + /** + * Catch ManuallyLockedException and retry in app context if possible. + * + * Allow users to go back to old versions via the versions tab in the sidebar + * even when the file is opened in the viewer next to it. + * + * Context: If a file is currently opened for editing + * the files_lock app will throw ManuallyLockedExceptions. + * This prevented the user from rolling an opened file back to a previous version. + * + * Text and Richdocuments can handle changes of open files. + * So we execute the rollback under their lock context + * to let them handle the conflict. + * + * @param callable $callback function to run with app locks handled + * @return bool|null + * @throws ManuallyLockedException + * + */ + private static function handleAppLocks(callable $callback): ?bool { + try { + return $callback(); + } catch (ManuallyLockedException $e) { + $owner = (string)$e->getOwner(); + $appsThatHandleUpdates = ['text', 'richdocuments']; + if (!in_array($owner, $appsThatHandleUpdates)) { + throw $e; + } + // The LockWrapper in the files_lock app only compares the lock type and owner + // when checking the lock against the current scope. + // So we do not need to get the actual node here + // and use the root node instead. + $root = Server::get(IRootFolder::class); + $lockContext = new LockContext($root, ILock::TYPE_APP, $owner); + $lockManager = Server::get(ILockManager::class); + $result = null; + $lockManager->runInScope($lockContext, function () use ($callback, &$result): void { + $result = $callback(); + }); + return $result; + } + } } |