Use resource sync notifications in the web client

This commit is contained in:
shuaiplus
2026-06-21 16:14:20 +08:00
parent f9fe53285f
commit 42b765b113
5 changed files with 264 additions and 10 deletions
+14
View File
@@ -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,
});
+3
View File
@@ -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,
+213 -10
View File
@@ -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<VaultCoreSnapshot> | 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<T extends { id: string }>(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<T extends { id: string }>(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<void> {
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<void> {
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<void> {
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<string, unknown> : 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;
}
});
+11
View File
@@ -67,6 +67,17 @@ export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
return body?.data || [];
}
export async function getSendById(authedFetch: AuthedFetch, sendId: string): Promise<Send> {
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<Send>(resp);
if (!body?.id) throw new Error('Load send failed');
return body;
}
export async function createSend(
authedFetch: AuthedFetch,
session: SessionState,
+23
View File
@@ -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<Folder> {
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<Folder>(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<Cipher> {
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<Cipher>(resp);
if (!body?.id) throw new Error('Load cipher failed');
return body;
}
export interface CiphersImportPayload {
ciphers: Array<Record<string, unknown>>;
folders: Array<{ name: string }>;