From 84ef2a7d38cb9b9f2a04802e1936e61363a57888 Mon Sep 17 00:00:00 2001 From: Richard Steinmetz Date: Sun, 17 Aug 2025 12:25:26 +0200 Subject: [PATCH 1/2] fix: handle UID collisions on thunderbird PUT request gracefully Signed-off-by: Richard Steinmetz Signed-off-by: Nico Donath --- .../composer/composer/autoload_classmap.php | 1 + .../dav/composer/composer/autoload_static.php | 1 + .../ThunderbirdPutInvitationQuirkPlugin.php | 119 +++++ apps/dav/lib/Server.php | 4 + ...hunderbirdPutInvitationQuirkPluginTest.php | 437 ++++++++++++++++++ 5 files changed, 562 insertions(+) create mode 100644 apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php create mode 100644 apps/dav/tests/unit/Connector/Sabre/ThunderbirdPutInvitationQuirkPluginTest.php diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 2ca5cf66f901f..ef1b14f9ab7fa 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -258,6 +258,7 @@ 'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => $baseDir . '/../lib/Connector/Sabre/SharesPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\TagList' => $baseDir . '/../lib/Connector/Sabre/TagList.php', 'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => $baseDir . '/../lib/Connector/Sabre/TagsPlugin.php', + 'OCA\\DAV\\Connector\\Sabre\\ThunderbirdPutInvitationQuirkPlugin' => $baseDir . '/../lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => $baseDir . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => $baseDir . '/../lib/Connector/Sabre/ZipFolderPlugin.php', 'OCA\\DAV\\Controller\\BirthdayCalendarController' => $baseDir . '/../lib/Controller/BirthdayCalendarController.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index c35dd97c02c0e..a10296e164459 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -273,6 +273,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Connector\\Sabre\\SharesPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/SharesPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\TagList' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagList.php', 'OCA\\DAV\\Connector\\Sabre\\TagsPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/TagsPlugin.php', + 'OCA\\DAV\\Connector\\Sabre\\ThunderbirdPutInvitationQuirkPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\UserIdHeaderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/UserIdHeaderPlugin.php', 'OCA\\DAV\\Connector\\Sabre\\ZipFolderPlugin' => __DIR__ . '/..' . '/../lib/Connector/Sabre/ZipFolderPlugin.php', 'OCA\\DAV\\Controller\\BirthdayCalendarController' => __DIR__ . '/..' . '/../lib/Controller/BirthdayCalendarController.php', diff --git a/apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php b/apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php new file mode 100644 index 0000000000000..e9e4026f84f11 --- /dev/null +++ b/apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php @@ -0,0 +1,119 @@ +server = $server; + + // Run right after the ACL plugin to make sure that the current user principal is available + $server->on('beforeMethod:PUT', $this->beforePut(...), 21); + } + + public function beforePut(RequestInterface $request, ResponseInterface $response): void { + $userAgent = $request->getHeader('User-Agent'); + if (!$userAgent || !$this->isThunderbirdUserAgent($userAgent)) { + return; + } + + if (!str_starts_with($request->getPath(), 'calendars/')) { + return; + } + + if (!str_contains($request->getHeader('Content-Type') ?? '', 'text/calendar')) { + return; + } + + $currentUserPrincipal = $this->getCurrentUserPrincipal(); + if ($currentUserPrincipal === null) { + return; + } + + // Need to set the body again here so that other handlers are able to read it afterward + $requestBody = $request->getBodyAsString(); + $request->setBody($requestBody); + + try { + $vCalendar = VObjectReader::read($requestBody); + } catch (\Throwable $e) { + return; + } + if (!($vCalendar instanceof VCalendar)) { + return; + } + + /** @var string|null $uid */ + $uid = $vCalendar->getBaseComponent('VEVENT')?->UID?->getValue(); + if ($uid === null) { + return; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('co.uri') + ->from('calendarobjects', 'co') + ->join('co', 'calendars', 'c', $qb->expr()->eq('co.calendarid', 'c.id')) + ->where( + $qb->expr()->eq( + 'c.principaluri', + $qb->createNamedParameter($currentUserPrincipal, IQueryBuilder::PARAM_STR), + IQueryBuilder::PARAM_STR, + ), + $qb->expr()->eq( + 'co.uid', + $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR), + IQueryBuilder::PARAM_STR, + ), + ); + $result = $qb->executeQuery(); + $rows = $result->fetchAll(); + $result->closeCursor(); + + if (count($rows) !== 1) { + // Either no collision or too many collisions + return; + } + + $requestUrl = $request->getUrl(); + [$prefix] = \Sabre\Uri\split($requestUrl); + $objectUri = $rows[0]['uri']; + $request->setUrl("$prefix/$objectUri"); + } + + private function isThunderbirdUserAgent(string $userAgent): bool { + return str_contains($userAgent, 'Thunderbird/'); + } + + private function getCurrentUserPrincipal(): ?string { + /** @var \Sabre\DAV\Auth\Plugin $authPlugin */ + $authPlugin = $this->server?->getPlugin('auth'); + return $authPlugin?->getCurrentPrincipal(); + } +} diff --git a/apps/dav/lib/Server.php b/apps/dav/lib/Server.php index ea4350bc1529d..246c2dffe7f80 100644 --- a/apps/dav/lib/Server.php +++ b/apps/dav/lib/Server.php @@ -53,6 +53,7 @@ use OCA\DAV\Connector\Sabre\RequestIdHeaderPlugin; use OCA\DAV\Connector\Sabre\SharesPlugin; use OCA\DAV\Connector\Sabre\TagsPlugin; +use OCA\DAV\Connector\Sabre\ThunderbirdPutInvitationQuirkPlugin; use OCA\DAV\Connector\Sabre\UserIdHeaderPlugin; use OCA\DAV\Connector\Sabre\ZipFolderPlugin; use OCA\DAV\DAV\CustomPropertiesBackend; @@ -136,6 +137,9 @@ public function __construct( $this->server->addPlugin(new MaintenancePlugin(\OCP\Server::get(IConfig::class), \OC::$server->getL10N('dav'))); $this->server->addPlugin(new AppleQuirksPlugin()); + $this->server->addPlugin(new ThunderbirdPutInvitationQuirkPlugin( + \OCP\Server::get(IDBConnection::class), + )); // Backends $authBackend = new Auth( diff --git a/apps/dav/tests/unit/Connector/Sabre/ThunderbirdPutInvitationQuirkPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/ThunderbirdPutInvitationQuirkPluginTest.php new file mode 100644 index 0000000000000..153e8b157a2dc --- /dev/null +++ b/apps/dav/tests/unit/Connector/Sabre/ThunderbirdPutInvitationQuirkPluginTest.php @@ -0,0 +1,437 @@ +server = $this->createMock(Server::class); + $this->db = $this->createMock(IDBConnection::class); + + $this->plugin = new ThunderbirdPutInvitationQuirkPlugin( + $this->db, + ); + } + + public function testInitialize(): void { + $this->server->expects(self::once()) + ->method('on') + ->with('beforeMethod:PUT', $this->plugin->beforePut(...), 21); + + $this->plugin->initialize($this->server); + } + + public static function provideBeforePutData(): array { + return [ + // No collision + [[], false], + // Many collisions + [ + [ + ['uri' => 'sabredav-3dd349f8-58e0-483d-921f-70bc9f02366b.ics'], + ['uri' => 'sabredav-19a50615-2db0-4046-a537-000979925e16.ics'], + ], + false, + ], + // Exactly one collision + [ + [ + ['uri' => 'sabredav-ab2dd681-c265-4b1e-8a20-e9d356f2c33c.ics'], + ], + true, + ], + ]; + } + + #[DataProvider('provideBeforePutData')] + public function testBeforePut(array $rows, bool $expectUrlChange): void { + $ics = <<createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'text/calendar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + $request->expects(self::once()) + ->method('getBodyAsString') + ->willReturn($ics); + $request->expects(self::once()) + ->method('setBody') + ->with($ics); + if ($expectUrlChange) { + $request->expects(self::once()) + ->method('getUrl') + ->willReturn('remote.php/dav/calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + $request->expects(self::once()) + ->method('setUrl') + ->with('remote.php/dav/calendars/usera/personal/sabredav-ab2dd681-c265-4b1e-8a20-e9d356f2c33c.ics'); + } else { + $request->expects(self::never()) + ->method('getUrl'); + $request->expects(self::never()) + ->method('setUrl'); + } + + $authPlugin = $this->createMock(\Sabre\DAV\Auth\Plugin::class); + $authPlugin->expects(self::once()) + ->method('getCurrentPrincipal') + ->willReturn('principals/users/usera'); + $this->server->expects(self::once()) + ->method('getPlugin') + ->with('auth') + ->willReturn($authPlugin); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select') + ->willReturnSelf(); + $qb->method('from') + ->willReturnSelf(); + $qb->method('join') + ->willReturnSelf(); + $qb->method('where') + ->willReturnSelf(); + $expr = $this->createMock(IExpressionBuilder::class); + $qb->method('expr') + ->willReturn($expr); + $this->db->expects(self::once()) + ->method('getQueryBuilder') + ->willReturn($qb); + + $result = $this->createMock(IResult::class); + $result->expects(self::once()) + ->method('fetchAll') + ->willReturn($rows); + $result->expects(self::once()) + ->method('closeCursor'); + $qb->expects(self::once()) + ->method('executeQuery') + ->willReturn($result); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public function testBeforePutWithInvalidUserAgent(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::once()) + ->method('getHeader') + ->with('User-Agent') + ->willReturn('curl/8.14.1'); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public function testBeforePutWithUnrelatedRequestPath(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::once()) + ->method('getHeader') + ->with('User-Agent') + ->willReturn('Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('foo/bar/baz'); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public function testBeforePutWithInvalidContentType(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'foo/bar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public function testBeforePutWithoutCurrentUserPrincipal(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'text/calendar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + + $authPlugin = $this->createMock(\Sabre\DAV\Auth\Plugin::class); + $authPlugin->expects(self::once()) + ->method('getCurrentPrincipal') + ->willReturn(null); + $this->server->expects(self::once()) + ->method('getPlugin') + ->with('auth') + ->willReturn($authPlugin); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public function testBeforePutWithoutAuthPlugin(): void { + $request = $this->createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'text/calendar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + + $this->server->expects(self::once()) + ->method('getPlugin') + ->with('auth') + ->willReturn(null); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + + public static function provideInvalidIcsData(): array { + $noUid = <<createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'text/calendar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + $request->expects(self::once()) + ->method('getBodyAsString') + ->willReturn($ics); + $request->expects(self::once()) + ->method('setBody') + ->with($ics); + + $authPlugin = $this->createMock(\Sabre\DAV\Auth\Plugin::class); + $authPlugin->expects(self::once()) + ->method('getCurrentPrincipal') + ->willReturn('principals/users/usera'); + $this->server->expects(self::once()) + ->method('getPlugin') + ->with('auth') + ->willReturn($authPlugin); + + $request->expects(self::never()) + ->method('setUrl'); + $this->db->expects(self::never()) + ->method('getQueryBuilder'); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } +} From f3a6854b665d16ef13647edc34bac94ab948204e Mon Sep 17 00:00:00 2001 From: Nico Donath Date: Thu, 11 Jun 2026 11:04:59 +0000 Subject: [PATCH 2/2] fix(dav): harden Thunderbird PUT quirk and restore the organizer reply Co-Authored-By: Claude Opus 4.8 Signed-off-by: Nico Donath --- .../ThunderbirdPutInvitationQuirkPlugin.php | 86 ++++++- ...hunderbirdPutInvitationQuirkPluginTest.php | 237 +++++++++++++++++- 2 files changed, 313 insertions(+), 10 deletions(-) diff --git a/apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php b/apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php index e9e4026f84f11..665348f99d6ea 100644 --- a/apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php +++ b/apps/dav/lib/Connector/Sabre/ThunderbirdPutInvitationQuirkPlugin.php @@ -9,6 +9,7 @@ namespace OCA\DAV\Connector\Sabre; +use OCA\DAV\CalDAV\CalDavBackend; use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; use Sabre\DAV\Server; @@ -34,8 +35,8 @@ public function __construct( public function initialize(Server $server) { $this->server = $server; - // Run right after the ACL plugin to make sure that the current user principal is available - $server->on('beforeMethod:PUT', $this->beforePut(...), 21); + // Before the ACL plugin (priority 20) so its PUT check sees the rewritten object. + $server->on('beforeMethod:PUT', $this->beforePut(...), 19); } public function beforePut(RequestInterface $request, ResponseInterface $response): void { @@ -44,9 +45,12 @@ public function beforePut(RequestInterface $request, ResponseInterface $response return; } - if (!str_starts_with($request->getPath(), 'calendars/')) { + // calendars/{principal}/{calendar}/{object} + $pathParts = explode('/', $request->getPath()); + if (count($pathParts) !== 4 || $pathParts[0] !== 'calendars') { return; } + [, , $calendarUri, $requestedObjectUri] = $pathParts; if (!str_contains($request->getHeader('Content-Type') ?? '', 'text/calendar')) { return; @@ -76,8 +80,9 @@ public function beforePut(RequestInterface $request, ResponseInterface $response return; } + // Same calendar as the request, real calendar objects only, nothing trashed. $qb = $this->db->getQueryBuilder(); - $qb->select('co.uri') + $qb->select('co.uri', 'co.calendardata') ->from('calendarobjects', 'co') ->join('co', 'calendars', 'c', $qb->expr()->eq('co.calendarid', 'c.id')) ->where( @@ -86,11 +91,23 @@ public function beforePut(RequestInterface $request, ResponseInterface $response $qb->createNamedParameter($currentUserPrincipal, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR, ), + $qb->expr()->eq( + 'c.uri', + $qb->createNamedParameter($calendarUri, IQueryBuilder::PARAM_STR), + IQueryBuilder::PARAM_STR, + ), $qb->expr()->eq( 'co.uid', $qb->createNamedParameter($uid, IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR, ), + $qb->expr()->eq( + 'co.calendartype', + $qb->createNamedParameter(CalDavBackend::CALENDAR_TYPE_CALENDAR, IQueryBuilder::PARAM_INT), + IQueryBuilder::PARAM_INT, + ), + $qb->expr()->isNull('co.deleted_at'), + $qb->expr()->isNull('c.deleted_at'), ); $result = $qb->executeQuery(); $rows = $result->fetchAll(); @@ -101,10 +118,67 @@ public function beforePut(RequestInterface $request, ResponseInterface $response return; } - $requestUrl = $request->getUrl(); - [$prefix] = \Sabre\Uri\split($requestUrl); $objectUri = $rows[0]['uri']; + if ($objectUri === $requestedObjectUri) { + // Already synced: Thunderbird targets the real URI, leave the request untouched. + return; + } + + // Restore SCHEDULE-AGENT from the stored organizer so server-side scheduling still runs. + $storedData = $rows[0]['calendardata']; + if (is_resource($storedData)) { + $storedData = stream_get_contents($storedData); + } + $storedScheduleAgent = null; + $storedHasOrganizer = false; + try { + $storedVCalendar = VObjectReader::read((string)$storedData); + if ($storedVCalendar instanceof VCalendar) { + $storedOrganizer = $storedVCalendar->getBaseComponent('VEVENT')?->ORGANIZER; + if ($storedOrganizer !== null) { + $storedHasOrganizer = true; + $storedScheduleAgent = isset($storedOrganizer['SCHEDULE-AGENT']) + ? $storedOrganizer['SCHEDULE-AGENT']->getValue() + : null; + } + } + } catch (\Throwable $e) { + $storedHasOrganizer = false; + } + + $organizerRestored = false; + if ($storedHasOrganizer) { + foreach ($vCalendar->getComponents() as $component) { + if ($component->name !== 'VEVENT') { + continue; + } + $organizer = $component->ORGANIZER ?? null; + if ($organizer === null) { + continue; + } + $incomingScheduleAgent = isset($organizer['SCHEDULE-AGENT']) + ? $organizer['SCHEDULE-AGENT']->getValue() + : null; + if ($incomingScheduleAgent === $storedScheduleAgent) { + continue; + } + if ($storedScheduleAgent === null) { + unset($organizer['SCHEDULE-AGENT']); + } else { + $organizer['SCHEDULE-AGENT'] = $storedScheduleAgent; + } + $organizerRestored = true; + } + } + if ($organizerRestored) { + $request->setBody($vCalendar->serialize()); + } + + [$prefix] = \Sabre\Uri\split($request->getUrl()); $request->setUrl("$prefix/$objectUri"); + + // "If-None-Match: *" from the attempted create would fail with 412 after the rewrite + $request->removeHeader('If-None-Match'); } private function isThunderbirdUserAgent(string $userAgent): bool { diff --git a/apps/dav/tests/unit/Connector/Sabre/ThunderbirdPutInvitationQuirkPluginTest.php b/apps/dav/tests/unit/Connector/Sabre/ThunderbirdPutInvitationQuirkPluginTest.php index 153e8b157a2dc..66e0538089cdf 100644 --- a/apps/dav/tests/unit/Connector/Sabre/ThunderbirdPutInvitationQuirkPluginTest.php +++ b/apps/dav/tests/unit/Connector/Sabre/ThunderbirdPutInvitationQuirkPluginTest.php @@ -41,27 +41,51 @@ protected function setUp(): void { public function testInitialize(): void { $this->server->expects(self::once()) ->method('on') - ->with('beforeMethod:PUT', $this->plugin->beforePut(...), 21); + ->with('beforeMethod:PUT', $this->plugin->beforePut(...), 19); $this->plugin->initialize($this->server); } + private static function buildIcs(string $organizerLine): string { + return << 'sabredav-3dd349f8-58e0-483d-921f-70bc9f02366b.ics'], - ['uri' => 'sabredav-19a50615-2db0-4046-a537-000979925e16.ics'], + ['uri' => 'sabredav-3dd349f8-58e0-483d-921f-70bc9f02366b.ics', 'calendardata' => $storedData], + ['uri' => 'sabredav-19a50615-2db0-4046-a537-000979925e16.ics', 'calendardata' => $storedData], ], false, ], // Exactly one collision [ [ - ['uri' => 'sabredav-ab2dd681-c265-4b1e-8a20-e9d356f2c33c.ics'], + ['uri' => 'sabredav-ab2dd681-c265-4b1e-8a20-e9d356f2c33c.ics', 'calendardata' => $storedData], ], true, ], @@ -133,11 +157,16 @@ public function testBeforePut(array $rows, bool $expectUrlChange): void { $request->expects(self::once()) ->method('setUrl') ->with('remote.php/dav/calendars/usera/personal/sabredav-ab2dd681-c265-4b1e-8a20-e9d356f2c33c.ics'); + $request->expects(self::once()) + ->method('removeHeader') + ->with('If-None-Match'); } else { $request->expects(self::never()) ->method('getUrl'); $request->expects(self::never()) ->method('setUrl'); + $request->expects(self::never()) + ->method('removeHeader'); } $authPlugin = $this->createMock(\Sabre\DAV\Auth\Plugin::class); @@ -396,6 +425,206 @@ public static function provideInvalidIcsData(): array { ]; } + public static function provideScheduleAgentRestoreData(): array { + return [ + 'incoming SCHEDULE-AGENT=CLIENT is dropped when the stored organizer has none' => [ + 'ORGANIZER;CN=Admin Account:mailto:admin@imap.localhost', + 'ORGANIZER;CN=Admin Account;SCHEDULE-AGENT=CLIENT:mailto:admin@imap.localhost', + true, + null, + ], + 'incoming SCHEDULE-AGENT matching the stored organizer is left alone' => [ + 'ORGANIZER;CN=Admin Account;SCHEDULE-AGENT=CLIENT:mailto:admin@imap.localhost', + 'ORGANIZER;CN=Admin Account;SCHEDULE-AGENT=CLIENT:mailto:admin@imap.localhost', + false, + null, + ], + 'missing incoming SCHEDULE-AGENT is restored from the stored organizer' => [ + 'ORGANIZER;CN=Admin Account;SCHEDULE-AGENT=CLIENT:mailto:admin@imap.localhost', + 'ORGANIZER;CN=Admin Account:mailto:admin@imap.localhost', + true, + 'CLIENT', + ], + 'unparsable stored calendar data disables the restore' => [ + null, + 'ORGANIZER;CN=Admin Account;SCHEDULE-AGENT=CLIENT:mailto:admin@imap.localhost', + false, + null, + ], + ]; + } + + #[DataProvider('provideScheduleAgentRestoreData')] + public function testBeforePutRestoresScheduleAgent( + ?string $storedOrganizerLine, + string $incomingOrganizerLine, + bool $expectRestore, + ?string $expectedAgent, + ): void { + $storedData = $storedOrganizerLine === null + ? 'THIS IS NOT A CALENDAR OBJECT' + : self::buildIcs($storedOrganizerLine); + $ics = self::buildIcs($incomingOrganizerLine); + + $request = $this->createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'text/calendar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + $request->expects(self::once()) + ->method('getBodyAsString') + ->willReturn($ics); + $request->expects(self::once()) + ->method('getUrl') + ->willReturn('remote.php/dav/calendars/usera/personal/cc5d41aa-7dbc-4278-8ffd-4fb5d626397c.ics'); + $request->expects(self::once()) + ->method('setUrl') + ->with('remote.php/dav/calendars/usera/personal/sabredav-ab2dd681-c265-4b1e-8a20-e9d356f2c33c.ics'); + $request->expects(self::once()) + ->method('removeHeader') + ->with('If-None-Match'); + + $bodies = []; + $request->expects($expectRestore ? self::exactly(2) : self::once()) + ->method('setBody') + ->willReturnCallback(function (string $body) use (&$bodies): void { + $bodies[] = $body; + }); + + $authPlugin = $this->createMock(\Sabre\DAV\Auth\Plugin::class); + $authPlugin->expects(self::once()) + ->method('getCurrentPrincipal') + ->willReturn('principals/users/usera'); + $this->server->expects(self::once()) + ->method('getPlugin') + ->with('auth') + ->willReturn($authPlugin); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select') + ->willReturnSelf(); + $qb->method('from') + ->willReturnSelf(); + $qb->method('join') + ->willReturnSelf(); + $qb->method('where') + ->willReturnSelf(); + $expr = $this->createMock(IExpressionBuilder::class); + $qb->method('expr') + ->willReturn($expr); + $this->db->expects(self::once()) + ->method('getQueryBuilder') + ->willReturn($qb); + + $result = $this->createMock(IResult::class); + $result->expects(self::once()) + ->method('fetchAll') + ->willReturn([ + ['uri' => 'sabredav-ab2dd681-c265-4b1e-8a20-e9d356f2c33c.ics', 'calendardata' => $storedData], + ]); + $result->expects(self::once()) + ->method('closeCursor'); + $qb->expects(self::once()) + ->method('executeQuery') + ->willReturn($result); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + + self::assertSame($ics, $bodies[0]); + if ($expectRestore) { + // Unfold the serialized body as long lines are split per RFC 5545 + $unfolded = str_replace("\r\n ", '', $bodies[1]); + if ($expectedAgent === null) { + self::assertStringNotContainsString('SCHEDULE-AGENT', $unfolded); + } else { + self::assertStringContainsString('SCHEDULE-AGENT=' . $expectedAgent, $unfolded); + } + } + } + + /** + * When Thunderbird already PUTs to the object's real URI (calendar was synced), the plugin + * must not rewrite anything and must leave the body untouched - in particular it must not + * strip a SCHEDULE-AGENT the user set on their own organizer copy. + */ + public function testBeforePutLeavesAlreadyKnownObjectUntouched(): void { + $ics = self::buildIcs('ORGANIZER;CN=Admin Account;SCHEDULE-AGENT=CLIENT:mailto:admin@imap.localhost'); + + $request = $this->createMock(RequestInterface::class); + $request->expects(self::exactly(2)) + ->method('getHeader') + ->willReturnMap([ + ['User-Agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:38.0) Gecko/20100101 Thunderbird/38.2.0 Lightning/4.0.2'], + ['Content-Type', 'text/calendar; charset=utf-8'], + ]); + $request->expects(self::once()) + ->method('getPath') + ->willReturn('calendars/usera/personal/known-object.ics'); + $request->expects(self::once()) + ->method('getBodyAsString') + ->willReturn($ics); + // Only the initial echo-back of the unchanged body, never a rewritten one. + $request->expects(self::once()) + ->method('setBody') + ->with($ics); + $request->expects(self::never()) + ->method('getUrl'); + $request->expects(self::never()) + ->method('setUrl'); + $request->expects(self::never()) + ->method('removeHeader'); + + $authPlugin = $this->createMock(\Sabre\DAV\Auth\Plugin::class); + $authPlugin->expects(self::once()) + ->method('getCurrentPrincipal') + ->willReturn('principals/users/usera'); + $this->server->expects(self::once()) + ->method('getPlugin') + ->with('auth') + ->willReturn($authPlugin); + + $qb = $this->createMock(IQueryBuilder::class); + $qb->method('select') + ->willReturnSelf(); + $qb->method('from') + ->willReturnSelf(); + $qb->method('join') + ->willReturnSelf(); + $qb->method('where') + ->willReturnSelf(); + $expr = $this->createMock(IExpressionBuilder::class); + $qb->method('expr') + ->willReturn($expr); + $this->db->expects(self::once()) + ->method('getQueryBuilder') + ->willReturn($qb); + + $result = $this->createMock(IResult::class); + $result->expects(self::once()) + ->method('fetchAll') + ->willReturn([ + ['uri' => 'known-object.ics', 'calendardata' => self::buildIcs('ORGANIZER;CN=Admin Account:mailto:admin@imap.localhost')], + ]); + $result->expects(self::once()) + ->method('closeCursor'); + $qb->expects(self::once()) + ->method('executeQuery') + ->willReturn($result); + + $response = $this->createMock(ResponseInterface::class); + + $this->plugin->initialize($this->server); + $this->plugin->beforePut($request, $response); + } + #[DataProvider('provideInvalidIcsData')] public function testBeforePutWithInvalidIcs(string $ics): void { $request = $this->createMock(RequestInterface::class);