Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
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,11 +17,21 @@ 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
*/
#[\Override]
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
22 changes: 22 additions & 0 deletions apps/dav/lib/CalDAV/Trashbin/TrashbinHome.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,28 @@ public function getOwner(): string {
return $this->principalInfo['uri'];
}

#[\Override]
public function getACL(): array {
$ownerPrincipal = $this->principalInfo['uri'];
return [
[
'privilege' => '{DAV:}all',
'principal' => $ownerPrincipal,
'protected' => true,
],
[
'privilege' => '{DAV:}read',
'principal' => $ownerPrincipal . '/calendar-proxy-write',
'protected' => true,
],
[
'privilege' => '{DAV:}read',
'principal' => $ownerPrincipal . '/calendar-proxy-read',
'protected' => true,
],
];
}

#[\Override]
public function createFile($name, $data = null) {
throw new Forbidden('Permission denied to create files in the trashbin');
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 @@ -70,6 +71,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 @@ -126,6 +132,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 @@ -226,6 +246,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 258 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:258: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)

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

View workflow job for this annotation

GitHub Actions / static-code-analysis

MissingOverrideAttribute

apps/dav/lib/Connector/Sabre/Principal.php:258:2: MissingOverrideAttribute: Method OCA\DAV\Connector\Sabre\Principal::setgroupmemberset should have the "Override" attribute (see https://psalm.dev/358)
[$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