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
278 changes: 262 additions & 16 deletions apps/dav/lib/CalDAV/CalDavBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Comment on lines +1204 to +1212
* 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.
Comment on lines +1213 to +1220
*/
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<int,array<string,mixed>> $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<string,bool> 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.
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading