diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index bbd586a..7cbdf6f 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -7,6 +7,7 @@ import { generateUUID } from '../utils/uuid'; import { LIMITS } from '../config/limits'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; +import { buildAccountKeys } from '../utils/user-decryption'; function looksLikeEncString(value: string): boolean { if (!value) return false; @@ -61,6 +62,7 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | } function toProfile(user: User, env: Env): ProfileResponse { + void env; return { id: user.id, name: user.name, @@ -74,7 +76,7 @@ function toProfile(user: User, env: Env): ProfileResponse { twoFactorEnabled: !!user.totpSecret, key: user.key, privateKey: user.privateKey, - accountKeys: null, + accountKeys: buildAccountKeys(user), securityStamp: user.securityStamp || user.id, organizations: [], providers: [], diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index 276da2a..06d5c02 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -3,7 +3,7 @@ import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt'; -import { cipherToResponse } from './ciphers'; +import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers'; import { LIMITS } from '../config/limits'; import { deleteBlobObject, @@ -84,7 +84,9 @@ export async function handleCreateAttachment( attachmentId: attachmentId, url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`, fileUploadType: 0, // Direct upload - cipherResponse: cipherToResponse(updatedCipher!, attachments), + cipherResponse: cipherToResponse(updatedCipher!, attachments, { + omitFido2Credentials: shouldOmitPasskeysForResponse(request), + }), }); } @@ -309,7 +311,9 @@ export async function handleDeleteAttachment( const attachments = await storage.getAttachmentsByCipher(cipherId); return jsonResponse({ - cipher: cipherToResponse(updatedCipher!, attachments), + cipher: cipherToResponse(updatedCipher!, attachments, { + omitFido2Credentials: shouldOmitPasskeysForResponse(request), + }), }); } diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts new file mode 100644 index 0000000..91e8e11 --- /dev/null +++ b/src/handlers/backup.ts @@ -0,0 +1,678 @@ +import { zipSync, unzipSync } from 'fflate'; +import { Env, User } from '../types'; +import { StorageService } from '../services/storage'; +import { errorResponse, jsonResponse } from '../utils/response'; +import { generateUUID } from '../utils/uuid'; +import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobObject, getBlobStorageKind, getSendFileObjectKey, putBlobObject } from '../services/blob-store'; + +type SqlRow = Record; + +interface BackupManifest { + formatVersion: 1; + exportedAt: string; + appVersion: string; + storageKind: 'r2' | 'kv' | null; + tableCounts: Record; + includes: { + attachments: boolean; + sendFiles: boolean; + }; + blobSummary: { + attachmentFiles: number; + sendFiles: number; + totalBytes: number; + largestObjectBytes: number; + }; +} + +interface BackupPayload { + manifest: BackupManifest; + db: { + config: SqlRow[]; + users: SqlRow[]; + user_revisions: SqlRow[]; + folders: SqlRow[]; + ciphers: SqlRow[]; + attachments: SqlRow[]; + sends: SqlRow[]; + }; +} + +function isAdmin(user: User): boolean { + return user.role === 'admin' && user.status === 'active'; +} + +async function writeAuditLog( + storage: StorageService, + actorUserId: string | null, + action: string, + targetType: string | null, + targetId: string | null, + metadata: Record | null +): Promise { + await storage.createAuditLog({ + id: generateUUID(), + actorUserId, + action, + targetType, + targetId, + metadata: metadata ? JSON.stringify(metadata) : null, + createdAt: new Date().toISOString(), + }); +} + +async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise { + const result = await db.prepare(sql).bind(...values).all(); + return (result.results || []).map((row) => ({ ...row })); +} + +async function streamToBytes(stream: ReadableStream | null): Promise { + if (!stream) return new Uint8Array(); + const buffer = await new Response(stream).arrayBuffer(); + return new Uint8Array(buffer); +} + +function parseSendFileId(data: string | null): string | null { + if (!data) return null; + try { + const parsed = JSON.parse(data) as Record; + return typeof parsed.id === 'string' && parsed.id.trim() ? parsed.id.trim() : null; + } catch { + return null; + } +} + +function buildBackupFileName(date: Date = new Date()): string { + const parts = [ + date.getUTCFullYear().toString().padStart(4, '0'), + (date.getUTCMonth() + 1).toString().padStart(2, '0'), + date.getUTCDate().toString().padStart(2, '0'), + date.getUTCHours().toString().padStart(2, '0'), + date.getUTCMinutes().toString().padStart(2, '0'), + date.getUTCSeconds().toString().padStart(2, '0'), + ]; + return `nodewarden_instance_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}.zip`; +} + +async function ensureImportTargetIsFresh(db: D1Database): Promise { + const counts = await Promise.all([ + db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(), + db.prepare('SELECT COUNT(*) AS count FROM folders').first<{ count: number }>(), + db.prepare('SELECT COUNT(*) AS count FROM attachments').first<{ count: number }>(), + db.prepare('SELECT COUNT(*) AS count FROM sends').first<{ count: number }>(), + ]); + const total = counts.reduce((sum, row) => sum + Number(row?.count || 0), 0); + if (total > 0) { + throw new Error('Backup import requires a fresh instance with no vault or send data'); + } +} + +async function clearExistingBlobFiles(env: Env, db: D1Database): Promise { + const attachmentRows = await queryRows( + db, + `SELECT a.id, a.cipher_id + FROM attachments a + INNER JOIN ciphers c ON c.id = a.cipher_id` + ); + for (const row of attachmentRows) { + const cipherId = String(row.cipher_id || '').trim(); + const attachmentId = String(row.id || '').trim(); + if (!cipherId || !attachmentId) continue; + await deleteBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId)); + } + + const sendRows = await queryRows(db, 'SELECT id, data FROM sends'); + for (const row of sendRows) { + const sendId = String(row.id || '').trim(); + const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null); + if (!sendId || !fileId) continue; + await deleteBlobObject(env, getSendFileObjectKey(sendId, fileId)); + } +} + +async function resetImportTarget(db: D1Database): Promise { + const statements = [ + 'DELETE FROM attachments', + 'DELETE FROM ciphers', + 'DELETE FROM folders', + 'DELETE FROM sends', + 'DELETE FROM trusted_two_factor_device_tokens', + 'DELETE FROM devices', + 'DELETE FROM refresh_tokens', + 'DELETE FROM invites', + 'DELETE FROM audit_logs', + 'DELETE FROM user_revisions', + 'DELETE FROM users', + 'DELETE FROM config', + 'DELETE FROM login_attempts_ip', + 'DELETE FROM api_rate_limits', + 'DELETE FROM used_attachment_download_tokens', + ].map((sql) => db.prepare(sql)); + await db.batch(statements); +} + +function getRequiredZipEntries(db: BackupPayload['db']): string[] { + const entries: string[] = []; + for (const row of db.attachments) { + const cipherId = String(row.cipher_id || '').trim(); + const attachmentId = String(row.id || '').trim(); + if (!cipherId || !attachmentId) continue; + entries.push(`attachments/${cipherId}/${attachmentId}.bin`); + } + for (const row of db.sends) { + const sendId = String(row.id || '').trim(); + const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null); + if (!sendId || !fileId) continue; + entries.push(`send-files/${sendId}/${fileId}.bin`); + } + return entries; +} + +function parseBackupArchive(bytes: Uint8Array): { payload: BackupPayload; files: Record } { + let zipped: Record; + try { + zipped = unzipSync(bytes); + } catch { + throw new Error('Invalid backup archive'); + } + + const manifestBytes = zipped['manifest.json']; + const dbBytes = zipped['db.json']; + if (!manifestBytes || !dbBytes) { + throw new Error('Backup archive is missing manifest.json or db.json'); + } + + const decoder = new TextDecoder(); + let manifest: BackupManifest; + let db: BackupPayload['db']; + try { + manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest; + db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db']; + } catch { + throw new Error('Backup archive contains invalid JSON metadata'); + } + + if (manifest?.formatVersion !== 1) { + throw new Error('Unsupported backup format version'); + } + if (!db || typeof db !== 'object') { + throw new Error('Backup archive database payload is invalid'); + } + + const requiredEntries = getRequiredZipEntries(db); + for (const entry of requiredEntries) { + if (!zipped[entry]) { + throw new Error(`Backup archive is missing required file: ${entry}`); + } + } + + return { + payload: { manifest, db }, + files: zipped, + }; +} + +function ensureRowArray(value: unknown, table: string): SqlRow[] { + if (!Array.isArray(value)) { + throw new Error(`Backup archive table ${table} is invalid`); + } + return value as SqlRow[]; +} + +function validateBackupPayloadContents(payload: BackupPayload, files: Record): void { + const configRows = ensureRowArray(payload.db.config, 'config'); + const userRows = ensureRowArray(payload.db.users, 'users'); + const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions'); + const folderRows = ensureRowArray(payload.db.folders, 'folders'); + const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers'); + const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments'); + const sendRows = ensureRowArray(payload.db.sends, 'sends'); + + const userIds = new Set(); + for (const row of userRows) { + const id = String(row.id || '').trim(); + const email = String(row.email || '').trim(); + if (!id || !email) throw new Error('Backup archive contains an invalid user row'); + if (userIds.has(id)) throw new Error(`Backup archive contains duplicate user id: ${id}`); + userIds.add(id); + } + + for (const row of configRows) { + const key = String(row.key || '').trim(); + if (!key) throw new Error('Backup archive contains an invalid config row'); + } + + for (const row of revisionRows) { + const userId = String(row.user_id || '').trim(); + if (!userId || !userIds.has(userId)) { + throw new Error(`Backup archive contains a revision for an unknown user: ${userId || '(empty)'}`); + } + } + + const folderIds = new Set(); + for (const row of folderRows) { + const id = String(row.id || '').trim(); + const userId = String(row.user_id || '').trim(); + if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid folder row'); + if (folderIds.has(id)) throw new Error(`Backup archive contains duplicate folder id: ${id}`); + folderIds.add(id); + } + + const cipherIds = new Set(); + for (const row of cipherRows) { + const id = String(row.id || '').trim(); + const userId = String(row.user_id || '').trim(); + const folderId = String(row.folder_id || '').trim(); + if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid cipher row'); + if (folderId && !folderIds.has(folderId)) { + throw new Error(`Backup archive contains a cipher that references a missing folder: ${id}`); + } + if (cipherIds.has(id)) throw new Error(`Backup archive contains duplicate cipher id: ${id}`); + cipherIds.add(id); + } + + const attachmentIds = new Set(); + for (const row of attachmentRows) { + const id = String(row.id || '').trim(); + const cipherId = String(row.cipher_id || '').trim(); + if (!id || !cipherIds.has(cipherId)) throw new Error('Backup archive contains an invalid attachment row'); + if (attachmentIds.has(id)) throw new Error(`Backup archive contains duplicate attachment id: ${id}`); + attachmentIds.add(id); + + const path = `attachments/${cipherId}/${id}.bin`; + const entry = files[path]; + if (!(entry instanceof Uint8Array)) { + throw new Error(`Backup archive is missing required file: ${path}`); + } + } + + const sendIds = new Set(); + for (const row of sendRows) { + const id = String(row.id || '').trim(); + const userId = String(row.user_id || '').trim(); + if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid send row'); + if (sendIds.has(id)) throw new Error(`Backup archive contains duplicate send id: ${id}`); + sendIds.add(id); + + const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null); + if (!fileId) continue; + const path = `send-files/${id}/${fileId}.bin`; + const entry = files[path]; + if (!(entry instanceof Uint8Array)) { + throw new Error(`Backup archive is missing required file: ${path}`); + } + } +} + +function validateImportBlobLimits(env: Env, payload: BackupPayload, files: Record): void { + const storageKind = getBlobStorageKind(env); + const hasBlobFiles = + payload.db.attachments.length > 0 || + payload.db.sends.some((row) => !!parseSendFileId(typeof row.data === 'string' ? row.data : null)); + + if (!storageKind && hasBlobFiles) { + throw new Error('Backup contains files but attachment storage is not configured on the target instance'); + } + + if (storageKind !== 'kv') return; + + let largestObjectBytes = 0; + for (const row of payload.db.attachments) { + const cipherId = String(row.cipher_id || '').trim(); + const attachmentId = String(row.id || '').trim(); + if (!cipherId || !attachmentId) continue; + const entry = files[`attachments/${cipherId}/${attachmentId}.bin`]; + if (!entry) continue; + largestObjectBytes = Math.max(largestObjectBytes, entry.byteLength); + } + + for (const row of payload.db.sends) { + const sendId = String(row.id || '').trim(); + const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null); + if (!sendId || !fileId) continue; + const entry = files[`send-files/${sendId}/${fileId}.bin`]; + if (!entry) continue; + largestObjectBytes = Math.max(largestObjectBytes, entry.byteLength); + } + + if (largestObjectBytes > KV_MAX_OBJECT_BYTES) { + throw new Error(`Backup contains a file larger than the Workers KV ${Math.floor(KV_MAX_OBJECT_BYTES / (1024 * 1024))} MiB per-object limit`); + } +} + +async function insertRows(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): Promise { + if (!rows.length) return; + const placeholders = columns.map(() => '?').join(', '); + const updateSql = upsert + ? ' ON CONFLICT(key) DO UPDATE SET value = excluded.value' + : ''; + const sql = `INSERT INTO ${table}(${columns.join(', ')}) VALUES(${placeholders})${updateSql}`; + const statements: D1PreparedStatement[] = rows.map((row) => + db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)) + ); + const chunkSize = 32; + for (let i = 0; i < statements.length; i += chunkSize) { + await db.batch(statements.slice(i, i + chunkSize)); + } +} + +async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record): Promise<{ attachments: number; sendFiles: number }> { + let attachmentCount = 0; + let sendFileCount = 0; + + for (const row of db.attachments) { + const cipherId = String(row.cipher_id || '').trim(); + const attachmentId = String(row.id || '').trim(); + if (!cipherId || !attachmentId) continue; + const zipPath = `attachments/${cipherId}/${attachmentId}.bin`; + const bytes = files[zipPath]; + await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, { + size: bytes.byteLength, + contentType: 'application/octet-stream', + customMetadata: { cipherId, attachmentId }, + }); + attachmentCount += 1; + } + + for (const row of db.sends) { + const sendId = String(row.id || '').trim(); + const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null); + if (!sendId || !fileId) continue; + const zipPath = `send-files/${sendId}/${fileId}.bin`; + const bytes = files[zipPath]; + await putBlobObject(env, getSendFileObjectKey(sendId, fileId), bytes, { + size: bytes.byteLength, + contentType: 'application/octet-stream', + customMetadata: { sendId, fileId }, + }); + sendFileCount += 1; + } + + return { attachments: attachmentCount, sendFiles: sendFileCount }; +} + +// POST /api/admin/backup/export +export async function handleAdminExportBackup( + request: Request, + env: Env, + actorUser: User +): Promise { + void request; + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + + const storage = new StorageService(env.DB); + const encoder = new TextEncoder(); + + const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows, sendRows] = await Promise.all([ + queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), + queryRows(env.DB, 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), + queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'), + queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'), + queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'), + queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'), + queryRows(env.DB, 'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends ORDER BY created_at ASC'), + ]); + + let attachmentBlobCount = 0; + let sendFileBlobCount = 0; + let totalBlobBytes = 0; + let largestObjectBytes = 0; + + const files: Record = { + 'manifest.json': encoder.encode( + JSON.stringify( + { + formatVersion: 1, + exportedAt: new Date().toISOString(), + appVersion: '1.0', + storageKind: getBlobStorageKind(env), + tableCounts: { + config: configRows.length, + users: userRows.length, + user_revisions: revisionRows.length, + folders: folderRows.length, + ciphers: cipherRows.length, + attachments: attachmentRows.length, + sends: sendRows.length, + }, + includes: { + attachments: true, + sendFiles: true, + }, + blobSummary: { + attachmentFiles: 0, + sendFiles: 0, + totalBytes: 0, + largestObjectBytes: 0, + }, + } satisfies BackupManifest, + null, + 2 + ) + ), + 'db.json': encoder.encode( + JSON.stringify( + { + config: configRows, + users: userRows, + user_revisions: revisionRows, + folders: folderRows, + ciphers: cipherRows, + attachments: attachmentRows, + sends: sendRows, + }, + null, + 2 + ) + ), + }; + + for (const row of attachmentRows) { + const cipherId = String(row.cipher_id || '').trim(); + const attachmentId = String(row.id || '').trim(); + if (!cipherId || !attachmentId) continue; + const object = await getBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId)); + if (!object) { + return errorResponse(`Attachment blob missing for ${cipherId}/${attachmentId}`, 409); + } + const bytes = await streamToBytes(object.body); + files[`attachments/${cipherId}/${attachmentId}.bin`] = bytes; + attachmentBlobCount += 1; + totalBlobBytes += bytes.byteLength; + largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength); + } + + for (const row of sendRows) { + const sendId = String(row.id || '').trim(); + const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null); + if (!sendId || !fileId) continue; + const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId)); + if (!object) { + return errorResponse(`Send file blob missing for ${sendId}/${fileId}`, 409); + } + const bytes = await streamToBytes(object.body); + files[`send-files/${sendId}/${fileId}.bin`] = bytes; + sendFileBlobCount += 1; + totalBlobBytes += bytes.byteLength; + largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength); + } + + files['manifest.json'] = encoder.encode( + JSON.stringify( + { + formatVersion: 1, + exportedAt: new Date().toISOString(), + appVersion: '1.0', + storageKind: getBlobStorageKind(env), + tableCounts: { + config: configRows.length, + users: userRows.length, + user_revisions: revisionRows.length, + folders: folderRows.length, + ciphers: cipherRows.length, + attachments: attachmentRows.length, + sends: sendRows.length, + }, + includes: { + attachments: true, + sendFiles: true, + }, + blobSummary: { + attachmentFiles: attachmentBlobCount, + sendFiles: sendFileBlobCount, + totalBytes: totalBlobBytes, + largestObjectBytes, + }, + } satisfies BackupManifest, + null, + 2 + ) + ); + + const zipped = zipSync(files, { level: 0 }); + await writeAuditLog(storage, actorUser.id, 'admin.backup.export', 'backup', null, { + users: userRows.length, + ciphers: cipherRows.length, + attachments: attachmentRows.length, + sends: sendRows.length, + }); + + return new Response(zipped, { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${buildBackupFileName()}"`, + 'Cache-Control': 'no-store', + }, + }); +} + +// POST /api/admin/backup/import +export async function handleAdminImportBackup( + request: Request, + env: Env, + actorUser: User +): Promise { + if (!isAdmin(actorUser)) { + return errorResponse('Forbidden', 403); + } + + const storage = new StorageService(env.DB); + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return errorResponse('Content-Type must be multipart/form-data', 400); + } + + const file = formData.get('file'); + if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) { + return errorResponse('Backup file is required', 400); + } + const backupFile = file as { arrayBuffer(): Promise }; + const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1'; + + let archiveBytes: Uint8Array; + try { + archiveBytes = new Uint8Array(await backupFile.arrayBuffer()); + } catch { + return errorResponse('Unable to read backup file', 400); + } + + let parsed: { payload: BackupPayload; files: Record }; + try { + parsed = parseBackupArchive(archiveBytes); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : 'Invalid backup archive', 400); + } + + try { + validateBackupPayloadContents(parsed.payload, parsed.files); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : 'Backup archive contents are invalid', 400); + } + + try { + validateImportBlobLimits(env, parsed.payload, parsed.files); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : 'Backup import is not supported by the current storage backend', 409); + } + + let targetIsFresh = true; + try { + await ensureImportTargetIsFresh(env.DB); + } catch (error) { + targetIsFresh = false; + if (!replaceExisting) { + return errorResponse(error instanceof Error ? error.message : 'Backup import requires a fresh instance', 409); + } + } + + const { db } = parsed.payload; + try { + if (!targetIsFresh) { + await clearExistingBlobFiles(env, env.DB); + await resetImportTarget(env.DB); + } else { + await resetImportTarget(env.DB); + } + await insertRows(env.DB, 'config', ['key', 'value'], db.config || [], true); + await insertRows( + env.DB, + 'users', + ['id', 'email', 'name', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'], + db.users || [] + ); + await insertRows(env.DB, 'user_revisions', ['user_id', 'revision_date'], db.user_revisions || []); + await insertRows(env.DB, 'folders', ['id', 'user_id', 'name', 'created_at', 'updated_at'], db.folders || []); + await insertRows( + env.DB, + 'ciphers', + ['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'], + db.ciphers || [] + ); + await insertRows( + env.DB, + 'attachments', + ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], + db.attachments || [] + ); + await insertRows( + env.DB, + 'sends', + ['id', 'user_id', 'type', 'name', 'notes', 'data', 'key', 'password_hash', 'password_salt', 'password_iterations', 'auth_type', 'emails', 'max_access_count', 'access_count', 'disabled', 'hide_email', 'created_at', 'updated_at', 'expiration_date', 'deletion_date'], + db.sends || [] + ); + + const blobCounts = await restoreBlobFiles(env, db, parsed.files); + await storage.setRegistered(); + const importedActorUserId = (db.users || []).some((row) => String(row.id || '').trim() === actorUser.id) ? actorUser.id : null; + await writeAuditLog(storage, importedActorUserId, 'admin.backup.import', 'backup', null, { + users: (db.users || []).length, + ciphers: (db.ciphers || []).length, + attachments: blobCounts.attachments, + sendFiles: blobCounts.sendFiles, + replaceExisting, + }); + + return jsonResponse({ + object: 'instance-backup-import', + imported: { + config: (db.config || []).length, + users: (db.users || []).length, + userRevisions: (db.user_revisions || []).length, + folders: (db.folders || []).length, + ciphers: (db.ciphers || []).length, + attachments: (db.attachments || []).length, + sends: (db.sends || []).length, + attachmentFiles: blobCounts.attachments, + sendFiles: blobCounts.sendFiles, + }, + }); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : 'Backup import failed', 500); + } +} diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index dd5bd31..318a69e 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -15,31 +15,82 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val return { present: false, value: undefined }; } -// Android 2026.2.0 expects fido2Credentials[].counter to be a string. -export function normalizeCipherLoginForCompatibility(login: any): any { - if (!login || typeof login !== 'object') return login ?? null; +function looksLikeCipherString(value: unknown): boolean { + return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); +} - const fido2 = Array.isArray(login.fido2Credentials) - ? login.fido2Credentials.map((cred: any) => { - if (!cred || typeof cred !== 'object') return cred; - const rawCounter = cred.counter; - const counter = - rawCounter === null || rawCounter === undefined - ? '0' - : String(rawCounter); - return { - ...cred, - counter, - }; - }) - : login.fido2Credentials; +export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean { + const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase(); + if (!userAgent) return false; + + // Temporary compatibility fallback: + // mobile clients expect official EncString payloads for most FIDO2 fields. + // Keep passkeys available everywhere, but suppress only legacy malformed data + // for mobile clients so newly-saved credentials can flow through unchanged. + return ( + userAgent.includes('android') || + userAgent.includes('iphone') || + userAgent.includes('ipad') || + userAgent.includes('ios') + ); +} + +export function normalizeCipherLoginForStorage(login: any): any { + if (!login || typeof login !== 'object') return login ?? null; return { ...login, - fido2Credentials: fido2, + fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null, }; } +export function normalizeCipherLoginForCompatibility( + login: any, + options?: { omitFido2Credentials?: boolean } +): any { + const normalized = normalizeCipherLoginForStorage(login); + if (!normalized || typeof normalized !== 'object') return normalized ?? null; + if (!options?.omitFido2Credentials) return normalized; + + const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null; + if (!credentials?.length) return normalized; + + const hasMalformedCredential = credentials.some((credential: any) => { + if (!credential || typeof credential !== 'object') return true; + const requiredEncryptedFields = [ + credential.credentialId, + credential.keyType, + credential.keyAlgorithm, + credential.keyCurve, + credential.keyValue, + credential.rpId, + credential.counter, + credential.discoverable, + ]; + const optionalEncryptedFields = [ + credential.userHandle, + credential.userName, + credential.rpName, + credential.userDisplayName, + ]; + + if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) { + return true; + } + if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) { + return true; + } + return false; + }); + + return hasMalformedCredential + ? { + ...normalized, + fido2Credentials: null, + } + : normalized; +} + // Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads. // Keep legacy alias "fingerprint" in parallel for older web payloads. export function normalizeCipherSshKeyForCompatibility(sshKey: any): any { @@ -81,10 +132,14 @@ export function formatAttachments(attachments: Attachment[]): any[] | null { // Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones), // then overlays server-computed fields. This ensures new Bitwarden client fields // survive a round-trip without code changes. -export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse { +export function cipherToResponse( + cipher: Cipher, + attachments: Attachment[] = [], + options?: { omitFido2Credentials?: boolean } +): CipherResponse { // Strip internal-only fields that must not appear in the API response const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher; - const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null); + const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null); return { @@ -119,6 +174,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin const url = new URL(request.url); const includeDeleted = url.searchParams.get('deleted') === 'true'; const pagination = parsePagination(url); + const omitFido2Credentials = shouldOmitPasskeysForResponse(request); let filteredCiphers: Cipher[]; let continuationToken: string | null = null; @@ -145,7 +201,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin const cipherResponses = []; for (const cipher of filteredCiphers) { const attachments = attachmentsByCipher.get(cipher.id) || []; - cipherResponses.push(cipherToResponse(cipher, attachments)); + cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials })); } return jsonResponse({ @@ -165,7 +221,11 @@ export async function handleGetCipher(request: Request, env: Env, userId: string } const attachments = await storage.getAttachmentsByCipher(cipher.id); - return jsonResponse(cipherToResponse(cipher, attachments)); + return jsonResponse( + cipherToResponse(cipher, attachments, { + omitFido2Credentials: shouldOmitPasskeysForResponse(request), + }) + ); } async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise { @@ -204,7 +264,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str updatedAt: now, deletedAt: null, }; - cipher.login = normalizeCipherLoginForCompatibility(cipher.login); + cipher.login = normalizeCipherLoginForStorage(cipher.login); cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); @@ -218,7 +278,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); await storage.updateRevisionDate(userId); - return jsonResponse(cipherToResponse(cipher), 200); + return jsonResponse( + cipherToResponse(cipher, [], { + omitFido2Credentials: shouldOmitPasskeysForResponse(request), + }), + 200 + ); } // PUT /api/ciphers/:id @@ -256,7 +321,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str updatedAt: new Date().toISOString(), deletedAt: existingCipher.deletedAt, }; - cipher.login = normalizeCipherLoginForCompatibility(cipher.login); + cipher.login = normalizeCipherLoginForStorage(cipher.login); cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey); // Custom fields deletion compatibility: @@ -279,7 +344,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); await storage.updateRevisionDate(userId); - return jsonResponse(cipherToResponse(cipher)); + return jsonResponse( + cipherToResponse(cipher, [], { + omitFido2Credentials: shouldOmitPasskeysForResponse(request), + }) + ); } // DELETE /api/ciphers/:id @@ -297,7 +366,11 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str await storage.saveCipher(cipher); await storage.updateRevisionDate(userId); - return jsonResponse(cipherToResponse(cipher)); + return jsonResponse( + cipherToResponse(cipher, [], { + omitFido2Credentials: shouldOmitPasskeysForResponse(request), + }) + ); } // DELETE /api/ciphers/:id (compat mode) @@ -355,7 +428,11 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st await storage.saveCipher(cipher); await storage.updateRevisionDate(userId); - return jsonResponse(cipherToResponse(cipher)); + return jsonResponse( + cipherToResponse(cipher, [], { + omitFido2Credentials: shouldOmitPasskeysForResponse(request), + }) + ); } // PUT /api/ciphers/:id/partial - Update only favorite/folderId @@ -389,7 +466,11 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user await storage.saveCipher(cipher); await storage.updateRevisionDate(userId); - return jsonResponse(cipherToResponse(cipher)); + return jsonResponse( + cipherToResponse(cipher, [], { + omitFido2Credentials: shouldOmitPasskeysForResponse(request), + }) + ); } // POST/PUT /api/ciphers/move - Bulk move to folder diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 907dbe2..c7f57ad 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -9,6 +9,10 @@ import { createRefreshToken } from '../utils/jwt'; import { readAuthRequestDeviceInfo } from '../utils/device'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { issueSendAccessToken } from './sends'; +import { + buildAccountKeys, + buildUserDecryptionOptions, +} from '../utils/user-decryption'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; @@ -241,30 +245,22 @@ export async function handleToken(request: Request, env: Env): Promise ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), Key: user.key, PrivateKey: user.privateKey, + AccountKeys: buildAccountKeys(user), + accountKeys: buildAccountKeys(user), Kdf: user.kdfType, KdfIterations: user.kdfIterations, KdfMemory: user.kdfMemory, KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, + MasterPasswordPolicy: { + Object: 'masterPasswordPolicy', + }, + ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, - UserDecryptionOptions: { - HasMasterPassword: true, - Object: 'userDecryptionOptions', - MasterPasswordUnlock: { - Kdf: { - KdfType: user.kdfType, - Iterations: user.kdfIterations, - Memory: user.kdfMemory || null, - Parallelism: user.kdfParallelism || null, - }, - MasterKeyEncryptedUserKey: user.key, - MasterKeyWrappedUserKey: user.key, - Salt: email, // email is already lowercased above - Object: 'masterPasswordUnlock', - }, - }, + UserDecryptionOptions: buildUserDecryptionOptions(user), + userDecryptionOptions: buildUserDecryptionOptions(user), }; return jsonResponse(response); @@ -360,30 +356,22 @@ export async function handleToken(request: Request, env: Env): Promise refresh_token: newRefreshToken, Key: user.key, PrivateKey: user.privateKey, + AccountKeys: buildAccountKeys(user), + accountKeys: buildAccountKeys(user), Kdf: user.kdfType, KdfIterations: user.kdfIterations, KdfMemory: user.kdfMemory, KdfParallelism: user.kdfParallelism, ForcePasswordReset: false, ResetMasterPassword: false, + MasterPasswordPolicy: { + Object: 'masterPasswordPolicy', + }, + ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, - UserDecryptionOptions: { - HasMasterPassword: true, - Object: 'userDecryptionOptions', - MasterPasswordUnlock: { - Kdf: { - KdfType: user.kdfType, - Iterations: user.kdfIterations, - Memory: user.kdfMemory || null, - Parallelism: user.kdfParallelism || null, - }, - MasterKeyEncryptedUserKey: user.key, - MasterKeyWrappedUserKey: user.key, - Salt: user.email.toLowerCase(), - Object: 'masterPasswordUnlock', - }, - }, + UserDecryptionOptions: buildUserDecryptionOptions(user), + userDecryptionOptions: buildUserDecryptionOptions(user), }; return jsonResponse(response); diff --git a/src/handlers/import.ts b/src/handlers/import.ts index 11c7cfc..51bd31e 100644 --- a/src/handlers/import.ts +++ b/src/handlers/import.ts @@ -3,7 +3,7 @@ import { StorageService } from '../services/storage'; import { errorResponse, jsonResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { LIMITS } from '../config/limits'; -import { normalizeCipherLoginForCompatibility, normalizeCipherSshKeyForCompatibility } from './ciphers'; +import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers'; // Bitwarden client import request format interface CiphersImportRequest { @@ -232,7 +232,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st updatedAt: now, deletedAt: null, }; - cipher.login = normalizeCipherLoginForCompatibility(cipher.login); + cipher.login = normalizeCipherLoginForStorage(cipher.login); cipherRows.push(cipher); cipherMapRows.push({ index: i, sourceId, id: cipher.id }); diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 67cf434..b9b7cca 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -4,6 +4,11 @@ import { errorResponse } from '../utils/response'; import { cipherToResponse } from './ciphers'; import { sendToResponse } from './sends'; import { LIMITS } from '../config/limits'; +import { + buildAccountKeys, + buildUserDecryptionCompat, + buildUserDecryptionOptions, +} from '../utils/user-decryption'; interface SyncCacheEntry { body: string; @@ -43,6 +48,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr const url = new URL(request.url); const excludeDomainsParam = url.searchParams.get('excludeDomains'); const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam); + const userAgent = String(request.headers.get('user-agent') || '').toLowerCase(); + const omitFido2Credentials = + userAgent.includes('android') || + userAgent.includes('iphone') || + userAgent.includes('ipad') || + userAgent.includes('ios'); const user = await storage.getUserById(userId); if (!user) { @@ -78,7 +89,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr twoFactorEnabled: !!user.totpSecret, key: user.key, privateKey: user.privateKey, - accountKeys: null, + accountKeys: buildAccountKeys(user), securityStamp: user.securityStamp || user.id, organizations: [], providers: [], @@ -93,7 +104,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr const cipherResponses: CipherResponse[] = []; for (const cipher of ciphers) { const attachments = attachmentsByCipher.get(cipher.id) || []; - cipherResponses.push(cipherToResponse(cipher, attachments)); + cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials })); } // Build folder responses @@ -119,36 +130,9 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr policies: [], sends: sends.map(sendToResponse), // PascalCase for desktop/browser clients - UserDecryptionOptions: { - HasMasterPassword: true, - Object: 'userDecryptionOptions', - MasterPasswordUnlock: { - Kdf: { - KdfType: user.kdfType, - Iterations: user.kdfIterations, - Memory: user.kdfMemory || null, - Parallelism: user.kdfParallelism || null, - }, - MasterKeyEncryptedUserKey: user.key, - MasterKeyWrappedUserKey: user.key, - Salt: user.email.toLowerCase(), - Object: 'masterPasswordUnlock', - }, - }, + UserDecryptionOptions: buildUserDecryptionOptions(user), // camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption")) - userDecryption: { - masterPasswordUnlock: { - kdf: { - kdfType: user.kdfType, - iterations: user.kdfIterations, - memory: user.kdfMemory || null, - parallelism: user.kdfParallelism || null, - }, - masterKeyWrappedUserKey: user.key, - masterKeyEncryptedUserKey: user.key, - salt: user.email.toLowerCase(), - }, - }, + userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'], object: 'sync', }; diff --git a/src/router.ts b/src/router.ts index 84b056a..1b6fbc0 100644 --- a/src/router.ts +++ b/src/router.ts @@ -99,6 +99,10 @@ import { handleAdminSetUserStatus, handleAdminDeleteUser, } from './handlers/admin'; +import { + handleAdminExportBackup, + handleAdminImportBackup, +} from './handlers/backup'; function isSameOriginWriteRequest(request: Request): boolean { const targetOrigin = new URL(request.url).origin; @@ -269,11 +273,12 @@ export async function handleRequest(request: Request, env: Env): Promise LIMITS.request.maxBodyBytes) { return errorResponse('Request body too large', 413); @@ -771,6 +776,14 @@ export async function handleRequest(request: Request, env: Env): Promise): Record | null { + if (!user.privateKey || !user.publicKey) { + return null; + } + + return { + publicKeyEncryptionKeyPair: { + wrappedPrivateKey: user.privateKey, + publicKey: user.publicKey, + Object: 'publicKeyEncryptionKeyPair', + }, + Object: 'privateKeys', + }; +} + +export function buildMasterPasswordUnlock( + user: Pick +): UserDecryptionOptions['MasterPasswordUnlock'] { + return { + Kdf: { + KdfType: user.kdfType, + Iterations: user.kdfIterations, + Memory: user.kdfMemory ?? null, + Parallelism: user.kdfParallelism ?? null, + }, + MasterKeyEncryptedUserKey: user.key, + MasterKeyWrappedUserKey: user.key, + Salt: user.email.toLowerCase(), + Object: 'masterPasswordUnlock', + }; +} + +export function buildUserDecryptionOptions( + user: Pick +): UserDecryptionOptions { + return { + HasMasterPassword: true, + Object: 'userDecryptionOptions', + MasterPasswordUnlock: buildMasterPasswordUnlock(user), + TrustedDeviceOption: null, + KeyConnectorOption: null, + }; +} + +export function buildUserDecryptionCompat( + user: Pick +): Record { + return { + masterPasswordUnlock: { + kdf: { + kdfType: user.kdfType, + iterations: user.kdfIterations, + memory: user.kdfMemory ?? null, + parallelism: user.kdfParallelism ?? null, + }, + masterKeyWrappedUserKey: user.key, + masterKeyEncryptedUserKey: user.key, + salt: user.email.toLowerCase(), + }, + }; +} diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 2ea3e93..89121d2 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -27,6 +27,8 @@ import { createAuthedFetch, createInvite, downloadCipherAttachmentDecrypted, + exportAdminBackup, + importAdminBackup, importCiphers, createSend, deleteAllInvites, @@ -109,7 +111,12 @@ function asText(value: unknown): string { function summarizeImportResult( ciphers: Array>, - folderCount: number + folderCount: number, + attachmentSummary?: { + total: number; + imported: number; + failed: Array<{ fileName: string; reason: string }>; + } ): ImportResultSummary { const typeLabel = (type: number): string => { if (type === 1) return t('txt_login'); @@ -136,6 +143,9 @@ function summarizeImportResult( totalItems: ciphers.length, folderCount: Math.max(0, folderCount), typeCounts, + attachmentCount: Math.max(0, attachmentSummary?.total || 0), + importedAttachmentCount: Math.max(0, attachmentSummary?.imported || 0), + failedAttachments: attachmentSummary?.failed || [], }; } @@ -1122,13 +1132,16 @@ export default function App() { async function uploadImportedAttachments( attachments: ImportAttachmentFile[], idMaps: { byIndex: Map; bySourceId: Map } - ): Promise { - if (!attachments.length) return; + ): Promise<{ total: number; imported: number; failed: Array<{ fileName: string; reason: string }> }> { + if (!attachments.length) { + return { total: 0, imported: 0, failed: [] }; + } if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); const initialCiphers = (await ciphersQuery.refetch()).data || []; const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher])); - const unresolved: ImportAttachmentFile[] = []; + const failed: Array<{ fileName: string; reason: string }> = []; + let imported = 0; for (const attachment of attachments) { const sourceId = String(attachment.sourceCipherId || '').trim(); @@ -1137,7 +1150,10 @@ export default function App() { const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null; const targetCipherId = byId || byIndex || null; if (!targetCipherId) { - unresolved.push(attachment); + failed.push({ + fileName: String(attachment.fileName || '').trim() || 'attachment.bin', + reason: t('txt_import_attachment_target_not_found'), + }); continue; } @@ -1145,14 +1161,23 @@ export default function App() { const fileBytes = Uint8Array.from(attachment.bytes); const file = new File([fileBytes], name, { type: 'application/octet-stream' }); const cipher = cipherById.get(targetCipherId) || null; - await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher); - } - - if (unresolved.length) { - throw new Error(t('txt_failed_to_map_attachments', { count: unresolved.length })); + try { + await uploadCipherAttachment(authedFetch, session, targetCipherId, file, cipher); + imported += 1; + } catch (error) { + failed.push({ + fileName: name, + reason: error instanceof Error ? error.message : t('txt_upload_attachment_failed'), + }); + } } await ciphersQuery.refetch(); + return { + total: attachments.length, + imported, + failed, + }; } function toImportedCipherMapsFromResponse( @@ -1252,10 +1277,10 @@ export default function App() { const idMaps = buildImportedCipherMaps(payload.ciphers, createdCipherIdsByIndex); await foldersQuery.refetch(); await ciphersQuery.refetch(); - if (attachments.length) { - await uploadImportedAttachments(attachments, idMaps); - } - return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0); + const attachmentSummary = attachments.length + ? await uploadImportedAttachments(attachments, idMaps) + : undefined; + return summarizeImportResult(payload.ciphers, mode === 'original' ? createdFolderCount : 0, attachmentSummary); } async function handleImportEncryptedRawAction( @@ -1280,11 +1305,14 @@ export default function App() { returnCipherMap: attachments.length > 0, }); await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); - if (attachments.length) { - const idMaps = toImportedCipherMapsFromResponse(importedCipherMap); - await uploadImportedAttachments(attachments, idMaps); - } - return summarizeImportResult(nextPayload.ciphers, mode === 'original' ? nextPayload.folders.length : 0); + const attachmentSummary = attachments.length + ? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap)) + : undefined; + return summarizeImportResult( + nextPayload.ciphers, + mode === 'original' ? nextPayload.folders.length : 0, + attachmentSummary + ); } async function handleExportAction(request: ExportRequest) { @@ -1519,6 +1547,30 @@ export default function App() { throw new Error(t('txt_unsupported_export_format')); } + function downloadBytesAsFile(bytes: Uint8Array, fileName: string, mimeType: string) { + const blob = new Blob([bytes], { type: mimeType || 'application/octet-stream' }); + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = objectUrl; + anchor.download = fileName || 'download.bin'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); + } + + async function handleBackupExportAction() { + const payload = await exportAdminBackup(authedFetch); + downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); + } + + async function handleBackupImportAction(file: File, replaceExisting: boolean = false) { + await importAdminBackup(authedFetch, file, replaceExisting); + window.setTimeout(() => { + logoutNow(); + }, 200); + } + const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0]; @@ -1540,6 +1592,12 @@ export default function App() { } }, [phase, isImportHashRoute, location, navigate]); + useEffect(() => { + if (phase === 'app' && profile?.role !== 'admin' && location === '/help') { + navigate('/vault'); + } + }, [phase, profile?.role, location, navigate]); + if (jwtWarning) { return ; } @@ -1695,10 +1753,12 @@ export default function App() { {t('nav_device_management')} - - - {t('nav_backup_strategy')} - + {profile?.role === 'admin' && ( + + + {t('nav_backup_strategy')} + + )} {t('nav_import_export')} @@ -1913,7 +1973,9 @@ export default function App() { /> - + {profile?.role === 'admin' ? ( + + ) : null} diff --git a/webapp/src/components/HelpPage.tsx b/webapp/src/components/HelpPage.tsx index 03eb8f7..bef7298 100644 --- a/webapp/src/components/HelpPage.tsx +++ b/webapp/src/components/HelpPage.tsx @@ -1,18 +1,139 @@ -import { Cloud } from 'lucide-preact'; +import { useRef, useState } from 'preact/hooks'; +import { Download, FileUp } from 'lucide-preact'; +import ConfirmDialog from '@/components/ConfirmDialog'; import { t } from '@/lib/i18n'; -export default function HelpPage() { +interface HelpPageProps { + onExport: () => Promise; + onImport: (file: File, replaceExisting?: boolean) => Promise; + onNotify: (type: 'success' | 'error', text: string) => void; +} + +export default function HelpPage(props: HelpPageProps) { + const fileInputRef = useRef(null); + const [selectedFile, setSelectedFile] = useState(null); + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); + const [localError, setLocalError] = useState(''); + const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false); + + function isReplaceRequiredError(error: unknown): boolean { + const message = error instanceof Error ? String(error.message || '') : ''; + return message.toLowerCase().includes('fresh instance'); + } + + async function handleExport() { + setLocalError(''); + setExporting(true); + try { + await props.onExport(); + props.onNotify('success', t('txt_backup_export_success')); + } catch (error) { + const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); + setLocalError(message); + props.onNotify('error', message); + } finally { + setExporting(false); + } + } + + async function runImport(replaceExisting: boolean) { + if (!selectedFile) { + const message = t('txt_backup_file_required'); + setLocalError(message); + props.onNotify('error', message); + return; + } + + setLocalError(''); + setImporting(true); + try { + await props.onImport(selectedFile, replaceExisting); + props.onNotify('success', t('txt_backup_import_success_relogin')); + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + setConfirmReplaceOpen(false); + } catch (error) { + if (!replaceExisting && isReplaceRequiredError(error)) { + setConfirmReplaceOpen(true); + return; + } + const message = error instanceof Error ? error.message : t('txt_backup_import_failed'); + setLocalError(message); + props.onNotify('error', message); + } finally { + setImporting(false); + } + } + + async function handleImport() { + await runImport(false); + } + return ( -
-
-

{t('backup_strategy_title')}

-
-
- -
{t('backup_strategy_under_construction')}
+
+
+
+
+

{t('txt_backup_export')}

-
-
+

{t('txt_backup_export_description')}

+
+ +
+ + +
+
+

{t('txt_backup_import')}

+
+

{t('txt_backup_import_description')}

+ +
+ {selectedFile ? ( + {t('txt_backup_selected_file_name', { name: selectedFile.name })} + ) : ( + {t('txt_backup_no_file_selected')} + )} +
+

{t('txt_backup_restore_note')}

+
+ +
+ {localError &&
{localError}
} +
+
+ + void runImport(true)} + onCancel={() => setConfirmReplaceOpen(false)} + /> ); } diff --git a/webapp/src/components/ImportPage.tsx b/webapp/src/components/ImportPage.tsx index 91c3220..077c75c 100644 --- a/webapp/src/components/ImportPage.tsx +++ b/webapp/src/components/ImportPage.tsx @@ -2,7 +2,7 @@ import { argon2idAsync } from '@noble/hashes/argon2.js'; import { strFromU8, unzipSync } from 'fflate'; import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js'; -import { Archive, ArrowLeftRight, Download, FileJson, FileUp } from 'lucide-preact'; +import { Download, FileUp } from 'lucide-preact'; import ConfirmDialog from '@/components/ConfirmDialog'; import type { CiphersImportPayload } from '@/lib/api'; import { @@ -55,6 +55,9 @@ export interface ImportResultSummary { totalItems: number; folderCount: number; typeCounts: Array<{ label: string; count: number }>; + attachmentCount: number; + importedAttachmentCount: number; + failedAttachments: Array<{ fileName: string; reason: string }>; } interface BitwardenPasswordProtectedInput extends BitwardenJsonInput { @@ -582,46 +585,10 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys return (
-
-

{t('txt_import_export_title')}

-

{t('txt_import_export_feature_intro')}

-
-
- - - -
- {t('txt_import_export_feature_bw_zip_title')} -

{t('txt_import_export_feature_bw_zip_desc')}

-
-
-
- - - -
- {t('txt_import_export_feature_nodewarden_json_title')} -

{t('txt_import_export_feature_nodewarden_json_desc')}

-
-
-
- - - -
- {t('txt_import_export_feature_compat_title')} -

{t('txt_import_export_feature_compat_desc')}

-
-
-
-
-

{t('txt_import')}

-

- {t('txt_import_vault_data_hint')} -

+

{t('txt_import_vault_data_hint')}