This commit is contained in:
Antoon P. 2026-02-03 19:57:44 -01:00 committed by GitHub
commit 7e5db80ca9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 55 additions and 375 deletions

View file

@ -20,12 +20,6 @@ return [
'verb' => 'POST',
'root' => '/ocm',
],
[
'name' => 'RequestHandler#inviteAccepted',
'url' => '/invite-accepted',
'verb' => 'POST',
'root' => '/ocm',
],
// needs to be kept at the bottom of the list
[

View file

@ -12,9 +12,7 @@ return array(
'OCA\\CloudFederationAPI\\Config' => $baseDir . '/../lib/Config.php',
'OCA\\CloudFederationAPI\\Controller\\OCMRequestController' => $baseDir . '/../lib/Controller/OCMRequestController.php',
'OCA\\CloudFederationAPI\\Controller\\RequestHandlerController' => $baseDir . '/../lib/Controller/RequestHandlerController.php',
'OCA\\CloudFederationAPI\\Db\\FederatedInvite' => $baseDir . '/../lib/Db/FederatedInvite.php',
'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => $baseDir . '/../lib/Db/FederatedInviteMapper.php',
'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => $baseDir . '/../lib/Events/FederatedInviteAcceptedEvent.php',
'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => $baseDir . '/../lib/Migration/Version1016Date202502262004.php',
'OCA\\CloudFederationAPI\\Migration\\Version1018Date20260202110547' => $baseDir . '/../lib/Migration/Version1018Date20260202110547.php',
'OCA\\CloudFederationAPI\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
);

View file

@ -7,14 +7,14 @@ namespace Composer\Autoload;
class ComposerStaticInitCloudFederationAPI
{
public static $prefixLengthsPsr4 = array (
'O' =>
'O' =>
array (
'OCA\\CloudFederationAPI\\' => 23,
),
);
public static $prefixDirsPsr4 = array (
'OCA\\CloudFederationAPI\\' =>
'OCA\\CloudFederationAPI\\' =>
array (
0 => __DIR__ . '/..' . '/../lib',
),
@ -27,10 +27,8 @@ class ComposerStaticInitCloudFederationAPI
'OCA\\CloudFederationAPI\\Config' => __DIR__ . '/..' . '/../lib/Config.php',
'OCA\\CloudFederationAPI\\Controller\\OCMRequestController' => __DIR__ . '/..' . '/../lib/Controller/OCMRequestController.php',
'OCA\\CloudFederationAPI\\Controller\\RequestHandlerController' => __DIR__ . '/..' . '/../lib/Controller/RequestHandlerController.php',
'OCA\\CloudFederationAPI\\Db\\FederatedInvite' => __DIR__ . '/..' . '/../lib/Db/FederatedInvite.php',
'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => __DIR__ . '/..' . '/../lib/Db/FederatedInviteMapper.php',
'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => __DIR__ . '/..' . '/../lib/Events/FederatedInviteAcceptedEvent.php',
'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => __DIR__ . '/..' . '/../lib/Migration/Version1016Date202502262004.php',
'OCA\\CloudFederationAPI\\Migration\\Version1018Date20260202110547' => __DIR__ . '/..' . '/../lib/Migration/Version1018Date20260202110547.php',
'OCA\\CloudFederationAPI\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
);

View file

@ -22,7 +22,6 @@ class Application extends App implements IBootstrap {
}
public function register(IRegistrationContext $context): void {
$context->registerCapability(Capabilities::class);
}
public function boot(IBootContext $context): void {

View file

@ -67,8 +67,6 @@ class RequestHandlerController extends Controller {
private IURLGenerator $urlGenerator,
private ICloudFederationProviderManager $cloudFederationProviderManager,
private Config $config,
private IEventDispatcher $dispatcher,
private FederatedInviteMapper $federatedInviteMapper,
private readonly AddressHandler $addressHandler,
private readonly IAppConfig $appConfig,
private ICloudFederationFactory $factory,
@ -225,101 +223,6 @@ class RequestHandlerController extends Controller {
return new JSONResponse($responseData, Http::STATUS_CREATED);
}
/**
* Inform the sender that an invitation was accepted to start sharing
*
* Inform about an accepted invitation so the user on the sender provider's side
* can initiate the OCM share creation. To protect the identity of the parties,
* for shares created following an OCM invitation, the user id MAY be hashed,
* and recipients implementing the OCM invitation workflow MAY refuse to process
* shares coming from unknown parties.
* @link https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post
*
* @param string $recipientProvider The address of the recipent's provider
* @param string $token The token used for the invitation
* @param string $userID The userID of the recipient at the recipient's provider
* @param string $email The email address of the recipient
* @param string $name The display name of the recipient
*
* @return JSONResponse<Http::STATUS_OK, array{userID: string, email: string, name: string}, array{}>|JSONResponse<Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST|Http::STATUS_CONFLICT, array{message: string, error: true}, array{}>
*
* Note: Not implementing 404 Invitation token does not exist, instead using 400
* 200: Invitation accepted
* 400: Invalid token
* 403: Invitation token does not exist
* 409: User is already known by the OCM provider
*/
#[PublicPage]
#[NoCSRFRequired]
#[BruteForceProtection(action: 'inviteAccepted')]
public function inviteAccepted(string $recipientProvider, string $token, string $userID, string $email, string $name): JSONResponse {
$this->logger->debug('Processing share invitation for ' . $userID . ' with token ' . $token . ' and email ' . $email . ' and name ' . $name);
$updated = $this->timeFactory->getTime();
if ($token === '') {
$response = new JSONResponse(['message' => 'Invalid or non existing token', 'error' => true], Http::STATUS_BAD_REQUEST);
$response->throttle();
return $response;
}
try {
$invitation = $this->federatedInviteMapper->findByToken($token);
} catch (DoesNotExistException) {
$response = ['message' => 'Invalid or non existing token', 'error' => true];
$status = Http::STATUS_BAD_REQUEST;
$response = new JSONResponse($response, $status);
$response->throttle();
return $response;
}
if ($invitation->isAccepted() === true) {
$response = ['message' => 'Invite already accepted', 'error' => true];
$status = Http::STATUS_CONFLICT;
return new JSONResponse($response, $status);
}
if ($invitation->getExpiredAt() !== null && $updated > $invitation->getExpiredAt()) {
$response = ['message' => 'Invitation expired', 'error' => true];
$status = Http::STATUS_BAD_REQUEST;
return new JSONResponse($response, $status);
}
$localUser = $this->userManager->get($invitation->getUserId());
if ($localUser === null) {
$response = ['message' => 'Invalid or non existing token', 'error' => true];
$status = Http::STATUS_BAD_REQUEST;
$response = new JSONResponse($response, $status);
$response->throttle();
return $response;
}
$sharedFromEmail = $localUser->getEMailAddress();
if ($sharedFromEmail === null) {
$response = ['message' => 'Invalid or non existing token', 'error' => true];
$status = Http::STATUS_BAD_REQUEST;
$response = new JSONResponse($response, $status);
$response->throttle();
return $response;
}
$sharedFromDisplayName = $localUser->getDisplayName();
$response = ['userID' => $localUser->getUID(), 'email' => $sharedFromEmail, 'name' => $sharedFromDisplayName];
$status = Http::STATUS_OK;
$invitation->setAccepted(true);
$invitation->setRecipientEmail($email);
$invitation->setRecipientName($name);
$invitation->setRecipientProvider($recipientProvider);
$invitation->setRecipientUserId($userID);
$invitation->setAcceptedAt($updated);
$invitation = $this->federatedInviteMapper->update($invitation);
$event = new FederatedInviteAcceptedEvent($invitation);
$this->dispatcher->dispatchTyped($event);
return new JSONResponse($response, $status);
}
/**
* Send a notification about an existing share
*

View file

@ -1,62 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\CloudFederationAPI\Db;
use OCP\AppFramework\Db\Entity;
use OCP\DB\Types;
/**
* @method bool isAccepted()
* @method void setAccepted(bool $accepted)
* @method int|null getAcceptedAt()
* @method void setAcceptedAt(int $acceptedAt)
* @method int|null getCreatedAt()
* @method void setCreatedAt(int $createdAt)
* @method int|null getExpiredAt()
* @method void setExpiredAt(int $expiredAt)
* @method string|null getRecipientEmail()
* @method void setRecipientEmail(string $recipientEmail)
* @method string|null getRecipientName()
* @method void setRecipientName(string $recipientName)
* @method string|null getRecipientProvider()
* @method void setRecipientProvider(string $recipientProvider)
* @method string|null getRecipientUserId()
* @method void setRecipientUserId(string $recipientUserId)
* @method string getToken()
* @method void setToken(string $token)
* @method string|null getUserId()
* @method void setUserId(string $userId)
*/
class FederatedInvite extends Entity {
protected bool $accepted = false;
protected ?int $acceptedAt = 0;
protected int $createdAt = 0;
protected ?int $expiredAt = 0;
protected ?string $recipientEmail = null;
protected ?string $recipientName = null;
protected ?string $recipientProvider = null;
protected ?string $recipientUserId = null;
protected string $token = '';
protected string $userId = '';
public function __construct() {
$this->addType('accepted', Types::BOOLEAN);
$this->addType('acceptedAt', Types::BIGINT);
$this->addType('createdAt', Types::BIGINT);
$this->addType('expiredAt', Types::BIGINT);
$this->addType('recipientEmail', Types::STRING);
$this->addType('recipientName', Types::STRING);
$this->addType('recipientProvider', Types::STRING);
$this->addType('recipientUserId', Types::STRING);
$this->addType('token', Types::STRING);
$this->addType('userId', Types::STRING);
}
}

View file

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\CloudFederationAPI\Db;
use OCP\AppFramework\Db\QBMapper;
use OCP\IDBConnection;
/**
* @template-extends QBMapper<FederatedInvite>
*/
class FederatedInviteMapper extends QBMapper {
public const TABLE_NAME = 'federated_invites';
public function __construct(IDBConnection $db) {
parent::__construct($db, self::TABLE_NAME);
}
public function findByToken(string $token): FederatedInvite {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('federated_invites')
->where($qb->expr()->eq('token', $qb->createNamedParameter($token)));
return $this->findEntity($qb);
}
}

View file

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\CloudFederationAPI\Events;
use OCA\CloudFederationAPI\Db\FederatedInvite;
use OCP\EventDispatcher\Event;
class FederatedInviteAcceptedEvent extends Event {
public function __construct(
private FederatedInvite $invitation,
) {
parent::__construct();
}
public function getInvitation(): FederatedInvite {
return $this->invitation;
}
}

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\CloudFederationAPI\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\Migration\Attributes\DropTable;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
use Override;
#[DropTable(table: 'federated_invites', columns: ['id', 'user_id', 'recipient_provider', 'recipient_user_id', 'recipient_name', 'recipient_email', 'token', 'accepted', 'created_at', 'expired_at', 'accepted_at'], description: 'Table is moved out of this app.')]
class Version1018Date20260202110547 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
#[Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table_name = 'federated_invites';
if ($schema->hasTable($table_name)) {
$schema->dropTable($table_name);
return $schema;
}
return null;
}
}

View file

@ -1,137 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\CloudFederationApi\Tests;
use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Controller\RequestHandlerController;
use OCA\CloudFederationAPI\Db\FederatedInvite;
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
use OCA\FederatedFileSharing\AddressHandler;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Federation\ICloudFederationFactory;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Federation\ICloudIdManager;
use OCP\IAppConfig;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Security\Signature\ISignatureManager;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class RequestHandlerControllerTest extends TestCase {
private IRequest&MockObject $request;
private LoggerInterface&MockObject $logger;
private IUserManager&MockObject $userManager;
private IGroupManager&MockObject $groupManager;
private IURLGenerator&MockObject $urlGenerator;
private ICloudFederationProviderManager&MockObject $cloudFederationProviderManager;
private Config&MockObject $config;
private IEventDispatcher&MockObject $eventDispatcher;
private FederatedInviteMapper&MockObject $federatedInviteMapper;
private AddressHandler&MockObject $addressHandler;
private IAppConfig&MockObject $appConfig;
private ICloudFederationFactory&MockObject $cloudFederationFactory;
private ICloudIdManager&MockObject $cloudIdManager;
private IOCMDiscoveryService&MockObject $discoveryService;
private ISignatureManager&MockObject $signatureManager;
private ITimeFactory&MockObject $timeFactory;
private RequestHandlerController $requestHandlerController;
protected function setUp(): void {
parent::setUp();
$this->request = $this->createMock(IRequest::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->groupManager = $this->createMock(IGroupManager::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->cloudFederationProviderManager = $this->createMock(ICloudFederationProviderManager::class);
$this->config = $this->createMock(Config::class);
$this->eventDispatcher = $this->createMock(IEventDispatcher::class);
$this->federatedInviteMapper = $this->createMock(FederatedInviteMapper::class);
$this->addressHandler = $this->createMock(AddressHandler::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class);
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
$this->discoveryService = $this->createMock(IOCMDiscoveryService::class);
$this->signatureManager = $this->createMock(ISignatureManager::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->requestHandlerController = new RequestHandlerController(
'cloud_federation_api',
$this->request,
$this->logger,
$this->userManager,
$this->groupManager,
$this->urlGenerator,
$this->cloudFederationProviderManager,
$this->config,
$this->eventDispatcher,
$this->federatedInviteMapper,
$this->addressHandler,
$this->appConfig,
$this->cloudFederationFactory,
$this->cloudIdManager,
$this->discoveryService,
$this->signatureManager,
$this->timeFactory,
);
}
public function testInviteAccepted(): void {
$token = 'token';
$userId = 'userId';
$invite = new FederatedInvite();
$invite->setCreatedAt(1);
$invite->setUserId($userId);
$invite->setToken($token);
$this->federatedInviteMapper->expects(self::once())
->method('findByToken')
->with($token)
->willReturn($invite);
$this->federatedInviteMapper->expects(self::once())
->method('update')
->willReturnArgument(0);
$user = $this->createMock(IUser::class);
$user->method('getUID')
->willReturn($userId);
$user->method('getEMailAddress')
->willReturn('email');
$user->method('getDisplayName')
->willReturn('displayName');
$this->userManager->expects(self::once())
->method('get')
->with($userId)
->willReturn($user);
$recipientProvider = 'http://127.0.0.1';
$recipientId = 'remote';
$recipientEmail = 'remote@example.org';
$recipientName = 'Remote Remoteson';
$response = ['userID' => $userId, 'email' => 'email', 'name' => 'displayName'];
$json = new JSONResponse($response, Http::STATUS_OK);
$this->assertEquals($json, $this->requestHandlerController->inviteAccepted($recipientProvider, $token, $recipientId, $recipientEmail, $recipientName));
}
}

View file

@ -200,13 +200,7 @@ final class OCMDiscoveryService implements IOCMDiscoveryService {
$provider->setEnabled(true);
$provider->setApiVersion(self::API_VERSION);
$provider->setEndPoint(substr($url, 0, $pos));
$provider->setCapabilities(['invite-accepted', 'notifications', 'shares']);
// The inviteAcceptDialog is available from the contacts app, if this config value is set
$inviteAcceptDialog = $this->appConfig->getValueString('core', ConfigLexicon::OCM_INVITE_ACCEPT_DIALOG);
if ($inviteAcceptDialog !== '') {
$provider->setInviteAcceptDialog($this->urlGenerator->linkToRouteAbsolute($inviteAcceptDialog));
}
$provider->setCapabilities(['notifications', 'shares']);
$resource = $provider->createNewResourceType();
$resource->setName('file')

View file

@ -54,4 +54,15 @@ class LocalOCMDiscoveryEvent extends Event {
->setProtocols($protocols);
$this->provider->addResourceType($resourceType);
}
/**
* Returns the ocm provider.
*
* @return IOCMProvider
*
* @since 33.0.0
*/
public function getProvider(): IOCMProvider {
return $this->provider;
}
}