Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add transfer endpoint #1173

Merged
merged 14 commits into from
Jul 12, 2024
5 changes: 5 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@
'url' => '/api/v1/users/{userId}',
'verb' => 'GET'
],
[
'name' => 'users#transfer',
'url' => '/api/v1/transfer',
'verb' => 'PUT'
Pytal marked this conversation as resolved.
Show resolved Hide resolved
],
[
'name' => 'API#languages',
'url' => '/api/v1/languages',
Expand Down
4 changes: 4 additions & 0 deletions img/account-arrow-right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
147 changes: 147 additions & 0 deletions lib/BackgroundJob/TransferJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
<?php

declare(strict_types=1);

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

namespace OCA\Guests\BackgroundJob;

use OCA\Guests\AppInfo\Application;
use OCA\Guests\Db\Transfer;
use OCA\Guests\Db\TransferMapper;
use OCA\Guests\TransferService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\Notification\IManager as NotificationManager;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;

class TransferJob extends QueuedJob {
public function __construct(
ITimeFactory $time,
private IUserManager $userManager,
private ISecureRandom $secureRandom,
private NotificationManager $notificationManager,
private IURLGenerator $urlGenerator,
private TransferService $transferService,
private TransferMapper $transferMapper,
private LoggerInterface $logger,
) {
parent::__construct($time);
}

private function notifyFailure(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-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);
}
}
123 changes: 76 additions & 47 deletions lib/Controller/UsersController.php
Original file line number Diff line number Diff line change
@@ -1,74 +1,49 @@
<?php

declare(strict_types=1);

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

namespace OCA\Guests\Controller;

use OC\Hooks\PublicEmitter;
use OCA\Guests\Config;
use OCA\Guests\Db\Transfer;
use OCA\Guests\Db\TransferMapper;
use OCA\Guests\GuestManager;
use OCA\Guests\TransferService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCSController;
use OCP\Group\ISubAdmin;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\Mail\IMailer;

class UsersController extends OCSController {
/**
* @var IRequest
*/
protected $request;
/**
* @var IUserManager
*/
private $userManager;
/**
* @var IL10N
*/
private $l10n;
/**
* @var IMailer
*/
private $mailer;
/**
* @var GuestManager
*/
private $guestManager;
/** @var IUserSession */
private $userSession;
/** @var Config */
private $config;
/** @var ISubAdmin */
private $subAdmin;
/** @var IGroupManager */
private $groupManager;

public function __construct(
string $appName,
IRequest $request,
IUserManager $userManager,
IL10N $l10n,
Config $config,
IMailer $mailer,
GuestManager $guestManager,
IUserSession $userSession,
ISubAdmin $subAdmin,
IGroupManager $groupManager
private IUserManager $userManager,
private IL10N $l10n,
private Config $config,
private IMailer $mailer,
private GuestManager $guestManager,
private IUserSession $userSession,
private ISubAdmin $subAdmin,
private IGroupManager $groupManager,
private TransferService $transferService,
private TransferMapper $transferMapper,
) {
parent::__construct($appName, $request);

$this->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;
}

/**
Expand Down Expand Up @@ -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([
Pytal marked this conversation as resolved.
Show resolved Hide resolved
'status' => $transfer->getStatus(),
'source' => $transfer->getSource(),
'target' => $transfer->getTarget(),
], Http::STATUS_ACCEPTED);
}

$this->transferService->addTransferJob($author, $sourceUser, $targetUserId);
return new DataResponse([], Http::STATUS_CREATED);
Pytal marked this conversation as resolved.
Show resolved Hide resolved
}
}
46 changes: 46 additions & 0 deletions lib/Db/Transfer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

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

namespace OCA\Guests\Db;

use OCP\AppFramework\Db\Entity;

/**
* @method void setAuthor(string $userId)
* @method string getAuthor()
*
* @method void setSource(string $userId)
* @method string getSource()
*
* @method void setTarget(string $userId)
* @method string getTarget()
*
* @method void setStatus(string $status)
* @method string getStatus()
*/
class Transfer extends Entity {
public const STATUS_WAITING = 'waiting';
public const STATUS_STARTED = 'started';

/** @var string */
protected $author;
/** @var string */
protected $source;
/** @var string */
protected $target;
/** @var string */
protected $status;

public function __construct() {
$this->addType('author', 'string');
$this->addType('source', 'string');
$this->addType('target', 'string');
$this->addType('status', 'string');
}
}
Loading
Loading