feat: calendar federation

Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
This commit is contained in:
Richard Steinmetz 2025-05-14 11:21:28 +02:00
parent 45f5daa45a
commit b7dc720848
No known key found for this signature in database
GPG key ID: 27137D9E7D273FB2
66 changed files with 4343 additions and 189 deletions

View file

@ -10,7 +10,7 @@
<name>WebDAV</name>
<summary>WebDAV endpoint</summary>
<description>WebDAV endpoint</description>
<version>1.34.1</version>
<version>1.34.2</version>
<licence>agpl</licence>
<author>owncloud.org</author>
<namespace>DAV</namespace>
@ -30,6 +30,7 @@
<job>OCA\DAV\BackgroundJob\EventReminderJob</job>
<job>OCA\DAV\BackgroundJob\CalendarRetentionJob</job>
<job>OCA\DAV\BackgroundJob\PruneOutdatedSyncTokensJob</job>
<job>OCA\DAV\BackgroundJob\FederatedCalendarPeriodicSyncJob</job>
</background-jobs>
<repair-steps>

View file

@ -10,6 +10,8 @@ use OC\KnownUser\KnownUserService;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\CalendarRoot;
use OCA\DAV\CalDAV\DefaultCalendarValidator;
use OCA\DAV\CalDAV\Federation\FederatedCalendarFactory;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Proxy\ProxyMapper;
use OCA\DAV\CalDAV\Schedule\IMipPlugin;
use OCA\DAV\CalDAV\Security\RateLimitingPlugin;
@ -29,6 +31,7 @@ use OCP\IRequest;
use OCP\ISession;
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory as IL10NFactory;
use OCP\Security\Bruteforce\IThrottler;
use OCP\Security\ISecureRandom;
use OCP\Server;
@ -61,6 +64,9 @@ $random = Server::get(ISecureRandom::class);
$logger = Server::get(LoggerInterface::class);
$dispatcher = Server::get(IEventDispatcher::class);
$config = Server::get(IConfig::class);
$l10nFactory = Server::get(IL10NFactory::class);
$davL10n = $l10nFactory->get('dav');
$federatedCalendarFactory = Server::get(FederatedCalendarFactory::class);
$calDavBackend = new CalDavBackend(
$db,
@ -71,6 +77,7 @@ $calDavBackend = new CalDavBackend(
$dispatcher,
$config,
Server::get(\OCA\DAV\CalDAV\Sharing\Backend::class),
Server::get(FederatedCalendarMapper::class),
true
);
@ -81,7 +88,7 @@ $sendInvitations = Server::get(IConfig::class)->getAppValue('dav', 'sendInvitati
$principalCollection = new \Sabre\CalDAV\Principal\Collection($principalBackend);
$principalCollection->disableListing = !$debugging; // Disable listing
$addressBookRoot = new CalendarRoot($principalBackend, $calDavBackend, 'principals', $logger);
$addressBookRoot = new CalendarRoot($principalBackend, $calDavBackend, 'principals', $logger, $davL10n, $config, $federatedCalendarFactory);
$addressBookRoot->disableListing = !$debugging; // Disable listing
$nodes = [
@ -96,7 +103,7 @@ $server->httpRequest->setUrl(Server::get(IRequest::class)->getRequestUri());
$server->setBaseUri($baseuri);
// Add plugins
$server->addPlugin(new MaintenancePlugin(Server::get(IConfig::class), \OC::$server->getL10N('dav')));
$server->addPlugin(new MaintenancePlugin(Server::get(IConfig::class), $davL10n));
$server->addPlugin(new \Sabre\DAV\Auth\Plugin($authBackend));
$server->addPlugin(new \Sabre\CalDAV\Plugin());

View file

@ -19,6 +19,8 @@ return array(
'OCA\\DAV\\BackgroundJob\\CleanupOrphanedChildrenJob' => $baseDir . '/../lib/BackgroundJob/CleanupOrphanedChildrenJob.php',
'OCA\\DAV\\BackgroundJob\\DeleteOutdatedSchedulingObjects' => $baseDir . '/../lib/BackgroundJob/DeleteOutdatedSchedulingObjects.php',
'OCA\\DAV\\BackgroundJob\\EventReminderJob' => $baseDir . '/../lib/BackgroundJob/EventReminderJob.php',
'OCA\\DAV\\BackgroundJob\\FederatedCalendarPeriodicSyncJob' => $baseDir . '/../lib/BackgroundJob/FederatedCalendarPeriodicSyncJob.php',
'OCA\\DAV\\BackgroundJob\\FederatedCalendarSyncJob' => $baseDir . '/../lib/BackgroundJob/FederatedCalendarSyncJob.php',
'OCA\\DAV\\BackgroundJob\\GenerateBirthdayCalendarBackgroundJob' => $baseDir . '/../lib/BackgroundJob/GenerateBirthdayCalendarBackgroundJob.php',
'OCA\\DAV\\BackgroundJob\\OutOfOfficeEventDispatcherJob' => $baseDir . '/../lib/BackgroundJob/OutOfOfficeEventDispatcherJob.php',
'OCA\\DAV\\BackgroundJob\\PruneOutdatedSyncTokensJob' => $baseDir . '/../lib/BackgroundJob/PruneOutdatedSyncTokensJob.php',
@ -66,6 +68,21 @@ return array(
'OCA\\DAV\\CalDAV\\EventReaderRDate' => $baseDir . '/../lib/CalDAV/EventReaderRDate.php',
'OCA\\DAV\\CalDAV\\EventReaderRRule' => $baseDir . '/../lib/CalDAV/EventReaderRRule.php',
'OCA\\DAV\\CalDAV\\Export\\ExportService' => $baseDir . '/../lib/CalDAV/Export/ExportService.php',
'OCA\\DAV\\CalDAV\\Federation\\CalendarFederationConfig' => $baseDir . '/../lib/CalDAV/Federation/CalendarFederationConfig.php',
'OCA\\DAV\\CalDAV\\Federation\\CalendarFederationNotifier' => $baseDir . '/../lib/CalDAV/Federation/CalendarFederationNotifier.php',
'OCA\\DAV\\CalDAV\\Federation\\CalendarFederationProvider' => $baseDir . '/../lib/CalDAV/Federation/CalendarFederationProvider.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendar' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendar.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarAuth' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarAuth.php',
'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\\FederatedCalendarMapper' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php',
'OCA\\DAV\\CalDAV\\Federation\\FederationSharingService' => $baseDir . '/../lib/CalDAV/Federation/FederationSharingService.php',
'OCA\\DAV\\CalDAV\\Federation\\Protocol\\CalendarFederationProtocolV1' => $baseDir . '/../lib/CalDAV/Federation/Protocol/CalendarFederationProtocolV1.php',
'OCA\\DAV\\CalDAV\\Federation\\Protocol\\CalendarProtocolParseException' => $baseDir . '/../lib/CalDAV/Federation/Protocol/CalendarProtocolParseException.php',
'OCA\\DAV\\CalDAV\\Federation\\Protocol\\ICalendarFederationProtocol' => $baseDir . '/../lib/CalDAV/Federation/Protocol/ICalendarFederationProtocol.php',
'OCA\\DAV\\CalDAV\\Federation\\RemoteUserCalendarHome' => $baseDir . '/../lib/CalDAV/Federation/RemoteUserCalendarHome.php',
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => $baseDir . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => $baseDir . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
'OCA\\DAV\\CalDAV\\IRestorable' => $baseDir . '/../lib/CalDAV/IRestorable.php',
@ -116,6 +133,8 @@ return array(
'OCA\\DAV\\CalDAV\\Sharing\\Backend' => $baseDir . '/../lib/CalDAV/Sharing/Backend.php',
'OCA\\DAV\\CalDAV\\Sharing\\Service' => $baseDir . '/../lib/CalDAV/Sharing/Service.php',
'OCA\\DAV\\CalDAV\\Status\\StatusService' => $baseDir . '/../lib/CalDAV/Status/StatusService.php',
'OCA\\DAV\\CalDAV\\SyncService' => $baseDir . '/../lib/CalDAV/SyncService.php',
'OCA\\DAV\\CalDAV\\SyncServiceResult' => $baseDir . '/../lib/CalDAV/SyncServiceResult.php',
'OCA\\DAV\\CalDAV\\TimeZoneFactory' => $baseDir . '/../lib/CalDAV/TimeZoneFactory.php',
'OCA\\DAV\\CalDAV\\TimezoneService' => $baseDir . '/../lib/CalDAV/TimezoneService.php',
'OCA\\DAV\\CalDAV\\TipBroker' => $baseDir . '/../lib/CalDAV/TipBroker.php',
@ -243,6 +262,7 @@ return array(
'OCA\\DAV\\DAV\\CustomPropertiesBackend' => $baseDir . '/../lib/DAV/CustomPropertiesBackend.php',
'OCA\\DAV\\DAV\\GroupPrincipalBackend' => $baseDir . '/../lib/DAV/GroupPrincipalBackend.php',
'OCA\\DAV\\DAV\\PublicAuth' => $baseDir . '/../lib/DAV/PublicAuth.php',
'OCA\\DAV\\DAV\\RemoteUserPrincipalBackend' => $baseDir . '/../lib/DAV/RemoteUserPrincipalBackend.php',
'OCA\\DAV\\DAV\\Sharing\\Backend' => $baseDir . '/../lib/DAV/Sharing/Backend.php',
'OCA\\DAV\\DAV\\Sharing\\IShareable' => $baseDir . '/../lib/DAV/Sharing/IShareable.php',
'OCA\\DAV\\DAV\\Sharing\\Plugin' => $baseDir . '/../lib/DAV/Sharing/Plugin.php',
@ -304,6 +324,7 @@ return array(
'OCA\\DAV\\Listener\\BirthdayListener' => $baseDir . '/../lib/Listener/BirthdayListener.php',
'OCA\\DAV\\Listener\\CalendarContactInteractionListener' => $baseDir . '/../lib/Listener/CalendarContactInteractionListener.php',
'OCA\\DAV\\Listener\\CalendarDeletionDefaultUpdaterListener' => $baseDir . '/../lib/Listener/CalendarDeletionDefaultUpdaterListener.php',
'OCA\\DAV\\Listener\\CalendarFederationNotificationListener' => $baseDir . '/../lib/Listener/CalendarFederationNotificationListener.php',
'OCA\\DAV\\Listener\\CalendarObjectReminderUpdaterListener' => $baseDir . '/../lib/Listener/CalendarObjectReminderUpdaterListener.php',
'OCA\\DAV\\Listener\\CalendarPublicationListener' => $baseDir . '/../lib/Listener/CalendarPublicationListener.php',
'OCA\\DAV\\Listener\\CalendarShareUpdateListener' => $baseDir . '/../lib/Listener/CalendarShareUpdateListener.php',
@ -311,6 +332,7 @@ return array(
'OCA\\DAV\\Listener\\ClearPhotoCacheListener' => $baseDir . '/../lib/Listener/ClearPhotoCacheListener.php',
'OCA\\DAV\\Listener\\DavAdminSettingsListener' => $baseDir . '/../lib/Listener/DavAdminSettingsListener.php',
'OCA\\DAV\\Listener\\OutOfOfficeListener' => $baseDir . '/../lib/Listener/OutOfOfficeListener.php',
'OCA\\DAV\\Listener\\SabrePluginAuthInitListener' => $baseDir . '/../lib/Listener/SabrePluginAuthInitListener.php',
'OCA\\DAV\\Listener\\SubscriptionListener' => $baseDir . '/../lib/Listener/SubscriptionListener.php',
'OCA\\DAV\\Listener\\TrustedServerRemovedListener' => $baseDir . '/../lib/Listener/TrustedServerRemovedListener.php',
'OCA\\DAV\\Listener\\UserEventsListener' => $baseDir . '/../lib/Listener/UserEventsListener.php',
@ -359,6 +381,7 @@ return array(
'OCA\\DAV\\Migration\\Version1029Date20231004091403' => $baseDir . '/../lib/Migration/Version1029Date20231004091403.php',
'OCA\\DAV\\Migration\\Version1030Date20240205103243' => $baseDir . '/../lib/Migration/Version1030Date20240205103243.php',
'OCA\\DAV\\Migration\\Version1031Date20240610134258' => $baseDir . '/../lib/Migration/Version1031Date20240610134258.php',
'OCA\\DAV\\Migration\\Version1034Date20250605132605' => $baseDir . '/../lib/Migration/Version1034Date20250605132605.php',
'OCA\\DAV\\Migration\\Version1034Date20250813093701' => $baseDir . '/../lib/Migration/Version1034Date20250813093701.php',
'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php',
@ -375,6 +398,7 @@ return array(
'OCA\\DAV\\Search\\TasksSearchProvider' => $baseDir . '/../lib/Search/TasksSearchProvider.php',
'OCA\\DAV\\Server' => $baseDir . '/../lib/Server.php',
'OCA\\DAV\\ServerFactory' => $baseDir . '/../lib/ServerFactory.php',
'OCA\\DAV\\Service\\ASyncService' => $baseDir . '/../lib/Service/ASyncService.php',
'OCA\\DAV\\Service\\AbsenceService' => $baseDir . '/../lib/Service/AbsenceService.php',
'OCA\\DAV\\Service\\ExampleContactService' => $baseDir . '/../lib/Service/ExampleContactService.php',
'OCA\\DAV\\Service\\ExampleEventService' => $baseDir . '/../lib/Service/ExampleEventService.php',

View file

@ -34,6 +34,8 @@ class ComposerStaticInitDAV
'OCA\\DAV\\BackgroundJob\\CleanupOrphanedChildrenJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupOrphanedChildrenJob.php',
'OCA\\DAV\\BackgroundJob\\DeleteOutdatedSchedulingObjects' => __DIR__ . '/..' . '/../lib/BackgroundJob/DeleteOutdatedSchedulingObjects.php',
'OCA\\DAV\\BackgroundJob\\EventReminderJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/EventReminderJob.php',
'OCA\\DAV\\BackgroundJob\\FederatedCalendarPeriodicSyncJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/FederatedCalendarPeriodicSyncJob.php',
'OCA\\DAV\\BackgroundJob\\FederatedCalendarSyncJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/FederatedCalendarSyncJob.php',
'OCA\\DAV\\BackgroundJob\\GenerateBirthdayCalendarBackgroundJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/GenerateBirthdayCalendarBackgroundJob.php',
'OCA\\DAV\\BackgroundJob\\OutOfOfficeEventDispatcherJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/OutOfOfficeEventDispatcherJob.php',
'OCA\\DAV\\BackgroundJob\\PruneOutdatedSyncTokensJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/PruneOutdatedSyncTokensJob.php',
@ -81,6 +83,21 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\EventReaderRDate' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRDate.php',
'OCA\\DAV\\CalDAV\\EventReaderRRule' => __DIR__ . '/..' . '/../lib/CalDAV/EventReaderRRule.php',
'OCA\\DAV\\CalDAV\\Export\\ExportService' => __DIR__ . '/..' . '/../lib/CalDAV/Export/ExportService.php',
'OCA\\DAV\\CalDAV\\Federation\\CalendarFederationConfig' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/CalendarFederationConfig.php',
'OCA\\DAV\\CalDAV\\Federation\\CalendarFederationNotifier' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/CalendarFederationNotifier.php',
'OCA\\DAV\\CalDAV\\Federation\\CalendarFederationProvider' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/CalendarFederationProvider.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendar' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendar.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarAuth' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarAuth.php',
'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\\FederatedCalendarMapper' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php',
'OCA\\DAV\\CalDAV\\Federation\\FederationSharingService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederationSharingService.php',
'OCA\\DAV\\CalDAV\\Federation\\Protocol\\CalendarFederationProtocolV1' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/Protocol/CalendarFederationProtocolV1.php',
'OCA\\DAV\\CalDAV\\Federation\\Protocol\\CalendarProtocolParseException' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/Protocol/CalendarProtocolParseException.php',
'OCA\\DAV\\CalDAV\\Federation\\Protocol\\ICalendarFederationProtocol' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/Protocol/ICalendarFederationProtocol.php',
'OCA\\DAV\\CalDAV\\Federation\\RemoteUserCalendarHome' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/RemoteUserCalendarHome.php',
'OCA\\DAV\\CalDAV\\FreeBusy\\FreeBusyGenerator' => __DIR__ . '/..' . '/../lib/CalDAV/FreeBusy/FreeBusyGenerator.php',
'OCA\\DAV\\CalDAV\\ICSExportPlugin\\ICSExportPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/ICSExportPlugin/ICSExportPlugin.php',
'OCA\\DAV\\CalDAV\\IRestorable' => __DIR__ . '/..' . '/../lib/CalDAV/IRestorable.php',
@ -131,6 +148,8 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\Sharing\\Backend' => __DIR__ . '/..' . '/../lib/CalDAV/Sharing/Backend.php',
'OCA\\DAV\\CalDAV\\Sharing\\Service' => __DIR__ . '/..' . '/../lib/CalDAV/Sharing/Service.php',
'OCA\\DAV\\CalDAV\\Status\\StatusService' => __DIR__ . '/..' . '/../lib/CalDAV/Status/StatusService.php',
'OCA\\DAV\\CalDAV\\SyncService' => __DIR__ . '/..' . '/../lib/CalDAV/SyncService.php',
'OCA\\DAV\\CalDAV\\SyncServiceResult' => __DIR__ . '/..' . '/../lib/CalDAV/SyncServiceResult.php',
'OCA\\DAV\\CalDAV\\TimeZoneFactory' => __DIR__ . '/..' . '/../lib/CalDAV/TimeZoneFactory.php',
'OCA\\DAV\\CalDAV\\TimezoneService' => __DIR__ . '/..' . '/../lib/CalDAV/TimezoneService.php',
'OCA\\DAV\\CalDAV\\TipBroker' => __DIR__ . '/..' . '/../lib/CalDAV/TipBroker.php',
@ -258,6 +277,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\DAV\\CustomPropertiesBackend' => __DIR__ . '/..' . '/../lib/DAV/CustomPropertiesBackend.php',
'OCA\\DAV\\DAV\\GroupPrincipalBackend' => __DIR__ . '/..' . '/../lib/DAV/GroupPrincipalBackend.php',
'OCA\\DAV\\DAV\\PublicAuth' => __DIR__ . '/..' . '/../lib/DAV/PublicAuth.php',
'OCA\\DAV\\DAV\\RemoteUserPrincipalBackend' => __DIR__ . '/..' . '/../lib/DAV/RemoteUserPrincipalBackend.php',
'OCA\\DAV\\DAV\\Sharing\\Backend' => __DIR__ . '/..' . '/../lib/DAV/Sharing/Backend.php',
'OCA\\DAV\\DAV\\Sharing\\IShareable' => __DIR__ . '/..' . '/../lib/DAV/Sharing/IShareable.php',
'OCA\\DAV\\DAV\\Sharing\\Plugin' => __DIR__ . '/..' . '/../lib/DAV/Sharing/Plugin.php',
@ -319,6 +339,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Listener\\BirthdayListener' => __DIR__ . '/..' . '/../lib/Listener/BirthdayListener.php',
'OCA\\DAV\\Listener\\CalendarContactInteractionListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarContactInteractionListener.php',
'OCA\\DAV\\Listener\\CalendarDeletionDefaultUpdaterListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarDeletionDefaultUpdaterListener.php',
'OCA\\DAV\\Listener\\CalendarFederationNotificationListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarFederationNotificationListener.php',
'OCA\\DAV\\Listener\\CalendarObjectReminderUpdaterListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarObjectReminderUpdaterListener.php',
'OCA\\DAV\\Listener\\CalendarPublicationListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarPublicationListener.php',
'OCA\\DAV\\Listener\\CalendarShareUpdateListener' => __DIR__ . '/..' . '/../lib/Listener/CalendarShareUpdateListener.php',
@ -326,6 +347,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Listener\\ClearPhotoCacheListener' => __DIR__ . '/..' . '/../lib/Listener/ClearPhotoCacheListener.php',
'OCA\\DAV\\Listener\\DavAdminSettingsListener' => __DIR__ . '/..' . '/../lib/Listener/DavAdminSettingsListener.php',
'OCA\\DAV\\Listener\\OutOfOfficeListener' => __DIR__ . '/..' . '/../lib/Listener/OutOfOfficeListener.php',
'OCA\\DAV\\Listener\\SabrePluginAuthInitListener' => __DIR__ . '/..' . '/../lib/Listener/SabrePluginAuthInitListener.php',
'OCA\\DAV\\Listener\\SubscriptionListener' => __DIR__ . '/..' . '/../lib/Listener/SubscriptionListener.php',
'OCA\\DAV\\Listener\\TrustedServerRemovedListener' => __DIR__ . '/..' . '/../lib/Listener/TrustedServerRemovedListener.php',
'OCA\\DAV\\Listener\\UserEventsListener' => __DIR__ . '/..' . '/../lib/Listener/UserEventsListener.php',
@ -374,6 +396,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Migration\\Version1029Date20231004091403' => __DIR__ . '/..' . '/../lib/Migration/Version1029Date20231004091403.php',
'OCA\\DAV\\Migration\\Version1030Date20240205103243' => __DIR__ . '/..' . '/../lib/Migration/Version1030Date20240205103243.php',
'OCA\\DAV\\Migration\\Version1031Date20240610134258' => __DIR__ . '/..' . '/../lib/Migration/Version1031Date20240610134258.php',
'OCA\\DAV\\Migration\\Version1034Date20250605132605' => __DIR__ . '/..' . '/../lib/Migration/Version1034Date20250605132605.php',
'OCA\\DAV\\Migration\\Version1034Date20250813093701' => __DIR__ . '/..' . '/../lib/Migration/Version1034Date20250813093701.php',
'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php',
'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php',
@ -390,6 +413,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Search\\TasksSearchProvider' => __DIR__ . '/..' . '/../lib/Search/TasksSearchProvider.php',
'OCA\\DAV\\Server' => __DIR__ . '/..' . '/../lib/Server.php',
'OCA\\DAV\\ServerFactory' => __DIR__ . '/..' . '/../lib/ServerFactory.php',
'OCA\\DAV\\Service\\ASyncService' => __DIR__ . '/..' . '/../lib/Service/ASyncService.php',
'OCA\\DAV\\Service\\AbsenceService' => __DIR__ . '/..' . '/../lib/Service/AbsenceService.php',
'OCA\\DAV\\Service\\ExampleContactService' => __DIR__ . '/..' . '/../lib/Service/ExampleContactService.php',
'OCA\\DAV\\Service\\ExampleEventService' => __DIR__ . '/..' . '/../lib/Service/ExampleEventService.php',

View file

@ -13,6 +13,7 @@ use OCA\DAV\CalDAV\AppCalendar\AppCalendarPlugin;
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\Reminder\NotificationProvider\AudioProvider;
use OCA\DAV\CalDAV\Reminder\NotificationProvider\EmailProvider;
use OCA\DAV\CalDAV\Reminder\NotificationProvider\PushProvider;
@ -36,6 +37,7 @@ use OCA\DAV\Events\CalendarUpdatedEvent;
use OCA\DAV\Events\CardCreatedEvent;
use OCA\DAV\Events\CardDeletedEvent;
use OCA\DAV\Events\CardUpdatedEvent;
use OCA\DAV\Events\SabrePluginAuthInitEvent;
use OCA\DAV\Events\SubscriptionCreatedEvent;
use OCA\DAV\Events\SubscriptionDeletedEvent;
use OCA\DAV\Listener\ActivityUpdaterListener;
@ -44,6 +46,7 @@ use OCA\DAV\Listener\AddressbookListener;
use OCA\DAV\Listener\BirthdayListener;
use OCA\DAV\Listener\CalendarContactInteractionListener;
use OCA\DAV\Listener\CalendarDeletionDefaultUpdaterListener;
use OCA\DAV\Listener\CalendarFederationNotificationListener;
use OCA\DAV\Listener\CalendarObjectReminderUpdaterListener;
use OCA\DAV\Listener\CalendarPublicationListener;
use OCA\DAV\Listener\CalendarShareUpdateListener;
@ -51,6 +54,7 @@ use OCA\DAV\Listener\CardListener;
use OCA\DAV\Listener\ClearPhotoCacheListener;
use OCA\DAV\Listener\DavAdminSettingsListener;
use OCA\DAV\Listener\OutOfOfficeListener;
use OCA\DAV\Listener\SabrePluginAuthInitListener;
use OCA\DAV\Listener\SubscriptionListener;
use OCA\DAV\Listener\TrustedServerRemovedListener;
use OCA\DAV\Listener\UserEventsListener;
@ -82,6 +86,7 @@ use OCP\Config\BeforePreferenceSetEvent;
use OCP\Contacts\IManager as IContactsManager;
use OCP\DB\Events\AddMissingIndicesEvent;
use OCP\Federation\Events\TrustedServerRemovedEvent;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\IUserSession;
use OCP\Server;
use OCP\Settings\Events\DeclarativeSettingsGetValueEvent;
@ -198,6 +203,12 @@ class Application extends App implements IBootstrap {
$context->registerEventListener(UserChangedEvent::class, UserEventsListener::class);
$context->registerEventListener(UserUpdatedEvent::class, UserEventsListener::class);
$context->registerEventListener(SabrePluginAuthInitEvent::class, SabrePluginAuthInitListener::class);
$context->registerEventListener(CalendarObjectCreatedEvent::class, CalendarFederationNotificationListener::class);
$context->registerEventListener(CalendarObjectUpdatedEvent::class, CalendarFederationNotificationListener::class);
$context->registerEventListener(CalendarObjectDeletedEvent::class, CalendarFederationNotificationListener::class);
$context->registerNotifierService(Notifier::class);
$context->registerCalendarProvider(CalendarProvider::class);
@ -213,7 +224,6 @@ class Application extends App implements IBootstrap {
$context->registerDeclarativeSettings(SystemAddressBookSettings::class);
$context->registerEventListener(DeclarativeSettingsGetValueEvent::class, DavAdminSettingsListener::class);
$context->registerEventListener(DeclarativeSettingsSetValueEvent::class, DavAdminSettingsListener::class);
}
public function boot(IBootContext $context): void {
@ -223,6 +233,7 @@ class Application extends App implements IBootstrap {
$context->injectFn($this->registerContactsManager(...));
$context->injectFn($this->registerCalendarManager(...));
$context->injectFn($this->registerCalendarReminders(...));
$context->injectFn($this->registerCloudFederationProvider(...));
}
public function registerContactsManager(IContactsManager $cm, IAppContainer $container): void {
@ -279,4 +290,14 @@ class Application extends App implements IBootstrap {
$logger->error($ex->getMessage(), ['exception' => $ex]);
}
}
public function registerCloudFederationProvider(
ICloudFederationProviderManager $manager,
): void {
$manager->addCloudFederationProvider(
CalendarFederationProvider::PROVIDER_ID,
'Calendar Federation',
static fn () => Server::get(CalendarFederationProvider::class),
);
}
}

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\BackgroundJob;
use OCA\DAV\CalDAV\Federation\CalendarFederationConfig;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Log\LoggerInterface;
class FederatedCalendarPeriodicSyncJob extends TimedJob {
private const DOWNLOAD_LIMIT = 500;
public function __construct(
ITimeFactory $time,
private readonly FederatedCalendarSyncService $syncService,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
private readonly CalendarFederationConfig $calendarFederationConfig,
private readonly LoggerInterface $logger,
) {
parent::__construct($time);
$this->setTimeSensitivity(self::TIME_SENSITIVE);
$this->setAllowParallelRuns(false);
$this->setInterval(3600);
}
protected function run($argument): void {
if (!$this->calendarFederationConfig->isFederationEnabled()) {
return;
}
$downloadedEvents = 0;
$oneHourAgo = $this->time->getTime() - 3600;
$calendars = $this->federatedCalendarMapper->findUnsyncedSinceBefore($oneHourAgo);
foreach ($calendars as $calendar) {
try {
$downloadedEvents += $this->syncService->syncOne($calendar);
} catch (ClientExceptionInterface $e) {
$name = $calendar->getUri();
$this->logger->error("Failed to sync federated calendar $name: " . $e->getMessage(), [
'exception' => $e,
'calendar' => $calendar->toCalendarInfo(),
]);
}
// Prevent stalling the background job queue for too long
if ($downloadedEvents >= self::DOWNLOAD_LIMIT) {
break;
}
}
}
}

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\BackgroundJob;
use OCA\DAV\CalDAV\Federation\CalendarFederationConfig;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\QueuedJob;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Log\LoggerInterface;
class FederatedCalendarSyncJob extends QueuedJob {
public const ARGUMENT_ID = 'id';
public function __construct(
ITimeFactory $time,
private readonly FederatedCalendarSyncService $syncService,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
private readonly CalendarFederationConfig $calendarFederationConfig,
private readonly LoggerInterface $logger,
) {
parent::__construct($time);
$this->setAllowParallelRuns(false);
}
protected function run($argument): void {
if (!$this->calendarFederationConfig->isFederationEnabled()) {
return;
}
$id = $argument[self::ARGUMENT_ID] ?? null;
if (!is_numeric($id)) {
return;
}
$id = (int)$id;
try {
$calendar = $this->federatedCalendarMapper->find($id);
} catch (DoesNotExistException $e) {
return;
}
try {
$this->syncService->syncOne($calendar);
} catch (ClientExceptionInterface $e) {
$name = $calendar->getUri();
$this->logger->error("Failed to sync federated calendar $name: " . $e->getMessage(), [
'exception' => $e,
'calendar' => $calendar->toCalendarInfo(),
]);
// Let the periodic background job pick up the calendar at a later point
$calendar->setLastSync(1);
$this->federatedCalendarMapper->update($calendar);
}
}
}

View file

@ -11,6 +11,7 @@ namespace OCA\DAV\CalDAV\AppCalendar;
use OCA\DAV\CalDAV\CachedSubscriptionImpl;
use OCA\DAV\CalDAV\CalendarImpl;
use OCA\DAV\CalDAV\Federation\FederatedCalendarImpl;
use OCA\DAV\CalDAV\Integration\ExternalCalendar;
use OCA\DAV\CalDAV\Integration\ICalendarProvider;
use OCP\Calendar\IManager;
@ -51,7 +52,11 @@ class AppCalendarPlugin implements ICalendarProvider {
return array_values(
array_filter($this->manager->getCalendarsForPrincipal($principalUri, $calendarUris), function ($c) {
// We must not provide a wrapper for DAV calendars
return ! (($c instanceof CalendarImpl) || ($c instanceof CachedSubscriptionImpl));
return !(
($c instanceof CalendarImpl)
|| ($c instanceof CachedSubscriptionImpl)
|| ($c instanceof FederatedCalendarImpl)
);
})
);
}

View file

@ -12,6 +12,8 @@ use DateTimeImmutable;
use DateTimeInterface;
use Generator;
use OCA\DAV\AppInfo\Application;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Sharing\Backend;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\Sharing\IShareable;
@ -110,6 +112,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
public const CALENDAR_TYPE_CALENDAR = 0;
public const CALENDAR_TYPE_SUBSCRIPTION = 1;
public const CALENDAR_TYPE_FEDERATED = 2;
public const PERSONAL_CALENDAR_URI = 'personal';
public const PERSONAL_CALENDAR_NAME = 'Personal';
@ -208,6 +211,7 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
private IEventDispatcher $dispatcher,
private IConfig $config,
private Sharing\Backend $calendarSharingBackend,
private FederatedCalendarMapper $federatedCalendarMapper,
private bool $legacyEndpoint = false,
) {
}
@ -1408,10 +1412,12 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$shares = $this->getShares($calendarId);
$this->dispatcher->dispatchTyped(new CalendarObjectCreatedEvent($calendarId, $calendarRow, $shares, $objectRow));
} else {
} elseif ($calendarType === self::CALENDAR_TYPE_SUBSCRIPTION) {
$subscriptionRow = $this->getSubscriptionById($calendarId);
$this->dispatcher->dispatchTyped(new CachedCalendarObjectCreatedEvent($calendarId, $subscriptionRow, [], $objectRow));
} elseif ($calendarType === self::CALENDAR_TYPE_FEDERATED) {
// TODO: implement custom event for federated calendars
}
return '"' . $extraData['etag'] . '"';
@ -1468,10 +1474,12 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
$shares = $this->getShares($calendarId);
$this->dispatcher->dispatchTyped(new CalendarObjectUpdatedEvent($calendarId, $calendarRow, $shares, $objectRow));
} else {
} elseif ($calendarType === self::CALENDAR_TYPE_SUBSCRIPTION) {
$subscriptionRow = $this->getSubscriptionById($calendarId);
$this->dispatcher->dispatchTyped(new CachedCalendarObjectUpdatedEvent($calendarId, $subscriptionRow, [], $objectRow));
} elseif ($calendarType === self::CALENDAR_TYPE_FEDERATED) {
// TODO: implement custom event for federated calendars
}
}
@ -1978,6 +1986,8 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
if (isset($calendarInfo['source'])) {
$calendarType = self::CALENDAR_TYPE_SUBSCRIPTION;
} elseif (isset($calendarInfo['federated'])) {
$calendarType = self::CALENDAR_TYPE_FEDERATED;
} else {
$calendarType = self::CALENDAR_TYPE_CALENDAR;
}
@ -3197,6 +3207,10 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
return $this->calendarSharingBackend->getShares($resourceId);
}
public function getSharesByShareePrincipal(string $principal): array {
return $this->calendarSharingBackend->getSharesByShareePrincipal($principal);
}
public function preloadShares(array $resourceIds): void {
$this->calendarSharingBackend->preloadShares($resourceIds);
}
@ -3692,4 +3706,20 @@ class CalDavBackend extends AbstractBackend implements SyncSupport, Subscription
}
}, $this->db);
}
/**
* @return array<string, mixed>[]
*/
public function getFederatedCalendarsForUser(string $principalUri): array {
$federatedCalendars = $this->federatedCalendarMapper->findByPrincipalUri($principalUri);
return array_map(
static fn (FederatedCalendarEntity $entity) => $entity->toCalendarInfo(),
$federatedCalendars,
);
}
public function getFederatedCalendarByUri(string $principalUri, string $uri): ?array {
$federatedCalendar = $this->federatedCalendarMapper->findByUri($principalUri, $uri);
return $federatedCalendar?->toCalendarInfo();
}
}

View file

@ -64,6 +64,10 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable
return $this->calendarInfo['uri'];
}
protected function getCalendarType(): int {
return CalDavBackend::CALENDAR_TYPE_CALENDAR;
}
/**
* {@inheritdoc}
* @throws Forbidden
@ -197,7 +201,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable
$this->getOwner() . '/calendar-proxy-read',
$this->getOwner() . '/calendar-proxy-write',
parent::getOwner(),
'principals/system/public'
'principals/system/public',
];
/** @var list<array{privilege: string, principal: string, protected: bool}> $acl */
$acl = array_filter($acl, function (array $rule) use ($allowedPrincipals): bool {
@ -247,7 +251,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable
}
public function getChild($name) {
$obj = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $name);
$obj = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $name, $this->getCalendarType());
if (!$obj) {
throw new NotFound('Calendar object not found');
@ -263,7 +267,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable
}
public function getChildren() {
$objs = $this->caldavBackend->getCalendarObjects($this->calendarInfo['id']);
$objs = $this->caldavBackend->getCalendarObjects($this->calendarInfo['id'], $this->getCalendarType());
$children = [];
foreach ($objs as $obj) {
if ($obj['classification'] === CalDavBackend::CLASSIFICATION_PRIVATE && $this->isShared()) {
@ -276,7 +280,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable
}
public function getMultipleChildren(array $paths) {
$objs = $this->caldavBackend->getMultipleCalendarObjects($this->calendarInfo['id'], $paths);
$objs = $this->caldavBackend->getMultipleCalendarObjects($this->calendarInfo['id'], $paths, $this->getCalendarType());
$children = [];
foreach ($objs as $obj) {
if ($obj['classification'] === CalDavBackend::CLASSIFICATION_PRIVATE && $this->isShared()) {
@ -289,7 +293,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable
}
public function childExists($name) {
$obj = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $name);
$obj = $this->caldavBackend->getCalendarObject($this->calendarInfo['id'], $name, $this->getCalendarType());
if (!$obj) {
return false;
}
@ -301,7 +305,7 @@ class Calendar extends \Sabre\CalDAV\Calendar implements IRestorable, IShareable
}
public function calendarQuery(array $filters) {
$uris = $this->caldavBackend->calendarQuery($this->calendarInfo['id'], $filters);
$uris = $this->caldavBackend->calendarQuery($this->calendarInfo['id'], $filters, $this->getCalendarType());
if ($this->isShared()) {
return array_filter($uris, function ($uri) {
return $this->childExists($uri);

View file

@ -8,6 +8,7 @@
namespace OCA\DAV\CalDAV;
use OCA\DAV\AppInfo\PluginManager;
use OCA\DAV\CalDAV\Federation\FederatedCalendarFactory;
use OCA\DAV\CalDAV\Integration\ExternalCalendar;
use OCA\DAV\CalDAV\Integration\ICalendarProvider;
use OCA\DAV\CalDAV\Trashbin\TrashbinHome;
@ -37,12 +38,14 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome {
/** @var PluginManager */
private $pluginManager;
private ?array $cachedChildren = null;
public function __construct(
BackendInterface $caldavBackend,
array $principalInfo,
private LoggerInterface $logger,
private FederatedCalendarFactory $federatedCalendarFactory,
private bool $returnCachedSubscriptions,
) {
parent::__construct($caldavBackend, $principalInfo);
@ -102,6 +105,15 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome {
if ($this->caldavBackend instanceof CalDavBackend) {
$objects[] = new TrashbinHome($this->caldavBackend, $this->principalInfo);
$federatedCalendars = $this->caldavBackend->getFederatedCalendarsForUser(
$this->principalInfo['uri'],
);
foreach ($federatedCalendars as $federatedCalendarInfo) {
$objects[] = $this->federatedCalendarFactory->createFederatedCalendar(
$federatedCalendarInfo,
);
}
}
// If the backend supports subscriptions, we'll add those as well,
@ -147,13 +159,22 @@ class CalendarHome extends \Sabre\CalDAV\CalendarHome {
return new TrashbinHome($this->caldavBackend, $this->principalInfo);
}
// Calendar - this covers all "regular" calendars, but not shared
// only check if the method is available
// Only check if the methods are available
if ($this->caldavBackend instanceof CalDavBackend) {
// Calendar - this covers all "regular" calendars, but not shared
$calendar = $this->caldavBackend->getCalendarByUri($this->principalInfo['uri'], $name);
if (!empty($calendar)) {
return new Calendar($this->caldavBackend, $calendar, $this->l10n, $this->config, $this->logger);
}
// Federated calendar
$federatedCalendar = $this->caldavBackend->getFederatedCalendarByUri(
$this->principalInfo['uri'],
$name,
);
if ($federatedCalendar !== null) {
return $this->federatedCalendarFactory->createFederatedCalendar($federatedCalendar);
}
}
// Fallback to cover shared calendars

View file

@ -8,6 +8,7 @@ declare(strict_types=1);
*/
namespace OCA\DAV\CalDAV;
use OCA\DAV\CalDAV\Federation\FederatedCalendarImpl;
use OCA\DAV\Db\Property;
use OCA\DAV\Db\PropertyMapper;
use OCP\Calendar\ICalendarProvider;
@ -27,17 +28,24 @@ class CalendarProvider implements ICalendarProvider {
}
public function getCalendars(string $principalUri, array $calendarUris = []): array {
/** @var array{uri: string, principaluri: string}[] $calendarInfos */
$calendarInfos = $this->calDavBackend->getCalendarsForUser($principalUri) ?? [];
/** @var array{uri: string, principaluri: string}[] $federatedCalendarInfos */
$federatedCalendarInfos = $this->calDavBackend->getFederatedCalendarsForUser($principalUri);
if (!empty($calendarUris)) {
$calendarInfos = array_filter($calendarInfos, function ($calendar) use ($calendarUris) {
return in_array($calendar['uri'], $calendarUris);
});
$federatedCalendarInfos = array_filter($federatedCalendarInfos, function ($federatedCalendar) use ($calendarUris) {
return in_array($federatedCalendar['uri'], $calendarUris);
});
}
$additionalProperties = $this->getAdditionalPropertiesForCalendars($calendarInfos);
$iCalendars = [];
foreach ($calendarInfos as $calendarInfo) {
$user = str_replace('principals/users/', '', $calendarInfo['principaluri']);
$path = 'calendars/' . $user . '/' . $calendarInfo['uri'];
@ -51,6 +59,20 @@ class CalendarProvider implements ICalendarProvider {
$this->calDavBackend,
);
}
$additionalFederatedProps = $this->getAdditionalPropertiesForCalendars(
$federatedCalendarInfos,
);
foreach ($federatedCalendarInfos as $calendarInfo) {
$user = str_replace('principals/users/', '', $calendarInfo['principaluri']);
$path = 'calendars/' . $user . '/' . $calendarInfo['uri'];
if (isset($additionalFederatedProps[$path])) {
$calendarInfo = array_merge($calendarInfo, $additionalProperties[$path]);
}
$iCalendars[] = new FederatedCalendarImpl($calendarInfo, $this->calDavBackend);
}
return $iCalendars;
}

View file

@ -7,6 +7,11 @@
*/
namespace OCA\DAV\CalDAV;
use OCA\DAV\CalDAV\Federation\FederatedCalendarFactory;
use OCA\DAV\CalDAV\Federation\RemoteUserCalendarHome;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCP\IConfig;
use OCP\IL10N;
use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Backend;
use Sabre\DAVACL\PrincipalBackend;
@ -19,15 +24,30 @@ class CalendarRoot extends \Sabre\CalDAV\CalendarRoot {
Backend\BackendInterface $caldavBackend,
$principalPrefix,
private LoggerInterface $logger,
private IL10N $l10n,
private IConfig $config,
private FederatedCalendarFactory $federatedCalendarFactory,
) {
parent::__construct($principalBackend, $caldavBackend, $principalPrefix);
}
public function getChildForPrincipal(array $principal) {
[$prefix] = \Sabre\Uri\split($principal['uri']);
if ($prefix === RemoteUserPrincipalBackend::PRINCIPAL_PREFIX) {
return new RemoteUserCalendarHome(
$this->caldavBackend,
$principal,
$this->l10n,
$this->config,
$this->logger,
);
}
return new CalendarHome(
$this->caldavBackend,
$principal,
$this->logger,
$this->federatedCalendarFactory,
array_key_exists($principal['uri'], $this->returnCachedSubscriptions)
);
}
@ -40,6 +60,10 @@ class CalendarRoot extends \Sabre\CalDAV\CalendarRoot {
return $parts[1];
}
if ($this->principalPrefix === RemoteUserPrincipalBackend::PRINCIPAL_PREFIX) {
return 'remote-calendars';
}
return parent::getName();
}

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCP\AppFramework\Services\IAppConfig;
class CalendarFederationConfig {
public function __construct(
private readonly IAppConfig $appConfig,
) {
}
public function isFederationEnabled(): bool {
return $this->appConfig->getAppValueBool('enableCalendarFederation', true);
}
}

View file

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCP\Federation\ICloudFederationFactory;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Federation\ICloudId;
use OCP\Http\Client\IResponse;
use OCP\IURLGenerator;
use OCP\OCM\Exceptions\OCMProviderException;
class CalendarFederationNotifier {
public const NOTIFICATION_SYNC_CALENDAR = 'SYNC_CALENDAR';
public const PROP_SYNC_CALENDAR_SHARE_WITH = 'shareWith';
public const PROP_SYNC_CALENDAR_CALENDAR_URL = 'calendarUrl';
public function __construct(
private readonly ICloudFederationFactory $federationFactory,
private readonly ICloudFederationProviderManager $federationManager,
private readonly IURLGenerator $url,
) {
}
/**
* Notify a remote server to sync a calendar.
*
* @param ICloudId $shareWith The cloud id of the remote sharee.
* @return IResponse
*
* @throws OCMProviderException If sending the notification fails.
*/
public function notifySyncCalendar(
ICloudId $shareWith,
string $calendarOwner,
string $calendarName,
string $sharedSecret,
): IResponse {
$sharedWithEncoded = base64_encode($shareWith->getId());
$relativeCalendarUrl = "remote-calendars/$sharedWithEncoded/{$calendarName}_shared_by_$calendarOwner";
$calendarUrl = $this->url->linkTo('', 'remote.php') . "/dav/$relativeCalendarUrl";
$calendarUrl = $this->url->getAbsoluteURL($calendarUrl);
$notification = $this->federationFactory->getCloudFederationNotification();
$notification->setMessage(
self::NOTIFICATION_SYNC_CALENDAR,
CalendarFederationProvider::CALENDAR_RESOURCE,
CalendarFederationProvider::PROVIDER_ID,
[
'sharedSecret' => $sharedSecret,
self::PROP_SYNC_CALENDAR_SHARE_WITH => $shareWith->getId(),
self::PROP_SYNC_CALENDAR_CALENDAR_URL => $calendarUrl,
],
);
return $this->federationManager->sendCloudNotification(
$shareWith->getRemote(),
$notification,
);
}
}

View file

@ -0,0 +1,203 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\BackgroundJob\FederatedCalendarSyncJob;
use OCA\DAV\CalDAV\Federation\Protocol\CalendarFederationProtocolV1;
use OCA\DAV\CalDAV\Federation\Protocol\ICalendarFederationProtocol;
use OCA\DAV\DAV\Sharing\Backend as DavSharingBackend;
use OCP\AppFramework\Http;
use OCP\BackgroundJob\IJobList;
use OCP\Constants;
use OCP\Federation\Exceptions\BadRequestException;
use OCP\Federation\Exceptions\ProviderCouldNotAddShareException;
use OCP\Federation\ICloudFederationProvider;
use OCP\Federation\ICloudFederationShare;
use OCP\Federation\ICloudIdManager;
use OCP\Share\Exceptions\ShareNotFound;
use Psr\Log\LoggerInterface;
class CalendarFederationProvider implements ICloudFederationProvider {
public const PROVIDER_ID = 'calendar';
public const CALENDAR_RESOURCE = 'calendar';
public const USER_SHARE_TYPE = 'user';
public function __construct(
private readonly LoggerInterface $logger,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
private readonly CalendarFederationConfig $calendarFederationConfig,
private readonly IJobList $jobList,
private readonly ICloudIdManager $cloudIdManager,
) {
}
public function getShareType(): string {
return self::PROVIDER_ID;
}
public function shareReceived(ICloudFederationShare $share): string {
if (!$this->calendarFederationConfig->isFederationEnabled()) {
$this->logger->debug('Received a federation invite but federation is disabled');
throw new ProviderCouldNotAddShareException(
'Server does not support calendar federation',
'',
Http::STATUS_SERVICE_UNAVAILABLE,
);
}
if (!in_array($share->getShareType(), $this->getSupportedShareTypes(), true)) {
$this->logger->debug('Received a federation invite for invalid share type');
throw new ProviderCouldNotAddShareException(
'Support for sharing with non-users not implemented yet',
'',
Http::STATUS_NOT_IMPLEMENTED,
);
// TODO: Implement group shares
}
$rawProtocol = $share->getProtocol();
switch ($rawProtocol[ICalendarFederationProtocol::PROP_VERSION]) {
case CalendarFederationProtocolV1::VERSION:
try {
$protocol = CalendarFederationProtocolV1::parse($rawProtocol);
} catch (Protocol\CalendarProtocolParseException $e) {
throw new ProviderCouldNotAddShareException(
'Invalid protocol data (v1)',
'',
Http::STATUS_BAD_REQUEST,
);
}
$calendarUrl = $protocol->getUrl();
$displayName = $protocol->getDisplayName();
$color = $protocol->getColor();
$access = $protocol->getAccess();
$components = $protocol->getComponents();
break;
default:
throw new ProviderCouldNotAddShareException(
'Unknown protocol version',
'',
Http::STATUS_BAD_REQUEST,
);
}
if (!$calendarUrl || !$displayName) {
throw new ProviderCouldNotAddShareException(
'Incomplete protocol data',
'',
Http::STATUS_BAD_REQUEST,
);
}
// TODO: implement read-write sharing
$permissions = match ($access) {
DavSharingBackend::ACCESS_READ => Constants::PERMISSION_READ,
default => throw new ProviderCouldNotAddShareException(
"Unsupported access value: $access",
'',
Http::STATUS_BAD_REQUEST,
),
};
// The calendar uri is the local name of the calendar. As such it must not contain slashes.
// Just use the hashed url for simplicity here.
// Example: calendars/foo-bar-user/<calendar-uri>
$calendarUri = hash('md5', $calendarUrl);
$sharedWithPrincipal = 'principals/users/' . $share->getShareWith();
// Delete existing incoming federated share first
$this->federatedCalendarMapper->deleteByUri($sharedWithPrincipal, $calendarUri);
$calendar = new FederatedCalendarEntity();
$calendar->setPrincipaluri($sharedWithPrincipal);
$calendar->setUri($calendarUri);
$calendar->setRemoteUrl($calendarUrl);
$calendar->setDisplayName($displayName);
$calendar->setColor($color);
$calendar->setToken($share->getShareSecret());
$calendar->setSharedBy($share->getSharedBy());
$calendar->setSharedByDisplayName($share->getSharedByDisplayName());
$calendar->setPermissions($permissions);
$calendar->setComponents($components);
$calendar = $this->federatedCalendarMapper->insert($calendar);
$this->jobList->add(FederatedCalendarSyncJob::class, [
FederatedCalendarSyncJob::ARGUMENT_ID => $calendar->getId(),
]);
return (string)$calendar->getId();
}
public function notificationReceived(
$notificationType,
$providerId,
array $notification,
): array {
if ($providerId !== self::PROVIDER_ID) {
throw new BadRequestException(['providerId']);
}
switch ($notificationType) {
case CalendarFederationNotifier::NOTIFICATION_SYNC_CALENDAR:
return $this->handleSyncCalendarNotification($notification);
default:
return [];
}
}
/**
* @return string[]
*/
public function getSupportedShareTypes(): array {
return [self::USER_SHARE_TYPE];
}
/**
* @throws BadRequestException If notification props are missing.
* @throws ShareNotFound If the notification is not related to a known share.
*/
private function handleSyncCalendarNotification(array $notification): array {
$sharedSecret = $notification['sharedSecret'];
$shareWithRaw = $notification[CalendarFederationNotifier::PROP_SYNC_CALENDAR_SHARE_WITH] ?? null;
$calendarUrl = $notification[CalendarFederationNotifier::PROP_SYNC_CALENDAR_CALENDAR_URL] ?? null;
if ($shareWithRaw === null || $shareWithRaw === '') {
throw new BadRequestException([CalendarFederationNotifier::PROP_SYNC_CALENDAR_SHARE_WITH]);
}
if ($calendarUrl === null || $calendarUrl === '') {
throw new BadRequestException([CalendarFederationNotifier::PROP_SYNC_CALENDAR_CALENDAR_URL]);
}
try {
$shareWith = $this->cloudIdManager->resolveCloudId($shareWithRaw);
} catch (\InvalidArgumentException $e) {
throw new ShareNotFound('Invalid sharee cloud id');
}
$calendars = $this->federatedCalendarMapper->findByRemoteUrl(
$calendarUrl,
'principals/users/' . $shareWith->getUser(),
$sharedSecret,
);
if (empty($calendars)) {
throw new ShareNotFound('Calendar is not shared with the sharee');
}
foreach ($calendars as $calendar) {
$this->jobList->add(FederatedCalendarSyncJob::class, [
FederatedCalendarSyncJob::ARGUMENT_ID => $calendar->getId(),
]);
}
return [];
}
}

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Calendar;
use OCP\IConfig;
use OCP\IL10N;
use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Backend;
class FederatedCalendar extends Calendar {
public function __construct(
Backend\BackendInterface $caldavBackend,
$calendarInfo,
IL10N $l10n,
IConfig $config,
LoggerInterface $logger,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
) {
parent::__construct($caldavBackend, $calendarInfo, $l10n, $config, $logger);
}
public function delete() {
$this->federatedCalendarMapper->deleteById($this->getResourceId());
}
protected function getCalendarType(): int {
return CalDavBackend::CALENDAR_TYPE_FEDERATED;
}
}

View file

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCA\DAV\DAV\Sharing\SharingMapper;
use OCP\Defaults;
use Sabre\DAV\Auth\Backend\BackendInterface;
use Sabre\HTTP\Auth\Basic as BasicAuth;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
class FederatedCalendarAuth implements BackendInterface {
private readonly string $realm;
public function __construct(
private readonly SharingMapper $sharingMapper,
) {
$defaults = new Defaults();
$this->realm = $defaults->getName();
}
/**
* @return string|null A principal uri if the given combination of user and pass is valid and null otherwise.
*/
private function validateUserPass(
string $requestPath,
string $username,
string $password,
): ?string {
$remoteUserPrincipalUri = RemoteUserPrincipalBackend::PRINCIPAL_PREFIX . "/$username";
[, $remoteUserPrincipalId] = \Sabre\Uri\split($remoteUserPrincipalUri);
$rows = $this->sharingMapper->getSharedCalendarsForRemoteUser(
$remoteUserPrincipalUri,
$password,
);
// Is the requested calendar actually shared with the remote user?
foreach ($rows as $row) {
$ownerPrincipalUri = $row['principaluri'];
[, $ownerUserId] = \Sabre\Uri\split($ownerPrincipalUri);
$shareUri = $row['uri'] . '_shared_by_' . $ownerUserId;
if (str_starts_with($requestPath, "remote-calendars/$remoteUserPrincipalId/$shareUri")) {
// Yes? -> return early
return $remoteUserPrincipalUri;
}
}
return null;
}
public function check(RequestInterface $request, ResponseInterface $response): array {
if (!str_starts_with($request->getPath(), 'remote-calendars/')) {
return [false, 'This request is not for a federated calendar'];
}
$auth = new BasicAuth($this->realm, $request, $response);
$userpass = $auth->getCredentials();
if ($userpass === null || count($userpass) !== 2) {
return [false, "No 'Authorization: Basic' header found. Either the client didn't send one, or the server is misconfigured"];
}
$principal = $this->validateUserPass($request->getPath(), $userpass[0], $userpass[1]);
if ($principal === null) {
return [false, 'Username or password was incorrect'];
}
return [true, $principal];
}
public function challenge(RequestInterface $request, ResponseInterface $response): void {
// No special challenge is needed here
}
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCP\AppFramework\Db\Entity;
use OCP\DB\Types;
use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
/**
* @method string getPrincipaluri()
* @method void setPrincipaluri(string $principaluri)
* @method string getUri()
* @method void setUri(string $uri)
* @method string getDisplayName()
* @method void setDisplayName(string $displayName)
* @method string|null getColor()
* @method void setColor(string|null $color)
* @method int getPermissions()
* @method void setPermissions(int $permissions)
* @method int getSyncToken()
* @method void setSyncToken(int $syncToken)
* @method string getRemoteUrl()
* @method void setRemoteUrl(string $remoteUrl)
* @method string getToken()
* @method void setToken(string $token)
* @method int|null getLastSync()
* @method void setLastSync(int|null $lastSync)
* @method string getSharedBy()
* @method void setSharedBy(string $sharedBy)
* @method string getSharedByDisplayName()
* @method void setSharedByDisplayName(string $sharedByDisplayName)
* @method string getComponents()
* @method void setComponents(string $components)
*/
class FederatedCalendarEntity extends Entity {
protected string $principaluri = '';
protected string $uri = '';
protected string $displayName = '';
protected ?string $color = null;
protected int $permissions = 0;
protected int $syncToken = 0;
protected string $remoteUrl = '';
protected string $token = '';
protected ?int $lastSync = null;
protected string $sharedBy = '';
protected string $sharedByDisplayName = '';
protected string $components = '';
public function __construct() {
$this->addType('principaluri', Types::STRING);
$this->addType('uri', Types::STRING);
$this->addType('color', Types::STRING);
$this->addType('displayName', Types::STRING);
$this->addType('permissions', Types::INTEGER);
$this->addType('syncToken', Types::INTEGER);
$this->addType('remoteUrl', Types::STRING);
$this->addType('token', Types::STRING);
$this->addType('lastSync', Types::INTEGER);
$this->addType('sharedBy', Types::STRING);
$this->addType('sharedByDisplayName', Types::STRING);
$this->addType('components', Types::STRING);
}
public function getSyncTokenForSabre(): string {
return 'http://sabre.io/ns/sync/' . $this->getSyncToken();
}
public function getSharedByPrincipal(): string {
return RemoteUserPrincipalBackend::PRINCIPAL_PREFIX . '/' . base64_encode($this->getSharedBy());
}
public function getSupportedCalendarComponentSet(): SupportedCalendarComponentSet {
$components = explode(',', $this->getComponents());
return new SupportedCalendarComponentSet($components);
}
public function toCalendarInfo(): array {
return [
'id' => $this->getId(),
'uri' => $this->getUri(),
'principaluri' => $this->getPrincipaluri(),
'federated' => 1,
'{DAV:}displayname' => $this->getDisplayName(),
'{http://sabredav.org/ns}sync-token' => $this->getSyncToken(),
'{' . \Sabre\CalDAV\Plugin::NS_CALENDARSERVER . '}getctag' => $this->getSyncTokenForSabre(),
'{' . \Sabre\CalDAV\Plugin::NS_CALDAV . '}supported-calendar-component-set' => $this->getSupportedCalendarComponentSet(),
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->getSharedByPrincipal(),
// TODO: implement read-write sharing
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only' => 1
];
}
}

View file

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 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\CalDAV\CalDavBackend;
use OCP\IConfig;
use OCP\IL10N;
use OCP\L10N\IFactory as IL10NFactory;
use Psr\Log\LoggerInterface;
class FederatedCalendarFactory {
private readonly IL10N $l10n;
public function __construct(
private readonly CalDavBackend $caldavBackend,
private readonly IConfig $config,
private readonly LoggerInterface $logger,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
IL10NFactory $l10nFactory,
) {
$this->l10n = $l10nFactory->get(Application::APP_ID);
}
public function createFederatedCalendar(array $calendarInfo): FederatedCalendar {
return new FederatedCalendar(
$this->caldavBackend,
$calendarInfo,
$this->l10n,
$this->config,
$this->logger,
$this->federatedCalendarMapper,
);
}
}

View file

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\CalDAV\CalDavBackend;
use OCP\Calendar\ICalendar;
use OCP\Calendar\ICalendarIsEnabled;
use OCP\Calendar\ICalendarIsShared;
use OCP\Calendar\ICalendarIsWritable;
use OCP\Constants;
class FederatedCalendarImpl implements ICalendar, ICalendarIsShared, ICalendarIsWritable, ICalendarIsEnabled {
public function __construct(
private readonly array $calendarInfo,
private readonly CalDavBackend $calDavBackend,
) {
}
public function getKey(): string {
return (string)$this->calendarInfo['id'];
}
public function getUri(): string {
return $this->calendarInfo['uri'];
}
public function getDisplayName(): ?string {
return $this->calendarInfo['{DAV:}displayname'];
}
public function getDisplayColor(): ?string {
return $this->calendarInfo['{http://apple.com/ns/ical/}calendar-color'];
}
public function search(string $pattern, array $searchProperties = [], array $options = [], ?int $limit = null, ?int $offset = null): array {
return $this->calDavBackend->search(
$this->calendarInfo,
$pattern,
$searchProperties,
$options,
$limit,
$offset,
);
}
public function getPermissions(): int {
// TODO: implement read-write sharing
return Constants::PERMISSION_READ;
}
public function isDeleted(): bool {
return false;
}
public function isShared(): bool {
return true;
}
public function isWritable(): bool {
return false;
}
public function isEnabled(): bool {
return $this->calendarInfo['{http://owncloud.org/ns}calendar-enabled'] ?? true;
}
}

View file

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\AppFramework\Db\QBMapper;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
/** @template-extends QBMapper<FederatedCalendarEntity> */
class FederatedCalendarMapper extends QBMapper {
public const TABLE_NAME = 'calendars_federated';
public function __construct(
IDBConnection $db,
private readonly ITimeFactory $time,
) {
parent::__construct($db, self::TABLE_NAME, FederatedCalendarEntity::class);
}
/**
* @throws DoesNotExistException If there is no federated calendar with the given id.
*/
public function find(int $id): FederatedCalendarEntity {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from(self::TABLE_NAME)
->where($qb->expr()->eq(
'id',
$qb->createNamedParameter($id, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
));
return $this->findEntity($qb);
}
/**
* @return FederatedCalendarEntity[]
*/
public function findByPrincipalUri(string $principalUri): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from(self::TABLE_NAME)
->where($qb->expr()->eq(
'principaluri',
$qb->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
));
return $this->findEntities($qb);
}
public function findByUri(string $principalUri, string $uri): ?FederatedCalendarEntity {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from(self::TABLE_NAME)
->where($qb->expr()->eq(
'principaluri',
$qb->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
))
->andWhere($qb->expr()->eq(
'uri',
$qb->createNamedParameter($uri, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
));
try {
return $this->findEntity($qb);
} catch (DoesNotExistException $e) {
return null;
} catch (MultipleObjectsReturnedException $e) {
// Should never happen
return null;
}
}
/**
* @return FederatedCalendarEntity[]
*/
public function findUnsyncedSinceBefore(int $beforeTimestamp): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from(self::TABLE_NAME)
->where($qb->expr()->lt(
'last_sync',
$qb->createNamedParameter($beforeTimestamp, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
))
// Omit unsynced calendars for now as they are synced by a separate job
->andWhere($qb->expr()->isNotNull('last_sync'));
return $this->findEntities($qb);
}
public function deleteById(int $id): void {
$qb = $this->db->getQueryBuilder();
$qb->delete(self::TABLE_NAME)
->where($qb->expr()->eq(
'id',
$qb->createNamedParameter($id, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
));
$qb->executeStatement();
}
public function updateSyncTime(int $id): void {
$now = $this->time->getTime();
$qb = $this->db->getQueryBuilder();
$qb->update(self::TABLE_NAME)
->set('last_sync', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq(
'id',
$qb->createNamedParameter($id, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
));
$qb->executeStatement();
}
public function updateSyncTokenAndTime(int $id, int $syncToken): void {
$now = $this->time->getTime();
$qb = $this->db->getQueryBuilder();
$qb->update(self::TABLE_NAME)
->set('sync_token', $qb->createNamedParameter($syncToken, IQueryBuilder::PARAM_INT))
->set('last_sync', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))
->where($qb->expr()->eq(
'id',
$qb->createNamedParameter($id, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
));
$qb->executeStatement();
}
/**
* @return \Generator<mixed, FederatedCalendarEntity>
*/
public function findAll(): \Generator {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from(self::TABLE_NAME);
$result = $qb->executeQuery();
while ($row = $result->fetch()) {
yield $this->mapRowToEntity($row);
}
$result->closeCursor();
}
public function countAll(): int {
$qb = $this->db->getQueryBuilder();
$qb->select($qb->func()->count('*'))
->from(self::TABLE_NAME);
$result = $qb->executeQuery();
$count = (int)$result->fetchOne();
$result->closeCursor();
return $count;
}
public function deleteByUri(string $principalUri, string $uri): void {
$qb = $this->db->getQueryBuilder();
$qb->delete(self::TABLE_NAME)
->where($qb->expr()->eq(
'principaluri',
$qb->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
))
->andWhere($qb->expr()->eq(
'uri',
$qb->createNamedParameter($uri, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
));
$qb->executeStatement();
}
/**
* @return FederatedCalendarEntity[]
*/
public function findByRemoteUrl(string $remoteUrl, string $principalUri, string $token): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from(self::TABLE_NAME)
->where($qb->expr()->eq(
'remote_url',
$qb->createNamedParameter($remoteUrl, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
))
->andWhere($qb->expr()->eq(
'principaluri',
$qb->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
))
->andWhere($qb->expr()->eq(
'token',
$qb->createNamedParameter($token, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
));
return $this->findEntities($qb);
}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\CalDAV\SyncService as CalDavSyncService;
use OCP\Federation\ICloudIdManager;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Log\LoggerInterface;
class FederatedCalendarSyncService {
private const SYNC_TOKEN_PREFIX = 'http://sabre.io/ns/sync/';
public function __construct(
private readonly FederatedCalendarMapper $federatedCalendarMapper,
private readonly LoggerInterface $logger,
private readonly CalDavSyncService $syncService,
private readonly ICloudIdManager $cloudIdManager,
) {
}
/**
* @return int Downloaded event count (created or updated).
*
* @throws ClientExceptionInterface If syncing the calendar fails.
*/
public function syncOne(FederatedCalendarEntity $calendar): int {
[,, $sharedWith] = explode('/', $calendar->getPrincipaluri());
$calDavUser = $this->cloudIdManager->getCloudId($sharedWith, null)->getId();
$remoteUrl = $calendar->getRemoteUrl();
$syncToken = $calendar->getSyncTokenForSabre();
// Need to encode the cloud id as it might contain a colon which is not allowed in basic
// auth according to RFC 7617
$calDavUser = base64_encode($calDavUser);
$syncResponse = $this->syncService->syncRemoteCalendar(
$remoteUrl,
$calDavUser,
$calendar->getToken(),
$syncToken,
$calendar,
);
$newSyncToken = $syncResponse->getSyncToken();
// Check sync token format and extract the actual sync token integer
$matches = [];
if (!preg_match('/^http:\/\/sabre\.io\/ns\/sync\/([0-9]+)$/', $newSyncToken, $matches)) {
$this->logger->error("Failed to sync federated calendar at $remoteUrl: New sync token has unexpected format: $newSyncToken", [
'calendar' => $calendar->toCalendarInfo(),
'newSyncToken' => $newSyncToken,
]);
return 0;
}
$newSyncToken = (int)$matches[1];
if ($newSyncToken !== $calendar->getSyncToken()) {
$this->federatedCalendarMapper->updateSyncTokenAndTime(
$calendar->getId(),
$newSyncToken,
);
} else {
$this->logger->debug("Sync Token for $remoteUrl unchanged from previous sync");
$this->federatedCalendarMapper->updateSyncTime($calendar->getId());
}
return $syncResponse->getDownloadedEvents();
}
}

View file

@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\CalDAV\Federation\Protocol\CalendarFederationProtocolV1;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCA\DAV\DAV\Sharing\IShareable;
use OCA\DAV\DAV\Sharing\SharingMapper;
use OCP\AppFramework\Http;
use OCP\Federation\ICloudFederationFactory;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\IURLGenerator;
use OCP\IUserManager;
use OCP\OCM\Exceptions\OCMProviderException;
use OCP\Security\ISecureRandom;
use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Calendar;
// TODO: Convert this to an abstract service like the addressbook/calendar sharing services once we
// support addressbook federation as well.
class FederationSharingService {
public function __construct(
private readonly ICloudFederationProviderManager $federationManager,
private readonly ICloudFederationFactory $federationFactory,
private readonly IUserManager $userManager,
private readonly IURLGenerator $url,
private readonly LoggerInterface $logger,
private readonly ISecureRandom $random,
private readonly SharingMapper $sharingMapper,
) {
}
/**
* Decode a (base64) encoded remote user principal and return the remote user's cloud id. Will
* return null if the given principal is not belonging to a remote user (or has an invalid
* format).
*
* The remote user/cloud id needs to be encoded as it might contain slashes.
*/
private function decodeRemoteUserPrincipal(string $principal): ?string {
// Expected format: principals/remote-users/abcdef123
[$prefix, $collection, $encodedId] = explode('/', $principal);
if ($prefix !== 'principals' || $collection !== 'remote-users') {
return null;
}
$decodedId = base64_decode($encodedId);
if (!is_string($decodedId)) {
return null;
}
return $decodedId;
}
/**
* Send a calendar share to a remote instance and create a federated share locally if it is
* accepted.
*
* @param IShareable $shareable The calendar to be shared.
* @param string $principal The principal to share with (should be a remote user principal).
* @param int $access The access level. The remote serve might reject it.
*/
public function shareWith(IShareable $shareable, string $principal, int $access): void {
$baseError = 'Failed to create federated calendar share: ';
// 1. Validate share data
$shareWith = $this->decodeRemoteUserPrincipal($principal);
if ($shareWith === null) {
$this->logger->error($baseError . 'Principal of sharee is not belonging to a remote user', [
'shareable' => $shareable->getName(),
'encodedShareWith' => $principal,
]);
return;
}
[,, $ownerUid] = explode('/', $shareable->getOwner());
$owner = $this->userManager->get($ownerUid);
if ($owner === null) {
$this->logger->error($baseError . 'Shareable is not owned by a user on this server', [
'shareable' => $shareable->getName(),
'shareWith' => $shareWith,
]);
return;
}
// Need a calendar instance to extract properties for the protocol
$calendar = $shareable;
if (!($calendar instanceof Calendar)) {
$this->logger->error($baseError . 'Shareable is not a calendar', [
'shareable' => $shareable->getName(),
'owner' => $owner,
'shareWith' => $shareWith,
]);
return;
}
$getProp = static fn (string $prop) => $calendar->getProperties([$prop])[$prop] ?? null;
$displayName = $getProp('{DAV:}displayname') ?? '';
$token = $this->random->generate(32);
$share = $this->federationFactory->getCloudFederationShare(
$shareWith,
$shareable->getName(),
$displayName,
CalendarFederationProvider::PROVIDER_ID,
// Resharing is not possible so the owner is always the sharer
$owner->getCloudId(),
$owner->getDisplayName(),
$owner->getCloudId(),
$owner->getDisplayName(),
$token,
CalendarFederationProvider::USER_SHARE_TYPE,
CalendarFederationProvider::CALENDAR_RESOURCE,
);
// 2. Send share to federated instance
$shareWithEncoded = base64_encode($shareWith);
$relativeCalendarUrl = "remote-calendars/$shareWithEncoded/" . $calendar->getName() . '_shared_by_' . $ownerUid;
$calendarUrl = $this->url->linkTo('', 'remote.php') . "/dav/$relativeCalendarUrl";
$calendarUrl = $this->url->getAbsoluteURL($calendarUrl);
$protocol = new CalendarFederationProtocolV1();
$protocol->setUrl($calendarUrl);
$protocol->setDisplayName($displayName);
$protocol->setColor($getProp('{http://apple.com/ns/ical/}calendar-color'));
$protocol->setAccess($access);
$protocol->setComponents(implode(',', $getProp(
'{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set')?->getValue() ?? [],
));
$share->setProtocol([
// Preserve original protocol contents
...$share->getProtocol(),
...$protocol->toProtocol(),
]);
try {
$response = $this->federationManager->sendCloudShare($share);
} catch (OCMProviderException $e) {
$this->logger->error($baseError . $e->getMessage(), [
'exception' => $e,
'owner' => $owner->getUID(),
'calendar' => $shareable->getName(),
'shareWith' => $shareWith,
]);
return;
}
if ($response->getStatusCode() !== Http::STATUS_CREATED) {
$this->logger->error($baseError . 'Server replied with code ' . $response->getStatusCode(), [
'responseBody' => $response->getBody(),
'owner' => $owner->getUID(),
'calendar' => $shareable->getName(),
'shareWith' => $shareWith,
]);
return;
}
// 3. Create a local DAV share to track the token for authentication
$shareWithPrincipalUri = RemoteUserPrincipalBackend::PRINCIPAL_PREFIX . '/' . $shareWithEncoded;
$this->sharingMapper->deleteShare(
$shareable->getResourceId(),
'calendar',
$shareWithPrincipalUri,
);
$this->sharingMapper->shareWithToken(
$shareable->getResourceId(),
'calendar',
$access,
$shareWithPrincipalUri,
$token,
);
}
}

View file

@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation\Protocol;
class CalendarFederationProtocolV1 implements ICalendarFederationProtocol {
public const VERSION = 'v1';
public const PROP_URL = 'url';
public const PROP_DISPLAY_NAME = 'displayName';
public const PROP_COLOR = 'color';
public const PROP_ACCESS = 'access';
public const PROP_COMPONENTS = 'components';
private string $url = '';
private string $displayName = '';
private ?string $color = null;
private int $access = 0;
private string $components = '';
/**
* @throws CalendarProtocolParseException If parsing the raw protocol array fails.
*/
public static function parse(array $rawProtocol): self {
if ($rawProtocol[self::PROP_VERSION] !== self::VERSION) {
throw new CalendarProtocolParseException('Unknown protocol version');
}
$url = $rawProtocol[self::PROP_URL] ?? null;
if (!is_string($url)) {
throw new CalendarProtocolParseException('URL is missing or not a string');
}
$displayName = $rawProtocol[self::PROP_DISPLAY_NAME] ?? null;
if (!is_string($displayName)) {
throw new CalendarProtocolParseException('Display name is missing or not a string');
}
$color = $rawProtocol[self::PROP_COLOR] ?? null;
if (!is_string($color) && $color !== null) {
throw new CalendarProtocolParseException('Color is set but not a string');
}
$access = $rawProtocol[self::PROP_ACCESS] ?? null;
if (!is_int($access)) {
throw new CalendarProtocolParseException('Access is missing or not an integer');
}
$components = $rawProtocol[self::PROP_COMPONENTS] ?? null;
if (!is_string($components)) {
throw new CalendarProtocolParseException('Supported calendar components are missing or not a string');
}
$protocol = new self();
$protocol->setUrl($url);
$protocol->setDisplayName($displayName);
$protocol->setColor($color);
$protocol->setAccess($access);
$protocol->setComponents($components);
return $protocol;
}
#[\Override]
public function toProtocol(): array {
return [
self::PROP_VERSION => $this->getVersion(),
self::PROP_URL => $this->getUrl(),
self::PROP_DISPLAY_NAME => $this->getDisplayName(),
self::PROP_COLOR => $this->getColor(),
self::PROP_ACCESS => $this->getAccess(),
self::PROP_COMPONENTS => $this->getComponents(),
];
}
#[\Override]
public function getVersion(): string {
return self::VERSION;
}
public function getUrl(): string {
return $this->url;
}
public function setUrl(string $url): void {
$this->url = $url;
}
public function getDisplayName(): string {
return $this->displayName;
}
public function setDisplayName(string $displayName): void {
$this->displayName = $displayName;
}
public function getColor(): ?string {
return $this->color;
}
public function setColor(?string $color): void {
$this->color = $color;
}
public function getAccess(): int {
return $this->access;
}
public function setAccess(int $access): void {
$this->access = $access;
}
public function getComponents(): string {
return $this->components;
}
public function setComponents(string $components): void {
$this->components = $components;
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation\Protocol;
class CalendarProtocolParseException extends \Exception {
}

View file

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation\Protocol;
interface ICalendarFederationProtocol {
public const PROP_VERSION = 'version';
/**
* Get the version of this protocol implementation.
*/
public function getVersion(): string;
/**
* Convert the protocol to an associative array to be sent to a remote instance.
* The resulting array still needs to be merged with the base protocol from the share!
*/
public function toProtocol(): array;
}

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\CalDAV\Calendar;
use OCP\IConfig;
use OCP\IL10N;
use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Backend;
use Sabre\CalDAV\CalendarHome;
use Sabre\DAV\Exception\NotFound;
class RemoteUserCalendarHome extends CalendarHome {
public function __construct(
Backend\BackendInterface $caldavBackend,
$principalInfo,
private readonly IL10N $l10n,
private readonly IConfig $config,
private readonly LoggerInterface $logger,
) {
parent::__construct($caldavBackend, $principalInfo);
}
public function getChild($name) {
// Remote users can only have incoming shared calendars so we can skip the rest of a regular
// calendar home
foreach ($this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']) as $calendar) {
if ($calendar['uri'] === $name) {
return new Calendar(
$this->caldavBackend,
$calendar,
$this->l10n,
$this->config,
$this->logger,
);
}
}
throw new NotFound("Node with name $name could not be found");
}
public function getChildren(): array {
$objects = [];
// Remote users can only have incoming shared calendars so we can skip the rest of a regular
// calendar home
$calendars = $this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']);
foreach ($calendars as $calendar) {
$objects[] = new Calendar(
$this->caldavBackend,
$calendar,
$this->l10n,
$this->config,
$this->logger,
);
}
return $objects;
}
}

View file

@ -8,7 +8,9 @@ declare(strict_types=1);
namespace OCA\DAV\CalDAV\Sharing;
use OCA\DAV\CalDAV\Federation\FederationSharingService;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCA\DAV\DAV\Sharing\Backend as SharingBackend;
use OCP\ICacheFactory;
use OCP\IGroupManager;
@ -21,10 +23,12 @@ class Backend extends SharingBackend {
private IUserManager $userManager,
private IGroupManager $groupManager,
private Principal $principalBackend,
private RemoteUserPrincipalBackend $remoteUserPrincipalBackend,
private ICacheFactory $cacheFactory,
private Service $service,
private FederationSharingService $federationSharingService,
private LoggerInterface $logger,
) {
parent::__construct($this->userManager, $this->groupManager, $this->principalBackend, $this->cacheFactory, $this->service, $this->logger);
parent::__construct($this->userManager, $this->groupManager, $this->principalBackend, $this->remoteUserPrincipalBackend, $this->cacheFactory, $this->service, $this->federationSharingService, $this->logger);
}
}

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\Service\ASyncService;
use OCP\AppFramework\Db\TTransactional;
use OCP\AppFramework\Http;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IDBConnection;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Log\LoggerInterface;
class SyncService extends ASyncService {
use TTransactional;
public function __construct(
IClientService $clientService,
IConfig $config,
private readonly CalDavBackend $backend,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
private readonly IDBConnection $dbConnection,
private readonly LoggerInterface $logger,
) {
parent::__construct($clientService, $config);
}
/**
* @param string $url
* @param string $username
* @param string $sharedSecret
* @param string|null $syncToken
* @param FederatedCalendarEntity $calendar
*/
public function syncRemoteCalendar(
string $url,
string $username,
string $sharedSecret,
?string $syncToken,
FederatedCalendarEntity $calendar,
): SyncServiceResult {
try {
$response = $this->requestSyncReport($url, $username, $sharedSecret, $syncToken);
} catch (ClientExceptionInterface $ex) {
if ($ex->getCode() === Http::STATUS_UNAUTHORIZED) {
// Remote server revoked access to the calendar => remove it
$this->federatedCalendarMapper->delete($calendar);
$this->logger->error("Authorization failed, remove federated calendar: $url", [
'app' => 'dav',
]);
throw $ex;
}
$this->logger->error('Client exception:', ['app' => 'dav', 'exception' => $ex]);
throw $ex;
}
// TODO: use multi-get for download
$downloadedEvents = 0;
foreach ($response['response'] as $resource => $status) {
$objectUri = basename($resource);
if (isset($status[200])) {
$absoluteUrl = $this->prepareUri($url, $resource);
$vCard = $this->download($absoluteUrl, $username, $sharedSecret);
$this->atomic(function () use ($calendar, $objectUri, $vCard): void {
$existingObject = $this->backend->getCalendarObject($calendar->getId(), $objectUri, CalDavBackend::CALENDAR_TYPE_FEDERATED);
if (!$existingObject) {
$this->backend->createCalendarObject($calendar->getId(), $objectUri, $vCard, CalDavBackend::CALENDAR_TYPE_FEDERATED);
} else {
$this->backend->updateCalendarObject($calendar->getId(), $objectUri, $vCard, CalDavBackend::CALENDAR_TYPE_FEDERATED);
}
}, $this->dbConnection);
$downloadedEvents++;
} else {
$this->backend->deleteCalendarObject($calendar->getId(), $objectUri, CalDavBackend::CALENDAR_TYPE_FEDERATED, true);
}
}
return new SyncServiceResult(
$response['token'],
$downloadedEvents,
);
}
}

View file

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\CalDAV;
final class SyncServiceResult {
public function __construct(
private readonly string $syncToken,
private readonly int $downloadedEvents,
) {
}
public function getSyncToken(): string {
return $this->syncToken;
}
public function getDownloadedEvents(): int {
return $this->downloadedEvents;
}
}

View file

@ -8,7 +8,9 @@ declare(strict_types=1);
namespace OCA\DAV\CardDAV\Sharing;
use OCA\DAV\CalDAV\Federation\FederationSharingService;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCA\DAV\DAV\Sharing\Backend as SharingBackend;
use OCP\ICacheFactory;
use OCP\IGroupManager;
@ -20,10 +22,12 @@ class Backend extends SharingBackend {
private IUserManager $userManager,
private IGroupManager $groupManager,
private Principal $principalBackend,
private RemoteUserPrincipalBackend $remoteUserPrincipalBackend,
private ICacheFactory $cacheFactory,
private Service $service,
private FederationSharingService $federationSharingService,
private LoggerInterface $logger,
) {
parent::__construct($this->userManager, $this->groupManager, $this->principalBackend, $this->cacheFactory, $this->service, $this->logger);
parent::__construct($this->userManager, $this->groupManager, $this->principalBackend, $this->remoteUserPrincipalBackend, $this->cacheFactory, $this->service, $this->federationSharingService, $this->logger);
}
}

View file

@ -8,10 +8,10 @@
*/
namespace OCA\DAV\CardDAV;
use OCA\DAV\Service\ASyncService;
use OCP\AppFramework\Db\TTransactional;
use OCP\AppFramework\Http;
use OCP\DB\Exception;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use OCP\IDBConnection;
@ -19,27 +19,26 @@ use OCP\IUser;
use OCP\IUserManager;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Log\LoggerInterface;
use Sabre\DAV\Xml\Response\MultiStatus;
use Sabre\DAV\Xml\Service;
use Sabre\VObject\Reader;
use Sabre\Xml\ParseException;
use function is_null;
class SyncService {
class SyncService extends ASyncService {
use TTransactional;
private ?array $localSystemAddressBook = null;
protected string $certPath;
public function __construct(
IClientService $clientService,
IConfig $config,
private CardDavBackend $backend,
private IUserManager $userManager,
private IDBConnection $dbConnection,
private LoggerInterface $logger,
private Converter $converter,
private IClientService $clientService,
private IConfig $config,
) {
parent::__construct($clientService, $config);
$this->certPath = '';
}
@ -54,7 +53,8 @@ class SyncService {
// 2. query changes
try {
$response = $this->requestSyncReport($url, $userName, $addressBookUrl, $sharedSecret, $syncToken);
$absoluteUri = $this->prepareUri($url, $addressBookUrl);
$response = $this->requestSyncReport($absoluteUri, $userName, $sharedSecret, $syncToken);
} catch (ClientExceptionInterface $ex) {
if ($ex->getCode() === Http::STATUS_UNAUTHORIZED) {
// remote server revoked access to the address book, remove it
@ -71,7 +71,8 @@ class SyncService {
foreach ($response['response'] as $resource => $status) {
$cardUri = basename($resource);
if (isset($status[200])) {
$vCard = $this->download($url, $userName, $sharedSecret, $resource);
$absoluteUrl = $this->prepareUri($url, $resource);
$vCard = $this->download($absoluteUrl, $userName, $sharedSecret);
$this->atomic(function () use ($addressBookId, $cardUri, $vCard): void {
$existingCard = $this->backend->getCard($addressBookId, $cardUri);
if ($existingCard === false) {
@ -130,148 +131,6 @@ class SyncService {
]);
}
private function prepareUri(string $host, string $path): string {
/*
* The trailing slash is important for merging the uris.
*
* $host is stored in oc_trusted_servers.url and usually without a trailing slash.
*
* Example for a report request
*
* $host = 'https://server.internal/cloud'
* $path = 'remote.php/dav/addressbooks/system/system/system'
*
* Without the trailing slash, the webroot is missing:
* https://server.internal/remote.php/dav/addressbooks/system/system/system
*
* Example for a download request
*
* $host = 'https://server.internal/cloud'
* $path = '/cloud/remote.php/dav/addressbooks/system/system/system/Database:alice.vcf'
*
* The response from the remote usually contains the webroot already and must be normalized to:
* https://server.internal/cloud/remote.php/dav/addressbooks/system/system/system/Database:alice.vcf
*/
$host = rtrim($host, '/') . '/';
$uri = \GuzzleHttp\Psr7\UriResolver::resolve(
\GuzzleHttp\Psr7\Utils::uriFor($host),
\GuzzleHttp\Psr7\Utils::uriFor($path)
);
return (string)$uri;
}
/**
* @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool}
* @throws ClientExceptionInterface
* @throws ParseException
*/
protected function requestSyncReport(string $url, string $userName, string $addressBookUrl, string $sharedSecret, ?string $syncToken): array {
$client = $this->clientService->newClient();
$uri = $this->prepareUri($url, $addressBookUrl);
$options = [
'auth' => [$userName, $sharedSecret],
'body' => $this->buildSyncCollectionRequestBody($syncToken),
'headers' => ['Content-Type' => 'application/xml'],
'timeout' => $this->config->getSystemValueInt('carddav_sync_request_timeout', IClient::DEFAULT_REQUEST_TIMEOUT),
'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false),
];
$response = $client->request(
'REPORT',
$uri,
$options
);
$body = $response->getBody();
assert(is_string($body));
return $this->parseMultiStatus($body, $addressBookUrl);
}
protected function download(string $url, string $userName, string $sharedSecret, string $resourcePath): string {
$client = $this->clientService->newClient();
$uri = $this->prepareUri($url, $resourcePath);
$options = [
'auth' => [$userName, $sharedSecret],
'verify' => !$this->config->getSystemValue('sharing.federation.allowSelfSignedCertificates', false),
];
$response = $client->get(
$uri,
$options
);
return (string)$response->getBody();
}
private function buildSyncCollectionRequestBody(?string $syncToken): string {
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$root = $dom->createElementNS('DAV:', 'd:sync-collection');
$sync = $dom->createElement('d:sync-token', $syncToken ?? '');
$prop = $dom->createElement('d:prop');
$cont = $dom->createElement('d:getcontenttype');
$etag = $dom->createElement('d:getetag');
$prop->appendChild($cont);
$prop->appendChild($etag);
$root->appendChild($sync);
$root->appendChild($prop);
$dom->appendChild($root);
return $dom->saveXML();
}
/**
* @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool}
* @throws ParseException
*/
private function parseMultiStatus(string $body, string $addressBookUrl): array {
/** @var MultiStatus $multiStatus */
$multiStatus = (new Service())->expect('{DAV:}multistatus', $body);
$result = [];
$truncated = false;
foreach ($multiStatus->getResponses() as $response) {
$href = $response->getHref();
if ($response->getHttpStatus() === '507' && $this->isResponseForRequestUri($href, $addressBookUrl)) {
$truncated = true;
} else {
$result[$response->getHref()] = $response->getResponseProperties();
}
}
return ['response' => $result, 'token' => $multiStatus->getSyncToken(), 'truncated' => $truncated];
}
/**
* Determines whether the provided response URI corresponds to the given request URI.
*/
private function isResponseForRequestUri(string $responseUri, string $requestUri): bool {
/*
* Example response uri:
*
* /remote.php/dav/addressbooks/system/system/system/
* /cloud/remote.php/dav/addressbooks/system/system/system/ (when installed in a subdirectory)
*
* Example request uri:
*
* remote.php/dav/addressbooks/system/system/system
*
* References:
* https://github.com/nextcloud/3rdparty/blob/e0a509739b13820f0a62ff9cad5d0fede00e76ee/sabre/dav/lib/DAV/Sync/Plugin.php#L172-L174
* https://github.com/nextcloud/server/blob/b40acb34a39592070d8455eb91c5364c07928c50/apps/federation/lib/SyncFederationAddressBooks.php#L41
*/
return str_ends_with(
rtrim($responseUri, '/'),
rtrim($requestUri, '/')
);
}
/**
* @param IUser $user
*/

View file

@ -9,6 +9,7 @@ namespace OCA\DAV\Command;
use OC\KnownUser\KnownUserService;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Proxy\ProxyMapper;
use OCA\DAV\CalDAV\Sharing\Backend;
use OCA\DAV\Connector\Sabre\Principal;
@ -80,6 +81,7 @@ class CreateCalendar extends Command {
$dispatcher,
$config,
Server::get(Backend::class),
Server::get(FederatedCalendarMapper::class),
);
$caldav->createCalendar("principals/users/$user", $name, []);
return self::SUCCESS;

View file

@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\DAV;
use OCA\DAV\DAV\Sharing\SharingMapper;
use OCP\Federation\ICloudIdManager;
use Sabre\DAVACL\PrincipalBackend\BackendInterface;
class RemoteUserPrincipalBackend implements BackendInterface {
public const PRINCIPAL_PREFIX = 'principals/remote-users';
private bool $hasCachedAllChildren = false;
/** @var array<string, mixed>[] */
private array $principals = [];
/** @var array<string, array<string, mixed>|null> */
private array $principalsByPath = [];
public function __construct(
private readonly ICloudIdManager $cloudIdManager,
private readonly SharingMapper $sharingMapper,
) {
}
public function getPrincipalsByPrefix($prefixPath) {
if ($prefixPath !== self::PRINCIPAL_PREFIX) {
return [];
}
if (!$this->hasCachedAllChildren) {
$this->loadChildren();
$this->hasCachedAllChildren = true;
}
return $this->principals;
}
public function getPrincipalByPath($path) {
[$prefix] = \Sabre\Uri\split($path);
if ($prefix !== self::PRINCIPAL_PREFIX) {
return null;
}
if (isset($this->principalsByPath[$path])) {
return $this->principalsByPath[$path];
}
try {
$principal = $this->principalUriToPrincipal($path);
} catch (\Exception $e) {
$principal = null;
}
$this->principalsByPath[$path] = $principal;
return $principal;
}
public function updatePrincipal($path, \Sabre\DAV\PropPatch $propPatch) {
throw new \Sabre\DAV\Exception('Updating remote user principal is not supported');
}
public function searchPrincipals($prefixPath, array $searchProperties, $test = 'allof') {
// Searching is not supported
return [];
}
public function findByUri($uri, $principalPrefix) {
if (str_starts_with($uri, 'principal:')) {
$principal = substr($uri, strlen('principal:'));
$principal = $this->getPrincipalByPath($principal);
if ($principal !== null) {
return $principal['uri'];
}
}
return null;
}
public function getGroupMemberSet($principal) {
return [];
}
public function getGroupMembership($principal) {
// TODO: for now the group principal has only one member, the user itself
$principal = $this->getPrincipalByPath($principal);
if (!$principal) {
throw new \Sabre\DAV\Exception('Principal not found');
}
return [$principal['uri']];
}
public function setGroupMemberSet($principal, array $members) {
throw new \Sabre\DAV\Exception('Adding members to remote user is not supported');
}
/**
* @return array{'{DAV:}displayname': string, '{http://nextcloud.com/ns}cloud-id': \OCP\Federation\ICloudId, uri: string}
*/
private function principalUriToPrincipal(string $principalUri): array {
[, $name] = \Sabre\Uri\split($principalUri);
$cloudId = $this->cloudIdManager->resolveCloudId(base64_decode($name));
return [
'uri' => $principalUri,
'{DAV:}displayname' => $cloudId->getDisplayId(),
'{http://nextcloud.com/ns}cloud-id' => $cloudId,
];
}
private function loadChildren(): void {
$rows = $this->sharingMapper->getPrincipalUrisByPrefix('calendar', self::PRINCIPAL_PREFIX);
$this->principals = array_map(
fn (array $row) => $this->principalUriToPrincipal($row['principaluri']),
$rows,
);
$this->principalsByPath = [];
foreach ($this->principals as $child) {
$this->principalsByPath[$child['uri']] = $child;
}
}
}

View file

@ -8,7 +8,9 @@ declare(strict_types=1);
*/
namespace OCA\DAV\DAV\Sharing;
use OCA\DAV\CalDAV\Federation\FederationSharingService;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCP\AppFramework\Db\TTransactional;
use OCP\ICache;
use OCP\ICacheFactory;
@ -31,8 +33,13 @@ abstract class Backend {
private IUserManager $userManager,
private IGroupManager $groupManager,
private Principal $principalBackend,
private RemoteUserPrincipalBackend $remoteUserPrincipalBackend,
private ICacheFactory $cacheFactory,
private SharingService $service,
// TODO: Make `FederationSharingService` abstract once we support federated address book
// sharing. The abstract sharing backend should not take a service scoped to calendars
// by default.
private FederationSharingService $federationSharingService,
private LoggerInterface $logger,
) {
$this->shareCache = $this->cacheFactory->createInMemory();
@ -45,7 +52,9 @@ abstract class Backend {
public function updateShares(IShareable $shareable, array $add, array $remove, array $oldShares = []): void {
$this->shareCache->clear();
foreach ($add as $element) {
$principal = $this->principalBackend->findByUri($element['href'], '');
// Hacky code below ... shouldn't we check the whole (principal) root collection instead?
$principal = $this->principalBackend->findByUri($element['href'], '')
?? $this->remoteUserPrincipalBackend->findByUri($element['href'], '');
if (empty($principal)) {
continue;
}
@ -53,7 +62,7 @@ abstract class Backend {
// We need to validate manually because some principals are only virtual
// i.e. Group principals
$principalparts = explode('/', $principal, 3);
if (count($principalparts) !== 3 || $principalparts[0] !== 'principals' || !in_array($principalparts[1], ['users', 'groups', 'circles'], true)) {
if (count($principalparts) !== 3 || $principalparts[0] !== 'principals' || !in_array($principalparts[1], ['users', 'groups', 'circles', 'remote-users'], true)) {
// Invalid principal
continue;
}
@ -75,10 +84,16 @@ abstract class Backend {
$access = $element['readOnly'] ? Backend::ACCESS_READ : Backend::ACCESS_READ_WRITE;
}
$this->service->shareWith($shareable->getResourceId(), $principal, $access);
if ($principalparts[1] === 'remote-users') {
$this->federationSharingService->shareWith($shareable, $principal, $access);
} else {
$this->service->shareWith($shareable->getResourceId(), $principal, $access);
}
}
foreach ($remove as $element) {
$principal = $this->principalBackend->findByUri($element, '');
// Hacky code below ... shouldn't we check the whole (principal) root collection instead?
$principal = $this->principalBackend->findByUri($element, '')
?? $this->remoteUserPrincipalBackend->findByUri($element, '');
if (empty($principal)) {
continue;
}
@ -124,14 +139,14 @@ abstract class Backend {
$rows = $this->service->getShares($resourceId);
$shares = [];
foreach ($rows as $row) {
$p = $this->principalBackend->getPrincipalByPath($row['principaluri']);
$p = $this->getPrincipalByPath($row['principaluri']);
$shares[] = [
'href' => "principal:{$row['principaluri']}",
'commonName' => isset($p['{DAV:}displayname']) ? (string)$p['{DAV:}displayname'] : '',
'status' => 1,
'readOnly' => (int)$row['access'] === Backend::ACCESS_READ,
'{http://owncloud.org/ns}principal' => (string)$row['principaluri'],
'{http://owncloud.org/ns}group-share' => isset($p['uri']) && (str_starts_with($p['uri'], 'principals/groups') || str_starts_with($p['uri'], 'principals/circles'))
'{http://owncloud.org/ns}group-share' => isset($p['uri']) && (str_starts_with($p['uri'], 'principals/groups') || str_starts_with($p['uri'], 'principals/circles')),
];
}
$this->shareCache->set((string)$resourceId, $shares);
@ -150,7 +165,7 @@ abstract class Backend {
$sharesByResource = array_fill_keys($resourceIds, []);
foreach ($rows as $row) {
$resourceId = (int)$row['resourceid'];
$p = $this->principalBackend->getPrincipalByPath($row['principaluri']);
$p = $this->getPrincipalByPath($row['principaluri']);
$sharesByResource[$resourceId][] = [
'href' => "principal:{$row['principaluri']}",
'commonName' => isset($p['{DAV:}displayname']) ? (string)$p['{DAV:}displayname'] : '',
@ -237,4 +252,17 @@ abstract class Backend {
return count(array_intersect($memberships, $shares)) > 0;
}
public function getSharesByShareePrincipal(string $principal): array {
return $this->service->getSharesByPrincipals([$principal]);
}
private function getPrincipalByPath(string $principalUri): ?array {
// Hacky code below ... shouldn't we check the whole (principal) root collection instead?
if (str_starts_with($principalUri, RemoteUserPrincipalBackend::PRINCIPAL_PREFIX)) {
return $this->remoteUserPrincipalBackend->getPrincipalByPath($principalUri);
}
return $this->principalBackend->getPrincipalByPath($principalUri);
}
}

View file

@ -83,6 +83,19 @@ class SharingMapper {
$query->executeStatement();
}
public function shareWithToken(int $resourceId, string $resourceType, int $access, string $principal, string $token): void {
$query = $this->db->getQueryBuilder();
$query->insert('dav_shares')
->values([
'principaluri' => $query->createNamedParameter($principal),
'type' => $query->createNamedParameter($resourceType),
'access' => $query->createNamedParameter($access),
'resourceid' => $query->createNamedParameter($resourceId),
'token' => $query->createNamedParameter($token),
]);
$query->executeStatement();
}
public function deleteShare(int $resourceId, string $resourceType, string $principal): void {
$query = $this->db->getQueryBuilder();
$query->delete('dav_shares');
@ -134,4 +147,103 @@ class SharingMapper {
->andWhere($query->expr()->eq('access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT)))
->executeStatement();
}
/**
* @return array{principaluri: string}[]
* @throws \OCP\DB\Exception
*/
public function getPrincipalUrisByPrefix(string $resourceType, string $prefix): array {
$query = $this->db->getQueryBuilder();
$result = $query->selectDistinct('principaluri')
->from('dav_shares')
->where($query->expr()->like(
'principaluri',
$query->createNamedParameter("$prefix/%", IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
))
->andWhere($query->expr()->eq(
'type',
$query->createNamedParameter($resourceType, IQueryBuilder::PARAM_STR)),
IQueryBuilder::PARAM_STR,
)
->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();
return $rows;
}
/**
* @psalm-return array{uri: string, principaluri: string}[]
* @throws \OCP\DB\Exception
*/
public function getSharedCalendarsForRemoteUser(
string $remoteUserPrincipalUri,
string $token,
): array {
$qb = $this->db->getQueryBuilder();
$qb->select('c.uri', 'c.principaluri')
->from('dav_shares', 'ds')
->join('ds', 'calendars', 'c', $qb->expr()->eq(
'ds.resourceid',
'c.id',
IQueryBuilder::PARAM_INT,
))
->where($qb->expr()->eq(
'ds.type',
$qb->createNamedParameter('calendar', IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
))
->andWhere($qb->expr()->eq(
'ds.principaluri',
$qb->createNamedParameter($remoteUserPrincipalUri, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
))
->andWhere($qb->expr()->eq(
'ds.token',
$qb->createNamedParameter($token, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
));
$result = $qb->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();
return $rows;
}
/**
* @param string[] $principalUris
*
* @throws \OCP\DB\Exception
*/
public function getSharesByPrincipalsAndResource(
array $principalUris,
int $resourceId,
string $resourceType,
): array {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('dav_shares')
->where($qb->expr()->in(
'principaluri',
$qb->createNamedParameter($principalUris, IQueryBuilder::PARAM_STR_ARRAY),
IQueryBuilder::PARAM_STR_ARRAY,
))
->andWhere($qb->expr()->eq(
'resourceid',
$qb->createNamedParameter($resourceId, IQueryBuilder::PARAM_INT),
IQueryBuilder::PARAM_INT,
))
->andWhere($qb->expr()->eq(
'type',
$qb->createNamedParameter($resourceType, IQueryBuilder::PARAM_STR),
IQueryBuilder::PARAM_STR,
));
$result = $qb->executeQuery();
$rows = $result->fetchAll();
$result->closeCursor();
return $rows;
}
}

View file

@ -50,4 +50,11 @@ abstract class SharingService {
public function getSharesForIds(array $resourceIds): array {
return $this->mapper->getSharesForIds($resourceIds, $this->getResourceType());
}
/**
* @param string[] $principals
*/
public function getSharesByPrincipals(array $principals): array {
return $this->mapper->getSharesByPrincipals($principals, $this->getResourceType());
}
}

View file

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Listener;
use OCA\DAV\CalDAV\Federation\CalendarFederationNotifier;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCA\DAV\DAV\Sharing\SharingMapper;
use OCP\Calendar\Events\CalendarObjectCreatedEvent;
use OCP\Calendar\Events\CalendarObjectDeletedEvent;
use OCP\Calendar\Events\CalendarObjectUpdatedEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Federation\ICloudIdManager;
use OCP\OCM\Exceptions\OCMProviderException;
use Psr\Log\LoggerInterface;
/**
* @template-implements IEventListener<Event|CalendarObjectCreatedEvent|CalendarObjectUpdatedEvent|CalendarObjectDeletedEvent>
*/
class CalendarFederationNotificationListener implements IEventListener {
public function __construct(
private readonly ICloudIdManager $cloudIdManager,
private readonly CalendarFederationNotifier $calendarFederationNotifier,
private readonly LoggerInterface $logger,
private readonly SharingMapper $sharingMapper,
) {
}
public function handle(Event $event): void {
if (!($event instanceof CalendarObjectCreatedEvent)
&& !($event instanceof CalendarObjectUpdatedEvent)
&& !($event instanceof CalendarObjectDeletedEvent)
) {
return;
}
$remoteUserShares = array_filter($event->getShares(), function (array $share): bool {
$sharedWithPrincipal = $share['{http://owncloud.org/ns}principal'] ?? '';
[$prefix] = \Sabre\Uri\split($sharedWithPrincipal);
return $prefix === RemoteUserPrincipalBackend::PRINCIPAL_PREFIX;
});
if (empty($remoteUserShares)) {
// Not shared with any remote user
return;
}
$calendarInfo = $event->getCalendarData();
$remoteUserPrincipals = array_map(
static fn (array $share) => $share['{http://owncloud.org/ns}principal'],
$remoteUserShares,
);
$remoteShares = $this->sharingMapper->getSharesByPrincipalsAndResource(
$remoteUserPrincipals,
(int)$calendarInfo['id'],
'calendar',
);
foreach ($remoteShares as $share) {
[, $name] = \Sabre\Uri\split($share['principaluri']);
$shareWithRaw = base64_decode($name);
try {
$shareWith = $this->cloudIdManager->resolveCloudId($shareWithRaw);
} catch (\InvalidArgumentException $e) {
// Not a valid remote user principal
continue;
}
[, $sharedByUid] = \Sabre\Uri\split($calendarInfo['principaluri']);
$remoteUrl = $shareWith->getRemote();
try {
$response = $this->calendarFederationNotifier->notifySyncCalendar(
$shareWith,
$sharedByUid,
$calendarInfo['uri'],
$share['token'],
);
} catch (OCMProviderException $e) {
$this->logger->error("Failed to send SYNC_CALENDAR notification to remote $remoteUrl", [
'exception' => $e,
'shareWith' => $shareWith->getId(),
'calendarName' => $calendarInfo['uri'],
'calendarOwner' => $sharedByUid,
]);
continue;
}
if ($response->getStatusCode() < 200 || $response->getStatusCode() >= 300) {
$this->logger->error("Remote $remoteUrl rejected SYNC_CALENDAR notification", [
'statusCode' => $response->getStatusCode(),
'shareWith' => $shareWith->getId(),
'calendarName' => $calendarInfo['uri'],
'calendarOwner' => $sharedByUid,
]);
}
}
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Listener;
use OCA\DAV\CalDAV\Federation\FederatedCalendarAuth;
use OCA\DAV\Events\SabrePluginAuthInitEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Server;
use Sabre\DAV\Auth\Plugin;
/**
* @template-implements IEventListener<Event|SabrePluginAuthInitEvent>
*/
class SabrePluginAuthInitListener implements IEventListener {
public function handle(Event $event): void {
if (!($event instanceof SabrePluginAuthInitEvent)) {
return;
}
$server = $event->getServer();
$authPlugin = $server->getPlugin('auth');
if ($authPlugin instanceof Plugin) {
$authBackend = Server::get(FederatedCalendarAuth::class);
$authPlugin->addBackend($authBackend);
}
}
}

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Migration;
use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;
class Version1034Date20250605132605 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure(): ISchemaWrapper $schemaClosure
* @param array $options
* @return null|ISchemaWrapper
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
/** @var ISchemaWrapper $schema */
$schema = $schemaClosure();
$davSharesTable = $schema->getTable('dav_shares');
if (!$davSharesTable->hasColumn('token')) {
$davSharesTable->addColumn('token', Types::STRING, [
'notnull' => false,
'default' => null,
'length' => 255,
]);
}
if (!$schema->hasTable('calendars_federated')) {
$federatedCalendarsTable = $schema->createTable('calendars_federated');
$federatedCalendarsTable->addColumn('id', Types::BIGINT, [
'autoincrement' => true,
'notnull' => true,
'unsigned' => true,
]);
$federatedCalendarsTable->addColumn('display_name', Types::STRING, [
'notnull' => true,
'length' => 255,
]);
$federatedCalendarsTable->addColumn('color', Types::STRING, [
'notnull' => false,
'length' => 7,
'default' => null,
]);
$federatedCalendarsTable->addColumn('uri', Types::STRING, [
'notnull' => true,
'length' => 255,
]);
$federatedCalendarsTable->addColumn('principaluri', Types::STRING, [
'notnull' => true,
'length' => 255,
]);
$federatedCalendarsTable->addColumn('remote_Url', Types::STRING, [
'notnull' => true,
'length' => 255,
]);
$federatedCalendarsTable->addColumn('token', Types::STRING, [
'notnull' => true,
'length' => 255,
]);
$federatedCalendarsTable->addColumn('sync_token', Types::INTEGER, [
'notnull' => true,
'unsigned' => true,
'default' => 0,
]);
$federatedCalendarsTable->addColumn('last_sync', Types::BIGINT, [
'notnull' => false,
'unsigned' => true,
'default' => null,
]);
$federatedCalendarsTable->addColumn('shared_by', Types::STRING, [
'notnull' => true,
'length' => 255,
]);
$federatedCalendarsTable->addColumn('shared_by_display_name', Types::STRING, [
'notnull' => true,
'length' => 255,
]);
$federatedCalendarsTable->addColumn('components', Types::STRING, [
'notnull' => true,
'length' => 255,
]);
$federatedCalendarsTable->addColumn('permissions', Types::INTEGER, [
'notnull' => true,
]);
$federatedCalendarsTable->setPrimaryKey(['id']);
$federatedCalendarsTable->addIndex(['principaluri', 'uri'], 'fedcals_uris_index');
$federatedCalendarsTable->addIndex(['last_sync'], 'fedcals_last_sync_index');
}
return $schema;
}
}

View file

@ -11,6 +11,8 @@ use OC\KnownUser\KnownUserService;
use OCA\DAV\AppInfo\PluginManager;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\CalendarRoot;
use OCA\DAV\CalDAV\Federation\FederatedCalendarFactory;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Principal\Collection;
use OCA\DAV\CalDAV\Proxy\ProxyMapper;
use OCA\DAV\CalDAV\PublicCalendarRoot;
@ -21,6 +23,7 @@ use OCA\DAV\CardDAV\AddressBookRoot;
use OCA\DAV\CardDAV\CardDavBackend;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\GroupPrincipalBackend;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCA\DAV\DAV\SystemPrincipalBackend;
use OCA\DAV\Provisioning\Apple\AppleProvisioningNode;
use OCA\DAV\SystemTag\SystemTagsByIdCollection;
@ -59,6 +62,7 @@ class RootCollection extends SimpleCollection {
$config = Server::get(IConfig::class);
$proxyMapper = Server::get(ProxyMapper::class);
$rootFolder = Server::get(IRootFolder::class);
$federatedCalendarFactory = Server::get(FederatedCalendarFactory::class);
$userPrincipalBackend = new Principal(
$userManager,
@ -76,6 +80,7 @@ class RootCollection extends SimpleCollection {
$groupPrincipalBackend = new GroupPrincipalBackend($groupManager, $userSession, $shareManager, $config);
$calendarResourcePrincipalBackend = new ResourcePrincipalBackend($db, $userSession, $groupManager, $logger, $proxyMapper);
$calendarRoomPrincipalBackend = new RoomPrincipalBackend($db, $userSession, $groupManager, $logger, $proxyMapper);
$remoteUserPrincipalBackend = Server::get(RemoteUserPrincipalBackend::class);
// as soon as debug mode is enabled we allow listing of principals
$disableListing = !$config->getSystemValue('debug', false);
@ -88,6 +93,7 @@ class RootCollection extends SimpleCollection {
$systemPrincipals->disableListing = $disableListing;
$calendarResourcePrincipals = new Collection($calendarResourcePrincipalBackend, 'principals/calendar-resources');
$calendarRoomPrincipals = new Collection($calendarRoomPrincipalBackend, 'principals/calendar-rooms');
$remoteUserPrincipals = new Collection($remoteUserPrincipalBackend, RemoteUserPrincipalBackend::PRINCIPAL_PREFIX);
$calendarSharingBackend = Server::get(Backend::class);
$filesCollection = new Files\RootCollection($userPrincipalBackend, 'principals/users');
@ -101,14 +107,18 @@ class RootCollection extends SimpleCollection {
$dispatcher,
$config,
$calendarSharingBackend,
Server::get(FederatedCalendarMapper::class),
false,
);
$userCalendarRoot = new CalendarRoot($userPrincipalBackend, $caldavBackend, 'principals/users', $logger);
$userCalendarRoot = new CalendarRoot($userPrincipalBackend, $caldavBackend, 'principals/users', $logger, $l10n, $config, $federatedCalendarFactory);
$userCalendarRoot->disableListing = $disableListing;
$resourceCalendarRoot = new CalendarRoot($calendarResourcePrincipalBackend, $caldavBackend, 'principals/calendar-resources', $logger);
$remoteUserCalendarRoot = new CalendarRoot($remoteUserPrincipalBackend, $caldavBackend, RemoteUserPrincipalBackend::PRINCIPAL_PREFIX, $logger, $l10n, $config, $federatedCalendarFactory);
$remoteUserCalendarRoot->disableListing = $disableListing;
$resourceCalendarRoot = new CalendarRoot($calendarResourcePrincipalBackend, $caldavBackend, 'principals/calendar-resources', $logger, $l10n, $config, $federatedCalendarFactory);
$resourceCalendarRoot->disableListing = $disableListing;
$roomCalendarRoot = new CalendarRoot($calendarRoomPrincipalBackend, $caldavBackend, 'principals/calendar-rooms', $logger);
$roomCalendarRoot = new CalendarRoot($calendarRoomPrincipalBackend, $caldavBackend, 'principals/calendar-rooms', $logger, $l10n, $config, $federatedCalendarFactory);
$roomCalendarRoot->disableListing = $disableListing;
$publicCalendarRoot = new PublicCalendarRoot($caldavBackend, $l10n, $config, $logger);
@ -179,9 +189,11 @@ class RootCollection extends SimpleCollection {
$groupPrincipals,
$systemPrincipals,
$calendarResourcePrincipals,
$calendarRoomPrincipals]),
$calendarRoomPrincipals,
$remoteUserPrincipals]),
$filesCollection,
$userCalendarRoot,
$remoteUserCalendarRoot,
new SimpleCollection('system-calendars', [
$resourceCalendarRoot,
$roomCalendarRoot,

View file

@ -0,0 +1,194 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Service;
use OCP\Http\Client\IClient;
use OCP\Http\Client\IClientService;
use OCP\IConfig;
use Sabre\DAV\Xml\Response\MultiStatus;
use Sabre\DAV\Xml\Service as SabreXmlService;
use Sabre\Xml\ParseException;
/**
* Abstract sync service to sync CalDAV and CardDAV data from federated instances.
*/
abstract class ASyncService {
private ?IClient $client = null;
public function __construct(
protected IClientService $clientService,
protected IConfig $config,
) {
}
private function getClient(): IClient {
if ($this->client === null) {
$this->client = $this->clientService->newClient();
}
return $this->client;
}
protected function prepareUri(string $host, string $path): string {
/*
* The trailing slash is important for merging the uris together.
*
* $host is stored in oc_trusted_servers.url and usually without a trailing slash.
*
* Example for a report request
*
* $host = 'https://server.internal/cloud'
* $path = 'remote.php/dav/addressbooks/system/system/system'
*
* Without the trailing slash, the webroot is missing:
* https://server.internal/remote.php/dav/addressbooks/system/system/system
*
* Example for a download request
*
* $host = 'https://server.internal/cloud'
* $path = '/cloud/remote.php/dav/addressbooks/system/system/system/Database:alice.vcf'
*
* The response from the remote usually contains the webroot already and must be normalized to:
* https://server.internal/cloud/remote.php/dav/addressbooks/system/system/system/Database:alice.vcf
*/
$host = rtrim($host, '/') . '/';
$uri = \GuzzleHttp\Psr7\UriResolver::resolve(
\GuzzleHttp\Psr7\Utils::uriFor($host),
\GuzzleHttp\Psr7\Utils::uriFor($path)
);
return (string)$uri;
}
/**
* @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool}
*/
protected function requestSyncReport(
string $absoluteUrl,
string $userName,
string $sharedSecret,
?string $syncToken,
): array {
$client = $this->getClient();
$options = [
'auth' => [$userName, $sharedSecret],
'body' => $this->buildSyncCollectionRequestBody($syncToken),
'headers' => ['Content-Type' => 'application/xml'],
'timeout' => $this->config->getSystemValueInt(
'carddav_sync_request_timeout',
IClient::DEFAULT_REQUEST_TIMEOUT,
),
'verify' => !$this->config->getSystemValue(
'sharing.federation.allowSelfSignedCertificates',
false,
),
];
$response = $client->request(
'REPORT',
$absoluteUrl,
$options,
);
$body = $response->getBody();
assert(is_string($body));
return $this->parseMultiStatus($body, $absoluteUrl);
}
protected function download(
string $absoluteUrl,
string $userName,
string $sharedSecret,
): string {
$client = $this->getClient();
$options = [
'auth' => [$userName, $sharedSecret],
'verify' => !$this->config->getSystemValue(
'sharing.federation.allowSelfSignedCertificates',
false,
),
];
$response = $client->get(
$absoluteUrl,
$options,
);
return (string)$response->getBody();
}
private function buildSyncCollectionRequestBody(?string $syncToken): string {
$dom = new \DOMDocument('1.0', 'UTF-8');
$dom->formatOutput = true;
$root = $dom->createElementNS('DAV:', 'd:sync-collection');
$sync = $dom->createElement('d:sync-token', $syncToken ?? '');
$prop = $dom->createElement('d:prop');
$cont = $dom->createElement('d:getcontenttype');
$etag = $dom->createElement('d:getetag');
$prop->appendChild($cont);
$prop->appendChild($etag);
$root->appendChild($sync);
$root->appendChild($prop);
$dom->appendChild($root);
return $dom->saveXML();
}
/**
* @return array{response: array<string, array<array-key, mixed>>, token: ?string, truncated: bool}
* @throws ParseException
*/
private function parseMultiStatus(string $body, string $resourceUrl): array {
/** @var MultiStatus $multiStatus */
$multiStatus = (new SabreXmlService())->expect('{DAV:}multistatus', $body);
$result = [];
$truncated = false;
foreach ($multiStatus->getResponses() as $response) {
$href = $response->getHref();
if ($response->getHttpStatus() === '507' && $this->isResponseForRequestUri($href, $resourceUrl)) {
$truncated = true;
} else {
$result[$response->getHref()] = $response->getResponseProperties();
}
}
return ['response' => $result, 'token' => $multiStatus->getSyncToken(), 'truncated' => $truncated];
}
/**
* Determines whether the provided response URI corresponds to the given request URI.
*/
private function isResponseForRequestUri(string $responseUri, string $requestUri): bool {
/*
* Example response uri:
*
* /remote.php/dav/addressbooks/system/system/system/
* /cloud/remote.php/dav/addressbooks/system/system/system/ (when installed in a subdirectory)
*
* Example request uri:
*
* https://foo.bar/remote.php/dav/addressbooks/system/system/system
*
* References:
* https://github.com/nextcloud/3rdparty/blob/e0a509739b13820f0a62ff9cad5d0fede00e76ee/sabre/dav/lib/DAV/Sync/Plugin.php#L172-L174
* https://github.com/nextcloud/server/blob/b40acb34a39592070d8455eb91c5364c07928c50/apps/federation/lib/SyncFederationAddressBooks.php#L41
*/
return str_ends_with(
rtrim($requestUri, '/'),
rtrim($responseUri, '/'),
);
}
}

View file

@ -11,8 +11,10 @@ namespace OCA\DAV\Tests\integration\DAV\Sharing;
use OC\Memcache\NullCache;
use OCA\DAV\CalDAV\Calendar;
use OCA\DAV\CalDAV\Federation\FederationSharingService;
use OCA\DAV\CalDAV\Sharing\Service;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCA\DAV\DAV\Sharing\Backend;
use OCA\DAV\DAV\Sharing\SharingMapper;
use OCA\DAV\DAV\Sharing\SharingService;
@ -22,6 +24,7 @@ use OCP\IDBConnection;
use OCP\IGroupManager;
use OCP\IUserManager;
use OCP\Server;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
@ -39,6 +42,8 @@ class CalDavSharingBackendTest extends TestCase {
private SharingMapper $sharingMapper;
private SharingService $sharingService;
private Backend $sharingBackend;
private RemoteUserPrincipalBackend&MockObject $remoteUserPrincipalBackend;
private FederationSharingService&MockObject $federationSharingService;
private $resourceIds = [10001];
@ -54,6 +59,8 @@ class CalDavSharingBackendTest extends TestCase {
$this->cacheFactory->method('createInMemory')
->willReturn(new NullCache());
$this->logger = new \Psr\Log\NullLogger();
$this->remoteUserPrincipalBackend = $this->createMock(RemoteUserPrincipalBackend::class);
$this->federationSharingService = $this->createMock(FederationSharingService::class);
$this->sharingMapper = new SharingMapper($this->db);
$this->sharingService = new Service($this->sharingMapper);
@ -62,8 +69,10 @@ class CalDavSharingBackendTest extends TestCase {
$this->userManager,
$this->groupManager,
$this->principalBackend,
$this->remoteUserPrincipalBackend,
$this->cacheFactory,
$this->sharingService,
$this->federationSharingService,
$this->logger
);

View file

@ -9,10 +9,13 @@ namespace OCA\DAV\Tests\unit\CalDAV;
use OC\KnownUser\KnownUserService;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Federation\FederationSharingService;
use OCA\DAV\CalDAV\Proxy\ProxyMapper;
use OCA\DAV\CalDAV\Sharing\Backend as SharingBackend;
use OCA\DAV\CalDAV\Sharing\Service;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCA\DAV\DAV\Sharing\SharingMapper;
use OCP\Accounts\IAccountManager;
use OCP\App\IAppManager;
@ -53,6 +56,9 @@ abstract class AbstractCalDavBackend extends TestCase {
private ISecureRandom $random;
protected SharingBackend $sharingBackend;
protected IDBConnection $db;
protected RemoteUserPrincipalBackend&MockObject $remoteUserPrincipalBackend;
protected FederationSharingService&MockObject $federationSharingService;
protected FederatedCalendarMapper&MockObject $federatedCalendarMapper;
public const UNIT_TEST_USER = 'principals/users/caldav-unit-test';
public const UNIT_TEST_USER1 = 'principals/users/caldav-unit-test1';
public const UNIT_TEST_GROUP = 'principals/groups/caldav-unit-test-group';
@ -92,12 +98,17 @@ abstract class AbstractCalDavBackend extends TestCase {
$this->random = Server::get(ISecureRandom::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->config = $this->createMock(IConfig::class);
$this->remoteUserPrincipalBackend = $this->createMock(RemoteUserPrincipalBackend::class);
$this->federationSharingService = $this->createMock(FederationSharingService::class);
$this->federatedCalendarMapper = $this->createMock(FederatedCalendarMapper::class);
$this->sharingBackend = new SharingBackend(
$this->userManager,
$this->groupManager,
$this->principal,
$this->remoteUserPrincipalBackend,
$this->createMock(ICacheFactory::class),
new Service(new SharingMapper($this->db)),
$this->federationSharingService,
$this->logger);
$this->backend = new CalDavBackend(
$this->db,
@ -108,6 +119,7 @@ abstract class AbstractCalDavBackend extends TestCase {
$this->dispatcher,
$this->config,
$this->sharingBackend,
$this->federatedCalendarMapper,
false,
);

View file

@ -12,6 +12,8 @@ use OCA\DAV\AppInfo\PluginManager;
use OCA\DAV\CalDAV\CachedSubscription;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\CalendarHome;
use OCA\DAV\CalDAV\Federation\FederatedCalendar;
use OCA\DAV\CalDAV\Federation\FederatedCalendarFactory;
use OCA\DAV\CalDAV\Integration\ExternalCalendar;
use OCA\DAV\CalDAV\Integration\ICalendarProvider;
use OCA\DAV\CalDAV\Outbox;
@ -20,6 +22,7 @@ use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Schedule\Inbox;
use Sabre\CalDAV\Subscriptions\Subscription;
use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
use Sabre\DAV\MkCol;
use Test\TestCase;
@ -28,6 +31,7 @@ class CalendarHomeTest extends TestCase {
private array $principalInfo = [];
private PluginManager&MockObject $pluginManager;
private LoggerInterface&MockObject $logger;
private FederatedCalendarFactory&MockObject $federatedCalendarFactory;
private CalendarHome $calendarHome;
protected function setUp(): void {
@ -39,11 +43,13 @@ class CalendarHomeTest extends TestCase {
];
$this->pluginManager = $this->createMock(PluginManager::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->federatedCalendarFactory = $this->createMock(FederatedCalendarFactory::class);
$this->calendarHome = new CalendarHome(
$this->backend,
$this->principalInfo,
$this->logger,
$this->federatedCalendarFactory,
false
);
@ -97,6 +103,12 @@ class CalendarHomeTest extends TestCase {
->with('user-principal-123')
->willReturn([]);
$this->backend
->expects(self::once())
->method('getFederatedCalendarsForUser')
->with('user-principal-123')
->willReturn([]);
$this->backend
->expects(self::once())
->method('getSubscriptionsForUser')
@ -148,6 +160,10 @@ class CalendarHomeTest extends TestCase {
->with('user-principal-123')
->willReturn([]);
$this->backend
->expects(self::never())
->method('getFederatedCalendarsForUser');
$this->backend
->expects(self::once())
->method('getSubscriptionsForUser')
@ -177,6 +193,10 @@ class CalendarHomeTest extends TestCase {
->with('user-principal-123')
->willReturn([]);
$this->backend
->expects(self::never())
->method('getFederatedCalendarsForUser');
$this->backend
->expects(self::once())
->method('getSubscriptionsForUser')
@ -232,6 +252,12 @@ class CalendarHomeTest extends TestCase {
->with('user-principal-123')
->willReturn([]);
$this->backend
->expects(self::once())
->method('getFederatedCalendarsForUser')
->with('user-principal-123')
->willReturn([]);
$this->backend
->expects(self::once())
->method('getSubscriptionsForUser')
@ -268,6 +294,7 @@ class CalendarHomeTest extends TestCase {
$this->backend,
$this->principalInfo,
$this->logger,
$this->federatedCalendarFactory,
false
);
@ -292,6 +319,12 @@ class CalendarHomeTest extends TestCase {
->with('user-principal-123')
->willReturn([]);
$this->backend
->expects(self::once())
->method('getFederatedCalendarsForUser')
->with('user-principal-123')
->willReturn([]);
$this->backend
->expects(self::once())
->method('getSubscriptionsForUser')
@ -328,6 +361,7 @@ class CalendarHomeTest extends TestCase {
$this->backend,
$this->principalInfo,
$this->logger,
$this->federatedCalendarFactory,
true
);
@ -344,4 +378,56 @@ class CalendarHomeTest extends TestCase {
$this->assertInstanceOf(CachedSubscription::class, $actual[3]);
$this->assertInstanceOf(CachedSubscription::class, $actual[4]);
}
public function testGetChildrenFederatedCalendars(): void {
$this->backend
->expects(self::once())
->method('getCalendarsForUser')
->with('user-principal-123')
->willReturn([]);
$this->backend
->expects(self::once())
->method('getFederatedCalendarsForUser')
->with('user-principal-123')
->willReturn([
[
'id' => 10,
'uri' => 'fed-cal-1',
'principaluri' => 'user-principal-123',
'{DAV:}displayname' => 'Federated calendar 1',
'{http://sabredav.org/ns}sync-token' => 3,
'{http://calendarserver.org/ns/}getctag' => 'http://sabre.io/ns/sync/3',
'{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VEVENT']),
'{http://owncloud.org/ns}owner-principal' => 'principals/remote-users/c2hhcmVyQGhvc3QudGxkCg==',
'{http://owncloud.org/ns}read-only' => 1
],
[
'id' => 11,
'uri' => 'fed-cal-2',
'principaluri' => 'user-principal-123',
'{DAV:}displayname' => 'Federated calendar 2',
'{http://sabredav.org/ns}sync-token' => 5,
'{http://calendarserver.org/ns/}getctag' => 'http://sabre.io/ns/sync/5',
'{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set' => new SupportedCalendarComponentSet(['VEVENT']),
'{http://owncloud.org/ns}owner-principal' => 'principals/remote-users/c2hhcmVyQGhvc3QudGxkCg==',
'{http://owncloud.org/ns}read-only' => 1
],
]);
$this->backend
->expects(self::once())
->method('getSubscriptionsForUser')
->with('user-principal-123')
->willReturn([]);
$actual = $this->calendarHome->getChildren();
$this->assertCount(5, $actual);
$this->assertInstanceOf(Inbox::class, $actual[0]);
$this->assertInstanceOf(Outbox::class, $actual[1]);
$this->assertInstanceOf(TrashbinHome::class, $actual[2]);
$this->assertInstanceOf(FederatedCalendar::class, $actual[3]);
$this->assertInstanceOf(FederatedCalendar::class, $actual[4]);
}
}

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\CalDAV\Federation;
use OCA\DAV\CalDAV\Federation\CalendarFederationConfig;
use OCP\AppFramework\Services\IAppConfig;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class CalendarFederationConfigTest extends TestCase {
private CalendarFederationConfig $config;
private IAppConfig&MockObject $appConfig;
protected function setUp(): void {
parent::setUp();
$this->appConfig = $this->createMock(IAppConfig::class);
$this->config = new CalendarFederationConfig(
$this->appConfig,
);
}
public static function provideIsFederationEnabledData(): array {
return [
[true],
[false],
];
}
#[DataProvider('provideIsFederationEnabledData')]
public function testIsFederationEnabled(bool $configValue): void {
$this->appConfig->expects(self::once())
->method('getAppValueBool')
->with('enableCalendarFederation', true)
->willReturn($configValue);
$this->assertEquals($configValue, $this->config->isFederationEnabled());
}
}

View file

@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\CalDAV\Federation;
use OCA\DAV\CalDAV\Federation\CalendarFederationNotifier;
use OCP\Federation\ICloudFederationFactory;
use OCP\Federation\ICloudFederationNotification;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Federation\ICloudId;
use OCP\Http\Client\IResponse;
use OCP\IURLGenerator;
use OCP\OCM\Exceptions\OCMProviderException;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class CalendarFederationNotifierTest extends TestCase {
private readonly CalendarFederationNotifier $calendarFederationNotifier;
private readonly ICloudFederationFactory&MockObject $federationFactory;
private readonly ICloudFederationProviderManager&MockObject $federationManager;
private readonly IURLGenerator&MockObject $url;
protected function setUp(): void {
parent::setUp();
$this->federationFactory = $this->createMock(ICloudFederationFactory::class);
$this->federationManager = $this->createMock(ICloudFederationProviderManager::class);
$this->url = $this->createMock(IURLGenerator::class);
$this->calendarFederationNotifier = new CalendarFederationNotifier(
$this->federationFactory,
$this->federationManager,
$this->url,
);
}
public function testNotifySyncCalendar(): void {
$cloudId = $this->createMock(ICloudId::class);
$cloudId->method('getId')
->willReturn('remote1@nextcloud.remote');
$cloudId->method('getRemote')
->willReturn('nextcloud.remote');
$this->url->expects(self::once())
->method('linkTo')
->with('', 'remote.php')
->willReturn('/remote.php');
$this->url->expects(self::once())
->method('getAbsoluteURL')
->with('/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1')
->willReturn('https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1');
$notification = $this->createMock(ICloudFederationNotification::class);
$notification->expects(self::once())
->method('setMessage')
->with(
'SYNC_CALENDAR',
'calendar',
'calendar',
[
'sharedSecret' => 'token',
'shareWith' => 'remote1@nextcloud.remote',
'calendarUrl' => 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
],
);
$this->federationFactory->expects(self::once())
->method('getCloudFederationNotification')
->willReturn($notification);
$response = $this->createMock(IResponse::class);
$this->federationManager->expects(self::once())
->method('sendCloudNotification')
->with('nextcloud.remote', $notification)
->willReturn($response);
$this->calendarFederationNotifier->notifySyncCalendar($cloudId, 'host1', 'cal1', 'token');
}
public function testNotifySyncCalendarShouldRethrowOcmException(): void {
$cloudId = $this->createMock(ICloudId::class);
$cloudId->method('getId')
->willReturn('remote1@nextcloud.remote');
$cloudId->method('getRemote')
->willReturn('nextcloud.remote');
$this->url->expects(self::once())
->method('linkTo')
->with('', 'remote.php')
->willReturn('/remote.php');
$this->url->expects(self::once())
->method('getAbsoluteURL')
->with('/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1')
->willReturn('https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1');
$notification = $this->createMock(ICloudFederationNotification::class);
$notification->expects(self::once())
->method('setMessage')
->with(
'SYNC_CALENDAR',
'calendar',
'calendar',
[
'sharedSecret' => 'token',
'shareWith' => 'remote1@nextcloud.remote',
'calendarUrl' => 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
],
);
$this->federationFactory->expects(self::once())
->method('getCloudFederationNotification')
->willReturn($notification);
$this->federationManager->expects(self::once())
->method('sendCloudNotification')
->with('nextcloud.remote', $notification)
->willThrowException(new OCMProviderException('I threw this'));
$this->expectException(OCMProviderException::class);
$this->expectExceptionMessage('I threw this');
$this->calendarFederationNotifier->notifySyncCalendar($cloudId, 'host1', 'cal1', 'token');
}
}

View file

@ -0,0 +1,484 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\CalDAV\Federation;
use OC\BackgroundJob\JobList;
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\FederatedCalendarMapper;
use OCP\BackgroundJob\IJobList;
use OCP\Federation\Exceptions\BadRequestException;
use OCP\Federation\Exceptions\ProviderCouldNotAddShareException;
use OCP\Federation\ICloudFederationShare;
use OCP\Federation\ICloudId;
use OCP\Federation\ICloudIdManager;
use OCP\Share\Exceptions\ShareNotFound;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class CalendarFederationProviderTest extends TestCase {
private CalendarFederationProvider $calendarFederationProvider;
private LoggerInterface&MockObject $logger;
private FederatedCalendarMapper&MockObject $federatedCalendarMapper;
private CalendarFederationConfig&MockObject $calendarFederationConfig;
private IJobList&MockObject $jobList;
private ICloudIdManager&MockObject $cloudIdManager;
protected function setUp(): void {
parent::setUp();
$this->logger = $this->createMock(LoggerInterface::class);
$this->federatedCalendarMapper = $this->createMock(FederatedCalendarMapper::class);
$this->calendarFederationConfig = $this->createMock(CalendarFederationConfig::class);
$this->jobList = $this->createMock(JobList::class);
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
$this->calendarFederationProvider = new CalendarFederationProvider(
$this->logger,
$this->federatedCalendarMapper,
$this->calendarFederationConfig,
$this->jobList,
$this->cloudIdManager,
);
}
public function testGetShareType(): void {
$this->assertEquals('calendar', $this->calendarFederationProvider->getShareType());
}
public function testGetSupportedShareTypes(): void {
$this->assertEqualsCanonicalizing(
['user'],
$this->calendarFederationProvider->getSupportedShareTypes(),
);
}
public function testShareReceived(): 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('getSharedBy')
->willReturn('user1@nextcloud.remote');
$share->method('getSharedByDisplayName')
->willReturn('User 1');
$this->calendarFederationConfig->expects(self::once())
->method('isFederationEnabled')
->willReturn(true);
$this->federatedCalendarMapper->expects(self::once())
->method('deleteByUri')
->with(
'principals/users/sharee1',
'ae4b8ab904076fff2b955ea21b1a0d92',
);
$this->federatedCalendarMapper->expects(self::once())
->method('insert')
->willReturnCallback(function (FederatedCalendarEntity $calendar) {
$this->assertEquals('principals/users/sharee1', $calendar->getPrincipaluri());
$this->assertEquals('ae4b8ab904076fff2b955ea21b1a0d92', $calendar->getUri());
$this->assertEquals('https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1', $calendar->getRemoteUrl());
$this->assertEquals('Calendar 1', $calendar->getDisplayName());
$this->assertEquals('#ff0000', $calendar->getColor());
$this->assertEquals('token', $calendar->getToken());
$this->assertEquals('user1@nextcloud.remote', $calendar->getSharedBy());
$this->assertEquals('User 1', $calendar->getSharedByDisplayName());
$this->assertEquals(1, $calendar->getPermissions());
$this->assertEquals('VEVENT,VTODO', $calendar->getComponents());
$calendar->setId(10);
return $calendar;
});
$this->jobList->expects(self::once())
->method('add')
->with(FederatedCalendarSyncJob::class, ['id' => 10]);
$this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
}
public function testShareReceivedWithInvalidProtocolVersion(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
->willReturn('user');
$share->method('getProtocol')
->willReturn([
'version' => 'unknown',
'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1',
'displayName' => 'Calendar 1',
'color' => '#ff0000',
'access' => 3,
'components' => 'VEVENT,VTODO',
]);
$this->calendarFederationConfig->expects(self::once())
->method('isFederationEnabled')
->willReturn(true);
$this->federatedCalendarMapper->expects(self::never())
->method('insert');
$this->jobList->expects(self::never())
->method('add');
$this->expectException(ProviderCouldNotAddShareException::class);
$this->expectExceptionMessage('Unknown protocol version');
$this->expectExceptionCode(400);
$this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
}
public function testShareReceivedWithoutProtocolVersion(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
->willReturn('user');
$share->method('getProtocol')
->willReturn([
'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1',
'displayName' => 'Calendar 1',
'color' => '#ff0000',
'access' => 3,
'components' => 'VEVENT,VTODO',
]);
$this->calendarFederationConfig->expects(self::once())
->method('isFederationEnabled')
->willReturn(true);
$this->federatedCalendarMapper->expects(self::never())
->method('insert');
$this->jobList->expects(self::never())
->method('add');
$this->expectException(ProviderCouldNotAddShareException::class);
$this->expectExceptionMessage('Unknown protocol version');
$this->expectExceptionCode(400);
$this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
}
public function testShareReceivedWithDisabledConfig(): void {
$share = $this->createMock(ICloudFederationShare::class);
$this->calendarFederationConfig->expects(self::once())
->method('isFederationEnabled')
->willReturn(false);
$this->federatedCalendarMapper->expects(self::never())
->method('insert');
$this->jobList->expects(self::never())
->method('add');
$this->expectException(ProviderCouldNotAddShareException::class);
$this->expectExceptionMessage('Server does not support calendar federation');
$this->expectExceptionCode(503);
$this->calendarFederationProvider->shareReceived($share);
}
public function testShareReceivedWithUnsupportedShareType(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
->willReturn('foobar');
$this->calendarFederationConfig->expects(self::once())
->method('isFederationEnabled')
->willReturn(true);
$this->federatedCalendarMapper->expects(self::never())
->method('insert');
$this->jobList->expects(self::never())
->method('add');
$this->expectException(ProviderCouldNotAddShareException::class);
$this->expectExceptionMessage('Support for sharing with non-users not implemented yet');
$this->expectExceptionCode(501);
$this->calendarFederationProvider->shareReceived($share);
}
public static function provideIncompleteProtocolData(): array {
return [
[[
'version' => 'v1',
'url' => '',
'displayName' => 'Calendar 1',
'color' => '#ff0000',
'access' => 3,
'components' => 'VEVENT,VTODO',
]],
[[
'version' => 'v1',
'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1',
'displayName' => '',
'color' => '#ff0000',
'access' => 3,
'components' => 'VEVENT,VTODO',
]],
];
}
#[DataProvider('provideIncompleteProtocolData')]
public function testShareReceivedWithIncompleteProtocolData(array $protocol): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
->willReturn('user');
$share->method('getProtocol')
->willReturn($protocol);
$share->method('getShareWith')
->willReturn('sharee1');
$share->method('getShareSecret')
->willReturn('token');
$share->method('getSharedBy')
->willReturn('user1@nextcloud.remote');
$share->method('getSharedByDisplayName')
->willReturn('User 1');
$this->calendarFederationConfig->expects(self::once())
->method('isFederationEnabled')
->willReturn(true);
$this->federatedCalendarMapper->expects(self::never())
->method('insert');
$this->jobList->expects(self::never())
->method('add');
$this->expectException(ProviderCouldNotAddShareException::class);
$this->expectExceptionMessage('Incomplete protocol data');
$this->expectExceptionCode(400);
$this->calendarFederationProvider->shareReceived($share);
}
public function testShareReceivedWithUnsupportedAccess(): 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' => 2, // Backend::ACCESS_READ_WRITE
'components' => 'VEVENT,VTODO',
]);
$share->method('getShareWith')
->willReturn('sharee1');
$share->method('getShareSecret')
->willReturn('token');
$share->method('getSharedBy')
->willReturn('user1@nextcloud.remote');
$share->method('getSharedByDisplayName')
->willReturn('User 1');
$this->calendarFederationConfig->expects(self::once())
->method('isFederationEnabled')
->willReturn(true);
$this->federatedCalendarMapper->expects(self::never())
->method('insert');
$this->jobList->expects(self::never())
->method('add');
$this->expectException(ProviderCouldNotAddShareException::class);
$this->expectExceptionMessageMatches('/Unsupported access value: [0-9]+/');
$this->expectExceptionCode(400);
$this->calendarFederationProvider->shareReceived($share);
}
public function testNotificationReceivedWithUnknownNotification(): void {
$actual = $this->calendarFederationProvider->notificationReceived('UNKNOWN', 'calendar', [
'sharedSecret' => 'token',
'foobar' => 'baz',
]);
$this->assertEquals([], $actual);
}
public function testNotificationReceivedWithInvalidProviderId(): void {
$this->expectException(BadRequestException::class);
$this->calendarFederationProvider->notificationReceived('SYNC_CALENDAR', 'foobar', [
'sharedSecret' => 'token',
'shareWith' => 'remote1@nextcloud.remote',
'calendarUrl' => 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
]);
}
public function testNotificationReceivedWithSyncCalendarNotification(): void {
$cloudId = $this->createMock(ICloudId::class);
$cloudId->method('getId')
->willReturn('remote1@nextcloud.remote');
$cloudId->method('getUser')
->willReturn('remote1');
$cloudId->method('getRemote')
->willReturn('nextcloud.remote');
$this->cloudIdManager->expects(self::once())
->method('resolveCloudId')
->with('remote1@nextcloud.remote')
->willReturn($cloudId);
$calendar1 = new FederatedCalendarEntity();
$calendar1->setId(10);
$calendar2 = new FederatedCalendarEntity();
$calendar2->setId(11);
$calendars = [
$calendar1,
$calendar2,
];
$this->federatedCalendarMapper->expects(self::once())
->method('findByRemoteUrl')
->with(
'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
'principals/users/remote1',
'token',
)
->willReturn($calendars);
$this->jobList->expects(self::exactly(2))
->method('add')
->willReturnMap([
[FederatedCalendarSyncJob::class, ['id' => 10]],
[FederatedCalendarSyncJob::class, ['id' => 11]],
]);
$actual = $this->calendarFederationProvider->notificationReceived(
'SYNC_CALENDAR',
'calendar',
[
'sharedSecret' => 'token',
'shareWith' => 'remote1@nextcloud.remote',
'calendarUrl' => 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
],
);
$this->assertEquals([], $actual);
}
public static function provideIncompleteSyncCalendarNotificationData(): array {
return [
// Missing shareWith
[[
'sharedSecret' => 'token',
'shareWith' => '',
'calendarUrl' => 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
]],
[[
'sharedSecret' => 'token',
'calendarUrl' => 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
]],
// Missing calendarUrl
[[
'sharedSecret' => 'token',
'shareWith' => 'remote1@nextcloud.remote',
'calendarUrl' => '',
]],
[[
'sharedSecret' => 'token',
'shareWith' => 'remote1@nextcloud.remote',
]],
];
}
#[DataProvider('provideIncompleteSyncCalendarNotificationData')]
public function testNotificationReceivedWithSyncCalendarNotificationAndIncompleteData(
array $notification,
): void {
$this->cloudIdManager->expects(self::never())
->method('resolveCloudId');
$this->federatedCalendarMapper->expects(self::never())
->method('findByRemoteUrl');
$this->jobList->expects(self::never())
->method('add');
$this->expectException(BadRequestException::class);
$this->calendarFederationProvider->notificationReceived(
'SYNC_CALENDAR',
'calendar',
$notification,
);
}
public function testNotificationReceivedWithSyncCalendarNotificationAndInvalidCloudId(): void {
$this->cloudIdManager->expects(self::once())
->method('resolveCloudId')
->with('invalid-cloud-id')
->willThrowException(new \InvalidArgumentException());
$this->federatedCalendarMapper->expects(self::never())
->method('findByRemoteUrl');
$this->jobList->expects(self::never())
->method('add');
$this->expectException(ShareNotFound::class);
$this->expectExceptionMessage('Invalid sharee cloud id');
$this->calendarFederationProvider->notificationReceived(
'SYNC_CALENDAR',
'calendar',
[
'sharedSecret' => 'token',
'shareWith' => 'invalid-cloud-id',
'calendarUrl' => 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
],
);
}
public function testNotificationReceivedWithSyncCalendarNotificationAndNoCalendars(): void {
$cloudId = $this->createMock(ICloudId::class);
$cloudId->method('getId')
->willReturn('remote1@nextcloud.remote');
$cloudId->method('getUser')
->willReturn('remote1');
$cloudId->method('getRemote')
->willReturn('nextcloud.remote');
$this->cloudIdManager->expects(self::once())
->method('resolveCloudId')
->with('remote1@nextcloud.remote')
->willReturn($cloudId);
$this->federatedCalendarMapper->expects(self::once())
->method('findByRemoteUrl')
->with(
'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
'principals/users/remote1',
'token',
)
->willReturn([]);
$this->jobList->expects(self::never())
->method('add');
$this->expectException(ShareNotFound::class);
$this->expectExceptionMessage('Calendar is not shared with the sharee');
$this->calendarFederationProvider->notificationReceived(
'SYNC_CALENDAR',
'calendar',
[
'sharedSecret' => 'token',
'shareWith' => 'remote1@nextcloud.remote',
'calendarUrl' => 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
],
);
}
}

View file

@ -0,0 +1,139 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\CalDAV\Federation;
use OCA\DAV\CalDAV\Federation\FederatedCalendarAuth;
use OCA\DAV\DAV\Sharing\SharingMapper;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Sabre\HTTP\RequestInterface;
use Sabre\HTTP\ResponseInterface;
use Test\TestCase;
class FederatedCalendarAuthTest extends TestCase {
private FederatedCalendarAuth $auth;
private SharingMapper&MockObject $sharingMapper;
protected function setUp(): void {
parent::setUp();
$this->sharingMapper = $this->createMock(SharingMapper::class);
$this->auth = new FederatedCalendarAuth(
$this->sharingMapper,
);
}
private static function encodeBasicAuthHeader(array $userPass): string {
return 'Basic ' . base64_encode(implode(':', $userPass));
}
public static function provideCheckData(): array {
return [
// Valid credentials
[
'remote-calendars/abcdef123/cal1_shared_by_user1',
self::encodeBasicAuthHeader(['abcdef123', 'token']),
[['uri' => 'cal1', 'principaluri' => 'principals/users/user1']],
[true, 'principals/remote-users/abcdef123'],
],
[
'remote-calendars/abcdef123/cal1_shared_by_user1',
self::encodeBasicAuthHeader(['abcdef123', 'token']),
[
['uri' => 'other-cal', 'principaluri' => 'principals/users/user1'],
['uri' => 'cal1', 'principaluri' => 'principals/users/user1'],
],
[true, 'principals/remote-users/abcdef123'],
],
// Invalid basic auth header
[
'remote-calendars/abcdef123/cal1_shared_by_user1',
self::encodeBasicAuthHeader(['abcdef123']),
null,
[false, "No 'Authorization: Basic' header found. Either the client didn't send one, or the server is misconfigured"],
],
[
'remote-calendars/abcdef123/cal1_shared_by_user1',
'Bearer secret-bearer-token',
null,
[false, "No 'Authorization: Basic' header found. Either the client didn't send one, or the server is misconfigured"],
],
[
'remote-calendars/abcdef123/cal1_shared_by_user1',
null,
null,
[false, "No 'Authorization: Basic' header found. Either the client didn't send one, or the server is misconfigured"],
],
// Invalid request path
[
'calendars/user1/cal1',
self::encodeBasicAuthHeader(['abcdef123', 'token']),
null,
[false, 'This request is not for a federated calendar'],
],
// No shared calendars (or invalid credentials)
[
'remote-calendars/abcdef123/cal1_shared_by_user1',
self::encodeBasicAuthHeader(['abcdef123', 'token']),
[],
[false, 'Username or password was incorrect'],
],
// Shared calendar with invalid URI
[
'remote-calendars/abcdef123/cal1_shared_by_user1',
self::encodeBasicAuthHeader(['abcdef123', 'token']),
[['uri' => 'other-cal', 'principaluri' => 'principals/users/user1']],
[false, 'Username or password was incorrect'],
],
// Shared calendar from invalid sharer
[
'remote-calendars/abcdef123/cal1_shared_by_user1',
self::encodeBasicAuthHeader(['abcdef123', 'token']),
[['uri' => 'cal1', 'principaluri' => 'principals/users/user2']],
[false, 'Username or password was incorrect'],
],
];
}
#[DataProvider('provideCheckData')]
public function testCheck(
string $requestPath,
?string $authHeader,
?array $rows,
array $expected,
): void {
$request = $this->createMock(RequestInterface::class);
$request->method('getPath')
->willReturn($requestPath);
$request->method('getHeader')
->with('Authorization')
->willReturn($authHeader);
$response = $this->createMock(ResponseInterface::class);
if ($rows === null) {
$this->sharingMapper->expects(self::never())
->method('getSharedCalendarsForRemoteUser');
} else {
$this->sharingMapper->expects(self::once())
->method('getSharedCalendarsForRemoteUser')
->with('principals/remote-users/abcdef123', 'token')
->willReturn($rows);
}
$this->assertEquals($expected, $this->auth->check($request, $response));
}
}

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 OCA\DAV\Tests\unit\CalDAV\Federation;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Federation\FederatedCalendarImpl;
use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class FederatedCalendarImplTest extends TestCase {
private FederatedCalendarImpl $federatedCalendarImpl;
private CalDavBackend&MockObject $calDavBackend;
protected function setUp(): void {
parent::setUp();
$this->calDavBackend = $this->createMock(CalDavBackend::class);
$this->federatedCalendarImpl = new FederatedCalendarImpl(
[],
$this->calDavBackend,
);
}
public function testGetPermissions(): void {
$this->assertEquals(1, $this->federatedCalendarImpl->getPermissions());
}
public function testIsDeleted(): void {
$this->assertFalse($this->federatedCalendarImpl->isDeleted());
}
public function testIsShared(): void {
$this->assertTrue($this->federatedCalendarImpl->isShared());
}
public function testIsWritable(): void {
$this->assertFalse($this->federatedCalendarImpl->isWritable());
}
}

View file

@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\CalDAV\Federation;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService;
use OCA\DAV\CalDAV\SyncService as CalDavSyncService;
use OCA\DAV\CalDAV\SyncServiceResult;
use OCP\Federation\ICloudId;
use OCP\Federation\ICloudIdManager;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class FederatedCalendarSyncServiceTest extends TestCase {
private FederatedCalendarSyncService $federatedCalendarSyncService;
private FederatedCalendarMapper&MockObject $federatedCalendarMapper;
private LoggerInterface&MockObject $logger;
private CalDavSyncService&MockObject $calDavSyncService;
private ICloudIdManager&MockObject $cloudIdManager;
protected function setUp(): void {
parent::setUp();
$this->federatedCalendarMapper = $this->createMock(FederatedCalendarMapper::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->calDavSyncService = $this->createMock(CalDavSyncService::class);
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
$this->federatedCalendarSyncService = new FederatedCalendarSyncService(
$this->federatedCalendarMapper,
$this->logger,
$this->calDavSyncService,
$this->cloudIdManager,
);
}
public function testSyncOne(): void {
$calendar = new FederatedCalendarEntity();
$calendar->setId(1);
$calendar->setPrincipaluri('principals/users/user1');
$calendar->setRemoteUrl('https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2');
$calendar->setSyncToken(100);
$calendar->setToken('token');
$cloudId = $this->createMock(ICloudId::class);
$cloudId->method('getId')
->willReturn('user1@nextcloud.testing');
$this->cloudIdManager->expects(self::once())
->method('getCloudId')
->with('user1')
->willReturn($cloudId);
$this->calDavSyncService->expects(self::once())
->method('syncRemoteCalendar')
->with(
'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2',
'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=',
'token',
'http://sabre.io/ns/sync/100',
$calendar,
)
->willReturn(new SyncServiceResult('http://sabre.io/ns/sync/101', 10));
$this->federatedCalendarMapper->expects(self::once())
->method('updateSyncTokenAndTime')
->with(1, 101);
$this->federatedCalendarMapper->expects(self::never())
->method('updateSyncTime');
$this->assertEquals(10, $this->federatedCalendarSyncService->syncOne($calendar));
}
public function testSyncOneUnchanged(): void {
$calendar = new FederatedCalendarEntity();
$calendar->setId(1);
$calendar->setPrincipaluri('principals/users/user1');
$calendar->setRemoteUrl('https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2');
$calendar->setSyncToken(100);
$calendar->setToken('token');
$cloudId = $this->createMock(ICloudId::class);
$cloudId->method('getId')
->willReturn('user1@nextcloud.testing');
$this->cloudIdManager->expects(self::once())
->method('getCloudId')
->with('user1')
->willReturn($cloudId);
$this->calDavSyncService->expects(self::once())
->method('syncRemoteCalendar')
->with(
'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2',
'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=',
'token',
'http://sabre.io/ns/sync/100',
$calendar,
)
->willReturn(new SyncServiceResult('http://sabre.io/ns/sync/100', 0));
$this->federatedCalendarMapper->expects(self::never())
->method('updateSyncTokenAndTime');
$this->federatedCalendarMapper->expects(self::once())
->method('updateSyncTime')
->with(1);
$this->assertEquals(0, $this->federatedCalendarSyncService->syncOne($calendar));
}
public static function provideUnexpectedSyncTokenData(): array {
return [
['http://sabre.io/ns/sync/'],
['http://sabre.io/ns/sync/foobar'],
['http://sabre.io/ns/sync/23abc'],
['http://nextcloud.com/ns/sync/33'],
];
}
#[DataProvider('provideUnexpectedSyncTokenData')]
public function testSyncOneWithUnexpectedSyncTokenFormat(string $syncToken): void {
$calendar = new FederatedCalendarEntity();
$calendar->setId(1);
$calendar->setPrincipaluri('principals/users/user1');
$calendar->setRemoteUrl('https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2');
$calendar->setSyncToken(100);
$calendar->setToken('token');
$cloudId = $this->createMock(ICloudId::class);
$cloudId->method('getId')
->willReturn('user1@nextcloud.testing');
$this->cloudIdManager->expects(self::once())
->method('getCloudId')
->with('user1')
->willReturn($cloudId);
$this->calDavSyncService->expects(self::once())
->method('syncRemoteCalendar')
->with(
'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2',
'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=',
'token',
'http://sabre.io/ns/sync/100',
$calendar,
)
->willReturn(new SyncServiceResult($syncToken, 10));
$this->federatedCalendarMapper->expects(self::never())
->method('updateSyncTokenAndTime');
$this->federatedCalendarMapper->expects(self::never())
->method('updateSyncTime');
$this->assertEquals(0, $this->federatedCalendarSyncService->syncOne($calendar));
}
}

View file

@ -0,0 +1,463 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\CalDAV\Federation;
use OCA\DAV\CalDAV\Calendar;
use OCA\DAV\CalDAV\Federation\FederationSharingService;
use OCA\DAV\DAV\Sharing\IShareable;
use OCA\DAV\DAV\Sharing\SharingMapper;
use OCP\Federation\ICloudFederationFactory;
use OCP\Federation\ICloudFederationProviderManager;
use OCP\Federation\ICloudFederationShare;
use OCP\Http\Client\IResponse;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\IUserManager;
use OCP\OCM\Exceptions\OCMProviderException;
use OCP\Security\ISecureRandom;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
use Test\TestCase;
class FederationSharingServiceTest extends TestCase {
private FederationSharingService $federationSharingService;
private readonly ICloudFederationProviderManager&MockObject $federationManager;
private readonly ICloudFederationFactory&MockObject $federationFactory;
private readonly IUserManager&MockObject $userManager;
private readonly IURLGenerator&MockObject $url;
private readonly LoggerInterface&MockObject $logger;
private readonly ISecureRandom&MockObject $random;
private readonly SharingMapper&MockObject $sharingMapper;
protected function setUp(): void {
parent::setUp();
$this->federationManager = $this->createMock(ICloudFederationProviderManager::class);
$this->federationFactory = $this->createMock(ICloudFederationFactory::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->url = $this->createMock(IURLGenerator::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->random = $this->createMock(ISecureRandom::class);
$this->sharingMapper = $this->createMock(SharingMapper::class);
$this->federationSharingService = new FederationSharingService(
$this->federationManager,
$this->federationFactory,
$this->userManager,
$this->url,
$this->logger,
$this->random,
$this->sharingMapper,
);
}
public function testShareWith(): void {
$shareable = $this->createMock(Calendar::class);
$shareable->method('getOwner')
->willReturn('principals/users/host1');
$shareable->method('getName')
->willReturn('cal1');
$shareable->method('getResourceId')
->willReturn(10);
$shareable->method('getProperties')
->willReturnCallback(static fn (array $props) => match ($props[0]) {
'{DAV:}displayname' => ['{DAV:}displayname' => 'Calendar 1'],
'{http://apple.com/ns/ical/}calendar-color' => [
'{http://apple.com/ns/ical/}calendar-color' => '#ff0000',
],
'{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set' => [
'{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set' => new SupportedCalendarComponentSet([
'VEVENT',
'VTODO',
]),
]
});
$hostUser = $this->createMock(IUser::class);
$hostUser->method('getCloudId')
->willReturn('host1@nextcloud.host');
$hostUser->method('getDisplayName')
->willReturn('Host 1');
$hostUser->method('getUID')
->willReturn('host1');
$this->userManager->expects(self::once())
->method('get')
->with('host1')
->willReturn($hostUser);
$this->random->expects(self::once())
->method('generate')
->with(32)
->willReturn('token');
$share = $this->createMock(ICloudFederationShare::class);
$share->expects(self::once())
->method('getProtocol')
->willReturn([
'preservedValue1' => 'foobar',
'preservedValue2' => 'baz',
]);
$share->expects(self::once())
->method('setProtocol')
->with([
'preservedValue1' => 'foobar',
'preservedValue2' => 'baz',
'version' => 'v1',
'url' => 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
'displayName' => 'Calendar 1',
'color' => '#ff0000',
'access' => 3,
'components' => 'VEVENT,VTODO',
]);
$this->federationFactory->expects(self::once())
->method('getCloudFederationShare')
->with(
'remote1@nextcloud.remote',
'cal1',
'Calendar 1',
'calendar',
'host1@nextcloud.host',
'Host 1',
'host1@nextcloud.host',
'Host 1',
'token',
'user',
'calendar',
)
->willReturn($share);
$this->url->expects(self::once())
->method('linkTo')
->with('', 'remote.php')
->willReturn('/remote.php');
$this->url->expects(self::once())
->method('getAbsoluteURL')
->with('/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1')
->willReturn('https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1');
$response = $this->createMock(IResponse::class);
$response->method('getStatusCode')
->willReturn(201);
$this->federationManager->expects(self::once())
->method('sendCloudShare')
->with($share)
->willReturn($response);
$this->sharingMapper->expects(self::once())
->method('deleteShare')
->with(10, 'calendar', 'principals/remote-users/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl');
$this->sharingMapper->expects(self::once())
->method('shareWithToken')
->with(
10,
'calendar',
3,
'principals/remote-users/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl',
'token',
);
$this->federationSharingService->shareWith(
$shareable,
'principals/remote-users/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl',
3, // Read-only
);
}
public function testShareWithWithFailingFederationManager(): void {
$shareable = $this->createMock(Calendar::class);
$shareable->method('getOwner')
->willReturn('principals/users/host1');
$shareable->method('getName')
->willReturn('cal1');
$shareable->method('getResourceId')
->willReturn(10);
$shareable->method('getProperties')
->willReturnCallback(static fn (array $props) => match ($props[0]) {
'{DAV:}displayname' => ['{DAV:}displayname' => 'Calendar 1'],
'{http://apple.com/ns/ical/}calendar-color' => [
'{http://apple.com/ns/ical/}calendar-color' => '#ff0000',
],
'{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set' => [
'{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set' => new SupportedCalendarComponentSet([
'VEVENT',
'VTODO',
]),
]
});
$hostUser = $this->createMock(IUser::class);
$hostUser->method('getCloudId')
->willReturn('host1@nextcloud.host');
$hostUser->method('getDisplayName')
->willReturn('Host 1');
$hostUser->method('getUID')
->willReturn('host1');
$this->userManager->expects(self::once())
->method('get')
->with('host1')
->willReturn($hostUser);
$this->random->expects(self::once())
->method('generate')
->with(32)
->willReturn('token');
$share = $this->createMock(ICloudFederationShare::class);
$share->expects(self::once())
->method('getProtocol')
->willReturn([
'preservedValue1' => 'foobar',
'preservedValue2' => 'baz',
]);
$share->expects(self::once())
->method('setProtocol')
->with([
'preservedValue1' => 'foobar',
'preservedValue2' => 'baz',
'version' => 'v1',
'url' => 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
'displayName' => 'Calendar 1',
'color' => '#ff0000',
'access' => 3,
'components' => 'VEVENT,VTODO',
]);
$this->federationFactory->expects(self::once())
->method('getCloudFederationShare')
->with(
'remote1@nextcloud.remote',
'cal1',
'Calendar 1',
'calendar',
'host1@nextcloud.host',
'Host 1',
'host1@nextcloud.host',
'Host 1',
'token',
'user',
'calendar',
)
->willReturn($share);
$this->url->expects(self::once())
->method('linkTo')
->with('', 'remote.php')
->willReturn('/remote.php');
$this->url->expects(self::once())
->method('getAbsoluteURL')
->with('/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1')
->willReturn('https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1');
$response = $this->createMock(IResponse::class);
$response->method('getStatusCode')
->willReturn(201);
$this->federationManager->expects(self::once())
->method('sendCloudShare')
->with($share)
->willThrowException(new OCMProviderException());
$this->sharingMapper->expects(self::never())
->method('deleteShare');
$this->sharingMapper->expects(self::never())
->method('shareWithToken');
$this->federationSharingService->shareWith(
$shareable,
'principals/remote-users/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl',
3, // Read-only
);
}
public function testShareWithWithUnsuccessfulResponse(): void {
$shareable = $this->createMock(Calendar::class);
$shareable->method('getOwner')
->willReturn('principals/users/host1');
$shareable->method('getName')
->willReturn('cal1');
$shareable->method('getResourceId')
->willReturn(10);
$shareable->method('getProperties')
->willReturnCallback(static fn (array $props) => match ($props[0]) {
'{DAV:}displayname' => ['{DAV:}displayname' => 'Calendar 1'],
'{http://apple.com/ns/ical/}calendar-color' => [
'{http://apple.com/ns/ical/}calendar-color' => '#ff0000',
],
'{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set' => [
'{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set' => new SupportedCalendarComponentSet([
'VEVENT',
'VTODO',
]),
]
});
$hostUser = $this->createMock(IUser::class);
$hostUser->method('getCloudId')
->willReturn('host1@nextcloud.host');
$hostUser->method('getDisplayName')
->willReturn('Host 1');
$hostUser->method('getUID')
->willReturn('host1');
$this->userManager->expects(self::once())
->method('get')
->with('host1')
->willReturn($hostUser);
$this->random->expects(self::once())
->method('generate')
->with(32)
->willReturn('token');
$share = $this->createMock(ICloudFederationShare::class);
$share->expects(self::once())
->method('getProtocol')
->willReturn([
'preservedValue1' => 'foobar',
'preservedValue2' => 'baz',
]);
$share->expects(self::once())
->method('setProtocol')
->with([
'preservedValue1' => 'foobar',
'preservedValue2' => 'baz',
'version' => 'v1',
'url' => 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1',
'displayName' => 'Calendar 1',
'color' => '#ff0000',
'access' => 3,
'components' => 'VEVENT,VTODO',
]);
$this->federationFactory->expects(self::once())
->method('getCloudFederationShare')
->with(
'remote1@nextcloud.remote',
'cal1',
'Calendar 1',
'calendar',
'host1@nextcloud.host',
'Host 1',
'host1@nextcloud.host',
'Host 1',
'token',
'user',
'calendar',
)
->willReturn($share);
$this->url->expects(self::once())
->method('linkTo')
->with('', 'remote.php')
->willReturn('/remote.php');
$this->url->expects(self::once())
->method('getAbsoluteURL')
->with('/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1')
->willReturn('https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1');
$response = $this->createMock(IResponse::class);
$response->method('getStatusCode')
->willReturn(400);
$this->federationManager->expects(self::once())
->method('sendCloudShare')
->with($share)
->willReturn($response);
$this->sharingMapper->expects(self::never())
->method('deleteShare');
$this->sharingMapper->expects(self::never())
->method('shareWithToken');
$this->federationSharingService->shareWith(
$shareable,
'principals/remote-users/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl',
3, // Read-only
);
}
public static function provideInvalidRemoteUserPrincipalData(): array {
return [
['principals/users/foobar'],
['remote-users/remote1'],
['foobar/remote-users/remote1'],
['principals/remote-groups/group1'],
];
}
#[DataProvider('provideInvalidRemoteUserPrincipalData')]
public function testShareWithWithInvalidRemoteUserPrincipal(string $remoteUserPrincipal): void {
$shareable = $this->createMock(Calendar::class);
$shareable->method('getOwner')
->willReturn('principals/users/host1');
$this->userManager->expects(self::never())
->method('get');
$this->federationManager->expects(self::never())
->method('sendCloudShare');
$this->sharingMapper->expects(self::never())
->method('deleteShare');
$this->sharingMapper->expects(self::never())
->method('shareWithToken');
$this->federationSharingService->shareWith(
$shareable,
$remoteUserPrincipal,
3, // Read-only
);
}
public function testShareWithWithUnknownUser(): void {
$shareable = $this->createMock(Calendar::class);
$shareable->method('getOwner')
->willReturn('principals/users/host1');
$this->userManager->expects(self::once())
->method('get')
->with('host1')
->willReturn(null);
$this->federationManager->expects(self::never())
->method('sendCloudShare');
$this->sharingMapper->expects(self::never())
->method('deleteShare');
$this->sharingMapper->expects(self::never())
->method('shareWithToken');
$this->federationSharingService->shareWith(
$shareable,
'principals/remote-users/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl',
3, // Read-only
);
}
public function testShareWithWithInvalidShareable(): void {
$shareable = $this->createMock(IShareable::class);
$shareable->method('getOwner')
->willReturn('principals/users/host1');
$this->userManager->expects(self::once())
->method('get')
->with('host1')
->willReturn(null);
$this->federationManager->expects(self::never())
->method('sendCloudShare');
$this->sharingMapper->expects(self::never())
->method('deleteShare');
$this->sharingMapper->expects(self::never())
->method('shareWithToken');
$this->federationSharingService->shareWith(
$shareable,
'principals/remote-users/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl',
3, // Read-only
);
}
}

View file

@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\DAV\Tests\unit\CalDAV\Federation;
use OCA\DAV\CalDAV\Calendar;
use OCA\DAV\CalDAV\Federation\RemoteUserCalendarHome;
use OCP\IConfig;
use OCP\IL10N;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Sabre\CalDAV\Backend\BackendInterface;
use Sabre\DAV\Exception\NotFound;
use Test\TestCase;
class RemoteUserCalendarHomeTest extends TestCase {
private RemoteUserCalendarHome $remoteUserCalendarHome;
private BackendInterface&MockObject $calDavBackend;
private IL10N&MockObject $l10n;
private IConfig&MockObject $config;
private LoggerInterface&MockObject $logger;
protected function setUp(): void {
parent::setUp();
$this->calDavBackend = $this->createMock(BackendInterface::class);
$this->l10n = $this->createMock(IL10N::class);
$this->config = $this->createMock(IConfig::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->remoteUserCalendarHome = new RemoteUserCalendarHome(
$this->calDavBackend,
[
'uri' => 'principals/remote-users/abcdef123',
],
$this->l10n,
$this->config,
$this->logger,
);
}
public function testGetChild(): void {
$calendar1 = [
'id' => 10,
'uri' => 'cal1',
];
$calendar2 = [
'id' => 11,
'uri' => 'cal2',
];
$this->calDavBackend->expects(self::once())
->method('getCalendarsForUser')
->with('principals/remote-users/abcdef123')
->willReturn([
$calendar1,
$calendar2,
]);
$actual = $this->remoteUserCalendarHome->getChild('cal2');
$this->assertInstanceOf(Calendar::class, $actual);
$this->assertEquals(11, $actual->getResourceId());
$this->assertEquals('cal2', $actual->getName());
}
public function testGetChildNotFound(): void {
$calendar1 = [
'id' => 10,
'uri' => 'cal1',
];
$calendar2 = [
'id' => 11,
'uri' => 'cal2',
];
$this->calDavBackend->expects(self::once())
->method('getCalendarsForUser')
->with('principals/remote-users/abcdef123')
->willReturn([
$calendar1,
$calendar2,
]);
$this->expectException(NotFound::class);
$this->remoteUserCalendarHome->getChild('cal3');
}
public function testGetChildren(): void {
$calendar1 = [
'id' => 10,
'uri' => 'cal1',
];
$calendar2 = [
'id' => 11,
'uri' => 'cal2',
];
$this->calDavBackend->expects(self::once())
->method('getCalendarsForUser')
->with('principals/remote-users/abcdef123')
->willReturn([
$calendar1,
$calendar2,
]);
$actual = $this->remoteUserCalendarHome->getChildren();
$this->assertInstanceOf(Calendar::class, $actual[0]);
$this->assertEquals(10, $actual[0]->getResourceId());
$this->assertEquals('cal1', $actual[0]->getName());
$this->assertInstanceOf(Calendar::class, $actual[1]);
$this->assertEquals(11, $actual[1]->getResourceId());
$this->assertEquals('cal2', $actual[1]->getName());
}
}

View file

@ -9,6 +9,7 @@ namespace OCA\DAV\Tests\unit\CalDAV;
use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Calendar;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\PublicCalendar;
use OCA\DAV\CalDAV\PublicCalendarRoot;
use OCA\DAV\Connector\Sabre\Principal;
@ -43,6 +44,8 @@ class PublicCalendarRootTest extends TestCase {
private ISecureRandom $random;
private LoggerInterface&MockObject $logger;
protected FederatedCalendarMapper&MockObject $federatedCalendarMapper;
protected function setUp(): void {
parent::setUp();
@ -52,6 +55,7 @@ class PublicCalendarRootTest extends TestCase {
$this->groupManager = $this->createMock(IGroupManager::class);
$this->random = Server::get(ISecureRandom::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->federatedCalendarMapper = $this->createMock(FederatedCalendarMapper::class);
$dispatcher = $this->createMock(IEventDispatcher::class);
$config = $this->createMock(IConfig::class);
$sharingBackend = $this->createMock(\OCA\DAV\CalDAV\Sharing\Backend::class);
@ -73,6 +77,7 @@ class PublicCalendarRootTest extends TestCase {
$dispatcher,
$config,
$sharingBackend,
$this->federatedCalendarMapper,
false,
);
$this->l10n = $this->createMock(IL10N::class);

View file

@ -8,12 +8,14 @@
namespace OCA\DAV\Tests\unit\CardDAV;
use OC\KnownUser\KnownUserService;
use OCA\DAV\CalDAV\Federation\FederationSharingService;
use OCA\DAV\CalDAV\Proxy\ProxyMapper;
use OCA\DAV\CardDAV\AddressBook;
use OCA\DAV\CardDAV\CardDavBackend;
use OCA\DAV\CardDAV\Sharing\Backend;
use OCA\DAV\CardDAV\Sharing\Service;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCA\DAV\DAV\Sharing\SharingMapper;
use OCP\Accounts\IAccountManager;
use OCP\App\IAppManager;
@ -51,6 +53,8 @@ class CardDavBackendTest extends TestCase {
private IGroupManager&MockObject $groupManager;
private IEventDispatcher&MockObject $dispatcher;
private IConfig&MockObject $config;
private RemoteUserPrincipalBackend&MockObject $remoteUserPrincipalBackend;
private FederationSharingService&MockObject $federationSharingService;
private Backend $sharingBackend;
private IDBConnection $db;
private CardDavBackend $backend;
@ -122,13 +126,17 @@ class CardDavBackendTest extends TestCase {
->withAnyParameters()
->willReturn([self::UNIT_TEST_GROUP]);
$this->dispatcher = $this->createMock(IEventDispatcher::class);
$this->remoteUserPrincipalBackend = $this->createMock(RemoteUserPrincipalBackend::class);
$this->federationSharingService = $this->createMock(FederationSharingService::class);
$this->db = Server::get(IDBConnection::class);
$this->sharingBackend = new Backend($this->userManager,
$this->groupManager,
$this->principal,
$this->remoteUserPrincipalBackend,
$this->createMock(ICacheFactory::class),
new Service(new SharingMapper($this->db)),
$this->federationSharingService,
$this->createMock(LoggerInterface::class)
);

View file

@ -66,13 +66,13 @@ class SyncServiceTest extends TestCase {
->willReturn($this->client);
$this->service = new SyncService(
$clientService,
$this->config,
$this->backend,
$this->userManager,
$this->dbConnection,
$this->logger,
$this->converter,
$clientService,
$this->config
);
}
@ -314,7 +314,7 @@ END:VCARD';
$clientService = $this->createMock(IClientService::class);
$config = $this->createMock(IConfig::class);
$ss = new SyncService($backend, $userManager, $dbConnection, $logger, $converter, $clientService, $config);
$ss = new SyncService($clientService, $config, $backend, $userManager, $dbConnection, $logger, $converter);
$ss->ensureSystemAddressBookExists('principals/users/adam', 'contacts', []);
}
@ -359,7 +359,7 @@ END:VCARD';
$clientService = $this->createMock(IClientService::class);
$config = $this->createMock(IConfig::class);
$ss = new SyncService($backend, $userManager, $dbConnection, $logger, $converter, $clientService, $config);
$ss = new SyncService($clientService, $config, $backend, $userManager, $dbConnection, $logger, $converter);
$ss->updateUser($user);
$ss->updateUser($user);

View file

@ -7,10 +7,12 @@ declare(strict_types=1);
*/
namespace OCA\DAV\Tests\unit\DAV\Sharing;
use OCA\DAV\CalDAV\Federation\FederationSharingService;
use OCA\DAV\CalDAV\Sharing\Backend as CalendarSharingBackend;
use OCA\DAV\CalDAV\Sharing\Service;
use OCA\DAV\CardDAV\Sharing\Backend as ContactsSharingBackend;
use OCA\DAV\Connector\Sabre\Principal;
use OCA\DAV\DAV\RemoteUserPrincipalBackend;
use OCA\DAV\DAV\Sharing\Backend;
use OCA\DAV\DAV\Sharing\IShareable;
use OCP\ICache;
@ -32,6 +34,8 @@ class BackendTest extends TestCase {
private LoggerInterface&MockObject $logger;
private ICacheFactory&MockObject $cacheFactory;
private Service&MockObject $calendarService;
private RemoteUserPrincipalBackend&MockObject $remoteUserPrincipalBackend;
private FederationSharingService&MockObject $federationSharingService;
private CalendarSharingBackend $backend;
protected function setUp(): void {
@ -47,13 +51,17 @@ class BackendTest extends TestCase {
$this->cacheFactory->expects(self::any())
->method('createInMemory')
->willReturn($this->shareCache);
$this->remoteUserPrincipalBackend = $this->createMock(RemoteUserPrincipalBackend::class);
$this->federationSharingService = $this->createMock(FederationSharingService::class);
$this->backend = new CalendarSharingBackend(
$this->userManager,
$this->groupManager,
$this->principalBackend,
$this->remoteUserPrincipalBackend,
$this->cacheFactory,
$this->calendarService,
$this->federationSharingService,
$this->logger,
);
}
@ -313,8 +321,10 @@ class BackendTest extends TestCase {
$this->userManager,
$this->groupManager,
$this->principalBackend,
$this->remoteUserPrincipalBackend,
$this->cacheFactory,
$service,
$this->federationSharingService,
$this->logger);
$resourceId = 42;
$principal = 'principals/groups/bob';

View file

@ -31,6 +31,7 @@
<commands>
<command>OCA\Federation\Command\SyncFederationAddressBooks</command>
<command>OCA\Federation\Command\SyncFederationCalendars</command>
</commands>
<settings>

View file

@ -11,6 +11,7 @@ return array(
'OCA\\Federation\\BackgroundJob\\GetSharedSecret' => $baseDir . '/../lib/BackgroundJob/GetSharedSecret.php',
'OCA\\Federation\\BackgroundJob\\RequestSharedSecret' => $baseDir . '/../lib/BackgroundJob/RequestSharedSecret.php',
'OCA\\Federation\\Command\\SyncFederationAddressBooks' => $baseDir . '/../lib/Command/SyncFederationAddressBooks.php',
'OCA\\Federation\\Command\\SyncFederationCalendars' => $baseDir . '/../lib/Command/SyncFederationCalendars.php',
'OCA\\Federation\\Controller\\OCSAuthAPIController' => $baseDir . '/../lib/Controller/OCSAuthAPIController.php',
'OCA\\Federation\\Controller\\SettingsController' => $baseDir . '/../lib/Controller/SettingsController.php',
'OCA\\Federation\\DAV\\FedAuth' => $baseDir . '/../lib/DAV/FedAuth.php',

View file

@ -26,6 +26,7 @@ class ComposerStaticInitFederation
'OCA\\Federation\\BackgroundJob\\GetSharedSecret' => __DIR__ . '/..' . '/../lib/BackgroundJob/GetSharedSecret.php',
'OCA\\Federation\\BackgroundJob\\RequestSharedSecret' => __DIR__ . '/..' . '/../lib/BackgroundJob/RequestSharedSecret.php',
'OCA\\Federation\\Command\\SyncFederationAddressBooks' => __DIR__ . '/..' . '/../lib/Command/SyncFederationAddressBooks.php',
'OCA\\Federation\\Command\\SyncFederationCalendars' => __DIR__ . '/..' . '/../lib/Command/SyncFederationCalendars.php',
'OCA\\Federation\\Controller\\OCSAuthAPIController' => __DIR__ . '/..' . '/../lib/Controller/OCSAuthAPIController.php',
'OCA\\Federation\\Controller\\SettingsController' => __DIR__ . '/..' . '/../lib/Controller/SettingsController.php',
'OCA\\Federation\\DAV\\FedAuth' => __DIR__ . '/..' . '/../lib/DAV/FedAuth.php',

View file

@ -0,0 +1,59 @@
<?php
/**
* SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Federation\Command;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class SyncFederationCalendars extends Command {
public function __construct(
private readonly FederatedCalendarSyncService $syncService,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
) {
parent::__construct();
}
protected function configure() {
$this
->setName('federation:sync-calendars')
->setDescription('Synchronize all incoming federated calendar shares');
}
protected function execute(InputInterface $input, OutputInterface $output): int {
$calendarCount = $this->federatedCalendarMapper->countAll();
if ($calendarCount === 0) {
$output->writeln('There are no federated calendars');
return 0;
}
$progress = new ProgressBar($output, $calendarCount);
$progress->start();
$calendars = $this->federatedCalendarMapper->findAll();
foreach ($calendars as $calendar) {
try {
$this->syncService->syncOne($calendar);
} catch (\Exception $e) {
$url = $calendar->getUri();
$msg = $e->getMessage();
$output->writeln("\n<error>Failed to sync calendar $url: $msg</error>");
}
$progress->advance();
}
$progress->finish();
$output->writeln('');
return 0;
}
}

View file

@ -166,6 +166,10 @@ class ShareesAPIController extends OCSController {
$shareTypes[] = IShare::TYPE_SCIENCEMESH;
}
if ($itemType === 'calendar') {
$shareTypes[] = IShare::TYPE_REMOTE;
}
if ($shareType !== null && is_array($shareType)) {
$shareTypes = array_intersect($shareTypes, $shareType);
} elseif (is_numeric($shareType)) {

View file

@ -229,7 +229,7 @@ Feature: sharees
| test (localhost) | 6 | test@localhost |
Then "remotes" sharees returned is empty
Scenario: Remote sharee for calendars not allowed
Scenario: Remote sharee for calendars
Given As an "test"
When getting sharees for
| search | test@localhost |
@ -240,7 +240,8 @@ Feature: sharees
Then "users" sharees returned is empty
Then "exact groups" sharees returned is empty
Then "groups" sharees returned is empty
Then "exact remotes" sharees returned is empty
Then "exact remotes" sharees returned are
| test (localhost) | 6 | test@localhost |
Then "remotes" sharees returned is empty
Scenario: Group sharees not returned when group sharing is disabled

View file

@ -212,7 +212,7 @@ Feature: sharees_provisioningapiv2
| test (localhost) | 6 | test@localhost |
Then "remotes" sharees returned is empty
Scenario: Remote sharee for calendars not allowed
Scenario: Remote sharee for calendars
Given As an "test"
When getting sharees for
| search | test@localhost |
@ -223,7 +223,8 @@ Feature: sharees_provisioningapiv2
Then "users" sharees returned is empty
Then "exact groups" sharees returned is empty
Then "groups" sharees returned is empty
Then "exact remotes" sharees returned is empty
Then "exact remotes" sharees returned are
| test (localhost) | 6 | test@localhost |
Then "remotes" sharees returned is empty
Scenario: Group sharees not returned when group sharing is disabled

View file

@ -126,7 +126,6 @@
<DeprecatedMethod>
<code><![CDATA[exec]]></code>
<code><![CDATA[getAppValue]]></code>
<code><![CDATA[getL10N]]></code>
<code><![CDATA[getL10NFactory]]></code>
</DeprecatedMethod>
<UndefinedGlobalVariable>
@ -824,6 +823,20 @@
<code><![CDATA[null]]></code>
</NullableReturnStatement>
</file>
<file src="apps/dav/lib/DAV/RemoteUserPrincipalBackend.php">
<InvalidNullableReturnType>
<code><![CDATA[getPrincipalByPath]]></code>
</InvalidNullableReturnType>
<NullableReturnStatement>
<code><![CDATA[$principal]]></code>
<code><![CDATA[null]]></code>
</NullableReturnStatement>
</file>
<file src="apps/dav/lib/DAV/Sharing/Backend.php">
<LessSpecificReturnType>
<code><![CDATA[?array]]></code>
</LessSpecificReturnType>
</file>
<file src="apps/dav/lib/DAV/Sharing/Plugin.php">
<DeprecatedMethod>
<code><![CDATA[getAppValue]]></code>