From 668246d38017cc0dd04b9abecdb1ad99c61c8fdb Mon Sep 17 00:00:00 2001 From: Micke Nordin Date: Fri, 14 Mar 2025 09:54:00 +0100 Subject: [PATCH] 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 --- apps/cloud_federation_api/appinfo/routes.php | 12 +-- .../composer/composer/InstalledVersions.php | 27 +++++- .../cloud_federation_api/lib/Capabilities.php | 8 +- .../Controller/RequestHandlerController.php | 84 +++++++++++++++++++ ...04.php => Version1015Date202502262004.php} | 46 ++++++---- lib/private/OCM/Model/OCMProvider.php | 35 ++++++++ lib/public/OCM/IOCMProvider.php | 26 ++++++ 7 files changed, 210 insertions(+), 28 deletions(-) rename apps/cloud_federation_api/lib/Migration/{Version0001Date202502262004.php => Version1015Date202502262004.php} (50%) diff --git a/apps/cloud_federation_api/appinfo/routes.php b/apps/cloud_federation_api/appinfo/routes.php index 6b0774627a4..6467005e21b 100644 --- a/apps/cloud_federation_api/appinfo/routes.php +++ b/apps/cloud_federation_api/appinfo/routes.php @@ -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', + ] ], ]; diff --git a/apps/cloud_federation_api/composer/composer/InstalledVersions.php b/apps/cloud_federation_api/composer/composer/InstalledVersions.php index 51e734a774b..6d29bff66aa 100644 --- a/apps/cloud_federation_api/composer/composer/InstalledVersions.php +++ b/apps/cloud_federation_api/composer/composer/InstalledVersions.php @@ -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} $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; } diff --git a/apps/cloud_federation_api/lib/Capabilities.php b/apps/cloud_federation_api/lib/Capabilities.php index 8957fb8b9d8..e938d651d3c 100644 --- a/apps/cloud_federation_api/lib/Capabilities.php +++ b/apps/cloud_federation_api/lib/Capabilities.php @@ -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, * protocols: array * }>, * 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) { diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index 86af7924e6f..9f5f6df4cab 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -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 * diff --git a/apps/cloud_federation_api/lib/Migration/Version0001Date202502262004.php b/apps/cloud_federation_api/lib/Migration/Version1015Date202502262004.php similarity index 50% rename from apps/cloud_federation_api/lib/Migration/Version0001Date202502262004.php rename to apps/cloud_federation_api/lib/Migration/Version1015Date202502262004.php index c79f0ca75ea..047a8c426fd 100644 --- a/apps/cloud_federation_api/lib/Migration/Version0001Date202502262004.php +++ b/apps/cloud_federation_api/lib/Migration/Version1015Date202502262004.php @@ -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, ]); diff --git a/lib/private/OCM/Model/OCMProvider.php b/lib/private/OCM/Model/OCMProvider.php index f4b0ac584de..daa00499bae 100644 --- a/lib/private/OCM/Model/OCMProvider.php +++ b/lib/private/OCM/Model/OCMProvider.php @@ -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 diff --git a/lib/public/OCM/IOCMProvider.php b/lib/public/OCM/IOCMProvider.php index a267abc52d2..ea5bdcf3362 100644 --- a/lib/public/OCM/IOCMProvider.php +++ b/lib/public/OCM/IOCMProvider.php @@ -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