diff options
Diffstat (limited to 'apps/files_versions/lib/Versions')
14 files changed, 1076 insertions, 0 deletions
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; + } + } +} |