aboutsummaryrefslogtreecommitdiffstats
path: root/apps/files_versions/lib/Versions
diff options
context:
space:
mode:
Diffstat (limited to 'apps/files_versions/lib/Versions')
-rw-r--r--apps/files_versions/lib/Versions/BackendNotFoundException.php10
-rw-r--r--apps/files_versions/lib/Versions/IDeletableVersionBackend.php21
-rw-r--r--apps/files_versions/lib/Versions/IMetadataVersion.php31
-rw-r--r--apps/files_versions/lib/Versions/IMetadataVersionBackend.php29
-rw-r--r--apps/files_versions/lib/Versions/INameableVersion.php23
-rw-r--r--apps/files_versions/lib/Versions/INameableVersionBackend.php22
-rw-r--r--apps/files_versions/lib/Versions/INeedSyncVersionBackend.php25
-rw-r--r--apps/files_versions/lib/Versions/IVersion.php85
-rw-r--r--apps/files_versions/lib/Versions/IVersionBackend.php89
-rw-r--r--apps/files_versions/lib/Versions/IVersionManager.php31
-rw-r--r--apps/files_versions/lib/Versions/IVersionsImporterBackend.php33
-rw-r--r--apps/files_versions/lib/Versions/LegacyVersionsBackend.php395
-rw-r--r--apps/files_versions/lib/Versions/Version.php72
-rw-r--r--apps/files_versions/lib/Versions/VersionManager.php210
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;
+ }
+ }
+}