diff options
Diffstat (limited to 'apps/cloud_federation_api')
17 files changed, 681 insertions, 114 deletions
diff --git a/apps/cloud_federation_api/appinfo/info.xml b/apps/cloud_federation_api/appinfo/info.xml index 57a8d9b50a0..81343cb49bf 100644 --- a/apps/cloud_federation_api/appinfo/info.xml +++ b/apps/cloud_federation_api/appinfo/info.xml @@ -9,7 +9,7 @@ <name>Cloud Federation API</name> <summary>Enable clouds to communicate with each other and exchange data</summary> <description>The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data.</description> - <version>1.15.0</version> + <version>1.16.0</version> <licence>agpl</licence> <author>Bjoern Schiessle</author> <namespace>CloudFederationAPI</namespace> diff --git a/apps/cloud_federation_api/appinfo/routes.php b/apps/cloud_federation_api/appinfo/routes.php index 6b0774627a4..6467005e21b 100644 --- a/apps/cloud_federation_api/appinfo/routes.php +++ b/apps/cloud_federation_api/appinfo/routes.php @@ -20,11 +20,11 @@ return [ 'verb' => 'POST', 'root' => '/ocm', ], - // [ - // 'name' => 'RequestHandler#inviteAccepted', - // 'url' => '/invite-accepted', - // 'verb' => 'POST', - // 'root' => '/ocm', - // ] + [ + 'name' => 'RequestHandler#inviteAccepted', + 'url' => '/invite-accepted', + 'verb' => 'POST', + 'root' => '/ocm', + ] ], ]; diff --git a/apps/cloud_federation_api/composer/composer/autoload_classmap.php b/apps/cloud_federation_api/composer/composer/autoload_classmap.php index dd096ebf563..3cadc540c88 100644 --- a/apps/cloud_federation_api/composer/composer/autoload_classmap.php +++ b/apps/cloud_federation_api/composer/composer/autoload_classmap.php @@ -11,5 +11,9 @@ return array( 'OCA\\CloudFederationAPI\\Capabilities' => $baseDir . '/../lib/Capabilities.php', 'OCA\\CloudFederationAPI\\Config' => $baseDir . '/../lib/Config.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\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', ); diff --git a/apps/cloud_federation_api/composer/composer/autoload_static.php b/apps/cloud_federation_api/composer/composer/autoload_static.php index 75557a20126..849b755cd2f 100644 --- a/apps/cloud_federation_api/composer/composer/autoload_static.php +++ b/apps/cloud_federation_api/composer/composer/autoload_static.php @@ -26,6 +26,10 @@ class ComposerStaticInitCloudFederationAPI 'OCA\\CloudFederationAPI\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php', 'OCA\\CloudFederationAPI\\Config' => __DIR__ . '/..' . '/../lib/Config.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\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', ); diff --git a/apps/cloud_federation_api/l10n/pt_PT.js b/apps/cloud_federation_api/l10n/pt_PT.js new file mode 100644 index 00000000000..db0fadb64f8 --- /dev/null +++ b/apps/cloud_federation_api/l10n/pt_PT.js @@ -0,0 +1,8 @@ +OC.L10N.register( + "cloud_federation_api", + { + "Cloud Federation API" : "API de Federação Cloud", + "Enable clouds to communicate with each other and exchange data" : "Enable clouds to communicate with each other and exchange data", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." +}, +"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;"); diff --git a/apps/cloud_federation_api/l10n/pt_PT.json b/apps/cloud_federation_api/l10n/pt_PT.json new file mode 100644 index 00000000000..78fdf12910a --- /dev/null +++ b/apps/cloud_federation_api/l10n/pt_PT.json @@ -0,0 +1,6 @@ +{ "translations": { + "Cloud Federation API" : "API de Federação Cloud", + "Enable clouds to communicate with each other and exchange data" : "Enable clouds to communicate with each other and exchange data", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." +},"pluralForm" :"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" +}
\ No newline at end of file diff --git a/apps/cloud_federation_api/l10n/sw.js b/apps/cloud_federation_api/l10n/sw.js new file mode 100644 index 00000000000..c58c7d2b6f8 --- /dev/null +++ b/apps/cloud_federation_api/l10n/sw.js @@ -0,0 +1,8 @@ +OC.L10N.register( + "cloud_federation_api", + { + "Cloud Federation API" : "API ya Shirikisho la Cloud", + "Enable clouds to communicate with each other and exchange data" : "Washa clouds kuwasiliana na kubadilishana data", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "API ya Shirikisho la Cloud huwezesha matukio mbalimbali ya Nextcloud kuwasiliana na kubadilishana data." +}, +"nplurals=2; plural=(n != 1);"); diff --git a/apps/cloud_federation_api/l10n/sw.json b/apps/cloud_federation_api/l10n/sw.json new file mode 100644 index 00000000000..622453561cb --- /dev/null +++ b/apps/cloud_federation_api/l10n/sw.json @@ -0,0 +1,6 @@ +{ "translations": { + "Cloud Federation API" : "API ya Shirikisho la Cloud", + "Enable clouds to communicate with each other and exchange data" : "Washa clouds kuwasiliana na kubadilishana data", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "API ya Shirikisho la Cloud huwezesha matukio mbalimbali ya Nextcloud kuwasiliana na kubadilishana data." +},"pluralForm" :"nplurals=2; plural=(n != 1);" +}
\ No newline at end of file diff --git a/apps/cloud_federation_api/lib/Capabilities.php b/apps/cloud_federation_api/lib/Capabilities.php index 0348f6e7c11..599733123b3 100644 --- a/apps/cloud_federation_api/lib/Capabilities.php +++ b/apps/cloud_federation_api/lib/Capabilities.php @@ -6,6 +6,7 @@ declare(strict_types=1); * SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\CloudFederationAPI; use NCU\Security\Signature\Exceptions\IdentityNotFoundException; @@ -16,16 +17,16 @@ use OCP\Capabilities\IInitialStateExcludedCapability; use OCP\IAppConfig; use OCP\IURLGenerator; use OCP\OCM\Exceptions\OCMArgumentException; -use OCP\OCM\IOCMProvider; +use OCP\OCM\ICapabilityAwareOCMProvider; use Psr\Log\LoggerInterface; class Capabilities implements ICapability, IInitialStateExcludedCapability { - public const API_VERSION = '1.1'; // informative, real version. + public const API_VERSION = '1.1.0'; public function __construct( private IURLGenerator $urlGenerator, private IAppConfig $appConfig, - private IOCMProvider $provider, + private ICapabilityAwareOCMProvider $provider, private readonly OCMSignatoryManager $ocmSignatoryManager, private readonly LoggerInterface $logger, ) { @@ -34,23 +35,7 @@ class Capabilities implements ICapability, IInitialStateExcludedCapability { /** * Function an app uses to return the capabilities * - * @return array{ - * ocm: array{ - * apiVersion: '1.0-proposal1', - * enabled: bool, - * endPoint: string, - * publicKey?: array{ - * keyId: string, - * publicKeyPem: string, - * }, - * resourceTypes: list<array{ - * name: string, - * shareTypes: list<string>, - * protocols: array<string, string> - * }>, - * version: string - * } - * } + * @return array<string, array<string, mixed>> * @throws OCMArgumentException */ public function getCapabilities() { @@ -62,6 +47,8 @@ class Capabilities implements ICapability, IInitialStateExcludedCapability { $this->provider->setEnabled(true); $this->provider->setApiVersion(self::API_VERSION); + $this->provider->setCapabilities(['/invite-accepted', '/notifications', '/shares']); + $this->provider->setEndPoint(substr($url, 0, $pos)); $resource = $this->provider->createNewResourceType(); diff --git a/apps/cloud_federation_api/lib/Config.php b/apps/cloud_federation_api/lib/Config.php index f7c14a75c37..23788c26dc9 100644 --- a/apps/cloud_federation_api/lib/Config.php +++ b/apps/cloud_federation_api/lib/Config.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index cbd66f52382..a76b1884a0b 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -1,8 +1,10 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\CloudFederationAPI\Controller; use NCU\Federation\ISignedCloudFederationProvider; @@ -15,15 +17,20 @@ use NCU\Security\Signature\IIncomingSignedRequest; use NCU\Security\Signature\ISignatureManager; use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; +use OCA\CloudFederationAPI\Db\FederatedInviteMapper; +use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent; use OCA\CloudFederationAPI\ResponseDefinitions; use OCA\FederatedFileSharing\AddressHandler; use OCP\AppFramework\Controller; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\BruteForceProtection; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Federation\Exceptions\ActionNotSupportedException; use OCP\Federation\Exceptions\AuthenticationFailedException; use OCP\Federation\Exceptions\BadRequestException; @@ -61,12 +68,15 @@ 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, private ICloudIdManager $cloudIdManager, private readonly ISignatureManager $signatureManager, private readonly OCMSignatoryManager $signatoryManager, + private ITimeFactory $timeFactory, ) { parent::__construct($appName, $request); } @@ -107,16 +117,17 @@ class RequestHandlerController extends Controller { } // check if all required parameters are set - if ($shareWith === null || - $name === null || - $providerId === null || - $resourceType === null || - $shareType === null || - !is_array($protocol) || - !isset($protocol['name']) || - !isset($protocol['options']) || - !is_array($protocol['options']) || - !isset($protocol['options']['sharedSecret']) + if ( + $shareWith === null + || $name === null + || $providerId === null + || $resourceType === null + || $shareType === null + || !is_array($protocol) + || !isset($protocol['name']) + || !isset($protocol['options']) + || !is_array($protocol['options']) + || !isset($protocol['options']['sharedSecret']) ) { return new JSONResponse( [ @@ -214,6 +225,101 @@ class RequestHandlerController extends Controller { } /** + * 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 * * @param string $notificationType Notification type, e.g. SHARE_ACCEPTED @@ -233,10 +339,11 @@ class RequestHandlerController extends Controller { #[BruteForceProtection(action: 'receiveFederatedShareNotification')] public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) { // check if all required parameters are set - if ($notificationType === null || - $resourceType === null || - $providerId === null || - !is_array($notification) + if ( + $notificationType === null + || $resourceType === null + || $providerId === null + || !is_array($notification) ) { return new JSONResponse( [ diff --git a/apps/cloud_federation_api/lib/Db/FederatedInvite.php b/apps/cloud_federation_api/lib/Db/FederatedInvite.php new file mode 100644 index 00000000000..b2447ff4e23 --- /dev/null +++ b/apps/cloud_federation_api/lib/Db/FederatedInvite.php @@ -0,0 +1,62 @@ +<?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); + } +} diff --git a/apps/cloud_federation_api/lib/Db/FederatedInviteMapper.php b/apps/cloud_federation_api/lib/Db/FederatedInviteMapper.php new file mode 100644 index 00000000000..5feb08b2c7f --- /dev/null +++ b/apps/cloud_federation_api/lib/Db/FederatedInviteMapper.php @@ -0,0 +1,33 @@ +<?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); + } + +} diff --git a/apps/cloud_federation_api/lib/Events/FederatedInviteAcceptedEvent.php b/apps/cloud_federation_api/lib/Events/FederatedInviteAcceptedEvent.php new file mode 100644 index 00000000000..c4d079d083e --- /dev/null +++ b/apps/cloud_federation_api/lib/Events/FederatedInviteAcceptedEvent.php @@ -0,0 +1,24 @@ +<?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; + } +} diff --git a/apps/cloud_federation_api/lib/Migration/Version1016Date202502262004.php b/apps/cloud_federation_api/lib/Migration/Version1016Date202502262004.php new file mode 100644 index 00000000000..a3523d45e38 --- /dev/null +++ b/apps/cloud_federation_api/lib/Migration/Version1016Date202502262004.php @@ -0,0 +1,89 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2025 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\DB\Types; +use OCP\Migration\IOutput; +use OCP\Migration\SimpleMigrationStep; + +class Version1016Date202502262004 extends SimpleMigrationStep { + /** + * @param IOutput $output + * @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper` + * @param array $options + * @return null|ISchemaWrapper + */ + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options) { + /** @var ISchemaWrapper $schema */ + $schema = $schemaClosure(); + $table_name = 'federated_invites'; + + if (!$schema->hasTable($table_name)) { + $table = $schema->createTable($table_name); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 11, + 'unsigned' => true, + ]); + $table->addColumn('user_id', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + + ]); + // https://saturncloud.io/blog/what-is-the-maximum-length-of-a-url-in-different-browsers/#maximum-url-length-in-different-browsers + // We use the least common denominator, the minimum length supported by browsers + $table->addColumn('recipient_provider', Types::STRING, [ + 'notnull' => false, + 'length' => 2083, + ]); + $table->addColumn('recipient_user_id', Types::STRING, [ + 'notnull' => false, + 'length' => 1024, + ]); + $table->addColumn('recipient_name', Types::STRING, [ + 'notnull' => false, + 'length' => 1024, + ]); + // https://www.directedignorance.com/blog/maximum-length-of-email-address + $table->addColumn('recipient_email', Types::STRING, [ + 'notnull' => false, + 'length' => 320, + ]); + $table->addColumn('token', Types::STRING, [ + 'notnull' => true, + 'length' => 60, + ]); + $table->addColumn('accepted', Types::BOOLEAN, [ + 'notnull' => false, + 'default' => false + ]); + $table->addColumn('created_at', Types::BIGINT, [ + 'notnull' => true, + ]); + + $table->addColumn('expired_at', Types::BIGINT, [ + 'notnull' => false, + ]); + + $table->addColumn('accepted_at', Types::BIGINT, [ + 'notnull' => false, + ]); + + $table->addUniqueConstraint(['token']); + $table->setPrimaryKey(['id']); + return $schema; + } + + return null; + } +} diff --git a/apps/cloud_federation_api/openapi.json b/apps/cloud_federation_api/openapi.json index 730af73628f..9c92a152bf8 100644 --- a/apps/cloud_federation_api/openapi.json +++ b/apps/cloud_federation_api/openapi.json @@ -36,79 +36,10 @@ }, "Capabilities": { "type": "object", - "required": [ - "ocm" - ], - "properties": { - "ocm": { - "type": "object", - "required": [ - "apiVersion", - "enabled", - "endPoint", - "resourceTypes", - "version" - ], - "properties": { - "apiVersion": { - "type": "string", - "enum": [ - "1.0-proposal1" - ] - }, - "enabled": { - "type": "boolean" - }, - "endPoint": { - "type": "string" - }, - "publicKey": { - "type": "object", - "required": [ - "keyId", - "publicKeyPem" - ], - "properties": { - "keyId": { - "type": "string" - }, - "publicKeyPem": { - "type": "string" - } - } - }, - "resourceTypes": { - "type": "array", - "items": { - "type": "object", - "required": [ - "name", - "shareTypes", - "protocols" - ], - "properties": { - "name": { - "type": "string" - }, - "shareTypes": { - "type": "array", - "items": { - "type": "string" - } - }, - "protocols": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - }, - "version": { - "type": "string" - } - } + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "object" } } }, @@ -396,6 +327,167 @@ } } } + }, + "/index.php/ocm/invite-accepted": { + "post": { + "operationId": "request_handler-invite-accepted", + "summary": "Inform the sender that an invitation was accepted to start sharing", + "description": "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.\nhttps://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post\nNote: Not implementing 404 Invitation token does not exist, instead using 400", + "tags": [ + "request_handler" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "recipientProvider", + "token", + "userId", + "email", + "name" + ], + "properties": { + "recipientProvider": { + "type": "string", + "description": "The address of the recipent's provider" + }, + "token": { + "type": "string", + "description": "The token used for the invitation" + }, + "userId": { + "type": "string", + "description": "The userId of the recipient at the recipient's provider" + }, + "email": { + "type": "string", + "description": "The email address of the recipient" + }, + "name": { + "type": "string", + "description": "The display name of the recipient" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Invitation accepted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "userID", + "email", + "name" + ], + "properties": { + "userID": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + } + } + } + } + }, + "403": { + "description": "Invitation token does not exist", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message", + "error" + ], + "properties": { + "message": { + "type": "string" + }, + "error": { + "type": "boolean", + "enum": [ + true + ] + } + } + } + } + } + }, + "400": { + "description": "Invalid token", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message", + "error" + ], + "properties": { + "message": { + "type": "string" + }, + "error": { + "type": "boolean", + "enum": [ + true + ] + } + } + } + } + } + }, + "409": { + "description": "User is already known by the OCM provider", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "message", + "error" + ], + "properties": { + "message": { + "type": "string" + }, + "error": { + "type": "boolean", + "enum": [ + true + ] + } + } + } + } + } + } + } + } } }, "tags": [ diff --git a/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php b/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php new file mode 100644 index 00000000000..769e0a2dbff --- /dev/null +++ b/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php @@ -0,0 +1,136 @@ +<?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 NCU\Security\Signature\ISignatureManager; +use OC\OCM\OCMSignatoryManager; +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 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 ISignatureManager&MockObject $signatureManager; + private OCMSignatoryManager&MockObject $signatoryManager; + 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->signatureManager = $this->createMock(ISignatureManager::class); + $this->signatoryManager = $this->createMock(OCMSignatoryManager::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->signatureManager, + $this->signatoryManager, + $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)); + } +} |