diff options
Diffstat (limited to 'apps/cloud_federation_api')
32 files changed, 983 insertions, 136 deletions
diff --git a/apps/cloud_federation_api/appinfo/info.xml b/apps/cloud_federation_api/appinfo/info.xml index 39cd2add0f9..81343cb49bf 100644 --- a/apps/cloud_federation_api/appinfo/info.xml +++ b/apps/cloud_federation_api/appinfo/info.xml @@ -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.13.0</version> + <version>1.16.0</version> <licence>agpl</licence> <author>Bjoern Schiessle</author> <namespace>CloudFederationAPI</namespace> @@ -19,6 +19,6 @@ <category>integration</category> <bugs>https://github.com/nextcloud/server/issues</bugs> <dependencies> - <nextcloud min-version="30" max-version="30"/> + <nextcloud min-version="32" max-version="32"/> </dependencies> </info> 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/autoload_classmap.php b/apps/cloud_federation_api/composer/composer/autoload_classmap.php index dd096ebf563..3cadc540c88 100644 --- a/apps/cloud_federation_api/composer/composer/autoload_classmap.php +++ b/apps/cloud_federation_api/composer/composer/autoload_classmap.php @@ -11,5 +11,9 @@ 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\\Db\\FederatedInvite' => $baseDir . '/../lib/Db/FederatedInvite.php', + 'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => $baseDir . '/../lib/Db/FederatedInviteMapper.php', + 'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => $baseDir . '/../lib/Events/FederatedInviteAcceptedEvent.php', + 'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => $baseDir . '/../lib/Migration/Version1016Date202502262004.php', 'OCA\\CloudFederationAPI\\ResponseDefinitions' => $baseDir . '/../lib/ResponseDefinitions.php', ); diff --git a/apps/cloud_federation_api/composer/composer/autoload_static.php b/apps/cloud_federation_api/composer/composer/autoload_static.php index 75557a20126..849b755cd2f 100644 --- a/apps/cloud_federation_api/composer/composer/autoload_static.php +++ b/apps/cloud_federation_api/composer/composer/autoload_static.php @@ -26,6 +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\\Db\\FederatedInvite' => __DIR__ . '/..' . '/../lib/Db/FederatedInvite.php', + 'OCA\\CloudFederationAPI\\Db\\FederatedInviteMapper' => __DIR__ . '/..' . '/../lib/Db/FederatedInviteMapper.php', + 'OCA\\CloudFederationAPI\\Events\\FederatedInviteAcceptedEvent' => __DIR__ . '/..' . '/../lib/Events/FederatedInviteAcceptedEvent.php', + 'OCA\\CloudFederationAPI\\Migration\\Version1016Date202502262004' => __DIR__ . '/..' . '/../lib/Migration/Version1016Date202502262004.php', 'OCA\\CloudFederationAPI\\ResponseDefinitions' => __DIR__ . '/..' . '/../lib/ResponseDefinitions.php', ); diff --git a/apps/cloud_federation_api/l10n/da.js b/apps/cloud_federation_api/l10n/da.js index 941a8a761e8..a3a8d6a2303 100644 --- a/apps/cloud_federation_api/l10n/da.js +++ b/apps/cloud_federation_api/l10n/da.js @@ -1,8 +1,8 @@ OC.L10N.register( "cloud_federation_api", { - "Cloud Federation API" : "Cloud Federation API", + "Cloud Federation API" : "Cloud sammenkoblings API", "Enable clouds to communicate with each other and exchange data" : "Gør det muligt for skyer at kommunikere med hinanden og udveksle data", - "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Cloud Federation API gør det muligt for forskellige Nextcloud-instanser at kommunikere med hinanden og udveksle data." + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Cloud sammenkoblings API gør det muligt for forskellige Nextcloud-instanser at kommunikere med hinanden og udveksle data." }, "nplurals=2; plural=(n != 1);"); diff --git a/apps/cloud_federation_api/l10n/da.json b/apps/cloud_federation_api/l10n/da.json index b3fee15a43a..fc26d6c54d6 100644 --- a/apps/cloud_federation_api/l10n/da.json +++ b/apps/cloud_federation_api/l10n/da.json @@ -1,6 +1,6 @@ { "translations": { - "Cloud Federation API" : "Cloud Federation API", + "Cloud Federation API" : "Cloud sammenkoblings API", "Enable clouds to communicate with each other and exchange data" : "Gør det muligt for skyer at kommunikere med hinanden og udveksle data", - "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Cloud Federation API gør det muligt for forskellige Nextcloud-instanser at kommunikere med hinanden og udveksle data." + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Cloud sammenkoblings API gør det muligt for forskellige Nextcloud-instanser at kommunikere med hinanden og udveksle data." },"pluralForm" :"nplurals=2; plural=(n != 1);" }
\ No newline at end of file diff --git a/apps/cloud_federation_api/l10n/ga.js b/apps/cloud_federation_api/l10n/ga.js index cbe95333836..a6e46e561c9 100644 --- a/apps/cloud_federation_api/l10n/ga.js +++ b/apps/cloud_federation_api/l10n/ga.js @@ -2,7 +2,7 @@ OC.L10N.register( "cloud_federation_api", { "Cloud Federation API" : "Ligeann API Comhdhéanta na Scamaill", - "Enable clouds to communicate with each other and exchange data" : "Lig dóimhneacht a chur ar chumas na scamaill cumarsáid a dhéanamh lena chéile agus sonraí a mhalartú.", - "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Ligeann API Comhdhéanta na Scamaill do réimse éagsúil de chásanna Nextcloud cumarsáid a dhéanamh lena chéile agus sonraí a mhalartú." + "Enable clouds to communicate with each other and exchange data" : "Cumasaigh scamaill cumarsáid a dhéanamh lena chéile agus sonraí a mhalartú", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Cuireann API Cloud Federation ar chumas cásanna éagsúla Nextcloud cumarsáid a dhéanamh lena chéile agus sonraí a mhalartú." }, "nplurals=5; plural=(n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4);"); diff --git a/apps/cloud_federation_api/l10n/ga.json b/apps/cloud_federation_api/l10n/ga.json index 2664e04ff90..5c3d361aef4 100644 --- a/apps/cloud_federation_api/l10n/ga.json +++ b/apps/cloud_federation_api/l10n/ga.json @@ -1,6 +1,6 @@ { "translations": { "Cloud Federation API" : "Ligeann API Comhdhéanta na Scamaill", - "Enable clouds to communicate with each other and exchange data" : "Lig dóimhneacht a chur ar chumas na scamaill cumarsáid a dhéanamh lena chéile agus sonraí a mhalartú.", - "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Ligeann API Comhdhéanta na Scamaill do réimse éagsúil de chásanna Nextcloud cumarsáid a dhéanamh lena chéile agus sonraí a mhalartú." + "Enable clouds to communicate with each other and exchange data" : "Cumasaigh scamaill cumarsáid a dhéanamh lena chéile agus sonraí a mhalartú", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Cuireann API Cloud Federation ar chumas cásanna éagsúla Nextcloud cumarsáid a dhéanamh lena chéile agus sonraí a mhalartú." },"pluralForm" :"nplurals=5; plural=(n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4);" }
\ No newline at end of file diff --git a/apps/cloud_federation_api/l10n/ko.js b/apps/cloud_federation_api/l10n/ko.js new file mode 100644 index 00000000000..09af58b662e --- /dev/null +++ b/apps/cloud_federation_api/l10n/ko.js @@ -0,0 +1,8 @@ +OC.L10N.register( + "cloud_federation_api", + { + "Cloud Federation API" : "Cloud Federation API", + "Enable clouds to communicate with each other and exchange data" : "클라우드 간 소통과 데이터 교환을 가능케 합니다.", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Cloud Federation API는 다양한 Nextcloud 인스턴스가 서로 소통하거나 데이터를 교환할 수 있도록 합니다." +}, +"nplurals=1; plural=0;"); diff --git a/apps/cloud_federation_api/l10n/ko.json b/apps/cloud_federation_api/l10n/ko.json new file mode 100644 index 00000000000..aa9e0642e29 --- /dev/null +++ b/apps/cloud_federation_api/l10n/ko.json @@ -0,0 +1,6 @@ +{ "translations": { + "Cloud Federation API" : "Cloud Federation API", + "Enable clouds to communicate with each other and exchange data" : "클라우드 간 소통과 데이터 교환을 가능케 합니다.", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Cloud Federation API는 다양한 Nextcloud 인스턴스가 서로 소통하거나 데이터를 교환할 수 있도록 합니다." +},"pluralForm" :"nplurals=1; plural=0;" +}
\ No newline at end of file diff --git a/apps/cloud_federation_api/l10n/pt_BR.js b/apps/cloud_federation_api/l10n/pt_BR.js index ca2e6ccfcf7..210c2d16cbd 100644 --- a/apps/cloud_federation_api/l10n/pt_BR.js +++ b/apps/cloud_federation_api/l10n/pt_BR.js @@ -2,7 +2,7 @@ OC.L10N.register( "cloud_federation_api", { "Cloud Federation API" : "API de Nuvem Federada", - "Enable clouds to communicate with each other and exchange data" : "Permite que diferentes nuvens se comuniquem entre si e troquem dados", + "Enable clouds to communicate with each other and exchange data" : "Permitir que as nuvens se comuniquem entre si e troquem dados", "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "A API de Nuvem Federada permite que várias instâncias do Nextcloud se comuniquem entre si e troquem dados." }, "nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;"); diff --git a/apps/cloud_federation_api/l10n/pt_BR.json b/apps/cloud_federation_api/l10n/pt_BR.json index 766cdaaf8cc..56091c2b9d6 100644 --- a/apps/cloud_federation_api/l10n/pt_BR.json +++ b/apps/cloud_federation_api/l10n/pt_BR.json @@ -1,6 +1,6 @@ { "translations": { "Cloud Federation API" : "API de Nuvem Federada", - "Enable clouds to communicate with each other and exchange data" : "Permite que diferentes nuvens se comuniquem entre si e troquem dados", + "Enable clouds to communicate with each other and exchange data" : "Permitir que as nuvens se comuniquem entre si e troquem dados", "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "A API de Nuvem Federada permite que várias instâncias do Nextcloud se comuniquem entre si e troquem dados." },"pluralForm" :"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" }
\ No newline at end of file diff --git a/apps/cloud_federation_api/l10n/pt_PT.js b/apps/cloud_federation_api/l10n/pt_PT.js new file mode 100644 index 00000000000..db0fadb64f8 --- /dev/null +++ b/apps/cloud_federation_api/l10n/pt_PT.js @@ -0,0 +1,8 @@ +OC.L10N.register( + "cloud_federation_api", + { + "Cloud Federation API" : "API de Federação Cloud", + "Enable clouds to communicate with each other and exchange data" : "Enable clouds to communicate with each other and exchange data", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." +}, +"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;"); diff --git a/apps/cloud_federation_api/l10n/pt_PT.json b/apps/cloud_federation_api/l10n/pt_PT.json new file mode 100644 index 00000000000..78fdf12910a --- /dev/null +++ b/apps/cloud_federation_api/l10n/pt_PT.json @@ -0,0 +1,6 @@ +{ "translations": { + "Cloud Federation API" : "API de Federação Cloud", + "Enable clouds to communicate with each other and exchange data" : "Enable clouds to communicate with each other and exchange data", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." +},"pluralForm" :"nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;" +}
\ No newline at end of file diff --git a/apps/cloud_federation_api/l10n/sw.js b/apps/cloud_federation_api/l10n/sw.js new file mode 100644 index 00000000000..c58c7d2b6f8 --- /dev/null +++ b/apps/cloud_federation_api/l10n/sw.js @@ -0,0 +1,8 @@ +OC.L10N.register( + "cloud_federation_api", + { + "Cloud Federation API" : "API ya Shirikisho la Cloud", + "Enable clouds to communicate with each other and exchange data" : "Washa clouds kuwasiliana na kubadilishana data", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "API ya Shirikisho la Cloud huwezesha matukio mbalimbali ya Nextcloud kuwasiliana na kubadilishana data." +}, +"nplurals=2; plural=(n != 1);"); diff --git a/apps/cloud_federation_api/l10n/sw.json b/apps/cloud_federation_api/l10n/sw.json new file mode 100644 index 00000000000..622453561cb --- /dev/null +++ b/apps/cloud_federation_api/l10n/sw.json @@ -0,0 +1,6 @@ +{ "translations": { + "Cloud Federation API" : "API ya Shirikisho la Cloud", + "Enable clouds to communicate with each other and exchange data" : "Washa clouds kuwasiliana na kubadilishana data", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "API ya Shirikisho la Cloud huwezesha matukio mbalimbali ya Nextcloud kuwasiliana na kubadilishana data." +},"pluralForm" :"nplurals=2; plural=(n != 1);" +}
\ No newline at end of file diff --git a/apps/cloud_federation_api/l10n/ug.js b/apps/cloud_federation_api/l10n/ug.js new file mode 100644 index 00000000000..7428c9eb203 --- /dev/null +++ b/apps/cloud_federation_api/l10n/ug.js @@ -0,0 +1,8 @@ +OC.L10N.register( + "cloud_federation_api", + { + "Cloud Federation API" : "بۇلۇت فېدېراتسىيەسى API", + "Enable clouds to communicate with each other and exchange data" : "بۇلۇتلارنىڭ ئۆز-ئارا ئالاقە قىلىشى ۋە سانلىق مەلۇمات ئالماشتۇرۇشىنى قوزغىتىڭ", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "بۇلۇت فېدېراتسىيەسى API ھەر خىل Nextcloud مىساللىرىنى ئۆز-ئارا ئالاقە قىلىش ۋە سانلىق مەلۇمات ئالماشتۇرۇش ئىمكانىيىتىگە ئىگە قىلىدۇ." +}, +"nplurals=2; plural=(n != 1);"); diff --git a/apps/cloud_federation_api/l10n/ug.json b/apps/cloud_federation_api/l10n/ug.json new file mode 100644 index 00000000000..d7c588468fa --- /dev/null +++ b/apps/cloud_federation_api/l10n/ug.json @@ -0,0 +1,6 @@ +{ "translations": { + "Cloud Federation API" : "بۇلۇت فېدېراتسىيەسى API", + "Enable clouds to communicate with each other and exchange data" : "بۇلۇتلارنىڭ ئۆز-ئارا ئالاقە قىلىشى ۋە سانلىق مەلۇمات ئالماشتۇرۇشىنى قوزغىتىڭ", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "بۇلۇت فېدېراتسىيەسى API ھەر خىل Nextcloud مىساللىرىنى ئۆز-ئارا ئالاقە قىلىش ۋە سانلىق مەلۇمات ئالماشتۇرۇش ئىمكانىيىتىگە ئىگە قىلىدۇ." +},"pluralForm" :"nplurals=2; plural=(n != 1);" +}
\ No newline at end of file diff --git a/apps/cloud_federation_api/l10n/uz.js b/apps/cloud_federation_api/l10n/uz.js new file mode 100644 index 00000000000..cec1c75f757 --- /dev/null +++ b/apps/cloud_federation_api/l10n/uz.js @@ -0,0 +1,8 @@ +OC.L10N.register( + "cloud_federation_api", + { + "Cloud Federation API" : "Asl faylni o'chirishda kutilmagan xatolik yuz berdi.", + "Enable clouds to communicate with each other and exchange data" : "Bulutlar bir-biri bilan aloqa qilish va ma'lumot almashish imkonini beradi", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Cloud Federation API turli xil Nextcloud misollariga bir-biri bilan muloqot qilish va ma'lumotlarni almashish imkonini beradi." +}, +"nplurals=1; plural=0;"); diff --git a/apps/cloud_federation_api/l10n/uz.json b/apps/cloud_federation_api/l10n/uz.json new file mode 100644 index 00000000000..fdab2842db9 --- /dev/null +++ b/apps/cloud_federation_api/l10n/uz.json @@ -0,0 +1,6 @@ +{ "translations": { + "Cloud Federation API" : "Asl faylni o'chirishda kutilmagan xatolik yuz berdi.", + "Enable clouds to communicate with each other and exchange data" : "Bulutlar bir-biri bilan aloqa qilish va ma'lumot almashish imkonini beradi", + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "Cloud Federation API turli xil Nextcloud misollariga bir-biri bilan muloqot qilish va ma'lumotlarni almashish imkonini beradi." +},"pluralForm" :"nplurals=1; plural=0;" +}
\ No newline at end of file diff --git a/apps/cloud_federation_api/l10n/zh_TW.js b/apps/cloud_federation_api/l10n/zh_TW.js index 99b56dc3587..8f997c9aff0 100644 --- a/apps/cloud_federation_api/l10n/zh_TW.js +++ b/apps/cloud_federation_api/l10n/zh_TW.js @@ -1,8 +1,8 @@ OC.L10N.register( "cloud_federation_api", { - "Cloud Federation API" : "雲端聯盟 API", + "Cloud Federation API" : "雲端聯邦 API", "Enable clouds to communicate with each other and exchange data" : "讓雲端可互相通訊並交換資料", - "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "雲端聯盟 API 讓多個 Nextcloud 站台可以互相通訊並交換資料。" + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "雲端聯邦 API 能讓多個 Nextcloud 實體之間,可以互相通訊並交換資料。" }, "nplurals=1; plural=0;"); diff --git a/apps/cloud_federation_api/l10n/zh_TW.json b/apps/cloud_federation_api/l10n/zh_TW.json index 5850a7f8f91..785e7515484 100644 --- a/apps/cloud_federation_api/l10n/zh_TW.json +++ b/apps/cloud_federation_api/l10n/zh_TW.json @@ -1,6 +1,6 @@ { "translations": { - "Cloud Federation API" : "雲端聯盟 API", + "Cloud Federation API" : "雲端聯邦 API", "Enable clouds to communicate with each other and exchange data" : "讓雲端可互相通訊並交換資料", - "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "雲端聯盟 API 讓多個 Nextcloud 站台可以互相通訊並交換資料。" + "The Cloud Federation API enables various Nextcloud instances to communicate with each other and to exchange data." : "雲端聯邦 API 能讓多個 Nextcloud 實體之間,可以互相通訊並交換資料。" },"pluralForm" :"nplurals=1; plural=0;" }
\ No newline at end of file diff --git a/apps/cloud_federation_api/lib/Capabilities.php b/apps/cloud_federation_api/lib/Capabilities.php index 61cc45a24e6..599733123b3 100644 --- a/apps/cloud_federation_api/lib/Capabilities.php +++ b/apps/cloud_federation_api/lib/Capabilities.php @@ -9,57 +9,70 @@ declare(strict_types=1); namespace OCA\CloudFederationAPI; +use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\SignatoryException; +use OC\OCM\OCMSignatoryManager; use OCP\Capabilities\ICapability; +use OCP\Capabilities\IInitialStateExcludedCapability; +use OCP\IAppConfig; use OCP\IURLGenerator; use OCP\OCM\Exceptions\OCMArgumentException; -use OCP\OCM\IOCMProvider; +use OCP\OCM\ICapabilityAwareOCMProvider; +use Psr\Log\LoggerInterface; -class Capabilities implements ICapability { - public const API_VERSION = '1.0-proposal1'; +class Capabilities implements ICapability, IInitialStateExcludedCapability { + public const API_VERSION = '1.1.0'; public function __construct( private IURLGenerator $urlGenerator, - private IOCMProvider $provider, + private IAppConfig $appConfig, + private ICapabilityAwareOCMProvider $provider, + private readonly OCMSignatoryManager $ocmSignatoryManager, + private readonly LoggerInterface $logger, ) { } /** * Function an app uses to return the capabilities * - * @return array{ - * ocm: array{ - * enabled: bool, - * apiVersion: string, - * endPoint: string, - * resourceTypes: array{ - * name: string, - * shareTypes: string[], - * protocols: array<string, string> - * }[], - * }, - * } + * @return array<string, array<string, mixed>> * @throws OCMArgumentException */ public function getCapabilities() { $url = $this->urlGenerator->linkToRouteAbsolute('cloud_federation_api.requesthandlercontroller.addShare'); - - $this->provider->setEnabled(true); - $this->provider->setApiVersion(self::API_VERSION); - $pos = strrpos($url, '/'); if ($pos === false) { - throw new OCMArgumentException('generated route should contains a slash character'); + throw new OCMArgumentException('generated route should contain a slash character'); } + $this->provider->setEnabled(true); + $this->provider->setApiVersion(self::API_VERSION); + $this->provider->setCapabilities(['/invite-accepted', '/notifications', '/shares']); + $this->provider->setEndPoint(substr($url, 0, $pos)); $resource = $this->provider->createNewResourceType(); $resource->setName('file') - ->setShareTypes(['user', 'group']) - ->setProtocols(['webdav' => '/public.php/webdav/']); + ->setShareTypes(['user', 'group']) + ->setProtocols(['webdav' => '/public.php/webdav/']); $this->provider->addResourceType($resource); + // Adding a public key to the ocm discovery + try { + if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) { + /** + * @experimental 31.0.0 + * @psalm-suppress UndefinedInterfaceMethod + */ + $this->provider->setSignatory($this->ocmSignatoryManager->getLocalSignatory()); + } else { + $this->logger->debug('ocm public key feature disabled'); + } + } catch (SignatoryException|IdentityNotFoundException $e) { + $this->logger->warning('cannot generate local signatory', ['exception' => $e]); + } + return ['ocm' => $this->provider->jsonSerialize()]; } } diff --git a/apps/cloud_federation_api/lib/Config.php b/apps/cloud_federation_api/lib/Config.php index 9aefca746a1..23788c26dc9 100644 --- a/apps/cloud_federation_api/lib/Config.php +++ b/apps/cloud_federation_api/lib/Config.php @@ -1,4 +1,5 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later @@ -6,6 +7,7 @@ namespace OCA\CloudFederationAPI; use OCP\Federation\ICloudFederationProviderManager; +use Psr\Log\LoggerInterface; /** * Class config @@ -16,11 +18,10 @@ use OCP\Federation\ICloudFederationProviderManager; */ class Config { - /** @var ICloudFederationProviderManager */ - private $cloudFederationProviderManager; - - public function __construct(ICloudFederationProviderManager $cloudFederationProviderManager) { - $this->cloudFederationProviderManager = $cloudFederationProviderManager; + public function __construct( + private ICloudFederationProviderManager $cloudFederationProviderManager, + private LoggerInterface $logger, + ) { } /** @@ -34,6 +35,7 @@ class Config { $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); return $provider->getSupportedShareTypes(); } catch (\Exception $e) { + $this->logger->error('Failed to create federation provider', ['exception' => $e]); return []; } } diff --git a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php index 5623ab93086..a76b1884a0b 100644 --- a/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php +++ b/apps/cloud_federation_api/lib/Controller/RequestHandlerController.php @@ -1,16 +1,36 @@ <?php + /** * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ + namespace OCA\CloudFederationAPI\Controller; +use NCU\Federation\ISignedCloudFederationProvider; +use NCU\Security\Signature\Exceptions\IdentityNotFoundException; +use NCU\Security\Signature\Exceptions\IncomingRequestException; +use NCU\Security\Signature\Exceptions\SignatoryNotFoundException; +use NCU\Security\Signature\Exceptions\SignatureException; +use NCU\Security\Signature\Exceptions\SignatureNotFoundException; +use NCU\Security\Signature\IIncomingSignedRequest; +use NCU\Security\Signature\ISignatureManager; +use OC\OCM\OCMSignatoryManager; use OCA\CloudFederationAPI\Config; +use OCA\CloudFederationAPI\Db\FederatedInviteMapper; +use OCA\CloudFederationAPI\Events\FederatedInviteAcceptedEvent; use OCA\CloudFederationAPI\ResponseDefinitions; +use OCA\FederatedFileSharing\AddressHandler; 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; use OCP\AppFramework\Http\Attribute\OpenAPI; +use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\JSONResponse; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\IEventDispatcher; use OCP\Federation\Exceptions\ActionNotSupportedException; use OCP\Federation\Exceptions\AuthenticationFailedException; use OCP\Federation\Exceptions\BadRequestException; @@ -19,11 +39,13 @@ use OCP\Federation\Exceptions\ProviderDoesNotExistsException; use OCP\Federation\ICloudFederationFactory; use OCP\Federation\ICloudFederationProviderManager; use OCP\Federation\ICloudIdManager; +use OCP\IAppConfig; use OCP\IGroupManager; use OCP\IRequest; use OCP\IURLGenerator; use OCP\IUserManager; use OCP\Share\Exceptions\ShareNotFound; +use OCP\Util; use Psr\Log\LoggerInterface; /** @@ -46,8 +68,15 @@ class RequestHandlerController extends Controller { private IURLGenerator $urlGenerator, private ICloudFederationProviderManager $cloudFederationProviderManager, private Config $config, + private IEventDispatcher $dispatcher, + private FederatedInviteMapper $federatedInviteMapper, + private readonly AddressHandler $addressHandler, + private readonly IAppConfig $appConfig, private ICloudFederationFactory $factory, - private ICloudIdManager $cloudIdManager + private ICloudIdManager $cloudIdManager, + private readonly ISignatureManager $signatureManager, + private readonly OCMSignatoryManager $signatoryManager, + private ITimeFactory $timeFactory, ) { parent::__construct($appName, $request); } @@ -55,10 +84,6 @@ class RequestHandlerController extends Controller { /** * Add share * - * @NoCSRFRequired - * @PublicPage - * @BruteForceProtection(action=receiveFederatedShare) - * * @param string $shareWith The user who the share will be shared with * @param string $name The resource name (e.g. document.odt) * @param string|null $description Share description @@ -67,28 +92,42 @@ class RequestHandlerController extends Controller { * @param string|null $ownerDisplayName Display name of the user who shared the item * @param string|null $sharedBy Provider specific UID of the user who shared the resource * @param string|null $sharedByDisplayName Display name of the user who shared the resource - * @param array{name: string[], options: array<string, mixed>} $protocol e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]] + * @param array{name: list<string>, options: array<string, mixed>} $protocol e,.g. ['name' => 'webdav', 'options' => ['username' => 'john', 'permissions' => 31]] * @param string $shareType 'group' or 'user' share * @param string $resourceType 'file', 'calendar',... * * @return JSONResponse<Http::STATUS_CREATED, CloudFederationAPIAddShare, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}> + * * 201: The notification was successfully received. The display name of the recipient might be returned in the body * 400: Bad request due to invalid parameters, e.g. when `shareWith` is not found or required properties are missing * 501: Share type or the resource type is not supported */ + #[PublicPage] + #[NoCSRFRequired] + #[BruteForceProtection(action: 'receiveFederatedShare')] 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 + $signedRequest = $this->getSignedRequest(); + $this->confirmSignedOrigin($signedRequest, 'owner', $owner); + } catch (IncomingRequestException $e) { + $this->logger->warning('incoming request exception', ['exception' => $e]); + return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST); + } + // check if all required parameters are set - if ($shareWith === null || - $name === null || - $providerId === null || - $owner === null || - $resourceType === null || - $shareType === null || - !is_array($protocol) || - !isset($protocol['name']) || - !isset($protocol['options']) || - !is_array($protocol['options']) || - !isset($protocol['options']['sharedSecret']) + if ( + $shareWith === null + || $name === null + || $providerId === null + || $resourceType === null + || $shareType === null + || !is_array($protocol) + || !isset($protocol['name']) + || !isset($protocol['options']) + || !is_array($protocol['options']) + || !isset($protocol['options']['sharedSecret']) ) { return new JSONResponse( [ @@ -171,23 +210,117 @@ class RequestHandlerController extends Controller { ); } - $user = $this->userManager->get($shareWith); - $recipientDisplayName = ''; - if ($user) { - $recipientDisplayName = $user->getDisplayName(); + $responseData = ['recipientDisplayName' => '']; + if ($shareType === 'user') { + $user = $this->userManager->get($shareWith); + if ($user) { + $responseData = [ + 'recipientDisplayName' => $user->getDisplayName(), + 'recipientUserId' => $user->getUID(), + ]; + } } - return new JSONResponse( - ['recipientDisplayName' => $recipientDisplayName], - Http::STATUS_CREATED); + return new JSONResponse($responseData, Http::STATUS_CREATED); } /** - * Send a notification about an existing share + * 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{}> * - * @NoCSRFRequired - * @PublicPage - * @BruteForceProtection(action=receiveFederatedShareNotification) + * 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 * * @param string $notificationType Notification type, e.g. SHARE_ACCEPTED * @param string $resourceType calendar, file, contact,... @@ -195,17 +328,22 @@ class RequestHandlerController extends Controller { * @param array<string, mixed>|null $notification The actual payload of the notification * * @return JSONResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}> + * * 201: The notification was successfully received * 400: Bad request due to invalid parameters, e.g. when `type` is invalid or missing * 403: Getting resource is not allowed * 501: The resource type is not supported */ + #[NoCSRFRequired] + #[PublicPage] + #[BruteForceProtection(action: 'receiveFederatedShareNotification')] public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) { // check if all required parameters are set - if ($notificationType === null || - $resourceType === null || - $providerId === null || - !is_array($notification) + if ( + $notificationType === null + || $resourceType === null + || $providerId === null + || !is_array($notification) ) { return new JSONResponse( [ @@ -217,6 +355,16 @@ class RequestHandlerController extends Controller { } 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 + $signedRequest = $this->getSignedRequest(); + $this->confirmNotificationIdentity($signedRequest, $resourceType, $notification); + } catch (IncomingRequestException $e) { + $this->logger->warning('incoming request exception', ['exception' => $e]); + return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST); + } + + try { $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); $result = $provider->notificationReceived($notificationType, $providerId, $notification); } catch (ProviderDoesNotExistsException $e) { @@ -249,6 +397,7 @@ class RequestHandlerController extends Controller { $response->throttle(); return $response; } catch (\Exception $e) { + $this->logger->warning('incoming notification exception', ['exception' => $e]); return new JSONResponse( [ 'message' => 'Internal error at ' . $this->urlGenerator->getBaseUrl(), @@ -270,7 +419,7 @@ class RequestHandlerController extends Controller { private function mapUid($uid) { // FIXME this should be a method in the user management instead $this->logger->debug('shareWith before, ' . $uid, ['app' => $this->appName]); - \OCP\Util::emitHook( + Util::emitHook( '\OCA\Files_Sharing\API\Server2Server', 'preLoginNameUsedAsUserName', ['uid' => &$uid] @@ -279,4 +428,147 @@ class RequestHandlerController extends Controller { return $uid; } + + + /** + * returns signed request if available. + * throw an exception: + * - if request is signed, but wrongly signed + * - if request is not signed but instance is configured to only accept signed ocm request + * + * @return IIncomingSignedRequest|null null if remote does not (and never did) support signed request + * @throws IncomingRequestException + */ + 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) { + $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 + // core.enforce_signed_ocm_request is set to true (default: false) + if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)) { + $this->logger->notice('ignored unsigned request', ['exception' => $e]); + throw new IncomingRequestException('Unsigned request'); + } + } catch (SignatureException $e) { + $this->logger->warning('wrongly signed request', ['exception' => $e]); + throw new IncomingRequestException('Invalid signature'); + } + return null; + } + + + /** + * confirm that the value related to $key entry from the payload is in format userid@hostname + * and compare hostname with the origin of the signed request. + * + * If request is not signed, we still verify that the hostname from the extracted value does, + * actually, not support signed request + * + * @param IIncomingSignedRequest|null $signedRequest + * @param string $key entry from data available in data + * @param string $value value itself used in case request is not signed + * + * @throws IncomingRequestException + */ + private function confirmSignedOrigin(?IIncomingSignedRequest $signedRequest, string $key, string $value): void { + if ($signedRequest === null) { + $instance = $this->getHostFromFederationId($value); + try { + $this->signatureManager->getSignatory($instance); + throw new IncomingRequestException('instance is supposed to sign its request'); + } catch (SignatoryNotFoundException) { + return; + } + } + + $body = json_decode($signedRequest->getBody(), true) ?? []; + $entry = trim($body[$key] ?? '', '@'); + if ($this->getHostFromFederationId($entry) !== $signedRequest->getOrigin()) { + throw new IncomingRequestException('share initiation (' . $signedRequest->getOrigin() . ') from different instance (' . $entry . ') [key=' . $key . ']'); + } + } + + /** + * confirm identity of the remote instance on notification, based on the share token. + * + * If request is not signed, we still verify that the hostname from the extracted value does, + * actually, not support signed request + * + * @param IIncomingSignedRequest|null $signedRequest + * @param string $resourceType + * @param string $sharedSecret + * + * @throws IncomingRequestException + * @throws BadRequestException + */ + private function confirmNotificationIdentity( + ?IIncomingSignedRequest $signedRequest, + string $resourceType, + array $notification, + ): void { + $sharedSecret = $notification['sharedSecret'] ?? ''; + if ($sharedSecret === '') { + throw new BadRequestException(['sharedSecret']); + } + + try { + $provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType); + if ($provider instanceof ISignedCloudFederationProvider) { + $identity = $provider->getFederationIdFromSharedSecret($sharedSecret, $notification); + } else { + $this->logger->debug('cloud federation provider {provider} does not implements ISignedCloudFederationProvider', ['provider' => $provider::class]); + return; + } + } catch (\Exception $e) { + throw new IncomingRequestException($e->getMessage()); + } + + $this->confirmNotificationEntry($signedRequest, $identity); + } + + + /** + * @param IIncomingSignedRequest|null $signedRequest + * @param string $entry + * + * @return void + * @throws IncomingRequestException + */ + private function confirmNotificationEntry(?IIncomingSignedRequest $signedRequest, string $entry): void { + $instance = $this->getHostFromFederationId($entry); + if ($signedRequest === null) { + try { + $this->signatureManager->getSignatory($instance); + throw new IncomingRequestException('instance is supposed to sign its request'); + } catch (SignatoryNotFoundException) { + return; + } + } elseif ($instance !== $signedRequest->getOrigin()) { + throw new IncomingRequestException('remote instance ' . $instance . ' not linked to origin ' . $signedRequest->getOrigin()); + } + } + + /** + * @param string $entry + * @return string + * @throws IncomingRequestException + */ + private function getHostFromFederationId(string $entry): string { + if (!str_contains($entry, '@')) { + throw new IncomingRequestException('entry ' . $entry . ' does not contain @'); + } + $rightPart = substr($entry, strrpos($entry, '@') + 1); + + // in case the full scheme is sent; getting rid of it + $rightPart = $this->addressHandler->removeProtocolFromUrl($rightPart); + try { + return $this->signatureManager->extractIdentityFromUri('https://' . $rightPart); + } catch (IdentityNotFoundException) { + throw new IncomingRequestException('invalid host within federation id: ' . $entry); + } + } } diff --git a/apps/cloud_federation_api/lib/Db/FederatedInvite.php b/apps/cloud_federation_api/lib/Db/FederatedInvite.php new file mode 100644 index 00000000000..b2447ff4e23 --- /dev/null +++ b/apps/cloud_federation_api/lib/Db/FederatedInvite.php @@ -0,0 +1,62 @@ +<?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); + } +} diff --git a/apps/cloud_federation_api/lib/Db/FederatedInviteMapper.php b/apps/cloud_federation_api/lib/Db/FederatedInviteMapper.php new file mode 100644 index 00000000000..5feb08b2c7f --- /dev/null +++ b/apps/cloud_federation_api/lib/Db/FederatedInviteMapper.php @@ -0,0 +1,33 @@ +<?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); + } + +} diff --git a/apps/cloud_federation_api/lib/Events/FederatedInviteAcceptedEvent.php b/apps/cloud_federation_api/lib/Events/FederatedInviteAcceptedEvent.php new file mode 100644 index 00000000000..c4d079d083e --- /dev/null +++ b/apps/cloud_federation_api/lib/Events/FederatedInviteAcceptedEvent.php @@ -0,0 +1,24 @@ +<?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; + } +} diff --git a/apps/cloud_federation_api/lib/Migration/Version1016Date202502262004.php b/apps/cloud_federation_api/lib/Migration/Version1016Date202502262004.php new file mode 100644 index 00000000000..a3523d45e38 --- /dev/null +++ b/apps/cloud_federation_api/lib/Migration/Version1016Date202502262004.php @@ -0,0 +1,89 @@ +<?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 Version1016Date202502262004 extends SimpleMigrationStep { + /** + * @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('created_at', Types::BIGINT, [ + 'notnull' => true, + ]); + + $table->addColumn('expired_at', Types::BIGINT, [ + 'notnull' => false, + ]); + + $table->addColumn('accepted_at', Types::BIGINT, [ + 'notnull' => false, + ]); + + $table->addUniqueConstraint(['token']); + $table->setPrimaryKey(['id']); + return $schema; + } + + return null; + } +} diff --git a/apps/cloud_federation_api/lib/ResponseDefinitions.php b/apps/cloud_federation_api/lib/ResponseDefinitions.php index d9a55e13e07..b17f5aadc1d 100644 --- a/apps/cloud_federation_api/lib/ResponseDefinitions.php +++ b/apps/cloud_federation_api/lib/ResponseDefinitions.php @@ -12,6 +12,7 @@ namespace OCA\CloudFederationAPI; /** * @psalm-type CloudFederationAPIAddShare = array{ * recipientDisplayName: string, + * recipientUserId?: string, * } * * @psalm-type CloudFederationAPIError = array{ @@ -19,10 +20,10 @@ namespace OCA\CloudFederationAPI; * } * * @psalm-type CloudFederationAPIValidationError = CloudFederationAPIError&array{ - * validationErrors: array{ + * validationErrors: list<array{ * name: string, * message: string|null, - * }[], + * }>, * } */ class ResponseDefinitions { diff --git a/apps/cloud_federation_api/openapi.json b/apps/cloud_federation_api/openapi.json index a6e529d4f89..9c92a152bf8 100644 --- a/apps/cloud_federation_api/openapi.json +++ b/apps/cloud_federation_api/openapi.json @@ -28,62 +28,18 @@ "properties": { "recipientDisplayName": { "type": "string" + }, + "recipientUserId": { + "type": "string" } } }, "Capabilities": { "type": "object", - "required": [ - "ocm" - ], - "properties": { - "ocm": { - "type": "object", - "required": [ - "enabled", - "apiVersion", - "endPoint", - "resourceTypes" - ], - "properties": { - "enabled": { - "type": "boolean" - }, - "apiVersion": { - "type": "string" - }, - "endPoint": { - "type": "string" - }, - "resourceTypes": { - "type": "array", - "items": { - "type": "object", - "required": [ - "name", - "shareTypes", - "protocols" - ], - "properties": { - "name": { - "type": "string" - }, - "shareTypes": { - "type": "array", - "items": { - "type": "string" - } - }, - "protocols": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - } - } - } + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "object" } } }, @@ -371,6 +327,167 @@ } } } + }, + "/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": [ diff --git a/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php b/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php new file mode 100644 index 00000000000..769e0a2dbff --- /dev/null +++ b/apps/cloud_federation_api/tests/RequestHandlerControllerTest.php @@ -0,0 +1,136 @@ +<?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 NCU\Security\Signature\ISignatureManager; +use OC\OCM\OCMSignatoryManager; +use OCA\CloudFederationAPI\Config; +use OCA\CloudFederationAPI\Controller\RequestHandlerController; +use OCA\CloudFederationAPI\Db\FederatedInvite; +use OCA\CloudFederationAPI\Db\FederatedInviteMapper; +use OCA\FederatedFileSharing\AddressHandler; +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\ICloudFederationProviderManager; +use OCP\Federation\ICloudIdManager; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IRequest; +use OCP\IURLGenerator; +use OCP\IUser; +use OCP\IUserManager; +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 AddressHandler&MockObject $addressHandler; + private IAppConfig&MockObject $appConfig; + private ICloudFederationFactory&MockObject $cloudFederationFactory; + private ICloudIdManager&MockObject $cloudIdManager; + private ISignatureManager&MockObject $signatureManager; + private OCMSignatoryManager&MockObject $signatoryManager; + 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->addressHandler = $this->createMock(AddressHandler::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class); + $this->cloudIdManager = $this->createMock(ICloudIdManager::class); + $this->signatureManager = $this->createMock(ISignatureManager::class); + $this->signatoryManager = $this->createMock(OCMSignatoryManager::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->addressHandler, + $this->appConfig, + $this->cloudFederationFactory, + $this->cloudIdManager, + $this->signatureManager, + $this->signatoryManager, + $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)); + } +} |