import type { Env, User } from '../types'; import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store'; import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config'; import { type BackupManifestAttachmentBlob, type BackupPayload, parseBackupArchive, validateBackupPayloadContents, } from './backup-archive'; // CONTRACT: // Restore is intentionally whitelist-based. Old backups may contain retired // fields, but only the columns listed here are imported. Keep this file in sync // with src/services/backup-archive.ts whenever backup contents change. // // WHEN CHANGING THIS: // - Update BackupTableName, BACKUP_TABLES, reset statements, prepared payloads, // shadow-table count validation, insert column lists, and frontend import // count types together. // - Do not import users.api_key, even if an older backup contains it. type SqlRow = Record; type BackupTableName = | 'config' | 'users' | 'domain_settings' | 'user_revisions' | 'folders' | 'ciphers' | 'attachments'; const BACKUP_TABLES: BackupTableName[] = [ 'config', 'users', 'domain_settings', 'user_revisions', 'folders', 'ciphers', 'attachments', ]; function shadowTableName(table: BackupTableName): string { return `${table}__restore`; } export interface BackupImportResultBody { object: 'instance-backup-import'; imported: { config: number; users: number; domainSettings: number; userRevisions: number; folders: number; ciphers: number; attachments: number; attachmentFiles: number; }; skipped: { reason: string | null; attachments: number; items: Array<{ kind: 'attachment'; path: string; sizeBytes: number; }>; }; } export interface BackupImportExecutionResult { result: BackupImportResultBody; auditActorUserId: string | null; } async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise { const response = await db.prepare(sql).bind(...values).all(); return (response.results || []).map((row) => ({ ...row })); } async function getTableCreateSql(db: D1Database, table: BackupTableName): Promise { const row = await db .prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?") .bind(table) .first<{ sql: string | null }>(); const sql = String(row?.sql || '').trim(); if (!sql) { throw new Error(`Restore shadow schema is missing table definition for ${table}`); } return sql; } function buildShadowTableCreateSql(createSql: string, table: BackupTableName): string { const tablePattern = new RegExp(`^CREATE TABLE(?:\\s+IF NOT EXISTS)?\\s+(?:\"${table}\"|${table})(?=\\s*\\()`, 'i'); let next = createSql.replace(tablePattern, `CREATE TABLE "${shadowTableName(table)}"`); if (next === createSql) { throw new Error(`Restore shadow schema could not rewrite CREATE TABLE statement for ${table}`); } for (const currentTable of BACKUP_TABLES) { const referencePattern = new RegExp(`\\bREFERENCES\\s+(?:\"${currentTable}\"|${currentTable})(?=\\s*\\()`, 'gi'); next = next.replace( referencePattern, `REFERENCES "${shadowTableName(currentTable)}"` ); } return next; } async function resetRestoreArtifacts(db: D1Database): Promise { const dropStatements = BACKUP_TABLES .slice() .reverse() .map((table) => db.prepare(`DROP TABLE IF EXISTS ${shadowTableName(table)}`)); if (dropStatements.length) { await db.batch(dropStatements); } } async function createShadowTables(db: D1Database): Promise { const createStatements: D1PreparedStatement[] = []; for (const table of BACKUP_TABLES) { const createSql = await getTableCreateSql(db, table); createStatements.push(db.prepare(buildShadowTableCreateSql(createSql, table))); } await db.batch(createStatements); } async function validateShadowTableCounts( db: D1Database, expectedCounts: Partial> ): Promise { await Promise.all(BACKUP_TABLES.map(async (table) => { const expected = expectedCounts[table] ?? 0; const row = await db.prepare(`SELECT COUNT(*) AS count FROM ${shadowTableName(table)}`).first<{ count: number }>(); const actual = Number(row?.count || 0); if (actual !== expected) { throw new Error(`Restore shadow validation failed for ${table}: expected ${expected}, received ${actual}`); } })); } async function swapShadowTablesIntoPlace(db: D1Database): Promise { const statements: D1PreparedStatement[] = []; // Commit by replacing live table contents from validated shadow tables. // This avoids D1 schema-rename edge cases while keeping current data intact // until the final batch succeeds. for (const sql of buildResetImportTargetStatements(db)) { statements.push(sql); } for (const table of BACKUP_TABLES) { statements.push(db.prepare(`INSERT INTO ${table} SELECT * FROM ${shadowTableName(table)}`)); } await db.batch(statements); } 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'); } } function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[] { return [ 'DELETE FROM attachments', 'DELETE FROM ciphers', 'DELETE FROM folders', 'DELETE FROM domain_settings', 'DELETE FROM user_revisions', 'DELETE FROM users', 'DELETE FROM config', ].map((sql) => db.prepare(sql)); } async function collectCurrentBlobKeys(db: D1Database): Promise> { const keys = new Set(); 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; keys.add(getAttachmentObjectKey(cipherId, attachmentId)); } return keys; } const KV_BLOB_SKIP_REASON = 'Cloudflare KV object size limit (25 MB)'; const BLOB_STORAGE_UNAVAILABLE_SKIP_REASON = 'Attachment storage is not configured'; const ATTACHMENT_RESTORE_FAILED_REASON = 'Some attachments could not be restored and were skipped'; interface BackupImportSkipSummary { reason: string | null; attachments: number; items: Array<{ kind: 'attachment'; path: string; sizeBytes: number; }>; } interface PreparedBackupImportPayload { payload: BackupPayload; skipped: BackupImportSkipSummary; } interface AttachmentRestoreResult { imported: number; restoredAttachments: SqlRow[]; skipped: BackupImportSkipSummary; } interface RemoteAttachmentSource { loadAttachment(blobName: string): Promise; } export interface BackupRestoreProgressEvent { source: 'local' | 'remote'; step: string; fileName: string; stageTitle: string; stageDetail: string; replaceExisting: boolean; done?: boolean; ok?: boolean; error?: string | null; } export type BackupRestoreProgressReporter = (event: BackupRestoreProgressEvent) => Promise | void; function attachmentRowKey(row: SqlRow): string { const attachmentId = String(row.id || '').trim(); const cipherId = String(row.cipher_id || '').trim(); return `${cipherId}/${attachmentId}`; } function cloneRows(rows: SqlRow[]): SqlRow[] { return rows.map((row) => ({ ...row })); } function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] { let replaced = false; const nextRows = rows.map((row) => { if (String(row.key || '').trim() !== key) return { ...row }; replaced = true; return { ...row, key, value }; }); if (!replaced) { nextRows.push({ key, value }); } return nextRows; } async function prepareImportedConfigRows( env: Env, configRows: SqlRow[], userRows: SqlRow[] ): Promise { let nextConfigRows = cloneRows(configRows || []); const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY); const normalizedBackupSettings = await normalizeImportedBackupSettingsValue( typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null, env, userRows.map((row) => ({ id: String(row.id || '').trim(), publicKey: typeof row.public_key === 'string' ? row.public_key : null, role: String(row.role || '').trim() as User['role'], status: String(row.status || '').trim() as User['status'], })), 'UTC' ); if (normalizedBackupSettings !== null) { nextConfigRows = upsertConfigRow(nextConfigRows, BACKUP_SETTINGS_CONFIG_KEY, normalizedBackupSettings); } nextConfigRows = upsertConfigRow(nextConfigRows, 'registered', 'true'); return nextConfigRows; } async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['db'], env: Env): Promise { const preparedDb: BackupPayload['db'] = { config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []), users: cloneRows(payload.users || []).map((row) => ({ ...row, verify_devices: row.verify_devices ?? 1, })), domain_settings: cloneRows(payload.domain_settings || []), user_revisions: cloneRows(payload.user_revisions || []), folders: cloneRows(payload.folders || []), ciphers: cloneRows(payload.ciphers || []).map((row) => ({ ...row, archived_at: row.archived_at ?? null, })), attachments: cloneRows(payload.attachments || []), }; await importBackupRows(db, preparedDb, true); return preparedDb; } function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record): PreparedBackupImportPayload { const storageKind = getBlobStorageKind(env); if (storageKind === 'r2') { return { payload, skipped: { reason: null, attachments: 0, items: [], }, }; } if (storageKind === null) { const skippedItems = (payload.db.attachments || []).map((row) => { const cipherId = String(row.cipher_id || '').trim(); const attachmentId = String(row.id || '').trim(); return { kind: 'attachment' as const, path: `attachments/${cipherId}/${attachmentId}.bin`, sizeBytes: Number(row.size || 0) || 0, }; }); const result = { payload: { ...payload, db: { ...payload.db, attachments: [], }, }, skipped: { reason: skippedItems.length ? BLOB_STORAGE_UNAVAILABLE_SKIP_REASON : null, attachments: skippedItems.length, items: skippedItems, }, }; return result; } const oversizedAttachmentPaths = new Set(); const skippedItems: BackupImportSkipSummary['items'] = []; for (const entry of Object.keys(files)) { if (!entry.endsWith('.bin')) continue; const sizeBytes = files[entry].byteLength; if (sizeBytes <= KV_MAX_OBJECT_BYTES) continue; if (entry.startsWith('attachments/')) { oversizedAttachmentPaths.add(entry); skippedItems.push({ kind: 'attachment', path: entry, sizeBytes }); } } const nextAttachments = (payload.db.attachments || []).filter((row) => { const cipherId = String(row.cipher_id || '').trim(); const attachmentId = String(row.id || '').trim(); if (!cipherId || !attachmentId) return false; return !oversizedAttachmentPaths.has(`attachments/${cipherId}/${attachmentId}.bin`); }); const nextPayload: BackupPayload = { ...payload, db: { ...payload.db, attachments: nextAttachments, }, }; const needsKvBlobStorage = nextAttachments.length > 0; if (needsKvBlobStorage && !env.ATTACHMENTS_KV) { throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage'); } const result = { payload: nextPayload, skipped: { reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null, attachments: skippedItems.length, items: skippedItems, }, }; return result; } function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] { if (!rows.length) return []; const placeholders = `(${columns.map(() => '?').join(', ')})`; const sql = `INSERT ${upsert ? 'OR REPLACE ' : ''}INTO ${table} (${columns.join(', ')}) VALUES ${placeholders}`; return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null))); } async function runInsertBatch(db: D1Database, table: string, statements: D1PreparedStatement[]): Promise { if (!statements.length) return; try { await db.batch(statements); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Restore insert failed for ${table}: ${message}`); } } async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record): Promise { const restoredAttachments: SqlRow[] = []; const skippedItems: BackupImportSkipSummary['items'] = []; for (const row of db.attachments || []) { const cipherId = String(row.cipher_id || '').trim(); const attachmentId = String(row.id || '').trim(); if (!cipherId || !attachmentId) continue; const key = `attachments/${cipherId}/${attachmentId}.bin`; const bytes = files[key]; if (!bytes) { skippedItems.push({ kind: 'attachment', path: key, sizeBytes: Number(row.size || 0) || 0, }); continue; } try { await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, { size: bytes.byteLength, contentType: 'application/octet-stream', }); restoredAttachments.push(row); } catch { skippedItems.push({ kind: 'attachment', path: key, sizeBytes: bytes.byteLength, }); } } return { imported: restoredAttachments.length, restoredAttachments, skipped: { reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null, attachments: skippedItems.length, items: skippedItems, }, }; } function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map { return new Map( (manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item]) ); } async function prepareRemoteAttachmentPayload( env: Env, payload: BackupPayload, files: Record, source: RemoteAttachmentSource ): Promise { const manifestLookup = buildAttachmentBlobLookup(payload.manifest); const storageKind = getBlobStorageKind(env); const nextAttachments: SqlRow[] = []; const skippedItems: BackupImportSkipSummary['items'] = []; for (const row of payload.db.attachments || []) { const cipherId = String(row.cipher_id || '').trim(); const attachmentId = String(row.id || '').trim(); const lookupKey = `${cipherId}/${attachmentId}`; const ref = manifestLookup.get(lookupKey); const sizeBytes = ref?.sizeBytes || Number(row.size || 0) || 0; const path = ref ? `attachments/${ref.blobName}` : `attachments/${lookupKey}`; const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`; if (files[inlinePath]) { nextAttachments.push(row); continue; } if (!ref) { skippedItems.push({ kind: 'attachment', path, sizeBytes }); continue; } if (storageKind === 'kv' && sizeBytes > KV_MAX_OBJECT_BYTES) { skippedItems.push({ kind: 'attachment', path, sizeBytes }); continue; } if (storageKind === null) { skippedItems.push({ kind: 'attachment', path, sizeBytes }); continue; } nextAttachments.push(row); } const result = { payload: { ...payload, db: { ...payload.db, attachments: nextAttachments, }, }, skipped: { reason: skippedItems.length ? 'Some remote attachments were unavailable and were skipped' : null, attachments: skippedItems.length, items: skippedItems, }, }; return result; } async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[], useShadowTable: boolean = false): Promise { if (!attachmentRows.length) return; const tableName = useShadowTable ? shadowTableName('attachments') : 'attachments'; const statements = attachmentRows .map((row) => { const attachmentId = String(row.id || '').trim(); const cipherId = String(row.cipher_id || '').trim(); if (!attachmentId || !cipherId) return null; return db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND cipher_id = ?`).bind(attachmentId, cipherId); }) .filter((statement): statement is D1PreparedStatement => !!statement); if (!statements.length) return; await db.batch(statements); } async function restoreRemoteAttachmentFiles( env: Env, payload: BackupPayload, files: Record, source: RemoteAttachmentSource ): Promise<{ imported: number; skipped: BackupImportSkipSummary; restoredAttachments: SqlRow[]; }> { const manifestLookup = buildAttachmentBlobLookup(payload.manifest); const restoredAttachments: SqlRow[] = []; const skippedItems: BackupImportSkipSummary['items'] = []; for (const row of payload.db.attachments || []) { const cipherId = String(row.cipher_id || '').trim(); const attachmentId = String(row.id || '').trim(); const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`; const ref = manifestLookup.get(`${cipherId}/${attachmentId}`); if (!ref && !files[inlinePath]) { skippedItems.push({ kind: 'attachment', path: `attachments/${cipherId}/${attachmentId}`, sizeBytes: Number(row.size || 0) || 0, }); continue; } const bytes = files[inlinePath] || (ref ? await source.loadAttachment(ref.blobName) : null); if (!bytes) { skippedItems.push({ kind: 'attachment', path: ref ? `attachments/${ref.blobName}` : inlinePath, sizeBytes: ref?.sizeBytes || Number(row.size || 0) || 0, }); continue; } try { await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, { size: bytes.byteLength, contentType: 'application/octet-stream', }); restoredAttachments.push(row); } catch { skippedItems.push({ kind: 'attachment', path: ref ? `attachments/${ref.blobName}` : inlinePath, sizeBytes: bytes.byteLength, }); } } return { imported: restoredAttachments.length, restoredAttachments, skipped: { reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null, attachments: skippedItems.length, items: skippedItems, }, }; } async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set, afterKeys: Set): Promise { const staleKeys = Array.from(beforeKeys).filter((key) => !afterKeys.has(key)); for (const key of staleKeys) { await deleteBlobObject(env, key); } } async function importBackupRows(db: D1Database, payload: BackupPayload['db'], useShadowTables: boolean = false): Promise { const tableName = (table: BackupTableName): string => (useShadowTables ? shadowTableName(table) : table); await runInsertBatch( db, tableName('config'), buildInsertStatements(db, tableName('config'), ['key', 'value'], payload.config || [], true) ); await runInsertBatch( db, tableName('users'), buildInsertStatements( db, tableName('users'), ['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'], payload.users || [] ) ); await runInsertBatch( db, tableName('user_revisions'), buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true) ); await runInsertBatch( db, tableName('domain_settings'), buildInsertStatements( db, tableName('domain_settings'), ['user_id', 'equivalent_domains', 'custom_equivalent_domains', 'excluded_global_equivalent_domains', 'updated_at'], payload.domain_settings || [], true ) ); await runInsertBatch( db, tableName('folders'), buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || []) ); await runInsertBatch( db, tableName('ciphers'), buildInsertStatements( db, tableName('ciphers'), ['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'], payload.ciphers || [] ) ); await runInsertBatch( db, tableName('attachments'), buildInsertStatements(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []) ); } export async function importBackupArchiveBytes( archiveBytes: Uint8Array, env: Env, actorUserId: string, replaceExisting: boolean, progress?: BackupRestoreProgressReporter, fileName: string = 'nodewarden_backup.zip' ): Promise { const parsed = parseBackupArchive(archiveBytes); validateBackupPayloadContents(parsed.payload, parsed.files); const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files); try { await ensureImportTargetIsFresh(env.DB); } catch (error) { if (!replaceExisting) { throw error instanceof Error ? error : new Error('Backup import requires a fresh instance'); } } await resetRestoreArtifacts(env.DB); const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set(); try { await progress?.({ source: 'local', step: 'local_create_shadow', fileName, stageTitle: 'txt_backup_restore_progress_local_shadow_title', stageDetail: 'txt_backup_restore_progress_local_shadow_detail', replaceExisting, }); await createShadowTables(env.DB); await progress?.({ source: 'local', step: 'local_import_data', fileName, stageTitle: 'txt_backup_restore_progress_local_data_title', stageDetail: 'txt_backup_restore_progress_local_data_detail', replaceExisting, }); const db = await importPreparedBackupRows(env.DB, prepared.payload.db, env); await validateShadowTableCounts(env.DB, { config: (db.config || []).length, users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: (db.attachments || []).length, }); await progress?.({ source: 'local', step: 'local_restore_files', fileName, stageTitle: 'txt_backup_restore_progress_local_files_title', stageDetail: 'txt_backup_restore_progress_local_files_detail', replaceExisting, }); const restored = await restoreBlobFiles(env, db, parsed.files); const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey)); const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row))); await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined); await validateShadowTableCounts(env.DB, { config: (db.config || []).length, users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: restored.restoredAttachments.length, }); await progress?.({ source: 'local', step: 'local_finalize', fileName, stageTitle: 'txt_backup_restore_progress_local_finalize_title', stageDetail: 'txt_backup_restore_progress_local_finalize_detail', replaceExisting, }); await swapShadowTablesIntoPlace(env.DB); await resetRestoreArtifacts(env.DB).catch(() => undefined); if (replaceExisting && previousBlobKeys.size) { const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null); if (nextBlobKeys) { await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined); } } await progress?.({ source: 'local', step: 'local_complete', fileName, stageTitle: 'txt_backup_restore_progress_local_finalize_title', stageDetail: 'txt_backup_restore_progress_local_finalize_detail', replaceExisting, done: true, ok: true, }); return { auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null, result: { object: 'instance-backup-import', imported: { config: (db.config || []).length, users: (db.users || []).length, domainSettings: (db.domain_settings || []).length, userRevisions: (db.user_revisions || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: restored.restoredAttachments.length, attachmentFiles: restored.imported, }, skipped: { reason: restored.skipped.reason || prepared.skipped.reason, attachments: prepared.skipped.attachments + restored.skipped.attachments, items: [...prepared.skipped.items, ...restored.skipped.items], }, }, }; } catch (error) { await progress?.({ source: 'local', step: 'local_failed', fileName, stageTitle: 'txt_backup_restore_progress_local_finalize_title', stageDetail: 'txt_backup_restore_progress_local_finalize_detail', replaceExisting, done: true, ok: false, error: error instanceof Error ? error.message : String(error), }); await resetRestoreArtifacts(env.DB).catch(() => undefined); throw error; } } export async function importRemoteBackupArchiveBytes( archiveBytes: Uint8Array, env: Env, actorUserId: string, replaceExisting: boolean, source: RemoteAttachmentSource, progress?: BackupRestoreProgressReporter, fileName: string = 'nodewarden_backup.zip' ): Promise { const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true }); const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source); validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true }); try { await ensureImportTargetIsFresh(env.DB); } catch (error) { if (!replaceExisting) { throw error instanceof Error ? error : new Error('Backup import requires a fresh instance'); } } await resetRestoreArtifacts(env.DB); const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set(); try { await progress?.({ source: 'remote', step: 'remote_create_shadow', fileName, stageTitle: 'txt_backup_restore_progress_remote_shadow_title', stageDetail: 'txt_backup_restore_progress_remote_shadow_detail', replaceExisting, }); await createShadowTables(env.DB); await progress?.({ source: 'remote', step: 'remote_import_data', fileName, stageTitle: 'txt_backup_restore_progress_remote_data_title', stageDetail: 'txt_backup_restore_progress_remote_data_detail', replaceExisting, }); const db = await importPreparedBackupRows(env.DB, preparedRemote.payload.db, env); await validateShadowTableCounts(env.DB, { config: (db.config || []).length, users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: (db.attachments || []).length, }); await progress?.({ source: 'remote', step: 'remote_restore_files', fileName, stageTitle: 'txt_backup_restore_progress_remote_files_title', stageDetail: 'txt_backup_restore_progress_remote_files_detail', replaceExisting, }); const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source); const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey)); const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row))); await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined); await validateShadowTableCounts(env.DB, { config: (db.config || []).length, users: (db.users || []).length, domain_settings: (db.domain_settings || []).length, user_revisions: (db.user_revisions || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: restored.restoredAttachments.length, }); await progress?.({ source: 'remote', step: 'remote_finalize', fileName, stageTitle: 'txt_backup_restore_progress_remote_finalize_title', stageDetail: 'txt_backup_restore_progress_remote_finalize_detail', replaceExisting, }); await swapShadowTablesIntoPlace(env.DB); await resetRestoreArtifacts(env.DB).catch(() => undefined); if (replaceExisting && previousBlobKeys.size) { const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null); if (nextBlobKeys) { await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined); } } await progress?.({ source: 'remote', step: 'remote_complete', fileName, stageTitle: 'txt_backup_restore_progress_remote_finalize_title', stageDetail: 'txt_backup_restore_progress_remote_finalize_detail', replaceExisting, done: true, ok: true, }); const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items]; const finalSkippedReason = finalSkippedItems.length ? restored.skipped.reason || preparedRemote.skipped.reason : null; return { auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null, result: { object: 'instance-backup-import', imported: { config: (db.config || []).length, users: (db.users || []).length, domainSettings: (db.domain_settings || []).length, userRevisions: (db.user_revisions || []).length, folders: (db.folders || []).length, ciphers: (db.ciphers || []).length, attachments: restored.restoredAttachments.length, attachmentFiles: restored.imported, }, skipped: { reason: finalSkippedReason, attachments: finalSkippedItems.length, items: finalSkippedItems, }, }, }; } catch (error) { await progress?.({ source: 'remote', step: 'remote_failed', fileName, stageTitle: 'txt_backup_restore_progress_remote_finalize_title', stageDetail: 'txt_backup_restore_progress_remote_finalize_detail', replaceExisting, done: true, ok: false, error: error instanceof Error ? error.message : String(error), }); await resetRestoreArtifacts(env.DB).catch(() => undefined); throw error; } }