Skip to content
Draft
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
3 changes: 3 additions & 0 deletions apps/dav/appinfo/v1/caldav.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory as IL10NFactory;
use OCP\Mail\IMailer;
use OCP\Security\Bruteforce\IThrottler;
use OCP\Security\ISecureRandom;
use OCP\Server;
Expand All @@ -58,6 +59,8 @@
Server::get(KnownUserService::class),
Server::get(IConfig::class),
Server::get(IL10NFactory::class),
Server::get(IMailer::class),
Server::get(LoggerInterface::class),
'principals/'
);
$db = Server::get(IDBConnection::class);
Expand Down
3 changes: 3 additions & 0 deletions apps/dav/appinfo/v1/carddav.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory as IL10nFactory;
use OCP\Mail\IMailer;
use OCP\Security\Bruteforce\IThrottler;
use OCP\Server;
use Psr\Log\LoggerInterface;
Expand All @@ -55,6 +56,8 @@
Server::get(KnownUserService::class),
Server::get(IConfig::class),
Server::get(IL10nFactory::class),
Server::get(IMailer::class),
Server::get(LoggerInterface::class),
'principals/'
);
$db = Server::get(IDBConnection::class);
Expand Down
31 changes: 31 additions & 0 deletions apps/dav/lib/CalDAV/CalendarHome.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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

/**
* @inheritdoc
*
* Extends the default ACL to grant proxy principals access to list this
* calendar home. Individual Calendar objects already have their own proxy
* ACL entries; this entry allows the PROPFIND on the home collection itself.
*
* @return array
*/
public function getACL(): array {
$acl = parent::getACL();
$ownerPrincipal = $this->principalInfo['uri'];

// Write-proxy delegates may list and read the calendar home so they can
// discover which calendars are available.
$acl[] = [
'privilege' => '{DAV:}read',
'principal' => $ownerPrincipal . '/calendar-proxy-write',
'protected' => true,
];
// Read-proxy delegates may also list the calendar home.
$acl[] = [
'privilege' => '{DAV:}read',
'principal' => $ownerPrincipal . '/calendar-proxy-read',
'protected' => true,
];

return $acl;
}

/**
* @param array $filters
* @param integer|null $limit
Expand Down
10 changes: 10 additions & 0 deletions apps/dav/lib/CalDAV/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,20 @@ class Plugin extends \Sabre\CalDAV\Plugin {
* This function should return null in case a principal did not have
* a calendar home.
*
* For calendar-proxy group principals (e.g. principals/users/alice/calendar-proxy-write),
* this returns the calendar home of the principal owner (alice), so that CalDAV clients
* can discover and access the delegated calendar home correctly.
*
* @param string $principalUrl
* @return string|null
*/
public function getCalendarHomeForPrincipal($principalUrl) {
// calendar-proxy group principals must resolve to the owner's calendar home
if (str_ends_with($principalUrl, '/calendar-proxy-write') || str_ends_with($principalUrl, '/calendar-proxy-read')) {
$ownerPrincipalUrl = substr($principalUrl, 0, strrpos($principalUrl, '/'));
return $this->getCalendarHomeForPrincipal($ownerPrincipalUrl);
}

if (strrpos($principalUrl, 'principals/users', -strlen($principalUrl)) !== false) {
[, $principalId] = \Sabre\Uri\split($principalUrl);
return self::CALENDAR_ROOT . '/' . $principalId;
Expand Down
3 changes: 3 additions & 0 deletions apps/dav/lib/Command/CreateCalendar.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Mail\IMailer;
use OCP\Security\ISecureRandom;
use OCP\Server;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -68,6 +69,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
Server::get(KnownUserService::class),
Server::get(IConfig::class),
Server::get(IFactory::class),
Server::get(IMailer::class),
Server::get(LoggerInterface::class),
);
$random = Server::get(ISecureRandom::class);
$logger = Server::get(LoggerInterface::class);
Expand Down
103 changes: 103 additions & 0 deletions apps/dav/lib/Connector/Sabre/Principal.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

/**
* SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
Expand All @@ -25,8 +26,10 @@
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Mail\IMailer;
use OCP\Share\IManager as IShareManager;
use Psr\Container\ContainerExceptionInterface;
use Psr\Log\LoggerInterface;
use Sabre\DAV\Exception;
use Sabre\DAV\PropPatch;
use Sabre\DAVACL\PrincipalBackend\BackendInterface;
Expand All @@ -53,6 +56,8 @@
private KnownUserService $knownUserService,
private IConfig $config,
private IFactory $languageFactory,
private IMailer $mailer,
private LoggerInterface $logger,
string $principalPrefix = 'principals/users/',
) {
$this->principalPrefix = trim($principalPrefix, '/');
Expand All @@ -61,6 +66,7 @@

use PrincipalProxyTrait {
getGroupMembership as protected traitGetGroupMembership;
setGroupMemberSet as protected traitSetGroupMemberSet;
}

/**
Expand Down Expand Up @@ -124,6 +130,20 @@
if ($user !== null) {
return [
'uri' => 'principals/users/' . $user->getUID() . '/' . $name,
// Only the principal owner may modify their own proxy group.
// Any authenticated user may read it (needed by Sabre internals).
'{DAV:}acl' => [
[
'privilege' => '{DAV:}read',
'principal' => '{DAV:}authenticated',
'protected' => true,
],
[
'privilege' => '{DAV:}write',
'principal' => 'principals/users/' . $user->getUID(),
'protected' => true,
],
],
];
}
return null;
Expand Down Expand Up @@ -222,6 +242,89 @@
return 0;
}

/**
* Updates the list of group members for a group principal.
*
* Overrides the trait implementation to send email notifications when new
* write-proxy delegates are added.
*
* @param string $principal
* @param string[] $members
*/
public function setGroupMemberSet($principal, array $members): void {

Check failure on line 254 in apps/dav/lib/Connector/Sabre/Principal.php

View workflow job for this annotation

GitHub Actions / static-code-analysis

MoreSpecificImplementedParamType

apps/dav/lib/Connector/Sabre/Principal.php:254:54: MoreSpecificImplementedParamType: Argument 2 of OCA\DAV\Connector\Sabre\Principal::setGroupMemberSet has the more specific type 'array<array-key, string>', expecting 'array<array-key, mixed>' as defined by Sabre\DAVACL\PrincipalBackend\BackendInterface::setGroupMemberSet (see https://psalm.dev/140)
[$principalUri, $target] = \Sabre\Uri\split($principal);

// Snapshot the current write-proxy members before applying changes so
// we can diff and notify only the newly added ones.
$oldMemberUids = [];
if ($target === 'calendar-proxy-write') {
$oldProxies = $this->proxyMapper->getProxiesOf($principalUri);
foreach ($oldProxies as $proxy) {
if ($proxy->getPermissions() === (ProxyMapper::PERMISSION_READ | ProxyMapper::PERMISSION_WRITE)) {
$oldMemberUids[] = $proxy->getProxyId();
}
}
}

// Apply the new member set via the trait implementation.
$this->traitSetGroupMemberSet($principal, $members);

// Notify newly added write-proxy delegates.
if ($target === 'calendar-proxy-write') {
[, $ownerUid] = \Sabre\Uri\split($principalUri);
$addedMembers = array_diff($members, $oldMemberUids);
foreach ($addedMembers as $memberUri) {
[, $delegateUid] = \Sabre\Uri\split($memberUri);
$this->sendDelegationNotification($ownerUid, $delegateUid);
}
}
}

/**
* Send an email to a newly added delegate informing them of the delegation.
*
* @param string $ownerUid User ID of the calendar owner who granted access
* @param string $delegateUid User ID of the user who was just granted access
*/
private function sendDelegationNotification(string $ownerUid, string $delegateUid): void {
$delegateUser = $this->userManager->get($delegateUid);
$ownerUser = $this->userManager->get($ownerUid);

if ($delegateUser === null || $ownerUser === null) {
return;
}

$delegateEmail = $delegateUser->getEMailAddress();
if ($delegateEmail === null || $delegateEmail === '') {
return; // No email address on file — skip silently.
}

$l = $this->languageFactory->get('dav');

$ownerDisplayName = $ownerUser->getDisplayName() ?: $ownerUid;
$delegateDisplayName = $delegateUser->getDisplayName() ?: $delegateUid;

$subject = $l->t('%s has granted you access to their calendars', [$ownerDisplayName]);
$bodyText = $l->t(
"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.",
[$delegateDisplayName, $ownerDisplayName]
);

try {
$message = $this->mailer->createMessage();
$message->setTo([$delegateEmail => $delegateDisplayName]);
$message->setSubject($subject);
$message->setPlainBody($bodyText);
$this->mailer->send($message);
} catch (\Exception $e) {
// Notification failure must never block the PROPPATCH response.
$this->logger->warning(
'Could not send delegation notification email',
['owner' => $ownerUid, 'delegate' => $delegateUid, 'error' => $e->getMessage()]
);
}
}

/**
* Search user principals
*
Expand Down
3 changes: 3 additions & 0 deletions apps/dav/lib/Connector/Sabre/ServerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Mail\IMailer;
use OCP\SabrePluginEvent;
use OCP\SystemTag\ISystemTagManager;
use OCP\SystemTag\ISystemTagObjectMapper;
Expand Down Expand Up @@ -258,6 +259,8 @@ private function initRootCollection(SimpleCollection $rootCollection, Directory|
\OCP\Server::get(KnownUserService::class),
\OCP\Server::get(IConfig::class),
\OCP\Server::get(IFactory::class),
\OCP\Server::get(IMailer::class),
$this->logger,
);

// Mount the share collection at /public.php/dav/files/<share token>
Expand Down
5 changes: 4 additions & 1 deletion apps/dav/lib/RootCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
use OCP\IUserManager;
use OCP\IUserSession;
use OCP\L10N\IFactory;
use OCP\Mail\IMailer;
use OCP\Security\ISecureRandom;
use OCP\Server;
use OCP\SystemTag\ISystemTagManager;
Expand Down Expand Up @@ -76,7 +77,9 @@ public function __construct() {
$proxyMapper,
Server::get(KnownUserService::class),
Server::get(IConfig::class),
Server::get(IFactory::class)
Server::get(IFactory::class),
Server::get(IMailer::class),
$logger
);

$groupPrincipalBackend = new GroupPrincipalBackend($groupManager, $userSession, $shareManager, $config);
Expand Down
34 changes: 34 additions & 0 deletions apps/dav/tests/unit/CalDAV/CalendarHomeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -430,4 +430,38 @@ public function testGetChildrenFederatedCalendars(): void {
$this->assertInstanceOf(FederatedCalendar::class, $actual[3]);
$this->assertInstanceOf(FederatedCalendar::class, $actual[4]);
}

public function testGetAclContainsProxyWritePrincipal(): void {
$acl = $this->calendarHome->getACL();

$principals = array_column($acl, 'principal');
$this->assertContains('user-principal-123/calendar-proxy-write', $principals);

// Find the specific entry and verify all fields
foreach ($acl as $entry) {
if ($entry['principal'] === 'user-principal-123/calendar-proxy-write') {
$this->assertSame('{DAV:}read', $entry['privilege']);
$this->assertTrue($entry['protected']);
return;
}
}
$this->fail('ACL entry for calendar-proxy-write not found');
}

public function testGetAclContainsProxyReadPrincipal(): void {
$acl = $this->calendarHome->getACL();

$principals = array_column($acl, 'principal');
$this->assertContains('user-principal-123/calendar-proxy-read', $principals);

// Find the specific entry and verify all fields
foreach ($acl as $entry) {
if ($entry['principal'] === 'user-principal-123/calendar-proxy-read') {
$this->assertSame('{DAV:}read', $entry['privilege']);
$this->assertTrue($entry['protected']);
return;
}
}
$this->fail('ACL entry for calendar-proxy-read not found');
}
}
11 changes: 11 additions & 0 deletions apps/dav/tests/unit/CalDAV/PluginTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ public static function linkProvider(): array {
'principals/calendar-rooms/Room-ABC',
'system-calendars/calendar-rooms/Room-ABC',
],
// calendar-proxy-write and calendar-proxy-read group principals must
// resolve to the owner's calendar home so that delegates can discover
// the delegated calendar home via PROPFIND on the proxy group principal.
[
'principals/users/alice/calendar-proxy-write',
'calendars/alice',
],
[
'principals/users/alice/calendar-proxy-read',
'calendars/alice',
],
];
}

Expand Down
Loading
Loading