diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml
index 1d14f8e15fa01..396f016e8d57c 100644
--- a/apps/dav/appinfo/info.xml
+++ b/apps/dav/appinfo/info.xml
@@ -10,7 +10,7 @@
WebDAV
WebDAV endpoint
WebDAV endpoint
- 1.34.1
+ 1.34.2
agpl
owncloud.org
DAV
@@ -30,6 +30,7 @@
OCA\DAV\BackgroundJob\EventReminderJob
OCA\DAV\BackgroundJob\CalendarRetentionJob
OCA\DAV\BackgroundJob\PruneOutdatedSyncTokensJob
+ OCA\DAV\BackgroundJob\FederatedCalendarPeriodicSyncJob
diff --git a/apps/dav/appinfo/v1/caldav.php b/apps/dav/appinfo/v1/caldav.php
index 2cee1866a365e..d3f05b25510bb 100644
--- a/apps/dav/appinfo/v1/caldav.php
+++ b/apps/dav/appinfo/v1/caldav.php
@@ -10,6 +10,8 @@
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\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 @@
$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 @@
$dispatcher,
$config,
Server::get(\OCA\DAV\CalDAV\Sharing\Backend::class),
+ Server::get(FederatedCalendarMapper::class),
true
);
@@ -81,7 +88,7 @@
$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->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());
diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php
index 9028b7c73099d..36ef79c37c999 100644
--- a/apps/dav/composer/composer/autoload_classmap.php
+++ b/apps/dav/composer/composer/autoload_classmap.php
@@ -19,6 +19,8 @@
'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 @@
'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 @@
'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 @@
'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 @@
'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 @@
'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 @@
'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 @@
'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',
diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php
index 6b6fbaef093d6..75b8c9193e8d6 100644
--- a/apps/dav/composer/composer/autoload_static.php
+++ b/apps/dav/composer/composer/autoload_static.php
@@ -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',
diff --git a/apps/dav/lib/AppInfo/Application.php b/apps/dav/lib/AppInfo/Application.php
index 9807b5850806c..6d1eeeeba532c 100644
--- a/apps/dav/lib/AppInfo/Application.php
+++ b/apps/dav/lib/AppInfo/Application.php
@@ -13,6 +13,7 @@
use OCA\DAV\CalDAV\CachedSubscriptionProvider;
use OCA\DAV\CalDAV\CalendarManager;
use OCA\DAV\CalDAV\CalendarProvider;
+use OCA\DAV\CalDAV\Federation\CalendarFederationProvider;
use OCA\DAV\CalDAV\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\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\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\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\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 @@ public function register(IRegistrationContext $context): void {
$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 @@ public function register(IRegistrationContext $context): void {
$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 @@ public function boot(IBootContext $context): void {
$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 @@ public function registerCalendarReminders(NotificationProviderManager $manager,
$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),
+ );
+ }
}
diff --git a/apps/dav/lib/BackgroundJob/FederatedCalendarPeriodicSyncJob.php b/apps/dav/lib/BackgroundJob/FederatedCalendarPeriodicSyncJob.php
new file mode 100644
index 0000000000000..f64973fc558bd
--- /dev/null
+++ b/apps/dav/lib/BackgroundJob/FederatedCalendarPeriodicSyncJob.php
@@ -0,0 +1,62 @@
+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;
+ }
+ }
+ }
+}
diff --git a/apps/dav/lib/BackgroundJob/FederatedCalendarSyncJob.php b/apps/dav/lib/BackgroundJob/FederatedCalendarSyncJob.php
new file mode 100644
index 0000000000000..9a3a7d89ca402
--- /dev/null
+++ b/apps/dav/lib/BackgroundJob/FederatedCalendarSyncJob.php
@@ -0,0 +1,67 @@
+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);
+ }
+ }
+}
diff --git a/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php b/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php
index 72f2ed2c1637f..5bb7bc66bc874 100644
--- a/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php
+++ b/apps/dav/lib/CalDAV/AppCalendar/AppCalendarPlugin.php
@@ -11,6 +11,7 @@
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 @@ protected function getWrappedCalendars(string $principalUri, array $calendarUris
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)
+ );
})
);
}
diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php
index 6aea1ba436f0f..105442296690d 100644
--- a/apps/dav/lib/CalDAV/CalDavBackend.php
+++ b/apps/dav/lib/CalDAV/CalDavBackend.php
@@ -12,6 +12,8 @@
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 @@ public function __construct(
private IEventDispatcher $dispatcher,
private IConfig $config,
private Sharing\Backend $calendarSharingBackend,
+ private FederatedCalendarMapper $federatedCalendarMapper,
private bool $legacyEndpoint = false,
) {
}
@@ -1408,10 +1412,12 @@ public function createCalendarObject($calendarId, $objectUri, $calendarData, $ca
$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 @@ public function updateCalendarObject($calendarId, $objectUri, $calendarData, $ca
$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 @@ public function search(
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 @@ public function getShares(int $resourceId): array {
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 @@ public function unshare(IShareable $shareable, string $principal): void {
}
}, $this->db);
}
+
+ /**
+ * @return array[]
+ */
+ 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();
+ }
}
diff --git a/apps/dav/lib/CalDAV/Calendar.php b/apps/dav/lib/CalDAV/Calendar.php
index deb00caa93dc8..306562e83f587 100644
--- a/apps/dav/lib/CalDAV/Calendar.php
+++ b/apps/dav/lib/CalDAV/Calendar.php
@@ -64,6 +64,10 @@ public function getUri(): string {
return $this->calendarInfo['uri'];
}
+ protected function getCalendarType(): int {
+ return CalDavBackend::CALENDAR_TYPE_CALENDAR;
+ }
+
/**
* {@inheritdoc}
* @throws Forbidden
@@ -197,7 +201,7 @@ public function getACL() {
$this->getOwner() . '/calendar-proxy-read',
$this->getOwner() . '/calendar-proxy-write',
parent::getOwner(),
- 'principals/system/public'
+ 'principals/system/public',
];
/** @var list $acl */
$acl = array_filter($acl, function (array $rule) use ($allowedPrincipals): bool {
@@ -247,7 +251,7 @@ public function propPatch(PropPatch $propPatch) {
}
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 @@ public function getChild($name) {
}
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 @@ public function getChildren() {
}
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 @@ public function getMultipleChildren(array $paths) {
}
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 @@ public function childExists($name) {
}
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);
diff --git a/apps/dav/lib/CalDAV/CalendarHome.php b/apps/dav/lib/CalDAV/CalendarHome.php
index 89b78ba9007c9..52f45f8aa31e4 100644
--- a/apps/dav/lib/CalDAV/CalendarHome.php
+++ b/apps/dav/lib/CalDAV/CalendarHome.php
@@ -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 @@ public function getChildren() {
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 @@ public function getChild($name) {
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
diff --git a/apps/dav/lib/CalDAV/CalendarProvider.php b/apps/dav/lib/CalDAV/CalendarProvider.php
index a8b818e59aa8b..e53fe0369c80d 100644
--- a/apps/dav/lib/CalDAV/CalendarProvider.php
+++ b/apps/dav/lib/CalDAV/CalendarProvider.php
@@ -8,6 +8,7 @@
*/
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 @@ public function __construct(
}
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 @@ public function getCalendars(string $principalUri, array $calendarUris = []): ar
$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;
}
diff --git a/apps/dav/lib/CalDAV/CalendarRoot.php b/apps/dav/lib/CalDAV/CalendarRoot.php
index c0a313955bb80..5e0c2d1e31a05 100644
--- a/apps/dav/lib/CalDAV/CalendarRoot.php
+++ b/apps/dav/lib/CalDAV/CalendarRoot.php
@@ -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 @@ public function __construct(
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 @@ public function getName() {
return $parts[1];
}
+ if ($this->principalPrefix === RemoteUserPrincipalBackend::PRINCIPAL_PREFIX) {
+ return 'remote-calendars';
+ }
+
return parent::getName();
}
diff --git a/apps/dav/lib/CalDAV/Federation/CalendarFederationConfig.php b/apps/dav/lib/CalDAV/Federation/CalendarFederationConfig.php
new file mode 100644
index 0000000000000..b7293bad9fa9f
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/CalendarFederationConfig.php
@@ -0,0 +1,23 @@
+appConfig->getAppValueBool('enableCalendarFederation', true);
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/CalendarFederationNotifier.php b/apps/dav/lib/CalDAV/Federation/CalendarFederationNotifier.php
new file mode 100644
index 0000000000000..48f87f92f2073
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/CalendarFederationNotifier.php
@@ -0,0 +1,68 @@
+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,
+ );
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php b/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php
new file mode 100644
index 0000000000000..aef3c75ca385e
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php
@@ -0,0 +1,203 @@
+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/
+ $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 [];
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendar.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendar.php
new file mode 100644
index 0000000000000..34367e0e88efd
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendar.php
@@ -0,0 +1,38 @@
+federatedCalendarMapper->deleteById($this->getResourceId());
+ }
+
+ protected function getCalendarType(): int {
+ return CalDavBackend::CALENDAR_TYPE_FEDERATED;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarAuth.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarAuth.php
new file mode 100644
index 0000000000000..a5d71e48cf154
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarAuth.php
@@ -0,0 +1,81 @@
+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
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php
new file mode 100644
index 0000000000000..91027ef0d7bac
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php
@@ -0,0 +1,101 @@
+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
+ ];
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarFactory.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarFactory.php
new file mode 100644
index 0000000000000..df7af2a6ad53d
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarFactory.php
@@ -0,0 +1,42 @@
+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,
+ );
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarImpl.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarImpl.php
new file mode 100644
index 0000000000000..59cece7e8c88a
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarImpl.php
@@ -0,0 +1,73 @@
+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;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarMapper.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarMapper.php
new file mode 100644
index 0000000000000..00fa8e659debf
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarMapper.php
@@ -0,0 +1,209 @@
+ */
+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
+ */
+ 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);
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php
new file mode 100644
index 0000000000000..2ec3e57821201
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php
@@ -0,0 +1,76 @@
+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();
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/FederationSharingService.php b/apps/dav/lib/CalDAV/Federation/FederationSharingService.php
new file mode 100644
index 0000000000000..8dcb8c7cebcea
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/FederationSharingService.php
@@ -0,0 +1,180 @@
+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,
+ );
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/Protocol/CalendarFederationProtocolV1.php b/apps/dav/lib/CalDAV/Federation/Protocol/CalendarFederationProtocolV1.php
new file mode 100644
index 0000000000000..847837a1fa860
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/Protocol/CalendarFederationProtocolV1.php
@@ -0,0 +1,125 @@
+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;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Federation/Protocol/CalendarProtocolParseException.php b/apps/dav/lib/CalDAV/Federation/Protocol/CalendarProtocolParseException.php
new file mode 100644
index 0000000000000..1e453e97c9acb
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/Protocol/CalendarProtocolParseException.php
@@ -0,0 +1,13 @@
+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;
+ }
+}
diff --git a/apps/dav/lib/CalDAV/Sharing/Backend.php b/apps/dav/lib/CalDAV/Sharing/Backend.php
index fc5d65b5994b4..2403cafa18b5e 100644
--- a/apps/dav/lib/CalDAV/Sharing/Backend.php
+++ b/apps/dav/lib/CalDAV/Sharing/Backend.php
@@ -8,7 +8,9 @@
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 @@ public function __construct(
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);
}
}
diff --git a/apps/dav/lib/CalDAV/SyncService.php b/apps/dav/lib/CalDAV/SyncService.php
new file mode 100644
index 0000000000000..f11f0a4bf301e
--- /dev/null
+++ b/apps/dav/lib/CalDAV/SyncService.php
@@ -0,0 +1,92 @@
+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,
+ );
+ }
+}
diff --git a/apps/dav/lib/CalDAV/SyncServiceResult.php b/apps/dav/lib/CalDAV/SyncServiceResult.php
new file mode 100644
index 0000000000000..e8971fb97f2be
--- /dev/null
+++ b/apps/dav/lib/CalDAV/SyncServiceResult.php
@@ -0,0 +1,26 @@
+syncToken;
+ }
+
+ public function getDownloadedEvents(): int {
+ return $this->downloadedEvents;
+ }
+}
diff --git a/apps/dav/lib/CardDAV/Sharing/Backend.php b/apps/dav/lib/CardDAV/Sharing/Backend.php
index 557115762fc2c..ab870b9d4805a 100644
--- a/apps/dav/lib/CardDAV/Sharing/Backend.php
+++ b/apps/dav/lib/CardDAV/Sharing/Backend.php
@@ -8,7 +8,9 @@
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 @@ public function __construct(
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);
}
}
diff --git a/apps/dav/lib/CardDAV/SyncService.php b/apps/dav/lib/CardDAV/SyncService.php
index e6da3ed5923d1..1b5e85fb83c83 100644
--- a/apps/dav/lib/CardDAV/SyncService.php
+++ b/apps/dav/lib/CardDAV/SyncService.php
@@ -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\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 @@ public function syncRemoteAddressBook(string $url, string $userName, string $add
// 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 @@ public function syncRemoteAddressBook(string $url, string $userName, string $add
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 @@ public function ensureLocalSystemAddressBookExists(): ?array {
]);
}
- 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>, 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>, 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
*/
diff --git a/apps/dav/lib/Command/CreateCalendar.php b/apps/dav/lib/Command/CreateCalendar.php
index 033b5f8d347ee..afcae136822dd 100644
--- a/apps/dav/lib/Command/CreateCalendar.php
+++ b/apps/dav/lib/Command/CreateCalendar.php
@@ -9,6 +9,7 @@
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 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$dispatcher,
$config,
Server::get(Backend::class),
+ Server::get(FederatedCalendarMapper::class),
);
$caldav->createCalendar("principals/users/$user", $name, []);
return self::SUCCESS;
diff --git a/apps/dav/lib/DAV/RemoteUserPrincipalBackend.php b/apps/dav/lib/DAV/RemoteUserPrincipalBackend.php
new file mode 100644
index 0000000000000..ec9874427bae9
--- /dev/null
+++ b/apps/dav/lib/DAV/RemoteUserPrincipalBackend.php
@@ -0,0 +1,129 @@
+[] */
+ private array $principals = [];
+
+ /** @var array|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;
+ }
+ }
+}
diff --git a/apps/dav/lib/DAV/Sharing/Backend.php b/apps/dav/lib/DAV/Sharing/Backend.php
index d60f5cca7c6fb..281063bfa09b1 100644
--- a/apps/dav/lib/DAV/Sharing/Backend.php
+++ b/apps/dav/lib/DAV/Sharing/Backend.php
@@ -8,7 +8,9 @@
*/
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 @@ public function __construct(
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 @@ public function __construct(
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 @@ public function updateShares(IShareable $shareable, array $add, array $remove, a
// 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 @@ public function updateShares(IShareable $shareable, array $add, array $remove, a
$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 @@ public function getShares(int $resourceId): array {
$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 @@ public function preloadShares(array $resourceIds): void {
$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 @@ private function hasAccessByGroupOrCirclesMembership(int $resourceId, string $pr
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);
+ }
}
diff --git a/apps/dav/lib/DAV/Sharing/SharingMapper.php b/apps/dav/lib/DAV/Sharing/SharingMapper.php
index e47222081896c..d6e52c63866d5 100644
--- a/apps/dav/lib/DAV/Sharing/SharingMapper.php
+++ b/apps/dav/lib/DAV/Sharing/SharingMapper.php
@@ -83,6 +83,19 @@ public function share(int $resourceId, string $resourceType, int $access, string
$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 @@ public function deleteUnsharesByPrincipal(string $principal, string $resourceTyp
->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;
+ }
}
diff --git a/apps/dav/lib/DAV/Sharing/SharingService.php b/apps/dav/lib/DAV/Sharing/SharingService.php
index 11459e12d74c8..798ed26fbca53 100644
--- a/apps/dav/lib/DAV/Sharing/SharingService.php
+++ b/apps/dav/lib/DAV/Sharing/SharingService.php
@@ -50,4 +50,11 @@ public function getUnshares(int $resourceId): array {
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());
+ }
}
diff --git a/apps/dav/lib/Listener/CalendarFederationNotificationListener.php b/apps/dav/lib/Listener/CalendarFederationNotificationListener.php
new file mode 100644
index 0000000000000..2c3a36c734ff1
--- /dev/null
+++ b/apps/dav/lib/Listener/CalendarFederationNotificationListener.php
@@ -0,0 +1,105 @@
+
+ */
+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,
+ ]);
+ }
+ }
+ }
+}
diff --git a/apps/dav/lib/Listener/SabrePluginAuthInitListener.php b/apps/dav/lib/Listener/SabrePluginAuthInitListener.php
new file mode 100644
index 0000000000000..241ebea625ac3
--- /dev/null
+++ b/apps/dav/lib/Listener/SabrePluginAuthInitListener.php
@@ -0,0 +1,35 @@
+
+ */
+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);
+ }
+ }
+}
diff --git a/apps/dav/lib/Migration/Version1034Date20250605132605.php b/apps/dav/lib/Migration/Version1034Date20250605132605.php
new file mode 100644
index 0000000000000..d46892f6e5af0
--- /dev/null
+++ b/apps/dav/lib/Migration/Version1034Date20250605132605.php
@@ -0,0 +1,102 @@
+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;
+ }
+}
diff --git a/apps/dav/lib/RootCollection.php b/apps/dav/lib/RootCollection.php
index 870aa0d4540ba..82f048136bfa8 100644
--- a/apps/dav/lib/RootCollection.php
+++ b/apps/dav/lib/RootCollection.php
@@ -11,6 +11,8 @@
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\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 @@ public function __construct() {
$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 @@ public function __construct() {
$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 @@ public function __construct() {
$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 @@ public function __construct() {
$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 @@ public function __construct() {
$groupPrincipals,
$systemPrincipals,
$calendarResourcePrincipals,
- $calendarRoomPrincipals]),
+ $calendarRoomPrincipals,
+ $remoteUserPrincipals]),
$filesCollection,
$userCalendarRoot,
+ $remoteUserCalendarRoot,
new SimpleCollection('system-calendars', [
$resourceCalendarRoot,
$roomCalendarRoot,
diff --git a/apps/dav/lib/Service/ASyncService.php b/apps/dav/lib/Service/ASyncService.php
new file mode 100644
index 0000000000000..b9e045cfe86af
--- /dev/null
+++ b/apps/dav/lib/Service/ASyncService.php
@@ -0,0 +1,194 @@
+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>, 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>, 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, '/'),
+ );
+ }
+}
diff --git a/apps/dav/tests/integration/DAV/Sharing/CalDavSharingBackendTest.php b/apps/dav/tests/integration/DAV/Sharing/CalDavSharingBackendTest.php
index be06e8e4d4b66..ef60817b84cdf 100644
--- a/apps/dav/tests/integration/DAV/Sharing/CalDavSharingBackendTest.php
+++ b/apps/dav/tests/integration/DAV/Sharing/CalDavSharingBackendTest.php
@@ -11,8 +11,10 @@
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\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 @@ protected function setUp(): void {
$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 @@ protected function setUp(): void {
$this->userManager,
$this->groupManager,
$this->principalBackend,
+ $this->remoteUserPrincipalBackend,
$this->cacheFactory,
$this->sharingService,
+ $this->federationSharingService,
$this->logger
);
diff --git a/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php b/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php
index 45937d8687376..fb1eb389c61d8 100644
--- a/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php
+++ b/apps/dav/tests/unit/CalDAV/AbstractCalDavBackend.php
@@ -9,10 +9,13 @@
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 @@ protected function setUp(): void {
$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 @@ protected function setUp(): void {
$this->dispatcher,
$this->config,
$this->sharingBackend,
+ $this->federatedCalendarMapper,
false,
);
diff --git a/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php b/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php
index e25cc099bd63e..e515e6fbea05a 100644
--- a/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php
+++ b/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php
@@ -12,6 +12,8 @@
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 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 @@ protected function setUp(): void {
];
$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 @@ public function testGetChildren():void {
->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 @@ public function testGetChildNonAppGenerated():void {
->with('user-principal-123')
->willReturn([]);
+ $this->backend
+ ->expects(self::never())
+ ->method('getFederatedCalendarsForUser');
+
$this->backend
->expects(self::once())
->method('getSubscriptionsForUser')
@@ -177,6 +193,10 @@ public function testGetChildAppGenerated():void {
->with('user-principal-123')
->willReturn([]);
+ $this->backend
+ ->expects(self::never())
+ ->method('getFederatedCalendarsForUser');
+
$this->backend
->expects(self::once())
->method('getSubscriptionsForUser')
@@ -232,6 +252,12 @@ public function testGetChildrenSubscriptions(): void {
->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 @@ public function testGetChildrenSubscriptions(): void {
$this->backend,
$this->principalInfo,
$this->logger,
+ $this->federatedCalendarFactory,
false
);
@@ -292,6 +319,12 @@ public function testGetChildrenCachedSubscriptions(): void {
->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 @@ public function testGetChildrenCachedSubscriptions(): void {
$this->backend,
$this->principalInfo,
$this->logger,
+ $this->federatedCalendarFactory,
true
);
@@ -344,4 +378,56 @@ public function testGetChildrenCachedSubscriptions(): void {
$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]);
+ }
}
diff --git a/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationConfigTest.php b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationConfigTest.php
new file mode 100644
index 0000000000000..ad0e442ad4f47
--- /dev/null
+++ b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationConfigTest.php
@@ -0,0 +1,49 @@
+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());
+ }
+}
diff --git a/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationNotifierTest.php b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationNotifierTest.php
new file mode 100644
index 0000000000000..4a5adcb6821c9
--- /dev/null
+++ b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationNotifierTest.php
@@ -0,0 +1,128 @@
+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');
+ }
+}
diff --git a/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php
new file mode 100644
index 0000000000000..aba2f9279342d
--- /dev/null
+++ b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php
@@ -0,0 +1,484 @@
+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',
+ ],
+ );
+ }
+}
diff --git a/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarAuthTest.php b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarAuthTest.php
new file mode 100644
index 0000000000000..d4bd4201f0402
--- /dev/null
+++ b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarAuthTest.php
@@ -0,0 +1,139 @@
+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));
+ }
+}
diff --git a/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarImplTest.php b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarImplTest.php
new file mode 100644
index 0000000000000..4b2c6aa0a6fd7
--- /dev/null
+++ b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarImplTest.php
@@ -0,0 +1,48 @@
+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());
+ }
+}
diff --git a/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarSyncServiceTest.php b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarSyncServiceTest.php
new file mode 100644
index 0000000000000..64dda266af063
--- /dev/null
+++ b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarSyncServiceTest.php
@@ -0,0 +1,164 @@
+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));
+ }
+}
diff --git a/apps/dav/tests/unit/CalDAV/Federation/FederationSharingServiceTest.php b/apps/dav/tests/unit/CalDAV/Federation/FederationSharingServiceTest.php
new file mode 100644
index 0000000000000..1bb03e387baf5
--- /dev/null
+++ b/apps/dav/tests/unit/CalDAV/Federation/FederationSharingServiceTest.php
@@ -0,0 +1,463 @@
+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
+ );
+ }
+}
diff --git a/apps/dav/tests/unit/CalDAV/Federation/RemoteUserCalendarHomeTest.php b/apps/dav/tests/unit/CalDAV/Federation/RemoteUserCalendarHomeTest.php
new file mode 100644
index 0000000000000..7a6635873b54c
--- /dev/null
+++ b/apps/dav/tests/unit/CalDAV/Federation/RemoteUserCalendarHomeTest.php
@@ -0,0 +1,121 @@
+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());
+ }
+}
diff --git a/apps/dav/tests/unit/CalDAV/PublicCalendarRootTest.php b/apps/dav/tests/unit/CalDAV/PublicCalendarRootTest.php
index 6acceed6f648b..7e8db22c8f0cb 100644
--- a/apps/dav/tests/unit/CalDAV/PublicCalendarRootTest.php
+++ b/apps/dav/tests/unit/CalDAV/PublicCalendarRootTest.php
@@ -9,6 +9,7 @@
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 @@ protected function setUp(): void {
$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 @@ protected function setUp(): void {
$dispatcher,
$config,
$sharingBackend,
+ $this->federatedCalendarMapper,
false,
);
$this->l10n = $this->createMock(IL10N::class);
diff --git a/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php b/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php
index c5eafa0764a42..9bc7e28f2c7b3 100644
--- a/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php
+++ b/apps/dav/tests/unit/CardDAV/CardDavBackendTest.php
@@ -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 @@ protected function setUp(): void {
->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)
);
diff --git a/apps/dav/tests/unit/CardDAV/SyncServiceTest.php b/apps/dav/tests/unit/CardDAV/SyncServiceTest.php
index 77caed336f49d..716db41c49a00 100644
--- a/apps/dav/tests/unit/CardDAV/SyncServiceTest.php
+++ b/apps/dav/tests/unit/CardDAV/SyncServiceTest.php
@@ -66,13 +66,13 @@ public function setUp(): void {
->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 @@ public function testEnsureSystemAddressBookExists(): void {
$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 @@ public function testUpdateAndDeleteUser(bool $activated, int $createCalls, int $
$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);
diff --git a/apps/dav/tests/unit/DAV/Sharing/BackendTest.php b/apps/dav/tests/unit/DAV/Sharing/BackendTest.php
index 556a623a73f31..d980747760b6e 100644
--- a/apps/dav/tests/unit/DAV/Sharing/BackendTest.php
+++ b/apps/dav/tests/unit/DAV/Sharing/BackendTest.php
@@ -7,10 +7,12 @@
*/
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 @@ protected function setUp(): void {
$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 @@ public function testGetSharesAddressbooks(): void {
$this->userManager,
$this->groupManager,
$this->principalBackend,
+ $this->remoteUserPrincipalBackend,
$this->cacheFactory,
$service,
+ $this->federationSharingService,
$this->logger);
$resourceId = 42;
$principal = 'principals/groups/bob';
diff --git a/apps/federation/appinfo/info.xml b/apps/federation/appinfo/info.xml
index 2762344344d34..1c4b816267df9 100644
--- a/apps/federation/appinfo/info.xml
+++ b/apps/federation/appinfo/info.xml
@@ -31,6 +31,7 @@
OCA\Federation\Command\SyncFederationAddressBooks
+ OCA\Federation\Command\SyncFederationCalendars
diff --git a/apps/federation/composer/composer/autoload_classmap.php b/apps/federation/composer/composer/autoload_classmap.php
index 4195911150cb2..deca7ce3f04c8 100644
--- a/apps/federation/composer/composer/autoload_classmap.php
+++ b/apps/federation/composer/composer/autoload_classmap.php
@@ -11,6 +11,7 @@
'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',
diff --git a/apps/federation/composer/composer/autoload_static.php b/apps/federation/composer/composer/autoload_static.php
index b2945ddb80d39..4754c830d11b5 100644
--- a/apps/federation/composer/composer/autoload_static.php
+++ b/apps/federation/composer/composer/autoload_static.php
@@ -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',
diff --git a/apps/federation/lib/Command/SyncFederationCalendars.php b/apps/federation/lib/Command/SyncFederationCalendars.php
new file mode 100644
index 0000000000000..b7961778f416c
--- /dev/null
+++ b/apps/federation/lib/Command/SyncFederationCalendars.php
@@ -0,0 +1,59 @@
+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("\nFailed to sync calendar $url: $msg");
+ }
+
+ $progress->advance();
+ }
+
+ $progress->finish();
+ $output->writeln('');
+
+ return 0;
+ }
+}
diff --git a/apps/files_sharing/lib/Controller/ShareesAPIController.php b/apps/files_sharing/lib/Controller/ShareesAPIController.php
index 0c458ce9662de..8d88af0c19f3d 100644
--- a/apps/files_sharing/lib/Controller/ShareesAPIController.php
+++ b/apps/files_sharing/lib/Controller/ShareesAPIController.php
@@ -166,6 +166,10 @@ public function search(string $search = '', ?string $itemType = null, int $page
$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)) {
diff --git a/build/integration/sharees_features/sharees.feature b/build/integration/sharees_features/sharees.feature
index 8b0a0e5133e3e..9480ef997ae57 100644
--- a/build/integration/sharees_features/sharees.feature
+++ b/build/integration/sharees_features/sharees.feature
@@ -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
diff --git a/build/integration/sharees_features/sharees_provisioningapiv2.feature b/build/integration/sharees_features/sharees_provisioningapiv2.feature
index 7bd8ecbdbb703..b27bb7a4f21fd 100644
--- a/build/integration/sharees_features/sharees_provisioningapiv2.feature
+++ b/build/integration/sharees_features/sharees_provisioningapiv2.feature
@@ -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
diff --git a/build/psalm-baseline.xml b/build/psalm-baseline.xml
index fd9c1b31a310d..b88050d2f2d29 100644
--- a/build/psalm-baseline.xml
+++ b/build/psalm-baseline.xml
@@ -126,7 +126,6 @@
-
@@ -824,6 +823,20 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+