diff --git a/appinfo/routes.php b/appinfo/routes.php index 11acc1bf..18683d47 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -58,6 +58,11 @@ 'url' => '/api/v1/users/{userId}', 'verb' => 'GET' ], + [ + 'name' => 'users#transfer', + 'url' => '/api/v1/transfer', + 'verb' => 'PUT' + ], [ 'name' => 'API#languages', 'url' => '/api/v1/languages', diff --git a/img/account-arrow-right.svg b/img/account-arrow-right.svg new file mode 100644 index 00000000..efed9e4a --- /dev/null +++ b/img/account-arrow-right.svg @@ -0,0 +1,4 @@ + + + diff --git a/lib/BackgroundJob/TransferJob.php b/lib/BackgroundJob/TransferJob.php new file mode 100644 index 00000000..f74f9e6b --- /dev/null +++ b/lib/BackgroundJob/TransferJob.php @@ -0,0 +1,147 @@ +notificationManager->createNotification(); + $notification + ->setApp(Application::APP_ID) + ->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath(Application::APP_ID, 'account-arrow-right.svg'))) + ->setUser($transfer->getAuthor()) + ->setDateTime($this->time->getDateTime()) + ->setObject('guest-transfer', (string)$transfer->getId()) + ->setSubject('guest-transfer-fail', [ + 'source' => $transfer->getSource(), + 'target' => $transfer->getTarget(), + ]); + $this->notificationManager->notify($notification); + } + + private function notifySuccess(Transfer $transfer): void { + $notification = $this->notificationManager->createNotification(); + $notification + ->setApp(Application::APP_ID) + ->setIcon($this->urlGenerator->getAbsoluteURL($this->urlGenerator->imagePath(Application::APP_ID, 'account-arrow-right.svg'))) + ->setUser($transfer->getAuthor()) + ->setDateTime($this->time->getDateTime()) + ->setObject('guest-transfer', (string)$transfer->getId()) + ->setSubject('guest-transfer-done', [ + 'source' => $transfer->getSource(), + 'target' => $transfer->getTarget(), + ]); + $this->notificationManager->notify($notification); + } + + private function fail(Transfer $transfer, ?IUser $targetUser = null): void { + $this->notifyFailure($transfer); + $this->transferMapper->delete($transfer); + if (!($targetUser instanceof IUser)) { + return; + } + $result = $targetUser->delete(); // Rollback created user + if (!$result) { + $this->logger->error('Failed to delete target user', ['user' => $targetUser->getUID()]); + } + } + + public function run($argument): void { + /** @var int $id */ + $id = $argument['id']; + + $transfer = $this->transferMapper->getById($id); + $transfer->setStatus(Transfer::STATUS_STARTED); + $this->transferMapper->update($transfer); + + $source = $transfer->getSource(); + $target = $transfer->getTarget(); + + $sourceUser = $this->userManager->get($source); + if (!($sourceUser instanceof IUser)) { + $this->logger->error('Failed to transfer missing guest user: ' . $source); + $this->fail($transfer); + return; + } + + if ($this->userManager->userExists($target)) { + $this->logger->error("Cannot transfer guest user \"$source\", target user \"$target\" already exists"); + $this->fail($transfer); + return; + } + + $targetUser = $this->userManager->createUser( + $target, + $this->secureRandom->generate(20), // Password hash will be copied to target user from source user + ); + + if (!($targetUser instanceof IUser)) { + $this->logger->error('Failed to create new user: ' . $target); + $this->fail($transfer); + return; + } + + $targetUser->setSystemEMailAddress($sourceUser->getUID()); // Guest user id is an email + + try { + $this->transferService->transfer($sourceUser, $targetUser); + } catch (\Throwable $th) { + $this->logger->error($th->getMessage(), ['exception' => $th]); + $this->fail($transfer, $targetUser); + return; + } + + $passwordHash = $sourceUser->getPasswordHash(); + if (empty($passwordHash)) { + $this->logger->error('Invalid guest password hash', ['guest' => $sourceUser->getUID(), 'passwordHash' => $passwordHash]); + $this->fail($transfer, $targetUser); + return; + } + + $setPasswordHashResult = $targetUser->setPasswordHash($passwordHash); // Copy password hash after transfer to prevent login before completion + if (!$setPasswordHashResult) { + $this->logger->error('Failed to set password hash on target user', ['user' => $targetUser->getUID()]); + $this->fail($transfer, $targetUser); + return; + } + + $result = $sourceUser->delete(); + if (!$result) { + $this->logger->error('Failed to delete guest user', ['user' => $sourceUser->getUID()]); + } + $this->notifySuccess($transfer); + $this->transferMapper->delete($transfer); + } +} diff --git a/lib/Controller/UsersController.php b/lib/Controller/UsersController.php index 67a57afe..38f4f5ec 100644 --- a/lib/Controller/UsersController.php +++ b/lib/Controller/UsersController.php @@ -1,10 +1,21 @@ request = $request; - $this->userManager = $userManager; - $this->l10n = $l10n; - $this->mailer = $mailer; - $this->guestManager = $guestManager; - $this->userSession = $userSession; - $this->config = $config; - $this->subAdmin = $subAdmin; - $this->groupManager = $groupManager; } /** @@ -199,4 +174,58 @@ public function get(string $userId): DataResponse { return new DataResponse($guests); } + + /** + * Transfer guest to a full account + */ + public function transfer(string $guestUserId, string $targetUserId): DataResponse { + $author = $this->userSession->getUser(); + if (!($author instanceof IUser)) { + return new DataResponse([ + 'message' => $this->l10n->t('Failed to authorize') + ], Http::STATUS_UNAUTHORIZED); + } + + $sourceUser = $this->userManager->get($guestUserId); + if (!($sourceUser instanceof IUser)) { + return new DataResponse([ + 'message' => $this->l10n->t('Guest does not exist') + ], Http::STATUS_NOT_FOUND); + } + + if ($this->userManager->userExists($targetUserId)) { + return new DataResponse([ + 'message' => $this->l10n->t('User already exists') + ], Http::STATUS_CONFLICT); + } + + if (!$this->guestManager->isGuest($sourceUser)) { + return new DataResponse([ + 'message' => $this->l10n->t('User is not a guest'), + ], Http::STATUS_CONFLICT); + } + + try { + $transfer = $this->transferMapper->getBySource($sourceUser->getUID()); + } catch (DoesNotExistException $e) { + // Allow as this just means there is no pending transfer + } + + try { + $transfer = $this->transferMapper->getByTarget($targetUserId); + } catch (DoesNotExistException $e) { + // Allow as this just means there is no pending transfer + } + + if (!empty($transfer)) { + return new DataResponse([ + 'status' => $transfer->getStatus(), + 'source' => $transfer->getSource(), + 'target' => $transfer->getTarget(), + ], Http::STATUS_ACCEPTED); + } + + $this->transferService->addTransferJob($author, $sourceUser, $targetUserId); + return new DataResponse([], Http::STATUS_CREATED); + } } diff --git a/lib/Db/Transfer.php b/lib/Db/Transfer.php new file mode 100644 index 00000000..840e29ec --- /dev/null +++ b/lib/Db/Transfer.php @@ -0,0 +1,46 @@ +addType('author', 'string'); + $this->addType('source', 'string'); + $this->addType('target', 'string'); + $this->addType('status', 'string'); + } +} diff --git a/lib/Db/TransferMapper.php b/lib/Db/TransferMapper.php new file mode 100644 index 00000000..879bbe1c --- /dev/null +++ b/lib/Db/TransferMapper.php @@ -0,0 +1,68 @@ + + */ +class TransferMapper extends QBMapper { + public const TABLE_NAME = 'guests_transfers'; + + public function __construct(IDBConnection $db) { + parent::__construct( + $db, + static::TABLE_NAME, + Transfer::class, + ); + } + + /** + * @throws DoesNotExistException + */ + public function getById(int $id): Transfer { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from(static::TABLE_NAME) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id))); + + return $this->findEntity($qb); + } + + /** + * @throws DoesNotExistException + */ + public function getBySource(string $userId): Transfer { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from(static::TABLE_NAME) + ->where($qb->expr()->eq('source', $qb->createNamedParameter($userId))); + + return $this->findEntity($qb); + } + + /** + * @throws DoesNotExistException + */ + public function getByTarget(string $userId): Transfer { + $qb = $this->db->getQueryBuilder(); + + $qb->select('*') + ->from(static::TABLE_NAME) + ->where($qb->expr()->eq('target', $qb->createNamedParameter($userId))); + + return $this->findEntity($qb); + } +} diff --git a/lib/GuestManager.php b/lib/GuestManager.php index a28e9811..cc8c288f 100644 --- a/lib/GuestManager.php +++ b/lib/GuestManager.php @@ -35,53 +35,17 @@ use OCP\Share\IShare; class GuestManager { - /** @var IConfig */ - private $config; - - /** @var UserBackend */ - private $userBackend; - - /** @var IUserManager */ - private $userManager; - - /** @var ISecureRandom */ - private $secureRandom; - - /** @var ICrypto */ - private $crypto; - - /** @var IManager */ - private $shareManager; - - /** @var IDBConnection */ - private $connection; - - /** @var IUserSession */ - private $userSession; - - /** @var IEventDispatcher */ - private $eventDispatcher; - public function __construct( - IConfig $config, - UserBackend $userBackend, - ISecureRandom $secureRandom, - ICrypto $crypto, - IManager $shareManager, - IDBConnection $connection, - IUserSession $userSession, - IEventDispatcher $eventDispatcher, - IUserManager $userManager + private IConfig $config, + private UserBackend $userBackend, + private ISecureRandom $secureRandom, + private ICrypto $crypto, + private IManager $shareManager, + private IDBConnection $connection, + private IUserSession $userSession, + private IEventDispatcher $eventDispatcher, + private IUserManager $userManager, ) { - $this->config = $config; - $this->userBackend = $userBackend; - $this->secureRandom = $secureRandom; - $this->crypto = $crypto; - $this->shareManager = $shareManager; - $this->connection = $connection; - $this->userSession = $userSession; - $this->eventDispatcher = $eventDispatcher; - $this->userManager = $userManager; } /** diff --git a/lib/Hooks.php b/lib/Hooks.php index 8ed6a3e9..944fb99b 100644 --- a/lib/Hooks.php +++ b/lib/Hooks.php @@ -23,85 +23,34 @@ namespace OCA\Guests; use OC\Files\Filesystem; -use OCA\Files\Exception\TransferOwnershipException; -use OCA\Files\Service\OwnershipTransferService; use OCA\Guests\AppInfo\Application; use OCA\Guests\Storage\ReadOnlyJail; use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\IAppContainer; -use OCP\AppFramework\QueryException; use OCP\Constants; use OCP\Files\Storage\IStorage; use OCP\IConfig; use OCP\IUser; use OCP\IUserManager; use OCP\IUserSession; -use OCP\Notification\IManager as INotificationManager; use OCP\Security\ICrypto; use OCP\Share\Events\ShareCreatedEvent; -use OCP\Share\IManager as IShareManager; -use OCP\Share\IShare; use OCP\User\Events\UserFirstTimeLoggedInEvent; use Psr\Log\LoggerInterface; class Hooks { - - /** @var LoggerInterface */ - private $logger; - - /** @var IUserSession */ - private $userSession; - - /** @var Mail */ - private $mail; - - /** @var IUserManager */ - private $userManager; - - /** @var ICrypto */ - private $crypto; - - /** @var GuestManager */ - private $guestManager; - - /** @var UserBackend */ - private $userBackend; - - /** @var IAppContainer */ - private $container; - - /** @var INotificationManager */ - private $notificationManager; - /** @var IShareManager */ - private $shareManager; - - /** @var IConfig */ - private $config; - public function __construct( - LoggerInterface $logger, - IUserSession $userSession, - Mail $mail, - IUserManager $userManager, - IConfig $config, - ICrypto $crypto, - GuestManager $guestManager, - UserBackend $userBackend, - IAppContainer $container, - INotificationManager $notificationManager, - IShareManager $shareManager + private LoggerInterface $logger, + private IUserSession $userSession, + private Mail $mail, + private IUserManager $userManager, + private IConfig $config, + private ICrypto $crypto, + private GuestManager $guestManager, + private UserBackend $userBackend, + private IAppContainer $container, + private TransferService $transferService, ) { - $this->logger = $logger; - $this->userSession = $userSession; - $this->mail = $mail; - $this->userManager = $userManager; - $this->config = $config; - $this->crypto = $crypto; - $this->guestManager = $guestManager; - $this->userBackend = $userBackend; - $this->container = $container; - $this->notificationManager = $notificationManager; - $this->shareManager = $shareManager; } public function handlePostShare(ShareCreatedEvent $event): void { @@ -195,7 +144,7 @@ public function setupReadonlyFilesystem(array $params): void { } public function handleFirstLogin(UserFirstTimeLoggedInEvent $event): void { - if ($this->config->getSystemValue('migrate_guest_user_data', false) === false) { + if (!$this->config->getSystemValueBool('migrate_guest_user_data', false)) { return; } @@ -219,58 +168,20 @@ public function handleFirstLogin(UserFirstTimeLoggedInEvent $event): void { return; } - try { - /** @var OwnershipTransferService $ownershipTransferService */ - $ownershipTransferService = $this->container->get(OwnershipTransferService::class); - } catch (QueryException $e) { - $this->logger->error('Could not resolve ownership transfer service to import guest user data', [ - 'exception' => $e, - ]); - return; - } - $guestUser = $this->userManager->get($email); if ($guestUser === null) { $this->logger->warning("Guest user $email does not exist (anymore)"); return; } - try { - $ownershipTransferService->transfer( - $guestUser, - $user, - '/', - null, - true, - true - ); - } catch (TransferOwnershipException $e) { - $this->logger->error('Could not import guest user data', [ - 'exception' => $e, - ]); - } - // Update incomming shares - $shares = $this->shareManager->getSharedWith($guestUser->getUID(), IShare::TYPE_USER); - foreach ($shares as $share) { - $share->setSharedWith($user->getUID()); - $this->shareManager->updateShare($share); - } + $this->transferService->transfer($guestUser, $user); - if ($this->config->getSystemValue('remove_guest_account_on_conversion', false) === false) { + if (!$this->config->getSystemValueBool('remove_guest_account_on_conversion', false)) { // Disable previous account $guestUser->setEnabled(false); } else { // Remove previous account $guestUser->delete(); } - - $notification = $this->notificationManager->createNotification(); - $notification - ->setApp(Application::APP_ID) - ->setSubject('data_migrated_to_system_user') - ->setObject('user', $email) - ->setDateTime(new \DateTime()) - ->setUser($user->getUID()); - $this->notificationManager->notify($notification); } } diff --git a/lib/Migration/Version4000Date20240619221613.php b/lib/Migration/Version4000Date20240619221613.php new file mode 100644 index 00000000..61e98efc --- /dev/null +++ b/lib/Migration/Version4000Date20240619221613.php @@ -0,0 +1,57 @@ +hasTable(TransferMapper::TABLE_NAME)) { + return null; + } + + $table = $schema->createTable(TransferMapper::TABLE_NAME); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + 'unsigned' => true, + ]); + $table->addColumn('author', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('source', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('target', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('status', Types::STRING, [ + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + return $schema; + } +} diff --git a/lib/Notifications/Notifier.php b/lib/Notifications/Notifier.php index 4173c10d..0765cf79 100644 --- a/lib/Notifications/Notifier.php +++ b/lib/Notifications/Notifier.php @@ -3,24 +3,8 @@ declare(strict_types=1); /** - * @copyright 2019 Christoph Wurst - * - * @author 2019 Christoph Wurst - * - * @license GNU AGPL version 3 or any later version - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . + * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later */ namespace OCA\Guests\Notifications; @@ -28,22 +12,17 @@ use InvalidArgumentException; use OCA\Guests\AppInfo\Application; use OCP\IURLGenerator; +use OCP\IUserManager; use OCP\L10N\IFactory; use OCP\Notification\INotification; use OCP\Notification\INotifier; class Notifier implements INotifier { - - /** @var IFactory */ - private $factory; - - /** @var IURLGenerator */ - private $url; - - public function __construct(IFactory $factory, - IURLGenerator $url) { - $this->factory = $factory; - $this->url = $url; + public function __construct( + private IFactory $factory, + private IURLGenerator $url, + private IUserManager $userManager, + ) { } public function getID(): string { @@ -54,6 +33,26 @@ public function getName(): string { return $this->factory->get(Application::APP_ID)->t('Guests'); } + private function getRichMessageParams(string $source, string $target): array { + $sourceUser = $this->userManager->get($source); + $targetUser = $this->userManager->get($target); + return [ + 'guest' => [ + 'type' => $sourceUser ? 'guest' : 'highlight', + 'id' => $sourceUser?->getUID() ?? $source, + 'name' => $sourceUser?->getDisplayName() ?? $source, + ], + 'user' => [ + 'type' => $targetUser ? 'user' : 'highlight', + 'id' => $targetUser?->getUID() ?? $source, + 'name' => $targetUser?->getDisplayName() ?? $target, + ], + ]; + } + + /** + * @throws InvalidArgumentException + */ public function prepare(INotification $notification, string $languageCode): INotification { if ($notification->getApp() !== Application::APP_ID) { // Not my app => throw @@ -74,6 +73,26 @@ public function prepare(INotification $notification, string $languageCode): INot return $notification; + case 'guest-transfer-fail': + $params = $notification->getSubjectParameters(); + $notification + ->setRichSubject($l->t('Guest transfer failed')) + ->setRichMessage( + $l->t('Failed to transfer guest {guest} to {user}'), + $this->getRichMessageParams($params['source'], $params['target']), + ); + return $notification; + + case 'guest-transfer-done': + $params = $notification->getSubjectParameters(); + $notification + ->setRichSubject($l->t('Guest transfer done')) + ->setRichMessage( + $l->t('Transfer of guest {guest} to {user} completed'), + $this->getRichMessageParams($params['source'], $params['target']), + ); + return $notification; + default: // Unknown subject => Unknown notification => throw throw new InvalidArgumentException(); diff --git a/lib/TransferService.php b/lib/TransferService.php new file mode 100644 index 00000000..4ff0ac3f --- /dev/null +++ b/lib/TransferService.php @@ -0,0 +1,98 @@ +container->get(OwnershipTransferService::class); + } catch (QueryException $e) { + $this->logger->error('Could not resolve ownership transfer service to import guest user data', [ + 'exception' => $e, + ]); + throw $e; + } + + try { + $ownershipTransferService->transfer( + $guestUser, + $user, + '/', + null, + true, + true + ); + } catch (TransferOwnershipException $e) { + $this->logger->error('Could not import guest user data', [ + 'exception' => $e, + ]); + throw $e; + } + + // Update incomming shares + $shares = $this->shareManager->getSharedWith($guestUser->getUID(), IShare::TYPE_USER); + foreach ($shares as $share) { + $share->setSharedWith($user->getUID()); + $this->shareManager->updateShare($share); + } + + $notification = $this->notificationManager->createNotification(); + $notification + ->setApp(Application::APP_ID) + ->setSubject('data_migrated_to_system_user') + ->setObject('user', $user->getEMailAddress()) + ->setDateTime(new \DateTime()) + ->setUser($user->getUID()); + $this->notificationManager->notify($notification); + } + + /** + * @param string $target Target user id + */ + public function addTransferJob(IUser $author, IUser $source, string $target): void { + $transfer = new Transfer(); + $transfer->setAuthor($author->getUID()); + $transfer->setSource($source->getUID()); + $transfer->setTarget($target); + $transfer->setStatus(Transfer::STATUS_WAITING); + /** @var Transfer $transfer */ + $transfer = $this->transferMapper->insert($transfer); + + $this->jobList->add(TransferJob::class, [ + 'id' => $transfer->getId(), + ]); + } +}