mirror of https://github.com/nextcloud/server.git
feat(files_versions): Add listener and interfaces to allow versions migration across storages
Signed-off-by: Louis Chemineau <louis@chmn.me>pull/44187/head
parent
1a55084930
commit
369274c9ee
@ -0,0 +1,154 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (c) 2024 Louis Chmn <louis@chmn.me>
|
||||||
|
*
|
||||||
|
* @author Louis Chmn <louis@chmn.me>
|
||||||
|
*
|
||||||
|
* @license GNU AGPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* 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 Exception;
|
||||||
|
use OC\Files\Node\NonExistingFile;
|
||||||
|
use OCA\Files_Versions\Versions\IVersionBackend;
|
||||||
|
use OCA\Files_Versions\Versions\IVersionManager;
|
||||||
|
use OCA\Files_Versions\Versions\IVersionsImporterBackend;
|
||||||
|
use OCP\EventDispatcher\Event;
|
||||||
|
use OCP\EventDispatcher\IEventListener;
|
||||||
|
use OCP\Files\Events\Node\AbstractNodesEvent;
|
||||||
|
use OCP\Files\Events\Node\BeforeNodeRenamedEvent;
|
||||||
|
use OCP\Files\Events\Node\NodeCopiedEvent;
|
||||||
|
use OCP\Files\Events\Node\NodeRenamedEvent;
|
||||||
|
use OCP\Files\File;
|
||||||
|
use OCP\Files\Folder;
|
||||||
|
use OCP\Files\Node;
|
||||||
|
use OCP\Files\Storage\IStorage;
|
||||||
|
use OCP\IUser;
|
||||||
|
use OCP\IUserSession;
|
||||||
|
|
||||||
|
/** @template-implements IEventListener<Event> */
|
||||||
|
class VersionStorageMoveListener implements IEventListener {
|
||||||
|
/** @var File[] */
|
||||||
|
private array $movedNodes = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private IVersionManager $versionManager,
|
||||||
|
private IUserSession $userSession,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @abstract Moves version across storages if necessary.
|
||||||
|
* @throws Exception No user in session
|
||||||
|
*/
|
||||||
|
public function handle(Event $event): void {
|
||||||
|
if (!($event instanceof AbstractNodesEvent)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$source = $event->getSource();
|
||||||
|
$target = $event->getTarget();
|
||||||
|
|
||||||
|
$sourceStorage = $this->getNodeStorage($source);
|
||||||
|
$targetStorage = $this->getNodeStorage($target);
|
||||||
|
|
||||||
|
$sourceBackend = $this->versionManager->getBackendForStorage($sourceStorage);
|
||||||
|
$targetBackend = $this->versionManager->getBackendForStorage($targetStorage);
|
||||||
|
|
||||||
|
// If same backend, nothing to do.
|
||||||
|
if ($sourceBackend === $targetBackend) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $this->userSession->getUser() ?? $source->getOwner();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
throw new Exception("Cannot move versions across storages without a user.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event instanceof BeforeNodeRenamedEvent) {
|
||||||
|
$this->recursivelyPrepareMove($source);
|
||||||
|
} elseif ($event instanceof NodeRenamedEvent || $event instanceof NodeCopiedEvent) {
|
||||||
|
$this->recursivelyHandleMoveOrCopy($event, $user, $source, $target, $sourceBackend, $targetBackend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store all sub files in this->movedNodes so their info can be used after the operation.
|
||||||
|
*/
|
||||||
|
private function recursivelyPrepareMove(Node $source): void {
|
||||||
|
if ($source instanceof File) {
|
||||||
|
$this->movedNodes[$source->getId()] = $source;
|
||||||
|
} elseif ($source instanceof Folder) {
|
||||||
|
foreach ($source->getDirectoryListing() as $child) {
|
||||||
|
$this->recursivelyPrepareMove($child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call handleMoveOrCopy on each sub files
|
||||||
|
* @param NodeRenamedEvent|NodeCopiedEvent $event
|
||||||
|
*/
|
||||||
|
private function recursivelyHandleMoveOrCopy(Event $event, IUser $user, ?Node $source, Node $target, IVersionBackend $sourceBackend, IVersionBackend $targetBackend): void {
|
||||||
|
if ($target instanceof File) {
|
||||||
|
if ($event instanceof NodeRenamedEvent) {
|
||||||
|
$source = $this->movedNodes[$target->getId()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var File $source */
|
||||||
|
$this->handleMoveOrCopy($event, $user, $source, $target, $sourceBackend, $targetBackend);
|
||||||
|
} elseif ($target instanceof Folder) {
|
||||||
|
/** @var Folder $source */
|
||||||
|
foreach ($target->getDirectoryListing() as $targetChild) {
|
||||||
|
if ($event instanceof NodeCopiedEvent) {
|
||||||
|
$sourceChild = $source->get($targetChild->getName());
|
||||||
|
} else {
|
||||||
|
$sourceChild = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->recursivelyHandleMoveOrCopy($event, $user, $sourceChild, $targetChild, $sourceBackend, $targetBackend);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called only during NodeRenamedEvent or NodeCopiedEvent
|
||||||
|
* Will send the source node versions to the new backend, and then delete them from the old backend.
|
||||||
|
* @param NodeRenamedEvent|NodeCopiedEvent $event
|
||||||
|
*/
|
||||||
|
private function handleMoveOrCopy(Event $event, IUser $user, File $source, File $target, IVersionBackend $sourceBackend, IVersionBackend $targetBackend): void {
|
||||||
|
if ($targetBackend instanceof IVersionsImporterBackend) {
|
||||||
|
$versions = $sourceBackend->getVersionsForFile($user, $source);
|
||||||
|
$targetBackend->importVersionsForFile($user, $source, $target, $versions);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($event instanceof NodeRenamedEvent && $sourceBackend instanceof IVersionsImporterBackend) {
|
||||||
|
$sourceBackend->clearVersionsForFile($user, $source, $target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getNodeStorage(Node $node): IStorage {
|
||||||
|
if ($node instanceof NonExistingFile) {
|
||||||
|
return $node->getParent()->getStorage();
|
||||||
|
} else {
|
||||||
|
return $node->getStorage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @copyright Copyright (c) 2024 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\Versions;
|
||||||
|
|
||||||
|
use OCP\Files\Node;
|
||||||
|
use OCP\IUser;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @since 29.0.0
|
||||||
|
*/
|
||||||
|
interface IVersionsImporterBackend {
|
||||||
|
/**
|
||||||
|
* Import the given versions for the target file.
|
||||||
|
*
|
||||||
|
* @param Node $source - The source might not exist anymore.
|
||||||
|
* @param IVersion[] $versions
|
||||||
|
* @since 29.0.0
|
||||||
|
*/
|
||||||
|
public function importVersionsForFile(IUser $user, Node $source, Node $target, array $versions): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all versions for a file
|
||||||
|
*
|
||||||
|
* @since 29.0.0
|
||||||
|
*/
|
||||||
|
public function clearVersionsForFile(IUser $user, Node $source, Node $target): void;
|
||||||
|
}
|
@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* @copyright Copyright (c) 2024 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, setupTestSharedFileFromUser, uploadThreeVersions, nameVersion } from './filesVersionsUtils'
|
||||||
|
import { clickOnBreadcumbs, closeSidebar, copyFile, moveFile, navigateToFolder } from '../files/FilesUtils'
|
||||||
|
import type { User } from '@nextcloud/cypress'
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param filePath
|
||||||
|
*/
|
||||||
|
function assertVersionsContent(filePath: string) {
|
||||||
|
const path = filePath.split('/').slice(0, -1).join('/')
|
||||||
|
|
||||||
|
clickOnBreadcumbs('All files')
|
||||||
|
|
||||||
|
if (path !== '') {
|
||||||
|
navigateToFolder(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
openVersionsPanel(filePath)
|
||||||
|
|
||||||
|
cy.get('[data-files-versions-version]').should('have.length', 3)
|
||||||
|
assertVersionContent(0, 'v3')
|
||||||
|
assertVersionContent(1, 'v2')
|
||||||
|
assertVersionContent(2, 'v1')
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Versions cross share move and copy', () => {
|
||||||
|
let randomSharedFolderName = ''
|
||||||
|
let randomFileName = ''
|
||||||
|
let randomFilePath = ''
|
||||||
|
let alice: User
|
||||||
|
let bob: User
|
||||||
|
|
||||||
|
before(() => {
|
||||||
|
randomSharedFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
|
||||||
|
|
||||||
|
cy.createRandomUser()
|
||||||
|
.then((user) => {
|
||||||
|
alice = user
|
||||||
|
cy.mkdir(alice, `/${randomSharedFolderName}`)
|
||||||
|
setupTestSharedFileFromUser(alice, randomSharedFolderName, {})
|
||||||
|
})
|
||||||
|
.then((user) => { bob = user })
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
randomFileName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10) + '.txt'
|
||||||
|
randomFilePath = `${randomSharedFolderName}/${randomFileName}`
|
||||||
|
uploadThreeVersions(alice, randomFilePath)
|
||||||
|
|
||||||
|
cy.login(bob)
|
||||||
|
cy.visit('/apps/files')
|
||||||
|
navigateToFolder(randomSharedFolderName)
|
||||||
|
openVersionsPanel(randomFilePath)
|
||||||
|
nameVersion(2, 'v1')
|
||||||
|
closeSidebar()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Also moves versions when bob moves the file out of a received share', () => {
|
||||||
|
moveFile(randomFileName, '/')
|
||||||
|
assertVersionsContent(randomFileName)
|
||||||
|
// TODO: move that in assertVersionsContent when copying files keeps the versions' metadata
|
||||||
|
cy.get('[data-files-versions-version]').eq(2).contains('v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Also copies versions when bob copies the file out of a received share', () => {
|
||||||
|
copyFile(randomFileName, '/')
|
||||||
|
assertVersionsContent(randomFileName)
|
||||||
|
})
|
||||||
|
|
||||||
|
context('When a file is in a subfolder', () => {
|
||||||
|
let randomSubFolderName
|
||||||
|
let randomSubSubFolderName
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
randomSubFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
|
||||||
|
randomSubSubFolderName = Math.random().toString(36).replace(/[^a-z]+/g, '').substring(0, 10)
|
||||||
|
clickOnBreadcumbs('All files')
|
||||||
|
cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}`)
|
||||||
|
cy.mkdir(bob, `/${randomSharedFolderName}/${randomSubFolderName}/${randomSubSubFolderName}`)
|
||||||
|
cy.login(bob)
|
||||||
|
navigateToFolder(randomSharedFolderName)
|
||||||
|
moveFile(randomFileName, `${randomSubFolderName}/${randomSubSubFolderName}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Also moves versions when bob moves the containing folder out of a received share', () => {
|
||||||
|
moveFile(randomSubFolderName, '/')
|
||||||
|
assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`)
|
||||||
|
// TODO: move that in assertVersionsContent when copying files keeps the versions' metadata
|
||||||
|
cy.get('[data-files-versions-version]').eq(2).contains('v1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Also copies versions when bob copies the containing folder out of a received share', () => {
|
||||||
|
copyFile(randomSubFolderName, '/')
|
||||||
|
assertVersionsContent(`${randomSubFolderName}/${randomSubSubFolderName}/${randomFileName}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue