mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
fix(backup): verify remote deletes and validate archives
This commit is contained in:
@@ -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
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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('');
|
||||
@@ -247,10 +249,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
? t('txt_backup_run_manual')
|
||||
: 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;
|
||||
setLoadingSettings(true);
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user