diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 40e034da3..a93a54866 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -41,6 +41,8 @@ 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\OcmEndpointRequestListener; use OCA\Circles\Listeners\TeamFolderLifecycleListener; use OCA\Circles\Listeners\UserCreated; use OCA\Circles\Listeners\UserDeleted; @@ -68,6 +70,8 @@ use OCP\INavigationManager; use OCP\IURLGenerator; use OCP\L10N\IFactory; +use OCP\OCM\Events\LocalOCMDiscoveryEvent; +use OCP\OCM\Events\OCMEndpointRequestEvent; use OCP\Share\IManager as IShareManager; use OCP\User\Events\UserChangedEvent; use OCP\User\Events\UserCreatedEvent; @@ -89,6 +93,7 @@ class Application extends App implements IBootstrap { public const APP_SUBJECT = 'http://nextcloud.com/'; public const APP_REL = 'https://apps.nextcloud.com/apps/circles'; public const APP_API = '1'; + public const OCM_CAPABILITY = 'circles-v1'; public const CLIENT_TIMEOUT = 3; @@ -141,6 +146,8 @@ public function register(IRegistrationContext $context): void { $context->registerSearchProvider(UnifiedSearchProvider::class); $context->registerWellKnownHandler(WebfingerHandler::class); + $context->registerEventListener(LocalOCMDiscoveryEvent::class, OcmDiscoveryListener::class); + $context->registerEventListener(OCMEndpointRequestEvent::class, OcmEndpointRequestListener::class); $context->registerDashboardWidget(TeamDashboardWidget::class); $context->registerTeamResourceProvider(FileSharingTeamResourceProvider::class); diff --git a/lib/ConfigLexicon.php b/lib/ConfigLexicon.php index 2dc7f8e72..5d0a50bff 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: 'send requests to remote instances through their OCM endpoint when they 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/Controller/RemoteController.php b/lib/Controller/RemoteController.php index c750fccec..e92d5e190 100644 --- a/lib/Controller/RemoteController.php +++ b/lib/Controller/RemoteController.php @@ -11,33 +11,20 @@ use Exception; use OC\AppFramework\Middleware\Security\Exceptions\NotLoggedInException; -use OCA\Circles\ConfigLexicon; use OCA\Circles\Db\CircleRequest; use OCA\Circles\Exceptions\FederatedEventException; -use OCA\Circles\Exceptions\FederatedItemException; use OCA\Circles\Exceptions\FederatedUserException; -use OCA\Circles\Exceptions\FederatedUserNotFoundException; use OCA\Circles\Exceptions\JsonNotRequestedException; use OCA\Circles\Exceptions\UnknownInterfaceException; -use OCA\Circles\Model\Circle; use OCA\Circles\Model\Federated\FederatedEvent; use OCA\Circles\Model\Federated\RemoteInstance; -use OCA\Circles\Model\FederatedUser; -use OCA\Circles\Model\Member; -use OCA\Circles\Model\Probes\BasicProbe; -use OCA\Circles\Model\Probes\CircleProbe; -use OCA\Circles\Service\CircleService; -use OCA\Circles\Service\ConfigService; -use OCA\Circles\Service\FederatedUserService; use OCA\Circles\Service\InterfaceService; -use OCA\Circles\Service\MemberService; use OCA\Circles\Service\MembershipService; -use OCA\Circles\Service\RemoteDownstreamService; +use OCA\Circles\Service\RemoteEndpointService; use OCA\Circles\Service\RemoteStreamService; use OCA\Circles\Tools\Exceptions\InvalidItemException; use OCA\Circles\Tools\Exceptions\InvalidOriginException; use OCA\Circles\Tools\Exceptions\InvalidRemoteInstanceException; -use OCA\Circles\Tools\Exceptions\ItemNotFoundException; use OCA\Circles\Tools\Exceptions\MalformedArrayException; use OCA\Circles\Tools\Exceptions\SignatoryException; use OCA\Circles\Tools\Exceptions\SignatureException; @@ -51,7 +38,6 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\PublicPage; use OCP\AppFramework\Http\DataResponse; -use OCP\AppFramework\Services\IAppConfig; use OCP\IRequest; use OCP\IUserSession; @@ -68,15 +54,10 @@ public function __construct( string $appName, IRequest $request, private readonly CircleRequest $circleRequest, - private readonly IAppConfig $appConfig, private readonly RemoteStreamService $remoteStreamService, - private readonly RemoteDownstreamService $remoteDownstreamService, - private readonly FederatedUserService $federatedUserService, - private readonly CircleService $circleService, - private readonly MemberService $memberService, + private readonly RemoteEndpointService $remoteEndpointService, private readonly MembershipService $membershipService, private readonly InterfaceService $interfaceService, - private readonly ConfigService $configService, private readonly IUserSession $userSession, ) { parent::__construct($appName, $request); @@ -119,17 +100,15 @@ public function event(): DataResponse { try { $event = $this->extractEventFromRequest(); } catch (Exception $e) { - return $this->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + return $this->remoteEndpointService->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); } try { - $this->remoteDownstreamService->requestedEvent($event); - - return new DataResponse($event->getOutcome()); + return new DataResponse($this->remoteEndpointService->requestedEvent($event)); } catch (Exception $e) { $this->e($e, ['event' => $event->getWrapperToken()]); - return $this->exceptionResponse($e); + return $this->remoteEndpointService->exceptionResponse($e); } } @@ -145,15 +124,13 @@ public function incoming(): DataResponse { } catch (Exception $e) { $this->e($e); - return $this->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + return $this->remoteEndpointService->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); } try { - $this->remoteDownstreamService->incomingEvent($event); - - return new DataResponse($this->serialize($event->getResult())); + return new DataResponse($this->remoteEndpointService->incomingEvent($event)); } catch (Exception $e) { - return $this->exceptionResponse($e); + return $this->remoteEndpointService->exceptionResponse($e); } } @@ -172,7 +149,7 @@ public function test(): DataResponse { } catch (Exception $e) { $this->e($e); - return $this->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + return $this->remoteEndpointService->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); } } @@ -186,25 +163,13 @@ public function circles(): DataResponse { try { $data = $this->extractDataFromFromRequest(); } catch (Exception $e) { - return $this->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + return $this->remoteEndpointService->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); } try { - /** @var Circle $filterCircle */ - $filterCircle = $data->gObj('filterCircle'); - /** @var Member $filterMember */ - $filterMember = $data->gObj('filterMember'); - - $probe = new CircleProbe(); - $probe->setFilterCircle($filterCircle) - ->setFilterMember($filterMember) - ->addDetail(BasicProbe::DETAILS_POPULATION); - - $circles = $this->circleService->getCircles($probe); - - return new DataResponse($circles); + return new DataResponse($this->remoteEndpointService->getCircles($data)); } catch (Exception $e) { - return $this->exceptionResponse($e); + return $this->remoteEndpointService->exceptionResponse($e); } } @@ -220,15 +185,13 @@ public function circle(string $circleId): DataResponse { try { $this->extractDataFromFromRequest(); } catch (Exception $e) { - return $this->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + return $this->remoteEndpointService->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); } try { - $circle = $this->circleService->getCircle($circleId); - - return new DataResponse($this->serialize($circle)); + return new DataResponse($this->remoteEndpointService->getCircle($circleId)); } catch (Exception $e) { - return $this->exceptionResponse($e); + return $this->remoteEndpointService->exceptionResponse($e); } } @@ -244,15 +207,13 @@ public function members(string $circleId): DataResponse { try { $this->extractDataFromFromRequest(); } catch (Exception $e) { - return $this->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + return $this->remoteEndpointService->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); } try { - $members = $this->memberService->getMembers($circleId, true); - - return new DataResponse($members); + return new DataResponse($this->remoteEndpointService->getMembers($circleId)); } catch (Exception $e) { - return $this->exceptionResponse($e); + return $this->remoteEndpointService->exceptionResponse($e); } } @@ -273,24 +234,13 @@ public function member(string $type, string $userId): DataResponse { } catch (Exception $e) { $this->e($e); - return $this->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + return $this->remoteEndpointService->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); } try { - // FILTER CIRCLE BASED ON THE CONFIG/FEDERATED_8192 !! - if ($type === Member::$TYPE[Member::TYPE_SINGLE]) { - $federatedUser = $this->federatedUserService->getFederatedUser($userId, Member::TYPE_SINGLE); - } elseif ($type === Member::$TYPE[Member::TYPE_CIRCLE]) { - $federatedUser = $this->federatedUserService->getFederatedUser($userId, Member::TYPE_CIRCLE); - } elseif ($type === Member::$TYPE[Member::TYPE_USER]) { - $federatedUser = $this->federatedUserService->getLocalFederatedUser($userId); - } else { - throw new FederatedUserNotFoundException('Entity not found'); - } - - return new DataResponse($this->serialize($federatedUser)); + return new DataResponse($this->remoteEndpointService->getFederatedUser($type, $userId)); } catch (Exception $e) { - return $this->exceptionResponse($e); + return $this->remoteEndpointService->exceptionResponse($e); } } @@ -306,15 +256,13 @@ public function inherited(string $circleId): DataResponse { try { $this->extractDataFromFromRequest(); } catch (Exception $e) { - return $this->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + return $this->remoteEndpointService->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); } try { - $circle = $this->circleService->getCircle($circleId); - - return new DataResponse($circle->getInheritedMembers()); + return new DataResponse($this->remoteEndpointService->getInheritedMembers($circleId)); } catch (Exception $e) { - return $this->exceptionResponse($e); + return $this->remoteEndpointService->exceptionResponse($e); } } @@ -330,15 +278,13 @@ public function memberships(string $circleId): DataResponse { try { $this->extractDataFromFromRequest(); } catch (Exception $e) { - return $this->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + return $this->remoteEndpointService->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); } try { - $circle = $this->circleService->getCircle($circleId); - - return new DataResponse($circle->getMemberships()); + return new DataResponse($this->remoteEndpointService->getMemberships($circleId)); } catch (Exception $e) { - return $this->exceptionResponse($e); + return $this->remoteEndpointService->exceptionResponse($e); } } @@ -354,21 +300,12 @@ public function memberships(string $circleId): DataResponse { */ private function extractEventFromRequest(): FederatedEvent { // will throw exception if instance is not configured for this event. - $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'); - } + $this->remoteEndpointService->confirmEventInterface($this->request); $signed = $this->remoteStreamService->incomingSignedRequest(); $this->confirmRemoteInstance($signed); - $event = new FederatedEvent(); - $event->import(json_decode($signed->getBody(), true)); - $event->setSender($signed->getOrigin()); - - return $event; + return $this->remoteEndpointService->importEvent(json_decode($signed->getBody(), true), $signed->getOrigin()); } /** @@ -385,39 +322,7 @@ private function extractDataFromFromRequest(): SimpleDataStore { $signed = $this->remoteStreamService->incomingSignedRequest(); $remoteInstance = $this->confirmRemoteInstance($signed); - // There should be no need to confirm the need or the origin of the initiator as $remoteInstance - // already helps filtering request to the database. - // initiator here is only used to play with the visibility, on top of the visibility provided to - // the remote instance based on its type. - $this->federatedUserService->setRemoteInstance($remoteInstance); - - $data = new SimpleDataStore(); - $store = new SimpleDataStore(json_decode($signed->getBody(), true)); - try { - /** @var FederatedUser $initiator */ - $initiator = $store->gObj('initiator', FederatedUser::class); - if ($initiator->getInstance() !== $remoteInstance->getInstance() && !in_array($initiator->getInstance(), $remoteInstance->getAliases(), true)) { - throw new InvalidRemoteInstanceException('Initiator instance does not match remote instance'); - } - $this->federatedUserService->setCurrentUser($initiator); - } catch (InvalidItemException|ItemNotFoundException) { - } - - try { - /** @var FederatedUser $initiator */ - $filterMember = $store->gObj('filterMember', Member::class); - $data->aObj('filterMember', $filterMember); - } catch (InvalidItemException|ItemNotFoundException) { - } - - try { - /** @var FederatedUser $initiator */ - $filterCircle = $store->gObj('filterCircle', Circle::class); - $data->aObj('filterCircle', $filterCircle); - } catch (InvalidItemException|ItemNotFoundException) { - } - - return $data; + return $this->remoteEndpointService->prepareDataRequest($remoteInstance, json_decode($signed->getBody(), true)); } /** @@ -427,46 +332,11 @@ private function extractDataFromFromRequest(): SimpleDataStore { * @throws SignatoryException */ private function confirmRemoteInstance(NCSignedRequest $signedRequest): RemoteInstance { - /** @var RemoteInstance $signatory */ $signatory = $signedRequest->getSignatory(); - if (!$signatory instanceof RemoteInstance) { - $this->debug('Signatory is not a known RemoteInstance', ['signedRequest' => $signedRequest]); - throw new SignatoryException('Could not confirm identity'); - } - - if (!$this->configService->isLocalInstance($signedRequest->getOrigin()) - && $signatory->getType() === RemoteInstance::TYPE_UNKNOWN) { - $this->debug('Could not confirm identity', ['signedRequest' => $signedRequest]); - throw new SignatoryException('Could not confirm identity'); - } - - $this->interfaceService->setCurrentInterface($signatory->getInterface()); - - return $signatory; - } - - /** - * @param Exception $e - * @param int $httpErrorCode - * - * @return DataResponse - */ - public function exceptionResponse( - Exception $e, - int $httpErrorCode = Http::STATUS_BAD_REQUEST, - ): DataResponse { - if ($e instanceof FederatedItemException) { - return new DataResponse($this->serialize($e), $e->getStatus()); - } - - $errorCode = (int)$e->getCode(); - return new DataResponse( - [ - 'message' => $e->getMessage(), - 'code' => $e->getCode() - ], - ($errorCode > 0) ? $errorCode : $httpErrorCode + return $this->remoteEndpointService->confirmRemoteInstance( + ($signatory instanceof RemoteInstance) ? $signatory : null, + $signedRequest->getOrigin() ); } diff --git a/lib/Listeners/OcmDiscoveryListener.php b/lib/Listeners/OcmDiscoveryListener.php new file mode 100644 index 000000000..3dc0ee0cf --- /dev/null +++ b/lib/Listeners/OcmDiscoveryListener.php @@ -0,0 +1,48 @@ + */ +class OcmDiscoveryListener implements IEventListener { + public function __construct( + private readonly IRequest $request, + private readonly InterfaceService $interfaceService, + private readonly IAppConfig $appConfig, + ) { + } + + #[\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->addCapability(Application::OCM_CAPABILITY); + } +} diff --git a/lib/Listeners/OcmEndpointRequestListener.php b/lib/Listeners/OcmEndpointRequestListener.php new file mode 100644 index 000000000..991fc5177 --- /dev/null +++ b/lib/Listeners/OcmEndpointRequestListener.php @@ -0,0 +1,134 @@ + */ +class OcmEndpointRequestListener implements IEventListener { + use TNCLogger; + + public function __construct( + private readonly IRequest $request, + private readonly RemoteEndpointService $remoteEndpointService, + private readonly RemoteStreamService $remoteStreamService, + ) { + $this->setup('app', Application::APP_ID); + } + + #[\Override] + public function handle(Event $event): void { + if (!$event instanceof OCMEndpointRequestEvent + || $event->getRequestedCapability() !== Application::OCM_CAPABILITY) { + return; + } + + if ($event->getUsedMethod() !== 'POST') { + $event->setResponse(new DataResponse([], Http::STATUS_METHOD_NOT_ALLOWED)); + return; + } + + $args = $event->getArgs(); + $item = array_shift($args); + $response = match ([$item, count($args)]) { + [RemoteInstance::EVENT, 0] => $this->event($event, false), + [RemoteInstance::INCOMING, 0] => $this->event($event, true), + [RemoteInstance::CIRCLES, 0] => $this->dataRequest($event, fn (SimpleDataStore $data): array => $this->remoteEndpointService->getCircles($data)), + [RemoteInstance::CIRCLE, 1] => $this->dataRequest($event, fn (): array => $this->remoteEndpointService->getCircle($args[0])), + [RemoteInstance::MEMBERS, 1] => $this->dataRequest($event, fn (): array => $this->remoteEndpointService->getMembers($args[0])), + [RemoteInstance::MEMBER, 2] => $this->dataRequest($event, fn (): array => $this->remoteEndpointService->getFederatedUser($args[0], $args[1])), + [RemoteInstance::INHERITED, 1] => $this->dataRequest($event, fn (): array => $this->remoteEndpointService->getInheritedMembers($args[0])), + [RemoteInstance::MEMBERSHIPS, 1] => $this->dataRequest($event, fn (): array => $this->remoteEndpointService->getMemberships($args[0])), + default => new DataResponse([], Http::STATUS_NOT_FOUND), + }; + + $event->setResponse($response); + } + + /** + * @param bool $incoming: true for an event broadcast by the instance owning the circle, + * false for an event requested by another instance + */ + private function event(OCMEndpointRequestEvent $event, bool $incoming): DataResponse { + try { + $this->remoteEndpointService->confirmEventInterface($this->request); + $this->confirmRemoteInstance($event); + $federatedEvent = $this->remoteEndpointService->importEvent($event->getPayload(), (string)$event->getRemote()); + } catch (Exception $e) { + $this->e($e); + + return $this->remoteEndpointService->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + } + + try { + if ($incoming) { + return new DataResponse($this->remoteEndpointService->incomingEvent($federatedEvent)); + } + + return new DataResponse($this->remoteEndpointService->requestedEvent($federatedEvent)); + } catch (Exception $e) { + $this->e($e, ['event' => $federatedEvent->getWrapperToken()]); + + return $this->remoteEndpointService->exceptionResponse($e); + } + } + + /** + * @param callable(SimpleDataStore): array $handler + */ + private function dataRequest(OCMEndpointRequestEvent $event, callable $handler): DataResponse { + try { + $remoteInstance = $this->confirmRemoteInstance($event); + $data = $this->remoteEndpointService->prepareDataRequest($remoteInstance, $event->getPayload()); + } catch (Exception $e) { + $this->e($e); + + return $this->remoteEndpointService->exceptionResponse($e, Http::STATUS_UNAUTHORIZED); + } + + try { + return new DataResponse($handler($data)); + } catch (Exception $e) { + return $this->remoteEndpointService->exceptionResponse($e); + } + } + + /** + * @throws SignatoryException + */ + private function confirmRemoteInstance(OCMEndpointRequestEvent $event): RemoteInstance { + $origin = $event->getRemote(); + if ($origin === null) { + throw new SignatoryException('Request is not signed'); + } + + try { + $remoteInstance = $this->remoteStreamService->getRemoteInstanceFromAddress($origin); + } catch (RemoteNotFoundException) { + $remoteInstance = null; + } + + return $this->remoteEndpointService->confirmRemoteInstance($remoteInstance, $origin); + } +} 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/RemoteEndpointService.php b/lib/Service/RemoteEndpointService.php new file mode 100644 index 000000000..a580dab4f --- /dev/null +++ b/lib/Service/RemoteEndpointService.php @@ -0,0 +1,250 @@ +setup('app', Application::APP_ID); + } + + /** + * Sets the current interface from the host of the request and refuses events + * received on the frontal interface while Federated Teams are disabled. + * + * @throws UnknownInterfaceException + * @throws FederatedEventException + */ + public function confirmEventInterface(IRequest $request): void { + $this->interfaceService->setCurrentInterfaceFromRequest($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 of the request is a known and trusted remote instance, + * and sets the current interface to the one used to reach it. + * + * @param RemoteInstance|null $remoteInstance the remote instance that signed the request + * @param string $origin the origin of the signed request + * + * @throws SignatoryException + */ + public function confirmRemoteInstance(?RemoteInstance $remoteInstance, string $origin): RemoteInstance { + if ($remoteInstance === null) { + $this->debug('Signatory is not a known RemoteInstance', ['origin' => $origin]); + throw new SignatoryException('Could not confirm identity'); + } + + if (!$this->configService->isLocalInstance($origin) + && $remoteInstance->getType() === RemoteInstance::TYPE_UNKNOWN) { + $this->debug('Could not confirm identity', ['origin' => $origin]); + throw new SignatoryException('Could not confirm identity'); + } + + $this->interfaceService->setCurrentInterface($remoteInstance->getInterface()); + + return $remoteInstance; + } + + public function importEvent(array $data, string $origin): FederatedEvent { + $event = new FederatedEvent(); + $event->import($data); + $event->setSender($origin); + + return $event; + } + + /** + * Sets the remote instance and the initiator used to filter the data returned + * to the remote instance, and extracts the filters sent with the request. + * + * @throws InvalidRemoteInstanceException + */ + public function prepareDataRequest(RemoteInstance $remoteInstance, ?array $payload): SimpleDataStore { + // There should be no need to confirm the need or the origin of the initiator as $remoteInstance + // already helps filtering request to the database. + // initiator here is only used to play with the visibility, on top of the visibility provided to + // the remote instance based on its type. + $this->federatedUserService->setRemoteInstance($remoteInstance); + + $data = new SimpleDataStore(); + $store = new SimpleDataStore($payload); + try { + /** @var FederatedUser $initiator */ + $initiator = $store->gObj('initiator', FederatedUser::class); + if ($initiator->getInstance() !== $remoteInstance->getInstance() && !in_array($initiator->getInstance(), $remoteInstance->getAliases(), true)) { + throw new InvalidRemoteInstanceException('Initiator instance does not match remote instance'); + } + $this->federatedUserService->setCurrentUser($initiator); + } catch (InvalidItemException|ItemNotFoundException) { + } + + try { + /** @var FederatedUser $initiator */ + $filterMember = $store->gObj('filterMember', Member::class); + $data->aObj('filterMember', $filterMember); + } catch (InvalidItemException|ItemNotFoundException) { + } + + try { + /** @var FederatedUser $initiator */ + $filterCircle = $store->gObj('filterCircle', Circle::class); + $data->aObj('filterCircle', $filterCircle); + } catch (InvalidItemException|ItemNotFoundException) { + } + + return $data; + } + + /** + * @throws Exception + */ + public function requestedEvent(FederatedEvent $event): array { + $this->remoteDownstreamService->requestedEvent($event); + + return $event->getOutcome(); + } + + /** + * @throws Exception + */ + public function incomingEvent(FederatedEvent $event): array { + $this->remoteDownstreamService->incomingEvent($event); + + return $this->serialize($event->getResult()); + } + + /** + * @return Circle[] + * @throws Exception + */ + public function getCircles(SimpleDataStore $data): array { + /** @var Circle $filterCircle */ + $filterCircle = $data->gObj('filterCircle'); + /** @var Member $filterMember */ + $filterMember = $data->gObj('filterMember'); + + $probe = new CircleProbe(); + $probe->setFilterCircle($filterCircle) + ->setFilterMember($filterMember) + ->addDetail(BasicProbe::DETAILS_POPULATION); + + return $this->circleService->getCircles($probe); + } + + /** + * @throws Exception + */ + public function getCircle(string $circleId): array { + return $this->serialize($this->circleService->getCircle($circleId)); + } + + /** + * @return Member[] + * @throws Exception + */ + public function getMembers(string $circleId): array { + return $this->memberService->getMembers($circleId, true); + } + + /** + * @throws Exception + */ + public function getFederatedUser(string $type, string $userId): array { + // FILTER CIRCLE BASED ON THE CONFIG/FEDERATED_8192 !! + if ($type === Member::$TYPE[Member::TYPE_SINGLE]) { + $federatedUser = $this->federatedUserService->getFederatedUser($userId, Member::TYPE_SINGLE); + } elseif ($type === Member::$TYPE[Member::TYPE_CIRCLE]) { + $federatedUser = $this->federatedUserService->getFederatedUser($userId, Member::TYPE_CIRCLE); + } elseif ($type === Member::$TYPE[Member::TYPE_USER]) { + $federatedUser = $this->federatedUserService->getLocalFederatedUser($userId); + } else { + throw new FederatedUserNotFoundException('Entity not found'); + } + + return $this->serialize($federatedUser); + } + + /** + * @return Member[] + * @throws Exception + */ + public function getInheritedMembers(string $circleId): array { + return $this->circleService->getCircle($circleId)->getInheritedMembers(); + } + + /** + * @throws Exception + */ + public function getMemberships(string $circleId): array { + return $this->circleService->getCircle($circleId)->getMemberships(); + } + + public function exceptionResponse( + Exception $e, + int $httpErrorCode = Http::STATUS_BAD_REQUEST, + ): DataResponse { + if ($e instanceof FederatedItemException) { + return new DataResponse($this->serialize($e), $e->getStatus()); + } + + $errorCode = (int)$e->getCode(); + return new DataResponse( + [ + 'message' => $e->getMessage(), + 'code' => $e->getCode() + ], + ($errorCode > 0) ? $errorCode : $httpErrorCode + ); + } +} diff --git a/lib/Service/RemoteStreamService.php b/lib/Service/RemoteStreamService.php index 485954d4a..9434cefa2 100644 --- a/lib/Service/RemoteStreamService.php +++ b/lib/Service/RemoteStreamService.php @@ -9,8 +9,10 @@ namespace OCA\Circles\Service; +use Exception; use JsonSerializable; use OCA\Circles\AppInfo\Application; +use OCA\Circles\ConfigLexicon; use OCA\Circles\Db\RemoteRequest; use OCA\Circles\Exceptions\FederatedItemException; use OCA\Circles\Exceptions\RemoteAlreadyExistsException; @@ -27,7 +29,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; @@ -37,8 +38,11 @@ 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\OCMProviderException; +use OCP\OCM\IOCMDiscoveryService; use ReflectionClass; use ReflectionException; @@ -59,6 +63,18 @@ class RemoteStreamService extends NCSignature { public const UPDATE_INSTANCE = 'instance'; public const UPDATE_HREF = 'href'; + /** Paths of the items served by the OCM endpoint, relative to the Circles OCM capability */ + private const OCM_PATHS = [ + RemoteInstance::EVENT => 'event', + RemoteInstance::INCOMING => 'incoming', + RemoteInstance::CIRCLES => 'circles', + RemoteInstance::CIRCLE => 'circle/{circleId}', + RemoteInstance::MEMBERS => 'members/{circleId}', + RemoteInstance::MEMBER => 'member/{type}/{userId}', + RemoteInstance::INHERITED => 'inherited/{circleId}', + RemoteInstance::MEMBERSHIPS => 'memberships/{circleId}', + ]; + /** * RemoteStreamService constructor. * @@ -73,6 +89,8 @@ public function __construct( private RemoteRequest $remoteRequest, private InterfaceService $interfaceService, private ConfigService $configService, + private readonly IAppConfig $appConfig, + private readonly IOCMDiscoveryService $ocmDiscoveryService, ) { $this->setup('app', 'circles'); } @@ -186,6 +204,12 @@ public function resultRequestRemoteInstance( ): array { $this->interfaceService->setCurrentInterfaceFromInstance($instance); + // use OCM endpoint when possible, otherwise fall back to circles own federation protocol + $ocmPath = $this->getOcmPath($instance, $item, $params); + if ($ocmPath !== null) { + return $this->resultRequestRemoteOcmEndpoint($instance, $ocmPath, $object); + } + $signedRequest = $this->requestRemoteInstance($instance, $item, $type, $object, $params); if (!$signedRequest->getOutgoingRequest()->hasResult()) { throw new RemoteInstanceException(); @@ -197,7 +221,79 @@ public function resultRequestRemoteInstance( return $result->getAsArray(); } - throw $this->getFederatedItemExceptionFromResult($result); + throw $this->getFederatedItemException($result->getStatusCode(), $result->getAsArray()); + } + + /** + * Returns the path to request the item on OCM endpoint of the remote instance, + * or null if the request should circles own federation protocol instead. + */ + private function getOcmPath(string $instance, string $item, array $params): ?string { + if (!array_key_exists($item, self::OCM_PATHS) + || $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 null; + } + + try { + $provider = $this->ocmDiscoveryService->discover($this->getCachedRemoteInstance($instance)->getRoot()); + } catch (RemoteNotFoundException|UnknownRemoteException|OCMProviderException) { + return null; + } + + if (!$provider->isEnabled() || !$provider->hasCapability(Application::OCM_CAPABILITY)) { + return null; + } + + return Application::OCM_CAPABILITY . '/' . $this->feedStringWithParams(self::OCM_PATHS[$item], $params); + } + + /** + * Sends a signed request to the OCM endpoint of the remote instance. + * + * @throws FederatedItemException + * @throws RemoteInstanceException + * @throws RemoteNotFoundException + * @throws UnknownRemoteException + */ + private function resultRequestRemoteOcmEndpoint(string $instance, string $ocmPath, ?JsonSerializable $object): array { + $remote = $this->getCachedRemoteInstance($instance); + $options = [ + 'timeout' => 5, + 'connect_timeout' => 5, + 'http_errors' => false, + 'verify' => $this->configService->getAppValue(ConfigService::SELF_SIGNED_CERT) !== '1', + 'nextcloud' => ['allow_local_address' => true], + ]; + + $payload = is_null($object) ? [] : $this->serialize($object); + // The OCM endpoint identifies the signing instance from the OCM address in the 'sender' field + $payload['sender'] = Application::APP_ID . '@' . $this->interfaceService->getLocalInstance(); + + try { + $response = $this->ocmDiscoveryService->requestRemoteOcmEndpoint( + Application::OCM_CAPABILITY, + $remote->getRoot(), + $ocmPath, + $payload, + 'post', + options: $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_OK) { + return $data; + } + + throw $this->getFederatedItemException($response->getStatusCode(), $data); } /** @@ -456,13 +552,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); @@ -472,7 +569,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);