Skip to content

Commit 0379870

Browse files
committed
fix: access shared trashbin objects
Signed-off-by: Hamza <hamzamahjoubi221@gmail.com> Assisted-by: claude:Opus 4.7 (1M context)
1 parent 2f6041f commit 0379870

3 files changed

Lines changed: 302 additions & 30 deletions

File tree

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 257 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,44 +1190,198 @@ public function getDeletedCalendarObjects(int $deletedBefore): array {
11901190
}
11911191

11921192
/**
1193-
* Return all deleted calendar objects by the given principal that are not
1194-
* in deleted calendars.
1193+
* Return all deleted calendar objects accessible to the given principal:
1194+
* - Calendars owned by the principal.
1195+
* - Calendars shared with the principal.
1196+
* - Calendars owned by users who delegated the principal (calendar-proxy-*),
1197+
* plus calendars shared with those delegators (transitively).
11951198
*
11961199
* @param string $principalUri
11971200
* @return array
11981201
* @throws Exception
11991202
*/
12001203
public function getDeletedCalendarObjectsByPrincipal(string $principalUri): array {
1204+
$result = $this->collectDeletedCalendarObjectsForPrincipal($principalUri, null);
1205+
foreach ($this->getProxyDelegators($principalUri) as $delegator => $hasProxyWrite) {
1206+
$overlay = $hasProxyWrite ? Backend::ACCESS_READ_WRITE : Backend::ACCESS_READ;
1207+
$result = array_merge($result, $this->collectDeletedCalendarObjectsForPrincipal($delegator, $overlay));
1208+
}
1209+
return $result;
1210+
}
1211+
1212+
/**
1213+
* Run the owned + shared trashbin queries for $principalUri and merge into $result.
1214+
*
1215+
* @param string $principalUri principal whose calendars to scan.
1216+
* @param array $result accumulator keyed by calendar object id; merged in-place.
1217+
* @param int|null $proxyOverlay if non-null, the entries are being collected on
1218+
* behalf of a different accessor via calendar-proxy; the value caps the
1219+
* effective share access for that accessor (READ_WRITE for proxy-write,
1220+
* READ for proxy-read). null means $principalUri is the accessor itself.
1221+
*/
1222+
private function collectDeletedCalendarObjectsForPrincipal(string $principalUri, ?int $proxyOverlay): array {
1223+
[$principalUri, $principals] = $this->resolvePrincipal($principalUri);
1224+
1225+
$result = [];
1226+
1227+
// Owned calendars
12011228
$query = $this->db->getQueryBuilder();
12021229
$query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at'])
12031230
->selectAlias('c.uri', 'calendaruri')
1231+
->selectAlias('c.principaluri', 'calendarprincipaluri')
12041232
->from('calendarobjects', 'co')
12051233
->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
1206-
->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)))
1234+
->where($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri)))
12071235
->andWhere($query->expr()->isNotNull('co.deleted_at'))
12081236
->andWhere($query->expr()->isNull('c.deleted_at'));
12091237
$stmt = $query->executeQuery();
1238+
while ($row = $stmt->fetchAssociative()) {
1239+
if ($this->resultHasMorePermissiveEntry($result, $row['id'], $proxyOverlay)) {
1240+
continue;
1241+
}
1242+
$result[$row['id']] = $this->rowToDeletedCalendarObject($row, $row['calendaruri'], false, $proxyOverlay);
1243+
}
1244+
$stmt->closeCursor();
12101245

1211-
$result = [];
1246+
// Shared calendars — multiple share rows may match (user + group, etc.),
1247+
// so we dedupe in PHP keeping the most permissive effective access.
1248+
$select = $this->db->getQueryBuilder();
1249+
$select->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at'])
1250+
->selectAlias('c.uri', 'calendaruri')
1251+
->selectAlias('c.principaluri', 'calendarprincipaluri')
1252+
->selectAlias('s.access', 'shareaccess')
1253+
->from('calendarobjects', 'co')
1254+
->join('co', 'calendars', 'c', $select->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
1255+
->andWhere($select->expr()->isNotNull('co.deleted_at'))
1256+
->andWhere($select->expr()->isNull('c.deleted_at'));
1257+
$this->applySharedCalendarFilters($select, $principals, $principalUri);
1258+
1259+
$stmt = $select->executeQuery();
12121260
while ($row = $stmt->fetchAssociative()) {
1213-
$result[] = [
1214-
'id' => $row['id'],
1215-
'uri' => $row['uri'],
1216-
'lastmodified' => $row['lastmodified'],
1217-
'etag' => '"' . $row['etag'] . '"',
1218-
'calendarid' => $row['calendarid'],
1219-
'calendaruri' => $row['calendaruri'],
1220-
'size' => (int)$row['size'],
1221-
'component' => strtolower($row['componenttype']),
1222-
'classification' => (int)$row['classification'],
1223-
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'],
1224-
];
1261+
$effective = $this->effectiveAccess((int)$row['shareaccess'], $proxyOverlay);
1262+
if ($this->resultHasMorePermissiveEntry($result, $row['id'], $effective)) {
1263+
continue;
1264+
}
1265+
[, $ownerName] = Uri\split($row['calendarprincipaluri']);
1266+
$result[$row['id']] = $this->rowToDeletedCalendarObject($row, $row['calendaruri'] . '_shared_by_' . $ownerName, false, $effective);
12251267
}
12261268
$stmt->closeCursor();
1269+
return array_values($result);
1270+
}
12271271

1272+
/**
1273+
* Effective access for an entry surfaced via a proxy delegator.
1274+
* Lower int = more permissive (READ_WRITE=2, READ=3); the more restrictive of
1275+
* the share access and the proxy overlay wins (max of the two ints).
1276+
*/
1277+
private function effectiveAccess(int $shareAccess, ?int $proxyOverlay): int {
1278+
if ($proxyOverlay === null) {
1279+
return $shareAccess;
1280+
}
1281+
return max($shareAccess, $proxyOverlay);
1282+
}
1283+
1284+
/**
1285+
* @param array<int,array<string,mixed>> $result keyed by object id.
1286+
* @param int|string $id the candidate row id.
1287+
* @param int|null $candidateAccess effective access of the candidate row.
1288+
* Owned/no-overlay rows pass null and always win over null entries.
1289+
*/
1290+
private function resultHasMorePermissiveEntry(array $result, int|string $id, ?int $candidateAccess): bool {
1291+
$existing = $result[$id] ?? null;
1292+
if ($existing === null) {
1293+
return false;
1294+
}
1295+
$existingAccess = $existing['shared_access'] ?? null;
1296+
if ($existingAccess === null) {
1297+
// Owned-by-accessor (no overlay) is the most permissive; keep it.
1298+
return true;
1299+
}
1300+
if ($candidateAccess === null) {
1301+
// Candidate is owned-by-accessor; replace.
1302+
return false;
1303+
}
1304+
return $existingAccess <= $candidateAccess;
1305+
}
1306+
1307+
/**
1308+
* Return the principals (users) for whom $principalUri acts as a calendar
1309+
* proxy. The value is true for proxy-write, false for proxy-read.
1310+
*
1311+
* @return array<string,bool> map of delegator-principal => has-write-proxy
1312+
*/
1313+
private function getProxyDelegators(string $principalUri): array {
1314+
$memberships = $this->principalBackend->getGroupMembership($principalUri, true);
1315+
$delegators = [];
1316+
foreach ($memberships as $membership) {
1317+
if (str_ends_with($membership, '/calendar-proxy-write')) {
1318+
$delegator = substr($membership, 0, -strlen('/calendar-proxy-write'));
1319+
$delegators[$delegator] = true;
1320+
} elseif (str_ends_with($membership, '/calendar-proxy-read')) {
1321+
$delegator = substr($membership, 0, -strlen('/calendar-proxy-read'));
1322+
$delegators[$delegator] ??= false;
1323+
}
1324+
}
1325+
return $delegators;
1326+
}
1327+
1328+
private function rowToDeletedCalendarObject(array $row, string $calendarUri, bool $includeData = false, ?int $sharedAccess = null): array {
1329+
$deletedAt = isset($row['deleted_at']) ? (int)$row['deleted_at'] : null;
1330+
$result = [
1331+
'id' => $row['id'],
1332+
'uri' => $row['uri'],
1333+
'lastmodified' => $row['lastmodified'],
1334+
'etag' => '"' . $row['etag'] . '"',
1335+
'calendarid' => $row['calendarid'],
1336+
'calendaruri' => $calendarUri,
1337+
'calendarprincipaluri' => $row['calendarprincipaluri'],
1338+
'size' => (int)$row['size'],
1339+
'component' => strtolower($row['componenttype']),
1340+
'classification' => (int)$row['classification'],
1341+
'deleted_at' => $deletedAt,
1342+
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $deletedAt,
1343+
];
1344+
if ($sharedAccess !== null) {
1345+
$result['shared_access'] = $sharedAccess;
1346+
}
1347+
if ($includeData) {
1348+
$result['calendardata'] = $this->readBlob($row['calendardata']);
1349+
}
12281350
return $result;
12291351
}
12301352

1353+
/**
1354+
* Resolve a principal URI into its converted form and all group/circle memberships.
1355+
*
1356+
* @return array{string, string[]} [$convertedUri, $allPrincipals]
1357+
*/
1358+
private function resolvePrincipal(string $principalUri): array {
1359+
$principals = $this->principalBackend->getGroupMembership($principalUri, true);
1360+
$principals = array_merge($principals, $this->principalBackend->getCircleMembership($principalUri));
1361+
$converted = $this->convertPrincipal($principalUri, true);
1362+
$principals[] = $converted;
1363+
return [$converted, $principals];
1364+
}
1365+
1366+
/**
1367+
* Add joins and WHERE conditions to $query to restrict results to calendars
1368+
* shared with any of $principals, excluding calendars explicitly unshared and
1369+
* calendars owned by $principalUri (already covered by the owned query).
1370+
*/
1371+
private function applySharedCalendarFilters(IQueryBuilder $query, array $principals, string $principalUri): void {
1372+
$subSelect = $this->db->getQueryBuilder();
1373+
$subSelect->select('resourceid')
1374+
->from('dav_shares', 'd')
1375+
->where($subSelect->expr()->eq('d.access', $query->createNamedParameter(Backend::ACCESS_UNSHARED, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
1376+
->andWhere($subSelect->expr()->in('d.principaluri', $query->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY));
1377+
1378+
$query->join('c', 'dav_shares', 's', $query->expr()->eq('s.resourceid', 'c.id', IQueryBuilder::PARAM_INT))
1379+
->andWhere($query->expr()->in('s.principaluri', $query->createNamedParameter($principals, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR_ARRAY))
1380+
->andWhere($query->expr()->eq('s.type', $query->createNamedParameter('calendar', IQueryBuilder::PARAM_STR), IQueryBuilder::PARAM_STR))
1381+
->andWhere($query->expr()->neq('c.principaluri', $query->createNamedParameter($principalUri, IQueryBuilder::PARAM_STR)))
1382+
->andWhere($query->expr()->notIn('c.id', $query->createFunction($subSelect->getSQL()), IQueryBuilder::PARAM_INT_ARRAY));
1383+
}
1384+
12311385
/**
12321386
* Returns information from a single calendar object, based on it's object
12331387
* uri.
@@ -2509,6 +2663,93 @@ public function getCalendarObjectById(string $principalUri, int $id): ?array {
25092663
];
25102664
}
25112665

2666+
/**
2667+
* Return a deleted calendar object by its ID, accessible to $principalUri
2668+
* via ownership, sharing, or proxy delegation. Returns the sharee-facing URI
2669+
* for shared/delegated entries.
2670+
*
2671+
* @param int $id
2672+
* @param string $principalUri
2673+
* @return array|null
2674+
*/
2675+
public function getDeletedCalendarObjectByIdForPrincipal(int $id, string $principalUri): ?array {
2676+
// Visit every accessible path (self + delegators) and keep the most
2677+
// permissive row, so canModify() doesn't get a read-only view when a
2678+
// write path also exists.
2679+
$candidates = [];
2680+
$row = $this->findDeletedCalendarObjectForPrincipal($id, $principalUri, null);
2681+
if ($row !== null) {
2682+
$candidates[$id] = $row;
2683+
}
2684+
foreach ($this->getProxyDelegators($principalUri) as $delegator => $hasProxyWrite) {
2685+
$overlay = $hasProxyWrite ? Backend::ACCESS_READ_WRITE : Backend::ACCESS_READ;
2686+
$row = $this->findDeletedCalendarObjectForPrincipal($id, $delegator, $overlay);
2687+
if ($row === null) {
2688+
continue;
2689+
}
2690+
if ($this->resultHasMorePermissiveEntry($candidates, $id, $row['shared_access'] ?? null)) {
2691+
continue;
2692+
}
2693+
$candidates[$id] = $row;
2694+
}
2695+
return $candidates[$id] ?? null;
2696+
}
2697+
2698+
/**
2699+
* Look up a single deleted calendar object by id for $principalUri.
2700+
*
2701+
* @param int $id
2702+
* @param string $principalUri
2703+
* @param int|null $proxyOverlay see collectDeletedCalendarObjectsForPrincipal.
2704+
*/
2705+
private function findDeletedCalendarObjectForPrincipal(int $id, string $principalUri, ?int $proxyOverlay): ?array {
2706+
[$principalUri, $principals] = $this->resolvePrincipal($principalUri);
2707+
2708+
// Check owned calendars first
2709+
$query = $this->db->getQueryBuilder();
2710+
$query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.calendardata', 'co.componenttype', 'co.classification', 'co.deleted_at'])
2711+
->selectAlias('c.uri', 'calendaruri')
2712+
->selectAlias('c.principaluri', 'calendarprincipaluri')
2713+
->from('calendarobjects', 'co')
2714+
->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
2715+
->where($query->expr()->eq('co.id', $query->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
2716+
->andWhere($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri)))
2717+
->andWhere($query->expr()->isNotNull('co.deleted_at'));
2718+
$stmt = $query->executeQuery();
2719+
$row = $stmt->fetchAssociative();
2720+
$stmt->closeCursor();
2721+
2722+
if ($row) {
2723+
return $this->rowToDeletedCalendarObject($row, $row['calendaruri'], true, $proxyOverlay);
2724+
}
2725+
2726+
// Check shared calendars; order by access ASC so the most permissive
2727+
// row wins when the principal matches multiple share entries.
2728+
$select = $this->db->getQueryBuilder();
2729+
$select->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.calendardata', 'co.componenttype', 'co.classification', 'co.deleted_at'])
2730+
->selectAlias('c.uri', 'calendaruri')
2731+
->selectAlias('c.principaluri', 'calendarprincipaluri')
2732+
->selectAlias('s.access', 'shareaccess')
2733+
->from('calendarobjects', 'co')
2734+
->join('co', 'calendars', 'c', $select->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
2735+
->andWhere($select->expr()->eq('co.id', $select->createNamedParameter($id, IQueryBuilder::PARAM_INT), IQueryBuilder::PARAM_INT))
2736+
->andWhere($select->expr()->isNotNull('co.deleted_at'))
2737+
->orderBy('s.access', 'ASC');
2738+
$this->applySharedCalendarFilters($select, $principals, $principalUri);
2739+
2740+
$stmt = $select->executeQuery();
2741+
$row = $stmt->fetchAssociative();
2742+
$stmt->closeCursor();
2743+
2744+
if (!$row) {
2745+
return null;
2746+
}
2747+
2748+
$effective = $this->effectiveAccess((int)$row['shareaccess'], $proxyOverlay);
2749+
[, $ownerName] = Uri\split($row['calendarprincipaluri']);
2750+
return $this->rowToDeletedCalendarObject($row, $row['calendaruri'] . '_shared_by_' . $ownerName, true, $effective);
2751+
}
2752+
25122753
/**
25132754
* The getChanges method returns all the changes that have happened, since
25142755
* the specified syncToken in the specified calendar.

0 commit comments

Comments
 (0)