From 42b765b113f41e8fbf0148cb212d3fc2fdf93daa Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 21 Jun 2026 16:14:20 +0800 Subject: [PATCH] Use resource sync notifications in the web client --- src/handlers/folders.ts | 14 +++ src/handlers/sends-private.ts | 3 + webapp/src/App.tsx | 223 ++++++++++++++++++++++++++++++++-- webapp/src/lib/api/send.ts | 11 ++ webapp/src/lib/api/vault.ts | 23 ++++ 5 files changed, 264 insertions(+), 10 deletions(-) diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index 84d87e6..c7826c2 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -202,9 +202,23 @@ export async function handleBulkDeleteFolders(request: Request, env: Env, userId return errorResponse('Folder ids are required', 400); } + const folders = ( + await Promise.all(ids.map(async (id) => { + const folder = await storage.getFolder(id); + return folder && folder.userId === userId ? folder : null; + })) + ).filter((folder): folder is Folder => !!folder); const revisionDate = await storage.bulkDeleteFolders(ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + for (const folder of folders) { + notifyUserFolderDelete(env, { + userId, + folderId: folder.id, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); + } await writeFolderAudit(storage, request, userId, 'folder.delete.bulk', { count: ids.length, }); diff --git a/src/handlers/sends-private.ts b/src/handlers/sends-private.ts index c8d0700..cf8c8de 100644 --- a/src/handlers/sends-private.ts +++ b/src/handlers/sends-private.ts @@ -683,6 +683,9 @@ export async function handleBulkDeleteSends(request: Request, env: Env, userId: const revisionDate = await storage.bulkDeleteSends(body.ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + for (const send of sends) { + notifySendDeleteForRequest(request, env, send.id, userId, revisionDate); + } await writeSendAudit(storage, request, userId, 'send.delete.bulk', { count: sends.length, requestedCount: body.ids.length, diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 53ddae6..6414617 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -31,8 +31,8 @@ import { } from '@/lib/api/auth-requests'; import { clearAuditLogs, getAuditLogSettings, listAdminInvites, listAdminUsers, listAuditLogs, saveAuditLogSettings, type AuditLogFilters } from '@/lib/api/admin'; import { getDomainRules, saveDomainRules } from '@/lib/api/domains'; -import { getSends } from '@/lib/api/send'; -import { repairCipherKeyMismatches, repairCipherUriChecksums } from '@/lib/api/vault'; +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 { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { @@ -134,8 +134,18 @@ function normalizeRoutePath(path: string): string { } const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1'; const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); +const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE = 0; +const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE = 1; +const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE = 3; +const SIGNALR_UPDATE_TYPE_SYNC_CIPHERS = 4; const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; +const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE = 7; +const SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE = 8; +const SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE = 9; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; +const SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE = 12; +const SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE = 13; +const SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE = 14; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101; const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102; @@ -1327,6 +1337,169 @@ export default function App() { silentRefreshVaultRef.current = refreshVaultSilently; + function normalizeVaultCoreSnapshot(snapshot?: Partial | null): VaultCoreSnapshot { + return { + ciphers: Array.isArray(snapshot?.ciphers) ? snapshot.ciphers : [], + folders: Array.isArray(snapshot?.folders) ? snapshot.folders : [], + sends: Array.isArray(snapshot?.sends) ? snapshot.sends : [], + }; + } + + function upsertById(items: T[], nextItem: T): T[] { + const nextId = String(nextItem.id || '').trim(); + if (!nextId) return items; + const index = items.findIndex((item) => String(item.id || '').trim() === nextId); + if (index < 0) return [...items, nextItem]; + const next = items.slice(); + next[index] = nextItem; + return next; + } + + function removeById(items: T[], id: string): T[] { + const normalizedId = String(id || '').trim(); + if (!normalizedId) return items; + return items.filter((item) => String(item.id || '').trim() !== normalizedId); + } + + function patchVaultCoreSnapshot(updater: (snapshot: VaultCoreSnapshot) => VaultCoreSnapshot): void { + if (!vaultCacheKey) return; + let nextSnapshot: VaultCoreSnapshot | null = null; + queryClient.setQueryData(['vault-core', vaultCacheKey], (previous?: VaultCoreSnapshot) => { + const base = normalizeVaultCoreSnapshot(previous || cachedVaultCore); + nextSnapshot = updater(base); + return nextSnapshot; + }); + if (nextSnapshot) setCachedVaultCore(nextSnapshot); + } + + function upsertEncryptedCipher(cipher: Cipher): void { + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + ciphers: upsertById(snapshot.ciphers, cipher), + })); + } + + function deleteCipherLocally(cipherId: string): void { + const id = String(cipherId || '').trim(); + if (!id) return; + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + ciphers: removeById(snapshot.ciphers, id), + })); + setDecryptedCiphers((current) => removeById(current, id)); + } + + function upsertEncryptedFolder(folder: VaultFolder): void { + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + folders: upsertById(snapshot.folders, folder), + })); + } + + function deleteFolderLocally(folderId: string): void { + const id = String(folderId || '').trim(); + if (!id) return; + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + folders: removeById(snapshot.folders, id), + ciphers: snapshot.ciphers.map((cipher) => ( + String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher + )), + })); + setDecryptedFolders((current) => removeById(current, id)); + setDecryptedCiphers((current) => current.map((cipher) => ( + String(cipher.folderId || '').trim() === id ? { ...cipher, folderId: null } : cipher + ))); + } + + function upsertEncryptedSend(send: Send): void { + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + sends: upsertById(snapshot.sends, send), + })); + queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => upsertById(Array.isArray(previous) ? previous : [], send)); + } + + function deleteSendLocally(sendId: string): void { + const id = String(sendId || '').trim(); + if (!id) return; + patchVaultCoreSnapshot((snapshot) => ({ + ...snapshot, + sends: removeById(snapshot.sends, id), + })); + queryClient.setQueryData(sendsQueryKey, (previous?: Send[]) => removeById(Array.isArray(previous) ? previous : [], id)); + setDecryptedSends((current) => removeById(current, id)); + } + + async function upsertCipherFromNotification(cipherId: string): Promise { + const id = String(cipherId || '').trim(); + if (!id || !session?.symEncKey || !session?.symMacKey) return; + try { + const encrypted = await getCipherById(authedFetch, id); + upsertEncryptedCipher(encrypted); + const result = await decryptVaultCore({ + folders: [], + ciphers: [encrypted], + symEncKeyB64: session.symEncKey, + symMacKeyB64: session.symMacKey, + }); + const decrypted = result.ciphers[0]; + if (decrypted) setDecryptedCiphers((current) => upsertById(current, decrypted)); + } catch (error) { + if ((error as { status?: number }).status === 404) { + deleteCipherLocally(id); + return; + } + console.warn('Failed to upsert cipher from notification:', error); + } + } + + async function upsertFolderFromNotification(folderId: string): Promise { + const id = String(folderId || '').trim(); + if (!id || !session?.symEncKey || !session?.symMacKey) return; + try { + const encrypted = await getFolderById(authedFetch, id); + upsertEncryptedFolder(encrypted); + const result = await decryptVaultCore({ + folders: [encrypted], + ciphers: [], + symEncKeyB64: session.symEncKey, + symMacKeyB64: session.symMacKey, + }); + const decrypted = result.folders[0]; + if (decrypted) setDecryptedFolders((current) => upsertById(current, decrypted)); + } catch (error) { + if ((error as { status?: number }).status === 404) { + deleteFolderLocally(id); + return; + } + console.warn('Failed to upsert folder from notification:', error); + } + } + + async function upsertSendFromNotification(sendId: string): Promise { + const id = String(sendId || '').trim(); + if (!id || !session?.symEncKey || !session?.symMacKey) return; + try { + const encrypted = await getSendById(authedFetch, id); + upsertEncryptedSend(encrypted); + const sends = await decryptSends({ + sends: [encrypted], + symEncKeyB64: session.symEncKey, + symMacKeyB64: session.symMacKey, + origin: window.location.origin, + }); + const decrypted = sends[0]; + if (decrypted) setDecryptedSends((current) => upsertById(current, decrypted)); + } catch (error) { + if ((error as { status?: number }).status === 404) { + deleteSendLocally(id); + return; + } + console.warn('Failed to upsert send from notification:', error); + } + } + useEffect(() => { if (IS_DEMO_MODE) return; if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey || !vaultInitialDecryptDone) return; @@ -1404,6 +1577,10 @@ export default function App() { 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 payloadRecord = payload && typeof payload === 'object' ? payload as Record : null; + const resourceId = String(payloadRecord?.Id || payloadRecord?.id || '').trim(); if (updateType === SIGNALR_UPDATE_TYPE_LOG_OUT) { logoutNow(); return; @@ -1417,16 +1594,42 @@ export default function App() { if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload); continue; } - if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; - const contextId = String(frame.arguments?.[0]?.ContextId || '').trim(); if (contextId && contextId === getCurrentDeviceIdentifier()) continue; - if (notificationRefreshTimerRef.current !== null) { - window.clearTimeout(notificationRefreshTimerRef.current); + if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHERS) { + if (notificationRefreshTimerRef.current !== null) { + window.clearTimeout(notificationRefreshTimerRef.current); + } + notificationRefreshTimerRef.current = window.setTimeout(() => { + notificationRefreshTimerRef.current = null; + void silentRefreshVaultRef.current(); + }, 250); + continue; } - notificationRefreshTimerRef.current = window.setTimeout(() => { - notificationRefreshTimerRef.current = null; - void silentRefreshVaultRef.current(); - }, 250); + if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE) && resourceId) { + void upsertCipherFromNotification(resourceId); + continue; + } + if (updateType === SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE && resourceId) { + deleteCipherLocally(resourceId); + continue; + } + if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE) && resourceId) { + void upsertFolderFromNotification(resourceId); + continue; + } + if (updateType === SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE && resourceId) { + deleteFolderLocally(resourceId); + continue; + } + if ((updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE || updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE) && resourceId) { + void upsertSendFromNotification(resourceId); + continue; + } + if (updateType === SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE && resourceId) { + deleteSendLocally(resourceId); + continue; + } + if (updateType === SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; } }); diff --git a/webapp/src/lib/api/send.ts b/webapp/src/lib/api/send.ts index 3230827..104c986 100644 --- a/webapp/src/lib/api/send.ts +++ b/webapp/src/lib/api/send.ts @@ -67,6 +67,17 @@ export async function getSends(authedFetch: AuthedFetch): Promise { return body?.data || []; } +export async function getSendById(authedFetch: AuthedFetch, sendId: string): Promise { + const id = String(sendId || '').trim(); + if (!id) throw new Error('Send id is required'); + const resp = await authedFetch(`/api/sends/${encodeURIComponent(id)}`); + if (resp.status === 404) throw createApiError('Send not found', 404); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load send failed')); + const body = await parseJson(resp); + if (!body?.id) throw new Error('Load send failed'); + return body; +} + export async function createSend( authedFetch: AuthedFetch, session: SessionState, diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 4da0d0b..8bfbe0f 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -10,6 +10,7 @@ import type { import { BULK_API_CHUNK_SIZE, chunkArray, + createApiError, parseErrorMessage, parseJson, uploadDirectEncryptedPayload, @@ -27,6 +28,17 @@ export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Pr return body.folders || []; } +export async function getFolderById(authedFetch: AuthedFetch, folderId: string): Promise { + const id = String(folderId || '').trim(); + if (!id) throw new Error('Folder id is required'); + const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`); + if (resp.status === 404) throw createApiError('Folder not found', 404); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load folder failed')); + const body = await parseJson(resp); + if (!body?.id) throw new Error('Load folder failed'); + return body; +} + export async function createFolder( authedFetch: AuthedFetch, session: SessionState, @@ -100,6 +112,17 @@ export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Pr return body.ciphers || []; } +export async function getCipherById(authedFetch: AuthedFetch, cipherId: string): Promise { + const id = String(cipherId || '').trim(); + if (!id) throw new Error('Cipher id is required'); + const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}`); + if (resp.status === 404) throw createApiError('Cipher not found', 404); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Load cipher failed')); + const body = await parseJson(resp); + if (!body?.id) throw new Error('Load cipher failed'); + return body; +} + export interface CiphersImportPayload { ciphers: Array>; folders: Array<{ name: string }>;