diff options
Diffstat (limited to 'core/Command/User')
-rw-r--r-- | core/Command/User/Add.php | 195 | ||||
-rw-r--r-- | core/Command/User/AuthTokens/Add.php | 104 | ||||
-rw-r--r-- | core/Command/User/AuthTokens/Delete.php | 104 | ||||
-rw-r--r-- | core/Command/User/AuthTokens/ListCommand.php | 84 | ||||
-rw-r--r-- | core/Command/User/ClearGeneratedAvatarCacheCommand.php | 35 | ||||
-rw-r--r-- | core/Command/User/Delete.php | 63 | ||||
-rw-r--r-- | core/Command/User/Disable.php | 65 | ||||
-rw-r--r-- | core/Command/User/Enable.php | 65 | ||||
-rw-r--r-- | core/Command/User/Info.php | 112 | ||||
-rw-r--r-- | core/Command/User/Keys/Verify.php | 83 | ||||
-rw-r--r-- | core/Command/User/LastSeen.php | 95 | ||||
-rw-r--r-- | core/Command/User/ListCommand.php | 108 | ||||
-rw-r--r-- | core/Command/User/Profile.php | 234 | ||||
-rw-r--r-- | core/Command/User/Report.php | 89 | ||||
-rw-r--r-- | core/Command/User/ResetPassword.php | 129 | ||||
-rw-r--r-- | core/Command/User/Setting.php | 264 | ||||
-rw-r--r-- | core/Command/User/SyncAccountDataCommand.php | 84 | ||||
-rw-r--r-- | core/Command/User/Welcome.php | 78 |
18 files changed, 1991 insertions, 0 deletions
diff --git a/core/Command/User/Add.php b/core/Command/User/Add.php new file mode 100644 index 00000000000..4de4e247991 --- /dev/null +++ b/core/Command/User/Add.php @@ -0,0 +1,195 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\User; + +use OC\Files\Filesystem; +use OCA\Settings\Mailer\NewUserMailHelper; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IAppConfig; +use OCP\IGroup; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use OCP\Mail\IMailer; +use OCP\Security\Events\GenerateSecurePasswordEvent; +use OCP\Security\ISecureRandom; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; + +class Add extends Command { + public function __construct( + protected IUserManager $userManager, + protected IGroupManager $groupManager, + protected IMailer $mailer, + private IAppConfig $appConfig, + private NewUserMailHelper $mailHelper, + private IEventDispatcher $eventDispatcher, + private ISecureRandom $secureRandom, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('user:add') + ->setDescription('adds an account') + ->addArgument( + 'uid', + InputArgument::REQUIRED, + 'Account ID used to login (must only contain a-z, A-Z, 0-9, -, _ and @)' + ) + ->addOption( + 'password-from-env', + null, + InputOption::VALUE_NONE, + 'read password from environment variable NC_PASS/OC_PASS' + ) + ->addOption( + 'generate-password', + null, + InputOption::VALUE_NONE, + 'Generate a secure password. A welcome email with a reset link will be sent to the user via an email if --email option and newUser.sendEmail config are set' + ) + ->addOption( + 'display-name', + null, + InputOption::VALUE_OPTIONAL, + 'Login used in the web UI (can contain any characters)' + ) + ->addOption( + 'group', + 'g', + InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, + 'groups the account should be added to (The group will be created if it does not exist)' + ) + ->addOption( + 'email', + null, + InputOption::VALUE_REQUIRED, + 'When set, users may register using the default email verification workflow' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $uid = $input->getArgument('uid'); + if ($this->userManager->userExists($uid)) { + $output->writeln('<error>The account "' . $uid . '" already exists.</error>'); + return 1; + } + + $password = ''; + + // Setup password. + if ($input->getOption('password-from-env')) { + $password = getenv('NC_PASS') ?: getenv('OC_PASS'); + + if (!$password) { + $output->writeln('<error>--password-from-env given, but NC_PASS/OC_PASS is empty!</error>'); + return 1; + } + } elseif ($input->getOption('generate-password')) { + $passwordEvent = new GenerateSecurePasswordEvent(); + $this->eventDispatcher->dispatchTyped($passwordEvent); + $password = $passwordEvent->getPassword() ?? $this->secureRandom->generate(20); + } elseif ($input->isInteractive()) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + $question = new Question('Enter password: '); + $question->setHidden(true); + $password = $helper->ask($input, $output, $question); + + $question = new Question('Confirm password: '); + $question->setHidden(true); + $confirm = $helper->ask($input, $output, $question); + + if ($password !== $confirm) { + $output->writeln('<error>Passwords did not match!</error>'); + return 1; + } + } else { + $output->writeln('<error>Interactive input or --password-from-env or --generate-password is needed for setting a password!</error>'); + return 1; + } + + try { + $user = $this->userManager->createUser( + $input->getArgument('uid'), + $password, + ); + } catch (\Exception $e) { + $output->writeln('<error>' . $e->getMessage() . '</error>'); + return 1; + } + + if ($user instanceof IUser) { + $output->writeln('<info>The account "' . $user->getUID() . '" was created successfully</info>'); + } else { + $output->writeln('<error>An error occurred while creating the account</error>'); + return 1; + } + + if ($input->getOption('display-name')) { + $user->setDisplayName($input->getOption('display-name')); + $output->writeln('Display name set to "' . $user->getDisplayName() . '"'); + } + + $groups = $input->getOption('group'); + + if (!empty($groups)) { + // Make sure we init the Filesystem for the user, in case we need to + // init some group shares. + Filesystem::init($user->getUID(), ''); + } + + foreach ($groups as $groupName) { + $group = $this->groupManager->get($groupName); + if (!$group) { + $this->groupManager->createGroup($groupName); + $group = $this->groupManager->get($groupName); + if ($group instanceof IGroup) { + $output->writeln('Created group "' . $group->getGID() . '"'); + } + } + if ($group instanceof IGroup) { + $group->addUser($user); + $output->writeln('Account "' . $user->getUID() . '" added to group "' . $group->getGID() . '"'); + } + } + + $email = $input->getOption('email'); + if (!empty($email)) { + if (!$this->mailer->validateMailAddress($email)) { + $output->writeln(\sprintf( + '<error>The given email address "%s" is invalid. Email not set for the user.</error>', + $email, + )); + + return 1; + } + + $user->setSystemEMailAddress($email); + + if ($this->appConfig->getValueString('core', 'newUser.sendEmail', 'yes') === 'yes') { + try { + $this->mailHelper->sendMail($user, $this->mailHelper->generateTemplate($user, true)); + $output->writeln('Welcome email sent to ' . $email); + } catch (\Exception $e) { + $output->writeln('Unable to send the welcome email to ' . $email); + } + } + } + + return 0; + } +} diff --git a/core/Command/User/AuthTokens/Add.php b/core/Command/User/AuthTokens/Add.php new file mode 100644 index 00000000000..89b20535c63 --- /dev/null +++ b/core/Command/User/AuthTokens/Add.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\User\AuthTokens; + +use OC\Authentication\Events\AppPasswordCreatedEvent; +use OC\Authentication\Token\IProvider; +use OC\Authentication\Token\IToken; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUserManager; +use OCP\Security\ISecureRandom; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\Question; + +class Add extends Command { + public function __construct( + protected IUserManager $userManager, + protected IProvider $tokenProvider, + private ISecureRandom $random, + private IEventDispatcher $eventDispatcher, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('user:auth-tokens:add') + ->setAliases(['user:add-app-password']) + ->setDescription('Add app password for the named account') + ->addArgument( + 'user', + InputArgument::REQUIRED, + 'Login to add app password for' + ) + ->addOption( + 'password-from-env', + null, + InputOption::VALUE_NONE, + 'Read password from environment variable NC_PASS/OC_PASS. Alternatively it will be asked for interactively or an app password without the login password will be created.' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $username = $input->getArgument('user'); + $password = null; + + $user = $this->userManager->get($username); + if (is_null($user)) { + $output->writeln('<error>Account does not exist</error>'); + return 1; + } + + if ($input->getOption('password-from-env')) { + $password = getenv('NC_PASS') ?: getenv('OC_PASS'); + if (!$password) { + $output->writeln('<error>--password-from-env given, but NC_PASS/OC_PASS is empty!</error>'); + return 1; + } + } elseif ($input->isInteractive()) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + $question = new Question('Enter the account password: '); + $question->setHidden(true); + /** @var null|string $password */ + $password = $helper->ask($input, $output, $question); + } + + if ($password === null) { + $output->writeln('<info>No password provided. The generated app password will therefore have limited capabilities. Any operation that requires the login password will fail.</info>'); + } + + $token = $this->random->generate(72, ISecureRandom::CHAR_UPPER . ISecureRandom::CHAR_LOWER . ISecureRandom::CHAR_DIGITS); + $generatedToken = $this->tokenProvider->generateToken( + $token, + $user->getUID(), + $user->getUID(), + $password, + 'cli', + IToken::PERMANENT_TOKEN, + IToken::DO_NOT_REMEMBER + ); + + $this->eventDispatcher->dispatchTyped( + new AppPasswordCreatedEvent($generatedToken) + ); + + $output->writeln('app password:'); + $output->writeln($token); + + return 0; + } +} diff --git a/core/Command/User/AuthTokens/Delete.php b/core/Command/User/AuthTokens/Delete.php new file mode 100644 index 00000000000..2047d2eae2a --- /dev/null +++ b/core/Command/User/AuthTokens/Delete.php @@ -0,0 +1,104 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\User\AuthTokens; + +use DateTimeImmutable; +use OC\Authentication\Token\IProvider; +use OC\Core\Command\Base; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Delete extends Base { + public function __construct( + protected IProvider $tokenProvider, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('user:auth-tokens:delete') + ->setDescription('Deletes an authentication token') + ->addArgument( + 'uid', + InputArgument::REQUIRED, + 'ID of the user to delete tokens for' + ) + ->addArgument( + 'id', + InputArgument::OPTIONAL, + 'ID of the auth token to delete' + ) + ->addOption( + 'last-used-before', + null, + InputOption::VALUE_REQUIRED, + 'Delete tokens last used before a given date.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $uid = $input->getArgument('uid'); + $id = (int)$input->getArgument('id'); + $before = $input->getOption('last-used-before'); + + if ($before) { + if ($id) { + throw new RuntimeException('Option --last-used-before cannot be used with [<id>]'); + } + + return $this->deleteLastUsedBefore($uid, $before); + } + + if (!$id) { + throw new RuntimeException('Not enough arguments. Specify the token <id> or use the --last-used-before option.'); + } + return $this->deleteById($uid, $id); + } + + protected function deleteById(string $uid, int $id): int { + $this->tokenProvider->invalidateTokenById($uid, $id); + + return Command::SUCCESS; + } + + protected function deleteLastUsedBefore(string $uid, string $before): int { + $date = $this->parseDateOption($before); + if (!$date) { + throw new RuntimeException('Invalid date format. Acceptable formats are: ISO8601 (w/o fractions), "YYYY-MM-DD" and Unix time in seconds.'); + } + + $this->tokenProvider->invalidateLastUsedBefore($uid, $date->getTimestamp()); + + return Command::SUCCESS; + } + + /** + * @return \DateTimeImmutable|false + */ + protected function parseDateOption(string $input) { + $date = false; + + // Handle Unix timestamp + if (filter_var($input, FILTER_VALIDATE_INT)) { + return new DateTimeImmutable('@' . $input); + } + + // ISO8601 + $date = DateTimeImmutable::createFromFormat(DateTimeImmutable::ATOM, $input); + if ($date) { + return $date; + } + + // YYYY-MM-DD + return DateTimeImmutable::createFromFormat('!Y-m-d', $input); + } +} diff --git a/core/Command/User/AuthTokens/ListCommand.php b/core/Command/User/AuthTokens/ListCommand.php new file mode 100644 index 00000000000..b36aa717505 --- /dev/null +++ b/core/Command/User/AuthTokens/ListCommand.php @@ -0,0 +1,84 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\User\AuthTokens; + +use OC\Authentication\Token\IProvider; +use OC\Authentication\Token\IToken; +use OC\Core\Command\Base; +use OCP\IUserManager; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class ListCommand extends Base { + public function __construct( + protected IUserManager $userManager, + protected IProvider $tokenProvider, + ) { + parent::__construct(); + } + + protected function configure(): void { + parent::configure(); + + $this + ->setName('user:auth-tokens:list') + ->setDescription('List authentication tokens of an user') + ->addArgument( + 'user', + InputArgument::REQUIRED, + 'User to list auth tokens for' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = $this->userManager->get($input->getArgument('user')); + + if (is_null($user)) { + $output->writeln('<error>user not found</error>'); + return 1; + } + + $tokens = $this->tokenProvider->getTokenByUser($user->getUID()); + + $tokens = array_map(function (IToken $token) use ($input): mixed { + $sensitive = [ + 'password', + 'password_hash', + 'token', + 'public_key', + 'private_key', + ]; + $data = array_diff_key($token->jsonSerialize(), array_flip($sensitive)); + + if ($input->getOption('output') === self::OUTPUT_FORMAT_PLAIN) { + $data = $this->formatTokenForPlainOutput($data); + } + + return $data; + }, $tokens); + + $this->writeTableInOutputFormat($input, $output, $tokens); + + return 0; + } + + public function formatTokenForPlainOutput(array $token): array { + $token['scope'] = implode(', ', array_keys(array_filter($token['scope'] ?? []))); + + $token['lastActivity'] = date(DATE_ATOM, $token['lastActivity']); + + $token['type'] = match ($token['type']) { + IToken::TEMPORARY_TOKEN => 'temporary', + IToken::PERMANENT_TOKEN => 'permanent', + IToken::WIPE_TOKEN => 'wipe', + default => $token['type'], + }; + + return $token; + } +} diff --git a/core/Command/User/ClearGeneratedAvatarCacheCommand.php b/core/Command/User/ClearGeneratedAvatarCacheCommand.php new file mode 100644 index 00000000000..515b3a913b7 --- /dev/null +++ b/core/Command/User/ClearGeneratedAvatarCacheCommand.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\User; + +use OC\Avatar\AvatarManager; +use OC\Core\Command\Base; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class ClearGeneratedAvatarCacheCommand extends Base { + public function __construct( + protected AvatarManager $avatarManager, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setDescription('clear avatar cache') + ->setName('user:clear-avatar-cache'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $output->writeln('Clearing avatar cache has started'); + $this->avatarManager->clearCachedAvatars(); + $output->writeln('Cleared avatar cache successfully'); + return 0; + } +} diff --git a/core/Command/User/Delete.php b/core/Command/User/Delete.php new file mode 100644 index 00000000000..c5d0578f5f8 --- /dev/null +++ b/core/Command/User/Delete.php @@ -0,0 +1,63 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\IUser; +use OCP\IUserManager; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class Delete extends Base { + public function __construct( + protected IUserManager $userManager, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('user:delete') + ->setDescription('deletes the specified user') + ->addArgument( + 'uid', + InputArgument::REQUIRED, + 'the username' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = $this->userManager->get($input->getArgument('uid')); + if (is_null($user)) { + $output->writeln('<error>User does not exist</error>'); + return 0; + } + + if ($user->delete()) { + $output->writeln('<info>The specified user was deleted</info>'); + return 0; + } + + $output->writeln('<error>The specified user could not be deleted. Please check the logs.</error>'); + return 1; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'uid') { + return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord())); + } + return []; + } +} diff --git a/core/Command/User/Disable.php b/core/Command/User/Disable.php new file mode 100644 index 00000000000..4713950bf30 --- /dev/null +++ b/core/Command/User/Disable.php @@ -0,0 +1,65 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\IUser; +use OCP\IUserManager; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class Disable extends Base { + public function __construct( + protected IUserManager $userManager, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('user:disable') + ->setDescription('disables the specified user') + ->addArgument( + 'uid', + InputArgument::REQUIRED, + 'the username' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = $this->userManager->get($input->getArgument('uid')); + if (is_null($user)) { + $output->writeln('<error>User does not exist</error>'); + return 1; + } + + $user->setEnabled(false); + $output->writeln('<info>The specified user is disabled</info>'); + return 0; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'uid') { + return array_map( + static fn (IUser $user) => $user->getUID(), + array_filter( + $this->userManager->search($context->getCurrentWord()), + static fn (IUser $user) => $user->isEnabled() + ) + ); + } + return []; + } +} diff --git a/core/Command/User/Enable.php b/core/Command/User/Enable.php new file mode 100644 index 00000000000..23f56e5dd4f --- /dev/null +++ b/core/Command/User/Enable.php @@ -0,0 +1,65 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\IUser; +use OCP\IUserManager; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +class Enable extends Base { + public function __construct( + protected IUserManager $userManager, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('user:enable') + ->setDescription('enables the specified user') + ->addArgument( + 'uid', + InputArgument::REQUIRED, + 'the username' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = $this->userManager->get($input->getArgument('uid')); + if (is_null($user)) { + $output->writeln('<error>User does not exist</error>'); + return 1; + } + + $user->setEnabled(true); + $output->writeln('<info>The specified user is enabled</info>'); + return 0; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'uid') { + return array_map( + static fn (IUser $user) => $user->getUID(), + array_filter( + $this->userManager->search($context->getCurrentWord()), + static fn (IUser $user) => !$user->isEnabled() + ) + ); + } + return []; + } +} diff --git a/core/Command/User/Info.php b/core/Command/User/Info.php new file mode 100644 index 00000000000..e7fc9286e74 --- /dev/null +++ b/core/Command/User/Info.php @@ -0,0 +1,112 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\Files\NotFoundException; +use OCP\IGroupManager; +use OCP\IUser; +use OCP\IUserManager; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Info extends Base { + public function __construct( + protected IUserManager $userManager, + protected IGroupManager $groupManager, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('user:info') + ->setDescription('show user info') + ->addArgument( + 'user', + InputArgument::REQUIRED, + 'user to show' + )->addOption( + 'output', + null, + InputOption::VALUE_OPTIONAL, + 'Output format (plain, json or json_pretty, default is plain)', + $this->defaultOutputFormat + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $user = $this->userManager->get($input->getArgument('user')); + if (is_null($user)) { + $output->writeln('<error>user not found</error>'); + return 1; + } + $groups = $this->groupManager->getUserGroupIds($user); + $data = [ + 'user_id' => $user->getUID(), + 'display_name' => $user->getDisplayName(), + 'email' => (string)$user->getSystemEMailAddress(), + 'cloud_id' => $user->getCloudId(), + 'enabled' => $user->isEnabled(), + 'groups' => $groups, + 'quota' => $user->getQuota(), + 'storage' => $this->getStorageInfo($user), + 'first_seen' => $this->formatLoginDate($user->getFirstLogin()), + 'last_seen' => $this->formatLoginDate($user->getLastLogin()), + 'user_directory' => $user->getHome(), + 'backend' => $user->getBackendClassName() + ]; + $this->writeArrayInOutputFormat($input, $output, $data); + return 0; + } + + private function formatLoginDate(int $timestamp): string { + if ($timestamp < 0) { + return 'unknown'; + } elseif ($timestamp === 0) { + return 'never'; + } else { + return date(\DateTimeInterface::ATOM, $timestamp); // ISO-8601 + } + } + + /** + * @param IUser $user + * @return array + */ + protected function getStorageInfo(IUser $user): array { + \OC_Util::tearDownFS(); + \OC_Util::setupFS($user->getUID()); + try { + $storage = \OC_Helper::getStorageInfo('/'); + } catch (NotFoundException $e) { + return []; + } + return [ + 'free' => $storage['free'], + 'used' => $storage['used'], + 'total' => $storage['total'], + 'relative' => $storage['relative'], + 'quota' => $storage['quota'], + ]; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'user') { + return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord())); + } + return []; + } +} diff --git a/core/Command/User/Keys/Verify.php b/core/Command/User/Keys/Verify.php new file mode 100644 index 00000000000..024e9346072 --- /dev/null +++ b/core/Command/User/Keys/Verify.php @@ -0,0 +1,83 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Core\Command\User\Keys; + +use OC\Security\IdentityProof\Manager; +use OCP\IUser; +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\Output\OutputInterface; + +class Verify extends Command { + public function __construct( + protected IUserManager $userManager, + protected Manager $keyManager, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('user:keys:verify') + ->setDescription('Verify if the stored public key matches the stored private key') + ->addArgument( + 'user-id', + InputArgument::REQUIRED, + 'User ID of the user to verify' + ) + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $userId = $input->getArgument('user-id'); + + $user = $this->userManager->get($userId); + if (!$user instanceof IUser) { + $output->writeln('Unknown user'); + return static::FAILURE; + } + + $key = $this->keyManager->getKey($user); + $publicKey = $key->getPublic(); + $privateKey = $key->getPrivate(); + + $output->writeln('User public key size: ' . strlen($publicKey)); + $output->writeln('User private key size: ' . strlen($privateKey)); + + // Derive the public key from the private key again to validate the stored public key + $opensslPrivateKey = openssl_pkey_get_private($privateKey); + $publicKeyDerived = openssl_pkey_get_details($opensslPrivateKey); + $publicKeyDerived = $publicKeyDerived['key']; + $output->writeln('User derived public key size: ' . strlen($publicKeyDerived)); + + $output->writeln(''); + + $output->writeln('Stored public key:'); + $output->writeln($publicKey); + $output->writeln('Derived public key:'); + $output->writeln($publicKeyDerived); + + if ($publicKey != $publicKeyDerived) { + $output->writeln('<error>Stored public key does not match stored private key</error>'); + return static::FAILURE; + } + + $output->writeln('<info>Stored public key matches stored private key</info>'); + + return static::SUCCESS; + } +} diff --git a/core/Command/User/LastSeen.php b/core/Command/User/LastSeen.php new file mode 100644 index 00000000000..984def72cd6 --- /dev/null +++ b/core/Command/User/LastSeen.php @@ -0,0 +1,95 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\IUser; +use OCP\IUserManager; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class LastSeen extends Base { + public function __construct( + protected IUserManager $userManager, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('user:lastseen') + ->setDescription('shows when the user was logged in last time') + ->addArgument( + 'uid', + InputArgument::OPTIONAL, + 'the username' + ) + ->addOption( + 'all', + null, + InputOption::VALUE_NONE, + 'shows a list of when all users were last logged in' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $singleUserId = $input->getArgument('uid'); + + if ($singleUserId) { + $user = $this->userManager->get($singleUserId); + if (is_null($user)) { + $output->writeln('<error>User does not exist</error>'); + return 1; + } + + $lastLogin = $user->getLastLogin(); + if ($lastLogin === 0) { + $output->writeln($user->getUID() . ' has never logged in.'); + } else { + $date = new \DateTime(); + $date->setTimestamp($lastLogin); + $output->writeln($user->getUID() . "'s last login: " . $date->format('Y-m-d H:i:s T')); + } + + return 0; + } + + if (!$input->getOption('all')) { + $output->writeln('<error>Please specify a username, or "--all" to list all</error>'); + return 1; + } + + $this->userManager->callForAllUsers(static function (IUser $user) use ($output): void { + $lastLogin = $user->getLastLogin(); + if ($lastLogin === 0) { + $output->writeln($user->getUID() . ' has never logged in.'); + } else { + $date = new \DateTime(); + $date->setTimestamp($lastLogin); + $output->writeln($user->getUID() . "'s last login: " . $date->format('Y-m-d H:i:s T')); + } + }); + return 0; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'uid') { + return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord())); + } + return []; + } +} diff --git a/core/Command/User/ListCommand.php b/core/Command/User/ListCommand.php new file mode 100644 index 00000000000..66b831c793b --- /dev/null +++ b/core/Command/User/ListCommand.php @@ -0,0 +1,108 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\IGroupManager; +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 ListCommand extends Base { + public function __construct( + protected IUserManager $userManager, + protected IGroupManager $groupManager, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('user:list') + ->setDescription('list configured users') + ->addOption( + 'disabled', + 'd', + InputOption::VALUE_NONE, + 'List disabled users only' + )->addOption( + 'limit', + 'l', + InputOption::VALUE_OPTIONAL, + 'Number of users to retrieve', + '500' + )->addOption( + 'offset', + 'o', + InputOption::VALUE_OPTIONAL, + 'Offset for retrieving users', + '0' + )->addOption( + 'output', + null, + InputOption::VALUE_OPTIONAL, + 'Output format (plain, json or json_pretty, default is plain)', + $this->defaultOutputFormat + )->addOption( + 'info', + 'i', + InputOption::VALUE_NONE, + 'Show detailed info' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + if ($input->getOption('disabled')) { + $users = $this->userManager->getDisabledUsers((int)$input->getOption('limit'), (int)$input->getOption('offset')); + } else { + $users = $this->userManager->searchDisplayName('', (int)$input->getOption('limit'), (int)$input->getOption('offset')); + } + + $this->writeArrayInOutputFormat($input, $output, $this->formatUsers($users, (bool)$input->getOption('info'))); + return 0; + } + + /** + * @param IUser[] $users + * @return \Generator<string,string|array> + */ + private function formatUsers(array $users, bool $detailed = false): \Generator { + foreach ($users as $user) { + if ($detailed) { + $groups = $this->groupManager->getUserGroupIds($user); + $value = [ + 'user_id' => $user->getUID(), + 'display_name' => $user->getDisplayName(), + 'email' => (string)$user->getSystemEMailAddress(), + 'cloud_id' => $user->getCloudId(), + 'enabled' => $user->isEnabled(), + 'groups' => $groups, + 'quota' => $user->getQuota(), + 'first_seen' => $this->formatLoginDate($user->getFirstLogin()), + 'last_seen' => $this->formatLoginDate($user->getLastLogin()), + 'user_directory' => $user->getHome(), + 'backend' => $user->getBackendClassName() + ]; + } else { + $value = $user->getDisplayName(); + } + yield $user->getUID() => $value; + } + } + + private function formatLoginDate(int $timestamp): string { + if ($timestamp < 0) { + return 'unknown'; + } elseif ($timestamp === 0) { + return 'never'; + } else { + return date(\DateTimeInterface::ATOM, $timestamp); // ISO-8601 + } + } +} diff --git a/core/Command/User/Profile.php b/core/Command/User/Profile.php new file mode 100644 index 00000000000..fd5fbed08cd --- /dev/null +++ b/core/Command/User/Profile.php @@ -0,0 +1,234 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\Accounts\IAccount; +use OCP\Accounts\IAccountManager; +use OCP\Accounts\PropertyDoesNotExistException; +use OCP\IUser; +use OCP\IUserManager; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Profile extends Base { + public function __construct( + protected IUserManager $userManager, + protected IAccountManager $accountManager, + ) { + parent::__construct(); + } + + protected function configure() { + parent::configure(); + $this + ->setName('user:profile') + ->setDescription('Read and modify user profile properties') + ->addArgument( + 'uid', + InputArgument::REQUIRED, + 'Account ID used to login' + ) + ->addArgument( + 'key', + InputArgument::OPTIONAL, + 'Profile property to set, get or delete', + '' + ) + + // Get + ->addOption( + 'default-value', + null, + InputOption::VALUE_REQUIRED, + '(Only applicable on get) If no default value is set and the property does not exist, the command will exit with 1' + ) + + // Set + ->addArgument( + 'value', + InputArgument::OPTIONAL, + 'The new value of the property', + null + ) + ->addOption( + 'update-only', + null, + InputOption::VALUE_NONE, + 'Only updates the value, if it is not set before, it is not being added' + ) + + // Delete + ->addOption( + 'delete', + null, + InputOption::VALUE_NONE, + 'Specify this option to delete the property value' + ) + ->addOption( + 'error-if-not-exists', + null, + InputOption::VALUE_NONE, + 'Checks whether the property exists before deleting it' + ) + ; + } + + protected function checkInput(InputInterface $input): IUser { + $uid = $input->getArgument('uid'); + $user = $this->userManager->get($uid); + if (!$user) { + throw new \InvalidArgumentException('The user "' . $uid . '" does not exist.'); + } + // normalize uid + $input->setArgument('uid', $user->getUID()); + + $key = $input->getArgument('key'); + if ($key === '') { + if ($input->hasParameterOption('--default-value')) { + throw new \InvalidArgumentException('The "default-value" option can only be used when specifying a key.'); + } + if ($input->getArgument('value') !== null) { + throw new \InvalidArgumentException('The value argument can only be used when specifying a key.'); + } + if ($input->getOption('delete')) { + throw new \InvalidArgumentException('The "delete" option can only be used when specifying a key.'); + } + } + + if ($input->getArgument('value') !== null && $input->hasParameterOption('--default-value')) { + throw new \InvalidArgumentException('The value argument can not be used together with "default-value".'); + } + if ($input->getOption('update-only') && $input->getArgument('value') === null) { + throw new \InvalidArgumentException('The "update-only" option can only be used together with "value".'); + } + + if ($input->getOption('delete') && $input->hasParameterOption('--default-value')) { + throw new \InvalidArgumentException('The "delete" option can not be used together with "default-value".'); + } + if ($input->getOption('delete') && $input->getArgument('value') !== null) { + throw new \InvalidArgumentException('The "delete" option can not be used together with "value".'); + } + if ($input->getOption('error-if-not-exists') && !$input->getOption('delete')) { + throw new \InvalidArgumentException('The "error-if-not-exists" option can only be used together with "delete".'); + } + + return $user; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $user = $this->checkInput($input); + } catch (\InvalidArgumentException $e) { + $output->writeln('<error>' . $e->getMessage() . '</error>'); + return self::FAILURE; + } + + $uid = $input->getArgument('uid'); + $key = $input->getArgument('key'); + $userAccount = $this->accountManager->getAccount($user); + + if ($key === '') { + $settings = $this->getAllProfileProperties($userAccount); + $this->writeArrayInOutputFormat($input, $output, $settings); + return self::SUCCESS; + } + + $value = $this->getStoredValue($userAccount, $key); + $inputValue = $input->getArgument('value'); + if ($inputValue !== null) { + if ($input->hasParameterOption('--update-only') && $value === null) { + $output->writeln('<error>The property does not exist for user "' . $uid . '".</error>'); + return self::FAILURE; + } + + return $this->editProfileProperty($output, $userAccount, $key, $inputValue); + } elseif ($input->hasParameterOption('--delete')) { + if ($input->hasParameterOption('--error-if-not-exists') && $value === null) { + $output->writeln('<error>The property does not exist for user "' . $uid . '".</error>'); + return self::FAILURE; + } + + return $this->deleteProfileProperty($output, $userAccount, $key); + } elseif ($value !== null) { + $output->writeln($value); + } elseif ($input->hasParameterOption('--default-value')) { + $output->writeln($input->getOption('default-value')); + } else { + $output->writeln('<error>The property does not exist for user "' . $uid . '".</error>'); + return self::FAILURE; + } + + return self::SUCCESS; + } + + private function deleteProfileProperty(OutputInterface $output, IAccount $userAccount, string $key): int { + return $this->editProfileProperty($output, $userAccount, $key, ''); + } + + private function editProfileProperty(OutputInterface $output, IAccount $userAccount, string $key, string $value): int { + try { + $userAccount->getProperty($key)->setValue($value); + } catch (PropertyDoesNotExistException $exception) { + $output->writeln('<error>' . $exception->getMessage() . '</error>'); + return self::FAILURE; + } + + $this->accountManager->updateAccount($userAccount); + return self::SUCCESS; + } + + private function getStoredValue(IAccount $userAccount, string $key): ?string { + try { + $property = $userAccount->getProperty($key); + } catch (PropertyDoesNotExistException) { + return null; + } + return $property->getValue() === '' ? null : $property->getValue(); + } + + private function getAllProfileProperties(IAccount $userAccount): array { + $properties = []; + + foreach ($userAccount->getAllProperties() as $property) { + if ($property->getValue() !== '') { + $properties[$property->getName()] = $property->getValue(); + } + } + + return $properties; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context): array { + if ($argumentName === 'uid') { + return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord())); + } + if ($argumentName === 'key') { + $userId = $context->getWordAtIndex($context->getWordIndex() - 1); + $user = $this->userManager->get($userId); + if (!($user instanceof IUser)) { + return []; + } + + $account = $this->accountManager->getAccount($user); + + $properties = $this->getAllProfileProperties($account); + return array_keys($properties); + } + return []; + } +} diff --git a/core/Command/User/Report.php b/core/Command/User/Report.php new file mode 100644 index 00000000000..c0f054adb00 --- /dev/null +++ b/core/Command/User/Report.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\User; + +use OC\Files\View; +use OCP\IConfig; +use OCP\IUserManager; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Report extends Command { + public const DEFAULT_COUNT_DIRS_MAX_USERS = 500; + + public function __construct( + protected IUserManager $userManager, + private IConfig $config, + ) { + parent::__construct(); + } + + protected function configure(): void { + $this + ->setName('user:report') + ->setDescription('shows how many users have access') + ->addOption( + 'count-dirs', + null, + InputOption::VALUE_NONE, + 'Also count the number of user directories in the database (could time out on huge installations, therefore defaults to no with ' . self::DEFAULT_COUNT_DIRS_MAX_USERS . '+ users)' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $table = new Table($output); + $table->setHeaders(['Account Report', '']); + $userCountArray = $this->countUsers(); + $total = 0; + if (!empty($userCountArray)) { + $rows = []; + foreach ($userCountArray as $classname => $users) { + $total += $users; + $rows[] = [$classname, $users]; + } + + $rows[] = [' ']; + $rows[] = ['total users', $total]; + } else { + $rows[] = ['No backend enabled that supports user counting', '']; + } + $rows[] = [' ']; + + if ($total <= self::DEFAULT_COUNT_DIRS_MAX_USERS || $input->getOption('count-dirs')) { + $userDirectoryCount = $this->countUserDirectories(); + $rows[] = ['user directories', $userDirectoryCount]; + } + + $activeUsers = $this->userManager->countSeenUsers(); + $rows[] = ['active users', $activeUsers]; + + $disabledUsers = $this->config->getUsersForUserValue('core', 'enabled', 'false'); + $disabledUsersCount = count($disabledUsers); + $rows[] = ['disabled users', $disabledUsersCount]; + + $table->setRows($rows); + $table->render(); + return 0; + } + + private function countUsers(): array { + return $this->userManager->countUsers(); + } + + private function countUserDirectories(): int { + $dataview = new View('/'); + $userDirectories = $dataview->getDirectoryContent('/', 'httpd/unix-directory'); + return count($userDirectories); + } +} diff --git a/core/Command/User/ResetPassword.php b/core/Command/User/ResetPassword.php new file mode 100644 index 00000000000..0e8b1325770 --- /dev/null +++ b/core/Command/User/ResetPassword.php @@ -0,0 +1,129 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\App\IAppManager; +use OCP\IUser; +use OCP\IUserManager; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Question\ConfirmationQuestion; +use Symfony\Component\Console\Question\Question; + +class ResetPassword extends Base { + public function __construct( + protected IUserManager $userManager, + private IAppManager $appManager, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('user:resetpassword') + ->setDescription('Resets the password of the named user') + ->addArgument( + 'user', + InputArgument::REQUIRED, + 'Login to reset password' + ) + ->addOption( + 'password-from-env', + null, + InputOption::VALUE_NONE, + 'read password from environment variable NC_PASS/OC_PASS' + ) + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $username = $input->getArgument('user'); + + $user = $this->userManager->get($username); + if (is_null($user)) { + $output->writeln('<error>User does not exist</error>'); + return 1; + } + + if ($input->getOption('password-from-env')) { + $password = getenv('NC_PASS') ?: getenv('OC_PASS'); + if (!$password) { + $output->writeln('<error>--password-from-env given, but NC_PASS/OC_PASS is empty!</error>'); + return 1; + } + } elseif ($input->isInteractive()) { + /** @var QuestionHelper $helper */ + $helper = $this->getHelper('question'); + + if ($this->appManager->isEnabledForUser('encryption', $user)) { + $output->writeln( + '<error>Warning: Resetting the password when using encryption will result in data loss!</error>' + ); + + $question = new ConfirmationQuestion('Do you want to continue?'); + if (!$helper->ask($input, $output, $question)) { + return 1; + } + } + + $question = new Question('Enter a new password: '); + $question->setHidden(true); + $password = $helper->ask($input, $output, $question); + + if ($password === null) { + $output->writeln('<error>Password cannot be empty!</error>'); + return 1; + } + + $question = new Question('Confirm the new password: '); + $question->setHidden(true); + $confirm = $helper->ask($input, $output, $question); + + if ($password !== $confirm) { + $output->writeln('<error>Passwords did not match!</error>'); + return 1; + } + } else { + $output->writeln('<error>Interactive input or --password-from-env is needed for entering a new password!</error>'); + return 1; + } + + + try { + $success = $user->setPassword($password); + } catch (\Exception $e) { + $output->writeln('<error>' . $e->getMessage() . '</error>'); + return 1; + } + + if ($success) { + $output->writeln('<info>Successfully reset password for ' . $username . '</info>'); + } else { + $output->writeln('<error>Error while resetting password!</error>'); + return 1; + } + return 0; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'user') { + return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord())); + } + return []; + } +} diff --git a/core/Command/User/Setting.php b/core/Command/User/Setting.php new file mode 100644 index 00000000000..7fc5aab1dc7 --- /dev/null +++ b/core/Command/User/Setting.php @@ -0,0 +1,264 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-FileCopyrightText: 2016 ownCloud, Inc. + * SPDX-License-Identifier: AGPL-3.0-only + */ +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\IConfig; +use OCP\IUser; +use OCP\IUserManager; +use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Setting extends Base { + public function __construct( + protected IUserManager $userManager, + protected IConfig $config, + ) { + parent::__construct(); + } + + protected function configure() { + parent::configure(); + $this + ->setName('user:setting') + ->setDescription('Read and modify user settings') + ->addArgument( + 'uid', + InputArgument::REQUIRED, + 'Account ID used to login' + ) + ->addArgument( + 'app', + InputArgument::OPTIONAL, + 'Restrict the settings to a given app', + '' + ) + ->addArgument( + 'key', + InputArgument::OPTIONAL, + 'Setting key to set, get or delete', + '' + ) + ->addOption( + 'ignore-missing-user', + null, + InputOption::VALUE_NONE, + 'Use this option to ignore errors when the user does not exist' + ) + + // Get + ->addOption( + 'default-value', + null, + InputOption::VALUE_REQUIRED, + '(Only applicable on get) If no default value is set and the config does not exist, the command will exit with 1' + ) + + // Set + ->addArgument( + 'value', + InputArgument::OPTIONAL, + 'The new value of the setting', + null + ) + ->addOption( + 'update-only', + null, + InputOption::VALUE_NONE, + 'Only updates the value, if it is not set before, it is not being added' + ) + + // Delete + ->addOption( + 'delete', + null, + InputOption::VALUE_NONE, + 'Specify this option to delete the config' + ) + ->addOption( + 'error-if-not-exists', + null, + InputOption::VALUE_NONE, + 'Checks whether the setting exists before deleting it' + ) + ; + } + + protected function checkInput(InputInterface $input) { + if (!$input->getOption('ignore-missing-user')) { + $uid = $input->getArgument('uid'); + $user = $this->userManager->get($uid); + if (!$user) { + throw new \InvalidArgumentException('The user "' . $uid . '" does not exist.'); + } + // normalize uid + $input->setArgument('uid', $user->getUID()); + } + + if ($input->getArgument('key') === '' && $input->hasParameterOption('--default-value')) { + throw new \InvalidArgumentException('The "default-value" option can only be used when specifying a key.'); + } + + if ($input->getArgument('key') === '' && $input->getArgument('value') !== null) { + throw new \InvalidArgumentException('The value argument can only be used when specifying a key.'); + } + if ($input->getArgument('value') !== null && $input->hasParameterOption('--default-value')) { + throw new \InvalidArgumentException('The value argument can not be used together with "default-value".'); + } + if ($input->getOption('update-only') && $input->getArgument('value') === null) { + throw new \InvalidArgumentException('The "update-only" option can only be used together with "value".'); + } + + if ($input->getArgument('key') === '' && $input->getOption('delete')) { + throw new \InvalidArgumentException('The "delete" option can only be used when specifying a key.'); + } + if ($input->getOption('delete') && $input->hasParameterOption('--default-value')) { + throw new \InvalidArgumentException('The "delete" option can not be used together with "default-value".'); + } + if ($input->getOption('delete') && $input->getArgument('value') !== null) { + throw new \InvalidArgumentException('The "delete" option can not be used together with "value".'); + } + if ($input->getOption('error-if-not-exists') && !$input->getOption('delete')) { + throw new \InvalidArgumentException('The "error-if-not-exists" option can only be used together with "delete".'); + } + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $this->checkInput($input); + } catch (\InvalidArgumentException $e) { + $output->writeln('<error>' . $e->getMessage() . '</error>'); + return 1; + } + + $uid = $input->getArgument('uid'); + $app = $input->getArgument('app'); + $key = $input->getArgument('key'); + + if ($key !== '') { + $value = $this->config->getUserValue($uid, $app, $key, null); + if ($input->getArgument('value') !== null) { + if ($input->hasParameterOption('--update-only') && $value === null) { + $output->writeln('<error>The setting does not exist for user "' . $uid . '".</error>'); + return 1; + } + + if ($app === 'settings' && in_array($key, ['email', 'display_name'])) { + $user = $this->userManager->get($uid); + if ($user instanceof IUser) { + if ($key === 'email') { + $email = $input->getArgument('value'); + $user->setSystemEMailAddress(mb_strtolower(trim($email))); + } elseif ($key === 'display_name') { + if (!$user->setDisplayName($input->getArgument('value'))) { + if ($user->getDisplayName() === $input->getArgument('value')) { + $output->writeln('<error>New and old display name are the same</error>'); + } elseif ($input->getArgument('value') === '') { + $output->writeln('<error>New display name can\'t be empty</error>'); + } else { + $output->writeln('<error>Could not set display name</error>'); + } + return 1; + } + } + // setEmailAddress and setDisplayName both internally set the value + return 0; + } + } + + $this->config->setUserValue($uid, $app, $key, $input->getArgument('value')); + return 0; + } elseif ($input->hasParameterOption('--delete')) { + if ($input->hasParameterOption('--error-if-not-exists') && $value === null) { + $output->writeln('<error>The setting does not exist for user "' . $uid . '".</error>'); + return 1; + } + + if ($app === 'settings' && in_array($key, ['email', 'display_name'])) { + $user = $this->userManager->get($uid); + if ($user instanceof IUser) { + if ($key === 'email') { + $user->setEMailAddress(''); + // setEmailAddress already deletes the value + return 0; + } elseif ($key === 'display_name') { + $output->writeln('<error>Display name can\'t be deleted.</error>'); + return 1; + } + } + } + + $this->config->deleteUserValue($uid, $app, $key); + return 0; + } elseif ($value !== null) { + $output->writeln($value); + return 0; + } elseif ($input->hasParameterOption('--default-value')) { + $output->writeln($input->getOption('default-value')); + return 0; + } else { + if ($app === 'settings' && $key === 'display_name') { + $user = $this->userManager->get($uid); + $output->writeln($user->getDisplayName()); + return 0; + } + $output->writeln('<error>The setting does not exist for user "' . $uid . '".</error>'); + return 1; + } + } else { + $settings = $this->getUserSettings($uid, $app); + $this->writeArrayInOutputFormat($input, $output, $settings); + return 0; + } + } + + protected function getUserSettings(string $uid, string $app): array { + $settings = $this->config->getAllUserValues($uid); + if ($app !== '') { + if (isset($settings[$app])) { + $settings = [$app => $settings[$app]]; + } else { + $settings = []; + } + } + + $user = $this->userManager->get($uid); + if ($user !== null) { + // Only add the display name if the user exists + $settings['settings']['display_name'] = $user->getDisplayName(); + } + + return $settings; + } + + /** + * @param string $argumentName + * @param CompletionContext $context + * @return string[] + */ + public function completeArgumentValues($argumentName, CompletionContext $context) { + if ($argumentName === 'uid') { + return array_map(static fn (IUser $user) => $user->getUID(), $this->userManager->search($context->getCurrentWord())); + } + if ($argumentName === 'app') { + $userId = $context->getWordAtIndex($context->getWordIndex() - 1); + $settings = $this->getUserSettings($userId, ''); + return array_keys($settings); + } + if ($argumentName === 'key') { + $userId = $context->getWordAtIndex($context->getWordIndex() - 2); + $app = $context->getWordAtIndex($context->getWordIndex() - 1); + $settings = $this->getUserSettings($userId, $app); + return array_keys($settings[$app]); + } + return []; + } +} diff --git a/core/Command/User/SyncAccountDataCommand.php b/core/Command/User/SyncAccountDataCommand.php new file mode 100644 index 00000000000..c353df6fe9f --- /dev/null +++ b/core/Command/User/SyncAccountDataCommand.php @@ -0,0 +1,84 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCP\Accounts\IAccountManager; +use OCP\Accounts\PropertyDoesNotExistException; +use OCP\IUser; +use OCP\IUserManager; +use OCP\User\Backend\IGetDisplayNameBackend; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class SyncAccountDataCommand extends Base { + public function __construct( + protected IUserManager $userManager, + protected IAccountManager $accountManager, + ) { + parent::__construct(); + } + + protected function configure() { + $this + ->setName('user:sync-account-data') + ->setDescription('sync user backend data to accounts table for configured users') + ->addOption( + 'limit', + 'l', + InputOption::VALUE_OPTIONAL, + 'Number of users to retrieve', + '500' + )->addOption( + 'offset', + 'o', + InputOption::VALUE_OPTIONAL, + 'Offset for retrieving users', + '0' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $users = $this->userManager->searchDisplayName('', (int)$input->getOption('limit'), (int)$input->getOption('offset')); + + foreach ($users as $user) { + $this->updateUserAccount($user, $output); + } + return 0; + } + + private function updateUserAccount(IUser $user, OutputInterface $output): void { + $changed = false; + $account = $this->accountManager->getAccount($user); + if ($user->getBackend() instanceof IGetDisplayNameBackend) { + try { + $displayNameProperty = $account->getProperty(IAccountManager::PROPERTY_DISPLAYNAME); + } catch (PropertyDoesNotExistException) { + $displayNameProperty = null; + } + if (!$displayNameProperty || $displayNameProperty->getValue() !== $user->getDisplayName()) { + $output->writeln($user->getUID() . ' - updating changed display name'); + $account->setProperty( + IAccountManager::PROPERTY_DISPLAYNAME, + $user->getDisplayName(), + $displayNameProperty ? $displayNameProperty->getScope() : IAccountManager::SCOPE_PRIVATE, + $displayNameProperty ? $displayNameProperty->getVerified() : IAccountManager::NOT_VERIFIED, + $displayNameProperty ? $displayNameProperty->getVerificationData() : '' + ); + $changed = true; + } + } + + if ($changed) { + $this->accountManager->updateAccount($account); + $output->writeln($user->getUID() . ' - account data updated'); + } else { + $output->writeln($user->getUID() . ' - nothing to update'); + } + } +} diff --git a/core/Command/User/Welcome.php b/core/Command/User/Welcome.php new file mode 100644 index 00000000000..65637759689 --- /dev/null +++ b/core/Command/User/Welcome.php @@ -0,0 +1,78 @@ +<?php + +/** + * SPDX-FileCopyrightText: 2023 FedericoHeichou <federicoheichou@gmail.com> + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace OC\Core\Command\User; + +use OC\Core\Command\Base; +use OCA\Settings\Mailer\NewUserMailHelper; +use OCP\IUserManager; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +class Welcome extends Base { + /** + * @param IUserManager $userManager + * @param NewUserMailHelper $newUserMailHelper + */ + public function __construct( + protected IUserManager $userManager, + private NewUserMailHelper $newUserMailHelper, + ) { + parent::__construct(); + } + + /** + * @return void + */ + protected function configure() { + $this + ->setName('user:welcome') + ->setDescription('Sends the welcome email') + ->addArgument( + 'user', + InputArgument::REQUIRED, + 'The user to send the email to' + ) + ->addOption( + 'reset-password', + 'r', + InputOption::VALUE_NONE, + 'Add the reset password link to the email' + ) + ; + } + + /** + * @param InputInterface $input + * @param OutputInterface $output + * @return int + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + $userId = $input->getArgument('user'); + // check if user exists + $user = $this->userManager->get($userId); + if ($user === null) { + $output->writeln('<error>User does not exist</error>'); + return 1; + } + $email = $user->getEMailAddress(); + if ($email === '' || $email === null) { + $output->writeln('<error>User does not have an email address</error>'); + return 1; + } + try { + $emailTemplate = $this->newUserMailHelper->generateTemplate($user, $input->getOption('reset-password')); + $this->newUserMailHelper->sendMail($user, $emailTemplate); + } catch (\Exception $e) { + $output->writeln('<error>Failed to send email: ' . $e->getMessage() . '</error>'); + return 1; + } + return 0; + } +} |