Skip to content

Commit fcd78e8

Browse files
hamza221backportbot[bot]
authored andcommitted
fix: access shared trashbin objects
fix: access shared trashbin objects Signed-off-by: Hamza <hamzamahjoubi221@gmail.com> Assisted-by: claude:Opus 4.7 (1M context) [skip ci]
1 parent 658f772 commit fcd78e8

4 files changed

Lines changed: 310 additions & 30 deletions

File tree

apps/dav/lib/CalDAV/CalDavBackend.php

Lines changed: 265 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,44 +1183,202 @@ public function getDeletedCalendarObjects(int $deletedBefore): array {
11831183
}
11841184

11851185
/**
1186-
* Return all deleted calendar objects by the given principal that are not
1187-
* in deleted calendars.
1186+
* Return all deleted calendar objects accessible to the given principal:
1187+
* - Calendars owned by the principal.
1188+
* - Calendars shared with the principal.
1189+
* - Calendars owned by users who delegated the principal (calendar-proxy-*),
1190+
* plus calendars shared with those delegators (transitively).
11881191
*
11891192
* @param string $principalUri
11901193
* @return array
11911194
* @throws Exception
11921195
*/
11931196
public function getDeletedCalendarObjectsByPrincipal(string $principalUri): array {
1197+
$result = [];
1198+
$this->collectDeletedCalendarObjectsForPrincipal($principalUri, $result, null);
1199+
foreach ($this->getProxyDelegators($principalUri) as $delegator => $hasProxyWrite) {
1200+
$overlay = $hasProxyWrite ? Backend::ACCESS_READ_WRITE : Backend::ACCESS_READ;
1201+
$this->collectDeletedCalendarObjectsForPrincipal($delegator, $result, $overlay);
1202+
}
1203+
return array_values($result);
1204+
}
1205+
1206+
/**
1207+
* Run the owned + shared trashbin queries for $principalUri and merge the
1208+
* results into $result, keyed by calendar object id.
1209+
*
1210+
* @param string $principalUri principal whose calendars to scan.
1211+
* @param array $result accumulator keyed by calendar object id; merged in-place.
1212+
* @param int|null $proxyOverlay if non-null, the entries are being collected on
1213+
* behalf of a different accessor via calendar-proxy; the value caps the
1214+
* effective share access for that accessor (READ_WRITE for proxy-write,
1215+
* READ for proxy-read). null means $principalUri is the accessor itself.
1216+
*/
1217+
private function collectDeletedCalendarObjectsForPrincipal(string $principalUri, array &$result, ?int $proxyOverlay): void {
1218+
[$principalUri, $principals] = $this->resolvePrincipal($principalUri);
1219+
1220+
// Owned calendars
11941221
$query = $this->db->getQueryBuilder();
11951222
$query->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at'])
11961223
->selectAlias('c.uri', 'calendaruri')
1224+
->selectAlias('c.principaluri', 'calendarprincipaluri')
11971225
->from('calendarobjects', 'co')
11981226
->join('co', 'calendars', 'c', $query->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
1199-
->where($query->expr()->eq('principaluri', $query->createNamedParameter($principalUri)))
1227+
->where($query->expr()->eq('c.principaluri', $query->createNamedParameter($principalUri)))
12001228
->andWhere($query->expr()->isNotNull('co.deleted_at'))
12011229
->andWhere($query->expr()->isNull('c.deleted_at'));
12021230
$stmt = $query->executeQuery();
1231+
while ($row = $stmt->fetchAssociative()) {
1232+
if ($this->resultHasMorePermissiveEntry($result, $row['id'], $proxyOverlay)) {
1233+
continue;
1234+
}
1235+
[, $ownerName] = Uri\split($row['calendarprincipaluri']);
1236+
$isDelegated = $proxyOverlay !== null;
1237+
$calendarUri = $isDelegated ? $row['calendaruri'] . '_delegated_by_' . $ownerName : $row['calendaruri'];
1238+
$result[$row['id']] = $this->rowToDeletedCalendarObject($row, $calendarUri, false, $proxyOverlay, $isDelegated ? $principalUri : null);
1239+
}
1240+
$stmt->closeCursor();
12031241

1204-
$result = [];
1242+
// Shared calendars — multiple share rows may match (user + group, etc.),
1243+
// so we dedupe in PHP keeping the most permissive effective access.
1244+
$select = $this->db->getQueryBuilder();
1245+
$select->select(['co.id', 'co.uri', 'co.lastmodified', 'co.etag', 'co.calendarid', 'co.size', 'co.componenttype', 'co.classification', 'co.deleted_at'])
1246+
->selectAlias('c.uri', 'calendaruri')
1247+
->selectAlias('c.principaluri', 'calendarprincipaluri')
1248+
->selectAlias('s.access', 'shareaccess')
1249+
->from('calendarobjects', 'co')
1250+
->join('co', 'calendars', 'c', $select->expr()->eq('c.id', 'co.calendarid', IQueryBuilder::PARAM_INT))
1251+
->andWhere($select->expr()->isNotNull('co.deleted_at'))
1252+
->andWhere($select->expr()->isNull('c.deleted_at'));
1253+
$this->applySharedCalendarFilters($select, $principals, $principalUri);
1254+
1255+
$stmt = $select->executeQuery();
12051256
while ($row = $stmt->fetchAssociative()) {
1206-
$result[] = [
1207-
'id' => $row['id'],
1208-
'uri' => $row['uri'],
1209-
'lastmodified' => $row['lastmodified'],
1210-
'etag' => '"' . $row['etag'] . '"',
1211-
'calendarid' => $row['calendarid'],
1212-
'calendaruri' => $row['calendaruri'],
1213-
'size' => (int)$row['size'],
1214-
'component' => strtolower($row['componenttype']),
1215-
'classification' => (int)$row['classification'],
1216-
'{' . \OCA\DAV\DAV\Sharing\Plugin::NS_NEXTCLOUD . '}deleted-at' => $row['deleted_at'] === null ? $row['deleted_at'] : (int)$row['deleted_at'],
1217-
];
1257+
$effective = $this->effectiveAccess((int)$row['shareaccess'], $proxyOverlay);
1258+
if ($this->resultHasMorePermissiveEntry($result, $row['id'], $effective)) {
1259+
continue;
1260+
}
1261+
[, $ownerName] = Uri\split($row['calendarprincipaluri']);
1262+
$result[$row['id']] = $this->rowToDeletedCalendarObject($row, $row['calendaruri'] . '_shared_by_' . $ownerName, false, $effective, null);
12181263
}
12191264
$stmt->closeCursor();
1265+
}
12201266

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

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

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

apps/dav/lib/CalDAV/Trashbin/DeletedCalendarObject.php

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
use OCA\DAV\CalDAV\CalDavBackend;
1212
use OCA\DAV\CalDAV\IRestorable;
13+
use OCA\DAV\DAV\Sharing\Backend;
1314
use Sabre\CalDAV\ICalendarObject;
1415
use Sabre\DAV\Exception\Forbidden;
1516
use Sabre\DAVACL\ACLTrait;
@@ -28,6 +29,9 @@ public function __construct(
2829
}
2930

3031
public function delete() {
32+
if (!$this->canModify()) {
33+
throw new Forbidden('Read-only sharees cannot permanently delete trashbin entries');
34+
}
3135
$this->calDavBackend->deleteCalendarObject(
3236
$this->objectData['calendarid'],
3337
$this->objectData['uri'],
@@ -74,6 +78,9 @@ public function getSize() {
7478
}
7579

7680
public function restore(): void {
81+
if (!$this->canModify()) {
82+
throw new Forbidden('Read-only sharees cannot restore trashbin entries');
83+
}
7784
$this->calDavBackend->restoreCalendarObject($this->objectData);
7885
}
7986

@@ -86,19 +93,14 @@ public function getCalendarUri(): string {
8693
}
8794

8895
public function getACL(): array {
89-
return [
96+
$acl = [
9097
[
9198
'privilege' => '{DAV:}read', // For queries
9299
'principal' => $this->getOwner(),
93100
'protected' => true,
94101
],
95102
[
96-
'privilege' => '{DAV:}unbind', // For moving and deletion
97-
'principal' => $this->getOwner(),
98-
'protected' => true,
99-
],
100-
[
101-
'privilege' => '{DAV:}all',
103+
'privilege' => '{DAV:}read',
102104
'principal' => $this->getOwner() . '/calendar-proxy-write',
103105
'protected' => true,
104106
],
@@ -108,6 +110,23 @@ public function getACL(): array {
108110
'protected' => true,
109111
],
110112
];
113+
114+
if ($this->canModify()) {
115+
$acl[] = [
116+
'privilege' => '{DAV:}unbind',
117+
'principal' => $this->getOwner(),
118+
'protected' => true,
119+
];
120+
121+
$acl[] = [
122+
'privilege' => '{DAV:}unbind',
123+
'principal' => $this->getOwner() . '/calendar-proxy-write',
124+
'protected' => true,
125+
];
126+
127+
}
128+
129+
return $acl;
111130
}
112131

113132
public function getOwner() {

0 commit comments

Comments
 (0)