Skip to content
4 changes: 3 additions & 1 deletion apps/files/appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<name>Files</name>
<summary>File Management</summary>
<description>File Management</description>
<version>2.1.1</version>
<version>2.1.2</version>
<licence>agpl</licence>
<author>John Molakvoæ</author>
<author>Robin Appelman</author>
Expand Down Expand Up @@ -44,6 +44,8 @@
<command>OCA\Files\Command\Object\Delete</command>
<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>
</commands>

<activity>
Expand Down
2 changes: 2 additions & 0 deletions apps/files/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +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\\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',
'OCA\\Files\\Command\\Put' => $baseDir . '/../lib/Command/Put.php',
Expand Down
2 changes: 2 additions & 0 deletions apps/files/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +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\\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',
'OCA\\Files\\Command\\Put' => __DIR__ . '/..' . '/../lib/Command/Put.php',
Expand Down
108 changes: 108 additions & 0 deletions apps/files/lib/Command/Object/Multi/Rename.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
* 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\IDBConnection;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;

class Rename extends Base {
public function __construct(
private IDBConnection $connection,
private PrimaryObjectStoreConfig $objectStoreConfig,
private IConfig $config,
) {
parent::__construct();
}

protected function configure(): void {
parent::configure();
$this
->setName('files:object:multi:rename-config')
->setDescription('Rename an object store configuration and move all users over to the new configuration,')
->addArgument('source', InputArgument::REQUIRED, 'Object store configuration to rename')
->addArgument('target', InputArgument::REQUIRED, 'New name for the object store configuration');
}

public function execute(InputInterface $input, OutputInterface $output): int {
$source = $input->getArgument('source');
$target = $input->getArgument('target');

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

if ($source === 'root') {
$output->writeln('<error>Renaming the root configuration is not supported.</error>');
return 1;
}

if ($source === 'default') {
$output->writeln('<error>Renaming the default configuration is not supported.</error>');
return 1;
}

if (!isset($configs[$target])) {
$output->writeln('<comment>Target object store configuration ' . $target . ' doesn\'t exist yet.</comment>');
$output->writeln('The target configuration can be created automatically.');
$output->writeln('However, as this depends on modifying the config.php, this only works as long as the instance runs on a single node or all nodes in a clustered setup have a shared config file (such as from a shared network mount).');
$output->writeln('If the different nodes have a separate copy of the config.php file, the automatic object store configuration creation will lead to the configuration going out of sync.');
$output->writeln('If these requirements are not met, you can manually create the target object store configuration in each node\'s configuration before running the command.');
$output->writeln('');
$output->writeln('<error>Failure to check these requirements will lead to data loss for users.</error>');

/** @var QuestionHelper $helper */
$helper = $this->getHelper('question');
$question = new ConfirmationQuestion('Automatically create target object store configuration? [y/N] ', false);
if ($helper->ask($input, $output, $question)) {
$configs[$target] = $configs[$source];

// update all aliases
foreach ($configs as &$config) {
if ($config === $source) {
$config = $target;
}
}
$this->config->setSystemValue('objectstore', $configs);
Copy link
Collaborator

Choose a reason for hiding this comment

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

When it duplicates a configuration it use the same bucket and later fails with:
Each object store configuration must use distinct bucket names

} else {
return 0;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe display the piece of config to add here ?

}
} elseif (($configs[$source] !== $configs[$target]) || $configs[$source] !== $target) {
$output->writeln('<error>Source and target configuration differ.</error>');
$output->writeln('');
$output->writeln('To ensure proper migration of users, the source and target configuration must be the same to ensure that the objects for the moved users exist on the target configuration.');
$output->writeln('The usual migration process consists of creating a clone of the old configuration, moving the users from the old configuration to the new one, and then adjust the old configuration that is longer used.');
return 1;
}

$query = $this->connection->getQueryBuilder();
$query->update('preferences')
->set('configvalue', $query->createNamedParameter($target))
->where($query->expr()->eq('appid', $query->createNamedParameter('homeobjectstore')))
->andWhere($query->expr()->eq('configkey', $query->createNamedParameter('objectstore')))
->andWhere($query->expr()->eq('configvalue', $query->createNamedParameter($source)));
$count = $query->executeStatement();

if ($count > 0) {
$output->writeln('Moved <info>' . $count . '</info> users');
} else {
$output->writeln('No users moved');
}

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

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Robin Appelman <robin@icewind.nl>
* 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\IUser;
use OCP\IUserManager;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

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

protected function configure(): void {
parent::configure();
$this
->setName('files:object:multi:users')
->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');
}

public function execute(InputInterface $input, OutputInterface $output): int {
if ($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 {
$bucket = (string) $input->getOption('bucket');
$objectStore = (string) $input->getOption('object-store');
if ($bucket !== '' && $objectStore === '') {
$users = $this->getUsers($this->config->getUsersForUserValue('homeobjectstore', 'bucket', $bucket));
} elseif ($bucket === '' && $objectStore !== '') {
$users = $this->getUsers($this->config->getUsersForUserValue('homeobjectstore', 'objectstore', $objectStore));
} elseif ($bucket) {
$users = $this->getUsers(array_intersect(
$this->config->getUsersForUserValue('homeobjectstore', 'bucket', $bucket),
$this->config->getUsersForUserValue('homeobjectstore', 'objectstore', $objectStore)
));
} else {
$users = $this->userManager->getSeenUsers();
}
}

$this->writeStreamingTableInOutputFormat($input, $output, $this->infoForUsers($users), 100);
return 0;
}

/**
* @param string[] $userIds
* @return \Iterator<IUser>
*/
private function getUsers(array $userIds): \Iterator {
foreach ($userIds as $userId) {
$user = $this->userManager->get($userId);
if ($user) {
yield $user;
}
}
}

/**
* @param \Iterator<IUser> $users
* @return \Iterator<array>
*/
private function infoForUsers(\Iterator $users): \Iterator {
foreach ($users as $user) {
yield $this->infoForUser($user);
}
}

private function infoForUser(IUser $user): array {
return [
'user' => $user->getUID(),
'object-store' => $this->objectStoreConfig->getObjectStoreForUser($user),
'bucket' => $this->objectStoreConfig->getSetBucketForUser($user) ?? 'unset',
];
}
}
8 changes: 0 additions & 8 deletions build/psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2055,14 +2055,6 @@
<code><![CDATA[wrap]]></code>
</UndefinedInterfaceMethod>
</file>
<file src="lib/private/Files/Mount/ObjectHomeMountProvider.php">
<InvalidNullableReturnType>
<code><![CDATA[\OCP\Files\Mount\IMountPoint]]></code>
</InvalidNullableReturnType>
<NullableReturnStatement>
<code><![CDATA[null]]></code>
</NullableReturnStatement>
</file>
<file src="lib/private/Files/Node/File.php">
<InvalidReturnStatement>
<code><![CDATA[$this->view->hash($type, $this->path, $raw)]]></code>
Expand Down
52 changes: 52 additions & 0 deletions core/Command/Base.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,58 @@ protected function writeTableInOutputFormat(InputInterface $input, OutputInterfa
}
}

protected function writeStreamingTableInOutputFormat(InputInterface $input, OutputInterface $output, \Iterator $items, int $tableGroupSize): void {
switch ($input->getOption('output')) {
case self::OUTPUT_FORMAT_JSON:
case self::OUTPUT_FORMAT_JSON_PRETTY:
$this->writeStreamingJsonArray($input, $output, $items);
break;
default:
foreach ($this->chunkIterator($items, $tableGroupSize) as $chunk) {
$this->writeTableInOutputFormat($input, $output, $chunk);
}
break;
}
}

protected function writeStreamingJsonArray(InputInterface $input, OutputInterface $output, \Iterator $items): void {
$first = true;
$outputType = $input->getOption('output');

$output->writeln('[');
foreach ($items as $item) {
if (!$first) {
$output->writeln(',');
}
if ($outputType === self::OUTPUT_FORMAT_JSON_PRETTY) {
$output->write(json_encode($item, JSON_PRETTY_PRINT));
} else {
$output->write(json_encode($item));
}
$first = false;
}
$output->writeln("\n]");
}

public function chunkIterator(\Iterator $iterator, int $count): \Iterator {
$chunk = [];

for ($i = 0; $iterator->valid(); $i++) {
$chunk[] = $iterator->current();
$iterator->next();
if (count($chunk) == $count) {
// Got a full chunk, yield and start a new one
yield $chunk;
$chunk = [];
}
}

if (count($chunk)) {
// Yield the last chunk even if incomplete
yield $chunk;
}
}


/**
* @param mixed $item
Expand Down
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -1437,9 +1437,11 @@
'OC\\Files\\ObjectStore\\AppdataPreviewObjectStoreStorage' => $baseDir . '/lib/private/Files/ObjectStore/AppdataPreviewObjectStoreStorage.php',
'OC\\Files\\ObjectStore\\Azure' => $baseDir . '/lib/private/Files/ObjectStore/Azure.php',
'OC\\Files\\ObjectStore\\HomeObjectStoreStorage' => $baseDir . '/lib/private/Files/ObjectStore/HomeObjectStoreStorage.php',
'OC\\Files\\ObjectStore\\InvalidObjectStoreConfigurationException' => $baseDir . '/lib/private/Files/ObjectStore/InvalidObjectStoreConfigurationException.php',
'OC\\Files\\ObjectStore\\Mapper' => $baseDir . '/lib/private/Files/ObjectStore/Mapper.php',
'OC\\Files\\ObjectStore\\ObjectStoreScanner' => $baseDir . '/lib/private/Files/ObjectStore/ObjectStoreScanner.php',
'OC\\Files\\ObjectStore\\ObjectStoreStorage' => $baseDir . '/lib/private/Files/ObjectStore/ObjectStoreStorage.php',
'OC\\Files\\ObjectStore\\PrimaryObjectStoreConfig' => $baseDir . '/lib/private/Files/ObjectStore/PrimaryObjectStoreConfig.php',
'OC\\Files\\ObjectStore\\S3' => $baseDir . '/lib/private/Files/ObjectStore/S3.php',
'OC\\Files\\ObjectStore\\S3ConfigTrait' => $baseDir . '/lib/private/Files/ObjectStore/S3ConfigTrait.php',
'OC\\Files\\ObjectStore\\S3ConnectionTrait' => $baseDir . '/lib/private/Files/ObjectStore/S3ConnectionTrait.php',
Expand Down
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -1470,9 +1470,11 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Files\\ObjectStore\\AppdataPreviewObjectStoreStorage' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/AppdataPreviewObjectStoreStorage.php',
'OC\\Files\\ObjectStore\\Azure' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/Azure.php',
'OC\\Files\\ObjectStore\\HomeObjectStoreStorage' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/HomeObjectStoreStorage.php',
'OC\\Files\\ObjectStore\\InvalidObjectStoreConfigurationException' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/InvalidObjectStoreConfigurationException.php',
'OC\\Files\\ObjectStore\\Mapper' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/Mapper.php',
'OC\\Files\\ObjectStore\\ObjectStoreScanner' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/ObjectStoreScanner.php',
'OC\\Files\\ObjectStore\\ObjectStoreStorage' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/ObjectStoreStorage.php',
'OC\\Files\\ObjectStore\\PrimaryObjectStoreConfig' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/PrimaryObjectStoreConfig.php',
'OC\\Files\\ObjectStore\\S3' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/S3.php',
'OC\\Files\\ObjectStore\\S3ConfigTrait' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/S3ConfigTrait.php',
'OC\\Files\\ObjectStore\\S3ConnectionTrait' => __DIR__ . '/../../..' . '/lib/private/Files/ObjectStore/S3ConnectionTrait.php',
Expand Down
Loading
Loading