fix(backup): verify remote deletes and validate archives

This commit is contained in:
shuaiplus
2026-07-05 23:44:25 +08:00
parent 8c481a1564
commit 0cef6a04e9
9 changed files with 233 additions and 99 deletions
+4 -4
View File
@@ -19,7 +19,7 @@ import {
executeConfiguredBackup,
importAndAuditRemoteBackupFile,
} from '../handlers/backup';
import { verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
import { zipSync } from 'fflate';
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
@@ -372,7 +372,7 @@ export class BackupTransferRunner {
return badRequest('Remote attachment download payload is invalid');
}
const blobName = String(body?.blobName || '').trim();
if (!body?.destination || !blobName) {
if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Remote attachment download payload is invalid');
}
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
@@ -398,7 +398,7 @@ export class BackupTransferRunner {
const blobNames = Array.from(new Set(
(Array.isArray(body?.blobNames) ? body.blobNames : [])
.map((blobName) => String(blobName || '').trim())
.filter(Boolean)
.filter(isSafeBackupAttachmentBlobName)
));
if (!body?.destination || !blobNames.length || blobNames.length > 40) {
return badRequest('Remote attachment batch download payload is invalid');
@@ -446,7 +446,7 @@ export class BackupTransferRunner {
for (const attachment of body.attachments) {
const blobName = String(attachment?.blobName || '').trim();
if (!blobName) {
if (!isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Attachment chunk payload is invalid');
}
+28 -8
View File
@@ -4,6 +4,7 @@ import {
type BackupArchiveBundle,
buildBackupArchive,
inspectBackupArchiveFileNameChecksum,
isSafeBackupAttachmentBlobName,
parseBackupArchive,
verifyBackupArchiveFileNameChecksum,
} from '../services/backup-archive';
@@ -129,11 +130,18 @@ function ensureBackupBlobName(value: string): string {
if (!normalized) {
throw new Error('Backup attachment blob is required');
}
const parts = normalized.split('/').filter(Boolean);
if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
if (!isSafeBackupAttachmentBlobName(normalized)) {
throw new Error('Backup attachment blob is invalid');
}
return parts.join('/');
return normalized;
}
function contentDispositionBackup(fileName: string | null | undefined): string {
const fallback = 'nodewarden_backup.zip';
const value = String(fileName || fallback)
.replace(/[\\/\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
}
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
@@ -654,6 +662,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
if (parsed.files[inlinePath]) continue;
const ref = refs.get(`${cipherId}/${attachmentId}`);
const blobName = String(ref?.blobName || '').trim();
if (!isSafeBackupAttachmentBlobName(blobName)) continue;
if (blobName && !seen.has(blobName)) {
seen.add(blobName);
names.push(blobName);
@@ -1028,8 +1037,9 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
status: 200,
headers: {
'Content-Type': remoteFile.contentType || 'application/zip',
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`,
'Content-Disposition': contentDispositionBackup(remoteFile.fileName),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
@@ -1063,12 +1073,21 @@ export async function handleInspectAdminRemoteBackup(request: Request, env: Env,
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup delete payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const destination = requireBackupDestination(settings, body.destinationId || null);
await deleteRemoteBackupFile(destination, path);
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
...getBackupDestinationSummary(destination),
@@ -1196,8 +1215,9 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
status: 200,
headers: {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${archive.fileName}"`,
'Content-Disposition': contentDispositionBackup(archive.fileName),
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
},
});
}
+64 -4
View File
@@ -1,4 +1,4 @@
import { zipSync, unzipSync } from 'fflate';
import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
import type { Env } from '../types';
import { APP_VERSION } from '../../shared/app-version';
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
@@ -32,6 +32,7 @@ const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000;
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
const MAX_BACKUP_PATH_SEGMENT_LENGTH = 128;
export interface BackupManifest {
formatVersion: 1;
@@ -186,6 +187,61 @@ function validateArchiveSize(bytes: Uint8Array): void {
}
}
function isSafeBackupPathSegment(value: string): boolean {
if (!value || value.length > MAX_BACKUP_PATH_SEGMENT_LENGTH) return false;
if (value === '.' || value === '..') return false;
return /^[A-Za-z0-9._-]+$/.test(value);
}
export function isSafeBackupAttachmentBlobName(value: unknown): boolean {
const normalized = String(value ?? '').trim();
const parts = normalized.split('/');
return parts.length === 2 && parts.every(isSafeBackupPathSegment);
}
function isSafeBackupAttachmentEntryName(value: string): boolean {
if (!value.startsWith('attachments/') || !value.endsWith('.bin')) return false;
const relative = value.slice('attachments/'.length, -'.bin'.length);
return isSafeBackupAttachmentBlobName(relative);
}
function validateBackupEntryName(name: string): void {
const normalized = String(name || '').trim();
if (normalized !== name || !normalized) {
throw new Error('Backup archive contains an invalid file name');
}
if (normalized.includes('\\') || normalized.includes('\0') || normalized.startsWith('/') || normalized.includes('//')) {
throw new Error(`Backup archive contains an unsafe file name: ${normalized}`);
}
if (normalized !== 'manifest.json' && normalized !== 'db.json' && !isSafeBackupAttachmentEntryName(normalized)) {
throw new Error(`Backup archive contains an unsupported file: ${normalized}`);
}
}
function createBackupUnzipFilter(): (file: UnzipFileInfo) => boolean {
let entryCount = 0;
let totalOriginalBytes = 0;
return (file: UnzipFileInfo): boolean => {
entryCount += 1;
if (entryCount > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
throw new Error('Backup archive contains too many files');
}
validateBackupEntryName(file.name);
const originalSize = Number(file.originalSize);
if (!Number.isFinite(originalSize) || originalSize < 0) {
throw new Error(`Backup archive contains an invalid file size: ${file.name}`);
}
if (file.name === 'db.json' && originalSize > MAX_BACKUP_DB_JSON_BYTES) {
throw new Error('Backup archive database payload is too large');
}
totalOriginalBytes += originalSize;
if (totalOriginalBytes > MAX_BACKUP_EXTRACTED_BYTES) {
throw new Error('Backup archive expands beyond the current restore limit');
}
return true;
};
}
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
const entries: string[] = [];
for (const row of db.attachments) {
@@ -223,8 +279,11 @@ export function parseBackupArchive(
validateArchiveSize(bytes);
let zipped: Record<string, Uint8Array>;
try {
zipped = unzipSync(bytes);
} catch {
zipped = unzipSync(bytes, { filter: createBackupUnzipFilter() });
} catch (error) {
if (error instanceof Error && error.message.startsWith('Backup archive ')) {
throw error;
}
throw new Error('Invalid backup archive');
}
@@ -235,6 +294,7 @@ export function parseBackupArchive(
let totalExtractedBytes = 0;
for (const entry of entryNames) {
validateBackupEntryName(entry);
const entryBytes = zipped[entry];
totalExtractedBytes += entryBytes.byteLength;
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
@@ -368,7 +428,7 @@ export function validateBackupPayloadContents(
for (const row of attachmentRows) {
const id = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim();
if (!id || !cipherId || !cipherIds.has(cipherId)) {
if (!id || !cipherId || !isSafeBackupPathSegment(id) || !isSafeBackupPathSegment(cipherId) || !cipherIds.has(cipherId)) {
throw new Error('Backup archive contains an invalid attachment row');
}
const attachmentPath = `attachments/${cipherId}/${id}.bin`;
+15 -3
View File
@@ -4,6 +4,7 @@ import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from
import {
type BackupManifestAttachmentBlob,
type BackupPayload,
isSafeBackupAttachmentBlobName,
parseBackupArchive,
validateBackupPayloadContents,
} from './backup-archive';
@@ -462,9 +463,20 @@ async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record
}
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
return new Map(
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item])
);
const lookup = new Map<string, BackupManifestAttachmentBlob>();
for (const item of manifest.attachmentBlobs || []) {
const cipherId = String(item.cipherId || '').trim();
const attachmentId = String(item.attachmentId || '').trim();
const blobName = String(item.blobName || '').trim();
if (!cipherId || !attachmentId || !isSafeBackupAttachmentBlobName(blobName)) continue;
lookup.set(`${cipherId}/${attachmentId}`, {
...item,
cipherId,
attachmentId,
blobName,
});
}
return lookup;
}
async function prepareRemoteAttachmentPayload(
+4 -1
View File
@@ -2122,7 +2122,10 @@ export default function App() {
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
},
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.deleteRemoteBackup(hash, destinationId, path);
},
onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
+1 -1
View File
@@ -169,7 +169,7 @@ export interface AppMainRoutesProps {
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (masterPassword: string, 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>;
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
}
+109 -72
View File
@@ -43,7 +43,7 @@ interface BackupCenterPageProps {
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (masterPassword: string, 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>;
onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -60,6 +60,7 @@ type PendingBackupVerification =
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
| { action: 'runRemoteBackup' }
| { action: 'downloadRemote'; path: string }
| { action: 'deleteRemote'; destinationId: string; path: string }
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
interface BackupProgressPhase {
@@ -204,6 +205,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
const [backupPasswordValue, setBackupPasswordValue] = useState('');
const [backupPasswordError, setBackupPasswordError] = useState('');
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
@@ -245,11 +247,29 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
? t('txt_backup_save_settings')
: pendingBackupVerification?.action === 'runRemoteBackup'
? t('txt_backup_run_manual')
: pendingBackupVerification?.action === 'downloadRemote'
? t('txt_backup_remote_download')
: pendingBackupVerification?.action === 'restoreRemote'
? t('txt_backup_import')
: t('txt_backup_import');
: pendingBackupVerification?.action === 'downloadRemote'
? t('txt_backup_remote_download')
: pendingBackupVerification?.action === 'deleteRemote'
? t('txt_delete')
: pendingBackupVerification?.action === 'restoreRemote'
? t('txt_backup_import')
: t('txt_backup_import');
function openBackupPasswordPrompt(request: PendingBackupVerification): void {
setPendingBackupVerification(request);
setBackupPasswordValue('');
setBackupPasswordError('');
}
function showActionError(error: unknown, fallback: string): string {
const message = error instanceof Error ? error.message : fallback;
setLocalError(message);
if (backupPasswordSubmitting || pendingBackupVerification) {
setBackupPasswordError(message);
}
props.onNotify('error', message);
return message;
}
useEffect(() => {
let cancelled = false;
@@ -502,12 +522,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
};
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
setBackupPasswordValue('');
openBackupPasswordPrompt({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
setConfirmDeleteDestinationOpen(false);
}
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) {
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings): Promise<boolean> {
setSavingSettings(true);
setLocalError('');
try {
@@ -527,10 +546,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setSelectedDestinationId(nextSelected);
setConfirmDeleteDestinationOpen(false);
props.onNotify('success', t('txt_backup_destination_deleted'));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_settings_save_failed'));
return false;
} finally {
setSavingSettings(false);
}
@@ -538,22 +557,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleExport() {
if (exporting) return;
setPendingBackupVerification({ action: 'export' });
setBackupPasswordValue('');
openBackupPasswordPrompt({ action: 'export' });
}
async function executeExport(masterPassword: string) {
async function executeExport(masterPassword: string): Promise<boolean> {
setLocalError('');
setExporting(true);
try {
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
await props.onExport(masterPassword, exportIncludeAttachments);
props.onNotify('success', t('txt_backup_export_success'));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_export_failed'));
window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally {
setExporting(false);
}
@@ -571,13 +589,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
props.onNotify('error', message);
return;
}
setPendingBackupVerification({
openBackupPasswordPrompt({
action: 'import',
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeLocalRestore(
@@ -585,13 +602,14 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (importing) return;
): Promise<boolean> {
if (importing) return false;
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
setBackupPasswordError(message);
props.onNotify('error', message);
return;
return false;
}
setLocalError('');
setConfirmLocalRestoreOpen(false);
@@ -614,17 +632,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false);
resetPendingIntegrityWarning();
return true;
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
clearRestoreProgress();
setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(true);
return;
return true;
}
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_restore_failed'));
window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally {
setImporting(false);
}
@@ -632,11 +650,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleSaveSettings() {
if (savingSettings) return;
setPendingBackupVerification({ action: 'saveSettings' });
setBackupPasswordValue('');
openBackupPasswordPrompt({ action: 'saveSettings' });
}
async function executeSaveSettings(masterPassword: string) {
async function executeSaveSettings(masterPassword: string): Promise<boolean> {
const payload = buildSettingsPayloadForSelectedDestination();
const destinationIdToInvalidate = selectedDestinationId;
setSavingSettings(true);
@@ -656,10 +673,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}
setSelectedDestinationId(nextSelected);
props.onNotify('success', t('txt_backup_settings_saved'));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_settings_save_failed'));
return false;
} finally {
setSavingSettings(false);
}
@@ -678,12 +695,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleRunRemoteBackup() {
if (!selectedDestination || runningRemoteBackup) return;
setPendingBackupVerification({ action: 'runRemoteBackup' });
setBackupPasswordValue('');
openBackupPasswordPrompt({ action: 'runRemoteBackup' });
}
async function executeRunRemoteBackup(masterPassword: string) {
if (!selectedDestination) return;
async function executeRunRemoteBackup(masterPassword: string): Promise<boolean> {
if (!selectedDestination) return false;
setRunningRemoteBackup(true);
setLocalError('');
try {
@@ -697,32 +713,31 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setSelectedDestinationId(selectedDestination.id);
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_remote_run_failed'));
window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally {
setRunningRemoteBackup(false);
}
}
async function handleDownloadRemote(path: string) {
setPendingBackupVerification({ action: 'downloadRemote', path });
setBackupPasswordValue('');
openBackupPasswordPrompt({ action: 'downloadRemote', path });
}
async function executeDownloadRemote(masterPassword: string, path: string) {
if (!savedSelectedDestination) return;
async function executeDownloadRemote(masterPassword: string, path: string): Promise<boolean> {
if (!savedSelectedDestination) return false;
setDownloadingRemotePath(path);
setDownloadingRemotePercent(null);
setLocalError('');
try {
await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_remote_download_failed'));
return false;
} finally {
setDownloadingRemotePath('');
setDownloadingRemotePercent(null);
@@ -732,18 +747,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleDeleteRemote(path: string) {
if (deletingRemotePath) return;
if (!savedSelectedDestination) return;
openBackupPasswordPrompt({ action: 'deleteRemote', destinationId: savedSelectedDestination.id, path });
setConfirmRemoteDeleteOpen(false);
}
async function executeDeleteRemote(masterPassword: string, destinationId: string, path: string): Promise<boolean> {
if (deletingRemotePath) return false;
setDeletingRemotePath(path);
setLocalError('');
try {
await props.onDeleteRemoteBackup(savedSelectedDestination.id, path);
await props.onDeleteRemoteBackup(masterPassword, destinationId, path);
setConfirmRemoteDeleteOpen(false);
setPendingRemoteDeletePath('');
await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true });
await loadRemoteBrowser(destinationId, remoteBrowserPathByDestination[destinationId] || '', { force: true });
props.onNotify('success', t('txt_backup_remote_delete_success'));
return true;
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_remote_delete_failed'));
return false;
} finally {
setDeletingRemotePath('');
}
@@ -802,14 +823,13 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
) {
if (restoringRemotePath) return;
if (!savedSelectedDestination) return;
setPendingBackupVerification({
openBackupPasswordPrompt({
action: 'restoreRemote',
path,
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeRemoteRestore(
@@ -818,9 +838,9 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (restoringRemotePath) return;
if (!savedSelectedDestination) return;
): Promise<boolean> {
if (restoringRemotePath) return false;
if (!savedSelectedDestination) return false;
setConfirmRemoteReplaceOpen(false);
setConfirmIntegrityWarningOpen(false);
setRestoringRemotePath(path);
@@ -840,17 +860,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
resetPendingIntegrityWarning();
return true;
} catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) {
setPendingRemoteRestorePath(path);
setConfirmRemoteReplaceOpen(true);
clearRestoreProgress();
return;
return true;
}
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
setLocalError(message);
props.onNotify('error', message);
showActionError(error, t('txt_backup_remote_restore_failed'));
window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally {
setRestoringRemotePath('');
}
@@ -861,31 +881,38 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const masterPassword = backupPasswordValue;
if (!request || backupPasswordSubmitting) return;
if (!masterPassword.trim()) {
props.onNotify('error', t('txt_master_password_is_required'));
setBackupPasswordError(t('txt_master_password_is_required'));
return;
}
setBackupPasswordSubmitting(true);
setPendingBackupVerification(null);
setBackupPasswordValue('');
setBackupPasswordError('');
let succeeded = false;
try {
if (request.action === 'export') {
await executeExport(masterPassword);
succeeded = await executeExport(masterPassword);
} else if (request.action === 'saveSettings') {
await executeSaveSettings(masterPassword);
succeeded = await executeSaveSettings(masterPassword);
} else if (request.action === 'deleteDestination') {
await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
succeeded = await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
} else if (request.action === 'import') {
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
succeeded = await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
} else if (request.action === 'runRemoteBackup') {
await executeRunRemoteBackup(masterPassword);
succeeded = await executeRunRemoteBackup(masterPassword);
} else if (request.action === 'downloadRemote') {
await executeDownloadRemote(masterPassword, request.path);
succeeded = await executeDownloadRemote(masterPassword, request.path);
} else if (request.action === 'deleteRemote') {
succeeded = await executeDeleteRemote(masterPassword, request.destinationId, request.path);
} else if (request.action === 'restoreRemote') {
await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
succeeded = await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
}
} finally {
setBackupPasswordSubmitting(false);
}
if (succeeded) {
setPendingBackupVerification(null);
setBackupPasswordValue('');
setBackupPasswordError('');
}
}
return (
@@ -1031,17 +1058,27 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (backupPasswordSubmitting) return;
setPendingBackupVerification(null);
setBackupPasswordValue('');
setBackupPasswordError('');
}}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
id="backup-master-password"
className="input"
type="password"
autoComplete="current-password"
value={backupPasswordValue}
onInput={(event) => setBackupPasswordValue((event.currentTarget as HTMLInputElement).value)}
aria-invalid={!!backupPasswordError}
aria-describedby={backupPasswordError ? 'backup-master-password-error' : undefined}
onInput={(event) => {
setBackupPasswordValue((event.currentTarget as HTMLInputElement).value);
if (backupPasswordError) setBackupPasswordError('');
}}
/>
{backupPasswordError ? (
<div id="backup-master-password-error" className="local-error" role="alert">{backupPasswordError}</div>
) : null}
</label>
</ConfirmDialog>
+2 -2
View File
@@ -86,8 +86,8 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
},
async deleteRemoteBackup(destinationId: string, path: string) {
await deleteRemoteBackup(authedFetch, destinationId, path);
async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
await deleteRemoteBackup(authedFetch, masterPasswordHash, destinationId, path);
},
async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
+6 -4
View File
@@ -403,13 +403,15 @@ export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: str
export async function deleteRemoteBackup(
authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string,
path: string
): Promise<void> {
const params = new URLSearchParams();
params.set('destinationId', destinationId);
params.set('path', path);
const resp = await authedFetch(`/api/admin/backup/remote/file?${params.toString()}`, { method: 'DELETE' });
const resp = await authedFetch('/api/admin/backup/remote/file', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
}