From 79ed7c9f85ec61503d3de68c1fd9a551fa38578d Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 22 Jun 2026 22:09:38 +0800 Subject: [PATCH] Add Bitwarden push relay support --- README.md | 2 +- README_EN.md | 2 +- migrations/0001_init.sql | 3 + src/durable/notifications-hub.ts | 11 ++ src/handlers/devices.ts | 49 ++++- src/handlers/identity.ts | 42 +++++ src/router-devices.ts | 34 ++-- src/services/push-relay.ts | 275 ++++++++++++++++++++++++++++ src/services/storage-device-repo.ts | 69 ++++++- src/services/storage-schema.ts | 5 +- src/services/storage.ts | 29 ++- src/types/index.ts | 2 + 12 files changed, 492 insertions(+), 31 deletions(-) create mode 100644 src/services/push-relay.ts diff --git a/README.md b/README.md index 79715bd..bde5784 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ | **PWA 支持** | ⚠️ 基础 | ✅ | **可安装、离线使用、App快捷方式** | | **Web Vault 离线查看** | ❌ | ✅ | **网页端支持离线查看保险库** | | **Passkey 登录** | ✅ | ✅ | **支持WebAuthn/FIDO2无密码登录** | -| 全量同步 `/api/sync` | ✅ | ✅ | 已针对官方客户端做兼容优化 | +| 实时同步 | ✅ | ✅ | 网页端、浏览器扩展、电脑端和手机端实时同步 | | 附件上传 / 下载 | ✅ | ✅ | Cloudflare R2 或 KV | | Send | ✅ | ✅ | 支持文本与文件 Send | | 导入 / 导出 | ✅ | ✅ | 支持 Bitwarden JSON / CSV / **ZIP 导入(包括附件)** | diff --git a/README_EN.md b/README_EN.md index c60eb82..aaf5c72 100644 --- a/README_EN.md +++ b/README_EN.md @@ -40,7 +40,7 @@ | **PWA Support** | ⚠️ Basic | ✅ | **Installable, offline-capable, app shortcuts** | | **Web Vault Offline Access** | ❌ | ✅ | **Web client supports offline vault viewing** | | **Passkey Login** | ✅ | ✅ | **WebAuthn/FIDO2 passwordless login** | -| Full sync `/api/sync` | ✅ | ✅ | Compatibility optimized for official clients | +| Real-time sync | ✅ | ✅ | Web, browser extension, desktop, and mobile clients stay in sync in real time | | Attachment upload / download | ✅ | ✅ | Cloudflare R2 or KV | | Send | ✅ | ✅ | Supports both text and file Sends | | Import / Export | ✅ | ✅ | Supports Bitwarden JSON / CSV / **ZIP import with attachments** | diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 6755792..668e648 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -176,6 +176,8 @@ CREATE TABLE IF NOT EXISTS devices ( encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, + push_uuid TEXT, + push_token TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, @@ -187,6 +189,7 @@ CREATE TABLE IF NOT EXISTS devices ( ); CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at); CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at); +CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token); CREATE TABLE IF NOT EXISTS auth_requests ( id TEXT PRIMARY KEY, diff --git a/src/durable/notifications-hub.ts b/src/durable/notifications-hub.ts index 0b9c132..f367bb2 100644 --- a/src/durable/notifications-hub.ts +++ b/src/durable/notifications-hub.ts @@ -1,5 +1,6 @@ import { DurableObject, waitUntil } from 'cloudflare:workers'; import type { Env } from '../types'; +import { notifyMobilePush } from '../services/push-relay'; const SIGNALR_RECORD_SEPARATOR = 0x1e; const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARATOR]); @@ -767,6 +768,16 @@ async function notifyUserUpdate( }, }), }); + await notifyMobilePush(env, { + userId, + updateType, + revisionDate, + contextId, + payload: payloadOverride || { + UserId: userId, + Date: revisionDate, + }, + }); } catch (error) { console.error('Failed to broadcast realtime notification:', error); } diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts index 280657d..4a312b6 100644 --- a/src/handlers/devices.ts +++ b/src/handlers/devices.ts @@ -3,6 +3,7 @@ import { Env } from '../types'; import { getOnlineUserDevices, notifyUserLogout } from '../durable/notifications-hub'; import { AuthService } from '../services/auth'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; +import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay'; import { StorageService } from '../services/storage'; import { errorResponse, jsonResponse } from '../utils/response'; import { readKnownDeviceProbe } from '../utils/device'; @@ -223,6 +224,8 @@ export async function handleGetAuthorizedDevices(request: Request, env: Env, use encryptedUserKey: null, encryptedPublicKey: null, encryptedPrivateKey: null, + pushUuid: null, + pushToken: null, devicePendingAuthRequest: null, deviceNote: null, lastSeenAt: null, @@ -325,10 +328,12 @@ export async function handleDeleteDevice( if (!normalized) return errorResponse('Invalid device identifier', 400); const storage = new StorageService(env.DB); + const device = await storage.getDevice(userId, normalized); await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized); if (deleted) { + await unregisterMobilePushDevice(env, device?.pushUuid); AuthService.invalidateDeviceCache(userId, normalized); notifyUserLogout(env, userId, normalized); } @@ -537,10 +542,12 @@ export async function handleDeactivateDevice( if (!normalized) return errorResponse('Invalid device identifier', 400); const storage = new StorageService(env.DB); + const device = await storage.getDevice(userId, normalized); await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized); await storage.deleteRefreshTokensByDevice(userId, normalized); const deleted = await storage.deleteDevice(userId, normalized); if (deleted) { + await unregisterMobilePushDevice(env, device?.pushUuid); AuthService.invalidateDeviceCache(userId, normalized); notifyUserLogout(env, userId, normalized); } @@ -557,18 +564,36 @@ export async function handleDeactivateDevice( } // PUT /api/devices/identifier/{deviceIdentifier}/token -// Bitwarden mobile reports push token updates to this endpoint. -// NodeWarden does not implement push notifications, so accept and no-op. +// Bitwarden mobile reports APNs/FCM push token updates to this endpoint. export async function handleUpdateDeviceToken( request: Request, env: Env, userId: string, deviceIdentifier: string ): Promise { - void request; - void env; - void userId; - void deviceIdentifier; + const normalized = normalizeIdentifier(deviceIdentifier); + if (!normalized) return errorResponse('Invalid device identifier', 400); + + const body = await readJsonBody(request); + const pushToken = String(body?.pushToken ?? body?.PushToken ?? '').trim(); + if (!pushToken) return errorResponse('Invalid push token', 400); + + const storage = new StorageService(env.DB); + const device = await storage.getDevice(userId, normalized); + if (!device) return errorResponse('Device not found', 404); + + const pushUuid = device.pushUuid || generateUUID(); + const updated = await storage.updateDevicePushToken(userId, normalized, pushUuid, pushToken); + if (updated) { + await registerMobilePushDevice(env, { + userId, + deviceIdentifier: normalized, + type: device.type, + pushUuid, + pushToken, + }); + } + return new Response(null, { status: 200 }); } @@ -594,9 +619,15 @@ export async function handleClearDeviceToken( deviceIdentifier: string ): Promise { void request; - void env; - void userId; - void deviceIdentifier; + const normalized = normalizeIdentifier(deviceIdentifier); + if (!normalized) return errorResponse('Invalid device identifier', 400); + + const storage = new StorageService(env.DB); + const cleared = await storage.clearDevicePushToken(userId, normalized); + if (cleared?.pushUuid) { + await unregisterMobilePushDevice(env, cleared.pushUuid); + } + return new Response(null, { status: 200 }); } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 0701c2e..3a15102 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -10,6 +10,7 @@ import { readAuthRequestDeviceInfo } from '../utils/device'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { generateUUID } from '../utils/uuid'; import { issueSendAccessToken } from './sends'; +import { registerMobilePushDevice } from '../services/push-relay'; import { buildAccountKeys, buildUserDecryptionOptions, @@ -50,6 +51,44 @@ async function resolveDeviceSession( return { identifier: deviceInfo.deviceIdentifier, sessionStamp }; } +function readDevicePushToken(body: Record): string { + return String(readBodyValue(body, ['devicePushToken', 'DevicePushToken', 'device_push_token']) || '').trim(); +} + +async function persistIdentityDevicePushToken( + env: Env, + storage: StorageService, + userId: string, + deviceSession: { identifier: string; sessionStamp: string } | null, + deviceType: number, + body: Record +): Promise { + if (!deviceSession) return; + const pushToken = readDevicePushToken(body); + if (!pushToken) return; + + const device = await storage.getDevice(userId, deviceSession.identifier); + if (!device) return; + + const pushUuid = device.pushUuid || generateUUID(); + await storage.updateDevicePushToken(userId, deviceSession.identifier, pushUuid, pushToken); + const registered = await registerMobilePushDevice(env, { + userId, + deviceIdentifier: deviceSession.identifier, + type: device.type || deviceType, + pushUuid, + pushToken, + }); + console.info('Mobile push token updated from identity token request', { + userId, + deviceIdentifier: deviceSession.identifier, + deviceType: device.type || deviceType, + pushUuid, + pushTokenLength: pushToken.length, + relayRegistered: registered, + }); +} + function shouldUseWebSession(request: Request): boolean { return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1'; } @@ -414,6 +453,7 @@ export async function handleToken(request: Request, env: Env): Promise deviceInfo.deviceType, deviceSession.sessionStamp ); + await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } // Successful login - clear failed attempts @@ -536,6 +576,7 @@ export async function handleToken(request: Request, env: Env): Promise deviceInfo.deviceType, deviceSession.sessionStamp ); + await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } await rateLimit.clearLoginAttempts(loginIdentifier); @@ -664,6 +705,7 @@ export async function handleToken(request: Request, env: Env): Promise deviceInfo.deviceType, deviceSession.sessionStamp ); + await persistIdentityDevicePushToken(env, storage, user.id, deviceSession, deviceInfo.deviceType, body); } // Successful login - clear failed attempts diff --git a/src/router-devices.ts b/src/router-devices.ts index c6730c1..195e110 100644 --- a/src/router-devices.ts +++ b/src/router-devices.ts @@ -20,6 +20,10 @@ import { handleClearDeviceToken, } from './handlers/devices'; +function devicesPath(pattern: string): RegExp { + return new RegExp(`^/(?:api/)?devices${pattern}$`, 'i'); +} + export async function handleAuthenticatedDeviceRoute( request: Request, env: Env, @@ -27,31 +31,31 @@ export async function handleAuthenticatedDeviceRoute( path: string, method: string ): Promise { - if (path === '/api/devices') { + if (path === '/api/devices' || path === '/devices') { if (method === 'GET') return handleGetDevices(request, env, userId); if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId); return null; } - if (path === '/api/devices/authorized') { + if (path === '/api/devices/authorized' || path === '/devices/authorized') { if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId); if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId); return null; } - const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i); + const authorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)')); if (authorizedDeviceMatch && method === 'DELETE') { const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]); return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier); } - const permanentAuthorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)\/permanent$/i); + const permanentAuthorizedDeviceMatch = path.match(devicesPath('/authorized/([^/]+)/permanent')); if (permanentAuthorizedDeviceMatch && method === 'POST') { const deviceIdentifier = decodeURIComponent(permanentAuthorizedDeviceMatch[1]); return handleTrustDevicePermanently(request, env, userId, deviceIdentifier); } - const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i); + const deleteDeviceMatch = path.match(devicesPath('/([^/]+)')); if (deleteDeviceMatch && method === 'GET') { const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]); return handleGetDevice(request, env, userId, deviceIdentifier); @@ -61,59 +65,59 @@ export async function handleAuthenticatedDeviceRoute( return handleDeleteDevice(request, env, userId, deviceIdentifier); } - const updateDeviceNameMatch = path.match(/^\/api\/devices\/([^/]+)\/name$/i); + const updateDeviceNameMatch = path.match(devicesPath('/([^/]+)/name')); if (updateDeviceNameMatch && method === 'PUT') { const deviceIdentifier = decodeURIComponent(updateDeviceNameMatch[1]); return handleUpdateDeviceName(request, env, userId, deviceIdentifier); } - const identifierMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)$/i); + const identifierMatch = path.match(devicesPath('/identifier/([^/]+)')); if (identifierMatch && method === 'GET') { const deviceIdentifier = decodeURIComponent(identifierMatch[1]); return handleGetDeviceByIdentifier(request, env, userId, deviceIdentifier); } - const deviceKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/keys$/i) || path.match(/^\/api\/devices\/identifier\/([^/]+)\/keys$/i); + const deviceKeysMatch = path.match(devicesPath('/([^/]+)/keys')) || path.match(devicesPath('/identifier/([^/]+)/keys')); if (deviceKeysMatch && (method === 'PUT' || method === 'POST')) { const deviceIdentifier = decodeURIComponent(deviceKeysMatch[1]); return handleUpdateDeviceKeys(request, env, userId, deviceIdentifier); } - const identifierTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i); + const identifierTokenMatch = path.match(devicesPath('/identifier/([^/]+)/token')); if (identifierTokenMatch && (method === 'PUT' || method === 'POST')) { const deviceIdentifier = decodeURIComponent(identifierTokenMatch[1]); return handleUpdateDeviceToken(request, env, userId, deviceIdentifier); } - const identifierWebPushMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/web-push-auth$/i); + const identifierWebPushMatch = path.match(devicesPath('/identifier/([^/]+)/web-push-auth')); if (identifierWebPushMatch && (method === 'PUT' || method === 'POST')) { const deviceIdentifier = decodeURIComponent(identifierWebPushMatch[1]); return handleUpdateDeviceWebPushAuth(request, env, userId, deviceIdentifier); } - const identifierClearTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/clear-token$/i); + const identifierClearTokenMatch = path.match(devicesPath('/identifier/([^/]+)/clear-token')); if (identifierClearTokenMatch && (method === 'PUT' || method === 'POST')) { const deviceIdentifier = decodeURIComponent(identifierClearTokenMatch[1]); return handleClearDeviceToken(request, env, userId, deviceIdentifier); } - const identifierRetrieveKeysMatch = path.match(/^\/api\/devices\/([^/]+)\/retrieve-keys$/i); + const identifierRetrieveKeysMatch = path.match(devicesPath('/([^/]+)/retrieve-keys')); if (identifierRetrieveKeysMatch && method === 'POST') { const deviceIdentifier = decodeURIComponent(identifierRetrieveKeysMatch[1]); return handleRetrieveDeviceKeys(request, env, userId, deviceIdentifier); } - const identifierDeactivateMatch = path.match(/^\/api\/devices\/([^/]+)\/deactivate$/i); + const identifierDeactivateMatch = path.match(devicesPath('/([^/]+)/deactivate')); if (identifierDeactivateMatch && (method === 'POST' || method === 'DELETE')) { const deviceIdentifier = decodeURIComponent(identifierDeactivateMatch[1]); return handleDeactivateDevice(request, env, userId, deviceIdentifier); } - if (path === '/api/devices/update-trust' && method === 'POST') { + if ((path === '/api/devices/update-trust' || path === '/devices/update-trust') && method === 'POST') { return handleUpdateDeviceTrust(request, env, userId); } - if (path === '/api/devices/untrust' && method === 'POST') { + if ((path === '/api/devices/untrust' || path === '/devices/untrust') && method === 'POST') { return handleUntrustDevices(request, env, userId); } diff --git a/src/services/push-relay.ts b/src/services/push-relay.ts new file mode 100644 index 0000000..e65cab0 --- /dev/null +++ b/src/services/push-relay.ts @@ -0,0 +1,275 @@ +import type { Env } from '../types'; +import { + setConfigValue as saveConfigValue, +} from './storage-config-repo'; + +const PUSH_RELAY_URI = 'https://push.bitwarden.com'; +const PUSH_IDENTITY_URI = 'https://identity.bitwarden.com'; +const INSTALLATIONS_URI = 'https://api.bitwarden.com/installations'; +const PUSH_INSTALLATION_ID_KEY = 'push.installation.id'; +const PUSH_INSTALLATION_KEY_KEY = 'push.installation.key'; +const PUSH_REQUEST_TIMEOUT_MS = 5000; + +interface CachedPushAccessToken { + token: string; + expiresAt: number; +} + +let cachedPushAccessToken: CachedPushAccessToken | null = null; + +async function fetchPushEndpoint(url: string, init: RequestInit, errorMessage: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PUSH_REQUEST_TIMEOUT_MS); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } catch (error) { + console.error(errorMessage, error); + return null; + } finally { + clearTimeout(timeout); + } +} + +function randomInstallationEmail(): string { + const bytes = new Uint8Array(10); + crypto.getRandomValues(bytes); + const localPart = Array.from(bytes, (byte) => (byte % 36).toString(36)).join(''); + return `${localPart}@nodewarden.app`; +} + +async function getConfigKeyPresence(db: D1Database, key: string): Promise { + const row = await db.prepare('SELECT value FROM config WHERE key = ? LIMIT 1').bind(key).first<{ value: string }>(); + return typeof row?.value === 'string' ? row.value : null; +} + +async function getPushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> { + const [id, key] = await Promise.all([ + getConfigKeyPresence(db, PUSH_INSTALLATION_ID_KEY), + getConfigKeyPresence(db, PUSH_INSTALLATION_KEY_KEY), + ]); + const normalizedId = String(id || '').trim(); + const normalizedKey = String(key || '').trim(); + return normalizedId && normalizedKey ? { id: normalizedId, key: normalizedKey } : null; +} + +export async function ensurePushInstallationCredentials(db: D1Database): Promise<{ id: string; key: string } | null> { + const existing = await getPushInstallationCredentials(db); + if (existing) return existing; + + const response = await fetchPushEndpoint( + INSTALLATIONS_URI, + { + method: 'POST', + headers: { + accept: 'application/json', + 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'cache-control': 'no-cache', + 'content-type': 'application/json', + origin: 'https://bitwarden.com', + pragma: 'no-cache', + priority: 'u=1, i', + referer: 'https://bitwarden.com/host/', + 'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-site', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36', + }, + body: JSON.stringify({ + formName: 'request_host', + url: '/host/', + locale: 'zh-CN', + email: randomInstallationEmail(), + region: 'us', + }), + }, + 'Failed to request Bitwarden push installation:' + ); + if (!response) return null; + + if (!response.ok) { + console.error('Failed to request Bitwarden push installation:', response.status, await response.text().catch(() => '')); + return null; + } + + const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null; + const id = String(body?.id || '').trim(); + const key = String(body?.key || '').trim(); + if (!id || !key) { + console.error('Bitwarden push installation response did not include id/key'); + return null; + } + + await Promise.all([ + saveConfigValue(db, PUSH_INSTALLATION_ID_KEY, id), + saveConfigValue(db, PUSH_INSTALLATION_KEY_KEY, key), + ]); + return { id, key }; +} + +async function getPushAccessToken(env: Env): Promise { + const credentials = await ensurePushInstallationCredentials(env.DB); + if (!credentials) return null; + + const now = Date.now(); + if (cachedPushAccessToken && cachedPushAccessToken.expiresAt > now + 30_000) { + return cachedPushAccessToken.token; + } + + const params = new URLSearchParams({ + grant_type: 'client_credentials', + scope: 'api.push', + client_id: `installation.${credentials.id}`, + client_secret: credentials.key, + }); + + const response = await fetchPushEndpoint( + `${PUSH_IDENTITY_URI}/connect/token`, + { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }, + 'Failed to get Bitwarden push relay token:' + ); + if (!response) return null; + + if (!response.ok) { + console.error('Failed to get Bitwarden push relay token:', response.status, await response.text().catch(() => '')); + return null; + } + + const body = (await response.json().catch(() => null)) as { access_token?: string; expires_in?: number } | null; + const token = String(body?.access_token || '').trim(); + if (!token) { + console.error('Bitwarden push relay token response did not include an access_token'); + return null; + } + + const expiresInSeconds = Math.max(60, Number(body?.expires_in || 3600)); + cachedPushAccessToken = { + token, + expiresAt: now + Math.floor(expiresInSeconds * 500), + }; + return token; +} + +async function postToPushRelay(env: Env, path: string, body?: unknown): Promise { + const token = await getPushAccessToken(env); + if (!token) return false; + + const response = await fetchPushEndpoint( + `${PUSH_RELAY_URI}${path}`, + { + method: 'POST', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + ...(body === undefined ? {} : { 'content-type': 'application/json' }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }, + `Bitwarden push relay request failed: ${path}` + ); + if (!response) return false; + + if (!response.ok) { + console.error('Bitwarden push relay request failed:', path, response.status, await response.text().catch(() => '')); + return false; + } + + return true; +} + +function mobilePayloadFromSignalR(updateType: number, userId: string, revisionDate: string, payload: Record | null | undefined): Record { + const source = payload || {}; + const id = source.Id ?? source.id; + const organizationId = source.OrganizationId ?? source.organizationId ?? null; + const collectionIds = source.CollectionIds ?? source.collectionIds ?? null; + + if (id != null) { + return { + id, + userId: source.UserId ?? source.userId ?? userId, + organizationId, + collectionIds, + revisionDate: source.RevisionDate ?? source.revisionDate ?? revisionDate, + }; + } + + return { + userId: source.UserId ?? source.userId ?? userId, + date: source.Date ?? source.date ?? revisionDate, + }; +} + +export async function registerMobilePushDevice( + env: Env, + input: { + userId: string; + deviceIdentifier: string; + type: number; + pushUuid: string; + pushToken: string; + } +): Promise { + const credentials = await ensurePushInstallationCredentials(env.DB); + if (!credentials) return false; + + return postToPushRelay(env, '/push/register', { + deviceId: input.pushUuid, + pushToken: input.pushToken, + userId: input.userId, + type: input.type, + identifier: input.deviceIdentifier, + installationId: credentials.id, + }); +} + +export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise { + const normalized = String(pushUuid || '').trim(); + if (!normalized) return false; + return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`); +} + +export async function notifyMobilePush( + env: Env, + input: { + userId: string; + updateType: number; + revisionDate: string; + contextId: string | null; + payload: Record | null | undefined; + } +): Promise { + const hasPushDevice = await env.DB + .prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1') + .bind(input.userId, '') + .first<{ '1': number }>(); + if (!hasPushDevice) return; + + let actingPushUuid: string | null = null; + if (input.contextId) { + const row = await env.DB + .prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') + .bind(input.userId, input.contextId) + .first<{ push_uuid: string | null }>(); + actingPushUuid = row?.push_uuid ?? null; + } + + await postToPushRelay(env, '/push/send', { + userId: input.userId, + organizationId: null, + deviceId: actingPushUuid, + identifier: input.contextId, + type: input.updateType, + payload: mobilePayloadFromSignalR(input.updateType, input.userId, input.revisionDate, input.payload), + clientType: null, + installationId: null, + }); +} diff --git a/src/services/storage-device-repo.ts b/src/services/storage-device-repo.ts index 1a93cfd..3bd33fa 100644 --- a/src/services/storage-device-repo.ts +++ b/src/services/storage-device-repo.ts @@ -1,4 +1,5 @@ import type { Device, TrustedDeviceTokenSummary, User } from '../types'; +import { generateUUID } from '../utils/uuid'; type GetUserByEmail = (email: string) => Promise; type TrustedTokenKeyFn = (token: string) => Promise; @@ -14,6 +15,8 @@ function mapDeviceRow(row: any): Device { encryptedUserKey: row.encrypted_user_key ?? null, encryptedPublicKey: row.encrypted_public_key ?? null, encryptedPrivateKey: row.encrypted_private_key ?? null, + pushUuid: row.push_uuid ?? null, + pushToken: row.push_token ?? null, lastSeenAt: row.last_seen_at ?? null, createdAt: row.created_at, updatedAt: row.updated_at, @@ -38,13 +41,15 @@ export async function upsertDevice( const existingDevice = await getDeviceById(userId, deviceIdentifier); const effectiveSessionStamp = String(sessionStamp || '').trim() || existingDevice?.sessionStamp || ''; const effectiveName = String(name || '').trim() || String(existingDevice?.name || '').trim(); + const effectivePushUuid = String(existingDevice?.pushUuid || '').trim() || generateUUID(); await db .prepare( - 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, banned, banned_at, device_note, last_seen_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?) ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, ' + 'encrypted_user_key=COALESCE(excluded.encrypted_user_key, encrypted_user_key), ' + 'encrypted_public_key=COALESCE(excluded.encrypted_public_key, encrypted_public_key), ' + 'encrypted_private_key=COALESCE(excluded.encrypted_private_key, encrypted_private_key), ' + + 'push_uuid=COALESCE(push_uuid, excluded.push_uuid), ' + 'last_seen_at=excluded.last_seen_at, ' + 'updated_at=excluded.updated_at' ) @@ -57,6 +62,7 @@ export async function upsertDevice( keys?.encryptedUserKey ?? null, keys?.encryptedPublicKey ?? null, keys?.encryptedPrivateKey ?? null, + effectivePushUuid, existingDevice?.deviceNote ?? null, now, now, @@ -166,7 +172,7 @@ export async function isKnownDeviceByEmail( export async function getDevicesByUserId(db: D1Database, userId: string): Promise { const res = await db .prepare( - 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + 'FROM devices WHERE user_id = ? ORDER BY COALESCE(last_seen_at, created_at) DESC, updated_at DESC' ) .bind(userId) @@ -177,7 +183,7 @@ export async function getDevicesByUserId(db: D1Database, userId: string): Promis export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { const row = await db .prepare( - 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + + 'SELECT user_id, device_identifier, name, type, session_stamp, encrypted_user_key, encrypted_public_key, encrypted_private_key, push_uuid, push_token, banned, banned_at, device_note, last_seen_at, created_at, updated_at ' + 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' ) .bind(userId, deviceIdentifier) @@ -185,6 +191,63 @@ export async function getDevice(db: D1Database, userId: string, deviceIdentifier return row ? mapDeviceRow(row) : null; } +export async function updateDevicePushToken( + db: D1Database, + userId: string, + deviceIdentifier: string, + pushUuid: string, + pushToken: string +): Promise { + const now = new Date().toISOString(); + const result = await db + .prepare( + 'UPDATE devices SET push_uuid = ?, push_token = ?, updated_at = ? ' + + 'WHERE user_id = ? AND device_identifier = ?' + ) + .bind(pushUuid, pushToken, now, userId, deviceIdentifier) + .run(); + return Number(result.meta.changes ?? 0) > 0; +} + +export async function clearDevicePushToken( + db: D1Database, + userId: string, + deviceIdentifier: string +): Promise<{ pushUuid: string | null } | null> { + const existing = await db + .prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') + .bind(userId, deviceIdentifier) + .first<{ push_uuid: string | null }>(); + if (!existing) return null; + + await db + .prepare('UPDATE devices SET push_token = NULL, updated_at = ? WHERE user_id = ? AND device_identifier = ?') + .bind(new Date().toISOString(), userId, deviceIdentifier) + .run(); + + return { pushUuid: existing.push_uuid ?? null }; +} + +export async function getDevicePushUuid( + db: D1Database, + userId: string, + deviceIdentifier: string +): Promise { + const row = await db + .prepare('SELECT push_uuid FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') + .bind(userId, deviceIdentifier) + .first<{ push_uuid: string | null }>(); + return row?.push_uuid ?? null; +} + +export async function userHasPushDevice(db: D1Database, userId: string): Promise { + const row = await db + .prepare('SELECT 1 FROM devices WHERE user_id = ? AND push_token IS NOT NULL AND push_token <> ? LIMIT 1') + .bind(userId, '') + .first<{ '1': number }>(); + return !!row; +} + export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { const result = await db .prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?') diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index babb48e..bdf6458 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -94,7 +94,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE INDEX IF NOT EXISTS idx_audit_logs_level_created ON audit_logs(level, created_at)', 'CREATE TABLE IF NOT EXISTS devices (' + - 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' + + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, push_uuid TEXT, push_token TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, device_note TEXT, last_seen_at TEXT, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'PRIMARY KEY (user_id, device_identifier), ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', @@ -103,11 +103,14 @@ const SCHEMA_STATEMENTS: readonly string[] = [ 'ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT', 'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT', + 'ALTER TABLE devices ADD COLUMN push_uuid TEXT', + 'ALTER TABLE devices ADD COLUMN push_token TEXT', 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', 'ALTER TABLE devices ADD COLUMN banned_at TEXT', 'ALTER TABLE devices ADD COLUMN device_note TEXT', 'ALTER TABLE devices ADD COLUMN last_seen_at TEXT', 'CREATE INDEX IF NOT EXISTS idx_devices_user_last_seen ON devices(user_id, last_seen_at)', + 'CREATE INDEX IF NOT EXISTS idx_devices_user_push ON devices(user_id, push_token)', 'CREATE TABLE IF NOT EXISTS auth_requests (' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, organization_id TEXT, type INTEGER NOT NULL, request_device_identifier TEXT NOT NULL, request_device_type INTEGER NOT NULL, ' + diff --git a/src/services/storage.ts b/src/services/storage.ts index fc23b73..980fc43 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,5 +1,6 @@ import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential, AuthRequestRecord } from '../types'; import { LIMITS } from '../config/limits'; +import { ensurePushInstallationCredentials } from './push-relay'; import { ensureStorageSchema } from './storage-schema'; import { getConfigValue as getStoredConfigValue, @@ -87,10 +88,12 @@ import { import { deleteDevice as deleteStoredDevice, deleteDevicesByUserId as deleteStoredDevicesByUserId, + clearDevicePushToken as clearStoredDevicePushToken, clearDeviceKeys as clearStoredDeviceKeys, deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice, deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId, getDevice as findStoredDevice, + getDevicePushUuid as findStoredDevicePushUuid, getDevicesByUserId as listStoredDevicesByUserId, getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries, getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId, @@ -101,7 +104,9 @@ import { upsertDevice as saveStoredDevice, updateDeviceName as updateStoredDeviceName, updateDeviceKeys as updateStoredDeviceKeys, + updateDevicePushToken as updateStoredDevicePushToken, updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice, + userHasPushDevice as getUserHasPushDevice, } from './storage-device-repo'; import { createAuthRequest as createStoredAuthRequest, @@ -143,7 +148,7 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // changes. Existing D1 installs only rerun ensureStorageSchema() when this value // differs from config.schema.version. -const STORAGE_SCHEMA_VERSION = '2026-06-12-auth-requests'; +const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications'; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const; // D1-backed storage. @@ -235,6 +240,7 @@ export class StorageService { await ensureStorageSchema(this.db); await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION); } + await ensurePushInstallationCredentials(this.db); StorageService.schemaVerified = true; } @@ -713,6 +719,27 @@ export class StorageService { return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier); } + async updateDevicePushToken( + userId: string, + deviceIdentifier: string, + pushUuid: string, + pushToken: string + ): Promise { + return updateStoredDevicePushToken(this.db, userId, deviceIdentifier, pushUuid, pushToken); + } + + async clearDevicePushToken(userId: string, deviceIdentifier: string): Promise<{ pushUuid: string | null } | null> { + return clearStoredDevicePushToken(this.db, userId, deviceIdentifier); + } + + async getDevicePushUuid(userId: string, deviceIdentifier: string): Promise { + return findStoredDevicePushUuid(this.db, userId, deviceIdentifier); + } + + async userHasPushDevice(userId: string): Promise { + return getUserHasPushDevice(this.db, userId); + } + async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise { return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers); } diff --git a/src/types/index.ts b/src/types/index.ts index 245773b..8ffe519 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -231,6 +231,8 @@ export interface Device { encryptedUserKey: string | null; encryptedPublicKey: string | null; encryptedPrivateKey: string | null; + pushUuid: string | null; + pushToken: string | null; devicePendingAuthRequest?: DevicePendingAuthRequest | null; lastSeenAt: string | null; createdAt: string;