Merge pull request #52378 from nextcloud/transfer-external-storage

feat: add option to include external storage when transfering files
pull/52798/head
Robin Appelman 2025-05-14 11:10:32 +07:00 committed by GitHub
commit 1950076ca1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 108 additions and 17 deletions

@ -11,20 +11,26 @@ namespace OCA\Files\Command;
use OCA\Files\Exception\TransferOwnershipException;
use OCA\Files\Service\OwnershipTransferService;
use OCA\Files_External\Config\ConfigAdapter;
use OCP\Files\Mount\IMountManager;
use OCP\Files\Mount\IMountPoint;
use OCP\IConfig;
use OCP\IUser;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
class TransferOwnership extends Command {
public function __construct(
private IUserManager $userManager,
private OwnershipTransferService $transferService,
private IConfig $config,
private IMountManager $mountManager,
) {
parent::__construct();
}
@ -60,6 +66,16 @@ class TransferOwnership extends Command {
InputOption::VALUE_OPTIONAL,
'transfer incoming user file shares to destination user. Usage: --transfer-incoming-shares=1 (value required)',
'2'
)->addOption(
'include-external-storage',
null,
InputOption::VALUE_NONE,
'include files on external storages, this will _not_ setup an external storage for the target user, but instead moves all the files from the external storages into the target users home directory',
)->addOption(
'force-include-external-storage',
null,
InputOption::VALUE_NONE,
'don\'t ask for confirmation for transferring external storages',
);
}
@ -87,6 +103,31 @@ class TransferOwnership extends Command {
return self::FAILURE;
}
$path = ltrim($input->getOption('path'), '/');
$includeExternalStorage = $input->getOption('include-external-storage');
if ($includeExternalStorage) {
$mounts = $this->mountManager->findIn('/' . rtrim($sourceUserObject->getUID() . '/files/' . $path, '/'));
/** @var IMountPoint[] $mounts */
$mounts = array_filter($mounts, fn ($mount) => $mount->getMountProvider() === ConfigAdapter::class);
if (count($mounts) > 0) {
$output->writeln(count($mounts) . ' external storages will be transferred:');
foreach ($mounts as $mount) {
$output->writeln(' - <info>' . $mount->getMountPoint() . '</info>');
}
$output->writeln('');
$output->writeln('<comment>Any other users with access to these external storages will lose access to the files.</comment>');
$output->writeln('');
if (!$input->getOption('force-include-external-storage')) {
/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion('Are you sure you want to transfer external storages? (y/N) ', false);
if (!$helper->ask($input, $output, $question)) {
return self::FAILURE;
}
}
}
}
try {
$includeIncomingArgument = $input->getOption('transfer-incoming-shares');
@ -112,11 +153,12 @@ class TransferOwnership extends Command {
$this->transferService->transfer(
$sourceUserObject,
$destinationUserObject,
ltrim($input->getOption('path'), '/'),
$path,
$output,
$input->getOption('move') === true,
false,
$includeIncoming
$includeIncoming,
$includeExternalStorage,
);
} catch (TransferOwnershipException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');

@ -15,10 +15,11 @@ use OC\Files\View;
use OC\User\NoUserException;
use OCA\Encryption\Util;
use OCA\Files\Exception\TransferOwnershipException;
use OCA\Files_External\Config\ConfigAdapter;
use OCP\Encryption\IManager as IEncryptionManager;
use OCP\Files\Config\IHomeMountProvider;
use OCP\Files\Config\IUserMountCache;
use OCP\Files\FileInfo;
use OCP\Files\IHomeStorage;
use OCP\Files\InvalidPathException;
use OCP\Files\IRootFolder;
use OCP\Files\Mount\IMountManager;
@ -70,6 +71,7 @@ class OwnershipTransferService {
bool $move = false,
bool $firstLogin = false,
bool $transferIncomingShares = false,
bool $includeExternalStorage = false,
): void {
$output = $output ?? new NullOutput();
$sourceUid = $sourceUser->getUID();
@ -149,7 +151,8 @@ class OwnershipTransferService {
$sourcePath,
$finalTarget,
$view,
$output
$output,
$includeExternalStorage,
);
$sizeDifference = $sourceSize - $view->getFileInfo($finalTarget)->getSize();
@ -215,11 +218,14 @@ class OwnershipTransferService {
*
* @throws TransferOwnershipException
*/
protected function analyse(string $sourceUid,
protected function analyse(
string $sourceUid,
string $destinationUid,
string $sourcePath,
View $view,
OutputInterface $output): void {
OutputInterface $output,
bool $includeExternalStorage = false,
): void {
$output->writeln('Validating quota');
$sourceFileInfo = $view->getFileInfo($sourcePath, false);
if ($sourceFileInfo === false) {
@ -247,17 +253,22 @@ class OwnershipTransferService {
$encryptedFiles[] = $sourceFileInfo;
} else {
$this->walkFiles($view, $sourcePath,
function (FileInfo $fileInfo) use ($progress, $masterKeyEnabled, &$encryptedFiles) {
function (FileInfo $fileInfo) use ($progress, $masterKeyEnabled, &$encryptedFiles, $includeExternalStorage) {
if ($fileInfo->getType() === FileInfo::TYPE_FOLDER) {
$mount = $fileInfo->getMountPoint();
// only analyze into folders from main storage,
if (!$fileInfo->getStorage()->instanceOfStorage(IHomeStorage::class)) {
return false;
}
if (
$mount->getMountProvider() instanceof IHomeMountProvider ||
($includeExternalStorage && $mount->getMountProvider() instanceof ConfigAdapter)
) {
if ($fileInfo->isEncrypted()) {
/* Encrypted folder means e2ee encrypted, we cannot transfer it */
$encryptedFiles[] = $fileInfo;
}
return true;
} else {
return false;
}
}
$progress->advance();
if ($fileInfo->isEncrypted() && !$masterKeyEnabled) {
@ -399,11 +410,14 @@ class OwnershipTransferService {
/**
* @throws TransferOwnershipException
*/
protected function transferFiles(string $sourceUid,
protected function transferFiles(
string $sourceUid,
string $sourcePath,
string $finalTarget,
View $view,
OutputInterface $output): void {
OutputInterface $output,
bool $includeExternalStorage,
): void {
$output->writeln("Transferring files to $finalTarget ...");
// This change will help user to transfer the folder specified using --path option.
@ -412,15 +426,50 @@ class OwnershipTransferService {
$view->mkdir($finalTarget);
$finalTarget = $finalTarget . '/' . basename($sourcePath);
}
$sourceInfo = $view->getFileInfo($sourcePath);
/// handle the external storages mounted at the root, or the admin specifying an external storage with --path
if ($sourceInfo->getInternalPath() === '' && $includeExternalStorage) {
$this->moveMountContents($view, $sourcePath, $finalTarget);
} else {
if ($view->rename($sourcePath, $finalTarget, ['checkSubMounts' => false]) === false) {
throw new TransferOwnershipException('Could not transfer files.', 1);
}
}
if ($includeExternalStorage) {
$nestedMounts = $this->mountManager->findIn($sourcePath);
foreach ($nestedMounts as $mount) {
if ($mount->getMountProvider() === ConfigAdapter::class) {
$relativePath = substr(trim($mount->getMountPoint(), '/'), strlen($sourcePath));
$this->moveMountContents($view, $mount->getMountPoint(), $finalTarget . $relativePath);
}
}
}
if (!is_dir("$sourceUid/files")) {
// because the files folder is moved away we need to recreate it
$view->mkdir("$sourceUid/files");
}
}
private function moveMountContents(View $rootView, string $source, string $target) {
if ($rootView->copy($source, $target)) {
// just doing `rmdir` on the mountpoint would cause it to try and unmount the storage
// we need to empty the contents instead
$content = $rootView->getDirectoryContent($source);
foreach ($content as $item) {
if ($item->getType() === FileInfo::TYPE_FOLDER) {
$rootView->rmdir($item->getPath());
} else {
$rootView->unlink($item->getPath());
}
}
} else {
throw new TransferOwnershipException("Could not transfer $source to $target");
}
}
/**
* @param string $targetLocation New location of the transfered node
* @param array<array{share: IShare, suffix: string}> $shares previously collected share information