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