diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php
index 6716b6c5ac1cd..c010a3e98d602 100644
--- a/apps/dav/composer/composer/autoload_classmap.php
+++ b/apps/dav/composer/composer/autoload_classmap.php
@@ -77,6 +77,7 @@
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarFactory' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarFactory.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarImpl' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarImpl.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarMapper' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php',
+ 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarObject' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarObject.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => $baseDir . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php',
'OCA\\DAV\\CalDAV\\Federation\\FederationSharingService' => $baseDir . '/../lib/CalDAV/Federation/FederationSharingService.php',
'OCA\\DAV\\CalDAV\\Federation\\Protocol\\CalendarFederationProtocolV1' => $baseDir . '/../lib/CalDAV/Federation/Protocol/CalendarFederationProtocolV1.php',
diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php
index 5b5b1e3fcb413..bdb965aab3ee0 100644
--- a/apps/dav/composer/composer/autoload_static.php
+++ b/apps/dav/composer/composer/autoload_static.php
@@ -92,6 +92,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarFactory' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarFactory.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarImpl' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarImpl.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarMapper' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarMapper.php',
+ 'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarObject.php',
'OCA\\DAV\\CalDAV\\Federation\\FederatedCalendarSyncService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederatedCalendarSyncService.php',
'OCA\\DAV\\CalDAV\\Federation\\FederationSharingService' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/FederationSharingService.php',
'OCA\\DAV\\CalDAV\\Federation\\Protocol\\CalendarFederationProtocolV1' => __DIR__ . '/..' . '/../lib/CalDAV/Federation/Protocol/CalendarFederationProtocolV1.php',
diff --git a/apps/dav/lib/CalDAV/CalendarProvider.php b/apps/dav/lib/CalDAV/CalendarProvider.php
index e53fe0369c80d..87a8e99c3d1f5 100644
--- a/apps/dav/lib/CalDAV/CalendarProvider.php
+++ b/apps/dav/lib/CalDAV/CalendarProvider.php
@@ -43,9 +43,9 @@ public function getCalendars(string $principalUri, array $calendarUris = []): ar
});
}
- $additionalProperties = $this->getAdditionalPropertiesForCalendars($calendarInfos);
$iCalendars = [];
+ $additionalProperties = $this->getAdditionalPropertiesForCalendars($calendarInfos);
foreach ($calendarInfos as $calendarInfo) {
$user = str_replace('principals/users/', '', $calendarInfo['principaluri']);
$path = 'calendars/' . $user . '/' . $calendarInfo['uri'];
@@ -60,14 +60,12 @@ public function getCalendars(string $principalUri, array $calendarUris = []): ar
);
}
- $additionalFederatedProps = $this->getAdditionalPropertiesForCalendars(
- $federatedCalendarInfos,
- );
+ $additionalFederatedProps = $this->getAdditionalPropertiesForCalendars($federatedCalendarInfos);
foreach ($federatedCalendarInfos as $calendarInfo) {
$user = str_replace('principals/users/', '', $calendarInfo['principaluri']);
$path = 'calendars/' . $user . '/' . $calendarInfo['uri'];
if (isset($additionalFederatedProps[$path])) {
- $calendarInfo = array_merge($calendarInfo, $additionalProperties[$path]);
+ $calendarInfo = array_merge($calendarInfo, $additionalFederatedProps[$path]);
}
$iCalendars[] = new FederatedCalendarImpl($calendarInfo, $this->calDavBackend);
diff --git a/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php b/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php
index 05d9ea9d6ecfd..8eab255b9ef3c 100644
--- a/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php
+++ b/apps/dav/lib/CalDAV/Federation/CalendarFederationProvider.php
@@ -104,9 +104,10 @@ public function shareReceived(ICloudFederationShare $share): string {
);
}
- // TODO: implement read-write sharing
+ // convert access to permissions
$permissions = match ($access) {
DavSharingBackend::ACCESS_READ => Constants::PERMISSION_READ,
+ DavSharingBackend::ACCESS_READ_WRITE => Constants::PERMISSION_READ | Constants::PERMISSION_CREATE | Constants::PERMISSION_UPDATE | Constants::PERMISSION_DELETE,
default => throw new ProviderCouldNotAddShareException(
"Unsupported access value: $access",
'',
@@ -122,20 +123,27 @@ 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);
+ $calendar = $this->federatedCalendarMapper->findByUri($sharedWithPrincipal, $calendarUri);
+
+ 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 = $this->federatedCalendarMapper->insert($calendar);
+ } else {
+ $calendar->setToken($share->getShareSecret());
+ $calendar->setPermissions($permissions);
+ $calendar->setComponents($components);
+ $this->federatedCalendarMapper->update($calendar);
+ }
$this->jobList->add(FederatedCalendarSyncJob::class, [
FederatedCalendarSyncJob::ARGUMENT_ID => $calendar->getId(),
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendar.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendar.php
index 34367e0e88efd..b3df30f547012 100644
--- a/apps/dav/lib/CalDAV/Federation/FederatedCalendar.php
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendar.php
@@ -10,29 +10,247 @@
namespace OCA\DAV\CalDAV\Federation;
use OCA\DAV\CalDAV\CalDavBackend;
-use OCA\DAV\CalDAV\Calendar;
-use OCP\IConfig;
-use OCP\IL10N;
-use Psr\Log\LoggerInterface;
-use Sabre\CalDAV\Backend;
+use OCP\Constants;
+use Sabre\CalDAV\ICalendar;
+use Sabre\CalDAV\Plugin;
+use Sabre\CalDAV\Xml\Property\SupportedCalendarComponentSet;
+use Sabre\DAV\Exception\MethodNotAllowed;
+use Sabre\DAV\Exception\NotFound;
+use Sabre\DAV\IMultiGet;
+use Sabre\DAV\IProperties;
+use Sabre\DAV\PropPatch;
+
+class FederatedCalendar implements ICalendar, IProperties, IMultiGet {
+
+ private const CALENDAR_TYPE = CalDavBackend::CALENDAR_TYPE_FEDERATED;
+ private const DAV_PROPERTY_CALENDAR_LABEL = '{DAV:}displayname';
+ private const DAV_PROPERTY_CALENDAR_COLOR = '{http://apple.com/ns/ical/}calendar-color';
+
+ private string $principalUri;
+ private string $calendarUri;
+ private ?array $calendarACL = null;
+ private FederatedCalendarEntity $federationInfo;
-class FederatedCalendar extends Calendar {
public function __construct(
- Backend\BackendInterface $caldavBackend,
- $calendarInfo,
- IL10N $l10n,
- IConfig $config,
- LoggerInterface $logger,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
+ private readonly FederatedCalendarSyncService $federatedCalendarService,
+ private readonly CalDavBackend $caldavBackend,
+ $calendarInfo,
) {
- parent::__construct($caldavBackend, $calendarInfo, $l10n, $config, $logger);
+ $this->principalUri = $calendarInfo['principaluri'];
+ $this->calendarUri = $calendarInfo['uri'];
+ $this->federationInfo = $federatedCalendarMapper->findByUri($this->principalUri, $this->calendarUri);
+ }
+
+ public function getResourceId(): int {
+ return $this->federationInfo->getId();
+ }
+
+ public function getName() {
+ return $this->federationInfo->getUri();
+ }
+
+ public function setName($name) {
+ throw new MethodNotAllowed('Renaming federated calendars is not allowed');
+ }
+
+ protected function getCalendarType(): int {
+ return self::CALENDAR_TYPE;
+ }
+
+ public function getPrincipalURI() {
+ return $this->federationInfo->getPrincipaluri();
+ }
+
+ public function getOwner() {
+ return $this->federationInfo->getSharedByPrincipal();
+ }
+
+ public function getGroup() {
+ return null;
+ }
+
+ public function getACL() {
+
+ if ($this->calendarACL !== null) {
+ return $this->calendarACL;
+ }
+
+ $permissions = $this->federationInfo->getPermissions();
+ // default permission
+ $acl = [
+ // read object permission
+ [
+ 'privilege' => '{DAV:}read',
+ 'principal' => $this->principalUri,
+ 'protected' => true,
+ ],
+ // read acl permission
+ [
+ 'privilege' => '{DAV:}read-acl',
+ 'principal' => $this->principalUri,
+ 'protected' => true,
+ ],
+ // write properties permission (calendar name, color)
+ [
+ 'privilege' => '{DAV:}write-properties',
+ 'principal' => $this->principalUri,
+ 'protected' => true,
+ ],
+ ];
+ // create permission
+ if ($permissions & Constants::PERMISSION_CREATE) {
+ $acl[] = [
+ 'privilege' => '{DAV:}bind',
+ 'principal' => $this->principalUri,
+ 'protected' => true,
+ ];
+ }
+ // update permission
+ if ($permissions & Constants::PERMISSION_UPDATE) {
+ $acl[] = [
+ 'privilege' => '{DAV:}write-content',
+ 'principal' => $this->principalUri,
+ 'protected' => true,
+ ];
+ }
+ // delete permission
+ if ($permissions & Constants::PERMISSION_DELETE) {
+ $acl[] = [
+ 'privilege' => '{DAV:}unbind',
+ 'principal' => $this->principalUri,
+ 'protected' => true,
+ ];
+ }
+
+ // cache the calculated ACL for later use
+ $this->calendarACL = $acl;
+
+ return $acl;
+ }
+
+ public function setACL(array $acl) {
+ throw new MethodNotAllowed('Changing ACLs on federated calendars is not allowed');
+ }
+
+ public function getSupportedPrivilegeSet(): ?array {
+ return null;
+ }
+
+ public function getProperties($properties): array {
+ return [
+ self::DAV_PROPERTY_CALENDAR_LABEL => $this->federationInfo->getDisplayName(),
+ self::DAV_PROPERTY_CALENDAR_COLOR => $this->federationInfo->getColor(),
+ '{' . Plugin::NS_CALDAV . '}supported-calendar-component-set' => new SupportedCalendarComponentSet(explode(',', $this->federationInfo->getComponents())),
+ ];
+ }
+
+ public function propPatch(PropPatch $propPatch): void {
+ $mutations = $propPatch->getMutations();
+ if (count($mutations) > 0) {
+ // evaluate if name was changed
+ if (isset($mutations[self::DAV_PROPERTY_CALENDAR_LABEL])) {
+ $this->federationInfo->setDisplayName($mutations[self::DAV_PROPERTY_CALENDAR_LABEL]);
+ $propPatch->setResultCode(self::DAV_PROPERTY_CALENDAR_LABEL, 200);
+ }
+ // evaluate if color was changed
+ if (isset($mutations[self::DAV_PROPERTY_CALENDAR_COLOR])) {
+ $this->federationInfo->setColor($mutations[self::DAV_PROPERTY_CALENDAR_COLOR]);
+ $propPatch->setResultCode(self::DAV_PROPERTY_CALENDAR_COLOR, 200);
+ }
+ $this->federatedCalendarMapper->update($this->federationInfo);
+ }
+ }
+
+
+ public function getChildACL() {
+ return $this->getACL();
+ }
+
+ public function getLastModified() {
+ return $this->federationInfo->getLastSync();
}
public function delete() {
$this->federatedCalendarMapper->deleteById($this->getResourceId());
}
- protected function getCalendarType(): int {
- return CalDavBackend::CALENDAR_TYPE_FEDERATED;
+ public function createDirectory($name) {
+ throw new MethodNotAllowed('Creating nested collection is not allowed');
+ }
+
+ public function calendarQuery(array $filters) {
+ $uris = $this->caldavBackend->calendarQuery($this->federationInfo->getId(), $filters, $this->getCalendarType());
+ return $uris;
+ }
+
+ public function getChild($name) {
+ $obj = $this->caldavBackend->getCalendarObject($this->federationInfo->getId(), $name, $this->getCalendarType());
+
+ if ($obj === null) {
+ throw new NotFound('Calendar object not found');
+ }
+
+ return new FederatedCalendarObject($this, $obj);
+ }
+
+ public function getChildren() {
+ $objs = $this->caldavBackend->getCalendarObjects($this->federationInfo->getId(), $this->getCalendarType());
+
+ $children = [];
+ foreach ($objs as $obj) {
+ $children[] = new FederatedCalendarObject($this, $obj);
+ }
+
+ return $children;
+ }
+
+ public function getMultipleChildren(array $paths) {
+ $objs = $this->caldavBackend->getMultipleCalendarObjects($this->federationInfo->getId(), $paths, $this->getCalendarType());
+
+ $children = [];
+ foreach ($objs as $obj) {
+ $children[] = new FederatedCalendarObject($this, $obj);
+ }
+
+ return $children;
+ }
+
+ public function childExists($name) {
+ $obj = $this->caldavBackend->getCalendarObject($this->federationInfo->getId(), $name, $this->getCalendarType());
+ return $obj !== null;
+ }
+
+ public function createFile($name, $data = null) {
+ if (is_resource($data)) {
+ $data = stream_get_contents($data);
+ }
+
+ // Create on remote server first
+ $etag = $this->federatedCalendarService->createCalendarObject($this->federationInfo, $name, $data);
+
+ // Then store locally
+ return $this->caldavBackend->createCalendarObject($this->federationInfo->getId(), $name, $data, $this->getCalendarType());
}
+
+ public function updateFile($name, $data = null) {
+ if (is_resource($data)) {
+ $data = stream_get_contents($data);
+ }
+
+ // Update remote calendar first
+ $etag = $this->federatedCalendarService->updateCalendarObject($this->federationInfo, $name, $data);
+
+ // Then update locally
+ return $this->caldavBackend->updateCalendarObject($this->federationInfo->getId(), $name, $data, $this->getCalendarType());
+ }
+
+ public function deleteFile($name) {
+ // Delete from remote server first
+ $this->federatedCalendarService->deleteCalendarObject($this->federationInfo, $name);
+
+ // Then delete locally
+ return $this->caldavBackend->deleteCalendarObject($this->federationInfo->getId(), $name, $this->getCalendarType());
+ }
+
}
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php
index 91027ef0d7bac..e6d869fec88b7 100644
--- a/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarEntity.php
@@ -94,8 +94,8 @@ public function toCalendarInfo(): array {
'{' . \Sabre\CalDAV\Plugin::NS_CALENDARSERVER . '}getctag' => $this->getSyncTokenForSabre(),
'{' . \Sabre\CalDAV\Plugin::NS_CALDAV . '}supported-calendar-component-set' => $this->getSupportedCalendarComponentSet(),
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}owner-principal' => $this->getSharedByPrincipal(),
- // TODO: implement read-write sharing
- '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only' => 1
+ '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}read-only' => ($this->getPermissions() & \OCP\Constants::PERMISSION_UPDATE) === 0 ? 1 : 0,
+ '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_OWNCLOUD . '}permissions' => $this->getPermissions(),
];
}
}
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarFactory.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarFactory.php
index df7af2a6ad53d..89a544d79389f 100644
--- a/apps/dav/lib/CalDAV/Federation/FederatedCalendarFactory.php
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarFactory.php
@@ -9,34 +9,23 @@
namespace OCA\DAV\CalDAV\Federation;
-use OCA\DAV\AppInfo\Application;
use OCA\DAV\CalDAV\CalDavBackend;
-use OCP\IConfig;
-use OCP\IL10N;
-use OCP\L10N\IFactory as IL10NFactory;
-use Psr\Log\LoggerInterface;
class FederatedCalendarFactory {
- private readonly IL10N $l10n;
public function __construct(
- private readonly CalDavBackend $caldavBackend,
- private readonly IConfig $config,
- private readonly LoggerInterface $logger,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
- IL10NFactory $l10nFactory,
+ private readonly FederatedCalendarSyncService $federatedCalendarService,
+ private readonly CalDavBackend $caldavBackend,
) {
- $this->l10n = $l10nFactory->get(Application::APP_ID);
}
public function createFederatedCalendar(array $calendarInfo): FederatedCalendar {
return new FederatedCalendar(
+ $this->federatedCalendarMapper,
+ $this->federatedCalendarService,
$this->caldavBackend,
$calendarInfo,
- $this->l10n,
- $this->config,
- $this->logger,
- $this->federatedCalendarMapper,
);
}
}
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarImpl.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarImpl.php
index 59cece7e8c88a..23db57f1f0839 100644
--- a/apps/dav/lib/CalDAV/Federation/FederatedCalendarImpl.php
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarImpl.php
@@ -51,8 +51,7 @@ public function search(string $pattern, array $searchProperties = [], array $opt
}
public function getPermissions(): int {
- // TODO: implement read-write sharing
- return Constants::PERMISSION_READ;
+ return $this->calendarInfo['{http://owncloud.org/ns}permissions'] ?? Constants::PERMISSION_READ;
}
public function isDeleted(): bool {
@@ -64,7 +63,8 @@ public function isShared(): bool {
}
public function isWritable(): bool {
- return false;
+ $permissions = $this->getPermissions();
+ return ($permissions & Constants::PERMISSION_UPDATE) !== 0;
}
public function isEnabled(): bool {
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarObject.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarObject.php
new file mode 100644
index 0000000000000..c2af313e92201
--- /dev/null
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarObject.php
@@ -0,0 +1,130 @@
+objectData['uri'];
+ }
+
+ public function setName($name) {
+ throw new \Exception('Not implemented');
+ }
+
+ public function get() {
+ return $this->objectData['calendardata'];
+ }
+
+ public function put($data) {
+
+ $etag = $this->calendarObject->updateFile($this->objectData['uri'], $data);
+ $this->objectData['calendardata'] = $data;
+ $this->objectData['etag'] = $etag;
+
+ return $etag;
+ }
+
+ /**
+ * Deletes the calendar object.
+ */
+ public function delete() {
+ $this->calendarObject->deleteFile($this->objectData['uri']);
+ }
+
+ /**
+ * Returns the mime content-type.
+ *
+ * @return string
+ */
+ public function getContentType() {
+ $mime = 'text/calendar; charset=utf-8';
+ if (isset($this->objectData['component']) && $this->objectData['component']) {
+ $mime .= '; component=' . $this->objectData['component'];
+ }
+
+ return $mime;
+ }
+
+ /**
+ * Returns an ETag for this object.
+ *
+ * The ETag is an arbitrary string, but MUST be surrounded by double-quotes.
+ *
+ * @return string
+ */
+ public function getETag() {
+ if (isset($this->objectData['etag'])) {
+ return $this->objectData['etag'];
+ } else {
+ return '"' . md5($this->get()) . '"';
+ }
+ }
+
+ /**
+ * Returns the last modification date as a unix timestamp.
+ *
+ * @return int
+ */
+ public function getLastModified() {
+ return $this->objectData['lastmodified'];
+ }
+
+ /**
+ * Returns the size of this object in bytes.
+ *
+ * @return int
+ */
+ public function getSize() {
+ if (array_key_exists('size', $this->objectData)) {
+ return $this->objectData['size'];
+ } else {
+ return strlen($this->get());
+ }
+ }
+
+ /**
+ * Returns the owner principal.
+ *
+ * This must be a url to a principal, or null if there's no owner
+ *
+ * @return string|null
+ */
+ public function getOwner() {
+ return $this->calendarObject->getPrincipalURI();
+ }
+
+ public function getGroup() {
+ return null;
+ }
+
+ public function getACL() {
+ return $this->calendarObject->getACL();
+ }
+
+ public function setACL(array $acl) {
+ throw new MethodNotAllowed('Changing ACLs on federated events is not allowed');
+ }
+
+ public function getSupportedPrivilegeSet() {
+ return null;
+ }
+
+}
diff --git a/apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php b/apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php
index 2ec3e57821201..4f265dde5e981 100644
--- a/apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php
+++ b/apps/dav/lib/CalDAV/Federation/FederatedCalendarSyncService.php
@@ -9,51 +9,131 @@
namespace OCA\DAV\CalDAV\Federation;
-use OCA\DAV\CalDAV\SyncService as CalDavSyncService;
+use OCA\DAV\CalDAV\CalDavBackend;
+use OCA\DAV\Service\ASyncService;
+use OCP\AppFramework\Db\TTransactional;
+use OCP\AppFramework\Http;
use OCP\Federation\ICloudIdManager;
+use OCP\Http\Client\IClientService;
+use OCP\IConfig;
+use OCP\IDBConnection;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Log\LoggerInterface;
-class FederatedCalendarSyncService {
+class FederatedCalendarSyncService extends ASyncService {
+ use TTransactional;
+
private const SYNC_TOKEN_PREFIX = 'http://sabre.io/ns/sync/';
public function __construct(
+ IClientService $clientService,
+ IConfig $config,
private readonly FederatedCalendarMapper $federatedCalendarMapper,
private readonly LoggerInterface $logger,
- private readonly CalDavSyncService $syncService,
+ private readonly CalDavBackend $backend,
+ private readonly IDBConnection $dbConnection,
private readonly ICloudIdManager $cloudIdManager,
) {
+ parent::__construct($clientService, $config);
}
/**
- * @return int Downloaded event count (created or updated).
+ * Extract and encode credentials from a federated calendar entity.
*
- * @throws ClientExceptionInterface If syncing the calendar fails.
+ * @return array{username: string, remoteUrl: string, token: string}
*/
- public function syncOne(FederatedCalendarEntity $calendar): int {
+ private function getCredentials(FederatedCalendarEntity $calendar): array {
[,, $sharedWith] = explode('/', $calendar->getPrincipaluri());
$calDavUser = $this->cloudIdManager->getCloudId($sharedWith, null)->getId();
- $remoteUrl = $calendar->getRemoteUrl();
- $syncToken = $calendar->getSyncTokenForSabre();
// Need to encode the cloud id as it might contain a colon which is not allowed in basic
// auth according to RFC 7617
$calDavUser = base64_encode($calDavUser);
- $syncResponse = $this->syncService->syncRemoteCalendar(
- $remoteUrl,
- $calDavUser,
- $calendar->getToken(),
- $syncToken,
- $calendar,
- );
+ return [
+ 'username' => $calDavUser,
+ 'remoteUrl' => $calendar->getRemoteUrl(),
+ 'token' => $calendar->getToken(),
+ ];
+ }
+
+ /**
+ * @return int Downloaded event count (created or updated).
+ *
+ * @throws ClientExceptionInterface If syncing the calendar fails.
+ */
+ public function syncOne(FederatedCalendarEntity $calendar): int {
+ $credentials = $this->getCredentials($calendar);
+ $syncToken = $calendar->getSyncTokenForSabre();
+
+ try {
+ $response = $this->requestSyncReport(
+ $credentials['remoteUrl'],
+ $credentials['username'],
+ $credentials['token'],
+ $syncToken,
+ );
+ } catch (ClientExceptionInterface $ex) {
+ if ($ex->getCode() === Http::STATUS_UNAUTHORIZED) {
+ // Remote server revoked access to the calendar => remove it
+ $this->federatedCalendarMapper->delete($calendar);
+ $this->logger->error("Authorization failed, remove federated calendar: {$credentials['remoteUrl']}", [
+ 'app' => 'dav',
+ ]);
+ throw $ex;
+ }
+ $this->logger->error('Client exception:', ['app' => 'dav', 'exception' => $ex]);
+ throw $ex;
+ }
+
+ // Process changes from remote
+ $downloadedEvents = 0;
+ foreach ($response['response'] as $resource => $status) {
+ $objectUri = basename($resource);
+ if (isset($status[200])) {
+ // Object created or updated
+ $absoluteUrl = $this->prepareUri($credentials['remoteUrl'], $resource);
+ $calendarData = $this->download($absoluteUrl, $credentials['username'], $credentials['token']);
+ $this->atomic(function () use ($calendar, $objectUri, $calendarData): void {
+ $existingObject = $this->backend->getCalendarObject(
+ $calendar->getId(),
+ $objectUri,
+ CalDavBackend::CALENDAR_TYPE_FEDERATED
+ );
+ if (!$existingObject) {
+ $this->backend->createCalendarObject(
+ $calendar->getId(),
+ $objectUri,
+ $calendarData,
+ CalDavBackend::CALENDAR_TYPE_FEDERATED
+ );
+ } else {
+ $this->backend->updateCalendarObject(
+ $calendar->getId(),
+ $objectUri,
+ $calendarData,
+ CalDavBackend::CALENDAR_TYPE_FEDERATED
+ );
+ }
+ }, $this->dbConnection);
+ $downloadedEvents++;
+ } else {
+ // Object deleted
+ $this->backend->deleteCalendarObject(
+ $calendar->getId(),
+ $objectUri,
+ CalDavBackend::CALENDAR_TYPE_FEDERATED,
+ true
+ );
+ }
+ }
- $newSyncToken = $syncResponse->getSyncToken();
+ $newSyncToken = $response['token'];
// Check sync token format and extract the actual sync token integer
$matches = [];
if (!preg_match('/^http:\/\/sabre\.io\/ns\/sync\/([0-9]+)$/', $newSyncToken, $matches)) {
- $this->logger->error("Failed to sync federated calendar at $remoteUrl: New sync token has unexpected format: $newSyncToken", [
+ $this->logger->error("Failed to sync federated calendar at {$credentials['remoteUrl']}: New sync token has unexpected format: $newSyncToken", [
'calendar' => $calendar->toCalendarInfo(),
'newSyncToken' => $newSyncToken,
]);
@@ -67,10 +147,58 @@ public function syncOne(FederatedCalendarEntity $calendar): int {
$newSyncToken,
);
} else {
- $this->logger->debug("Sync Token for $remoteUrl unchanged from previous sync");
+ $this->logger->debug("Sync Token for {$credentials['remoteUrl']} unchanged from previous sync");
$this->federatedCalendarMapper->updateSyncTime($calendar->getId());
}
- return $syncResponse->getDownloadedEvents();
+ return $downloadedEvents;
+ }
+
+ /**
+ * Create a calendar object on the remote server.
+ *
+ * @throws ClientExceptionInterface If the remote request fails.
+ */
+ public function createCalendarObject(FederatedCalendarEntity $calendar, string $name, string $data): string {
+ $credentials = $this->getCredentials($calendar);
+ $objectUrl = $this->prepareUri($credentials['remoteUrl'], $name);
+
+ return $this->requestPut(
+ $objectUrl,
+ $credentials['username'],
+ $credentials['token'],
+ $data,
+ 'text/calendar; charset=utf-8'
+ );
+ }
+
+ /**
+ * Update a calendar object on the remote server.
+ *
+ * @throws ClientExceptionInterface If the remote request fails.
+ */
+ public function updateCalendarObject(FederatedCalendarEntity $calendar, string $name, string $data): string {
+ $credentials = $this->getCredentials($calendar);
+ $objectUrl = $this->prepareUri($credentials['remoteUrl'], $name);
+
+ return $this->requestPut(
+ $objectUrl,
+ $credentials['username'],
+ $credentials['token'],
+ $data,
+ 'text/calendar; charset=utf-8'
+ );
+ }
+
+ /**
+ * Delete a calendar object on the remote server.
+ *
+ * @throws ClientExceptionInterface If the remote request fails.
+ */
+ public function deleteCalendarObject(FederatedCalendarEntity $calendar, string $name): void {
+ $credentials = $this->getCredentials($calendar);
+ $objectUrl = $this->prepareUri($credentials['remoteUrl'], $name);
+
+ $this->requestDelete($objectUrl, $credentials['username'], $credentials['token']);
}
}
diff --git a/apps/dav/lib/CalDAV/Schedule/Plugin.php b/apps/dav/lib/CalDAV/Schedule/Plugin.php
index d5ddfa09b248c..f885b9a045f11 100644
--- a/apps/dav/lib/CalDAV/Schedule/Plugin.php
+++ b/apps/dav/lib/CalDAV/Schedule/Plugin.php
@@ -12,6 +12,7 @@
use OCA\DAV\CalDAV\CalendarHome;
use OCA\DAV\CalDAV\CalendarObject;
use OCA\DAV\CalDAV\DefaultCalendarValidator;
+use OCA\DAV\CalDAV\Federation\FederatedCalendar;
use OCA\DAV\CalDAV\TipBroker;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
@@ -172,8 +173,15 @@ public function calendarObjectChange(RequestInterface $request, ResponseInterfac
return;
}
- /** @var Calendar $calendarNode */
+ /** @var Calendar&ICalendar $calendarNode */
$calendarNode = $this->server->tree->getNodeForPath($calendarPath);
+
+ // abort if calendar is federated
+ if ($calendarNode instanceof FederatedCalendar) {
+ $this->logger->debug('Not processing scheduling for federated calendar at path: ' . $calendarPath);
+ return;
+ }
+
// extract addresses for owner
$addresses = $this->getAddressesForPrincipal($calendarNode->getOwner());
// determine if request is from a sharee
diff --git a/apps/dav/lib/Service/ASyncService.php b/apps/dav/lib/Service/ASyncService.php
index b9e045cfe86af..97c02be1f67ad 100644
--- a/apps/dav/lib/Service/ASyncService.php
+++ b/apps/dav/lib/Service/ASyncService.php
@@ -191,4 +191,70 @@ private function isResponseForRequestUri(string $responseUri, string $requestUri
rtrim($responseUri, '/'),
);
}
+
+ /**
+ * Push data to the remote server via HTTP PUT.
+ * Used for creating or updating CalDAV/CardDAV objects.
+ *
+ * @param string $url The absolute URL to PUT to
+ * @param string $username The username for authentication
+ * @param string $token The authentication token/password
+ * @param string $data The data to upload
+ * @param string $contentType The Content-Type header (e.g., 'text/calendar' or 'text/vcard')
+ *
+ * @return string The ETag returned by the server
+ */
+ protected function requestPut(
+ string $url,
+ string $username,
+ string $token,
+ string $data,
+ string $contentType = 'text/calendar; charset=utf-8',
+ ): string {
+ $client = $this->getClient();
+
+ $options = [
+ 'auth' => [$username, $token],
+ 'body' => $data,
+ 'headers' => [
+ 'Content-Type' => $contentType,
+ ],
+ 'verify' => !$this->config->getSystemValue(
+ 'sharing.federation.allowSelfSignedCertificates',
+ false,
+ ),
+ ];
+
+ $response = $client->put($url, $options);
+
+ // Extract and return the ETag from the response
+ $etag = $response->getHeader('ETag');
+ return $etag;
+ }
+
+ /**
+ * Delete a resource from the remote server via HTTP DELETE.
+ * Used for deleting CalDAV/CardDAV objects.
+ *
+ * @param string $url The absolute URL to DELETE
+ * @param string $username The username for authentication
+ * @param string $token The authentication token/password
+ */
+ protected function requestDelete(
+ string $url,
+ string $username,
+ string $token,
+ ): void {
+ $client = $this->getClient();
+
+ $options = [
+ 'auth' => [$username, $token],
+ 'verify' => !$this->config->getSystemValue(
+ 'sharing.federation.allowSelfSignedCertificates',
+ false,
+ ),
+ ];
+
+ $client->delete($url, $options);
+ }
}
diff --git a/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php
index 64188322f65a1..6e6577b24fa13 100644
--- a/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php
+++ b/apps/dav/tests/unit/CalDAV/Federation/CalendarFederationProviderTest.php
@@ -92,11 +92,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')
@@ -123,6 +124,68 @@ public function testShareReceived(): void {
$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);
+
+ $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');
+
+ $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());
+ return $calendar;
+ });
+
+ $this->jobList->expects(self::once())
+ ->method('add')
+ ->with(FederatedCalendarSyncJob::class, ['id' => 10]);
+
+ $this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
+ }
+
public function testShareReceivedWithInvalidProtocolVersion(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
@@ -270,7 +333,7 @@ public function testShareReceivedWithIncompleteProtocolData(array $protocol): vo
$this->calendarFederationProvider->shareReceived($share);
}
- public function testShareReceivedWithUnsupportedAccess(): void {
+ public function testShareReceivedWithReadWriteAccess(): void {
$share = $this->createMock(ICloudFederationShare::class);
$share->method('getShareType')
->willReturn('user');
@@ -296,6 +359,65 @@ public function testShareReceivedWithUnsupportedAccess(): void {
->method('isFederationEnabled')
->willReturn(true);
+ $this->federatedCalendarMapper->expects(self::once())
+ ->method('findByUri')
+ ->with(
+ 'principals/users/sharee1',
+ 'ae4b8ab904076fff2b955ea21b1a0d92',
+ )
+ ->willReturn(null);
+
+ $this->federatedCalendarMapper->expects(self::once())
+ ->method('insert')
+ ->willReturnCallback(function (FederatedCalendarEntity $calendar) {
+ $this->assertEquals('principals/users/sharee1', $calendar->getPrincipaluri());
+ $this->assertEquals('ae4b8ab904076fff2b955ea21b1a0d92', $calendar->getUri());
+ $this->assertEquals('https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1', $calendar->getRemoteUrl());
+ $this->assertEquals('Calendar 1', $calendar->getDisplayName());
+ $this->assertEquals('#ff0000', $calendar->getColor());
+ $this->assertEquals('token', $calendar->getToken());
+ $this->assertEquals('user1@nextcloud.remote', $calendar->getSharedBy());
+ $this->assertEquals('User 1', $calendar->getSharedByDisplayName());
+ $this->assertEquals(15, $calendar->getPermissions()); // READ | CREATE | UPDATE | DELETE
+ $this->assertEquals('VEVENT,VTODO', $calendar->getComponents());
+
+ $calendar->setId(10);
+ return $calendar;
+ });
+
+ $this->jobList->expects(self::once())
+ ->method('add')
+ ->with(FederatedCalendarSyncJob::class, ['id' => 10]);
+
+ $this->assertEquals(10, $this->calendarFederationProvider->shareReceived($share));
+ }
+
+ public function testShareReceivedWithUnsupportedAccess(): void {
+ $share = $this->createMock(ICloudFederationShare::class);
+ $share->method('getShareType')
+ ->willReturn('user');
+ $share->method('getProtocol')
+ ->willReturn([
+ 'version' => 'v1',
+ 'url' => 'https://nextcloud.remote/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user1',
+ 'displayName' => 'Calendar 1',
+ 'color' => '#ff0000',
+ 'access' => 999, // Invalid access value
+ 'components' => 'VEVENT,VTODO',
+ ]);
+ $share->method('getShareWith')
+ ->willReturn('sharee1');
+ $share->method('getShareSecret')
+ ->willReturn('token');
+ $share->method('getSharedBy')
+ ->willReturn('user1@nextcloud.remote');
+ $share->method('getSharedByDisplayName')
+ ->willReturn('User 1');
+
+ $this->calendarFederationConfig->expects(self::once())
+ ->method('isFederationEnabled')
+ ->willReturn(true);
+
$this->federatedCalendarMapper->expects(self::never())
->method('insert');
$this->jobList->expects(self::never())
diff --git a/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarSyncServiceTest.php b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarSyncServiceTest.php
index 03a83348d081b..21c410a879e1a 100644
--- a/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarSyncServiceTest.php
+++ b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarSyncServiceTest.php
@@ -9,13 +9,17 @@
namespace OCA\DAV\Tests\unit\CalDAV\Federation;
+use OCA\DAV\CalDAV\CalDavBackend;
use OCA\DAV\CalDAV\Federation\FederatedCalendarEntity;
use OCA\DAV\CalDAV\Federation\FederatedCalendarMapper;
use OCA\DAV\CalDAV\Federation\FederatedCalendarSyncService;
-use OCA\DAV\CalDAV\SyncService as CalDavSyncService;
-use OCA\DAV\CalDAV\SyncServiceResult;
use OCP\Federation\ICloudId;
use OCP\Federation\ICloudIdManager;
+use OCP\Http\Client\IClient;
+use OCP\Http\Client\IClientService;
+use OCP\Http\Client\IResponse;
+use OCP\IConfig;
+use OCP\IDBConnection;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
@@ -26,21 +30,30 @@ class FederatedCalendarSyncServiceTest extends TestCase {
private FederatedCalendarMapper&MockObject $federatedCalendarMapper;
private LoggerInterface&MockObject $logger;
- private CalDavSyncService&MockObject $calDavSyncService;
+ private CalDavBackend&MockObject $backend;
+ private IDBConnection&MockObject $dbConnection;
private ICloudIdManager&MockObject $cloudIdManager;
+ private IClientService&MockObject $clientService;
+ private IConfig&MockObject $config;
protected function setUp(): void {
parent::setUp();
$this->federatedCalendarMapper = $this->createMock(FederatedCalendarMapper::class);
$this->logger = $this->createMock(LoggerInterface::class);
- $this->calDavSyncService = $this->createMock(CalDavSyncService::class);
+ $this->backend = $this->createMock(CalDavBackend::class);
+ $this->dbConnection = $this->createMock(IDBConnection::class);
$this->cloudIdManager = $this->createMock(ICloudIdManager::class);
+ $this->clientService = $this->createMock(IClientService::class);
+ $this->config = $this->createMock(IConfig::class);
$this->federatedCalendarSyncService = new FederatedCalendarSyncService(
+ $this->clientService,
+ $this->config,
$this->federatedCalendarMapper,
$this->logger,
- $this->calDavSyncService,
+ $this->backend,
+ $this->dbConnection,
$this->cloudIdManager,
);
}
@@ -61,16 +74,24 @@ public function testSyncOne(): void {
->with('user1')
->willReturn($cloudId);
- $this->calDavSyncService->expects(self::once())
- ->method('syncRemoteCalendar')
- ->with(
- 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2',
- 'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=',
- 'token',
- 'http://sabre.io/ns/sync/100',
- $calendar,
- )
- ->willReturn(new SyncServiceResult('http://sabre.io/ns/sync/101', 10));
+ // Mock HTTP client for sync report
+ $client = $this->createMock(IClient::class);
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')
+ ->willReturn('http://sabre.io/ns/sync/101');
+
+ $client->expects(self::once())
+ ->method('request')
+ ->with('REPORT', 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', self::anything())
+ ->willReturn($response);
+
+ $this->clientService->method('newClient')
+ ->willReturn($client);
+
+ $this->config->method('getSystemValueInt')
+ ->willReturn(30);
+ $this->config->method('getSystemValue')
+ ->willReturn(false);
$this->federatedCalendarMapper->expects(self::once())
->method('updateSyncTokenAndTime')
@@ -78,7 +99,7 @@ public function testSyncOne(): void {
$this->federatedCalendarMapper->expects(self::never())
->method('updateSyncTime');
- $this->assertEquals(10, $this->federatedCalendarSyncService->syncOne($calendar));
+ $this->assertEquals(0, $this->federatedCalendarSyncService->syncOne($calendar));
}
public function testSyncOneUnchanged(): void {
@@ -97,16 +118,24 @@ public function testSyncOneUnchanged(): void {
->with('user1')
->willReturn($cloudId);
- $this->calDavSyncService->expects(self::once())
- ->method('syncRemoteCalendar')
- ->with(
- 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2',
- 'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=',
- 'token',
- 'http://sabre.io/ns/sync/100',
- $calendar,
- )
- ->willReturn(new SyncServiceResult('http://sabre.io/ns/sync/100', 0));
+ // Mock HTTP client for sync report
+ $client = $this->createMock(IClient::class);
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')
+ ->willReturn('http://sabre.io/ns/sync/100');
+
+ $client->expects(self::once())
+ ->method('request')
+ ->with('REPORT', 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', self::anything())
+ ->willReturn($response);
+
+ $this->clientService->method('newClient')
+ ->willReturn($client);
+
+ $this->config->method('getSystemValueInt')
+ ->willReturn(30);
+ $this->config->method('getSystemValue')
+ ->willReturn(false);
$this->federatedCalendarMapper->expects(self::never())
->method('updateSyncTokenAndTime');
@@ -143,16 +172,24 @@ public function testSyncOneWithUnexpectedSyncTokenFormat(string $syncToken): voi
->with('user1')
->willReturn($cloudId);
- $this->calDavSyncService->expects(self::once())
- ->method('syncRemoteCalendar')
- ->with(
- 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2',
- 'dXNlcjFAbmV4dGNsb3VkLnRlc3Rpbmc=',
- 'token',
- 'http://sabre.io/ns/sync/100',
- $calendar,
- )
- ->willReturn(new SyncServiceResult($syncToken, 10));
+ // Mock HTTP client for sync report with unexpected token format
+ $client = $this->createMock(IClient::class);
+ $response = $this->createMock(IResponse::class);
+ $response->method('getBody')
+ ->willReturn('' . $syncToken . '');
+
+ $client->expects(self::once())
+ ->method('request')
+ ->with('REPORT', 'https://remote.tld/remote.php/dav/remote-calendars/abcdef123/cal1_shared_by_user2', self::anything())
+ ->willReturn($response);
+
+ $this->clientService->method('newClient')
+ ->willReturn($client);
+
+ $this->config->method('getSystemValueInt')
+ ->willReturn(30);
+ $this->config->method('getSystemValue')
+ ->willReturn(false);
$this->federatedCalendarMapper->expects(self::never())
->method('updateSyncTokenAndTime');
diff --git a/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarTest.php b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarTest.php
new file mode 100644
index 0000000000000..431c520b5e7b1
--- /dev/null
+++ b/apps/dav/tests/unit/CalDAV/Federation/FederatedCalendarTest.php
@@ -0,0 +1,404 @@
+federatedCalendarMapper = $this->createMock(FederatedCalendarMapper::class);
+ $this->federatedCalendarService = $this->createMock(FederatedCalendarSyncService::class);
+ $this->caldavBackend = $this->createMock(CalDavBackend::class);
+
+ $this->federationInfo = new FederatedCalendarEntity();
+ $this->federationInfo->setId(10);
+ $this->federationInfo->setPrincipaluri('principals/users/user1');
+ $this->federationInfo->setUri('calendar-uri');
+ $this->federationInfo->setDisplayName('Federated Calendar');
+ $this->federationInfo->setColor('#ff0000');
+ $this->federationInfo->setSharedBy('user2@nextcloud.remote');
+ $this->federationInfo->setSharedByDisplayName('User 2');
+ $this->federationInfo->setPermissions(Constants::PERMISSION_READ);
+ $this->federationInfo->setLastSync(1234567890);
+
+ $this->federatedCalendarMapper->method('findByUri')
+ ->with('principals/users/user1', 'calendar-uri')
+ ->willReturn($this->federationInfo);
+
+ $calendarInfo = [
+ 'principaluri' => 'principals/users/user1',
+ 'id' => 10,
+ 'uri' => 'calendar-uri',
+ ];
+
+ $this->federatedCalendar = new FederatedCalendar(
+ $this->federatedCalendarMapper,
+ $this->federatedCalendarService,
+ $this->caldavBackend,
+ $calendarInfo,
+ );
+ }
+
+ public function testGetResourceId(): void {
+ $this->assertEquals(10, $this->federatedCalendar->getResourceId());
+ }
+
+ public function testGetName(): void {
+ $this->assertEquals('calendar-uri', $this->federatedCalendar->getName());
+ }
+
+ public function testSetName(): void {
+ $this->expectException(MethodNotAllowed::class);
+ $this->expectExceptionMessage('Renaming federated calendars is not allowed');
+ $this->federatedCalendar->setName('new-name');
+ }
+
+ public function testGetPrincipalURI(): void {
+ $this->assertEquals('principals/users/user1', $this->federatedCalendar->getPrincipalURI());
+ }
+
+ public function testGetOwner(): void {
+ $expected = 'principals/remote-users/' . base64_encode('user2@nextcloud.remote');
+ $this->assertEquals($expected, $this->federatedCalendar->getOwner());
+ }
+
+ public function testGetGroup(): void {
+ $this->assertNull($this->federatedCalendar->getGroup());
+ }
+
+ public function testGetACLWithReadOnlyPermissions(): void {
+ $this->federationInfo->setPermissions(Constants::PERMISSION_READ);
+
+ $acl = $this->federatedCalendar->getACL();
+
+ $this->assertCount(3, $acl);
+ // Check basic read permissions
+ $this->assertEquals('{DAV:}read', $acl[0]['privilege']);
+ $this->assertTrue($acl[0]['protected']);
+ $this->assertEquals('{DAV:}read-acl', $acl[1]['privilege']);
+ $this->assertTrue($acl[1]['protected']);
+ $this->assertEquals('{DAV:}write-properties', $acl[2]['privilege']);
+ $this->assertTrue($acl[2]['protected']);
+ }
+
+ public function testGetACLWithCreatePermission(): void {
+ $this->federationInfo->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_CREATE);
+
+ $acl = $this->federatedCalendar->getACL();
+
+ $this->assertCount(4, $acl);
+ // Check that create permission is added
+ $privileges = array_column($acl, 'privilege');
+ $this->assertContains('{DAV:}bind', $privileges);
+ }
+
+ public function testGetACLWithUpdatePermission(): void {
+ $this->federationInfo->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_UPDATE);
+
+ $acl = $this->federatedCalendar->getACL();
+
+ $this->assertCount(4, $acl);
+ // Check that update permission is added (write-content, not write-properties which is already in base ACL)
+ $privileges = array_column($acl, 'privilege');
+ $this->assertContains('{DAV:}write-content', $privileges);
+ }
+
+ public function testGetACLWithDeletePermission(): void {
+ $this->federationInfo->setPermissions(Constants::PERMISSION_READ | Constants::PERMISSION_DELETE);
+
+ $acl = $this->federatedCalendar->getACL();
+
+ $this->assertCount(4, $acl);
+ // Check that delete permission is added
+ $privileges = array_column($acl, 'privilege');
+ $this->assertContains('{DAV:}unbind', $privileges);
+ }
+
+ public function testGetACLWithAllPermissions(): void {
+ $this->federationInfo->setPermissions(
+ Constants::PERMISSION_READ
+ | Constants::PERMISSION_CREATE
+ | Constants::PERMISSION_UPDATE
+ | Constants::PERMISSION_DELETE
+ );
+
+ $acl = $this->federatedCalendar->getACL();
+
+ $this->assertCount(6, $acl);
+ $privileges = array_column($acl, 'privilege');
+ $this->assertContains('{DAV:}read', $privileges);
+ $this->assertContains('{DAV:}bind', $privileges);
+ $this->assertContains('{DAV:}write-content', $privileges);
+ $this->assertContains('{DAV:}write-properties', $privileges);
+ $this->assertContains('{DAV:}unbind', $privileges);
+ }
+
+ public function testSetACL(): void {
+ $this->expectException(MethodNotAllowed::class);
+ $this->expectExceptionMessage('Changing ACLs on federated calendars is not allowed');
+ $this->federatedCalendar->setACL([]);
+ }
+
+ public function testGetSupportedPrivilegeSet(): void {
+ $this->assertNull($this->federatedCalendar->getSupportedPrivilegeSet());
+ }
+
+ public function testGetProperties(): void {
+ $properties = $this->federatedCalendar->getProperties([
+ '{DAV:}displayname',
+ '{http://apple.com/ns/ical/}calendar-color',
+ ]);
+
+ $this->assertEquals('Federated Calendar', $properties['{DAV:}displayname']);
+ $this->assertEquals('#ff0000', $properties['{http://apple.com/ns/ical/}calendar-color']);
+ }
+
+ public function testPropPatchWithDisplayName(): void {
+ $propPatch = $this->createMock(PropPatch::class);
+ $propPatch->method('getMutations')
+ ->willReturn([
+ '{DAV:}displayname' => 'New Calendar Name',
+ ]);
+
+ $this->federatedCalendarMapper->expects(self::once())
+ ->method('update')
+ ->willReturnCallback(function (FederatedCalendarEntity $entity) {
+ $this->assertEquals('New Calendar Name', $entity->getDisplayName());
+ return $entity;
+ });
+
+ $propPatch->expects(self::once())
+ ->method('setResultCode')
+ ->with('{DAV:}displayname', 200);
+
+ $this->federatedCalendar->propPatch($propPatch);
+ }
+
+ public function testPropPatchWithColor(): void {
+ $propPatch = $this->createMock(PropPatch::class);
+ $propPatch->method('getMutations')
+ ->willReturn([
+ '{http://apple.com/ns/ical/}calendar-color' => '#00ff00',
+ ]);
+
+ $this->federatedCalendarMapper->expects(self::once())
+ ->method('update')
+ ->willReturnCallback(function (FederatedCalendarEntity $entity) {
+ $this->assertEquals('#00ff00', $entity->getColor());
+ return $entity;
+ });
+
+ $propPatch->expects(self::once())
+ ->method('setResultCode')
+ ->with('{http://apple.com/ns/ical/}calendar-color', 200);
+
+ $this->federatedCalendar->propPatch($propPatch);
+ }
+
+ public function testPropPatchWithNoMutations(): void {
+ $propPatch = $this->createMock(PropPatch::class);
+ $propPatch->method('getMutations')
+ ->willReturn([]);
+
+ $this->federatedCalendarMapper->expects(self::never())
+ ->method('update');
+
+ $propPatch->expects(self::never())
+ ->method('handle');
+
+ $this->federatedCalendar->propPatch($propPatch);
+ }
+
+ public function testGetChildACL(): void {
+ $this->assertEquals($this->federatedCalendar->getACL(), $this->federatedCalendar->getChildACL());
+ }
+
+ public function testGetLastModified(): void {
+ $this->assertEquals(1234567890, $this->federatedCalendar->getLastModified());
+ }
+
+ public function testDelete(): void {
+ $this->federatedCalendarMapper->expects(self::once())
+ ->method('deleteById')
+ ->with(10);
+
+ $this->federatedCalendar->delete();
+ }
+
+ public function testCreateDirectory(): void {
+ $this->expectException(MethodNotAllowed::class);
+ $this->expectExceptionMessage('Creating nested collection is not allowed');
+ $this->federatedCalendar->createDirectory('test');
+ }
+
+ public function testCalendarQuery(): void {
+ $filters = ['comp-filter' => ['name' => 'VEVENT']];
+ $expectedUris = ['event1.ics', 'event2.ics'];
+
+ $this->caldavBackend->expects(self::once())
+ ->method('calendarQuery')
+ ->with(10, $filters, 2) // 2 is CALENDAR_TYPE_FEDERATED
+ ->willReturn($expectedUris);
+
+ $result = $this->federatedCalendar->calendarQuery($filters);
+ $this->assertEquals($expectedUris, $result);
+ }
+
+ public function testGetChild(): void {
+ $objectData = [
+ 'id' => 1,
+ 'uri' => 'event1.ics',
+ 'calendardata' => 'BEGIN:VCALENDAR...',
+ ];
+
+ $this->caldavBackend->expects(self::once())
+ ->method('getCalendarObject')
+ ->with(10, 'event1.ics', 2) // 2 is CALENDAR_TYPE_FEDERATED
+ ->willReturn($objectData);
+
+ $child = $this->federatedCalendar->getChild('event1.ics');
+ $this->assertInstanceOf(FederatedCalendarObject::class, $child);
+ }
+
+ public function testGetChildNotFound(): void {
+ $this->caldavBackend->expects(self::once())
+ ->method('getCalendarObject')
+ ->with(10, 'nonexistent.ics', 2)
+ ->willReturn(null);
+
+ $this->expectException(NotFound::class);
+ $this->federatedCalendar->getChild('nonexistent.ics');
+ }
+
+ public function testGetChildren(): void {
+ $objects = [
+ ['id' => 1, 'uri' => 'event1.ics', 'calendardata' => 'BEGIN:VCALENDAR...'],
+ ['id' => 2, 'uri' => 'event2.ics', 'calendardata' => 'BEGIN:VCALENDAR...'],
+ ];
+
+ $this->caldavBackend->expects(self::once())
+ ->method('getCalendarObjects')
+ ->with(10, 2) // 2 is CALENDAR_TYPE_FEDERATED
+ ->willReturn($objects);
+
+ $children = $this->federatedCalendar->getChildren();
+ $this->assertCount(2, $children);
+ $this->assertInstanceOf(FederatedCalendarObject::class, $children[0]);
+ $this->assertInstanceOf(FederatedCalendarObject::class, $children[1]);
+ }
+
+ public function testGetMultipleChildren(): void {
+ $paths = ['event1.ics', 'event2.ics'];
+ $objects = [
+ ['id' => 1, 'uri' => 'event1.ics', 'calendardata' => 'BEGIN:VCALENDAR...'],
+ ['id' => 2, 'uri' => 'event2.ics', 'calendardata' => 'BEGIN:VCALENDAR...'],
+ ];
+
+ $this->caldavBackend->expects(self::once())
+ ->method('getMultipleCalendarObjects')
+ ->with(10, $paths, 2) // 2 is CALENDAR_TYPE_FEDERATED
+ ->willReturn($objects);
+
+ $children = $this->federatedCalendar->getMultipleChildren($paths);
+ $this->assertCount(2, $children);
+ $this->assertInstanceOf(FederatedCalendarObject::class, $children[0]);
+ $this->assertInstanceOf(FederatedCalendarObject::class, $children[1]);
+ }
+
+ public function testChildExists(): void {
+ $this->caldavBackend->expects(self::once())
+ ->method('getCalendarObject')
+ ->with(10, 'event1.ics', 2)
+ ->willReturn(['id' => 1, 'uri' => 'event1.ics']);
+
+ $result = $this->federatedCalendar->childExists('event1.ics');
+ $this->assertTrue($result);
+ }
+
+ public function testChildNotExists(): void {
+ $this->caldavBackend->expects(self::once())
+ ->method('getCalendarObject')
+ ->with(10, 'nonexistent.ics', 2)
+ ->willReturn(null);
+
+ $result = $this->federatedCalendar->childExists('nonexistent.ics');
+ $this->assertFalse($result);
+ }
+
+ public function testCreateFile(): void {
+ $calendarData = 'BEGIN:VCALENDAR...END:VCALENDAR';
+ $remoteEtag = '"remote-etag-123"';
+ $localEtag = '"local-etag-456"';
+
+ $this->federatedCalendarService->expects(self::once())
+ ->method('createCalendarObject')
+ ->with($this->federationInfo, 'event1.ics', $calendarData)
+ ->willReturn($remoteEtag);
+
+ $this->caldavBackend->expects(self::once())
+ ->method('createCalendarObject')
+ ->with(10, 'event1.ics', $calendarData, 2)
+ ->willReturn($localEtag);
+
+ $result = $this->federatedCalendar->createFile('event1.ics', $calendarData);
+ $this->assertEquals($localEtag, $result);
+ }
+
+ public function testUpdateFile(): void {
+ $calendarData = 'BEGIN:VCALENDAR...UPDATED...END:VCALENDAR';
+ $remoteEtag = '"remote-etag-updated"';
+ $localEtag = '"local-etag-updated"';
+
+ $this->federatedCalendarService->expects(self::once())
+ ->method('updateCalendarObject')
+ ->with($this->federationInfo, 'event1.ics', $calendarData)
+ ->willReturn($remoteEtag);
+
+ $this->caldavBackend->expects(self::once())
+ ->method('updateCalendarObject')
+ ->with(10, 'event1.ics', $calendarData, 2)
+ ->willReturn($localEtag);
+
+ $result = $this->federatedCalendar->updateFile('event1.ics', $calendarData);
+ $this->assertEquals($localEtag, $result);
+ }
+
+ public function testDeleteFile(): void {
+ $this->federatedCalendarService->expects(self::once())
+ ->method('deleteCalendarObject')
+ ->with($this->federationInfo, 'event1.ics');
+
+ $this->caldavBackend->expects(self::once())
+ ->method('deleteCalendarObject')
+ ->with(10, 'event1.ics', 2);
+
+ $this->federatedCalendar->deleteFile('event1.ics');
+ }
+}