mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-22 21:50:13 +00:00
Use resource sync notifications in the web client
This commit is contained in:
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
Reference in New Issue
Block a user