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>
This commit is contained in:
Micke Nordin 2025-03-14 09:54:00 +01:00 committed by Micke Nordin
parent 9e09cddf9b
commit 668246d380
7 changed files with 210 additions and 28 deletions

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

@ -32,6 +32,11 @@ class InstalledVersions
*/
private static $installed;
/**
* @var bool
*/
private static $installedIsLocalDir;
/**
* @var bool|null
*/
@ -309,6 +314,12 @@ 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;
}
/**
@ -322,19 +333,27 @@ 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';
$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;
}
}
}
@ -350,7 +369,7 @@ class InstalledVersions
}
}
if (self::$installed !== array()) {
if (self::$installed !== array() && !$copiedLocalDir) {
$installed[] = self::$installed;
}

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,
@ -42,13 +42,16 @@ class Capabilities implements ICapability {
* 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
*/
@ -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

@ -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;
@ -17,6 +18,7 @@ use OC\OCM\OCMSignatoryManager;
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;
@ -24,6 +26,7 @@ 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;
@ -33,6 +36,7 @@ use OCP\Federation\ICloudFederationFactory;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Federation\ICloudIdManager;
use OCP\IAppConfig;
use OCP\IDBConnection;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IURLGenerator;
@ -61,12 +65,14 @@ class RequestHandlerController extends Controller {
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);
}
@ -213,6 +219,84 @@ 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
* @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;
return new JSONResponse($response,$status);
}
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 f')
->set('f.accepted', $qb->createNamedParameter(true))
->set('f.acceptedAt', $qb->createNamedParameter($updated))
->set('f.recipient_email', $qb->createNamedParameter($email))
->set('f.recipient_name', $qb->createNamedParameter($name))
->set('f.recipient_user_id', $qb->createNamedParameter($userId))
->set('f.recipient_provider', $qb->createNamedParameter($recipientProvider))
->where($qb->expr()->eq('token', $qb->createNamedParameter($token)));
$result = $qb->executeQuery();
$result->closeCursor();
return new JSONResponse($response,$status);
}
/**
* Send a notification about an existing share
*

View file

@ -11,10 +11,11 @@ namespace OCA\CloudFederationAPI\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version0001Date202502262004 extends SimpleMigrationStep
class Version1015Date202502262004 extends SimpleMigrationStep
{
/**
@ -34,42 +35,55 @@ class Version0001Date202502262004 extends SimpleMigrationStep
if (! $schema->hasTable($table_name)) {
$table = $schema->createTable($table_name);
$table->addColumn('id', 'bigint', [
$table->addColumn('id', Types::BIGINT, [
'autoincrement' => true,
'notnull' => true,
'length' => 20,
'length' => 11,
'unsigned' => true,
]);
$table->addColumn('user_id', 'bigint', [
$table->addColumn('user_id', Types::STRING, [
'notnull' => false,
'length' => 20,
'unsigned' => true,
'length' => 64,
]);
$table->addColumn('token', 'string', [
// 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' => true,
'length' => 2083,
]);
$table->addColumn('recipient_user_id', Types::STRING, [
'notnull' => true,
'length' => 1024,
]);
$table->addColumn('recipient_name', Types::STRING, [
'notnull' => true,
'length' => 1024,
]);
// https://www.directedignorance.com/blog/maximum-length-of-email-address
$table->addColumn('recipient_email', Types::STRING, [
'notnull' => true,
'length' => 320,
]);
$table->addColumn('token', Types::STRING, [
'notnull' => true,
'length' => 60,
]);
$table->addColumn('email', 'string', [
'notnull' => true,
'length' => 256,
]);
$table->addColumn('accepted', 'boolean', [
$table->addColumn('accepted', Types::BOOLEAN, [
'notnull' => false,
'default' => false
]);
$table->addColumn('createdAt', 'datetime', [
$table->addColumn('createdAt', Types::DATETIME, [
'notnull' => true,
]);
$table->addColumn('expiredAt', 'datetime', [
$table->addColumn('expiredAt', Types::DATETIME, [
'notnull' => false,
]);
$table->addColumn('acceptedAt', 'datetime', [
$table->addColumn('acceptedAt', Types::DATETIME, [
'notnull' => false,
]);

View file

@ -16,13 +16,17 @@ use OCP\OCM\Exceptions\OCMArgumentException;
use OCP\OCM\Exceptions\OCMProviderException;
use OCP\OCM\IOCMProvider;
use OCP\OCM\IOCMResource;
use OCP\IConfig;
/**
* @since 28.0.0
*/
class OCMProvider implements IOCMProvider {
private IConfig $config;
private string $provider;
private bool $enabled = false;
private string $apiVersion = '';
private array $capabilities = [];
private string $endPoint = '';
/** @var IOCMResource[] */
private array $resourceTypes = [];
@ -31,7 +35,10 @@ class OCMProvider implements IOCMProvider {
public function __construct(
protected IEventDispatcher $dispatcher,
IConfig $config,
) {
$this->config = $config;
$this->provider = 'Nextcloud ' . $config->getSystemValue('version');
}
/**
@ -88,6 +95,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 $key => $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 30.0.0
*/
public function getCapabilities(): array;
/**
* set the capabilities of the API
*
* @param array $capabilities
*
* @return $this
* @since 30.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 30.0.0
*/
public function getProvider(): string;
/**
* create a new resource to later add it with {@see addResourceType()}
* @return IOCMResource