diff --git a/apps/dav/lib/CalDAV/CalDavBackend.php b/apps/dav/lib/CalDAV/CalDavBackend.php index 66d216c1f5af8..f98ede5232ded 100644 --- a/apps/dav/lib/CalDAV/CalDavBackend.php +++ b/apps/dav/lib/CalDAV/CalDavBackend.php @@ -1190,44 +1190,201 @@ public function getDeletedCalendarObjects(int $deletedBefore): array { } /** - * Return all deleted calendar objects by the given principal that are not - * in deleted calendars. + * Return all deleted calendar objects accessible to the given principal: + * - Calendars owned by the principal. + * - Calendars shared with the principal. + * - Calendars owned by users who delegated the principal (calendar-proxy-*), + * plus calendars shared with those delegators (transitively). * * @param string $principalUri * @return array * @throws Exception */ public function getDeletedCalendarObjectsByPrincipal(string $principalUri): array { + $result = $this->collectDeletedCalendarObjectsForPrincipal($principalUri, null); + foreach ($this->getProxyDelegators($principalUri) as $delegator => $hasProxyWrite) { + $overlay = $hasProxyWrite ? Backend::ACCESS_READ_WRITE : Backend::ACCESS_READ; + $result = array_merge($result, $this->collectDeletedCalendarObjectsForPrincipal($delegator, $overlay)); + } + return $result; + } + + /** + * Run the owned + shared trashbin queries for $principalUri and merge into $result. + * + * @param string $principalUri principal whose calendars to scan. + * @param array $result accumulator keyed by calendar object id; merged in-place. + * @param int|null $proxyOverlay if non-null, the entries are being collected on + * behalf of a different accessor via calendar-proxy; the value caps the + * effective share access for that accessor (READ_WRITE for proxy-write, + * READ for proxy-read). null means $principalUri is the accessor itself. + */ + private function collectDeletedCalendarObjectsForPrincipal(string $principalUri, ?int $proxyOverlay): array { + [$principalUri, $principals] = $this->resolvePrincipal($principalUri); + + $result = []; + + // Owned calendars $query = $this->db->getQueryBuilder(); $query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at']) ->selectAlias('c.uri', 'calendaruri') + ->selectAlias('c.principaluri', 'calendarprincipaluri') ->from('calendarobjects', 'co') ->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT)) - ->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri))) + ->where($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri))) ->andWhere($query->expr()->isNotNull('co.deleted_at')) ->andWhere($query->expr()->isNull('c.deleted_at')); $stmt = $query->executeQuery(); + while ($row = $stmt->fetchAssociative()) { + if ($this->resultHasMorePermissiveEntry($result, $row['id'], $proxyOverlay)) { + continue; + } + [, $ownerName] = Uri\split($row['calendarprincipaluri']); + $calendarUri = $proxyOverlay !== null ? $row['calendaruri'] . '_delegated_by_' . $ownerName : $row['calendaruri']; + $result[$row['id']] = $this->rowToDeletedCalendarObject($row, $calendarUri, false, $proxyOverlay); + } + $stmt->closeCursor(); - $result = []; + // Shared calendars — multiple share rows may match (user + group, etc.), + // so we dedupe in PHP keeping the most permissive effective access. + $select = $this->db->getQueryBuilder(); + $select->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at']) + ->selectAlias('c.uri', 'calendaruri') + ->selectAlias('c.principaluri', 'calendarprincipaluri') + ->selectAlias('s.access', 'shareaccess') + ->from('calendarobjects', 'co') + ->join('co', 'calendars', 'c', $select->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT)) + ->andWhere($select->expr()->isNotNull('co.deleted_at')) + ->andWhere($select->expr()->isNull('c.deleted_at')); + $this->applySharedCalendarFilters($select, $principals, $principalUri); + + $stmt = $select->executeQuery(); while ($row = $stmt->fetchAssociative()) { - $result[] = [ - 'id' => $row['id'], - 'uri' => $row['uri'], - 'lastmodified' => $row['lastmodified'], - 'etag' => '"' . $row['etag'] . '"', - 'calendarid' => $row['calendarid'], - 'calendaruri' => $row['calendaruri'], - 'size' => (int)$row['size'], - 'component' => strtolower($row['componenttype']), - 'classification' => (int)$row['classification'], - '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'], - ]; + $effective = $this->effectiveAccess((int)$row['shareaccess'], $proxyOverlay); + if ($this->resultHasMorePermissiveEntry($result, $row['id'], $effective)) { + continue; + } + [, $ownerName] = Uri\split($row['calendarprincipaluri']); + $result[$row['id']] = $this->rowToDeletedCalendarObject($row, $row['calendaruri'] . '_shared_by_' . $ownerName, false, $effective); } $stmt->closeCursor(); + return array_values($result); + } + /** + * Effective access for an entry surfaced via a proxy delegator. + * Lower int = more permissive (READ_WRITE=2, READ=3); the more restrictive of + * the share access and the proxy overlay wins (max of the two ints). + */ + private function effectiveAccess(int $shareAccess, ?int $proxyOverlay): int { + if ($proxyOverlay === null) { + return $shareAccess; + } + return max($shareAccess, $proxyOverlay); + } + + /** + * @param array> $result keyed by object id. + * @param int|string $id the candidate row id. + * @param int|null $candidateAccess effective access of the candidate row. + * Owned/no-overlay rows pass null and always win over null entries. + */ + private function resultHasMorePermissiveEntry(array $result, int|string $id, ?int $candidateAccess): bool { + $existing = $result[$id] ?? null; + if ($existing === null) { + return false; + } + $existingAccess = $existing['shared_access'] ?? null; + if ($existingAccess === null) { + // Owned-by-accessor (no overlay) is the most permissive; keep it. + return true; + } + if ($candidateAccess === null) { + // Candidate is owned-by-accessor; replace. + return false; + } + return $existingAccess <= $candidateAccess; + } + + /** + * Return the principals (users) for whom $principalUri acts as a calendar + * proxy. The value is true for proxy-write, false for proxy-read. + * + * @return array map of delegator-principal => has-write-proxy + */ + private function getProxyDelegators(string $principalUri): array { + $memberships = $this->principalBackend->getGroupMembership($principalUri, true); + $delegators = []; + foreach ($memberships as $membership) { + if (str_ends_with($membership, '/calendar-proxy-write')) { + $delegator = substr($membership, 0, -strlen('/calendar-proxy-write')); + $delegators[$delegator] = true; + } elseif (str_ends_with($membership, '/calendar-proxy-read')) { + $delegator = substr($membership, 0, -strlen('/calendar-proxy-read')); + $delegators[$delegator] ??= false; + } + } + return $delegators; + } + + private function rowToDeletedCalendarObject(array $row, string $calendarUri, bool $includeData = false, ?int $sharedAccess = null): array { + $deletedAt = isset($row['deleted_at']) ? (int)$row['deleted_at'] : null; + $result = [ + 'id' => $row['id'], + 'uri' => $row['uri'], + 'lastmodified' => $row['lastmodified'], + 'etag' => '"' . $row['etag'] . '"', + 'calendarid' => $row['calendarid'], + 'calendaruri' => $calendarUri, + 'sourcecalendaruri' => $row['calendaruri'], + 'calendarprincipaluri' => $row['calendarprincipaluri'], + 'size' => (int)$row['size'], + 'component' => strtolower($row['componenttype']), + 'classification' => (int)$row['classification'], + 'deleted_at' => $deletedAt, + '{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $deletedAt, + ]; + if ($sharedAccess !== null) { + $result['shared_access'] = $sharedAccess; + } + if ($includeData) { + $result['calendardata'] = $this->readBlob($row['calendardata']); + } return $result; } + /** + * Resolve a principal URI into its converted form and all group/circle memberships. + * + * @return array{string, string[]} [$convertedUri, $allPrincipals] + */ + private function resolvePrincipal(string $principalUri): array { + $principals = $this->principalBackend->getGroupMembership($principalUri, true); + $principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUri)); + $converted = $this->convertPrincipal($principalUri, true); + $principals[] = $converted; + return [$converted, $principals]; + } + + /** + * Add joins and WHERE conditions to $query to restrict results to calendars + * shared with any of $principals, excluding calendars explicitly unshared and + * calendars owned by $principalUri (already covered by the owned query). + */ + private function applySharedCalendarFilters(IQueryBuilder $query, array $principals, string $principalUri): void { + $subSelect = $this->db->getQueryBuilder(); + $subSelect->select('resourceid') + ->from('dav_shares', 'd') + ->where($subSelect->expr()->eq('d.access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) + ->andWhere($subSelect->expr()->in('d.principaluri', $query->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)); + + $query->join('c', 'dav_shares', 's', $query->expr()->eq('s.resourceid', 'c.id', IQueryBuilder::PARAM_INT)) + ->andWhere($query->expr()->in('s.principaluri', $query->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY)) + ->andWhere($query->expr()->eq('s.type', $query->createNamedParameter('calendar', IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR)) + ->andWhere($query->expr()->neq('c.principaluri', $query->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR))) + ->andWhere($query->expr()->notIn('c.id', $query->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY)); + } + /** * Returns information from a single calendar object, based on it's object * uri. @@ -2509,6 +2666,95 @@ public function getCalendarObjectById(string $principalUri, int $id): ?array { ]; } + /** + * Return a deleted calendar object by its ID, accessible to $principalUri + * via ownership, sharing, or proxy delegation. Returns the sharee-facing URI + * for shared/delegated entries. + * + * @param int $id + * @param string $principalUri + * @return array|null + */ + public function getDeletedCalendarObjectByIdForPrincipal(int $id, string $principalUri): ?array { + // Visit every accessible path (self + delegators) and keep the most + // permissive row, so canModify() doesn't get a read-only view when a + // write path also exists. + $candidates = []; + $row = $this->findDeletedCalendarObjectForPrincipal($id, $principalUri, null); + if ($row !== null) { + $candidates[$id] = $row; + } + foreach ($this->getProxyDelegators($principalUri) as $delegator => $hasProxyWrite) { + $overlay = $hasProxyWrite ? Backend::ACCESS_READ_WRITE : Backend::ACCESS_READ; + $row = $this->findDeletedCalendarObjectForPrincipal($id, $delegator, $overlay); + if ($row === null) { + continue; + } + if ($this->resultHasMorePermissiveEntry($candidates, $id, $row['shared_access'] ?? null)) { + continue; + } + $candidates[$id] = $row; + } + return $candidates[$id] ?? null; + } + + /** + * Look up a single deleted calendar object by id for $principalUri. + * + * @param int $id + * @param string $principalUri + * @param int|null $proxyOverlay see collectDeletedCalendarObjectsForPrincipal. + */ + private function findDeletedCalendarObjectForPrincipal(int $id, string $principalUri, ?int $proxyOverlay): ?array { + [$principalUri, $principals] = $this->resolvePrincipal($principalUri); + + // Check owned calendars first + $query = $this->db->getQueryBuilder(); + $query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.calendardata', 'co.componenttype', 'co.classification', 'co.deleted_at']) + ->selectAlias('c.uri', 'calendaruri') + ->selectAlias('c.principaluri', 'calendarprincipaluri') + ->from('calendarobjects', 'co') + ->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT)) + ->where($query->expr()->eq('co.id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) + ->andWhere($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri))) + ->andWhere($query->expr()->isNotNull('co.deleted_at')); + $stmt = $query->executeQuery(); + $row = $stmt->fetchAssociative(); + $stmt->closeCursor(); + + if ($row) { + [, $ownerName] = Uri\split($row['calendarprincipaluri']); + $calendarUri = $proxyOverlay !== null ? $row['calendaruri'] . '_delegated_by_' . $ownerName : $row['calendaruri']; + return $this->rowToDeletedCalendarObject($row, $calendarUri, true, $proxyOverlay); + } + + // Check shared calendars; order by access ASC so the most permissive + // row wins when the principal matches multiple share entries. + $select = $this->db->getQueryBuilder(); + $select->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.calendardata', 'co.componenttype', 'co.classification', 'co.deleted_at']) + ->selectAlias('c.uri', 'calendaruri') + ->selectAlias('c.principaluri', 'calendarprincipaluri') + ->selectAlias('s.access', 'shareaccess') + ->from('calendarobjects', 'co') + ->join('co', 'calendars', 'c', $select->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT)) + ->andWhere($select->expr()->eq('co.id', $select->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT)) + ->andWhere($select->expr()->isNotNull('co.deleted_at')) + ->orderBy('s.access', 'ASC'); + $this->applySharedCalendarFilters($select, $principals, $principalUri); + + $stmt = $select->executeQuery(); + $row = $stmt->fetchAssociative(); + $stmt->closeCursor(); + + if (!$row) { + return null; + } + + $effective = $this->effectiveAccess((int)$row['shareaccess'], $proxyOverlay); + [, $ownerName] = Uri\split($row['calendarprincipaluri']); + return $this->rowToDeletedCalendarObject($row, $row['calendaruri'] . '_shared_by_' . $ownerName, true, $effective); + } + /** * The getChanges method returns all the changes that have happened, since * the specified syncToken in the specified calendar. diff --git a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php index f622cb439952d..c585754b768d8 100644 --- a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php +++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php @@ -10,6 +10,7 @@ use OCA\DAV\CalDAV\CalDavBackend; use OCA\DAV\CalDAV\IRestorable; +use OCA\DAV\DAV\Sharing\Backend; use Sabre\CalDAV\ICalendarObject; use Sabre\DAV\Exception\Forbidden; use Sabre\DAVACL\ACLTrait; @@ -29,6 +30,9 @@ public function __construct( #[\Override] public function delete() { + if (!$this->canModify()) { + throw new Forbidden('Read-only sharees cannot permanently delete trashbin entries'); + } $this->calDavBackend->deleteCalendarObject( $this->objectData['calendarid'], $this->objectData['uri'], @@ -37,6 +41,19 @@ public function delete() { ); } + private function isShared(): bool { + $calendarOwner = $this->objectData['calendarprincipaluri'] ?? null; + return $calendarOwner !== null && $calendarOwner !== $this->principalUri; + } + + private function canModify(): bool { + if (!$this->isShared()) { + return true; + } + // For shared entries, only write sharees may delete/restore. + return ($this->objectData['shared_access'] ?? null) === Backend::ACCESS_READ_WRITE; + } + #[\Override] public function getName() { return $this->name; @@ -84,6 +101,9 @@ public function getSize() { #[\Override] public function restore(): void { + if (!$this->canModify()) { + throw new Forbidden('Read-only sharees cannot restore trashbin entries'); + } $this->calDavBackend->restoreCalendarObject($this->objectData); } @@ -95,21 +115,24 @@ public function getCalendarUri(): string { return $this->objectData['calendaruri']; } + public function getSourceCalendarUri(): string { + return $this->objectData['sourcecalendaruri'] ?? $this->objectData['calendaruri']; + } + + public function getCalendarPrincipalUri(): ?string { + return $this->objectData['calendarprincipaluri'] ?? null; + } + #[\Override] public function getACL(): array { - return [ + $acl = [ [ 'privilege' => '{DAV:}read', // For queries 'principal' => $this->getOwner(), 'protected' => true, ], [ - 'privilege' => '{DAV:}unbind', // For moving and deletion - 'principal' => $this->getOwner(), - 'protected' => true, - ], - [ - 'privilege' => '{DAV:}all', + 'privilege' => '{DAV:}read', 'principal' => $this->getOwner() . '/calendar-proxy-write', 'protected' => true, ], @@ -119,6 +142,23 @@ public function getACL(): array { 'protected' => true, ], ]; + + if ($this->canModify()) { + $acl[] = [ + 'privilege' => '{DAV:}unbind', + 'principal' => $this->getOwner(), + 'protected' => true, + ]; + + $acl[] = [ + 'privilege' => '{DAV:}unbind', + 'principal' => $this->getOwner() . '/calendar-proxy-write', + 'protected' => true, + ]; + + } + + return $acl; } #[\Override] diff --git a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php index d853ed773f3d3..890cfeab2b6b8 100644 --- a/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php +++ b/apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php @@ -16,7 +16,6 @@ use Sabre\DAV\Exception\NotImplemented; use Sabre\DAVACL\ACLTrait; use Sabre\DAVACL\IACL; -use function array_map; use function implode; use function preg_match; @@ -46,12 +45,11 @@ public function getChild($name) { throw new NotFound(); } - $data = $this->caldavBackend->getCalendarObjectById( - $this->principalInfo['uri'], + $data = $this->caldavBackend->getDeletedCalendarObjectByIdForPrincipal( (int)$matches[1], + $this->principalInfo['uri'], ); - // If the object hasn't been deleted yet then we don't want to find it here if ($data === null) { throw new NotFound(); } @@ -110,9 +108,10 @@ public function getLastModified(): int { #[\Override] public function calendarQuery(array $filters) { - return array_map(function (array $calendarObjectInfo) { - return $this->getRelativeObjectPath($calendarObjectInfo); - }, $this->caldavBackend->getDeletedCalendarObjectsByPrincipal($this->principalInfo['uri'])); + return array_map( + fn (array $obj) => $this->getRelativeObjectPath($obj), + $this->caldavBackend->getDeletedCalendarObjectsByPrincipal($this->principalInfo['uri']), + ); } private function getRelativeObjectPath(array $calendarInfo): string { diff --git a/apps/dav/lib/CalDAV/Trashbin/Plugin.php b/apps/dav/lib/CalDAV/Trashbin/Plugin.php index 14c33956225bb..d905306aa4517 100644 --- a/apps/dav/lib/CalDAV/Trashbin/Plugin.php +++ b/apps/dav/lib/CalDAV/Trashbin/Plugin.php @@ -27,6 +27,8 @@ class Plugin extends ServerPlugin { public const PROPERTY_DELETED_AT = '{http://nextcloud.com/ns}deleted-at'; public const PROPERTY_CALENDAR_URI = '{http://nextcloud.com/ns}calendar-uri'; + public const PROPERTY_SOURCE_CALENDAR_URI = '{http://nextcloud.com/ns}source-calendar-uri'; + public const PROPERTY_CALENDAR_OWNER_PRINCIPAL_URI = '{http://nextcloud.com/ns}calendar-owner-principal-uri'; public const PROPERTY_RETENTION_DURATION = '{http://nextcloud.com/ns}trash-bin-retention-duration'; /** @var bool */ @@ -97,6 +99,13 @@ private function propFind( $propFind->handle(self::PROPERTY_CALENDAR_URI, function () use ($node) { return $node->getCalendarUri(); }); + // needed in case of delegated or shared calendars + $propFind->handle(self::PROPERTY_SOURCE_CALENDAR_URI, function () use ($node) { + return $node->getSourceCalendarUri(); + }); + $propFind->handle(self::PROPERTY_CALENDAR_OWNER_PRINCIPAL_URI, function () use ($node) { + return $node->getCalendarPrincipalUri(); + }); } if ($node instanceof TrashbinHome) { $propFind->handle(self::PROPERTY_RETENTION_DURATION, function () use ($node) {