diff --git a/appinfo/info.xml b/appinfo/info.xml index f85c804ff..2e531b5d1 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -10,7 +10,7 @@ Folders can be configured from *Group folders* in the admin settings. After a folder is created, the admin can give access to the folder to one or more groups, control their write/sharing permissions and assign a quota for the folder. Note: Encrypting the contents of group folders is currently not supported.]]> - 15.0.2 + 15.1.0 agpl Robin Appelman GroupFolders diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 0bed887ce..31eb86a54 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -49,6 +49,7 @@ use OCA\GroupFolders\Trash\TrashBackend; use OCA\GroupFolders\Trash\TrashManager; use OCA\GroupFolders\Versions\GroupVersionsExpireManager; +use OCA\GroupFolders\Versions\GroupVersionsMapper; use OCA\GroupFolders\Versions\VersionsBackend; use OCP\AppFramework\App; use OCP\AppFramework\Bootstrap\IBootContext; @@ -57,6 +58,8 @@ use OCP\AppFramework\IAppContainer; use OCP\AppFramework\Utility\ITimeFactory; use OCP\Files\Config\IMountProviderCollection; +use OCP\Files\IMimeTypeLoader; +use OCP\Files\IRootFolder; use OCP\ICacheFactory; use OCP\IDBConnection; use OCP\IGroup; @@ -133,10 +136,13 @@ public function register(IRegistrationContext $context): void { $context->registerService(VersionsBackend::class, function (IAppContainer $c): VersionsBackend { return new VersionsBackend( + $c->get(IRootFolder::class), $c->get('GroupAppFolder'), $c->get(MountProvider::class), $c->get(ITimeFactory::class), - $c->get(LoggerInterface::class) + $c->get(LoggerInterface::class), + $c->get(GroupVersionsMapper::class), + $c->get(IMimeTypeLoader::class), ); }); diff --git a/lib/Helper/LazyFolder.php b/lib/Helper/LazyFolder.php index 13ae2364f..b0d50dff7 100644 --- a/lib/Helper/LazyFolder.php +++ b/lib/Helper/LazyFolder.php @@ -262,4 +262,8 @@ public function changeLock($targetType) { public function unlock($type) { return $this->__call(__FUNCTION__, func_get_args()); } + + public function getParentId(): int { + return $this->__call(__FUNCTION__, func_get_args()); + } } diff --git a/lib/Migration/Version16000Date20230821085801.php b/lib/Migration/Version16000Date20230821085801.php new file mode 100644 index 000000000..e34346bff --- /dev/null +++ b/lib/Migration/Version16000Date20230821085801.php @@ -0,0 +1,86 @@ + + * + * @author Louis Chmn + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\GroupFolders\Migration; + +use Closure; +use OCP\DB\ISchemaWrapper; +use OCP\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +/** + * Auto-generated migration step: Please modify to your needs! + */ +class Version16000Date20230821085801 extends SimpleMigrationStep { + + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + + if ($schema->hasTable("group_folders_versions")) { + return null; + } + + $table = $schema->createTable("group_folders_versions"); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('file_id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('timestamp', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('size', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('mimetype', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('metadata', Types::TEXT, [ + 'notnull' => true, + 'default' => '{}', + ]); + + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['file_id', 'timestamp'], 'gf_versions_uniq_index'); + + return $schema; + } +} diff --git a/lib/Versions/GroupVersion.php b/lib/Versions/GroupVersion.php index 6e40fd780..20fda8d72 100644 --- a/lib/Versions/GroupVersion.php +++ b/lib/Versions/GroupVersion.php @@ -30,11 +30,6 @@ use OCP\IUser; class GroupVersion extends Version { - /** @var File */ - private $versionFile; - - /** @var int */ - private $folderId; public function __construct( int $timestamp, @@ -46,12 +41,11 @@ public function __construct( FileInfo $sourceFileInfo, IVersionBackend $backend, IUser $user, - File $versionFile, - int $folderId + string $label, + private File $versionFile, + private int $folderId, ) { - parent::__construct($timestamp, $revisionId, $name, $size, $mimetype, $path, $sourceFileInfo, $backend, $user); - $this->versionFile = $versionFile; - $this->folderId = $folderId; + parent::__construct($timestamp, $revisionId, $name, $size, $mimetype, $path, $sourceFileInfo, $backend, $user, $label); } public function getVersionFile(): File { diff --git a/lib/Versions/GroupVersionEntity.php b/lib/Versions/GroupVersionEntity.php new file mode 100644 index 000000000..6284e18ba --- /dev/null +++ b/lib/Versions/GroupVersionEntity.php @@ -0,0 +1,92 @@ + + * + * @author Louis Chmn + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\GroupFolders\Versions; + +use JsonSerializable; + +use OCP\AppFramework\Db\Entity; +use OCP\DB\Types; + +/** + * @method int getFileId() + * @method void setFileId(int $fileId) + * @method int getTimestamp() + * @method void setTimestamp(int $timestamp) + * @method int|float getSize() + * @method void setSize(int|float $size) + * @method int getMimetype() + * @method void setMimetype(int $mimetype) + * @method string getMetadata() + * @method void setMetadata(string $metadata) + */ +class GroupVersionEntity extends Entity implements JsonSerializable { + protected ?int $fileId = null; + protected ?int $timestamp = null; + protected ?int $size = null; + protected ?int $mimetype = null; + protected ?string $metadata = null; + + public function __construct() { + $this->addType('id', Types::INTEGER); + $this->addType('file_id', Types::INTEGER); + $this->addType('timestamp', Types::INTEGER); + $this->addType('size', Types::INTEGER); + $this->addType('mimetype', Types::INTEGER); + $this->addType('metadata', Types::STRING); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'file_id' => $this->fileId, + 'timestamp' => $this->timestamp, + 'size' => $this->size, + 'mimetype' => $this->mimetype, + 'metadata' => $this->metadata, + ]; + } + + public function getLabel(): string { + return $this->getDecodedMetadata()['label'] ?? ''; + } + + public function setLabel(string $label): void { + $metadata = $this->getDecodedMetadata(); + $metadata['label'] = $label; + $this->setDecodedMetadata($metadata); + $this->markFieldUpdated('metadata'); + } + + public function getDecodedMetadata(): array { + return json_decode($this->metadata ?? '', true, 512, JSON_THROW_ON_ERROR) ?? []; + } + + public function setDecodedMetadata(array $value): void { + $this->metadata = json_encode($value, JSON_THROW_ON_ERROR); + $this->markFieldUpdated('metadata'); + } +} diff --git a/lib/Versions/GroupVersionsMapper.php b/lib/Versions/GroupVersionsMapper.php new file mode 100644 index 000000000..e7d932051 --- /dev/null +++ b/lib/Versions/GroupVersionsMapper.php @@ -0,0 +1,86 @@ + + * + * @author Louis Chmn + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +namespace OCA\GroupFolders\Versions; + +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * @extends QBMapper + */ +class GroupVersionsMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'group_folders_versions', GroupVersionEntity::class); + } + + /** + * @return GroupVersionEntity[] + */ + public function findAllVersionsForFileId(int $fileId): array { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))); + + return $this->findEntities($qb); + } + + /** + * @return GroupVersionEntity + */ + public function findCurrentVersionForFileId(int $fileId): GroupVersionEntity { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))) + ->orderBy('timestamp', 'DESC') + ->setMaxResults(1); + + return $this->findEntity($qb); + } + + public function findVersionForFileId(int $fileId, int $timestamp): GroupVersionEntity { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))) + ->andWhere($qb->expr()->eq('timestamp', $qb->createNamedParameter($timestamp))); + + return $this->findEntity($qb); + } + + public function deleteAllVersionsForFileId(int $fileId): int { + $qb = $this->db->getQueryBuilder(); + + return $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('file_id', $qb->createNamedParameter($fileId))) + ->executeStatement(); + } +} diff --git a/lib/Versions/VersionsBackend.php b/lib/Versions/VersionsBackend.php index 6a62ea13f..7fb53aa79 100644 --- a/lib/Versions/VersionsBackend.php +++ b/lib/Versions/VersionsBackend.php @@ -23,6 +23,9 @@ namespace OCA\GroupFolders\Versions; +use OCA\Files_Versions\Versions\IDeletableVersionBackend; +use OCA\Files_Versions\Versions\INameableVersionBackend; +use OCA\Files_Versions\Versions\INeedSyncVersionBackend; use OCA\Files_Versions\Versions\IVersion; use OCA\Files_Versions\Versions\IVersionBackend; use OCA\GroupFolders\Mount\GroupMountPoint; @@ -31,6 +34,8 @@ 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\IStorage; @@ -38,60 +43,113 @@ use OCP\Constants; use Psr\Log\LoggerInterface; -class VersionsBackend implements IVersionBackend { - private Folder $appFolder; - private MountProvider $mountProvider; - private ITimeFactory $timeFactory; - private LoggerInterface $logger; - - public function __construct(Folder $appFolder, MountProvider $mountProvider, ITimeFactory $timeFactory, LoggerInterface $logger) { - $this->appFolder = $appFolder; - $this->mountProvider = $mountProvider; - $this->timeFactory = $timeFactory; - $this->logger = $logger; +class VersionsBackend implements IVersionBackend, INameableVersionBackend, IDeletableVersionBackend, INeedSyncVersionBackend { + public function __construct( + private IRootFolder $rootFolder, + private Folder $appFolder, + private MountProvider $mountProvider, + private ITimeFactory $timeFactory, + private LoggerInterface $logger, + private GroupVersionsMapper $groupVersionsMapper, + private IMimeTypeLoader $mimeTypeLoader, + ) { } public function useBackendForStorage(IStorage $storage): bool { return true; } - public function getVersionsForFile(IUser $user, FileInfo $file): array { - $mount = $file->getMountPoint(); - if ($mount instanceof GroupMountPoint) { - try { - $folderId = $mount->getFolderId(); - /** @var Folder $versionsFolder */ - $versionsFolder = $this->getVersionsFolder($mount->getFolderId())->get((string)$file->getId()); - $versions = array_map(function (Node $versionFile) use ($file, $user, $folderId): GroupVersion { - if ($versionFile instanceof Folder) { - $this->logger->error('Found an unexpected subfolder inside the groupfolder version folder.'); - } - return new GroupVersion( - (int)$versionFile->getName(), - (int)$versionFile->getName(), - $file->getName(), - $versionFile->getSize(), - $versionFile->getMimetype(), - $versionFile->getPath(), - $file, - $this, - $user, - $versionFile, - $folderId - ); - }, $versionsFolder->getDirectoryListing()); - usort($versions, function (GroupVersion $v1, GroupVersion $v2): int { - return $v2->getTimestamp() <=> $v1->getTimestamp(); - }); + public function getVersionsForFile(IUser $user, FileInfo $fileInfo): array { + $mount = $fileInfo->getMountPoint(); + if (!($mount instanceof GroupMountPoint)) { + return []; + } + + try { + $folderId = $mount->getFolderId(); + /** @var Folder $versionsFolder */ + $versionsFolder = $this->getVersionsFolder($mount->getFolderId())->get((string)$fileInfo->getId()); + + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + $nodes = $userFolder->getById($fileInfo->getId()); + $file = array_pop($nodes); + + $versions = $this->getVersionsForFileFromDB($fileInfo, $user, $folderId); + + // 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; - } catch (NotFoundException $e) { - return []; } - } else { + + // 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 groupfolder 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, $folderId); + } catch (NotFoundException $e) { return []; } } + /** + * @return IVersion[] + */ + private function getVersionsForFileFromDB(FileInfo $file, IUser $user, int $folderId): array { + $userFolder = $this->rootFolder->getUserFolder($user->getUID()); + /** @var Folder $versionsFolder */ + $versionsFolder = $this->getVersionsFolder($folderId)->get((string)$file->getId()); + + return array_map( + fn (GroupVersionEntity $versionEntity) => new GroupVersion( + $versionEntity->getTimestamp(), + $versionEntity->getTimestamp(), + $file->getName(), + $versionEntity->getSize(), + $this->mimeTypeLoader->getMimetypeById($versionEntity->getMimetype()), + $userFolder->getRelativePath($file->getPath()), + $file, + $this, + $user, + $versionEntity->getLabel(), + $file->getMtime() === $versionEntity->getTimestamp() ? $file : $versionsFolder->get((string)$versionEntity->getTimestamp()), + $folderId, + ), + $this->groupVersionsMapper->findAllVersionsForFileId($file->getId()) + ); + } + /** * @return void */ @@ -111,7 +169,7 @@ public function createVersion(IUser $user, FileInfo $file) { $versionMount = $versionFolder->getMountPoint(); $sourceMount = $file->getMountPoint(); $sourceCache = $sourceMount->getStorage()->getCache(); - $revision = $this->timeFactory->getTime(); + $revision = $file->getMtime(); $versionInternalPath = $versionFolder->getInternalPath() . '/' . $revision; $sourceInternalPath = $file->getInternalPath(); @@ -198,6 +256,7 @@ public function deleteAllVersionsForFile(int $folderId, int $fileId): void { $versionsFolder = $this->getVersionsFolder($folderId); try { $versionsFolder->get((string)$fileId)->delete(); + $this->groupVersionsMapper->deleteAllVersionsForFileId($fileId); } catch (NotFoundException $e) { } } @@ -211,4 +270,67 @@ private function getVersionsFolder(int $folderId): Folder { return $trashRoot->newFolder((string)$folderId); } } + + public function setVersionLabel(IVersion $version, string $label): void { + $versionEntity = $this->groupVersionsMapper->findVersionForFileId( + $version->getSourceFile()->getId(), + $version->getTimestamp(), + ); + if (trim($label) === '') { + $label = null; + } + $versionEntity->setLabel($label ?? ''); + $this->groupVersionsMapper->update($versionEntity); + } + + public function deleteVersion(IVersion $version): void { + $sourceFile = $version->getSourceFile(); + $mount = $sourceFile->getMountPoint(); + + if (!($mount instanceof GroupMountPoint)) { + return; + } + + $versionsFolder = $this->getVersionsFolder($mount->getFolderId())->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 { + $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); + } + + 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()); + } } diff --git a/tests/stub.phpstub b/tests/stub.phpstub index 5b8d85955..866ac0d3e 100644 --- a/tests/stub.phpstub +++ b/tests/stub.phpstub @@ -260,6 +260,20 @@ namespace OCA\Files_Versions\Versions { public function getVersionFile(IUser $user, FileInfo $sourceFile, $revision): ?File; } + interface INameableVersionBackend { + public function setVersionLabel(IVersion $version, string $label): void; + } + + interface IDeletableVersionBackend { + public function deleteVersion(IVersion $version): void; + } + + interface INeedSyncVersionBackend { + public function createVersionEntity(File $file): void; + public function updateVersionEntity(File $sourceFile, int $revision, array $properties): void; + public function deleteVersionsEntity(File $file): void; + } + interface IVersion { public function getBackend(): IVersionBackend; @@ -293,7 +307,8 @@ namespace OCA\Files_Versions\Versions { string $path, FileInfo $sourceFileInfo, IVersionBackend $backend, - IUser $user + IUser $user, + string $label = '' ) { }