@@ -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.
0 commit comments