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
140 changes: 140 additions & 0 deletions apps/files/lib/Command/Object/Multi/Move.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<?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;
}

$targetValid = $this->validateForUser($user, $objectStore, $bucket);
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', null);
if (!$currentObjectStore) {
return false;
}
if ($currentObjectStore === $targetObjectStore) {
return false;
}

$currentBucket = $this->objectStoreConfig->getSetBucketForUser($user);
if (!$currentBucket) {

Check notice

Code scanning / Psalm

RiskyTruthyFalsyComparison Note

Operand of type null|string contains type string, which can be falsy and truthy. This can cause possibly unexpected behavior. Use strict comparison instead.
return false;
}
if ($currentBucket === $targetBucket) {
return false;
}

$storageFactory = new StorageFactory();
$homeMount = $this->mountProvider->getHomeMountForUser($user, $storageFactory);
if ($homeMount === null) {
return false;
}

$homeStorage = $homeMount->getStorage();
$storageId = $homeStorage->getCache()->getNumericStorageId();

Check notice

Code scanning / Psalm

PossiblyNullReference Note

Cannot call method getCache on possibly null value
$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)));
$result = $query->execute();

Check notice

Code scanning / Psalm

DeprecatedMethod Note

The method OCP\DB\QueryBuilder\IQueryBuilder::execute has been marked as deprecated
Comment on lines +119 to +124
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we already have the storage/cache, we can just do $storage->getCache()->getId('')

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't that give the id of a user's root folder, which doesn't get saved as an object?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, I forgot about that.

It does probably make sense to add a limit to the number of fileids we check though. Checking ~5 files gives mostly the same safety as checking all of them without the potentially high cost of checking all files.

$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 true;
}
}

return false;
}
}
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