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>
This commit is contained in:
Micke Nordin 2025-02-28 16:37:18 +01:00 committed by Micke Nordin
parent bf31fa9dc7
commit 5999d77893
10 changed files with 111 additions and 53 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

@ -32,11 +32,6 @@ class InstalledVersions
*/
private static $installed;
/**
* @var bool
*/
private static $installedIsLocalDir;
/**
* @var bool|null
*/
@ -314,12 +309,6 @@ class InstalledVersions
{
self::$installed = $data;
self::$installedByVendor = array();
// when using reload, we disable the duplicate protection to ensure that self::$installed data is
// always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not,
// so we have to assume it does not, and that may result in duplicate data being returned when listing
// all installed packages for example
self::$installedIsLocalDir = false;
}
/**
@ -333,27 +322,19 @@ class InstalledVersions
}
$installed = array();
$copiedLocalDir = false;
if (self::$canGetVendors) {
$selfDir = strtr(__DIR__, '\\', '/');
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
$vendorDir = strtr($vendorDir, '\\', '/');
if (isset(self::$installedByVendor[$vendorDir])) {
$installed[] = self::$installedByVendor[$vendorDir];
} elseif (is_file($vendorDir.'/composer/installed.php')) {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require $vendorDir.'/composer/installed.php';
self::$installedByVendor[$vendorDir] = $required;
$installed[] = $required;
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
self::$installed = $required;
self::$installedIsLocalDir = true;
$installed[] = self::$installedByVendor[$vendorDir] = $required;
if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
self::$installed = $installed[count($installed) - 1];
}
}
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
$copiedLocalDir = true;
}
}
}
@ -369,7 +350,7 @@ class InstalledVersions
}
}
if (self::$installed !== array() && !$copiedLocalDir) {
if (self::$installed !== array()) {
$installed[] = self::$installed;
}

View file

@ -11,5 +11,8 @@ 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\\Migration\\Version1016Date202502262004' => $baseDir . '/../lib/Migration/Version1016Date202502262004.php',
'OCA\\CloudFederationAPI\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php',
'OCA\\CloudFederationApi\\Events\\OCMInvitationAcceptedEvent' => $baseDir . '/../lib/Events/OCMInvitationAcceptedEvent.php',
'OCA\\CloudFederationApi\\OCMInvitation' => $baseDir . '/../lib/OCMInvitation.php',
);

View file

@ -26,7 +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\\Migration\\Version1016Date202502262004' => __DIR__ . '/..' . '/../lib/Migration/Version1016Date202502262004.php',
'OCA\\CloudFederationAPI\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php',
'OCA\\CloudFederationApi\\Events\\OCMInvitationAcceptedEvent' => __DIR__ . '/..' . '/../lib/Events/OCMInvitationAcceptedEvent.php',
'OCA\\CloudFederationApi\\OCMInvitation' => __DIR__ . '/..' . '/../lib/OCMInvitation.php',
);
public static function getInitializer(ClassLoader $loader)

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 DateTime;
@ -16,6 +18,8 @@ use NCU\Security\Signature\IIncomingSignedRequest;
use NCU\Security\Signature\ISignatureManager;
use OC\OCM\OCMSignatoryManager;
use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\Events\OCMInvitationAcceptedEvent;
use OCA\CloudFederationAPI\OCMInvitation;
use OCA\CloudFederationAPI\ResponseDefinitions;
use OCA\FederatedFileSharing\AddressHandler;
use OCA\Federation\TrustedServers;
@ -27,6 +31,7 @@ use OCP\AppFramework\Http\Attribute\OpenAPI;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\JSONResponse;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Federation\Exceptions\ActionNotSupportedException;
use OCP\Federation\Exceptions\AuthenticationFailedException;
use OCP\Federation\Exceptions\BadRequestException;
@ -55,7 +60,8 @@ use Psr\Log\LoggerInterface;
* @psalm-import-type CloudFederationAPIError from ResponseDefinitions
*/
#[OpenAPI(scope: OpenAPI::SCOPE_FEDERATION)]
class RequestHandlerController extends Controller {
class RequestHandlerController extends Controller
{
public function __construct(
string $appName,
IRequest $request,
@ -65,6 +71,7 @@ class RequestHandlerController extends Controller {
private IURLGenerator $urlGenerator,
private ICloudFederationProviderManager $cloudFederationProviderManager,
private Config $config,
private IEventDispatcher $dispatcher,
private IDBConnection $db,
private readonly AddressHandler $addressHandler,
private readonly IAppConfig $appConfig,
@ -101,7 +108,8 @@ class RequestHandlerController extends Controller {
#[PublicPage]
#[NoCSRFRequired]
#[BruteForceProtection(action: 'receiveFederatedShare')]
public function addShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $protocol, $shareType, $resourceType) {
public function addShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $protocol, $shareType, $resourceType)
{
try {
// if request is signed and well signed, no exception are thrown
// if request is not signed and host is known for not supporting signed request, no exception are thrown
@ -113,7 +121,8 @@ class RequestHandlerController extends Controller {
}
// check if all required parameters are set
if ($shareWith === null ||
if (
$shareWith === null ||
$name === null ||
$providerId === null ||
$resourceType === null ||
@ -189,7 +198,7 @@ class RequestHandlerController extends Controller {
$share = $this->factory->getCloudFederationShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, '', $shareType, $resourceType);
$share->setProtocol($protocol);
$provider->shareReceived($share);
} catch (ProviderDoesNotExistsException|ProviderCouldNotAddShareException $e) {
} catch (ProviderDoesNotExistsException | ProviderCouldNotAddShareException $e) {
return new JSONResponse(
['message' => $e->getMessage()],
Http::STATUS_NOT_IMPLEMENTED
@ -243,7 +252,8 @@ class RequestHandlerController extends Controller {
#[PublicPage]
#[NoCSRFRequired]
#[BruteForceProtection(action: 'inviteAccepted')]
public function inviteAccepted(string $recipientProvider, string $token, string $userId, string $email, string $name): JSONResponse {
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);
/** @var IQueryBuilder $qb */
@ -255,6 +265,7 @@ class RequestHandlerController extends Controller {
$data = $result->fetch();
$result->closeCursor();
$found_for_this_user = false;
$updated = new DateTime("now");
if ($data) {
$found_for_this_user = $data['recipient_user_id'] === $userId && isset($data['user_id']);
}
@ -265,17 +276,22 @@ class RequestHandlerController extends Controller {
$response->throttle();
return $response;
}
if(!$this->trustedServers->isTrustedServer($recipientProvider)) {
if (!$this->trustedServers->isTrustedServer($recipientProvider)) {
$response = ['message' => 'Remote server not trusted', 'error' => true];
$status = Http::STATUS_FORBIDDEN;
return new JSONResponse($response,$status);
return new JSONResponse($response, $status);
}
// Note: Not implementing 404 Invitation token does not exist, instead using 400
if ($data['accepted'] === true ) {
if ($data['accepted'] === true) {
$response = ['message' => 'Invite already accepted', 'error' => true];
$status = Http::STATUS_CONFLICT;
return new JSONResponse($response,$status);
return new JSONResponse($response, $status);
}
if ($data['expiresAt'] < $updated) {
$response = ['message' => 'Invitation expired', 'error' => true];
$status = Http::STATUS_BAD_REQUEST;
return new JSONResponse($response, $status);
}
$localUser = $this->userManager->get($data['user_id']);
@ -284,7 +300,6 @@ class RequestHandlerController extends Controller {
$response = ['userID' => $data['user_id'], 'email' => $sharedFromEmail, 'name' => $sharedFromDisplayName];
$status = Http::STATUS_OK;
$updated = new DateTime("now");
$qb->update('federated_invites')
->set('accepted', $qb->createNamedParameter(true))
->set('acceptedAt', $qb->createNamedParameter($updated))
@ -295,7 +310,21 @@ class RequestHandlerController extends Controller {
->where($qb->expr()->eq('token', $qb->createNamedParameter($token)));
$qb->executeStatement();
return new JSONResponse($response,$status);
$invitation = new OCMInvitation(
accepted: true,
recipient_email: $email,
recipient_name: $name,
recipient_user_id: $userId,
recipient_provider: $recipientProvider,
token: $token,
user_id: $data['user_id'],
acceptedAt: $updated,
createdAt: $data['createdAt'],
expiresAt: $data['expiresAt']
);
$this->dispatcher->dispatchTyped(new OCMInvitationAcceptedEvent($invitation));
return new JSONResponse($response, $status);
}
/**
@ -316,9 +345,11 @@ class RequestHandlerController extends Controller {
#[NoCSRFRequired]
#[PublicPage]
#[BruteForceProtection(action: 'receiveFederatedShareNotification')]
public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) {
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)
@ -394,7 +425,8 @@ class RequestHandlerController extends Controller {
* @param string $uid
* @return string mixed
*/
private function mapUid($uid) {
private function mapUid($uid)
{
// FIXME this should be a method in the user management instead
$this->logger->debug('shareWith before, ' . $uid, ['app' => $this->appName]);
Util::emitHook(
@ -417,12 +449,13 @@ class RequestHandlerController extends Controller {
* @return IIncomingSignedRequest|null null if remote does not (and never did) support signed request
* @throws IncomingRequestException
*/
private function getSignedRequest(): ?IIncomingSignedRequest {
private function getSignedRequest(): ?IIncomingSignedRequest
{
try {
$signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager);
$this->logger->debug('signed request available', ['signedRequest' => $signedRequest]);
return $signedRequest;
} catch (SignatureNotFoundException|SignatoryNotFoundException $e) {
} catch (SignatureNotFoundException | SignatoryNotFoundException $e) {
$this->logger->debug('remote does not support signed request', ['exception' => $e]);
// remote does not support signed request.
// currently we still accept unsigned request until lazy appconfig
@ -452,7 +485,8 @@ class RequestHandlerController extends Controller {
*
* @throws IncomingRequestException
*/
private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void {
private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void
{
if ($signedRequest === null) {
$instance = $this->getHostFromFederationId($value);
try {
@ -516,7 +550,8 @@ class RequestHandlerController extends Controller {
* @return void
* @throws IncomingRequestException
*/
private function confirmNotificationEntry(?IIncomingSignedRequest $signedRequest, string $entry): void {
private function confirmNotificationEntry(?IIncomingSignedRequest $signedRequest, string $entry): void
{
$instance = $this->getHostFromFederationId($entry);
if ($signedRequest === null) {
try {
@ -535,7 +570,8 @@ class RequestHandlerController extends Controller {
* @return string
* @throws IncomingRequestException
*/
private function getHostFromFederationId(string $entry): string {
private function getHostFromFederationId(string $entry): string
{
if (!str_contains($entry, '@')) {
throw new IncomingRequestException('entry ' . $entry . ' does not contains @');
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Micke Nordin <kano@sunet.se>
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\CloudFederationApi\Events;
use OCP\EventDispatcher\Event;
use OCA\CloudFederationApi\OCMInvitation;
class OCMInvitationAcceptedEvent extends Event
{
public function __construct(
private OCMInvitation $invitation
) {
parent::__construct();
}
public function getInvitation(): OCMInvitation
{
return $this->invitation;
}
}

View file

@ -15,7 +15,7 @@ use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version1015Date202502262004 extends SimpleMigrationStep
class Version1016Date202502262004 extends SimpleMigrationStep
{
/**

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace OCA\CloudFederationApi;
class OCMInvitation {
public bool $accepted;
public string $recipient_email;
public string $recipient_name;
public string $recipient_provider;
public string $recipient_user_id;
public string $token;
public string $user_id;
public \Datetime $acceptedAt;
public \Datetime $createdAt;
public \Datetime $expiresAt;
}

View file

@ -10,6 +10,5 @@ return array(
'OC\\' => array($baseDir . '/lib/private'),
'OCP\\' => array($baseDir . '/lib/public'),
'NCU\\' => array($baseDir . '/lib/unstable'),
'Bamarni\\Composer\\Bin\\' => array($vendorDir . '/bamarni/composer-bin-plugin/src'),
'' => array($baseDir . '/lib/private/legacy'),
);

View file

@ -21,10 +21,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
array (
'NCU\\' => 4,
),
'B' =>
array (
'Bamarni\\Composer\\Bin\\' => 21,
),
);
public static $prefixDirsPsr4 = array (
@ -44,10 +40,6 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
array (
0 => __DIR__ . '/../../..' . '/lib/unstable',
),
'Bamarni\\Composer\\Bin\\' =>
array (
0 => __DIR__ . '/..' . '/bamarni/composer-bin-plugin/src',
),
);
public static $fallbackDirsPsr4 = array (