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:
shuaiplus
2026-03-28 05:52:47 +08:00
parent bd8e26d2ab
commit 2a7879efaa
18 changed files with 2250 additions and 225 deletions
+20
View File
@@ -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) {
+6
View File
@@ -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}
+414 -19
View File
@@ -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();
}}
/>
+71 -6
View File
@@ -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);
}
+14 -5
View File
@@ -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>
);
}
+40 -5
View File
@@ -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;
},
+144 -6
View File
@@ -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<string> {
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<void>
): Promise<AdminBackupExportPayload> {
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<string> {
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<BackupFileIntegrityCheckResult> {
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<RemoteBackupIntegrityResponse> {
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<RemoteBackupIntegrityResponse>(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<AdminBackupImportResponse> {
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<AdminBackupImportResponse>(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<AdminBackupImportResponse> {
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',
+1
View File
@@ -17,6 +17,7 @@ export interface WebVaultSignalRInvocation {
UserId?: string;
Date?: string;
RevisionDate?: string;
[key: string]: unknown;
};
}>;
}
+27
View File
@@ -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<BackupProgressDetail>(BACKUP_PROGRESS_EVENT, { detail }));
}
export const dispatchBackupRestoreProgress = dispatchBackupProgress;
+154 -6
View File
@@ -26,11 +26,16 @@ const messages: Record<Locale, Record<string, string>> = {
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<Locale, Record<string, string>> = {
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<Locale, Record<string, string>> = {
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<Locale, Record<string, string>> = {
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<Locale, Record<string, string>> = {
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<Locale, Record<string, string>> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
txt_user_status_updated: '用户状态已更新',
txt_vault_synced: '密码库已同步',
txt_verify: '验证',
txt_warning: '警告',
txt_web: '网页',
txt_windows_desktop: 'Windows 桌面端',
txt_jwt_warning_title: 'JWT_SECRET 配置警告',
+257
View File
@@ -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 {