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