Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(OCM-invites): Implementation of invitation flow for OCM 1.1.0 #51113

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
12 changes: 6 additions & 6 deletions apps/cloud_federation_api/appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@
'verb' => 'POST',
'root' => '/ocm',
],
// [
// 'name' => 'RequestHandler#inviteAccepted',
// 'url' => '/invite-accepted',
// 'verb' => 'POST',
// 'root' => '/ocm',
// ]
[
'name' => 'RequestHandler#inviteAccepted',
'url' => '/invite-accepted',
'verb' => 'POST',
'root' => '/ocm',
]
],
];
27 changes: 23 additions & 4 deletions apps/cloud_federation_api/composer/composer/InstalledVersions.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ class InstalledVersions
*/
private static $installed;

/**
* @var bool
*/
private static $installedIsLocalDir;

/**
* @var bool|null
*/
Expand Down Expand Up @@ -309,6 +314,12 @@ public static function reload($data)
{
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;
}

/**
Expand All @@ -322,19 +333,27 @@ private static function getInstalled()
}

$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';
$installed[] = self::$installedByVendor[$vendorDir] = $required;
if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
self::$installed = $installed[count($installed) - 1];
self::$installedByVendor[$vendorDir] = $required;
$installed[] = $required;
if (self::$installed === null && $vendorDir.'/composer' === $selfDir) {
self::$installed = $required;
self::$installedIsLocalDir = true;
}
}
if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) {
$copiedLocalDir = true;
}
}
}

Expand All @@ -350,7 +369,7 @@ private static function getInstalled()
}
}

if (self::$installed !== array()) {
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}

Expand Down
8 changes: 6 additions & 2 deletions apps/cloud_federation_api/lib/Capabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
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,
Expand All @@ -42,13 +42,16 @@ public function __construct(
* keyId: string,
* publicKeyPem: string,
* },
* provider: string,
* resourceTypes: list<array{
* name: string,
* shareTypes: list<string>,
* protocols: array<string, string>
* }>,
* version: string
* }
* capabilities: array{
* string,
* }
* }
* @throws OCMArgumentException
*/
Expand All @@ -57,6 +60,7 @@ public function getCapabilities() {

$this->provider->setEnabled(true);
$this->provider->setApiVersion(self::API_VERSION);
$this->provider->setCapabilities(['/invite-accepted', '/notifications', '/shares']);

$pos = strrpos($url, '/');
if ($pos === false) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/
namespace OCA\CloudFederationAPI\Controller;

use DateTime;
use NCU\Federation\ISignedCloudFederationProvider;
use NCU\Security\Signature\Exceptions\IdentityNotFoundException;
use NCU\Security\Signature\Exceptions\IncomingRequestException;
Expand All @@ -17,13 +18,15 @@
use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\ResponseDefinitions;
use OCA\FederatedFileSharing\AddressHandler;
use OCA\Federation\TrustedServers;
use OCP\AppFramework\Controller;
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\DB\QueryBuilder\IQueryBuilder;
use OCP\Federation\Exceptions\ActionNotSupportedException;
use OCP\Federation\Exceptions\AuthenticationFailedException;
use OCP\Federation\Exceptions\BadRequestException;
Expand All @@ -33,6 +36,7 @@
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Federation\ICloudIdManager;
use OCP\IAppConfig;
use OCP\IDBConnection;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IURLGenerator;
Expand Down Expand Up @@ -61,12 +65,14 @@ public function __construct(
private IURLGenerator $urlGenerator,
private ICloudFederationProviderManager $cloudFederationProviderManager,
private Config $config,
private IDBConnection $db,
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
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -213,6 +219,85 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $
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
* @param string $token
* @param string $userId
* @param string $email
* @param string $name
* @return JSONResponse
* 200: invitation accepted
* 400: Invalid token
* 403: Invitation token does not exist
* 409: User is allready 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);

/** @var IQueryBuilder $qb */
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('federated_invites')
->where($qb->expr()->eq('token', $qb->createNamedParameter($token)));
$result = $qb->executeQuery();
$data = $result->fetch();
$result->closeCursor();
$found_for_this_user = false;
if ($data) {
$found_for_this_user = $data['recipient_user_id'] === $userId && isset($data['user_id']);
}
if (!$found_for_this_user) {
$response = ['message' => 'Invalid or non existing token', 'error' => true];
$status = Http::STATUS_BAD_REQUEST;
$response = new JSONResponse($response, $status);
$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);
}
// Note: Not implementing 404 Invitation token does not exist, instead using 400

if ($data['accepted'] === true ) {
$response = ['message' => 'Invite already accepted', 'error' => true];
$status = Http::STATUS_CONFLICT;
return new JSONResponse($response,$status);
}

$localUser = $this->userManager->get($data['user_id']);
$sharedFromEmail = $localUser->getPrimaryEMailAddress();
$sharedFromDisplayName = $localUser->getDisplayName();

$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))
->set('recipient_email', $qb->createNamedParameter($email))
->set('recipient_name', $qb->createNamedParameter($name))
->set('recipient_user_id', $qb->createNamedParameter($userId))
->set('recipient_provider', $qb->createNamedParameter($recipientProvider))
->where($qb->expr()->eq('token', $qb->createNamedParameter($token)));
$qb->executeStatement();

return new JSONResponse($response,$status);
}

/**
* Send a notification about an existing share
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?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 Version1015Date202502262004 extends SimpleMigrationStep
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, missed it before. When adding a migration you have to bump the version in the apps appinfo/info.xml

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is correct now?

{

/**
* @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('createdAt', Types::DATETIME, [
'notnull' => true,
]);

$table->addColumn('expiredAt', Types::DATETIME, [
'notnull' => false,
]);

$table->addColumn('acceptedAt', Types::DATETIME, [
'notnull' => false,
]);


$table->setPrimaryKey(['id']);
}

return $schema;
}
}
Loading