Compare commits

...

16 Commits

Author SHA1 Message Date
Côme Chilliet
03464f1429 chore: Use constructor property promotion in OCMProvider
Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
2025-03-25 17:30:42 +01:00
Côme Chilliet
fafac38f74 chore: Update openapi json files
Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
2025-03-25 17:28:16 +01:00
Côme Chilliet
8cd70560d9 fix: Use mocks instead of real services in RequestHandlerControllerTest
Signed-off-by: Côme Chilliet <come.chilliet@nextcloud.com>
2025-03-25 17:26:39 +01:00
Micke Nordin
5149a333cb feat(OCM-invites): add test
Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
61aad3f1e8 feat(OCM-invites): Fix psalm issues
Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
b0a523b999 feat(OCM-invites): Fix psalm issues
Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
616447413e feat(OCM-invites): Simplify accepted timestampcheck
Also run cs:fix to fix the code style, and address some minor points.

Fix typo in setRecipientName

Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
8404324774 feat(OCM-invites): Further code style
Co-authored-by: Anna <anna@nextcloud.com>
Co-authored-by: Côme Chilliet <91878298+come-nc@users.noreply.github.com>
Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
4a902b477e feat(OCM-invite): check fails with too strict equality check
Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
6ec00d3df8 feat(OCM-invites): Code style and formatting
Use more ideomatic code and use helper functions that exists where possible.
It seems notnull must be false for sqlite to store false Otherwise we get a chrash.

Co-authored-by: Anna <anna@nextcloud.com>
Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
42cb346efd feat(ITimeFactory): Implement createFromFormat
Allow usage of \DateTime::createFromFormat from the ITimeFactory

Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
a178acfa1c feat(OCM-invites): Add entity and mapper
This patch introduces an entity and a mapper for Invites

Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
5999d77893 feat(OCM-invites): Add invitation class and emit event
I realize that we need to be able to react to accepted invites
elsewhere, e.g. contacts app, so adding invite class and event for
that purpose.

Also rename the migration and bump the version so it will take affect correctly.

Co-authored-by: Navid Shokri <navid.pdp11@gmail.com>
Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
bf31fa9dc7 feat(OCM-invites): Address review feedback
Codestyle and more ideomatic code

Co-authored-by: Joas Schilling <213943+nickvergessen@users.noreply.github.com>
Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
668246d380 feat(OCM-invites): Implementation of invitation flow
This patchset implements the /invite-accepted endpoint

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

Also normalize names of columns, and populate them all

Inspo from:
 - apps/dav/lib/Migration/Version1005Date20180413093149.php
 - https://saturncloud.io/blog/what-is-the-maximum-length-of-a-url-in-different-browsers/#maximum-url-length-in-different-browsers
 - https://www.directedignorance.com/blog/maximum-length-of-email-address

Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
Micke Nordin
9e09cddf9b feat(OCM-invites): Add database table federated_invites.
Co-authored-by: Navid Shokri <navid.pdp11@gmail.com>
Signed-off-by: Micke Nordin <kano@sunet.se>
2025-03-25 16:07:12 +01:00
15 changed files with 967 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ use OCP\OCM\IOCMProvider;
use Psr\Log\LoggerInterface;
class Capabilities implements ICapability {
public const API_VERSION = '1.1'; // informative, real version.
public const API_VERSION = '1.1.0';
public function __construct(
private IURLGenerator $urlGenerator,
@@ -40,16 +40,19 @@ class Capabilities implements ICapability {
* endPoint: string,
* publicKey?: array{
* keyId: string,
* publicKeyPem: string,
* publicKeyPem: string
* },
* provider: string,
* resourceTypes: list<array{
* name: string,
* shareTypes: list<string>,
* protocols: array<string, string>
* }>,
* version: string
* }
* }
* version: string,
* capabilities: array{
* }
* }
* } OCM provider information
* @throws OCMArgumentException
*/
public function getCapabilities() {
@@ -57,6 +60,7 @@ class Capabilities implements ICapability {
$this->provider->setEnabled(true);
$this->provider->setApiVersion(self::API_VERSION);
$this->provider->setCapabilities(['/invite-accepted', '/notifications', '/shares']);
$pos = strrpos($url, '/');
if ($pos === false) {

View File

@@ -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,21 @@ 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 OCA\Federation\TrustedServers;
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 +69,16 @@ 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 TrustedServers $trustedServers,
private ITimeFactory $timeFactory,
) {
parent::__construct($appName, $request);
}
@@ -107,7 +119,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 +226,99 @@ 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.
*
* @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|Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST|Http::STATUS_CONFLICT, array{email?: null|string, error?: true, message?: string, name?: string, userID?: string}, 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
* spec link: https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post
*/
#[PublicPage]
#[NoCSRFRequired]
#[BruteForceProtection(action: 'inviteAccepted')]
public function inviteAccepted(string $recipientProvider, string $token, string $userId, string $email, string $name): JSONResponse {
$this->logger->debug('Invite accepted 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;
}
if (!$this->trustedServers->isTrustedServer($recipientProvider)) {
$response = ['message' => 'Remote server not trusted', 'error' => true];
$status = Http::STATUS_FORBIDDEN;
return new JSONResponse($response, $status);
}
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 (!empty($invitation->getExpiredAt()) && $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();
$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)

View File

@@ -0,0 +1,92 @@
<?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 getAcceptedAt()
* @method void setAcceptedAt(int $acceptedAt)
* @method ?int getCreatedAt()
* @method void setCreatedAt(int $createdAt)
* @method ?int getExpiredAt()
* @method void setExpiredAt(int $expiredAt)
* @method ?string getRecipientEmail()
* @method void setRecipientEmail(string $recipientEmail)
* @method ?string getRecipientName()
* @method void setRecipientName(string $recipientName)
* @method ?string getRecipientProvider()
* @method void setRecipientProvider(string $recipientProvider)
* @method ?string getRecipientUserId()
* @method void setRecipientUserId(string $recipientUserId)
* @method string getToken()
* @method void setToken(string $token)
* @method ?string getUserId()
* @method void setUserId(string $userId)
*/
class FederatedInvite extends Entity {
/**
* @var bool $accepted
*/
protected $accepted;
/**
* @var ?int $acceptedAt
*/
protected $acceptedAt;
/**
* @var int $createdAt
*/
protected $createdAt;
/**
* @var ?int $expiredAt
*/
protected $expiredAt;
/**
* @var ?string $recipientEmail
*/
protected $recipientEmail;
/**
* @var ?string $recipientName
*/
protected $recipientName;
/**
* @var ?string $recipientProvider
*/
protected $recipientProvider;
/**
* @var ?string $recipientUserId
*/
protected $recipientUserId;
/**
* @var string $token
*/
protected $token;
/**
* @var string $userId
*/
protected $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

@@ -0,0 +1,35 @@
<?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\DB\QueryBuilder\IQueryBuilder;
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 {
/** @var IQueryBuilder $qb */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('federated_invites')
->where($qb->expr()->eq('token', $qb->createNamedParameter($token)));
return $this->findEntity($qb);
}
}

View File

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

View File

@@ -0,0 +1,92 @@
<?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->setPrimaryKey(['id']);
return $schema;
}
return null;
}
}

View File

@@ -46,8 +46,10 @@
"apiVersion",
"enabled",
"endPoint",
"provider",
"resourceTypes",
"version"
"version",
"capabilities"
],
"properties": {
"apiVersion": {
@@ -77,6 +79,9 @@
}
}
},
"provider": {
"type": "string"
},
"resourceTypes": {
"type": "array",
"items": {
@@ -107,6 +112,9 @@
},
"version": {
"type": "string"
},
"capabilities": {
"type": "object"
}
}
}
@@ -396,6 +404,190 @@
}
}
}
},
"/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.\nNote: Not implementing 404 Invitation token does not exist, instead using 400 spec link: https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post",
"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",
"properties": {
"email": {
"type": "string",
"nullable": true
},
"error": {
"type": "boolean",
"enum": [
true
]
},
"message": {
"type": "string"
},
"name": {
"type": "string"
},
"userID": {
"type": "string"
}
}
}
}
}
},
"403": {
"description": "Invitation token does not exist",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"nullable": true
},
"error": {
"type": "boolean",
"enum": [
true
]
},
"message": {
"type": "string"
},
"name": {
"type": "string"
},
"userID": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Invalid token",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"nullable": true
},
"error": {
"type": "boolean",
"enum": [
true
]
},
"message": {
"type": "string"
},
"name": {
"type": "string"
},
"userID": {
"type": "string"
}
}
}
}
}
},
"409": {
"description": "User is already known by the OCM provider",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"nullable": true
},
"error": {
"type": "boolean",
"enum": [
true
]
},
"message": {
"type": "string"
},
"name": {
"type": "string"
},
"userID": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"tags": [

View File

@@ -0,0 +1,146 @@
<?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 OCA\Federation\TrustedServers;
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 TrustedServers&MockObject $trustedServers;
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->trustedServers = $this->createMock(TrustedServers::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->trustedServers,
$this->timeFactory,
);
}
public function testInviteAccepted(): void {
$token = 'token';
$trusted_server = 'http://127.0.0.1';
$userId = 'userId';
$invite = new FederatedInvite();
$invite->setCreatedAt(1);
$invite->setUserId($userId);
$invite->setToken($token);
$this->trustedServers->expects(self::once())
->method('isTrustedServer')
->with($trusted_server)
->willReturn(true);
$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 = $trusted_server;
$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

@@ -11,6 +11,7 @@ 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;
@@ -21,8 +22,10 @@ use OCP\OCM\IOCMResource;
* @since 28.0.0
*/
class OCMProvider implements IOCMProvider {
private string $provider;
private bool $enabled = false;
private string $apiVersion = '';
private array $capabilities = [];
private string $endPoint = '';
/** @var IOCMResource[] */
private array $resourceTypes = [];
@@ -31,7 +34,9 @@ class OCMProvider implements IOCMProvider {
public function __construct(
protected IEventDispatcher $dispatcher,
protected IConfig $config,
) {
$this->provider = 'Nextcloud ' . $config->getSystemValue('version');
}
/**
@@ -88,6 +93,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

View File

@@ -54,6 +54,25 @@ interface IOCMProvider extends JsonSerializable {
*/
public function getApiVersion(): string;
/**
* returns the capabilities of the API
*
* @return array
* @since 32.0.0
*/
public function getCapabilities(): array;
/**
* set the capabilities of the API
*
* @param array $capabilities
*
* @return $this
* @since 32.0.0
*/
public function setCapabilities(array $capabilities): static;
/**
* configure endpoint
*
@@ -72,6 +91,13 @@ interface IOCMProvider extends JsonSerializable {
*/
public function getEndPoint(): string;
/**
* get provider
*
* @return string
* @since 32.0.0
*/
public function getProvider(): string;
/**
* create a new resource to later add it with {@see addResourceType()}
* @return IOCMResource

View File

@@ -1118,8 +1118,10 @@
"apiVersion",
"enabled",
"endPoint",
"provider",
"resourceTypes",
"version"
"version",
"capabilities"
],
"properties": {
"apiVersion": {
@@ -1149,6 +1151,9 @@
}
}
},
"provider": {
"type": "string"
},
"resourceTypes": {
"type": "array",
"items": {
@@ -1179,6 +1184,9 @@
},
"version": {
"type": "string"
},
"capabilities": {
"type": "object"
}
}
}
@@ -13656,6 +13664,190 @@
}
}
},
"/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.\nNote: Not implementing 404 Invitation token does not exist, instead using 400 spec link: https://cs3org.github.io/OCM-API/docs.html?branch=v1.1.0&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post",
"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",
"properties": {
"email": {
"type": "string",
"nullable": true
},
"error": {
"type": "boolean",
"enum": [
true
]
},
"message": {
"type": "string"
},
"name": {
"type": "string"
},
"userID": {
"type": "string"
}
}
}
}
}
},
"403": {
"description": "Invitation token does not exist",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"nullable": true
},
"error": {
"type": "boolean",
"enum": [
true
]
},
"message": {
"type": "string"
},
"name": {
"type": "string"
},
"userID": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Invalid token",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"nullable": true
},
"error": {
"type": "boolean",
"enum": [
true
]
},
"message": {
"type": "string"
},
"name": {
"type": "string"
},
"userID": {
"type": "string"
}
}
}
}
}
},
"409": {
"description": "User is already known by the OCM provider",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"email": {
"type": "string",
"nullable": true
},
"error": {
"type": "boolean",
"enum": [
true
]
},
"message": {
"type": "string"
},
"name": {
"type": "string"
},
"userID": {
"type": "string"
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/dashboard/api/v1/widget-items": {
"get": {
"operationId": "dashboard-dashboard_api-get-widget-items",