feat(OCM-invites): Implementation of invitation flow

This patchset:
* implements the /invite-accepted endpoint
* adds capabilities and inviteAceptDialog to the discovery
* adds a FederatedInviteAcceptedEvent

https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post

Co-authored-by: Anna <anna@nextcloud.com>
Co-authored-by: Côme Chilliet <come.chilliet@nextcloud.com>
Co-authored-by: Joas Schilling <213943+nickvergessen@users.noreply.github.com>
Co-authored-by: Navid Shokri <navid.pdp11@gmail.com>
Signed-off-by: Micke Nordin <kano@sunet.se>
pull/51113/head
Micke Nordin 7 months ago
parent acc2311a0d
commit 623f2f0240
No known key found for this signature in database
GPG Key ID: 0DA0A7A5708FE257
  1. 2
      apps/cloud_federation_api/appinfo/info.xml
  2. 12
      apps/cloud_federation_api/appinfo/routes.php
  3. 4
      apps/cloud_federation_api/composer/composer/autoload_classmap.php
  4. 4
      apps/cloud_federation_api/composer/composer/autoload_static.php
  5. 27
      apps/cloud_federation_api/lib/Capabilities.php
  6. 111
      apps/cloud_federation_api/lib/Controller/RequestHandlerController.php
  7. 62
      apps/cloud_federation_api/lib/Db/FederatedInvite.php
  8. 33
      apps/cloud_federation_api/lib/Db/FederatedInviteMapper.php
  9. 24
      apps/cloud_federation_api/lib/Events/FederatedInviteAcceptedEvent.php
  10. 89
      apps/cloud_federation_api/lib/Migration/Version1016Date202502262004.php
  11. 238
      apps/cloud_federation_api/openapi.json
  12. 136
      apps/cloud_federation_api/tests/RequestHandlerControllerTest.php
  13. 5
      lib/composer/autoload.php
  14. 1
      lib/composer/composer/autoload_classmap.php
  15. 1
      lib/composer/composer/autoload_static.php
  16. 94
      lib/private/OCM/Model/OCMProvider.php
  17. 8
      lib/private/OCM/OCMDiscoveryService.php
  18. 5
      lib/private/Server.php
  19. 60
      lib/public/OCM/ICapabilityAwareOCMProvider.php
  20. 238
      openapi.json

@ -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>

@ -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',
]
],
];

@ -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',
);

@ -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',
);

@ -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();

@ -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,7 +117,8 @@ class RequestHandlerController extends Controller {
}
// check if all required parameters are set
if ($shareWith === null ||
if (
$shareWith === null ||
$name === null ||
$providerId === null ||
$resourceType === null ||
@ -213,6 +224,101 @@ 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->getPrimaryEMailAddress();
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
*
@ -233,7 +339,8 @@ 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 ||
if (
$notificationType === null ||
$resourceType === null ||
$providerId === null ||
!is_array($notification)

@ -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);
}
}

@ -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);
}
}

@ -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;
}
}

@ -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;
}
}

@ -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": [

@ -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('getPrimaryEMailAddress')
->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));
}
}

@ -14,10 +14,7 @@ if (PHP_VERSION_ID < 50600) {
echo $err;
}
}
trigger_error(
$err,
E_USER_ERROR
);
throw new RuntimeException($err);
}
require_once __DIR__ . '/composer/autoload_real.php';

@ -679,6 +679,7 @@ return array(
'OCP\\OCM\\Events\\ResourceTypeRegisterEvent' => $baseDir . '/lib/public/OCM/Events/ResourceTypeRegisterEvent.php',
'OCP\\OCM\\Exceptions\\OCMArgumentException' => $baseDir . '/lib/public/OCM/Exceptions/OCMArgumentException.php',
'OCP\\OCM\\Exceptions\\OCMProviderException' => $baseDir . '/lib/public/OCM/Exceptions/OCMProviderException.php',
'OCP\\OCM\\ICapabilityAwareOCMProvider' => $baseDir . '/lib/public/OCM/ICapabilityAwareOCMProvider.php',
'OCP\\OCM\\IOCMDiscoveryService' => $baseDir . '/lib/public/OCM/IOCMDiscoveryService.php',
'OCP\\OCM\\IOCMProvider' => $baseDir . '/lib/public/OCM/IOCMProvider.php',
'OCP\\OCM\\IOCMResource' => $baseDir . '/lib/public/OCM/IOCMResource.php',

@ -720,6 +720,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\OCM\\Events\\ResourceTypeRegisterEvent' => __DIR__ . '/../../..' . '/lib/public/OCM/Events/ResourceTypeRegisterEvent.php',
'OCP\\OCM\\Exceptions\\OCMArgumentException' => __DIR__ . '/../../..' . '/lib/public/OCM/Exceptions/OCMArgumentException.php',
'OCP\\OCM\\Exceptions\\OCMProviderException' => __DIR__ . '/../../..' . '/lib/public/OCM/Exceptions/OCMProviderException.php',
'OCP\\OCM\\ICapabilityAwareOCMProvider' => __DIR__ . '/../../..' . '/lib/public/OCM/ICapabilityAwareOCMProvider.php',
'OCP\\OCM\\IOCMDiscoveryService' => __DIR__ . '/../../..' . '/lib/public/OCM/IOCMDiscoveryService.php',
'OCP\\OCM\\IOCMProvider' => __DIR__ . '/../../..' . '/lib/public/OCM/IOCMProvider.php',
'OCP\\OCM\\IOCMResource' => __DIR__ . '/../../..' . '/lib/public/OCM/IOCMResource.php',

@ -11,18 +11,22 @@ namespace OC\OCM\Model;
use NCU\Security\Signature\Model\Signatory;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IConfig;
use OCP\OCM\Events\ResourceTypeRegisterEvent;
use OCP\OCM\Exceptions\OCMArgumentException;
use OCP\OCM\Exceptions\OCMProviderException;
use OCP\OCM\IOCMProvider;
use OCP\OCM\ICapabilityAwareOCMProvider;
use OCP\OCM\IOCMResource;
/**
* @since 28.0.0
*/
class OCMProvider implements IOCMProvider {
class OCMProvider implements ICapabilityAwareOCMProvider {
private string $provider;
private bool $enabled = false;
private string $apiVersion = '';
private string $inviteAcceptDialog = '';
private array $capabilities = [];
private string $endPoint = '';
/** @var IOCMResource[] */
private array $resourceTypes = [];
@ -31,7 +35,9 @@ class OCMProvider implements IOCMProvider {
public function __construct(
protected IEventDispatcher $dispatcher,
protected IConfig $config,
) {
$this->provider = 'Nextcloud ' . $config->getSystemValue('version');
}
/**
@ -70,6 +76,30 @@ class OCMProvider implements IOCMProvider {
return $this->apiVersion;
}
/**
* returns the invite accept dialog
*
* @return string
* @since 32.0.0
*/
public function getInviteAcceptDialog(): string {
return $this->inviteAcceptDialog;
}
/**
* set the invite accept dialog
*
* @param string $inviteAcceptDialog
*
* @return $this
* @since 32.0.0
*/
public function setInviteAcceptDialog(string $inviteAcceptDialog): static {
$this->inviteAcceptDialog = $inviteAcceptDialog;
return $this;
}
/**
* @param string $endPoint
*
@ -88,6 +118,34 @@ class OCMProvider implements IOCMProvider {
return $this->endPoint;
}
/**
* @return string
*/
public function getProvider(): string {
return $this->provider;
}
/**
* @param array $capabilities
*
* @return $this
*/
public function setCapabilities(array $capabilities): static {
foreach ($capabilities as $value) {
if (!in_array($value, $this->capabilities)) {
array_push($this->capabilities, $value);
}
}
return $this;
}
/**
* @return array
*/
public function getCapabilities(): array {
return $this->capabilities;
}
/**
* create a new resource to later add it with {@see IOCMProvider::addResourceType()}
* @return IOCMResource
@ -166,9 +224,8 @@ class OCMProvider implements IOCMProvider {
*
* @param array $data
*
* @return $this
* @return OCMProvider&static
* @throws OCMProviderException in case a descent provider cannot be generated from data
* @see self::jsonSerialize()
*/
public function import(array $data): static {
$this->setEnabled(is_bool($data['enabled'] ?? '') ? $data['enabled'] : false)
@ -209,21 +266,7 @@ class OCMProvider implements IOCMProvider {
}
/**
* @return array{
* enabled: bool,
* apiVersion: '1.0-proposal1',
* endPoint: string,
* publicKey?: array{
* keyId: string,
* publicKeyPem: string
* },
* resourceTypes: list<array{
* name: string,
* shareTypes: list<string>,
* protocols: array<string, string>
* }>,
* version: string
* }
* @since 28.0.0
*/
public function jsonSerialize(): array {
$resourceTypes = [];
@ -231,7 +274,7 @@ class OCMProvider implements IOCMProvider {
$resourceTypes[] = $res->jsonSerialize();
}
return [
$response = [
'enabled' => $this->isEnabled(),
'apiVersion' => '1.0-proposal1', // deprecated, but keep it to stay compatible with old version
'version' => $this->getApiVersion(), // informative but real version
@ -239,5 +282,16 @@ class OCMProvider implements IOCMProvider {
'publicKey' => $this->getSignatory()?->jsonSerialize(),
'resourceTypes' => $resourceTypes
];
$capabilities = $this->getCapabilities();
$inviteAcceptDialog = $this->getInviteAcceptDialog();
if ($capabilities) {
$response['capabilities'] = $capabilities;
}
if ($inviteAcceptDialog) {
$response['inviteAcceptDialog'] = $inviteAcceptDialog;
}
return $response;
}
}

@ -17,8 +17,8 @@ use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\OCM\Exceptions\OCMProviderException;
use OCP\OCM\ICapabilityAwareOCMProvider;
use OCP\OCM\IOCMDiscoveryService;
use OCP\OCM\IOCMProvider;
use Psr\Log\LoggerInterface;
/**
@ -31,7 +31,7 @@ class OCMDiscoveryService implements IOCMDiscoveryService {
ICacheFactory $cacheFactory,
private IClientService $clientService,
private IConfig $config,
private IOCMProvider $provider,
private ICapabilityAwareOCMProvider $provider,
private LoggerInterface $logger,
) {
$this->cache = $cacheFactory->createDistributed('ocm-discovery');
@ -42,10 +42,10 @@ class OCMDiscoveryService implements IOCMDiscoveryService {
* @param string $remote
* @param bool $skipCache
*
* @return IOCMProvider
* @return ICapabilityAwareOCMProvider
* @throws OCMProviderException
*/
public function discover(string $remote, bool $skipCache = false): IOCMProvider {
public function discover(string $remote, bool $skipCache = false): ICapabilityAwareOCMProvider {
$remote = rtrim($remote, '/');
if (!str_starts_with($remote, 'http://') && !str_starts_with($remote, 'https://')) {
// if scheme not specified, we test both;

@ -1,4 +1,5 @@
<?php
/**
* SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
@ -196,8 +197,8 @@ use OCP\Lock\ILockingProvider;
use OCP\Lockdown\ILockdownManager;
use OCP\Log\ILogFactory;
use OCP\Mail\IMailer;
use OCP\OCM\ICapabilityAwareOCMProvider;
use OCP\OCM\IOCMDiscoveryService;
use OCP\OCM\IOCMProvider;
use OCP\Preview\IMimeIconProvider;
use OCP\Profile\IProfileManager;
use OCP\Profiler\IProfiler;
@ -1271,7 +1272,7 @@ class Server extends ServerContainer implements IServerContainer {
$this->registerAlias(IPhoneNumberUtil::class, PhoneNumberUtil::class);
$this->registerAlias(IOCMProvider::class, OCMProvider::class);
$this->registerAlias(ICapabilityAwareOCMProvider::class, OCMProvider::class);
$this->registerAlias(ISetupCheckManager::class, SetupCheckManager::class);

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\OCM;
/**
* Version 1.1 and 1.2 extensions to the Open Cloud Mesh Discovery API
* @link https://github.com/cs3org/OCM-API/
* @since 32.0.0
*/
interface ICapabilityAwareOCMProvider extends IOCMProvider {
/**
* get the capabilities
*
* @return array
* @since 32.0.0
*/
public function getCapabilities(): array;
/**
* get the provider name
*
* @return string
* @since 32.0.0
*/
public function getProvider(): string;
/**
* returns the invite accept dialog
*
* @return string
* @since 32.0.0
*/
public function getInviteAcceptDialog(): string;
/**
* set the capabilities
*
* @param array $capabilities
*
* @return $this
* @since 32.0.0
*/
public function setCapabilities(array $capabilities): static;
/**
* set the invite accept dialog
*
* @param string $inviteAcceptDialog
*
* @return $this
* @since 32.0.0
*/
public function setInviteAcceptDialog(string $inviteAcceptDialog): static;
}

@ -1206,79 +1206,10 @@
},
"CloudFederationApiCapabilities": {
"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"
}
}
},
@ -13918,6 +13849,167 @@
}
}
},
"/index.php/ocm/invite-accepted": {
"post": {
"operationId": "cloud_federation_api-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": [
"cloud_federation_api/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
]
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/dashboard/api/v1/widget-items": {
"get": {
"operationId": "dashboard-dashboard_api-get-widget-items",

Loading…
Cancel
Save