Ask before installing user_migration; add the beta how-to

backup:user:backup and backup:user:restore now ask the administrator before
installing or enabling user_migration (or take --install-user-migration), so
nothing changes on the server without consent. The restore warns that
user_migration imports only its allowlisted app settings.

Also: a clear error for unknown locations instead of a raw database message;
prune reports the number of affected users, not the number of kept ones; and
--user-last rejects values that are not whole numbers. Adds docs/HOWTO.md and
updates the README and PLAN.md for the beta.
This commit is contained in:
2026-10-06 16:57:56 +02:00
parent ba7f70e0f8
commit 943c95bf07
9 changed files with 268 additions and 8 deletions
+5
View File
@@ -9,10 +9,12 @@ namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use OCA\NgBackup\Service\UserBackupService;
use OCA\NgBackup\UserMigration\UserMigrationInstaller;
use OCP\IUserManager;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class BackupUser extends Command {
@@ -20,6 +22,7 @@ final class BackupUser extends Command {
private TargetService $targets,
private UserBackupService $userBackup,
private IUserManager $userManager,
private UserMigrationInstaller $installer,
) {
parent::__construct();
}
@@ -29,6 +32,7 @@ final class BackupUser extends Command {
->setDescription('Back up one user via user_migration (account, settings, files and everything else a migrator covers)')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('user', InputArgument::REQUIRED, 'User id')
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
->setHelp(<<<'HELP'
Streams a user_migration export straight into the encrypted repository: no zip, no copy in the
user's own storage. Shares data with the regular backup; this needs the user_migration app
@@ -47,6 +51,7 @@ HELP);
return 1;
}
try {
$this->installer->ensureEnabled($input, $output);
$target = $this->targets->get($input->getArgument('target'));
$manifest = $this->userBackup->backupUser($target, $user);
} catch (\Throwable $e) {
+2 -1
View File
@@ -48,8 +48,9 @@ final class Prune extends Command {
continue;
}
if ($ru['forgotten'] !== []) {
$affected = count(array_unique(array_map(static fn (string $path): string => explode('/', $path)[1] ?? '', $ru['forgotten'])));
$output->writeln(sprintf('<info>%s</info>: %s %d user export(s) for %d user(s)',
$t->getName(), $dry ? 'would forget' : 'forgot', count($ru['forgotten']), count($ru['kept'])));
$t->getName(), $dry ? 'would forget' : 'forgot', count($ru['forgotten']), $affected));
}
try {
$r = $this->prune->apply($t, $dry);
+5
View File
@@ -9,6 +9,7 @@ namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use OCA\NgBackup\Service\UserRestoreService;
use OCA\NgBackup\UserMigration\UserMigrationInstaller;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
@@ -19,6 +20,7 @@ final class RestoreUser extends Command {
public function __construct(
private TargetService $targets,
private UserRestoreService $userRestore,
private UserMigrationInstaller $installer,
) {
parent::__construct();
}
@@ -29,6 +31,7 @@ final class RestoreUser extends Command {
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('manifest', InputArgument::REQUIRED, 'Manifest path printed by backup:user:backup (users/<uid>/<exportId>)')
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'replace or as-backup', UserRestoreService::MODE_REPLACE)
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
->setHelp(<<<'HELP'
Never imports over a live account. Modes:
replace (default) safety-exports the current account first, then deletes and recreates it
@@ -45,6 +48,8 @@ HELP);
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$this->installer->ensureEnabled($input, $output);
$output->writeln('<comment>Note: on restore, user_migration only imports the app settings on its own allowlist (e.g. calendar view and reminder settings); other app settings of this user are skipped. Reason: NG Backup uses user_migration\'s importer; widening the list would mean building a settings importer of our own.</comment>');
$target = $this->targets->get($input->getArgument('target'));
$user = $this->userRestore->restoreUser($target, (string)$input->getArgument('manifest'), (string)$input->getOption('mode'));
} catch (\Throwable $e) {
+6 -1
View File
@@ -50,8 +50,13 @@ final class Retention extends Command {
}
}
if ($input->getOption('user-last') !== null) {
$userLast = (string)$input->getOption('user-last');
if (!ctype_digit($userLast)) {
$output->writeln('<error>--user-last must be a whole number</error>');
return 1;
}
try {
$this->prune->setUserExportRetention((int)$input->getOption('user-last'));
$this->prune->setUserExportRetention((int)$userLast);
} catch (\InvalidArgumentException $e) {
$output->writeln('<error>' . $e->getMessage() . '</error>');
return 1;