feat: enhance backup functionality with attachment options

- Added support for including attachments in backup exports.
- Updated backup-related interfaces and functions to handle attachment options.
- Introduced a new UI component for selecting attachment inclusion during backup operations.
- Modified existing components to integrate the new attachment functionality.
- Improved user feedback and error handling during backup processes.
This commit is contained in:
shuaiplus
2026-03-20 04:55:23 +08:00
parent 3d38424d77
commit cbf1e86881
19 changed files with 883 additions and 352 deletions
+298 -95
View File
@@ -1,8 +1,13 @@
import type { Env } from '../types';
import { StorageService } from './storage';
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, getSendFileObjectKey, putBlobObject } from './blob-store';
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
import { normalizeImportedBackupSettings } from './backup-config';
import { type BackupPayload, parseBackupArchive, parseSendFileId, validateBackupPayloadContents } from './backup-archive';
import {
type BackupManifestAttachmentBlob,
type BackupPayload,
parseBackupArchive,
validateBackupPayloadContents,
} from './backup-archive';
type SqlRow = Record<string, string | number | null>;
@@ -15,16 +20,13 @@ export interface BackupImportResultBody {
folders: number;
ciphers: number;
attachments: number;
sends: number;
attachmentFiles: number;
sendFiles: number;
};
skipped: {
reason: string | null;
attachments: number;
sendFiles: number;
items: Array<{
kind: 'attachment' | 'send-file';
kind: 'attachment';
path: string;
sizeBytes: number;
}>;
@@ -88,42 +90,18 @@ async function collectCurrentBlobKeys(db: D1Database): Promise<Set<string>> {
if (!cipherId || !attachmentId) continue;
keys.add(getAttachmentObjectKey(cipherId, attachmentId));
}
const sendRows = await queryRows(db, 'SELECT id, data FROM sends');
for (const row of sendRows) {
const sendId = String(row.id || '').trim();
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
if (!sendId || !fileId) continue;
keys.add(getSendFileObjectKey(sendId, fileId));
}
return keys;
}
function collectImportedBlobKeys(db: BackupPayload['db']): Set<string> {
const keys = new Set<string>();
for (const row of db.attachments) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
if (!cipherId || !attachmentId) continue;
keys.add(getAttachmentObjectKey(cipherId, attachmentId));
}
for (const row of db.sends) {
const sendId = String(row.id || '').trim();
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
if (!sendId || !fileId) continue;
keys.add(getSendFileObjectKey(sendId, fileId));
}
return keys;
}
const KV_BLOB_SKIP_REASON = 'Cloudflare KV object size limit (25 MB)';
const BLOB_STORAGE_UNAVAILABLE_SKIP_REASON = 'Attachment storage is not configured';
const ATTACHMENT_RESTORE_FAILED_REASON = 'Some attachments could not be restored and were skipped';
interface BackupImportSkipSummary {
reason: string | null;
attachments: number;
sendFiles: number;
items: Array<{
kind: 'attachment' | 'send-file';
kind: 'attachment';
path: string;
sizeBytes: number;
}>;
@@ -134,21 +112,58 @@ interface PreparedBackupImportPayload {
skipped: BackupImportSkipSummary;
}
interface AttachmentRestoreResult {
imported: number;
restoredAttachments: SqlRow[];
skipped: BackupImportSkipSummary;
}
interface RemoteAttachmentSource {
hasAttachment(blobName: string): Promise<boolean>;
loadAttachment(blobName: string): Promise<Uint8Array | null>;
}
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
if (getBlobStorageKind(env) !== 'kv') {
const storageKind = getBlobStorageKind(env);
if (storageKind === 'r2') {
return {
payload,
skipped: {
reason: null,
attachments: 0,
sendFiles: 0,
items: [],
},
};
}
if (storageKind === null) {
const skippedItems = (payload.db.attachments || []).map((row) => {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
return {
kind: 'attachment' as const,
path: `attachments/${cipherId}/${attachmentId}.bin`,
sizeBytes: Number(row.size || 0) || 0,
};
});
return {
payload: {
...payload,
db: {
...payload.db,
attachments: [],
},
},
skipped: {
reason: skippedItems.length ? BLOB_STORAGE_UNAVAILABLE_SKIP_REASON : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
}
const oversizedAttachmentPaths = new Set<string>();
const oversizedSendPaths = new Set<string>();
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const entry of Object.keys(files)) {
@@ -158,11 +173,6 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
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 });
}
}
@@ -173,28 +183,15 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
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;
});
const needsKvBlobStorage = nextAttachments.length > 0;
if (needsKvBlobStorage && !env.ATTACHMENTS_KV) {
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
@@ -204,8 +201,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
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,
attachments: skippedItems.length,
items: skippedItems,
},
};
@@ -218,9 +214,9 @@ function buildInsertStatements(db: D1Database, table: string, columns: string[],
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
}
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<{ attachments: number; sendFiles: number }> {
let attachmentCount = 0;
let sendFileCount = 0;
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
const restoredAttachments: SqlRow[] = [];
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const row of db.attachments || []) {
const cipherId = String(row.cipher_id || '').trim();
@@ -228,31 +224,178 @@ async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record
if (!cipherId || !attachmentId) continue;
const key = `attachments/${cipherId}/${attachmentId}.bin`;
const bytes = files[key];
if (!bytes) throw new Error(`Backup archive is missing required file: ${key}`);
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
size: bytes.byteLength,
contentType: 'application/octet-stream',
});
attachmentCount += 1;
}
for (const row of db.sends || []) {
const sendId = String(row.id || '').trim();
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
if (!sendId || !fileId) continue;
const key = `send-files/${sendId}/${fileId}.bin`;
const bytes = files[key];
if (!bytes) throw new Error(`Backup archive is missing required file: ${key}`);
await putBlobObject(env, getSendFileObjectKey(sendId, fileId), bytes, {
size: bytes.byteLength,
contentType: 'application/octet-stream',
});
sendFileCount += 1;
if (!bytes) {
skippedItems.push({
kind: 'attachment',
path: key,
sizeBytes: Number(row.size || 0) || 0,
});
continue;
}
try {
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
size: bytes.byteLength,
contentType: 'application/octet-stream',
});
restoredAttachments.push(row);
} catch {
skippedItems.push({
kind: 'attachment',
path: key,
sizeBytes: bytes.byteLength,
});
}
}
return {
attachments: attachmentCount,
sendFiles: sendFileCount,
imported: restoredAttachments.length,
restoredAttachments,
skipped: {
reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
}
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
return new Map(
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item])
);
}
async function prepareRemoteAttachmentPayload(
env: Env,
payload: BackupPayload,
files: Record<string, Uint8Array>,
source: RemoteAttachmentSource
): Promise<PreparedBackupImportPayload> {
const manifestLookup = buildAttachmentBlobLookup(payload.manifest);
const storageKind = getBlobStorageKind(env);
const nextAttachments: SqlRow[] = [];
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const row of payload.db.attachments || []) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
const lookupKey = `${cipherId}/${attachmentId}`;
const ref = manifestLookup.get(lookupKey);
const sizeBytes = ref?.sizeBytes || Number(row.size || 0) || 0;
const path = ref ? `attachments/${ref.blobName}` : `attachments/${lookupKey}`;
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
if (files[inlinePath]) {
nextAttachments.push(row);
continue;
}
if (!ref) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
if (storageKind === 'kv' && sizeBytes > KV_MAX_OBJECT_BYTES) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
if (storageKind === null) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
if (!(await source.hasAttachment(ref.blobName))) {
skippedItems.push({ kind: 'attachment', path, sizeBytes });
continue;
}
nextAttachments.push(row);
}
return {
payload: {
...payload,
db: {
...payload.db,
attachments: nextAttachments,
},
},
skipped: {
reason: skippedItems.length ? 'Some remote attachments were unavailable and were skipped' : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
}
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[]): Promise<void> {
if (!attachmentRows.length) return;
const statements = attachmentRows
.map((row) => {
const attachmentId = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim();
if (!attachmentId || !cipherId) return null;
return db.prepare('DELETE FROM attachments WHERE id = ? AND cipher_id = ?').bind(attachmentId, cipherId);
})
.filter((statement): statement is D1PreparedStatement => !!statement);
if (!statements.length) return;
await db.batch(statements);
}
async function restoreRemoteAttachmentFiles(
env: Env,
payload: BackupPayload,
files: Record<string, Uint8Array>,
source: RemoteAttachmentSource
): Promise<{
imported: number;
skipped: BackupImportSkipSummary;
restoredAttachments: SqlRow[];
}> {
const manifestLookup = buildAttachmentBlobLookup(payload.manifest);
const restoredAttachments: SqlRow[] = [];
const skippedItems: BackupImportSkipSummary['items'] = [];
for (const row of payload.db.attachments || []) {
const cipherId = String(row.cipher_id || '').trim();
const attachmentId = String(row.id || '').trim();
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
const ref = manifestLookup.get(`${cipherId}/${attachmentId}`);
if (!ref && !files[inlinePath]) {
skippedItems.push({
kind: 'attachment',
path: `attachments/${cipherId}/${attachmentId}`,
sizeBytes: Number(row.size || 0) || 0,
});
continue;
}
const bytes = files[inlinePath] || (ref ? await source.loadAttachment(ref.blobName) : null);
if (!bytes) {
skippedItems.push({
kind: 'attachment',
path: ref ? `attachments/${ref.blobName}` : inlinePath,
sizeBytes: ref?.sizeBytes || Number(row.size || 0) || 0,
});
continue;
}
try {
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
size: bytes.byteLength,
contentType: 'application/octet-stream',
});
restoredAttachments.push(row);
} catch {
skippedItems.push({
kind: 'attachment',
path: ref ? `attachments/${ref.blobName}` : inlinePath,
sizeBytes: bytes.byteLength,
});
}
}
return {
imported: restoredAttachments.length,
restoredAttachments,
skipped: {
reason: skippedItems.length ? ATTACHMENT_RESTORE_FAILED_REASON : null,
attachments: skippedItems.length,
items: skippedItems,
},
};
}
@@ -282,12 +425,6 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db']): P
payload.ciphers || []
),
...buildInsertStatements(db, 'attachments', ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []),
...buildInsertStatements(
db,
'sends',
['id', 'user_id', 'type', 'name', 'notes', 'data', 'key', 'password_hash', 'password_salt', 'password_iterations', 'auth_type', 'emails', 'max_access_count', 'access_count', 'disabled', 'hide_email', 'created_at', 'updated_at', 'expiration_date', 'deletion_date'],
payload.sends || []
),
];
await db.batch(statements);
}
@@ -316,9 +453,11 @@ export async function importBackupArchiveBytes(
await importBackupRows(env.DB, db);
await normalizeImportedBackupSettings(storage, env, 'UTC');
const blobCounts = await restoreBlobFiles(env, db, parsed.files);
const restored = await restoreBlobFiles(env, db, parsed.files);
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
await removeAttachmentRows(env.DB, failedRestoreRows);
if (replaceExisting && previousBlobKeys.size) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, collectImportedBlobKeys(db));
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
}
await storage.setRegistered();
@@ -333,12 +472,76 @@ export async function importBackupArchiveBytes(
userRevisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: (db.attachments || []).length,
sends: (db.sends || []).length,
attachmentFiles: blobCounts.attachments,
sendFiles: blobCounts.sendFiles,
attachments: restored.restoredAttachments.length,
attachmentFiles: restored.imported,
},
skipped: {
reason: restored.skipped.reason || prepared.skipped.reason,
attachments: prepared.skipped.attachments + restored.skipped.attachments,
items: [...prepared.skipped.items, ...restored.skipped.items],
},
},
};
}
export async function importRemoteBackupArchiveBytes(
archiveBytes: Uint8Array,
env: Env,
actorUserId: string,
replaceExisting: boolean,
source: RemoteAttachmentSource
): Promise<BackupImportExecutionResult> {
const storage = new StorageService(env.DB);
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source);
validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true });
try {
await ensureImportTargetIsFresh(env.DB);
} catch (error) {
if (!replaceExisting) {
throw error instanceof Error ? error : new Error('Backup import requires a fresh instance');
}
}
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
const { db } = preparedRemote.payload;
await importBackupRows(env.DB, db);
await normalizeImportedBackupSettings(storage, env, 'UTC');
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
await removeAttachmentRows(env.DB, failedRestoreRows);
if (replaceExisting && previousBlobKeys.size) {
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
}
await storage.setRegistered();
const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items];
const finalSkippedReason = finalSkippedItems.length
? restored.skipped.reason || preparedRemote.skipped.reason
: null;
return {
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
result: {
object: 'instance-backup-import',
imported: {
config: (db.config || []).length,
users: (db.users || []).length,
userRevisions: (db.user_revisions || []).length,
folders: (db.folders || []).length,
ciphers: (db.ciphers || []).length,
attachments: restored.restoredAttachments.length,
attachmentFiles: restored.imported,
},
skipped: {
reason: finalSkippedReason,
attachments: finalSkippedItems.length,
items: finalSkippedItems,
},
skipped: prepared.skipped,
},
};
}