Merge pull request #57853 from redblom/move-ocm-invite-accepted-route-impl-to-contacts-app

Move invite-accepted capability and implementation to contacts app.
pull/61984/head
Anna 1 month ago committed by GitHub
commit ccaa4c5cbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 5
      apps/cloud_federation_api/appinfo/info.xml
  2. 6
      apps/cloud_federation_api/appinfo/routes.php
  3. 5
      apps/cloud_federation_api/composer/composer/autoload_classmap.php
  4. 5
      apps/cloud_federation_api/composer/composer/autoload_static.php
  5. 2
      apps/cloud_federation_api/lib/AppInfo/Application.php
  6. 34
      apps/cloud_federation_api/lib/Capabilities.php
  7. 99
      apps/cloud_federation_api/lib/Controller/RequestHandlerController.php
  8. 62
      apps/cloud_federation_api/lib/Db/FederatedInvite.php
  9. 33
      apps/cloud_federation_api/lib/Db/FederatedInviteMapper.php
  10. 24
      apps/cloud_federation_api/lib/Events/FederatedInviteAcceptedEvent.php
  11. 54
      apps/cloud_federation_api/lib/Migration/DropFederatedInvitesTable.php
  12. 170
      apps/cloud_federation_api/openapi.json
  13. 1
      apps/cloud_federation_api/tests/BackgroundJob/CleanupExpiredOcmTokensJobTest.php
  14. 2
      apps/cloud_federation_api/tests/Controller/TokenControllerTest.php
  15. 264
      apps/cloud_federation_api/tests/RequestHandlerControllerTest.php
  16. 2
      core/AppInfo/ConfigLexicon.php
  17. 8
      lib/private/OCM/OCMDiscoveryService.php
  18. 11
      lib/public/OCM/Events/LocalOCMDiscoveryEvent.php
  19. 173
      openapi.json
  20. 1
      psalm.xml

@ -25,4 +25,9 @@
<background-jobs>
<job>OCA\CloudFederationAPI\BackgroundJob\CleanupExpiredOcmTokensJob</job>
</background-jobs>
<repair-steps>
<post-migration>
<step>OCA\CloudFederationAPI\Migration\DropFederatedInvitesTable</step>
</post-migration>
</repair-steps>
</info>

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

@ -9,16 +9,13 @@ return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'OCA\\CloudFederationAPI\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
'OCA\\CloudFederationAPI\\BackgroundJob\\CleanupExpiredOcmTokensJob' => $baseDir . '/../lib/BackgroundJob/CleanupExpiredOcmTokensJob.php',
'OCA\\CloudFederationAPI\\Capabilities' => $baseDir . '/../lib/Capabilities.php',
'OCA\\CloudFederationAPI\\Config' => $baseDir . '/../lib/Config.php',
'OCA\\CloudFederationAPI\\Controller\\OCMRequestController' => $baseDir . '/../lib/Controller/OCMRequestController.php',
'OCA\\CloudFederationAPI\\Controller\\RequestHandlerController' => $baseDir . '/../lib/Controller/RequestHandlerController.php',
'OCA\\CloudFederationAPI\\Controller\\TokenController' => $baseDir . '/../lib/Controller/TokenController.php',
'OCA\\CloudFederationAPI\\Db\\FederatedInvite' => $baseDir . '/../lib/Db/FederatedInvite.php',
'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => $baseDir . '/../lib/Db/FederatedInviteMapper.php',
'OCA\\CloudFederationAPI\\Db\\OcmTokenMap' => $baseDir . '/../lib/Db/OcmTokenMap.php',
'OCA\\CloudFederationAPI\\Db\\OcmTokenMapMapper' => $baseDir . '/../lib/Db/OcmTokenMapMapper.php',
'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => $baseDir . '/../lib/Events/FederatedInviteAcceptedEvent.php',
'OCA\\CloudFederationAPI\\Migration\\DropFederatedInvitesTable' => $baseDir . '/../lib/Migration/DropFederatedInvitesTable.php',
'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => $baseDir . '/../lib/Migration/Version1016Date202502262004.php',
'OCA\\CloudFederationAPI\\Migration\\Version1017Date20260306120000' => $baseDir . '/../lib/Migration/Version1017Date20260306120000.php',
'OCA\\CloudFederationAPI\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',

@ -24,16 +24,13 @@ class ComposerStaticInitCloudFederationAPI
'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php',
'OCA\\CloudFederationAPI\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
'OCA\\CloudFederationAPI\\BackgroundJob\\CleanupExpiredOcmTokensJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupExpiredOcmTokensJob.php',
'OCA\\CloudFederationAPI\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',
'OCA\\CloudFederationAPI\\Config' => __DIR__ . '/..' . '/../lib/Config.php',
'OCA\\CloudFederationAPI\\Controller\\OCMRequestController' => __DIR__ . '/..' . '/../lib/Controller/OCMRequestController.php',
'OCA\\CloudFederationAPI\\Controller\\RequestHandlerController' => __DIR__ . '/..' . '/../lib/Controller/RequestHandlerController.php',
'OCA\\CloudFederationAPI\\Controller\\TokenController' => __DIR__ . '/..' . '/../lib/Controller/TokenController.php',
'OCA\\CloudFederationAPI\\Db\\FederatedInvite' => __DIR__ . '/..' . '/../lib/Db/FederatedInvite.php',
'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => __DIR__ . '/..' . '/../lib/Db/FederatedInviteMapper.php',
'OCA\\CloudFederationAPI\\Db\\OcmTokenMap' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMap.php',
'OCA\\CloudFederationAPI\\Db\\OcmTokenMapMapper' => __DIR__ . '/..' . '/../lib/Db/OcmTokenMapMapper.php',
'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => __DIR__ . '/..' . '/../lib/Events/FederatedInviteAcceptedEvent.php',
'OCA\\CloudFederationAPI\\Migration\\DropFederatedInvitesTable' => __DIR__ . '/..' . '/../lib/Migration/DropFederatedInvitesTable.php',
'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => __DIR__ . '/..' . '/../lib/Migration/Version1016Date202502262004.php',
'OCA\\CloudFederationAPI\\Migration\\Version1017Date20260306120000' => __DIR__ . '/..' . '/../lib/Migration/Version1017Date20260306120000.php',
'OCA\\CloudFederationAPI\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',

@ -9,7 +9,6 @@ declare(strict_types=1);
namespace OCA\CloudFederationAPI\AppInfo;
use OCA\CloudFederationAPI\Capabilities;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
@ -24,7 +23,6 @@ class Application extends App implements IBootstrap {
#[\Override]
public function register(IRegistrationContext $context): void {
$context->registerCapability(Capabilities::class);
}
#[\Override]

@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\CloudFederationAPI;
use OC\OCM\OCMDiscoveryService;
use OCP\Capabilities\ICapability;
use OCP\Capabilities\IInitialStateExcludedCapability;
use OCP\OCM\Exceptions\OCMArgumentException;
class Capabilities implements ICapability, IInitialStateExcludedCapability {
public function __construct(
private readonly OCMDiscoveryService $ocmDiscoveryService,
) {
}
/**
* Function an app uses to return the capabilities
*
* @return array<string, array<string, mixed>>
* @throws OCMArgumentException
*/
#[\Override]
public function getCapabilities() {
$provider = $this->ocmDiscoveryService->getLocalOCMProvider(false);
return ['ocm' => $provider->jsonSerialize()];
}
}

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

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

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

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

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\CloudFederationAPI\Migration;
use OC\DB\Connection;
use OC\DB\SchemaWrapper;
use OCP\Migration\IOutput;
use OCP\Migration\IRepairStep;
/**
* Drops the federated_invites table unless it contains data.
*/
class DropFederatedInvitesTable implements IRepairStep {
public function __construct(
protected Connection $db,
) {
}
#[\Override]
public function getName(): string {
return 'Conditionally drop the federated_invites table';
}
#[\Override]
public function run(IOutput $output): void {
$table_name = 'federated_invites';
$schema = new SchemaWrapper($this->db);
if (!$schema->hasTable($table_name)) {
echo("$table_name does not exist");
return;
}
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from($table_name)
->setMaxResults(1);
$result = $qb->executeQuery();
$hasRows = $result->fetchOne();
if (!$hasRows) {
$schema->dropTable($table_name);
$schema->performDropTableCalls();
$output->info('Table federated_invites dropped');
} else {
$output->info('Table federated_invites contains data. Table will be kept.');
}
}
}

@ -34,15 +34,6 @@
}
}
},
"Capabilities": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
},
"Error": {
"type": "object",
"required": [
@ -329,167 +320,6 @@
}
}
}
},
"/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": [

@ -20,6 +20,7 @@ class CleanupExpiredOcmTokensJobTest extends TestCase {
private OcmTokenMapMapper&MockObject $mapper;
private CleanupExpiredOcmTokensJob $job;
#[\Override]
protected function setUp(): void {
parent::setUp();

@ -53,6 +53,7 @@ class TokenControllerTest extends TestCase {
/** Public key matching the signatory private key configured by configureHappyPath(). */
private string $publicKeyPem = '';
#[\Override]
protected function setUp(): void {
parent::setUp();
@ -81,6 +82,7 @@ class TokenControllerTest extends TestCase {
);
}
#[\Override]
protected function tearDown(): void {
JWT::$timestamp = null;
parent::tearDown();

@ -1,264 +0,0 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\CloudFederationApi\Tests;
use OC\Federation\CloudFederationNotification;
use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Controller\RequestHandlerController;
use OCA\CloudFederationAPI\Db\FederatedInvite;
use OCA\CloudFederationAPI\Db\FederatedInviteMapper;
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\ICloudFederationProvider;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Federation\ICloudFederationShare;
use OCP\Federation\ICloudId;
use OCP\Federation\ICloudIdManager;
use OCP\IAppConfig;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\OCM\Events\OCMNotificationReceivedEvent;
use OCP\OCM\IOCMDiscoveryService;
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 IAppConfig&MockObject $appConfig;
private ICloudFederationFactory&MockObject $cloudFederationFactory;
private ICloudIdManager&MockObject $cloudIdManager;
private IOCMDiscoveryService&MockObject $discoveryService;
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->appConfig = $this->createMock(IAppConfig::class);
$this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class);
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
$this->discoveryService = $this->createMock(IOCMDiscoveryService::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->appConfig,
$this->cloudFederationFactory,
$this->cloudIdManager,
$this->discoveryService,
$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));
}
public function testNotificationReceived(): void {
$notificationType = 'SHARE_ACCEPTED';
$resourceType = 'file';
$providerId = '1337';
$notification = ['sharedSecret' => 'secret'];
$notificationObject = new CloudFederationNotification();
$this->appConfig->method('getValueBool')->willReturn(true);
$provider = $this->createMock(ICloudFederationProvider::class);
$provider->method('notificationReceived')->willReturn([]);
$this->cloudFederationFactory->method('getCloudFederationNotification')
->willReturn($notificationObject);
$this->cloudFederationProviderManager->method('getCloudFederationProvider')
->willReturn($provider);
$this->eventDispatcher->expects(self::once())
->method('dispatchTyped')
->with(self::callback(
fn (
OCMNotificationReceivedEvent $event)
=> $event->getNotification() === $notificationObject
)
);
$response = $this->requestHandlerController->receiveNotification(
$notificationType,
$resourceType,
$providerId,
$notification
);
self::assertEquals(Http::STATUS_CREATED, $response->getStatus());
self::assertEquals([
'notificationType' => $notificationType,
'resourceType' => $resourceType,
'providerId' => $providerId,
'notification' => $notification,
], $notificationObject->getMessage());
}
public function testAddShareRejectsProtocolWithoutSharedSecret(): void {
// Disable signature verification so we reach the protocol validation.
$this->appConfig->method('getValueBool')->willReturn(true);
$protocol = [
'name' => 'multi',
'webdav' => ['requirements' => ['must-exchange-token']],
];
$result = $this->requestHandlerController->addShare(
'bob@https://bob.example.com', 'Jupyter', '', '8',
'alice@alice.example.com', 'alice', 'alice@alice.example.com', 'alice',
$protocol, 'user', 'file',
);
$this->assertInstanceOf(JSONResponse::class, $result);
$this->assertEquals(Http::STATUS_BAD_REQUEST, $result->getStatus());
$this->assertSame('Missing sharedSecret in protocol', $result->getData()['message']);
}
public function testAddShareAcceptsMultiProtocolSharedSecret(): void {
// Disable signature verification so we reach the protocol validation.
$this->appConfig->method('getValueBool')->willReturn(true);
// No supported share types: the share passes the sharedSecret gate and is
// rejected later with 501, proving the multi envelope validated.
$this->config->method('getSupportedShareTypes')->willReturn([]);
$protocol = [
'name' => 'multi',
'webdav' => ['sharedSecret' => 'XHRcgrx1X8uZELY8kxApldZtzoreH8Wj', 'requirements' => ['must-exchange-token']],
'webapp' => ['sharedSecret' => 'XHRcgrx1X8uZELY8kxApldZtzoreH8Wj', 'uri' => 'https://app.example/open'],
];
$result = $this->requestHandlerController->addShare(
'bob@https://bob.example.com', 'Jupyter', '', '8',
'alice@alice.example.com', 'alice', 'alice@alice.example.com', 'alice',
$protocol, 'user', 'file',
);
$this->assertInstanceOf(JSONResponse::class, $result);
$this->assertEquals(Http::STATUS_NOT_IMPLEMENTED, $result->getStatus());
$this->assertNotSame('Missing sharedSecret in protocol', $result->getData()['message'] ?? '');
}
public function testAddShareRoutesFolderResourceTypeMultiProtocol(): void {
// Disable signature verification so we reach the share handling.
$this->appConfig->method('getValueBool')->willReturn(true);
// The files provider is registered for both 'file' and 'folder'.
$this->config->method('getSupportedShareTypes')->with('folder')->willReturn(['user']);
$cloudId = $this->createMock(ICloudId::class);
$cloudId->method('getUser')->willReturn('bob');
$this->cloudIdManager->method('resolveCloudId')->willReturn($cloudId);
$this->userManager->method('userExists')->with('bob')->willReturn(true);
$share = $this->createMock(ICloudFederationShare::class);
$this->cloudFederationFactory->method('getCloudFederationShare')->willReturn($share);
$provider = $this->createMock(ICloudFederationProvider::class);
$provider->expects($this->once())
->method('shareReceived')
->with($share)
->willReturn('share-id-1');
$this->cloudFederationProviderManager->expects($this->once())
->method('getCloudFederationProvider')
->with('folder')
->willReturn($provider);
$recipient = $this->createMock(IUser::class);
$recipient->method('getDisplayName')->willReturn('Bob');
$recipient->method('getUID')->willReturn('bob');
$this->userManager->method('get')->with('bob')->willReturn($recipient);
$protocol = [
'name' => 'multi',
'webdav' => ['sharedSecret' => 'XHRcgrx1X8uZELY8kxApldZtzoreH8Wj', 'requirements' => ['must-exchange-token']],
'webapp' => ['sharedSecret' => 'XHRcgrx1X8uZELY8kxApldZtzoreH8Wj', 'uri' => 'https://app.example/open'],
];
$result = $this->requestHandlerController->addShare(
'bob@https://bob.example.com', 'Jupyter', '', '8',
'alice@alice.example.com', 'alice', 'alice@alice.example.com', 'alice',
$protocol, 'user', 'folder',
);
$this->assertInstanceOf(JSONResponse::class, $result);
$this->assertEquals(Http::STATUS_CREATED, $result->getStatus());
$this->assertSame('Bob', $result->getData()['recipientDisplayName']);
}
}

@ -28,7 +28,6 @@ class ConfigLexicon implements ILexicon {
public const SHARE_LINK_EXPIRE_DATE_ENFORCED = 'shareapi_enforce_expire_date';
public const USER_LANGUAGE = 'lang';
public const OCM_DISCOVERY_ENABLED = 'ocm_discovery_enabled';
public const OCM_INVITE_ACCEPT_DIALOG = 'ocm_invite_accept_dialog';
public const USER_LOCALE = 'locale';
public const USER_TIMEZONE = 'timezone';
@ -94,7 +93,6 @@ class ConfigLexicon implements ILexicon {
),
new Entry(self::LASTCRON_TIMESTAMP, ValueType::INT, 0, 'timestamp of last cron execution'),
new Entry(self::OCM_DISCOVERY_ENABLED, ValueType::BOOL, true, 'enable/disable OCM'),
new Entry(self::OCM_INVITE_ACCEPT_DIALOG, ValueType::STRING, '', 'route to local invite accept dialog', note: 'set as empty string to disable feature'),
new Entry(self::UNIFIED_SEARCH_MIN_SEARCH_LENGTH, ValueType::INT, 1, 'Minimum search length to trigger the request', rename: 'unified-search.min-search-length'),
new Entry(self::UNIFIED_SEARCH_MAX_RESULTS_PER_REQUEST, ValueType::INT, 25, 'Maximum results returned per search request', rename: 'unified-search.max-results-per-request'),
new Entry(

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

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

@ -1304,15 +1304,6 @@
}
}
},
"CloudFederationApiCapabilities": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
},
"CloudFederationApiError": {
"type": "object",
"required": [
@ -7329,9 +7320,6 @@
{
"$ref": "#/components/schemas/CorePublicCapabilities"
},
{
"$ref": "#/components/schemas/CloudFederationApiCapabilities"
},
{
"$ref": "#/components/schemas/CommentsCapabilities"
},
@ -18562,167 +18550,6 @@
}
}
},
"/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",

@ -71,7 +71,6 @@
<file name="tests/lib/Files/Template/*.php"/>
<ignoreFiles>
<directory name="apps/admin_audit/tests"/>
<directory name="apps/cloud_federation_api/tests"/>
<directory name="apps/contactsinteraction/tests"/>
<directory name="apps/dashboard/tests"/>
<directory name="apps/dav/tests"/>

Loading…
Cancel
Save