diff --git a/src/durable/notifications-hub.ts b/src/durable/notifications-hub.ts index 526dc1f..b969e84 100644 --- a/src/durable/notifications-hub.ts +++ b/src/durable/notifications-hub.ts @@ -5,6 +5,7 @@ const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARAT const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; +const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; const SIGNALR_PING_INTERVAL_MS = 15_000; type HubProtocol = 'json' | 'messagepack'; @@ -127,25 +128,21 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array { } function buildSignalRJsonInvocation( - userId: string, updateType: number, - revisionDate: string, + payload: Record, contextId: string | null ): string { return JSON.stringify({ type: 1, target: 'ReceiveMessage', arguments: [ - { - ContextId: contextId, - Type: updateType, - Payload: { - UserId: userId, - Date: revisionDate, + { + ContextId: contextId, + Type: updateType, + Payload: payload, }, - }, - ], - }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR); + ], + }) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR); } function buildSignalRJsonPing(): string { @@ -153,14 +150,13 @@ function buildSignalRJsonPing(): string { } function buildSignalRMessagePackInvocation( - userId: string, updateType: number, - revisionDate: string, + messagePayload: Record, contextId: string | null ): Uint8Array { // SignalR MessagePack hub protocol uses an array-based invocation shape: // [type, headers, invocationId, target, arguments] - const payload = encodeMsgPack([ + const encodedPayload = encodeMsgPack([ 1, {}, null, @@ -169,14 +165,11 @@ function buildSignalRMessagePackInvocation( { ContextId: contextId, Type: updateType, - Payload: { - UserId: userId, - Date: new Date(revisionDate), - }, + Payload: messagePayload, }, ], ]); - return frameSignalRBinary(payload); + return frameSignalRBinary(encodedPayload); } function buildSignalRMessagePackPing(): Uint8Array { @@ -209,13 +202,20 @@ export class NotificationsHub { contextId?: string | null; updateType?: number; targetDeviceIdentifier?: string | null; + payload?: Record | null; } | null; const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString(); this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim(); const contextId = String(body?.contextId || '').trim() || null; const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT; const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null; - this.broadcastMessage(updateType, revisionDate, contextId, targetDeviceIdentifier); + const payload = body?.payload && typeof body.payload === 'object' + ? body.payload + : { + UserId: this.userId, + Date: revisionDate, + }; + this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier); return new Response(null, { status: 204 }); } @@ -360,7 +360,7 @@ export class NotificationsHub { private broadcastMessage( updateType: number, - revisionDate: string, + payload: Record, contextId: string | null, targetDeviceIdentifier: string | null ): void { @@ -371,9 +371,9 @@ export class NotificationsHub { if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue; try { if (connection.protocol === 'json') { - socket.send(buildSignalRJsonInvocation(this.userId, updateType, revisionDate, contextId)); + socket.send(buildSignalRJsonInvocation(updateType, payload, contextId)); } else { - socket.send(buildSignalRMessagePackInvocation(this.userId, updateType, revisionDate, contextId)); + socket.send(buildSignalRMessagePackInvocation(updateType, payload, contextId)); } } catch { this.connections.delete(socket); @@ -389,7 +389,15 @@ export class NotificationsHub { } private broadcastDeviceStatus(): void { - this.broadcastMessage(SIGNALR_UPDATE_TYPE_DEVICE_STATUS, new Date().toISOString(), null, null); + this.broadcastMessage( + SIGNALR_UPDATE_TYPE_DEVICE_STATUS, + { + UserId: this.userId, + Date: new Date().toISOString(), + }, + null, + null + ); } } @@ -445,9 +453,79 @@ async function notifyUserUpdate( contextId: contextId || null, updateType, targetDeviceIdentifier: targetDeviceIdentifier || null, + payload: { + UserId: userId, + Date: revisionDate, + }, }), }); } catch (error) { console.error('Failed to broadcast realtime notification:', error); } } + +export async function notifyUserBackupProgress( + env: Env, + userId: string, + progress: { + operation: 'backup-restore' | 'backup-export' | 'backup-remote-run'; + source?: 'local' | 'remote'; + step: string; + fileName: string; + stageTitle?: string; + stageDetail?: string; + replaceExisting?: boolean; + done?: boolean; + ok?: boolean; + error?: string | null; + timestamp?: string; + }, + targetDeviceIdentifier?: string | null +): Promise { + const revisionDate = progress.timestamp || new Date().toISOString(); + try { + const id = env.NOTIFICATIONS_HUB.idFromName(userId); + const stub = env.NOTIFICATIONS_HUB.get(id); + await stub.fetch('https://notifications/internal/notify', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-NodeWarden-UserId': userId, + }, + body: JSON.stringify({ + revisionDate, + contextId: null, + updateType: SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS, + targetDeviceIdentifier: targetDeviceIdentifier || null, + payload: { + UserId: userId, + Date: revisionDate, + ...progress, + }, + }), + }); + } catch (error) { + console.error('Failed to broadcast backup progress:', error); + } +} + +export async function notifyUserBackupRestoreProgress( + env: Env, + userId: string, + progress: { + operation: 'backup-restore'; + source: 'local' | 'remote'; + step: string; + fileName: string; + stageTitle?: string; + stageDetail?: string; + replaceExisting?: boolean; + done?: boolean; + ok?: boolean; + error?: string | null; + timestamp?: string; + }, + targetDeviceIdentifier?: string | null +): Promise { + return notifyUserBackupProgress(env, userId, progress, targetDeviceIdentifier); +} diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index bbf1278..6c18bc4 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -1,7 +1,12 @@ import type { Env, User } from '../types'; import { errorResponse, jsonResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; -import { type BackupArchiveBundle, buildBackupArchive } from '../services/backup-archive'; +import { + type BackupArchiveBundle, + buildBackupArchive, + inspectBackupArchiveFileNameChecksum, + verifyBackupArchiveFileNameChecksum, +} from '../services/backup-archive'; import { type BackupDestinationRecord, type BackupSettingsInput, @@ -17,19 +22,25 @@ import { requireBackupDestination, saveBackupSettings, } from '../services/backup-config'; -import { type BackupImportExecutionResult, importBackupArchiveBytes, importRemoteBackupArchiveBytes } from '../services/backup-import'; import { + type BackupImportExecutionResult, + type BackupRestoreProgressReporter, + importBackupArchiveBytes, + importRemoteBackupArchiveBytes, +} from '../services/backup-import'; +import { + type RemoteBackupTransferSession, + createRemoteBackupTransferSession, deleteRemoteBackupFile, downloadRemoteBackupFile, ensureRemoteRestoreCandidate, listRemoteBackupEntries, pruneRemoteBackupArchives, - remoteBackupFileExists, - uploadRemoteBackupFile, uploadBackupArchive, } from '../services/backup-uploader'; import { StorageService } from '../services/storage'; import { getBlobObject } from '../services/blob-store'; +import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; function isAdmin(user: User): boolean { return user.role === 'admin' && user.status === 'active'; @@ -81,13 +92,74 @@ function ensureBackupBlobName(value: string): string { return parts.join('/'); } +const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json'; + +interface RemoteAttachmentIndexPayload { + version: 1; + blobs: Record; +} + +async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession): Promise> { + try { + const file = await session.download(REMOTE_ATTACHMENT_INDEX_PATH); + const payload = JSON.parse(new TextDecoder().decode(file.bytes)) as RemoteAttachmentIndexPayload; + if (payload?.version !== 1 || !payload.blobs || typeof payload.blobs !== 'object') { + return new Map(); + } + return new Map( + Object.entries(payload.blobs) + .filter(([key, value]) => !!String(key || '').trim() && Number.isFinite(Number(value?.sizeBytes || 0))) + .map(([key, value]) => [key, Number(value.sizeBytes || 0)]) + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('404') || message.includes('Please select a backup file')) { + return new Map(); + } + throw error; + } +} + +async function saveRemoteAttachmentIndex( + session: RemoteBackupTransferSession, + index: Map +): Promise { + const payload: RemoteAttachmentIndexPayload = { + version: 1, + blobs: Object.fromEntries( + Array.from(index.entries()).map(([blobName, sizeBytes]) => [ + blobName, + { + sizeBytes, + updatedAt: new Date().toISOString(), + }, + ]) + ), + }; + const bytes = new TextEncoder().encode(JSON.stringify(payload)); + await session.putFile(REMOTE_ATTACHMENT_INDEX_PATH, bytes, { + contentType: 'application/json; charset=utf-8', + }); +} + async function executeConfiguredBackup( env: Env, storage: StorageService, actorUserId: string | null, trigger: 'manual' | 'scheduled', - destinationId?: string | null + destinationId?: string | null, + progress?: ((event: { + operation: 'backup-remote-run'; + step: string; + fileName: string; + stageTitle: string; + stageDetail: string; + done?: boolean; + ok?: boolean; + error?: string | null; + }) => Promise) | null ): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> { + const maxArchiveUploadAttempts = 3; const currentSettings = await loadBackupSettings(storage, env, 'UTC'); const destination = requireBackupDestination(currentSettings, destinationId); @@ -99,25 +171,109 @@ async function executeConfiguredBackup( await saveBackupSettings(storage, env, currentSettings); try { + await progress?.({ + operation: 'backup-remote-run', + step: 'remote_run_prepare', + fileName: '', + stageTitle: 'txt_backup_remote_run_progress_prepare_title', + stageDetail: 'txt_backup_remote_run_progress_prepare_detail', + }); const archive = await buildBackupArchive(env, now, { includeAttachments: destination.includeAttachments, + progress: progress + ? async (event) => { + if (event.step === 'archive_ready') { + return; + } + await progress({ + operation: 'backup-remote-run', + step: `remote_run_${event.step}`, + fileName: event.fileName || '', + stageTitle: event.stageTitle, + stageDetail: event.stageDetail, + }); + } + : undefined, }); + await progress?.({ + operation: 'backup-remote-run', + step: 'remote_run_sync_attachments', + fileName: archive.fileName, + stageTitle: 'txt_backup_remote_run_progress_sync_attachments_title', + stageDetail: destination.includeAttachments + ? 'txt_backup_remote_run_progress_sync_attachments_detail' + : 'txt_backup_remote_run_progress_sync_attachments_skipped_detail', + }); + const remoteSession = createRemoteBackupTransferSession(destination); + const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession); + let attachmentIndexChanged = false; for (const attachment of archive.manifest.attachmentBlobs || []) { + if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) { + continue; + } const remotePath = `attachments/${attachment.blobName}`; - if (await remoteBackupFileExists(destination, remotePath)) continue; const object = await getBlobObject(env, attachment.blobName); if (!object) { throw new Error(`Attachment blob missing for ${attachment.blobName}`); } const bytes = new Uint8Array(await new Response(object.body).arrayBuffer()); - await uploadRemoteBackupFile(destination, remotePath, bytes, { + await remoteSession.putFile(remotePath, bytes, { contentType: object.contentType, }); + remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes); + attachmentIndexChanged = true; + } + if (attachmentIndexChanged) { + await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex); + } + let upload: Awaited> | null = null; + for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) { + await progress?.({ + operation: 'backup-remote-run', + step: 'remote_run_upload_archive', + fileName: archive.fileName, + stageTitle: 'txt_backup_remote_run_progress_upload_title', + stageDetail: 'txt_backup_remote_run_progress_upload_detail', + }); + upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName); + try { + await progress?.({ + operation: 'backup-remote-run', + step: 'remote_run_verify_archive', + fileName: archive.fileName, + stageTitle: 'txt_backup_remote_run_progress_verify_title', + stageDetail: 'txt_backup_remote_run_progress_verify_detail', + }); + const remoteFile = await remoteSession.download(archive.fileName); + const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName); + if (!checksumOk) { + throw new Error('Remote backup ZIP checksum verification failed'); + } + if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) { + throw new Error('Remote backup ZIP size verification failed'); + } + break; + } catch (error) { + await remoteSession.deleteFile(archive.fileName).catch(() => undefined); + if (attempt === maxArchiveUploadAttempts) { + const message = error instanceof Error ? error.message : 'Remote backup ZIP verification failed'; + throw new Error(`Backup archive upload verification failed after ${maxArchiveUploadAttempts} attempts: ${message}`); + } + } + } + if (!upload) { + throw new Error('Backup archive upload failed'); } - const upload = await uploadBackupArchive(destination, archive.bytes, archive.fileName); let prunedFileCount = 0; let pruneErrorMessage: string | null = null; try { + await progress?.({ + operation: 'backup-remote-run', + step: 'remote_run_cleanup', + fileName: archive.fileName, + stageTitle: 'txt_backup_remote_run_progress_cleanup_title', + stageDetail: 'txt_backup_remote_run_progress_cleanup_detail', + }); prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName); } catch (error) { pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed'; @@ -137,10 +293,21 @@ async function executeConfiguredBackup( remotePath: upload.remotePath, fileName: archive.fileName, fileBytes: archive.bytes.byteLength, + uploadVerificationAttempts: maxArchiveUploadAttempts, prunedFileCount, pruneError: pruneErrorMessage, }); + await progress?.({ + operation: 'backup-remote-run', + step: 'remote_run_complete', + fileName: archive.fileName, + stageTitle: 'txt_backup_remote_run_progress_complete_title', + stageDetail: 'txt_backup_remote_run_progress_complete_detail', + done: true, + ok: true, + }); + return { fileName: archive.fileName, fileSize: archive.bytes.byteLength, @@ -156,6 +323,16 @@ async function executeConfiguredBackup( ...getBackupDestinationSummary(destination), error: destination.runtime.lastErrorMessage, }); + await progress?.({ + operation: 'backup-remote-run', + step: 'remote_run_failed', + fileName: '', + stageTitle: 'txt_backup_remote_run_progress_failed_title', + stageDetail: 'txt_backup_remote_run_progress_failed_detail', + done: true, + ok: false, + error: destination.runtime.lastErrorMessage, + }); throw error; } } @@ -170,13 +347,35 @@ function toImportStatusCode(message: string): number { async function runImportAndAudit( env: Env, + request: Request, actorUser: User, archiveBytes: Uint8Array, + fileName: string, replaceExisting: boolean, metadata: Record ): Promise { const storage = new StorageService(env.DB); - const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting); + const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; + const progress: BackupRestoreProgressReporter = async (event) => { + await notifyUserBackupRestoreProgress( + env, + actorUser.id, + { + operation: 'backup-restore', + ...event, + }, + targetDeviceIdentifier + ); + }; + await progress({ + source: 'local', + step: 'local_upload_received', + fileName, + stageTitle: 'txt_backup_restore_progress_local_upload_title', + stageDetail: 'txt_backup_restore_progress_local_upload_detail', + replaceExisting, + }); + const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting, progress, fileName); await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, { users: imported.result.imported.users, ciphers: imported.result.imported.ciphers, @@ -309,7 +508,20 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env, return errorResponse('Backup run payload is invalid', 400); } - const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null); + const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; + const progress = async (event: { + operation: 'backup-remote-run'; + step: string; + fileName: string; + stageTitle: string; + stageDetail: string; + done?: boolean; + ok?: boolean; + error?: string | null; + }) => { + await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier); + }; + const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress); const settings = await loadBackupSettings(storage, env, 'UTC'); return jsonResponse({ object: 'backup-run', @@ -369,6 +581,29 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env } } +export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise { + if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); + + const storage = new StorageService(env.DB); + try { + const settings = await loadBackupSettings(storage, env, 'UTC'); + const url = new URL(request.url); + const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || ''); + const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null); + const remoteFile = await downloadRemoteBackupFile(destination, path); + const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path); + return jsonResponse({ + object: 'backup-remote-integrity', + destinationId: destination.id, + path, + fileName: remoteFile.fileName || path.split('/').pop() || path, + integrity, + }); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : 'Remote backup integrity inspection failed', 409); + } +} + export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise { if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); @@ -392,7 +627,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise { if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); - let body: { destinationId?: string; path?: string; replaceExisting?: boolean }; + let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean }; try { body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>(); } catch { @@ -404,7 +639,39 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, const settings = await loadBackupSettings(storage, env, 'UTC'); const destination = requireBackupDestination(settings, body.destinationId || null); const path = ensureRemoteRestoreCandidate(String(body.path || '')); + const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; + const restoreFileNameFromPath = path.split('/').pop() || path; + await notifyUserBackupRestoreProgress( + env, + actorUser.id, + { + operation: 'backup-restore', + source: 'remote', + step: 'remote_fetch_archive', + fileName: restoreFileNameFromPath, + stageTitle: 'txt_backup_restore_progress_remote_fetch_title', + stageDetail: 'txt_backup_restore_progress_remote_fetch_detail', + replaceExisting: !!body.replaceExisting, + }, + targetDeviceIdentifier + ); const remoteFile = await downloadRemoteBackupFile(destination, path); + const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path); + if (!checksumOk && !body.allowChecksumMismatch) { + return errorResponse('Remote backup file checksum does not match its filename', 400); + } + const restoreFileName = remoteFile.fileName || path.split('/').pop() || path; + const progress: BackupRestoreProgressReporter = async (event) => { + await notifyUserBackupRestoreProgress( + env, + actorUser.id, + { + operation: 'backup-restore', + ...event, + }, + targetDeviceIdentifier + ); + }; const imported = await (async () => { const storage = new StorageService(env.DB); const result = await importRemoteBackupArchiveBytes( @@ -413,12 +680,13 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser.id, !!body.replaceExisting, { - hasAttachment: async (blobName) => remoteBackupFileExists(destination, `attachments/${blobName}`), - loadAttachment: async (blobName) => { - const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null); - return file?.bytes || null; - }, - } + loadAttachment: async (blobName) => { + const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null); + return file?.bytes || null; + }, + }, + progress, + restoreFileName ); await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, { users: result.result.imported.users, @@ -431,6 +699,7 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, remotePath: path, bytes: remoteFile.bytes.byteLength, trigger: 'remote', + checksumMismatchAccepted: !checksumOk, }); return result; })(); @@ -445,6 +714,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); const storage = new StorageService(env.DB); + const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; let body: { includeAttachments?: boolean } | null = null; try { if ((request.headers.get('Content-Type') || '').includes('application/json')) { @@ -455,11 +725,49 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU } let archive: BackupArchiveBundle; try { + const progress = async (event: { + step: string; + fileName?: string; + stageTitle: string; + stageDetail: string; + includeAttachments: boolean; + }) => { + await notifyUserBackupProgress( + env, + actorUser.id, + { + operation: 'backup-export', + source: 'local', + step: `export_${event.step}`, + fileName: event.fileName || '', + stageTitle: event.stageTitle, + stageDetail: event.stageDetail, + }, + targetDeviceIdentifier + ); + }; archive = await buildBackupArchive(env, new Date(), { includeAttachments: !!body?.includeAttachments, + progress, }); } catch (error) { const message = error instanceof Error ? error.message : 'Backup export failed'; + await notifyUserBackupProgress( + env, + actorUser.id, + { + operation: 'backup-export', + source: 'local', + step: 'export_failed', + fileName: '', + stageTitle: 'txt_backup_export_progress_failed_title', + stageDetail: 'txt_backup_export_progress_failed_detail', + done: true, + ok: false, + error: message, + }, + targetDeviceIdentifier + ); return errorResponse(message, message.includes('blob missing') ? 409 : 500); } @@ -520,6 +828,7 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU } const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1'; + const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1'; let archiveBytes: Uint8Array; try { archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise }).arrayBuffer()); @@ -528,9 +837,15 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU } try { - const imported = await runImportAndAudit(env, actorUser, archiveBytes, replaceExisting, { + const fileName = 'name' in file ? String((file as File).name || '') : ''; + const checksumOk = await verifyBackupArchiveFileNameChecksum(archiveBytes, fileName); + if (!checksumOk && !allowChecksumMismatch) { + return errorResponse('Backup file checksum does not match its filename', 400); + } + const imported = await runImportAndAudit(env, request, actorUser, archiveBytes, fileName || 'nodewarden_backup.zip', replaceExisting, { trigger: 'local', bytes: archiveBytes.byteLength, + checksumMismatchAccepted: !checksumOk, }); return jsonResponse(imported.result); } catch (error) { diff --git a/src/router-admin-backup.ts b/src/router-admin-backup.ts index 9df721b..548b073 100644 --- a/src/router-admin-backup.ts +++ b/src/router-admin-backup.ts @@ -6,6 +6,7 @@ import { handleDownloadAdminBackupAttachment, handleGetAdminBackupSettings, handleGetAdminBackupSettingsRepairState, + handleInspectAdminRemoteBackup, handleAdminImportBackup, handleListAdminRemoteBackups, handleRepairAdminBackupSettings, @@ -53,6 +54,10 @@ export async function handleAdminBackupRoute( return handleDownloadAdminRemoteBackup(request, env, actorUser); } + if (path === '/api/admin/backup/remote/integrity' && method === 'GET') { + return handleInspectAdminRemoteBackup(request, env, actorUser); + } + if (path === '/api/admin/backup/remote/file' && method === 'DELETE') { return handleDeleteAdminRemoteBackup(request, env, actorUser); } diff --git a/src/services/backup-archive.ts b/src/services/backup-archive.ts index 39ed209..dca00e5 100644 --- a/src/services/backup-archive.ts +++ b/src/services/backup-archive.ts @@ -9,6 +9,7 @@ import { type SqlRow = Record; const BACKUP_FORMAT_VERSION = 1; +const BACKUP_FILE_HASH_PREFIX_LENGTH = 5; // Worker-side backup export must stay well below Cloudflare CPU limits. // Prefer store-only ZIP entries over heavier compression to keep exports reliable. const BACKUP_TEXT_COMPRESSION_LEVEL = 0; @@ -60,16 +61,39 @@ export interface BackupArchiveBundle { manifest: BackupManifest; } +export interface BackupFileIntegrityCheckResult { + hasChecksumPrefix: boolean; + expectedPrefix: string | null; + actualPrefix: string; + matches: boolean; +} + export interface BuildBackupArchiveOptions { includeAttachments?: boolean; + progress?: BackupArchiveBuildProgressReporter; } +export interface BackupArchiveBuildProgressEvent { + step: string; + fileName?: string; + stageTitle: string; + stageDetail: string; + includeAttachments: boolean; +} + +export type BackupArchiveBuildProgressReporter = (event: BackupArchiveBuildProgressEvent) => Promise; + 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 })); } -function buildBackupFileName(date: Date = new Date()): string { +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +function buildBackupFileName(date: Date = new Date(), checksumPrefix: string | null = null): string { const parts = [ date.getUTCFullYear().toString().padStart(4, '0'), (date.getUTCMonth() + 1).toString().padStart(2, '0'), @@ -78,7 +102,34 @@ function buildBackupFileName(date: Date = new Date()): string { date.getUTCMinutes().toString().padStart(2, '0'), date.getUTCSeconds().toString().padStart(2, '0'), ]; - return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}.zip`; + const suffix = checksumPrefix ? `_${checksumPrefix}` : ''; + return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}${suffix}.zip`; +} + +export function extractBackupFileChecksumPrefix(fileName: string): string | null { + const normalized = String(fileName || '').trim(); + const match = normalized.match(/_([0-9a-f]{5})\.zip$/i); + return match ? match[1].toLowerCase() : null; +} + +export async function inspectBackupArchiveFileNameChecksum( + bytes: Uint8Array, + fileName: string +): Promise { + const expectedPrefix = extractBackupFileChecksumPrefix(fileName); + const actualHash = await sha256Hex(bytes); + const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH); + return { + hasChecksumPrefix: !!expectedPrefix, + expectedPrefix, + actualPrefix, + matches: !expectedPrefix || actualPrefix === expectedPrefix, + }; +} + +export async function verifyBackupArchiveFileNameChecksum(bytes: Uint8Array, fileName: string): Promise { + const result = await inspectBackupArchiveFileNameChecksum(bytes, fileName); + return result.matches; } function validateArchiveSize(bytes: Uint8Array): void { @@ -269,16 +320,25 @@ export async function buildBackupArchive( date: Date = new Date(), options: BuildBackupArchiveOptions = {} ): Promise { + const includeAttachments = options.includeAttachments !== false; + await options.progress?.({ + step: 'collect_data', + fileName: '', + stageTitle: 'txt_backup_archive_progress_collect_title', + stageDetail: includeAttachments + ? 'txt_backup_archive_progress_collect_with_attachments_detail' + : 'txt_backup_archive_progress_collect_detail', + includeAttachments, + }); const encoder = new TextEncoder(); const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([ queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), - queryRows(env.DB, 'SELECT 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, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), + queryRows(env.DB, 'SELECT 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 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, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_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'), ]); - const includeAttachments = options.includeAttachments !== false; const exportedAttachmentRows = includeAttachments ? attachmentRows : []; const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => { const cipherId = String(row.cipher_id || '').trim(); @@ -327,9 +387,29 @@ export async function buildBackupArchive( }, null, BACKUP_JSON_INDENT)), }; + await options.progress?.({ + step: 'package_archive', + fileName: '', + stageTitle: 'txt_backup_archive_progress_package_title', + stageDetail: includeAttachments + ? 'txt_backup_archive_progress_package_with_attachments_detail' + : 'txt_backup_archive_progress_package_detail', + includeAttachments, + }); + const bytes = zipSync(createZipEntries(files)); + const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH); + const fileName = buildBackupFileName(date, fileHashPrefix); + await options.progress?.({ + step: 'archive_ready', + fileName, + stageTitle: 'txt_backup_archive_progress_ready_title', + stageDetail: 'txt_backup_archive_progress_ready_detail', + includeAttachments, + }); + return { - bytes: zipSync(createZipEntries(files)), - fileName: buildBackupFileName(date), + bytes, + fileName, manifest: manifestBase, }; } diff --git a/src/services/backup-config.ts b/src/services/backup-config.ts index 3ea0d9b..a5121e0 100644 --- a/src/services/backup-config.ts +++ b/src/services/backup-config.ts @@ -1,4 +1,4 @@ -import type { Env } from '../types'; +import type { Env, User } from '../types'; import { StorageService } from './storage'; import { type BackupSettingsPortableEnvelope, @@ -422,20 +422,45 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise { const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY); if (!raw) return; + const users = await storage.getAllUsers(); + const normalized = await normalizeImportedBackupSettingsValue(raw, env, users, fallbackTimezone); + if (normalized !== null) { + await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, normalized); + } +} + +export async function normalizeImportedBackupSettingsValue( + raw: string | null, + env: Env, + users: Pick[], + fallbackTimezone: string = 'UTC' +): Promise { + if (!raw) return null; const envelope = parseBackupSettingsEnvelope(raw); if (envelope) { try { const decrypted = await decryptBackupSettingsRuntime(raw, env); const settings = parseBackupSettings(decrypted, fallbackTimezone); - await saveBackupSettings(storage, env, settings); - return; + const hasPortableAdmins = users.some( + (user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0 + ); + if (!hasPortableAdmins) { + return serializeBackupSettings(settings); + } + return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); } catch { // Keep imported portable recovery data intact until an admin signs in and repairs it. - return; + return raw; } } const settings = parseBackupSettings(raw, fallbackTimezone); - await saveBackupSettings(storage, env, settings); + const hasPortableAdmins = users.some( + (user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0 + ); + if (!hasPortableAdmins) { + return serializeBackupSettings(settings); + } + return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); } export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise { diff --git a/src/services/backup-import.ts b/src/services/backup-import.ts index 38d1b1e..24088c8 100644 --- a/src/services/backup-import.ts +++ b/src/services/backup-import.ts @@ -1,7 +1,6 @@ -import type { Env } from '../types'; -import { StorageService } from './storage'; +import type { Env, User } from '../types'; import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store'; -import { normalizeImportedBackupSettings } from './backup-config'; +import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config'; import { type BackupManifestAttachmentBlob, type BackupPayload, @@ -10,6 +9,26 @@ import { } from './backup-archive'; type SqlRow = Record; +type BackupTableName = + | 'config' + | 'users' + | 'user_revisions' + | 'folders' + | 'ciphers' + | 'attachments'; + +const BACKUP_TABLES: BackupTableName[] = [ + 'config', + 'users', + 'user_revisions', + 'folders', + 'ciphers', + 'attachments', +]; + +function shadowTableName(table: BackupTableName): string { + return `${table}__restore`; +} export interface BackupImportResultBody { object: 'instance-backup-import'; @@ -43,6 +62,81 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro 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 }>(), @@ -61,18 +155,9 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[] '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)); } @@ -119,10 +204,90 @@ interface AttachmentRestoreResult { } interface RemoteAttachmentSource { - hasAttachment(blobName: string): Promise; 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, + })), + 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') { @@ -147,7 +312,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: }; }); - return { + const result = { payload: { ...payload, db: { @@ -161,6 +326,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: items: skippedItems, }, }; + return result; } const oversizedAttachmentPaths = new Set(); @@ -197,7 +363,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage'); } - return { + const result = { payload: nextPayload, skipped: { reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null, @@ -205,6 +371,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: items: skippedItems, }, }; + return result; } function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] { @@ -214,6 +381,16 @@ function buildInsertStatements(db: D1Database, table: string, columns: string[], 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'] = []; @@ -300,14 +477,10 @@ async function prepareRemoteAttachmentPayload( skippedItems.push({ kind: 'attachment', path, sizeBytes }); continue; } - if (!(await source.hasAttachment(ref.blobName))) { - skippedItems.push({ kind: 'attachment', path, sizeBytes }); - continue; - } nextAttachments.push(row); } - return { + const result = { payload: { ...payload, db: { @@ -321,16 +494,18 @@ async function prepareRemoteAttachmentPayload( items: skippedItems, }, }; + return result; } -async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[]): Promise { +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 attachments WHERE id = ? AND cipher_id = ?').bind(attachmentId, cipherId); + return db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND cipher_id = ?`).bind(attachmentId, cipherId); }) .filter((statement): statement is D1PreparedStatement => !!statement); if (!statements.length) return; @@ -406,36 +581,58 @@ async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set, after } } -async function importBackupRows(db: D1Database, payload: BackupPayload['db']): Promise { - const statements: D1PreparedStatement[] = [ - ...buildResetImportTargetStatements(db), - ...buildInsertStatements(db, 'config', ['key', 'value'], payload.config || [], true), - ...buildInsertStatements( +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, - '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', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'], + 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 || [] - ), - ...buildInsertStatements(db, 'user_revisions', ['user_id', 'revision_date'], payload.user_revisions || [], true), - ...buildInsertStatements(db, 'folders', ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || []), - ...buildInsertStatements( + ) + ); + await runInsertBatch( + db, + tableName('user_revisions'), + buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], 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, - 'ciphers', - ['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'], + tableName('ciphers'), + ['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'], payload.ciphers || [] - ), - ...buildInsertStatements(db, 'attachments', ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []), - ]; - await db.batch(statements); + ) + ); + 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 + replaceExisting: boolean, + progress?: BackupRestoreProgressReporter, + fileName: string = 'nodewarden_backup.zip' ): Promise { - const storage = new StorageService(env.DB); const parsed = parseBackupArchive(archiveBytes); validateBackupPayloadContents(parsed.payload, parsed.files); const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files); @@ -448,40 +645,118 @@ export async function importBackupArchiveBytes( } } + await resetRestoreArtifacts(env.DB); const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set(); - const { db } = prepared.payload; - await importBackupRows(env.DB, db); - await normalizeImportedBackupSettings(storage, env, 'UTC'); + 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, + user_revisions: (db.user_revisions || []).length, + folders: (db.folders || []).length, + ciphers: (db.ciphers || []).length, + attachments: (db.attachments || []).length, + }); - const restored = await restoreBlobFiles(env, db, parsed.files); - const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row)); - await removeAttachmentRows(env.DB, failedRestoreRows); - if (replaceExisting && previousBlobKeys.size) { - await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB)); + 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, + 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, + 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; } - - await storage.setRegistered(); - - 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, - 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], - }, - }, - }; } export async function importRemoteBackupArchiveBytes( @@ -489,9 +764,10 @@ export async function importRemoteBackupArchiveBytes( env: Env, actorUserId: string, replaceExisting: boolean, - source: RemoteAttachmentSource + source: RemoteAttachmentSource, + progress?: BackupRestoreProgressReporter, + fileName: string = 'nodewarden_backup.zip' ): Promise { - const storage = new StorageService(env.DB); const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true }); const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source); validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true }); @@ -504,44 +780,122 @@ export async function importRemoteBackupArchiveBytes( } } + await resetRestoreArtifacts(env.DB); const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set(); - const { db } = preparedRemote.payload; - await importBackupRows(env.DB, db); - await normalizeImportedBackupSettings(storage, env, 'UTC'); + 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, + user_revisions: (db.user_revisions || []).length, + folders: (db.folders || []).length, + ciphers: (db.ciphers || []).length, + attachments: (db.attachments || []).length, + }); - const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source); - const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row)); - await removeAttachmentRows(env.DB, failedRestoreRows); + 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, + 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) { - await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB)); + 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, + 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; } - - await storage.setRegistered(); - - 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, - 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, - }, - }, - }; } diff --git a/src/services/backup-uploader.ts b/src/services/backup-uploader.ts index 1b80c25..1903851 100644 --- a/src/services/backup-uploader.ts +++ b/src/services/backup-uploader.ts @@ -250,18 +250,49 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut } } +async function ensureWebDavDirectoryCached( + baseUrl: string, + directoryPath: string, + authHeader: string, + ensuredDirectories: Set +): Promise { + const segments = trimSlashes(directoryPath).split('/').filter(Boolean); + let current = ''; + for (const segment of segments) { + current = buildJoinedPath(current, segment); + if (ensuredDirectories.has(current)) continue; + const url = buildWebDavUrl(baseUrl, current); + const response = await fetch(url, { + method: 'MKCOL', + headers: { + Authorization: authHeader, + }, + }); + if ([200, 201, 204, 301, 302, 405].includes(response.status)) { + ensuredDirectories.add(current); + continue; + } + throw new Error(`WebDAV directory creation failed: ${response.status}`); + } +} + async function putToWebDav( config: WebDavBackupDestination, relativePath: string, bytes: Uint8Array, - options: RemoteBackupFilePutOptions = {} + options: RemoteBackupFilePutOptions = {}, + ensuredDirectories?: Set ): Promise { const authHeader = toBasicAuthHeader(config.username, config.password); const remoteFilePath = buildJoinedPath(config.remotePath, relativePath); const remoteDir = parentPath(remoteFilePath); if (remoteDir) { - await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader); + if (ensuredDirectories) { + await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories); + } else { + await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader); + } } const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), { @@ -608,6 +639,16 @@ interface ConfiguredDestinationAdapter { exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise; } +export interface RemoteBackupTransferSession { + provider: BackupDestinationType; + uploadArchive(archive: Uint8Array, fileName: string): Promise; + putFile(relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions): Promise; + list(relativePath: string): Promise; + download(relativePath: string): Promise; + deleteFile(relativePath: string): Promise; + exists(relativePath: string): Promise; +} + function resolveConfiguredDestinationAdapter( destination: BackupDestinationRecord ): ConfiguredDestinationAdapter { @@ -641,35 +682,62 @@ function resolveConfiguredDestinationAdapter( throw new Error('Unsupported backup destination type'); } +export function createRemoteBackupTransferSession(destination: BackupDestinationRecord): RemoteBackupTransferSession { + const adapter = resolveConfiguredDestinationAdapter(destination); + const ensuredDirectories = adapter.provider === 'webdav' ? new Set() : null; + + const putFile = async (relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}): Promise => { + const normalized = normalizeRelativePath(relativePath); + if (adapter.provider === 'webdav' && ensuredDirectories) { + await putToWebDav(adapter.config as WebDavBackupDestination, normalized, bytes, options, ensuredDirectories); + return; + } + await adapter.putFile(adapter.config, normalized, bytes, options); + }; + + return { + provider: adapter.provider, + uploadArchive: async (archive: Uint8Array, fileName: string) => { + await putFile(fileName, archive, { contentType: 'application/zip' }); + return { + provider: adapter.provider, + remotePath: adapter.provider === 'webdav' + ? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName) + : normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName), + }; + }, + putFile, + list: async (relativePath: string) => adapter.list(adapter.config, relativePath), + download: async (relativePath: string) => adapter.download(adapter.config, relativePath), + deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)), + exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)), + }; +} + export async function uploadBackupArchive( destination: BackupDestinationRecord, archive: Uint8Array, fileName: string ): Promise { - const adapter = resolveConfiguredDestinationAdapter(destination); - return adapter.upload(adapter.config, archive, fileName); + return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName); } export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise { - const adapter = resolveConfiguredDestinationAdapter(destination); - return adapter.list(adapter.config, relativePath); + return createRemoteBackupTransferSession(destination).list(relativePath); } export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise { - const adapter = resolveConfiguredDestinationAdapter(destination); - return adapter.download(adapter.config, relativePath); + return createRemoteBackupTransferSession(destination).download(relativePath); } export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise { const normalized = ensureRemoteRestoreCandidate(relativePath); - const adapter = resolveConfiguredDestinationAdapter(destination); - await adapter.deleteFile(adapter.config, normalized); + await createRemoteBackupTransferSession(destination).deleteFile(normalized); } export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise { const normalized = normalizeRelativePath(relativePath); - const adapter = resolveConfiguredDestinationAdapter(destination); - return adapter.exists(adapter.config, normalized); + return createRemoteBackupTransferSession(destination).exists(normalized); } export async function uploadRemoteBackupFile( @@ -679,8 +747,7 @@ export async function uploadRemoteBackupFile( options: RemoteBackupFilePutOptions = {} ): Promise { const normalized = normalizeRelativePath(relativePath); - const adapter = resolveConfiguredDestinationAdapter(destination); - await adapter.putFile(adapter.config, normalized, bytes, options); + await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options); } function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number { diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index f057eaa..7abf002 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -50,6 +50,7 @@ import useVaultSendActions from '@/hooks/useVaultSendActions'; import { useToastManager } from '@/hooks/useToastManager'; import { t } from '@/lib/i18n'; import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify'; +import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress'; import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; const IMPORT_ROUTE = '/backup/import-export'; @@ -62,6 +63,7 @@ const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; +const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13; type ThemePreference = 'system' | 'light' | 'dark'; const MAGNETIC_SELECTOR = '.topbar .btn, .topbar .user-chip, .side-link, .mobile-tab'; @@ -908,6 +910,21 @@ export default function App() { void refreshAuthorizedDevicesRef.current(); continue; } + if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) { + const payload = frame.arguments?.[0]?.Payload; + if ( + payload + && typeof payload === 'object' + && ( + payload.operation === 'backup-restore' + || payload.operation === 'backup-export' + || payload.operation === 'backup-remote-run' + ) + ) { + dispatchBackupProgress(payload as BackupProgressDetail); + } + continue; + } if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; const contextId = String(frame.arguments?.[0]?.ContextId || '').trim(); if (contextId && contextId === getCurrentDeviceIdentifier()) continue; @@ -1113,13 +1130,16 @@ export default function App() { onRevokeInvite: adminActions.revokeInvite, onExportBackup: backupActions.exportBackup, onImportBackup: backupActions.importBackup, + onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch, onLoadBackupSettings: backupActions.loadSettings, onSaveBackupSettings: backupActions.saveSettings, onRunRemoteBackup: backupActions.runRemoteBackup, onListRemoteBackups: backupActions.listRemoteBackups, onDownloadRemoteBackup: backupActions.downloadRemoteBackup, + onInspectRemoteBackup: backupActions.inspectRemoteBackup, onDeleteRemoteBackup: backupActions.deleteRemoteBackup, onRestoreRemoteBackup: backupActions.restoreRemoteBackup, + onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch, }; if (jwtWarning) { diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 303d578..6ab5f27 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -106,13 +106,16 @@ export interface AppMainRoutesProps { onRevokeInvite: (code: string) => Promise; onExportBackup: (includeAttachments?: boolean) => Promise; onImportBackup: (file: File, replaceExisting?: boolean) => Promise; + onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise; onLoadBackupSettings: () => Promise; onSaveBackupSettings: (settings: AdminBackupSettings) => Promise; onRunRemoteBackup: (destinationId?: string | null) => Promise; onListRemoteBackups: (destinationId: string, path: string) => Promise; onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; + onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise; onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; + onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; } export default function AppMainRoutes(props: AppMainRoutesProps) { @@ -333,11 +336,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { currentUserId={props.profile?.id || null} onExport={props.onExportBackup} onImport={props.onImportBackup} + onImportAllowingChecksumMismatch={props.onImportBackupAllowingChecksumMismatch} onLoadSettings={props.onLoadBackupSettings} onListRemoteBackups={props.onListRemoteBackups} onDownloadRemoteBackup={props.onDownloadRemoteBackup} + onInspectRemoteBackup={props.onInspectRemoteBackup} onDeleteRemoteBackup={props.onDeleteRemoteBackup} onRestoreRemoteBackup={props.onRestoreRemoteBackup} + onRestoreRemoteBackupAllowingChecksumMismatch={props.onRestoreRemoteBackupAllowingChecksumMismatch} onSaveSettings={props.onSaveBackupSettings} onRunRemoteBackup={props.onRunRemoteBackup} onNotify={props.onNotify} diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx index 62ad011..9c46df7 100644 --- a/webapp/src/components/BackupCenterPage.tsx +++ b/webapp/src/components/BackupCenterPage.tsx @@ -1,12 +1,15 @@ +import { createPortal } from 'preact/compat'; import { useEffect, useRef, useState } from 'preact/hooks'; import ConfirmDialog from '@/components/ConfirmDialog'; import { type AdminBackupImportResponse, type AdminBackupRunResponse, type AdminBackupSettings, + type BackupFileIntegrityCheckResult, type BackupDestinationRecord, type BackupDestinationType, type RemoteBackupBrowserResponse, + verifyBackupFileIntegrity, } from '@/lib/api/backup'; import { REMOTE_BROWSER_ITEMS_PER_PAGE, @@ -22,6 +25,7 @@ import { loadPersistedRemoteBrowserState, persistRemoteBrowserState, } from '@/lib/backup-center'; +import { BACKUP_PROGRESS_EVENT, type BackupProgressDetail, type BackupProgressOperation } from '@/lib/backup-restore-progress'; import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations'; import { t } from '@/lib/i18n'; import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail'; @@ -32,16 +36,82 @@ interface BackupCenterPageProps { currentUserId: string | null; onExport: (includeAttachments?: boolean) => Promise; onImport: (file: File, replaceExisting?: boolean) => Promise; + onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise; onLoadSettings: () => Promise; onSaveSettings: (settings: AdminBackupSettings) => Promise; onRunRemoteBackup: (destinationId?: string | null) => Promise; onListRemoteBackups: (destinationId: string, path: string) => Promise; onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; + onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise; onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; + onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; } +type PendingRestoreIntegrity = + | { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult } + | { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult }; + +interface BackupProgressPhase { + titleKey: string; + detailKey: string; +} + +interface BackupProgressState { + operation: BackupProgressOperation; + source: 'local' | 'remote' | null; + includeAttachments: boolean; + fileLabel: string; + startedAt: number; + phaseIndex: number; + phases: BackupProgressPhase[]; + currentTitleKey: string; + currentDetailKey: string; +} + +const LOCAL_RESTORE_PHASES: BackupProgressPhase[] = [ + { titleKey: 'txt_backup_restore_progress_local_upload_title', detailKey: 'txt_backup_restore_progress_local_upload_detail' }, + { titleKey: 'txt_backup_restore_progress_local_shadow_title', detailKey: 'txt_backup_restore_progress_local_shadow_detail' }, + { titleKey: 'txt_backup_restore_progress_local_data_title', detailKey: 'txt_backup_restore_progress_local_data_detail' }, + { titleKey: 'txt_backup_restore_progress_local_files_title', detailKey: 'txt_backup_restore_progress_local_files_detail' }, + { titleKey: 'txt_backup_restore_progress_local_finalize_title', detailKey: 'txt_backup_restore_progress_local_finalize_detail' }, +]; + +const REMOTE_RESTORE_PHASES: BackupProgressPhase[] = [ + { titleKey: 'txt_backup_restore_progress_remote_fetch_title', detailKey: 'txt_backup_restore_progress_remote_fetch_detail' }, + { titleKey: 'txt_backup_restore_progress_remote_shadow_title', detailKey: 'txt_backup_restore_progress_remote_shadow_detail' }, + { titleKey: 'txt_backup_restore_progress_remote_data_title', detailKey: 'txt_backup_restore_progress_remote_data_detail' }, + { titleKey: 'txt_backup_restore_progress_remote_files_title', detailKey: 'txt_backup_restore_progress_remote_files_detail' }, + { titleKey: 'txt_backup_restore_progress_remote_finalize_title', detailKey: 'txt_backup_restore_progress_remote_finalize_detail' }, +]; + +const EXPORT_PROGRESS_PHASES: BackupProgressPhase[] = [ + { titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_detail' }, + { titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_detail' }, + { titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' }, + { titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' }, +]; + +const EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES: BackupProgressPhase[] = [ + { titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' }, + { titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' }, + { titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' }, + { titleKey: 'txt_backup_export_progress_fetch_attachments_title', detailKey: 'txt_backup_export_progress_fetch_attachments_detail' }, + { titleKey: 'txt_backup_export_progress_rebuild_title', detailKey: 'txt_backup_export_progress_rebuild_detail' }, + { titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' }, +]; + +const REMOTE_RUN_PROGRESS_PHASES: BackupProgressPhase[] = [ + { titleKey: 'txt_backup_remote_run_progress_prepare_title', detailKey: 'txt_backup_remote_run_progress_prepare_detail' }, + { titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' }, + { titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' }, + { titleKey: 'txt_backup_remote_run_progress_sync_attachments_title', detailKey: 'txt_backup_remote_run_progress_sync_attachments_detail' }, + { titleKey: 'txt_backup_remote_run_progress_upload_title', detailKey: 'txt_backup_remote_run_progress_upload_detail' }, + { titleKey: 'txt_backup_remote_run_progress_verify_title', detailKey: 'txt_backup_remote_run_progress_verify_detail' }, + { titleKey: 'txt_backup_remote_run_progress_cleanup_title', detailKey: 'txt_backup_remote_run_progress_cleanup_detail' }, +]; + function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null { const skipped = result.skipped; if (!skipped || !skipped.attachments) return null; @@ -51,10 +121,56 @@ function buildSkippedImportMessage(result: AdminBackupImportResponse): string | }); } +function buildIntegrityStatusMessage(result: BackupFileIntegrityCheckResult, options?: { remote?: boolean }): string { + if (!result.hasChecksumPrefix) { + return t(options?.remote ? 'txt_backup_remote_restore_completed_without_checksum' : 'txt_backup_restore_completed_without_checksum'); + } + return t(options?.remote ? 'txt_backup_remote_restore_completed_verified' : 'txt_backup_restore_completed_verified'); +} + +function buildIntegrityWarningMessage(entry: PendingRestoreIntegrity): string { + if (entry.source === 'remote') { + return t('txt_backup_remote_restore_checksum_warning_message', { + name: entry.fileName, + expected: entry.result.expectedPrefix || '-----', + actual: entry.result.actualPrefix, + }); + } + return t('txt_backup_restore_checksum_warning_message', { + name: entry.fileName, + expected: entry.result.expectedPrefix || '-----', + actual: entry.result.actualPrefix, + }); +} + +function getBackupProgressPhases( + operation: BackupProgressOperation, + source: 'local' | 'remote' | null, + includeAttachments: boolean +): BackupProgressPhase[] { + if (operation === 'backup-restore') { + return source === 'remote' ? REMOTE_RESTORE_PHASES : LOCAL_RESTORE_PHASES; + } + if (operation === 'backup-export') { + return includeAttachments ? EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES : EXPORT_PROGRESS_PHASES; + } + return REMOTE_RUN_PROGRESS_PHASES; +} + +function getBackupProgressTitleKey(state: BackupProgressState): string { + if (state.operation === 'backup-export') return 'txt_backup_export_progress_title'; + if (state.operation === 'backup-remote-run') return 'txt_backup_remote_run_progress_title'; + return state.source === 'remote' + ? 'txt_backup_restore_progress_remote_title' + : 'txt_backup_restore_progress_local_title'; +} + export default function BackupCenterPage(props: BackupCenterPageProps) { const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId)); const persistedRemoteState = persistedRemoteStateRef.current; const fileInputRef = useRef(null); + const restoreProgressTimerRef = useRef(null); + const restoreProgressPendingRef = useRef(null); const [selectedFile, setSelectedFile] = useState(null); const [exporting, setExporting] = useState(false); @@ -67,14 +183,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { const [downloadingRemotePath, setDownloadingRemotePath] = useState(''); const [downloadingRemotePercent, setDownloadingRemotePercent] = useState(null); const [restoringRemotePath, setRestoringRemotePath] = useState(''); - const [remoteRestoreStatusText, setRemoteRestoreStatusText] = useState(''); const [deletingRemotePath, setDeletingRemotePath] = useState(''); const [localError, setLocalError] = useState(''); + const [restoreProgress, setRestoreProgress] = useState(null); + const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0); const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false); const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false); const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false); + const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false); const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false); const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false); + const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState(null); const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState(''); const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState(''); const [savedSettings, setSavedSettings] = useState(null); @@ -148,6 +267,59 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { }); }, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]); + useEffect(() => { + if (!restoreProgress) { + setRestoreElapsedSeconds(0); + return; + } + setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000))); + const tickTimer = window.setInterval(() => { + setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000))); + }, 1000); + return () => window.clearInterval(tickTimer); + }, [restoreProgress]); + + useEffect(() => { + const handleProgress = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (!detail) return; + const pending = restoreProgressPendingRef.current; + const operation = detail.operation || pending?.operation || 'backup-restore'; + const source = (detail.source || pending?.source || null) as 'local' | 'remote' | null; + const includeAttachments = pending?.includeAttachments || false; + const phases = getBackupProgressPhases(operation, source, includeAttachments); + const matchedPhaseIndex = phases.findIndex((phase) => phase.titleKey === detail.stageTitle); + const phaseIndex = matchedPhaseIndex >= 0 ? matchedPhaseIndex : 0; + const nextState: BackupProgressState = { + operation, + source, + includeAttachments, + fileLabel: detail.fileName || pending?.fileLabel || '', + startedAt: pending?.operation === operation + ? pending.startedAt + : Date.now(), + phaseIndex, + phases, + currentTitleKey: detail.stageTitle || phases[Math.max(0, phaseIndex)].titleKey, + currentDetailKey: detail.stageDetail || phases[Math.max(0, phaseIndex)].detailKey, + }; + restoreProgressPendingRef.current = nextState; + if (restoreProgressTimerRef.current === null) { + setRestoreProgress(nextState); + } + if (detail.done) { + window.setTimeout(() => { + setRestoreProgress((current) => ( + current && current.fileLabel === (detail.fileName || current.fileLabel) ? null : current + )); + setRestoreElapsedSeconds(0); + }, detail.ok === false ? 1200 : 900); + } + }; + window.addEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener); + return () => window.removeEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener); + }, []); + function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) { setSettings((current) => { const next = mutator(current); @@ -225,6 +397,67 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { if (fileInputRef.current) fileInputRef.current.value = ''; } + function resetPendingIntegrityWarning() { + setPendingRestoreIntegrity(null); + setConfirmIntegrityWarningOpen(false); + } + + function startRestoreProgress( + operation: BackupProgressOperation, + fileLabel: string, + options?: { source?: 'local' | 'remote' | null; includeAttachments?: boolean; delayMs?: number } + ) { + if (restoreProgressTimerRef.current !== null) { + window.clearTimeout(restoreProgressTimerRef.current); + restoreProgressTimerRef.current = null; + } + setRestoreElapsedSeconds(0); + const source = options?.source || null; + const includeAttachments = !!options?.includeAttachments; + const phases = getBackupProgressPhases(operation, source, includeAttachments); + restoreProgressPendingRef.current = { + operation, + source, + includeAttachments, + fileLabel, + startedAt: Date.now(), + phaseIndex: 0, + phases, + currentTitleKey: phases[0].titleKey, + currentDetailKey: phases[0].detailKey, + }; + restoreProgressTimerRef.current = window.setTimeout(() => { + restoreProgressTimerRef.current = null; + if (!restoreProgressPendingRef.current) return; + setRestoreProgress(restoreProgressPendingRef.current); + }, options?.delayMs ?? 480); + } + + function clearRestoreProgress() { + if (restoreProgressTimerRef.current !== null) { + window.clearTimeout(restoreProgressTimerRef.current); + restoreProgressTimerRef.current = null; + } + restoreProgressPendingRef.current = null; + setRestoreProgress(null); + setRestoreElapsedSeconds(0); + } + + async function inspectLocalBackupFile(file: File): Promise { + const bytes = new Uint8Array(await file.arrayBuffer()); + return verifyBackupFileIntegrity(bytes, file.name || ''); + } + + async function inspectRemoteBackupFile(destinationId: string, path: string): Promise { + const payload = await props.onInspectRemoteBackup(destinationId, path); + return { + source: 'remote', + path, + fileName: payload.fileName || path.split('/').pop() || path, + result: payload.integrity, + }; + } + function handleAddDestination(type: BackupDestinationType) { updateSettings((current) => { const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1); @@ -277,18 +510,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { setLocalError(''); setExporting(true); try { + startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments }); await props.onExport(exportIncludeAttachments); 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); + window.setTimeout(() => clearRestoreProgress(), 1200); } finally { setExporting(false); } } - async function runLocalRestore(replaceExisting: boolean) { + async function runLocalRestore( + replaceExisting: boolean, + allowChecksumMismatch: boolean = false, + knownIntegrity?: BackupFileIntegrityCheckResult + ) { if (!selectedFile) { const message = t('txt_backup_file_required'); setLocalError(message); @@ -296,17 +535,29 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { return; } setLocalError(''); + setConfirmLocalRestoreOpen(false); + setConfirmReplaceOpen(false); + setConfirmIntegrityWarningOpen(false); setImporting(true); try { - const result = await props.onImport(selectedFile, replaceExisting); - props.onNotify('success', t('txt_backup_restore_success_relogin')); + const integrity = knownIntegrity || await inspectLocalBackupFile(selectedFile); + startRestoreProgress('backup-restore', selectedFile.name || t('txt_backup_import'), { + source: 'local', + delayMs: replaceExisting ? 480 : 1400, + }); + const result = allowChecksumMismatch + ? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting) + : await props.onImport(selectedFile, replaceExisting); + props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`); const skippedMessage = buildSkippedImportMessage(result); if (skippedMessage) props.onNotify('warning', skippedMessage); resetSelectedFile(); setConfirmLocalRestoreOpen(false); setConfirmReplaceOpen(false); + resetPendingIntegrityWarning(); } catch (error) { if (!replaceExisting && isReplaceRequiredError(error)) { + clearRestoreProgress(); setConfirmLocalRestoreOpen(false); setConfirmReplaceOpen(true); return; @@ -314,6 +565,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { const message = error instanceof Error ? error.message : t('txt_backup_restore_failed'); setLocalError(message); props.onNotify('error', message); + window.setTimeout(() => clearRestoreProgress(), 1200); } finally { setImporting(false); } @@ -364,16 +616,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { setRunningRemoteBackup(true); setLocalError(''); try { + startRestoreProgress('backup-remote-run', selectedDestination.name || t('txt_backup_run_now'), { + source: 'remote', + includeAttachments: !!selectedDestination.includeAttachments, + }); const result = await props.onRunRemoteBackup(selectedDestination.id); setSavedSettings(result.settings); setSettings(result.settings); setSelectedDestinationId(selectedDestination.id); await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true }); - props.onNotify('success', t('txt_backup_remote_run_success')); + props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.fileName })); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed'); setLocalError(message); props.onNotify('error', message); + window.setTimeout(() => clearRestoreProgress(), 1200); } finally { setRunningRemoteBackup(false); } @@ -415,30 +672,88 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { } } - async function runRemoteRestore(path: string, replaceExisting: boolean) { + async function handleSelectedLocalFile(nextFile: File | null) { + setSelectedFile(nextFile); + setLocalError(''); + resetPendingIntegrityWarning(); + setConfirmLocalRestoreOpen(false); + if (!nextFile) return; + + try { + const integrity = await inspectLocalBackupFile(nextFile); + if (!integrity.matches) { + setPendingRestoreIntegrity({ + source: 'local', + fileName: nextFile.name, + result: integrity, + }); + setConfirmIntegrityWarningOpen(true); + return; + } + setConfirmLocalRestoreOpen(true); + } catch (error) { + const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed'); + setLocalError(message); + props.onNotify('error', message); + } + } + + async function handlePromptRemoteRestore(path: string) { if (!savedSelectedDestination) return; + setLocalError(''); + resetPendingIntegrityWarning(); + try { + const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path); + if (!integrity.result.matches) { + setPendingRestoreIntegrity(integrity); + setConfirmIntegrityWarningOpen(true); + return; + } + await runRemoteRestore(path, false, false, integrity.result); + } catch (error) { + const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed'); + setLocalError(message); + props.onNotify('error', message); + } + } + + async function runRemoteRestore( + path: string, + replaceExisting: boolean, + allowChecksumMismatch: boolean = false, + knownIntegrity?: BackupFileIntegrityCheckResult + ) { + if (!savedSelectedDestination) return; + setConfirmRemoteReplaceOpen(false); + setConfirmIntegrityWarningOpen(false); setRestoringRemotePath(path); - setRemoteRestoreStatusText(replaceExisting ? t('txt_backup_remote_restore_stage_replace') : t('txt_backup_remote_restore_stage_prepare')); setLocalError(''); try { - const result = await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); + const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path); + startRestoreProgress('backup-restore', path.split('/').pop() || path, { + source: 'remote', + delayMs: replaceExisting ? 480 : 1400, + }); + const result = allowChecksumMismatch + ? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting) + : await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); setConfirmRemoteReplaceOpen(false); setPendingRemoteRestorePath(''); - setRemoteRestoreStatusText(''); - props.onNotify('success', t('txt_backup_restore_success_relogin')); + props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`); const skippedMessage = buildSkippedImportMessage(result); if (skippedMessage) props.onNotify('warning', skippedMessage); + resetPendingIntegrityWarning(); } catch (error) { if (!replaceExisting && isReplaceRequiredError(error)) { setPendingRemoteRestorePath(path); setConfirmRemoteReplaceOpen(true); - setRemoteRestoreStatusText(''); + clearRestoreProgress(); return; } const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed'); - setRemoteRestoreStatusText(''); setLocalError(message); props.onNotify('error', message); + window.setTimeout(() => clearRestoreProgress(), 1200); } finally { setRestoringRemotePath(''); } @@ -454,9 +769,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { disabled={disableWhileBusy} onChange={(event) => { const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null; - setSelectedFile(nextFile); - setLocalError(''); - if (nextFile) setConfirmLocalRestoreOpen(true); + void handleSelectedLocalFile(nextFile); }} /> @@ -521,7 +834,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path); }} onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)} - onRestoreRemoteBackup={(path) => void runRemoteRestore(path, false)} + onRestoreRemoteBackup={(path) => void handlePromptRemoteRestore(path)} onPromptDeleteRemoteBackup={(path) => { setPendingRemoteDeletePath(path); setConfirmRemoteDeleteOpen(true); @@ -533,7 +846,49 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { /> {localError ?
{localError}
: null} - {!localError && remoteRestoreStatusText ?
{remoteRestoreStatusText}
: null} + {restoreProgress && typeof document !== 'undefined' ? createPortal(( +
+
+
+
+
{t('txt_backup_progress_kicker')}
+

+ {t(getBackupProgressTitleKey(restoreProgress))} +

+

+ {t('txt_backup_progress_subject', { name: restoreProgress.fileLabel })} +

+
+
+ {t('txt_backup_restore_progress_elapsed', { seconds: String(restoreElapsedSeconds) })} +
+
+
+ +
+
+ {t(restoreProgress.currentTitleKey)} +

{t(restoreProgress.currentDetailKey)}

+
+
    + {restoreProgress.phases.map((phase, index) => { + const status = index < restoreProgress.phaseIndex ? 'done' : index === restoreProgress.phaseIndex ? 'active' : 'pending'; + return ( +
  1. + + {t(phase.titleKey)} +
  2. + ); + })} +
+
+
+ ), document.body) : null} { setConfirmLocalRestoreOpen(false); resetSelectedFile(); + resetPendingIntegrityWarning(); }} /> @@ -558,11 +914,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { confirmDisabled={importing} cancelDisabled={importing} danger - onConfirm={() => void runLocalRestore(true)} + onConfirm={() => void runLocalRestore( + true, + pendingRestoreIntegrity?.source === 'local', + pendingRestoreIntegrity?.source === 'local' ? pendingRestoreIntegrity.result : undefined + )} onCancel={() => { if (importing) return; setConfirmReplaceOpen(false); resetSelectedFile(); + resetPendingIntegrityWarning(); }} /> @@ -575,11 +936,45 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { confirmDisabled={!!restoringRemotePath} cancelDisabled={!!restoringRemotePath} danger - onConfirm={() => void runRemoteRestore(pendingRemoteRestorePath, true)} + onConfirm={() => void runRemoteRestore( + pendingRemoteRestorePath, + true, + pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath, + pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath + ? pendingRestoreIntegrity.result + : undefined + )} onCancel={() => { if (restoringRemotePath) return; setConfirmRemoteReplaceOpen(false); setPendingRemoteRestorePath(''); + resetPendingIntegrityWarning(); + }} + /> + + { + if (!pendingRestoreIntegrity) return; + setConfirmIntegrityWarningOpen(false); + if (pendingRestoreIntegrity.source === 'local') { + void runLocalRestore(false, true, pendingRestoreIntegrity.result); + return; + } + void runRemoteRestore(pendingRestoreIntegrity.path, false, true, pendingRestoreIntegrity.result); + }} + onCancel={() => { + if (importing || restoringRemotePath) return; + resetPendingIntegrityWarning(); + setPendingRemoteRestorePath(''); + setConfirmLocalRestoreOpen(false); + resetSelectedFile(); }} /> diff --git a/webapp/src/components/ConfirmDialog.tsx b/webapp/src/components/ConfirmDialog.tsx index 4f0d0be..5f277c6 100644 --- a/webapp/src/components/ConfirmDialog.tsx +++ b/webapp/src/components/ConfirmDialog.tsx @@ -1,11 +1,14 @@ +import { createPortal } from 'preact/compat'; import { useEffect, useState } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; +import { TriangleAlert } from 'lucide-preact'; import { t } from '@/lib/i18n'; interface ConfirmDialogProps { open: boolean; title: string; message: string; + variant?: 'default' | 'warning'; showIcon?: boolean; confirmText?: string; cancelText?: string; @@ -19,9 +22,49 @@ interface ConfirmDialogProps { afterActions?: ComponentChildren; } +function incrementDialogBodyLock() { + if (typeof document === 'undefined') return; + const body = document.body; + const nextCount = Number(body.dataset.dialogCount || '0') + 1; + body.dataset.dialogCount = String(nextCount); + body.classList.add('dialog-open'); +} + +function decrementDialogBodyLock() { + if (typeof document === 'undefined') return; + const body = document.body; + const nextCount = Math.max(0, Number(body.dataset.dialogCount || '0') - 1); + if (nextCount === 0) { + delete body.dataset.dialogCount; + body.classList.remove('dialog-open'); + return; + } + body.dataset.dialogCount = String(nextCount); +} + +export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) { + useEffect(() => { + if (!active) return; + incrementDialogBodyLock(); + return () => decrementDialogBodyLock(); + }, [active]); + + useEffect(() => { + if (!active || !onCancel || typeof window === 'undefined') return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + onCancel(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [active, onCancel]); +} + export default function ConfirmDialog(props: ConfirmDialogProps) { const [present, setPresent] = useState(props.open); const [closing, setClosing] = useState(false); + const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel; useEffect(() => { if (props.open) { @@ -38,19 +81,41 @@ export default function ConfirmDialog(props: ConfirmDialogProps) { return () => window.clearTimeout(timer); }, [props.open, present]); - if (!present) return null; - return ( -
+ useDialogLifecycle(present, canDismiss ? props.onCancel : null); + + if (!present || typeof document === 'undefined') return null; + return createPortal(( +
{ + if (event.target !== event.currentTarget || !canDismiss) return; + props.onCancel(); + }} + >
{ e.preventDefault(); if (props.confirmDisabled || closing) return; props.onConfirm(); }} > + {props.variant === 'warning' ? ( + <> + - ); + ), document.body); } diff --git a/webapp/src/components/ImportPage.tsx b/webapp/src/components/ImportPage.tsx index c4eed7d..173ce12 100644 --- a/webapp/src/components/ImportPage.tsx +++ b/webapp/src/components/ImportPage.tsx @@ -1,9 +1,10 @@ import { useState } from 'preact/hooks'; import { argon2idAsync } from '@noble/hashes/argon2.js'; +import { createPortal } from 'preact/compat'; import { strFromU8, unzipSync } from 'fflate'; import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js'; import { Download, FileUp } from 'lucide-preact'; -import ConfirmDialog from '@/components/ConfirmDialog'; +import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog'; import type { CiphersImportPayload } from '@/lib/api/vault'; import { type EncryptedJsonMode, @@ -311,6 +312,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false); const [exportAuthPassword, setExportAuthPassword] = useState(''); const [importSummary, setImportSummary] = useState(null); + + useDialogLifecycle(!!importSummary, importSummary ? () => setImportSummary(null) : null); const commonSourceSet = new Set(COMMON_IMPORT_SOURCE_IDS); const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId)); const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId)); @@ -803,9 +806,15 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys - {importSummary && ( -
-
+ {importSummary && typeof document !== 'undefined' ? createPortal(( +
{ + if (event.target !== event.currentTarget) return; + setImportSummary(null); + }} + > +
- )} + ), document.body) : null}
); } diff --git a/webapp/src/hooks/useBackupActions.ts b/webapp/src/hooks/useBackupActions.ts index a62c523..09a85be 100644 --- a/webapp/src/hooks/useBackupActions.ts +++ b/webapp/src/hooks/useBackupActions.ts @@ -1,16 +1,19 @@ import { useMemo } from 'preact/hooks'; import { + type BackupExportClientProgressEvent, buildCompleteAdminBackupExport, deleteRemoteBackup, - downloadRemoteBackup, + downloadRemoteBackup as fetchRemoteBackupPayload, getAdminBackupSettings, importAdminBackup, + inspectRemoteBackupIntegrity, listRemoteBackups, - restoreRemoteBackup, + restoreRemoteBackup as restoreRemoteBackupRequest, runAdminBackupNow, saveAdminBackupSettings, } from '@/lib/api/backup'; import { downloadBytesAsFile } from '@/lib/download'; +import { dispatchBackupProgress } from '@/lib/backup-restore-progress'; import type { AuthedFetch } from '@/lib/api/shared'; interface UseBackupActionsOptions { @@ -25,8 +28,24 @@ export default function useBackupActions(options: UseBackupActionsOptions) { return useMemo( () => ({ async exportBackup(includeAttachments: boolean = false) { - const payload = await buildCompleteAdminBackupExport(authedFetch, includeAttachments); + const payload = await buildCompleteAdminBackupExport( + authedFetch, + includeAttachments, + async (event: BackupExportClientProgressEvent) => { + dispatchBackupProgress(event); + } + ); downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); + dispatchBackupProgress({ + operation: 'backup-export', + source: 'local', + step: 'export_complete', + fileName: payload.fileName, + stageTitle: 'txt_backup_export_progress_complete_title', + stageDetail: 'txt_backup_export_progress_complete_detail', + done: true, + ok: true, + }); }, async importBackup(file: File, replaceExisting: boolean = false) { @@ -35,6 +54,12 @@ export default function useBackupActions(options: UseBackupActionsOptions) { return result; }, + async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) { + const result = await importAdminBackup(authedFetch, file, replaceExisting, true); + onImported?.(); + return result; + }, + async loadSettings() { return getAdminBackupSettings(authedFetch); }, @@ -52,16 +77,26 @@ export default function useBackupActions(options: UseBackupActionsOptions) { }, async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) { - const payload = await downloadRemoteBackup(authedFetch, destinationId, path, onProgress); + const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress); downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); }, + async inspectRemoteBackup(destinationId: string, path: string) { + return inspectRemoteBackupIntegrity(authedFetch, destinationId, path); + }, + async deleteRemoteBackup(destinationId: string, path: string) { await deleteRemoteBackup(authedFetch, destinationId, path); }, async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) { - const result = await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting); + const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting); + onRestored?.(); + return result; + }, + + async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) { + const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true); onRestored?.(); return result; }, diff --git a/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts index 6f76baa..5965c15 100644 --- a/webapp/src/lib/api/backup.ts +++ b/webapp/src/lib/api/backup.ts @@ -57,6 +57,21 @@ export interface AdminBackupRunResponse { settings: AdminBackupSettings; } +export interface BackupFileIntegrityCheckResult { + hasChecksumPrefix: boolean; + expectedPrefix: string | null; + actualPrefix: string; + matches: boolean; +} + +export interface RemoteBackupIntegrityResponse { + object: 'backup-remote-integrity'; + destinationId: string; + path: string; + fileName: string; + integrity: BackupFileIntegrityCheckResult; +} + export interface RemoteBackupItem { path: string; name: string; @@ -109,6 +124,18 @@ export interface AdminBackupExportPayload { bytes: Uint8Array; } +export interface BackupExportClientProgressEvent { + operation: 'backup-export'; + source: 'local'; + step: string; + fileName: string; + stageTitle: string; + stageDetail: string; + done?: boolean; + ok?: boolean; + error?: string | null; +} + interface BackupExportManifestAttachmentBlob { cipherId: string; attachmentId: string; @@ -119,6 +146,36 @@ interface BackupExportManifest { attachmentBlobs?: BackupExportManifestAttachmentBlob[]; } +const BACKUP_FILE_HASH_PREFIX_LENGTH = 5; + +function parseBackupTimestampFromFileName(fileName: string): Date | null { + const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i); + if (!match) return null; + const datePart = match[1]; + const timePart = match[2]; + const iso = `${datePart.slice(0, 4)}-${datePart.slice(4, 6)}-${datePart.slice(6, 8)}T${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}.000Z`; + const parsed = new Date(iso); + return Number.isFinite(parsed.getTime()) ? parsed : null; +} + +function buildBackupFileName(date: Date, checksumPrefix: string): 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_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}_${checksumPrefix}.zip`; +} + +async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise { + const integrity = await verifyBackupFileIntegrity(bytes, fileName); + const effectiveDate = parseBackupTimestampFromFileName(fileName) || new Date(); + return buildBackupFileName(effectiveDate, integrity.actualPrefix); +} + export async function exportAdminBackup( authedFetch: AuthedFetch, includeAttachments: boolean = false @@ -149,10 +206,21 @@ export async function downloadAdminBackupAttachmentBlob( export async function buildCompleteAdminBackupExport( authedFetch: AuthedFetch, - includeAttachments: boolean = false + includeAttachments: boolean = false, + onProgress?: (event: BackupExportClientProgressEvent) => void | Promise ): Promise { const payload = await exportAdminBackup(authedFetch, includeAttachments); - if (!includeAttachments) return payload; + if (!includeAttachments) { + await onProgress?.({ + operation: 'backup-export', + source: 'local', + step: 'export_client_save', + fileName: payload.fileName, + stageTitle: 'txt_backup_export_progress_save_title', + stageDetail: 'txt_backup_export_progress_save_detail', + }); + return payload; + } const zipped = unzipSync(payload.bytes); const manifestBytes = zipped['manifest.json']; @@ -167,14 +235,41 @@ export async function buildCompleteAdminBackupExport( throw new Error(t('txt_backup_export_failed')); } + await onProgress?.({ + operation: 'backup-export', + source: 'local', + step: 'export_client_fetch_attachments', + fileName: payload.fileName, + stageTitle: 'txt_backup_export_progress_fetch_attachments_title', + stageDetail: 'txt_backup_export_progress_fetch_attachments_detail', + }); for (const attachment of manifest.attachmentBlobs || []) { const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName); zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes; } + await onProgress?.({ + operation: 'backup-export', + source: 'local', + step: 'export_client_rebuild', + fileName: payload.fileName, + stageTitle: 'txt_backup_export_progress_rebuild_title', + stageDetail: 'txt_backup_export_progress_rebuild_detail', + }); + const rebuiltBytes = zipSync(zipped, { level: 0 }); + const rebuiltFileName = await applyBackupFileIntegrityName(payload.fileName, rebuiltBytes); + await onProgress?.({ + operation: 'backup-export', + source: 'local', + step: 'export_client_save', + fileName: rebuiltFileName, + stageTitle: 'txt_backup_export_progress_save_title', + stageDetail: 'txt_backup_export_progress_save_detail', + }); return { ...payload, - bytes: zipSync(zipped, { level: 0 }), + bytes: rebuiltBytes, + fileName: rebuiltFileName, }; } @@ -276,6 +371,29 @@ export async function downloadRemoteBackup( return { fileName, mimeType, bytes }; } +export function extractBackupFileChecksumPrefix(fileName: string): string | null { + const normalized = String(fileName || '').trim(); + const match = normalized.match(/_([0-9a-f]{5})\.zip$/i); + return match ? match[1].toLowerCase() : null; +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: string): Promise { + const expectedPrefix = extractBackupFileChecksumPrefix(fileName); + const actualHash = await sha256Hex(bytes); + const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH); + return { + hasChecksumPrefix: !!expectedPrefix, + expectedPrefix, + actualPrefix, + matches: !expectedPrefix || expectedPrefix === actualPrefix, + }; +} + export async function deleteRemoteBackup( authedFetch: AuthedFetch, destinationId: string, @@ -288,16 +406,32 @@ export async function deleteRemoteBackup( if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed'))); } +export async function inspectRemoteBackupIntegrity( + authedFetch: AuthedFetch, + destinationId: string, + path: string +): Promise { + const params = new URLSearchParams(); + params.set('destinationId', destinationId); + params.set('path', path); + const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed'))); + const body = await parseJson(resp); + if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response')); + return body; +} + export async function restoreRemoteBackup( authedFetch: AuthedFetch, destinationId: string, path: string, - replaceExisting: boolean = false + replaceExisting: boolean = false, + allowChecksumMismatch: boolean = false ): Promise { const resp = await authedFetch('/api/admin/backup/remote/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ destinationId, path, replaceExisting }), + body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }), }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed'))); const body = await parseJson(resp); @@ -308,13 +442,17 @@ export async function restoreRemoteBackup( export async function importAdminBackup( authedFetch: AuthedFetch, file: File, - replaceExisting: boolean = false + replaceExisting: boolean = false, + allowChecksumMismatch: boolean = false ): Promise { const formData = new FormData(); formData.set('file', file, file.name || 'nodewarden_backup.zip'); if (replaceExisting) { formData.set('replaceExisting', '1'); } + if (allowChecksumMismatch) { + formData.set('allowChecksumMismatch', '1'); + } const resp = await authedFetch('/api/admin/backup/import', { method: 'POST', diff --git a/webapp/src/lib/app-support.ts b/webapp/src/lib/app-support.ts index 0ad5a0d..7b0d5b0 100644 --- a/webapp/src/lib/app-support.ts +++ b/webapp/src/lib/app-support.ts @@ -17,6 +17,7 @@ export interface WebVaultSignalRInvocation { UserId?: string; Date?: string; RevisionDate?: string; + [key: string]: unknown; }; }>; } diff --git a/webapp/src/lib/backup-restore-progress.ts b/webapp/src/lib/backup-restore-progress.ts new file mode 100644 index 0000000..ea06a17 --- /dev/null +++ b/webapp/src/lib/backup-restore-progress.ts @@ -0,0 +1,27 @@ +export type BackupProgressOperation = 'backup-restore' | 'backup-export' | 'backup-remote-run'; + +export interface BackupProgressDetail { + operation: BackupProgressOperation; + source?: 'local' | 'remote'; + step: string; + fileName: string; + stageTitle?: string; + stageDetail?: string; + replaceExisting?: boolean; + done?: boolean; + ok?: boolean; + error?: string | null; + Date?: string; +} + +export type BackupRestoreProgressDetail = BackupProgressDetail; + +export const BACKUP_PROGRESS_EVENT = 'nodewarden:backup-progress'; +export const BACKUP_RESTORE_PROGRESS_EVENT = BACKUP_PROGRESS_EVENT; + +export function dispatchBackupProgress(detail: BackupProgressDetail): void { + if (typeof window === 'undefined') return; + window.dispatchEvent(new CustomEvent(BACKUP_PROGRESS_EVENT, { detail })); +} + +export const dispatchBackupRestoreProgress = dispatchBackupProgress; diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index bff7a4f..78d0315 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -26,11 +26,16 @@ const messages: Record> = { txt_backup_export_success: "Backup exported", txt_backup_import_success_relogin: "Backup restored. Please sign in again.", txt_backup_restore_success_relogin: "Backup restored. Please sign in again.", + txt_backup_restore_completed_verified: "Backup file integrity verification passed.", + txt_backup_restore_completed_without_checksum: "Backup restored. No filename integrity marker was available for verification.", + txt_backup_remote_restore_completed_verified: "Remote backup integrity verification passed.", + txt_backup_remote_restore_completed_without_checksum: "Remote backup restored. No filename integrity marker was available for verification.", txt_backup_restore_skipped_summary: "{reason}. Skipped {attachments} attachment(s).", txt_backup_restore_skipped_reason_default: "Some files could not be restored", txt_backup_export_failed: "Backup export failed", txt_backup_import_failed: "Backup restore failed", txt_backup_restore_failed: "Backup restore failed", + txt_backup_integrity_check_failed: "Backup integrity verification failed", txt_backup_center_title: "Instance Backup", txt_backup_center_description: "Keep local exports for manual restore, and configure one daily remote backup target for unattended protection.", txt_backup_restore_note: "Restoring will overwrite the current instance if you choose the replace flow.", @@ -99,6 +104,7 @@ const messages: Record> = { txt_backup_run_manual: "Run Manually", txt_backup_running_now: "Running...", txt_backup_remote_run_success: "Remote backup completed", + txt_backup_remote_run_success_verified: "Remote backup completed and integrity verification passed.", txt_backup_remote_run_failed: "Remote backup failed", txt_backup_remote_title: "Remote Backups", txt_backup_remote_note: "Browse the saved destination and choose a backup ZIP to download or restore.", @@ -112,6 +118,68 @@ const messages: Record> = { txt_backup_remote_restore: "Restore", txt_backup_remote_restore_stage_prepare: "Preparing remote backup restore...", txt_backup_remote_restore_stage_replace: "Clearing current data and restoring remote backup...", + txt_backup_progress_kicker: "Backup Task", + txt_backup_progress_subject: "Current item: {name}", + txt_backup_restore_progress_kicker: "Restore Progress", + txt_backup_restore_progress_local_title: "Restoring local backup", + txt_backup_restore_progress_remote_title: "Restoring remote backup", + txt_backup_export_progress_title: "Exporting backup", + txt_backup_remote_run_progress_title: "Running remote backup", + txt_backup_restore_progress_file: "Current file: {name}", + txt_backup_restore_progress_elapsed: "{seconds}s elapsed", + txt_backup_archive_progress_collect_title: "Collecting vault data", + txt_backup_archive_progress_collect_detail: "The server is reading database tables and assembling the backup payload.", + txt_backup_archive_progress_collect_with_attachments_detail: "The server is reading database tables and collecting attachment metadata for the backup payload.", + txt_backup_archive_progress_package_title: "Packaging backup archive", + txt_backup_archive_progress_package_detail: "The server is generating the backup ZIP and computing its checksum prefix.", + txt_backup_archive_progress_package_with_attachments_detail: "The server is generating the backup ZIP metadata and computing its checksum prefix for the attachment-aware export.", + txt_backup_archive_progress_ready_title: "Preparing download", + txt_backup_archive_progress_ready_detail: "The backup archive is ready and is being returned to the browser.", + txt_backup_export_progress_fetch_attachments_title: "Downloading attachment files", + txt_backup_export_progress_fetch_attachments_detail: "The browser is fetching attachment objects and adding them into the export package.", + txt_backup_export_progress_rebuild_title: "Rebuilding export archive", + txt_backup_export_progress_rebuild_detail: "The browser is rebuilding the final ZIP and refreshing its checksum suffix.", + txt_backup_export_progress_save_title: "Saving export file", + txt_backup_export_progress_save_detail: "The browser is preparing the final backup file for download.", + txt_backup_export_progress_complete_title: "Export completed", + txt_backup_export_progress_complete_detail: "The backup export is ready.", + txt_backup_export_progress_failed_title: "Export failed", + txt_backup_export_progress_failed_detail: "The backup export could not be completed.", + txt_backup_remote_run_progress_prepare_title: "Preparing remote backup", + txt_backup_remote_run_progress_prepare_detail: "The server is loading the selected destination and preparing this backup run.", + txt_backup_remote_run_progress_sync_attachments_title: "Checking attachment index", + txt_backup_remote_run_progress_sync_attachments_detail: "The server is comparing attachment metadata so only missing attachment objects are uploaded.", + txt_backup_remote_run_progress_sync_attachments_skipped_detail: "This backup does not include attachments, so attachment synchronization is skipped.", + txt_backup_remote_run_progress_upload_title: "Uploading backup archive", + txt_backup_remote_run_progress_upload_detail: "The server is uploading the backup ZIP to the remote destination.", + txt_backup_remote_run_progress_verify_title: "Verifying uploaded archive", + txt_backup_remote_run_progress_verify_detail: "The server is downloading the uploaded ZIP back and verifying its checksum and size.", + txt_backup_remote_run_progress_cleanup_title: "Cleaning older backups", + txt_backup_remote_run_progress_cleanup_detail: "The server is pruning older backup files according to the retention policy.", + txt_backup_remote_run_progress_complete_title: "Remote backup completed", + txt_backup_remote_run_progress_complete_detail: "The remote backup has been uploaded and verified successfully.", + txt_backup_remote_run_progress_failed_title: "Remote backup failed", + txt_backup_remote_run_progress_failed_detail: "The remote backup could not be completed.", + txt_backup_restore_progress_local_upload_title: "Uploading backup archive", + txt_backup_restore_progress_local_upload_detail: "The selected ZIP is being sent to the server for processing.", + txt_backup_restore_progress_local_shadow_title: "Creating shadow workspace", + txt_backup_restore_progress_local_shadow_detail: "The server is preparing an isolated restore area so the current data remains untouched until validation passes.", + txt_backup_restore_progress_local_data_title: "Writing vault data", + txt_backup_restore_progress_local_data_detail: "The server is importing users, folders, vault items, and related metadata into shadow tables.", + txt_backup_restore_progress_local_files_title: "Restoring attachment files", + txt_backup_restore_progress_local_files_detail: "The server is writing attachment objects back to storage and removing any attachment rows that cannot be restored.", + txt_backup_restore_progress_local_finalize_title: "Validating and switching data", + txt_backup_restore_progress_local_finalize_detail: "The server is performing final validation and then swapping the verified restore data into the live tables.", + txt_backup_restore_progress_remote_fetch_title: "Reading remote backup", + txt_backup_restore_progress_remote_fetch_detail: "The server is downloading the selected backup package from the remote destination.", + txt_backup_restore_progress_remote_shadow_title: "Creating shadow workspace", + txt_backup_restore_progress_remote_shadow_detail: "The server is preparing an isolated restore area so the current data remains untouched until validation passes.", + txt_backup_restore_progress_remote_data_title: "Writing vault data", + txt_backup_restore_progress_remote_data_detail: "The server is importing users, folders, vault items, and related metadata into shadow tables.", + txt_backup_restore_progress_remote_files_title: "Restoring remote attachments", + txt_backup_restore_progress_remote_files_detail: "The server is fetching required attachment objects from remote storage and writing them back into local storage.", + txt_backup_restore_progress_remote_finalize_title: "Validating and switching data", + txt_backup_restore_progress_remote_finalize_detail: "The server is performing final validation and then switching the verified restore data into the live tables.", txt_backup_remote_loading: "Loading remote backups...", txt_backup_remote_cached_empty: "Click Refresh to load this destination.", txt_backup_remote_empty: "No backup files found in this folder.", @@ -126,6 +194,11 @@ const messages: Record> = { txt_backup_remote_delete_confirm_message: "Delete backup file \"{name}\"? This cannot be undone.", txt_backup_remote_deleting: "Deleting...", txt_backup_remote_restore_failed: "Restoring remote backup failed", + txt_backup_restore_checksum_warning_title: "Backup Integrity Warning", + txt_backup_restore_checksum_warning_message: "The selected backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be incomplete or corrupted. Continuing may restore damaged data.", + txt_backup_remote_restore_checksum_warning_message: "The remote backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be corrupted during upload or storage. Continuing may restore damaged data and may cause serious data loss.", + txt_backup_restore_checksum_warning_message_fallback: "The selected backup file failed integrity verification. Continuing may restore damaged data.", + txt_backup_restore_checksum_warning_confirm: "Continue Restore", txt_backup_remote_restore_invalid_response: "Invalid remote backup restore response", txt_backup_remote_run_invalid_response: "Invalid remote backup run response", txt_backup_settings_invalid_response: "Invalid backup settings response", @@ -197,9 +270,9 @@ const messages: Record> = { txt_backup_no_file_selected: "No backup file selected", txt_backup_selected_file_name: "Selected file: {name}", txt_backup_replace_confirm_title: "Replace Current Instance Data", - txt_backup_replace_confirm_message: "The current instance already contains data. Clear it and restore the selected backup?", - txt_backup_clear_and_import: "Clear and Import", - txt_backup_clear_and_restore: "Clear and Restore", + txt_backup_replace_confirm_message: "The current instance already contains data. Continue restoring and replace the current instance data with the selected backup after verification succeeds?", + txt_backup_clear_and_import: "Replace and Import", + txt_backup_clear_and_restore: "Replace and Restore", txt_access_count: "Access Count", txt_accessed_count_times: "Accessed {count} times", txt_actions: "Actions", @@ -643,6 +716,7 @@ const messages: Record> = { txt_vault_synced: "Vault synced", txt_verification_code: "Verification Code", txt_verify: "Verify", + txt_warning: "Warning", txt_view_recovery_code: "View Recovery Code", txt_web: "Web", txt_website: "Website", @@ -676,11 +750,16 @@ const zhCNOverrides: Record = { txt_backup_export_success: '备份已导出', txt_backup_import_success_relogin: '备份已还原,请重新登录', txt_backup_restore_success_relogin: '备份已还原,请重新登录', + txt_backup_restore_completed_verified: '备份文件完整性校验已通过。', + txt_backup_restore_completed_without_checksum: '备份已还原,但文件名中未提供可校验的完整性标记。', + txt_backup_remote_restore_completed_verified: '远程备份完整性校验已通过。', + txt_backup_remote_restore_completed_without_checksum: '远程备份已还原,但文件名中未提供可校验的完整性标记。', txt_backup_restore_skipped_summary: '{reason},已跳过 {attachments} 个附件', txt_backup_restore_skipped_reason_default: '部分文件无法还原', txt_backup_export_failed: '备份导出失败', txt_backup_import_failed: '备份还原失败', txt_backup_restore_failed: '备份还原失败', + txt_backup_integrity_check_failed: '备份完整性校验失败', txt_backup_center_title: '实例备份', txt_backup_center_description: '把本地导出和远程自动备份放在一起管理,既方便手动恢复,也能每天自动留一份。', txt_backup_restore_note: '还原会覆盖当前实例;如果当前已有数据,系统会要求你确认“清空后还原”。', @@ -749,6 +828,7 @@ const zhCNOverrides: Record = { txt_backup_run_manual: '手动执行', txt_backup_running_now: '执行中...', txt_backup_remote_run_success: '远程备份已完成', + txt_backup_remote_run_success_verified: '远程备份已完成,且完整性校验已通过。', txt_backup_remote_run_failed: '远程备份失败', txt_backup_remote_title: '远端备份', txt_backup_remote_note: '浏览已保存的备份地点,选择某个备份 ZIP 后可以下载,也可以直接还原。', @@ -762,6 +842,68 @@ const zhCNOverrides: Record = { txt_backup_remote_restore: '还原', txt_backup_remote_restore_stage_prepare: '正在读取远端备份并检查可恢复内容...', txt_backup_remote_restore_stage_replace: '正在清空当前数据并还原远端备份,请稍候...', + txt_backup_progress_kicker: '备份任务', + txt_backup_progress_subject: '当前对象:{name}', + txt_backup_restore_progress_kicker: '还原进度', + txt_backup_restore_progress_local_title: '正在还原本地备份', + txt_backup_restore_progress_remote_title: '正在还原远端备份', + txt_backup_export_progress_title: '正在导出备份', + txt_backup_remote_run_progress_title: '正在执行远程备份', + txt_backup_restore_progress_file: '当前文件:{name}', + txt_backup_restore_progress_elapsed: '已耗时 {seconds} 秒', + txt_backup_archive_progress_collect_title: '正在收集密码库数据', + txt_backup_archive_progress_collect_detail: '服务器正在读取数据库表,并整理备份所需的数据内容。', + txt_backup_archive_progress_collect_with_attachments_detail: '服务器正在读取数据库表,并整理附件元数据与备份内容。', + txt_backup_archive_progress_package_title: '正在打包备份压缩包', + txt_backup_archive_progress_package_detail: '服务器正在生成备份 ZIP,并计算文件名校验前缀。', + txt_backup_archive_progress_package_with_attachments_detail: '服务器正在生成带附件信息的备份 ZIP 元数据,并计算文件名校验前缀。', + txt_backup_archive_progress_ready_title: '正在准备下载', + txt_backup_archive_progress_ready_detail: '备份压缩包已经生成,服务器正在把它返回给浏览器。', + txt_backup_export_progress_fetch_attachments_title: '正在下载附件文件', + txt_backup_export_progress_fetch_attachments_detail: '浏览器正在读取附件对象,并把它们补入导出备份包。', + txt_backup_export_progress_rebuild_title: '正在重建导出压缩包', + txt_backup_export_progress_rebuild_detail: '浏览器正在重建最终 ZIP,并刷新文件名里的校验后缀。', + txt_backup_export_progress_save_title: '正在保存导出文件', + txt_backup_export_progress_save_detail: '浏览器正在准备最终的备份文件下载。', + txt_backup_export_progress_complete_title: '备份导出已完成', + txt_backup_export_progress_complete_detail: '导出备份已经准备完成。', + txt_backup_export_progress_failed_title: '备份导出失败', + txt_backup_export_progress_failed_detail: '导出备份未能完成。', + txt_backup_remote_run_progress_prepare_title: '正在准备远程备份', + txt_backup_remote_run_progress_prepare_detail: '服务器正在读取当前备份目标,并准备执行这次远程备份。', + txt_backup_remote_run_progress_sync_attachments_title: '正在检查附件索引', + txt_backup_remote_run_progress_sync_attachments_detail: '服务器正在比对附件索引,只会上传缺失或不一致的附件对象。', + txt_backup_remote_run_progress_sync_attachments_skipped_detail: '当前备份未包含附件,因此跳过附件同步。', + txt_backup_remote_run_progress_upload_title: '正在上传备份压缩包', + txt_backup_remote_run_progress_upload_detail: '服务器正在把备份 ZIP 上传到远程备份目标。', + txt_backup_remote_run_progress_verify_title: '正在校验已上传压缩包', + txt_backup_remote_run_progress_verify_detail: '服务器正在回读刚上传的 ZIP,并校验它的哈希和大小。', + txt_backup_remote_run_progress_cleanup_title: '正在清理旧备份', + txt_backup_remote_run_progress_cleanup_detail: '服务器正在按保留策略清理旧备份文件。', + txt_backup_remote_run_progress_complete_title: '远程备份已完成', + txt_backup_remote_run_progress_complete_detail: '远程备份已上传完成,并通过完整性校验。', + txt_backup_remote_run_progress_failed_title: '远程备份失败', + txt_backup_remote_run_progress_failed_detail: '远程备份未能完成。', + txt_backup_restore_progress_local_upload_title: '正在上传备份包', + txt_backup_restore_progress_local_upload_detail: '已选 ZIP 正在发送到服务器,服务器收到后会开始执行还原。', + txt_backup_restore_progress_local_shadow_title: '正在创建影子恢复区', + txt_backup_restore_progress_local_shadow_detail: '服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。', + txt_backup_restore_progress_local_data_title: '正在写入密码库数据', + txt_backup_restore_progress_local_data_detail: '服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。', + txt_backup_restore_progress_local_files_title: '正在恢复附件文件', + txt_backup_restore_progress_local_files_detail: '服务器正在把附件对象写回存储,并剔除无法恢复的附件记录。', + txt_backup_restore_progress_local_finalize_title: '正在校验并完成切换', + txt_backup_restore_progress_local_finalize_detail: '服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。', + txt_backup_restore_progress_remote_fetch_title: '正在读取远端备份包', + txt_backup_restore_progress_remote_fetch_detail: '服务器正在从远端备份目标下载你选中的备份包。', + txt_backup_restore_progress_remote_shadow_title: '正在创建影子恢复区', + txt_backup_restore_progress_remote_shadow_detail: '服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。', + txt_backup_restore_progress_remote_data_title: '正在写入密码库数据', + txt_backup_restore_progress_remote_data_detail: '服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。', + txt_backup_restore_progress_remote_files_title: '正在恢复远端附件', + txt_backup_restore_progress_remote_files_detail: '服务器正在从远端存储读取所需附件,并写回到当前实例的附件存储。', + txt_backup_restore_progress_remote_finalize_title: '正在校验并完成切换', + txt_backup_restore_progress_remote_finalize_detail: '服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。', txt_backup_remote_loading: '正在读取远端备份...', txt_backup_remote_cached_empty: '点击“刷新”后读取', txt_backup_remote_empty: '这个目录下还没有备份文件', @@ -776,6 +918,11 @@ const zhCNOverrides: Record = { txt_backup_remote_delete_confirm_message: '删除备份文件“{name}”?此操作不可撤销。', txt_backup_remote_deleting: '删除中...', txt_backup_remote_restore_failed: '还原远端备份失败', + txt_backup_restore_checksum_warning_title: '备份完整性警告', + txt_backup_restore_checksum_warning_message: '所选备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能不完整或已经损坏。继续还原可能会导入受损数据。', + txt_backup_remote_restore_checksum_warning_message: '远程备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能在上传或存储过程中损坏。继续还原可能会导入受损数据,并可能造成严重后果。', + txt_backup_restore_checksum_warning_message_fallback: '所选备份文件未通过完整性校验。继续还原可能会导入受损数据。', + txt_backup_restore_checksum_warning_confirm: '继续还原', txt_backup_remote_restore_invalid_response: '远端备份还原响应无效', txt_backup_remote_run_invalid_response: '远端备份执行响应无效', txt_backup_settings_invalid_response: '备份设置响应无效', @@ -847,9 +994,9 @@ const zhCNOverrides: Record = { txt_backup_no_file_selected: '尚未选择备份文件', txt_backup_selected_file_name: '已选择文件:{name}', txt_backup_replace_confirm_title: '替换当前实例数据', - txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再还原所选备份吗?', - txt_backup_clear_and_import: '清空后导入', - txt_backup_clear_and_restore: '清空后还原', + txt_backup_replace_confirm_message: '当前实例里已经有数据。确认后,系统会先完成校验与恢复准备,只有在恢复成功后才会用所选备份替换当前实例数据。是否继续?', + txt_backup_clear_and_import: '替换并导入', + txt_backup_clear_and_restore: '替换并还原', txt_sign_out: '退出登录', txt_log_in: '登录', txt_logging_in: '正在登录...', @@ -1243,6 +1390,7 @@ const zhCNOverrides: Record = { txt_user_status_updated: '用户状态已更新', txt_vault_synced: '密码库已同步', txt_verify: '验证', + txt_warning: '警告', txt_web: '网页', txt_windows_desktop: 'Windows 桌面端', txt_jwt_warning_title: 'JWT_SECRET 配置警告', diff --git a/webapp/src/styles.css b/webapp/src/styles.css index add4889..88dafc3 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -80,6 +80,11 @@ body { color var(--dur-medium) var(--ease-smooth); } +body.dialog-open { + overflow: hidden; + overscroll-behavior: contain; +} + body::before { content: none; } @@ -2929,6 +2934,148 @@ input[type='file'].input::file-selector-button:hover { font-weight: 700; } +.restore-progress-card { + margin: 8px 0 12px; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid #d7e2f1; + background: #ffffff; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.10); +} + +.restore-progress-overlay { + position: fixed; + inset: 0; + z-index: 1250; + display: grid; + place-items: center; + padding: 20px; + background: rgba(15, 23, 42, 0.30); +} + +.restore-progress-modal { + width: min(520px, 100%); + margin: 0; +} + +.restore-progress-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.restore-progress-kicker { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; + color: #64748b; +} + +.restore-progress-title { + margin: 4px 0 2px; + font-size: 20px; + line-height: 1.2; +} + +.restore-progress-subtitle { + margin: 0; + color: #6b7280; + font-size: 13px; +} + +.restore-progress-elapsed { + flex: 0 0 auto; + min-width: 88px; + padding: 6px 8px; + border-radius: 10px; + background: #f8fbff; + border: 1px solid #d7e2f1; + color: #475569; + font-weight: 600; + font-size: 13px; + text-align: center; +} + +.restore-progress-meter { + height: 6px; + border-radius: 999px; + background: #e7eef8; + overflow: hidden; +} + +.restore-progress-meter-bar { + display: block; + height: 100%; + border-radius: inherit; + background: #3a71d8; + transition: width 280ms ease; +} + +.restore-progress-current { + margin-top: 12px; + padding: 10px 12px; + border-radius: 10px; + background: #f8fbff; + border: 1px solid #d7e2f1; +} + +.restore-progress-current strong { + display: block; + color: #0f172a; + font-size: 14px; +} + +.restore-progress-current p { + margin: 4px 0 0; + color: #64748b; + line-height: 1.45; + font-size: 13px; +} + +.restore-progress-list { + list-style: none; + margin: 12px 0 0; + padding: 0; + display: grid; + gap: 6px; +} + +.restore-progress-item { + display: flex; + align-items: center; + gap: 8px; + min-height: 30px; + color: #64748b; + font-weight: 500; + font-size: 13px; +} + +.restore-progress-item.active { + color: #1d4ed8; +} + +.restore-progress-item.done { + color: #475569; +} + +.restore-progress-dot { + width: 8px; + height: 8px; + border-radius: 999px; + background: #cbd5e1; + flex: 0 0 auto; +} + +.restore-progress-item.active .restore-progress-dot { + background: #1d4ed8; +} + +.restore-progress-item.done .restore-progress-dot { + background: #94a3b8; +} + .kv-line strong { overflow-wrap: anywhere; } @@ -3022,6 +3169,8 @@ input[type='file'].input::file-selector-button:hover { .dialog-mask { position: fixed; inset: 0; + width: 100vw; + height: 100dvh; background: rgba(15, 23, 42, 0.5); display: grid; place-items: center; @@ -3029,6 +3178,8 @@ input[type='file'].input::file-selector-button:hover { padding: 20px; opacity: 0; animation: fade-in var(--dur-medium) var(--ease-smooth) both; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); } .dialog-card { @@ -3043,6 +3194,54 @@ input[type='file'].input::file-selector-button:hover { animation: dialog-in 240ms var(--ease-out-strong) both; } +.dialog-mask.warning { + background: + radial-gradient(circle at top, rgba(255, 237, 213, 0.32), transparent 34%), + linear-gradient(180deg, rgba(127, 29, 29, 0.36), rgba(15, 23, 42, 0.72)); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.dialog-card.warning { + width: min(520px, 100%); + border: 1px solid rgba(220, 38, 38, 0.22); + background: + linear-gradient(180deg, rgba(255, 246, 246, 0.98), rgba(255, 255, 255, 0.99)); + box-shadow: + 0 36px 90px rgba(69, 10, 10, 0.28), + 0 0 0 1px rgba(255, 255, 255, 0.7) inset; +} + +.dialog-warning-head { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-bottom: 8px; +} + +.dialog-warning-badge { + width: 48px; + height: 48px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 16px; + background: linear-gradient(180deg, #fff1f2, #ffe4e6); + color: #dc2626; + box-shadow: + 0 12px 30px rgba(220, 38, 38, 0.18), + 0 0 0 1px rgba(220, 38, 38, 0.08) inset; +} + +.dialog-warning-kicker { + font-size: 12px; + font-weight: 800; + letter-spacing: 0.16em; + text-transform: uppercase; + color: #b91c1c; +} + .dialog-mask.closing { animation: fade-out 220ms var(--ease-smooth) both; } @@ -3070,6 +3269,22 @@ input[type='file'].input::file-selector-button:hover { margin-bottom: 10px; } +.dialog-card.warning .dialog-title { + color: #7f1d1d; + margin-bottom: 10px; +} + +.dialog-message.warning { + margin-bottom: 16px; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid rgba(220, 38, 38, 0.16); + background: linear-gradient(180deg, rgba(255, 241, 242, 0.94), rgba(255, 247, 237, 0.9)); + color: #7a2832; + line-height: 1.65; + box-shadow: 0 10px 28px rgba(248, 113, 113, 0.08) inset; +} + .dialog-btn { width: 100%; height: 50px; @@ -4132,6 +4347,14 @@ input[type='file'].input::file-selector-button:hover { padding: 18px 16px calc(18px + env(safe-area-inset-bottom)); } + .dialog-card.warning { + max-width: 520px; + } + + .dialog-warning-strip { + margin: -18px -16px 16px; + } + .dialog-title { font-size: 24px; } @@ -4252,6 +4475,40 @@ input[type='file'].input::file-selector-button:hover { color: var(--text); } +:root[data-theme='dark'] .dialog-card.warning { + border-color: rgba(248, 113, 113, 0.36); + background: linear-gradient(180deg, rgba(39, 16, 16, 0.98), rgba(27, 12, 12, 0.98)); + box-shadow: + 0 36px 90px rgba(5, 5, 5, 0.56), + 0 0 0 1px rgba(248, 113, 113, 0.12) inset; +} + +:root[data-theme='dark'] .dialog-mask.warning { + background: + radial-gradient(circle at top, rgba(127, 29, 29, 0.28), transparent 34%), + linear-gradient(180deg, rgba(20, 12, 12, 0.64), rgba(2, 6, 23, 0.82)); +} + +:root[data-theme='dark'] .dialog-warning-badge { + background: linear-gradient(180deg, rgba(127, 29, 29, 0.8), rgba(69, 10, 10, 0.86)); + color: #fda4af; + box-shadow: + 0 12px 30px rgba(0, 0, 0, 0.32), + 0 0 0 1px rgba(248, 113, 113, 0.14) inset; +} + +:root[data-theme='dark'] .dialog-warning-kicker, +:root[data-theme='dark'] .dialog-card.warning .dialog-title { + color: #fecaca; +} + +:root[data-theme='dark'] .dialog-message.warning { + border-color: rgba(248, 113, 113, 0.18); + background: linear-gradient(180deg, rgba(69, 10, 10, 0.54), rgba(67, 20, 7, 0.46)); + color: #fecdd3; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18) inset; +} + :root[data-theme='dark'] .app-side, :root[data-theme='dark'] .sidebar, :root[data-theme='dark'] .mobile-sidebar-sheet .sidebar-block {