Merge pull request #63112 from nextcloud/feat/add-federated-calendars-invitation

feat(federation): Add accept/decline mechanism for federated calendars
pull/63124/head
Andy Scherzinger 1 week ago committed by GitHub
commit 8614ca43b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 3
      apps/dav/appinfo/routes.php
  2. 4
      apps/dav/composer/composer/autoload_classmap.php
  3. 4
      apps/dav/composer/composer/autoload_static.php
  4. 2
      apps/dav/lib/AppInfo/Application.php
  5. 6
      apps/dav/lib/BackgroundJob/FederatedCalendarSyncJob.php
  6. 11
      apps/dav/lib/CalDAV/CalDavBackend.php
  7. 7
      apps/dav/lib/CalDAV/Federation/CalendarFederationConfig.php
  8. 27
      apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php
  9. 7
      apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php
  10. 152
      apps/dav/lib/CalDAV/Federation/FederatedCalendarInvitationService.php
  11. 108
      apps/dav/lib/CalDAV/Federation/FederatedCalendarInviteNotifier.php
  12. 49
      apps/dav/lib/CalDAV/Federation/FederatedCalendarMapper.php
  13. 121
      apps/dav/lib/Controller/FederatedCalendarController.php
  14. 64
      apps/dav/lib/Migration/Version1040Date20260805000000.php
  15. 11
      apps/dav/lib/ResponseDefinitions.php
  16. 413
      apps/dav/openapi.json
  17. 47
      apps/dav/tests/unit/CalDAV/CalDavBackendTest.php
  18. 17
      apps/dav/tests/unit/CalDAV/Federation/CalendarFederationConfigTest.php
  19. 189
      apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php
  20. 5
      apps/federation/lib/Command/SyncFederationCalendars.php
  21. 413
      openapi.json

@ -17,6 +17,9 @@ return [
],
'ocs' => [
['name' => 'direct#getUrl', 'url' => '/api/v1/direct', 'verb' => 'POST'],
['name' => 'federated_calendar#getPending', 'url' => '/api/v1/federated_calendars/pending', 'verb' => 'GET'],
['name' => 'federated_calendar#accept', 'url' => '/api/v1/federated_calendars/pending/{id}', 'verb' => 'POST'],
['name' => 'federated_calendar#decline', 'url' => '/api/v1/federated_calendars/pending/{id}', 'verb' => 'DELETE'],
['name' => 'upcoming_events#getEvents', 'url' => '/api/v1/events/upcoming', 'verb' => 'GET'],
['name' => 'out_of_office#getCurrentOutOfOfficeData', 'url' => '/api/v1/outOfOffice/{userId}/now', 'verb' => 'GET'],
['name' => 'out_of_office#getOutOfOffice', 'url' => '/api/v1/outOfOffice/{userId}', 'verb' => 'GET'],

@ -76,6 +76,8 @@ return array(
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarEntity' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarEntity.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarFactory' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarFactory.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarImpl' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarImpl.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarInvitationService' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarInvitationService.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarInviteNotifier' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarInviteNotifier.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarMapper' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarObject' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarObject.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php',
@ -263,6 +265,7 @@ return array(
'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php',
'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php',
'OCA\\DAV\\Controller\\ExampleContentController' => $baseDir . '/../lib/Controller/ExampleContentController.php',
'OCA\\DAV\\Controller\\FederatedCalendarController' => $baseDir . '/../lib/Controller/FederatedCalendarController.php',
'OCA\\DAV\\Controller\\InvitationResponseController' => $baseDir . '/../lib/Controller/InvitationResponseController.php',
'OCA\\DAV\\Controller\\OutOfOfficeController' => $baseDir . '/../lib/Controller/OutOfOfficeController.php',
'OCA\\DAV\\Controller\\UpcomingEventsController' => $baseDir . '/../lib/Controller/UpcomingEventsController.php',
@ -397,6 +400,7 @@ return array(
'OCA\\DAV\\Migration\\Version1036Date20251202000000' => $baseDir . '/../lib/Migration/Version1036Date20251202000000.php',
'OCA\\DAV\\Migration\\Version1038Date20260302000000' => $baseDir . '/../lib/Migration/Version1038Date20260302000000.php',
'OCA\\DAV\\Migration\\Version1039Date20260408000000' => $baseDir . '/../lib/Migration/Version1039Date20260408000000.php',
'OCA\\DAV\\Migration\\Version1040Date20260805000000' => $baseDir . '/../lib/Migration/Version1040Date20260805000000.php',
'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php',

@ -91,6 +91,8 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarEntity' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarEntity.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarFactory' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarFactory.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarImpl' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarImpl.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarInvitationService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarInvitationService.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarInviteNotifier' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarInviteNotifier.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarMapper' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarObject.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php',
@ -278,6 +280,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php',
'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php',
'OCA\\DAV\\Controller\\ExampleContentController' => __DIR__ . '/..' . '/../lib/Controller/ExampleContentController.php',
'OCA\\DAV\\Controller\\FederatedCalendarController' => __DIR__ . '/..' . '/../lib/Controller/FederatedCalendarController.php',
'OCA\\DAV\\Controller\\InvitationResponseController' => __DIR__ . '/..' . '/../lib/Controller/InvitationResponseController.php',
'OCA\\DAV\\Controller\\OutOfOfficeController' => __DIR__ . '/..' . '/../lib/Controller/OutOfOfficeController.php',
'OCA\\DAV\\Controller\\UpcomingEventsController' => __DIR__ . '/..' . '/../lib/Controller/UpcomingEventsController.php',
@ -412,6 +415,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Migration\\Version1036Date20251202000000' => __DIR__ . '/..' . '/../lib/Migration/Version1036Date20251202000000.php',
'OCA\\DAV\\Migration\\Version1038Date20260302000000' => __DIR__ . '/..' . '/../lib/Migration/Version1038Date20260302000000.php',
'OCA\\DAV\\Migration\\Version1039Date20260408000000' => __DIR__ . '/..' . '/../lib/Migration/Version1039Date20260408000000.php',
'OCA\\DAV\\Migration\\Version1040Date20260805000000' => __DIR__ . '/..' . '/../lib/Migration/Version1040Date20260805000000.php',
'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php',
'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php',

@ -15,6 +15,7 @@ use OCA\DAV\CalDAV\CachedSubscriptionProvider;
use OCA\DAV\CalDAV\CalendarManager;
use OCA\DAV\CalDAV\CalendarProvider;
use OCA\DAV\CalDAV\Federation\CalendarFederationProvider;
use OCA\DAV\CalDAV\Federation\FederatedCalendarInviteNotifier;
use OCA\DAV\CalDAV\Reminder\NotificationProvider\AudioProvider;
use OCA\DAV\CalDAV\Reminder\NotificationProvider\EmailProvider;
use OCA\DAV\CalDAV\Reminder\NotificationProvider\PushProvider;
@ -226,6 +227,7 @@ class Application extends App implements IBootstrap {
$context->registerNotifierService(NotifierCalDAV::class);
$context->registerNotifierService(NotifierCardDAV::class);
$context->registerNotifierService(FederatedCalendarInviteNotifier::class);
$context->registerCalendarProvider(CalendarProvider::class);
$context->registerCalendarProvider(CachedSubscriptionProvider::class);

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace OCA\DAV\BackgroundJob;
use OCA\DAV\CalDAV\Federation\CalendarFederationConfig;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService;
use OCP\AppFramework\Db\DoesNotExistException;
@ -51,6 +52,11 @@ class FederatedCalendarSyncJob extends QueuedJob {
return;
}
// Never fetch data for invitations the sharee did not accept (yet)
if ($calendar->getState() !== FederatedCalendarEntity::STATE_ACCEPTED) {
return;
}
try {
$this->syncService->syncOne($calendar);
} catch (ClientExceptionInterface $e) {

@ -4083,7 +4083,10 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
* @return array<string, mixed>[]
*/
public function getFederatedCalendarsForUser(string $principalUri): array {
$federatedCalendars = $this->federatedCalendarMapper->findByPrincipalUri($principalUri);
$federatedCalendars = $this->federatedCalendarMapper->findByPrincipalUri(
$principalUri,
FederatedCalendarEntity::STATE_ACCEPTED,
);
return array_map(
static fn (FederatedCalendarEntity $entity) => $entity->toCalendarInfo(),
$federatedCalendars,
@ -4092,6 +4095,10 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
public function getFederatedCalendarByUri(string $principalUri, string $uri): ?array {
$federatedCalendar = $this->federatedCalendarMapper->findByUri($principalUri, $uri);
return $federatedCalendar?->toCalendarInfo();
if ($federatedCalendar === null
|| $federatedCalendar->getState() !== FederatedCalendarEntity::STATE_ACCEPTED) {
return null;
}
return $federatedCalendar->toCalendarInfo();
}
}

@ -42,4 +42,11 @@ class CalendarFederationConfig {
}
return $this->appConfig->getValueBool('files_sharing', 'incoming_server2server_share_enabled', true);
}
/**
* Check if incoming shares from trusted servers are accepted automatically
*/
public function isTrustedShareAutoAcceptEnabled(): bool {
return $this->appConfig->getValueBool('files_sharing', 'federatedTrustedShareAutoAccept', true);
}
}

@ -37,6 +37,7 @@ class CalendarFederationProvider implements ICloudFederationProvider, IValidatio
private readonly CalendarFederationConfig $calendarFederationConfig,
private readonly IJobList $jobList,
private readonly ICloudIdManager $cloudIdManager,
private readonly FederatedCalendarInvitationService $invitationService,
) {
}
@ -121,8 +122,8 @@ class CalendarFederationProvider implements ICloudFederationProvider, IValidatio
$sharedWithPrincipal = 'principals/users/' . $share->getShareWith();
// Delete existing incoming federated share first
$calendar = $this->federatedCalendarMapper->findByUri($sharedWithPrincipal, $calendarUri);
$isNew = $calendar === null;
if ($calendar === null) {
$calendar = new FederatedCalendarEntity();
@ -136,17 +137,34 @@ class CalendarFederationProvider implements ICloudFederationProvider, IValidatio
$calendar->setSharedByDisplayName($share->getSharedByDisplayName());
$calendar->setPermissions($permissions);
$calendar->setComponents($components);
$calendar->setState(FederatedCalendarEntity::STATE_PENDING);
$calendar = $this->federatedCalendarMapper->insert($calendar);
} else {
$calendar->setToken($share->getShareSecret());
$calendar->setPermissions($permissions);
$calendar->setComponents($components);
if ($calendar->getState() === FederatedCalendarEntity::STATE_PENDING) {
// The open invitation shows the sharer's metadata, keep it
// fresh. Accepted calendars are not touched as the sharee owns
// the display name and color from that point on.
$calendar->setDisplayName($displayName);
$calendar->setColor($color);
$calendar->setSharedByDisplayName($share->getSharedByDisplayName());
}
$this->federatedCalendarMapper->update($calendar);
}
$this->jobList->add(FederatedCalendarSyncJob::class, [
FederatedCalendarSyncJob::ARGUMENT_ID => $calendar->getId(),
]);
if ($calendar->getState() === FederatedCalendarEntity::STATE_ACCEPTED) {
// Re-share of an already accepted calendar: just refresh the data
$this->jobList->add(FederatedCalendarSyncJob::class, [
FederatedCalendarSyncJob::ARGUMENT_ID => $calendar->getId(),
]);
} elseif ($this->invitationService->shouldAutoAccept($share->getOwner(), $calendar->getRemoteUrl())) {
$this->invitationService->accept($calendar);
} elseif ($isNew) {
// A re-shared pending calendar keeps its original invitation
$this->invitationService->notifyAboutNewShare($calendar);
}
return (string)$calendar->getId();
}
@ -204,6 +222,7 @@ class CalendarFederationProvider implements ICloudFederationProvider, IValidatio
$calendarUrl,
'principals/users/' . $shareWith->getUser(),
$sharedSecret,
FederatedCalendarEntity::STATE_ACCEPTED,
);
if (empty($calendars)) {
throw new ShareNotFound('Calendar is not shared with the sharee');

@ -40,8 +40,13 @@ use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
* @method void setSharedByDisplayName(string $sharedByDisplayName)
* @method string getComponents()
* @method void setComponents(string $components)
* @method int getState()
* @method void setState(int $state)
*/
class FederatedCalendarEntity extends Entity {
public const STATE_PENDING = 0;
public const STATE_ACCEPTED = 1;
protected string $principaluri = '';
protected string $uri = '';
protected string $displayName = '';
@ -54,6 +59,7 @@ class FederatedCalendarEntity extends Entity {
protected string $sharedBy = '';
protected string $sharedByDisplayName = '';
protected string $components = '';
protected int $state = self::STATE_PENDING;
public function __construct() {
$this->addType('principaluri', Types::STRING);
@ -68,6 +74,7 @@ class FederatedCalendarEntity extends Entity {
$this->addType('sharedBy', Types::STRING);
$this->addType('sharedByDisplayName', Types::STRING);
$this->addType('components', Types::STRING);
$this->addType('state', Types::INTEGER);
}
public function getSyncTokenForSabre(): string {

@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\BackgroundJob\FederatedCalendarSyncJob;
use OCA\Federation\TrustedServers;
use OCP\App\IAppManager;
use OCP\BackgroundJob\IJobList;
use OCP\Federation\ICloudIdManager;
use OCP\IURLGenerator;
use OCP\Notification\IManager as INotificationManager;
use OCP\Server;
use Psr\Log\LoggerInterface;
class FederatedCalendarInvitationService {
public const NOTIFICATION_OBJECT_TYPE = 'federated_calendar_share';
public const NOTIFICATION_SUBJECT_NEW_SHARE = 'federated_calendar_share';
private const PRINCIPAL_PREFIX = 'principals/users/';
public function __construct(
private readonly FederatedCalendarMapper $mapper,
private readonly IJobList $jobList,
private readonly INotificationManager $notificationManager,
private readonly IURLGenerator $url,
private readonly ICloudIdManager $cloudIdManager,
private readonly CalendarFederationConfig $config,
private readonly IAppManager $appManager,
private readonly LoggerInterface $logger,
) {
}
public function accept(FederatedCalendarEntity $calendar): void {
$calendar->setState(FederatedCalendarEntity::STATE_ACCEPTED);
$this->mapper->update($calendar);
$this->jobList->add(FederatedCalendarSyncJob::class, [
FederatedCalendarSyncJob::ARGUMENT_ID => $calendar->getId(),
]);
$this->dismissNotification($calendar);
}
public function decline(FederatedCalendarEntity $calendar): void {
// Dismiss first: a leftover bell notification would offer actions for
// a share that no longer exists
$this->dismissNotification($calendar);
$this->mapper->deleteById($calendar->getId());
}
public function notifyAboutNewShare(FederatedCalendarEntity $calendar): void {
$notification = $this->notificationManager->createNotification();
$notification->setApp(Application::APP_ID)
->setUser($this->getUserId($calendar))
->setDateTime(new \DateTime())
->setObject(self::NOTIFICATION_OBJECT_TYPE, (string)$calendar->getId())
->setSubject(self::NOTIFICATION_SUBJECT_NEW_SHARE, [
'sharedBy' => $calendar->getSharedBy(),
'sharedByDisplayName' => $calendar->getSharedByDisplayName(),
'calendarName' => $calendar->getDisplayName(),
]);
$endpointUrl = $this->url->linkToOCSRouteAbsolute('dav.federated_calendar.accept', [
'id' => $calendar->getId(),
]);
$declineAction = $notification->createAction();
$declineAction->setLabel('decline')
->setLink($endpointUrl, 'DELETE');
$notification->addAction($declineAction);
$acceptAction = $notification->createAction();
$acceptAction->setLabel('accept')
->setLink($endpointUrl, 'POST');
$notification->addAction($acceptAction);
$this->notificationManager->notify($notification);
}
public function dismissNotification(FederatedCalendarEntity $calendar): void {
$notification = $this->notificationManager->createNotification();
$notification->setApp(Application::APP_ID)
->setUser($this->getUserId($calendar))
->setObject(self::NOTIFICATION_OBJECT_TYPE, (string)$calendar->getId());
$this->notificationManager->markProcessed($notification);
}
public function shouldAutoAccept(?string $ownerCloudId, string $remoteUrl): bool {
if ($ownerCloudId === null || $ownerCloudId === '') {
return false;
}
if (!$this->config->isTrustedShareAutoAcceptEnabled()) {
return false;
}
$trustedServers = $this->getTrustedServers();
if ($trustedServers === null) {
return false;
}
try {
$remote = $this->cloudIdManager->resolveCloudId($ownerCloudId)->getRemote();
} catch (\InvalidArgumentException) {
return false;
}
if (!$trustedServers->isTrustedServer($remote)) {
return false;
}
return $this->isSameHost($remote, $remoteUrl);
}
private function isSameHost(string $remote, string $remoteUrl): bool {
$remoteParts = parse_url(str_contains($remote, '://') ? $remote : '//' . $remote);
$urlParts = parse_url($remoteUrl);
if (!is_array($remoteParts) || !is_array($urlParts)
|| !isset($remoteParts['host'], $urlParts['host'])) {
return false;
}
return strcasecmp($remoteParts['host'], $urlParts['host']) === 0
&& ($remoteParts['port'] ?? null) === ($urlParts['port'] ?? null);
}
protected function getTrustedServers(): ?TrustedServers {
if (!$this->appManager->isEnabledForAnyone('federation')
|| !class_exists(TrustedServers::class)) {
return null;
}
try {
return Server::get(TrustedServers::class);
} catch (\Throwable $e) {
$this->logger->debug('Failed to create TrustedServers', ['exception' => $e]);
return null;
}
}
private function getUserId(FederatedCalendarEntity $calendar): string {
return substr($calendar->getPrincipaluri(), strlen(self::PRINCIPAL_PREFIX));
}
}

@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\AppInfo\Application;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\L10N\IFactory;
use OCP\Notification\INotification;
use OCP\Notification\INotifier;
use OCP\Notification\UnknownNotificationException;
class FederatedCalendarInviteNotifier implements INotifier {
public function __construct(
private readonly IFactory $l10nFactory,
private readonly IURLGenerator $url,
) {
}
#[\Override]
public function getID(): string {
return Application::APP_ID;
}
#[\Override]
public function getName(): string {
return $this->l10nFactory->get(Application::APP_ID)->t('Calendar federation');
}
/**
* @inheritDoc
*/
#[\Override]
public function prepare(INotification $notification, string $languageCode): INotification {
if ($notification->getApp() !== Application::APP_ID
|| $notification->getObjectType() !== FederatedCalendarInvitationService::NOTIFICATION_OBJECT_TYPE) {
throw new UnknownNotificationException();
}
$l = $this->l10nFactory->get(Application::APP_ID, $languageCode);
return match ($notification->getSubject()) {
FederatedCalendarInvitationService::NOTIFICATION_SUBJECT_NEW_SHARE
=> $this->parseNewShare($notification, $l),
default => throw new UnknownNotificationException(),
};
}
private function parseNewShare(INotification $notification, IL10N $l): INotification {
$params = $notification->getSubjectParameters();
$sharedBy = $params['sharedBy'] ?? '';
$sharedByDisplayName = $params['sharedByDisplayName'] ?? '';
$calendarName = $params['calendarName'] ?? '';
$notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar.svg')));
$notification->setRichSubject(
$l->t('{user} has shared the calendar {calendar} with you'),
[
'calendar' => [
'type' => 'calendar',
'id' => $notification->getObjectId(),
'name' => $calendarName,
],
'user' => $this->createRemoteUser($sharedBy, $sharedByDisplayName),
],
);
foreach ($notification->getActions() as $action) {
switch ($action->getLabel()) {
case 'accept':
$action->setParsedLabel($l->t('Accept'))
->setPrimary(true);
break;
case 'decline':
$action->setParsedLabel($l->t('Decline'));
break;
}
$notification->addParsedAction($action);
}
return $notification;
}
private function createRemoteUser(string $cloudId, string $displayName): array {
$user = $cloudId;
$server = '';
$atPos = strrpos($cloudId, '@');
if ($atPos !== false) {
$user = substr($cloudId, 0, $atPos);
$server = substr($cloudId, $atPos + 1);
}
return [
'type' => 'user',
'id' => $user,
'name' => $displayName !== '' ? $displayName : $user,
'server' => $server,
];
}
}

@ -43,9 +43,10 @@ class FederatedCalendarMapper extends QBMapper {
}
/**
* @param int|null $state Only return calendars in the given invitation state. Null returns all.
* @return FederatedCalendarEntity[]
*/
public function findByPrincipalUri(string $principalUri): array {
public function findByPrincipalUri(string $principalUri, ?int $state = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from(self::TABLE_NAME)
@ -54,6 +55,13 @@ class FederatedCalendarMapper extends QBMapper {
$qb->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
));
if ($state !== null) {
$qb->andWhere($qb->expr()->eq(
'state',
$qb->createNamedParameter($state, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
));
}
return $this->findEntities($qb);
}
@ -95,7 +103,12 @@ class FederatedCalendarMapper extends QBMapper {
IQueryBuilder::PARAM_INT,
))
// Omit unsynced calendars for now as they are synced by a separate job
->andWhere($qb->expr()->isNotNull('last_sync'));
->andWhere($qb->expr()->isNotNull('last_sync'))
->andWhere($qb->expr()->eq(
'state',
$qb->createNamedParameter(FederatedCalendarEntity::STATE_ACCEPTED, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
));
return $this->findEntities($qb);
}
@ -140,12 +153,20 @@ class FederatedCalendarMapper extends QBMapper {
}
/**
* @param int|null $state Only return calendars in the given invitation state. Null returns all.
* @return \Generator<mixed, FederatedCalendarEntity>
*/
public function findAll(): \Generator {
public function findAll(?int $state = null): \Generator {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from(self::TABLE_NAME);
if ($state !== null) {
$qb->where($qb->expr()->eq(
'state',
$qb->createNamedParameter($state, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
));
}
$result = $qb->executeQuery();
while ($row = $result->fetchAssociative()) {
@ -154,10 +175,20 @@ class FederatedCalendarMapper extends QBMapper {
$result->closeCursor();
}
public function countAll(): int {
/**
* @param int|null $state Only count calendars in the given invitation state. Null counts all.
*/
public function countAll(?int $state = null): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*'))
->from(self::TABLE_NAME);
if ($state !== null) {
$qb->where($qb->expr()->eq(
'state',
$qb->createNamedParameter($state, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
));
}
$result = $qb->executeQuery();
$count = (int)$result->fetchOne();
$result->closeCursor();
@ -182,9 +213,10 @@ class FederatedCalendarMapper extends QBMapper {
}
/**
* @param int|null $state Only return calendars in the given invitation state. Null returns all.
* @return FederatedCalendarEntity[]
*/
public function findByRemoteUrl(string $remoteUrl, string $principalUri, string $token): array {
public function findByRemoteUrl(string $remoteUrl, string $principalUri, string $token, ?int $state = null): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from(self::TABLE_NAME)
@ -203,6 +235,13 @@ class FederatedCalendarMapper extends QBMapper {
$qb->createNamedParameter($token, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
));
if ($state !== null) {
$qb->andWhere($qb->expr()->eq(
'state',
$qb->createNamedParameter($state, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
));
}
return $this->findEntities($qb);
}

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Controller;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\CalDAV\Federation\FederatedCalendarInvitationService;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\ResponseDefinitions;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\AppFramework\OCS\OCSNotFoundException;
use OCP\AppFramework\OCSController;
use OCP\IRequest;
/**
* @psalm-import-type DAVPendingFederatedCalendar from ResponseDefinitions
*/
class FederatedCalendarController extends OCSController {
public function __construct(
IRequest $request,
private readonly ?string $userId,
private readonly FederatedCalendarMapper $mapper,
private readonly FederatedCalendarInvitationService $invitationService,
) {
parent::__construct(Application::APP_ID, $request);
}
/**
* Get the pending federated calendar invitations of the current user
*
* @return DataResponse<Http::STATUS_OK, list<DAVPendingFederatedCalendar>, array{}>|DataResponse<Http::STATUS_UNAUTHORIZED, null, array{}>
*
* 200: Pending federated calendar invitations
* 401: When the user is not logged in
*/
#[NoAdminRequired]
public function getPending(): DataResponse {
if ($this->userId === null) {
return new DataResponse(null, Http::STATUS_UNAUTHORIZED);
}
$calendars = $this->mapper->findByPrincipalUri(
'principals/users/' . $this->userId,
FederatedCalendarEntity::STATE_PENDING,
);
return new DataResponse(array_values(array_map(static fn (FederatedCalendarEntity $calendar) => [
'id' => $calendar->getId(),
'displayName' => $calendar->getDisplayName(),
'color' => $calendar->getColor(),
'sharedBy' => $calendar->getSharedBy(),
'sharedByDisplayName' => $calendar->getSharedByDisplayName(),
'remoteUrl' => $calendar->getRemoteUrl(),
'permissions' => $calendar->getPermissions(),
'components' => $calendar->getComponents(),
], $calendars)));
}
/**
* Accept a pending federated calendar invitation
*
* @param int $id The id of the pending federated calendar
* @return DataResponse<Http::STATUS_OK, null, array{}>
* @throws OCSNotFoundException The federated calendar was not found
*
* 200: Invitation accepted successfully
*/
#[NoAdminRequired]
public function accept(int $id): DataResponse {
$this->invitationService->accept($this->getPendingCalendar($id));
return new DataResponse(null);
}
/**
* Decline a pending federated calendar invitation
*
* @param int $id The id of the pending federated calendar
* @return DataResponse<Http::STATUS_OK, null, array{}>
* @throws OCSNotFoundException The federated calendar was not found
*
* 200: Invitation declined successfully
*/
#[NoAdminRequired]
public function decline(int $id): DataResponse {
$this->invitationService->decline($this->getPendingCalendar($id));
return new DataResponse(null);
}
/**
* @throws OCSNotFoundException
*/
private function getPendingCalendar(int $id): FederatedCalendarEntity {
if ($this->userId === null) {
throw new OCSNotFoundException();
}
try {
$calendar = $this->mapper->find($id);
} catch (DoesNotExistException) {
throw new OCSNotFoundException();
}
// Also return 404 for foreign calendars to not leak their existence
if ($calendar->getPrincipaluri() !== 'principals/users/' . $this->userId
|| $calendar->getState() !== FederatedCalendarEntity::STATE_PENDING) {
throw new OCSNotFoundException();
}
return $calendar;
}
}

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Migration;
use Closure;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCP\DB\ISchemaWrapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\DB\Types;
use OCP\IDBConnection;
use OCP\Migration\Attributes\AddColumn;
use OCP\Migration\Attributes\ColumnType;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
#[AddColumn(table: 'calendars_federated', name: 'state', type: ColumnType::INTEGER, description: 'Invitation state of the incoming federated calendar share (0 = pending, 1 = accepted)')]
class Version1040Date20260805000000 extends SimpleMigrationStep {
private bool $backfillState = false;
public function __construct(
private readonly IDBConnection $connection,
) {
}
#[\Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$table = $schema->getTable('calendars_federated');
if (!$table->hasColumn('state')) {
$table->addColumn('state', Types::INTEGER, [
'notnull' => true,
'unsigned' => true,
'default' => FederatedCalendarEntity::STATE_PENDING,
]);
$this->backfillState = true;
}
return $schema;
}
#[\Override]
public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void {
if (!$this->backfillState) {
return;
}
// Calendars shared before the invitation flow was introduced were
// never explicitly accepted, keep them visible
$qb = $this->connection->getQueryBuilder();
$qb->update('calendars_federated')
->set('state', $qb->createNamedParameter(FederatedCalendarEntity::STATE_ACCEPTED, IQueryBuilder::PARAM_INT));
$qb->executeStatement();
}
}

@ -42,6 +42,17 @@ use OCA\DAV\CalDAV\UpcomingEvent;
* summary: ?string,
* location: ?string,
* }
*
* @psalm-type DAVPendingFederatedCalendar = array{
* id: int,
* displayName: string,
* color: ?string,
* sharedBy: string,
* sharedByDisplayName: string,
* remoteUrl: string,
* permissions: int,
* components: string,
* }
*/
class ResponseDefinitions {
}

@ -176,6 +176,48 @@
}
}
},
"PendingFederatedCalendar": {
"type": "object",
"required": [
"id",
"displayName",
"color",
"sharedBy",
"sharedByDisplayName",
"remoteUrl",
"permissions",
"components"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"displayName": {
"type": "string"
},
"color": {
"type": "string",
"nullable": true
},
"sharedBy": {
"type": "string"
},
"sharedByDisplayName": {
"type": "string"
},
"remoteUrl": {
"type": "string"
},
"permissions": {
"type": "integer",
"format": "int64"
},
"components": {
"type": "string"
}
}
},
"UpcomingEvent": {
"type": "object",
"required": [
@ -416,6 +458,377 @@
}
}
},
"/ocs/v2.php/apps/dav/api/v1/federated_calendars/pending": {
"get": {
"operationId": "federated_calendar-get-pending",
"summary": "Get the pending federated calendar invitations of the current user",
"tags": [
"federated_calendar"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Pending federated calendar invitations",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PendingFederatedCalendar"
}
}
}
}
}
}
}
}
},
"401": {
"description": "When the user is not logged in",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
},
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
]
}
}
}
}
}
}
},
"/ocs/v2.php/apps/dav/api/v1/federated_calendars/pending/{id}": {
"post": {
"operationId": "federated_calendar-accept",
"summary": "Accept a pending federated calendar invitation",
"tags": [
"federated_calendar"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"description": "The id of the pending federated calendar",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Invitation accepted successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"404": {
"description": "The federated calendar was not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"delete": {
"operationId": "federated_calendar-decline",
"summary": "Decline a pending federated calendar invitation",
"tags": [
"federated_calendar"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"description": "The id of the pending federated calendar",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Invitation declined successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"404": {
"description": "The federated calendar was not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/dav/api/v1/events/upcoming": {
"get": {
"operationId": "upcoming_events-get-events",

@ -15,6 +15,7 @@ use DateTimeImmutable;
use DateTimeZone;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Calendar;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\DAV\Sharing\Plugin as SharingPlugin;
use OCA\DAV\Events\CalendarDeletedEvent;
use OCA\DAV\Exception\UidConflict;
@ -2430,4 +2431,50 @@ EOD;
private function assertStringEqualsStringIgnoringLineEndingsWithTrim(string $expected, string $actual, string $message = ''): void {
$this->assertStringEqualsStringIgnoringLineEndings(trim($expected), trim($actual), $message);
}
private function buildFederatedCalendarEntity(int $state): FederatedCalendarEntity {
$entity = new FederatedCalendarEntity();
$entity->setId(10);
$entity->setPrincipaluri(self::UNIT_TEST_USER);
$entity->setUri('federated-cal');
$entity->setDisplayName('Federated calendar');
$entity->setComponents('VEVENT');
$entity->setSharedBy('sharer@nextcloud.remote');
$entity->setState($state);
return $entity;
}
public function testGetFederatedCalendarsForUserOnlyQueriesAcceptedCalendars(): void {
$entity = $this->buildFederatedCalendarEntity(FederatedCalendarEntity::STATE_ACCEPTED);
$this->federatedCalendarMapper->expects(self::once())
->method('findByPrincipalUri')
->with(self::UNIT_TEST_USER, FederatedCalendarEntity::STATE_ACCEPTED)
->willReturn([$entity]);
$calendars = $this->backend->getFederatedCalendarsForUser(self::UNIT_TEST_USER);
$this->assertCount(1, $calendars);
$this->assertSame($entity->getId(), $calendars[0]['id']);
}
public function testGetFederatedCalendarByUriReturnsAcceptedCalendar(): void {
$entity = $this->buildFederatedCalendarEntity(FederatedCalendarEntity::STATE_ACCEPTED);
$this->federatedCalendarMapper->expects(self::once())
->method('findByUri')
->with(self::UNIT_TEST_USER, 'federated-cal')
->willReturn($entity);
$calendarInfo = $this->backend->getFederatedCalendarByUri(self::UNIT_TEST_USER, 'federated-cal');
$this->assertNotNull($calendarInfo);
$this->assertSame($entity->getId(), $calendarInfo['id']);
}
public function testGetFederatedCalendarByUriHidesPendingCalendar(): void {
$entity = $this->buildFederatedCalendarEntity(FederatedCalendarEntity::STATE_PENDING);
$this->federatedCalendarMapper->expects(self::once())
->method('findByUri')
->with(self::UNIT_TEST_USER, 'federated-cal')
->willReturn($entity);
$this->assertNull($this->backend->getFederatedCalendarByUri(self::UNIT_TEST_USER, 'federated-cal'));
}
}

@ -114,4 +114,21 @@ class CalendarFederationConfigTest extends TestCase {
$this->assertEquals($expected, $this->config->isIncomingServer2serverShareEnabled());
}
public static function provideIsTrustedShareAutoAcceptEnabledData(): array {
return [
[true],
[false],
];
}
#[DataProvider(methodName: 'provideIsTrustedShareAutoAcceptEnabledData')]
public function testIsTrustedShareAutoAcceptEnabled(bool $configValue): void {
$this->appConfig->expects(self::once())
->method('getValueBool')
->with('files_sharing', 'federatedTrustedShareAutoAccept', true)
->willReturn($configValue);
$this->assertEquals($configValue, $this->config->isTrustedShareAutoAcceptEnabled());
}
}

@ -14,6 +14,7 @@ use OCA\DAV\BackgroundJob\FederatedCalendarSyncJob;
use OCA\DAV\CalDAV\Federation\CalendarFederationConfig;
use OCA\DAV\CalDAV\Federation\CalendarFederationProvider;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\CalDAV\Federation\FederatedCalendarInvitationService;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCP\BackgroundJob\IJobList;
use OCP\Federation\Exceptions\BadRequestException;
@ -35,6 +36,7 @@ class CalendarFederationProviderTest extends TestCase {
private CalendarFederationConfig&MockObject $calendarFederationConfig;
private IJobList&MockObject $jobList;
private ICloudIdManager&MockObject $cloudIdManager;
private FederatedCalendarInvitationService&MockObject $invitationService;
protected function setUp(): void {
parent::setUp();
@ -44,6 +46,7 @@ class CalendarFederationProviderTest extends TestCase {
$this->calendarFederationConfig = $this->createMock(CalendarFederationConfig::class);
$this->jobList = $this->createMock(JobList::class);
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
$this->invitationService = $this->createMock(FederatedCalendarInvitationService::class);
$this->calendarFederationProvider = new CalendarFederationProvider(
$this->logger,
@ -51,6 +54,7 @@ class CalendarFederationProviderTest extends TestCase {
$this->calendarFederationConfig,
$this->jobList,
$this->cloudIdManager,
$this->invitationService,
);
}
@ -82,6 +86,8 @@ class CalendarFederationProviderTest extends TestCase {
->willReturn('sharee1');
$share->method('getShareSecret')
->willReturn('token');
$share->method('getOwner')
->willReturn('user1@nextcloud.remote');
$share->method('getSharedBy')
->willReturn('user1@nextcloud.remote');
$share->method('getSharedByDisplayName')
@ -116,14 +122,92 @@ class CalendarFederationProviderTest extends TestCase {
$this->assertEquals('User 1', $calendar->getSharedByDisplayName());
$this->assertEquals(1, $calendar->getPermissions());
$this->assertEquals('VEVENT,VTODO', $calendar->getComponents());
$this->assertEquals(FederatedCalendarEntity::STATE_PENDING, $calendar->getState());
$calendar->setId(10);
return $calendar;
});
$this->jobList->expects(self::once())
->method('add')
->with(FederatedCalendarSyncJob::class, ['id' => 10]);
$this->invitationService->expects(self::once())
->method('shouldAutoAccept')
->with(
'user1@nextcloud.remote',
'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1',
)
->willReturn(false);
$this->invitationService->expects(self::once())
->method('notifyAboutNewShare')
->with(self::callback(
static fn (FederatedCalendarEntity $calendar) => $calendar->getId() === 10,
));
$this->invitationService->expects(self::never())
->method('accept');
$this->jobList->expects(self::never())
->method('add');
$this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
}
public function testShareReceivedWithAutoAccept(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
->willReturn('user');
$share->method('getProtocol')
->willReturn([
'version' => 'v1',
'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1',
'displayName' => 'Calendar 1',
'color' => '#ff0000',
'access' => 3,
'components' => 'VEVENT,VTODO',
]);
$share->method('getShareWith')
->willReturn('sharee1');
$share->method('getShareSecret')
->willReturn('token');
$share->method('getOwner')
->willReturn('user1@nextcloud.remote');
$share->method('getSharedBy')
->willReturn('user1@nextcloud.remote');
$share->method('getSharedByDisplayName')
->willReturn('User 1');
$this->calendarFederationConfig->expects(self::once())
->method('isFederationEnabled')
->willReturn(true);
$this->calendarFederationConfig->expects(self::once())
->method('isIncomingServer2serverShareEnabled')
->willReturn(true);
$this->federatedCalendarMapper->expects(self::once())
->method('findByUri')
->willReturn(null);
$this->federatedCalendarMapper->expects(self::once())
->method('insert')
->willReturnCallback(function (FederatedCalendarEntity $calendar) {
$this->assertEquals(FederatedCalendarEntity::STATE_PENDING, $calendar->getState());
$calendar->setId(10);
return $calendar;
});
$this->invitationService->expects(self::once())
->method('shouldAutoAccept')
->with(
'user1@nextcloud.remote',
'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1',
)
->willReturn(true);
$this->invitationService->expects(self::once())
->method('accept')
->with(self::callback(
static fn (FederatedCalendarEntity $calendar) => $calendar->getId() === 10,
));
$this->invitationService->expects(self::never())
->method('notifyAboutNewShare');
$this->jobList->expects(self::never())
->method('add');
$this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
}
@ -166,6 +250,7 @@ class CalendarFederationProviderTest extends TestCase {
$existingCalendar->setToken('old-token');
$existingCalendar->setPermissions(1);
$existingCalendar->setComponents('VEVENT');
$existingCalendar->setState(FederatedCalendarEntity::STATE_ACCEPTED);
$this->federatedCalendarMapper->expects(self::once())
->method('findByUri')
@ -184,9 +269,19 @@ class CalendarFederationProviderTest extends TestCase {
$this->assertEquals('new-token', $calendar->getToken());
$this->assertEquals(1, $calendar->getPermissions());
$this->assertEquals('VEVENT,VTODO', $calendar->getComponents());
$this->assertEquals(FederatedCalendarEntity::STATE_ACCEPTED, $calendar->getState());
// Accepted calendars are owned by the sharee, the sharer's
// metadata must not overwrite them
$this->assertEquals('', $calendar->getDisplayName());
return $calendar;
});
$this->invitationService->expects(self::never())
->method('shouldAutoAccept');
$this->invitationService->expects(self::never())
->method('accept');
$this->invitationService->expects(self::never())
->method('notifyAboutNewShare');
$this->jobList->expects(self::once())
->method('add')
->with(FederatedCalendarSyncJob::class, ['id' => 10]);
@ -194,6 +289,81 @@ class CalendarFederationProviderTest extends TestCase {
$this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
}
public function testShareReceivedWithExistingPendingCalendar(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
->willReturn('user');
$share->method('getProtocol')
->willReturn([
'version' => 'v1',
'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1',
'displayName' => 'Calendar 1',
'color' => '#ff0000',
'access' => 3,
'components' => 'VEVENT,VTODO',
]);
$share->method('getShareWith')
->willReturn('sharee1');
$share->method('getShareSecret')
->willReturn('new-token');
$share->method('getOwner')
->willReturn('user1@nextcloud.remote');
$share->method('getSharedBy')
->willReturn('user1@nextcloud.remote');
$share->method('getSharedByDisplayName')
->willReturn('User 1');
$this->calendarFederationConfig->expects(self::once())
->method('isFederationEnabled')
->willReturn(true);
$this->calendarFederationConfig->expects(self::once())
->method('isIncomingServer2serverShareEnabled')
->willReturn(true);
$existingCalendar = new FederatedCalendarEntity();
$existingCalendar->setId(10);
$existingCalendar->setPrincipaluri('principals/users/sharee1');
$existingCalendar->setUri('ae4b8ab904076fff2b955ea21b1a0d92');
$existingCalendar->setRemoteUrl('https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1');
$existingCalendar->setToken('old-token');
$existingCalendar->setState(FederatedCalendarEntity::STATE_PENDING);
$this->federatedCalendarMapper->expects(self::once())
->method('findByUri')
->willReturn($existingCalendar);
$this->federatedCalendarMapper->expects(self::never())
->method('insert');
$this->federatedCalendarMapper->expects(self::once())
->method('update')
->willReturnCallback(function (FederatedCalendarEntity $calendar) {
$this->assertEquals('new-token', $calendar->getToken());
$this->assertEquals(FederatedCalendarEntity::STATE_PENDING, $calendar->getState());
// A pending invitation keeps showing the sharer's metadata
$this->assertEquals('Calendar 1', $calendar->getDisplayName());
$this->assertEquals('#ff0000', $calendar->getColor());
$this->assertEquals('User 1', $calendar->getSharedByDisplayName());
return $calendar;
});
$this->invitationService->expects(self::once())
->method('shouldAutoAccept')
->with(
'user1@nextcloud.remote',
'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1',
)
->willReturn(false);
$this->invitationService->expects(self::never())
->method('accept');
// The original invitation notification is still open, do not notify twice
$this->invitationService->expects(self::never())
->method('notifyAboutNewShare');
$this->jobList->expects(self::never())
->method('add');
$this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
}
public function testShareReceivedWithInvalidProtocolVersion(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
@ -437,9 +607,13 @@ class CalendarFederationProviderTest extends TestCase {
return $calendar;
});
$this->jobList->expects(self::once())
->method('add')
->with(FederatedCalendarSyncJob::class, ['id' => 10]);
$this->invitationService->expects(self::once())
->method('shouldAutoAccept')
->willReturn(false);
$this->invitationService->expects(self::once())
->method('notifyAboutNewShare');
$this->jobList->expects(self::never())
->method('add');
$this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
}
@ -530,6 +704,7 @@ class CalendarFederationProviderTest extends TestCase {
'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
'principals/users/remote1',
'token',
FederatedCalendarEntity::STATE_ACCEPTED,
)
->willReturn($calendars);
@ -641,6 +816,7 @@ class CalendarFederationProviderTest extends TestCase {
'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
'principals/users/remote1',
'token',
FederatedCalendarEntity::STATE_ACCEPTED,
)
->willReturn([]);
@ -659,4 +835,5 @@ class CalendarFederationProviderTest extends TestCase {
],
);
}
}

@ -7,6 +7,7 @@
namespace OCA\Federation\Command;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService;
use Symfony\Component\Console\Command\Command;
@ -31,7 +32,7 @@ class SyncFederationCalendars extends Command {
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
$calendarCount = $this->federatedCalendarMapper->countAll();
$calendarCount = $this->federatedCalendarMapper->countAll(FederatedCalendarEntity::STATE_ACCEPTED);
if ($calendarCount === 0) {
$output->writeln('There are no federated calendars');
return 0;
@ -40,7 +41,7 @@ class SyncFederationCalendars extends Command {
$progress = new ProgressBar($output, $calendarCount);
$progress->start();
$calendars = $this->federatedCalendarMapper->findAll();
$calendars = $this->federatedCalendarMapper->findAll(FederatedCalendarEntity::STATE_ACCEPTED);
foreach ($calendars as $calendar) {
try {
$this->syncService->syncOne($calendar);

@ -1625,6 +1625,48 @@
}
}
},
"DavPendingFederatedCalendar": {
"type": "object",
"required": [
"id",
"displayName",
"color",
"sharedBy",
"sharedByDisplayName",
"remoteUrl",
"permissions",
"components"
],
"properties": {
"id": {
"type": "integer",
"format": "int64"
},
"displayName": {
"type": "string"
},
"color": {
"type": "string",
"nullable": true
},
"sharedBy": {
"type": "string"
},
"sharedByDisplayName": {
"type": "string"
},
"remoteUrl": {
"type": "string"
},
"permissions": {
"type": "integer",
"format": "int64"
},
"components": {
"type": "string"
}
}
},
"DavUpcomingEvent": {
"type": "object",
"required": [
@ -20096,6 +20138,377 @@
}
}
},
"/ocs/v2.php/apps/dav/api/v1/federated_calendars/pending": {
"get": {
"operationId": "dav-federated_calendar-get-pending",
"summary": "Get the pending federated calendar invitations of the current user",
"tags": [
"dav/federated_calendar"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Pending federated calendar invitations",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DavPendingFederatedCalendar"
}
}
}
}
}
}
}
}
},
"401": {
"description": "When the user is not logged in",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
},
{
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
]
}
}
}
}
}
}
},
"/ocs/v2.php/apps/dav/api/v1/federated_calendars/pending/{id}": {
"post": {
"operationId": "dav-federated_calendar-accept",
"summary": "Accept a pending federated calendar invitation",
"tags": [
"dav/federated_calendar"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"description": "The id of the pending federated calendar",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Invitation accepted successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"404": {
"description": "The federated calendar was not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
},
"delete": {
"operationId": "dav-federated_calendar-decline",
"summary": "Decline a pending federated calendar invitation",
"tags": [
"dav/federated_calendar"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "id",
"in": "path",
"description": "The id of the pending federated calendar",
"required": true,
"schema": {
"type": "integer",
"format": "int64"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Invitation declined successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"nullable": true
}
}
}
}
}
}
}
},
"404": {
"description": "The federated calendar was not found",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/dav/api/v1/events/upcoming": {
"get": {
"operationId": "dav-upcoming_events-get-events",

Loading…
Cancel
Save