diff options
Diffstat (limited to 'apps/files_versions/lib')
46 files changed, 4441 insertions, 1166 deletions
diff --git a/apps/files_versions/lib/AppInfo/Application.php b/apps/files_versions/lib/AppInfo/Application.php new file mode 100644 index 00000000000..29158276415 --- /dev/null +++ b/apps/files_versions/lib/AppInfo/Application.php @@ -0,0 +1,151 @@ +<?php + +/** + * 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; + +use OC\KnownUser\KnownUserService; +use OCA\DAV\CalDAV\Proxy\ProxyMapper; +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\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; + +class Application extends App implements IBootstrap { + public const APP_ID = 'files_versions'; + + public function __construct(array $urlParams = []) { + parent::__construct(self::APP_ID, $urlParams); + } + + public function register(IRegistrationContext $context): void { + /** + * Register capabilities + */ + $context->registerCapability(Capabilities::class); + + /** + * Register $principalBackend for the DAV collection + */ + $context->registerService('principalBackend', function (ContainerInterface $c) { + /** @var IServerContainer $server */ + $server = $c->get(IServerContainer::class); + 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), + $server->get(ProxyMapper::class), + $server->get(KnownUserService::class), + $server->get(IConfig::class), + $server->get(IFactory::class), + ); + }); + + $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'])); + } + + public function registerVersionBackends(ContainerInterface $container, IAppManager $appManager, LoggerInterface $logger): void { + foreach ($appManager->getEnabledApps() as $app) { + $appInfo = $appManager->getAppInfo($app); + if (isset($appInfo['versions'])) { + $backends = $appInfo['versions']; + foreach ($backends as $backend) { + if (isset($backend['@value'])) { + $this->loadBackend($backend, $container, $logger); + } else { + foreach ($backend as $singleBackend) { + $this->loadBackend($singleBackend, $container, $logger); + } + } + } + } + } + } + + private function loadBackend(array $backend, ContainerInterface $container, LoggerInterface $logger): void { + /** @var IVersionManager $versionManager */ + $versionManager = $container->get(IVersionManager::class); + $class = $backend['@value']; + $for = $backend['@attributes']['for']; + try { + $backendObject = $container->get($class); + $versionManager->registerBackend($for, $backendObject); + } catch (\Exception $e) { + $logger->error($e->getMessage(), ['exception' => $e]); + } + } +} diff --git a/apps/files_versions/lib/BackgroundJob/ExpireVersions.php b/apps/files_versions/lib/BackgroundJob/ExpireVersions.php new file mode 100644 index 00000000000..794cbc5b882 --- /dev/null +++ b/apps/files_versions/lib/BackgroundJob/ExpireVersions.php @@ -0,0 +1,68 @@ +<?php + +/** + * 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 TimedJob { + public const ITEMS_PER_SESSION = 1000; + + 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); + } + + public function run($argument) { + $backgroundJob = $this->config->getAppValue('files_versions', 'background_job_expire_versions', 'yes'); + if ($backgroundJob === 'no') { + return; + } + + $maxAge = $this->expiration->getMaxAgeAsTimestamp(); + if (!$maxAge) { + return; + } + + $this->userManager->callForSeenUsers(function (IUser $user): void { + $uid = $user->getUID(); + if (!$this->setupFS($uid)) { + return; + } + Storage::expireOlderThanMaxForUser($uid); + }); + } + + /** + * Act on behalf on trash item owner + */ + protected function setupFS(string $user): bool { + \OC_Util::tearDownFS(); + \OC_Util::setupFS($user); + + // Check if this user has a versions directory + $view = new View('/' . $user); + if (!$view->is_dir('/files_versions')) { + return false; + } + + return true; + } +} diff --git a/apps/files_versions/lib/Capabilities.php b/apps/files_versions/lib/Capabilities.php new file mode 100644 index 00000000000..cb6394f0a36 --- /dev/null +++ b/apps/files_versions/lib/Capabilities.php @@ -0,0 +1,35 @@ +<?php + +/** + * 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{files: array{versioning: bool, version_labeling: bool, version_deletion: bool}} + */ + public function getCapabilities() { + return [ + 'files' => [ + '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 new file mode 100644 index 00000000000..e8c46afef16 --- /dev/null +++ b/apps/files_versions/lib/Command/CleanUp.php @@ -0,0 +1,115 @@ +<?php + +/** + * 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; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class CleanUp extends Command { + public function __construct( + protected IRootFolder $rootFolder, + protected IUserManager $userManager, + protected VersionsMapper $versionMapper, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('versions:cleanup') + ->setDescription('Delete versions') + ->addArgument( + 'user_id', + InputArgument::OPTIONAL | InputArgument::IS_ARRAY, + 'delete versions of the given user(s), if no user is given all versions will be deleted' + ) + ->addOption( + 'path', + 'p', + InputOption::VALUE_REQUIRED, + 'only delete versions of this path, e.g. --path="/alice/files/Music"' + ); + } + + + protected function execute(InputInterface $input, OutputInterface $output): int { + $users = $input->getArgument('user_id'); + + $path = $input->getOption('path'); + if ($path) { + if (!preg_match('#^/([^/]+)/files(/.*)?$#', $path, $pathMatches)) { + $output->writeln('<error>Invalid path given</error>'); + return self::FAILURE; + } + + $users = [ $pathMatches[1] ]; + $path = trim($pathMatches[2], '/'); + } + + if (!empty($users)) { + foreach ($users as $user) { + if (!$this->userManager->userExists($user)) { + $output->writeln("<error>Unknown user $user</error>"); + return self::FAILURE; + } + + $output->writeln("Delete versions of <info>$user</info>"); + $this->deleteVersions($user, $path); + } + return self::SUCCESS; + } + + $output->writeln('Delete all versions'); + foreach ($this->userManager->getBackends() as $backend) { + $name = get_class($backend); + + 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 self::SUCCESS; + } + + + /** + * delete versions for the given user + */ + 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 new file mode 100644 index 00000000000..a30e623c347 --- /dev/null +++ b/apps/files_versions/lib/Command/Expire.php @@ -0,0 +1,49 @@ +<?php + +/** + * 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; + +use OC\Command\FileAccess; +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; + + public function __construct( + private string $user, + private string $fileName, + ) { + } + + public function handle(): void { + /** @var IUserManager $userManager */ + $userManager = Server::get(IUserManager::class); + if (!$userManager->userExists($this->user)) { + // User has been deleted already + return; + } + + try { + Storage::expire($this->fileName, $this->user); + } catch (StorageNotAvailableException $e) { + // In case of external storage and session credentials, the expiration + // fails because the command does not have those credentials + + $logger = Server::get(LoggerInterface::class); + $logger->warning($e->getMessage(), [ + 'exception' => $e, + 'uid' => $this->user, + 'fileName' => $this->fileName, + ]); + } + } +} diff --git a/apps/files_versions/lib/Command/ExpireVersions.php b/apps/files_versions/lib/Command/ExpireVersions.php new file mode 100644 index 00000000000..d3f341a21d2 --- /dev/null +++ b/apps/files_versions/lib/Command/ExpireVersions.php @@ -0,0 +1,96 @@ +<?php + +/** + * 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; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class ExpireVersions extends Command { + public function __construct( + private IUserManager $userManager, + private Expiration $expiration, + ) { + parent::__construct(); + } + + 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 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 self::FAILURE; + } + + $users = $input->getArgument('user_id'); + if (!empty($users)) { + foreach ($users as $user) { + 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); + } + return self::SUCCESS; + } + + $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): void { + $uid = $user->getUID(); + if (!$this->setupFS($uid)) { + return; + } + Storage::expireOlderThanMaxForUser($uid); + } + + /** + * Act on behalf on versions item owner + */ + protected function setupFS(string $user): bool { + \OC_Util::tearDownFS(); + \OC_Util::setupFS($user); + + // Check if this user has a version directory + $view = new View('/' . $user); + if (!$view->is_dir('/files_versions')) { + return false; + } + + return true; + } +} diff --git a/apps/files_versions/lib/Controller/PreviewController.php b/apps/files_versions/lib/Controller/PreviewController.php new file mode 100644 index 00000000000..2c3ff8da70d --- /dev/null +++ b/apps/files_versions/lib/Controller/PreviewController.php @@ -0,0 +1,92 @@ +<?php + +/** + * 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 { + + public function __construct( + string $appName, + IRequest $request, + private IRootFolder $rootFolder, + private IUserSession $userSession, + private IVersionManager $versionManager, + private IPreview $previewManager, + private IMimeIconProvider $mimeIconProvider, + ) { + parent::__construct($appName, $request); + } + + /** + * Get the preview for a file version + * + * @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 = '', + 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()); + $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 new file mode 100644 index 00000000000..92ed26b2dd6 --- /dev/null +++ b/apps/files_versions/lib/Events/CreateVersionEvent.php @@ -0,0 +1,60 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Events; + +use OCP\EventDispatcher\Event; +use OCP\Files\Node; + +/** + * Class CreateVersionEvent + * + * Event to allow other apps to disable versions for specific files + * + * @package OCA\Files_Versions + */ +class CreateVersionEvent extends Event { + + + /** @var bool */ + private $createVersion; + + /** + * CreateVersionEvent constructor. + * + * @param Node $node + */ + public function __construct( + private Node $node, + ) { + $this->createVersion = true; + } + + /** + * get Node of the file which should be versioned + * + * @return Node + */ + public function getNode(): Node { + return $this->node; + } + + /** + * disable versions for this file + */ + public function disableVersions(): void { + $this->createVersion = false; + } + + /** + * should a version be created for this file? + * + * @return bool + */ + public function shouldCreateVersion(): bool { + return $this->createVersion; + } +} 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 ffc7640e7f9..1e04d93379f 100644 --- a/apps/files_versions/lib/expiration.php +++ b/apps/files_versions/lib/Expiration.php @@ -1,36 +1,20 @@ <?php + /** - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only */ - namespace OCA\Files_Versions; -use \OCP\IConfig; -use \OCP\AppFramework\Utility\ITimeFactory; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; +use Psr\Log\LoggerInterface; class Expiration { // how long do we keep files a version if no other value is defined in the config file (unit: days) - const NO_OBLIGATION = -1; - - /** @var ITimeFactory */ - private $timeFactory; + public const NO_OBLIGATION = -1; /** @var string */ private $retentionObligation; @@ -44,8 +28,11 @@ class Expiration { /** @var bool */ private $canPurgeToSaveSpace; - public function __construct(IConfig $config,ITimeFactory $timeFactory){ - $this->timeFactory = $timeFactory; + public function __construct( + IConfig $config, + private ITimeFactory $timeFactory, + private LoggerInterface $logger, + ) { $this->retentionObligation = $config->getSystemValue('versions_retention_obligation', 'auto'); if ($this->retentionObligation !== 'disabled') { @@ -57,14 +44,14 @@ class Expiration { * Is versions expiration enabled * @return bool */ - public function isEnabled(){ + public function isEnabled(): bool { return $this->retentionObligation !== 'disabled'; } /** * Is default expiration active */ - public function shouldAutoExpire(){ + public function shouldAutoExpire(): bool { return $this->minAge === self::NO_OBLIGATION || $this->maxAge === self::NO_OBLIGATION; } @@ -75,7 +62,7 @@ class Expiration { * @param bool $quotaExceeded * @return bool */ - public function isExpired($timestamp, $quotaExceeded = false){ + public function isExpired(int $timestamp, bool $quotaExceeded = false): bool { // No expiration if disabled if (!$this->isEnabled()) { return false; @@ -89,7 +76,7 @@ class Expiration { $time = $this->timeFactory->getTime(); // Never expire dates in future e.g. misconfiguration or negative time // adjustment - if ($time<$timestamp) { + if ($time < $timestamp) { return false; } @@ -113,10 +100,25 @@ 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 + * + * @return int|false */ - public function getMaxAgeAsTimestamp(){ + public function getMaxAgeAsTimestamp() { $maxAge = false; if ($this->isEnabled() && $this->maxAge !== self::NO_OBLIGATION) { $time = $this->timeFactory->getTime(); @@ -126,10 +128,10 @@ class Expiration { } /** - * Read versions_retention_obligation, validate it - * and set private members accordingly - */ - private function parseRetentionObligation(){ + * Read versions_retention_obligation, validate it + * and set private members accordingly + */ + private function parseRetentionObligation(): void { $splitValues = explode(',', $this->retentionObligation); if (!isset($splitValues[0])) { $minValue = 'auto'; @@ -147,21 +149,21 @@ class Expiration { // Validate if (!ctype_digit($minValue) && $minValue !== 'auto') { $isValid = false; - \OC::$server->getLogger()->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'] + $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'] ); } if (!ctype_digit($maxValue) && $maxValue !== 'auto') { $isValid = false; - \OC::$server->getLogger()->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'] + $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'] ); } - if (!$isValid){ + if (!$isValid) { $minValue = 'auto'; $maxValue = 'auto'; } @@ -174,13 +176,13 @@ class Expiration { $this->canPurgeToSaveSpace = true; } elseif ($minValue !== 'auto' && $maxValue === 'auto') { // Keep for X days but delete anytime if space needed - $this->minAge = intval($minValue); + $this->minAge = (int)$minValue; $this->maxAge = self::NO_OBLIGATION; $this->canPurgeToSaveSpace = true; } elseif ($minValue === 'auto' && $maxValue !== 'auto') { // Delete anytime if space needed, Delete all older than max automatically $this->minAge = self::NO_OBLIGATION; - $this->maxAge = intval($maxValue); + $this->maxAge = (int)$maxValue; $this->canPurgeToSaveSpace = true; } elseif ($minValue !== 'auto' && $maxValue !== 'auto') { // Delete all older than max OR older than min if space needed @@ -190,8 +192,8 @@ class Expiration { $maxValue = $minValue; } - $this->minAge = intval($minValue); - $this->maxAge = intval($maxValue); + $this->minAge = (int)$minValue; + $this->maxAge = (int)$maxValue; $this->canPurgeToSaveSpace = false; } } diff --git a/apps/files_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 new file mode 100644 index 00000000000..cb955629c0f --- /dev/null +++ b/apps/files_versions/lib/Listener/LoadAdditionalListener.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Listener; + +use OCA\Files\Event\LoadAdditionalScriptsEvent; +use OCA\Files_Versions\AppInfo\Application; +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)) { + return; + } + + // TODO: make sure to only include the sidebar script when + // we properly split it between files list and sidebar + Util::addScript(Application::APP_ID, 'files_versions'); + } +} diff --git a/apps/files_versions/lib/Listener/LoadSidebarListener.php b/apps/files_versions/lib/Listener/LoadSidebarListener.php new file mode 100644 index 00000000000..b8d13fa4810 --- /dev/null +++ b/apps/files_versions/lib/Listener/LoadSidebarListener.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Listener; + +use OCA\Files\Event\LoadSidebar; +use OCA\Files_Versions\AppInfo\Application; +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)) { + return; + } + + // TODO: make sure to only include the sidebar script when + // we properly split it between files list and sidebar + Util::addScript(Application::APP_ID, 'files_versions'); + } +} 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 new file mode 100644 index 00000000000..984c4a36e5b --- /dev/null +++ b/apps/files_versions/lib/Sabre/Plugin.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +/** + * 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; + + public const LABEL = 'label'; + + 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; + } + + public function initialize(Server $server) { + $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) { + $path = $request->getPath(); + if (!str_starts_with($path, 'versions')) { + return; + } + + try { + $node = $this->server->tree->getNodeForPath($path); + } catch (NotFound $e) { + return; + } + + if (!($node instanceof VersionFile)) { + return; + } + + $filename = $node->getVersion()->getSourceFileName(); + + if ($this->request->isUserAgent( + [ + Request::USER_AGENT_IE, + Request::USER_AGENT_ANDROID_MOBILE_CHROME, + Request::USER_AGENT_FREEBOX, + ])) { + $response->addHeader('Content-Disposition', 'attachment; filename="' . rawurlencode($filename) . '"'); + } else { + $response->addHeader('Content-Disposition', 'attachment; filename*=UTF-8\'\'' . rawurlencode($filename) + . '; 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 new file mode 100644 index 00000000000..7904b098a4f --- /dev/null +++ b/apps/files_versions/lib/Sabre/RestoreFolder.php @@ -0,0 +1,61 @@ +<?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\Sabre; + +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\ICollection; +use Sabre\DAV\IMoveTarget; +use Sabre\DAV\INode; + +class RestoreFolder implements ICollection, IMoveTarget { + public function createFile($name, $data = null) { + throw new Forbidden(); + } + + public function createDirectory($name) { + throw new Forbidden(); + } + + public function getChild($name) { + return null; + } + + public function delete() { + throw new Forbidden(); + } + + public function getName() { + return 'restore'; + } + + public function setName($name) { + throw new Forbidden(); + } + + public function getLastModified(): int { + return 0; + } + + public function getChildren(): array { + return []; + } + + public function childExists($name): bool { + return false; + } + + public function moveInto($targetName, $sourcePath, INode $sourceNode): bool { + if (!($sourceNode instanceof VersionFile)) { + return false; + } + + $sourceNode->rollBack(); + return true; + } +} diff --git a/apps/files_versions/lib/Sabre/RootCollection.php b/apps/files_versions/lib/Sabre/RootCollection.php new file mode 100644 index 00000000000..1e7129f23da --- /dev/null +++ b/apps/files_versions/lib/Sabre/RootCollection.php @@ -0,0 +1,55 @@ +<?php + +/** + * 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\IVersionManager; +use OCP\Files\IRootFolder; +use OCP\IConfig; +use OCP\IUserManager; +use OCP\IUserSession; +use Sabre\DAV\INode; +use Sabre\DAVACL\AbstractPrincipalCollection; +use Sabre\DAVACL\PrincipalBackend; + +class RootCollection extends AbstractPrincipalCollection { + + public function __construct( + PrincipalBackend\BackendInterface $principalBackend, + private IRootFolder $rootFolder, + IConfig $config, + private IUserManager $userManager, + private IVersionManager $versionManager, + private IUserSession $userSession, + ) { + parent::__construct($principalBackend, 'principals/users'); + + $this->disableListing = !$config->getSystemValue('debug', false); + } + + /** + * This method returns a node for a principal. + * + * The passed array contains principal information, and is guaranteed to + * at least contain a uri item. Other properties may or may not be + * supplied by the authentication backend. + * + * @param array $principalInfo + * @return INode + */ + public function getChildForPrincipal(array $principalInfo) { + [, $name] = \Sabre\Uri\split($principalInfo['uri']); + $user = $this->userSession->getUser(); + if (is_null($user) || $name !== $user->getUID()) { + throw new \Sabre\DAV\Exception\Forbidden(); + } + return new VersionHome($principalInfo, $this->rootFolder, $this->userManager, $this->versionManager); + } + + public function getName() { + return 'versions'; + } +} diff --git a/apps/files_versions/lib/Sabre/VersionCollection.php b/apps/files_versions/lib/Sabre/VersionCollection.php new file mode 100644 index 00000000000..375d5cf99f2 --- /dev/null +++ b/apps/files_versions/lib/Sabre/VersionCollection.php @@ -0,0 +1,81 @@ +<?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\Sabre; + +use OCA\Files_Versions\Versions\IVersion; +use OCA\Files_Versions\Versions\IVersionManager; +use OCP\Files\File; +use OCP\IUser; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; + +class VersionCollection implements ICollection { + + public function __construct( + private File $file, + private IUser $user, + private IVersionManager $versionManager, + ) { + } + + public function createFile($name, $data = null) { + throw new Forbidden(); + } + + public function createDirectory($name) { + throw new Forbidden(); + } + + public function getChild($name) { + /** @var VersionFile[] $versions */ + $versions = $this->getChildren(); + + foreach ($versions as $version) { + if ($version->getName() === $name) { + return $version; + } + } + + throw new NotFound(); + } + + public function getChildren(): array { + $versions = $this->versionManager->getVersionsForFile($this->user, $this->file); + + return array_map(function (IVersion $version) { + return new VersionFile($version, $this->versionManager); + }, $versions); + } + + public function childExists($name): bool { + try { + $this->getChild($name); + return true; + } catch (NotFound $e) { + return false; + } + } + + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + return (string)$this->file->getId(); + } + + public function setName($name) { + throw new Forbidden(); + } + + public function getLastModified(): int { + return 0; + } +} diff --git a/apps/files_versions/lib/Sabre/VersionFile.php b/apps/files_versions/lib/Sabre/VersionFile.php new file mode 100644 index 00000000000..faa03473648 --- /dev/null +++ b/apps/files_versions/lib/Sabre/VersionFile.php @@ -0,0 +1,108 @@ +<?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\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; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\IFile; + +class VersionFile implements IFile { + public function __construct( + private IVersion $version, + private IVersionManager $versionManager, + ) { + } + + public function put($data) { + throw new Forbidden(); + } + + public function get() { + try { + return $this->versionManager->read($this->version); + } catch (NotFoundException $e) { + throw new NotFound(); + } + } + + public function getContentType(): string { + return $this->version->getMimeType(); + } + + public function getETag(): string { + return (string)$this->version->getRevisionId(); + } + + /** + * @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() { + if ($this->versionManager instanceof IDeletableVersionBackend) { + $this->versionManager->deleteVersion($this->version); + } else { + throw new Forbidden(); + } + } + + public function getName(): string { + return (string)$this->version->getRevisionId(); + } + + public function setName($name) { + 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(); + } + + public function rollBack() { + $this->versionManager->rollback($this->version); + } + + public function getVersion(): IVersion { + return $this->version; + } +} diff --git a/apps/files_versions/lib/Sabre/VersionHome.php b/apps/files_versions/lib/Sabre/VersionHome.php new file mode 100644 index 00000000000..07ac491f2a1 --- /dev/null +++ b/apps/files_versions/lib/Sabre/VersionHome.php @@ -0,0 +1,82 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Sabre; + +use OC\User\NoUserException; +use OCA\Files_Versions\Versions\IVersionManager; +use OCP\Files\IRootFolder; +use OCP\IUserManager; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\ICollection; + +class VersionHome implements ICollection { + + public function __construct( + private array $principalInfo, + private IRootFolder $rootFolder, + private IUserManager $userManager, + private IVersionManager $versionManager, + ) { + } + + private function getUser() { + [, $name] = \Sabre\Uri\split($this->principalInfo['uri']); + $user = $this->userManager->get($name); + if (!$user) { + throw new NoUserException(); + } + return $user; + } + + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + return $this->getUser()->getUID(); + } + + public function setName($name) { + throw new Forbidden(); + } + + public function createFile($name, $data = null) { + throw new Forbidden(); + } + + public function createDirectory($name) { + throw new Forbidden(); + } + + public function getChild($name) { + $user = $this->getUser(); + + if ($name === 'versions') { + return new VersionRoot($user, $this->rootFolder, $this->versionManager); + } + if ($name === 'restore') { + return new RestoreFolder(); + } + } + + public function getChildren() { + $user = $this->getUser(); + + return [ + new VersionRoot($user, $this->rootFolder, $this->versionManager), + new RestoreFolder(), + ]; + } + + public function childExists($name) { + return $name === 'versions' || $name === 'restore'; + } + + public function getLastModified() { + return 0; + } +} diff --git a/apps/files_versions/lib/Sabre/VersionRoot.php b/apps/files_versions/lib/Sabre/VersionRoot.php new file mode 100644 index 00000000000..7f7014fbee3 --- /dev/null +++ b/apps/files_versions/lib/Sabre/VersionRoot.php @@ -0,0 +1,81 @@ +<?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\Sabre; + +use OCA\Files_Versions\Versions\IVersionManager; +use OCP\Files\File; +use OCP\Files\IRootFolder; +use OCP\IUser; +use Sabre\DAV\Exception\Forbidden; +use Sabre\DAV\Exception\NotFound; +use Sabre\DAV\ICollection; + +class VersionRoot implements ICollection { + + public function __construct( + private IUser $user, + private IRootFolder $rootFolder, + private IVersionManager $versionManager, + ) { + } + + public function delete() { + throw new Forbidden(); + } + + public function getName(): string { + return 'versions'; + } + + public function setName($name) { + throw new Forbidden(); + } + + public function createFile($name, $data = null) { + throw new Forbidden(); + } + + public function createDirectory($name) { + throw new Forbidden(); + } + + public function getChild($name) { + $userFolder = $this->rootFolder->getUserFolder($this->user->getUID()); + + $fileId = (int)$name; + $node = $userFolder->getFirstNodeById($fileId); + + if (!$node) { + throw new NotFound(); + } + + if (!$node instanceof File) { + throw new NotFound(); + } + + return new VersionCollection($node, $this->user, $this->versionManager); + } + + public function getChildren(): array { + return []; + } + + public function childExists($name): bool { + try { + $this->getChild($name); + return true; + } catch (NotFound $e) { + return false; + } + } + + public function getLastModified(): int { + return 0; + } +} diff --git a/apps/files_versions/lib/Storage.php b/apps/files_versions/lib/Storage.php new file mode 100644 index 00000000000..6d53a19a518 --- /dev/null +++ b/apps/files_versions/lib/Storage.php @@ -0,0 +1,1007 @@ +<?php + +/** + * 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\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\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 { + public const DEFAULTENABLED = true; + public const DEFAULTMAXSIZE = 50; // unit: percentage; 50% of available disk space/quota + public const VERSIONS_ROOT = 'files_versions/'; + + public const DELETE_TRIGGER_MASTER_REMOVED = 0; + public const DELETE_TRIGGER_RETENTION_CONSTRAINT = 1; + public const DELETE_TRIGGER_QUOTA_EXCEEDED = 2; + + // files for which we can remove the versions after the delete operation was successful + private static $deletedFiles = []; + + private static $sourcePathAndUser = []; + + private static $max_versions_per_interval = [ + //first 10sec, one version every 2sec + 1 => ['intervalEndsAfter' => 10, 'step' => 2], + //next minute, one version every 10sec + 2 => ['intervalEndsAfter' => 60, 'step' => 10], + //next hour, one version every minute + 3 => ['intervalEndsAfter' => 3600, 'step' => 60], + //next 24h, one version every hour + 4 => ['intervalEndsAfter' => 86400, 'step' => 3600], + //next 30days, one version per day + 5 => ['intervalEndsAfter' => 2592000, 'step' => 86400], + //until the end one version per week + 6 => ['intervalEndsAfter' => -1, 'step' => 604800], + ]; + + /** @var Application */ + private static $application; + + /** + * get the UID of the owner of the file and the path to the file relative to + * owners files folder + * + * @param string $filename + * @return array + * @throws NoUserException + */ + public static function getUidAndFilename($filename) { + $uid = Filesystem::getOwner($filename); + $userManager = Server::get(IUserManager::class); + // if the user with the UID doesn't exists, e.g. because the UID points + // to a remote user with a federated cloud ID we use the current logged-in + // user. We need a valid local user to create the versions + if (!$userManager->userExists($uid)) { + $uid = OC_User::getUser(); + } + Filesystem::initMountPoints($uid); + if ($uid !== OC_User::getUser()) { + $info = Filesystem::getFileInfo($filename); + $ownerView = new View('/' . $uid . '/files'); + try { + $filename = $ownerView->getPath($info['fileid']); + // make sure that the file name doesn't end with a trailing slash + // can for example happen single files shared across servers + $filename = rtrim($filename, '/'); + } catch (NotFoundException $e) { + $filename = null; + } + } + return [$uid, $filename]; + } + + /** + * Remember the owner and the owner path of the source file + * + * @param string $source source path + */ + public static function setSourcePathAndUser($source) { + [$uid, $path] = self::getUidAndFilename($source); + self::$sourcePathAndUser[$source] = ['uid' => $uid, 'path' => $path]; + } + + /** + * Gets the owner and the owner path from the source path + * + * @param string $source source path + * @return array with user id and path + */ + public static function getSourcePathAndUser($source) { + if (isset(self::$sourcePathAndUser[$source])) { + $uid = self::$sourcePathAndUser[$source]['uid']; + $path = self::$sourcePathAndUser[$source]['path']; + unset(self::$sourcePathAndUser[$source]); + } else { + $uid = $path = false; + } + return [$uid, $path]; + } + + /** + * get current size of all versions from a given user + * + * @param string $user user who owns the versions + * @return int versions size + */ + private static function getVersionsSize($user) { + $view = new View('/' . $user); + $fileInfo = $view->getFileInfo('/files_versions'); + return isset($fileInfo['size']) ? $fileInfo['size'] : 0; + } + + /** + * 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); + if ($ext === 'part') { + $filename = substr($filename, 0, -5); + } + + // we only handle existing files + if (! Filesystem::file_exists($filename) || Filesystem::is_dir($filename)) { + return false; + } + + // 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(''); + + /** @var IRootFolder $rootFolder */ + $rootFolder = Server::get(IRootFolder::class); + $userFolder = $rootFolder->getUserFolder($uid); + + $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 ($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 = Server::get(IVersionManager::class); + + $versionManager->createVersion($user, $file); + } + + + /** + * mark file as deleted so that we can remove the versions if the file is gone + * @param string $path + */ + public static function markDeletedFile($path) { + [$uid, $filename] = self::getUidAndFilename($path); + self::$deletedFiles[$path] = [ + 'uid' => $uid, + 'filename' => $filename]; + } + + /** + * delete the version from the storage and cache + * + * @param View $view + * @param string $path + */ + protected static function deleteVersion($view, $path) { + $view->unlink($path); + /** + * @var \OC\Files\Storage\Storage $storage + * @var string $internalPath + */ + [$storage, $internalPath] = $view->resolvePath($path); + $cache = $storage->getCache($internalPath); + $cache->remove($internalPath); + } + + /** + * Delete versions of a file + */ + public static function delete($path) { + $deletedFile = self::$deletedFiles[$path]; + $uid = $deletedFile['uid']; + $filename = $deletedFile['filename']; + + if (!Filesystem::file_exists($path)) { + $view = new View('/' . $uid . '/files_versions'); + + $versions = self::getVersions($uid, $filename); + if (!empty($versions)) { + foreach ($versions as $v) { + \OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]); + self::deleteVersion($view, $filename . '.v' . $v['version']); + \OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]); + } + } + } + unset(self::$deletedFiles[$path]); + } + + /** + * 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 + * @param string $targetPath target path of the file to move, relative to + * the currently logged in user's "files" folder + * @param string $operation can be 'copy' or 'rename' + */ + public static function renameOrCopy($sourcePath, $targetPath, $operation) { + [$sourceOwner, $sourcePath] = self::getSourcePathAndUser($sourcePath); + + // it was a upload of a existing file if no old path exists + // in this case the pre-hook already called the store method and we can + // stop here + if ($sourcePath === false) { + return true; + } + + [$targetOwner, $targetPath] = self::getUidAndFilename($targetPath); + + $sourcePath = ltrim($sourcePath, '/'); + $targetPath = ltrim($targetPath, '/'); + + $rootView = new View(''); + + // did we move a directory ? + if ($rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) { + // 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)); + + // move the directory containing the versions + $rootView->$operation( + '/' . $sourceOwner . '/files_versions/' . $sourcePath, + '/' . $targetOwner . '/files_versions/' . $targetPath + ); + } + } elseif ($versions = Storage::getVersions($sourceOwner, '/' . $sourcePath)) { + // create missing dirs if necessary + 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'] + ); + } + } + + // if we moved versions directly for a file, schedule expiration check for that file + if (!$rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) { + self::scheduleExpire($targetOwner, $targetPath); + } + } + + /** + * Rollback to an old version of a file. + * + * @param string $file file name + * @param int $revision revision timestamp + * @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 = Server::get(IRootFolder::class); + $userFolder = $root->getUserFolder($user->getUID()); + + $users_view = new View('/' . $user->getUID()); + $files_view = new View('/' . $user->getUID() . '/files'); + + $versionCreated = false; + + $fileInfo = $files_view->getFileInfo($file); + + // check if user has the permissions to revert a version + if (!$fileInfo->isUpdateable()) { + return false; + } + + //first create a new version + $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)); + $versionCreated = true; + } + + $fileToRestore = 'files_versions' . $filename . '.v' . $revision; + + // Restore encrypted version of the old file for the newly restored file + // This has to happen manually here since the file is manually copied below + $oldVersion = $users_view->getFileInfo($fileToRestore)->getEncryptedVersion(); + $oldFileInfo = $users_view->getFileInfo($fileToRestore); + $cache = $fileInfo->getStorage()->getCache(); + $cache->update( + $fileInfo->getId(), [ + 'encrypted' => $oldVersion, + 'encryptedVersion' => $oldVersion, + 'size' => $oldFileInfo->getData()['size'], + 'unencrypted_size' => $oldFileInfo->getData()['unencrypted_size'], + ] + ); + + // rollback + if (self::copyFileContents($users_view, $fileToRestore, 'files' . $filename)) { + $files_view->touch($file, $revision); + Storage::scheduleExpire($user->getUID(), $file); + + return true; + } elseif ($versionCreated) { + self::deleteVersion($users_view, $version); + } + + return false; + } + + /** + * Stream copy file contents from $path1 to $path2 + * + * @param View $view view to use for copying + * @param string $path1 source file to copy + * @param string $path2 target file + * + * @return bool true for success, false otherwise + */ + private static function copyFileContents($view, $path1, $path2) { + /** @var \OC\Files\Storage\Storage $storage1 */ + [$storage1, $internalPath1] = $view->resolvePath($path1); + /** @var \OC\Files\Storage\Storage $storage2 */ + [$storage2, $internalPath2] = $view->resolvePath($path2); + + $view->lockFile($path1, ILockingProvider::LOCK_EXCLUSIVE); + $view->lockFile($path2, ILockingProvider::LOCK_EXCLUSIVE); + + 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); + } + } else { + $result = $storage2->moveFromStorage($storage1, $internalPath1, $internalPath2); + } + } finally { + $view->unlockFile($path1, ILockingProvider::LOCK_EXCLUSIVE); + $view->unlockFile($path2, ILockingProvider::LOCK_EXCLUSIVE); + } + + return ($result !== false); + } + + /** + * get a list of all available versions of a file in descending chronological order + * @param string $uid user id from the owner of the file + * @param string $filename file to find versions of, relative to the user files dir + * @param string $userFullPath + * @return array versions newest version first + */ + public static function getVersions($uid, $filename, $userFullPath = '') { + $versions = []; + if (empty($filename)) { + return $versions; + } + // fetch for old versions + $view = new View('/' . $uid . '/'); + + $pathinfo = pathinfo($filename); + $versionedFile = $pathinfo['basename']; + + $dir = Filesystem::normalizePath(self::VERSIONS_ROOT . '/' . $pathinfo['dirname']); + + $dirContent = false; + if ($view->is_dir($dir)) { + $dirContent = $view->opendir($dir); + } + + if ($dirContent === false) { + return $versions; + } + + if (is_resource($dirContent)) { + while (($entryName = readdir($dirContent)) !== false) { + if (!Filesystem::isIgnoredDir($entryName)) { + $pathparts = pathinfo($entryName); + $filename = $pathparts['filename']; + if ($filename === $versionedFile) { + $pathparts = pathinfo($entryName); + $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((int)$timestamp); + if (empty($userFullPath)) { + $versions[$key]['preview'] = ''; + } else { + /** @var IURLGenerator $urlGenerator */ + $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'] = Server::get(IMimeTypeDetector::class)->detectPath($versionedFile); + } + } + } + closedir($dirContent); + } + + // sort with newest version first + krsort($versions); + + return $versions; + } + + /** + * Expire versions that older than max version retention time + * + * @param string $uid + */ + public static function expireOlderThanMaxForUser($uid) { + /** @var IRootFolder $root */ + $root = Server::get(IRootFolder::class); + try { + /** @var Folder $versionsRoot */ + $versionsRoot = $root->get('/' . $uid . '/files_versions'); + } catch (NotFoundException $e) { + return; + } + + $expiration = self::getExpiration(); + $threshold = $expiration->getMaxAgeAsTimestamp(); + if (!$threshold) { + return; + } + + $allVersions = $versionsRoot->search(new SearchQuery( + new SearchBinaryOperator(ISearchBinaryOperator::OPERATOR_NOT, [ + new SearchComparison(ISearchComparison::COMPARE_EQUAL, 'mimetype', FileInfo::MIMETYPE_FOLDER), + ]), + 0, + 0, + [] + )); + + /** @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, $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]); + + $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]); + } + } + } + + /** + * translate a timestamp into a string like "5 days ago" + * + * @param int $timestamp + * @return string for example "5 days ago" + */ + private static function getHumanReadableTimestamp(int $timestamp): string { + $diff = time() - $timestamp; + + if ($diff < 60) { // first minute + return $diff . ' seconds ago'; + } elseif ($diff < 3600) { //first hour + return round($diff / 60) . ' minutes ago'; + } elseif ($diff < 86400) { // first day + return round($diff / 3600) . ' hours ago'; + } elseif ($diff < 604800) { //first week + return round($diff / 86400) . ' days ago'; + } elseif ($diff < 2419200) { //first month + return round($diff / 604800) . ' weeks ago'; + } elseif ($diff < 29030400) { // first year + return round($diff / 2419200) . ' months ago'; + } else { + return round($diff / 29030400) . ' years ago'; + } + } + + /** + * returns all stored file versions from a given user + * @param string $uid id of the user + * @return array with contains two arrays 'all' which contains all versions sorted by age and 'by_file' which contains all versions sorted by filename + */ + private static function getAllVersions($uid) { + $view = new View('/' . $uid . '/'); + $dirs = [self::VERSIONS_ROOT]; + $versions = []; + + while (!empty($dirs)) { + $dir = array_pop($dirs); + $files = $view->getDirectoryContent($dir); + + foreach ($files as $file) { + $fileData = $file->getData(); + $filePath = $dir . '/' . $fileData['name']; + if ($file['type'] === 'dir') { + $dirs[] = $filePath; + } else { + $versionsBegin = strrpos($filePath, '.v'); + $relPathStart = strlen(self::VERSIONS_ROOT); + $version = substr($filePath, $versionsBegin + 2); + $relpath = substr($filePath, $relPathStart, $versionsBegin - $relPathStart); + $key = $version . '#' . $relpath; + $versions[$key] = ['path' => $relpath, 'timestamp' => $version]; + } + } + } + + // newest version first + krsort($versions); + + $result = [ + 'all' => [], + 'by_file' => [], + ]; + + foreach ($versions as $key => $value) { + $size = $view->filesize(self::VERSIONS_ROOT . '/' . $value['path'] . '.v' . $value['timestamp']); + $filename = $value['path']; + + $result['all'][$key]['version'] = $value['timestamp']; + $result['all'][$key]['path'] = $filename; + $result['all'][$key]['size'] = $size; + + $result['by_file'][$filename][$key]['version'] = $value['timestamp']; + $result['by_file'][$filename][$key]['path'] = $filename; + $result['by_file'][$filename][$key]['size'] = $size; + } + + return $result; + } + + /** + * get list of files we want to expire + * @param array $versions list of versions + * @param integer $time + * @param bool $quotaExceeded is versions storage limit reached + * @return array containing the list of to deleted versions and the size of them + */ + protected static function getExpireList($time, $versions, $quotaExceeded = false) { + $expiration = self::getExpiration(); + + if ($expiration->shouldAutoExpire()) { + // 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 (!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']; + } + } + + return [$toDelete, $size]; + } + + /** + * get list of files we want to expire + * @param array $versions list of versions + * @param integer $time + * @return array containing the list of to deleted versions and the size of them + */ + protected static function getAutoExpireList($time, $versions) { + $size = 0; + $toDelete = []; // versions we want to delete + + $interval = 1; + $step = Storage::$max_versions_per_interval[$interval]['step']; + if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] === -1) { + $nextInterval = -1; + } else { + $nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter']; + } + + $firstVersion = reset($versions); + + if ($firstVersion === false) { + return [$toDelete, $size]; + } + + $firstKey = key($versions); + $prevTimestamp = $firstVersion['version']; + $nextVersion = $firstVersion['version'] - $step; + unset($versions[$firstKey]); + + foreach ($versions as $key => $version) { + $newInterval = true; + while ($newInterval) { + if ($nextInterval === -1 || $prevTimestamp > $nextInterval) { + if ($version['version'] > $nextVersion) { + //distance between two version too small, mark to delete + $toDelete[$key] = $version['path'] . '.v' . $version['version']; + $size += $version['size']; + 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']; + } + $newInterval = false; // version checked so we can move to the next one + } else { // time to move on to the next interval + $interval++; + $step = Storage::$max_versions_per_interval[$interval]['step']; + $nextVersion = $prevTimestamp - $step; + if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] === -1) { + $nextInterval = -1; + } else { + $nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter']; + } + $newInterval = true; // we changed the interval -> check same version with new interval + } + } + } + + return [$toDelete, $size]; + } + + /** + * Schedule versions expiration for the given file + * + * @param string $uid owner of the file + * @param string $fileName file/folder for which to schedule expiration + */ + public static function scheduleExpire($uid, $fileName) { + // let the admin disable auto expire + $expiration = self::getExpiration(); + if ($expiration->isEnabled()) { + $command = new Expire($uid, $fileName); + /** @var IBus $bus */ + $bus = Server::get(IBus::class); + $bus->push($command); + } + } + + /** + * Expire versions which exceed the quota. + * + * This will setup the filesystem for the given user but will not + * tear it down afterwards. + * + * @param string $filename path to file to expire + * @param string $uid user for which to expire the version + * @return bool|int|null + */ + public static function expire($filename, $uid) { + $expiration = self::getExpiration(); + + /** @var LoggerInterface $logger */ + $logger = Server::get(LoggerInterface::class); + + if ($expiration->isEnabled()) { + // get available disk space for user + $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 NoUserException('Backends provided no user object for ' . $uid); + } + + \OC_Util::setupFS($uid); + + try { + if (!Filesystem::file_exists($filename)) { + return false; + } + } catch (StorageNotAvailableException $e) { + // if we can't check that the file hasn't been deleted we can only assume that it hasn't + // note that this `StorageNotAvailableException` is about the file the versions originate from, + // not the storage that the versions are stored on + } + + if (empty($filename)) { + // file maybe renamed or deleted + return false; + } + $versionsFileview = new View('/' . $uid . '/files_versions'); + + $softQuota = true; + $quota = $user->getQuota(); + if ($quota === null || $quota === 'none') { + $quota = Filesystem::free_space('/'); + $softQuota = false; + } else { + $quota = Util::computerFileSize($quota); + } + + // make sure that we have the current size of the version history + $versionsSize = self::getVersionsSize($uid); + + // calculate available space for version history + // subtract size of files and current versions size from quota + if ($quota >= 0) { + if ($softQuota) { + $root = Server::get(IRootFolder::class); + $userFolder = $root->getUserFolder($uid); + if (is_null($userFolder)) { + $availableSpace = 0; + } else { + $free = $quota - $userFolder->getSize(false); // remaining free space for user + if ($free > 0) { + $availableSpace = ($free * self::DEFAULTMAXSIZE / 100) - $versionsSize; // how much space can be used for versions + } else { + $availableSpace = $free - $versionsSize; + } + } + } else { + $availableSpace = $quota; + } + } else { + $availableSpace = PHP_INT_MAX; + } + + $allVersions = Storage::getVersions($uid, $filename); + + $time = time(); + [$toDelete, $sizeOfDeletedVersions] = self::getExpireList($time, $allVersions, $availableSpace <= 0); + + $availableSpace = $availableSpace + $sizeOfDeletedVersions; + $versionsSize = $versionsSize - $sizeOfDeletedVersions; + + // if still not enough free space we rearrange the versions from all files + if ($availableSpace <= 0) { + $result = self::getAllVersions($uid); + $allVersions = $result['all']; + + foreach ($result['by_file'] as $versions) { + [$toDeleteNew, $size] = self::getExpireList($time, $versions, $availableSpace <= 0); + $toDelete = array_merge($toDelete, $toDeleteNew); + $sizeOfDeletedVersions += $size; + } + $availableSpace = $availableSpace + $sizeOfDeletedVersions; + $versionsSize = $versionsSize - $sizeOfDeletedVersions; + } + + 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]); + unset($allVersions[$key]); // update array with the versions we keep + $logger->info('Expire: ' . $path, ['app' => 'files_versions']); + } + + // Check if enough space is available after versions are rearranged. + // If not we delete the oldest versions until we meet the size limit for versions, + // but always keep the two latest versions + $numOfVersions = count($allVersions) - 2 ; + $i = 0; + // sort oldest first and make sure that we start at the first element + ksort($allVersions); + 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]); + 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']); + $versionsSize -= $version['size']; + $availableSpace += $version['size']; + next($allVersions); + $i++; + } + + return $versionsSize; // finally return the new size of the version history + } + + return false; + } + + /** + * Create recursively missing directories inside of files_versions + * that match the given path to a file. + * + * @param string $filename $path to a file, relative to the user's + * "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'; + foreach ($dirParts as $part) { + $dir = $dir . '/' . $part; + if (!$view->file_exists($dir)) { + $view->mkdir($dir); + } + } + } + + /** + * Static workaround + * @return Expiration + */ + protected static function getExpiration() { + if (self::$application === null) { + 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 new file mode 100644 index 00000000000..f1fbecb852a --- /dev/null +++ b/apps/files_versions/lib/Versions/BackendNotFoundException.php @@ -0,0 +1,10 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Versions; + +class BackendNotFoundException extends \Exception { +} 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 new file mode 100644 index 00000000000..e5fd53d0157 --- /dev/null +++ b/apps/files_versions/lib/Versions/IVersion.php @@ -0,0 +1,85 @@ +<?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\Versions; + +use OCP\Files\FileInfo; +use OCP\IUser; + +/** + * @since 15.0.0 + */ +interface IVersion { + /** + * @return IVersionBackend + * @since 15.0.0 + */ + public function getBackend(): IVersionBackend; + + /** + * Get the file info of the source file + * + * @return FileInfo + * @since 15.0.0 + */ + public function getSourceFile(): FileInfo; + + /** + * Get the id of the revision for the file + * + * @return int|string + * @since 15.0.0 + */ + public function getRevisionId(); + + /** + * Get the timestamp this version was created + * + * @return int + * @since 15.0.0 + */ + public function getTimestamp(): int; + + /** + * Get the size of this version + * + * @return int|float + * @since 15.0.0 + */ + public function getSize(): int|float; + + /** + * Get the name of the source file at the time of making this version + * + * @return string + * @since 15.0.0 + */ + public function getSourceFileName(): string; + + /** + * Get the mimetype of this version + * + * @return string + * @since 15.0.0 + */ + public function getMimeType(): string; + + /** + * Get the path of this version + * + * @return string + * @since 15.0.0 + */ + public function getVersionPath(): string; + + /** + * @return IUser + * @since 15.0.0 + */ + public function getUser(): IUser; +} diff --git a/apps/files_versions/lib/Versions/IVersionBackend.php b/apps/files_versions/lib/Versions/IVersionBackend.php new file mode 100644 index 00000000000..18f8c17f0ac --- /dev/null +++ b/apps/files_versions/lib/Versions/IVersionBackend.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCA\Files_Versions\Versions; + +use OC\Files\Node\Node; +use OCP\Files\File; +use OCP\Files\FileInfo; +use OCP\Files\NotFoundException; +use OCP\Files\Storage\IStorage; +use OCP\IUser; + +/** + * @since 15.0.0 + */ +interface IVersionBackend { + /** + * Whether or not this version backend should be used for a storage + * + * If false is returned then the next applicable backend will be used + * + * @param IStorage $storage + * @return bool + * @since 17.0.0 + */ + public function useBackendForStorage(IStorage $storage): bool; + + /** + * Get all versions for a file + * + * @param IUser $user + * @param FileInfo $file + * @return IVersion[] + * @since 15.0.0 + */ + public function getVersionsForFile(IUser $user, FileInfo $file): array; + + /** + * Create a new version for a file + * + * @param IUser $user + * @param FileInfo $file + * @since 15.0.0 + */ + public function createVersion(IUser $user, FileInfo $file); + + /** + * Restore this version + * + * @param IVersion $version + * @since 15.0.0 + */ + public function rollback(IVersion $version); + + /** + * Open the file for reading + * + * @param IVersion $version + * @return resource|false + * @throws NotFoundException + * @since 15.0.0 + */ + public function read(IVersion $version); + + /** + * Get the preview for a specific version of a file + * + * @param IUser $user + * @param FileInfo $sourceFile + * @param int|string $revision + * + * @return File + * + * @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 new file mode 100644 index 00000000000..ecd424d0cc1 --- /dev/null +++ b/apps/files_versions/lib/Versions/IVersionManager.php @@ -0,0 +1,31 @@ +<?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\Versions; + +use OCP\Files\Storage\IStorage; + +/** + * @since 15.0.0 + */ +interface IVersionManager extends IVersionBackend { + /** + * Register a new backend + * + * @param string $storageType + * @param IVersionBackend $backend + * @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 new file mode 100644 index 00000000000..48d69d31629 --- /dev/null +++ b/apps/files_versions/lib/Versions/LegacyVersionsBackend.php @@ -0,0 +1,395 @@ +<?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\Versions; + +use Exception; +use OC\Files\View; +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, 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 { + return true; + } + + public function getVersionsForFile(IUser $user, FileInfo $file): array { + $storage = $file->getStorage(); + + 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()); + } + + $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, + $versions['db']->getMetadata() ?? [], + ); + + array_push($davVersions, $version); + } + + return $davVersions; + } + + public function createVersion(IUser $user, FileInfo $file) { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $relativePath = $userFolder->getRelativePath($file->getPath()); + $userView = new View('/' . $user->getUID()); + // create all parent folders + Storage::createMissingDirectories($relativePath, $userView); + + Storage::scheduleExpire($user->getUID(), $relativePath); + + // store a new version of a file + $userView->copy('files/' . $relativePath, 'files_versions/' . $relativePath . '.v' . $file->getMtime()); + // ensure the file is scanned + $userView->getFileInfo('files_versions/' . $relativePath . '.v' . $file->getMtime()); + } + + 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()); + } + + private function getVersionFolder(IUser $user): Folder { + $userRoot = $this->rootFolder->getUserFolder($user->getUID()) + ->getParent(); + try { + /** @var Folder $folder */ + $folder = $userRoot->get('files_versions'); + return $folder; + } catch (NotFoundException $e) { + return $userRoot->newFolder('files_versions'); + } + } + + public function read(IVersion $version) { + $versions = $this->getVersionFolder($version->getUser()); + /** @var File $file */ + $file = $versions->get($version->getVersionPath() . '.v' . $version->getRevisionId()); + return $file->fopen('r'); + } + + 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 new file mode 100644 index 00000000000..e202a69b7d7 --- /dev/null +++ b/apps/files_versions/lib/Versions/Version.php @@ -0,0 +1,72 @@ +<?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\Versions; + +use OCP\Files\FileInfo; +use OCP\IUser; + +class Version implements IVersion, IMetadataVersion { + public function __construct( + 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 = [], + ) { + } + + public function getBackend(): IVersionBackend { + return $this->backend; + } + + public function getSourceFile(): FileInfo { + return $this->sourceFileInfo; + } + + public function getRevisionId() { + return $this->revisionId; + } + + public function getTimestamp(): int { + return $this->timestamp; + } + + public function getSize(): int|float { + return $this->size; + } + + public function getSourceFileName(): string { + return $this->name; + } + + public function getMimeType(): string { + return $this->mimetype; + } + + public function getVersionPath(): string { + return $this->path; + } + + 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 new file mode 100644 index 00000000000..9acea8c6513 --- /dev/null +++ b/apps/files_versions/lib/Versions/VersionManager.php @@ -0,0 +1,210 @@ +<?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\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 { + + /** @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] = []; + } + $this->backends[$storageType][] = $backend; + } + + /** + * @return (IVersionBackend[])[] + */ + private function getBackends(): array { + return $this->backends; + } + + /** + * @param IStorage $storage + * @return IVersionBackend + * @throws BackendNotFoundException + */ + public function getBackendForStorage(IStorage $storage): IVersionBackend { + $fullType = get_class($storage); + $backends = $this->getBackends(); + + $foundType = ''; + $foundBackend = null; + + foreach ($backends as $type => $backendsForType) { + if ( + $storage->instanceOfStorage($type) + && ($foundType === '' || is_subclass_of($type, $foundType)) + ) { + foreach ($backendsForType as $backend) { + /** @var IVersionBackend $backend */ + if ($backend->useBackendForStorage($storage)) { + $foundBackend = $backend; + $foundType = $type; + } + } + } + } + + if ($foundType === '' || $foundBackend === null) { + throw new BackendNotFoundException("Version backend for $fullType not found"); + } else { + return $foundBackend; + } + } + + public function getVersionsForFile(IUser $user, FileInfo $file): array { + $backend = $this->getBackendForStorage($file->getStorage()); + return $backend->getVersionsForFile($user, $file); + } + + public function createVersion(IUser $user, FileInfo $file) { + $backend = $this->getBackendForStorage($file->getStorage()); + $backend->createVersion($user, $file); + } + + public function rollback(IVersion $version) { + $backend = $version->getBackend(); + $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) { + $backend = $version->getBackend(); + return $backend->read($version); + } + + public function getVersionFile(IUser $user, FileInfo $sourceFile, $revision): File { + $backend = $this->getBackendForStorage($sourceFile->getStorage()); + 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; + } + } +} diff --git a/apps/files_versions/lib/backgroundjob/expireversions.php b/apps/files_versions/lib/backgroundjob/expireversions.php deleted file mode 100644 index 5d8eef4e351..00000000000 --- a/apps/files_versions/lib/backgroundjob/expireversions.php +++ /dev/null @@ -1,98 +0,0 @@ -<?php -/** - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\Files_Versions\BackgroundJob; - -use OCP\IUserManager; -use OCA\Files_Versions\AppInfo\Application; -use OCA\Files_Versions\Storage; -use OCA\Files_Versions\Expiration; - -class ExpireVersions extends \OC\BackgroundJob\TimedJob { - - const ITEMS_PER_SESSION = 1000; - - /** - * @var Expiration - */ - private $expiration; - - /** - * @var IUserManager - */ - private $userManager; - - public function __construct(IUserManager $userManager = null, Expiration $expiration = null) { - // Run once per 30 minutes - $this->setInterval(60 * 30); - - if (is_null($expiration) || is_null($userManager)) { - $this->fixDIForJobs(); - } else { - $this->expiration = $expiration; - $this->userManager = $userManager; - } - } - - protected function fixDIForJobs() { - $application = new Application(); - $this->expiration = $application->getContainer()->query('Expiration'); - $this->userManager = \OC::$server->getUserManager(); - } - - protected function run($argument) { - $maxAge = $this->expiration->getMaxAgeAsTimestamp(); - if (!$maxAge) { - return; - } - - $users = $this->userManager->search(''); - $isFSready = false; - foreach ($users as $user) { - $uid = $user->getUID(); - if (!$isFSready) { - if (!$this->setupFS($uid)) { - continue; - } - $isFSready = true; - } - Storage::expireOlderThanMaxForUser($uid); - } - - \OC_Util::tearDownFS(); - } - - /** - * Act on behalf on trash item owner - * @param string $user - * @return boolean - */ - private function setupFS($user){ - if (!$this->userManager->userExists($user)) { - return false; - } - - \OC_Util::tearDownFS(); - \OC_Util::setupFS($user); - - return true; - } -} diff --git a/apps/files_versions/lib/capabilities.php b/apps/files_versions/lib/capabilities.php deleted file mode 100644 index 441b2adfba3..00000000000 --- a/apps/files_versions/lib/capabilities.php +++ /dev/null @@ -1,43 +0,0 @@ -<?php -/** - * @author Christopher Schäpers <kondou@ts.unde.re> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Roeland Jago Douma <rullzer@owncloud.com> - * @author Tom Needham <tom@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -namespace OCA\Files_Versions; - -use OCP\Capabilities\ICapability; - -class Capabilities implements ICapability { - - /** - * Return this classes capabilities - * - * @return array - */ - public function getCapabilities() { - return [ - 'files' => [ - 'versioning' => true - ] - ]; - } -} diff --git a/apps/files_versions/lib/hooks.php b/apps/files_versions/lib/hooks.php deleted file mode 100644 index beaf81c7471..00000000000 --- a/apps/files_versions/lib/hooks.php +++ /dev/null @@ -1,170 +0,0 @@ -<?php -/** - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <schiessle@owncloud.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <icewind@owncloud.com> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Sam Tuke <mail@samtuke.com> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -/** - * This class contains all hooks. - */ - -namespace OCA\Files_Versions; - -class Hooks { - - public static function connectHooks() { - // Listen to write signals - \OCP\Util::connectHook('OC_Filesystem', 'write', 'OCA\Files_Versions\Hooks', 'write_hook'); - // Listen to delete and rename signals - \OCP\Util::connectHook('OC_Filesystem', 'post_delete', 'OCA\Files_Versions\Hooks', 'remove_hook'); - \OCP\Util::connectHook('OC_Filesystem', 'delete', 'OCA\Files_Versions\Hooks', 'pre_remove_hook'); - \OCP\Util::connectHook('OC_Filesystem', 'post_rename', 'OCA\Files_Versions\Hooks', 'rename_hook'); - \OCP\Util::connectHook('OC_Filesystem', 'post_copy', 'OCA\Files_Versions\Hooks', 'copy_hook'); - \OCP\Util::connectHook('OC_Filesystem', 'rename', 'OCA\Files_Versions\Hooks', 'pre_renameOrCopy_hook'); - \OCP\Util::connectHook('OC_Filesystem', 'copy', 'OCA\Files_Versions\Hooks', 'pre_renameOrCopy_hook'); - - $eventDispatcher = \OC::$server->getEventDispatcher(); - $eventDispatcher->addListener('OCA\Files::loadAdditionalScripts', ['OCA\Files_Versions\Hooks', 'onLoadFilesAppScripts']); - } - - /** - * listen to write event. - */ - public static function write_hook( $params ) { - - if (\OCP\App::isEnabled('files_versions')) { - $path = $params[\OC\Files\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($params) { - - if (\OCP\App::isEnabled('files_versions')) { - $path = $params[\OC\Files\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($params) { - $path = $params[\OC\Files\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($params) { - - if (\OCP\App::isEnabled('files_versions')) { - $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($params) { - - if (\OCP\App::isEnabled('files_versions')) { - $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($params) { - if (\OCP\App::isEnabled('files_versions')) { - - // if we rename a movable mount point, then the versions don't have - // to be renamed - $absOldPath = \OC\Files\Filesystem::normalizePath('/' . \OCP\User::getUser() . '/files' . $params['oldpath']); - $manager = \OC\Files\Filesystem::getMountManager(); - $mount = $manager->find($absOldPath); - $internalPath = $mount->getInternalPath($absOldPath); - if ($internalPath === '' and $mount instanceof \OC\Files\Mount\MoveableMount) { - return; - } - - $view = new \OC\Files\View(\OCP\User::getUser() . '/files'); - if ($view->file_exists($params['newpath'])) { - Storage::store($params['newpath']); - } else { - Storage::setSourcePathAndUser($params['oldpath']); - } - - } - } - - /** - * Load additional scripts when the files app is visible - */ - public static function onLoadFilesAppScripts() { - \OCP\Util::addScript('files_versions', 'versionmodel'); - \OCP\Util::addScript('files_versions', 'versioncollection'); - \OCP\Util::addScript('files_versions', 'versionstabview'); - \OCP\Util::addScript('files_versions', 'filesplugin'); - } -} diff --git a/apps/files_versions/lib/storage.php b/apps/files_versions/lib/storage.php deleted file mode 100644 index a213ea75238..00000000000 --- a/apps/files_versions/lib/storage.php +++ /dev/null @@ -1,808 +0,0 @@ -<?php -/** - * @author Arthur Schiwon <blizzz@owncloud.com> - * @author Bart Visscher <bartv@thisnet.nl> - * @author Björn Schießle <schiessle@owncloud.com> - * @author Felix Moeller <mail@felixmoeller.de> - * @author Florin Peter <github@florin-peter.de> - * @author Georg Ehrke <georg@owncloud.com> - * @author Joas Schilling <nickvergessen@owncloud.com> - * @author Jörn Friedrich Dreyer <jfd@butonic.de> - * @author Lukas Reschke <lukas@owncloud.com> - * @author Morris Jobke <hey@morrisjobke.de> - * @author Robin Appelman <icewind@owncloud.com> - * @author Robin McCorkell <robin@mccorkell.me.uk> - * @author Thomas Müller <thomas.mueller@tmit.eu> - * @author Victor Dubiniuk <dubiniuk@owncloud.com> - * @author Vincent Petry <pvince81@owncloud.com> - * - * @copyright Copyright (c) 2016, ownCloud, Inc. - * @license AGPL-3.0 - * - * This code is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License, version 3, - * as published by the Free Software Foundation. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License, version 3, - * along with this program. If not, see <http://www.gnu.org/licenses/> - * - */ - -/** - * Versions - * - * A class to handle the versioning of files. - */ - -namespace OCA\Files_Versions; - -use OC\Files\Filesystem; -use OC\Files\View; -use OCA\Files_Versions\AppInfo\Application; -use OCA\Files_Versions\Command\Expire; -use OCP\Lock\ILockingProvider; -use OCP\User; - -class Storage { - - const DEFAULTENABLED=true; - const DEFAULTMAXSIZE=50; // unit: percentage; 50% of available disk space/quota - const VERSIONS_ROOT = 'files_versions/'; - - const DELETE_TRIGGER_MASTER_REMOVED = 0; - const DELETE_TRIGGER_RETENTION_CONSTRAINT = 1; - const DELETE_TRIGGER_QUOTA_EXCEEDED = 2; - - // files for which we can remove the versions after the delete operation was successful - private static $deletedFiles = array(); - - private static $sourcePathAndUser = array(); - - private static $max_versions_per_interval = array( - //first 10sec, one version every 2sec - 1 => array('intervalEndsAfter' => 10, 'step' => 2), - //next minute, one version every 10sec - 2 => array('intervalEndsAfter' => 60, 'step' => 10), - //next hour, one version every minute - 3 => array('intervalEndsAfter' => 3600, 'step' => 60), - //next 24h, one version every hour - 4 => array('intervalEndsAfter' => 86400, 'step' => 3600), - //next 30days, one version per day - 5 => array('intervalEndsAfter' => 2592000, 'step' => 86400), - //until the end one version per week - 6 => array('intervalEndsAfter' => -1, 'step' => 604800), - ); - - /** @var \OCA\Files_Versions\AppInfo\Application */ - private static $application; - - /** - * get the UID of the owner of the file and the path to the file relative to - * owners files folder - * - * @param string $filename - * @return array - * @throws \OC\User\NoUserException - */ - public static function getUidAndFilename($filename) { - $uid = Filesystem::getOwner($filename); - $userManager = \OC::$server->getUserManager(); - // if the user with the UID doesn't exists, e.g. because the UID points - // to a remote user with a federated cloud ID we use the current logged-in - // user. We need a valid local user to create the versions - if (!$userManager->userExists($uid)) { - $uid = User::getUser(); - } - Filesystem::initMountPoints($uid); - if ( $uid != User::getUser() ) { - $info = Filesystem::getFileInfo($filename); - $ownerView = new View('/'.$uid.'/files'); - try { - $filename = $ownerView->getPath($info['fileid']); - // make sure that the file name doesn't end with a trailing slash - // can for example happen single files shared across servers - $filename = rtrim($filename, '/'); - } catch (NotFoundException $e) { - $filename = null; - } - } - return [$uid, $filename]; - } - - /** - * Remember the owner and the owner path of the source file - * - * @param string $source source path - */ - public static function setSourcePathAndUser($source) { - list($uid, $path) = self::getUidAndFilename($source); - self::$sourcePathAndUser[$source] = array('uid' => $uid, 'path' => $path); - } - - /** - * Gets the owner and the owner path from the source path - * - * @param string $source source path - * @return array with user id and path - */ - public static function getSourcePathAndUser($source) { - - if (isset(self::$sourcePathAndUser[$source])) { - $uid = self::$sourcePathAndUser[$source]['uid']; - $path = self::$sourcePathAndUser[$source]['path']; - unset(self::$sourcePathAndUser[$source]); - } else { - $uid = $path = false; - } - return array($uid, $path); - } - - /** - * get current size of all versions from a given user - * - * @param string $user user who owns the versions - * @return int versions size - */ - private static function getVersionsSize($user) { - $view = new View('/' . $user); - $fileInfo = $view->getFileInfo('/files_versions'); - return isset($fileInfo['size']) ? $fileInfo['size'] : 0; - } - - /** - * store a new version of a file. - */ - public static function store($filename) { - if(\OCP\Config::getSystemValue('files_versions', Storage::DEFAULTENABLED)=='true') { - - // if the file gets streamed we need to remove the .part extension - // to get the right target - $ext = pathinfo($filename, PATHINFO_EXTENSION); - if ($ext === 'part') { - $filename = substr($filename, 0, strlen($filename) - 5); - } - - // we only handle existing files - if (! Filesystem::file_exists($filename) || Filesystem::is_dir($filename)) { - return false; - } - - list($uid, $filename) = self::getUidAndFilename($filename); - - $files_view = new View('/'.$uid .'/files'); - $users_view = new View('/'.$uid); - - // no use making versions for empty files - if ($files_view->filesize($filename) === 0) { - return false; - } - - // create all parent folders - self::createMissingDirectories($filename, $users_view); - - self::scheduleExpire($uid, $filename); - - // store a new version of a file - $mtime = $users_view->filemtime('files/' . $filename); - $users_view->copy('files/' . $filename, 'files_versions/' . $filename . '.v' . $mtime); - // call getFileInfo to enforce a file cache entry for the new version - $users_view->getFileInfo('files_versions/' . $filename . '.v' . $mtime); - } - } - - - /** - * mark file as deleted so that we can remove the versions if the file is gone - * @param string $path - */ - public static function markDeletedFile($path) { - list($uid, $filename) = self::getUidAndFilename($path); - self::$deletedFiles[$path] = array( - 'uid' => $uid, - 'filename' => $filename); - } - - /** - * delete the version from the storage and cache - * - * @param View $view - * @param string $path - */ - protected static function deleteVersion($view, $path) { - $view->unlink($path); - /** - * @var \OC\Files\Storage\Storage $storage - * @var string $internalPath - */ - list($storage, $internalPath) = $view->resolvePath($path); - $cache = $storage->getCache($internalPath); - $cache->remove($internalPath); - } - - /** - * Delete versions of a file - */ - public static function delete($path) { - - $deletedFile = self::$deletedFiles[$path]; - $uid = $deletedFile['uid']; - $filename = $deletedFile['filename']; - - if (!Filesystem::file_exists($path)) { - - $view = new View('/' . $uid . '/files_versions'); - - $versions = self::getVersions($uid, $filename); - if (!empty($versions)) { - foreach ($versions as $v) { - \OC_Hook::emit('\OCP\Versions', 'preDelete', array('path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED)); - self::deleteVersion($view, $filename . '.v' . $v['version']); - \OC_Hook::emit('\OCP\Versions', 'delete', array('path' => $path . $v['version'], 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED)); - } - } - } - unset(self::$deletedFiles[$path]); - } - - /** - * 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 - * @param string $targetPath target path of the file to move, relative to - * the currently logged in user's "files" folder - * @param string $operation can be 'copy' or 'rename' - */ - public static function renameOrCopy($sourcePath, $targetPath, $operation) { - list($sourceOwner, $sourcePath) = self::getSourcePathAndUser($sourcePath); - - // it was a upload of a existing file if no old path exists - // in this case the pre-hook already called the store method and we can - // stop here - if ($sourcePath === false) { - return true; - } - - list($targetOwner, $targetPath) = self::getUidAndFilename($targetPath); - - $sourcePath = ltrim($sourcePath, '/'); - $targetPath = ltrim($targetPath, '/'); - - $rootView = new View(''); - - // did we move a directory ? - if ($rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) { - // 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)); - - // move the directory containing the versions - $rootView->$operation( - '/' . $sourceOwner . '/files_versions/' . $sourcePath, - '/' . $targetOwner . '/files_versions/' . $targetPath - ); - } - } else if ($versions = Storage::getVersions($sourceOwner, '/' . $sourcePath)) { - // create missing dirs if necessary - 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'] - ); - } - } - - // if we moved versions directly for a file, schedule expiration check for that file - if (!$rootView->is_dir('/' . $targetOwner . '/files/' . $targetPath)) { - self::scheduleExpire($targetOwner, $targetPath); - } - - } - - /** - * Rollback to an old version of a file. - * - * @param string $file file name - * @param int $revision revision timestamp - */ - public static function rollback($file, $revision) { - - if(\OCP\Config::getSystemValue('files_versions', Storage::DEFAULTENABLED)=='true') { - // add expected leading slash - $file = '/' . ltrim($file, '/'); - list($uid, $filename) = self::getUidAndFilename($file); - $users_view = new View('/'.$uid); - $files_view = new View('/'. User::getUser().'/files'); - $versionCreated = false; - - //first create a new version - $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)); - $versionCreated = true; - } - - $fileToRestore = 'files_versions' . $filename . '.v' . $revision; - - // Restore encrypted version of the old file for the newly restored file - // This has to happen manually here since the file is manually copied below - $oldVersion = $users_view->getFileInfo($fileToRestore)->getEncryptedVersion(); - $newFileInfo = $files_view->getFileInfo($filename); - $cache = $newFileInfo->getStorage()->getCache(); - $cache->update($newFileInfo->getId(), ['encrypted' => $oldVersion, 'encryptedVersion' => $oldVersion]); - - // rollback - if (self::copyFileContents($users_view, $fileToRestore, 'files' . $filename)) { - $files_view->touch($file, $revision); - Storage::scheduleExpire($uid, $file); - \OC_Hook::emit('\OCP\Versions', 'rollback', array( - 'path' => $filename, - 'revision' => $revision, - )); - return true; - } else if ($versionCreated) { - self::deleteVersion($users_view, $version); - } - } - return false; - - } - - /** - * Stream copy file contents from $path1 to $path2 - * - * @param View $view view to use for copying - * @param string $path1 source file to copy - * @param string $path2 target file - * - * @return bool true for success, false otherwise - */ - private static function copyFileContents($view, $path1, $path2) { - /** @var \OC\Files\Storage\Storage $storage1 */ - list($storage1, $internalPath1) = $view->resolvePath($path1); - /** @var \OC\Files\Storage\Storage $storage2 */ - list($storage2, $internalPath2) = $view->resolvePath($path2); - - $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'); - list(, $result) = \OC_Helper::streamCopy($source, $target); - fclose($source); - fclose($target); - - if ($result !== false) { - $storage1->unlink($internalPath1); - } - } else { - $result = $storage2->moveFromStorage($storage1, $internalPath1, $internalPath2); - } - - $view->unlockFile($path1, ILockingProvider::LOCK_EXCLUSIVE); - $view->unlockFile($path2, ILockingProvider::LOCK_EXCLUSIVE); - - return ($result !== false); - } - - /** - * get a list of all available versions of a file in descending chronological order - * @param string $uid user id from the owner of the file - * @param string $filename file to find versions of, relative to the user files dir - * @param string $userFullPath - * @return array versions newest version first - */ - public static function getVersions($uid, $filename, $userFullPath = '') { - $versions = array(); - if (empty($filename)) { - return $versions; - } - // fetch for old versions - $view = new View('/' . $uid . '/'); - - $pathinfo = pathinfo($filename); - $versionedFile = $pathinfo['basename']; - - $dir = Filesystem::normalizePath(self::VERSIONS_ROOT . '/' . $pathinfo['dirname']); - - $dirContent = false; - if ($view->is_dir($dir)) { - $dirContent = $view->opendir($dir); - } - - if ($dirContent === false) { - return $versions; - } - - if (is_resource($dirContent)) { - while (($entryName = readdir($dirContent)) !== false) { - if (!Filesystem::isIgnoredDir($entryName)) { - $pathparts = pathinfo($entryName); - $filename = $pathparts['filename']; - if ($filename === $versionedFile) { - $pathparts = pathinfo($entryName); - $timestamp = substr($pathparts['extension'], 1); - $filename = $pathparts['filename']; - $key = $timestamp . '#' . $filename; - $versions[$key]['version'] = $timestamp; - $versions[$key]['humanReadableTimestamp'] = self::getHumanReadableTimestamp($timestamp); - if (empty($userFullPath)) { - $versions[$key]['preview'] = ''; - } else { - $versions[$key]['preview'] = \OCP\Util::linkToRoute('core_ajax_versions_preview', array('file' => $userFullPath, 'version' => $timestamp)); - } - $versions[$key]['path'] = Filesystem::normalizePath($pathinfo['dirname'] . '/' . $filename); - $versions[$key]['name'] = $versionedFile; - $versions[$key]['size'] = $view->filesize($dir . '/' . $entryName); - } - } - } - closedir($dirContent); - } - - // sort with newest version first - krsort($versions); - - return $versions; - } - - /** - * Expire versions that older than max version retention time - * @param string $uid - */ - public static function expireOlderThanMaxForUser($uid){ - $expiration = self::getExpiration(); - $threshold = $expiration->getMaxAgeAsTimestamp(); - $versions = self::getAllVersions($uid); - if (!$threshold || !array_key_exists('all', $versions)) { - return; - } - - $toDelete = []; - foreach (array_reverse($versions['all']) as $key => $version) { - if (intval($version['version'])<$threshold) { - $toDelete[$key] = $version; - } else { - //Versions are sorted by time - nothing mo to iterate. - break; - } - } - - $view = new View('/' . $uid . '/files_versions'); - if (!empty($toDelete)) { - foreach ($toDelete as $version) { - \OC_Hook::emit('\OCP\Versions', 'preDelete', array('path' => $version['path'].'.v'.$version['version'], 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT)); - self::deleteVersion($view, $version['path'] . '.v' . $version['version']); - \OC_Hook::emit('\OCP\Versions', 'delete', array('path' => $version['path'].'.v'.$version['version'], 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT)); - } - } - } - - /** - * translate a timestamp into a string like "5 days ago" - * @param int $timestamp - * @return string for example "5 days ago" - */ - private static function getHumanReadableTimestamp($timestamp) { - - $diff = time() - $timestamp; - - if ($diff < 60) { // first minute - return $diff . " seconds ago"; - } elseif ($diff < 3600) { //first hour - return round($diff / 60) . " minutes ago"; - } elseif ($diff < 86400) { // first day - return round($diff / 3600) . " hours ago"; - } elseif ($diff < 604800) { //first week - return round($diff / 86400) . " days ago"; - } elseif ($diff < 2419200) { //first month - return round($diff / 604800) . " weeks ago"; - } elseif ($diff < 29030400) { // first year - return round($diff / 2419200) . " months ago"; - } else { - return round($diff / 29030400) . " years ago"; - } - - } - - /** - * returns all stored file versions from a given user - * @param string $uid id of the user - * @return array with contains two arrays 'all' which contains all versions sorted by age and 'by_file' which contains all versions sorted by filename - */ - private static function getAllVersions($uid) { - $view = new View('/' . $uid . '/'); - $dirs = array(self::VERSIONS_ROOT); - $versions = array(); - - while (!empty($dirs)) { - $dir = array_pop($dirs); - $files = $view->getDirectoryContent($dir); - - foreach ($files as $file) { - if ($file['type'] === 'dir') { - array_push($dirs, $file['path']); - } else { - $versionsBegin = strrpos($file['path'], '.v'); - $relPathStart = strlen(self::VERSIONS_ROOT); - $version = substr($file['path'], $versionsBegin + 2); - $relpath = substr($file['path'], $relPathStart, $versionsBegin - $relPathStart); - $key = $version . '#' . $relpath; - $versions[$key] = array('path' => $relpath, 'timestamp' => $version); - } - } - } - - // newest version first - krsort($versions); - - $result = array(); - - foreach ($versions as $key => $value) { - $size = $view->filesize(self::VERSIONS_ROOT.'/'.$value['path'].'.v'.$value['timestamp']); - $filename = $value['path']; - - $result['all'][$key]['version'] = $value['timestamp']; - $result['all'][$key]['path'] = $filename; - $result['all'][$key]['size'] = $size; - - $result['by_file'][$filename][$key]['version'] = $value['timestamp']; - $result['by_file'][$filename][$key]['path'] = $filename; - $result['by_file'][$filename][$key]['size'] = $size; - } - - return $result; - } - - /** - * get list of files we want to expire - * @param array $versions list of versions - * @param integer $time - * @param bool $quotaExceeded is versions storage limit reached - * @return array containing the list of to deleted versions and the size of them - */ - protected static function getExpireList($time, $versions, $quotaExceeded = false) { - $expiration = self::getExpiration(); - - if ($expiration->shouldAutoExpire()) { - list($toDelete, $size) = self::getAutoExpireList($time, $versions); - } else { - $size = 0; - $toDelete = []; // versions we want to delete - } - - foreach ($versions as $key => $version) { - if ($expiration->isExpired($version['version'], $quotaExceeded) && !isset($toDelete[$key])) { - $size += $version['size']; - $toDelete[$key] = $version['path'] . '.v' . $version['version']; - } - } - - return [$toDelete, $size]; - } - - /** - * get list of files we want to expire - * @param array $versions list of versions - * @param integer $time - * @return array containing the list of to deleted versions and the size of them - */ - protected static function getAutoExpireList($time, $versions) { - $size = 0; - $toDelete = array(); // versions we want to delete - - $interval = 1; - $step = Storage::$max_versions_per_interval[$interval]['step']; - if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] == -1) { - $nextInterval = -1; - } else { - $nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter']; - } - - $firstVersion = reset($versions); - $firstKey = key($versions); - $prevTimestamp = $firstVersion['version']; - $nextVersion = $firstVersion['version'] - $step; - unset($versions[$firstKey]); - - foreach ($versions as $key => $version) { - $newInterval = true; - while ($newInterval) { - if ($nextInterval == -1 || $prevTimestamp > $nextInterval) { - if ($version['version'] > $nextVersion) { - //distance between two version too small, mark to delete - $toDelete[$key] = $version['path'] . '.v' . $version['version']; - $size += $version['size']; - \OCP\Util::writeLog('files_versions', 'Mark to expire '. $version['path'] .' next version should be ' . $nextVersion . " or smaller. (prevTimestamp: " . $prevTimestamp . "; step: " . $step, \OCP\Util::DEBUG); - } else { - $nextVersion = $version['version'] - $step; - $prevTimestamp = $version['version']; - } - $newInterval = false; // version checked so we can move to the next one - } else { // time to move on to the next interval - $interval++; - $step = Storage::$max_versions_per_interval[$interval]['step']; - $nextVersion = $prevTimestamp - $step; - if (Storage::$max_versions_per_interval[$interval]['intervalEndsAfter'] == -1) { - $nextInterval = -1; - } else { - $nextInterval = $time - Storage::$max_versions_per_interval[$interval]['intervalEndsAfter']; - } - $newInterval = true; // we changed the interval -> check same version with new interval - } - } - } - - return array($toDelete, $size); - } - - /** - * Schedule versions expiration for the given file - * - * @param string $uid owner of the file - * @param string $fileName file/folder for which to schedule expiration - */ - private static function scheduleExpire($uid, $fileName) { - // let the admin disable auto expire - $expiration = self::getExpiration(); - if ($expiration->isEnabled()) { - $command = new Expire($uid, $fileName); - \OC::$server->getCommandBus()->push($command); - } - } - - /** - * Expire versions which exceed the quota - * - * @param string $filename - * @return bool|int|null - */ - public static function expire($filename) { - $config = \OC::$server->getConfig(); - $expiration = self::getExpiration(); - - if($config->getSystemValue('files_versions', Storage::DEFAULTENABLED)=='true' && $expiration->isEnabled()) { - - if (!Filesystem::file_exists($filename)) { - return false; - } - - list($uid, $filename) = self::getUidAndFilename($filename); - if (empty($filename)) { - // file maybe renamed or deleted - return false; - } - $versionsFileview = new View('/'.$uid.'/files_versions'); - - // get available disk space for user - $user = \OC::$server->getUserManager()->get($uid); - $softQuota = true; - $quota = $user->getQuota(); - if ( $quota === null || $quota === 'none' ) { - $quota = Filesystem::free_space('/'); - $softQuota = false; - } else { - $quota = \OCP\Util::computerFileSize($quota); - } - - // make sure that we have the current size of the version history - $versionsSize = self::getVersionsSize($uid); - - // calculate available space for version history - // subtract size of files and current versions size from quota - if ($quota >= 0) { - if ($softQuota) { - $files_view = new View('/' . $uid . '/files'); - $rootInfo = $files_view->getFileInfo('/', false); - $free = $quota - $rootInfo['size']; // remaining free space for user - if ($free > 0) { - $availableSpace = ($free * self::DEFAULTMAXSIZE / 100) - $versionsSize; // how much space can be used for versions - } else { - $availableSpace = $free - $versionsSize; - } - } else { - $availableSpace = $quota; - } - } else { - $availableSpace = PHP_INT_MAX; - } - - $allVersions = Storage::getVersions($uid, $filename); - - $time = time(); - list($toDelete, $sizeOfDeletedVersions) = self::getExpireList($time, $allVersions, $availableSpace <= 0); - - $availableSpace = $availableSpace + $sizeOfDeletedVersions; - $versionsSize = $versionsSize - $sizeOfDeletedVersions; - - // if still not enough free space we rearrange the versions from all files - if ($availableSpace <= 0) { - $result = Storage::getAllVersions($uid); - $allVersions = $result['all']; - - foreach ($result['by_file'] as $versions) { - list($toDeleteNew, $size) = self::getExpireList($time, $versions, $availableSpace <= 0); - $toDelete = array_merge($toDelete, $toDeleteNew); - $sizeOfDeletedVersions += $size; - } - $availableSpace = $availableSpace + $sizeOfDeletedVersions; - $versionsSize = $versionsSize - $sizeOfDeletedVersions; - } - - foreach($toDelete as $key => $path) { - \OC_Hook::emit('\OCP\Versions', 'preDelete', array('path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED)); - self::deleteVersion($versionsFileview, $path); - \OC_Hook::emit('\OCP\Versions', 'delete', array('path' => $path, 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED)); - unset($allVersions[$key]); // update array with the versions we keep - \OCP\Util::writeLog('files_versions', "Expire: " . $path, \OCP\Util::DEBUG); - } - - // Check if enough space is available after versions are rearranged. - // If not we delete the oldest versions until we meet the size limit for versions, - // but always keep the two latest versions - $numOfVersions = count($allVersions) -2 ; - $i = 0; - // sort oldest first and make sure that we start at the first element - ksort($allVersions); - reset($allVersions); - while ($availableSpace < 0 && $i < $numOfVersions) { - $version = current($allVersions); - \OC_Hook::emit('\OCP\Versions', 'preDelete', array('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', array('path' => $version['path'].'.v'.$version['version'], 'trigger' => self::DELETE_TRIGGER_QUOTA_EXCEEDED)); - \OCP\Util::writeLog('files_versions', 'running out of space! Delete oldest version: ' . $version['path'].'.v'.$version['version'] , \OCP\Util::DEBUG); - $versionsSize -= $version['size']; - $availableSpace += $version['size']; - next($allVersions); - $i++; - } - - return $versionsSize; // finally return the new size of the version history - } - - return false; - } - - /** - * Create recursively missing directories inside of files_versions - * that match the given path to a file. - * - * @param string $filename $path to a file, relative to the user's - * "files" folder - * @param View $view view on data/user/ - */ - private static function createMissingDirectories($filename, $view) { - $dirname = Filesystem::normalizePath(dirname($filename)); - $dirParts = explode('/', $dirname); - $dir = "/files_versions"; - foreach ($dirParts as $part) { - $dir = $dir . '/' . $part; - if (!$view->file_exists($dir)) { - $view->mkdir($dir); - } - } - } - - /** - * Static workaround - * @return Expiration - */ - protected static function getExpiration(){ - if (is_null(self::$application)) { - self::$application = new Application(); - } - return self::$application->getContainer()->query('Expiration'); - } - -} |