@@ -1190,44 +1190,202 @@ 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 = [];
1205+ $ this ->collectDeletedCalendarObjectsForPrincipal ($ principalUri , $ result , null );
1206+ foreach ($ this ->getProxyDelegators ($ principalUri ) as $ delegator => $ hasProxyWrite ) {
1207+ $ overlay = $ hasProxyWrite ? Backend::ACCESS_READ_WRITE : Backend::ACCESS_READ ;
1208+ $ this ->collectDeletedCalendarObjectsForPrincipal ($ delegator , $ result , $ overlay );
1209+ }
1210+ return array_values ($ result );
1211+ }
1212+
1213+ /**
1214+ * Run the owned + shared trashbin queries for $principalUri and merge the
1215+ * results into $result, keyed by calendar object id.
1216+ *
1217+ * @param string $principalUri principal whose calendars to scan.
1218+ * @param array $result accumulator keyed by calendar object id; merged in-place.
1219+ * @param int|null $proxyOverlay if non-null, the entries are being collected on
1220+ * behalf of a different accessor via calendar-proxy; the value caps the
1221+ * effective share access for that accessor (READ_WRITE for proxy-write,
1222+ * READ for proxy-read). null means $principalUri is the accessor itself.
1223+ */
1224+ private function collectDeletedCalendarObjectsForPrincipal (string $ principalUri , array &$ result , ?int $ proxyOverlay ): void {
1225+ [$ principalUri , $ principals ] = $ this ->resolvePrincipal ($ principalUri );
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+ [, $ ownerName ] = Uri \split ($ row ['calendarprincipaluri ' ]);
1243+ $ isDelegated = $ proxyOverlay !== null ;
1244+ $ calendarUri = $ isDelegated ? $ row ['calendaruri ' ] . '_delegated_by_ ' . $ ownerName : $ row ['calendaruri ' ];
1245+ $ result [$ row ['id ' ]] = $ this ->rowToDeletedCalendarObject ($ row , $ calendarUri , false , $ proxyOverlay , $ isDelegated ? $ principalUri : null );
1246+ }
1247+ $ stmt ->closeCursor ();
12101248
1211- $ result = [];
1249+ // Shared calendars — multiple share rows may match (user + group, etc.),
1250+ // so we dedupe in PHP keeping the most permissive effective access.
1251+ $ select = $ this ->db ->getQueryBuilder ();
1252+ $ select ->select (['co.id ' , 'co.uri ' , 'co.lastmodified ' , 'co.etag ' , 'co.calendarid ' , 'co.size ' , 'co.componenttype ' , 'co.classification ' , 'co.deleted_at ' ])
1253+ ->selectAlias ('c.uri ' , 'calendaruri ' )
1254+ ->selectAlias ('c.principaluri ' , 'calendarprincipaluri ' )
1255+ ->selectAlias ('s.access ' , 'shareaccess ' )
1256+ ->from ('calendarobjects ' , 'co ' )
1257+ ->join ('co ' , 'calendars ' , 'c ' , $ select ->expr ()->eq ('c.id ' , 'co.calendarid ' , IQueryBuilder::PARAM_INT ))
1258+ ->andWhere ($ select ->expr ()->isNotNull ('co.deleted_at ' ))
1259+ ->andWhere ($ select ->expr ()->isNull ('c.deleted_at ' ));
1260+ $ this ->applySharedCalendarFilters ($ select , $ principals , $ principalUri );
1261+
1262+ $ stmt = $ select ->executeQuery ();
12121263 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- ];
1264+ $ effective = $ this ->effectiveAccess ((int )$ row ['shareaccess ' ], $ proxyOverlay );
1265+ if ($ this ->resultHasMorePermissiveEntry ($ result , $ row ['id ' ], $ effective )) {
1266+ continue ;
1267+ }
1268+ [, $ ownerName ] = Uri \split ($ row ['calendarprincipaluri ' ]);
1269+ $ result [$ row ['id ' ]] = $ this ->rowToDeletedCalendarObject ($ row , $ row ['calendaruri ' ] . '_shared_by_ ' . $ ownerName , false , $ effective , null );
12251270 }
12261271 $ stmt ->closeCursor ();
1272+ }
12271273
1274+ /**
1275+ * Effective access for an entry surfaced via a proxy delegator.
1276+ * Lower int = more permissive (READ_WRITE=2, READ=3); the more restrictive of
1277+ * the share access and the proxy overlay wins (max of the two ints).
1278+ */
1279+ private function effectiveAccess (int $ shareAccess , ?int $ proxyOverlay ): int {
1280+ if ($ proxyOverlay === null ) {
1281+ return $ shareAccess ;
1282+ }
1283+ return max ($ shareAccess , $ proxyOverlay );
1284+ }
1285+
1286+ /**
1287+ * @param array<int,array<string,mixed>> $result keyed by object id.
1288+ * @param int|string $id the candidate row id.
1289+ * @param int|null $candidateAccess effective access of the candidate row.
1290+ * Owned/no-overlay rows pass null and always win over null entries.
1291+ */
1292+ private function resultHasMorePermissiveEntry (array $ result , int |string $ id , ?int $ candidateAccess ): bool {
1293+ $ existing = $ result [$ id ] ?? null ;
1294+ if ($ existing === null ) {
1295+ return false ;
1296+ }
1297+ $ existingAccess = $ existing ['shared_access ' ] ?? null ;
1298+ if ($ existingAccess === null ) {
1299+ // Owned-by-accessor (no overlay) is the most permissive; keep it.
1300+ return true ;
1301+ }
1302+ if ($ candidateAccess === null ) {
1303+ // Candidate is owned-by-accessor; replace.
1304+ return false ;
1305+ }
1306+ return $ existingAccess <= $ candidateAccess ;
1307+ }
1308+
1309+ /**
1310+ * Return the principals (users) for whom $principalUri acts as a calendar
1311+ * proxy. The value is true for proxy-write, false for proxy-read.
1312+ *
1313+ * @return array<string,bool> map of delegator-principal => has-write-proxy
1314+ */
1315+ private function getProxyDelegators (string $ principalUri ): array {
1316+ $ memberships = $ this ->principalBackend ->getGroupMembership ($ principalUri , true );
1317+ $ delegators = [];
1318+ foreach ($ memberships as $ membership ) {
1319+ if (str_ends_with ($ membership , '/calendar-proxy-write ' )) {
1320+ $ delegator = substr ($ membership , 0 , -strlen ('/calendar-proxy-write ' ));
1321+ $ delegators [$ delegator ] = true ;
1322+ } elseif (str_ends_with ($ membership , '/calendar-proxy-read ' )) {
1323+ $ delegator = substr ($ membership , 0 , -strlen ('/calendar-proxy-read ' ));
1324+ $ delegators [$ delegator ] ??= false ;
1325+ }
1326+ }
1327+ return $ delegators ;
1328+ }
1329+
1330+ private function rowToDeletedCalendarObject (array $ row , string $ calendarUri , bool $ includeData = false , ?int $ sharedAccess = null , ?string $ delegator = null ): array {
1331+ $ deletedAt = isset ($ row ['deleted_at ' ]) ? (int )$ row ['deleted_at ' ] : null ;
1332+ $ result = [
1333+ 'id ' => $ row ['id ' ],
1334+ 'uri ' => $ row ['uri ' ],
1335+ 'lastmodified ' => $ row ['lastmodified ' ],
1336+ 'etag ' => '" ' . $ row ['etag ' ] . '" ' ,
1337+ 'calendarid ' => $ row ['calendarid ' ],
1338+ 'calendaruri ' => $ calendarUri ,
1339+ 'sourcecalendaruri ' => $ row ['calendaruri ' ],
1340+ 'calendarprincipaluri ' => $ row ['calendarprincipaluri ' ],
1341+ 'size ' => (int )$ row ['size ' ],
1342+ 'component ' => strtolower ($ row ['componenttype ' ]),
1343+ 'classification ' => (int )$ row ['classification ' ],
1344+ 'deleted_at ' => $ deletedAt ,
1345+ 'delegator ' => $ delegator ,
1346+ '{ ' . \OCA \DAV \DAV \Sharing \Plugin::NS_NEXTCLOUD . '}deleted-at ' => $ deletedAt ,
1347+ ];
1348+ if ($ sharedAccess !== null ) {
1349+ $ result ['shared_access ' ] = $ sharedAccess ;
1350+ }
1351+ if ($ includeData ) {
1352+ $ result ['calendardata ' ] = $ this ->readBlob ($ row ['calendardata ' ]);
1353+ }
12281354 return $ result ;
12291355 }
12301356
1357+ /**
1358+ * Resolve a principal URI into its converted form and all group/circle memberships.
1359+ *
1360+ * @return array{string, string[]} [$convertedUri, $allPrincipals]
1361+ */
1362+ private function resolvePrincipal (string $ principalUri ): array {
1363+ $ principals = $ this ->principalBackend ->getGroupMembership ($ principalUri , true );
1364+ $ principals = array_merge ($ principals , $ this ->principalBackend ->getCircleMembership ($ principalUri ));
1365+ $ converted = $ this ->convertPrincipal ($ principalUri , true );
1366+ $ principals [] = $ converted ;
1367+ return [$ converted , $ principals ];
1368+ }
1369+
1370+ /**
1371+ * Add joins and WHERE conditions to $query to restrict results to calendars
1372+ * shared with any of $principals, excluding calendars explicitly unshared and
1373+ * calendars owned by $principalUri (already covered by the owned query).
1374+ */
1375+ private function applySharedCalendarFilters (IQueryBuilder $ query , array $ principals , string $ principalUri ): void {
1376+ $ subSelect = $ this ->db ->getQueryBuilder ();
1377+ $ subSelect ->select ('resourceid ' )
1378+ ->from ('dav_shares ' , 'd ' )
1379+ ->where ($ subSelect ->expr ()->eq ('d.access ' , $ query ->createNamedParameter (Backend::ACCESS_UNSHARED , IQueryBuilder::PARAM_INT ), IQueryBuilder::PARAM_INT ))
1380+ ->andWhere ($ subSelect ->expr ()->in ('d.principaluri ' , $ query ->createNamedParameter ($ principals , IQueryBuilder::PARAM_STR_ARRAY ), IQueryBuilder::PARAM_STR_ARRAY ));
1381+
1382+ $ query ->join ('c ' , 'dav_shares ' , 's ' , $ query ->expr ()->eq ('s.resourceid ' , 'c.id ' , IQueryBuilder::PARAM_INT ))
1383+ ->andWhere ($ query ->expr ()->in ('s.principaluri ' , $ query ->createNamedParameter ($ principals , IQueryBuilder::PARAM_STR_ARRAY ), IQueryBuilder::PARAM_STR_ARRAY ))
1384+ ->andWhere ($ query ->expr ()->eq ('s.type ' , $ query ->createNamedParameter ('calendar ' , IQueryBuilder::PARAM_STR ), IQueryBuilder::PARAM_STR ))
1385+ ->andWhere ($ query ->expr ()->neq ('c.principaluri ' , $ query ->createNamedParameter ($ principalUri , IQueryBuilder::PARAM_STR )))
1386+ ->andWhere ($ query ->expr ()->notIn ('c.id ' , $ query ->createFunction ($ subSelect ->getSQL ()), IQueryBuilder::PARAM_INT_ARRAY ));
1387+ }
1388+
12311389 /**
12321390 * Returns information from a single calendar object, based on it's object
12331391 * uri.
@@ -2529,6 +2687,97 @@ public function getCalendarObjectById(string $principalUri, int $id): ?array {
25292687 ];
25302688 }
25312689
2690+ /**
2691+ * Return a deleted calendar object by its ID, accessible to $principalUri
2692+ * via ownership, sharing, or proxy delegation. Returns the sharee-facing URI
2693+ * for shared/delegated entries.
2694+ *
2695+ * @param int $id
2696+ * @param string $principalUri
2697+ * @return array|null
2698+ */
2699+ public function getDeletedCalendarObjectByIdForPrincipal (int $ id , string $ principalUri ): ?array {
2700+ // Visit every accessible path (self + delegators) and keep the most
2701+ // permissive row, so canModify() doesn't get a read-only view when a
2702+ // write path also exists.
2703+ $ candidates = [];
2704+ $ row = $ this ->findDeletedCalendarObjectForPrincipal ($ id , $ principalUri , null );
2705+ if ($ row !== null ) {
2706+ $ candidates [$ id ] = $ row ;
2707+ }
2708+ foreach ($ this ->getProxyDelegators ($ principalUri ) as $ delegator => $ hasProxyWrite ) {
2709+ $ overlay = $ hasProxyWrite ? Backend::ACCESS_READ_WRITE : Backend::ACCESS_READ ;
2710+ $ row = $ this ->findDeletedCalendarObjectForPrincipal ($ id , $ delegator , $ overlay );
2711+ if ($ row === null ) {
2712+ continue ;
2713+ }
2714+ if ($ this ->resultHasMorePermissiveEntry ($ candidates , $ id , $ row ['shared_access ' ] ?? null )) {
2715+ continue ;
2716+ }
2717+ $ candidates [$ id ] = $ row ;
2718+ }
2719+ return $ candidates [$ id ] ?? null ;
2720+ }
2721+
2722+ /**
2723+ * Look up a single deleted calendar object by id for $principalUri.
2724+ *
2725+ * @param int $id
2726+ * @param string $principalUri
2727+ * @param int|null $proxyOverlay see collectDeletedCalendarObjectsForPrincipal.
2728+ * @return array|null
2729+ */
2730+ private function findDeletedCalendarObjectForPrincipal (int $ id , string $ principalUri , ?int $ proxyOverlay ): ?array {
2731+ [$ principalUri , $ principals ] = $ this ->resolvePrincipal ($ principalUri );
2732+
2733+ // Check owned calendars first
2734+ $ query = $ this ->db ->getQueryBuilder ();
2735+ $ query ->select (['co.id ' , 'co.uri ' , 'co.lastmodified ' , 'co.etag ' , 'co.calendarid ' , 'co.size ' , 'co.calendardata ' , 'co.componenttype ' , 'co.classification ' , 'co.deleted_at ' ])
2736+ ->selectAlias ('c.uri ' , 'calendaruri ' )
2737+ ->selectAlias ('c.principaluri ' , 'calendarprincipaluri ' )
2738+ ->from ('calendarobjects ' , 'co ' )
2739+ ->join ('co ' , 'calendars ' , 'c ' , $ query ->expr ()->eq ('c.id ' , 'co.calendarid ' , IQueryBuilder::PARAM_INT ))
2740+ ->where ($ query ->expr ()->eq ('co.id ' , $ query ->createNamedParameter ($ id , IQueryBuilder::PARAM_INT ), IQueryBuilder::PARAM_INT ))
2741+ ->andWhere ($ query ->expr ()->eq ('c.principaluri ' , $ query ->createNamedParameter ($ principalUri )))
2742+ ->andWhere ($ query ->expr ()->isNotNull ('co.deleted_at ' ));
2743+ $ stmt = $ query ->executeQuery ();
2744+ $ row = $ stmt ->fetchAssociative ();
2745+ $ stmt ->closeCursor ();
2746+
2747+ if ($ row ) {
2748+ [, $ ownerName ] = Uri \split ($ row ['calendarprincipaluri ' ]);
2749+ $ isDelegated = $ proxyOverlay !== null ;
2750+ $ calendarUri = $ isDelegated ? $ row ['calendaruri ' ] . '_delegated_by_ ' . $ ownerName : $ row ['calendaruri ' ];
2751+ return $ this ->rowToDeletedCalendarObject ($ row , $ calendarUri , true , $ proxyOverlay , $ isDelegated ? $ principalUri : null );
2752+ }
2753+
2754+ // Check shared calendars; order by access ASC so the most permissive
2755+ // row wins when the principal matches multiple share entries.
2756+ $ select = $ this ->db ->getQueryBuilder ();
2757+ $ select ->select (['co.id ' , 'co.uri ' , 'co.lastmodified ' , 'co.etag ' , 'co.calendarid ' , 'co.size ' , 'co.calendardata ' , 'co.componenttype ' , 'co.classification ' , 'co.deleted_at ' ])
2758+ ->selectAlias ('c.uri ' , 'calendaruri ' )
2759+ ->selectAlias ('c.principaluri ' , 'calendarprincipaluri ' )
2760+ ->selectAlias ('s.access ' , 'shareaccess ' )
2761+ ->from ('calendarobjects ' , 'co ' )
2762+ ->join ('co ' , 'calendars ' , 'c ' , $ select ->expr ()->eq ('c.id ' , 'co.calendarid ' , IQueryBuilder::PARAM_INT ))
2763+ ->andWhere ($ select ->expr ()->eq ('co.id ' , $ select ->createNamedParameter ($ id , IQueryBuilder::PARAM_INT ), IQueryBuilder::PARAM_INT ))
2764+ ->andWhere ($ select ->expr ()->isNotNull ('co.deleted_at ' ))
2765+ ->orderBy ('s.access ' , 'ASC ' );
2766+ $ this ->applySharedCalendarFilters ($ select , $ principals , $ principalUri );
2767+
2768+ $ stmt = $ select ->executeQuery ();
2769+ $ row = $ stmt ->fetchAssociative ();
2770+ $ stmt ->closeCursor ();
2771+
2772+ if (!$ row ) {
2773+ return null ;
2774+ }
2775+
2776+ $ effective = $ this ->effectiveAccess ((int )$ row ['shareaccess ' ], $ proxyOverlay );
2777+ [, $ ownerName ] = Uri \split ($ row ['calendarprincipaluri ' ]);
2778+ return $ this ->rowToDeletedCalendarObject ($ row , $ row ['calendaruri ' ] . '_shared_by_ ' . $ ownerName , true , $ effective , null );
2779+ }
2780+
25322781 /**
25332782 * The getChanges method returns all the changes that have happened, since
25342783 * the specified syncToken in the specified calendar.
0 commit comments