diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 86c61dd82b42a..ec57a882d9c32 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -98,6 +98,7 @@ 'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => $baseDir . '/../lib/CalDAV/Import/XmlImporter.php', 'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => $baseDir . '/../lib/CalDAV/Integration/ExternalCalendar.php', 'OCA\\DAV\\CalDAV\\Integration\\ICalendarProvider' => $baseDir . '/../lib/CalDAV/Integration/ICalendarProvider.php', + 'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseAclPlugin' => $baseDir . '/../lib/CalDAV/InvitationResponse/InvitationResponseAclPlugin.php', 'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseServer' => $baseDir . '/../lib/CalDAV/InvitationResponse/InvitationResponseServer.php', 'OCA\\DAV\\CalDAV\\Outbox' => $baseDir . '/../lib/CalDAV/Outbox.php', 'OCA\\DAV\\CalDAV\\Plugin' => $baseDir . '/../lib/CalDAV/Plugin.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index d52f4c2c18d07..c7cbd931eca87 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -113,6 +113,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\Import\\XmlImporter' => __DIR__ . '/..' . '/../lib/CalDAV/Import/XmlImporter.php', 'OCA\\DAV\\CalDAV\\Integration\\ExternalCalendar' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ExternalCalendar.php', 'OCA\\DAV\\CalDAV\\Integration\\ICalendarProvider' => __DIR__ . '/..' . '/../lib/CalDAV/Integration/ICalendarProvider.php', + 'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseAclPlugin' => __DIR__ . '/..' . '/../lib/CalDAV/InvitationResponse/InvitationResponseAclPlugin.php', 'OCA\\DAV\\CalDAV\\InvitationResponse\\InvitationResponseServer' => __DIR__ . '/..' . '/../lib/CalDAV/InvitationResponse/InvitationResponseServer.php', 'OCA\\DAV\\CalDAV\\Outbox' => __DIR__ . '/..' . '/../lib/CalDAV/Outbox.php', 'OCA\\DAV\\CalDAV\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/Plugin.php', diff --git a/apps/dav/lib/CalDAV/CalendarHome.php b/apps/dav/lib/CalDAV/CalendarHome.php index ae3d310f48ec2..d7deec6dd0227 100644 --- a/apps/dav/lib/CalDAV/CalendarHome.php +++ b/apps/dav/lib/CalDAV/CalendarHome.php @@ -29,6 +29,7 @@ use Sabre\DAV\Exception\NotFound; use Sabre\DAV\INode; use Sabre\DAV\MkCol; +use Sabre\VObject\Reader; class CalendarHome extends \Sabre\CalDAV\CalendarHome { @@ -228,4 +229,48 @@ public function calendarSearch(array $filters, $limit = null, $offset = null) { $principalUri = $this->principalInfo['uri']; return $this->caldavBackend->calendarSearch($principalUri, $filters, $limit, $offset); } + + /** + * @param string $uid + * @return string|null + */ + #[\Override] + public function getCalendarObjectByUID($uid) { + $path = parent::getCalendarObjectByUID($uid); + if ($path !== null || !$this->caldavBackend instanceof CalDavBackend) { + return $path; + } + + // events organized by this user may be stored in a writable calendar shared with them + $addresses = $this->getPrincipalAddresses(); + foreach ($this->caldavBackend->getCalendarsForUser($this->principalInfo['uri']) as $calendar) { + if ($calendar['{http://owncloud.org/ns}owner-principal'] === $this->principalInfo['uri'] + || !empty($calendar['{http://owncloud.org/ns}read-only'])) { + continue; + } + + $object = $this->caldavBackend->findCalendarObjectByUid((int)$calendar['id'], $uid); + if ($object === null || $object['classification'] === CalDavBackend::CLASSIFICATION_PRIVATE) { + continue; + } + + $organizer = Reader::read($object['calendardata'])->getBaseComponent()?->ORGANIZER; + if ($organizer !== null && in_array(strtolower($organizer->getNormalizedValue()), $addresses, true)) { + return $calendar['uri'] . '/' . $object['uri']; + } + } + + return null; + } + + /** + * @return list + */ + private function getPrincipalAddresses(): array { + $addresses = $this->principalInfo['{DAV:}alternate-URI-set'] ?? []; + if (isset($this->principalInfo['{http://sabredav.org/ns}email-address'])) { + $addresses[] = 'mailto:' . $this->principalInfo['{http://sabredav.org/ns}email-address']; + } + return array_map('strtolower', $addresses); + } } diff --git a/apps/dav/lib/CalDAV/CalendarImpl.php b/apps/dav/lib/CalDAV/CalendarImpl.php index be2299c8f824b..464f558091d4c 100644 --- a/apps/dav/lib/CalDAV/CalendarImpl.php +++ b/apps/dav/lib/CalDAV/CalendarImpl.php @@ -338,6 +338,9 @@ public function handleIMipMessage(string $name, string $calendarData): void { $iTip->message = $vObject; $server->server->emit('schedule', [$iTip]); + if (!str_starts_with((string)$iTip->scheduleStatus, '1.2')) { + throw new CalendarException('iMip message could not be processed: ' . $iTip->scheduleStatus); + } } public function getInvitationResponseServer(): InvitationResponseServer { diff --git a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseAclPlugin.php b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseAclPlugin.php new file mode 100644 index 0000000000000..23647e156425c --- /dev/null +++ b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseAclPlugin.php @@ -0,0 +1,44 @@ +userManager->getByEmail(substr($uri, 7)); + if (count($users) !== 1) { + return null; + } + + return 'principals/users/' . $users[0]->getUID(); + } +} diff --git a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php index 766f7221255bd..ecd9acc795b4b 100644 --- a/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php +++ b/apps/dav/lib/CalDAV/InvitationResponse/InvitationResponseServer.php @@ -15,7 +15,6 @@ use OCA\DAV\Connector\Sabre\AnonymousOptionsPlugin; use OCA\DAV\Connector\Sabre\BlockLegacyClientPlugin; use OCA\DAV\Connector\Sabre\CachingTree; -use OCA\DAV\Connector\Sabre\DavAclPlugin; use OCA\DAV\Connector\Sabre\ExceptionLoggerPlugin; use OCA\DAV\Connector\Sabre\LockPlugin; use OCA\DAV\Connector\Sabre\MaintenancePlugin; @@ -26,6 +25,7 @@ use OCP\EventDispatcher\IEventDispatcher; use OCP\IConfig; use OCP\IURLGenerator; +use OCP\IUserManager; use OCP\L10N\IFactory; use OCP\Server; use Psr\Log\LoggerInterface; @@ -75,7 +75,7 @@ public function __construct(bool $public = true) { $this->server->addPlugin(new \Sabre\DAV\Sync\Plugin()); // acl - $acl = new DavAclPlugin(); + $acl = new InvitationResponseAclPlugin(Server::get(IUserManager::class)); $acl->principalCollectionSet = [ 'principals/users', 'principals/groups' ]; diff --git a/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php b/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php index 25150ddcca1c1..2fb2d86622a06 100644 --- a/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php +++ b/apps/dav/tests/unit/CalDAV/CalendarHomeTest.php @@ -19,6 +19,7 @@ use OCA\DAV\CalDAV\Integration\ICalendarProvider; use OCA\DAV\CalDAV\Outbox; use OCA\DAV\CalDAV\Trashbin\TrashbinHome; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use Sabre\CalDAV\Schedule\Inbox; @@ -431,4 +432,92 @@ public function testGetChildrenFederatedCalendars(): void { $this->assertInstanceOf(FederatedCalendar::class, $actual[3]); $this->assertInstanceOf(FederatedCalendar::class, $actual[4]); } + + public function testGetCalendarObjectByUIDOwnCalendar(): void { + $calendarHome = $this->createCalendarHomeWithAddresses(); + + $this->backend->expects(self::once()) + ->method('getCalendarObjectByUID') + ->with('principals/users/organizer', 'event-uid') + ->willReturn('personal/event.ics'); + $this->backend->expects(self::never()) + ->method('getCalendarsForUser'); + + $this->assertSame('personal/event.ics', $calendarHome->getCalendarObjectByUID('event-uid')); + } + + public static function sharedCalendarObjectProvider(): array { + $event = static fn (string $organizer, string $class = 'PUBLIC'): string => implode("\r\n", [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Test//EN', + 'BEGIN:VEVENT', + 'UID:event-uid', + 'DTSTAMP:20260101T000000Z', + 'DTSTART:20260101T100000Z', + 'CLASS:' . $class, + 'ORGANIZER:' . $organizer, + 'ATTENDEE:mailto:external@example.com', + 'END:VEVENT', + 'END:VCALENDAR', + ]); + + return [ + 'writable share, organized by user' => [false, $event('mailto:Organizer@Example.com'), 0, 'work_shared_by_owner/event.ics'], + 'writable share, organized by user alias' => [false, $event('mailto:alias@example.com'), 0, 'work_shared_by_owner/event.ics'], + 'writable share, organized by someone else' => [false, $event('mailto:owner@example.com'), 0, null], + 'writable share, private event' => [false, $event('mailto:organizer@example.com', 'PRIVATE'), CalDavBackend::CLASSIFICATION_PRIVATE, null], + 'read-only share' => [true, $event('mailto:organizer@example.com'), 0, null], + ]; + } + + #[DataProvider(methodName: 'sharedCalendarObjectProvider')] + public function testGetCalendarObjectByUIDSharedCalendar(bool $readOnly, string $calendarData, int $classification, ?string $expected): void { + $calendarHome = $this->createCalendarHomeWithAddresses(); + + $this->backend->expects(self::once()) + ->method('getCalendarObjectByUID') + ->with('principals/users/organizer', 'event-uid') + ->willReturn(null); + $this->backend->expects(self::once()) + ->method('getCalendarsForUser') + ->with('principals/users/organizer') + ->willReturn([ + [ + 'id' => 1, + 'uri' => 'personal', + '{http://owncloud.org/ns}owner-principal' => 'principals/users/organizer', + ], + [ + 'id' => 2, + 'uri' => 'work_shared_by_owner', + '{http://owncloud.org/ns}owner-principal' => 'principals/users/owner', + '{http://owncloud.org/ns}read-only' => $readOnly, + ], + ]); + $this->backend->expects($readOnly ? self::never() : self::once()) + ->method('findCalendarObjectByUid') + ->with(2, 'event-uid') + ->willReturn([ + 'uri' => 'event.ics', + 'calendardata' => $calendarData, + 'classification' => $classification, + ]); + + $this->assertSame($expected, $calendarHome->getCalendarObjectByUID('event-uid')); + } + + private function createCalendarHomeWithAddresses(): CalendarHome { + return new CalendarHome( + $this->backend, + [ + 'uri' => 'principals/users/organizer', + '{http://sabredav.org/ns}email-address' => 'organizer@example.com', + '{DAV:}alternate-URI-set' => ['mailto:alias@example.com'], + ], + $this->logger, + $this->federatedCalendarFactory, + false + ); + } } diff --git a/apps/dav/tests/unit/CalDAV/CalendarImplTest.php b/apps/dav/tests/unit/CalDAV/CalendarImplTest.php index 03978ad6cfc86..e6434034a1daf 100644 --- a/apps/dav/tests/unit/CalDAV/CalendarImplTest.php +++ b/apps/dav/tests/unit/CalDAV/CalendarImplTest.php @@ -210,6 +210,27 @@ public function testHandleImipNoAttendee(): void { } public function testHandleImipRequest(): void { + $vObject = $this->vCalendar1a; + $vObject->add('METHOD', 'REQUEST'); + + $calendarImpl = $this->createCalendarImplWithScheduleStatus('1.2;Message delivered locally'); + + $calendarImpl->handleIMipMessage('fakeUser', $vObject->serialize()); + } + + public function testHandleImipRequestNotDelivered(): void { + $vObject = $this->vCalendar1a; + $vObject->add('METHOD', 'REQUEST'); + + $calendarImpl = $this->createCalendarImplWithScheduleStatus('3.7;Could not find principal.'); + + $this->expectException(CalendarException::class); + $this->expectExceptionMessage('iMip message could not be processed: 3.7;Could not find principal.'); + + $calendarImpl->handleIMipMessage('fakeUser', $vObject->serialize()); + } + + private function createCalendarImplWithScheduleStatus(string $scheduleStatus): CalendarImpl&MockObject { $userAddressSet = new class([ 'mailto:attendee1@testing.com', '/remote.php/dav/principals/users/attendee1/', ]) { public function __construct( private array $hrefs, @@ -220,18 +241,6 @@ public function getHrefs(): array { } }; - $vObject = $this->vCalendar1a; - $vObject->add('METHOD', 'REQUEST'); - - $iTip = new Message(); - $iTip->method = 'REQUEST'; - $iTip->sender = $vObject->VEVENT->ORGANIZER->getValue(); - $iTip->recipient = $vObject->VEVENT->ATTENDEE->getValue(); - $iTip->component = 'VEVENT'; - $iTip->uid = $vObject->VEVENT->UID->getValue(); - $iTip->sequence = (int)$vObject->VEVENT->SEQUENCE->getValue() ?? 0; - $iTip->message = $vObject; - /** @var CustomPrincipalPlugin|MockObject $authPlugin */ $authPlugin = $this->createMock(CustomPrincipalPlugin::class); $authPlugin->expects(self::once()) @@ -262,7 +271,13 @@ public function getHrefs(): array { '{urn:ietf:params:xml:ns:caldav}calendar-user-address-set' => $userAddressSet, ]); $server->expects(self::once()) - ->method('emit'); + ->method('emit') + ->willReturnCallback(function (string $eventName, array $arguments) use ($scheduleStatus): bool { + /** @var Message $iTip */ + $iTip = $arguments[0]; + $iTip->scheduleStatus = $scheduleStatus; + return true; + }); $invitationResponseServer = $this->createMock(InvitationResponseServer::class, ['getServer']); $invitationResponseServer->server = $server; $invitationResponseServer->expects($this->any()) @@ -276,6 +291,6 @@ public function getHrefs(): array { ->method('getInvitationResponseServer') ->willReturn($invitationResponseServer); - $calendarImpl->handleIMipMessage('fakeUser', $vObject->serialize()); + return $calendarImpl; } } diff --git a/apps/dav/tests/unit/CalDAV/InvitationResponse/InvitationResponseAclPluginTest.php b/apps/dav/tests/unit/CalDAV/InvitationResponse/InvitationResponseAclPluginTest.php new file mode 100644 index 0000000000000..18a824131933f --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/InvitationResponse/InvitationResponseAclPluginTest.php @@ -0,0 +1,100 @@ +userManager = $this->createMock(IUserManager::class); + $this->principalCollection = $this->createMock(IPrincipalCollection::class); + + $tree = $this->createMock(Tree::class); + $tree->method('getNodeForPath') + ->with('principals/users') + ->willReturn($this->principalCollection); + $server = $this->createMock(Server::class); + $server->tree = $tree; + + $this->plugin = new InvitationResponseAclPlugin($this->userManager); + $this->plugin->principalCollectionSet = ['principals/users']; + $reflectionProperty = new \ReflectionProperty($this->plugin, 'server'); + $reflectionProperty->setValue($this->plugin, $server); + } + + public function testPrincipalFoundByCollection(): void { + $this->principalCollection->expects(self::once()) + ->method('findByUri') + ->with('mailto:user@example.com') + ->willReturn('principals/users/user'); + $this->userManager->expects(self::never()) + ->method('getByEmail'); + + $this->assertSame('principals/users/user', $this->plugin->getPrincipalByUri('mailto:user@example.com')); + } + + public function testPrincipalFoundByEmail(): void { + $this->principalCollection->method('findByUri') + ->willReturn(null); + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('user'); + $this->userManager->expects(self::once()) + ->method('getByEmail') + ->with('user@example.com') + ->willReturn([$user]); + + $this->assertSame('principals/users/user', $this->plugin->getPrincipalByUri('mailto:user@example.com')); + } + + public function testPrincipalNotFoundForAmbiguousEmail(): void { + $this->principalCollection->method('findByUri') + ->willReturn(null); + $this->userManager->expects(self::once()) + ->method('getByEmail') + ->with('user@example.com') + ->willReturn([$this->createMock(IUser::class), $this->createMock(IUser::class)]); + + $this->assertNull($this->plugin->getPrincipalByUri('mailto:user@example.com')); + } + + public function testPrincipalNotFoundForUnknownEmail(): void { + $this->principalCollection->method('findByUri') + ->willReturn(null); + $this->userManager->expects(self::once()) + ->method('getByEmail') + ->with('user@example.com') + ->willReturn([]); + + $this->assertNull($this->plugin->getPrincipalByUri('mailto:user@example.com')); + } + + public function testPrincipalNotFoundForNonMailtoUri(): void { + $this->principalCollection->method('findByUri') + ->willReturn(null); + $this->userManager->expects(self::never()) + ->method('getByEmail'); + + $this->assertNull($this->plugin->getPrincipalByUri('principal:principals/users/user')); + } +}