diff --git a/apps/dav/appinfo/routes.php b/apps/dav/appinfo/routes.php index dba7bcbbdf07f..8514c32a2320f 100644 --- a/apps/dav/appinfo/routes.php +++ b/apps/dav/appinfo/routes.php @@ -15,6 +15,9 @@ ], 'ocs' => [ ['name' => 'direct#getUrl', 'url' => '/api/v1/direct', 'verb' => 'POST'], + ['name' => 'federated_calendar#getPending', 'url' => '/api/v1/federated_calendars/pending', 'verb' => 'GET'], + ['name' => 'federated_calendar#accept', 'url' => '/api/v1/federated_calendars/pending/{id}', 'verb' => 'POST'], + ['name' => 'federated_calendar#decline', 'url' => '/api/v1/federated_calendars/pending/{id}', 'verb' => 'DELETE'], ['name' => 'upcoming_events#getEvents', 'url' => '/api/v1/events/upcoming', 'verb' => 'GET'], ['name' => 'out_of_office#getCurrentOutOfOfficeData', 'url' => '/api/v1/outOfOffice/{userId}/now', 'verb' => 'GET'], ['name' => 'out_of_office#getOutOfOffice', 'url' => '/api/v1/outOfOffice/{userId}', 'verb' => 'GET'], diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index ad51f5b66075f..60effe4612b0d 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -76,6 +76,8 @@ 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarEntity' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarEntity.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarFactory' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarFactory.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarImpl' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarImpl.php', + 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarInvitationService' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarInvitationService.php', + 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarInviteNotifier' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarInviteNotifier.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarMapper' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarObject' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarObject.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php', @@ -262,6 +264,7 @@ 'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php', 'OCA\\DAV\\Controller\\DirectController' => $baseDir . '/../lib/Controller/DirectController.php', 'OCA\\DAV\\Controller\\ExampleContentController' => $baseDir . '/../lib/Controller/ExampleContentController.php', + 'OCA\\DAV\\Controller\\FederatedCalendarController' => $baseDir . '/../lib/Controller/FederatedCalendarController.php', 'OCA\\DAV\\Controller\\InvitationResponseController' => $baseDir . '/../lib/Controller/InvitationResponseController.php', 'OCA\\DAV\\Controller\\OutOfOfficeController' => $baseDir . '/../lib/Controller/OutOfOfficeController.php', 'OCA\\DAV\\Controller\\UpcomingEventsController' => $baseDir . '/../lib/Controller/UpcomingEventsController.php', @@ -393,6 +396,7 @@ 'OCA\\DAV\\Migration\\Version1034Date20250605132605' => $baseDir . '/../lib/Migration/Version1034Date20250605132605.php', 'OCA\\DAV\\Migration\\Version1034Date20250813093701' => $baseDir . '/../lib/Migration/Version1034Date20250813093701.php', 'OCA\\DAV\\Migration\\Version1036Date20251202000000' => $baseDir . '/../lib/Migration/Version1036Date20251202000000.php', + 'OCA\\DAV\\Migration\\Version1040Date20260805000000' => $baseDir . '/../lib/Migration/Version1040Date20260805000000.php', 'OCA\\DAV\\Model\\ExampleEvent' => $baseDir . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => $baseDir . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => $baseDir . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index 59836e7e4a6d8..35113c2745b9a 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -7,14 +7,14 @@ class ComposerStaticInitDAV { public static $prefixLengthsPsr4 = array ( - 'O' => + 'O' => array ( 'OCA\\DAV\\' => 8, ), ); public static $prefixDirsPsr4 = array ( - 'OCA\\DAV\\' => + 'OCA\\DAV\\' => array ( 0 => __DIR__ . '/..' . '/../lib', ), @@ -91,6 +91,8 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarEntity' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarEntity.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarFactory' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarFactory.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarImpl' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarImpl.php', + 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarInvitationService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarInvitationService.php', + 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarInviteNotifier' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarInviteNotifier.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarMapper' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarObject.php', 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php', @@ -277,6 +279,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php', 'OCA\\DAV\\Controller\\DirectController' => __DIR__ . '/..' . '/../lib/Controller/DirectController.php', 'OCA\\DAV\\Controller\\ExampleContentController' => __DIR__ . '/..' . '/../lib/Controller/ExampleContentController.php', + 'OCA\\DAV\\Controller\\FederatedCalendarController' => __DIR__ . '/..' . '/../lib/Controller/FederatedCalendarController.php', 'OCA\\DAV\\Controller\\InvitationResponseController' => __DIR__ . '/..' . '/../lib/Controller/InvitationResponseController.php', 'OCA\\DAV\\Controller\\OutOfOfficeController' => __DIR__ . '/..' . '/../lib/Controller/OutOfOfficeController.php', 'OCA\\DAV\\Controller\\UpcomingEventsController' => __DIR__ . '/..' . '/../lib/Controller/UpcomingEventsController.php', @@ -408,6 +411,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Migration\\Version1034Date20250605132605' => __DIR__ . '/..' . '/../lib/Migration/Version1034Date20250605132605.php', 'OCA\\DAV\\Migration\\Version1034Date20250813093701' => __DIR__ . '/..' . '/../lib/Migration/Version1034Date20250813093701.php', 'OCA\\DAV\\Migration\\Version1036Date20251202000000' => __DIR__ . '/..' . '/../lib/Migration/Version1036Date20251202000000.php', + 'OCA\\DAV\\Migration\\Version1040Date20260805000000' => __DIR__ . '/..' . '/../lib/Migration/Version1040Date20260805000000.php', 'OCA\\DAV\\Model\\ExampleEvent' => __DIR__ . '/..' . '/../lib/Model/ExampleEvent.php', 'OCA\\DAV\\Paginate\\LimitedCopyIterator' => __DIR__ . '/..' . '/../lib/Paginate/LimitedCopyIterator.php', 'OCA\\DAV\\Paginate\\PaginateCache' => __DIR__ . '/..' . '/../lib/Paginate/PaginateCache.php', diff --git a/apps/dav/lib/AppInfo/Application.php b/apps/dav/lib/AppInfo/Application.php index f4b590a92808c..eab5e7c5fe725 100644 --- a/apps/dav/lib/AppInfo/Application.php +++ b/apps/dav/lib/AppInfo/Application.php @@ -14,6 +14,7 @@ use OCA\DAV\CalDAV\CalendarManager; use OCA\DAV\CalDAV\CalendarProvider; use OCA\DAV\CalDAV\Federation\CalendarFederationProvider; +use OCA\DAV\CalDAV\Federation\FederatedCalendarInviteNotifier; use OCA\DAV\CalDAV\Reminder\NotificationProvider\AudioProvider; use OCA\DAV\CalDAV\Reminder\NotificationProvider\EmailProvider; use OCA\DAV\CalDAV\Reminder\NotificationProvider\PushProvider; @@ -216,6 +217,7 @@ public function register(IRegistrationContext $context): void { $context->registerNotifierService(NotifierCalDAV::class); $context->registerNotifierService(NotifierCardDAV::class); + $context->registerNotifierService(FederatedCalendarInviteNotifier::class); $context->registerCalendarProvider(CalendarProvider::class); $context->registerCalendarProvider(CachedSubscriptionProvider::class); diff --git a/apps/dav/lib/BackgroundJob/FederatedCalendarSyncJob.php b/apps/dav/lib/BackgroundJob/FederatedCalendarSyncJob.php index 9a3a7d89ca402..721146bc3702c 100644 --- a/apps/dav/lib/BackgroundJob/FederatedCalendarSyncJob.php +++ b/apps/dav/lib/BackgroundJob/FederatedCalendarSyncJob.php @@ -10,6 +10,7 @@ namespace OCA\DAV\BackgroundJob; use OCA\DAV\CalDAV\Federation\CalendarFederationConfig; +use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity; use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper; use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService; use OCP\AppFramework\Db\DoesNotExistException; @@ -50,6 +51,11 @@ protected function run($argument): void { return; } + // Never fetch data for invitations the sharee did not accept (yet) + if ($calendar->getState() !== FederatedCalendarEntity::STATE_ACCEPTED) { + return; + } + try { $this->syncService->syncOne($calendar); } catch (ClientExceptionInterface $e) { diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 76ca18cfb148d..19cc5e58e8962 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -4053,7 +4053,10 @@ public function unshare(IShareable $shareable, string $principal): void { * @return array[] */ public function getFederatedCalendarsForUser(string $principalUri): array { - $federatedCalendars = $this->federatedCalendarMapper->findByPrincipalUri($principalUri); + $federatedCalendars = $this->federatedCalendarMapper->findByPrincipalUri( + $principalUri, + FederatedCalendarEntity::STATE_ACCEPTED, + ); return array_map( static fn (FederatedCalendarEntity $entity) => $entity->toCalendarInfo(), $federatedCalendars, @@ -4062,6 +4065,10 @@ public function getFederatedCalendarsForUser(string $principalUri): array { public function getFederatedCalendarByUri(string $principalUri, string $uri): ?array { $federatedCalendar = $this->federatedCalendarMapper->findByUri($principalUri, $uri); - return $federatedCalendar?->toCalendarInfo(); + if ($federatedCalendar === null + || $federatedCalendar->getState() !== FederatedCalendarEntity::STATE_ACCEPTED) { + return null; + } + return $federatedCalendar->toCalendarInfo(); } } diff --git a/apps/dav/lib/CalDAV/Federation/CalendarFederationConfig.php b/apps/dav/lib/CalDAV/Federation/CalendarFederationConfig.php index cfbbbe029e6de..11e94eb9a647b 100644 --- a/apps/dav/lib/CalDAV/Federation/CalendarFederationConfig.php +++ b/apps/dav/lib/CalDAV/Federation/CalendarFederationConfig.php @@ -41,4 +41,11 @@ public function isIncomingServer2serverShareEnabled(): bool { } return $this->appConfig->getValueBool('files_sharing', 'incoming_server2server_share_enabled', true); } + + /** + * Check if incoming shares from trusted servers are accepted automatically + */ + public function isTrustedShareAutoAcceptEnabled(): bool { + return $this->appConfig->getValueBool('files_sharing', 'federatedTrustedShareAutoAccept', true); + } } diff --git a/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php b/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php index a62cba7aad242..6a3ce5aab3140 100644 --- a/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php +++ b/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php @@ -36,6 +36,7 @@ public function __construct( private readonly CalendarFederationConfig $calendarFederationConfig, private readonly IJobList $jobList, private readonly ICloudIdManager $cloudIdManager, + private readonly FederatedCalendarInvitationService $invitationService, ) { } @@ -131,8 +132,8 @@ public function shareReceived(ICloudFederationShare $share): string { $sharedWithPrincipal = 'principals/users/' . $share->getShareWith(); - // Delete existing incoming federated share first $calendar = $this->federatedCalendarMapper->findByUri($sharedWithPrincipal, $calendarUri); + $isNew = $calendar === null; if ($calendar === null) { $calendar = new FederatedCalendarEntity(); @@ -146,17 +147,34 @@ public function shareReceived(ICloudFederationShare $share): string { $calendar->setSharedByDisplayName($share->getSharedByDisplayName()); $calendar->setPermissions($permissions); $calendar->setComponents($components); + $calendar->setState(FederatedCalendarEntity::STATE_PENDING); $calendar = $this->federatedCalendarMapper->insert($calendar); } else { $calendar->setToken($share->getShareSecret()); $calendar->setPermissions($permissions); $calendar->setComponents($components); + if ($calendar->getState() === FederatedCalendarEntity::STATE_PENDING) { + // The open invitation shows the sharer's metadata, keep it + // fresh. Accepted calendars are not touched as the sharee owns + // the display name and color from that point on. + $calendar->setDisplayName($displayName); + $calendar->setColor($color); + $calendar->setSharedByDisplayName($share->getSharedByDisplayName()); + } $this->federatedCalendarMapper->update($calendar); } - $this->jobList->add(FederatedCalendarSyncJob::class, [ - FederatedCalendarSyncJob::ARGUMENT_ID => $calendar->getId(), - ]); + if ($calendar->getState() === FederatedCalendarEntity::STATE_ACCEPTED) { + // Re-share of an already accepted calendar: just refresh the data + $this->jobList->add(FederatedCalendarSyncJob::class, [ + FederatedCalendarSyncJob::ARGUMENT_ID => $calendar->getId(), + ]); + } elseif ($this->invitationService->shouldAutoAccept($share->getOwner(), $calendar->getRemoteUrl())) { + $this->invitationService->accept($calendar); + } elseif ($isNew) { + // A re-shared pending calendar keeps its original invitation + $this->invitationService->notifyAboutNewShare($calendar); + } return (string)$calendar->getId(); } @@ -212,6 +230,7 @@ private function handleSyncCalendarNotification(array $notification): array { $calendarUrl, 'principals/users/' . $shareWith->getUser(), $sharedSecret, + FederatedCalendarEntity::STATE_ACCEPTED, ); if (empty($calendars)) { throw new ShareNotFound('Calendar is not shared with the sharee'); diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php index e6d869fec88b7..55d5b11515bef 100644 --- a/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php +++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php @@ -39,8 +39,13 @@ * @method void setSharedByDisplayName(string $sharedByDisplayName) * @method string getComponents() * @method void setComponents(string $components) + * @method int getState() + * @method void setState(int $state) */ class FederatedCalendarEntity extends Entity { + public const STATE_PENDING = 0; + public const STATE_ACCEPTED = 1; + protected string $principaluri = ''; protected string $uri = ''; protected string $displayName = ''; @@ -53,6 +58,7 @@ class FederatedCalendarEntity extends Entity { protected string $sharedBy = ''; protected string $sharedByDisplayName = ''; protected string $components = ''; + protected int $state = self::STATE_PENDING; public function __construct() { $this->addType('principaluri', Types::STRING); @@ -67,6 +73,7 @@ public function __construct() { $this->addType('sharedBy', Types::STRING); $this->addType('sharedByDisplayName', Types::STRING); $this->addType('components', Types::STRING); + $this->addType('state', Types::INTEGER); } public function getSyncTokenForSabre(): string { diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarInvitationService.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarInvitationService.php new file mode 100644 index 0000000000000..671b4cabd3f08 --- /dev/null +++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarInvitationService.php @@ -0,0 +1,152 @@ +setState(FederatedCalendarEntity::STATE_ACCEPTED); + $this->mapper->update($calendar); + + $this->jobList->add(FederatedCalendarSyncJob::class, [ + FederatedCalendarSyncJob::ARGUMENT_ID => $calendar->getId(), + ]); + + $this->dismissNotification($calendar); + } + + public function decline(FederatedCalendarEntity $calendar): void { + // Dismiss first: a leftover bell notification would offer actions for + // a share that no longer exists + $this->dismissNotification($calendar); + $this->mapper->deleteById($calendar->getId()); + } + + public function notifyAboutNewShare(FederatedCalendarEntity $calendar): void { + $notification = $this->notificationManager->createNotification(); + $notification->setApp(Application::APP_ID) + ->setUser($this->getUserId($calendar)) + ->setDateTime(new \DateTime()) + ->setObject(self::NOTIFICATION_OBJECT_TYPE, (string)$calendar->getId()) + ->setSubject(self::NOTIFICATION_SUBJECT_NEW_SHARE, [ + 'sharedBy' => $calendar->getSharedBy(), + 'sharedByDisplayName' => $calendar->getSharedByDisplayName(), + 'calendarName' => $calendar->getDisplayName(), + ]); + + $endpointUrl = $this->url->linkToOCSRouteAbsolute('dav.federated_calendar.accept', [ + 'id' => $calendar->getId(), + ]); + + $declineAction = $notification->createAction(); + $declineAction->setLabel('decline') + ->setLink($endpointUrl, 'DELETE'); + $notification->addAction($declineAction); + + $acceptAction = $notification->createAction(); + $acceptAction->setLabel('accept') + ->setLink($endpointUrl, 'POST'); + $notification->addAction($acceptAction); + + $this->notificationManager->notify($notification); + } + + public function dismissNotification(FederatedCalendarEntity $calendar): void { + $notification = $this->notificationManager->createNotification(); + $notification->setApp(Application::APP_ID) + ->setUser($this->getUserId($calendar)) + ->setObject(self::NOTIFICATION_OBJECT_TYPE, (string)$calendar->getId()); + $this->notificationManager->markProcessed($notification); + } + + public function shouldAutoAccept(?string $ownerCloudId, string $remoteUrl): bool { + if ($ownerCloudId === null || $ownerCloudId === '') { + return false; + } + + if (!$this->config->isTrustedShareAutoAcceptEnabled()) { + return false; + } + + $trustedServers = $this->getTrustedServers(); + if ($trustedServers === null) { + return false; + } + + try { + $remote = $this->cloudIdManager->resolveCloudId($ownerCloudId)->getRemote(); + } catch (\InvalidArgumentException) { + return false; + } + + if (!$trustedServers->isTrustedServer($remote)) { + return false; + } + + return $this->isSameHost($remote, $remoteUrl); + } + + private function isSameHost(string $remote, string $remoteUrl): bool { + $remoteParts = parse_url(str_contains($remote, '://') ? $remote : '//' . $remote); + $urlParts = parse_url($remoteUrl); + if (!is_array($remoteParts) || !is_array($urlParts) + || !isset($remoteParts['host'], $urlParts['host'])) { + return false; + } + + return strcasecmp($remoteParts['host'], $urlParts['host']) === 0 + && ($remoteParts['port'] ?? null) === ($urlParts['port'] ?? null); + } + + protected function getTrustedServers(): ?TrustedServers { + if (!$this->appManager->isEnabledForAnyone('federation') + || !class_exists(TrustedServers::class)) { + return null; + } + + try { + return Server::get(TrustedServers::class); + } catch (\Throwable $e) { + $this->logger->debug('Failed to create TrustedServers', ['exception' => $e]); + return null; + } + } + + private function getUserId(FederatedCalendarEntity $calendar): string { + return substr($calendar->getPrincipaluri(), strlen(self::PRINCIPAL_PREFIX)); + } +} diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarInviteNotifier.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarInviteNotifier.php new file mode 100644 index 0000000000000..a0661a756fc86 --- /dev/null +++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarInviteNotifier.php @@ -0,0 +1,108 @@ +l10nFactory->get(Application::APP_ID)->t('Calendar federation'); + } + + /** + * @inheritDoc + */ + #[\Override] + public function prepare(INotification $notification, string $languageCode): INotification { + if ($notification->getApp() !== Application::APP_ID + || $notification->getObjectType() !== FederatedCalendarInvitationService::NOTIFICATION_OBJECT_TYPE) { + throw new UnknownNotificationException(); + } + + $l = $this->l10nFactory->get(Application::APP_ID, $languageCode); + + return match ($notification->getSubject()) { + FederatedCalendarInvitationService::NOTIFICATION_SUBJECT_NEW_SHARE + => $this->parseNewShare($notification, $l), + default => throw new UnknownNotificationException(), + }; + } + + private function parseNewShare(INotification $notification, IL10N $l): INotification { + $params = $notification->getSubjectParameters(); + $sharedBy = $params['sharedBy'] ?? ''; + $sharedByDisplayName = $params['sharedByDisplayName'] ?? ''; + $calendarName = $params['calendarName'] ?? ''; + + $notification->setIcon($this->url->getAbsoluteURL($this->url->imagePath('core', 'places/calendar.svg'))); + $notification->setRichSubject( + $l->t('{user} has shared the calendar {calendar} with you'), + [ + 'calendar' => [ + 'type' => 'calendar', + 'id' => $notification->getObjectId(), + 'name' => $calendarName, + ], + 'user' => $this->createRemoteUser($sharedBy, $sharedByDisplayName), + ], + ); + + foreach ($notification->getActions() as $action) { + switch ($action->getLabel()) { + case 'accept': + $action->setParsedLabel($l->t('Accept')) + ->setPrimary(true); + break; + case 'decline': + $action->setParsedLabel($l->t('Decline')); + break; + } + + $notification->addParsedAction($action); + } + + return $notification; + } + + private function createRemoteUser(string $cloudId, string $displayName): array { + $user = $cloudId; + $server = ''; + $atPos = strrpos($cloudId, '@'); + if ($atPos !== false) { + $user = substr($cloudId, 0, $atPos); + $server = substr($cloudId, $atPos + 1); + } + + return [ + 'type' => 'user', + 'id' => $user, + 'name' => $displayName !== '' ? $displayName : $user, + 'server' => $server, + ]; + } +} diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarMapper.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarMapper.php index 4a8103a08656f..669de49f18379 100644 --- a/apps/dav/lib/CalDAV/Federation/FederatedCalendarMapper.php +++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarMapper.php @@ -43,9 +43,10 @@ public function find(int $id): FederatedCalendarEntity { } /** + * @param int|null $state Only return calendars in the given invitation state. Null returns all. * @return FederatedCalendarEntity[] */ - public function findByPrincipalUri(string $principalUri): array { + public function findByPrincipalUri(string $principalUri, ?int $state = null): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from(self::TABLE_NAME) @@ -54,6 +55,13 @@ public function findByPrincipalUri(string $principalUri): array { $qb->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR, )); + if ($state !== null) { + $qb->andWhere($qb->expr()->eq( + 'state', + $qb->createNamedParameter($state, IQueryBuilder::PARAM_INT), + IQueryBuilder::PARAM_INT, + )); + } return $this->findEntities($qb); } @@ -95,7 +103,12 @@ public function findUnsyncedSinceBefore(int $beforeTimestamp): array { IQueryBuilder::PARAM_INT, )) // Omit unsynced calendars for now as they are synced by a separate job - ->andWhere($qb->expr()->isNotNull('last_sync')); + ->andWhere($qb->expr()->isNotNull('last_sync')) + ->andWhere($qb->expr()->eq( + 'state', + $qb->createNamedParameter(FederatedCalendarEntity::STATE_ACCEPTED, IQueryBuilder::PARAM_INT), + IQueryBuilder::PARAM_INT, + )); return $this->findEntities($qb); } @@ -140,12 +153,20 @@ public function updateSyncTokenAndTime(int $id, int $syncToken): void { } /** + * @param int|null $state Only return calendars in the given invitation state. Null returns all. * @return \Generator */ - public function findAll(): \Generator { + public function findAll(?int $state = null): \Generator { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from(self::TABLE_NAME); + if ($state !== null) { + $qb->where($qb->expr()->eq( + 'state', + $qb->createNamedParameter($state, IQueryBuilder::PARAM_INT), + IQueryBuilder::PARAM_INT, + )); + } $result = $qb->executeQuery(); while ($row = $result->fetchAssociative()) { @@ -154,10 +175,20 @@ public function findAll(): \Generator { $result->closeCursor(); } - public function countAll(): int { + /** + * @param int|null $state Only count calendars in the given invitation state. Null counts all. + */ + public function countAll(?int $state = null): int { $qb = $this->db->getQueryBuilder(); $qb->select($qb->func()->count('*')) ->from(self::TABLE_NAME); + if ($state !== null) { + $qb->where($qb->expr()->eq( + 'state', + $qb->createNamedParameter($state, IQueryBuilder::PARAM_INT), + IQueryBuilder::PARAM_INT, + )); + } $result = $qb->executeQuery(); $count = (int)$result->fetchOne(); $result->closeCursor(); @@ -182,9 +213,10 @@ public function deleteByUri(string $principalUri, string $uri): void { } /** + * @param int|null $state Only return calendars in the given invitation state. Null returns all. * @return FederatedCalendarEntity[] */ - public function findByRemoteUrl(string $remoteUrl, string $principalUri, string $token): array { + public function findByRemoteUrl(string $remoteUrl, string $principalUri, string $token, ?int $state = null): array { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from(self::TABLE_NAME) @@ -203,6 +235,13 @@ public function findByRemoteUrl(string $remoteUrl, string $principalUri, string $qb->createNamedParameter($token, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR, )); + if ($state !== null) { + $qb->andWhere($qb->expr()->eq( + 'state', + $qb->createNamedParameter($state, IQueryBuilder::PARAM_INT), + IQueryBuilder::PARAM_INT, + )); + } return $this->findEntities($qb); } diff --git a/apps/dav/lib/Controller/FederatedCalendarController.php b/apps/dav/lib/Controller/FederatedCalendarController.php new file mode 100644 index 0000000000000..cd674f6006a08 --- /dev/null +++ b/apps/dav/lib/Controller/FederatedCalendarController.php @@ -0,0 +1,121 @@ +, array{}>|DataResponse + * + * 200: Pending federated calendar invitations + * 401: When the user is not logged in + */ + #[NoAdminRequired] + public function getPending(): DataResponse { + if ($this->userId === null) { + return new DataResponse(null, Http::STATUS_UNAUTHORIZED); + } + + $calendars = $this->mapper->findByPrincipalUri( + 'principals/users/' . $this->userId, + FederatedCalendarEntity::STATE_PENDING, + ); + + return new DataResponse(array_values(array_map(static fn (FederatedCalendarEntity $calendar) => [ + 'id' => $calendar->getId(), + 'displayName' => $calendar->getDisplayName(), + 'color' => $calendar->getColor(), + 'sharedBy' => $calendar->getSharedBy(), + 'sharedByDisplayName' => $calendar->getSharedByDisplayName(), + 'remoteUrl' => $calendar->getRemoteUrl(), + 'permissions' => $calendar->getPermissions(), + 'components' => $calendar->getComponents(), + ], $calendars))); + } + + /** + * Accept a pending federated calendar invitation + * + * @param int $id The id of the pending federated calendar + * @return DataResponse + * @throws OCSNotFoundException The federated calendar was not found + * + * 200: Invitation accepted successfully + */ + #[NoAdminRequired] + public function accept(int $id): DataResponse { + $this->invitationService->accept($this->getPendingCalendar($id)); + return new DataResponse(null); + } + + /** + * Decline a pending federated calendar invitation + * + * @param int $id The id of the pending federated calendar + * @return DataResponse + * @throws OCSNotFoundException The federated calendar was not found + * + * 200: Invitation declined successfully + */ + #[NoAdminRequired] + public function decline(int $id): DataResponse { + $this->invitationService->decline($this->getPendingCalendar($id)); + return new DataResponse(null); + } + + /** + * @throws OCSNotFoundException + */ + private function getPendingCalendar(int $id): FederatedCalendarEntity { + if ($this->userId === null) { + throw new OCSNotFoundException(); + } + + try { + $calendar = $this->mapper->find($id); + } catch (DoesNotExistException) { + throw new OCSNotFoundException(); + } + + // Also return 404 for foreign calendars to not leak their existence + if ($calendar->getPrincipaluri() !== 'principals/users/' . $this->userId + || $calendar->getState() !== FederatedCalendarEntity::STATE_PENDING) { + throw new OCSNotFoundException(); + } + + return $calendar; + } +} diff --git a/apps/dav/lib/Migration/Version1040Date20260805000000.php b/apps/dav/lib/Migration/Version1040Date20260805000000.php new file mode 100644 index 0000000000000..df486312cd288 --- /dev/null +++ b/apps/dav/lib/Migration/Version1040Date20260805000000.php @@ -0,0 +1,64 @@ +getTable('calendars_federated'); + + if (!$table->hasColumn('state')) { + $table->addColumn('state', Types::INTEGER, [ + 'notnull' => true, + 'unsigned' => true, + 'default' => FederatedCalendarEntity::STATE_PENDING, + ]); + $this->backfillState = true; + } + + return $schema; + } + + #[\Override] + public function postSchemaChange(IOutput $output, Closure $schemaClosure, array $options): void { + if (!$this->backfillState) { + return; + } + + // Calendars shared before the invitation flow was introduced were + // never explicitly accepted, keep them visible + $qb = $this->connection->getQueryBuilder(); + $qb->update('calendars_federated') + ->set('state', $qb->createNamedParameter(FederatedCalendarEntity::STATE_ACCEPTED, IQueryBuilder::PARAM_INT)); + $qb->executeStatement(); + } +} diff --git a/apps/dav/lib/ResponseDefinitions.php b/apps/dav/lib/ResponseDefinitions.php index 3deafad6704c1..a9b986f341fb8 100644 --- a/apps/dav/lib/ResponseDefinitions.php +++ b/apps/dav/lib/ResponseDefinitions.php @@ -42,6 +42,17 @@ * summary: ?string, * location: ?string, * } + * + * @psalm-type DAVPendingFederatedCalendar = array{ + * id: int, + * displayName: string, + * color: ?string, + * sharedBy: string, + * sharedByDisplayName: string, + * remoteUrl: string, + * permissions: int, + * components: string, + * } */ class ResponseDefinitions { } diff --git a/apps/dav/openapi.json b/apps/dav/openapi.json index 344d37815318c..e69d5be01d9a0 100644 --- a/apps/dav/openapi.json +++ b/apps/dav/openapi.json @@ -176,6 +176,48 @@ } } }, + "PendingFederatedCalendar": { + "type": "object", + "required": [ + "id", + "displayName", + "color", + "sharedBy", + "sharedByDisplayName", + "remoteUrl", + "permissions", + "components" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "displayName": { + "type": "string" + }, + "color": { + "type": "string", + "nullable": true + }, + "sharedBy": { + "type": "string" + }, + "sharedByDisplayName": { + "type": "string" + }, + "remoteUrl": { + "type": "string" + }, + "permissions": { + "type": "integer", + "format": "int64" + }, + "components": { + "type": "string" + } + } + }, "UpcomingEvent": { "type": "object", "required": [ @@ -416,6 +458,377 @@ } } }, + "/ocs/v2.php/apps/dav/api/v1/federated_calendars/pending": { + "get": { + "operationId": "federated_calendar-get-pending", + "summary": "Get the pending federated calendar invitations of the current user", + "tags": [ + "federated_calendar" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Pending federated calendar invitations", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PendingFederatedCalendar" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "When the user is not logged in", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/dav/api/v1/federated_calendars/pending/{id}": { + "post": { + "operationId": "federated_calendar-accept", + "summary": "Accept a pending federated calendar invitation", + "tags": [ + "federated_calendar" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The id of the pending federated calendar", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Invitation accepted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "404": { + "description": "The federated calendar was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "federated_calendar-decline", + "summary": "Decline a pending federated calendar invitation", + "tags": [ + "federated_calendar" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The id of the pending federated calendar", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Invitation declined successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "404": { + "description": "The federated calendar was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/dav/api/v1/events/upcoming": { "get": { "operationId": "upcoming_events-get-events", diff --git a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php index 7c19f24bad780..4d68d66fcc107 100644 --- a/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php +++ b/apps/dav/tests/unit/CalDAV/CalDavBackendTest.php @@ -15,6 +15,7 @@ use DateTimeZone; use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\Calendar; +use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity; use OCA\DAV\DAV\Sharing\Plugin as SharingPlugin; use OCA\DAV\Events\CalendarDeletedEvent; use OCP\IConfig; @@ -2369,4 +2370,50 @@ public function testSearchPrincipalWithTimeRange(): void { private function assertStringEqualsStringIgnoringLineEndingsWithTrim(string $expected, string $actual, string $message = ''): void { $this->assertStringEqualsStringIgnoringLineEndings(trim($expected), trim($actual), $message); } + + private function buildFederatedCalendarEntity(int $state): FederatedCalendarEntity { + $entity = new FederatedCalendarEntity(); + $entity->setId(10); + $entity->setPrincipaluri(self::UNIT_TEST_USER); + $entity->setUri('federated-cal'); + $entity->setDisplayName('Federated calendar'); + $entity->setComponents('VEVENT'); + $entity->setSharedBy('sharer@nextcloud.remote'); + $entity->setState($state); + return $entity; + } + + public function testGetFederatedCalendarsForUserOnlyQueriesAcceptedCalendars(): void { + $entity = $this->buildFederatedCalendarEntity(FederatedCalendarEntity::STATE_ACCEPTED); + $this->federatedCalendarMapper->expects(self::once()) + ->method('findByPrincipalUri') + ->with(self::UNIT_TEST_USER, FederatedCalendarEntity::STATE_ACCEPTED) + ->willReturn([$entity]); + + $calendars = $this->backend->getFederatedCalendarsForUser(self::UNIT_TEST_USER); + $this->assertCount(1, $calendars); + $this->assertSame($entity->getId(), $calendars[0]['id']); + } + + public function testGetFederatedCalendarByUriReturnsAcceptedCalendar(): void { + $entity = $this->buildFederatedCalendarEntity(FederatedCalendarEntity::STATE_ACCEPTED); + $this->federatedCalendarMapper->expects(self::once()) + ->method('findByUri') + ->with(self::UNIT_TEST_USER, 'federated-cal') + ->willReturn($entity); + + $calendarInfo = $this->backend->getFederatedCalendarByUri(self::UNIT_TEST_USER, 'federated-cal'); + $this->assertNotNull($calendarInfo); + $this->assertSame($entity->getId(), $calendarInfo['id']); + } + + public function testGetFederatedCalendarByUriHidesPendingCalendar(): void { + $entity = $this->buildFederatedCalendarEntity(FederatedCalendarEntity::STATE_PENDING); + $this->federatedCalendarMapper->expects(self::once()) + ->method('findByUri') + ->with(self::UNIT_TEST_USER, 'federated-cal') + ->willReturn($entity); + + $this->assertNull($this->backend->getFederatedCalendarByUri(self::UNIT_TEST_USER, 'federated-cal')); + } } diff --git a/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationConfigTest.php b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationConfigTest.php index 845a4ade77cc0..6878ad1aadfaa 100644 --- a/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationConfigTest.php +++ b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationConfigTest.php @@ -114,4 +114,21 @@ public function testIsIncomingServer2serverShareEnabled( $this->assertEquals($expected, $this->config->isIncomingServer2serverShareEnabled()); } + + public static function provideIsTrustedShareAutoAcceptEnabledData(): array { + return [ + [true], + [false], + ]; + } + + #[DataProvider(methodName: 'provideIsTrustedShareAutoAcceptEnabledData')] + public function testIsTrustedShareAutoAcceptEnabled(bool $configValue): void { + $this->appConfig->expects(self::once()) + ->method('getValueBool') + ->with('files_sharing', 'federatedTrustedShareAutoAccept', true) + ->willReturn($configValue); + + $this->assertEquals($configValue, $this->config->isTrustedShareAutoAcceptEnabled()); + } } diff --git a/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php index 24ffec58343e6..cac81e7512eab 100644 --- a/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php +++ b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php @@ -14,6 +14,7 @@ use OCA\DAV\CalDAV\Federation\CalendarFederationConfig; use OCA\DAV\CalDAV\Federation\CalendarFederationProvider; use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity; +use OCA\DAV\CalDAV\Federation\FederatedCalendarInvitationService; use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper; use OCP\BackgroundJob\IJobList; use OCP\Federation\Exceptions\BadRequestException; @@ -35,6 +36,7 @@ class CalendarFederationProviderTest extends TestCase { private CalendarFederationConfig&MockObject $calendarFederationConfig; private IJobList&MockObject $jobList; private ICloudIdManager&MockObject $cloudIdManager; + private FederatedCalendarInvitationService&MockObject $invitationService; protected function setUp(): void { parent::setUp(); @@ -44,6 +46,7 @@ protected function setUp(): void { $this->calendarFederationConfig = $this->createMock(CalendarFederationConfig::class); $this->jobList = $this->createMock(JobList::class); $this->cloudIdManager = $this->createMock(ICloudIdManager::class); + $this->invitationService = $this->createMock(FederatedCalendarInvitationService::class); $this->calendarFederationProvider = new CalendarFederationProvider( $this->logger, @@ -51,6 +54,7 @@ protected function setUp(): void { $this->calendarFederationConfig, $this->jobList, $this->cloudIdManager, + $this->invitationService, ); } @@ -82,6 +86,8 @@ public function testShareReceived(): void { ->willReturn('sharee1'); $share->method('getShareSecret') ->willReturn('token'); + $share->method('getOwner') + ->willReturn('user1@nextcloud.remote'); $share->method('getSharedBy') ->willReturn('user1@nextcloud.remote'); $share->method('getSharedByDisplayName') @@ -116,14 +122,92 @@ public function testShareReceived(): void { $this->assertEquals('User 1', $calendar->getSharedByDisplayName()); $this->assertEquals(1, $calendar->getPermissions()); $this->assertEquals('VEVENT,VTODO', $calendar->getComponents()); + $this->assertEquals(FederatedCalendarEntity::STATE_PENDING, $calendar->getState()); $calendar->setId(10); return $calendar; }); - $this->jobList->expects(self::once()) - ->method('add') - ->with(FederatedCalendarSyncJob::class, ['id' => 10]); + $this->invitationService->expects(self::once()) + ->method('shouldAutoAccept') + ->with( + 'user1@nextcloud.remote', + 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1', + ) + ->willReturn(false); + $this->invitationService->expects(self::once()) + ->method('notifyAboutNewShare') + ->with(self::callback( + static fn (FederatedCalendarEntity $calendar) => $calendar->getId() === 10, + )); + $this->invitationService->expects(self::never()) + ->method('accept'); + $this->jobList->expects(self::never()) + ->method('add'); + + $this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share)); + } + + public function testShareReceivedWithAutoAccept(): void { + $share = $this->createMock(ICloudFederationShare::class); + $share->method('getShareType') + ->willReturn('user'); + $share->method('getProtocol') + ->willReturn([ + 'version' => 'v1', + 'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1', + 'displayName' => 'Calendar 1', + 'color' => '#ff0000', + 'access' => 3, + 'components' => 'VEVENT,VTODO', + ]); + $share->method('getShareWith') + ->willReturn('sharee1'); + $share->method('getShareSecret') + ->willReturn('token'); + $share->method('getOwner') + ->willReturn('user1@nextcloud.remote'); + $share->method('getSharedBy') + ->willReturn('user1@nextcloud.remote'); + $share->method('getSharedByDisplayName') + ->willReturn('User 1'); + + $this->calendarFederationConfig->expects(self::once()) + ->method('isFederationEnabled') + ->willReturn(true); + + $this->calendarFederationConfig->expects(self::once()) + ->method('isIncomingServer2serverShareEnabled') + ->willReturn(true); + + $this->federatedCalendarMapper->expects(self::once()) + ->method('findByUri') + ->willReturn(null); + + $this->federatedCalendarMapper->expects(self::once()) + ->method('insert') + ->willReturnCallback(function (FederatedCalendarEntity $calendar) { + $this->assertEquals(FederatedCalendarEntity::STATE_PENDING, $calendar->getState()); + $calendar->setId(10); + return $calendar; + }); + + $this->invitationService->expects(self::once()) + ->method('shouldAutoAccept') + ->with( + 'user1@nextcloud.remote', + 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1', + ) + ->willReturn(true); + $this->invitationService->expects(self::once()) + ->method('accept') + ->with(self::callback( + static fn (FederatedCalendarEntity $calendar) => $calendar->getId() === 10, + )); + $this->invitationService->expects(self::never()) + ->method('notifyAboutNewShare'); + $this->jobList->expects(self::never()) + ->method('add'); $this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share)); } @@ -166,6 +250,7 @@ public function testShareReceivedWithExistingCalendar(): void { $existingCalendar->setToken('old-token'); $existingCalendar->setPermissions(1); $existingCalendar->setComponents('VEVENT'); + $existingCalendar->setState(FederatedCalendarEntity::STATE_ACCEPTED); $this->federatedCalendarMapper->expects(self::once()) ->method('findByUri') @@ -184,9 +269,19 @@ public function testShareReceivedWithExistingCalendar(): void { $this->assertEquals('new-token', $calendar->getToken()); $this->assertEquals(1, $calendar->getPermissions()); $this->assertEquals('VEVENT,VTODO', $calendar->getComponents()); + $this->assertEquals(FederatedCalendarEntity::STATE_ACCEPTED, $calendar->getState()); + // Accepted calendars are owned by the sharee, the sharer's + // metadata must not overwrite them + $this->assertEquals('', $calendar->getDisplayName()); return $calendar; }); + $this->invitationService->expects(self::never()) + ->method('shouldAutoAccept'); + $this->invitationService->expects(self::never()) + ->method('accept'); + $this->invitationService->expects(self::never()) + ->method('notifyAboutNewShare'); $this->jobList->expects(self::once()) ->method('add') ->with(FederatedCalendarSyncJob::class, ['id' => 10]); @@ -194,6 +289,81 @@ public function testShareReceivedWithExistingCalendar(): void { $this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share)); } + public function testShareReceivedWithExistingPendingCalendar(): void { + $share = $this->createMock(ICloudFederationShare::class); + $share->method('getShareType') + ->willReturn('user'); + $share->method('getProtocol') + ->willReturn([ + 'version' => 'v1', + 'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1', + 'displayName' => 'Calendar 1', + 'color' => '#ff0000', + 'access' => 3, + 'components' => 'VEVENT,VTODO', + ]); + $share->method('getShareWith') + ->willReturn('sharee1'); + $share->method('getShareSecret') + ->willReturn('new-token'); + $share->method('getOwner') + ->willReturn('user1@nextcloud.remote'); + $share->method('getSharedBy') + ->willReturn('user1@nextcloud.remote'); + $share->method('getSharedByDisplayName') + ->willReturn('User 1'); + + $this->calendarFederationConfig->expects(self::once()) + ->method('isFederationEnabled') + ->willReturn(true); + + $this->calendarFederationConfig->expects(self::once()) + ->method('isIncomingServer2serverShareEnabled') + ->willReturn(true); + + $existingCalendar = new FederatedCalendarEntity(); + $existingCalendar->setId(10); + $existingCalendar->setPrincipaluri('principals/users/sharee1'); + $existingCalendar->setUri('ae4b8ab904076fff2b955ea21b1a0d92'); + $existingCalendar->setRemoteUrl('https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1'); + $existingCalendar->setToken('old-token'); + $existingCalendar->setState(FederatedCalendarEntity::STATE_PENDING); + + $this->federatedCalendarMapper->expects(self::once()) + ->method('findByUri') + ->willReturn($existingCalendar); + $this->federatedCalendarMapper->expects(self::never()) + ->method('insert'); + $this->federatedCalendarMapper->expects(self::once()) + ->method('update') + ->willReturnCallback(function (FederatedCalendarEntity $calendar) { + $this->assertEquals('new-token', $calendar->getToken()); + $this->assertEquals(FederatedCalendarEntity::STATE_PENDING, $calendar->getState()); + // A pending invitation keeps showing the sharer's metadata + $this->assertEquals('Calendar 1', $calendar->getDisplayName()); + $this->assertEquals('#ff0000', $calendar->getColor()); + $this->assertEquals('User 1', $calendar->getSharedByDisplayName()); + return $calendar; + }); + + $this->invitationService->expects(self::once()) + ->method('shouldAutoAccept') + ->with( + 'user1@nextcloud.remote', + 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1', + ) + ->willReturn(false); + $this->invitationService->expects(self::never()) + ->method('accept'); + // The original invitation notification is still open, do not notify twice + $this->invitationService->expects(self::never()) + ->method('notifyAboutNewShare'); + $this->jobList->expects(self::never()) + ->method('add'); + + $this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share)); + } + public function testShareReceivedWithInvalidProtocolVersion(): void { $share = $this->createMock(ICloudFederationShare::class); $share->method('getShareType') @@ -437,9 +607,13 @@ public function testShareReceivedWithReadWriteAccess(): void { return $calendar; }); - $this->jobList->expects(self::once()) - ->method('add') - ->with(FederatedCalendarSyncJob::class, ['id' => 10]); + $this->invitationService->expects(self::once()) + ->method('shouldAutoAccept') + ->willReturn(false); + $this->invitationService->expects(self::once()) + ->method('notifyAboutNewShare'); + $this->jobList->expects(self::never()) + ->method('add'); $this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share)); } @@ -530,6 +704,7 @@ public function testNotificationReceivedWithSyncCalendarNotification(): void { 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1', 'principals/users/remote1', 'token', + FederatedCalendarEntity::STATE_ACCEPTED, ) ->willReturn($calendars); @@ -641,6 +816,7 @@ public function testNotificationReceivedWithSyncCalendarNotificationAndNoCalenda 'https://nextcloud.host/remote.php/dav/remote-calendars/cmVtb3RlMUBuZXh0Y2xvdWQucmVtb3Rl/cal1_shared_by_host1', 'principals/users/remote1', 'token', + FederatedCalendarEntity::STATE_ACCEPTED, ) ->willReturn([]); @@ -659,4 +835,5 @@ public function testNotificationReceivedWithSyncCalendarNotificationAndNoCalenda ], ); } + } diff --git a/apps/federation/lib/Command/SyncFederationCalendars.php b/apps/federation/lib/Command/SyncFederationCalendars.php index b7961778f416c..d3c5bdc58a9b6 100644 --- a/apps/federation/lib/Command/SyncFederationCalendars.php +++ b/apps/federation/lib/Command/SyncFederationCalendars.php @@ -7,6 +7,7 @@ namespace OCA\Federation\Command; +use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity; use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper; use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService; use Symfony\Component\Console\Command\Command; @@ -29,7 +30,7 @@ protected function configure() { } protected function execute(InputInterface $input, OutputInterface $output): int { - $calendarCount = $this->federatedCalendarMapper->countAll(); + $calendarCount = $this->federatedCalendarMapper->countAll(FederatedCalendarEntity::STATE_ACCEPTED); if ($calendarCount === 0) { $output->writeln('There are no federated calendars'); return 0; @@ -38,7 +39,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $progress = new ProgressBar($output, $calendarCount); $progress->start(); - $calendars = $this->federatedCalendarMapper->findAll(); + $calendars = $this->federatedCalendarMapper->findAll(FederatedCalendarEntity::STATE_ACCEPTED); foreach ($calendars as $calendar) { try { $this->syncService->syncOne($calendar); diff --git a/openapi.json b/openapi.json index bd3c0b3596d70..4eb3b8e0bdc99 100644 --- a/openapi.json +++ b/openapi.json @@ -1626,6 +1626,48 @@ } } }, + "DavPendingFederatedCalendar": { + "type": "object", + "required": [ + "id", + "displayName", + "color", + "sharedBy", + "sharedByDisplayName", + "remoteUrl", + "permissions", + "components" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "displayName": { + "type": "string" + }, + "color": { + "type": "string", + "nullable": true + }, + "sharedBy": { + "type": "string" + }, + "sharedByDisplayName": { + "type": "string" + }, + "remoteUrl": { + "type": "string" + }, + "permissions": { + "type": "integer", + "format": "int64" + }, + "components": { + "type": "string" + } + } + }, "DavUpcomingEvent": { "type": "object", "required": [ @@ -18132,6 +18174,377 @@ } } }, + "/ocs/v2.php/apps/dav/api/v1/federated_calendars/pending": { + "get": { + "operationId": "dav-federated_calendar-get-pending", + "summary": "Get the pending federated calendar invitations of the current user", + "tags": [ + "dav/federated_calendar" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Pending federated calendar invitations", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DavPendingFederatedCalendar" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "When the user is not logged in", + "content": { + "application/json": { + "schema": { + "anyOf": [ + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + }, + { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + ] + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/dav/api/v1/federated_calendars/pending/{id}": { + "post": { + "operationId": "dav-federated_calendar-accept", + "summary": "Accept a pending federated calendar invitation", + "tags": [ + "dav/federated_calendar" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The id of the pending federated calendar", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Invitation accepted successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "404": { + "description": "The federated calendar was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "dav-federated_calendar-decline", + "summary": "Decline a pending federated calendar invitation", + "tags": [ + "dav/federated_calendar" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The id of the pending federated calendar", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Invitation declined successfully", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "nullable": true + } + } + } + } + } + } + } + }, + "404": { + "description": "The federated calendar was not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/dav/api/v1/events/upcoming": { "get": { "operationId": "dav-upcoming_events-get-events",