diff --git a/shared/backup-schema.ts b/shared/backup-schema.ts index dc9ebdb..bc7a58c 100644 --- a/shared/backup-schema.ts +++ b/shared/backup-schema.ts @@ -52,6 +52,7 @@ export interface BackupDestinationRecord { id: string; name: string; type: BackupDestinationType; + includeAttachments: boolean; destination: BackupDestinationConfig; schedule: BackupScheduleConfig; runtime: BackupRuntimeState; @@ -132,6 +133,7 @@ export function createBackupDestinationRecord( id: options.id || createBackupRandomId(), name: options.name || createDefaultBackupDestinationName(type, index), type, + includeAttachments: false, destination: createDefaultBackupDestinationConfig(type), schedule: createDefaultBackupScheduleConfig(options.timezone || BACKUP_DEFAULT_TIMEZONE), runtime: createDefaultBackupRuntimeState(), diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index 87076cc..bbf1278 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -17,16 +17,19 @@ import { requireBackupDestination, saveBackupSettings, } from '../services/backup-config'; -import { type BackupImportExecutionResult, importBackupArchiveBytes } from '../services/backup-import'; +import { type BackupImportExecutionResult, importBackupArchiveBytes, importRemoteBackupArchiveBytes } from '../services/backup-import'; import { deleteRemoteBackupFile, downloadRemoteBackupFile, ensureRemoteRestoreCandidate, listRemoteBackupEntries, pruneRemoteBackupArchives, + remoteBackupFileExists, + uploadRemoteBackupFile, uploadBackupArchive, } from '../services/backup-uploader'; import { StorageService } from '../services/storage'; +import { getBlobObject } from '../services/blob-store'; function isAdmin(user: User): boolean { return user.role === 'admin' && user.status === 'active'; @@ -66,6 +69,18 @@ function getBackupDestinationSummary(destination: BackupDestinationRecord | null }; } +function ensureBackupBlobName(value: string): string { + const normalized = String(value || '').trim().replace(/\\/g, '/').replace(/^\/+|\/+$/g, ''); + if (!normalized) { + throw new Error('Backup attachment blob is required'); + } + const parts = normalized.split('/').filter(Boolean); + if (!parts.length || parts.some((part) => part === '.' || part === '..')) { + throw new Error('Backup attachment blob is invalid'); + } + return parts.join('/'); +} + async function executeConfiguredBackup( env: Env, storage: StorageService, @@ -84,7 +99,21 @@ async function executeConfiguredBackup( await saveBackupSettings(storage, env, currentSettings); try { - const archive = await buildBackupArchive(env, now); + const archive = await buildBackupArchive(env, now, { + includeAttachments: destination.includeAttachments, + }); + for (const attachment of archive.manifest.attachmentBlobs || []) { + const remotePath = `attachments/${attachment.blobName}`; + if (await remoteBackupFileExists(destination, remotePath)) continue; + const object = await getBlobObject(env, attachment.blobName); + if (!object) { + throw new Error(`Attachment blob missing for ${attachment.blobName}`); + } + const bytes = new Uint8Array(await new Response(object.body).arrayBuffer()); + await uploadRemoteBackupFile(destination, remotePath, bytes, { + contentType: object.contentType, + }); + } const upload = await uploadBackupArchive(destination, archive.bytes, archive.fileName); let prunedFileCount = 0; let pruneErrorMessage: string | null = null; @@ -152,9 +181,7 @@ async function runImportAndAudit( users: imported.result.imported.users, 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, @@ -378,12 +405,35 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, const destination = requireBackupDestination(settings, body.destinationId || null); const path = ensureRemoteRestoreCandidate(String(body.path || '')); const remoteFile = await downloadRemoteBackupFile(destination, path); - const imported = await runImportAndAudit(env, actorUser, remoteFile.bytes, !!body.replaceExisting, { - ...getBackupDestinationSummary(destination), - remotePath: path, - bytes: remoteFile.bytes.byteLength, - trigger: 'remote', - }); + const imported = await (async () => { + const storage = new StorageService(env.DB); + const result = await importRemoteBackupArchiveBytes( + remoteFile.bytes, + env, + actorUser.id, + !!body.replaceExisting, + { + hasAttachment: async (blobName) => remoteBackupFileExists(destination, `attachments/${blobName}`), + loadAttachment: async (blobName) => { + const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null); + return file?.bytes || null; + }, + } + ); + await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, { + users: result.result.imported.users, + ciphers: result.result.imported.ciphers, + attachments: result.result.imported.attachmentFiles, + skippedAttachments: result.result.skipped.attachments, + skippedReason: result.result.skipped.reason, + replaceExisting: !!body.replaceExisting, + ...getBackupDestinationSummary(destination), + remotePath: path, + bytes: remoteFile.bytes.byteLength, + trigger: 'remote', + }); + return result; + })(); return jsonResponse(imported.result); } catch (error) { const message = error instanceof Error ? error.message : 'Remote backup restore failed'; @@ -392,13 +442,22 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, } export async function handleAdminExportBackup(request: Request, env: Env, actorUser: User): Promise { - void request; if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); const storage = new StorageService(env.DB); + let body: { includeAttachments?: boolean } | null = null; + try { + if ((request.headers.get('Content-Type') || '').includes('application/json')) { + body = await request.json<{ includeAttachments?: boolean }>(); + } + } catch { + return errorResponse('Backup export payload is invalid', 400); + } let archive: BackupArchiveBundle; try { - archive = await buildBackupArchive(env); + archive = await buildBackupArchive(env, new Date(), { + includeAttachments: !!body?.includeAttachments, + }); } catch (error) { const message = error instanceof Error ? error.message : 'Backup export failed'; return errorResponse(message, message.includes('blob missing') ? 409 : 500); @@ -408,8 +467,8 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU users: archive.manifest.tableCounts.users, ciphers: archive.manifest.tableCounts.ciphers, attachments: archive.manifest.tableCounts.attachments, - sends: archive.manifest.tableCounts.sends, compressedBytes: archive.bytes.byteLength, + includesAttachments: archive.manifest.includes.attachments, }); return new Response(archive.bytes, { @@ -422,6 +481,29 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU }); } +export async function handleDownloadAdminBackupAttachment(request: Request, env: Env, actorUser: User): Promise { + if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); + + try { + const url = new URL(request.url); + const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || ''); + const object = await getBlobObject(env, blobName); + if (!object) { + return errorResponse('Backup attachment blob not found', 404); + } + return new Response(object.body, { + status: 200, + headers: { + 'Content-Type': object.contentType || 'application/octet-stream', + 'Content-Length': String(object.size), + 'Cache-Control': 'no-store', + }, + }); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : 'Backup attachment download failed', 400); + } +} + export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise { if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); diff --git a/src/router-admin-backup.ts b/src/router-admin-backup.ts index 93bec93..9df721b 100644 --- a/src/router-admin-backup.ts +++ b/src/router-admin-backup.ts @@ -3,6 +3,7 @@ import { handleAdminExportBackup, handleDownloadAdminRemoteBackup, handleDeleteAdminRemoteBackup, + handleDownloadAdminBackupAttachment, handleGetAdminBackupSettings, handleGetAdminBackupSettingsRepairState, handleAdminImportBackup, @@ -24,6 +25,10 @@ export async function handleAdminBackupRoute( return handleAdminExportBackup(request, env, actorUser); } + if (path === '/api/admin/backup/blob' && method === 'GET') { + return handleDownloadAdminBackupAttachment(request, env, actorUser); + } + if (path === '/api/admin/backup/settings') { if (method === 'GET') return handleGetAdminBackupSettings(request, env, actorUser); if (method === 'PUT') return handleUpdateAdminBackupSettings(request, env, actorUser); diff --git a/src/services/backup-archive.ts b/src/services/backup-archive.ts index b923772..596e0ea 100644 --- a/src/services/backup-archive.ts +++ b/src/services/backup-archive.ts @@ -2,9 +2,7 @@ import { zipSync, unzipSync } from 'fflate'; import type { Env } from '../types'; import { getAttachmentObjectKey, - getBlobObject, getBlobStorageKind, - getSendFileObjectKey, } from './blob-store'; type SqlRow = Record; @@ -13,16 +11,12 @@ const BACKUP_FORMAT_VERSION = 1; const BACKUP_APP_VERSION = '1.3.0'; // Worker-side backup export must stay well below Cloudflare CPU limits. // Prefer store-only ZIP entries over heavier compression to keep exports reliable. -const BACKUP_TEXT_COMPRESSION_LEVEL = 1; -const BACKUP_BINARY_COMPRESSION_LEVEL = 1; -const BACKUP_R2_BLOB_READ_CONCURRENCY = 4; -const BACKUP_KV_BLOB_READ_CONCURRENCY = 4; -const BACKUP_R2_BLOB_READ_CHUNK_SIZE = 32; -const BACKUP_KV_BLOB_READ_CHUNK_SIZE = 162; -const MAX_BACKUP_ARCHIVE_BYTES = 32 * 1024 * 1024; -const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 800; +const BACKUP_TEXT_COMPRESSION_LEVEL = 0; +const BACKUP_JSON_INDENT = 2; +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 = 8 * 1024 * 1024; +const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024; export interface BackupManifest { formatVersion: 1; @@ -32,14 +26,20 @@ export interface BackupManifest { tableCounts: Record; includes: { attachments: boolean; - sendFiles: boolean; }; blobSummary: { attachmentFiles: number; - sendFiles: number; totalBytes: number; largestObjectBytes: number; }; + attachmentBlobs?: BackupManifestAttachmentBlob[]; +} + +export interface BackupManifestAttachmentBlob { + cipherId: string; + attachmentId: string; + blobName: string; + sizeBytes: number; } export interface BackupPayload { @@ -51,7 +51,6 @@ export interface BackupPayload { folders: SqlRow[]; ciphers: SqlRow[]; attachments: SqlRow[]; - sends: SqlRow[]; }; } @@ -61,27 +60,8 @@ export interface BackupArchiveBundle { manifest: BackupManifest; } -interface BackupBlobTask { - archivePath: string; - objectKey: string; - kind: 'attachment' | 'send-file'; - missingMessage: string; -} - -interface BackupBlobTaskResult { - archivePath: string; - bytes: Uint8Array; - kind: BackupBlobTask['kind']; -} - -export function parseSendFileId(data: string | null): string | null { - if (!data) return null; - try { - const parsed = JSON.parse(data) as Record; - return typeof parsed.id === 'string' && parsed.id.trim() ? parsed.id.trim() : null; - } catch { - return null; - } +export interface BuildBackupArchiveOptions { + includeAttachments?: boolean; } async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise { @@ -89,95 +69,6 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro return (result.results || []).map((row) => ({ ...row })); } -async function streamToBytes(stream: ReadableStream | null): Promise { - if (!stream) return new Uint8Array(); - const buffer = await new Response(stream).arrayBuffer(); - return new Uint8Array(buffer); -} - -function getBackupBlobReadConcurrency(env: Env): number { - return getBlobStorageKind(env) === 'kv' ? BACKUP_KV_BLOB_READ_CONCURRENCY : BACKUP_R2_BLOB_READ_CONCURRENCY; -} - -function getBackupBlobReadChunkSize(env: Env): number { - return getBlobStorageKind(env) === 'kv' ? BACKUP_KV_BLOB_READ_CHUNK_SIZE : BACKUP_R2_BLOB_READ_CHUNK_SIZE; -} - -async function mapWithConcurrency( - items: readonly T[], - concurrency: number, - worker: (item: T, index: number) => Promise -): Promise { - if (!items.length) return []; - - const results = new Array(items.length); - let nextIndex = 0; - - const runWorker = async (): Promise => { - while (true) { - const currentIndex = nextIndex; - nextIndex += 1; - if (currentIndex >= items.length) return; - results[currentIndex] = await worker(items[currentIndex], currentIndex); - } - }; - - const workerCount = Math.max(1, Math.min(concurrency, items.length)); - await Promise.all(Array.from({ length: workerCount }, () => runWorker())); - return results; -} - -async function loadBackupBlobFiles(env: Env, tasks: BackupBlobTask[]): Promise<{ - files: Record; - attachmentFiles: number; - sendFiles: number; - totalBytes: number; - largestObjectBytes: number; -}> { - const files: Record = {}; - let attachmentFiles = 0; - let sendFiles = 0; - let totalBytes = 0; - let largestObjectBytes = 0; - - const concurrency = getBackupBlobReadConcurrency(env); - const chunkSize = getBackupBlobReadChunkSize(env); - - for (let offset = 0; offset < tasks.length; offset += chunkSize) { - const chunk = tasks.slice(offset, offset + chunkSize); - const loaded = await mapWithConcurrency(chunk, concurrency, async (task) => { - const object = await getBlobObject(env, task.objectKey); - if (!object) { - throw new Error(task.missingMessage); - } - return { - archivePath: task.archivePath, - bytes: await streamToBytes(object.body), - kind: task.kind, - } satisfies BackupBlobTaskResult; - }); - - for (const item of loaded) { - files[item.archivePath] = item.bytes; - totalBytes += item.bytes.byteLength; - largestObjectBytes = Math.max(largestObjectBytes, item.bytes.byteLength); - if (item.kind === 'attachment') { - attachmentFiles += 1; - } else { - sendFiles += 1; - } - } - } - - return { - files, - attachmentFiles, - sendFiles, - totalBytes, - largestObjectBytes, - }; -} - function buildBackupFileName(date: Date = new Date()): string { const parts = [ date.getUTCFullYear().toString().padStart(4, '0'), @@ -204,12 +95,6 @@ function getRequiredZipEntries(db: BackupPayload['db']): string[] { if (!cipherId || !attachmentId) continue; entries.push(`attachments/${cipherId}/${attachmentId}.bin`); } - 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; - entries.push(`send-files/${sendId}/${fileId}.bin`); - } return entries; } @@ -223,13 +108,19 @@ function ensureRowArray(value: unknown, table: string): SqlRow[] { function createZipEntries(files: Record): Record { const entries: Record = {}; for (const [path, bytes] of Object.entries(files)) { - const isBinaryBlob = path.endsWith('.bin'); - entries[path] = [bytes, { level: isBinaryBlob ? BACKUP_BINARY_COMPRESSION_LEVEL : BACKUP_TEXT_COMPRESSION_LEVEL }]; + entries[path] = [bytes, { level: BACKUP_TEXT_COMPRESSION_LEVEL }]; } return entries; } -export function parseBackupArchive(bytes: Uint8Array): { payload: BackupPayload; files: Record } { +export interface ParseBackupArchiveOptions { + allowExternalAttachmentBlobs?: boolean; +} + +export function parseBackupArchive( + bytes: Uint8Array, + options: ParseBackupArchiveOptions = {} +): { payload: BackupPayload; files: Record } { validateArchiveSize(bytes); let zipped: Record; try { @@ -278,7 +169,12 @@ export function parseBackupArchive(bytes: Uint8Array): { payload: BackupPayload; throw new Error('Backup archive database payload is invalid'); } - const requiredEntries = getRequiredZipEntries(db); + const externalAttachmentKeys = new Set( + options.allowExternalAttachmentBlobs + ? (manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) + : [] + ); + const requiredEntries = getRequiredZipEntries(db).filter((entry) => !externalAttachmentKeys.has(entry)); for (const entry of requiredEntries) { if (!zipped[entry]) { throw new Error(`Backup archive is missing required file: ${entry}`); @@ -291,14 +187,26 @@ export function parseBackupArchive(bytes: Uint8Array): { payload: BackupPayload; }; } -export function validateBackupPayloadContents(payload: BackupPayload, files: Record): void { +export interface ValidateBackupPayloadOptions { + allowExternalAttachmentBlobs?: boolean; +} + +export function validateBackupPayloadContents( + payload: BackupPayload, + files: Record, + options: ValidateBackupPayloadOptions = {} +): void { const configRows = ensureRowArray(payload.db.config, 'config'); const userRows = ensureRowArray(payload.db.users, 'users'); const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions'); const folderRows = ensureRowArray(payload.db.folders, 'folders'); const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers'); const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments'); - const sendRows = ensureRowArray(payload.db.sends, 'sends'); + const externalAttachmentKeys = new Set( + options.allowExternalAttachmentBlobs + ? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`) + : [] + ); const userIds = new Set(); for (const row of userRows) { @@ -349,36 +257,39 @@ export function validateBackupPayloadContents(payload: BackupPayload, files: Rec if (!id || !cipherId || !cipherIds.has(cipherId)) { throw new Error('Backup archive contains an invalid attachment row'); } - if (!files[`attachments/${cipherId}/${id}.bin`]) { + const attachmentPath = `attachments/${cipherId}/${id}.bin`; + if (!files[attachmentPath] && !externalAttachmentKeys.has(attachmentPath)) { throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`); } } - - const sendIds = new Set(); - for (const row of sendRows) { - const id = String(row.id || '').trim(); - const userId = String(row.user_id || '').trim(); - if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid send row'); - if (sendIds.has(id)) throw new Error(`Backup archive contains duplicate send id: ${id}`); - sendIds.add(id); - const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null); - if (fileId && !files[`send-files/${id}/${fileId}.bin`]) { - throw new Error(`Backup archive is missing required file: send-files/${id}/${fileId}.bin`); - } - } } -export async function buildBackupArchive(env: Env, date: Date = new Date()): Promise { +export async function buildBackupArchive( + env: Env, + date: Date = new Date(), + options: BuildBackupArchiveOptions = {} +): Promise { const encoder = new TextEncoder(); - const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows, sendRows] = await Promise.all([ + const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([ queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'), queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'), queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'), queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'), - queryRows(env.DB, 'SELECT 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 FROM sends ORDER BY created_at ASC'), ]); + const includeAttachments = options.includeAttachments !== false; + const exportedAttachmentRows = includeAttachments ? attachmentRows : []; + const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => { + const cipherId = String(row.cipher_id || '').trim(); + const attachmentId = String(row.id || '').trim(); + return { + cipherId, + attachmentId, + blobName: getAttachmentObjectKey(cipherId, attachmentId), + sizeBytes: Number(row.size || 0) || 0, + }; + }); const manifestBase = { formatVersion: BACKUP_FORMAT_VERSION, @@ -391,76 +302,34 @@ export async function buildBackupArchive(env: Env, date: Date = new Date()): Pro user_revisions: revisionRows.length, folders: folderRows.length, ciphers: cipherRows.length, - attachments: attachmentRows.length, - sends: sendRows.length, + attachments: exportedAttachmentRows.length, }, includes: { - attachments: true, - sendFiles: true, + attachments: includeAttachments, }, blobSummary: { - attachmentFiles: 0, - sendFiles: 0, - totalBytes: 0, - largestObjectBytes: 0, + attachmentFiles: attachmentBlobs.length, + totalBytes: attachmentBlobs.reduce((sum, item) => sum + item.sizeBytes, 0), + largestObjectBytes: attachmentBlobs.reduce((max, item) => Math.max(max, item.sizeBytes), 0), }, + attachmentBlobs: includeAttachments ? attachmentBlobs : [], } satisfies BackupManifest; const files: Record = { - 'manifest.json': encoder.encode(JSON.stringify(manifestBase)), + 'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, BACKUP_JSON_INDENT)), 'db.json': encoder.encode(JSON.stringify({ config: configRows, users: userRows, user_revisions: revisionRows, folders: folderRows, ciphers: cipherRows, - attachments: attachmentRows, - sends: sendRows, - })), + attachments: exportedAttachmentRows, + }, null, BACKUP_JSON_INDENT)), }; - const blobTasks: BackupBlobTask[] = []; - for (const row of attachmentRows) { - const cipherId = String(row.cipher_id || '').trim(); - const attachmentId = String(row.id || '').trim(); - if (!cipherId || !attachmentId) continue; - blobTasks.push({ - archivePath: `attachments/${cipherId}/${attachmentId}.bin`, - objectKey: getAttachmentObjectKey(cipherId, attachmentId), - kind: 'attachment', - missingMessage: `Attachment blob missing for ${cipherId}/${attachmentId}`, - }); - } - - 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; - blobTasks.push({ - archivePath: `send-files/${sendId}/${fileId}.bin`, - objectKey: getSendFileObjectKey(sendId, fileId), - kind: 'send-file', - missingMessage: `Send file blob missing for ${sendId}/${fileId}`, - }); - } - - const blobFiles = await loadBackupBlobFiles(env, blobTasks); - Object.assign(files, blobFiles.files); - - const manifest: BackupManifest = { - ...manifestBase, - blobSummary: { - attachmentFiles: blobFiles.attachmentFiles, - sendFiles: blobFiles.sendFiles, - totalBytes: blobFiles.totalBytes, - largestObjectBytes: blobFiles.largestObjectBytes, - }, - }; - files['manifest.json'] = encoder.encode(JSON.stringify(manifest)); - return { bytes: zipSync(createZipEntries(files)), fileName: buildBackupFileName(date), - manifest, + manifest: manifestBase, }; } diff --git a/src/services/backup-config.ts b/src/services/backup-config.ts index 97346aa..a9dfcb9 100644 --- a/src/services/backup-config.ts +++ b/src/services/backup-config.ts @@ -264,6 +264,9 @@ function normalizeDestinationRecord( id, name, type, + includeAttachments: typeof input.includeAttachments === 'boolean' + ? input.includeAttachments + : previous?.includeAttachments ?? false, destination, schedule, runtime, @@ -280,6 +283,7 @@ function parseLegacyBackupSettings(rawValue: Record, fallbackTi id: createBackupRandomId(), name: defaultDestinationName(destinationType, 1), type: destinationType, + includeAttachments: false, destination: normalizeDestination(destinationType, rawValue.destination), schedule: { enabled: !!rawValue.enabled, diff --git a/src/services/backup-import.ts b/src/services/backup-import.ts index 9e2c70e..38d1b1e 100644 --- a/src/services/backup-import.ts +++ b/src/services/backup-import.ts @@ -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; @@ -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> { 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 { - const keys = new Set(); - 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; + loadAttachment(blobName: string): Promise; +} + function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record): 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(); - const oversizedSendPaths = new Set(); 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): Promise<{ attachments: number; sendFiles: number }> { - let attachmentCount = 0; - let sendFileCount = 0; +async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record): Promise { + 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 { + return new Map( + (manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item]) + ); +} + +async function prepareRemoteAttachmentPayload( + env: Env, + payload: BackupPayload, + files: Record, + source: RemoteAttachmentSource +): Promise { + 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 { + 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, + 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 { + 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(); + 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, }, }; } diff --git a/src/services/backup-uploader.ts b/src/services/backup-uploader.ts index d920211..1b80c25 100644 --- a/src/services/backup-uploader.ts +++ b/src/services/backup-uploader.ts @@ -33,6 +33,10 @@ export interface RemoteBackupFile { bytes: Uint8Array; } +export interface RemoteBackupFilePutOptions { + contentType?: string; +} + function isBackupArchiveName(name: string): boolean { return /\.zip$/i.test(String(name || '').trim()); } @@ -83,6 +87,9 @@ function parentPath(path: string): string | null { function sortRemoteItems(items: RemoteBackupItem[]): RemoteBackupItem[] { return items.slice().sort((a, b) => { + const aIsAttachmentsDir = a.isDirectory && a.name === 'attachments'; + const bIsAttachmentsDir = b.isDirectory && b.name === 'attachments'; + if (aIsAttachmentsDir !== bIsAttachmentsDir) return aIsAttachmentsDir ? -1 : 1; if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; return a.name.localeCompare(b.name, 'en'); }); @@ -243,9 +250,14 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut } } -async function uploadToWebDav(config: WebDavBackupDestination, archive: Uint8Array, fileName: string): Promise { +async function putToWebDav( + config: WebDavBackupDestination, + relativePath: string, + bytes: Uint8Array, + options: RemoteBackupFilePutOptions = {} +): Promise { const authHeader = toBasicAuthHeader(config.username, config.password); - const remoteFilePath = buildJoinedPath(config.remotePath, fileName); + const remoteFilePath = buildJoinedPath(config.remotePath, relativePath); const remoteDir = parentPath(remoteFilePath); if (remoteDir) { @@ -256,19 +268,22 @@ async function uploadToWebDav(config: WebDavBackupDestination, archive: Uint8Arr method: 'PUT', headers: { Authorization: authHeader, - 'Content-Type': 'application/zip', - 'Content-Length': String(archive.byteLength), + 'Content-Type': options.contentType || 'application/octet-stream', + 'Content-Length': String(bytes.byteLength), }, - body: archive, + body: bytes, }); if (!response.ok) { throw new Error(`WebDAV upload failed: ${response.status}`); } +} +async function uploadToWebDav(config: WebDavBackupDestination, archive: Uint8Array, fileName: string): Promise { + await putToWebDav(config, fileName, archive, { contentType: 'application/zip' }); return { provider: 'webdav', - remotePath: remoteFilePath, + remotePath: buildJoinedPath(config.remotePath, fileName), }; } @@ -386,6 +401,22 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s } } +async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise { + const authHeader = toBasicAuthHeader(config.username, config.password); + const remotePath = webDavFullPath(config, relativePath); + const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { + method: 'HEAD', + headers: { + Authorization: authHeader, + }, + }); + if (response.status === 404) return false; + if (!response.ok) { + throw new Error(`WebDAV existence check failed: ${response.status}`); + } + return true; +} + function e3BucketBaseUrl(config: E3BackupDestination): URL { return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`); } @@ -396,9 +427,10 @@ function normalizeE3ObjectKey(config: E3BackupDestination, relativePath: string) async function signedE3Request( config: E3BackupDestination, - method: 'GET' | 'PUT' | 'DELETE', + method: 'GET' | 'PUT' | 'DELETE' | 'HEAD', url: URL, - body?: Uint8Array + body?: Uint8Array, + contentType?: string ): Promise { const payloadHashHex = await sha256Hex(body || new Uint8Array()); const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''); @@ -407,7 +439,7 @@ async function signedE3Request( 'x-amz-content-sha256': payloadHashHex, 'x-amz-date': amzDate, }; - if (method === 'PUT') headers['content-type'] = 'application/zip'; + if (method === 'PUT') headers['content-type'] = contentType || 'application/octet-stream'; const authorization = await buildAwsV4Authorization( method, @@ -431,18 +463,26 @@ async function signedE3Request( }); } -async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise { - const objectKey = normalizeE3ObjectKey(config, fileName); +async function putToE3( + config: E3BackupDestination, + relativePath: string, + bytes: Uint8Array, + options: RemoteBackupFilePutOptions = {} +): Promise { + const objectKey = normalizeE3ObjectKey(config, relativePath); const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); - const response = await signedE3Request(config, 'PUT', url, archive); + const response = await signedE3Request(config, 'PUT', url, bytes, options.contentType); if (!response.ok) { throw new Error(`E3 upload failed: ${response.status}`); } +} +async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise { + await putToE3(config, fileName, archive, { contentType: 'application/zip' }); return { provider: 'e3', - remotePath: objectKey, + remotePath: normalizeE3ObjectKey(config, fileName), }; } @@ -546,13 +586,26 @@ async function deleteFromE3(config: E3BackupDestination, relativePath: string): } } +async function existsInE3(config: E3BackupDestination, relativePath: string): Promise { + const objectKey = normalizeE3ObjectKey(config, relativePath); + const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`); + const response = await signedE3Request(config, 'HEAD', url); + if (response.status === 404) return false; + if (!response.ok) { + throw new Error(`E3 existence check failed: ${response.status}`); + } + return true; +} + interface ConfiguredDestinationAdapter { provider: 'webdav' | 'e3'; config: WebDavBackupDestination | E3BackupDestination; upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise; + putFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise; list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise; download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise; deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise; + exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise; } function resolveConfiguredDestinationAdapter( @@ -565,9 +618,11 @@ function resolveConfiguredDestinationAdapter( provider: 'webdav', config: destination.destination as WebDavBackupDestination, upload: (config, archive, fileName) => uploadToWebDav(config as WebDavBackupDestination, archive, fileName), + putFile: (config, relativePath, bytes, options) => putToWebDav(config as WebDavBackupDestination, relativePath, bytes, options), list: (config, relativePath) => listWebDavEntries(config as WebDavBackupDestination, relativePath), download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath), + exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath), }; } if (destination.type === 'e3') { @@ -575,9 +630,11 @@ function resolveConfiguredDestinationAdapter( provider: 'e3', config: destination.destination as E3BackupDestination, upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName), + putFile: (config, relativePath, bytes, options) => putToE3(config as E3BackupDestination, relativePath, bytes, options), list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath), download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath), + exists: (config, relativePath) => existsInE3(config as E3BackupDestination, relativePath), }; } @@ -609,6 +666,23 @@ export async function deleteRemoteBackupFile(destination: BackupDestinationRecor await adapter.deleteFile(adapter.config, normalized); } +export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise { + const normalized = normalizeRelativePath(relativePath); + const adapter = resolveConfiguredDestinationAdapter(destination); + return adapter.exists(adapter.config, normalized); +} + +export async function uploadRemoteBackupFile( + destination: BackupDestinationRecord, + relativePath: string, + bytes: Uint8Array, + options: RemoteBackupFilePutOptions = {} +): Promise { + const normalized = normalizeRelativePath(relativePath); + const adapter = resolveConfiguredDestinationAdapter(destination); + await adapter.putFile(adapter.config, normalized, bytes, options); +} + function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number { if (preferredFileName) { const aPreferred = a.name === preferredFileName ? 1 : 0; diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 113abd9..273e973 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -92,7 +92,7 @@ export interface AppMainRoutesProps { onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise; onDeleteUser: (userId: string) => Promise; onRevokeInvite: (code: string) => Promise; - onExportBackup: () => Promise; + onExportBackup: (includeAttachments?: boolean) => Promise; onImportBackup: (file: File, replaceExisting?: boolean) => Promise; onLoadBackupSettings: () => Promise; onSaveBackupSettings: (settings: AdminBackupSettings) => Promise; diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx index 58d8d41..62ad011 100644 --- a/webapp/src/components/BackupCenterPage.tsx +++ b/webapp/src/components/BackupCenterPage.tsx @@ -30,7 +30,7 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar interface BackupCenterPageProps { currentUserId: string | null; - onExport: () => Promise; + onExport: (includeAttachments?: boolean) => Promise; onImport: (file: File, replaceExisting?: boolean) => Promise; onLoadSettings: () => Promise; onSaveSettings: (settings: AdminBackupSettings) => Promise; @@ -44,11 +44,10 @@ interface BackupCenterPageProps { function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null { const skipped = result.skipped; - if (!skipped || (!skipped.attachments && !skipped.sendFiles)) return null; + if (!skipped || !skipped.attachments) 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), }); } @@ -59,6 +58,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { const [selectedFile, setSelectedFile] = useState(null); const [exporting, setExporting] = useState(false); + const [exportIncludeAttachments, setExportIncludeAttachments] = useState(false); const [importing, setImporting] = useState(false); const [loadingSettings, setLoadingSettings] = useState(true); const [savingSettings, setSavingSettings] = useState(false); @@ -67,6 +67,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { const [downloadingRemotePath, setDownloadingRemotePath] = useState(''); const [downloadingRemotePercent, setDownloadingRemotePercent] = useState(null); const [restoringRemotePath, setRestoringRemotePath] = useState(''); + const [remoteRestoreStatusText, setRemoteRestoreStatusText] = useState(''); const [deletingRemotePath, setDeletingRemotePath] = useState(''); const [localError, setLocalError] = useState(''); const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false); @@ -276,7 +277,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { setLocalError(''); setExporting(true); try { - await props.onExport(); + await props.onExport(exportIncludeAttachments); props.onNotify('success', t('txt_backup_export_success')); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); @@ -417,11 +418,13 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { async function runRemoteRestore(path: string, replaceExisting: boolean) { if (!savedSelectedDestination) return; setRestoringRemotePath(path); + setRemoteRestoreStatusText(replaceExisting ? t('txt_backup_remote_restore_stage_replace') : t('txt_backup_remote_restore_stage_prepare')); setLocalError(''); try { const result = await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); setConfirmRemoteReplaceOpen(false); setPendingRemoteRestorePath(''); + setRemoteRestoreStatusText(''); props.onNotify('success', t('txt_backup_restore_success_relogin')); const skippedMessage = buildSkippedImportMessage(result); if (skippedMessage) props.onNotify('warning', skippedMessage); @@ -429,9 +432,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { if (!replaceExisting && isReplaceRequiredError(error)) { setPendingRemoteRestorePath(path); setConfirmRemoteReplaceOpen(true); + setRemoteRestoreStatusText(''); return; } const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed'); + setRemoteRestoreStatusText(''); setLocalError(message); props.onNotify('error', message); } finally { @@ -459,11 +464,13 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { disableWhileBusy={disableWhileBusy} exporting={exporting} importing={importing} + exportIncludeAttachments={exportIncludeAttachments} selectedProviderId={selectedProviderId} recommendedWebDavProviders={recommendedWebDavProviders} recommendedS3Providers={recommendedS3Providers} onExport={() => void handleExport()} onImport={() => fileInputRef.current?.click()} + onExportIncludeAttachmentsChange={setExportIncludeAttachments} onSelectProvider={(providerId) => setSelectedProviderId(providerId)} /> @@ -526,6 +533,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { /> {localError ?
{localError}
: null} + {!localError && remoteRestoreStatusText ?
{remoteRestoreStatusText}
: null} void runLocalRestore(true)} onCancel={() => { + if (importing) return; setConfirmReplaceOpen(false); resetSelectedFile(); }} @@ -559,11 +570,14 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { open={confirmRemoteReplaceOpen} title={t('txt_backup_replace_confirm_title')} message={t('txt_backup_replace_confirm_message')} - confirmText={t('txt_backup_clear_and_restore')} + confirmText={restoringRemotePath ? t('txt_backup_restoring') : t('txt_backup_clear_and_restore')} cancelText={t('txt_cancel')} + confirmDisabled={!!restoringRemotePath} + cancelDisabled={!!restoringRemotePath} danger onConfirm={() => void runRemoteRestore(pendingRemoteRestorePath, true)} onCancel={() => { + if (restoringRemotePath) return; setConfirmRemoteReplaceOpen(false); setPendingRemoteRestorePath(''); }} diff --git a/webapp/src/components/ConfirmDialog.tsx b/webapp/src/components/ConfirmDialog.tsx index 099960b..da12b60 100644 --- a/webapp/src/components/ConfirmDialog.tsx +++ b/webapp/src/components/ConfirmDialog.tsx @@ -11,6 +11,8 @@ interface ConfirmDialogProps { cancelText?: string; danger?: boolean; hideCancel?: boolean; + confirmDisabled?: boolean; + cancelDisabled?: boolean; onConfirm: () => void; onCancel: () => void; children?: ComponentChildren; @@ -25,6 +27,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) { className="dialog-card" onSubmit={(e) => { e.preventDefault(); + if (props.confirmDisabled) return; props.onConfirm(); }} > @@ -34,12 +37,21 @@ export default function ConfirmDialog(props: ConfirmDialogProps) { {!props.hideCancel && ( - diff --git a/webapp/src/components/backup-center/BackupDestinationDetail.tsx b/webapp/src/components/backup-center/BackupDestinationDetail.tsx index 7dae7d2..897ced8 100644 --- a/webapp/src/components/backup-center/BackupDestinationDetail.tsx +++ b/webapp/src/components/backup-center/BackupDestinationDetail.tsx @@ -9,6 +9,7 @@ import { COMMON_TIME_ZONES, WEEKDAY_OPTIONS, getDestinationTypeLabel } from '@/l import type { RecommendedProvider } from '@/lib/backup-recommendations'; import { RemoteBackupBrowser } from './RemoteBackupBrowser'; import { t } from '@/lib/i18n'; +import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField'; interface BackupDestinationDetailProps { selectedRecommendedProvider: RecommendedProvider | null; @@ -287,6 +288,15 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) { + props.onUpdateDestination((destination) => ({ + ...destination, + includeAttachments: checked, + }))} + /> + {props.selectedDestination.schedule.frequency === 'weekly' ? (
))} diff --git a/webapp/src/hooks/useBackupActions.ts b/webapp/src/hooks/useBackupActions.ts index c030ab1..a62c523 100644 --- a/webapp/src/hooks/useBackupActions.ts +++ b/webapp/src/hooks/useBackupActions.ts @@ -1,8 +1,8 @@ import { useMemo } from 'preact/hooks'; import { + buildCompleteAdminBackupExport, deleteRemoteBackup, downloadRemoteBackup, - exportAdminBackup, getAdminBackupSettings, importAdminBackup, listRemoteBackups, @@ -24,8 +24,8 @@ export default function useBackupActions(options: UseBackupActionsOptions) { return useMemo( () => ({ - async exportBackup() { - const payload = await exportAdminBackup(authedFetch); + async exportBackup(includeAttachments: boolean = false) { + const payload = await buildCompleteAdminBackupExport(authedFetch, includeAttachments); downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); }, diff --git a/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts index b6b5e4c..6f76baa 100644 --- a/webapp/src/lib/api/backup.ts +++ b/webapp/src/lib/api/backup.ts @@ -16,6 +16,7 @@ import { type AuthedFetch, } from './shared'; import { readResponseBytesWithProgress } from '../download'; +import { unzipSync, zipSync } from 'fflate'; export type { BackupDestinationConfig, @@ -81,13 +82,11 @@ export interface AdminBackupImportCounts { folders: number; ciphers: number; attachments: number; - sends: number; attachmentFiles: number; - sendFiles: number; } export interface AdminBackupImportSkippedItem { - kind: 'attachment' | 'send-file'; + kind: 'attachment'; path: string; sizeBytes: number; } @@ -95,7 +94,6 @@ export interface AdminBackupImportSkippedItem { export interface AdminBackupImportSkipped { reason: string | null; attachments: number; - sendFiles: number; items: AdminBackupImportSkippedItem[]; } @@ -111,8 +109,25 @@ export interface AdminBackupExportPayload { bytes: Uint8Array; } -export async function exportAdminBackup(authedFetch: AuthedFetch): Promise { - const resp = await authedFetch('/api/admin/backup/export', { method: 'POST' }); +interface BackupExportManifestAttachmentBlob { + cipherId: string; + attachmentId: string; + blobName: string; +} + +interface BackupExportManifest { + attachmentBlobs?: BackupExportManifestAttachmentBlob[]; +} + +export async function exportAdminBackup( + authedFetch: AuthedFetch, + includeAttachments: boolean = false +): Promise { + const resp = await authedFetch('/api/admin/backup/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ includeAttachments }), + }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed'))); const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip'; @@ -121,6 +136,48 @@ export async function exportAdminBackup(authedFetch: AuthedFetch): Promise { + const params = new URLSearchParams(); + params.set('blobName', blobName); + const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed'))); + return new Uint8Array(await resp.arrayBuffer()); +} + +export async function buildCompleteAdminBackupExport( + authedFetch: AuthedFetch, + includeAttachments: boolean = false +): Promise { + const payload = await exportAdminBackup(authedFetch, includeAttachments); + if (!includeAttachments) return payload; + + const zipped = unzipSync(payload.bytes); + const manifestBytes = zipped['manifest.json']; + if (!manifestBytes) { + throw new Error(t('txt_backup_export_failed')); + } + + let manifest: BackupExportManifest; + try { + manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as BackupExportManifest; + } catch { + throw new Error(t('txt_backup_export_failed')); + } + + for (const attachment of manifest.attachmentBlobs || []) { + const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName); + zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes; + } + + return { + ...payload, + bytes: zipSync(zipped, { level: 0 }), + }; +} + export async function getAdminBackupSettings(authedFetch: AuthedFetch): Promise { const resp = await authedFetch('/api/admin/backup/settings', { method: 'GET' }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_load_failed'))); diff --git a/webapp/src/lib/backup-center.ts b/webapp/src/lib/backup-center.ts index dac0a81..99c253f 100644 --- a/webapp/src/lib/backup-center.ts +++ b/webapp/src/lib/backup-center.ts @@ -100,9 +100,12 @@ function getRemoteItemSortTime(item: RemoteBackupItem): number { } export function compareRemoteItems(a: RemoteBackupItem, b: RemoteBackupItem): number { + const aIsAttachmentsDir = a.isDirectory && a.name === 'attachments'; + const bIsAttachmentsDir = b.isDirectory && b.name === 'attachments'; + if (aIsAttachmentsDir !== bIsAttachmentsDir) return aIsAttachmentsDir ? -1 : 1; + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; const timeDiff = getRemoteItemSortTime(b) - getRemoteItemSortTime(a); if (timeDiff !== 0) return timeDiff; - if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; return b.name.localeCompare(a.name, 'en'); } diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index cc1c7b0..4beee77 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -17,6 +17,7 @@ const messages: Record> = { import_export_under_construction: "Under construction.", txt_backup_export: "Export Backup", txt_backup_import: "Restore", + txt_backup_include_attachments: "Include attachments", txt_backup_export_description: "Download a full instance backup ZIP for manual safekeeping.", txt_backup_import_description: "Upload a previously exported backup ZIP and restore it into this instance.", txt_backup_exporting: "Exporting...", @@ -25,7 +26,7 @@ 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_summary: "{reason}. Skipped {attachments} attachment(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", @@ -109,6 +110,8 @@ const messages: Record> = { txt_backup_remote_download: "Download", txt_backup_remote_downloading: "Downloading...", txt_backup_remote_restore: "Restore", + txt_backup_remote_restore_stage_prepare: "Preparing remote backup restore...", + txt_backup_remote_restore_stage_replace: "Clearing current data and restoring remote backup...", txt_backup_remote_loading: "Loading remote backups...", txt_backup_remote_cached_empty: "Click Refresh to load this destination.", txt_backup_remote_empty: "No backup files found in this folder.", @@ -154,6 +157,9 @@ const messages: Record> = { txt_backup_retention_count: "Keep", txt_backup_retention_count_suffix: "items", txt_backup_retention_count_hint: "Leave empty to keep all backup files. New destinations default to 30.", + txt_backup_destination_include_attachments: "Include attachments", + txt_backup_include_attachments_help_button: "Attachment backup help", + txt_backup_include_attachments_help: "Attachments are stored incrementally in the remote attachments folder, so later backups usually only upload new files. Deleting an attachment locally does not remove earlier remote copies. During restore, NodeWarden reads the required files from the attachments folder and skips any attachment that is no longer available.", txt_backup_enable_schedule: "Enable automatic daily backup", txt_backup_schedule_note: "The worker checks the schedule every 5 minutes and runs the backup as soon as the selected time window is reached.", txt_backup_schedule_disabled: "Disabled", @@ -629,6 +635,7 @@ const zhCNOverrides: Record = { import_export_under_construction: '正在搭建中', txt_backup_export: '导出备份', txt_backup_import: '还原', + txt_backup_include_attachments: '包含附件', txt_backup_export_description: '下载一个完整的实例备份 ZIP,手动保管即可。', txt_backup_import_description: '上传之前导出的备份 ZIP,并还原到当前实例。', txt_backup_exporting: '正在导出...', @@ -637,7 +644,7 @@ 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_summary: '{reason},已跳过 {attachments} 个附件', txt_backup_restore_skipped_reason_default: '部分文件无法还原', txt_backup_export_failed: '备份导出失败', txt_backup_import_failed: '备份还原失败', @@ -721,6 +728,8 @@ const zhCNOverrides: Record = { txt_backup_remote_download: '下载', txt_backup_remote_downloading: '下载中...', txt_backup_remote_restore: '还原', + txt_backup_remote_restore_stage_prepare: '正在读取远端备份并检查可恢复内容...', + txt_backup_remote_restore_stage_replace: '正在清空当前数据并还原远端备份,请稍候...', txt_backup_remote_loading: '正在读取远端备份...', txt_backup_remote_cached_empty: '点击“刷新”后读取', txt_backup_remote_empty: '这个目录下还没有备份文件', @@ -766,6 +775,9 @@ const zhCNOverrides: Record = { txt_backup_retention_count: '只保留', txt_backup_retention_count_suffix: '个', txt_backup_retention_count_hint: '留空表示不限,新建备份地点默认保留 30 个', + txt_backup_destination_include_attachments: '包含附件', + txt_backup_include_attachments_help_button: '附件备份说明', + txt_backup_include_attachments_help: '附件会以增量方式保存在远端的 attachments 文件夹中,后续备份通常只上传新增文件。你在本地删除附件时,已经备份到远端的旧文件不会自动删除。恢复时会按需从 attachments 文件夹读取对应附件,找不到的附件会自动跳过。', txt_backup_enable_schedule: '启用每日自动备份', txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划,到达你设定的时间窗口后会尽快执行备份。', txt_backup_schedule_disabled: '未启用', diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 8d0bf07..34a6239 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -1434,6 +1434,105 @@ input[type='file'].input::file-selector-button:hover { gap: 10px; } +.backup-option-field { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.backup-option-label { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 15px; + font-weight: 700; + color: #0f172a; + cursor: pointer; +} + +.backup-option-label input[type='checkbox'] { + width: 22px; + height: 22px; + margin: 0; + flex-shrink: 0; +} + +.backup-help-wrap { + position: relative; + display: inline-flex; + align-items: center; +} + +.backup-help-trigger { + width: 22px; + height: 22px; + border: 1px solid #bfd1f3; + border-radius: 999px; + padding: 0; + background: #eef4ff; + color: #1d4ed8; + font-size: 13px; + font-weight: 800; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; +} + +.backup-help-trigger:hover, +.backup-help-trigger:focus-visible { + border-color: #7ea4ef; + background: #e1ecff; + outline: none; +} + +.backup-help-bubble { + position: absolute; + left: 50%; + top: calc(100% + 10px); + z-index: 30; + width: min(320px, calc(100vw - 40px)); + padding: 10px 12px; + border: 1px solid #d5dce7; + border-radius: 12px; + background: #ffffff; + box-shadow: 0 16px 38px rgba(15, 23, 42, 0.14); + color: #475467; + font-size: 13px; + line-height: 1.55; + transform: translate(-50%, -4px); + opacity: 0; + visibility: hidden; + pointer-events: none; + transition: opacity 140ms ease, transform 140ms ease, visibility 140ms ease; +} + +.backup-help-bubble::before { + content: ''; + position: absolute; + left: 50%; + top: -6px; + width: 10px; + height: 10px; + background: #ffffff; + border-left: 1px solid #d5dce7; + border-top: 1px solid #d5dce7; + transform: translateX(-50%) rotate(45deg); +} + +.backup-help-wrap:hover .backup-help-bubble, +.backup-help-wrap:focus-within .backup-help-bubble, +.backup-help-wrap.open .backup-help-bubble { + opacity: 1; + visibility: visible; + pointer-events: auto; + transform: translate(-50%, 0); +} + .backup-manual-inline-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -1646,7 +1745,6 @@ input[type='file'].input::file-selector-button:hover { .backup-detail-schedule-grid { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; - margin-bottom: 12px; } .backup-retention-input { @@ -3051,4 +3149,24 @@ input[type='file'].input::file-selector-button:hover { .backup-name-row { grid-template-columns: 1fr; } + + .backup-option-field { + align-items: flex-start; + } + + .backup-help-bubble { + left: 0; + transform: translate(0, -4px); + } + + .backup-help-bubble::before { + left: 16px; + transform: rotate(45deg); + } + + .backup-help-wrap:hover .backup-help-bubble, + .backup-help-wrap:focus-within .backup-help-bubble, + .backup-help-wrap.open .backup-help-bubble { + transform: translate(0, 0); + } }