diff options
Diffstat (limited to 'apps/files_versions/lib')
23 files changed, 397 insertions, 72 deletions
diff --git a/apps/files_versions/lib/AppInfo/Application.php b/apps/files_versions/lib/AppInfo/Application.php index a31e9823efd..29158276415 100644 --- a/apps/files_versions/lib/AppInfo/Application.php +++ b/apps/files_versions/lib/AppInfo/Application.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. @@ -12,7 +13,9 @@ 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; @@ -42,6 +45,7 @@ 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; @@ -68,7 +72,7 @@ class Application extends App implements IBootstrap { return new Principal( $server->get(IUserManager::class), $server->get(IGroupManager::class), - \OC::$server->get(IAccountManager::class), + Server::get(IAccountManager::class), $server->get(IShareManager::class), $server->get(IUserSession::class), $server->get(IAppManager::class), @@ -79,9 +83,7 @@ class Application extends App implements IBootstrap { ); }); - $context->registerService(IVersionManager::class, function () { - return new VersionManager(); - }); + $context->registerServiceAlias(IVersionManager::class, VersionManager::class); /** * Register Events @@ -106,7 +108,10 @@ class Application extends App implements IBootstrap { $context->registerEventListener(BeforeNodeRenamedEvent::class, FileEventsListener::class); $context->registerEventListener(BeforeNodeCopiedEvent::class, FileEventsListener::class); - $context->registerEventListener(NodeWrittenEvent::class, VersionAuthorListener::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 { @@ -114,7 +119,7 @@ class Application extends App implements IBootstrap { } public function registerVersionBackends(ContainerInterface $container, IAppManager $appManager, LoggerInterface $logger): void { - foreach ($appManager->getInstalledApps() as $app) { + foreach ($appManager->getEnabledApps() as $app) { $appInfo = $appManager->getAppInfo($app); if (isset($appInfo['versions'])) { $backends = $appInfo['versions']; diff --git a/apps/files_versions/lib/BackgroundJob/ExpireVersions.php b/apps/files_versions/lib/BackgroundJob/ExpireVersions.php index 6de29364835..794cbc5b882 100644 --- a/apps/files_versions/lib/BackgroundJob/ExpireVersions.php +++ b/apps/files_versions/lib/BackgroundJob/ExpireVersions.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. diff --git a/apps/files_versions/lib/Capabilities.php b/apps/files_versions/lib/Capabilities.php index 4919006bce3..cb6394f0a36 100644 --- a/apps/files_versions/lib/Capabilities.php +++ b/apps/files_versions/lib/Capabilities.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. diff --git a/apps/files_versions/lib/Command/CleanUp.php b/apps/files_versions/lib/Command/CleanUp.php index 1cd3dccc4dd..e8c46afef16 100644 --- a/apps/files_versions/lib/Command/CleanUp.php +++ b/apps/files_versions/lib/Command/CleanUp.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. diff --git a/apps/files_versions/lib/Command/Expire.php b/apps/files_versions/lib/Command/Expire.php index 6ec7a4fd32a..a30e623c347 100644 --- a/apps/files_versions/lib/Command/Expire.php +++ b/apps/files_versions/lib/Command/Expire.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. @@ -11,6 +12,7 @@ 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 { @@ -24,7 +26,7 @@ class Expire implements ICommand { public function handle(): void { /** @var IUserManager $userManager */ - $userManager = \OC::$server->get(IUserManager::class); + $userManager = Server::get(IUserManager::class); if (!$userManager->userExists($this->user)) { // User has been deleted already return; @@ -36,7 +38,7 @@ class Expire implements ICommand { // In case of external storage and session credentials, the expiration // fails because the command does not have those credentials - $logger = \OC::$server->get(LoggerInterface::class); + $logger = Server::get(LoggerInterface::class); $logger->warning($e->getMessage(), [ 'exception' => $e, 'uid' => $this->user, diff --git a/apps/files_versions/lib/Command/ExpireVersions.php b/apps/files_versions/lib/Command/ExpireVersions.php index fd0118549c6..d3f341a21d2 100644 --- a/apps/files_versions/lib/Command/ExpireVersions.php +++ b/apps/files_versions/lib/Command/ExpireVersions.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud GmbH. diff --git a/apps/files_versions/lib/Controller/PreviewController.php b/apps/files_versions/lib/Controller/PreviewController.php index 2c2793b6878..2c3ff8da70d 100644 --- a/apps/files_versions/lib/Controller/PreviewController.php +++ b/apps/files_versions/lib/Controller/PreviewController.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -13,11 +14,13 @@ 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 { @@ -29,6 +32,7 @@ class PreviewController extends Controller { private IUserSession $userSession, private IVersionManager $versionManager, private IPreview $previewManager, + private IMimeIconProvider $mimeIconProvider, ) { parent::__construct($appName, $request); } @@ -40,9 +44,11 @@ class PreviewController extends Controller { * @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 - * @return FileDisplayResponse<Http::STATUS_OK, array{Content-Type: string}>|DataResponse<Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND, list<empty>, array{}> + * @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 */ @@ -53,19 +59,31 @@ class PreviewController extends Controller { 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()); - return new FileDisplayResponse($preview, Http::STATUS_OK, ['Content-Type' => $preview->getMimeType()]); + $response = new FileDisplayResponse($preview, Http::STATUS_OK, ['Content-Type' => $preview->getMimeType()]); + $response->cacheFor(3600 * 24, false, true); + return $response; } catch (NotFoundException $e) { + // If we have no preview enabled, we can redirect to the mime icon if any + if ($mimeFallback && $versionFile !== null) { + $url = $this->mimeIconProvider->getMimeIconUrl($versionFile->getMimeType()); + if ($url !== null) { + return new RedirectResponse($url); + } + } + return new DataResponse([], Http::STATUS_NOT_FOUND); } catch (\InvalidArgumentException $e) { return new DataResponse([], Http::STATUS_BAD_REQUEST); diff --git a/apps/files_versions/lib/Events/CreateVersionEvent.php b/apps/files_versions/lib/Events/CreateVersionEvent.php index 1399cdbb988..92ed26b2dd6 100644 --- a/apps/files_versions/lib/Events/CreateVersionEvent.php +++ b/apps/files_versions/lib/Events/CreateVersionEvent.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later 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 b84756212e4..1e04d93379f 100644 --- a/apps/files_versions/lib/Expiration.php +++ b/apps/files_versions/lib/Expiration.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. @@ -99,6 +100,20 @@ class Expiration { } /** + * Get minimal retention obligation as a timestamp + * + * @return int|false + */ + public function getMinAgeAsTimestamp() { + $minAge = false; + if ($this->isEnabled() && $this->minAge !== self::NO_OBLIGATION) { + $time = $this->timeFactory->getTime(); + $minAge = $time - ($this->minAge * 86400); + } + return $minAge; + } + + /** * Get maximal retention obligation as a timestamp * * @return int|false diff --git a/apps/files_versions/lib/Listener/FileEventsListener.php b/apps/files_versions/lib/Listener/FileEventsListener.php index c078f4bc347..969ca4ded45 100644 --- a/apps/files_versions/lib/Listener/FileEventsListener.php +++ b/apps/files_versions/lib/Listener/FileEventsListener.php @@ -12,6 +12,7 @@ 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; @@ -36,6 +37,7 @@ 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; @@ -124,6 +126,22 @@ class FileEventsListener implements IEventListener { } 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) { @@ -134,8 +152,10 @@ class FileEventsListener implements IEventListener { 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, $previousNode->getMTime(), ['timestamp' => $node->getMTime()]); + $this->versionManager->updateVersionEntity($node, $revision, ['timestamp' => $node->getMTime()]); } } catch (DbalException $ex) { // Ignore UniqueConstraintViolationException, as we are probably in the middle of a rollback @@ -151,7 +171,22 @@ class FileEventsListener implements IEventListener { public function created(Node $node): void { // Do not handle folders. - if ($node instanceof File && $this->versionManager instanceof INeedSyncVersionBackend) { + 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); } } @@ -189,6 +224,17 @@ class FileEventsListener implements IEventListener { 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) { @@ -196,8 +242,8 @@ class FileEventsListener implements IEventListener { } if ( - $writeHookInfo['versionCreated'] && - $node->getMTime() !== $writeHookInfo['previousNode']->getMTime() + $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, @@ -208,9 +254,11 @@ class FileEventsListener implements IEventListener { // 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, - $writeHookInfo['previousNode']->getMtime(), + $revision, [ 'timestamp' => $node->getMTime(), 'size' => $node->getSize(), @@ -218,6 +266,15 @@ class FileEventsListener implements IEventListener { ], ); } + } 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, @@ -241,7 +298,7 @@ class FileEventsListener implements IEventListener { /** * Erase versions of deleted file * - * This function is connected to the delete signal of OC_Filesystem + * 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 { @@ -273,7 +330,7 @@ class FileEventsListener implements IEventListener { /** * rename/move versions of renamed/moved files * - * This function is connected to the rename signal of OC_Filesystem and adjust the name and location + * 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 { @@ -292,7 +349,7 @@ class FileEventsListener implements IEventListener { /** * copy versions of copied files * - * This function is connected to the copy signal of OC_Filesystem and copies the + * 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 { @@ -323,11 +380,19 @@ class FileEventsListener implements IEventListener { return; } - // if we rename a movable mount point, then the versions don't have - // to be renamed + // if we rename a movable mount point, then the versions don't have to be renamed $oldPath = $this->getPathForNode($source); $newPath = $this->getPathForNode($target); - $absOldPath = Filesystem::normalizePath('/' . \OC_User::getUser() . '/files' . $oldPath); + 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); @@ -335,7 +400,7 @@ class FileEventsListener implements IEventListener { return; } - $view = new View(\OC_User::getUser() . '/files'); + $view = new View($user . '/files'); if ($view->file_exists($newPath)) { Storage::store($newPath); } else { @@ -359,7 +424,11 @@ class FileEventsListener implements IEventListener { } } - $owner = $node->getOwner()?->getUid(); + try { + $owner = $node->getOwner()?->getUid(); + } catch (NotFoundException) { + $owner = null; + } // If no owner, extract it from the path. // e.g. /user/files/foobar.txt @@ -380,6 +449,24 @@ class FileEventsListener implements IEventListener { } } + 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/VersionAuthorListener.php b/apps/files_versions/lib/Listener/VersionAuthorListener.php index 6f4e6c0ee57..9b93b1f888b 100644 --- a/apps/files_versions/lib/Listener/VersionAuthorListener.php +++ b/apps/files_versions/lib/Listener/VersionAuthorListener.php @@ -9,6 +9,7 @@ declare(strict_types=1); 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; @@ -47,8 +48,9 @@ class VersionAuthorListener implements IEventListener { } // 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, $node->getMTime(), 'author', $author); + $this->versionManager->setMetadataValue($node, $revision, Plugin::AUTHOR, $author); } } } diff --git a/apps/files_versions/lib/Sabre/Plugin.php b/apps/files_versions/lib/Sabre/Plugin.php index 10a1896773d..984c4a36e5b 100644 --- a/apps/files_versions/lib/Sabre/Plugin.php +++ b/apps/files_versions/lib/Sabre/Plugin.php @@ -24,6 +24,10 @@ 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 @@ -76,9 +80,12 @@ class Plugin extends ServerPlugin { public function propFind(PropFind $propFind, INode $node): void { if ($node instanceof VersionFile) { - $propFind->handle(self::VERSION_LABEL, fn () => $node->getMetadataValue('label')); - $propFind->handle(self::VERSION_AUTHOR, fn () => $node->getMetadataValue('author')); - $propFind->handle(FilesPlugin::HAS_PREVIEW_PROPERTYNAME, fn () => $this->previewManager->isMimeSupported($node->getContentType())); + $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', + ); } } @@ -86,7 +93,7 @@ class Plugin extends ServerPlugin { $node = $this->server->tree->getNodeForPath($path); if ($node instanceof VersionFile) { - $propPatch->handle(self::VERSION_LABEL, fn (string $label) => $node->setMetadataValue('label', $label)); + $propPatch->handle(self::VERSION_LABEL, fn (string $label) => $node->setMetadataValue(self::LABEL, $label)); } } } diff --git a/apps/files_versions/lib/Sabre/RootCollection.php b/apps/files_versions/lib/Sabre/RootCollection.php index ded4f3dda7b..1e7129f23da 100644 --- a/apps/files_versions/lib/Sabre/RootCollection.php +++ b/apps/files_versions/lib/Sabre/RootCollection.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/files_versions/lib/Sabre/VersionHome.php b/apps/files_versions/lib/Sabre/VersionHome.php index 7f182958d94..07ac491f2a1 100644 --- a/apps/files_versions/lib/Sabre/VersionHome.php +++ b/apps/files_versions/lib/Sabre/VersionHome.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/files_versions/lib/Storage.php b/apps/files_versions/lib/Storage.php index 11e6bff6b6b..6d53a19a518 100644 --- a/apps/files_versions/lib/Storage.php +++ b/apps/files_versions/lib/Storage.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors * SPDX-FileCopyrightText: 2016 ownCloud, Inc. @@ -8,6 +9,7 @@ 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; @@ -23,6 +25,7 @@ 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; @@ -32,6 +35,7 @@ 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; @@ -84,7 +88,7 @@ class Storage { */ public static function getUidAndFilename($filename) { $uid = Filesystem::getOwner($filename); - $userManager = \OC::$server->get(IUserManager::class); + $userManager = Server::get(IUserManager::class); // if the user with the UID doesn't exists, e.g. because the UID points // to a remote user with a federated cloud ID we use the current logged-in // user. We need a valid local user to create the versions @@ -167,10 +171,10 @@ class Storage { $uid = Filesystem::getView()->getOwner(''); /** @var IRootFolder $rootFolder */ - $rootFolder = \OC::$server->get(IRootFolder::class); + $rootFolder = Server::get(IRootFolder::class); $userFolder = $rootFolder->getUserFolder($uid); - $eventDispatcher = \OC::$server->get(IEventDispatcher::class); + $eventDispatcher = Server::get(IEventDispatcher::class); try { $file = $userFolder->get($filename); } catch (NotFoundException $e) { @@ -188,7 +192,7 @@ class Storage { } /** @var IUserManager $userManager */ - $userManager = \OC::$server->get(IUserManager::class); + $userManager = Server::get(IUserManager::class); $user = $userManager->get($uid); if (!$user) { @@ -207,7 +211,7 @@ class Storage { } /** @var IVersionManager $versionManager */ - $versionManager = \OC::$server->get(IVersionManager::class); + $versionManager = Server::get(IVersionManager::class); $versionManager->createVersion($user, $file); } @@ -345,7 +349,7 @@ class Storage { $filename = '/' . ltrim($file, '/'); // Fetch the userfolder to trigger view hooks - $root = \OC::$server->get(IRootFolder::class); + $root = Server::get(IRootFolder::class); $userFolder = $root->getUserFolder($user->getUID()); $users_view = new View('/' . $user->getUID()); @@ -416,12 +420,31 @@ class Storage { try { // 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')) { + if ($storage1->instanceOfStorage(ObjectStoreStorage::class) + || $storage2->instanceOfStorage(ObjectStoreStorage::class) + ) { $source = $storage1->fopen($internalPath1, 'r'); - $target = $storage2->fopen($internalPath2, 'w'); - [, $result] = \OC_Helper::streamCopy($source, $target); - fclose($source); - fclose($target); + $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); @@ -475,7 +498,7 @@ class Storage { $pathparts = pathinfo($entryName); $timestamp = substr($pathparts['extension'] ?? '', 1); if (!is_numeric($timestamp)) { - \OC::$server->get(LoggerInterface::class)->error( + Server::get(LoggerInterface::class)->error( 'Version file {path} has incorrect name format', [ 'path' => $entryName, @@ -492,14 +515,14 @@ class Storage { $versions[$key]['preview'] = ''; } else { /** @var IURLGenerator $urlGenerator */ - $urlGenerator = \OC::$server->get(IURLGenerator::class); + $urlGenerator = Server::get(IURLGenerator::class); $versions[$key]['preview'] = $urlGenerator->linkToRoute('files_version.Preview.getPreview', ['file' => $userFullPath, 'version' => $timestamp]); } $versions[$key]['path'] = Filesystem::normalizePath($pathinfo['dirname'] . '/' . $filename); $versions[$key]['name'] = $versionedFile; $versions[$key]['size'] = $view->filesize($dir . '/' . $entryName); - $versions[$key]['mimetype'] = \OC::$server->get(IMimeTypeDetector::class)->detectPath($versionedFile); + $versions[$key]['mimetype'] = Server::get(IMimeTypeDetector::class)->detectPath($versionedFile); } } } @@ -519,7 +542,7 @@ class Storage { */ public static function expireOlderThanMaxForUser($uid) { /** @var IRootFolder $root */ - $root = \OC::$server->get(IRootFolder::class); + $root = Server::get(IRootFolder::class); try { /** @var Folder $versionsRoot */ $versionsRoot = $root->get('/' . $uid . '/files_versions'); @@ -543,7 +566,7 @@ class Storage { )); /** @var VersionsMapper $versionsMapper */ - $versionsMapper = \OC::$server->get(VersionsMapper::class); + $versionsMapper = Server::get(VersionsMapper::class); $userFolder = $root->getUserFolder($uid); $versionEntities = []; @@ -696,7 +719,15 @@ class Storage { $expiration = self::getExpiration(); if ($expiration->shouldAutoExpire()) { - [$toDelete, $size] = self::getAutoExpireList($time, $versions); + // Exclude versions that are newer than the minimum age from the auto expiration logic. + $minAge = $expiration->getMinAgeAsTimestamp(); + if ($minAge !== false) { + $versionsToAutoExpire = array_filter($versions, fn ($version) => $version['version'] < $minAge); + } else { + $versionsToAutoExpire = $versions; + } + + [$toDelete, $size] = self::getAutoExpireList($time, $versionsToAutoExpire); } else { $size = 0; $toDelete = []; // versions we want to delete @@ -704,7 +735,7 @@ class Storage { foreach ($versions as $key => $version) { if (!is_numeric($version['version'])) { - \OC::$server->get(LoggerInterface::class)->error( + Server::get(LoggerInterface::class)->error( 'Found a non-numeric timestamp version: ' . json_encode($version), ['app' => 'files_versions']); continue; @@ -755,7 +786,7 @@ class Storage { //distance between two version too small, mark to delete $toDelete[$key] = $version['path'] . '.v' . $version['version']; $size += $version['size']; - \OC::$server->get(LoggerInterface::class)->info('Mark to expire ' . $version['path'] . ' next version should be ' . $nextVersion . ' or smaller. (prevTimestamp: ' . $prevTimestamp . '; step: ' . $step, ['app' => 'files_versions']); + Server::get(LoggerInterface::class)->info('Mark to expire ' . $version['path'] . ' next version should be ' . $nextVersion . ' or smaller. (prevTimestamp: ' . $prevTimestamp . '; step: ' . $step, ['app' => 'files_versions']); } else { $nextVersion = $version['version'] - $step; $prevTimestamp = $version['version']; @@ -790,7 +821,7 @@ class Storage { if ($expiration->isEnabled()) { $command = new Expire($uid, $fileName); /** @var IBus $bus */ - $bus = \OC::$server->get(IBus::class); + $bus = Server::get(IBus::class); $bus->push($command); } } @@ -809,11 +840,11 @@ class Storage { $expiration = self::getExpiration(); /** @var LoggerInterface $logger */ - $logger = \OC::$server->get(LoggerInterface::class); + $logger = Server::get(LoggerInterface::class); if ($expiration->isEnabled()) { // get available disk space for user - $user = \OC::$server->get(IUserManager::class)->get($uid); + $user = Server::get(IUserManager::class)->get($uid); if (is_null($user)) { $logger->error('Backends provided no user object for ' . $uid, ['app' => 'files_versions']); throw new NoUserException('Backends provided no user object for ' . $uid); @@ -853,7 +884,7 @@ class Storage { // subtract size of files and current versions size from quota if ($quota >= 0) { if ($softQuota) { - $root = \OC::$server->get(IRootFolder::class); + $root = Server::get(IRootFolder::class); $userFolder = $root->getUserFolder($uid); if (is_null($userFolder)) { $availableSpace = 0; @@ -898,8 +929,8 @@ class Storage { // Make sure to cleanup version table relations as expire does not pass deleteVersion try { /** @var VersionsMapper $versionsMapper */ - $versionsMapper = \OC::$server->get(VersionsMapper::class); - $file = \OC::$server->get(IRootFolder::class)->getUserFolder($uid)->get($filename); + $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); @@ -969,7 +1000,7 @@ class Storage { */ protected static function getExpiration() { if (self::$application === null) { - self::$application = \OC::$server->get(Application::class); + self::$application = Server::get(Application::class); } return self::$application->getContainer()->get(Expiration::class); } diff --git a/apps/files_versions/lib/Versions/BackendNotFoundException.php b/apps/files_versions/lib/Versions/BackendNotFoundException.php index af903e9abeb..f1fbecb852a 100644 --- a/apps/files_versions/lib/Versions/BackendNotFoundException.php +++ b/apps/files_versions/lib/Versions/BackendNotFoundException.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/files_versions/lib/Versions/INeedSyncVersionBackend.php b/apps/files_versions/lib/Versions/INeedSyncVersionBackend.php index 4faeaa48513..e52e2f8e8bc 100644 --- a/apps/files_versions/lib/Versions/INeedSyncVersionBackend.php +++ b/apps/files_versions/lib/Versions/INeedSyncVersionBackend.php @@ -8,13 +8,18 @@ declare(strict_types=1); */ namespace OCA\Files_Versions\Versions; +use OCA\Files_Versions\Db\VersionEntity; use OCP\Files\File; /** * @since 28.0.0 */ interface INeedSyncVersionBackend { - public function createVersionEntity(File $file): void; + /** + * 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/IVersionBackend.php b/apps/files_versions/lib/Versions/IVersionBackend.php index 28e3f4dffcf..18f8c17f0ac 100644 --- a/apps/files_versions/lib/Versions/IVersionBackend.php +++ b/apps/files_versions/lib/Versions/IVersionBackend.php @@ -8,6 +8,7 @@ declare(strict_types=1); */ namespace OCA\Files_Versions\Versions; +use OC\Files\Node\Node; use OCP\Files\File; use OCP\Files\FileInfo; use OCP\Files\NotFoundException; @@ -78,4 +79,11 @@ interface IVersionBackend { * @since 15.0.0 */ public function getVersionFile(IUser $user, FileInfo $sourceFile, $revision): File; + + /** + * Get the revision for a node + * + * @since 32.0.0 + */ + public function getRevision(Node $node): int; } diff --git a/apps/files_versions/lib/Versions/LegacyVersionsBackend.php b/apps/files_versions/lib/Versions/LegacyVersionsBackend.php index 92b326b6cd3..48d69d31629 100644 --- a/apps/files_versions/lib/Versions/LegacyVersionsBackend.php +++ b/apps/files_versions/lib/Versions/LegacyVersionsBackend.php @@ -212,6 +212,10 @@ class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend 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.'); @@ -225,7 +229,7 @@ class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend $this->versionsMapper->delete($versionEntity); } - public function createVersionEntity(File $file): void { + public function createVersionEntity(File $file): ?VersionEntity { $versionEntity = new VersionEntity(); $versionEntity->setFileId($file->getId()); $versionEntity->setTimestamp($file->getMTime()); @@ -237,8 +241,7 @@ class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend while ($tries < 5) { try { $this->versionsMapper->insert($versionEntity); - /* No errors, get out of the method */ - return; + return $versionEntity; } catch (\OCP\DB\Exception $e) { if (!in_array($e->getReason(), [ \OCP\DB\Exception::REASON_CONSTRAINT_VIOLATION, @@ -253,6 +256,8 @@ class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend $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 { @@ -367,16 +372,20 @@ class LegacyVersionsBackend implements IVersionBackend, IDeletableVersionBackend * @inheritdoc */ public function clearVersionsForFile(IUser $user, Node $source, Node $target): void { - $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $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()); } - $versions = Storage::getVersions($user->getUID(), $relativePath); - /** @var Folder versionFolder */ - $versionFolder = $this->rootFolder->get('admin/files_versions'); + $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(); } diff --git a/apps/files_versions/lib/Versions/VersionManager.php b/apps/files_versions/lib/Versions/VersionManager.php index b7dcd7d3a02..9acea8c6513 100644 --- a/apps/files_versions/lib/Versions/VersionManager.php +++ b/apps/files_versions/lib/Versions/VersionManager.php @@ -8,6 +8,10 @@ declare(strict_types=1); */ 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; @@ -18,11 +22,18 @@ 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] = []; @@ -51,8 +62,8 @@ class VersionManager implements IVersionManager, IDeletableVersionBackend, INeed foreach ($backends as $type => $backendsForType) { if ( - $storage->instanceOfStorage($type) && - ($foundType === '' || is_subclass_of($type, $foundType)) + $storage->instanceOfStorage($type) + && ($foundType === '' || is_subclass_of($type, $foundType)) ) { foreach ($backendsForType as $backend) { /** @var IVersionBackend $backend */ @@ -86,11 +97,7 @@ class VersionManager implements IVersionManager, IDeletableVersionBackend, INeed $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) { - \OC_Hook::emit('\OCP\Versions', 'rollback', [ - 'path' => $version->getVersionPath(), - 'revision' => $version->getRevisionId(), - 'node' => $version->getSourceFile(), - ]); + $this->dispatcher->dispatchTyped(new VersionRestoredEvent($version)); } return $result; } @@ -105,6 +112,11 @@ class VersionManager implements IVersionManager, IDeletableVersionBackend, INeed 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; } @@ -119,7 +131,16 @@ class VersionManager implements IVersionManager, IDeletableVersionBackend, INeed public function createVersionEntity(File $file): void { $backend = $this->getBackendForStorage($file->getStorage()); if ($backend instanceof INeedSyncVersionBackend) { - $backend->createVersionEntity($file); + $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; + } + } + } } } @@ -176,9 +197,9 @@ class VersionManager implements IVersionManager, IDeletableVersionBackend, INeed // 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 = \OC::$server->get(IRootFolder::class); + $root = Server::get(IRootFolder::class); $lockContext = new LockContext($root, ILock::TYPE_APP, $owner); - $lockManager = \OC::$server->get(ILockManager::class); + $lockManager = Server::get(ILockManager::class); $result = null; $lockManager->runInScope($lockContext, function () use ($callback, &$result): void { $result = $callback(); |