Merge pull request #35160 from nextcloud/artonge/feat/version_naming_backend

Allow to name a version
pull/36332/head
Louis 1 year ago committed by GitHub
commit 2f3007205d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -92,6 +92,8 @@ class StorageTest extends \Test\TestCase {
parent::setUp();
\OC_Hook::clear();
\OC::$server->boot();
// register trashbin hooks
$trashbinApp = new Application();
$trashbinApp->boot($this->createMock(IBootContext::class));
@ -224,8 +226,6 @@ class StorageTest extends \Test\TestCase {
* Test that deleted versions properly land in the trashbin.
*/
public function testDeleteVersionsOfFile() {
\OCA\Files_Versions\Hooks::connectHooks();
// trigger a version (multiple would not work because of the expire logic)
$this->userView->file_put_contents('test.txt', 'v1');
@ -253,8 +253,6 @@ class StorageTest extends \Test\TestCase {
* Test that deleted versions properly land in the trashbin.
*/
public function testDeleteVersionsOfFolder() {
\OCA\Files_Versions\Hooks::connectHooks();
// trigger a version (multiple would not work because of the expire logic)
$this->userView->file_put_contents('folder/inside.txt', 'v1');
@ -288,8 +286,6 @@ class StorageTest extends \Test\TestCase {
* Test that deleted versions properly land in the trashbin when deleting as share recipient.
*/
public function testDeleteVersionsOfFileAsRecipient() {
\OCA\Files_Versions\Hooks::connectHooks();
$this->userView->mkdir('share');
// trigger a version (multiple would not work because of the expire logic)
$this->userView->file_put_contents('share/test.txt', 'v1');
@ -341,8 +337,6 @@ class StorageTest extends \Test\TestCase {
* Test that deleted versions properly land in the trashbin when deleting as share recipient.
*/
public function testDeleteVersionsOfFolderAsRecipient() {
\OCA\Files_Versions\Hooks::connectHooks();
$this->userView->mkdir('share');
$this->userView->mkdir('share/folder');
// trigger a version (multiple would not work because of the expire logic)
@ -410,8 +404,6 @@ class StorageTest extends \Test\TestCase {
* unlink() which should NOT trigger the version deletion logic.
*/
public function testKeepFileAndVersionsWhenMovingFileBetweenStorages() {
\OCA\Files_Versions\Hooks::connectHooks();
$storage2 = new Temporary([]);
\OC\Files\Filesystem::mount($storage2, [], $this->user . '/files/substorage');
@ -451,8 +443,6 @@ class StorageTest extends \Test\TestCase {
* unlink() which should NOT trigger the version deletion logic.
*/
public function testKeepFileAndVersionsWhenMovingFolderBetweenStorages() {
\OCA\Files_Versions\Hooks::connectHooks();
$storage2 = new Temporary([]);
\OC\Files\Filesystem::mount($storage2, [], $this->user . '/files/substorage');

@ -8,7 +8,7 @@
This application automatically maintains older versions of files that are changed. When enabled, a hidden versions folder is provisioned in every user's directory and is used to store old file versions. A user can revert to an older version through the web interface at any time, with the replaced file becoming a version. The app automatically manages the versions folder to ensure the user does not run out of Quota because of versions.
In addition to the expiry of versions, the versions app makes certain never to use more than 50% of the user's currently available free space. If stored versions exceed this limit, the app will delete the oldest versions first until it meets this limit. More information is available in the Versions documentation.
</description>
<version>1.19.0</version>
<version>1.19.1</version>
<licence>agpl</licence>
<author>Frank Karlitschek</author>
<author>Bjoern Schiessle</author>

@ -14,11 +14,14 @@ return array(
'OCA\\Files_Versions\\Command\\Expire' => $baseDir . '/../lib/Command/Expire.php',
'OCA\\Files_Versions\\Command\\ExpireVersions' => $baseDir . '/../lib/Command/ExpireVersions.php',
'OCA\\Files_Versions\\Controller\\PreviewController' => $baseDir . '/../lib/Controller/PreviewController.php',
'OCA\\Files_Versions\\Db\\VersionEntity' => $baseDir . '/../lib/Db/VersionEntity.php',
'OCA\\Files_Versions\\Db\\VersionsMapper' => $baseDir . '/../lib/Db/VersionsMapper.php',
'OCA\\Files_Versions\\Events\\CreateVersionEvent' => $baseDir . '/../lib/Events/CreateVersionEvent.php',
'OCA\\Files_Versions\\Expiration' => $baseDir . '/../lib/Expiration.php',
'OCA\\Files_Versions\\Hooks' => $baseDir . '/../lib/Hooks.php',
'OCA\\Files_Versions\\Listener\\FileEventsListener' => $baseDir . '/../lib/Listener/FileEventsListener.php',
'OCA\\Files_Versions\\Listener\\LoadAdditionalListener' => $baseDir . '/../lib/Listener/LoadAdditionalListener.php',
'OCA\\Files_Versions\\Listener\\LoadSidebarListener' => $baseDir . '/../lib/Listener/LoadSidebarListener.php',
'OCA\\Files_Versions\\Migration\\Version1020Date20221114144058' => $baseDir . '/../lib/Migration/Version1020Date20221114144058.php',
'OCA\\Files_Versions\\Sabre\\Plugin' => $baseDir . '/../lib/Sabre/Plugin.php',
'OCA\\Files_Versions\\Sabre\\RestoreFolder' => $baseDir . '/../lib/Sabre/RestoreFolder.php',
'OCA\\Files_Versions\\Sabre\\RootCollection' => $baseDir . '/../lib/Sabre/RootCollection.php',
@ -28,6 +31,9 @@ return array(
'OCA\\Files_Versions\\Sabre\\VersionRoot' => $baseDir . '/../lib/Sabre/VersionRoot.php',
'OCA\\Files_Versions\\Storage' => $baseDir . '/../lib/Storage.php',
'OCA\\Files_Versions\\Versions\\BackendNotFoundException' => $baseDir . '/../lib/Versions/BackendNotFoundException.php',
'OCA\\Files_Versions\\Versions\\IDeletableVersionBackend' => $baseDir . '/../lib/Versions/IDeletableVersionBackend.php',
'OCA\\Files_Versions\\Versions\\INameableVersion' => $baseDir . '/../lib/Versions/INameableVersion.php',
'OCA\\Files_Versions\\Versions\\INameableVersionBackend' => $baseDir . '/../lib/Versions/INameableVersionBackend.php',
'OCA\\Files_Versions\\Versions\\IVersion' => $baseDir . '/../lib/Versions/IVersion.php',
'OCA\\Files_Versions\\Versions\\IVersionBackend' => $baseDir . '/../lib/Versions/IVersionBackend.php',
'OCA\\Files_Versions\\Versions\\IVersionManager' => $baseDir . '/../lib/Versions/IVersionManager.php',

@ -29,11 +29,14 @@ class ComposerStaticInitFiles_Versions
'OCA\\Files_Versions\\Command\\Expire' => __DIR__ . '/..' . '/../lib/Command/Expire.php',
'OCA\\Files_Versions\\Command\\ExpireVersions' => __DIR__ . '/..' . '/../lib/Command/ExpireVersions.php',
'OCA\\Files_Versions\\Controller\\PreviewController' => __DIR__ . '/..' . '/../lib/Controller/PreviewController.php',
'OCA\\Files_Versions\\Db\\VersionEntity' => __DIR__ . '/..' . '/../lib/Db/VersionEntity.php',
'OCA\\Files_Versions\\Db\\VersionsMapper' => __DIR__ . '/..' . '/../lib/Db/VersionsMapper.php',
'OCA\\Files_Versions\\Events\\CreateVersionEvent' => __DIR__ . '/..' . '/../lib/Events/CreateVersionEvent.php',
'OCA\\Files_Versions\\Expiration' => __DIR__ . '/..' . '/../lib/Expiration.php',
'OCA\\Files_Versions\\Hooks' => __DIR__ . '/..' . '/../lib/Hooks.php',
'OCA\\Files_Versions\\Listener\\FileEventsListener' => __DIR__ . '/..' . '/../lib/Listener/FileEventsListener.php',
'OCA\\Files_Versions\\Listener\\LoadAdditionalListener' => __DIR__ . '/..' . '/../lib/Listener/LoadAdditionalListener.php',
'OCA\\Files_Versions\\Listener\\LoadSidebarListener' => __DIR__ . '/..' . '/../lib/Listener/LoadSidebarListener.php',
'OCA\\Files_Versions\\Migration\\Version1020Date20221114144058' => __DIR__ . '/..' . '/../lib/Migration/Version1020Date20221114144058.php',
'OCA\\Files_Versions\\Sabre\\Plugin' => __DIR__ . '/..' . '/../lib/Sabre/Plugin.php',
'OCA\\Files_Versions\\Sabre\\RestoreFolder' => __DIR__ . '/..' . '/../lib/Sabre/RestoreFolder.php',
'OCA\\Files_Versions\\Sabre\\RootCollection' => __DIR__ . '/..' . '/../lib/Sabre/RootCollection.php',
@ -43,6 +46,9 @@ class ComposerStaticInitFiles_Versions
'OCA\\Files_Versions\\Sabre\\VersionRoot' => __DIR__ . '/..' . '/../lib/Sabre/VersionRoot.php',
'OCA\\Files_Versions\\Storage' => __DIR__ . '/..' . '/../lib/Storage.php',
'OCA\\Files_Versions\\Versions\\BackendNotFoundException' => __DIR__ . '/..' . '/../lib/Versions/BackendNotFoundException.php',
'OCA\\Files_Versions\\Versions\\IDeletableVersionBackend' => __DIR__ . '/..' . '/../lib/Versions/IDeletableVersionBackend.php',
'OCA\\Files_Versions\\Versions\\INameableVersion' => __DIR__ . '/..' . '/../lib/Versions/INameableVersion.php',
'OCA\\Files_Versions\\Versions\\INameableVersionBackend' => __DIR__ . '/..' . '/../lib/Versions/INameableVersionBackend.php',
'OCA\\Files_Versions\\Versions\\IVersion' => __DIR__ . '/..' . '/../lib/Versions/IVersion.php',
'OCA\\Files_Versions\\Versions\\IVersionBackend' => __DIR__ . '/..' . '/../lib/Versions/IVersionBackend.php',
'OCA\\Files_Versions\\Versions\\IVersionManager' => __DIR__ . '/..' . '/../lib/Versions/IVersionManager.php',

@ -33,7 +33,7 @@ use OCA\DAV\Connector\Sabre\Principal;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCA\Files\Event\LoadSidebar;
use OCA\Files_Versions\Capabilities;
use OCA\Files_Versions\Hooks;
use OCA\Files_Versions\Listener\FileEventsListener;
use OCA\Files_Versions\Listener\LoadAdditionalListener;
use OCA\Files_Versions\Listener\LoadSidebarListener;
use OCA\Files_Versions\Versions\IVersionManager;
@ -44,6 +44,17 @@ use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Files\Events\Node\BeforeNodeCopiedEvent;
use OCP\Files\Events\Node\BeforeNodeDeletedEvent;
use OCP\Files\Events\Node\BeforeNodeRenamedEvent;
use OCP\Files\Events\Node\BeforeNodeTouchedEvent;
use OCP\Files\Events\Node\NodeCopiedEvent;
use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\Files\Events\Node\NodeRenamedEvent;
use OCP\Files\Events\Node\BeforeNodeWrittenEvent;
use OCP\Files\Events\Node\NodeCreatedEvent;
use OCP\Files\Events\Node\NodeTouchedEvent;
use OCP\Files\Events\Node\NodeWrittenEvent;
use OCP\IConfig;
use OCP\IGroupManager;
use OCP\IServerContainer;
@ -96,15 +107,22 @@ class Application extends App implements IBootstrap {
*/
$context->registerEventListener(LoadAdditionalScriptsEvent::class, LoadAdditionalListener::class);
$context->registerEventListener(LoadSidebar::class, LoadSidebarListener::class);
$context->registerEventListener(NodeCreatedEvent::class, FileEventsListener::class);
$context->registerEventListener(BeforeNodeTouchedEvent::class, FileEventsListener::class);
$context->registerEventListener(NodeTouchedEvent::class, FileEventsListener::class);
$context->registerEventListener(BeforeNodeWrittenEvent::class, FileEventsListener::class);
$context->registerEventListener(NodeWrittenEvent::class, FileEventsListener::class);
$context->registerEventListener(BeforeNodeDeletedEvent::class, FileEventsListener::class);
$context->registerEventListener(NodeDeletedEvent::class, FileEventsListener::class);
$context->registerEventListener(NodeRenamedEvent::class, FileEventsListener::class);
$context->registerEventListener(NodeCopiedEvent::class, FileEventsListener::class);
$context->registerEventListener(BeforeNodeRenamedEvent::class, FileEventsListener::class);
$context->registerEventListener(BeforeNodeCopiedEvent::class, FileEventsListener::class);
}
public function boot(IBootContext $context): void {
$context->injectFn(\Closure::fromCallable([$this, 'registerVersionBackends']));
/**
* Register hooks
*/
Hooks::connectHooks();
}
public function registerVersionBackends(ContainerInterface $container, IAppManager $appManager, LoggerInterface $logger): void {

@ -24,19 +24,35 @@
*/
namespace OCA\Files_Versions;
use OCP\App\IAppManager;
use OCP\Capabilities\ICapability;
use OCP\IConfig;
class Capabilities implements ICapability {
private IConfig $config;
private IAppManager $appManager;
public function __construct(
IConfig $config,
IAppManager $appManager
) {
$this->config = $config;
$this->appManager = $appManager;
}
/**
* Return this classes capabilities
*
* @return array
*/
public function getCapabilities() {
$groupFolderOrS3VersioningInstalled = $this->appManager->isInstalled('groupfolders') || $this->appManager->isInstalled('groupfolders');
return [
'files' => [
'versioning' => true
'versioning' => true,
'version_labeling' => !$groupFolderOrS3VersioningInstalled && $this->config->getSystemValueBool('enable_version_labeling', true),
'version_deletion' => !$groupFolderOrS3VersioningInstalled && $this->config->getSystemValueBool('enable_version_deletion', true),
]
];
}

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Files_Versions\Db;
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 getSize()
* @method void setSize(int $size)
* @method int getMimetype()
* @method void setMimetype(int $mimetype)
* @method array|null getMetadata()
* @method void setMetadata(array $metadata)
*/
class VersionEntity extends Entity implements JsonSerializable {
protected ?int $fileId = null;
protected ?int $timestamp = null;
protected ?int $size = null;
protected ?int $mimetype = null;
protected ?array $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::JSON);
}
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->metadata['label'] ?? '';
}
public function setLabel(string $label): void {
$this->metadata['label'] = $label;
$this->markFieldUpdated('metadata');
}
}

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Files_Versions\Db;
use OCA\Files_Versions\Db\VersionEntity;
use OCP\IDBConnection;
use OCP\AppFramework\Db\QBMapper;
use OCP\DB\IResult;
/**
* @extends QBMapper<VersionEntity>
*/
class VersionsMapper extends QBMapper {
public function __construct(IDBConnection $db) {
parent::__construct($db, 'files_versions', VersionEntity::class);
}
/**
* @return VersionEntity[]
*/
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 VersionEntity
*/
public function findCurrentVersionForFileId(int $fileId): VersionEntity {
$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): VersionEntity {
$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();
}
}

@ -1,142 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2016, ownCloud, Inc.
*
* @author Bart Visscher <bartv@thisnet.nl>
* @author Björn Schießle <bjoern@schiessle.org>
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Jörn Friedrich Dreyer <jfd@butonic.de>
* @author Morris Jobke <hey@morrisjobke.de>
* @author Robin Appelman <robin@icewind.nl>
* @author Robin McCorkell <robin@mccorkell.me.uk>
* @author Sam Tuke <mail@samtuke.com>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* 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, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\Files_Versions;
use OC\Files\Filesystem;
use OC\Files\Mount\MoveableMount;
use OC\Files\View;
use OCP\Util;
class Hooks {
public static function connectHooks() {
// Listen to write signals
Util::connectHook('OC_Filesystem', 'write', Hooks::class, 'write_hook');
// Listen to delete and rename signals
Util::connectHook('OC_Filesystem', 'post_delete', Hooks::class, 'remove_hook');
Util::connectHook('OC_Filesystem', 'delete', Hooks::class, 'pre_remove_hook');
Util::connectHook('OC_Filesystem', 'post_rename', Hooks::class, 'rename_hook');
Util::connectHook('OC_Filesystem', 'post_copy', Hooks::class, 'copy_hook');
Util::connectHook('OC_Filesystem', 'rename', Hooks::class, 'pre_renameOrCopy_hook');
Util::connectHook('OC_Filesystem', 'copy', Hooks::class, 'pre_renameOrCopy_hook');
}
/**
* listen to write event.
*/
public static function write_hook(array $params): void {
$path = $params[Filesystem::signal_param_path];
if ($path !== '') {
Storage::store($path);
}
}
/**
* Erase versions of deleted file
* @param array $params
*
* This function is connected to the delete signal of OC_Filesystem
* cleanup the versions directory if the actual file gets deleted
*/
public static function remove_hook(array $params): void {
$path = $params[Filesystem::signal_param_path];
if ($path !== '') {
Storage::delete($path);
}
}
/**
* mark file as "deleted" so that we can clean up the versions if the file is gone
* @param array $params
*/
public static function pre_remove_hook(array $params): void {
$path = $params[Filesystem::signal_param_path];
if ($path !== '') {
Storage::markDeletedFile($path);
}
}
/**
* rename/move versions of renamed/moved files
* @param array $params array with oldpath and newpath
*
* This function is connected to the rename signal of OC_Filesystem and adjust the name and location
* of the stored versions along the actual file
*/
public static function rename_hook(array $params): void {
$oldpath = $params['oldpath'];
$newpath = $params['newpath'];
if ($oldpath !== '' && $newpath !== '') {
Storage::renameOrCopy($oldpath, $newpath, 'rename');
}
}
/**
* copy versions of copied files
* @param array $params array with oldpath and newpath
*
* This function is connected to the copy signal of OC_Filesystem and copies the
* the stored versions to the new location
*/
public static function copy_hook(array $params): void {
$oldpath = $params['oldpath'];
$newpath = $params['newpath'];
if ($oldpath !== '' && $newpath !== '') {
Storage::renameOrCopy($oldpath, $newpath, 'copy');
}
}
/**
* Remember owner and the owner path of the source file.
* If the file already exists, then it was a upload of a existing file
* over the web interface and we call Storage::store() directly
*
* @param array $params array with oldpath and newpath
*
*/
public static function pre_renameOrCopy_hook(array $params): void {
// if we rename a movable mount point, then the versions don't have
// to be renamed
$absOldPath = Filesystem::normalizePath('/' . \OC_User::getUser() . '/files' . $params['oldpath']);
$manager = Filesystem::getMountManager();
$mount = $manager->find($absOldPath);
$internalPath = $mount->getInternalPath($absOldPath);
if ($internalPath === '' and $mount instanceof MoveableMount) {
return;
}
$view = new View(\OC_User::getUser() . '/files');
if ($view->file_exists($params['newpath'])) {
Storage::store($params['newpath']);
} else {
Storage::setSourcePathAndUser($params['oldpath']);
}
}
}

@ -0,0 +1,347 @@
<?php
/**
* @copyright Copyright (c) 2016, ownCloud, Inc.
*
* @author Bart Visscher <bartv@thisnet.nl>
* @author Björn Schießle <bjoern@schiessle.org>
* @author Christoph Wurst <christoph@winzerhof-wurst.at>
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Jörn Friedrich Dreyer <jfd@butonic.de>
* @author Morris Jobke <hey@morrisjobke.de>
* @author Robin Appelman <robin@icewind.nl>
* @author Robin McCorkell <robin@mccorkell.me.uk>
* @author Sam Tuke <mail@samtuke.com>
* @author Louis Chmn <louis@chmn.me>
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* 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, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/
namespace OCA\Files_Versions\Listener;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use OC\DB\Exceptions\DbalException;
use OC\Files\Filesystem;
use OC\Files\Mount\MoveableMount;
use OC\Files\Node\NonExistingFile;
use OC\Files\View;
use OCA\Files_Versions\Db\VersionEntity;
use OCA\Files_Versions\Db\VersionsMapper;
use OCA\Files_Versions\Storage;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\BeforeNodeCopiedEvent;
use OCP\Files\Events\Node\BeforeNodeDeletedEvent;
use OCP\Files\Events\Node\BeforeNodeRenamedEvent;
use OCP\Files\Events\Node\BeforeNodeTouchedEvent;
use OCP\Files\Events\Node\BeforeNodeWrittenEvent;
use OCP\Files\Events\Node\NodeCopiedEvent;
use OCP\Files\Events\Node\NodeCreatedEvent;
use OCP\Files\Events\Node\NodeDeletedEvent;
use OCP\Files\Events\Node\NodeRenamedEvent;
use OCP\Files\Events\Node\NodeTouchedEvent;
use OCP\Files\Events\Node\NodeWrittenEvent;
use OCP\Files\Folder;
use OCP\Files\IMimeTypeLoader;
use OCP\Files\IRootFolder;
use OCP\Files\Node;
class FileEventsListener implements IEventListener {
private IRootFolder $rootFolder;
private VersionsMapper $versionsMapper;
/**
* @var array<int, array>
*/
private array $writeHookInfo = [];
/**
* @var array<int, Node>
*/
private array $nodesTouched = [];
/**
* @var array<string, Node>
*/
private array $versionsDeleted = [];
private IMimeTypeLoader $mimeTypeLoader;
public function __construct(
IRootFolder $rootFolder,
VersionsMapper $versionsMapper,
IMimeTypeLoader $mimeTypeLoader
) {
$this->rootFolder = $rootFolder;
$this->versionsMapper = $versionsMapper;
$this->mimeTypeLoader = $mimeTypeLoader;
}
public function handle(Event $event): void {
if ($event instanceof NodeCreatedEvent) {
$this->created($event->getNode());
}
if ($event instanceof BeforeNodeTouchedEvent) {
$this->pre_touch_hook($event->getNode());
}
if ($event instanceof NodeTouchedEvent) {
$this->touch_hook($event->getNode());
}
if ($event instanceof BeforeNodeWrittenEvent) {
$this->write_hook($event->getNode());
}
if ($event instanceof NodeWrittenEvent) {
$this->post_write_hook($event->getNode());
}
if ($event instanceof BeforeNodeDeletedEvent) {
$this->pre_remove_hook($event->getNode());
}
if ($event instanceof NodeDeletedEvent) {
$this->remove_hook($event->getNode());
}
if ($event instanceof NodeRenamedEvent) {
$this->rename_hook($event->getSource(), $event->getTarget());
}
if ($event instanceof NodeCopiedEvent) {
$this->copy_hook($event->getSource(), $event->getTarget());
}
if ($event instanceof BeforeNodeRenamedEvent) {
$this->pre_renameOrCopy_hook($event->getSource(), $event->getTarget());
}
if ($event instanceof BeforeNodeCopiedEvent) {
$this->pre_renameOrCopy_hook($event->getSource(), $event->getTarget());
}
}
public function pre_touch_hook(Node $node): void {
// Do not handle folders.
if ($node instanceof Folder) {
return;
}
// $node is a non-existing on file creation.
if ($node instanceof NonExistingFile) {
return;
}
$this->nodesTouched[$node->getId()] = $node;
}
public function touch_hook(Node $node): void {
$previousNode = $this->nodesTouched[$node->getId()] ?? null;
if ($previousNode === null) {
return;
}
unset($this->nodesTouched[$node->getId()]);
try {
// We update the timestamp of the version entity associated with the previousNode.
$versionEntity = $this->versionsMapper->findVersionForFileId($previousNode->getId(), $previousNode->getMTime());
// Create a version in the DB for the current content.
$versionEntity->setTimestamp($node->getMTime());
$this->versionsMapper->update($versionEntity);
} catch (DbalException $ex) {
// Ignore UniqueConstraintViolationException, as we are probably in the middle of a rollback
// Where the previous node would temporary have the mtime of the old version, so the rollback touches it to fix it.
if (!($ex->getPrevious() instanceof UniqueConstraintViolationException)) {
throw $ex;
}
} catch (DoesNotExistException $ex) {
// Ignore DoesNotExistException, as we are probably in the middle of a rollback
// Where the previous node would temporary have a wrong mtime, so the rollback touches it to fix it.
}
}
public function created(Node $node): void {
// Do not handle folders.
if ($node instanceof Folder) {
return;
}
$versionEntity = new VersionEntity();
$versionEntity->setFileId($node->getId());
$versionEntity->setTimestamp($node->getMTime());
$versionEntity->setSize($node->getSize());
$versionEntity->setMimetype($this->mimeTypeLoader->getId($node->getMimetype()));
$versionEntity->setMetadata([]);
$this->versionsMapper->insert($versionEntity);
}
/**
* listen to write event.
*/
public function write_hook(Node $node): void {
// Do not handle folders.
if ($node instanceof Folder) {
return;
}
// $node is a non-existing on file creation.
if ($node instanceof NonExistingFile) {
return;
}
$path = $this->getPathForNode($node);
$result = Storage::store($path);
// Store the result of the version creation so it can be used in post_write_hook.
$this->writeHookInfo[$node->getId()] = [
'previousNode' => $node,
'versionCreated' => $result !== false
];
}
/**
* listen to post_write event.
*/
public function post_write_hook(Node $node): void {
// Do not handle folders.
if ($node instanceof Folder) {
return;
}
$writeHookInfo = $this->writeHookInfo[$node->getId()] ?? null;
if ($writeHookInfo === null) {
return;
}
if ($writeHookInfo['versionCreated'] && $node->getMTime() !== $writeHookInfo['previousNode']->getMTime()) {
// If a new version was created, insert a version in the DB for the current content.
// Unless both versions have the same mtime.
$versionEntity = new VersionEntity();
$versionEntity->setFileId($node->getId());
$versionEntity->setTimestamp($node->getMTime());
$versionEntity->setSize($node->getSize());
$versionEntity->setMimetype($this->mimeTypeLoader->getId($node->getMimetype()));
$versionEntity->setMetadata([]);
$this->versionsMapper->insert($versionEntity);
} else {
// If no new version was stored in the FS, no new version should be added in the DB.
// So we simply update the associated version.
$currentVersionEntity = $this->versionsMapper->findVersionForFileId($node->getId(), $writeHookInfo['previousNode']->getMtime());
$currentVersionEntity->setTimestamp($node->getMTime());
$currentVersionEntity->setSize($node->getSize());
$currentVersionEntity->setMimetype($this->mimeTypeLoader->getId($node->getMimetype()));
$this->versionsMapper->update($currentVersionEntity);
}
unset($this->writeHookInfo[$node->getId()]);
}
/**
* Erase versions of deleted file
*
* This function is connected to the delete signal of OC_Filesystem
* cleanup the versions directory if the actual file gets deleted
*/
public function remove_hook(Node $node): void {
// Need to normalize the path as there is an issue with path concatenation in View.php::getAbsolutePath.
$path = Filesystem::normalizePath($node->getPath());
if (!array_key_exists($path, $this->versionsDeleted)) {
return;
}
$node = $this->versionsDeleted[$path];
$relativePath = $this->getPathForNode($node);
unset($this->versionsDeleted[$path]);
Storage::delete($relativePath);
$this->versionsMapper->deleteAllVersionsForFileId($node->getId());
}
/**
* mark file as "deleted" so that we can clean up the versions if the file is gone
*/
public function pre_remove_hook(Node $node): void {
$path = $this->getPathForNode($node);
Storage::markDeletedFile($path);
$this->versionsDeleted[$node->getPath()] = $node;
}
/**
* rename/move versions of renamed/moved files
*
* This function is connected to the rename signal of OC_Filesystem and adjust the name and location
* of the stored versions along the actual file
*/
public function rename_hook(Node $source, Node $target): void {
$oldPath = $this->getPathForNode($source);
$newPath = $this->getPathForNode($target);
Storage::renameOrCopy($oldPath, $newPath, 'rename');
}
/**
* copy versions of copied files
*
* This function is connected to the copy signal of OC_Filesystem and copies the
* the stored versions to the new location
*/
public function copy_hook(Node $source, Node $target): void {
$oldPath = $this->getPathForNode($source);
$newPath = $this->getPathForNode($target);
Storage::renameOrCopy($oldPath, $newPath, 'copy');
}
/**
* Remember owner and the owner path of the source file.
* If the file already exists, then it was a upload of a existing file
* over the web interface and we call Storage::store() directly
*
*
*/
public function pre_renameOrCopy_hook(Node $source, Node $target): void {
// if we rename a movable mount point, then the versions don't have
// to be renamed
$oldPath = $this->getPathForNode($source);
$newPath = $this->getPathForNode($target);
$absOldPath = Filesystem::normalizePath('/' . \OC_User::getUser() . '/files' . $oldPath);
$manager = Filesystem::getMountManager();
$mount = $manager->find($absOldPath);
$internalPath = $mount->getInternalPath($absOldPath);
if ($internalPath === '' and $mount instanceof MoveableMount) {
return;
}
$view = new View(\OC_User::getUser() . '/files');
if ($view->file_exists($newPath)) {
Storage::store($newPath);
} else {
Storage::setSourcePathAndUser($oldPath);
}
}
/**
* Retrieve the path relative to the current user root folder.
* If no user is connected, use the node's owner.
*/
private function getPathForNode(Node $node): ?string {
try {
return $this->rootFolder
->getUserFolder(\OC_User::getUser())
->getRelativePath($node->getPath());
} catch (\Throwable $ex) {
return $this->rootFolder
->getUserFolder($node->getOwner()->getUid())
->getRelativePath($node->getPath());
}
}
}

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Files_Versions\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 Version1020Date20221114144058 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("files_versions")) {
return null;
}
$table = $schema->createTable("files_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::JSON, [
'notnull' => true,
]);
$table->setPrimaryKey(['id']);
$table->addUniqueIndex(['file_id', 'timestamp'], 'files_versions_uniq_index');
return $schema;
}
}

@ -29,19 +29,23 @@ namespace OCA\Files_Versions\Sabre;
use OC\AppFramework\Http\Request;
use OCP\IRequest;
use Sabre\DAV\Exception\NotFound;
use Sabre\DAV\INode;
use Sabre\DAV\PropFind;
use Sabre\DAV\PropPatch;
use Sabre\DAV\Server;
use Sabre\DAV\ServerPlugin;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
class Plugin extends ServerPlugin {
private Server $server;
private IRequest $request;
/** @var Server */
private $server;
/** @var IRequest */
private $request;
public const VERSION_LABEL = '{http://nextcloud.org/ns}version-label';
public function __construct(IRequest $request) {
public function __construct(
IRequest $request
) {
$this->request = $request;
}
@ -49,6 +53,8 @@ class Plugin extends ServerPlugin {
$this->server = $server;
$server->on('afterMethod:GET', [$this, 'afterGet']);
$server->on('propFind', [$this, 'propFind']);
$server->on('propPatch', [$this, 'propPatch']);
}
public function afterGet(RequestInterface $request, ResponseInterface $response) {
@ -81,4 +87,18 @@ class Plugin extends ServerPlugin {
. '; filename="' . rawurlencode($filename) . '"');
}
}
public function propFind(PropFind $propFind, INode $node): void {
if ($node instanceof VersionFile) {
$propFind->handle(self::VERSION_LABEL, fn() => $node->getLabel());
}
}
public function propPatch($path, PropPatch $propPatch): void {
$node = $this->server->tree->getNodeForPath($path);
if ($node instanceof VersionFile) {
$propPatch->handle(self::VERSION_LABEL, fn ($label) => $node->setLabel($label));
}
}
}

@ -26,6 +26,9 @@ declare(strict_types=1);
*/
namespace OCA\Files_Versions\Sabre;
use OCA\Files_Versions\Versions\IDeletableVersionBackend;
use OCA\Files_Versions\Versions\INameableVersion;
use OCA\Files_Versions\Versions\INameableVersionBackend;
use OCA\Files_Versions\Versions\IVersion;
use OCA\Files_Versions\Versions\IVersionManager;
use OCP\Files\NotFoundException;
@ -70,7 +73,11 @@ class VersionFile implements IFile {
}
public function delete() {
throw new Forbidden();
if ($this->versionManager instanceof IDeletableVersionBackend) {
$this->versionManager->deleteVersion($this->version);
} else {
throw new Forbidden();
}
}
public function getName(): string {
@ -81,6 +88,23 @@ class VersionFile implements IFile {
throw new Forbidden();
}
public function getLabel(): ?string {
if ($this->version instanceof INameableVersion && $this->version->getSourceFile()->getSize() > 0) {
return $this->version->getLabel();
} else {
return null;
}
}
public function setLabel($label): bool {
if ($this->versionManager instanceof INameableVersionBackend) {
$this->versionManager->setVersionLabel($this->version, $label);
return true;
} else {
return false;
}
}
public function getLastModified(): int {
return $this->version->getTimestamp();
}

@ -49,6 +49,7 @@ use OC\Files\View;
use OCA\Files_Sharing\SharedMount;
use OCA\Files_Versions\AppInfo\Application;
use OCA\Files_Versions\Command\Expire;
use OCA\Files_Versions\Db\VersionsMapper;
use OCA\Files_Versions\Events\CreateVersionEvent;
use OCA\Files_Versions\Versions\IVersionManager;
use OCP\Files\FileInfo;
@ -290,6 +291,17 @@ class Storage {
unset(self::$deletedFiles[$path]);
}
/**
* Delete a version of a file
*/
public static function deleteRevision(string $path, int $revision): void {
[$uid, $filename] = self::getUidAndFilename($path);
$view = new View('/' . $uid . '/files_versions');
\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
self::deleteVersion($view, $filename . '.v' . $revision);
\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $path . $revision, 'trigger' => self::DELETE_TRIGGER_MASTER_REMOVED]);
}
/**
* Rename or copy versions of a file of the given paths
*
@ -562,19 +574,39 @@ class Storage {
[]
));
/** @var VersionsMapper $versionsMapper */
$versionsMapper = \OC::$server->get(VersionsMapper::class);
$userFolder = $root->getUserFolder($uid);
$versionEntities = [];
/** @var Node[] $versions */
$versions = array_filter($allVersions, function (Node $info) use ($threshold) {
$versions = array_filter($allVersions, function (Node $info) use ($threshold, $userFolder, $versionsMapper, $versionsRoot, &$versionEntities) {
// Check that the file match '*.v*'
$versionsBegin = strrpos($info->getName(), '.v');
if ($versionsBegin === false) {
return false;
}
$version = (int)substr($info->getName(), $versionsBegin + 2);
// Check that the version does not have a label.
$path = $versionsRoot->getRelativePath($info->getPath());
$node = $userFolder->get(substr($path, 0, -strlen('.v'.$version)));
$versionEntity = $versionsMapper->findVersionForFileId($node->getId(), $version);
$versionEntities[$info->getId()] = $versionEntity;
if ($versionEntity->getLabel() !== '') {
return false;
}
// Check that the version's timestamp is lower than $threshold
return $version < $threshold;
});
foreach ($versions as $version) {
$internalPath = $version->getInternalPath();
\OC_Hook::emit('\OCP\Versions', 'preDelete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]);
$versionsMapper->delete($versionEntities[$version->getId()]);
$version->delete();
\OC_Hook::emit('\OCP\Versions', 'delete', ['path' => $internalPath, 'trigger' => self::DELETE_TRIGGER_RETENTION_CONSTRAINT]);
}

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Files_Versions\Versions;
/**
* @since 26.0.0
*/
interface IDeletableVersionBackend {
/**
* Delete a version.
*
* @since 26.0.0
*/
public function deleteVersion(IVersion $version): void;
}

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Files_Versions\Versions;
/**
* @since 26.0.0
*/
interface INameableVersion {
/**
* Get the user created label
*
* @return string
* @since 26.0.0
*/
public function getLabel(): string;
}

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Files_Versions\Versions;
/**
* @since 26.0.0
*/
interface INameableVersionBackend {
/**
* Set the label for a version.
*
* @since 26.0.0
*/
public function setVersionLabel(IVersion $version, string $label): void;
}

@ -28,25 +28,36 @@ namespace OCA\Files_Versions\Versions;
use OC\Files\View;
use OCA\Files_Sharing\SharedStorage;
use OCA\Files_Versions\Db\VersionEntity;
use OCA\Files_Versions\Db\VersionsMapper;
use OCA\Files_Versions\Storage;
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;
use OCP\IUser;
use OCP\IUserManager;
class LegacyVersionsBackend implements IVersionBackend {
/** @var IRootFolder */
private $rootFolder;
/** @var IUserManager */
private $userManager;
public function __construct(IRootFolder $rootFolder, IUserManager $userManager) {
class LegacyVersionsBackend implements IVersionBackend, INameableVersionBackend, IDeletableVersionBackend {
private IRootFolder $rootFolder;
private IUserManager $userManager;
private VersionsMapper $versionsMapper;
private IMimeTypeLoader $mimeTypeLoader;
public function __construct(
IRootFolder $rootFolder,
IUserManager $userManager,
VersionsMapper $versionsMapper,
IMimeTypeLoader $mimeTypeLoader
) {
$this->rootFolder = $rootFolder;
$this->userManager = $userManager;
$this->versionsMapper = $versionsMapper;
$this->mimeTypeLoader = $mimeTypeLoader;
}
public function useBackendForStorage(IStorage $storage): bool {
@ -63,21 +74,58 @@ class LegacyVersionsBackend implements IVersionBackend {
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
$nodes = $userFolder->getById($file->getId());
$file2 = array_pop($nodes);
$versions = Storage::getVersions($user->getUID(), $userFolder->getRelativePath($file2->getPath()));
return array_map(function (array $data) use ($file, $user) {
return new Version(
(int)$data['version'],
(int)$data['version'],
$data['name'],
(int)$data['size'],
$data['mimetype'],
$data['path'],
$versions = $this->getVersionsForFileFromDB($file2, $user);
if (count($versions) > 0) {
return $versions;
}
// Insert the entry in the DB for the current version.
$versionEntity = new VersionEntity();
$versionEntity->setFileId($file2->getId());
$versionEntity->setTimestamp($file2->getMTime());
$versionEntity->setSize($file2->getSize());
$versionEntity->setMimetype($this->mimeTypeLoader->getId($file2->getMimetype()));
$versionEntity->setMetadata([]);
$this->versionsMapper->insert($versionEntity);
// Insert entries in the DB for existing versions.
$versionsOnFS = Storage::getVersions($user->getUID(), $userFolder->getRelativePath($file2->getPath()));
foreach ($versionsOnFS as $version) {
$versionEntity = new VersionEntity();
$versionEntity->setFileId($file2->getId());
$versionEntity->setTimestamp((int)$version['version']);
$versionEntity->setSize((int)$version['size']);
$versionEntity->setMimetype($this->mimeTypeLoader->getId($version['mimetype']));
$versionEntity->setMetadata([]);
$this->versionsMapper->insert($versionEntity);
}
return $this->getVersionsForFileFromDB($file2, $user);
}
/**
* @return IVersion[]
*/
private function getVersionsForFileFromDB(Node $file, IUser $user): array {
$userFolder = $this->rootFolder->getUserFolder($user->getUID());
return array_map(
fn (VersionEntity $versionEntity) => new Version(
$versionEntity->getTimestamp(),
$versionEntity->getTimestamp(),
$file->getName(),
$versionEntity->getSize(),
$this->mimeTypeLoader->getMimetypeById($versionEntity->getMimetype()),
$userFolder->getRelativePath($file->getPath()),
$file,
$this,
$user
);
}, $versions);
$user,
$versionEntity->getLabel(),
),
$this->versionsMapper->findAllVersionsForFileId($file->getId())
);
}
public function createVersion(IUser $user, FileInfo $file) {
@ -125,4 +173,25 @@ class LegacyVersionsBackend implements IVersionBackend {
$file = $versionFolder->get($userFolder->getRelativePath($sourceFile->getPath()) . '.v' . $revision);
return $file;
}
public function setVersionLabel(IVersion $version, string $label): void {
$versionEntity = $this->versionsMapper->findVersionForFileId(
$version->getSourceFile()->getId(),
$version->getTimestamp(),
);
if (trim($label) === '') {
$label = null;
}
$versionEntity->setLabel($label ?? '');
$this->versionsMapper->update($versionEntity);
}
public function deleteVersion(IVersion $version): void {
Storage::deleteRevision($version->getVersionPath(), $version->getRevisionId());
$versionEntity = $this->versionsMapper->findVersionForFileId(
$version->getSourceFile()->getId(),
$version->getTimestamp(),
);
$this->versionsMapper->delete($versionEntity);
}
}

@ -28,7 +28,7 @@ namespace OCA\Files_Versions\Versions;
use OCP\Files\FileInfo;
use OCP\IUser;
class Version implements IVersion {
class Version implements IVersion, INameableVersion {
/** @var int */
private $timestamp;
@ -38,6 +38,8 @@ class Version implements IVersion {
/** @var string */
private $name;
private string $label;
/** @var int */
private $size;
@ -65,11 +67,13 @@ class Version implements IVersion {
string $path,
FileInfo $sourceFileInfo,
IVersionBackend $backend,
IUser $user
IUser $user,
string $label = ''
) {
$this->timestamp = $timestamp;
$this->revisionId = $revisionId;
$this->name = $name;
$this->label = $label;
$this->size = $size;
$this->mimetype = $mimetype;
$this->path = $path;
@ -102,6 +106,10 @@ class Version implements IVersion {
return $this->name;
}
public function getLabel(): string {
return $this->label;
}
public function getMimeType(): string {
return $this->mimetype;
}

@ -30,7 +30,7 @@ use OCP\Files\FileInfo;
use OCP\Files\Storage\IStorage;
use OCP\IUser;
class VersionManager implements IVersionManager {
class VersionManager implements IVersionManager, INameableVersionBackend, IDeletableVersionBackend {
/** @var (IVersionBackend[])[] */
private $backends = [];
@ -110,4 +110,18 @@ class VersionManager implements IVersionManager {
public function useBackendForStorage(IStorage $storage): bool {
return false;
}
public function setVersionLabel(IVersion $version, string $label): void {
$backend = $this->getBackendForStorage($version->getSourceFile()->getStorage());
if ($backend instanceof INameableVersionBackend) {
$backend->setVersionLabel($version, $label);
}
}
public function deleteVersion(IVersion $version): void {
$backend = $this->getBackendForStorage($version->getSourceFile()->getStorage());
if ($backend instanceof IDeletableVersionBackend) {
$backend->deleteVersion($version);
}
}
}

@ -0,0 +1,303 @@
<!--
- @copyright 2022 Carl Schwan <carl@carlschwan.eu>
- @license AGPL-3.0-or-later
-
- 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 <http://www.gnu.org/licenses/>.
-->
<template>
<div>
<NcListItem class="version"
:title="versionLabel"
:href="downloadURL"
:force-display-actions="true"
data-files-versions-version>
<template #icon>
<img lazy="true"
:src="previewURL"
alt=""
height="256"
width="256"
class="version__image">
</template>
<template #subtitle>
<div class="version__info">
<span v-tooltip="formattedDate">{{ version.mtime | humanDateFromNow }}</span>
<!-- Separate dot to improve alignement -->
<span class="version__info__size"></span>
<span class="version__info__size">{{ version.size | humanReadableSize }}</span>
</div>
</template>
<template #actions>
<NcActionButton v-if="capabilities.files.version_labeling === true"
:close-after-click="true"
@click="openVersionLabelModal">
<template #icon>
<Pencil :size="22" />
</template>
{{ version.label === '' ? t('files_versions', 'Name this version') : t('files_versions', 'Edit version name') }}
</NcActionButton>
<NcActionButton v-if="!isCurrent"
:close-after-click="true"
@click="restoreVersion">
<template #icon>
<BackupRestore :size="22" />
</template>
{{ t('files_versions', 'Restore version') }}
</NcActionButton>
<NcActionLink :href="downloadURL"
:close-after-click="true"
:download="downloadURL">
<template #icon>
<Download :size="22" />
</template>
{{ t('files_versions', 'Download version') }}
</NcActionLink>
<NcActionButton v-if="!isCurrent && capabilities.files.version_deletion === true"
:close-after-click="true"
@click="deleteVersion">
<template #icon>
<Delete :size="22" />
</template>
{{ t('files_versions', 'Delete version') }}
</NcActionButton>
</template>
</NcListItem>
<NcModal v-if="showVersionLabelForm"
:title="t('files_versions', 'Name this version')"
@close="showVersionLabelForm = false">
<form class="version-label-modal"
@submit.prevent="setVersionLabel(formVersionLabelValue)">
<label>
<div class="version-label-modal__title">{{ t('photos', 'Version name') }}</div>
<NcTextField ref="labelInput"
:value.sync="formVersionLabelValue"
:placeholder="t('photos', 'Version name')"
:label-outside="true" />
</label>
<div class="version-label-modal__info">
{{ t('photos', 'Named versions are persisted, and excluded from automatic cleanups when your storage quota is full.') }}
</div>
<div class="version-label-modal__actions">
<NcButton :disabled="formVersionLabelValue.trim().length === 0" @click="setVersionLabel('')">
{{ t('files_versions', 'Remove version name') }}
</NcButton>
<NcButton type="primary" native-type="submit">
<template #icon>
<Check />
</template>
{{ t('files_versions', 'Save version name') }}
</NcButton>
</div>
</form>
</NcModal>
</div>
</template>
<script>
import BackupRestore from 'vue-material-design-icons/BackupRestore.vue'
import Download from 'vue-material-design-icons/Download.vue'
import Pencil from 'vue-material-design-icons/Pencil.vue'
import Check from 'vue-material-design-icons/Check.vue'
import Delete from 'vue-material-design-icons/Delete'
import { NcActionButton, NcActionLink, NcListItem, NcModal, NcButton, NcTextField, Tooltip } from '@nextcloud/vue'
import moment from '@nextcloud/moment'
import { translate } from '@nextcloud/l10n'
import { joinPaths } from '@nextcloud/paths'
import { generateUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
export default {
name: 'Version',
components: {
NcActionLink,
NcActionButton,
NcListItem,
NcModal,
NcButton,
NcTextField,
BackupRestore,
Download,
Pencil,
Check,
Delete,
},
directives: {
tooltip: Tooltip,
},
filters: {
/**
* @param {number} bytes
* @return {string}
*/
humanReadableSize(bytes) {
return OC.Util.humanFileSize(bytes)
},
/**
* @param {number} timestamp
* @return {string}
*/
humanDateFromNow(timestamp) {
return moment(timestamp).fromNow()
},
},
props: {
/** @type {Vue.PropOptions<import('../utils/versions.js').Version>} */
version: {
type: Object,
required: true,
},
fileInfo: {
type: Object,
required: true,
},
isCurrent: {
type: Boolean,
default: false,
},
isFirstVersion: {
type: Boolean,
default: false,
},
},
data() {
return {
showVersionLabelForm: false,
formVersionLabelValue: this.version.label,
capabilities: loadState('core', 'capabilities', { files: { version_labeling: false, version_deletion: false } }),
}
},
computed: {
/**
* @return {string}
*/
versionLabel() {
if (this.isCurrent) {
if (this.version.label === undefined || this.version.label === '') {
return translate('files_versions', 'Current version')
} else {
return `${this.version.label} (${translate('files_versions', 'Current version')})`
}
}
if (this.isFirstVersion && this.version.label === '') {
return translate('files_versions', 'Initial version')
}
return this.version.label
},
/**
* @return {string}
*/
downloadURL() {
if (this.isCurrent) {
return joinPaths('/remote.php/webdav', this.fileInfo.path, this.fileInfo.name)
} else {
return this.version.url
}
},
/**
* @return {string}
*/
previewURL() {
if (this.isCurrent) {
return generateUrl('/core/preview?fileId={fileId}&c={fileEtag}&x=250&y=250&forceIcon=0&a=0', {
fileId: this.fileInfo.id,
fileEtag: this.fileInfo.etag,
})
} else {
return this.version.preview
}
},
},
methods: {
openVersionLabelModal() {
this.showVersionLabelForm = true
this.$nextTick(() => {
this.$refs.labelInput.$el.getElementsByTagName('input')[0].focus()
})
},
restoreVersion() {
this.$emit('restore', this.version)
},
setVersionLabel(label) {
this.formVersionLabelValue = label
this.showVersionLabelForm = false
this.$emit('label-update', this.version, label)
},
deleteVersion() {
this.$emit('delete', this.version)
},
formattedDate() {
return moment(this.version.mtime)
},
},
}
</script>
<style scoped lang="scss">
.version {
display: flex;
flex-direction: row;
&__info {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
&__size {
color: var(--color-text-lighter);
}
}
&__image {
width: 3rem;
height: 3rem;
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
}
}
.version-label-modal {
display: flex;
justify-content: space-between;
flex-direction: column;
height: 250px;
padding: 16px;
&__title {
margin-bottom: 12px;
font-weight: 600;
}
&__info {
margin-top: 12px;
color: var(--color-text-maxcontrast);
}
&__actions {
display: flex;
justify-content: space-between;
margin-top: 64px;
}
}
</style>

@ -41,7 +41,7 @@ window.addEventListener('DOMContentLoaded', function() {
OCA.Files.Sidebar.registerTab(new OCA.Files.Sidebar.Tab({
id: 'version_vue',
name: t('files_versions', 'Version'),
name: t('files_versions', 'Versions'),
iconSvg: BackupRestore,
async mount(el, fileInfo, context) {

@ -29,5 +29,6 @@ export default `<?xml version="1.0"?>
<d:getcontentlength />
<d:getcontenttype />
<d:getlastmodified />
<nc:version-label />
</d:prop>
</d:propfind>`

@ -23,14 +23,14 @@ import { getCurrentUser } from '@nextcloud/auth'
import client from '../utils/davClient.js'
import davRequest from '../utils/davRequest.js'
import logger from '../utils/logger.js'
import { basename, joinPaths } from '@nextcloud/paths'
import { joinPaths } from '@nextcloud/paths'
import { generateUrl } from '@nextcloud/router'
import { translate } from '@nextcloud/l10n'
import moment from '@nextcloud/moment'
/**
* @typedef {object} Version
* @property {string} title - 'Current version' or ''
* @property {string} fileId - The id of the file associated to the version.
* @property {string} label - 'Current version' or ''
* @property {string} fileName - File name relative to the version DAV endpoint
* @property {string} mimeType - Empty for the current version, else the actual mime type of the version
* @property {string} size - Human readable size
@ -39,7 +39,6 @@ import moment from '@nextcloud/moment'
* @property {string} preview - Preview URL of the version
* @property {string} url - Download URL of the version
* @property {string|null} fileVersion - The version id, null for the current version
* @property {boolean} isCurrent - Whether this is the current version of the file
*/
/**
@ -50,11 +49,15 @@ export async function fetchVersions(fileInfo) {
const path = `/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}`
try {
/** @type {import('webdav').FileStat[]} */
/** @type {import('webdav').ResponseDataDetailed<import('webdav').FileStat[]>} */
const response = await client.getDirectoryContents(path, {
data: davRequest,
details: true,
})
return response.map(version => formatVersion(version, fileInfo))
return response.data
// Filter out root
.filter(({ mime }) => mime !== '')
.map(version => formatVersion(version, fileInfo))
} catch (exception) {
logger.error('Could not fetch version', { exception })
throw exception
@ -65,13 +68,12 @@ export async function fetchVersions(fileInfo) {
* Restore the given version
*
* @param {Version} version
* @param {object} fileInfo
*/
export async function restoreVersion(version, fileInfo) {
export async function restoreVersion(version) {
try {
logger.debug('Restoring version', { url: version.url })
await client.moveFile(
`/versions/${getCurrentUser()?.uid}/versions/${fileInfo.id}/${version.fileVersion}`,
`/versions/${getCurrentUser()?.uid}/versions/${version.fileId}/${version.fileVersion}`,
`/versions/${getCurrentUser()?.uid}/restore/target`
)
} catch (exception) {
@ -88,37 +90,50 @@ export async function restoreVersion(version, fileInfo) {
* @return {Version}
*/
function formatVersion(version, fileInfo) {
const isCurrent = version.mime === ''
const fileVersion = isCurrent ? null : basename(version.filename)
let url = null
let preview = null
if (isCurrent) {
// https://nextcloud_server2.test/remote.php/webdav/welcome.txt?downloadStartSecret=hl5awd7tbzg
url = joinPaths('/remote.php/webdav', fileInfo.path, fileInfo.name)
preview = generateUrl('/core/preview?fileId={fileId}&c={fileEtag}&x=250&y=250&forceIcon=0&a=0', {
fileId: fileInfo.id,
fileEtag: fileInfo.etag,
})
} else {
url = joinPaths('/remote.php/dav', version.filename)
preview = generateUrl('/apps/files_versions/preview?file={file}&version={fileVersion}', {
file: joinPaths(fileInfo.path, fileInfo.name),
fileVersion,
})
}
return {
title: isCurrent ? translate('files_versions', 'Current version') : '',
fileId: fileInfo.id,
label: version.props['version-label'],
fileName: version.filename,
mimeType: version.mime,
size: isCurrent ? fileInfo.size : version.size,
size: version.size,
type: version.type,
mtime: moment(isCurrent ? fileInfo.mtime : version.lastmod).unix(),
preview,
url,
fileVersion,
isCurrent,
mtime: moment(version.lastmod).unix() * 1000,
preview: generateUrl('/apps/files_versions/preview?file={file}&version={fileVersion}', {
file: joinPaths(fileInfo.path, fileInfo.name),
fileVersion: version.basename,
}),
url: joinPaths('/remote.php/dav', version.filename),
fileVersion: version.basename,
}
}
/**
* @param {Version} version
* @param {string} newLabel
*/
export async function setVersionLabel(version, newLabel) {
return await client.customRequest(
version.fileName,
{
method: 'PROPPATCH',
data: `<?xml version="1.0"?>
<d:propertyupdate xmlns:d="DAV:"
xmlns:oc="http://owncloud.org/ns"
xmlns:nc="http://nextcloud.org/ns"
xmlns:ocs="http://open-collaboration-services.org/ns">
<d:set>
<d:prop>
<nc:version-label>${newLabel}</nc:version-label>
</d:prop>
</d:set>
</d:propertyupdate>`,
}
)
}
/**
* @param {Version} version
*/
export async function deleteVersion(version) {
await client.deleteFile(version.fileName)
}

@ -16,84 +16,28 @@
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<div>
<ul>
<NcListItem v-for="version in versions"
:key="version.mtime"
class="version"
:title="version.title"
:href="version.url">
<template #icon>
<img lazy="true"
:src="version.preview"
alt=""
height="256"
width="256"
class="version__image">
</template>
<template #subtitle>
<div class="version__info">
<span>{{ version.mtime | humanDateFromNow }}</span>
<!-- Separate dot to improve alignement -->
<span class="version__info__size"></span>
<span class="version__info__size">{{ version.size | humanReadableSize }}</span>
</div>
</template>
<template v-if="!version.isCurrent" #actions>
<NcActionLink :href="version.url"
:download="version.url">
<template #icon>
<Download :size="22" />
</template>
{{ t('files_versions', 'Download version') }}
</NcActionLink>
<NcActionButton @click="restoreVersion(version)">
<template #icon>
<BackupRestore :size="22" />
</template>
{{ t('files_versions', 'Restore version') }}
</NcActionButton>
</template>
</NcListItem>
<NcEmptyContent v-if="!loading && versions.length === 1"
:title="t('files_version', 'No versions yet')">
<!-- length === 1, since we don't want to show versions if there is only the current file -->
<template #icon>
<BackupRestore />
</template>
</NcEmptyContent>
</ul>
</div>
<ul data-files-versions-versions-list>
<Version v-for="version in orderedVersions"
:key="version.mtime"
:version="version"
:file-info="fileInfo"
:is-current="version.mtime === fileInfo.mtime"
:is-first-version="version.mtime === initialVersionMtime"
@restore="handleRestore"
@label-update="handleLabelUpdate"
@delete="handleDelete" />
</ul>
</template>
<script>
import BackupRestore from 'vue-material-design-icons/BackupRestore.vue'
import Download from 'vue-material-design-icons/Download.vue'
import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton.js'
import NcActionLink from '@nextcloud/vue/dist/Components/NcActionLink.js'
import NcListItem from '@nextcloud/vue/dist/Components/NcListItem.js'
import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
import { showError, showSuccess } from '@nextcloud/dialogs'
import { fetchVersions, restoreVersion } from '../utils/versions.js'
import moment from '@nextcloud/moment'
import { fetchVersions, deleteVersion, restoreVersion, setVersionLabel } from '../utils/versions.js'
import Version from '../components/Version.vue'
export default {
name: 'VersionTab',
components: {
NcEmptyContent,
NcActionLink,
NcActionButton,
NcListItem,
BackupRestore,
Download,
},
filters: {
humanReadableSize(bytes) {
return OC.Util.humanFileSize(bytes)
},
humanDateFromNow(timestamp) {
return moment(timestamp * 1000).fromNow()
},
Version,
},
data() {
return {
@ -103,6 +47,35 @@ export default {
loading: false,
}
},
computed: {
/**
* Order versions by mtime.
* Put the current version at the top.
*
* @return {import('../utils/versions.js').Version[]}
*/
orderedVersions() {
return [...this.versions].sort((a, b) => {
if (a.mtime === this.fileInfo.mtime) {
return -1
} else if (b.mtime === this.fileInfo.mtime) {
return 1
} else {
return b.mtime - a.mtime
}
})
},
/**
* Return the mtime of the first version to display "Initial version" label
* @return {number}
*/
initialVersionMtime() {
return this.versions
.map(version => version.mtime)
.reduce((a, b) => Math.min(a, b))
},
},
methods: {
/**
* Update current fileInfo and fetch new data
@ -128,55 +101,77 @@ export default {
},
/**
* Restore the given version
* Handle restored event from Version.vue
*
* @param version
* @param {import('../utils/versions.js').Version} version
*/
async restoreVersion(version) {
async handleRestore(version) {
// Update local copy of fileInfo as rendering depends on it.
const oldFileInfo = this.fileInfo
this.fileInfo = {
...this.fileInfo,
size: version.size,
mtime: version.mtime,
}
try {
await restoreVersion(version, this.fileInfo)
// File info is not updated so we manually update its size and mtime if the restoration went fine.
this.fileInfo.size = version.size
this.fileInfo.mtime = version.lastmod
showSuccess(t('files_versions', 'Version restored'))
await restoreVersion(version)
if (version.label !== '') {
showSuccess(t('files_versions', `${version.label} restored`))
} else if (version.mtime === this.initialVersionMtime) {
showSuccess(t('files_versions', 'Initial version restored'))
} else {
showSuccess(t('files_versions', 'Version restored'))
}
await this.fetchVersions()
} catch (exception) {
this.fileInfo = oldFileInfo
showError(t('files_versions', 'Could not restore version'))
}
},
/**
* Handle label-updated event from Version.vue
*
* @param {import('../utils/versions.js').Version} version
* @param {string} newName
*/
async handleLabelUpdate(version, newName) {
const oldLabel = version.label
version.label = newName
try {
await setVersionLabel(version, newName)
} catch (exception) {
version.label = oldLabel
showError(t('files_versions', 'Could not set version name'))
}
},
/**
* Handle deleted event from Version.vue
*
* @param {import('../utils/versions.js').Version} version
* @param {string} newName
*/
async handleDelete(version) {
const index = this.versions.indexOf(version)
this.versions.splice(index, 1)
try {
await deleteVersion(version)
} catch (exception) {
this.versions.push(version)
showError(t('files_versions', 'Could not delete version'))
}
},
/**
* Reset the current view to its default state
*/
resetState() {
this.versions = []
this.$set(this, 'versions', [])
},
},
}
</script>
<style scopped lang="scss">
.version {
display: flex;
flex-direction: row;
&__info {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
&__size {
color: var(--color-text-lighter);
}
}
&__image {
width: 3rem;
height: 3rem;
border: 1px solid var(--color-border);
margin-right: 1rem;
border-radius: var(--border-radius-large);
}
}
</style>

@ -51,7 +51,7 @@ class StorageTest extends TestCase {
});
$this->overwriteService(Expiration::class, $expiration);
Hooks::connectHooks();
\OC::$server->boot();
$this->createUser('version_test', '');
$this->loginAsUser('version_test');

@ -35,6 +35,9 @@
namespace OCA\Files_Versions\Tests;
use OC\Files\Storage\Temporary;
use OCA\Files_Versions\Db\VersionEntity;
use OCA\Files_Versions\Db\VersionsMapper;
use OCP\Files\IMimeTypeLoader;
use OCP\IConfig;
use OCP\IUser;
use OCP\Share\IShare;
@ -54,6 +57,14 @@ class VersioningTest extends \Test\TestCase {
* @var \OC\Files\View
*/
private $rootView;
/**
* @var VersionsMapper
*/
private $versionsMapper;
/**
* @var IMimeTypeLoader
*/
private $mimeTypeLoader;
private $user1;
private $user2;
@ -100,7 +111,7 @@ class VersioningTest extends \Test\TestCase {
// clear hooks
\OC_Hook::clear();
\OC::registerShareHooks(\OC::$server->getSystemConfig());
\OCA\Files_Versions\Hooks::connectHooks();
\OC::$server->boot();
self::loginHelper(self::TEST_VERSIONS_USER);
$this->rootView = new \OC\Files\View();
@ -108,6 +119,9 @@ class VersioningTest extends \Test\TestCase {
$this->rootView->mkdir(self::USERS_VERSIONS_ROOT);
}
$this->versionsMapper = \OCP\Server::get(VersionsMapper::class);
$this->mimeTypeLoader = \OCP\Server::get(IMimeTypeLoader::class);
$this->user1 = $this->createMock(IUser::class);
$this->user1->method('getUID')
->willReturn(self::TEST_VERSIONS_USER);
@ -762,6 +776,7 @@ class VersioningTest extends \Test\TestCase {
$filePath = self::TEST_VERSIONS_USER . '/files/sub/test.txt';
$this->rootView->file_put_contents($filePath, 'test file');
$fileInfo = $this->rootView->getFileInfo($filePath);
$t0 = $this->rootView->filemtime($filePath);
// not exactly the same timestamp as the file
@ -774,8 +789,26 @@ class VersioningTest extends \Test\TestCase {
$v2 = self::USERS_VERSIONS_ROOT . '/sub/test.txt.v' . $t2;
$this->rootView->mkdir(self::USERS_VERSIONS_ROOT . '/sub');
$this->rootView->file_put_contents($v1, 'version1');
$fileInfoV1 = $this->rootView->getFileInfo($v1);
$versionEntity = new VersionEntity();
$versionEntity->setFileId($fileInfo->getId());
$versionEntity->setTimestamp($t1);
$versionEntity->setSize($fileInfoV1->getSize());
$versionEntity->setMimetype($this->mimeTypeLoader->getId($fileInfoV1->getMimetype()));
$versionEntity->setMetadata([]);
$this->versionsMapper->insert($versionEntity);
$this->rootView->file_put_contents($v2, 'version2');
$fileInfoV2 = $this->rootView->getFileInfo($v2);
$versionEntity = new VersionEntity();
$versionEntity->setFileId($fileInfo->getId());
$versionEntity->setTimestamp($t2);
$versionEntity->setSize($fileInfoV2->getSize());
$versionEntity->setMimetype($this->mimeTypeLoader->getId($fileInfoV2->getMimetype()));
$versionEntity->setMetadata([]);
$this->versionsMapper->insert($versionEntity);
$oldVersions = \OCA\Files_Versions\Storage::getVersions(
self::TEST_VERSIONS_USER, '/sub/test.txt'

@ -122,6 +122,7 @@ export const configureNextcloud = async function() {
await runExec(container, ['php', 'occ', 'config:system:set', 'default_locale', '--value', 'en_US'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'force_locale', '--value', 'en_US'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'enforce_theme', '--value', 'light'], true)
await runExec(container, ['php', 'occ', 'config:system:set', 'versions_retention_obligation', '--value', '0, 0'], true)
console.log('└─ Nextcloud is now ready to use 🎉')
}
@ -145,7 +146,7 @@ export const applyChangesToNextcloud = async function() {
'./ocs',
]
// Tar-streaming the above folder sinto the container
// Tar-streaming the above folders into the container
const serverTar = tar.c({ gzip: false }, folderPaths)
await container.putArchive(serverTar, {
path: htmlPath,

@ -0,0 +1,81 @@
/**
* @copyright Copyright (c) 2022 Louis Chemineau <louis@chmn.me>
*
* @author Louis Chemineau <louis@chmn.me>
*
* @license AGPL-3.0-or-later
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
import path from "path"
export function uploadThreeVersions(user) {
cy.uploadContent(user, new Blob(['v1'], { type: 'text/plain' }), 'text/plain', '/test.txt')
cy.wait(1000)
cy.uploadContent(user, new Blob(['v2'], { type: 'text/plain' }), 'text/plain', '/test.txt')
cy.wait(1000)
cy.uploadContent(user, new Blob(['v3'], { type: 'text/plain' }), 'text/plain', '/test.txt')
cy.login(user)
}
export function openVersionsPanel(fileName: string) {
cy.get(`[data-file="${fileName}"]`).within(() => {
cy.get('[data-action="menu"]')
.click()
cy.get('.fileActionsMenu')
.get('.action-details')
.click()
})
cy.get('#app-sidebar-vue')
.get('[aria-controls="tab-version_vue"]')
.click()
}
export function openVersionMenu(index: number) {
cy.get('#tab-version_vue').within(() => {
cy.get('[data-files-versions-version]')
.eq(index).within(() => {
cy.get('.action-item__menutoggle').filter(':visible')
.click()
})
})
}
export function clickPopperAction(actionName: string) {
cy.get('.v-popper__popper').filter(':visible')
.contains(actionName)
.click()
}
export function nameVersion(index: number, name: string) {
openVersionMenu(index)
clickPopperAction("Name this version")
cy.get(':focused').type(`${name}{enter}`)
}
export function assertVersionContent(index: number, expectedContent: string) {
const downloadsFolder = Cypress.config('downloadsFolder')
openVersionMenu(index)
clickPopperAction("Download version")
return cy.readFile(path.join(downloadsFolder, 'test.txt'))
.then((versionContent) => expect(versionContent).to.equal(expectedContent))
.then(() => cy.exec(`rm ${downloadsFolder}/test.txt`))
}

@ -0,0 +1,45 @@
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @license AGPL-3.0-or-later
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
import { openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils'
describe('Versions creation', () => {
before(() => {
cy.createRandomUser()
.then((user) => {
uploadThreeVersions(user)
cy.login(user)
cy.visit('/apps/files')
openVersionsPanel('test.txt')
})
})
it('Opens the versions panel and sees the versions', () => {
openVersionsPanel('test.txt')
cy.get('#tab-version_vue').within(() => {
cy.get('[data-files-versions-version]').should('have.length', 3)
cy.get('[data-files-versions-version]').eq(0).contains('Current version')
cy.get('[data-files-versions-version]').eq(2).contains('Initial version')
})
})
})

@ -0,0 +1,41 @@
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @license AGPL-3.0-or-later
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
import { assertVersionContent, openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils'
describe('Versions download', () => {
before(() => {
cy.createRandomUser()
.then((user) => {
uploadThreeVersions(user)
cy.login(user)
cy.visit('/apps/files')
openVersionsPanel('test.txt')
})
})
it('Download versions and assert there content', () => {
assertVersionContent(0, 'v3')
assertVersionContent(1, 'v2')
assertVersionContent(2, 'v1')
})
})

@ -0,0 +1,65 @@
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @license AGPL-3.0-or-later
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
import { assertVersionContent, nameVersion, openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils'
describe('Versions expiration', () => {
beforeEach(() => {
cy.createRandomUser()
.then((user) => {
uploadThreeVersions(user)
cy.login(user)
cy.visit('/apps/files')
openVersionsPanel('test.txt')
})
})
it('Expire all versions', () => {
cy.runOccCommand('versions:expire')
cy.visit('/apps/files')
openVersionsPanel('test.txt')
cy.get('#tab-version_vue').within(() => {
cy.get('[data-files-versions-version]').should('have.length', 1)
cy.get('[data-files-versions-version]').eq(0).contains('Current version')
})
assertVersionContent(0, 'v3')
})
it('Expire versions v2', () => {
nameVersion(2, 'v1')
cy.runOccCommand('versions:expire')
cy.visit('/apps/files')
openVersionsPanel('test.txt')
cy.get('#tab-version_vue').within(() => {
cy.get('[data-files-versions-version]').should('have.length', 2)
cy.get('[data-files-versions-version]').eq(0).contains('Current version')
cy.get('[data-files-versions-version]').eq(1).contains('v1')
})
assertVersionContent(0, 'v3')
assertVersionContent(1, 'v1')
})
})

@ -0,0 +1,57 @@
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @license AGPL-3.0-or-later
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
import { nameVersion, openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils'
describe('Versions naming', () => {
before(() => {
cy.createRandomUser()
.then((user) => {
uploadThreeVersions(user)
cy.login(user)
cy.visit('/apps/files')
openVersionsPanel('test.txt')
})
})
it('Names the initial version as v1', () => {
nameVersion(2, 'v1')
cy.get('#tab-version_vue').within(() => {
cy.get('[data-files-versions-version]').eq(2).contains('v1')
cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist')
})
})
it('Names the second version as v2', () => {
nameVersion(1, 'v2')
cy.get('#tab-version_vue').within(() => {
cy.get('[data-files-versions-version]').eq(1).contains('v2')
})
})
it('Names the current version as v3', () => {
nameVersion(0, 'v3')
cy.get('#tab-version_vue').within(() => {
cy.get('[data-files-versions-version]').eq(0).contains('v3 (Current version)')
})
})
})

@ -0,0 +1,55 @@
/**
* @copyright Copyright (c) 2022 Louis Chmn <louis@chmn.me>
*
* @author Louis Chmn <louis@chmn.me>
*
* @license AGPL-3.0-or-later
*
* 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 <http://www.gnu.org/licenses/>.
*
*/
import { assertVersionContent, clickPopperAction, openVersionMenu, openVersionsPanel, uploadThreeVersions } from './filesVersionsUtils'
function restoreVersion(index: number) {
openVersionMenu(index)
clickPopperAction("Restore version")
}
describe('Versions restoration', () => {
before(() => {
cy.createRandomUser()
.then((user) => {
uploadThreeVersions(user)
cy.login(user)
cy.visit('/apps/files')
openVersionsPanel('test.txt')
})
})
it('Restores initial version', () => {
restoreVersion(2)
cy.get('#tab-version_vue').within(() => {
cy.get('[data-files-versions-version]').should('have.length', 3)
cy.get('[data-files-versions-version]').eq(0).contains('Current version')
cy.get('[data-files-versions-version]').eq(2).contains('Initial version').should('not.exist')
})
})
it('Downloads versions and assert there content', () => {
assertVersionContent(0, 'v1')
assertVersionContent(1, 'v3')
assertVersionContent(2, 'v2')
})
})

@ -39,6 +39,12 @@ declare global {
*/
uploadFile(user: User, fixture?: string, mimeType?: string, target?: string): Cypress.Chainable<void>,
/**
* Upload a raw content to a given user storage.
* **Warning**: Using this function will reset the previous session
*/
uploadContent(user: User, content: Blob, mimeType: string, target: string): Cypress.Chainable<void>,
/**
* Reset the admin theming entirely.
* **Warning**: Using this function will reset the previous session
@ -51,6 +57,11 @@ declare global {
* **Warning**: Providing a user will reset the previous session.
*/
resetUserTheming(user?: User): Cypress.Chainable<void>,
/**
* Run an occ command in the docker container.
*/
runOccCommand(command: string): Cypress.Chainable<void>,
}
}
}
@ -68,19 +79,34 @@ Cypress.env('baseUrl', url)
* @param {string} [target] the target of the file relative to the user root
*/
Cypress.Commands.add('uploadFile', (user, fixture = 'image.jpg', mimeType = 'image/jpeg', target = `/${fixture}`) => {
cy.clearCookies()
const fileName = basename(target)
// get fixture
return cy.fixture(fixture, 'base64').then(async file => {
// convert the base64 string to a blob
const blob = Cypress.Blob.base64StringToBlob(file, mimeType)
cy.uploadContent(user, blob, mimeType, target)
})
})
/**
* cy.uploadedContent - uploads a raw content
* TODO: standardise in @nextcloud/cypress
*
* @param {User} user the owner of the file, e.g. admin
* @param {Blob} blob the content to upload
* @param {string} mimeType e.g. image/png
* @param {string} target the target of the file relative to the user root
*/
Cypress.Commands.add('uploadContent', (user, blob, mimeType, target) => {
cy.clearCookies()
.then(async () => {
const fileName = basename(target)
// Process paths
const rootPath = `${Cypress.env('baseUrl')}/remote.php/dav/files/${encodeURIComponent(user.userId)}`
const filePath = target.split('/').map(encodeURIComponent).join('/')
try {
const file = new File([blob], fileName, { type: mimeType })
const file = new File([blob], fileName, { type: mimeType })
await axios({
url: `${rootPath}${filePath}`,
method: 'PUT',
@ -93,11 +119,11 @@ Cypress.Commands.add('uploadFile', (user, fixture = 'image.jpg', mimeType = 'ima
password: user.password,
},
}).then(response => {
cy.log(`Uploaded ${fixture} as ${fileName}`, response)
cy.log(`Uploaded content as ${fileName}`, response)
})
} catch (error) {
cy.log('error', error)
throw new Error(`Unable to process fixture ${fixture}`)
throw new Error(`Unable to process fixture`)
}
})
})
@ -157,3 +183,7 @@ Cypress.Commands.add('resetUserTheming', (user?: User) => {
cy.clearCookies()
}
})
Cypress.Commands.add('runOccCommand', (command: string) => {
cy.exec(`docker exec --user www-data nextcloud-cypress-tests-server php ./occ ${command}`)
})

File diff suppressed because one or more lines are too long

@ -378,8 +378,6 @@ object-assign
/*! For license information please see NcEmojiPicker.js.LICENSE.txt */
/*! For license information please see NcListItem.js.LICENSE.txt */
/*! For license information please see NcModal.js.LICENSE.txt */
/*! For license information please see NcNoteCard.js.LICENSE.txt */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -24,6 +24,8 @@ declare(strict_types=1);
namespace OC\Collaboration\Reference\File;
use OC\Files\Node\NonExistingFile;
use OC\Files\Node\NonExistingFolder;
use OCP\Collaboration\Reference\IReferenceManager;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventDispatcher;
@ -51,6 +53,10 @@ class FileReferenceEventListener implements IEventListener {
*/
public function handle(Event $event): void {
if ($event instanceof NodeDeletedEvent) {
if ($event->getNode() instanceof NonExistingFolder || $event->getNode() instanceof NonExistingFile) {
return;
}
$this->manager->invalidateCache((string)$event->getNode()->getId());
}
if ($event instanceof ShareDeletedEvent) {

@ -69,8 +69,9 @@ class HookConnectorTest extends TestCase {
parent::setUp();
$this->userId = $this->getUniqueID();
$this->createUser($this->userId, 'pass');
// this will setup the FS
$this->loginAsUser($this->userId);
$this->registerMount($this->userId, new Temporary(), '/' . $this->userId . '/files/');
\OC_Util::setupFS($this->userId);
$this->view = new View();
$this->root = new Root(
Filesystem::getMountManager(),

Loading…
Cancel
Save