Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/files/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@
<command>OCA\Files\Command\Object\Get</command>
<command>OCA\Files\Command\Object\Put</command>
<command>OCA\Files\Command\Object\Multi\Users</command>
<command>OCA\Files\Command\Object\Multi\Rename</command>
<command>OCA\Files\Command\Object\Multi\Move</command>
<command>OCA\Files\Command\Object\Multi\PreMigrate</command>
</commands>

<activity>
Expand Down
3 changes: 2 additions & 1 deletion apps/files/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
'OCA\\Files\\Command\\Move' => $baseDir . '/../lib/Command/Move.php',
'OCA\\Files\\Command\\Object\\Delete' => $baseDir . '/../lib/Command/Object/Delete.php',
'OCA\\Files\\Command\\Object\\Get' => $baseDir . '/../lib/Command/Object/Get.php',
'OCA\\Files\\Command\\Object\\Multi\\Rename' => $baseDir . '/../lib/Command/Object/Multi/Rename.php',
'OCA\\Files\\Command\\Object\\Multi\\Move' => $baseDir . '/../lib/Command/Object/Multi/Move.php',
'OCA\\Files\\Command\\Object\\Multi\\PreMigrate' => $baseDir . '/../lib/Command/Object/Multi/PreMigrate.php',
'OCA\\Files\\Command\\Object\\Multi\\Users' => $baseDir . '/../lib/Command/Object/Multi/Users.php',
'OCA\\Files\\Command\\Object\\ObjectUtil' => $baseDir . '/../lib/Command/Object/ObjectUtil.php',
'OCA\\Files\\Command\\Object\\Put' => $baseDir . '/../lib/Command/Object/Put.php',
Expand Down
3 changes: 2 additions & 1 deletion apps/files/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ class ComposerStaticInitFiles
'OCA\\Files\\Command\\Move' => __DIR__ . '/..' . '/../lib/Command/Move.php',
'OCA\\Files\\Command\\Object\\Delete' => __DIR__ . '/..' . '/../lib/Command/Object/Delete.php',
'OCA\\Files\\Command\\Object\\Get' => __DIR__ . '/..' . '/../lib/Command/Object/Get.php',
'OCA\\Files\\Command\\Object\\Multi\\Rename' => __DIR__ . '/..' . '/../lib/Command/Object/Multi/Rename.php',
'OCA\\Files\\Command\\Object\\Multi\\Move' => __DIR__ . '/..' . '/../lib/Command/Object/Multi/Move.php',
'OCA\\Files\\Command\\Object\\Multi\\PreMigrate' => __DIR__ . '/..' . '/../lib/Command/Object/Multi/PreMigrate.php',
'OCA\\Files\\Command\\Object\\Multi\\Users' => __DIR__ . '/..' . '/../lib/Command/Object/Multi/Users.php',
'OCA\\Files\\Command\\Object\\ObjectUtil' => __DIR__ . '/..' . '/../lib/Command/Object/ObjectUtil.php',
'OCA\\Files\\Command\\Object\\Put' => __DIR__ . '/..' . '/../lib/Command/Object/Put.php',
Expand Down
152 changes: 152 additions & 0 deletions apps/files/lib/Command/Object/Multi/Move.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Files\Command\Object\Multi;

use OC\Core\Command\Base;
use OC\Files\Mount\ObjectHomeMountProvider;
use OC\Files\ObjectStore\PrimaryObjectStoreConfig;
use OC\Files\ObjectStore\S3;
use OC\Files\Storage\StorageFactory;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\Files\FileInfo;
use OCP\Files\IMimeTypeLoader;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\IUser;
use OCP\IUserManager;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class Move extends Base {
public function __construct(
private PrimaryObjectStoreConfig $objectStoreConfig,
private IUserManager $userManager,
private IConfig $config,
private ObjectHomeMountProvider $mountProvider,
private IMimeTypeLoader $mimeTypeLoader,
private IDBConnection $connection,
) {
parent::__construct();
}

protected function configure(): void {
parent::configure();
$this
->setName('files:object:multi:move')
->setDescription('Migrate user to the specified object store and bucket. The bucket must be created and known beforehand containing the same objects in the user\'s current bucket.')
->addOption('object-store', 'o', InputOption::VALUE_REQUIRED, 'The name of the object store')
->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, 'The name of the bucket')
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'The user to migrate')
->addOption('dry-run', null, InputOption::VALUE_NONE, 'Run command without commiting any changes');
}

public function execute(InputInterface $input, OutputInterface $output): int {
$objectStore = $input->getOption('object-store');
if (!$objectStore) {
$output->writeln('Please specify the object store');
}
$bucket = $input->getOption('bucket');
if (!$bucket) {
$output->writeln('Please specify the bucket');
}

$configs = $this->objectStoreConfig->getObjectStoreConfigs();
if (!isset($configs[$objectStore])) {
$output->writeln('<error>Unknown object store configuration: ' . $objectStore . '</error>');
return 1;
}

if ($userId = $input->getOption('user')) {
$user = $this->userManager->get($userId);
if (!$user) {
$output->writeln('<error>User ' . $userId . ' not found</error>');
return 1;
}
} else {
$output->writeln('<comment>Please specify a user id with --user</comment>');
return 1;
}

try {
$targetValid = $this->validateForUser($user, $objectStore, $bucket);
} catch (\Exception $e) {
$output->writeln('Object store <info>' . $objectStore . '</info> and bucket <info>' . $bucket . '</info> invalid for <info>' . $userId . '</info>: ' . $e->getMessage());

return 1;
}

if ($targetValid) {
if (!$input->getOption('dry-run')) {
$this->config->setUserValue($user->getUID(), 'homeobjectstore', 'objectstore', $objectStore);
$this->config->setUserValue($user->getUID(), 'homeobjectstore', 'bucket', $bucket);
}
$output->writeln('Moved <info>' . $user->getUID() . '</info> to object store <info>' . $objectStore . '</info> and bucket <info>' . $bucket . '</info>.');
} else {
$output->writeln('Object store <info>' . $objectStore . '</info> and bucket <info>' . $bucket . '</info> invalid for <info>' . $userId . '</info>. Bucket doesn\'t exist or contain expected user objects.');
return 1;
}

return 0;
}

private function validateForUser(IUser $user, string $targetObjectStore, string $targetBucket): bool {
$currentObjectStore = $this->config->getUserValue($user->getUID(), 'homeobjectstore', 'objectstore');
if ($currentObjectStore === '') {
throw new \Exception('No object store set for ' . $user->getUID() . '. Please set an object store and bucket before proceeding.');
}

$currentBucket = $this->objectStoreConfig->getSetBucketForUser($user);
if ($currentBucket === null || $currentBucket === '') {
throw new \Exception('No bucket set for ' . $user->getUID() . '. Please set a bucket before proceeding.');
}
if ($currentBucket === $targetBucket) {
if ($currentObjectStore !== $targetObjectStore) {
throw new \Exception('Bucket names must be unique');
}

return true;
}

$storageFactory = new StorageFactory();
$homeMount = $this->mountProvider->getHomeMountForUser($user, $storageFactory);
if ($homeMount === null) {
throw new \Exception('Failed to get home mount for ' . $user->getUID());
}

$homeStorage = $homeMount->getStorage();
$storageId = $homeStorage?->getCache()->getNumericStorageId();
if ($storageId === null) {

Check notice

Code scanning / Psalm

DocblockTypeContradiction Note

int does not contain null
throw new \Exception('Failed to get the user\'s home storage.');
}
$folderMimetype = $this->mimeTypeLoader->getId(FileInfo::MIMETYPE_FOLDER);

$query = $this->connection->getQueryBuilder();
$query->select('fileid')
->from('filecache')
->where($query->expr()->eq('storage', $query->createNamedParameter($storageId, IQueryBuilder::PARAM_INT)))
->andWhere($query->expr()->neq('mimetype', $query->createNamedParameter($folderMimetype, IQueryBuilder::PARAM_INT)))
->setMaxResults(10);
$result = $query->execute();

Check notice

Code scanning / Psalm

DeprecatedMethod Note

The method OCP\DB\QueryBuilder\IQueryBuilder::execute has been marked as deprecated
$fileIds = $result->fetchAll(\PDO::FETCH_COLUMN);

Check notice

Code scanning / Psalm

PossiblyInvalidMethodCall Note

Cannot call method on possible int variable $result

// Use a new S3 client to 'peek' into the target bucket since it's not yet mounted
$targetConfig = $this->objectStoreConfig->getObjectStoreConfiguration($targetObjectStore);
$targetConfig['arguments']['bucket'] = $targetBucket;
$s3 = new S3($targetConfig['arguments']);

foreach ($fileIds as $fileId) {
if (!$s3->objectExists('urn:oid:' . $fileId)) {
return false;
}
}

return true;
}
}
76 changes: 76 additions & 0 deletions apps/files/lib/Command/Object/Multi/PreMigrate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Files\Command\Object\Multi;

use OC\Core\Command\Base;
use OC\Files\ObjectStore\PrimaryObjectStoreConfig;
use OCP\IConfig;
use OCP\IUserManager;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class PreMigrate extends Base {
public function __construct(
private PrimaryObjectStoreConfig $objectStoreConfig,
private IUserManager $userManager,
private IConfig $config,
) {
parent::__construct();
}

protected function configure(): void {
parent::configure();
$this
->setName('files:object:multi:pre-migrate')
->setDescription('Assign a configured object store to users who don\'t have one assigned yet.')
->addOption('object-store', 'o', InputOption::VALUE_REQUIRED, 'The name of the configured object store')
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'The userId of the user to assign the object store')
->addOption('all', 'a', InputOption::VALUE_NONE, 'Assign the object store to all users');
}

public function execute(InputInterface $input, OutputInterface $output): int {
$objectStore = $input->getOption('object-store');
if (!$objectStore) {
$output->writeln('Please specify the object store');
return 1;
}

$configs = $this->objectStoreConfig->getObjectStoreConfigs();
if (!isset($configs[$objectStore])) {
$output->writeln('<error>Unknown object store configuration: ' . $objectStore . '</error>');
return 1;
}

if ($input->getOption('all')) {
$users = $this->userManager->getSeenUsers();
} elseif ($userId = $input->getOption('user')) {
$user = $this->userManager->get($userId);
if (!$user) {
$output->writeln('<error>User ' . $userId . ' not found</error>');
return 1;
}
$users = new \ArrayIterator([$user]);
} else {
$output->writeln('<comment>Please specify a user id with --user or --all for all users</comment>');
return 1;
}

$count = 0;
foreach ($users as $user) {
if (!$this->config->getUserValue($user->getUID(), 'homeobjectstore', 'objectstore', null)) {
$this->config->setUserValue($user->getUID(), 'homeobjectstore', 'objectstore', $objectStore);
$count++;
}
}
$output->writeln('Assigned object store <info>' . $objectStore . '</info> to <info>' . $count . '</info> users');

return 0;
}
}
108 changes: 0 additions & 108 deletions apps/files/lib/Command/Object/Multi/Rename.php

This file was deleted.

10 changes: 7 additions & 3 deletions apps/files/lib/Command/Object/Multi/Users.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,14 @@ protected function configure(): void {
->setDescription('Get the mapping between users and object store buckets')
->addOption('bucket', 'b', InputOption::VALUE_REQUIRED, 'Only list users using the specified bucket')
->addOption('object-store', 'o', InputOption::VALUE_REQUIRED, 'Only list users using the specified object store configuration')
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Only show the mapping for the specified user, ignores all other options');
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Only show the mapping for the specified user, ignores all other options')
->addOption('all', 'a', InputOption::VALUE_NONE, 'Show the mapping for all users');
}

public function execute(InputInterface $input, OutputInterface $output): int {
if ($userId = $input->getOption('user')) {
if ($input->getOption('all')) {
$users = $this->userManager->getSeenUsers();
} elseif ($userId = $input->getOption('user')) {
$user = $this->userManager->get($userId);
if (!$user) {
$output->writeln("<error>User $userId not found</error>");
Expand All @@ -57,7 +60,8 @@ public function execute(InputInterface $input, OutputInterface $output): int {
$this->config->getUsersForUserValue('homeobjectstore', 'objectstore', $objectStore)
));
} else {
$users = $this->userManager->getSeenUsers();
$output->writeln("<comment>No option given. Please specify a user id with --user to show the mapping for the user or --all for all users</comment>");
return 0;
}
}

Expand Down
Loading