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