f7cloud_client/apps/groupfolders/lib/Versions/VersionsBackend.php
root 8b6a0139db f7cloud_client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 22:59:26 +00:00

443 lines
16 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2018 F7cloud GmbH and F7cloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\GroupFolders\Versions;
use OCA\DAV\Connector\Sabre\Exception\Forbidden;
use OCA\Files_Versions\Versions\IDeletableVersionBackend;
use OCA\Files_Versions\Versions\IMetadataVersion;
use OCA\Files_Versions\Versions\IMetadataVersionBackend;
use OCA\Files_Versions\Versions\INeedSyncVersionBackend;
use OCA\Files_Versions\Versions\IVersion;
use OCA\Files_Versions\Versions\IVersionBackend;
use OCA\Files_Versions\Versions\IVersionsImporterBackend;
use OCA\GroupFolders\Folder\FolderDefinition;
use OCA\GroupFolders\Folder\FolderDefinitionWithMappings;
use OCA\GroupFolders\Folder\FolderDefinitionWithPermissions;
use OCA\GroupFolders\Folder\FolderWithMappingsAndCache;
use OCA\GroupFolders\Mount\GroupFolderStorage;
use OCA\GroupFolders\Mount\GroupMountPoint;
use OCA\GroupFolders\Mount\MountProvider;
use OCP\AppFramework\Db\DoesNotExistException;
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\Mount\IMountManager;
use OCP\Files\Node;
use OCP\Files\NotFoundException;
use OCP\Files\Storage\IStorage;
use OCP\Files\Storage\IStorageFactory;
use OCP\IUser;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
class VersionsBackend implements IVersionBackend, IMetadataVersionBackend, IDeletableVersionBackend, INeedSyncVersionBackend, IVersionsImporterBackend {
public function __construct(
private readonly IRootFolder $rootFolder,
private readonly MountProvider $mountProvider,
private readonly LoggerInterface $logger,
private readonly GroupVersionsMapper $groupVersionsMapper,
private readonly IMimeTypeLoader $mimeTypeLoader,
private readonly IUserSession $userSession,
private readonly IMountManager $mountManager,
private readonly IStorageFactory $storageFactory,
) {
}
public function useBackendForStorage(IStorage $storage): bool {
return true;
}
private function getFolderForFile(FileInfo $file): FolderDefinition {
$storage = $file->getStorage();
$mountPoint = $file->getMountPoint();
// getting it from the mountpoint is more efficient
if ($mountPoint instanceof GroupMountPoint) {
return $mountPoint->getFolder();
} elseif ($storage->instanceOfStorage(GroupFolderStorage::class)) {
/** @var GroupFolderStorage $storage */
return $storage->getFolder();
}
throw new \LogicException('Team folder version backend called for non Team folder file');
}
public function getVersionFolderForFile(FileInfo $file): Folder {
$folder = $this->getFolderForFile($file);
$groupFoldersVersionsFolder = $this->getVersionsFolder($folder);
try {
/** @var Folder $versionsFolder */
$versionsFolder = $groupFoldersVersionsFolder->get((string)$file->getId());
return $versionsFolder;
} catch (NotFoundException) {
// The folder for the file's versions might not exists if no versions has been create yet.
return $groupFoldersVersionsFolder->newFolder((string)$file->getId());
}
}
/**
* @return GroupVersion[]
*/
public function getVersionsForFile(IUser $user, FileInfo $file): array {
$versionsFolder = $this->getVersionFolderForFile($file);
try {
$versions = $this->getVersionsForFileFromDB($file, $user);
// Early exit if we find any version in the database.
// Else we continue to populate the DB from what's on disk.
if (count($versions) > 0) {
return $versions;
}
// Insert the entry in the DB for the current version.
$versionEntity = new GroupVersionEntity();
$versionEntity->setFileId($file->getId());
$versionEntity->setTimestamp($file->getMTime());
$versionEntity->setSize($file->getSize());
$versionEntity->setMimetype($this->mimeTypeLoader->getId($file->getMimetype()));
$versionEntity->setDecodedMetadata([]);
$this->groupVersionsMapper->insert($versionEntity);
// Insert entries in the DB for existing versions.
$versionsOnFS = $versionsFolder->getDirectoryListing();
foreach ($versionsOnFS as $version) {
if ($version instanceof Folder) {
$this->logger->error('Found an unexpected subfolder inside the Team folder version folder.');
}
$versionEntity = new GroupVersionEntity();
$versionEntity->setFileId($file->getId());
// HACK: before this commit, versions were created with the current timestamp instead of the version's mtime.
// This means that the name of some versions is the exact mtime of the next version. This behavior is now fixed.
// To prevent occasional conflicts between the last version and the current one, we decrement the last version mtime.
$mtime = (int)$version->getName();
if ($mtime === $file->getMTime()) {
$versionEntity->setTimestamp($mtime - 1);
$version->move($version->getParent()->getPath() . '/' . ($mtime - 1));
} else {
$versionEntity->setTimestamp($mtime);
}
$versionEntity->setSize($version->getSize());
// Use the main file mimetype for this initialization as the original mimetype is unknown.
$versionEntity->setMimetype($this->mimeTypeLoader->getId($file->getMimetype()));
$versionEntity->setDecodedMetadata([]);
$this->groupVersionsMapper->insert($versionEntity);
}
return $this->getVersionsForFileFromDB($file, $user);
} catch (NotFoundException) {
return [];
}
}
/**
* @return GroupVersion[]
*/
private function getVersionsForFileFromDB(FileInfo $fileInfo, IUser $user): array {
$folder = $this->getFolderForFile($fileInfo);
$mountPoint = $fileInfo->getMountPoint();
if (!$mountPoint instanceof GroupMountPoint) {
return [];
}
$versionsFolder = $this->getVersionFolderForFile($fileInfo);
$versionEntities = $this->groupVersionsMapper->findAllVersionsForFileId($fileInfo->getId());
$mappedVersions = array_map(
function (GroupVersionEntity $versionEntity) use ($versionsFolder, $mountPoint, $fileInfo, $user, $folder): ?GroupVersion {
$currentVersion = false;
if ($fileInfo->getMtime() === $versionEntity->getTimestamp()) {
$currentVersion = true;
if ($fileInfo instanceof File) {
$versionFile = $fileInfo;
} else {
$versionFile = $this->rootFolder->get($fileInfo->getPath());
}
} else {
try {
$versionFile = $versionsFolder->get((string)$versionEntity->getTimestamp());
} catch (NotFoundException) {
// The version does not exists on disk anymore, so we can delete its entity in the DB.
// The reality is that the disk version might have been lost during a move operation between storages,
// and its not possible to recover it, so removing the entity makes sense.
$this->groupVersionsMapper->delete($versionEntity);
return null;
}
}
return new GroupVersion(
$versionEntity->getTimestamp(),
$versionEntity->getTimestamp(),
$fileInfo->getName(),
$versionEntity->getSize(),
$this->mimeTypeLoader->getMimetypeById($versionEntity->getMimetype()),
$mountPoint->getInternalPath($fileInfo->getPath()),
$fileInfo,
$this,
$user,
$versionEntity->getDecodedMetadata(),
$versionFile,
$folder,
$currentVersion,
);
},
$versionEntities,
);
// Filter out null values.
return array_filter($mappedVersions);
}
public function createVersion(IUser $user, FileInfo $file): void {
$versionsFolder = $this->getVersionFolderForFile($file);
$versionMount = $versionsFolder->getMountPoint();
$sourceMount = $file->getMountPoint();
$sourceCache = $sourceMount->getStorage()->getCache();
$revision = $file->getMtime();
$versionInternalPath = $versionsFolder->getInternalPath() . '/' . $revision;
$sourceInternalPath = $file->getInternalPath();
$versionMount->getStorage()->copyFromStorage($sourceMount->getStorage(), $sourceInternalPath, $versionInternalPath);
$versionMount->getStorage()->getCache()->copyFromCache($sourceCache, $sourceCache->get($sourceInternalPath), $versionInternalPath);
}
public function rollback(IVersion $version): void {
if (!($version instanceof GroupVersion)) {
throw new \LogicException('Trying to restore a version from a file not in a Team folder');
}
if (!$this->currentUserHasPermissions($version->getSourceFile(), \OCP\Constants::PERMISSION_UPDATE)) {
throw new Forbidden('You cannot restore this version because you do not have update permissions on the source file.');
}
$this->createVersion($version->getUser(), $version->getSourceFile());
/** @var GroupMountPoint $targetMount */
$targetMount = $version->getSourceFile()->getMountPoint();
$targetCache = $targetMount->getStorage()->getCache();
$versionMount = $version->getVersionFile()->getMountPoint();
$versionCache = $versionMount->getStorage()->getCache();
$targetInternalPath = $version->getSourceFile()->getInternalPath();
$versionInternalPath = $version->getVersionFile()->getInternalPath();
$targetMount->getStorage()->copyFromStorage($versionMount->getStorage(), $versionInternalPath, $targetInternalPath);
$targetCache->copyFromCache($versionCache, $versionCache->get($versionInternalPath), $targetInternalPath);
}
public function read(IVersion $version) {
if ($version instanceof GroupVersion) {
return $version->getVersionFile()->fopen('r');
} else {
return false;
}
}
public function getVersionFile(IUser $user, FileInfo $sourceFile, $revision): File {
$versionsFolder = $this->getVersionFolderForFile($sourceFile);
$file = $versionsFolder->get((string)$revision);
assert($file instanceof File);
return $file;
}
/**
* @param FolderWithMappingsAndCache $folder
* @return array<int, ?FileInfo>
*/
public function getAllVersionedFiles(FolderDefinitionWithMappings $folder): array {
$versionsFolder = $this->getVersionsFolder($folder);
$folderWithPermissions = FolderDefinitionWithPermissions::fromFolder($folder, $folder->rootCacheEntry, Constants::PERMISSION_ALL);
$mount = $this->mountProvider->getMount($folderWithPermissions, '/groupfolders/' . $folder->mountPoint);
if ($mount === null) {
$this->logger->error('Tried to get all the versioned files from a non existing mountpoint');
return [];
}
$this->mountManager->addMount($mount);
try {
$contents = $versionsFolder->getDirectoryListing();
} catch (NotFoundException) {
return [];
}
$fileIds = array_map(fn (Node $node): int => (int)$node->getName(), $contents);
$files = array_map(function (int $fileId) use ($mount): ?FileInfo {
$cacheEntry = $mount->getStorage()->getCache()->get($fileId);
if ($cacheEntry) {
return new \OC\Files\FileInfo($mount->getMountPoint() . '/' . $cacheEntry->getPath(), $mount->getStorage(), $cacheEntry->getPath(), $cacheEntry, $mount);
} else {
return null;
}
}, $fileIds);
return array_combine($fileIds, $files);
}
public function deleteAllVersionsForFile(FolderDefinition $folder, int $fileId): void {
$versionsFolder = $this->getVersionsFolder($folder);
try {
$versionsFolder->get((string)$fileId)->delete();
$this->groupVersionsMapper->deleteAllVersionsForFileId($fileId);
} catch (NotFoundException) {
}
}
public function getVersionsFolder(FolderDefinition $folder): Folder {
$mountPoint = '/dummy/files_versions/groupfolders/' . $folder->id;
$mount = $this->mountManager->find($mountPoint);
// check that $mount is the version mount, and not a mount for a parent folder
if ($mount->getMountPoint() !== $mountPoint) {
$versionMount = $this->mountProvider->getVersionsMount(
$folder,
$mountPoint,
$this->storageFactory,
);
$this->mountManager->addMount($versionMount);
}
return $this->rootFolder->get($mountPoint);
}
public function setMetadataValue(Node $node, int $revision, string $key, string $value): void {
if (!$this->currentUserHasPermissions($node, \OCP\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->groupVersionsMapper->findVersionForFileId($node->getId(), $revision);
$versionEntity->setMetadataValue($key, $value);
$this->groupVersionsMapper->update($versionEntity);
}
public function deleteVersion(IVersion $version): void {
if (!$this->currentUserHasPermissions($version->getSourceFile(), \OCP\Constants::PERMISSION_DELETE)) {
throw new Forbidden('You cannot delete this version because you do not have delete permissions on the source file.');
}
$sourceFile = $version->getSourceFile();
$mount = $sourceFile->getMountPoint();
if (!($mount instanceof GroupMountPoint)) {
return;
}
$folder = $this->getFolderForFile($sourceFile);
$versionsFolder = $this->getVersionsFolder($folder)->get((string)$sourceFile->getId());
/** @var Folder $versionsFolder */
$versionsFolder->get((string)$version->getRevisionId())->delete();
$versionEntity = $this->groupVersionsMapper->findVersionForFileId(
$version->getSourceFile()->getId(),
$version->getTimestamp(),
);
$this->groupVersionsMapper->delete($versionEntity);
}
public function createVersionEntity(File $file): void {
$fileId = $file->getId();
$timestamp = $file->getMTime();
try {
$this->groupVersionsMapper->findVersionForFileId($fileId, $timestamp);
} catch (DoesNotExistException) {
$versionEntity = new GroupVersionEntity();
$versionEntity->setFileId($fileId);
$versionEntity->setTimestamp($timestamp);
$versionEntity->setSize($file->getSize());
$versionEntity->setMimetype($this->mimeTypeLoader->getId($file->getMimetype()));
$versionEntity->setDecodedMetadata([]);
$this->groupVersionsMapper->insert($versionEntity);
}
}
public function updateVersionEntity(File $sourceFile, int $revision, array $properties): void {
$versionEntity = $this->groupVersionsMapper->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->groupVersionsMapper->update($versionEntity);
}
public function deleteVersionsEntity(File $file): void {
$this->groupVersionsMapper->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');
}
return ($sourceFile->getPermissions() & $permissions) === $permissions;
}
/**
* @inheritdoc
* @psalm-suppress MethodSignatureMismatch - The signature of the method is correct, but psalm somehow can't understand it
*/
public function importVersionsForFile(IUser $user, Node $source, Node $target, array $versions): void {
$mount = $target->getMountPoint();
if (!($mount instanceof GroupMountPoint)) {
return;
}
$versionsFolder = $this->getVersionFolderForFile($target);
foreach ($versions as $version) {
// 1. Move the file to the new location
if ($version->getTimestamp() !== $source->getMTime()) {
$backend = $version->getBackend();
$versionFile = $backend->getVersionFile($user, $source, $version->getRevisionId());
$versionsFolder->newFile($version->getRevisionId(), $versionFile->fopen('r'));
}
// 2. Create the entity in the database
$versionEntity = new GroupVersionEntity();
$versionEntity->setFileId($target->getId());
$versionEntity->setTimestamp($version->getTimestamp());
$versionEntity->setSize($version->getSize());
$versionEntity->setMimetype($this->mimeTypeLoader->getId($version->getMimetype()));
if ($version instanceof IMetadataVersion) {
$versionEntity->setDecodedMetadata($version->getMetadata());
}
$this->groupVersionsMapper->insert($versionEntity);
}
}
/**
* @inheritdoc
*/
public function clearVersionsForFile(IUser $user, Node $source, Node $target): void {
$folder = $this->getFolderForFile($source);
$this->deleteAllVersionsForFile($folder, $target->getId());
}
public function getRevision(\OC\Files\Node\Node $node): int {
return $node->getMTime();
}
}