From 2f448964f2382e3a8bbd44e0fbb8ee012d652615 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 16 Mar 2026 00:38:44 +0800 Subject: [PATCH] feat: enhance backup import functionality to handle skipped items and provide detailed feedback --- src/handlers/backup.ts | 3 + src/services/backup-import.ts | 111 +++++++++++++++++++-- webapp/src/components/AppMainRoutes.tsx | 6 +- webapp/src/components/BackupCenterPage.tsx | 25 ++++- webapp/src/hooks/useBackupActions.ts | 6 +- webapp/src/lib/api/backup.ts | 14 +++ webapp/src/lib/i18n.ts | 4 + 7 files changed, 149 insertions(+), 20 deletions(-) diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index bad5ecb..87076cc 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -153,6 +153,9 @@ async function runImportAndAudit( ciphers: imported.result.imported.ciphers, attachments: imported.result.imported.attachmentFiles, sendFiles: imported.result.imported.sendFiles, + skippedAttachments: imported.result.skipped.attachments, + skippedSendFiles: imported.result.skipped.sendFiles, + skippedReason: imported.result.skipped.reason, replaceExisting, ...metadata, }); diff --git a/src/services/backup-import.ts b/src/services/backup-import.ts index 6e5cf30..0b4857f 100644 --- a/src/services/backup-import.ts +++ b/src/services/backup-import.ts @@ -19,6 +19,16 @@ export interface BackupImportResultBody { attachmentFiles: number; sendFiles: number; }; + skipped: { + reason: string | null; + attachments: number; + sendFiles: number; + items: Array<{ + kind: 'attachment' | 'send-file'; + path: string; + sizeBytes: number; + }>; + }; } export interface BackupImportExecutionResult { @@ -106,19 +116,99 @@ function collectImportedBlobKeys(db: BackupPayload['db']): Set { return keys; } -function validateImportBlobLimits(env: Env, payload: BackupPayload, files: Record): void { - if (getBlobStorageKind(env) !== 'kv') return; +const KV_BLOB_SKIP_REASON = 'Cloudflare KV object size limit (25 MB)'; + +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): PreparedBackupImportPayload { + if (getBlobStorageKind(env) !== 'kv') { + return { + payload, + skipped: { + reason: null, + attachments: 0, + sendFiles: 0, + items: [], + }, + }; + } + + const oversizedAttachmentPaths = new Set(); + const oversizedSendPaths = new Set(); + const skippedItems: BackupImportSkipSummary['items'] = []; + for (const entry of Object.keys(files)) { if (!entry.endsWith('.bin')) continue; - if (files[entry].byteLength > KV_MAX_OBJECT_BYTES) { - throw new Error(`Backup file ${entry} exceeds the Cloudflare KV object size limit`); + const sizeBytes = files[entry].byteLength; + 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) { - throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage'); - } + + const nextAttachments = (payload.db.attachments || []).filter((row) => { + 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[] { @@ -211,7 +301,7 @@ export async function importBackupArchiveBytes( const storage = new StorageService(env.DB); const parsed = parseBackupArchive(archiveBytes); validateBackupPayloadContents(parsed.payload, parsed.files); - validateImportBlobLimits(env, parsed.payload, parsed.files); + const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files); try { await ensureImportTargetIsFresh(env.DB); @@ -222,7 +312,7 @@ export async function importBackupArchiveBytes( } const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set(); - const { db } = parsed.payload; + const { db } = prepared.payload; await importBackupRows(env.DB, db); await normalizeImportedBackupSettings(storage, env, 'UTC'); @@ -248,6 +338,7 @@ export async function importBackupArchiveBytes( attachmentFiles: blobCounts.attachments, sendFiles: blobCounts.sendFiles, }, + skipped: prepared.skipped, }, }; } diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index c200523..e3623b5 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -2,7 +2,7 @@ import { lazy, Suspense } from 'preact/compat'; import { Link, Route, Switch } from 'wouter'; import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; 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 { t } from '@/lib/i18n'; 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; onRevokeInvite: (code: string) => Promise; onExportBackup: () => Promise; - onImportBackup: (file: File, replaceExisting?: boolean) => Promise; + onImportBackup: (file: File, replaceExisting?: boolean) => Promise; onLoadBackupSettings: () => Promise; onSaveBackupSettings: (settings: AdminBackupSettings) => Promise; onRunRemoteBackup: (destinationId?: string | null) => Promise; onListRemoteBackups: (destinationId: string, path: string) => Promise; onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise; - onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; + onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; } export default function AppMainRoutes(props: AppMainRoutesProps) { diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx index af296b4..58d8d41 100644 --- a/webapp/src/components/BackupCenterPage.tsx +++ b/webapp/src/components/BackupCenterPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'preact/hooks'; import ConfirmDialog from '@/components/ConfirmDialog'; import { + type AdminBackupImportResponse, type AdminBackupRunResponse, type AdminBackupSettings, type BackupDestinationRecord, @@ -30,15 +31,25 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar interface BackupCenterPageProps { currentUserId: string | null; onExport: () => Promise; - onImport: (file: File, replaceExisting?: boolean) => Promise; + onImport: (file: File, replaceExisting?: boolean) => Promise; onLoadSettings: () => Promise; onSaveSettings: (settings: AdminBackupSettings) => Promise; onRunRemoteBackup: (destinationId?: string | null) => Promise; onListRemoteBackups: (destinationId: string, path: string) => Promise; onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise; - onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; - onNotify: (type: 'success' | 'error', text: string) => void; + onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; + 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) { @@ -286,8 +297,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { setLocalError(''); setImporting(true); try { - await props.onImport(selectedFile, replaceExisting); + const result = await props.onImport(selectedFile, replaceExisting); props.onNotify('success', t('txt_backup_restore_success_relogin')); + const skippedMessage = buildSkippedImportMessage(result); + if (skippedMessage) props.onNotify('warning', skippedMessage); resetSelectedFile(); setConfirmLocalRestoreOpen(false); setConfirmReplaceOpen(false); @@ -406,10 +419,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { setRestoringRemotePath(path); setLocalError(''); try { - await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); + const result = await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); setConfirmRemoteReplaceOpen(false); setPendingRemoteRestorePath(''); props.onNotify('success', t('txt_backup_restore_success_relogin')); + const skippedMessage = buildSkippedImportMessage(result); + if (skippedMessage) props.onNotify('warning', skippedMessage); } catch (error) { if (!replaceExisting && isReplaceRequiredError(error)) { setPendingRemoteRestorePath(path); diff --git a/webapp/src/hooks/useBackupActions.ts b/webapp/src/hooks/useBackupActions.ts index 6e54633..c030ab1 100644 --- a/webapp/src/hooks/useBackupActions.ts +++ b/webapp/src/hooks/useBackupActions.ts @@ -30,8 +30,9 @@ export default function useBackupActions(options: UseBackupActionsOptions) { }, async importBackup(file: File, replaceExisting: boolean = false) { - await importAdminBackup(authedFetch, file, replaceExisting); + const result = await importAdminBackup(authedFetch, file, replaceExisting); onImported?.(); + return result; }, async loadSettings() { @@ -60,8 +61,9 @@ export default function useBackupActions(options: UseBackupActionsOptions) { }, async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) { - await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting); + const result = await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting); onRestored?.(); + return result; }, }), [authedFetch, onImported, onRestored] diff --git a/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts index 07c6a53..b6b5e4c 100644 --- a/webapp/src/lib/api/backup.ts +++ b/webapp/src/lib/api/backup.ts @@ -86,9 +86,23 @@ export interface AdminBackupImportCounts { 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 { object: 'instance-backup-import'; imported: AdminBackupImportCounts; + skipped: AdminBackupImportSkipped; } export interface AdminBackupExportPayload { diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 1fa056e..5f280d2 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -25,6 +25,8 @@ const messages: Record> = { txt_backup_export_success: "Backup exported", 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_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_import_failed: "Backup restore failed", txt_backup_restore_failed: "Backup restore failed", @@ -606,6 +608,8 @@ const zhCNOverrides: Record = { txt_backup_export_success: '备份已导出', txt_backup_import_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_import_failed: '备份还原失败', txt_backup_restore_failed: '备份还原失败',