Skip to content

Commit 8d29369

Browse files
committed
feat: calendar delegation
Signed-off-by: Grigory Vodyanov <scratchx@gmx.com>
1 parent 7e9e126 commit 8d29369

12 files changed

Lines changed: 458 additions & 2 deletions

File tree

apps/dav/appinfo/v1/caldav.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
use OCP\IUserManager;
3535
use OCP\IUserSession;
3636
use OCP\L10N\IFactory as IL10NFactory;
37+
use OCP\Mail\IMailer;
3738
use OCP\Security\Bruteforce\IThrottler;
3839
use OCP\Security\ISecureRandom;
3940
use OCP\Server;
@@ -58,6 +59,8 @@
5859
Server::get(KnownUserService::class),
5960
Server::get(IConfig::class),
6061
Server::get(IL10NFactory::class),
62+
Server::get(IMailer::class),
63+
Server::get(LoggerInterface::class),
6164
'principals/'
6265
);
6366
$db = Server::get(IDBConnection::class);

apps/dav/appinfo/v1/carddav.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use OCP\IUserManager;
3232
use OCP\IUserSession;
3333
use OCP\L10N\IFactory as IL10nFactory;
34+
use OCP\Mail\IMailer;
3435
use OCP\Security\Bruteforce\IThrottler;
3536
use OCP\Server;
3637
use Psr\Log\LoggerInterface;
@@ -55,6 +56,8 @@
5556
Server::get(KnownUserService::class),
5657
Server::get(IConfig::class),
5758
Server::get(IL10nFactory::class),
59+
Server::get(IMailer::class),
60+
Server::get(LoggerInterface::class),
5861
'principals/'
5962
);
6063
$db = Server::get(IDBConnection::class);

apps/dav/lib/CalDAV/CalendarHome.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
/**
44
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
56
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
67
* SPDX-License-Identifier: AGPL-3.0-only
78
*/
@@ -214,6 +215,36 @@ public function getChild($name) {
214215
throw new NotFound('Node with name \'' . $name . '\' could not be found');
215216
}
216217

218+
/**
219+
* @inheritdoc
220+
*
221+
* Extends the default ACL to grant proxy principals access to list this
222+
* calendar home. Individual Calendar objects already have their own proxy
223+
* ACL entries; this entry allows the PROPFIND on the home collection itself.
224+
*
225+
* @return array
226+
*/
227+
public function getACL(): array {
228+
$acl = parent::getACL();
229+
$ownerPrincipal = $this->principalInfo['uri'];
230+
231+
// Write-proxy delegates may list and read the calendar home so they can
232+
// discover which calendars are available.
233+
$acl[] = [
234+
'privilege' => '{DAV:}read',
235+
'principal' => $ownerPrincipal . '/calendar-proxy-write',
236+
'protected' => true,
237+
];
238+
// Read-proxy delegates may also list the calendar home.
239+
$acl[] = [
240+
'privilege' => '{DAV:}read',
241+
'principal' => $ownerPrincipal . '/calendar-proxy-read',
242+
'protected' => true,
243+
];
244+
245+
return $acl;
246+
}
247+
217248
/**
218249
* @param array $filters
219250
* @param integer|null $limit

apps/dav/lib/CalDAV/Plugin.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,20 @@ class Plugin extends \Sabre\CalDAV\Plugin {
1717
* This function should return null in case a principal did not have
1818
* a calendar home.
1919
*
20+
* For calendar-proxy group principals (e.g. principals/users/alice/calendar-proxy-write),
21+
* this returns the calendar home of the principal owner (alice), so that CalDAV clients
22+
* can discover and access the delegated calendar home correctly.
23+
*
2024
* @param string $principalUrl
2125
* @return string|null
2226
*/
2327
public function getCalendarHomeForPrincipal($principalUrl) {
28+
// calendar-proxy group principals must resolve to the owner's calendar home
29+
if (str_ends_with($principalUrl, '/calendar-proxy-write') || str_ends_with($principalUrl, '/calendar-proxy-read')) {
30+
$ownerPrincipalUrl = substr($principalUrl, 0, strrpos($principalUrl, '/'));
31+
return $this->getCalendarHomeForPrincipal($ownerPrincipalUrl);
32+
}
33+
2434
if (strrpos($principalUrl, 'principals/users', -strlen($principalUrl)) !== false) {
2535
[, $principalId] = \Sabre\Uri\split($principalUrl);
2636
return self::CALENDAR_ROOT . '/' . $principalId;

apps/dav/lib/Command/CreateCalendar.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use OCP\IUserManager;
2424
use OCP\IUserSession;
2525
use OCP\L10N\IFactory;
26+
use OCP\Mail\IMailer;
2627
use OCP\Security\ISecureRandom;
2728
use OCP\Server;
2829
use Psr\Log\LoggerInterface;
@@ -68,6 +69,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
6869
Server::get(KnownUserService::class),
6970
Server::get(IConfig::class),
7071
Server::get(IFactory::class),
72+
Server::get(IMailer::class),
73+
Server::get(LoggerInterface::class),
7174
);
7275
$random = Server::get(ISecureRandom::class);
7376
$logger = Server::get(LoggerInterface::class);

apps/dav/lib/Connector/Sabre/Principal.php

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
/**
44
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
56
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
67
* SPDX-License-Identifier: AGPL-3.0-only
78
*/
@@ -25,8 +26,10 @@
2526
use OCP\IUserManager;
2627
use OCP\IUserSession;
2728
use OCP\L10N\IFactory;
29+
use OCP\Mail\IMailer;
2830
use OCP\Share\IManager as IShareManager;
2931
use Psr\Container\ContainerExceptionInterface;
32+
use Psr\Log\LoggerInterface;
3033
use Sabre\DAV\Exception;
3134
use Sabre\DAV\PropPatch;
3235
use Sabre\DAVACL\PrincipalBackend\BackendInterface;
@@ -53,6 +56,8 @@ public function __construct(
5356
private KnownUserService $knownUserService,
5457
private IConfig $config,
5558
private IFactory $languageFactory,
59+
private IMailer $mailer,
60+
private LoggerInterface $logger,
5661
string $principalPrefix = 'principals/users/',
5762
) {
5863
$this->principalPrefix = trim($principalPrefix, '/');
@@ -61,6 +66,7 @@ public function __construct(
6166

6267
use PrincipalProxyTrait {
6368
getGroupMembership as protected traitGetGroupMembership;
69+
setGroupMemberSet as protected traitSetGroupMemberSet;
6470
}
6571

6672
/**
@@ -124,6 +130,20 @@ public function getPrincipalPropertiesByPath($path, ?array $propertyFilter = nul
124130
if ($user !== null) {
125131
return [
126132
'uri' => 'principals/users/' . $user->getUID() . '/' . $name,
133+
// Only the principal owner may modify their own proxy group.
134+
// Any authenticated user may read it (needed by Sabre internals).
135+
'{DAV:}acl' => [
136+
[
137+
'privilege' => '{DAV:}read',
138+
'principal' => '{DAV:}authenticated',
139+
'protected' => true,
140+
],
141+
[
142+
'privilege' => '{DAV:}write',
143+
'principal' => 'principals/users/' . $user->getUID(),
144+
'protected' => true,
145+
],
146+
],
127147
];
128148
}
129149
return null;
@@ -222,6 +242,89 @@ public function updatePrincipal($path, PropPatch $propPatch) {
222242
return 0;
223243
}
224244

245+
/**
246+
* Updates the list of group members for a group principal.
247+
*
248+
* Overrides the trait implementation to send email notifications when new
249+
* write-proxy delegates are added.
250+
*
251+
* @param string $principal
252+
* @param string[] $members
253+
*/
254+
public function setGroupMemberSet($principal, array $members): void {
255+
[$principalUri, $target] = \Sabre\Uri\split($principal);
256+
257+
// Snapshot the current write-proxy members before applying changes so
258+
// we can diff and notify only the newly added ones.
259+
$oldMemberUids = [];
260+
if ($target === 'calendar-proxy-write') {
261+
$oldProxies = $this->proxyMapper->getProxiesOf($principalUri);
262+
foreach ($oldProxies as $proxy) {
263+
if ($proxy->getPermissions() === (ProxyMapper::PERMISSION_READ | ProxyMapper::PERMISSION_WRITE)) {
264+
$oldMemberUids[] = $proxy->getProxyId();
265+
}
266+
}
267+
}
268+
269+
// Apply the new member set via the trait implementation.
270+
$this->traitSetGroupMemberSet($principal, $members);
271+
272+
// Notify newly added write-proxy delegates.
273+
if ($target === 'calendar-proxy-write') {
274+
[, $ownerUid] = \Sabre\Uri\split($principalUri);
275+
$addedMembers = array_diff($members, $oldMemberUids);
276+
foreach ($addedMembers as $memberUri) {
277+
[, $delegateUid] = \Sabre\Uri\split($memberUri);
278+
$this->sendDelegationNotification($ownerUid, $delegateUid);
279+
}
280+
}
281+
}
282+
283+
/**
284+
* Send an email to a newly added delegate informing them of the delegation.
285+
*
286+
* @param string $ownerUid User ID of the calendar owner who granted access
287+
* @param string $delegateUid User ID of the user who was just granted access
288+
*/
289+
private function sendDelegationNotification(string $ownerUid, string $delegateUid): void {
290+
$delegateUser = $this->userManager->get($delegateUid);
291+
$ownerUser = $this->userManager->get($ownerUid);
292+
293+
if ($delegateUser === null || $ownerUser === null) {
294+
return;
295+
}
296+
297+
$delegateEmail = $delegateUser->getEMailAddress();
298+
if ($delegateEmail === null || $delegateEmail === '') {
299+
return; // No email address on file — skip silently.
300+
}
301+
302+
$l = $this->languageFactory->get('dav');
303+
304+
$ownerDisplayName = $ownerUser->getDisplayName() ?: $ownerUid;
305+
$delegateDisplayName = $delegateUser->getDisplayName() ?: $delegateUid;
306+
307+
$subject = $l->t('%s has granted you access to their calendars', [$ownerDisplayName]);
308+
$bodyText = $l->t(
309+
"Hello %1\$s,\n\n%2\$s has added you as a calendar delegate. You can now view and manage their\ncalendars in the Nextcloud Calendar app under the \"Delegated\" section.\n\nTo remove yourself as a delegate, ask %2\$s to revoke your access in their\nCalendar settings.",
310+
[$delegateDisplayName, $ownerDisplayName]
311+
);
312+
313+
try {
314+
$message = $this->mailer->createMessage();
315+
$message->setTo([$delegateEmail => $delegateDisplayName]);
316+
$message->setSubject($subject);
317+
$message->setPlainBody($bodyText);
318+
$this->mailer->send($message);
319+
} catch (\Exception $e) {
320+
// Notification failure must never block the PROPPATCH response.
321+
$this->logger->warning(
322+
'Could not send delegation notification email',
323+
['owner' => $ownerUid, 'delegate' => $delegateUid, 'error' => $e->getMessage()]
324+
);
325+
}
326+
}
327+
225328
/**
226329
* Search user principals
227330
*

apps/dav/lib/Connector/Sabre/ServerFactory.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
use OCP\IUserManager;
3939
use OCP\IUserSession;
4040
use OCP\L10N\IFactory;
41+
use OCP\Mail\IMailer;
4142
use OCP\SabrePluginEvent;
4243
use OCP\SystemTag\ISystemTagManager;
4344
use OCP\SystemTag\ISystemTagObjectMapper;
@@ -258,6 +259,8 @@ private function initRootCollection(SimpleCollection $rootCollection, Directory|
258259
\OCP\Server::get(KnownUserService::class),
259260
\OCP\Server::get(IConfig::class),
260261
\OCP\Server::get(IFactory::class),
262+
\OCP\Server::get(IMailer::class),
263+
$this->logger,
261264
);
262265

263266
// Mount the share collection at /public.php/dav/files/<share token>

apps/dav/lib/RootCollection.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
use OCP\IUserManager;
4444
use OCP\IUserSession;
4545
use OCP\L10N\IFactory;
46+
use OCP\Mail\IMailer;
4647
use OCP\Security\ISecureRandom;
4748
use OCP\Server;
4849
use OCP\SystemTag\ISystemTagManager;
@@ -76,7 +77,9 @@ public function __construct() {
7677
$proxyMapper,
7778
Server::get(KnownUserService::class),
7879
Server::get(IConfig::class),
79-
Server::get(IFactory::class)
80+
Server::get(IFactory::class),
81+
Server::get(IMailer::class),
82+
$logger
8083
);
8184

8285
$groupPrincipalBackend = new GroupPrincipalBackend($groupManager, $userSession, $shareManager, $config);

apps/dav/tests/unit/CalDAV/CalendarHomeTest.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,4 +430,38 @@ public function testGetChildrenFederatedCalendars(): void {
430430
$this->assertInstanceOf(FederatedCalendar::class, $actual[3]);
431431
$this->assertInstanceOf(FederatedCalendar::class, $actual[4]);
432432
}
433+
434+
public function testGetAclContainsProxyWritePrincipal(): void {
435+
$acl = $this->calendarHome->getACL();
436+
437+
$principals = array_column($acl, 'principal');
438+
$this->assertContains('user-principal-123/calendar-proxy-write', $principals);
439+
440+
// Find the specific entry and verify all fields
441+
foreach ($acl as $entry) {
442+
if ($entry['principal'] === 'user-principal-123/calendar-proxy-write') {
443+
$this->assertSame('{DAV:}read', $entry['privilege']);
444+
$this->assertTrue($entry['protected']);
445+
return;
446+
}
447+
}
448+
$this->fail('ACL entry for calendar-proxy-write not found');
449+
}
450+
451+
public function testGetAclContainsProxyReadPrincipal(): void {
452+
$acl = $this->calendarHome->getACL();
453+
454+
$principals = array_column($acl, 'principal');
455+
$this->assertContains('user-principal-123/calendar-proxy-read', $principals);
456+
457+
// Find the specific entry and verify all fields
458+
foreach ($acl as $entry) {
459+
if ($entry['principal'] === 'user-principal-123/calendar-proxy-read') {
460+
$this->assertSame('{DAV:}read', $entry['privilege']);
461+
$this->assertTrue($entry['protected']);
462+
return;
463+
}
464+
}
465+
$this->fail('ACL entry for calendar-proxy-read not found');
466+
}
433467
}

apps/dav/tests/unit/CalDAV/PluginTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ public static function linkProvider(): array {
3333
'principals/calendar-rooms/Room-ABC',
3434
'system-calendars/calendar-rooms/Room-ABC',
3535
],
36+
// calendar-proxy-write and calendar-proxy-read group principals must
37+
// resolve to the owner's calendar home so that delegates can discover
38+
// the delegated calendar home via PROPFIND on the proxy group principal.
39+
[
40+
'principals/users/alice/calendar-proxy-write',
41+
'calendars/alice',
42+
],
43+
[
44+
'principals/users/alice/calendar-proxy-read',
45+
'calendars/alice',
46+
],
3647
];
3748
}
3849

0 commit comments

Comments
 (0)