Skip to content
Merged
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
1 change: 1 addition & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ This app allows users to easily migrate from one instance to another using an ex
</dependencies>

<commands>
<command>OCA\UserMigration\Command\Manage</command>
<command>OCA\UserMigration\Command\Export</command>
<command>OCA\UserMigration\Command\Import</command>
</commands>
Expand Down
6 changes: 6 additions & 0 deletions lib/Command/Export.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}
Expand Down Expand Up @@ -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()) {
Expand Down
5 changes: 5 additions & 0 deletions lib/Command/Import.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +25,7 @@ class Import extends Command {
public function __construct(
private IUserManager $userManager,
private UserMigrationService $migrationService,
private IConfig $config,
) {
parent::__construct();
}
Expand Down Expand Up @@ -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()) {
Expand Down
137 changes: 137 additions & 0 deletions lib/Command/Manage.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\UserMigration\Command;

use OC\Core\Command\Base;
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;
use Symfony\Component\Console\Helper\QuestionHelper;
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 Manage extends Base {
public function __construct(
private IDBConnection $connection,
private IUserManager $userManager,
private UserMigrationService $migrationService,
private IConfig $config,
private ITimeFactory $timeFactory,
) {
parent::__construct();
}

protected function configure(): void {
parent::configure();
$this
->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(
'since',
null,
InputOption::VALUE_REQUIRED,
'Filter by minimum export date',
)
->addOption(
'delete',
null,
InputOption::VALUE_NONE,
'Delete the exported users',
);
}

protected function execute(InputInterface $input, OutputInterface $output): int {
if ((string)$input->getOption('since') !== '') {
$since = new \DateTime($input->getOption('since'));
$output->writeln('<info>Since ' . $since->format(\DateTimeInterface::ATOM) . '</info>');
} 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 */
$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('<info>Deletion canceled</info>');
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, ?\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')));

if ($since !== null) {
$qb->andWhere($qb->expr()->gte('configvalue', $qb->createNamedParameter($since->getTimestamp(), IQueryBuilder::PARAM_INT)));
}

$qb->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<string> $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('<error>User ' . $uid . ' does not exist</error>');
$errors++;
continue;
}

if ($user->delete()) {
$output->writeln('<info>User "' . $uid . '" was deleted</info>');
} else {
$output->writeln('<error>User "' . $uid . '" could not be deleted. Please check the logs.</error>');
$errors++;
}
}
return $errors;
}
}
4 changes: 3 additions & 1 deletion tests/stubs/stub.phpstub
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {}
}
}
Loading