mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance backup and restore functionality with integrity checks and progress tracking
- Added support for backup integrity verification during export and restore processes. - Introduced progress dispatching for backup export and restore operations. - Implemented new API endpoints for inspecting remote backup integrity. - Enhanced user interface with progress indicators and warning dialogs for integrity issues. - Updated localization strings for new features and user feedback. - Refactored backup-related functions for better clarity and maintainability.
This commit is contained in:
@@ -57,6 +57,21 @@ export interface AdminBackupRunResponse {
|
||||
settings: AdminBackupSettings;
|
||||
}
|
||||
|
||||
export interface BackupFileIntegrityCheckResult {
|
||||
hasChecksumPrefix: boolean;
|
||||
expectedPrefix: string | null;
|
||||
actualPrefix: string;
|
||||
matches: boolean;
|
||||
}
|
||||
|
||||
export interface RemoteBackupIntegrityResponse {
|
||||
object: 'backup-remote-integrity';
|
||||
destinationId: string;
|
||||
path: string;
|
||||
fileName: string;
|
||||
integrity: BackupFileIntegrityCheckResult;
|
||||
}
|
||||
|
||||
export interface RemoteBackupItem {
|
||||
path: string;
|
||||
name: string;
|
||||
@@ -109,6 +124,18 @@ export interface AdminBackupExportPayload {
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export interface BackupExportClientProgressEvent {
|
||||
operation: 'backup-export';
|
||||
source: 'local';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface BackupExportManifestAttachmentBlob {
|
||||
cipherId: string;
|
||||
attachmentId: string;
|
||||
@@ -119,6 +146,36 @@ interface BackupExportManifest {
|
||||
attachmentBlobs?: BackupExportManifestAttachmentBlob[];
|
||||
}
|
||||
|
||||
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||
|
||||
function parseBackupTimestampFromFileName(fileName: string): Date | null {
|
||||
const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i);
|
||||
if (!match) return null;
|
||||
const datePart = match[1];
|
||||
const timePart = match[2];
|
||||
const iso = `${datePart.slice(0, 4)}-${datePart.slice(4, 6)}-${datePart.slice(6, 8)}T${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}.000Z`;
|
||||
const parsed = new Date(iso);
|
||||
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
||||
}
|
||||
|
||||
function buildBackupFileName(date: Date, checksumPrefix: string): string {
|
||||
const parts = [
|
||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||
date.getUTCDate().toString().padStart(2, '0'),
|
||||
date.getUTCHours().toString().padStart(2, '0'),
|
||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||
];
|
||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}_${checksumPrefix}.zip`;
|
||||
}
|
||||
|
||||
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
|
||||
const integrity = await verifyBackupFileIntegrity(bytes, fileName);
|
||||
const effectiveDate = parseBackupTimestampFromFileName(fileName) || new Date();
|
||||
return buildBackupFileName(effectiveDate, integrity.actualPrefix);
|
||||
}
|
||||
|
||||
export async function exportAdminBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
includeAttachments: boolean = false
|
||||
@@ -149,10 +206,21 @@ export async function downloadAdminBackupAttachmentBlob(
|
||||
|
||||
export async function buildCompleteAdminBackupExport(
|
||||
authedFetch: AuthedFetch,
|
||||
includeAttachments: boolean = false
|
||||
includeAttachments: boolean = false,
|
||||
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
|
||||
): Promise<AdminBackupExportPayload> {
|
||||
const payload = await exportAdminBackup(authedFetch, includeAttachments);
|
||||
if (!includeAttachments) return payload;
|
||||
if (!includeAttachments) {
|
||||
await onProgress?.({
|
||||
operation: 'backup-export',
|
||||
source: 'local',
|
||||
step: 'export_client_save',
|
||||
fileName: payload.fileName,
|
||||
stageTitle: 'txt_backup_export_progress_save_title',
|
||||
stageDetail: 'txt_backup_export_progress_save_detail',
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
const zipped = unzipSync(payload.bytes);
|
||||
const manifestBytes = zipped['manifest.json'];
|
||||
@@ -167,14 +235,41 @@ export async function buildCompleteAdminBackupExport(
|
||||
throw new Error(t('txt_backup_export_failed'));
|
||||
}
|
||||
|
||||
await onProgress?.({
|
||||
operation: 'backup-export',
|
||||
source: 'local',
|
||||
step: 'export_client_fetch_attachments',
|
||||
fileName: payload.fileName,
|
||||
stageTitle: 'txt_backup_export_progress_fetch_attachments_title',
|
||||
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
||||
});
|
||||
for (const attachment of manifest.attachmentBlobs || []) {
|
||||
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
|
||||
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
||||
}
|
||||
|
||||
await onProgress?.({
|
||||
operation: 'backup-export',
|
||||
source: 'local',
|
||||
step: 'export_client_rebuild',
|
||||
fileName: payload.fileName,
|
||||
stageTitle: 'txt_backup_export_progress_rebuild_title',
|
||||
stageDetail: 'txt_backup_export_progress_rebuild_detail',
|
||||
});
|
||||
const rebuiltBytes = zipSync(zipped, { level: 0 });
|
||||
const rebuiltFileName = await applyBackupFileIntegrityName(payload.fileName, rebuiltBytes);
|
||||
await onProgress?.({
|
||||
operation: 'backup-export',
|
||||
source: 'local',
|
||||
step: 'export_client_save',
|
||||
fileName: rebuiltFileName,
|
||||
stageTitle: 'txt_backup_export_progress_save_title',
|
||||
stageDetail: 'txt_backup_export_progress_save_detail',
|
||||
});
|
||||
return {
|
||||
...payload,
|
||||
bytes: zipSync(zipped, { level: 0 }),
|
||||
bytes: rebuiltBytes,
|
||||
fileName: rebuiltFileName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -276,6 +371,29 @@ export async function downloadRemoteBackup(
|
||||
return { fileName, mimeType, bytes };
|
||||
}
|
||||
|
||||
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||
const normalized = String(fileName || '').trim();
|
||||
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: string): Promise<BackupFileIntegrityCheckResult> {
|
||||
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
|
||||
const actualHash = await sha256Hex(bytes);
|
||||
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||
return {
|
||||
hasChecksumPrefix: !!expectedPrefix,
|
||||
expectedPrefix,
|
||||
actualPrefix,
|
||||
matches: !expectedPrefix || expectedPrefix === actualPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteRemoteBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
destinationId: string,
|
||||
@@ -288,16 +406,32 @@ export async function deleteRemoteBackup(
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
|
||||
}
|
||||
|
||||
export async function inspectRemoteBackupIntegrity(
|
||||
authedFetch: AuthedFetch,
|
||||
destinationId: string,
|
||||
path: string
|
||||
): Promise<RemoteBackupIntegrityResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('destinationId', destinationId);
|
||||
params.set('path', path);
|
||||
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
||||
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
|
||||
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function restoreRemoteBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
destinationId: string,
|
||||
path: string,
|
||||
replaceExisting: boolean = false
|
||||
replaceExisting: boolean = false,
|
||||
allowChecksumMismatch: boolean = false
|
||||
): Promise<AdminBackupImportResponse> {
|
||||
const resp = await authedFetch('/api/admin/backup/remote/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destinationId, path, replaceExisting }),
|
||||
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
|
||||
const body = await parseJson<AdminBackupImportResponse>(resp);
|
||||
@@ -308,13 +442,17 @@ export async function restoreRemoteBackup(
|
||||
export async function importAdminBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
file: File,
|
||||
replaceExisting: boolean = false
|
||||
replaceExisting: boolean = false,
|
||||
allowChecksumMismatch: boolean = false
|
||||
): Promise<AdminBackupImportResponse> {
|
||||
const formData = new FormData();
|
||||
formData.set('file', file, file.name || 'nodewarden_backup.zip');
|
||||
if (replaceExisting) {
|
||||
formData.set('replaceExisting', '1');
|
||||
}
|
||||
if (allowChecksumMismatch) {
|
||||
formData.set('allowChecksumMismatch', '1');
|
||||
}
|
||||
|
||||
const resp = await authedFetch('/api/admin/backup/import', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user