diff --git a/apps/dav/appinfo/info.xml b/apps/dav/appinfo/info.xml
index bf0deeccbc600..58f3d8428989a 100644
--- a/apps/dav/appinfo/info.xml
+++ b/apps/dav/appinfo/info.xml
@@ -10,7 +10,7 @@
WebDAV
WebDAV endpoint
WebDAV endpoint
- 1.34.2
+ 1.34.3
agpl
owncloud.org
DAV
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 21ffab9d460cc..a1f041998e35f 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\\FederatedCalendarSyncService' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php',
'OCA\\DAV\\CalDAV\\Federation\\FederationSharingService' => $baseDir . '/../lib/CalDAV/Federation/FederationSharingService.php',
@@ -259,6 +261,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',
@@ -388,6 +391,7 @@
'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\\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 f438d45a4b8cc..b34ff3819b2a4 100644
--- a/apps/dav/composer/composer/autoload_static.php
+++ b/apps/dav/composer/composer/autoload_static.php
@@ -91,6 +91,8 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarEntity' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarEntity.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarFactory' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarFactory.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarImpl' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarImpl.php',
+ 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarInvitationService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarInvitationService.php',
+ 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarInviteNotifier' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarInviteNotifier.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarMapper' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php',
'OCA\\DAV\\CalDAV\\Federation\\FederationSharingService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederationSharingService.php',
@@ -274,6 +276,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',
@@ -403,6 +406,7 @@ class ComposerStaticInitDAV
'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\\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 ebc5f110a6732..a564c5dc8f622 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;
@@ -212,6 +213,7 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(CalendarObjectDeletedEvent::class, CalendarFederationNotificationListener::class);
$context->registerNotifierService(Notifier::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 75540a1167f3b..22eb1411565d8 100644
--- a/apps/dav/lib/CalDAV/CalDavBackend.php
+++ b/apps/dav/lib/CalDAV/CalDavBackend.php
@@ -4049,7 +4049,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,
@@ -4058,6 +4061,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 f4ab1b46f5994..101d637fc1207 100644
--- a/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php
+++ b/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php
@@ -35,6 +35,7 @@ public function __construct(
private readonly CalendarFederationConfig $calendarFederationConfig,
private readonly IJobList $jobList,
private readonly ICloudIdManager $cloudIdManager,
+ private readonly FederatedCalendarInvitationService $invitationService,
) {
}
@@ -122,25 +123,49 @@ public function shareReceived(ICloudFederationShare $share): string {
$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(),
- ]);
+ $calendar = $this->federatedCalendarMapper->findByUri($sharedWithPrincipal, $calendarUri);
+ $isNew = $calendar === null;
+
+ if ($calendar === null) {
+ $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->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);
+ }
+
+ 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();
}
@@ -196,6 +221,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 91027ef0d7bac..f75d352ae7a13 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 00fa8e659debf..e0cb038a01702 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->fetch()) {
@@ -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 b9cf842591aef..dedf7cd8cdc6c 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;
@@ -2304,4 +2305,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 3de43508f2a47..135751bff0632 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 464045f34f295..df41ab8faf464 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')
@@ -96,11 +102,12 @@ public function testShareReceived(): void {
->willReturn(true);
$this->federatedCalendarMapper->expects(self::once())
- ->method('deleteByUri')
+ ->method('findByUri')
->with(
'principals/users/sharee1',
'ae4b8ab904076fff2b955ea21b1a0d92',
- );
+ )
+ ->willReturn(null);
$this->federatedCalendarMapper->expects(self::once())
->method('insert')
@@ -115,11 +122,166 @@ 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->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));
+ }
+
+ public function testShareReceivedWithExistingCalendar(): 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('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->setPermissions(1);
+ $existingCalendar->setComponents('VEVENT');
+ $existingCalendar->setState(FederatedCalendarEntity::STATE_ACCEPTED);
+
+ $this->federatedCalendarMapper->expects(self::once())
+ ->method('findByUri')
+ ->with(
+ 'principals/users/sharee1',
+ 'ae4b8ab904076fff2b955ea21b1a0d92',
+ )
+ ->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(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]);
@@ -127,6 +289,81 @@ public function testShareReceived(): 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')
@@ -400,6 +637,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);
@@ -511,6 +749,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([]);
@@ -529,4 +768,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 d653fd118b53d..7ba886c79d6f2 100644
--- a/openapi.json
+++ b/openapi.json
@@ -1606,6 +1606,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": [
@@ -16916,6 +16958,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",