From 045b23fc47195204c9230ba40b1357b824c0f2ba Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 21 Jun 2026 18:16:44 +0800 Subject: [PATCH] Align web vault updates with resource sync --- src/handlers/sends-private.ts | 1 + webapp/src/App.tsx | 118 +++++++++---- webapp/src/hooks/useVaultSendActions.ts | 214 +++++++++++++++++------- webapp/src/lib/api/vault-sync.ts | 23 +++ webapp/src/lib/api/vault.ts | 11 +- 5 files changed, 274 insertions(+), 93 deletions(-) diff --git a/src/handlers/sends-private.ts b/src/handlers/sends-private.ts index cf8c8de..15ac586 100644 --- a/src/handlers/sends-private.ts +++ b/src/handlers/sends-private.ts @@ -102,6 +102,7 @@ async function processSendFileUpload( const storage = new StorageService(env.DB); const revisionDate = await storage.updateRevisionDate(send.userId); notifyVaultSyncForRequest(request, env, send.userId, revisionDate); + notifySendUpdateForRequest(request, env, send.id, send.userId, revisionDate); return new Response(null, { status: 201 }); } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 6414617..25bb9be 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -20,6 +20,7 @@ import { saveProfileSnapshot, revokeCurrentSession, getTotpStatus, + getVaultRevisionDate, saveSession, stripProfileSecrets, } from '@/lib/api/auth'; @@ -33,7 +34,7 @@ import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; import { getSendById, getSends } from '@/lib/api/send'; import { getCipherById, getFolderById, repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault'; -import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; +import { getCachedVaultCoreSnapshot, invalidateVaultCoreSyncSnapshot, loadVaultCoreSyncSnapshot, saveVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { parseSignalRTextFrames, @@ -1361,7 +1362,15 @@ export default function App() { return items.filter((item) => String(item.id || '').trim() !== normalizedId); } - function patchVaultCoreSnapshot(updater: (snapshot: VaultCoreSnapshot) => VaultCoreSnapshot): void { + function revisionStampFromIso(value: unknown): number | null { + const stamp = new Date(String(value || '').trim()).getTime(); + return Number.isFinite(stamp) && stamp > 0 ? stamp : null; + } + + function patchVaultCoreSnapshot( + updater: (snapshot: VaultCoreSnapshot) => VaultCoreSnapshot, + options?: { revisionStamp?: number | null } + ): void { if (!vaultCacheKey) return; let nextSnapshot: VaultCoreSnapshot | null = null; queryClient.setQueryData(['vault-core', vaultCacheKey], (previous?: VaultCoreSnapshot) => { @@ -1369,34 +1378,50 @@ export default function App() { nextSnapshot = updater(base); return nextSnapshot; }); - if (nextSnapshot) setCachedVaultCore(nextSnapshot); + if (nextSnapshot) { + setCachedVaultCore(nextSnapshot); + void saveVaultCoreSyncSnapshot(vaultCacheKey, nextSnapshot, options?.revisionStamp ?? null); + } } - function upsertEncryptedCipher(cipher: Cipher): void { + async function refreshVaultCoreRevisionStamp(): Promise { + if (!vaultCacheKey || !session?.accessToken) return; + try { + const revisionStamp = await getVaultRevisionDate(authedFetch); + const currentSnapshot = normalizeVaultCoreSnapshot( + queryClient.getQueryData(['vault-core', vaultCacheKey]) || cachedVaultCore + ); + await saveVaultCoreSyncSnapshot(vaultCacheKey, currentSnapshot, revisionStamp); + } catch { + // A stale revision stamp only affects the next cache validation; the local resource patch remains valid. + } + } + + function upsertEncryptedCipher(cipher: Cipher, revisionStamp?: number | null): void { patchVaultCoreSnapshot((snapshot) => ({ ...snapshot, ciphers: upsertById(snapshot.ciphers, cipher), - })); + }), { revisionStamp: revisionStamp ?? revisionStampFromIso(cipher.revisionDate) }); } - function deleteCipherLocally(cipherId: string): void { + function deleteCipherLocally(cipherId: string, revisionStamp?: number | null): void { const id = String(cipherId || '').trim(); if (!id) return; patchVaultCoreSnapshot((snapshot) => ({ ...snapshot, ciphers: removeById(snapshot.ciphers, id), - })); + }), { revisionStamp }); setDecryptedCiphers((current) => removeById(current, id)); } - function upsertEncryptedFolder(folder: VaultFolder): void { + function upsertEncryptedFolder(folder: VaultFolder, revisionStamp?: number | null): void { patchVaultCoreSnapshot((snapshot) => ({ ...snapshot, folders: upsertById(snapshot.folders, folder), - })); + }), { revisionStamp: revisionStamp ?? revisionStampFromIso(folder.revisionDate) }); } - function deleteFolderLocally(folderId: string): void { + function deleteFolderLocally(folderId: string, revisionStamp?: number | null): void { const id = String(folderId || '').trim(); if (!id) return; patchVaultCoreSnapshot((snapshot) => ({ @@ -1405,38 +1430,38 @@ export default function App() { ciphers: snapshot.ciphers.map((cipher) => ( String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher )), - })); + }), { revisionStamp }); setDecryptedFolders((current) => removeById(current, id)); setDecryptedCiphers((current) => current.map((cipher) => ( String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher ))); } - function upsertEncryptedSend(send: Send): void { + function upsertEncryptedSend(send: Send, revisionStamp?: number | null): void { patchVaultCoreSnapshot((snapshot) => ({ ...snapshot, sends: upsertById(snapshot.sends, send), - })); + }), { revisionStamp: revisionStamp ?? revisionStampFromIso(send.revisionDate) }); queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => upsertById(Array.isArray(previous) ? previous : [], send)); } - function deleteSendLocally(sendId: string): void { + function deleteSendLocally(sendId: string, revisionStamp?: number | null): void { const id = String(sendId || '').trim(); if (!id) return; patchVaultCoreSnapshot((snapshot) => ({ ...snapshot, sends: removeById(snapshot.sends, id), - })); + }), { revisionStamp }); queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => removeById(Array.isArray(previous) ? previous : [], id)); setDecryptedSends((current) => removeById(current, id)); } - async function upsertCipherFromNotification(cipherId: string): Promise { + async function upsertCipherFromNotification(cipherId: string, revisionStamp?: number | null): Promise { const id = String(cipherId || '').trim(); if (!id || !session?.symEncKey || !session?.symMacKey) return; try { const encrypted = await getCipherById(authedFetch, id); - upsertEncryptedCipher(encrypted); + upsertEncryptedCipher(encrypted, revisionStamp); const result = await decryptVaultCore({ folders: [], ciphers: [encrypted], @@ -1454,12 +1479,12 @@ export default function App() { } } - async function upsertFolderFromNotification(folderId: string): Promise { + async function upsertFolderFromNotification(folderId: string, revisionStamp?: number | null): Promise { const id = String(folderId || '').trim(); if (!id || !session?.symEncKey || !session?.symMacKey) return; try { const encrypted = await getFolderById(authedFetch, id); - upsertEncryptedFolder(encrypted); + upsertEncryptedFolder(encrypted, revisionStamp); const result = await decryptVaultCore({ folders: [encrypted], ciphers: [], @@ -1477,12 +1502,12 @@ export default function App() { } } - async function upsertSendFromNotification(sendId: string): Promise { + async function upsertSendFromNotification(sendId: string, revisionStamp?: number | null): Promise { const id = String(sendId || '').trim(); if (!id || !session?.symEncKey || !session?.symMacKey) return; try { const encrypted = await getSendById(authedFetch, id); - upsertEncryptedSend(encrypted); + upsertEncryptedSend(encrypted, revisionStamp); const sends = await decryptSends({ sends: [encrypted], symEncKeyB64: session.symEncKey, @@ -1576,11 +1601,18 @@ export default function App() { const frames = parseSignalRTextFrames(event.data); for (const frame of frames) { if (frame.type !== 1 || frame.target !== 'ReceiveMessage') continue; - const updateType = Number(frame.arguments?.[0]?.Type || 0); - const contextId = String(frame.arguments?.[0]?.ContextId || '').trim(); - const payload = frame.arguments?.[0]?.Payload; + const message = frame.arguments?.[0] as Record | undefined; + const updateType = Number(message?.Type || 0); + const contextId = String(message?.ContextId || '').trim(); + const payload = message?.Payload; const payloadRecord = payload && typeof payload === 'object' ? payload as Record : null; const resourceId = String(payloadRecord?.Id || payloadRecord?.id || '').trim(); + const revisionStamp = revisionStampFromIso( + payloadRecord?.RevisionDate + || payloadRecord?.revisionDate + || message?.Date + || message?.date + ); if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) { logoutNow(); return; @@ -1590,7 +1622,6 @@ export default function App() { continue; } if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) { - const payload = frame.arguments?.[0]?.Payload; if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload); continue; } @@ -1606,27 +1637,27 @@ export default function App() { continue; } if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE) && resourceId) { - void upsertCipherFromNotification(resourceId); + void upsertCipherFromNotification(resourceId, revisionStamp); continue; } if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE && resourceId) { - deleteCipherLocally(resourceId); + deleteCipherLocally(resourceId, revisionStamp); continue; } if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE) && resourceId) { - void upsertFolderFromNotification(resourceId); + void upsertFolderFromNotification(resourceId, revisionStamp); continue; } if (updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE && resourceId) { - deleteFolderLocally(resourceId); + deleteFolderLocally(resourceId, revisionStamp); continue; } if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE) && resourceId) { - void upsertSendFromNotification(resourceId); + void upsertSendFromNotification(resourceId, revisionStamp); continue; } if (updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE && resourceId) { - deleteSendLocally(resourceId); + deleteSendLocally(resourceId, revisionStamp); continue; } if (updateType === SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; @@ -1688,8 +1719,33 @@ export default function App() { }, refetchSends: refetchSendsFromVaultCore, onNotify: pushToast, + patchEncryptedCiphers: (updater) => { + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + ciphers: updater(snapshot.ciphers), + })); + }, + patchEncryptedFolders: (updater) => { + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + folders: updater(snapshot.folders), + })); + }, + patchEncryptedSends: (updater) => { + let nextSends: Send[] = []; + patchVaultCoreSnapshot((snapshot) => { + nextSends = updater(snapshot.sends); + return { + ...snapshot, + sends: nextSends, + }; + }); + queryClient.setQueryData(sendsQueryKey, nextSends); + }, patchDecryptedCiphers: setDecryptedCiphers, patchDecryptedFolders: setDecryptedFolders, + patchDecryptedSends: setDecryptedSends, + refreshVaultRevisionStamp: refreshVaultCoreRevisionStamp, }); const accountSecurityActions = useAccountSecurityActions({ authedFetch, diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index cbe56e3..a3ae8dc 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -41,6 +41,7 @@ import { downloadCipherAttachmentDecrypted, encryptFolderImportName, getAttachmentDownloadInfo, + getCipherById, importCiphers, permanentDeleteCipher, type CiphersImportPayload, @@ -69,8 +70,13 @@ interface UseVaultSendActionsOptions { refetchFolders: () => Promise<{ data?: VaultFolder[] | undefined } | unknown>; refetchSends: () => Promise; onNotify: Notify; + patchEncryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void; + patchEncryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void; + patchEncryptedSends: (updater: (prev: Send[]) => Send[]) => void; patchDecryptedCiphers: (updater: (prev: Cipher[]) => Cipher[]) => void; patchDecryptedFolders: (updater: (prev: VaultFolder[]) => VaultFolder[]) => void; + patchDecryptedSends: (updater: (prev: Send[]) => Send[]) => void; + refreshVaultRevisionStamp: () => Promise; } function extractImportIdMaps(cipherMap: ImportedCipherMapEntry[] | null) { @@ -288,8 +294,13 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) refetchFolders, refetchSends, onNotify, + patchEncryptedCiphers, + patchEncryptedFolders, + patchEncryptedSends, patchDecryptedCiphers, patchDecryptedFolders, + patchDecryptedSends, + refreshVaultRevisionStamp, } = options; const [downloadingAttachmentKey, setDownloadingAttachmentKey] = useState(''); const [attachmentDownloadPercent, setAttachmentDownloadPercent] = useState(null); @@ -308,21 +319,20 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) throw new Error(t('txt_offline_vault_readonly')); }; - const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => { - const tasks: Promise[] = [Promise.resolve(refetchCiphers())]; - if (options?.includeFolders) { - tasks.push(Promise.resolve(refetchFolders())); - } - void Promise.all(tasks).catch((err) => { - console.warn('Background vault sync failed:', err); - }); - }; - async function decryptAndPatch(encrypted: Cipher) { if (!session?.symEncKey || !session?.symMacKey) { await refetchCiphers(); return; } + patchEncryptedCiphers((prev) => { + const idx = prev.findIndex((c) => c.id === encrypted.id); + if (idx >= 0) { + const next = [...prev]; + next[idx] = encrypted; + return next; + } + return [encrypted, ...prev]; + }); const encKey = base64ToBytes(session.symEncKey); const macKey = base64ToBytes(session.symMacKey); const decrypted = await decryptSingleCipher(encrypted, encKey, macKey); @@ -342,6 +352,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await refetchCiphers(); return; } + patchEncryptedCiphers((prev) => [encrypted, ...prev.filter((cipher) => cipher.id !== optimisticId && cipher.id !== encrypted.id)]); const encKey = base64ToBytes(session.symEncKey); const macKey = base64ToBytes(session.symMacKey); const decrypted = await decryptSingleCipher(encrypted, encKey, macKey); @@ -352,31 +363,70 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } function removeCipherFromState(id: string) { + patchEncryptedCiphers((prev) => prev.filter((c) => c.id !== id)); patchDecryptedCiphers((prev) => prev.filter((c) => c.id !== id)); } - function patchCipherBatch(ids: string[], updater: (cipher: Cipher) => Cipher | null) { + function patchCipherBatch( + ids: string[], + updater: (cipher: Cipher) => Cipher | null, + options?: { patchEncrypted?: boolean; patchDecrypted?: boolean } + ) { const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)); if (!idSet.size) return; - patchDecryptedCiphers((prev) => { - let changed = false; - const next: Cipher[] = []; - for (const cipher of prev) { - if (!idSet.has(cipher.id)) { - next.push(cipher); - continue; + const shouldPatchEncrypted = options?.patchEncrypted !== false; + const shouldPatchDecrypted = options?.patchDecrypted !== false; + if (shouldPatchEncrypted) { + patchEncryptedCiphers((prev) => { + let changed = false; + const next: Cipher[] = []; + for (const cipher of prev) { + if (!idSet.has(cipher.id)) { + next.push(cipher); + continue; + } + const updated = updater(cipher); + changed = true; + if (updated) next.push(updated); } - const updated = updater(cipher); - changed = true; - if (updated) next.push(updated); - } - return changed ? next : prev; - }); + return changed ? next : prev; + }); + } + if (shouldPatchDecrypted) { + patchDecryptedCiphers((prev) => { + let changed = false; + const next: Cipher[] = []; + for (const cipher of prev) { + if (!idSet.has(cipher.id)) { + next.push(cipher); + continue; + } + const updated = updater(cipher); + changed = true; + if (updated) next.push(updated); + } + return changed ? next : prev; + }); + } } function patchFolderBatch(ids: string[], updater: (folder: VaultFolder) => VaultFolder | null) { const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)); if (!idSet.size) return; + patchEncryptedFolders((prev) => { + let changed = false; + const next: VaultFolder[] = []; + for (const folder of prev) { + if (!idSet.has(folder.id)) { + next.push(folder); + continue; + } + const updated = updater(folder); + changed = true; + if (updated) next.push(updated); + } + return changed ? next : prev; + }); patchDecryptedFolders((prev) => { let changed = false; const next: VaultFolder[] = []; @@ -393,6 +443,31 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) }); } + function upsertEncryptedFolder(folder: VaultFolder) { + patchEncryptedFolders((prev) => { + const index = prev.findIndex((item) => item.id === folder.id); + if (index < 0) return [folder, ...prev]; + const next = [...prev]; + next[index] = folder; + return next; + }); + } + + function upsertSend(send: Send) { + patchEncryptedSends((prev) => { + const index = prev.findIndex((item) => item.id === send.id); + if (index < 0) return [send, ...prev]; + const next = [...prev]; + next[index] = send; + return next; + }); + } + + function removeSend(id: string) { + patchEncryptedSends((prev) => prev.filter((send) => send.id !== id)); + patchDecryptedSends((prev) => prev.filter((send) => send.id !== id)); + } + const uploadImportedAttachments = async ( attachments: ImportAttachmentFile[], idMaps: { byIndex: Map; bySourceId: Map } @@ -468,8 +543,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) setAttachmentUploadPercent(0); await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent); } - await decryptAndReplaceOptimistic(optimistic.id, created); - syncVaultCoreInBackground({ includeFolders: !!draft.folderId || attachments.length > 0 }); + const finalCipher = attachments.length ? await getCipherById(authedFetch, created.id) : created; + await decryptAndReplaceOptimistic(optimistic.id, finalCipher); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_created')); } catch (error) { patchDecryptedCiphers((prev) => prev.filter((cipher) => cipher.id !== optimistic.id)); @@ -511,7 +587,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) .filter((attachment) => !removedSet.has(String(attachment?.id || '').trim())) .map((attachment) => ({ ...attachment })); } - patchCipherBatch([cipher.id], () => optimistic); + patchCipherBatch([cipher.id], () => optimistic, { patchEncrypted: false }); try { const updated = await updateCipher(authedFetch, session, cipher, draft); for (const attachmentId of removeAttachmentIds) { @@ -524,16 +600,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) setAttachmentUploadPercent(0); await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent); } - await decryptAndPatch(updated); - syncVaultCoreInBackground({ - includeFolders: - draft.folderId !== (cipher.folderId || '') - || addFiles.length > 0 - || removeAttachmentIds.length > 0, - }); + const finalCipher = addFiles.length || removeAttachmentIds.length + ? await getCipherById(authedFetch, cipher.id) + : updated; + await decryptAndPatch(finalCipher); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_updated')); } catch (error) { - patchCipherBatch([cipher.id], () => previousCipher); + patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false }); onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed')); throw error; } finally { @@ -572,7 +646,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await permanentDeleteCipher(authedFetch, cipher.id); patchCipherBatch([cipher.id], () => null); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_deleted_permanently')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_permanent_delete_item_failed')); @@ -585,10 +659,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { const deleted = await deleteCipher(authedFetch, cipher.id); await decryptAndPatch(deleted); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_deleted')); } catch (error) { - patchCipherBatch([cipher.id], () => previousCipher); + patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false }); onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed')); throw error; } @@ -607,10 +681,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { const archived = await archiveCipher(authedFetch, cipher.id); await decryptAndPatch(archived); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_archived')); } catch (error) { - patchCipherBatch([cipher.id], () => previousCipher); + patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false }); onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed')); throw error; } @@ -629,10 +703,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { const unarchived = await unarchiveCipher(authedFetch, cipher.id); await decryptAndPatch(unarchived); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_item_unarchived')); } catch (error) { - patchCipherBatch([cipher.id], () => previousCipher); + patchCipherBatch([cipher.id], () => previousCipher, { patchEncrypted: false }); onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed')); throw error; } @@ -649,7 +723,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await bulkDeleteCiphers(authedFetch, ids); const deletedDate = new Date().toISOString(); patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate, archivedDate: null })); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_deleted_selected_items')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed')); @@ -668,7 +742,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await bulkArchiveCiphers(authedFetch, ids); const archivedDate = new Date().toISOString(); patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate, deletedDate: null })); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_archived_selected_items')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_archive_failed')); @@ -686,7 +760,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await bulkUnarchiveCiphers(authedFetch, ids); patchCipherBatch(ids, (cipher) => ({ ...cipher, archivedDate: null })); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_unarchived_selected_items')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_unarchive_failed')); @@ -704,7 +778,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await bulkMoveCiphers(authedFetch, ids, folderId); patchCipherBatch(ids, (cipher) => ({ ...cipher, folderId })); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_moved_selected_items')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_move_failed')); @@ -727,15 +801,18 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { if (!session) throw new Error(t('txt_vault_key_unavailable')); const created = await createFolder(authedFetch, session, folderName); + upsertEncryptedFolder(created); patchDecryptedFolders((prev) => [ { id: created.id, name: created.name || folderName, decName: folderName, + revisionDate: created.revisionDate, + creationDate: created.creationDate, }, ...prev, ]); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_folder_created')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_create_folder_failed')); @@ -758,8 +835,9 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await deleteFolder(authedFetch, id); patchFolderBatch([id], () => null); + patchEncryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher))); patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId === id ? { ...cipher, folderId: null } : cipher))); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_folder_deleted')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_delete_folder_failed')); @@ -786,9 +864,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } try { if (!session) throw new Error(t('txt_vault_key_unavailable')); - await updateFolder(authedFetch, session, id, nextName); - patchFolderBatch([id], (folder) => ({ ...folder, decName: nextName })); - syncVaultCoreInBackground({ includeFolders: true }); + const updated = await updateFolder(authedFetch, session, id, nextName); + upsertEncryptedFolder(updated); + patchDecryptedFolders((prev) => prev.map((folder) => ( + folder.id === id + ? { ...folder, name: updated.name || folder.name, decName: nextName, revisionDate: updated.revisionDate } + : folder + ))); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_folder_updated')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_update_folder_failed')); @@ -806,7 +889,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await bulkRestoreCiphers(authedFetch, ids); patchCipherBatch(ids, (cipher) => ({ ...cipher, deletedDate: null })); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_restored_selected_items')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed')); @@ -824,7 +907,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await bulkPermanentDeleteCiphers(authedFetch, ids); patchCipherBatch(ids, () => null); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_deleted_selected_items_permanently')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed')); @@ -844,9 +927,11 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { await bulkDeleteFolders(authedFetch, ids); const removedIds = new Set(ids); + patchEncryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id))); + patchEncryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher))); patchDecryptedFolders((prev) => prev.filter((folder) => !removedIds.has(folder.id))); patchDecryptedCiphers((prev) => prev.map((cipher) => (cipher.folderId && removedIds.has(cipher.folderId) ? { ...cipher, folderId: null } : cipher))); - syncVaultCoreInBackground({ includeFolders: true }); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_folders_deleted')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed')); @@ -874,7 +959,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) setSendUploadPercent(0); } const created = await createSend(authedFetch, session, draft, fileName ? setSendUploadPercent : undefined); - await refetchSends(); + upsertSend(created); + void refreshVaultRevisionStamp(); if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) { const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey); const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart); @@ -900,7 +986,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } try { const updated = await updateSend(authedFetch, session, send, draft); - await refetchSends(); + upsertSend(updated); + void refreshVaultRevisionStamp(); if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) { const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey); const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart); @@ -922,7 +1009,8 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } try { await deleteSend(authedFetch, send.id); - await refetchSends(); + removeSend(send.id); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_send_deleted')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_delete_send_failed')); @@ -939,7 +1027,10 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) } try { await bulkDeleteSends(authedFetch, ids); - await refetchSends(); + const idSet = new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)); + patchEncryptedSends((prev) => prev.filter((send) => !idSet.has(send.id))); + patchDecryptedSends((prev) => prev.filter((send) => !idSet.has(send.id))); + void refreshVaultRevisionStamp(); onNotify('success', t('txt_deleted_selected_sends')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_bulk_delete_sends_failed')); @@ -1299,10 +1390,17 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) encryptedFolders, importAuthedFetch, onNotify, + patchDecryptedCiphers, + patchDecryptedFolders, + patchDecryptedSends, + patchEncryptedCiphers, + patchEncryptedFolders, + patchEncryptedSends, profile, refetchCiphers, refetchFolders, refetchSends, + refreshVaultRevisionStamp, session, sendUploadPercent, uploadingAttachmentName, diff --git a/webapp/src/lib/api/vault-sync.ts b/webapp/src/lib/api/vault-sync.ts index 8e0fbb7..f37111b 100644 --- a/webapp/src/lib/api/vault-sync.ts +++ b/webapp/src/lib/api/vault-sync.ts @@ -51,6 +51,29 @@ export async function invalidateVaultCoreSyncSnapshot(cacheKey: string): Promise await clearCachedVaultCoreSnapshot(normalizedKey); } +export async function saveVaultCoreSyncSnapshot( + cacheKey: string, + snapshot: VaultCoreSnapshot, + revisionStamp?: number | null +): Promise { + const normalizedKey = String(cacheKey || '').trim(); + if (!normalizedKey) return; + + const normalizedSnapshot = normalizeCachedSnapshot(snapshot); + const currentMemory = memoryVaultCoreCache.get(normalizedKey); + let nextRevisionStamp = Number(revisionStamp); + if (!Number.isFinite(nextRevisionStamp) || nextRevisionStamp <= 0) { + const cached = await loadCachedVaultCoreSnapshot(normalizedKey); + nextRevisionStamp = currentMemory?.revisionStamp || cached?.revisionStamp || Date.now(); + } + + memoryVaultCoreCache.set(normalizedKey, { + revisionStamp: nextRevisionStamp, + snapshot: normalizedSnapshot, + }); + await saveCachedVaultCoreSnapshot(normalizedKey, nextRevisionStamp, normalizedSnapshot); +} + export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise { const normalizedKey = String(cacheKey || '').trim(); if (!normalizedKey) return { ciphers: [], folders: [], sends: [] }; diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 8bfbe0f..0ff375e 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -43,7 +43,7 @@ export async function createFolder( authedFetch: AuthedFetch, session: SessionState, name: string -): Promise<{ id: string; name?: string | null }> { +): Promise { if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); const enc = base64ToBytes(session.symEncKey); const mac = base64ToBytes(session.symMacKey); @@ -54,9 +54,9 @@ export async function createFolder( body: JSON.stringify({ name: encryptedName }), }); if (!resp.ok) throw new Error('Create folder failed'); - const body = await parseJson<{ id?: string; name?: string | null }>(resp); + const body = await parseJson(resp); if (!body?.id) throw new Error('Create folder failed'); - return { id: body.id, name: body.name ?? null }; + return body; } export async function encryptFolderImportName(session: SessionState, name: string): Promise { @@ -92,7 +92,7 @@ export async function updateFolder( session: SessionState, folderId: string, name: string -): Promise { +): Promise { const id = String(folderId || '').trim(); if (!id) throw new Error('Folder id is required'); if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable'); @@ -105,6 +105,9 @@ export async function updateFolder( body: JSON.stringify({ name: encryptedName }), }); if (!resp.ok) throw new Error('Update folder failed'); + const body = await parseJson(resp); + if (!body?.id) throw new Error('Update folder failed'); + return body; } export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Promise {