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 @@ + + + + + + + + + + + + + +