Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/dav/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
45 changes: 45 additions & 0 deletions apps/dav/lib/CalDAV/CalendarHome.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -228,4 +229,48 @@
$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;

Check failure on line 257 in apps/dav/lib/CalDAV/CalendarHome.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

UndefinedMethod

apps/dav/lib/CalDAV/CalendarHome.php:257:56: UndefinedMethod: Method Sabre\VObject\Document::getBaseComponent does not exist (see https://psalm.dev/022)
if ($organizer !== null && in_array(strtolower($organizer->getNormalizedValue()), $addresses, true)) {
return $calendar['uri'] . '/' . $object['uri'];
}
}

return null;
}

/**
* @return list<string>
*/
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);
}
}
3 changes: 3 additions & 0 deletions apps/dav/lib/CalDAV/CalendarImpl.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\DAV\CalDAV\InvitationResponse;

use OCA\DAV\Connector\Sabre\DavAclPlugin;
use OCP\IUserManager;

/**
* Resolves local users by email address without applying user discovery
* restrictions, as invitation responses are processed without a user session.
*/
class InvitationResponseAclPlugin extends DavAclPlugin {
public function __construct(
private IUserManager $userManager,
) {
parent::__construct();
}

/**
* @param string $uri
* @return string|null
*/
#[\Override]
public function getPrincipalByUri($uri) {
$principal = parent::getPrincipalByUri($uri);
if ($principal !== null || !str_starts_with($uri, 'mailto:')) {
return $principal;
}

$users = $this->userManager->getByEmail(substr($uri, 7));
if (count($users) !== 1) {
return null;
}

return 'principals/users/' . $users[0]->getUID();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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'
];
Expand Down
89 changes: 89 additions & 0 deletions apps/dav/tests/unit/CalDAV/CalendarHomeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
);
}
}
43 changes: 29 additions & 14 deletions apps/dav/tests/unit/CalDAV/CalendarImplTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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())
Expand Down Expand Up @@ -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())
Expand All @@ -276,6 +291,6 @@ public function getHrefs(): array {
->method('getInvitationResponseServer')
->willReturn($invitationResponseServer);

$calendarImpl->handleIMipMessage('fakeUser', $vObject->serialize());
return $calendarImpl;
}
}
Loading
Loading