@@ -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