From 7f428b1f80dbbdaf6ed1f222d5862b4835581b5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=B4me=20Chilliet?= Date: Tue, 2 Dec 2025 10:16:14 +0100 Subject: [PATCH 1/3] feat: Add user_migration:manage command and last export date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exporting a user from occ now stores the date of last export in the database. A new command user_migration:manage allows to list exported users and delete them as a batch. Signed-off-by: Côme Chilliet --- appinfo/info.xml | 1 + lib/Command/Export.php | 6 ++ lib/Command/Import.php | 5 ++ lib/Command/Manage.php | 119 +++++++++++++++++++++++++++++++++++++++ tests/stubs/stub.phpstub | 4 +- 5 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 lib/Command/Manage.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 86fe64b6..ad8b87fe 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -46,6 +46,7 @@ This app allows users to easily migrate from one instance to another using an ex + OCA\UserMigration\Command\Manage OCA\UserMigration\Command\Export OCA\UserMigration\Command\Import diff --git a/lib/Command/Export.php b/lib/Command/Export.php index 018670a7..900f0352 100644 --- a/lib/Command/Export.php +++ b/lib/Command/Export.php @@ -10,8 +10,11 @@ namespace OCA\UserMigration\Command; use OC\Core\Command\Base; +use OCA\UserMigration\AppInfo\Application; use OCA\UserMigration\ExportDestination; use OCA\UserMigration\Service\UserMigrationService; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\IConfig; use OCP\IUser; use OCP\IUserManager; use OCP\UserMigration\IMigrator; @@ -26,6 +29,8 @@ class Export extends Base { public function __construct( private IUserManager $userManager, private UserMigrationService $migrationService, + private IConfig $config, + private ITimeFactory $timeFactory, ) { parent::__construct(); } @@ -173,6 +178,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (rename($path, $finalPath) === false) { throw new \Exception('Failed to rename ' . basename($path) . ' to ' . basename($finalPath)); } + $this->config->setUserValue($user->getUID(), Application::APP_ID, 'lastExport', (string)$this->timeFactory->getTime()); $io->writeln("Export saved in $finalPath"); } catch (\Exception $e) { if ($io->isDebug()) { diff --git a/lib/Command/Import.php b/lib/Command/Import.php index e4d9d353..28f474c1 100644 --- a/lib/Command/Import.php +++ b/lib/Command/Import.php @@ -9,8 +9,10 @@ namespace OCA\UserMigration\Command; +use OCA\UserMigration\AppInfo\Application; use OCA\UserMigration\ImportSource; use OCA\UserMigration\Service\UserMigrationService; +use OCP\IConfig; use OCP\IUserManager; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -23,6 +25,7 @@ class Import extends Command { public function __construct( private IUserManager $userManager, private UserMigrationService $migrationService, + private IConfig $config, ) { parent::__construct(); } @@ -73,6 +76,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io->writeln("Importing from {$path}…"); $importSource = new ImportSource($path); $this->migrationService->import($importSource, $user, $io); + /* Reset exported state of user after import */ + $this->config->deleteUserValue($user->getUID(), Application::APP_ID, 'lastExport'); $io->writeln("Successfully imported from {$path}"); } catch (\Exception $e) { if ($io->isDebug()) { diff --git a/lib/Command/Manage.php b/lib/Command/Manage.php new file mode 100644 index 00000000..e0cb0ee2 --- /dev/null +++ b/lib/Command/Manage.php @@ -0,0 +1,119 @@ +setName('user_migration:manage') + ->setDescription('List users exported by the admin, delete them by batch') + ->addOption( + 'limit', + 'l', + InputOption::VALUE_REQUIRED, + 'Limit the number of listed users', + 100, + ) + ->addOption( + 'delete', + null, + InputOption::VALUE_NONE, + 'Delete the exported users', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $values = iterator_to_array($this->queryUsers((int)$input->getOption('limit'))); + $this->writeTableInOutputFormat($input, $output, $values); + if ($input->getOption('delete')) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion('Please confirm to delete the above listed users [y/n]', !$input->isInteractive()); + + if (!$helper->ask($input, $output, $question)) { + $output->writeln('Deletion canceled'); + return self::SUCCESS; + } + $errors = $this->deleteUsers(array_column($values, 'userid'), $output); + if ($errors > 0) { + return self::FAILURE; + } + } + return self::SUCCESS; + } + + private function queryUsers(int $limit): \Generator { + $qb = $this->connection->getQueryBuilder(); + $qb->select('userid', 'configvalue') + ->from('preferences') + ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID))) + ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('lastExport'))) + ->orderBy('configvalue') + ->setMaxResults($limit); + + $result = $qb->executeQuery(); + + while ($row = $result->fetch()) { + yield [ + 'userid' => $row['userid'], + 'Last export' => date(\DateTimeInterface::ATOM, (int)$row['configvalue']) + ]; + } + + $result->closeCursor(); + } + + /** + * @param iterable $uids + */ + private function deleteUsers(iterable $uids, OutputInterface $output): int { + $errors = 0; + foreach ($uids as $uid) { + $user = $this->userManager->get($uid); + if (is_null($user)) { + $output->writeln('User ' . $uid . ' does not exist'); + $errors++; + continue; + } + + if ($user->delete()) { + $output->writeln('User "' . $uid . '" was deleted'); + } else { + $output->writeln('User "' . $uid . '" could not be deleted. Please check the logs.'); + $errors++; + } + } + return $errors; + } +} diff --git a/tests/stubs/stub.phpstub b/tests/stubs/stub.phpstub index 6a203ed5..c0eb2768 100644 --- a/tests/stubs/stub.phpstub +++ b/tests/stubs/stub.phpstub @@ -125,8 +125,9 @@ namespace OC\Cache { namespace OC\Core\Command { use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; + use Symfony\Component\Console\Command\Command; - class Base { + class Base extends Command { public const OUTPUT_FORMAT_PLAIN = 'plain'; public const OUTPUT_FORMAT_JSON = 'json'; public const OUTPUT_FORMAT_JSON_PRETTY = 'json_pretty'; @@ -139,5 +140,6 @@ namespace OC\Core\Command { public function setName(string $name) {} public function getHelper(string $name) {} protected function writeArrayInOutputFormat(InputInterface $input, OutputInterface $output, array $items, string $prefix = ' - ') {} + protected function writeTableInOutputFormat(InputInterface $input, OutputInterface $output, array $items): void {} } } From ae1eca339575cdcc5f8bdff4c44912ead4cfa9a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=B4me=20Chilliet?= <91878298+come-nc@users.noreply.github.com> Date: Tue, 2 Dec 2025 17:36:57 +0100 Subject: [PATCH 2/3] chore: Fix license in lib/Command/Manage.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kate <26026535+provokateurin@users.noreply.github.com> Signed-off-by: Côme Chilliet <91878298+come-nc@users.noreply.github.com> --- lib/Command/Manage.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Command/Manage.php b/lib/Command/Manage.php index e0cb0ee2..8a3c9ebe 100644 --- a/lib/Command/Manage.php +++ b/lib/Command/Manage.php @@ -3,8 +3,8 @@ declare(strict_types=1); /** - * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-only + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\UserMigration\Command; From 5e0c9bdcdca32216e820dbf93ffb6cc5f708f9e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=B4me=20Chilliet?= Date: Mon, 15 Dec 2025 12:11:00 +0100 Subject: [PATCH 3/3] feat: Add a --since option to the manage command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Côme Chilliet --- lib/Command/Manage.php | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/lib/Command/Manage.php b/lib/Command/Manage.php index 8a3c9ebe..c37a1002 100644 --- a/lib/Command/Manage.php +++ b/lib/Command/Manage.php @@ -13,6 +13,7 @@ use OCA\UserMigration\AppInfo\Application; use OCA\UserMigration\Service\UserMigrationService; use OCP\AppFramework\Utility\ITimeFactory; +use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IConfig; use OCP\IDBConnection; use OCP\IUserManager; @@ -45,6 +46,12 @@ protected function configure(): void { 'Limit the number of listed users', 100, ) + ->addOption( + 'since', + null, + InputOption::VALUE_REQUIRED, + 'Filter by minimum export date', + ) ->addOption( 'delete', null, @@ -54,7 +61,13 @@ protected function configure(): void { } protected function execute(InputInterface $input, OutputInterface $output): int { - $values = iterator_to_array($this->queryUsers((int)$input->getOption('limit'))); + if ((string)$input->getOption('since') !== '') { + $since = new \DateTime($input->getOption('since')); + $output->writeln('Since ' . $since->format(\DateTimeInterface::ATOM) . ''); + } else { + $since = null; + } + $values = iterator_to_array($this->queryUsers((int)$input->getOption('limit'), $since)); $this->writeTableInOutputFormat($input, $output, $values); if ($input->getOption('delete')) { /** @var QuestionHelper $helper */ @@ -73,13 +86,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int return self::SUCCESS; } - private function queryUsers(int $limit): \Generator { + private function queryUsers(int $limit, ?\DateTime $since): \Generator { $qb = $this->connection->getQueryBuilder(); $qb->select('userid', 'configvalue') ->from('preferences') ->where($qb->expr()->eq('appid', $qb->createNamedParameter(Application::APP_ID))) - ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('lastExport'))) - ->orderBy('configvalue') + ->andWhere($qb->expr()->eq('configkey', $qb->createNamedParameter('lastExport'))); + + if ($since !== null) { + $qb->andWhere($qb->expr()->gte('configvalue', $qb->createNamedParameter($since->getTimestamp(), IQueryBuilder::PARAM_INT))); + } + + $qb->orderBy('configvalue') ->setMaxResults($limit); $result = $qb->executeQuery();