From fe0c66c561f964b2ef27e200ec61d03bd42afe52 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 21 Jun 2026 15:14:42 +0800 Subject: [PATCH] Add official Bitwarden resource sync notifications --- src/durable/notifications-hub.ts | 249 ++++++++++++++++++++++++++++++- src/handlers/ciphers.ts | 75 +++++++++- src/handlers/folders.ts | 25 +++- src/handlers/sends-private.ts | 9 ++ src/handlers/sends-shared.ts | 52 ++++++- webapp/src/App.tsx | 4 +- 6 files changed, 408 insertions(+), 6 deletions(-) diff --git a/src/durable/notifications-hub.ts b/src/durable/notifications-hub.ts index fa62ca0..ad72f7f 100644 --- a/src/durable/notifications-hub.ts +++ b/src/durable/notifications-hub.ts @@ -3,11 +3,21 @@ import type { Env } from '../types'; const SIGNALR_RECORD_SEPARATOR = 0x1e; const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]); +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_BACKUP_RESTORE_PROGRESS = 13; +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_AUTH_REQUEST = 15; const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16; +const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102; type HubProtocol = 'json' | 'messagepack'; type HubKind = 'user' | 'anonymous-auth-request'; @@ -422,6 +432,243 @@ export function notifyUserVaultSync( waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_VAULT, revisionDate, contextId ?? null, null)); } +export function notifyUserCiphersSync( + env: Env, + userId: string, + revisionDate: string, + contextId?: string | null +): void { + waitUntil(notifyUserUpdate(env, userId, SIGNALR_UPDATE_TYPE_SYNC_CIPHERS, revisionDate, contextId ?? null, null)); +} + +export function notifyUserCipherCreate( + env: Env, + payload: { + userId: string; + cipherId: string; + revisionDate: string; + organizationId?: string | null; + collectionIds?: string[] | null; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_CIPHER_CREATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.cipherId, + OrganizationId: payload.organizationId ?? null, + CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserCipherUpdate( + env: Env, + payload: { + userId: string; + cipherId: string; + revisionDate: string; + organizationId?: string | null; + collectionIds?: string[] | null; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_CIPHER_UPDATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.cipherId, + OrganizationId: payload.organizationId ?? null, + CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserCipherDelete( + env: Env, + payload: { + userId: string; + cipherId: string; + revisionDate: string; + organizationId?: string | null; + collectionIds?: string[] | null; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_CIPHER_DELETE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.cipherId, + OrganizationId: payload.organizationId ?? null, + CollectionIds: Array.isArray(payload.collectionIds) ? payload.collectionIds : null, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserFolderCreate( + env: Env, + payload: { + userId: string; + folderId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_FOLDER_CREATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.folderId, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserFolderUpdate( + env: Env, + payload: { + userId: string; + folderId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_FOLDER_UPDATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.folderId, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserFolderDelete( + env: Env, + payload: { + userId: string; + folderId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_FOLDER_DELETE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.folderId, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserSendCreate( + env: Env, + payload: { + userId: string; + sendId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_SEND_CREATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.sendId, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserSendUpdate( + env: Env, + payload: { + userId: string; + sendId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_SEND_UPDATE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.sendId, + RevisionDate: payload.revisionDate, + } + )); +} + +export function notifyUserSendDelete( + env: Env, + payload: { + userId: string; + sendId: string; + revisionDate: string; + contextId?: string | null; + } +): void { + waitUntil(notifyUserUpdate( + env, + payload.userId, + SIGNALR_UPDATE_TYPE_SYNC_SEND_DELETE, + payload.revisionDate, + payload.contextId ?? null, + null, + { + UserId: payload.userId, + Id: payload.sendId, + RevisionDate: payload.revisionDate, + } + )); +} + export function notifyUserLogout( env: Env, userId: string, diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 1563766..11921d3 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -11,7 +11,13 @@ import { PasswordHistory, } from '../types'; import { StorageService } from '../services/storage'; -import { notifyUserVaultSync } from '../durable/notifications-hub'; +import { + notifyUserCipherCreate, + notifyUserCipherDelete, + notifyUserCipherUpdate, + notifyUserCiphersSync, + notifyUserVaultSync, +} from '../durable/notifications-hub'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { deleteAllAttachmentsForCipher, deleteAllAttachmentsForCiphers } from './attachments'; @@ -51,6 +57,60 @@ function notifyVaultSyncForRequest( notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } +function notifyCipherCreateForRequest( + request: Request, + env: Env, + cipher: Cipher, + revisionDate: string +): void { + notifyUserCipherCreate(env, { + userId: cipher.userId, + cipherId: cipher.id, + revisionDate, + organizationId: normalizeOptionalId((cipher as any).organizationId ?? null), + collectionIds: Array.isArray((cipher as any).collectionIds) + ? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean) + : null, + contextId: readActingDeviceIdentifier(request), + }); +} + +function notifyCipherUpdateForRequest( + request: Request, + env: Env, + cipher: Cipher, + revisionDate: string +): void { + notifyUserCipherUpdate(env, { + userId: cipher.userId, + cipherId: cipher.id, + revisionDate, + organizationId: normalizeOptionalId((cipher as any).organizationId ?? null), + collectionIds: Array.isArray((cipher as any).collectionIds) + ? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean) + : null, + contextId: readActingDeviceIdentifier(request), + }); +} + +function notifyCipherDeleteForRequest( + request: Request, + env: Env, + cipher: Cipher, + revisionDate: string +): void { + notifyUserCipherDelete(env, { + userId: cipher.userId, + cipherId: cipher.id, + revisionDate, + organizationId: normalizeOptionalId((cipher as any).organizationId ?? null), + collectionIds: Array.isArray((cipher as any).collectionIds) + ? (cipher as any).collectionIds.map((id: unknown) => String(id || '').trim()).filter(Boolean) + : null, + contextId: readActingDeviceIdentifier(request), + }); +} + function getAliasedProp(source: any, aliases: string[]): { present: boolean; value: any } { if (!source || typeof source !== 'object') return { present: false, value: undefined }; for (const key of aliases) { @@ -815,6 +875,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherCreateForRequest(request, env, cipher, revisionDate); const responseOptions = cipherResponseOptionsForRequest(request); return jsonResponse( @@ -925,6 +986,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionDate); const attachments = await storage.getAttachmentsByCipher(cipher.id); const responseOptions = cipherResponseOptionsForRequest(request); @@ -949,6 +1011,7 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherDeleteForRequest(request, env, cipher, revisionDate); await writeCipherAudit(storage, request, userId, 'cipher.delete.soft', { id: cipher.id, type: cipher.type, @@ -978,6 +1041,7 @@ export async function handleDeleteCipherCompat(request: Request, env: Env, userI await storage.deleteCipher(id, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherDeleteForRequest(request, env, cipher, revisionDate); await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', { id, type: cipher.type, @@ -1005,6 +1069,7 @@ export async function handlePermanentDeleteCipher(request: Request, env: Env, us await storage.deleteCipher(id, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherDeleteForRequest(request, env, cipher, revisionDate); await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent', { id, type: cipher.type, @@ -1029,6 +1094,7 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionDate); return jsonResponse( cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request)) @@ -1068,6 +1134,7 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionDate); return jsonResponse( cipherToResponse(cipher, [], cipherResponseOptionsForRequest(request)) @@ -1144,6 +1211,7 @@ export async function handleArchiveCipher(request: Request, env: Env, userId: st await storage.saveCipher(cipher); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyCipherUpdateForRequest(request, env, cipher, revisionDate); const attachments = await storage.getAttachmentsByCipher(cipher.id); return jsonResponse( @@ -1192,6 +1260,7 @@ export async function handleBulkArchiveCiphers(request: Request, env: Env, userI const revisionDate = await storage.bulkArchiveCiphers(ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } return buildCipherListResponse(request, storage, userId, ids); @@ -1216,6 +1285,7 @@ export async function handleBulkUnarchiveCiphers(request: Request, env: Env, use const revisionDate = await storage.bulkUnarchiveCiphers(ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } return buildCipherListResponse(request, storage, userId, ids); @@ -1239,6 +1309,7 @@ export async function handleBulkDeleteCiphers(request: Request, env: Env, userId const revisionDate = await storage.bulkSoftDeleteCiphers(body.ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); await writeCipherAudit(storage, request, userId, 'cipher.delete.soft.bulk', { count: body.ids.length, }); @@ -1265,6 +1336,7 @@ export async function handleBulkRestoreCiphers(request: Request, env: Env, userI const revisionDate = await storage.bulkRestoreCiphers(body.ids, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } return new Response(null, { status: 204 }); @@ -1301,6 +1373,7 @@ export async function handleBulkPermanentDeleteCiphers(request: Request, env: En const revisionDate = await storage.bulkDeleteCiphers(ownedIds, userId); if (revisionDate) { notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserCiphersSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); await writeCipherAudit(storage, request, userId, 'cipher.delete.permanent.bulk', { count: ownedIds.length, requestedCount: ids.length, diff --git a/src/handlers/folders.ts b/src/handlers/folders.ts index 08ce5a5..84d87e6 100644 --- a/src/handlers/folders.ts +++ b/src/handlers/folders.ts @@ -1,5 +1,10 @@ import { Env, Folder, FolderResponse } from '../types'; -import { notifyUserVaultSync } from '../durable/notifications-hub'; +import { + notifyUserFolderCreate, + notifyUserFolderDelete, + notifyUserFolderUpdate, + notifyUserVaultSync, +} from '../durable/notifications-hub'; import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { readActingDeviceIdentifier } from '../utils/device'; @@ -111,6 +116,12 @@ export async function handleCreateFolder(request: Request, env: Env, userId: str await storage.saveFolder(folder); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserFolderCreate(env, { + userId, + folderId: folder.id, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); return jsonResponse(folderToResponse(folder), 200); } @@ -139,6 +150,12 @@ export async function handleUpdateFolder(request: Request, env: Env, userId: str await storage.saveFolder(folder); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserFolderUpdate(env, { + userId, + folderId: folder.id, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); return jsonResponse(folderToResponse(folder)); } @@ -156,6 +173,12 @@ export async function handleDeleteFolder(request: Request, env: Env, userId: str await storage.deleteFolder(id, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifyUserFolderDelete(env, { + userId, + folderId: id, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); await writeFolderAudit(storage, request, userId, 'folder.delete', { id, }); diff --git a/src/handlers/sends-private.ts b/src/handlers/sends-private.ts index 67daaf2..c8d0700 100644 --- a/src/handlers/sends-private.ts +++ b/src/handlers/sends-private.ts @@ -16,6 +16,9 @@ import { formatSize, getAliasedProp, normalizeEmails, + notifySendCreateForRequest, + notifySendDeleteForRequest, + notifySendUpdateForRequest, notifyVaultSyncForRequest, parseDate, parseFileLength, @@ -249,6 +252,7 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendCreateForRequest(request, env, send.id, userId, revisionDate); return jsonResponse(sendToResponse(send)); } @@ -372,6 +376,7 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId: await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendCreateForRequest(request, env, send.id, userId, revisionDate); const jwtSecret = getSafeJwtSecret(env); if (!jwtSecret) { return errorResponse('Server configuration error', 500); @@ -619,6 +624,7 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendUpdateForRequest(request, env, send.id, userId, revisionDate); return jsonResponse(sendToResponse(send)); } @@ -641,6 +647,7 @@ export async function handleDeleteSend(request: Request, env: Env, userId: strin await storage.deleteSend(sendId, userId); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendDeleteForRequest(request, env, sendId, userId, revisionDate); await writeSendAudit(storage, request, userId, 'send.delete', { id: sendId, type: send.type, @@ -697,6 +704,7 @@ export async function handleRemoveSendPassword(request: Request, env: Env, userI await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendUpdateForRequest(request, env, send.id, userId, revisionDate); await writeSendAudit(storage, request, userId, 'send.password.remove', { id: send.id, type: send.type, @@ -718,6 +726,7 @@ export async function handleRemoveSendAuth(request: Request, env: Env, userId: s await storage.saveSend(send); const revisionDate = await storage.updateRevisionDate(userId); notifyVaultSyncForRequest(request, env, userId, revisionDate); + notifySendUpdateForRequest(request, env, send.id, userId, revisionDate); await writeSendAudit(storage, request, userId, 'send.auth.remove', { id: send.id, type: send.type, diff --git a/src/handlers/sends-shared.ts b/src/handlers/sends-shared.ts index d5d97ff..b513422 100644 --- a/src/handlers/sends-shared.ts +++ b/src/handlers/sends-shared.ts @@ -1,5 +1,10 @@ import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types'; -import { notifyUserVaultSync } from '../durable/notifications-hub'; +import { + notifyUserSendCreate, + notifyUserSendDelete, + notifyUserSendUpdate, + notifyUserVaultSync, +} from '../durable/notifications-hub'; import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { readActingDeviceIdentifier } from '../utils/device'; @@ -18,6 +23,51 @@ export function notifyVaultSyncForRequest( notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); } +export function notifySendCreateForRequest( + request: Request, + env: Env, + sendId: string, + userId: string, + revisionDate: string +): void { + notifyUserSendCreate(env, { + userId, + sendId, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); +} + +export function notifySendUpdateForRequest( + request: Request, + env: Env, + sendId: string, + userId: string, + revisionDate: string +): void { + notifyUserSendUpdate(env, { + userId, + sendId, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); +} + +export function notifySendDeleteForRequest( + request: Request, + env: Env, + sendId: string, + userId: string, + revisionDate: string +): void { + notifyUserSendDelete(env, { + userId, + sendId, + revisionDate, + contextId: readActingDeviceIdentifier(request), + }); +} + export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } { if (!source || typeof source !== 'object') return { present: false, value: undefined }; for (const key of aliases) { diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index cd49fc1..53ddae6 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -136,8 +136,8 @@ const THEME_STORAGE_KEY = 'nodewarden.theme.preference.v1'; const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; -const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; -const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; +const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101; +const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102; type ThemePreference = 'system' | 'light' | 'dark'; type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;