Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace OCA\CloudFederationAPI\Controller;

use OC\AppFramework\Http\Attributes\FederationRateLimit;
use OC\Federation\SenderDomainAwareCloudFederationNotification;
use OC\OCM\OCMSignatoryManager;
use OCA\CloudFederationAPI\Config;
use OCA\CloudFederationAPI\ResponseDefinitions;
Expand Down Expand Up @@ -270,21 +271,27 @@ public function addShare($shareWith, $name, $description, $providerId, $owner, $
* Send a notification about an existing share
*
* @param string $notificationType Notification type, e.g. SHARE_ACCEPTED
* @param string $resourceType calendar, file, contact,...
* @param string|null $resourceType calendar, file, contact,...
* @param string|null $providerId ID of the share
* @param array<string, mixed>|null $notification The actual payload of the notification
* @param string|null $senderDomain Domain of the sending server
* @param string|null $shareType Type of the recipient the notification is about, e.g. user
*
* @return JSONResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}>
*
* 201: The notification was successfully received
* 400: Bad request due to invalid parameters, e.g. when `type` is invalid or missing
* 403: Getting resource is not allowed
* 501: The resource type is not supported
* 501: The resource type or the notification type is not supported
*/
#[NoCSRFRequired]
#[PublicPage]
#[BruteForceProtection(action: 'receiveFederatedShareNotification')]
public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification) {
public function receiveNotification($notificationType, $resourceType, $providerId, ?array $notification, $senderDomain, $shareType) {
if ($senderDomain !== null) {
return $this->receiveSenderDomainNotification($notificationType, $resourceType, $providerId, $notification, $senderDomain, $shareType);
}

// check if all required parameters are set
if (
$notificationType === null
Expand Down Expand Up @@ -317,6 +324,73 @@ public function receiveNotification($notificationType, $resourceType, $providerI
try {
$provider = $this->cloudFederationProviderManager->getCloudFederationProvider($resourceType);
$result = $provider->notificationReceived($notificationType, $providerId, $notification);
} catch (\Exception $e) {
return $this->notificationExceptionResponse($e);
}

try {
$notificationObject = new SenderDomainAwareCloudFederationNotification(null, null);
$notificationObject->setMessage($notificationType, $resourceType, $providerId, $notification);
$notificationEvent = new OCMNotificationReceivedEvent($notificationObject);
$this->dispatcher->dispatchTyped($notificationEvent);
} catch (\Exception $e) {
$this->logger->warning('error while dispatching OCM notification received event', ['exception' => $e]);
}

return new JSONResponse($result, Http::STATUS_CREATED);
}

/**
* Send a notification with a senderDomain field to listeners of {@see OCMNotificationReceivedEvent}
*
* @param array<string, mixed>|null $notification
* @return JSONResponse<Http::STATUS_CREATED, array<string, mixed>, array{}>|JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}>
*/
private function receiveSenderDomainNotification(?string $notificationType, ?string $resourceType, ?string $providerId, ?array $notification, string $senderDomain, ?string $shareType): JSONResponse {
// check if all required parameters are set
if (
$notificationType === null
|| $senderDomain === ''
|| ($resourceType === null && $shareType === null)
) {
return new JSONResponse(
[
'message' => 'Missing arguments',
'validationErrors' => [],
],
Http::STATUS_BAD_REQUEST
);
}

try {
$this->ocmDiscoveryService->getIncomingSignedRequestFromDomain($senderDomain);
} catch (IncomingRequestException $e) {
$this->logger->warning('incoming request exception', ['exception' => $e]);
return new JSONResponse(['message' => $e->getMessage(), 'validationErrors' => []], Http::STATUS_BAD_REQUEST);
}

$notificationObject = new SenderDomainAwareCloudFederationNotification($senderDomain, $shareType);
$notificationObject->setMessage($notificationType, $resourceType, $providerId, $notification ?? []);
$notificationEvent = new OCMNotificationReceivedEvent($notificationObject);

try {
$this->dispatcher->dispatchTyped($notificationEvent);
$result = $notificationEvent->getResult() ?? throw new ActionNotSupportedException($notificationType);
} catch (\Exception $e) {
return $this->notificationExceptionResponse($e);
}

return new JSONResponse($result, Http::STATUS_CREATED);
}

/**
* Map an exception thrown while handling a notification to its response
*
* @return JSONResponse<Http::STATUS_BAD_REQUEST, CloudFederationAPIValidationError, array{}>|JSONResponse<Http::STATUS_FORBIDDEN|Http::STATUS_NOT_IMPLEMENTED, CloudFederationAPIError, array{}>
*/
private function notificationExceptionResponse(\Exception $e): JSONResponse {
try {
throw $e;
} catch (ProviderDoesNotExistsException $e) {
return new JSONResponse(
[
Expand Down Expand Up @@ -356,17 +430,6 @@ public function receiveNotification($notificationType, $resourceType, $providerI
Http::STATUS_BAD_REQUEST
);
}

try {
$notificationObject = $this->factory->getCloudFederationNotification();
$notificationObject->setMessage($notificationType, $resourceType, $providerId, $notification);
$notificationEvent = new OCMNotificationReceivedEvent($notificationObject);
$this->dispatcher->dispatchTyped($notificationEvent);
} catch (\Exception $e) {
$this->logger->warning('error while dispatching OCM notification received event', ['exception' => $e]);
}

return new JSONResponse($result, Http::STATUS_CREATED);
}

/**
Expand Down
16 changes: 13 additions & 3 deletions apps/cloud_federation_api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,7 @@
"schema": {
"type": "object",
"required": [
"notificationType",
"resourceType"
"notificationType"
],
"properties": {
"notificationType": {
Expand All @@ -254,6 +253,7 @@
},
"resourceType": {
"type": "string",
"nullable": true,
"description": "calendar, file, contact,..."
},
"providerId": {
Expand All @@ -268,6 +268,16 @@
"additionalProperties": {
"type": "object"
}
},
"senderDomain": {
"type": "string",
"nullable": true,
"description": "Domain of the sending server"
},
"shareType": {
"type": "string",
"nullable": true,
"description": "Type of the recipient the notification is about, e.g. user"
}
}
}
Expand Down Expand Up @@ -309,7 +319,7 @@
}
},
"501": {
"description": "The resource type is not supported",
"description": "The resource type or the notification type is not supported",
"content": {
"application/json": {
"schema": {
Expand Down
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@
'OCP\\Federation\\ICloudId' => $baseDir . '/lib/public/Federation/ICloudId.php',
'OCP\\Federation\\ICloudIdManager' => $baseDir . '/lib/public/Federation/ICloudIdManager.php',
'OCP\\Federation\\ICloudIdResolver' => $baseDir . '/lib/public/Federation/ICloudIdResolver.php',
'OCP\\Federation\\ISenderDomainAwareCloudFederationNotification' => $baseDir . '/lib/public/Federation/ISenderDomainAwareCloudFederationNotification.php',
'OCP\\Federation\\ISignedCloudFederationProvider' => $baseDir . '/lib/public/Federation/ISignedCloudFederationProvider.php',
'OCP\\Federation\\IValidationAwareCloudFederationProvider' => $baseDir . '/lib/public/Federation/IValidationAwareCloudFederationProvider.php',
'OCP\\Files' => $baseDir . '/lib/public/Files.php',
Expand Down Expand Up @@ -1896,6 +1897,7 @@
'OC\\Federation\\CloudFederationShare' => $baseDir . '/lib/private/Federation/CloudFederationShare.php',
'OC\\Federation\\CloudId' => $baseDir . '/lib/private/Federation/CloudId.php',
'OC\\Federation\\CloudIdManager' => $baseDir . '/lib/private/Federation/CloudIdManager.php',
'OC\\Federation\\SenderDomainAwareCloudFederationNotification' => $baseDir . '/lib/private/Federation/SenderDomainAwareCloudFederationNotification.php',
'OC\\FilesMetadata\\FilesMetadataManager' => $baseDir . '/lib/private/FilesMetadata/FilesMetadataManager.php',
'OC\\FilesMetadata\\Job\\UpdateSingleMetadata' => $baseDir . '/lib/private/FilesMetadata/Job/UpdateSingleMetadata.php',
'OC\\FilesMetadata\\Listener\\MetadataDelete' => $baseDir . '/lib/private/FilesMetadata/Listener/MetadataDelete.php',
Expand Down
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OCP\\Federation\\ICloudId' => __DIR__ . '/../../..' . '/lib/public/Federation/ICloudId.php',
'OCP\\Federation\\ICloudIdManager' => __DIR__ . '/../../..' . '/lib/public/Federation/ICloudIdManager.php',
'OCP\\Federation\\ICloudIdResolver' => __DIR__ . '/../../..' . '/lib/public/Federation/ICloudIdResolver.php',
'OCP\\Federation\\ISenderDomainAwareCloudFederationNotification' => __DIR__ . '/../../..' . '/lib/public/Federation/ISenderDomainAwareCloudFederationNotification.php',
'OCP\\Federation\\ISignedCloudFederationProvider' => __DIR__ . '/../../..' . '/lib/public/Federation/ISignedCloudFederationProvider.php',
'OCP\\Federation\\IValidationAwareCloudFederationProvider' => __DIR__ . '/../../..' . '/lib/public/Federation/IValidationAwareCloudFederationProvider.php',
'OCP\\Files' => __DIR__ . '/../../..' . '/lib/public/Files.php',
Expand Down Expand Up @@ -1937,6 +1938,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\Federation\\CloudFederationShare' => __DIR__ . '/../../..' . '/lib/private/Federation/CloudFederationShare.php',
'OC\\Federation\\CloudId' => __DIR__ . '/../../..' . '/lib/private/Federation/CloudId.php',
'OC\\Federation\\CloudIdManager' => __DIR__ . '/../../..' . '/lib/private/Federation/CloudIdManager.php',
'OC\\Federation\\SenderDomainAwareCloudFederationNotification' => __DIR__ . '/../../..' . '/lib/private/Federation/SenderDomainAwareCloudFederationNotification.php',
'OC\\FilesMetadata\\FilesMetadataManager' => __DIR__ . '/../../..' . '/lib/private/FilesMetadata/FilesMetadataManager.php',
'OC\\FilesMetadata\\Job\\UpdateSingleMetadata' => __DIR__ . '/../../..' . '/lib/private/FilesMetadata/Job/UpdateSingleMetadata.php',
'OC\\FilesMetadata\\Listener\\MetadataDelete' => __DIR__ . '/../../..' . '/lib/private/FilesMetadata/Listener/MetadataDelete.php',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OC\Federation;

use OCP\Federation\ISenderDomainAwareCloudFederationNotification;

class SenderDomainAwareCloudFederationNotification extends CloudFederationNotification implements ISenderDomainAwareCloudFederationNotification {
public function __construct(
private readonly ?string $senderDomain,
private readonly ?string $shareType,
) {
}

#[\Override]
public function getNotificationType(): string {
return (string)($this->getMessage()['notificationType'] ?? '');
}

#[\Override]
public function getSenderDomain(): ?string {
return $this->senderDomain;
}

#[\Override]
public function getResourceType(): ?string {
return $this->getMessage()['resourceType'] ?? null;
}

#[\Override]
public function getShareType(): ?string {
return $this->shareType;
}

#[\Override]
public function getProviderId(): ?string {
$providerId = $this->getMessage()['providerId'] ?? '';
return $providerId === '' ? null : $providerId;
}

#[\Override]
public function getNotification(): array {
return $this->getMessage()['notification'] ?? [];
}
}
36 changes: 35 additions & 1 deletion lib/private/OCM/OCMDiscoveryService.php
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ public function getLocalOCMProvider(bool $fullDetails = true): IOCMProvider {
try {
// http-sig advertisement requires a jwksUri
$provider->setJwksUri($this->signatoryManager->getLocalJwksUri());
$provider->setCapabilities(['http-sig']);
$provider->setCapabilities(['http-sig', 'notification-sender-domain']);
} catch (IdentityNotFoundException $e) {
$this->logger->warning('cannot build local jwksUri, http-sig capability not advertised', ['exception' => $e]);
}
Expand Down Expand Up @@ -290,6 +290,40 @@ public function getIncomingSignedRequest(?string $ocmAddress = null): ?IIncoming
return null;
}

/**
* @inheritDoc
*
* @throws IncomingRequestException
* @since 36.0.0
*/
#[\Override]
public function getIncomingSignedRequestFromDomain(string $senderDomain): IIncomingSignedRequest {
if ($this->appConfig->getValueBool('core', OCMSignatoryManager::APPCONFIG_SIGN_DISABLED, lazy: true)) {
throw new IncomingRequestException('Signed requests are disabled');
}

try {
$origin = $this->signatureManager->extractIdentityFromUri('https://' . $senderDomain);
$signedRequest = $this->signatureManager->getIncomingSignedRequest($this->signatoryManager, null, $origin);
} catch (IdentityNotFoundException $e) {
throw new IncomingRequestException('invalid sender domain: ' . $senderDomain, previous: $e);
} catch (SignatureNotFoundException|SignatoryNotFoundException $e) {
$this->logger->debug('unsigned request from sender domain', ['exception' => $e]);
throw new IncomingRequestException('Unsigned request', previous: $e);
} catch (SignatureException $e) {
$this->logger->warning('wrongly signed request', ['exception' => $e]);
throw new IncomingRequestException('Invalid signature', previous: $e);
}

if ($signedRequest->getOrigin() !== $origin) {
throw new IncomingRequestException(
'claimed origin ' . $origin . ' does not match signed origin ' . $signedRequest->getOrigin()
);
}

return $signedRequest;
}

/**
* @inheritDoc
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCP\Federation;

use OCP\AppFramework\Attribute\Consumable;

/**
* Notification received on the OCM notifications endpoint.
*
* @link https://github.com/cs3org/OCM-API/blob/v1.5.0/IETF-OCM.md#notifications
* @since 36.0.0
*/
#[Consumable(since: '36.0.0')]
interface ISenderDomainAwareCloudFederationNotification extends ICloudFederationNotification {
/**
* Notification type, e.g. SHARE_ACCEPTED
*
* @since 36.0.0
*/
public function getNotificationType(): string;

/**
* Domain of the sending server, verified against the request signature.
* Null for notifications without a senderDomain field.
*
* @since 36.0.0
*/
public function getSenderDomain(): ?string;

/**
* Type of the resource the notification is about, e.g. file
*
* @since 36.0.0
*/
public function getResourceType(): ?string;

/**
* Type of the recipient the notification is about, e.g. user
*
* @since 36.0.0
*/
public function getShareType(): ?string;

/**
* Identifier assigned by the sending server to the underlying share
*
* @since 36.0.0
* @deprecated 36.0.0 deprecated by the OCM specification, identify the share from {@see getNotification()} instead
*/
public function getProviderId(): ?string;

/**
* Additional parameters, depending on the notification type and the resource type
*
* @return array<string, mixed>
* @since 36.0.0
*/
public function getNotification(): array;
}
Loading
Loading