Merge pull request #56899 from nextcloud/feat/noid/ocm-capabilities

This commit is contained in:
Benjamin Gaussorgues 2026-01-08 13:46:09 +01:00 committed by GitHub
commit ae250777fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 962 additions and 170 deletions

View file

@ -25,6 +25,15 @@ return [
'url' => '/invite-accepted',
'verb' => 'POST',
'root' => '/ocm',
]
],
// needs to be kept at the bottom of the list
[
'name' => 'OCMRequest#manageOCMRequests',
'url' => '/{ocmPath}',
'requirements' => ['ocmPath' => '.*'],
'verb' => ['GET', 'POST', 'PUT', 'DELETE'],
'root' => '/ocm',
],
],
];

View file

@ -10,6 +10,7 @@ return array(
'OCA\\CloudFederationAPI\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php',
'OCA\\CloudFederationAPI\\Capabilities' => $baseDir . '/../lib/Capabilities.php',
'OCA\\CloudFederationAPI\\Config' => $baseDir . '/../lib/Config.php',
'OCA\\CloudFederationAPI\\Controller\\OCMRequestController' => $baseDir . '/../lib/Controller/OCMRequestController.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',

View file

@ -25,6 +25,7 @@ class ComposerStaticInitCloudFederationAPI
'OCA\\CloudFederationAPI\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php',
'OCA\\CloudFederationAPI\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php',
'OCA\\CloudFederationAPI\\Config' => __DIR__ . '/..' . '/../lib/Config.php',
'OCA\\CloudFederationAPI\\Controller\\OCMRequestController' => __DIR__ . '/..' . '/../lib/Controller/OCMRequestController.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',

View file

@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\CloudFederationAPI\Controller;
use JsonException;
use NCU\Security\Signature\Exceptions\IncomingRequestException;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\BruteForceProtection;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\Response;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IRequest;
use OCP\OCM\Events\OCMEndpointRequestEvent;
use OCP\OCM\Exceptions\OCMArgumentException;
use OCP\OCM\IOCMDiscoveryService;
use Psr\Log\LoggerInterface;
class OCMRequestController extends Controller {
public function __construct(
string $appName,
IRequest $request,
private readonly IEventDispatcher $eventDispatcher,
private readonly IOCMDiscoveryService $ocmDiscoveryService,
private readonly LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
/**
* Method will catch any request done to /ocm/[...] and will broadcast an event.
* The first parameter of the remaining subpath (post-/ocm/) is defined as
* capability and should be used by listeners to filter incoming requests.
*
* @see OCMEndpointRequestEvent
* @see OCMEndpointRequestEvent::getArgs
*
* @param string $ocmPath
* @return Response
* @throws OCMArgumentException
*/
#[NoCSRFRequired]
#[PublicPage]
#[BruteForceProtection(action: 'receiveOcmRequest')]
public function manageOCMRequests(string $ocmPath): Response {
if (!mb_check_encoding($ocmPath, 'UTF-8')) {
throw new OCMArgumentException('path is not UTF-8');
}
try {
// if request is signed and well signed, no exceptions are thrown
// if request is not signed and host is known for not supporting signed request, no exceptions are thrown
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest();
} catch (IncomingRequestException $e) {
$this->logger->warning('incoming ocm request exception', ['exception' => $e]);
return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
}
// assuming that ocm request contains a json array
$payload = $signedRequest?->getBody() ?? file_get_contents('php://input');
try {
$payload = ($payload) ? json_decode($payload, true, 512, JSON_THROW_ON_ERROR) : null;
} catch (JsonException $e) {
$this->logger->debug('json decode error', ['exception' => $e]);
$payload = null;
}
$event = new OCMEndpointRequestEvent(
$this->request->getMethod(),
preg_replace('@/+@', '/', $ocmPath),
$payload,
$signedRequest?->getOrigin()
);
$this->eventDispatcher->dispatchTyped($event);
return $event->getResponse() ?? new DataResponse('', Http::STATUS_NOT_FOUND);
}
}

View file

@ -11,8 +11,6 @@ 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;
@ -44,6 +42,7 @@ use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\OCM\IOCMDiscoveryService;
use OCP\Share\Exceptions\ShareNotFound;
use OCP\Util;
use Psr\Log\LoggerInterface;
@ -74,8 +73,8 @@ class RequestHandlerController extends Controller {
private readonly IAppConfig $appConfig,
private ICloudFederationFactory $factory,
private ICloudIdManager $cloudIdManager,
private readonly IOCMDiscoveryService $ocmDiscoveryService,
private readonly ISignatureManager $signatureManager,
private readonly OCMSignatoryManager $signatoryManager,
private ITimeFactory $timeFactory,
) {
parent::__construct($appName, $request);
@ -108,9 +107,9 @@ class RequestHandlerController extends Controller {
public function addShare($shareWith, $name, $description, $providerId, $owner, $ownerDisplayName, $sharedBy, $sharedByDisplayName, $protocol, $shareType, $resourceType) {
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
try {
// if request is signed and well signed, no exception are thrown
// if request is signed and well signed, no exceptions are thrown
// if request is not signed and host is known for not supporting signed request, no exception are thrown
$signedRequest = $this->getSignedRequest();
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest();
$this->confirmSignedOrigin($signedRequest, 'owner', $owner);
} catch (IncomingRequestException $e) {
$this->logger->warning('incoming request exception', ['exception' => $e]);
@ -360,7 +359,7 @@ 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();
$signedRequest = $this->ocmDiscoveryService->getIncomingSignedRequest();
$this->confirmNotificationIdentity($signedRequest, $resourceType, $notification);
} catch (IncomingRequestException $e) {
$this->logger->warning('incoming request exception', ['exception' => $e]);
@ -434,37 +433,6 @@ class RequestHandlerController extends Controller {
}
/**
* 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.

View file

@ -10,7 +10,6 @@ declare(strict_types=1);
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;
@ -29,6 +28,7 @@ use OCP\IRequest;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\OCM\IOCMDiscoveryService;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
@ -45,10 +45,11 @@ class RequestHandlerControllerTest extends TestCase {
private FederatedInviteMapper&MockObject $federatedInviteMapper;
private AddressHandler&MockObject $addressHandler;
private IAppConfig&MockObject $appConfig;
private ICloudFederationFactory&MockObject $cloudFederationFactory;
private ICloudIdManager&MockObject $cloudIdManager;
private IOCMDiscoveryService&MockObject $discoveryService;
private ISignatureManager&MockObject $signatureManager;
private OCMSignatoryManager&MockObject $signatoryManager;
private ITimeFactory&MockObject $timeFactory;
private RequestHandlerController $requestHandlerController;
@ -69,8 +70,8 @@ class RequestHandlerControllerTest extends TestCase {
$this->appConfig = $this->createMock(IAppConfig::class);
$this->cloudFederationFactory = $this->createMock(ICloudFederationFactory::class);
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
$this->discoveryService = $this->createMock(IOCMDiscoveryService::class);
$this->signatureManager = $this->createMock(ISignatureManager::class);
$this->signatoryManager = $this->createMock(OCMSignatoryManager::class);
$this->timeFactory = $this->createMock(ITimeFactory::class);
$this->requestHandlerController = new RequestHandlerController(
@ -88,8 +89,8 @@ class RequestHandlerControllerTest extends TestCase {
$this->appConfig,
$this->cloudFederationFactory,
$this->cloudIdManager,
$this->discoveryService,
$this->signatureManager,
$this->signatoryManager,
$this->timeFactory,
);
}

View file

@ -718,9 +718,14 @@ return array(
'OCP\\Notification\\InvalidValueException' => $baseDir . '/lib/public/Notification/InvalidValueException.php',
'OCP\\Notification\\NotificationPreloadReason' => $baseDir . '/lib/public/Notification/NotificationPreloadReason.php',
'OCP\\Notification\\UnknownNotificationException' => $baseDir . '/lib/public/Notification/UnknownNotificationException.php',
'OCP\\OCM\\Enum\\ParamType' => $baseDir . '/lib/public/OCM/Enum/ParamType.php',
'OCP\\OCM\\Events\\LocalOCMDiscoveryEvent' => $baseDir . '/lib/public/OCM/Events/LocalOCMDiscoveryEvent.php',
'OCP\\OCM\\Events\\OCMEndpointRequestEvent' => $baseDir . '/lib/public/OCM/Events/OCMEndpointRequestEvent.php',
'OCP\\OCM\\Events\\ResourceTypeRegisterEvent' => $baseDir . '/lib/public/OCM/Events/ResourceTypeRegisterEvent.php',
'OCP\\OCM\\Exceptions\\OCMArgumentException' => $baseDir . '/lib/public/OCM/Exceptions/OCMArgumentException.php',
'OCP\\OCM\\Exceptions\\OCMCapabilityException' => $baseDir . '/lib/public/OCM/Exceptions/OCMCapabilityException.php',
'OCP\\OCM\\Exceptions\\OCMProviderException' => $baseDir . '/lib/public/OCM/Exceptions/OCMProviderException.php',
'OCP\\OCM\\Exceptions\\OCMRequestException' => $baseDir . '/lib/public/OCM/Exceptions/OCMRequestException.php',
'OCP\\OCM\\ICapabilityAwareOCMProvider' => $baseDir . '/lib/public/OCM/ICapabilityAwareOCMProvider.php',
'OCP\\OCM\\IOCMDiscoveryService' => $baseDir . '/lib/public/OCM/IOCMDiscoveryService.php',
'OCP\\OCM\\IOCMProvider' => $baseDir . '/lib/public/OCM/IOCMProvider.php',

View file

@ -759,9 +759,14 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\Notification\\InvalidValueException' => __DIR__ . '/../../..' . '/lib/public/Notification/InvalidValueException.php',
'OCP\\Notification\\NotificationPreloadReason' => __DIR__ . '/../../..' . '/lib/public/Notification/NotificationPreloadReason.php',
'OCP\\Notification\\UnknownNotificationException' => __DIR__ . '/../../..' . '/lib/public/Notification/UnknownNotificationException.php',
'OCP\\OCM\\Enum\\ParamType' => __DIR__ . '/../../..' . '/lib/public/OCM/Enum/ParamType.php',
'OCP\\OCM\\Events\\LocalOCMDiscoveryEvent' => __DIR__ . '/../../..' . '/lib/public/OCM/Events/LocalOCMDiscoveryEvent.php',
'OCP\\OCM\\Events\\OCMEndpointRequestEvent' => __DIR__ . '/../../..' . '/lib/public/OCM/Events/OCMEndpointRequestEvent.php',
'OCP\\OCM\\Events\\ResourceTypeRegisterEvent' => __DIR__ . '/../../..' . '/lib/public/OCM/Events/ResourceTypeRegisterEvent.php',
'OCP\\OCM\\Exceptions\\OCMArgumentException' => __DIR__ . '/../../..' . '/lib/public/OCM/Exceptions/OCMArgumentException.php',
'OCP\\OCM\\Exceptions\\OCMCapabilityException' => __DIR__ . '/../../..' . '/lib/public/OCM/Exceptions/OCMCapabilityException.php',
'OCP\\OCM\\Exceptions\\OCMProviderException' => __DIR__ . '/../../..' . '/lib/public/OCM/Exceptions/OCMProviderException.php',
'OCP\\OCM\\Exceptions\\OCMRequestException' => __DIR__ . '/../../..' . '/lib/public/OCM/Exceptions/OCMRequestException.php',
'OCP\\OCM\\ICapabilityAwareOCMProvider' => __DIR__ . '/../../..' . '/lib/public/OCM/ICapabilityAwareOCMProvider.php',
'OCP\\OCM\\IOCMDiscoveryService' => __DIR__ . '/../../..' . '/lib/public/OCM/IOCMDiscoveryService.php',
'OCP\\OCM\\IOCMProvider' => __DIR__ . '/../../..' . '/lib/public/OCM/IOCMProvider.php',

View file

@ -75,7 +75,6 @@ class RouteParser {
$root = $this->buildRootPrefix($route, $appName, $routeNamePrefix);
$url = $root . '/' . ltrim($route['url'], '/');
$verb = strtoupper($route['verb'] ?? 'GET');
$split = explode('#', $name, 3);
if (count($split) !== 2) {
@ -95,7 +94,7 @@ class RouteParser {
$routeName = strtolower($routeNamePrefix . $appName . '.' . $controller . '.' . $action . $postfix);
$routeObject = new Route($url);
$routeObject->method($verb);
$routeObject->method($route['verb'] ?? 'GET');
// optionally register requirements for route. This is used to
// tell the route parser how url parameters should be matched
@ -174,7 +173,6 @@ class RouteParser {
$url = $root . '/' . ltrim($config['url'], '/');
$method = $action['name'];
$verb = strtoupper($action['verb'] ?? 'GET');
$collectionAction = $action['on-collection'] ?? false;
if (!$collectionAction) {
$url .= '/{id}';
@ -188,7 +186,7 @@ class RouteParser {
$routeName = $routeNamePrefix . $appName . '.' . strtolower($resource) . '.' . $method;
$route = new Route($url);
$route->method($verb);
$route->method($action['verb'] ?? 'GET');
$route->defaults(['caller' => [$appName, $controllerName, $actionName]]);

View file

@ -23,6 +23,7 @@ use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\OCM\Exceptions\OCMCapabilityException;
use OCP\OCM\Exceptions\OCMProviderException;
use OCP\OCM\IOCMDiscoveryService;
use Psr\Log\LoggerInterface;
@ -107,7 +108,7 @@ class CloudFederationProviderManager implements ICloudFederationProviderManager
$cloudID = $this->cloudIdManager->resolveCloudId($share->getShareWith());
try {
try {
$response = $this->postOcmPayload($cloudID->getRemote(), '/shares', json_encode($share->getShare()));
$response = $this->postOcmPayload($cloudID->getRemote(), '/shares', $share->getShare());
} catch (OCMProviderException) {
return false;
}
@ -138,7 +139,7 @@ class CloudFederationProviderManager implements ICloudFederationProviderManager
$cloudID = $this->cloudIdManager->resolveCloudId($share->getShareWith());
$client = $this->httpClientService->newClient();
try {
return $this->postOcmPayload($cloudID->getRemote(), '/shares', json_encode($share->getShare()), $client);
return $this->postOcmPayload($cloudID->getRemote(), '/shares', $share->getShare(), $client);
} catch (\Throwable $e) {
$this->logger->error('Error while sending share to federation server: ' . $e->getMessage(), ['exception' => $e]);
try {
@ -158,7 +159,7 @@ class CloudFederationProviderManager implements ICloudFederationProviderManager
public function sendNotification($url, ICloudFederationNotification $notification) {
try {
try {
$response = $this->postOcmPayload($url, '/notifications', json_encode($notification->getMessage()));
$response = $this->postOcmPayload($url, '/notifications', $notification->getMessage());
} catch (OCMProviderException) {
return false;
}
@ -183,7 +184,7 @@ class CloudFederationProviderManager implements ICloudFederationProviderManager
public function sendCloudNotification(string $url, ICloudFederationNotification $notification): IResponse {
$client = $this->httpClientService->newClient();
try {
return $this->postOcmPayload($url, '/notifications', json_encode($notification->getMessage()), $client);
return $this->postOcmPayload($url, '/notifications', $notification->getMessage(), $client);
} catch (\Throwable $e) {
$this->logger->error('Error while sending notification to federation server: ' . $e->getMessage(), ['exception' => $e]);
try {
@ -204,51 +205,18 @@ class CloudFederationProviderManager implements ICloudFederationProviderManager
}
/**
* @param string $cloudId
* @param string $uri
* @param string $payload
*
* @return IResponse
* @throws OCMCapabilityException
* @throws OCMProviderException
*/
private function postOcmPayload(string $cloudId, string $uri, string $payload, ?IClient $client = null): IResponse {
$ocmProvider = $this->discoveryService->discover($cloudId);
$uri = $ocmProvider->getEndPoint() . '/' . ltrim($uri, '/');
$client = $client ?? $this->httpClientService->newClient();
return $client->post($uri, $this->prepareOcmPayload($uri, $payload));
}
/**
* @param string $uri
* @param string $payload
*
* @return array
*/
private function prepareOcmPayload(string $uri, string $payload): array {
$payload = array_merge($this->getDefaultRequestOptions(), ['body' => $payload]);
if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)
&& $this->signatoryManager->getRemoteSignatory($this->signatureManager->extractIdentityFromUri($uri)) === null) {
return $payload;
}
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
$signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload(
$this->signatoryManager,
$payload,
'post', $uri
);
}
return $signedPayload ?? $payload;
}
private function getDefaultRequestOptions(): array {
return [
'headers' => ['content-type' => 'application/json'],
'timeout' => 10,
'connect_timeout' => 10,
'verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false),
];
private function postOcmPayload(string $cloudId, string $uri, array $payload, ?IClient $client = null): IResponse {
return $this->discoveryService->requestRemoteOcmEndpoint(
null,
$cloudId,
$uri,
$payload,
'post',
$client,
['verify' => !$this->config->getSystemValueBool('sharing.federation.allowSelfSignedCertificates', false)],
);
}
}

View file

@ -12,13 +12,13 @@ namespace OC\OCM\Model;
use NCU\Security\Signature\Model\Signatory;
use OCP\OCM\Exceptions\OCMArgumentException;
use OCP\OCM\Exceptions\OCMProviderException;
use OCP\OCM\ICapabilityAwareOCMProvider;
use OCP\OCM\IOCMProvider;
use OCP\OCM\IOCMResource;
/**
* @since 28.0.0
*/
class OCMProvider implements ICapabilityAwareOCMProvider {
class OCMProvider implements IOCMProvider {
private bool $enabled = false;
private string $apiVersion = '';
private string $inviteAcceptDialog = '';
@ -124,12 +124,10 @@ class OCMProvider implements ICapabilityAwareOCMProvider {
* @return $this
*/
public function setCapabilities(array $capabilities): static {
foreach ($capabilities as $value) {
if (!in_array($value, $this->capabilities)) {
array_push($this->capabilities, $value);
}
}
$this->capabilities = array_unique(array_merge(
$this->capabilities,
array_map([$this, 'normalizeCapability'], $capabilities)
));
return $this;
}
@ -139,6 +137,20 @@ class OCMProvider implements ICapabilityAwareOCMProvider {
public function getCapabilities(): array {
return $this->capabilities;
}
/**
* @param string $capability
* @return bool
*/
public function hasCapability(string $capability): bool {
return (in_array($this->normalizeCapability($capability), $this->capabilities, true));
}
private function normalizeCapability(string $capability): string {
// since ocm 1.2, removing leading slashes from capabilities
return strtolower(ltrim($capability, '/'));
}
/**
* create a new resource to later add it with {@see IOCMProvider::addResourceType()}
* @return IOCMResource

View file

@ -9,35 +9,48 @@ declare(strict_types=1);
namespace OC\OCM;
use Exception;
use GuzzleHttp\Exception\ConnectException;
use JsonException;
use NCU\Security\Signature\Exceptions\IdentityNotFoundException;
use NCU\Security\Signature\Exceptions\IncomingRequestException;
use NCU\Security\Signature\Exceptions\SignatoryException;
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\Core\AppInfo\ConfigLexicon;
use OC\OCM\Model\OCMProvider;
use OCP\AppFramework\Attribute\Consumable;
use OCP\AppFramework\Http;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\Http\Client\IResponse;
use OCP\IAppConfig;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use OCP\IURLGenerator;
use OCP\OCM\Events\LocalOCMDiscoveryEvent;
use OCP\OCM\Events\ResourceTypeRegisterEvent;
use OCP\OCM\Exceptions\OCMCapabilityException;
use OCP\OCM\Exceptions\OCMProviderException;
use OCP\OCM\ICapabilityAwareOCMProvider;
use OCP\OCM\Exceptions\OCMRequestException;
use OCP\OCM\IOCMDiscoveryService;
use OCP\OCM\IOCMProvider;
use Psr\Log\LoggerInterface;
/**
* @since 28.0.0
*/
class OCMDiscoveryService implements IOCMDiscoveryService {
#[Consumable(since: '28.0.0')]
final class OCMDiscoveryService implements IOCMDiscoveryService {
private ICache $cache;
public const API_VERSION = '1.1.0';
private ?ICapabilityAwareOCMProvider $localProvider = null;
/** @var array<string, ICapabilityAwareOCMProvider> */
private ?IOCMProvider $localProvider = null;
/** @var array<string, IOCMProvider> */
private array $remoteProviders = [];
public function __construct(
@ -47,20 +60,25 @@ class OCMDiscoveryService implements IOCMDiscoveryService {
protected IConfig $config,
private IAppConfig $appConfig,
private IURLGenerator $urlGenerator,
private OCMSignatoryManager $ocmSignatoryManager,
private readonly ISignatureManager $signatureManager,
private readonly OCMSignatoryManager $signatoryManager,
private LoggerInterface $logger,
) {
$this->cache = $cacheFactory->createDistributed('ocm-discovery');
}
/**
* @param string $remote
* @param bool $skipCache
* @inheritDoc
*
* @return ICapabilityAwareOCMProvider
* @throws OCMProviderException
* @param string $remote address of the remote provider
* @param bool $skipCache ignore cache, refresh data
*
* @return IOCMProvider
* @throws OCMProviderException if no valid discovery data can be returned
* @since 28.0.0
*/
public function discover(string $remote, bool $skipCache = false): ICapabilityAwareOCMProvider {
public function discover(string $remote, bool $skipCache = false): IOCMProvider {
$remote = rtrim($remote, '/');
if (!str_starts_with($remote, 'http://') && !str_starts_with($remote, 'https://')) {
// if scheme not specified, we test both;
@ -138,7 +156,6 @@ class OCMDiscoveryService implements IOCMDiscoveryService {
throw $exception;
}
throw new OCMProviderException('invalid remote ocm endpoint');
} catch (JsonException|OCMProviderException) {
$this->cache->set($remote, false, 5 * 60);
@ -154,9 +171,15 @@ class OCMDiscoveryService implements IOCMDiscoveryService {
}
/**
* @return ICapabilityAwareOCMProvider
* @inheritDoc
*
* @param bool $fullDetails complete details, including public keys.
* Set to FALSE for client (capabilities) purpose.
*
* @return IOCMProvider
* @since 33.0.0
*/
public function getLocalOCMProvider(bool $fullDetails = true): ICapabilityAwareOCMProvider {
public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider {
if ($this->localProvider !== null) {
return $this->localProvider;
}
@ -176,7 +199,7 @@ class OCMDiscoveryService implements IOCMDiscoveryService {
$provider->setEnabled(true);
$provider->setApiVersion(self::API_VERSION);
$provider->setEndPoint(substr($url, 0, $pos));
$provider->setCapabilities(['/invite-accepted', '/notifications', '/shares']);
$provider->setCapabilities(['invite-accepted', 'notifications', 'shares']);
// The inviteAcceptDialog is available from the contacts app, if this config value is set
$inviteAcceptDialog = $this->appConfig->getValueString('core', ConfigLexicon::OCM_INVITE_ACCEPT_DIALOG);
@ -198,7 +221,7 @@ class OCMDiscoveryService implements IOCMDiscoveryService {
* @experimental 31.0.0
* @psalm-suppress UndefinedInterfaceMethod
*/
$provider->setSignatory($this->ocmSignatoryManager->getLocalSignatory());
$provider->setSignatory($this->signatoryManager->getLocalSignatory());
} else {
$this->logger->debug('ocm public key feature disabled');
}
@ -207,6 +230,10 @@ class OCMDiscoveryService implements IOCMDiscoveryService {
}
}
$event = new LocalOCMDiscoveryEvent($provider);
$this->eventDispatcher->dispatchTyped($event);
// deprecated since 33.0.0
$event = new ResourceTypeRegisterEvent($provider);
$this->eventDispatcher->dispatchTyped($event);
@ -214,4 +241,136 @@ class OCMDiscoveryService implements IOCMDiscoveryService {
return $provider;
}
/**
* @inheritDoc
*
* @return IIncomingSignedRequest|null null if remote does not (and never did) support signed request
* @throws IncomingRequestException
* @since 33.0.0
*/
public function getIncomingSignedRequest(): ?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;
}
/**
* @inheritDoc
*
* @param string|null $capability when not NULL, method will throw
* {@see OCMCapabilityException}
* if remote does not support the capability
* @param string $remote remote ocm cloud id
* @param string $ocmSubPath path to reach, complementing the ocm endpoint extracted
* from remote discovery data
* @param array|null $payload payload attached to the request
* @param string $method method to use ('get', 'post', 'put', 'delete')
* @param IClient|null $client NULL to use default {@see IClient}
* @param array|null $options options related to IClient
* @param bool $signed FALSE to not auth the request
*
* @throws OCMCapabilityException if remote does not support $capability
* @throws OCMProviderException if remote ocm provider is disabled or invalid data returned
* @throws OCMRequestException on internal issue
* @since 33.0.0
*/
public function requestRemoteOcmEndpoint(
?string $capability,
string $remote,
string $ocmSubPath,
?array $payload = null,
string $method = 'get',
?IClient $client = null,
?array $options = null,
bool $signed = true,
): IResponse {
$ocmProvider = $this->discover($remote);
if (!$ocmProvider->isEnabled()) {
throw new OCMProviderException('remote ocm provider is disabled');
}
if ($capability !== null && !$ocmProvider->hasCapability($capability)) {
throw new OCMCapabilityException(sprintf('remote does not support %s', $capability));
}
$uri = $ocmProvider->getEndPoint() . '/' . ltrim($ocmSubPath, '/');
$client = $client ?? $this->clientService->newClient();
try {
$body = json_encode($payload ?? [], JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
$this->logger->warning('payload could not be converted to JSON', ['exception' => $e]);
throw new OCMRequestException('ocm payload issue');
}
try {
$options = $options ?? [];
return match (strtolower($method)) {
'get' => $client->get($uri, $this->prepareOcmPayload($uri, 'get', $options, $body, $signed)),
'post' => $client->post($uri, $this->prepareOcmPayload($uri, 'post', $options, $body, $signed)),
'put' => $client->put($uri, $this->prepareOcmPayload($uri, 'put', $options, $body, $signed)),
'delete' => $client->delete($uri, $this->prepareOcmPayload($uri, 'delete', $options, $body, $signed)),
default => throw new OCMRequestException('unknown method'),
};
} catch (OCMRequestException $e) {
throw $e;
} catch (Exception $e) {
$this->logger->warning('error while requesting remote ocm endpoint', ['exception' => $e]);
throw new OCMProviderException('error while requesting remote endpoint');
}
}
/**
* add entries to the payload to auth the whole request
*
* @throws OCMProviderException
* @return array
*/
private function prepareOcmPayload(string $uri, string $method, array $options, string $payload, bool $signed): array {
$payload = array_merge($this->generateRequestOptions($options), ['body' => $payload]);
if (!$signed) {
return $payload;
}
if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_ENFORCED, lazy: true)
&& $this->signatoryManager->getRemoteSignatory($this->signatureManager->extractIdentityFromUri($uri)) === null) {
throw new OCMProviderException('remote endpoint does not support signed request');
}
if (!$this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
$signedPayload = $this->signatureManager->signOutgoingRequestIClientPayload(
$this->signatoryManager,
$payload,
$method, $uri
);
}
return $signedPayload ?? $payload;
}
private function generateRequestOptions(array $options): array {
return array_merge(
[
'headers' => ['content-type' => 'application/json'],
'timeout' => 5,
'connect_timeout' => 5,
],
$options
);
}
}

View file

@ -14,7 +14,7 @@ class Route extends SymfonyRoute implements IRoute {
/**
* Specify the method when this route is to be used
*
* @param string $method HTTP method (uppercase)
* @param string|array $method HTTP method
* @return \OC\Route\Route
*/
public function method($method) {

View file

@ -206,9 +206,7 @@ use OCP\Lockdown\ILockdownManager;
use OCP\Log\ILogFactory;
use OCP\Mail\IEmailValidator;
use OCP\Mail\IMailer;
use OCP\OCM\ICapabilityAwareOCMProvider;
use OCP\OCM\IOCMDiscoveryService;
use OCP\OCM\IOCMProvider;
use OCP\Preview\IMimeIconProvider;
use OCP\Profile\IProfileManager;
use OCP\Profiler\IProfiler;
@ -1245,9 +1243,9 @@ class Server extends ServerContainer implements IServerContainer {
$this->registerAlias(IPhoneNumberUtil::class, PhoneNumberUtil::class);
// there is no reason for having OCMProvider as a Service
$this->registerDeprecatedAlias(ICapabilityAwareOCMProvider::class, OCMProvider::class);
$this->registerDeprecatedAlias(IOCMProvider::class, OCMProvider::class);
// there is no reason for having OCMProvider as a Service (marked as deprecated since 32.0.0)
$this->registerDeprecatedAlias(\OCP\OCM\ICapabilityAwareOCMProvider::class, OCMProvider::class);
$this->registerDeprecatedAlias(\OCP\OCM\IOCMProvider::class, OCMProvider::class);
$this->registerAlias(ISetupCheckManager::class, SetupCheckManager::class);

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\OCM\Enum;
use OCP\AppFramework\Attribute\Consumable;
/**
* Expected type for each argument contained in the ocm path
*
* @since 33.0.0
*/
#[Consumable(since: '33.0.0')]
enum ParamType: string {
case STRING = 'string';
case INT = 'int';
case FLOAT = 'float';
case BOOL = 'bool';
}

View file

@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\OCM\Events;
use OCP\AppFramework\Attribute\Listenable;
use OCP\EventDispatcher\Event;
use OCP\OCM\IOCMProvider;
/**
* Use this event to register additional resources before the API returns
* them in the OCM provider list and capability
*
* @since 33.0.0
*
*/
#[Listenable(since: '33.0.0')]
class LocalOCMDiscoveryEvent extends Event {
/**
* @param IOCMProvider $provider
* @since 33.0.0
*/
public function __construct(
private readonly IOCMProvider $provider,
) {
parent::__construct();
}
/**
* Add a new OCM capability to the discovery data of local instance
*
* @since 33.0.0
*/
public function addCapability(string $capability): void {
$this->provider->setCapabilities([$capability]);
}
/**
* @param string $name
* @param list<string> $shareTypes List of supported share recipients, e.g. 'user', 'group',
* @param array<string, string> $protocols List of supported protocols and their location,
* e.g. ['webdav' => '/remote.php/webdav/']
* @since 33.0.0
*/
public function registerResourceType(string $name, array $shareTypes, array $protocols): void {
$resourceType = $this->provider->createNewResourceType();
$resourceType->setName($name)
->setShareTypes($shareTypes)
->setProtocols($protocols);
$this->provider->addResourceType($resourceType);
}
}

View file

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\OCM\Events;
use OCP\AppFramework\Attribute\Listenable;
use OCP\AppFramework\Http\Response;
use OCP\EventDispatcher\Event;
use OCP\OCM\Enum\ParamType;
/**
* Use this event to catch and manage incoming OCM request
*
* @since 33.0.0
*/
#[Listenable(since: '33.0.0')]
final class OCMEndpointRequestEvent extends Event {
private ?Response $response = null;
private string $capability = '';
private string $path; // does not start with a slash '/'
/**
* @since 33.0.0
*/
public function __construct(
private readonly string $method,
string $path,
private readonly ?array $payload = null,
private readonly ?string $remote = null,
) {
parent::__construct();
$path = trim($path, '/');
if (!str_contains($path, '/')) {
$this->capability = $path;
$path = '';
} else {
[$this->capability, $path] = explode('/', $path, 2);
}
$this->path = $path ?? '';
}
/**
* returns the first parameter of the sub-path (post-/ocm/) from the request
*
* @since 33.0.0
*/
public function getRequestedCapability(): string {
return $this->capability;
}
/**
* returns the method used
*
* @since 33.0.0
*/
public function getUsedMethod(): string {
return $this->method;
}
/**
* returns the sub-path (post-/ocm/) of the request
* will start with a slash ('/')
*
* @since 33.0.0
*/
public function getPath(): string {
return '/' . $this->path;
}
/**
* Returns the list of parameters from the request, post-'capability'
*
* If no ParamType is specified as parameter of the method, the returned array
* will contain all entries (all string).
*
* If one or multiple ParamType are set:
* - the returned array will contain as many entries as the number of ParamType,
* - each value from the returned array will be typed based on set ParamType,
* - if ParamType cannot be applied (i.e., only alphabetic chars while expecting
* integer), value will be NULL,
* - if missing elements to the request path, missing entries will be NULL,
*
* @since 33.0.0
*/
public function getArgs(ParamType ...$params): array {
if ($this->path === '') {
return [];
}
$args = explode('/', $this->path);
if (empty($params)) {
return $args;
}
$typedArgs = [];
$i = 0;
foreach ($params as $param) {
if (($args[$i] ?? null) === null) {
break;
}
$typedArgs[] = match($param) {
ParamType::STRING => $args[$i],
ParamType::INT => (is_numeric($args[$i]) && ((int)$args[$i] == (float)$args[$i])) ? (int)$args[$i] : null,
ParamType::FLOAT => (is_numeric($args[$i])) ? (float)$args[$i] : null,
ParamType::BOOL => in_array(strtolower($args[$i]), ['1', 'true', 'yes', 'on'], true),
};
$i++;
}
return $typedArgs;
}
/**
* return the number of parameters found in the subpath
*
* @since 33.0.0
*/
public function getArgsCount(): int {
return count($this->getArgs());
}
/**
* returns the payload attached to the request
*
* @since 33.0.0
*/
public function getPayload(): array {
return $this->payload ?? [];
}
/**
* @return bool TRUE if request is signed
* @since 33.0.0
*/
public function isSigned(): bool {
return ($this->getRemote() !== null);
}
/**
* returns the origin of the request, if signed.
*
* @return string|null NULL if request is not authed
* @since 33.0.0
*/
public function getRemote(): ?string {
return $this->remote;
}
/**
* set the Response to the Request to be sent to requester
*
* @since 33.0.0
*/
public function setResponse(Response $response): void {
$this->response = $response;
}
/**
* @since 33.0.0
*/
public function getResponse(): ?Response {
return $this->response;
}
}

View file

@ -16,11 +16,13 @@ use OCP\OCM\IOCMProvider;
* them in the OCM provider list and capability
*
* @since 28.0.0
* @depecated 33.0.0 (use {@see LocalOCMDiscoveryEvent})
*/
class ResourceTypeRegisterEvent extends Event {
/**
* @param IOCMProvider $provider
* @since 28.0.0
* @depecated 33.0.0 (use {@see LocalOCMDiscoveryEvent})
*/
public function __construct(
protected IOCMProvider $provider,
@ -34,6 +36,7 @@ class ResourceTypeRegisterEvent extends Event {
* @param array<string, string> $protocols List of supported protocols and their location,
* e.g. ['webdav' => '/remote.php/webdav/']
* @since 28.0.0
* @depecated 33.0.0 (use {@see LocalOCMDiscoveryEvent})
*/
public function registerResourceType(string $name, array $shareTypes, array $protocols): void {
$resourceType = $this->provider->createNewResourceType();

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\OCM\Exceptions;
use Exception;
/**
* @since 33.0.0
*/
class OCMCapabilityException extends Exception {
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCP\OCM\Exceptions;
use Exception;
/**
* @since 33.0.0
*/
class OCMRequestException extends Exception {
}

View file

@ -12,49 +12,7 @@ namespace OCP\OCM;
* Version 1.1 and 1.2 extensions to the Open Cloud Mesh Discovery API
* @link https://github.com/cs3org/OCM-API/
* @since 32.0.0
* @deprecated 33.0.0 {@see IOCMProvider}
*/
interface ICapabilityAwareOCMProvider extends IOCMProvider {
/**
* get the capabilities
*
* @return array
* @since 32.0.0
*/
public function getCapabilities(): array;
/**
* get the provider name
*
* @return string
* @since 32.0.0
*/
public function getProvider(): string;
/**
* returns the invite accept dialog
*
* @return string
* @since 32.0.0
*/
public function getInviteAcceptDialog(): string;
/**
* set the capabilities
*
* @param array $capabilities
*
* @return $this
* @since 32.0.0
*/
public function setCapabilities(array $capabilities): static;
/**
* set the invite accept dialog
*
* @param string $inviteAcceptDialog
*
* @return $this
* @since 32.0.0
*/
public function setInviteAcceptDialog(string $inviteAcceptDialog): static;
}

View file

@ -9,6 +9,13 @@ declare(strict_types=1);
namespace OCP\OCM;
use NCU\Security\Signature\Exceptions\IncomingRequestException;
use NCU\Security\Signature\IIncomingSignedRequest;
use OCP\AppFramework\Attribute\Consumable;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IResponse;
use OCP\OCM\Events\LocalOCMDiscoveryEvent;
use OCP\OCM\Exceptions\OCMCapabilityException;
use OCP\OCM\Exceptions\OCMProviderException;
/**
@ -16,6 +23,7 @@ use OCP\OCM\Exceptions\OCMProviderException;
*
* @since 28.0.0
*/
#[Consumable(since: '28.0.0')]
interface IOCMDiscoveryService {
/**
* Discover remote OCM services
@ -23,10 +31,70 @@ interface IOCMDiscoveryService {
* @param string $remote address of the remote provider
* @param bool $skipCache ignore cache, refresh data
*
* @return ICapabilityAwareOCMProvider
* @return IOCMProvider
* @throws OCMProviderException if no valid discovery data can be returned
* @since 28.0.0
* @since 32.0.0 returns ICapabilityAwareOCMProvider instead of IOCMProvider
* @since 33.0.0 returns IOCMProvider (rollback)
*/
public function discover(string $remote, bool $skipCache = false): ICapabilityAwareOCMProvider;
public function discover(string $remote, bool $skipCache = false): IOCMProvider;
/**
* return discovery data about local instance.
*
* will generate event {@see LocalOCMDiscoveryEvent} so that 3rd parties can define new resources.
*
* @param bool $fullDetails complete details, including public keys.
* Set to FALSE for client (capabilities) purpose.
*
* @return IOCMProvider
* @since 33.0.0
*/
public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider;
/**
* 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
* @since 33.0.0
*/
public function getIncomingSignedRequest(): ?IIncomingSignedRequest;
/**
* Request a remote OCM endpoint.
*
* Capability can be filtered out
* The final path will be generated based on remote discovery.
*
* @param string|null $capability when not NULL, method will throw
* {@see OCMCapabilityException}
* if remote does not support the capability
* @param string $remote remote ocm cloud id
* @param string $ocmSubPath path to reach, complementing the ocm endpoint extracted
* from remote discovery data
* @param array|null $payload payload attached to the request
* @param string $method method to use ('get', 'post', 'put', 'delete')
* @param IClient|null $client NULL to use default {@see IClient}
* @param array|null $options options related to IClient
* @param bool $signed FALSE to not auth the request
*
* @throws OCMProviderException
* @throws OCMCapabilityException if remote does not support $capability
* @since 33.0.0
*/
public function requestRemoteOcmEndpoint(
?string $capability,
string $remote,
string $ocmSubPath,
?array $payload = null,
string $method = 'get',
?IClient $client = null,
?array $options = null,
bool $signed = true,
): IResponse;
}

View file

@ -9,6 +9,7 @@ declare(strict_types=1);
namespace OCP\OCM;
use JsonSerializable;
use OCP\AppFramework\Attribute\Consumable;
use OCP\OCM\Exceptions\OCMArgumentException;
use OCP\OCM\Exceptions\OCMProviderException;
@ -16,8 +17,8 @@ use OCP\OCM\Exceptions\OCMProviderException;
* Model based on the Open Cloud Mesh Discovery API
* @link https://github.com/cs3org/OCM-API/
* @since 28.0.0
* @deprecated 32.0.0 Please use {@see \OCP\OCM\ICapabilityAwareOCMProvider}
*/
#[Consumable(since: '28.0.0')]
interface IOCMProvider extends JsonSerializable {
/**
* enable OCM
@ -108,6 +109,57 @@ interface IOCMProvider extends JsonSerializable {
*/
public function getResourceTypes(): array;
/**
* get the capabilities
*
* @return array
* @since 33.0.0
*/
public function getCapabilities(): array;
/**
* return if provider supports $capability
*
* @since 33.0.0
*/
public function hasCapability(string $capability): bool;
/**
* get the provider name
*
* @return string
* @since 33.0.0
*/
public function getProvider(): string;
/**
* returns the invite accept dialog
*
* @return string
* @since 33.0.0
*/
public function getInviteAcceptDialog(): string;
/**
* set the capabilities
*
* @param array $capabilities
*
* @return $this
* @since 33.0.0
*/
public function setCapabilities(array $capabilities): static;
/**
* set the invite accept dialog
*
* @param string $inviteAcceptDialog
*
* @return $this
* @since 33.0.0
*/
public function setInviteAcceptDialog(string $inviteAcceptDialog): static;
/**
* extract a specific string value from the listing of protocols, based on resource-name and protocol-name
*

View file

@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\OCM;
use OC\AppFramework\Bootstrap\RegistrationContext;
use OC\OCM\OCMDiscoveryService;
use OCA\CloudFederationAPI\Controller\OCMRequestController;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IConfig;
use OCP\OCM\Events\LocalOCMDiscoveryEvent;
use OCP\OCM\Events\OCMEndpointRequestEvent;
use OCP\Server;
use Psr\Log\LoggerInterface;
use ReflectionClass;
use Test\OCM\Listeners\LocalOCMDiscoveryTestEvent;
use Test\OCM\Listeners\OCMEndpointRequestTestEvent;
use Test\TestCase;
#[\PHPUnit\Framework\Attributes\Group('DB')]
class DiscoveryServiceTest extends TestCase {
private LoggerInterface $logger;
private RegistrationContext $context;
private IEventDispatcher $dispatcher;
private OCMDiscoveryService $discoveryService;
private IConfig $config;
private OCMRequestController $requestController;
protected function setUp(): void {
parent::setUp();
$this->logger = $this->createMock(LoggerInterface::class);
$this->context = Server::get(RegistrationContext::class);
$this->dispatcher = Server::get(IEventDispatcher::class);
$this->discoveryService = Server::get(OCMDiscoveryService::class);
$this->config = Server::get(IConfig::class);
// reset $localProvider value between tests
$reflection = new ReflectionClass($this->discoveryService);
$localProvider = $reflection->getProperty('localProvider');
$localProvider->setValue($this->discoveryService, null);
$this->requestController = Server::get(OCMRequestController::class);
}
public static function dataTestOCMRequest(): array {
return [
['/inexistant-path/', 404, null],
['/ocm-capability-test/', 404, null],
['/ocm-capability-test/get', 200,
[
'capability' => 'ocm-capability-test',
'path' => '/get',
'args' => ['get'],
'totalArgs' => 1,
'typedArgs' => ['get'],
]
],
['/ocm-capability-test/get/10/', 200,
[
'capability' => 'ocm-capability-test',
'path' => '/get/10',
'args' => ['get', '10'],
'totalArgs' => 2,
'typedArgs' => ['get', '10'],
]
],
['/ocm-capability-test/get/random/10/', 200,
[
'capability' => 'ocm-capability-test',
'path' => '/get/random/10',
'args' => ['get', 'random', '10'],
'totalArgs' => 3,
'typedArgs' => ['get', 'random', 10],
]
],
['/ocm-capability-test/get/random/10/1', 200,
[
'capability' => 'ocm-capability-test',
'path' => '/get/random/10/1',
'args' => ['get', 'random', '10', '1'],
'totalArgs' => 4,
'typedArgs' => ['get', 'random', 10, true],
]
],
['/ocm-capability-test/get/random/10/true', 200,
[
'capability' => 'ocm-capability-test',
'path' => '/get/random/10/true',
'args' => ['get', 'random', '10', 'true'],
'totalArgs' => 4,
'typedArgs' => ['get', 'random', 10, true],
]
],
['/ocm-capability-test/get/random/10/true/42', 200,
[
'capability' => 'ocm-capability-test',
'path' => '/get/random/10/true/42',
'args' => ['get', 'random', '10', 'true', '42'],
'totalArgs' => 5,
'typedArgs' => ['get', 'random', 10, true, 42],
]
],
];
}
#[\PHPUnit\Framework\Attributes\DataProvider('dataTestOCMRequest')]
public function testOCMRequest(string $path, int $expectedStatus, ?array $expectedResult): void {
$this->context->for('ocm-request-app')->registerEventListener(OCMEndpointRequestEvent::class, OCMEndpointRequestTestEvent::class);
$this->context->delegateEventListenerRegistrations($this->dispatcher);
$response = $this->requestController->manageOCMRequests($path);
$this->assertSame($expectedStatus, $response->getStatus());
if ($expectedResult !== null) {
$this->assertSame($expectedResult, $response->getData());
}
}
public function testLocalBaseCapability(): void {
$local = $this->discoveryService->getLocalOCMProvider();
$this->assertEmpty(array_diff(['notifications', 'shares'], $local->getCapabilities()));
}
public function testLocalAddedCapability(): void {
$this->context->for('ocm-capability-app')->registerEventListener(LocalOCMDiscoveryEvent::class, LocalOCMDiscoveryTestEvent::class);
$this->context->delegateEventListenerRegistrations($this->dispatcher);
$local = $this->discoveryService->getLocalOCMProvider();
$this->assertEmpty(array_diff(['notifications', 'shares', 'ocm-capability-test'], $local->getCapabilities()));
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\OCM\Listeners;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/** @template-implements IEventListener<\OCP\OCM\Events\LocalOCMDiscoveryEvent> */
class LocalOCMDiscoveryTestEvent implements IEventListener {
public function __construct(
) {
}
public function handle(Event $event): void {
if (!($event instanceof \OCP\OCM\Events\LocalOCMDiscoveryEvent)) {
return;
}
$event->addCapability('ocm-capability-test');
}
}

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\OCM\Listeners;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\Http\Response;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
/** @template-implements IEventListener<\OCP\OCM\Events\OCMEndpointRequestEvent> */
class OCMEndpointRequestTestEvent implements IEventListener {
public function __construct(
) {
}
public function handle(Event $event): void {
if (!($event instanceof \OCP\OCM\Events\OCMEndpointRequestEvent)) {
return;
}
if ($event->getPath() === '/') {
$event->setResponse(new Response(404));
return;
}
$event->setResponse(new DataResponse(
[
'capability' => $event->getRequestedCapability(),
'path' => $event->getPath(),
'args' => $event->getArgs(),
'totalArgs' => $event->getArgsCount(),
'typedArgs' => $event->getArgs(
\OCP\OCM\Enum\ParamType::STRING,
\OCP\OCM\Enum\ParamType::STRING,
\OCP\OCM\Enum\ParamType::INT,
\OCP\OCM\Enum\ParamType::BOOL,
\OCP\OCM\Enum\ParamType::INT
)
]
));
}
}