From b126192fe1d87b507bc00e9030f5df7d77fdf301 Mon Sep 17 00:00:00 2001 From: Cristian Scheid Date: Wed, 4 Nov 2026 17:45:19 -0300 Subject: [PATCH] feat: first prototype of circles federation over OCM Signed-off-by: Cristian Scheid --- lib/AppInfo/Application.php | 18 +++ lib/ConfigLexicon.php | 2 + lib/Listeners/OcmDiscoveryListener.php | 54 +++++++ .../OcmNotificationReceivedListener.php | 138 ++++++++++++++++++ lib/Service/InterfaceService.php | 23 ++- lib/Service/RemoteStreamService.php | 109 +++++++++++++- 6 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 lib/Listeners/OcmDiscoveryListener.php create mode 100644 lib/Listeners/OcmNotificationReceivedListener.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 40e034da3..30376b043 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -41,10 +41,13 @@ use OCA\Circles\Listeners\GroupMemberRemoved; use OCA\Circles\Listeners\NodeEventListener; use OCA\Circles\Listeners\Notifications\RequestingMember as ListenerNotificationsRequestingMember; +use OCA\Circles\Listeners\OcmDiscoveryListener; +use OCA\Circles\Listeners\OcmNotificationReceivedListener; use OCA\Circles\Listeners\TeamFolderLifecycleListener; use OCA\Circles\Listeners\UserCreated; use OCA\Circles\Listeners\UserDeleted; use OCA\Circles\Listeners\UserLoggedIn; +use OCA\Circles\Model\Federated\RemoteInstance; use OCA\Circles\MountManager\CircleMountProvider; use OCA\Circles\Notification\Notifier; use OCA\Circles\Search\UnifiedSearchProvider; @@ -68,6 +71,8 @@ use OCP\INavigationManager; use OCP\IURLGenerator; use OCP\L10N\IFactory; +use OCP\OCM\Events\LocalOCMDiscoveryEvent; +use OCP\OCM\Events\OCMNotificationReceivedEvent; use OCP\Share\IManager as IShareManager; use OCP\User\Events\UserChangedEvent; use OCP\User\Events\UserCreatedEvent; @@ -90,6 +95,15 @@ class Application extends App implements IBootstrap { public const APP_REL = 'https://apps.nextcloud.com/apps/circles'; public const APP_API = '1'; + public const OCM_RESOURCE_TYPE = 'circle'; + public const OCM_PROTOCOL = 'circles'; + public const OCM_NOTIFICATION_BROADCAST_EVENT = 'CIRCLES_BROADCAST_EVENT'; + public const OCM_NOTIFICATION_REQUEST_EVENT = 'CIRCLES_REQUEST_EVENT'; + public const OCM_NOTIFICATION_TYPES = [ + RemoteInstance::INCOMING => self::OCM_NOTIFICATION_BROADCAST_EVENT, + RemoteInstance::EVENT => self::OCM_NOTIFICATION_REQUEST_EVENT, + ]; + public const CLIENT_TIMEOUT = 3; public function __construct(array $params = []) { @@ -142,6 +156,10 @@ public function register(IRegistrationContext $context): void { $context->registerSearchProvider(UnifiedSearchProvider::class); $context->registerWellKnownHandler(WebfingerHandler::class); + // OCM + $context->registerEventListener(LocalOCMDiscoveryEvent::class, OcmDiscoveryListener::class); + $context->registerEventListener(OCMNotificationReceivedEvent::class, OcmNotificationReceivedListener::class); + $context->registerDashboardWidget(TeamDashboardWidget::class); $context->registerTeamResourceProvider(FileSharingTeamResourceProvider::class); diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 2dc7f8e72..9e07527df 100644 --- a/lib/ConfigLexicon.php +++ b/lib/ConfigLexicon.php @@ -23,6 +23,7 @@ class ConfigLexicon implements ILexicon { public const USER_SINGLE_ID = 'userSingleId'; public const FEDERATED_TEAMS_ENABLED = 'federated_teams_enabled'; public const FEDERATED_TEAMS_FRONTAL = 'federated_teams_frontal'; + public const FEDERATED_TEAMS_OCM = 'federated_teams_ocm'; public const TEAM_FOLDER_AUTO_CREATE = 'team_folder_auto_create'; public const TEAM_FOLDER_DEFAULT_QUOTA = 'team_folder_default_quota'; public const TEAM_FOLDER_GROUP_QUOTAS = 'team_folder_group_quotas'; @@ -57,6 +58,7 @@ public function getAppConfigs(): array { return [ new Entry(key: self::FEDERATED_TEAMS_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: 'disable/enable Federated Teams', lazy: true), new Entry(key: self::FEDERATED_TEAMS_FRONTAL, type: ValueType::STRING, defaultRaw: '', definition: 'domain name used to auth public request', lazy: true), + new Entry(key: self::FEDERATED_TEAMS_OCM, type: ValueType::BOOL, defaultRaw: false, definition: 'manage federated teams via OCM notifications when remote instances support it', lazy: true), new Entry(key: self::TEAM_FOLDER_AUTO_CREATE, type: ValueType::BOOL, defaultRaw: true, definition: 'whether Circles auto-creates team folders and allows UI/API upgrade (not exposed in admin UI)', lazy: true), new Entry(key: self::TEAM_FOLDER_DEFAULT_QUOTA, type: ValueType::INT, defaultRaw: self::DEFAULT_QUOTA, definition: 'default quota in bytes for auto-created team folders (0 means unlimited)', lazy: true), new Entry(key: self::TEAM_FOLDER_GROUP_QUOTAS, type: ValueType::ARRAY, defaultRaw: [], definition: 'quota overrides in bytes by Nextcloud group ID (0 means unlimited)', lazy: true), diff --git a/lib/Listeners/OcmDiscoveryListener.php b/lib/Listeners/OcmDiscoveryListener.php new file mode 100644 index 000000000..bd0bb6db1 --- /dev/null +++ b/lib/Listeners/OcmDiscoveryListener.php @@ -0,0 +1,54 @@ + */ +class OcmDiscoveryListener implements IEventListener { + public function __construct( + private readonly IRequest $request, + private readonly InterfaceService $interfaceService, + private readonly IAppConfig $appConfig, + private readonly IURLGenerator $urlGenerator, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof LocalOCMDiscoveryEvent) { + return; + } + + $interface = $this->interfaceService->getInterfaceFromRequest($this->request); + if ($interface === 0) { + return; + } + + if ($interface === InterfaceService::IFACE_FRONTAL + && !$this->appConfig->getAppValueBool(ConfigLexicon::FEDERATED_TEAMS_ENABLED)) { + return; + } + + $event->registerResourceType( + Application::OCM_RESOURCE_TYPE, + [], + [Application::OCM_PROTOCOL => $this->urlGenerator->linkToRoute('circles.Remote.appService')], + ); + } +} diff --git a/lib/Listeners/OcmNotificationReceivedListener.php b/lib/Listeners/OcmNotificationReceivedListener.php new file mode 100644 index 000000000..1c924f61c --- /dev/null +++ b/lib/Listeners/OcmNotificationReceivedListener.php @@ -0,0 +1,138 @@ + + */ +class OcmNotificationReceivedListener implements IEventListener { + use TDeserialize; + use TNCLogger; + + public function __construct( + private readonly IRequest $request, + private readonly IAppConfig $appConfig, + private readonly InterfaceService $interfaceService, + private readonly ConfigService $configService, + private readonly RemoteDownstreamService $remoteDownstreamService, + private readonly RemoteStreamService $remoteStreamService, + ) { + $this->setup('app', Application::APP_ID); + } + + /** + * @throws AuthenticationFailedException + */ + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof OCMNotificationReceivedEvent) { + return; + } + + $notification = $event->getNotification(); + if ( + !$notification instanceof ISenderDomainAwareCloudFederationNotification + || $notification->getSenderDomain() === null + || $notification->getResourceType() !== Application::OCM_RESOURCE_TYPE + || !in_array($notification->getNotificationType(), Application::OCM_NOTIFICATION_TYPES, true) + ) { + return; + } + + $senderDomain = $notification->getSenderDomain(); + try { + $this->confirmEventInterface(); + $this->confirmRemoteInstance($senderDomain); + $data = $notification->getNotification()['event'] ?? []; + $federatedEvent = new FederatedEvent(); + $federatedEvent->import(is_array($data) ? $data : []); + $federatedEvent->setSender($senderDomain); + } catch (Exception $e) { + $this->e($e); + throw new AuthenticationFailedException(); + } + + $notificationType = $notification->getNotificationType(); + try { + if ($notificationType === Application::OCM_NOTIFICATION_BROADCAST_EVENT) { + $this->remoteDownstreamService->incomingEvent($federatedEvent); + $event->setResult(['result' => $this->serialize($federatedEvent->getResult())]); + } elseif ($notificationType === Application::OCM_NOTIFICATION_REQUEST_EVENT) { + $this->remoteDownstreamService->requestedEvent($federatedEvent); + $event->setResult(['result' => $federatedEvent->getOutcome()]); + } + } catch (FederatedItemException $e) { + $event->setResult(['exception' => $e->jsonSerialize()]); + } catch (Exception $e) { + $this->e($e, ['event' => $federatedEvent->getWrapperToken()]); + + $event->setResult(['exception' => ['message' => $e->getMessage(), 'code' => $e->getCode()]]); + } + } + + /** + * @throws FederatedEventException + * @throws UnknownInterfaceException + */ + private function confirmEventInterface(): void { + $this->interfaceService->setCurrentInterfaceFromRequest($this->request); + $iface = $this->interfaceService->getCurrentInterface(); + if ($iface === InterfaceService::IFACE_FRONTAL + && !$this->appConfig->getAppValueBool(ConfigLexicon::FEDERATED_TEAMS_ENABLED)) { + throw new FederatedEventException('frontal interface is not enabled'); + } + } + + /** + * Confirms that the sender is a known and trusted remote instance, + * and sets the current interface to the one used to reach it. + * + * @throws RemoteNotFoundException + * @throws SignatoryException + */ + private function confirmRemoteInstance(string $senderDomain): void { + $remoteInstance = $this->remoteStreamService->getRemoteInstanceFromAddress($senderDomain); + if (!$this->configService->isLocalInstance($senderDomain) + && $remoteInstance->getType() === RemoteInstance::TYPE_UNKNOWN) { + throw new SignatoryException('Could not confirm identity'); + } + + $this->interfaceService->setCurrentInterface($remoteInstance->getInterface()); + } +} diff --git a/lib/Service/InterfaceService.php b/lib/Service/InterfaceService.php index af00237fe..2d524ffd3 100644 --- a/lib/Service/InterfaceService.php +++ b/lib/Service/InterfaceService.php @@ -127,6 +127,21 @@ public function isInterfaceInternal(int $interface): bool { * @param string $testToken */ public function setCurrentInterfaceFromRequest(IRequest $request, string $testToken = ''): void { + $interface = $this->getInterfaceFromRequest($request, $testToken); + if ($interface !== 0) { + $this->setCurrentInterface($interface); + } + } + + /** + * Returns the interface matching the host of the request, or 0 if none is configured for it. + * + * @param IRequest $request + * @param string $testToken + * + * @return int + */ + public function getInterfaceFromRequest(IRequest $request, string $testToken = ''): int { $testing = [ self::IFACE_INTERNAL => $this->configService->getInternalInstance(), self::IFACE_FRONTAL => $this->configService->getFrontalInstance(), @@ -144,16 +159,16 @@ public function setCurrentInterfaceFromRequest(IRequest $request, string $testTo $serverHost = strtolower($request->getServerHost()); if ($serverHost === '') { - return; + return 0; } foreach ($testing as $iface => $instance) { if ($serverHost === strtolower($instance)) { - $this->setCurrentInterface($iface); - - return; + return $iface; } } + + return 0; } /** diff --git a/lib/Service/RemoteStreamService.php b/lib/Service/RemoteStreamService.php index 9346d987a..fa227dc36 100644 --- a/lib/Service/RemoteStreamService.php +++ b/lib/Service/RemoteStreamService.php @@ -9,9 +9,11 @@ namespace OCA\Circles\Service; +use Exception; use JsonSerializable; use OCA\Circles\AppInfo\Application; use OCA\Circles\AuditLog\Operation; +use OCA\Circles\ConfigLexicon; use OCA\Circles\Db\RemoteRequest; use OCA\Circles\Exceptions\FederatedItemException; use OCA\Circles\Exceptions\RemoteAlreadyExistsException; @@ -28,7 +30,6 @@ use OCA\Circles\Tools\Exceptions\SignatureException; use OCA\Circles\Tools\Exceptions\WellKnownLinkNotFoundException; use OCA\Circles\Tools\Model\NCRequest; -use OCA\Circles\Tools\Model\NCRequestResult; use OCA\Circles\Tools\Model\NCSignatory; use OCA\Circles\Tools\Model\NCSignedRequest; use OCA\Circles\Tools\Model\Request; @@ -38,8 +39,12 @@ use OCA\Circles\Tools\Traits\TNCWellKnown; use OCA\Circles\Tools\Traits\TStringTools; use OCP\AppFramework\Http; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IURLGenerator; +use OCP\OCM\Exceptions\OCMArgumentException; +use OCP\OCM\Exceptions\OCMProviderException; +use OCP\OCM\IOCMDiscoveryService; use ReflectionClass; use ReflectionException; @@ -76,6 +81,8 @@ public function __construct( private InterfaceService $interfaceService, private ConfigService $configService, private AuditLogService $auditLogService, + private readonly IAppConfig $appConfig, + private readonly IOCMDiscoveryService $ocmDiscoveryService, ) { $this->setup('app', 'circles'); } @@ -189,6 +196,11 @@ public function resultRequestRemoteInstance( ): array { $this->interfaceService->setCurrentInterfaceFromInstance($instance); + // send events as OCM notifications when remote supports them, otherwise fall back to circles custom protocol + if (array_key_exists($item, Application::OCM_NOTIFICATION_TYPES) && $this->supportsOcmNotifications($instance)) { + return $this->requestRemoteInstanceOcm($instance, Application::OCM_NOTIFICATION_TYPES[$item], $object); + } + $signedRequest = $this->requestRemoteInstance($instance, $item, $type, $object, $params); if (!$signedRequest->getOutgoingRequest()->hasResult()) { throw new RemoteInstanceException(); @@ -200,7 +212,89 @@ public function resultRequestRemoteInstance( return $result->getAsArray(); } - throw $this->getFederatedItemExceptionFromResult($result); + throw $this->getFederatedItemException($result->getStatusCode(), $result->getAsArray()); + } + + private function supportsOcmNotifications(string $instance): bool { + if ($this->configService->isLocalInstance($instance) + || !$this->appConfig->getValueBool(Application::APP_ID, ConfigLexicon::FEDERATED_TEAMS_OCM) + || $this->appConfig->getValueBool('core', 'ocm_signed_request_disabled', lazy: true) + ) { + return false; + } + + try { + $provider = $this->ocmDiscoveryService->discover($this->getCachedRemoteInstance($instance)->getRoot()); + } catch (RemoteNotFoundException|UnknownRemoteException|OCMProviderException) { + return false; + } + + if (!$provider->isEnabled() || !$provider->getCapabilities()->hasNotificationSenderDomain()) { + return false; + } + + try { + $provider->extractProtocolEntry(Application::OCM_RESOURCE_TYPE, Application::OCM_PROTOCOL); + } catch (OCMArgumentException) { + return false; + } + + return true; + } + + /** + * Send event to remote instance OCM notifications endpoint. + * + * @throws FederatedItemException + * @throws RemoteInstanceException + * @throws RemoteNotFoundException + * @throws UnknownRemoteException + */ + private function requestRemoteInstanceOcm(string $instance, string $notificationType, ?JsonSerializable $object): array { + $remote = $this->getCachedRemoteInstance($instance); + $payload = [ + 'notificationType' => $notificationType, + 'senderDomain' => $this->interfaceService->getLocalInstance(), + 'resourceType' => Application::OCM_RESOURCE_TYPE, + 'notification' => [ + 'event' => is_null($object) ? [] : $this->serialize($object), + ], + ]; + $options = [ + 'http_errors' => false, + 'verify' => $this->configService->getAppValue(ConfigService::SELF_SIGNED_CERT) !== '1', + 'nextcloud' => ['allow_local_address' => true], + ]; + + try { + $response = $this->ocmDiscoveryService->requestRemoteOcmEndpoint( + null, + $remote->getRoot(), + '/notifications', + $payload, + 'post', + null, + $options, + ); + } catch (Exception $e) { + throw new RemoteInstanceException($e->getMessage()); + } + + $data = json_decode((string)$response->getBody(), true); + if (!is_array($data)) { + $data = []; + } + + if ($response->getStatusCode() !== Http::STATUS_CREATED) { + throw $this->getFederatedItemException($response->getStatusCode(), $data); + } + + if (array_key_exists('exception', $data)) { + $exception = is_array($data['exception']) ? $data['exception'] : []; + throw $this->getFederatedItemException((int)($exception['status'] ?? Http::STATUS_BAD_REQUEST), $exception); + } + + return is_array($data['result'] ?? null) ? $data['result'] : []; } /** @@ -484,13 +578,14 @@ private function confirmAuth(RemoteInstance $remote, string $auth): void { } /** - * @param NCRequestResult $result + * Rebuilds the exception returned by a remote instance from the status and body of its response. + * + * @param int $statusCode + * @param array $data * * @return FederatedItemException */ - private function getFederatedItemExceptionFromResult(NCRequestResult $result): FederatedItemException { - $data = $result->getAsArray(); - + private function getFederatedItemException(int $statusCode, array $data): FederatedItemException { $message = $this->get('message', $data); $code = $this->getInt('code', $data); $class = $this->get('class', $data); @@ -500,7 +595,7 @@ private function getFederatedItemExceptionFromResult(NCRequestResult $result): F $this->confirmFederatedItemExceptionFromClass($test); $e = $class; } catch (ReflectionException|FederatedItemException) { - $e = $this->getFederatedItemExceptionFromStatus($result->getStatusCode()); + $e = $this->getFederatedItemExceptionFromStatus($statusCode); } return new $e($message, $code);