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
+2
View File
@@ -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(),
+90 -8
View File
@@ -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, {
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<Response> {
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<Response> {
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<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
+5
View File
@@ -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);
+74 -205
View File
@@ -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<string, string | number | null>;
@@ -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<string, number>;
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<string, unknown>;
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<SqlRow[]> {
@@ -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<Uint8Array> {
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<T, R>(
items: readonly T[],
concurrency: number,
worker: (item: T, index: number) => Promise<R>
): Promise<R[]> {
if (!items.length) return [];
const results = new Array<R>(items.length);
let nextIndex = 0;
const runWorker = async (): Promise<void> => {
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<string, Uint8Array>;
attachmentFiles: number;
sendFiles: number;
totalBytes: number;
largestObjectBytes: number;
}> {
const files: Record<string, Uint8Array> = {};
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<string, Uint8Array>): Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> {
const entries: Record<string, Uint8Array | [Uint8Array, { level: 0 | 1 | 6 }]> = {};
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<string, Uint8Array> } {
export interface ParseBackupArchiveOptions {
allowExternalAttachmentBlobs?: boolean;
}
export function parseBackupArchive(
bytes: Uint8Array,
options: ParseBackupArchiveOptions = {}
): { payload: BackupPayload; files: Record<string, Uint8Array> } {
validateArchiveSize(bytes);
let zipped: Record<string, Uint8Array>;
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<string>(
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<string, Uint8Array>): void {
export interface ValidateBackupPayloadOptions {
allowExternalAttachmentBlobs?: boolean;
}
export function validateBackupPayloadContents(
payload: BackupPayload,
files: Record<string, Uint8Array>,
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<string>(
options.allowExternalAttachmentBlobs
? (payload.manifest.attachmentBlobs || []).map((item) => `attachments/${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}.bin`)
: []
);
const userIds = new Set<string>();
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<string>();
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<BackupArchiveBundle> {
export async function buildBackupArchive(
env: Env,
date: Date = new Date(),
options: BuildBackupArchiveOptions = {}
): Promise<BackupArchiveBundle> {
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<string, Uint8Array> = {
'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,
};
}
+4
View File
@@ -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<string, unknown>, fallbackTi
id: createBackupRandomId(),
name: defaultDestinationName(destinationType, 1),
type: destinationType,
includeAttachments: false,
destination: normalizeDestination(destinationType, rawValue.destination),
schedule: {
enabled: !!rawValue.enabled,
+293 -90
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}`);
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',
});
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',
restoredAttachments.push(row);
} catch {
skippedItems.push({
kind: 'attachment',
path: key,
sizeBytes: bytes.byteLength,
});
sendFileCount += 1;
}
}
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,
},
};
}
+87 -13
View File
@@ -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<BackupUploadResult> {
async function putToWebDav(
config: WebDavBackupDestination,
relativePath: string,
bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
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<BackupUploadResult> {
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<boolean> {
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<Response> {
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<BackupUploadResult> {
const objectKey = normalizeE3ObjectKey(config, fileName);
async function putToE3(
config: E3BackupDestination,
relativePath: string,
bytes: Uint8Array,
options: RemoteBackupFilePutOptions = {}
): Promise<void> {
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<BackupUploadResult> {
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<boolean> {
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<BackupUploadResult>;
putFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions) => Promise<void>;
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
}
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<boolean> {
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<void> {
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;
+1 -1
View File
@@ -92,7 +92,7 @@ export interface AppMainRoutesProps {
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onRevokeInvite: (code: string) => Promise<void>;
onExportBackup: () => Promise<void>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
+20 -6
View File
@@ -30,7 +30,7 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
interface BackupCenterPageProps {
currentUserId: string | null;
onExport: () => Promise<void>;
onExport: (includeAttachments?: boolean) => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadSettings: () => Promise<AdminBackupSettings>;
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
@@ -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<File | null>(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<number | null>(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 ? <div className="local-error">{localError}</div> : null}
{!localError && remoteRestoreStatusText ? <div className="status-ok">{remoteRestoreStatusText}</div> : null}
<ConfirmDialog
open={confirmLocalRestoreOpen}
@@ -545,11 +553,14 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
open={confirmReplaceOpen}
title={t('txt_backup_replace_confirm_title')}
message={t('txt_backup_replace_confirm_message')}
confirmText={t('txt_backup_clear_and_restore')}
confirmText={importing ? t('txt_backup_restoring') : t('txt_backup_clear_and_restore')}
cancelText={t('txt_cancel')}
confirmDisabled={importing}
cancelDisabled={importing}
danger
onConfirm={() => 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('');
}}
+13 -1
View File
@@ -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) {
<button
type="submit"
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
disabled={props.confirmDisabled}
>
<Check size={14} className="btn-icon" />
{props.confirmText || t('txt_yes')}
</button>
{!props.hideCancel && (
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
<button
type="button"
className="btn btn-secondary dialog-btn"
disabled={props.cancelDisabled}
onClick={() => {
if (props.cancelDisabled) return;
props.onCancel();
}}
>
<X size={14} className="btn-icon" />
{props.cancelText || t('txt_no')}
</button>
@@ -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) {
</label>
</div>
<BackupIncludeAttachmentsField
checked={props.selectedDestination.includeAttachments}
disabled={props.loadingSettings || props.disableWhileBusy}
onChange={(checked) => props.onUpdateDestination((destination) => ({
...destination,
includeAttachments: checked,
}))}
/>
{props.selectedDestination.schedule.frequency === 'weekly' ? (
<div className="field-grid backup-detail-schedule-extra-grid">
<label className="field">
@@ -0,0 +1,57 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { t } from '@/lib/i18n';
interface BackupIncludeAttachmentsFieldProps {
checked: boolean;
disabled?: boolean;
showHelp?: boolean;
onChange: (checked: boolean) => void;
}
export function BackupIncludeAttachmentsField(props: BackupIncludeAttachmentsFieldProps) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!open) return;
function handlePointerDown(event: PointerEvent) {
if (!wrapRef.current?.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [open]);
return (
<div className="backup-option-field">
<label className="backup-option-label">
<input
type="checkbox"
checked={props.checked}
disabled={props.disabled}
onInput={(event) => props.onChange((event.currentTarget as HTMLInputElement).checked)}
/>
<span>{t('txt_backup_include_attachments')}</span>
</label>
{props.showHelp !== false ? (
<div ref={wrapRef} className={`backup-help-wrap ${open ? 'open' : ''}`}>
<button
type="button"
className="backup-help-trigger"
aria-label={t('txt_backup_include_attachments_help_button')}
aria-expanded={open ? 'true' : 'false'}
onClick={() => setOpen((current) => !current)}
>
?
</button>
<div className="backup-help-bubble" role="tooltip">
{t('txt_backup_include_attachments_help')}
</div>
</div>
) : null}
</div>
);
}
@@ -2,16 +2,19 @@ import { Download, FileUp } from 'lucide-preact';
import type { RecommendedProvider } from '@/lib/backup-recommendations';
import { hasLinkedStorages } from '@/lib/backup-recommendations';
import { t } from '@/lib/i18n';
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
interface BackupOperationsSidebarProps {
disableWhileBusy: boolean;
exporting: boolean;
importing: boolean;
exportIncludeAttachments: boolean;
selectedProviderId: string | null;
recommendedWebDavProviders: RecommendedProvider[];
recommendedS3Providers: RecommendedProvider[];
onExport: () => void;
onImport: () => void;
onExportIncludeAttachmentsChange: (checked: boolean) => void;
onSelectProvider: (providerId: string) => void;
}
@@ -26,6 +29,12 @@ export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
<Download size={14} className="btn-icon" />
{props.exporting ? t('txt_backup_exporting') : t('txt_backup_export')}
</button>
<BackupIncludeAttachmentsField
checked={props.exportIncludeAttachments}
disabled={props.disableWhileBusy}
showHelp={false}
onChange={props.onExportIncludeAttachmentsChange}
/>
<button type="button" className="btn btn-secondary" disabled={props.disableWhileBusy} onClick={props.onImport}>
<FileUp size={14} className="btn-icon" />
{props.importing ? t('txt_backup_restoring') : t('txt_backup_import')}
@@ -102,22 +102,22 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
<FolderOpen size={14} className="btn-icon" />
{t('txt_backup_remote_open')}
</button>
) : (
) : isZipCandidate(item) ? (
<>
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || props.downloadingRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onDownload(item.path)}>
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || props.downloadingRemotePath === item.path} onClick={() => props.onDownload(item.path)}>
<Download size={14} className="btn-icon" />
{getDownloadLabel(item.path)}
</button>
<button type="button" className="btn btn-primary small" disabled={props.disableWhileBusy || props.restoringRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onRestore(item.path)}>
<button type="button" className="btn btn-primary small" disabled={props.disableWhileBusy || props.restoringRemotePath === item.path} onClick={() => props.onRestore(item.path)}>
<RotateCcw size={14} className="btn-icon" />
{props.restoringRemotePath === item.path ? t('txt_backup_restoring') : t('txt_backup_remote_restore')}
</button>
<button type="button" className="btn btn-danger small" disabled={props.disableWhileBusy || props.deletingRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onPromptDelete(item.path)}>
<button type="button" className="btn btn-danger small" disabled={props.disableWhileBusy || props.deletingRemotePath === item.path} onClick={() => props.onPromptDelete(item.path)}>
<Trash2 size={14} className="btn-icon" />
{props.deletingRemotePath === item.path ? t('txt_backup_remote_deleting') : t('txt_delete')}
</button>
</>
)}
) : null}
</div>
</div>
))}
+3 -3
View File
@@ -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);
},
+63 -6
View File
@@ -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<AdminBackupExportPayload> {
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<AdminBackupExportPayload> {
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<Admin
return { fileName, mimeType, bytes };
}
export async function downloadAdminBackupAttachmentBlob(
authedFetch: AuthedFetch,
blobName: string
): Promise<Uint8Array> {
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<AdminBackupExportPayload> {
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<AdminBackupSettings> {
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')));
+4 -1
View File
@@ -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');
}
+14 -2
View File
@@ -17,6 +17,7 @@ const messages: Record<Locale, Record<string, string>> = {
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<Locale, Record<string, string>> = {
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<Locale, Record<string, string>> = {
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<Locale, Record<string, string>> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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<string, string> = {
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: '未启用',
+119 -1
View File
@@ -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);
}
}