mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: enhance backup and restore functionality with integrity checks and progress tracking
- Added support for backup integrity verification during export and restore processes. - Introduced progress dispatching for backup export and restore operations. - Implemented new API endpoints for inspecting remote backup integrity. - Enhanced user interface with progress indicators and warning dialogs for integrity issues. - Updated localization strings for new features and user feedback. - Refactored backup-related functions for better clarity and maintainability.
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
|
||||
const BACKUP_FORMAT_VERSION = 1;
|
||||
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||
// 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 = 0;
|
||||
@@ -60,16 +61,39 @@ export interface BackupArchiveBundle {
|
||||
manifest: BackupManifest;
|
||||
}
|
||||
|
||||
export interface BackupFileIntegrityCheckResult {
|
||||
hasChecksumPrefix: boolean;
|
||||
expectedPrefix: string | null;
|
||||
actualPrefix: string;
|
||||
matches: boolean;
|
||||
}
|
||||
|
||||
export interface BuildBackupArchiveOptions {
|
||||
includeAttachments?: boolean;
|
||||
progress?: BackupArchiveBuildProgressReporter;
|
||||
}
|
||||
|
||||
export interface BackupArchiveBuildProgressEvent {
|
||||
step: string;
|
||||
fileName?: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
includeAttachments: boolean;
|
||||
}
|
||||
|
||||
export type BackupArchiveBuildProgressReporter = (event: BackupArchiveBuildProgressEvent) => Promise<void>;
|
||||
|
||||
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||
return (result.results || []).map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
function buildBackupFileName(date: Date = new Date()): string {
|
||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function buildBackupFileName(date: Date = new Date(), checksumPrefix: string | null = null): string {
|
||||
const parts = [
|
||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||
@@ -78,7 +102,34 @@ function buildBackupFileName(date: Date = new Date()): string {
|
||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||
];
|
||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}.zip`;
|
||||
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}${suffix}.zip`;
|
||||
}
|
||||
|
||||
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||
const normalized = String(fileName || '').trim();
|
||||
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
|
||||
return match ? match[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
export async function inspectBackupArchiveFileNameChecksum(
|
||||
bytes: Uint8Array,
|
||||
fileName: string
|
||||
): Promise<BackupFileIntegrityCheckResult> {
|
||||
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
|
||||
const actualHash = await sha256Hex(bytes);
|
||||
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||
return {
|
||||
hasChecksumPrefix: !!expectedPrefix,
|
||||
expectedPrefix,
|
||||
actualPrefix,
|
||||
matches: !expectedPrefix || actualPrefix === expectedPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyBackupArchiveFileNameChecksum(bytes: Uint8Array, fileName: string): Promise<boolean> {
|
||||
const result = await inspectBackupArchiveFileNameChecksum(bytes, fileName);
|
||||
return result.matches;
|
||||
}
|
||||
|
||||
function validateArchiveSize(bytes: Uint8Array): void {
|
||||
@@ -269,16 +320,25 @@ export async function buildBackupArchive(
|
||||
date: Date = new Date(),
|
||||
options: BuildBackupArchiveOptions = {}
|
||||
): Promise<BackupArchiveBundle> {
|
||||
const includeAttachments = options.includeAttachments !== false;
|
||||
await options.progress?.({
|
||||
step: 'collect_data',
|
||||
fileName: '',
|
||||
stageTitle: 'txt_backup_archive_progress_collect_title',
|
||||
stageDetail: includeAttachments
|
||||
? 'txt_backup_archive_progress_collect_with_attachments_detail'
|
||||
: 'txt_backup_archive_progress_collect_detail',
|
||||
includeAttachments,
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
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 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, verify_devices, 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, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_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'),
|
||||
]);
|
||||
const includeAttachments = options.includeAttachments !== false;
|
||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
@@ -327,9 +387,29 @@ export async function buildBackupArchive(
|
||||
}, null, BACKUP_JSON_INDENT)),
|
||||
};
|
||||
|
||||
await options.progress?.({
|
||||
step: 'package_archive',
|
||||
fileName: '',
|
||||
stageTitle: 'txt_backup_archive_progress_package_title',
|
||||
stageDetail: includeAttachments
|
||||
? 'txt_backup_archive_progress_package_with_attachments_detail'
|
||||
: 'txt_backup_archive_progress_package_detail',
|
||||
includeAttachments,
|
||||
});
|
||||
const bytes = zipSync(createZipEntries(files));
|
||||
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||
const fileName = buildBackupFileName(date, fileHashPrefix);
|
||||
await options.progress?.({
|
||||
step: 'archive_ready',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_archive_progress_ready_title',
|
||||
stageDetail: 'txt_backup_archive_progress_ready_detail',
|
||||
includeAttachments,
|
||||
});
|
||||
|
||||
return {
|
||||
bytes: zipSync(createZipEntries(files)),
|
||||
fileName: buildBackupFileName(date),
|
||||
bytes,
|
||||
fileName,
|
||||
manifest: manifestBase,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Env } from '../types';
|
||||
import type { Env, User } from '../types';
|
||||
import { StorageService } from './storage';
|
||||
import {
|
||||
type BackupSettingsPortableEnvelope,
|
||||
@@ -422,20 +422,45 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
|
||||
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||
if (!raw) return;
|
||||
const users = await storage.getAllUsers();
|
||||
const normalized = await normalizeImportedBackupSettingsValue(raw, env, users, fallbackTimezone);
|
||||
if (normalized !== null) {
|
||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export async function normalizeImportedBackupSettingsValue(
|
||||
raw: string | null,
|
||||
env: Env,
|
||||
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[],
|
||||
fallbackTimezone: string = 'UTC'
|
||||
): Promise<string | null> {
|
||||
if (!raw) return null;
|
||||
const envelope = parseBackupSettingsEnvelope(raw);
|
||||
if (envelope) {
|
||||
try {
|
||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return;
|
||||
const hasPortableAdmins = users.some(
|
||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||
);
|
||||
if (!hasPortableAdmins) {
|
||||
return serializeBackupSettings(settings);
|
||||
}
|
||||
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||
} catch {
|
||||
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
||||
return;
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
const hasPortableAdmins = users.some(
|
||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||
);
|
||||
if (!hasPortableAdmins) {
|
||||
return serializeBackupSettings(settings);
|
||||
}
|
||||
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||
}
|
||||
|
||||
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
|
||||
|
||||
+464
-110
@@ -1,7 +1,6 @@
|
||||
import type { Env } from '../types';
|
||||
import { StorageService } from './storage';
|
||||
import type { Env, User } from '../types';
|
||||
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
||||
import { normalizeImportedBackupSettings } from './backup-config';
|
||||
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
|
||||
import {
|
||||
type BackupManifestAttachmentBlob,
|
||||
type BackupPayload,
|
||||
@@ -10,6 +9,26 @@ import {
|
||||
} from './backup-archive';
|
||||
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
type BackupTableName =
|
||||
| 'config'
|
||||
| 'users'
|
||||
| 'user_revisions'
|
||||
| 'folders'
|
||||
| 'ciphers'
|
||||
| 'attachments';
|
||||
|
||||
const BACKUP_TABLES: BackupTableName[] = [
|
||||
'config',
|
||||
'users',
|
||||
'user_revisions',
|
||||
'folders',
|
||||
'ciphers',
|
||||
'attachments',
|
||||
];
|
||||
|
||||
function shadowTableName(table: BackupTableName): string {
|
||||
return `${table}__restore`;
|
||||
}
|
||||
|
||||
export interface BackupImportResultBody {
|
||||
object: 'instance-backup-import';
|
||||
@@ -43,6 +62,81 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro
|
||||
return (response.results || []).map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
async function getTableCreateSql(db: D1Database, table: BackupTableName): Promise<string> {
|
||||
const row = await db
|
||||
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.bind(table)
|
||||
.first<{ sql: string | null }>();
|
||||
const sql = String(row?.sql || '').trim();
|
||||
if (!sql) {
|
||||
throw new Error(`Restore shadow schema is missing table definition for ${table}`);
|
||||
}
|
||||
return sql;
|
||||
}
|
||||
|
||||
function buildShadowTableCreateSql(createSql: string, table: BackupTableName): string {
|
||||
const tablePattern = new RegExp(`^CREATE TABLE(?:\\s+IF NOT EXISTS)?\\s+(?:\"${table}\"|${table})(?=\\s*\\()`, 'i');
|
||||
let next = createSql.replace(tablePattern, `CREATE TABLE "${shadowTableName(table)}"`);
|
||||
if (next === createSql) {
|
||||
throw new Error(`Restore shadow schema could not rewrite CREATE TABLE statement for ${table}`);
|
||||
}
|
||||
for (const currentTable of BACKUP_TABLES) {
|
||||
const referencePattern = new RegExp(`\\bREFERENCES\\s+(?:\"${currentTable}\"|${currentTable})(?=\\s*\\()`, 'gi');
|
||||
next = next.replace(
|
||||
referencePattern,
|
||||
`REFERENCES "${shadowTableName(currentTable)}"`
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function resetRestoreArtifacts(db: D1Database): Promise<void> {
|
||||
const dropStatements = BACKUP_TABLES
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((table) => db.prepare(`DROP TABLE IF EXISTS ${shadowTableName(table)}`));
|
||||
if (dropStatements.length) {
|
||||
await db.batch(dropStatements);
|
||||
}
|
||||
}
|
||||
|
||||
async function createShadowTables(db: D1Database): Promise<void> {
|
||||
const createStatements: D1PreparedStatement[] = [];
|
||||
for (const table of BACKUP_TABLES) {
|
||||
const createSql = await getTableCreateSql(db, table);
|
||||
createStatements.push(db.prepare(buildShadowTableCreateSql(createSql, table)));
|
||||
}
|
||||
await db.batch(createStatements);
|
||||
}
|
||||
|
||||
async function validateShadowTableCounts(
|
||||
db: D1Database,
|
||||
expectedCounts: Partial<Record<BackupTableName, number>>
|
||||
): Promise<void> {
|
||||
await Promise.all(BACKUP_TABLES.map(async (table) => {
|
||||
const expected = expectedCounts[table] ?? 0;
|
||||
const row = await db.prepare(`SELECT COUNT(*) AS count FROM ${shadowTableName(table)}`).first<{ count: number }>();
|
||||
const actual = Number(row?.count || 0);
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Restore shadow validation failed for ${table}: expected ${expected}, received ${actual}`);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async function swapShadowTablesIntoPlace(db: D1Database): Promise<void> {
|
||||
const statements: D1PreparedStatement[] = [];
|
||||
// Commit by replacing live table contents from validated shadow tables.
|
||||
// This avoids D1 schema-rename edge cases while keeping current data intact
|
||||
// until the final batch succeeds.
|
||||
for (const sql of buildResetImportTargetStatements(db)) {
|
||||
statements.push(sql);
|
||||
}
|
||||
for (const table of BACKUP_TABLES) {
|
||||
statements.push(db.prepare(`INSERT INTO ${table} SELECT * FROM ${shadowTableName(table)}`));
|
||||
}
|
||||
await db.batch(statements);
|
||||
}
|
||||
|
||||
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
|
||||
const counts = await Promise.all([
|
||||
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
||||
@@ -61,18 +155,9 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
||||
'DELETE FROM attachments',
|
||||
'DELETE FROM ciphers',
|
||||
'DELETE FROM folders',
|
||||
'DELETE FROM sends',
|
||||
'DELETE FROM trusted_two_factor_device_tokens',
|
||||
'DELETE FROM devices',
|
||||
'DELETE FROM refresh_tokens',
|
||||
'DELETE FROM invites',
|
||||
'DELETE FROM audit_logs',
|
||||
'DELETE FROM user_revisions',
|
||||
'DELETE FROM users',
|
||||
'DELETE FROM config',
|
||||
'DELETE FROM login_attempts_ip',
|
||||
'DELETE FROM api_rate_limits',
|
||||
'DELETE FROM used_attachment_download_tokens',
|
||||
].map((sql) => db.prepare(sql));
|
||||
}
|
||||
|
||||
@@ -119,10 +204,90 @@ interface AttachmentRestoreResult {
|
||||
}
|
||||
|
||||
interface RemoteAttachmentSource {
|
||||
hasAttachment(blobName: string): Promise<boolean>;
|
||||
loadAttachment(blobName: string): Promise<Uint8Array | null>;
|
||||
}
|
||||
|
||||
export interface BackupRestoreProgressEvent {
|
||||
source: 'local' | 'remote';
|
||||
step: string;
|
||||
fileName: string;
|
||||
stageTitle: string;
|
||||
stageDetail: string;
|
||||
replaceExisting: boolean;
|
||||
done?: boolean;
|
||||
ok?: boolean;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export type BackupRestoreProgressReporter = (event: BackupRestoreProgressEvent) => Promise<void> | void;
|
||||
|
||||
function attachmentRowKey(row: SqlRow): string {
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
return `${cipherId}/${attachmentId}`;
|
||||
}
|
||||
|
||||
function cloneRows(rows: SqlRow[]): SqlRow[] {
|
||||
return rows.map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
||||
let replaced = false;
|
||||
const nextRows = rows.map((row) => {
|
||||
if (String(row.key || '').trim() !== key) return { ...row };
|
||||
replaced = true;
|
||||
return { ...row, key, value };
|
||||
});
|
||||
if (!replaced) {
|
||||
nextRows.push({ key, value });
|
||||
}
|
||||
return nextRows;
|
||||
}
|
||||
|
||||
async function prepareImportedConfigRows(
|
||||
env: Env,
|
||||
configRows: SqlRow[],
|
||||
userRows: SqlRow[]
|
||||
): Promise<SqlRow[]> {
|
||||
let nextConfigRows = cloneRows(configRows || []);
|
||||
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
|
||||
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
|
||||
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
|
||||
env,
|
||||
userRows.map((row) => ({
|
||||
id: String(row.id || '').trim(),
|
||||
publicKey: typeof row.public_key === 'string' ? row.public_key : null,
|
||||
role: String(row.role || '').trim() as User['role'],
|
||||
status: String(row.status || '').trim() as User['status'],
|
||||
})),
|
||||
'UTC'
|
||||
);
|
||||
if (normalizedBackupSettings !== null) {
|
||||
nextConfigRows = upsertConfigRow(nextConfigRows, BACKUP_SETTINGS_CONFIG_KEY, normalizedBackupSettings);
|
||||
}
|
||||
nextConfigRows = upsertConfigRow(nextConfigRows, 'registered', 'true');
|
||||
return nextConfigRows;
|
||||
}
|
||||
|
||||
async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['db'], env: Env): Promise<BackupPayload['db']> {
|
||||
const preparedDb: BackupPayload['db'] = {
|
||||
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
||||
users: cloneRows(payload.users || []).map((row) => ({
|
||||
...row,
|
||||
verify_devices: row.verify_devices ?? 1,
|
||||
})),
|
||||
user_revisions: cloneRows(payload.user_revisions || []),
|
||||
folders: cloneRows(payload.folders || []),
|
||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||
...row,
|
||||
archived_at: row.archived_at ?? null,
|
||||
})),
|
||||
attachments: cloneRows(payload.attachments || []),
|
||||
};
|
||||
await importBackupRows(db, preparedDb, true);
|
||||
return preparedDb;
|
||||
}
|
||||
|
||||
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
|
||||
const storageKind = getBlobStorageKind(env);
|
||||
if (storageKind === 'r2') {
|
||||
@@ -147,7 +312,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
const result = {
|
||||
payload: {
|
||||
...payload,
|
||||
db: {
|
||||
@@ -161,6 +326,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
||||
items: skippedItems,
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
const oversizedAttachmentPaths = new Set<string>();
|
||||
@@ -197,7 +363,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
||||
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
payload: nextPayload,
|
||||
skipped: {
|
||||
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
|
||||
@@ -205,6 +371,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
||||
items: skippedItems,
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
||||
@@ -214,6 +381,16 @@ function buildInsertStatements(db: D1Database, table: string, columns: string[],
|
||||
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
|
||||
}
|
||||
|
||||
async function runInsertBatch(db: D1Database, table: string, statements: D1PreparedStatement[]): Promise<void> {
|
||||
if (!statements.length) return;
|
||||
try {
|
||||
await db.batch(statements);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Restore insert failed for ${table}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
|
||||
const restoredAttachments: SqlRow[] = [];
|
||||
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||
@@ -300,14 +477,10 @@ async function prepareRemoteAttachmentPayload(
|
||||
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||
continue;
|
||||
}
|
||||
if (!(await source.hasAttachment(ref.blobName))) {
|
||||
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||
continue;
|
||||
}
|
||||
nextAttachments.push(row);
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
payload: {
|
||||
...payload,
|
||||
db: {
|
||||
@@ -321,16 +494,18 @@ async function prepareRemoteAttachmentPayload(
|
||||
items: skippedItems,
|
||||
},
|
||||
};
|
||||
return result;
|
||||
}
|
||||
|
||||
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[]): Promise<void> {
|
||||
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[], useShadowTable: boolean = false): Promise<void> {
|
||||
if (!attachmentRows.length) return;
|
||||
const tableName = useShadowTable ? shadowTableName('attachments') : 'attachments';
|
||||
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);
|
||||
return db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND cipher_id = ?`).bind(attachmentId, cipherId);
|
||||
})
|
||||
.filter((statement): statement is D1PreparedStatement => !!statement);
|
||||
if (!statements.length) return;
|
||||
@@ -406,36 +581,58 @@ async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, after
|
||||
}
|
||||
}
|
||||
|
||||
async function importBackupRows(db: D1Database, payload: BackupPayload['db']): Promise<void> {
|
||||
const statements: D1PreparedStatement[] = [
|
||||
...buildResetImportTargetStatements(db),
|
||||
...buildInsertStatements(db, 'config', ['key', 'value'], payload.config || [], true),
|
||||
...buildInsertStatements(
|
||||
async function importBackupRows(db: D1Database, payload: BackupPayload['db'], useShadowTables: boolean = false): Promise<void> {
|
||||
const tableName = (table: BackupTableName): string => (useShadowTables ? shadowTableName(table) : table);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('config'),
|
||||
buildInsertStatements(db, tableName('config'), ['key', 'value'], payload.config || [], true)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('users'),
|
||||
buildInsertStatements(
|
||||
db,
|
||||
'users',
|
||||
['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'],
|
||||
tableName('users'),
|
||||
['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', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
payload.users || []
|
||||
),
|
||||
...buildInsertStatements(db, 'user_revisions', ['user_id', 'revision_date'], payload.user_revisions || [], true),
|
||||
...buildInsertStatements(db, 'folders', ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || []),
|
||||
...buildInsertStatements(
|
||||
)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('user_revisions'),
|
||||
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('folders'),
|
||||
buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || [])
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('ciphers'),
|
||||
buildInsertStatements(
|
||||
db,
|
||||
'ciphers',
|
||||
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
|
||||
tableName('ciphers'),
|
||||
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'],
|
||||
payload.ciphers || []
|
||||
),
|
||||
...buildInsertStatements(db, 'attachments', ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []),
|
||||
];
|
||||
await db.batch(statements);
|
||||
)
|
||||
);
|
||||
await runInsertBatch(
|
||||
db,
|
||||
tableName('attachments'),
|
||||
buildInsertStatements(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || [])
|
||||
);
|
||||
}
|
||||
|
||||
export async function importBackupArchiveBytes(
|
||||
archiveBytes: Uint8Array,
|
||||
env: Env,
|
||||
actorUserId: string,
|
||||
replaceExisting: boolean
|
||||
replaceExisting: boolean,
|
||||
progress?: BackupRestoreProgressReporter,
|
||||
fileName: string = 'nodewarden_backup.zip'
|
||||
): Promise<BackupImportExecutionResult> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const parsed = parseBackupArchive(archiveBytes);
|
||||
validateBackupPayloadContents(parsed.payload, parsed.files);
|
||||
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
|
||||
@@ -448,40 +645,118 @@ export async function importBackupArchiveBytes(
|
||||
}
|
||||
}
|
||||
|
||||
await resetRestoreArtifacts(env.DB);
|
||||
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||
const { db } = prepared.payload;
|
||||
await importBackupRows(env.DB, db);
|
||||
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
||||
try {
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_create_shadow',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_shadow_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_shadow_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
await createShadowTables(env.DB);
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_import_data',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_data_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_data_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
const db = await importPreparedBackupRows(env.DB, prepared.payload.db, env);
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: (db.attachments || []).length,
|
||||
});
|
||||
|
||||
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, await collectCurrentBlobKeys(env.DB));
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_restore_files',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_files_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_files_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
const restored = await restoreBlobFiles(env, db, parsed.files);
|
||||
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
|
||||
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
|
||||
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
});
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_finalize',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
await swapShadowTablesIntoPlace(env.DB);
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
if (replaceExisting && previousBlobKeys.size) {
|
||||
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||
if (nextBlobKeys) {
|
||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_complete',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: true,
|
||||
});
|
||||
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: restored.skipped.reason || prepared.skipped.reason,
|
||||
attachments: prepared.skipped.attachments + restored.skipped.attachments,
|
||||
items: [...prepared.skipped.items, ...restored.skipped.items],
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await progress?.({
|
||||
source: 'local',
|
||||
step: 'local_failed',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
await storage.setRegistered();
|
||||
|
||||
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: restored.skipped.reason || prepared.skipped.reason,
|
||||
attachments: prepared.skipped.attachments + restored.skipped.attachments,
|
||||
items: [...prepared.skipped.items, ...restored.skipped.items],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function importRemoteBackupArchiveBytes(
|
||||
@@ -489,9 +764,10 @@ export async function importRemoteBackupArchiveBytes(
|
||||
env: Env,
|
||||
actorUserId: string,
|
||||
replaceExisting: boolean,
|
||||
source: RemoteAttachmentSource
|
||||
source: RemoteAttachmentSource,
|
||||
progress?: BackupRestoreProgressReporter,
|
||||
fileName: string = 'nodewarden_backup.zip'
|
||||
): 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 });
|
||||
@@ -504,44 +780,122 @@ export async function importRemoteBackupArchiveBytes(
|
||||
}
|
||||
}
|
||||
|
||||
await resetRestoreArtifacts(env.DB);
|
||||
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||
const { db } = preparedRemote.payload;
|
||||
await importBackupRows(env.DB, db);
|
||||
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
||||
try {
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_create_shadow',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_shadow_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_shadow_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
await createShadowTables(env.DB);
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_import_data',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_data_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_data_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
const db = await importPreparedBackupRows(env.DB, preparedRemote.payload.db, env);
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: (db.attachments || []).length,
|
||||
});
|
||||
|
||||
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);
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_restore_files',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_files_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_files_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
|
||||
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
|
||||
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
|
||||
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
|
||||
await validateShadowTableCounts(env.DB, {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
user_revisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: restored.restoredAttachments.length,
|
||||
});
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_finalize',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||
replaceExisting,
|
||||
});
|
||||
await swapShadowTablesIntoPlace(env.DB);
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
|
||||
if (replaceExisting && previousBlobKeys.size) {
|
||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
|
||||
if (replaceExisting && previousBlobKeys.size) {
|
||||
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||
if (nextBlobKeys) {
|
||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_complete',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: true,
|
||||
});
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await progress?.({
|
||||
source: 'remote',
|
||||
step: 'remote_failed',
|
||||
fileName,
|
||||
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||
replaceExisting,
|
||||
done: true,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,18 +250,49 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureWebDavDirectoryCached(
|
||||
baseUrl: string,
|
||||
directoryPath: string,
|
||||
authHeader: string,
|
||||
ensuredDirectories: Set<string>
|
||||
): Promise<void> {
|
||||
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (const segment of segments) {
|
||||
current = buildJoinedPath(current, segment);
|
||||
if (ensuredDirectories.has(current)) continue;
|
||||
const url = buildWebDavUrl(baseUrl, current);
|
||||
const response = await fetch(url, {
|
||||
method: 'MKCOL',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
|
||||
ensuredDirectories.add(current);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function putToWebDav(
|
||||
config: WebDavBackupDestination,
|
||||
relativePath: string,
|
||||
bytes: Uint8Array,
|
||||
options: RemoteBackupFilePutOptions = {}
|
||||
options: RemoteBackupFilePutOptions = {},
|
||||
ensuredDirectories?: Set<string>
|
||||
): Promise<void> {
|
||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||
const remoteFilePath = buildJoinedPath(config.remotePath, relativePath);
|
||||
const remoteDir = parentPath(remoteFilePath);
|
||||
|
||||
if (remoteDir) {
|
||||
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
|
||||
if (ensuredDirectories) {
|
||||
await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories);
|
||||
} else {
|
||||
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
|
||||
@@ -608,6 +639,16 @@ interface ConfiguredDestinationAdapter {
|
||||
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface RemoteBackupTransferSession {
|
||||
provider: BackupDestinationType;
|
||||
uploadArchive(archive: Uint8Array, fileName: string): Promise<BackupUploadResult>;
|
||||
putFile(relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions): Promise<void>;
|
||||
list(relativePath: string): Promise<RemoteBackupListResult>;
|
||||
download(relativePath: string): Promise<RemoteBackupFile>;
|
||||
deleteFile(relativePath: string): Promise<void>;
|
||||
exists(relativePath: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
function resolveConfiguredDestinationAdapter(
|
||||
destination: BackupDestinationRecord
|
||||
): ConfiguredDestinationAdapter {
|
||||
@@ -641,35 +682,62 @@ function resolveConfiguredDestinationAdapter(
|
||||
throw new Error('Unsupported backup destination type');
|
||||
}
|
||||
|
||||
export function createRemoteBackupTransferSession(destination: BackupDestinationRecord): RemoteBackupTransferSession {
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
const ensuredDirectories = adapter.provider === 'webdav' ? new Set<string>() : null;
|
||||
|
||||
const putFile = async (relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}): Promise<void> => {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
if (adapter.provider === 'webdav' && ensuredDirectories) {
|
||||
await putToWebDav(adapter.config as WebDavBackupDestination, normalized, bytes, options, ensuredDirectories);
|
||||
return;
|
||||
}
|
||||
await adapter.putFile(adapter.config, normalized, bytes, options);
|
||||
};
|
||||
|
||||
return {
|
||||
provider: adapter.provider,
|
||||
uploadArchive: async (archive: Uint8Array, fileName: string) => {
|
||||
await putFile(fileName, archive, { contentType: 'application/zip' });
|
||||
return {
|
||||
provider: adapter.provider,
|
||||
remotePath: adapter.provider === 'webdav'
|
||||
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
|
||||
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName),
|
||||
};
|
||||
},
|
||||
putFile,
|
||||
list: async (relativePath: string) => adapter.list(adapter.config, relativePath),
|
||||
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
||||
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
||||
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadBackupArchive(
|
||||
destination: BackupDestinationRecord,
|
||||
archive: Uint8Array,
|
||||
fileName: string
|
||||
): Promise<BackupUploadResult> {
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
return adapter.upload(adapter.config, archive, fileName);
|
||||
return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName);
|
||||
}
|
||||
|
||||
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
return adapter.list(adapter.config, relativePath);
|
||||
return createRemoteBackupTransferSession(destination).list(relativePath);
|
||||
}
|
||||
|
||||
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
return adapter.download(adapter.config, relativePath);
|
||||
return createRemoteBackupTransferSession(destination).download(relativePath);
|
||||
}
|
||||
|
||||
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
|
||||
const normalized = ensureRemoteRestoreCandidate(relativePath);
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
await adapter.deleteFile(adapter.config, normalized);
|
||||
await createRemoteBackupTransferSession(destination).deleteFile(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);
|
||||
return createRemoteBackupTransferSession(destination).exists(normalized);
|
||||
}
|
||||
|
||||
export async function uploadRemoteBackupFile(
|
||||
@@ -679,8 +747,7 @@ export async function uploadRemoteBackupFile(
|
||||
options: RemoteBackupFilePutOptions = {}
|
||||
): Promise<void> {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
await adapter.putFile(adapter.config, normalized, bytes, options);
|
||||
await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options);
|
||||
}
|
||||
|
||||
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
|
||||
|
||||
Reference in New Issue
Block a user