From 3eb517a92f29e6b5e5065099cb78a59e4e5c2a94 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Thu, 12 Mar 2026 01:37:33 +0800 Subject: [PATCH] feat(ciphers): add bulk restore and permanent delete functionality for ciphers style: enhance list count display in VaultPage and styles fix(i18n): add translations for bulk restore and permanent delete messages --- src/handlers/ciphers.ts | 55 ++++++++ src/router.ts | 10 ++ src/services/storage.ts | 114 ++++++++++++--- webapp/src/App.tsx | 26 ++++ webapp/src/components/VaultPage.tsx | 43 +++++- webapp/src/lib/api.ts | 211 +++++++++++++++++++++------- webapp/src/lib/i18n.ts | 17 +++ webapp/src/styles.css | 13 ++ 8 files changed, 415 insertions(+), 74 deletions(-) diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index b871b38..b7d380d 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -541,3 +541,58 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId return new Response(null, { status: 204 }); } + +// POST /api/ciphers/restore - Bulk restore +export async function handleBulkRestoreCiphers(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + + let body: { ids?: string[] }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (!body.ids || !Array.isArray(body.ids)) { + return errorResponse('ids array is required', 400); + } + + const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId); + if (revisionDate) { + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + } + + return new Response(null, { status: 204 }); +} + +// POST /api/ciphers/delete-permanent - Bulk permanent delete +export async function handleBulkPermanentDeleteCiphers(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + + let body: { ids?: string[] }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (!body.ids || !Array.isArray(body.ids)) { + return errorResponse('ids array is required', 400); + } + + const ids = Array.from(new Set(body.ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!ids.length) { + return new Response(null, { status: 204 }); + } + + for (const id of ids) { + await deleteAllAttachmentsForCipher(env, id); + } + + const revisionDate = await storage.bulkDeleteCiphers(ids, userId); + if (revisionDate) { + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + } + + return new Response(null, { status: 204 }); +} diff --git a/src/router.ts b/src/router.ts index 486cb36..6e21218 100644 --- a/src/router.ts +++ b/src/router.ts @@ -35,6 +35,8 @@ import { handlePartialUpdateCipher, handleBulkMoveCiphers, handleBulkDeleteCiphers, + handleBulkPermanentDeleteCiphers, + handleBulkRestoreCiphers, } from './handlers/ciphers'; // Folder handlers @@ -607,6 +609,14 @@ export async function handleRequest(request: Request, env: Env): Promise v === undefined ? null : v)); } + private sqlChunkSize(fixedBindCount: number): number { + return Math.max( + 1, + Math.min(LIMITS.performance.bulkMoveChunkSize, StorageService.MAX_D1_SQL_VARIABLES - fixedBindCount) + ); + } + private async sha256Hex(input: string): Promise { const bytes = new TextEncoder().encode(input); const digest = await crypto.subtle.digest('SHA-256', bytes); @@ -479,7 +487,7 @@ export class StorageService { deletedAt: now, updatedAt: now, }); - const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); + const chunkSize = this.sqlChunkSize(4); for (let i = 0; i < uniqueIds.length; i += chunkSize) { const chunk = uniqueIds.slice(i, i + chunkSize); @@ -497,6 +505,53 @@ export class StorageService { return this.updateRevisionDate(userId); } + async bulkRestoreCiphers(ids: string[], userId: string): Promise { + if (ids.length === 0) return null; + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return null; + + const now = new Date().toISOString(); + const patch = JSON.stringify({ + deletedAt: null, + updatedAt: now, + }); + const chunkSize = this.sqlChunkSize(3); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await this.db + .prepare( + `UPDATE ciphers + SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?) + WHERE user_id = ? AND id IN (${placeholders})` + ) + .bind(now, patch, userId, ...chunk) + .run(); + } + + return this.updateRevisionDate(userId); + } + + async bulkDeleteCiphers(ids: string[], userId: string): Promise { + if (ids.length === 0) return null; + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return null; + + const chunkSize = this.sqlChunkSize(1); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await this.db + .prepare(`DELETE FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`) + .bind(userId, ...chunk) + .run(); + } + + return this.updateRevisionDate(userId); + } + async getAllCiphers(userId: string): Promise { const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>(); return (res.results || []).flatMap(r => { @@ -523,13 +578,22 @@ export class StorageService { async getCiphersByIds(ids: string[], userId: string): Promise { if (ids.length === 0) return []; - // D1 doesn't support binding arrays directly; build placeholders. - const placeholders = ids.map(() => '?').join(','); - const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`); - const res = await stmt.bind(userId, ...ids).all<{ data: string }>(); - return (res.results || []).flatMap(r => { - try { return [JSON.parse(r.data) as Cipher]; } catch { return []; } - }); + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return []; + const chunkSize = this.sqlChunkSize(1); + const out: Cipher[] = []; + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`); + const res = await stmt.bind(userId, ...chunk).all<{ data: string }>(); + out.push( + ...(res.results || []).flatMap(r => { + try { return [JSON.parse(r.data) as Cipher]; } catch { return []; } + }) + ); + } + return out; } async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise { @@ -540,7 +604,7 @@ export class StorageService { folderId, updatedAt: now, }); - const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); + const chunkSize = this.sqlChunkSize(4); for (let i = 0; i < uniqueIds.length; i += chunkSize) { const chunk = uniqueIds.slice(i, i + chunkSize); @@ -594,7 +658,7 @@ export class StorageService { const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); if (!uniqueIds.length) return null; - const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); + const chunkSize = this.sqlChunkSize(1); const now = new Date().toISOString(); for (let i = 0; i < uniqueIds.length; i += chunkSize) { @@ -728,7 +792,7 @@ export class StorageService { if (cipherIds.length === 0) return grouped; const uniqueCipherIds = [...new Set(cipherIds)]; - const chunkSize = LIMITS.performance.bulkMoveChunkSize; + const chunkSize = this.sqlChunkSize(0); for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) { const chunk = uniqueCipherIds.slice(i, i + chunkSize); @@ -996,23 +1060,29 @@ export class StorageService { if (ids.length === 0) return []; const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); if (!uniqueIds.length) return []; - const placeholders = uniqueIds.map(() => '?').join(','); - const res = await this.db - .prepare( - `SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date - FROM sends - WHERE user_id = ? AND id IN (${placeholders})` - ) - .bind(userId, ...uniqueIds) - .all(); - return (res.results || []).map((row) => this.mapSendRow(row)); + const chunkSize = this.sqlChunkSize(1); + const out: Send[] = []; + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + const res = await this.db + .prepare( + `SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date + FROM sends + WHERE user_id = ? AND id IN (${placeholders})` + ) + .bind(userId, ...chunk) + .all(); + out.push(...(res.results || []).map((row) => this.mapSendRow(row))); + } + return out; } async bulkDeleteSends(ids: string[], userId: string): Promise { if (ids.length === 0) return null; const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); if (!uniqueIds.length) return null; - const chunkSize = Math.min(LIMITS.performance.bulkMoveChunkSize, 90); + const chunkSize = this.sqlChunkSize(1); for (let i = 0; i < uniqueIds.length; i += chunkSize) { const chunk = uniqueIds.slice(i, i + chunkSize); diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index f173912..d728fa0 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -26,6 +26,8 @@ import { deleteCipherAttachment, deleteFolder, bulkDeleteCiphers, + bulkPermanentDeleteCiphers, + bulkRestoreCiphers, bulkDeleteSends, createCipher, createAuthedFetch, @@ -1345,6 +1347,28 @@ export default function App() { } } + async function bulkRestoreVaultItems(ids: string[]) { + try { + await bulkRestoreCiphers(authedFetch, ids); + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + pushToast('success', t('txt_restored_selected_items')); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed')); + throw error; + } + } + + async function bulkPermanentDeleteVaultItems(ids: string[]) { + try { + await bulkPermanentDeleteCiphers(authedFetch, ids); + await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); + pushToast('success', t('txt_deleted_selected_items_permanently')); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed')); + throw error; + } + } + async function bulkDeleteFoldersAction(ids: string[]) { const folderIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); if (!folderIds.length) return; @@ -2056,6 +2080,8 @@ export default function App() { onUpdate={updateVaultItem} onDelete={deleteVaultItem} onBulkDelete={bulkDeleteVaultItems} + onBulkPermanentDelete={bulkPermanentDeleteVaultItems} + onBulkRestore={bulkRestoreVaultItems} onBulkMove={bulkMoveVaultItems} onVerifyMasterPassword={verifyMasterPasswordAction} onNotify={pushToast} diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 0ef0c78..27ef9c7 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -46,6 +46,8 @@ interface VaultPageProps { onUpdate: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise; onDelete: (cipher: Cipher) => Promise; onBulkDelete: (ids: string[]) => Promise; + onBulkPermanentDelete: (ids: string[]) => Promise; + onBulkRestore: (ids: string[]) => Promise; onBulkMove: (ids: string[], folderId: string | null) => Promise; onVerifyMasterPassword: (email: string, password: string) => Promise; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; @@ -679,6 +681,7 @@ export default function VaultPage(props: VaultPageProps) { () => Object.values(selectedMap).reduce((sum, v) => sum + (v ? 1 : 0), 0), [selectedMap] ); + const totalCipherCount = filteredCiphers.length; function folderName(id: string | null | undefined): string { if (!id) return t('txt_no_folder'); @@ -877,7 +880,11 @@ function folderName(id: string | null | undefined): string { if (!ids.length) return; setBusy(true); try { - await props.onBulkDelete(ids); + if (sidebarFilter.kind === 'trash') { + await props.onBulkPermanentDelete(ids); + } else { + await props.onBulkDelete(ids); + } setSelectedMap({}); setBulkDeleteOpen(false); } finally { @@ -958,6 +965,20 @@ function folderName(id: string | null | undefined): string { } } + async function confirmBulkRestore(): Promise { + const ids = Object.entries(selectedMap) + .filter(([, selected]) => selected) + .map(([id]) => id); + if (!ids.length) return; + setBusy(true); + try { + await props.onBulkRestore(ids); + setSelectedMap({}); + } finally { + setBusy(false); + } + } + async function confirmDeleteAllFolders(): Promise { if (!props.folders.length) return; setBusy(true); @@ -1111,13 +1132,16 @@ function folderName(id: string | null | undefined): string { )} +
+ {t('txt_total_items_count', { count: totalCipherCount })} +
- {selectedCount > 0 && ( + {selectedCount > 0 && sidebarFilter.kind === 'trash' && ( + + )} + {selectedCount > 0 && sidebarFilter.kind !== 'trash' && (