Skip to content

Commit fc2aa7f

Browse files
Merge pull request #51113 from mickenordin/master
feat(OCM-invites): Implementation of invitation flow for OCM 1.1.0
2 parents f8c64a1 + 623f2f0 commit fc2aa7f

20 files changed

+949
-205
lines changed

apps/cloud_federation_api/appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
<name>Cloud Federation API</name>
1010
<summary>Enable clouds to communicate with each other and exchange data</summary>
1111
<description>The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data.</description>
12-
<version>1.15.0</version>
12+
<version>1.16.0</version>
1313
<licence>agpl</licence>
1414
<author>Bjoern Schiessle</author>
1515
<namespace>CloudFederationAPI</namespace>

apps/cloud_federation_api/appinfo/routes.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@
2020
'verb' => 'POST',
2121
'root' => '/ocm',
2222
],
23-
// [
24-
// 'name' => 'RequestHandler#inviteAccepted',
25-
// 'url' => '/invite-accepted',
26-
// 'verb' => 'POST',
27-
// 'root' => '/ocm',
28-
// ]
23+
[
24+
'name' => 'RequestHandler#inviteAccepted',
25+
'url' => '/invite-accepted',
26+
'verb' => 'POST',
27+
'root' => '/ocm',
28+
]
2929
],
3030
];

apps/cloud_federation_api/composer/composer/autoload_classmap.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,9 @@
1111
'OCA\\CloudFederationAPI\\Capabilities' => $baseDir . '/../lib/Capabilities.php',
1212
'OCA\\CloudFederationAPI\\Config' => $baseDir . '/../lib/Config.php',
1313
'OCA\\CloudFederationAPI\\Controller\\RequestHandlerController' => $baseDir . '/../lib/Controller/RequestHandlerController.php',
14+
'OCA\\CloudFederationAPI\\Db\\FederatedInvite' => $baseDir . '/../lib/Db/FederatedInvite.php',
15+
'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => $baseDir . '/../lib/Db/FederatedInviteMapper.php',
16+
'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => $baseDir . '/../lib/Events/FederatedInviteAcceptedEvent.php',
17+
'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => $baseDir . '/../lib/Migration/Version1016Date202502262004.php',
1418
'OCA\\CloudFederationAPI\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
1519
);

apps/cloud_federation_api/composer/composer/autoload_static.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ class ComposerStaticInitCloudFederationAPI
2626
'OCA\\CloudFederationAPI\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',
2727
'OCA\\CloudFederationAPI\\Config' => __DIR__ . '/..' . '/../lib/Config.php',
2828
'OCA\\CloudFederationAPI\\Controller\\RequestHandlerController' => __DIR__ . '/..' . '/../lib/Controller/RequestHandlerController.php',
29+
'OCA\\CloudFederationAPI\\Db\\FederatedInvite' => __DIR__ . '/..' . '/../lib/Db/FederatedInvite.php',
30+
'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => __DIR__ . '/..' . '/../lib/Db/FederatedInviteMapper.php',
31+
'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => __DIR__ . '/..' . '/../lib/Events/FederatedInviteAcceptedEvent.php',
32+
'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => __DIR__ . '/..' . '/../lib/Migration/Version1016Date202502262004.php',
2933
'OCA\\CloudFederationAPI\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
3034
);
3135

apps/cloud_federation_api/lib/Capabilities.php

Lines changed: 7 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
77
* SPDX-License-Identifier: AGPL-3.0-or-later
88
*/
9+
910
namespace OCA\CloudFederationAPI;
1011

1112
use NCU\Security\Signature\Exceptions\IdentityNotFoundException;
@@ -16,16 +17,16 @@
1617
use OCP\IAppConfig;
1718
use OCP\IURLGenerator;
1819
use OCP\OCM\Exceptions\OCMArgumentException;
19-
use OCP\OCM\IOCMProvider;
20+
use OCP\OCM\ICapabilityAwareOCMProvider;
2021
use Psr\Log\LoggerInterface;
2122

2223
class Capabilities implements ICapability, IInitialStateExcludedCapability {
23-
public const API_VERSION = '1.1'; // informative, real version.
24+
public const API_VERSION = '1.1.0';
2425

2526
public function __construct(
2627
private IURLGenerator $urlGenerator,
2728
private IAppConfig $appConfig,
28-
private IOCMProvider $provider,
29+
private ICapabilityAwareOCMProvider $provider,
2930
private readonly OCMSignatoryManager $ocmSignatoryManager,
3031
private readonly LoggerInterface $logger,
3132
) {
@@ -34,23 +35,7 @@ public function __construct(
3435
/**
3536
* Function an app uses to return the capabilities
3637
*
37-
* @return array{
38-
* ocm: array{
39-
* apiVersion: '1.0-proposal1',
40-
* enabled: bool,
41-
* endPoint: string,
42-
* publicKey?: array{
43-
* keyId: string,
44-
* publicKeyPem: string,
45-
* },
46-
* resourceTypes: list<array{
47-
* name: string,
48-
* shareTypes: list<string>,
49-
* protocols: array<string, string>
50-
* }>,
51-
* version: string
52-
* }
53-
* }
38+
* @return array<string, array<string, mixed>>
5439
* @throws OCMArgumentException
5540
*/
5641
public function getCapabilities() {
@@ -62,6 +47,8 @@ public function getCapabilities() {
6247

6348
$this->provider->setEnabled(true);
6449
$this->provider->setApiVersion(self::API_VERSION);
50+
$this->provider->setCapabilities(['/invite-accepted', '/notifications', '/shares']);
51+
6552
$this->provider->setEndPoint(substr($url, 0, $pos));
6653

6754
$resource = $this->provider->createNewResourceType();

apps/cloud_federation_api/lib/Controller/RequestHandlerController.php

Lines changed: 109 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
<?php
2+
23
/**
34
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
45
* SPDX-License-Identifier: AGPL-3.0-or-later
56
*/
7+
68
namespace OCA\CloudFederationAPI\Controller;
79

810
use NCU\Federation\ISignedCloudFederationProvider;
@@ -15,15 +17,20 @@
1517
use NCU\Security\Signature\ISignatureManager;
1618
use OC\OCM\OCMSignatoryManager;
1719
use OCA\CloudFederationAPI\Config;
20+
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
21+
use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent;
1822
use OCA\CloudFederationAPI\ResponseDefinitions;
1923
use OCA\FederatedFileSharing\AddressHandler;
2024
use OCP\AppFramework\Controller;
25+
use OCP\AppFramework\Db\DoesNotExistException;
2126
use OCP\AppFramework\Http;
2227
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
2328
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
2429
use OCP\AppFramework\Http\Attribute\OpenAPI;
2530
use OCP\AppFramework\Http\Attribute\PublicPage;
2631
use OCP\AppFramework\Http\JSONResponse;
32+
use OCP\AppFramework\Utility\ITimeFactory;
33+
use OCP\EventDispatcher\IEventDispatcher;
2734
use OCP\Federation\Exceptions\ActionNotSupportedException;
2835
use OCP\Federation\Exceptions\AuthenticationFailedException;
2936
use OCP\Federation\Exceptions\BadRequestException;
@@ -61,12 +68,15 @@ public function __construct(
6168
private IURLGenerator $urlGenerator,
6269
private ICloudFederationProviderManager $cloudFederationProviderManager,
6370
private Config $config,
71+
private IEventDispatcher $dispatcher,
72+
private FederatedInviteMapper $federatedInviteMapper,
6473
private readonly AddressHandler $addressHandler,
6574
private readonly IAppConfig $appConfig,
6675
private ICloudFederationFactory $factory,
6776
private ICloudIdManager $cloudIdManager,
6877
private readonly ISignatureManager $signatureManager,
6978
private readonly OCMSignatoryManager $signatoryManager,
79+
private ITimeFactory $timeFactory,
7080
) {
7181
parent::__construct($appName, $request);
7282
}
@@ -107,7 +117,8 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $
107117
}
108118

109119
// check if all required parameters are set
110-
if ($shareWith === null ||
120+
if (
121+
$shareWith === null ||
111122
$name === null ||
112123
$providerId === null ||
113124
$resourceType === null ||
@@ -213,6 +224,101 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $
213224
return new JSONResponse($responseData, Http::STATUS_CREATED);
214225
}
215226

227+
/**
228+
* Inform the sender that an invitation was accepted to start sharing
229+
*
230+
* Inform about an accepted invitation so the user on the sender provider's side
231+
* can initiate the OCM share creation. To protect the identity of the parties,
232+
* for shares created following an OCM invitation, the user id MAY be hashed,
233+
* and recipients implementing the OCM invitation workflow MAY refuse to process
234+
* shares coming from unknown parties.
235+
* @link https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post
236+
*
237+
* @param string $recipientProvider The address of the recipent's provider
238+
* @param string $token The token used for the invitation
239+
* @param string $userId The userId of the recipient at the recipient's provider
240+
* @param string $email The email address of the recipient
241+
* @param string $name The display name of the recipient
242+
*
243+
* @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{}>
244+
*
245+
* Note: Not implementing 404 Invitation token does not exist, instead using 400
246+
* 200: Invitation accepted
247+
* 400: Invalid token
248+
* 403: Invitation token does not exist
249+
* 409: User is already known by the OCM provider
250+
*/
251+
#[PublicPage]
252+
#[NoCSRFRequired]
253+
#[BruteForceProtection(action: 'inviteAccepted')]
254+
public function inviteAccepted(string $recipientProvider, string $token, string $userId, string $email, string $name): JSONResponse {
255+
$this->logger->debug('Processing share invitation for ' . $userId . ' with token ' . $token . ' and email ' . $email . ' and name ' . $name);
256+
257+
$updated = $this->timeFactory->getTime();
258+
259+
if ($token === '') {
260+
$response = new JSONResponse(['message' => 'Invalid or non existing token', 'error' => true], Http::STATUS_BAD_REQUEST);
261+
$response->throttle();
262+
return $response;
263+
}
264+
265+
try {
266+
$invitation = $this->federatedInviteMapper->findByToken($token);
267+
} catch (DoesNotExistException) {
268+
$response = ['message' => 'Invalid or non existing token', 'error' => true];
269+
$status = Http::STATUS_BAD_REQUEST;
270+
$response = new JSONResponse($response, $status);
271+
$response->throttle();
272+
return $response;
273+
}
274+
275+
if ($invitation->isAccepted() === true) {
276+
$response = ['message' => 'Invite already accepted', 'error' => true];
277+
$status = Http::STATUS_CONFLICT;
278+
return new JSONResponse($response, $status);
279+
}
280+
281+
if ($invitation->getExpiredAt() !== null && $updated > $invitation->getExpiredAt()) {
282+
$response = ['message' => 'Invitation expired', 'error' => true];
283+
$status = Http::STATUS_BAD_REQUEST;
284+
return new JSONResponse($response, $status);
285+
}
286+
$localUser = $this->userManager->get($invitation->getUserId());
287+
if ($localUser === null) {
288+
$response = ['message' => 'Invalid or non existing token', 'error' => true];
289+
$status = Http::STATUS_BAD_REQUEST;
290+
$response = new JSONResponse($response, $status);
291+
$response->throttle();
292+
return $response;
293+
}
294+
295+
$sharedFromEmail = $localUser->getPrimaryEMailAddress();
296+
if ($sharedFromEmail === null) {
297+
$response = ['message' => 'Invalid or non existing token', 'error' => true];
298+
$status = Http::STATUS_BAD_REQUEST;
299+
$response = new JSONResponse($response, $status);
300+
$response->throttle();
301+
return $response;
302+
}
303+
$sharedFromDisplayName = $localUser->getDisplayName();
304+
305+
$response = ['userID' => $localUser->getUID(), 'email' => $sharedFromEmail, 'name' => $sharedFromDisplayName];
306+
$status = Http::STATUS_OK;
307+
308+
$invitation->setAccepted(true);
309+
$invitation->setRecipientEmail($email);
310+
$invitation->setRecipientName($name);
311+
$invitation->setRecipientProvider($recipientProvider);
312+
$invitation->setRecipientUserId($userId);
313+
$invitation->setAcceptedAt($updated);
314+
$invitation = $this->federatedInviteMapper->update($invitation);
315+
316+
$event = new FederatedInviteAcceptedEvent($invitation);
317+
$this->dispatcher->dispatchTyped($event);
318+
319+
return new JSONResponse($response, $status);
320+
}
321+
216322
/**
217323
* Send a notification about an existing share
218324
*
@@ -233,7 +339,8 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $
233339
#[BruteForceProtection(action: 'receiveFederatedShareNotification')]
234340
public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) {
235341
// check if all required parameters are set
236-
if ($notificationType === null ||
342+
if (
343+
$notificationType === null ||
237344
$resourceType === null ||
238345
$providerId === null ||
239346
!is_array($notification)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\CloudFederationAPI\Db;
11+
12+
use OCP\AppFramework\Db\Entity;
13+
use OCP\DB\Types;
14+
15+
/**
16+
* @method bool isAccepted()
17+
* @method void setAccepted(bool $accepted)
18+
* @method int|null getAcceptedAt()
19+
* @method void setAcceptedAt(int $acceptedAt)
20+
* @method int|null getCreatedAt()
21+
* @method void setCreatedAt(int $createdAt)
22+
* @method int|null getExpiredAt()
23+
* @method void setExpiredAt(int $expiredAt)
24+
* @method string|null getRecipientEmail()
25+
* @method void setRecipientEmail(string $recipientEmail)
26+
* @method string|null getRecipientName()
27+
* @method void setRecipientName(string $recipientName)
28+
* @method string|null getRecipientProvider()
29+
* @method void setRecipientProvider(string $recipientProvider)
30+
* @method string|null getRecipientUserId()
31+
* @method void setRecipientUserId(string $recipientUserId)
32+
* @method string getToken()
33+
* @method void setToken(string $token)
34+
* @method string|null getUserId()
35+
* @method void setUserId(string $userId)
36+
*/
37+
38+
class FederatedInvite extends Entity {
39+
protected bool $accepted = false;
40+
protected ?int $acceptedAt = 0;
41+
protected int $createdAt = 0;
42+
protected ?int $expiredAt = 0;
43+
protected ?string $recipientEmail = null;
44+
protected ?string $recipientName = null;
45+
protected ?string $recipientProvider = null;
46+
protected ?string $recipientUserId = null;
47+
protected string $token = '';
48+
protected string $userId = '';
49+
50+
public function __construct() {
51+
$this->addType('accepted', Types::BOOLEAN);
52+
$this->addType('acceptedAt', Types::BIGINT);
53+
$this->addType('createdAt', Types::BIGINT);
54+
$this->addType('expiredAt', Types::BIGINT);
55+
$this->addType('recipientEmail', Types::STRING);
56+
$this->addType('recipientName', Types::STRING);
57+
$this->addType('recipientProvider', Types::STRING);
58+
$this->addType('recipientUserId', Types::STRING);
59+
$this->addType('token', Types::STRING);
60+
$this->addType('userId', Types::STRING);
61+
}
62+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\CloudFederationAPI\Db;
11+
12+
use OCP\AppFramework\Db\QBMapper;
13+
use OCP\IDBConnection;
14+
15+
/**
16+
* @template-extends QBMapper<FederatedInvite>
17+
*/
18+
class FederatedInviteMapper extends QBMapper {
19+
public const TABLE_NAME = 'federated_invites';
20+
21+
public function __construct(IDBConnection $db) {
22+
parent::__construct($db, self::TABLE_NAME);
23+
}
24+
25+
public function findByToken(string $token): FederatedInvite {
26+
$qb = $this->db->getQueryBuilder();
27+
$qb->select('*')
28+
->from('federated_invites')
29+
->where($qb->expr()->eq('token', $qb->createNamedParameter($token)));
30+
return $this->findEntity($qb);
31+
}
32+
33+
}

0 commit comments

Comments
 (0)