diff --git a/src/durable/backup-transfer-runner.ts b/src/durable/backup-transfer-runner.ts index 90fed64..35b1d52 100644 --- a/src/durable/backup-transfer-runner.ts +++ b/src/durable/backup-transfer-runner.ts @@ -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'); } diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index 4f750a0..ee0d67c 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -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 { 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', }, }); } diff --git a/src/services/backup-archive.ts b/src/services/backup-archive.ts index ebfb35b..1a7e72d 100644 --- a/src/services/backup-archive.ts +++ b/src/services/backup-archive.ts @@ -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; 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`; diff --git a/src/services/backup-import.ts b/src/services/backup-import.ts index 351b652..03364a8 100644 --- a/src/services/backup-import.ts +++ b/src/services/backup-import.ts @@ -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 { - return new Map( - (manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item]) - ); + const lookup = new Map(); + 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( diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 066b065..3e654b8 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -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); diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 0e2dc21..8fed21d 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -169,7 +169,7 @@ export interface AppMainRoutesProps { onListRemoteBackups: (destinationId: string, path: string) => Promise; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>; - onDeleteRemoteBackup: (destinationId: string, path: string) => Promise; + onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; } diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx index de38543..50d6d94 100644 --- a/webapp/src/components/BackupCenterPage.tsx +++ b/webapp/src/components/BackupCenterPage.tsx @@ -43,7 +43,7 @@ interface BackupCenterPageProps { onListRemoteBackups: (destinationId: string, path: string) => Promise; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>; - onDeleteRemoteBackup: (destinationId: string, path: string) => Promise; + onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; 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(null); const [backupPasswordValue, setBackupPasswordValue] = useState(''); + const [backupPasswordError, setBackupPasswordError] = useState(''); const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false); const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState(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 { 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 { 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 { + 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 { 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 { + 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 { + 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 { + 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 { + 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(''); }} > diff --git a/webapp/src/hooks/useBackupActions.ts b/webapp/src/hooks/useBackupActions.ts index 3322e99..e00f8ae 100644 --- a/webapp/src/hooks/useBackupActions.ts +++ b/webapp/src/hooks/useBackupActions.ts @@ -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) { diff --git a/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts index 25d8c17..c2ba009 100644 --- a/webapp/src/lib/api/backup.ts +++ b/webapp/src/lib/api/backup.ts @@ -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 { - 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'))); }