Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/BackgroundJobs/EmptyTrashbinJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions lib/Controller/FoldersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => []]);
}
}
6 changes: 6 additions & 0 deletions lib/Controller/InternalFoldersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
8 changes: 6 additions & 2 deletions lib/Db/BookmarkMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
66 changes: 64 additions & 2 deletions lib/Db/TreeMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand All @@ -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]);
}
}
}
}
2 changes: 1 addition & 1 deletion src/components/BookmarksList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
5 changes: 5 additions & 0 deletions src/components/LoadingModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
</template>
<script>
import { NcModal } from '@nextcloud/vue'
import { privateRoutes } from '../router'

Check failure on line 10 in src/components/LoadingModal.vue

View workflow job for this annotation

GitHub Actions / eslint node16.x

"../router" is not found

Check warning on line 10 in src/components/LoadingModal.vue

View workflow job for this annotation

GitHub Actions / eslint node16.x

Missing file extension "js" for "../router"

export default {
name: 'LoadingModal',
Expand All @@ -22,6 +23,7 @@
moveSelection: this.t('bookmkarks', 'Moving selection'),
copySelection: this.t('bookmkarks', 'Adding selection to folders'),
emptyTrashbin: this.t('bookmkarks', 'Emptying trashbin'),
deleted_folders: this.t('bookmkarks', 'Loading trashbin'),
},
showNcModal: false,
showTimeout: null,
Expand All @@ -42,6 +44,9 @@
},
watch: {
state(newState, previous) {
if (this.state === 'deleted_folders' && this.$route.name !== privateRoutes.TRASHBIN) {
return
}
if (this.state && !previous) {
this.showTimeout = setTimeout(() => {
this.showNcModal = true
Expand Down
44 changes: 17 additions & 27 deletions src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -799,6 +799,9 @@
},

async [actions.LOAD_DELETED_FOLDERS]({ commit, dispatch, state }) {
if (state.loading.deleted_folders) {
return;

Check failure on line 803 in src/store/actions.js

View workflow job for this annotation

GitHub Actions / eslint node16.x

Extra semicolon
}
if (state.deletedFolders === null) {
try {
const count = loadState('bookmarks', 'deletedFolders')
Expand Down Expand Up @@ -826,11 +829,11 @@
} = response
if (status !== 'success') throw new Error(data)
const folders = data
commit(mutations.FETCH_END, 'folders')
commit(mutations.FETCH_END, 'deleted_folders');

Check failure on line 832 in src/store/actions.js

View workflow job for this annotation

GitHub Actions / eslint node16.x

Extra semicolon
return commit(mutations.SET_DELETED_FOLDERS, folders)
} catch (err) {
console.error(err)
commit(mutations.FETCH_END, 'folders')
commit(mutations.FETCH_END, 'deleted_folders');

Check failure on line 836 in src/store/actions.js

View workflow job for this annotation

GitHub Actions / eslint node16.x

Extra semicolon
commit(
mutations.SET_ERROR,
AppGlobal.methods.t('bookmarks', 'Failed to load deleted folders'),
Expand Down Expand Up @@ -1656,35 +1659,22 @@
},
async [actions.EMPTY_TRASHBIN]({ commit, dispatch, state }) {
if (state.loading.emptyTrashbin) return
let canceled = false
await commit(mutations.FETCH_START, {
type: 'emptyTrashbin',
cancel() {
canceled = true
}

Check warning on line 1667 in src/store/actions.js

View workflow job for this annotation

GitHub Actions / eslint node16.x

Missing trailing comma
})
try {
await Parallel.each(
state.deletedFolders,
folder =>
dispatch(actions.DELETE_FOLDER, {
id: folder.id,
avoidReload: true,
hard: true,
}),
10,
)
await Parallel.each(
state.bookmarks,
(bookmark) => {
// soft delete all occurences instead of hard deleting the bookmark itself
return Promise.all(bookmark.folders.map((folder) => {
return dispatch(actions.DELETE_BOOKMARK, {
id: bookmark.id,
folder,
avoidReload: true,
hard: true,
})
}))
},
10,
)
const response = await axios.delete(url(state, '/folder/deleted'), {
params: {},
})
if (canceled) return
const {
data: { data, status },
} = response
if (status !== 'success') throw new Error(data)
dispatch(actions.RELOAD_VIEW)
dispatch(actions.LOAD_DELETED_FOLDERS)
commit(mutations.FETCH_END, 'emptyTrashbin')
Expand All @@ -1704,8 +1694,8 @@
}

/**
* @param state

Check warning on line 1697 in src/store/actions.js

View workflow job for this annotation

GitHub Actions / eslint node16.x

Missing JSDoc @param "state" type

Check warning on line 1697 in src/store/actions.js

View workflow job for this annotation

GitHub Actions / eslint node16.x

Missing JSDoc @param "state" description
* @param url

Check warning on line 1698 in src/store/actions.js

View workflow job for this annotation

GitHub Actions / eslint node16.x

Missing JSDoc @param "url" type

Check warning on line 1698 in src/store/actions.js

View workflow job for this annotation

GitHub Actions / eslint node16.x

Missing JSDoc @param "url" description
*/
function url(state, url) {
if (state.public) {
Expand Down
Loading