feat: enhance backup import functionality to handle skipped items and provide detailed feedback

This commit is contained in:
shuaiplus
2026-03-16 00:38:44 +08:00
parent 3cb2ef1015
commit 2f448964f2
7 changed files with 149 additions and 20 deletions
+3
View File
@@ -153,6 +153,9 @@ async function runImportAndAudit(
ciphers: imported.result.imported.ciphers, ciphers: imported.result.imported.ciphers,
attachments: imported.result.imported.attachmentFiles, attachments: imported.result.imported.attachmentFiles,
sendFiles: imported.result.imported.sendFiles, sendFiles: imported.result.imported.sendFiles,
skippedAttachments: imported.result.skipped.attachments,
skippedSendFiles: imported.result.skipped.sendFiles,
skippedReason: imported.result.skipped.reason,
replaceExisting, replaceExisting,
...metadata, ...metadata,
}); });
+101 -10
View File
@@ -19,6 +19,16 @@ export interface BackupImportResultBody {
attachmentFiles: number; attachmentFiles: number;
sendFiles: number; sendFiles: number;
}; };
skipped: {
reason: string | null;
attachments: number;
sendFiles: number;
items: Array<{
kind: 'attachment' | 'send-file';
path: string;
sizeBytes: number;
}>;
};
} }
export interface BackupImportExecutionResult { export interface BackupImportExecutionResult {
@@ -106,19 +116,99 @@ function collectImportedBlobKeys(db: BackupPayload['db']): Set<string> {
return keys; return keys;
} }
function validateImportBlobLimits(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): void { const KV_BLOB_SKIP_REASON = 'Cloudflare KV object size limit (25 MB)';
if (getBlobStorageKind(env) !== 'kv') return;
interface BackupImportSkipSummary {
reason: string | null;
attachments: number;
sendFiles: number;
items: Array<{
kind: 'attachment' | 'send-file';
path: string;
sizeBytes: number;
}>;
}
interface PreparedBackupImportPayload {
payload: BackupPayload;
skipped: BackupImportSkipSummary;
}
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
if (getBlobStorageKind(env) !== 'kv') {
return {
payload,
skipped: {
reason: null,
attachments: 0,
sendFiles: 0,
items: [],
},
};
}
const oversizedAttachmentPaths = new Set<string>();
const oversizedSendPaths = new Set<string>();
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const entry of Object.keys(files)) { for (const entry of Object.keys(files)) {
if (!entry.endsWith('.bin')) continue; if (!entry.endsWith('.bin')) continue;
if (files[entry].byteLength > KV_MAX_OBJECT_BYTES) { const sizeBytes = files[entry].byteLength;
throw new Error(`Backup file ${entry} exceeds the Cloudflare KV object size limit`); if (sizeBytes <= KV_MAX_OBJECT_BYTES) continue;
if (entry.startsWith('attachments/')) {
oversizedAttachmentPaths.add(entry);
skippedItems.push({ kind: 'attachment', path: entry, sizeBytes });
continue;
}
if (entry.startsWith('send-files/')) {
oversizedSendPaths.add(entry);
skippedItems.push({ kind: 'send-file', path: entry, sizeBytes });
} }
} }
if ((payload.db.attachments || []).length > 0 || (payload.db.sends || []).length > 0) {
if (!env.ATTACHMENTS_KV) { const nextAttachments = (payload.db.attachments || []).filter((row) => {
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage'); const cipherId = String(row.cipher_id || '').trim();
} const attachmentId = String(row.id || '').trim();
if (!cipherId || !attachmentId) return false;
return !oversizedAttachmentPaths.has(`attachments/${cipherId}/${attachmentId}.bin`);
});
const nextSends = (payload.db.sends || []).filter((row) => {
const sendId = String(row.id || '').trim();
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
if (!sendId || !fileId) return true;
return !oversizedSendPaths.has(`send-files/${sendId}/${fileId}.bin`);
});
const nextPayload: BackupPayload = {
...payload,
db: {
...payload.db,
attachments: nextAttachments,
sends: nextSends,
},
};
const needsKvBlobStorage = nextAttachments.length > 0
|| nextSends.some((row) => {
const sendId = String(row.id || '').trim();
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
return !!sendId && !!fileId;
});
if (needsKvBlobStorage && !env.ATTACHMENTS_KV) {
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
} }
return {
payload: nextPayload,
skipped: {
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
attachments: skippedItems.filter((item) => item.kind === 'attachment').length,
sendFiles: skippedItems.filter((item) => item.kind === 'send-file').length,
items: skippedItems,
},
};
} }
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] { function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
@@ -211,7 +301,7 @@ export async function importBackupArchiveBytes(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const parsed = parseBackupArchive(archiveBytes); const parsed = parseBackupArchive(archiveBytes);
validateBackupPayloadContents(parsed.payload, parsed.files); validateBackupPayloadContents(parsed.payload, parsed.files);
validateImportBlobLimits(env, parsed.payload, parsed.files); const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
try { try {
await ensureImportTargetIsFresh(env.DB); await ensureImportTargetIsFresh(env.DB);
@@ -222,7 +312,7 @@ export async function importBackupArchiveBytes(
} }
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>(); const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
const { db } = parsed.payload; const { db } = prepared.payload;
await importBackupRows(env.DB, db); await importBackupRows(env.DB, db);
await normalizeImportedBackupSettings(storage, env, 'UTC'); await normalizeImportedBackupSettings(storage, env, 'UTC');
@@ -248,6 +338,7 @@ export async function importBackupArchiveBytes(
attachmentFiles: blobCounts.attachments, attachmentFiles: blobCounts.attachments,
sendFiles: blobCounts.sendFiles, sendFiles: blobCounts.sendFiles,
}, },
skipped: prepared.skipped,
}, },
}; };
} }
+3 -3
View File
@@ -2,7 +2,7 @@ import { lazy, Suspense } from 'preact/compat';
import { Link, Route, Switch } from 'wouter'; import { Link, Route, Switch } from 'wouter';
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import type { AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
import type { CiphersImportPayload } from '@/lib/api/vault'; import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types'; import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
@@ -88,14 +88,14 @@ export interface AppMainRoutesProps {
onDeleteUser: (userId: string) => Promise<void>; onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>; onRevokeInvite: (code: string) => Promise<void>;
onExportBackup: () => Promise<void>; onExportBackup: () => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<void>; onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>; onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>; onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
} }
export default function AppMainRoutes(props: AppMainRoutesProps) { export default function AppMainRoutes(props: AppMainRoutesProps) {
+20 -5
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import { import {
type AdminBackupImportResponse,
type AdminBackupRunResponse, type AdminBackupRunResponse,
type AdminBackupSettings, type AdminBackupSettings,
type BackupDestinationRecord, type BackupDestinationRecord,
@@ -30,15 +31,25 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
interface BackupCenterPageProps { interface BackupCenterPageProps {
currentUserId: string | null; currentUserId: string | null;
onExport: () => Promise<void>; onExport: () => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<void>; onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadSettings: () => Promise<AdminBackupSettings>; onLoadSettings: () => Promise<AdminBackupSettings>;
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>; onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
}
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
const skipped = result.skipped;
if (!skipped || (!skipped.attachments && !skipped.sendFiles)) return null;
return t('txt_backup_restore_skipped_summary', {
reason: skipped.reason || t('txt_backup_restore_skipped_reason_default'),
attachments: String(skipped.attachments),
sendFiles: String(skipped.sendFiles),
});
} }
export default function BackupCenterPage(props: BackupCenterPageProps) { export default function BackupCenterPage(props: BackupCenterPageProps) {
@@ -286,8 +297,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setLocalError(''); setLocalError('');
setImporting(true); setImporting(true);
try { try {
await props.onImport(selectedFile, replaceExisting); const result = await props.onImport(selectedFile, replaceExisting);
props.onNotify('success', t('txt_backup_restore_success_relogin')); props.onNotify('success', t('txt_backup_restore_success_relogin'));
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
resetSelectedFile(); resetSelectedFile();
setConfirmLocalRestoreOpen(false); setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false); setConfirmReplaceOpen(false);
@@ -406,10 +419,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setRestoringRemotePath(path); setRestoringRemotePath(path);
setLocalError(''); setLocalError('');
try { try {
await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); const result = await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false); setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath(''); setPendingRemoteRestorePath('');
props.onNotify('success', t('txt_backup_restore_success_relogin')); props.onNotify('success', t('txt_backup_restore_success_relogin'));
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
} catch (error) { } catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) { if (!replaceExisting && isReplaceRequiredError(error)) {
setPendingRemoteRestorePath(path); setPendingRemoteRestorePath(path);
+4 -2
View File
@@ -30,8 +30,9 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
}, },
async importBackup(file: File, replaceExisting: boolean = false) { async importBackup(file: File, replaceExisting: boolean = false) {
await importAdminBackup(authedFetch, file, replaceExisting); const result = await importAdminBackup(authedFetch, file, replaceExisting);
onImported?.(); onImported?.();
return result;
}, },
async loadSettings() { async loadSettings() {
@@ -60,8 +61,9 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
}, },
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) { async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting); const result = await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting);
onRestored?.(); onRestored?.();
return result;
}, },
}), }),
[authedFetch, onImported, onRestored] [authedFetch, onImported, onRestored]
+14
View File
@@ -86,9 +86,23 @@ export interface AdminBackupImportCounts {
sendFiles: number; sendFiles: number;
} }
export interface AdminBackupImportSkippedItem {
kind: 'attachment' | 'send-file';
path: string;
sizeBytes: number;
}
export interface AdminBackupImportSkipped {
reason: string | null;
attachments: number;
sendFiles: number;
items: AdminBackupImportSkippedItem[];
}
export interface AdminBackupImportResponse { export interface AdminBackupImportResponse {
object: 'instance-backup-import'; object: 'instance-backup-import';
imported: AdminBackupImportCounts; imported: AdminBackupImportCounts;
skipped: AdminBackupImportSkipped;
} }
export interface AdminBackupExportPayload { export interface AdminBackupExportPayload {
+4
View File
@@ -25,6 +25,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_backup_export_success: "Backup exported", txt_backup_export_success: "Backup exported",
txt_backup_import_success_relogin: "Backup restored. Please sign in again.", txt_backup_import_success_relogin: "Backup restored. Please sign in again.",
txt_backup_restore_success_relogin: "Backup restored. Please sign in again.", txt_backup_restore_success_relogin: "Backup restored. Please sign in again.",
txt_backup_restore_skipped_summary: "{reason}. Skipped {attachments} attachment(s) and {sendFiles} Send file(s).",
txt_backup_restore_skipped_reason_default: "Some files could not be restored",
txt_backup_export_failed: "Backup export failed", txt_backup_export_failed: "Backup export failed",
txt_backup_import_failed: "Backup restore failed", txt_backup_import_failed: "Backup restore failed",
txt_backup_restore_failed: "Backup restore failed", txt_backup_restore_failed: "Backup restore failed",
@@ -606,6 +608,8 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_export_success: '备份已导出', txt_backup_export_success: '备份已导出',
txt_backup_import_success_relogin: '备份已还原,请重新登录', txt_backup_import_success_relogin: '备份已还原,请重新登录',
txt_backup_restore_success_relogin: '备份已还原,请重新登录', txt_backup_restore_success_relogin: '备份已还原,请重新登录',
txt_backup_restore_skipped_summary: '{reason},已跳过 {attachments} 个附件和 {sendFiles} 个 Send 文件',
txt_backup_restore_skipped_reason_default: '部分文件无法还原',
txt_backup_export_failed: '备份导出失败', txt_backup_export_failed: '备份导出失败',
txt_backup_import_failed: '备份还原失败', txt_backup_import_failed: '备份还原失败',
txt_backup_restore_failed: '备份还原失败', txt_backup_restore_failed: '备份还原失败',