mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance backup and restore functionality with integrity checks and progress tracking
- Added support for backup integrity verification during export and restore processes. - Introduced progress dispatching for backup export and restore operations. - Implemented new API endpoints for inspecting remote backup integrity. - Enhanced user interface with progress indicators and warning dialogs for integrity issues. - Updated localization strings for new features and user feedback. - Refactored backup-related functions for better clarity and maintainability.
This commit is contained in:
@@ -106,13 +106,16 @@ export interface AppMainRoutesProps {
|
||||
onRevokeInvite: (code: string) => Promise<void>;
|
||||
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
||||
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
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<void>;
|
||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
}
|
||||
|
||||
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}
|
||||
|
||||
@@ -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<void>;
|
||||
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||
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<HTMLInputElement | null>(null);
|
||||
const restoreProgressTimerRef = useRef<number | null>(null);
|
||||
const restoreProgressPendingRef = useRef<BackupProgressState | null>(null);
|
||||
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
@@ -67,14 +183,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
|
||||
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
||||
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
||||
const [remoteRestoreStatusText, setRemoteRestoreStatusText] = useState('');
|
||||
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
||||
const [localError, setLocalError] = useState('');
|
||||
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(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<PendingRestoreIntegrity | null>(null);
|
||||
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
||||
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
||||
const [savedSettings, setSavedSettings] = useState<AdminBackupSettings | null>(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<BackupProgressDetail>).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<BackupFileIntegrityCheckResult> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
return verifyBackupFileIntegrity(bytes, file.name || '');
|
||||
}
|
||||
|
||||
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
||||
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 ? <div className="local-error">{localError}</div> : null}
|
||||
{!localError && remoteRestoreStatusText ? <div className="status-ok">{remoteRestoreStatusText}</div> : null}
|
||||
{restoreProgress && typeof document !== 'undefined' ? createPortal((
|
||||
<div className="restore-progress-overlay" aria-live="polite">
|
||||
<section className="restore-progress-card restore-progress-modal">
|
||||
<div className="restore-progress-head">
|
||||
<div>
|
||||
<div className="restore-progress-kicker">{t('txt_backup_progress_kicker')}</div>
|
||||
<h3 className="restore-progress-title">
|
||||
{t(getBackupProgressTitleKey(restoreProgress))}
|
||||
</h3>
|
||||
<p className="restore-progress-subtitle">
|
||||
{t('txt_backup_progress_subject', { name: restoreProgress.fileLabel })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="restore-progress-elapsed">
|
||||
{t('txt_backup_restore_progress_elapsed', { seconds: String(restoreElapsedSeconds) })}
|
||||
</div>
|
||||
</div>
|
||||
<div className="restore-progress-meter">
|
||||
<span
|
||||
className="restore-progress-meter-bar"
|
||||
style={{
|
||||
width: `${((restoreProgress.phaseIndex + 1) / restoreProgress.phases.length) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="restore-progress-current">
|
||||
<strong>{t(restoreProgress.currentTitleKey)}</strong>
|
||||
<p>{t(restoreProgress.currentDetailKey)}</p>
|
||||
</div>
|
||||
<ol className="restore-progress-list">
|
||||
{restoreProgress.phases.map((phase, index) => {
|
||||
const status = index < restoreProgress.phaseIndex ? 'done' : index === restoreProgress.phaseIndex ? 'active' : 'pending';
|
||||
return (
|
||||
<li key={phase.titleKey} className={`restore-progress-item ${status}`}>
|
||||
<span className="restore-progress-dot" />
|
||||
<span className="restore-progress-item-text">{t(phase.titleKey)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
), document.body) : null}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmLocalRestoreOpen}
|
||||
@@ -546,6 +901,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
onCancel={() => {
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmIntegrityWarningOpen}
|
||||
title={t('txt_backup_restore_checksum_warning_title')}
|
||||
message={pendingRestoreIntegrity ? buildIntegrityWarningMessage(pendingRestoreIntegrity) : t('txt_backup_restore_checksum_warning_message_fallback')}
|
||||
variant="warning"
|
||||
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
|
||||
cancelText={t('txt_cancel')}
|
||||
danger
|
||||
onConfirm={() => {
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className={`dialog-mask ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}>
|
||||
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
|
||||
|
||||
if (!present || typeof document === 'undefined') return null;
|
||||
return createPortal((
|
||||
<div
|
||||
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
onClick={(event) => {
|
||||
if (event.target !== event.currentTarget || !canDismiss) return;
|
||||
props.onCancel();
|
||||
}}
|
||||
>
|
||||
<form
|
||||
className={`dialog-card ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={props.title}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (props.confirmDisabled || closing) return;
|
||||
props.onConfirm();
|
||||
}}
|
||||
>
|
||||
{props.variant === 'warning' ? (
|
||||
<>
|
||||
<div className="dialog-warning-strip" aria-hidden="true" />
|
||||
<div className="dialog-warning-head">
|
||||
<div className="dialog-warning-badge" aria-hidden="true">
|
||||
<TriangleAlert size={24} />
|
||||
</div>
|
||||
<div className="dialog-warning-kicker">{t('txt_warning')}</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<h3 className="dialog-title">{props.title}</h3>
|
||||
<div className="dialog-message">{props.message}</div>
|
||||
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||
{props.children}
|
||||
<button
|
||||
type="submit"
|
||||
@@ -75,5 +140,5 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
{props.afterActions}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
), document.body);
|
||||
}
|
||||
|
||||
@@ -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<ImportResultSummary | null>(null);
|
||||
|
||||
useDialogLifecycle(!!importSummary, importSummary ? () => setImportSummary(null) : null);
|
||||
const commonSourceSet = new Set<ImportSourceId>(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
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
{importSummary && (
|
||||
<div className="dialog-mask">
|
||||
<section className="dialog-card import-summary-dialog">
|
||||
{importSummary && typeof document !== 'undefined' ? createPortal((
|
||||
<div
|
||||
className="dialog-mask"
|
||||
onClick={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
setImportSummary(null);
|
||||
}}
|
||||
>
|
||||
<section className="dialog-card import-summary-dialog" role="dialog" aria-modal="true" aria-label={t('txt_import_success')}>
|
||||
<button
|
||||
type="button"
|
||||
className="import-summary-close"
|
||||
@@ -866,7 +875,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
), document.body) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user