diff --git a/lib/BackgroundJobs/EmptyTrashbinJob.php b/lib/BackgroundJobs/EmptyTrashbinJob.php index ed3e4ae059..37e15462bb 100644 --- a/lib/BackgroundJobs/EmptyTrashbinJob.php +++ b/lib/BackgroundJobs/EmptyTrashbinJob.php @@ -13,7 +13,7 @@ use OCP\BackgroundJob\TimedJob; class EmptyTrashbinJob extends TimedJob { - public const BATCH_SIZE = 1000; // 40 items + public const BATCH_SIZE = 1000; public const INTERVAL = 5 * 60; // 5 minutes public const TRASHBIN_TTL = 2 * 4 * 4 * 7 * 24 * 60 * 60; // Two months diff --git a/lib/Controller/FoldersController.php b/lib/Controller/FoldersController.php index feb96fba4e..9df30ba7e3 100644 --- a/lib/Controller/FoldersController.php +++ b/lib/Controller/FoldersController.php @@ -850,4 +850,24 @@ public function getDeletedFolders(): DataResponse { return new Http\DataResponse(['status' => 'success', 'data' => $folderItems]); } + + /** + * @throws UnauthenticatedError + */ + #[Http\Attribute\NoAdminRequired] + #[Http\Attribute\NoCSRFRequired] + #[Http\Attribute\PublicPage] + #[Http\Attribute\BruteForceProtection(action: 'removeDeletedFolders')] + #[Http\Attribute\FrontpageRoute(verb: 'DELETE', url: '/public/rest/v2/folder/deleted')] + public function removeDeletedFolders(): DataResponse { + $this->authorizer->setCredentials($this->request); + if ($this->authorizer->getUserId() === null) { + $res = new Http\DataResponse(['status' => 'error', 'data' => ['Unauthorized']], Http::STATUS_FORBIDDEN); + $res->throttle(); + return $res; + } + $this->treeMapper->deleteTrashbinItemsForUser($this->authorizer->getUserId()); + + return new Http\DataResponse(['status' => 'success', 'data' => []]); + } } diff --git a/lib/Controller/InternalFoldersController.php b/lib/Controller/InternalFoldersController.php index 8b0a985163..3f6e267a64 100644 --- a/lib/Controller/InternalFoldersController.php +++ b/lib/Controller/InternalFoldersController.php @@ -156,4 +156,10 @@ public function deleteShare(int $shareId): DataResponse { public function getDeletedFolders(): DataResponse { return $this->controller->getDeletedFolders(); } + + #[NoAdminRequired] + #[FrontpageRoute(verb: 'DELETE', url: '/folder/deleted')] + public function removeDeletedFolders(): DataResponse { + return $this->controller->removeDeletedFolders(); + } } diff --git a/lib/Db/BookmarkMapper.php b/lib/Db/BookmarkMapper.php index bbeae161c8..3928544075 100644 --- a/lib/Db/BookmarkMapper.php +++ b/lib/Db/BookmarkMapper.php @@ -311,7 +311,10 @@ public function generateCTE(int $folderId, bool $withSoftDeleted) : array { ->from('*PREFIX*bookmarks_tree', 'tr') ->join('tr', $this->getDbType() === 'mysql'? 'folder_tree' : 'inner_folder_tree', 'e', 'e.item_id = tr.parent_folder AND e.type = ' . $recursiveCase->createPositionalParameter(TreeMapper::TYPE_FOLDER) . (!$withSoftDeleted ? ' AND e.soft_deleted_at is NULL' : '')); - // The second recursive case lists all children of shared folders we've already found + // The second recursive case lists all children of shared folders we've already found. + // We also require the share's underlying original folder to not be soft-deleted by its + // owner: when the sharer trashes the original folder, sharees must not see anything + // inside it — not even in their own trash bin. $recursiveCaseShares = $this->db->getQueryBuilder(); $recursiveCaseShares->automaticTablePrefix(false); $recursiveCaseShares @@ -321,7 +324,8 @@ public function generateCTE(int $folderId, bool $withSoftDeleted) : array { ->selectAlias('e.idx', 'idx') ->selectAlias('e.soft_deleted_at', 'soft_deleted_at') ->from(($this->getDbType() === 'mysql'? 'folder_tree' : 'second_folder_tree'), 'e') - ->join('e', '*PREFIX*bookmarks_shared_folders', 's', 's.id = e.item_id AND e.type = ' . $recursiveCaseShares->createPositionalParameter(TreeMapper::TYPE_SHARE) . (!$withSoftDeleted ? ' AND e.soft_deleted_at is NULL' : '')); + ->join('e', '*PREFIX*bookmarks_shared_folders', 's', 's.id = e.item_id AND e.type = ' . $recursiveCaseShares->createPositionalParameter(TreeMapper::TYPE_SHARE) . (!$withSoftDeleted ? ' AND e.soft_deleted_at is NULL' : '')) + ->join('s', '*PREFIX*bookmarks_tree', 'sof', 'sof.id = s.folder_id AND sof.type = ' . $recursiveCaseShares->createPositionalParameter(TreeMapper::TYPE_FOLDER) . ' AND sof.soft_deleted_at is NULL'); if ($this->getDbType() === 'mysql') { // For mysql we can just throw these three queries together in a CTE diff --git a/lib/Db/TreeMapper.php b/lib/Db/TreeMapper.php index 1a42bc1baa..7c332e1eea 100644 --- a/lib/Db/TreeMapper.php +++ b/lib/Db/TreeMapper.php @@ -238,6 +238,9 @@ protected function getFindChildrenQuery(string $type): IQueryBuilder { ->andWhere($qb->expr()->eq('t.type', $qb->createNamedParameter($type))) ->andWhere($qb->expr()->isNull('t.soft_deleted_at')) ->orderBy('t.index', 'ASC'); + if ($type === TreeMapper::TYPE_SHARE) { + $this->joinOriginalFolderNotSoftDeleted($qb); + } return $qb; } @@ -249,9 +252,27 @@ protected function getFindSoftDeletedChildrenQuery(string $type): IQueryBuilder ->andWhere($qb->expr()->eq('t.type', $qb->createNamedParameter($type))) ->andWhere($qb->expr()->isNotNull('t.soft_deleted_at')) ->orderBy('t.index', 'ASC'); + if ($type === TreeMapper::TYPE_SHARE) { + $this->joinOriginalFolderNotSoftDeleted($qb); + } return $qb; } + /** + * Restricts a TYPE_SHARE query to shared folders whose underlying original + * folder has not been soft-deleted by its owner. When the sharer trashes the + * original folder, the share must disappear for sharees entirely — including + * from their trash bin, since they cannot restore someone else's folder. + */ + private function joinOriginalFolderNotSoftDeleted(IQueryBuilder $qb): void { + $qb + ->innerJoin('i', 'bookmarks_tree', 'ot', $qb->expr()->andX( + $qb->expr()->eq('ot.id', 'i.folder_id'), + $qb->expr()->eq('ot.type', $qb->createNamedParameter(TreeMapper::TYPE_FOLDER)) + )) + ->andWhere($qb->expr()->isNull('ot.soft_deleted_at')); + } + /** * @param string $type * @psalm-param T $type @@ -1006,6 +1027,9 @@ public function getSoftDeletedRootItems(string $userId, string $type): array { $qb->expr()->isNull('t2.soft_deleted_at'), $qb->expr()->isNotNull('r.folder_id'), )); + if ($type === TreeMapper::TYPE_SHARE) { + $this->joinOriginalFolderNotSoftDeleted($qb); + } return $this->findEntitiesWithType($qb, $type); } if ($type === TreeMapper::TYPE_BOOKMARK) { @@ -1264,7 +1288,7 @@ public function deleteOldTrashbinItems(int $limit, float|int $maxAge): void { $qb->where($qb->expr()->neq('type', $qb->createNamedParameter(TreeMapper::TYPE_SHARE, IQueryBuilder::PARAM_STR))); $cutoffDate = $this->timeFactory->getDateTime(); $cutoffDate->modify('- ' . ((string)$maxAge) . ' seconds'); - $qb->andWhere($qb->expr()->lt('soft_deleted_at', $qb->createNamedParameter($cutoffDate, IQueryBuilder::PARAM_DATE))); + $qb->andWhere($qb->expr()->lt('soft_deleted_at', $qb->createNamedParameter($cutoffDate, IQueryBuilder::PARAM_DATETIME_MUTABLE))); $qb->setMaxResults($limit); try { $result = $qb->executeQuery(); @@ -1277,9 +1301,47 @@ public function deleteOldTrashbinItems(int $limit, float|int $maxAge): void { $this->deleteEntry($row['type'], $row['id'], $row['parent_folder']); } catch (DoesNotExistException $e) { // noop - } catch (UnsupportedOperation|MultipleObjectsReturnedException $e) { + } catch (UnsupportedOperation|MultipleObjectsReturnedException|Exception $e) { $this->logger->error('Could not delete old trash bin item: ' . var_export($row, true), ['exception' => $e]); } } } + + /** + * @param string $userId + * @return void + */ + public function deleteTrashbinItemsForUser(string $userId): void { + $qb = $this->db->getQueryBuilder(); + $qb->select('t.type', 't.id', 't.parent_folder')->from('bookmarks_tree', 't'); + $qb->where($qb->expr()->neq('type', $qb->createNamedParameter(TreeMapper::TYPE_SHARE, IQueryBuilder::PARAM_STR))); + $qb->andWhere($qb->expr()->isNotNull('soft_deleted_at')); + $qb->leftJoin('t', 'bookmarks', 'b', 't.id = b.id and t.type = ' . $qb->createNamedParameter(TreeMapper::TYPE_BOOKMARK)); + $qb->leftJoin('t', 'bookmarks_folders', 'f', 't.id = f.id and t.type = ' . $qb->createNamedParameter(TreeMapper::TYPE_FOLDER)); + $qb->andWhere($qb->expr()->orX( + $qb->expr()->andX( + $qb->expr()->eq('t.type', $qb->createNamedParameter(TreeMapper::TYPE_BOOKMARK)), + $qb->expr()->eq('b.user_id', $qb->createNamedParameter($userId)), + ), + $qb->expr()->andX( + $qb->expr()->eq('t.type', $qb->createNamedParameter(TreeMapper::TYPE_FOLDER)), + $qb->expr()->eq('f.user_id', $qb->createNamedParameter($userId)), + ) + )); + try { + $result = $qb->executeQuery(); + } catch (Exception $e) { + $this->logger->error('Could not query for trash bin items of user ' . $userId, ['exception' => $e]); + return; + } + while ($row = $result->fetch()) { + try { + $this->deleteEntry($row['type'], $row['id'], $row['parent_folder']); + } catch (DoesNotExistException $e) { + // noop + } catch (UnsupportedOperation|MultipleObjectsReturnedException|Exception $e) { + $this->logger->error('Could not delete trash bin item: ' . var_export($row, true), ['exception' => $e]); + } + } + } } diff --git a/src/components/BookmarksList.vue b/src/components/BookmarksList.vue index 9d32212da0..2668b415d8 100644 --- a/src/components/BookmarksList.vue +++ b/src/components/BookmarksList.vue @@ -209,7 +209,7 @@ export default { return this.$store.state.settings.sorting }, loading() { - return this.$store.state.loading.bookmarks || this.$store.state.loading.folders + return this.$store.state.loading.bookmarks || this.$store.state.loading.folders || (this.$route.name === privateRoutes.TRASHBIN && this.$store.state.loading.deleted_folders) }, }, methods: { diff --git a/src/components/LoadingModal.vue b/src/components/LoadingModal.vue index 95de4bf44a..a2133d5dbe 100644 --- a/src/components/LoadingModal.vue +++ b/src/components/LoadingModal.vue @@ -7,6 +7,7 @@