mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
1179 lines
42 KiB
TypeScript
1179 lines
42 KiB
TypeScript
import type { Env, User } from '../types';
|
|
import { errorResponse, jsonResponse } from '../utils/response';
|
|
import {
|
|
type BackupArchiveBundle,
|
|
buildBackupArchive,
|
|
inspectBackupArchiveFileNameChecksum,
|
|
parseBackupArchive,
|
|
verifyBackupArchiveFileNameChecksum,
|
|
} from '../services/backup-archive';
|
|
import {
|
|
type BackupDestinationRecord,
|
|
type BackupSettingsInput,
|
|
type BackupSettings,
|
|
type WebDavBackupDestination,
|
|
getBackupLocalDateKey,
|
|
getDefaultBackupSettings,
|
|
getBackupSettingsRepairState,
|
|
loadBackupSettings,
|
|
normalizeBackupSettingsInput,
|
|
normalizeImportedBackupSettings,
|
|
repairBackupSettings,
|
|
requireBackupDestination,
|
|
saveBackupSettings,
|
|
} from '../services/backup-config';
|
|
import {
|
|
type BackupImportExecutionResult,
|
|
type BackupRestoreProgressReporter,
|
|
importBackupArchiveBytes,
|
|
importRemoteBackupArchiveBytes,
|
|
} from '../services/backup-import';
|
|
import {
|
|
type RemoteBackupTransferSession,
|
|
type RemoteBackupFile,
|
|
createRemoteBackupTransferSession,
|
|
deleteRemoteBackupFile,
|
|
downloadRemoteBackupFile,
|
|
ensureRemoteRestoreCandidate,
|
|
listRemoteBackupEntries,
|
|
pruneRemoteBackupArchives,
|
|
uploadBackupArchive,
|
|
} from '../services/backup-uploader';
|
|
import { StorageService } from '../services/storage';
|
|
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
|
|
import { getBlobObject } from '../services/blob-store';
|
|
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
|
import { unzipSync } from 'fflate';
|
|
|
|
function isAdmin(user: User): boolean {
|
|
return user.role === 'admin' && user.status === 'active';
|
|
}
|
|
|
|
async function writeAuditLog(
|
|
storage: StorageService,
|
|
actorUserId: string | null,
|
|
action: string,
|
|
targetType: string | null,
|
|
targetId: string | null,
|
|
metadata: Record<string, unknown> | null,
|
|
request?: Request
|
|
): Promise<void> {
|
|
await writeAuditEvent(storage, {
|
|
actorUserId,
|
|
action,
|
|
targetType,
|
|
targetId,
|
|
category: 'data',
|
|
level: action.endsWith('.failed') ? 'error' : 'info',
|
|
metadata: {
|
|
...(metadata || {}),
|
|
...(request ? auditRequestMetadata(request) : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
function getBackupDestinationSummary(destination: BackupDestinationRecord | null): Record<string, unknown> {
|
|
if (!destination) {
|
|
return {
|
|
destinationId: null,
|
|
destinationName: null,
|
|
destinationType: null,
|
|
};
|
|
}
|
|
return {
|
|
destinationId: destination.id,
|
|
destinationName: destination.name,
|
|
destinationType: destination.type,
|
|
};
|
|
}
|
|
|
|
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('/');
|
|
}
|
|
|
|
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
|
|
|
|
interface RemoteAttachmentIndexPayload {
|
|
version: 1;
|
|
blobs: Record<string, { sizeBytes: number; updatedAt: string }>;
|
|
}
|
|
|
|
const REMOTE_ATTACHMENT_SYNC_EXTERNAL_SUBREQUEST_LIMIT = 50;
|
|
const REMOTE_ATTACHMENT_SYNC_SUBREQUEST_RESERVE = 6;
|
|
const REMOTE_ATTACHMENT_SYNC_MAX_WEB_DAV_BATCH_SIZE = 18;
|
|
const REMOTE_ATTACHMENT_SYNC_MAX_S3_BATCH_SIZE = 40;
|
|
const REMOTE_ATTACHMENT_RESTORE_BATCH_SIZE = 40;
|
|
|
|
function countRemotePathSegments(value: string): number {
|
|
return String(value || '').replace(/\\/g, '/').split('/').filter(Boolean).length;
|
|
}
|
|
|
|
function getRemoteAttachmentSyncBatchSize(destination: BackupDestinationRecord): number {
|
|
if (destination.type === 's3') {
|
|
return REMOTE_ATTACHMENT_SYNC_MAX_S3_BATCH_SIZE;
|
|
}
|
|
|
|
const remotePath = String((destination.destination as WebDavBackupDestination).remotePath || '');
|
|
const fixedWebDavDirectoryCalls = countRemotePathSegments(remotePath) + 1; // remotePath plus the shared "attachments" dir.
|
|
const available = REMOTE_ATTACHMENT_SYNC_EXTERNAL_SUBREQUEST_LIMIT
|
|
- REMOTE_ATTACHMENT_SYNC_SUBREQUEST_RESERVE
|
|
- fixedWebDavDirectoryCalls;
|
|
|
|
if (available < 2) {
|
|
throw new Error('WebDAV remote backup path is too deep for safe attachment batching');
|
|
}
|
|
|
|
return Math.max(1, Math.min(
|
|
REMOTE_ATTACHMENT_SYNC_MAX_WEB_DAV_BATCH_SIZE,
|
|
Math.floor(available / 2)
|
|
));
|
|
}
|
|
|
|
async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession): Promise<Map<string, number>> {
|
|
try {
|
|
const file = await session.download(REMOTE_ATTACHMENT_INDEX_PATH);
|
|
const payload = JSON.parse(new TextDecoder().decode(file.bytes)) as RemoteAttachmentIndexPayload;
|
|
if (payload?.version !== 1 || !payload.blobs || typeof payload.blobs !== 'object') {
|
|
return new Map<string, number>();
|
|
}
|
|
return new Map(
|
|
Object.entries(payload.blobs)
|
|
.filter(([key, value]) => !!String(key || '').trim() && Number.isFinite(Number(value?.sizeBytes || 0)))
|
|
.map(([key, value]) => [key, Number(value.sizeBytes || 0)])
|
|
);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const normalized = message.toLowerCase();
|
|
// Some WebDAV providers return non-standard codes such as 530 when the
|
|
// attachment index does not exist yet. Treat these "missing file" style
|
|
// responses as an empty index so first-time incremental backups can proceed.
|
|
if (
|
|
normalized.includes('404')
|
|
|| normalized.includes('403')
|
|
|| normalized.includes('530')
|
|
|| normalized.includes('not found')
|
|
|| normalized.includes('file not found')
|
|
|| normalized.includes('does not exist')
|
|
|| normalized.includes('please select a backup file')
|
|
) {
|
|
return new Map<string, number>();
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function saveRemoteAttachmentIndex(
|
|
session: RemoteBackupTransferSession,
|
|
index: Map<string, number>
|
|
): Promise<void> {
|
|
const payload: RemoteAttachmentIndexPayload = {
|
|
version: 1,
|
|
blobs: Object.fromEntries(
|
|
Array.from(index.entries()).map(([blobName, sizeBytes]) => [
|
|
blobName,
|
|
{
|
|
sizeBytes,
|
|
updatedAt: new Date().toISOString(),
|
|
},
|
|
])
|
|
),
|
|
};
|
|
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
|
await session.putFile(REMOTE_ATTACHMENT_INDEX_PATH, bytes, {
|
|
contentType: 'application/json; charset=utf-8',
|
|
});
|
|
}
|
|
|
|
async function uploadRemoteAttachmentChunk(
|
|
env: Env,
|
|
destination: BackupDestinationRecord,
|
|
attachments: Array<{ blobName: string }>
|
|
): Promise<void> {
|
|
if (!attachments.length) return;
|
|
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('remote-attachment-sync');
|
|
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
|
const response = await stub.fetch('https://backup-transfer/internal/upload-attachment-chunk', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
},
|
|
body: JSON.stringify({
|
|
destination,
|
|
attachments,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
let message = `Attachment sync failed: ${response.status}`;
|
|
try {
|
|
const payload = await response.json<{ error?: string }>();
|
|
if (payload?.error) {
|
|
message = payload.error;
|
|
}
|
|
} catch {
|
|
// Ignore JSON parse failures and preserve the status-based error.
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
export async function executeConfiguredBackup(
|
|
env: Env,
|
|
storage: StorageService,
|
|
actorUserId: string | null,
|
|
trigger: 'manual' | 'scheduled',
|
|
destinationId?: string | null,
|
|
keepAlive?: (() => Promise<void>) | null,
|
|
progress?: ((event: {
|
|
operation: 'backup-remote-run';
|
|
step: string;
|
|
fileName: string;
|
|
stageTitle: string;
|
|
stageDetail: string;
|
|
done?: boolean;
|
|
ok?: boolean;
|
|
error?: string | null;
|
|
}) => Promise<void>) | null,
|
|
auditMetadata?: Record<string, unknown> | null
|
|
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
|
const maxArchiveUploadAttempts = 3;
|
|
const touchLease = async () => {
|
|
await keepAlive?.();
|
|
};
|
|
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
|
const destination = requireBackupDestination(currentSettings, destinationId);
|
|
|
|
const now = new Date();
|
|
destination.runtime.lastAttemptAt = now.toISOString();
|
|
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
|
destination.runtime.lastErrorAt = null;
|
|
destination.runtime.lastErrorMessage = null;
|
|
await touchLease();
|
|
await saveBackupSettings(storage, env, currentSettings);
|
|
|
|
try {
|
|
await touchLease();
|
|
await progress?.({
|
|
operation: 'backup-remote-run',
|
|
step: 'remote_run_prepare',
|
|
fileName: '',
|
|
stageTitle: 'txt_backup_remote_run_progress_prepare_title',
|
|
stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
|
|
});
|
|
await touchLease();
|
|
const archive = await buildBackupArchive(env, now, {
|
|
includeAttachments: destination.includeAttachments,
|
|
timeZone: destination.schedule.timezone,
|
|
progress: progress
|
|
? async (event) => {
|
|
if (event.step === 'archive_ready') {
|
|
return;
|
|
}
|
|
await progress({
|
|
operation: 'backup-remote-run',
|
|
step: `remote_run_${event.step}`,
|
|
fileName: event.fileName || '',
|
|
stageTitle: event.stageTitle,
|
|
stageDetail: event.stageDetail,
|
|
});
|
|
}
|
|
: undefined,
|
|
});
|
|
await progress?.({
|
|
operation: 'backup-remote-run',
|
|
step: 'remote_run_sync_attachments',
|
|
fileName: archive.fileName,
|
|
stageTitle: 'txt_backup_remote_run_progress_sync_attachments_title',
|
|
stageDetail: destination.includeAttachments
|
|
? 'txt_backup_remote_run_progress_sync_attachments_detail'
|
|
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
|
|
});
|
|
const remoteSession = createRemoteBackupTransferSession(destination);
|
|
if (destination.includeAttachments) {
|
|
await touchLease();
|
|
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
|
const pendingAttachments = (archive.manifest.attachmentBlobs || [])
|
|
.filter((attachment) => remoteAttachmentIndex.get(attachment.blobName) !== attachment.sizeBytes);
|
|
const attachmentSyncBatchSize = getRemoteAttachmentSyncBatchSize(destination);
|
|
for (let i = 0; i < pendingAttachments.length; i += attachmentSyncBatchSize) {
|
|
await touchLease();
|
|
const chunk = pendingAttachments
|
|
.slice(i, i + attachmentSyncBatchSize)
|
|
.map((attachment) => ({ blobName: attachment.blobName }));
|
|
await uploadRemoteAttachmentChunk(env, destination, chunk);
|
|
}
|
|
if (pendingAttachments.length) {
|
|
for (const attachment of pendingAttachments) {
|
|
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
|
}
|
|
await touchLease();
|
|
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
|
}
|
|
}
|
|
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
|
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
|
await touchLease();
|
|
await progress?.({
|
|
operation: 'backup-remote-run',
|
|
step: 'remote_run_upload_archive',
|
|
fileName: archive.fileName,
|
|
stageTitle: 'txt_backup_remote_run_progress_upload_title',
|
|
stageDetail: 'txt_backup_remote_run_progress_upload_detail',
|
|
});
|
|
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
|
|
try {
|
|
await touchLease();
|
|
await progress?.({
|
|
operation: 'backup-remote-run',
|
|
step: 'remote_run_verify_archive',
|
|
fileName: archive.fileName,
|
|
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
|
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
|
});
|
|
const remoteFile = await remoteSession.download(archive.fileName);
|
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
|
|
if (!checksumOk) {
|
|
throw new Error('Remote backup ZIP checksum verification failed');
|
|
}
|
|
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
|
|
throw new Error('Remote backup ZIP size verification failed');
|
|
}
|
|
break;
|
|
} catch (error) {
|
|
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
|
if (attempt === maxArchiveUploadAttempts) {
|
|
const message = error instanceof Error ? error.message : 'Remote backup ZIP verification failed';
|
|
throw new Error(`Backup archive upload verification failed after ${maxArchiveUploadAttempts} attempts: ${message}`);
|
|
}
|
|
}
|
|
}
|
|
if (!upload) {
|
|
throw new Error('Backup archive upload failed');
|
|
}
|
|
let prunedFileCount = 0;
|
|
let pruneErrorMessage: string | null = null;
|
|
try {
|
|
await touchLease();
|
|
await progress?.({
|
|
operation: 'backup-remote-run',
|
|
step: 'remote_run_cleanup',
|
|
fileName: archive.fileName,
|
|
stageTitle: 'txt_backup_remote_run_progress_cleanup_title',
|
|
stageDetail: 'txt_backup_remote_run_progress_cleanup_detail',
|
|
});
|
|
prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName);
|
|
} catch (error) {
|
|
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
|
}
|
|
|
|
destination.runtime.lastSuccessAt = new Date().toISOString();
|
|
destination.runtime.lastErrorAt = null;
|
|
destination.runtime.lastErrorMessage = null;
|
|
destination.runtime.lastUploadedFileName = archive.fileName;
|
|
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
|
destination.runtime.lastUploadedDestination = upload.remotePath;
|
|
await touchLease();
|
|
await saveBackupSettings(storage, env, currentSettings);
|
|
|
|
await touchLease();
|
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
|
...getBackupDestinationSummary(destination),
|
|
provider: upload.provider,
|
|
remotePath: upload.remotePath,
|
|
fileName: archive.fileName,
|
|
fileBytes: archive.bytes.byteLength,
|
|
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
|
prunedFileCount,
|
|
pruneError: pruneErrorMessage,
|
|
...(auditMetadata || {}),
|
|
});
|
|
|
|
await progress?.({
|
|
operation: 'backup-remote-run',
|
|
step: 'remote_run_complete',
|
|
fileName: archive.fileName,
|
|
stageTitle: 'txt_backup_remote_run_progress_complete_title',
|
|
stageDetail: 'txt_backup_remote_run_progress_complete_detail',
|
|
done: true,
|
|
ok: true,
|
|
});
|
|
|
|
return {
|
|
fileName: archive.fileName,
|
|
fileSize: archive.bytes.byteLength,
|
|
remotePath: upload.remotePath,
|
|
provider: upload.provider,
|
|
};
|
|
} catch (error) {
|
|
destination.runtime.lastErrorAt = new Date().toISOString();
|
|
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
|
await touchLease();
|
|
await saveBackupSettings(storage, env, currentSettings);
|
|
|
|
await touchLease();
|
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
|
...getBackupDestinationSummary(destination),
|
|
error: destination.runtime.lastErrorMessage,
|
|
...(auditMetadata || {}),
|
|
});
|
|
await progress?.({
|
|
operation: 'backup-remote-run',
|
|
step: 'remote_run_failed',
|
|
fileName: '',
|
|
stageTitle: 'txt_backup_remote_run_progress_failed_title',
|
|
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
|
done: true,
|
|
ok: false,
|
|
error: destination.runtime.lastErrorMessage,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
interface DurableBackupRunResponse {
|
|
result: {
|
|
fileName: string;
|
|
fileSize: number;
|
|
remotePath: string;
|
|
provider: string;
|
|
};
|
|
settings: BackupSettings;
|
|
}
|
|
|
|
async function runConfiguredBackupInDurableObject(
|
|
env: Env,
|
|
payload: {
|
|
actorUserId: string | null;
|
|
auditMetadata?: Record<string, unknown> | null;
|
|
destinationId?: string | null;
|
|
targetDeviceIdentifier?: string | null;
|
|
trigger: 'manual' | 'scheduled';
|
|
}
|
|
): Promise<DurableBackupRunResponse | null> {
|
|
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('configured-backup-runner');
|
|
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
|
const response = await stub.fetch('https://backup-transfer/internal/run-configured-backup', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (response.status === 409) {
|
|
return null;
|
|
}
|
|
if (!response.ok) {
|
|
let message = `Backup run failed: ${response.status}`;
|
|
try {
|
|
const body = await response.json<{ error?: string }>();
|
|
if (body?.error) message = body.error;
|
|
} catch {
|
|
// Preserve the status-based message when the DO returns a non-JSON error.
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
const body = await response.json<DurableBackupRunResponse>();
|
|
if (!body?.result || !body?.settings) {
|
|
throw new Error('Backup run response is invalid');
|
|
}
|
|
return body;
|
|
}
|
|
|
|
async function runScheduledBackupsInDurableObject(env: Env): Promise<void> {
|
|
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('configured-backup-runner');
|
|
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
|
const response = await stub.fetch('https://backup-transfer/internal/run-scheduled-backups', {
|
|
method: 'POST',
|
|
});
|
|
if (response.status === 409) {
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
let message = `Scheduled backup failed: ${response.status}`;
|
|
try {
|
|
const body = await response.json<{ error?: string }>();
|
|
if (body?.error) message = body.error;
|
|
} catch {
|
|
// Preserve the status-based message when the DO returns a non-JSON error.
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
async function downloadRemoteAttachmentViaDurableObject(
|
|
env: Env,
|
|
destination: BackupDestinationRecord,
|
|
blobName: string
|
|
): Promise<Uint8Array | null> {
|
|
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('remote-attachment-restore');
|
|
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
|
const response = await stub.fetch('https://backup-transfer/internal/download-remote-attachment', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
},
|
|
body: JSON.stringify({
|
|
destination,
|
|
blobName,
|
|
}),
|
|
});
|
|
if (response.status === 404) {
|
|
return null;
|
|
}
|
|
if (!response.ok) {
|
|
throw new Error(`Remote attachment download failed: ${response.status}`);
|
|
}
|
|
return new Uint8Array(await response.arrayBuffer());
|
|
}
|
|
|
|
async function downloadRemoteAttachmentBatchViaDurableObject(
|
|
env: Env,
|
|
destination: BackupDestinationRecord,
|
|
blobNames: string[]
|
|
): Promise<Map<string, Uint8Array>> {
|
|
const names = Array.from(new Set(blobNames.map((blobName) => String(blobName || '').trim()).filter(Boolean)));
|
|
const result = new Map<string, Uint8Array>();
|
|
if (!names.length) return result;
|
|
|
|
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('remote-attachment-restore');
|
|
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
|
const response = await stub.fetch('https://backup-transfer/internal/download-remote-attachment-batch', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
},
|
|
body: JSON.stringify({
|
|
destination,
|
|
blobNames: names,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(`Remote attachment batch download failed: ${response.status}`);
|
|
}
|
|
|
|
const files = unzipSync(new Uint8Array(await response.arrayBuffer()));
|
|
const manifestBytes = files['manifest.json'];
|
|
if (!manifestBytes) return result;
|
|
const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as {
|
|
entries?: Array<{ blobName?: string; path?: string }>;
|
|
};
|
|
for (const entry of manifest.entries || []) {
|
|
const blobName = String(entry.blobName || '').trim();
|
|
const path = String(entry.path || '').trim();
|
|
const bytes = path ? files[path] : null;
|
|
if (blobName && bytes) {
|
|
result.set(blobName, bytes);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): string[] {
|
|
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
|
|
const refs = new Map(
|
|
(parsed.payload.manifest.attachmentBlobs || [])
|
|
.map((item) => [`${String(item.cipherId || '').trim()}/${String(item.attachmentId || '').trim()}`, item])
|
|
);
|
|
const names: string[] = [];
|
|
const seen = new Set<string>();
|
|
|
|
for (const row of parsed.payload.db.attachments || []) {
|
|
const cipherId = String(row.cipher_id || '').trim();
|
|
const attachmentId = String(row.id || '').trim();
|
|
const inlinePath = `attachments/${cipherId}/${attachmentId}.bin`;
|
|
if (parsed.files[inlinePath]) continue;
|
|
const ref = refs.get(`${cipherId}/${attachmentId}`);
|
|
const blobName = String(ref?.blobName || '').trim();
|
|
if (blobName && !seen.has(blobName)) {
|
|
seen.add(blobName);
|
|
names.push(blobName);
|
|
}
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
function toImportStatusCode(message: string): number {
|
|
const lower = message.toLowerCase();
|
|
if (lower.includes('checksum')) return 400;
|
|
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
|
|
if (lower.includes('fresh instance')) return 409;
|
|
if (lower.includes('not configured') || lower.includes('kv')) return 409;
|
|
return 500;
|
|
}
|
|
|
|
export async function importAndAuditRemoteBackupFile(
|
|
env: Env,
|
|
storage: StorageService,
|
|
actorUserId: string,
|
|
remoteFile: RemoteBackupFile,
|
|
destination: BackupDestinationRecord,
|
|
remotePath: string,
|
|
replaceExisting: boolean,
|
|
checksumMismatchAccepted: boolean,
|
|
auditMetadata: Record<string, unknown> | null = null,
|
|
targetDeviceIdentifier: string | null = null
|
|
): Promise<BackupImportExecutionResult> {
|
|
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
|
|
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
|
|
const externalAttachmentCache = new Map<string, Uint8Array | null>();
|
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
|
await notifyUserBackupRestoreProgress(
|
|
env,
|
|
actorUserId,
|
|
{
|
|
operation: 'backup-restore',
|
|
...event,
|
|
},
|
|
targetDeviceIdentifier
|
|
);
|
|
};
|
|
const result = await importRemoteBackupArchiveBytes(
|
|
remoteFile.bytes,
|
|
env,
|
|
actorUserId,
|
|
replaceExisting,
|
|
{
|
|
loadAttachment: async (blobName) => {
|
|
const normalized = String(blobName || '').trim();
|
|
if (!normalized) return null;
|
|
if (externalAttachmentCache.has(normalized)) {
|
|
return externalAttachmentCache.get(normalized) || null;
|
|
}
|
|
|
|
const start = Math.max(0, externalAttachmentBlobNames.indexOf(normalized));
|
|
const batchNames = externalAttachmentBlobNames
|
|
.slice(start, start + REMOTE_ATTACHMENT_RESTORE_BATCH_SIZE)
|
|
.filter((name) => !externalAttachmentCache.has(name));
|
|
if (!batchNames.includes(normalized)) {
|
|
batchNames.unshift(normalized);
|
|
}
|
|
|
|
try {
|
|
const batch = await downloadRemoteAttachmentBatchViaDurableObject(env, destination, batchNames);
|
|
for (const name of batchNames) {
|
|
externalAttachmentCache.set(name, batch.get(name) || null);
|
|
}
|
|
} catch {
|
|
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
|
|
}
|
|
return externalAttachmentCache.get(normalized) || null;
|
|
},
|
|
},
|
|
progress,
|
|
restoreFileName
|
|
);
|
|
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,
|
|
...getBackupDestinationSummary(destination),
|
|
remotePath,
|
|
bytes: remoteFile.bytes.byteLength,
|
|
trigger: 'remote',
|
|
checksumMismatchAccepted,
|
|
...(auditMetadata || {}),
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async function restoreRemoteBackupInDurableObject(
|
|
env: Env,
|
|
payload: {
|
|
actorUserId: string;
|
|
allowChecksumMismatch?: boolean;
|
|
auditMetadata?: Record<string, unknown> | null;
|
|
destinationId?: string | null;
|
|
path: string;
|
|
replaceExisting?: boolean;
|
|
targetDeviceIdentifier?: string | null;
|
|
}
|
|
): Promise<BackupImportExecutionResult['result'] | null> {
|
|
const id = env.BACKUP_TRANSFER_RUNNER.idFromName('configured-backup-runner');
|
|
const stub = env.BACKUP_TRANSFER_RUNNER.get(id);
|
|
const response = await stub.fetch('https://backup-transfer/internal/restore-remote-backup', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json; charset=utf-8',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (response.status === 409) {
|
|
return null;
|
|
}
|
|
if (!response.ok) {
|
|
let message = `Remote backup restore failed: ${response.status}`;
|
|
try {
|
|
const body = await response.json<{ error?: string }>();
|
|
if (body?.error) message = body.error;
|
|
} catch {
|
|
// Preserve the status-based message when the DO returns a non-JSON error.
|
|
}
|
|
throw new Error(message);
|
|
}
|
|
return response.json<BackupImportExecutionResult['result']>();
|
|
}
|
|
|
|
async function runImportAndAudit(
|
|
env: Env,
|
|
request: Request,
|
|
actorUser: User,
|
|
archiveBytes: Uint8Array,
|
|
fileName: string,
|
|
replaceExisting: boolean,
|
|
metadata: Record<string, unknown>
|
|
): Promise<BackupImportExecutionResult> {
|
|
const storage = new StorageService(env.DB);
|
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
|
await notifyUserBackupRestoreProgress(
|
|
env,
|
|
actorUser.id,
|
|
{
|
|
operation: 'backup-restore',
|
|
...event,
|
|
},
|
|
targetDeviceIdentifier
|
|
);
|
|
};
|
|
await progress({
|
|
source: 'local',
|
|
step: 'local_upload_received',
|
|
fileName,
|
|
stageTitle: 'txt_backup_restore_progress_local_upload_title',
|
|
stageDetail: 'txt_backup_restore_progress_local_upload_detail',
|
|
replaceExisting,
|
|
});
|
|
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting, progress, fileName);
|
|
await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
|
users: imported.result.imported.users,
|
|
ciphers: imported.result.imported.ciphers,
|
|
attachments: imported.result.imported.attachmentFiles,
|
|
skippedAttachments: imported.result.skipped.attachments,
|
|
skippedReason: imported.result.skipped.reason,
|
|
replaceExisting,
|
|
...metadata,
|
|
}, request);
|
|
return imported;
|
|
}
|
|
|
|
export async function runScheduledBackupIfDue(env: Env): Promise<void> {
|
|
await runScheduledBackupsInDurableObject(env);
|
|
}
|
|
|
|
export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
void request;
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
return jsonResponse(settings);
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
|
|
}
|
|
}
|
|
|
|
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
let body: BackupSettingsInput;
|
|
try {
|
|
body = await request.json<BackupSettingsInput>();
|
|
} catch {
|
|
return errorResponse('Backup settings payload is invalid', 400);
|
|
}
|
|
|
|
const storage = new StorageService(env.DB);
|
|
let previous;
|
|
try {
|
|
previous = await loadBackupSettings(storage, env, 'UTC');
|
|
} catch {
|
|
previous = getDefaultBackupSettings('UTC');
|
|
}
|
|
|
|
let next;
|
|
try {
|
|
next = normalizeBackupSettingsInput(body, previous);
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings are invalid', 400);
|
|
}
|
|
|
|
await saveBackupSettings(storage, env, next);
|
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.update', 'backup', null, {
|
|
destinationCount: next.destinations.length,
|
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
|
}, request);
|
|
return jsonResponse(next);
|
|
}
|
|
|
|
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
void request;
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const state = await getBackupSettingsRepairState(storage, env, 'UTC');
|
|
return jsonResponse({
|
|
object: 'backup-settings-repair',
|
|
needsRepair: state.needsRepair,
|
|
portable: state.portable,
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings repair state could not be loaded', 409);
|
|
}
|
|
}
|
|
|
|
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
let body: BackupSettingsInput;
|
|
try {
|
|
body = await request.json<BackupSettingsInput>();
|
|
} catch {
|
|
return errorResponse('Backup settings repair payload is invalid', 400);
|
|
}
|
|
|
|
const storage = new StorageService(env.DB);
|
|
let previous;
|
|
try {
|
|
previous = await loadBackupSettings(storage, env, 'UTC');
|
|
} catch {
|
|
previous = getDefaultBackupSettings('UTC');
|
|
}
|
|
|
|
let next;
|
|
try {
|
|
next = normalizeBackupSettingsInput(body, previous);
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Backup settings repair payload is invalid', 400);
|
|
}
|
|
|
|
await repairBackupSettings(storage, env, next);
|
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.settings.repair', 'backup', null, {
|
|
destinationCount: next.destinations.length,
|
|
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
|
|
}, request);
|
|
return jsonResponse(next);
|
|
}
|
|
|
|
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
try {
|
|
let body: { destinationId?: string } | null = null;
|
|
try {
|
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
|
body = await request.json<{ destinationId?: string }>();
|
|
}
|
|
} catch {
|
|
return errorResponse('Backup run payload is invalid', 400);
|
|
}
|
|
|
|
const outcome = await runConfiguredBackupInDurableObject(env, {
|
|
actorUserId: actorUser.id,
|
|
auditMetadata: auditRequestMetadata(request),
|
|
destinationId: body?.destinationId || null,
|
|
targetDeviceIdentifier: String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null,
|
|
trigger: 'manual',
|
|
});
|
|
if (!outcome) {
|
|
return errorResponse('Another backup run is already in progress', 409);
|
|
}
|
|
return jsonResponse({
|
|
object: 'backup-run',
|
|
result: {
|
|
fileName: outcome.result.fileName,
|
|
fileSize: outcome.result.fileSize,
|
|
provider: outcome.result.provider,
|
|
remotePath: outcome.result.remotePath,
|
|
},
|
|
settings: outcome.settings,
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
|
|
}
|
|
}
|
|
|
|
export async function handleListAdminRemoteBackups(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
const url = new URL(request.url);
|
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
const listing = await listRemoteBackupEntries(destination, url.searchParams.get('path') || '');
|
|
return jsonResponse({
|
|
object: 'backup-remote-browser',
|
|
destinationId: destination.id,
|
|
destinationName: destination.name,
|
|
...listing,
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup listing failed', 409);
|
|
}
|
|
}
|
|
|
|
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
const url = new URL(request.url);
|
|
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
|
return new Response(remoteFile.bytes, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': remoteFile.contentType || 'application/zip',
|
|
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`,
|
|
'Cache-Control': 'no-store',
|
|
},
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup download failed', 409);
|
|
}
|
|
}
|
|
|
|
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
const url = new URL(request.url);
|
|
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
|
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
|
return jsonResponse({
|
|
object: 'backup-remote-integrity',
|
|
destinationId: destination.id,
|
|
path,
|
|
fileName: remoteFile.fileName || path.split('/').pop() || path,
|
|
integrity,
|
|
});
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup integrity inspection failed', 409);
|
|
}
|
|
}
|
|
|
|
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
const url = new URL(request.url);
|
|
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
|
await deleteRemoteBackupFile(destination, path);
|
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
|
|
...getBackupDestinationSummary(destination),
|
|
remotePath: path,
|
|
}, request);
|
|
return jsonResponse({ object: 'backup-remote-delete', deleted: true, path });
|
|
} catch (error) {
|
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup delete failed', 409);
|
|
}
|
|
}
|
|
|
|
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
|
|
try {
|
|
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
|
} catch {
|
|
return errorResponse('Remote restore payload is invalid', 400);
|
|
}
|
|
|
|
try {
|
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
|
const imported = await restoreRemoteBackupInDurableObject(env, {
|
|
actorUserId: actorUser.id,
|
|
allowChecksumMismatch: !!body.allowChecksumMismatch,
|
|
auditMetadata: auditRequestMetadata(request),
|
|
destinationId: body.destinationId || null,
|
|
path,
|
|
replaceExisting: !!body.replaceExisting,
|
|
targetDeviceIdentifier,
|
|
});
|
|
if (!imported) {
|
|
return errorResponse('Another backup or restore run is already in progress', 409);
|
|
}
|
|
return jsonResponse(imported);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Remote backup restore failed';
|
|
return errorResponse(message, toImportStatusCode(message));
|
|
}
|
|
}
|
|
|
|
export async function handleAdminExportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
|
|
|
const storage = new StorageService(env.DB);
|
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
|
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 {
|
|
const progress = async (event: {
|
|
step: string;
|
|
fileName?: string;
|
|
stageTitle: string;
|
|
stageDetail: string;
|
|
includeAttachments: boolean;
|
|
}) => {
|
|
await notifyUserBackupProgress(
|
|
env,
|
|
actorUser.id,
|
|
{
|
|
operation: 'backup-export',
|
|
source: 'local',
|
|
step: `export_${event.step}`,
|
|
fileName: event.fileName || '',
|
|
stageTitle: event.stageTitle,
|
|
stageDetail: event.stageDetail,
|
|
},
|
|
targetDeviceIdentifier
|
|
);
|
|
};
|
|
archive = await buildBackupArchive(env, new Date(), {
|
|
includeAttachments: !!body?.includeAttachments,
|
|
progress,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Backup export failed';
|
|
await notifyUserBackupProgress(
|
|
env,
|
|
actorUser.id,
|
|
{
|
|
operation: 'backup-export',
|
|
source: 'local',
|
|
step: 'export_failed',
|
|
fileName: '',
|
|
stageTitle: 'txt_backup_export_progress_failed_title',
|
|
stageDetail: 'txt_backup_export_progress_failed_detail',
|
|
done: true,
|
|
ok: false,
|
|
error: message,
|
|
},
|
|
targetDeviceIdentifier
|
|
);
|
|
return errorResponse(message, message.includes('blob missing') ? 409 : 500);
|
|
}
|
|
|
|
await writeAuditLog(storage, actorUser.id, 'admin.backup.export', 'backup', null, {
|
|
users: archive.manifest.tableCounts.users,
|
|
ciphers: archive.manifest.tableCounts.ciphers,
|
|
attachments: archive.manifest.tableCounts.attachments,
|
|
compressedBytes: archive.bytes.byteLength,
|
|
includesAttachments: archive.manifest.includes.attachments,
|
|
}, request);
|
|
|
|
return new Response(archive.bytes, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'application/zip',
|
|
'Content-Disposition': `attachment; filename="${archive.fileName}"`,
|
|
'Cache-Control': 'no-store',
|
|
},
|
|
});
|
|
}
|
|
|
|
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);
|
|
|
|
let formData: FormData;
|
|
try {
|
|
formData = await request.formData();
|
|
} catch {
|
|
return errorResponse('Content-Type must be multipart/form-data', 400);
|
|
}
|
|
|
|
const file = formData.get('file');
|
|
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
|
|
return errorResponse('Backup file is required', 400);
|
|
}
|
|
|
|
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
|
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
|
|
let archiveBytes: Uint8Array;
|
|
try {
|
|
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
|
|
} catch {
|
|
return errorResponse('Unable to read backup file', 400);
|
|
}
|
|
|
|
try {
|
|
const fileName = 'name' in file ? String((file as File).name || '') : '';
|
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(archiveBytes, fileName);
|
|
if (!checksumOk && !allowChecksumMismatch) {
|
|
return errorResponse('Backup file checksum does not match its filename', 400);
|
|
}
|
|
const imported = await runImportAndAudit(env, request, actorUser, archiveBytes, fileName || 'nodewarden_backup.zip', replaceExisting, {
|
|
trigger: 'local',
|
|
bytes: archiveBytes.byteLength,
|
|
checksumMismatchAccepted: !checksumOk,
|
|
});
|
|
return jsonResponse(imported.result);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Backup import failed';
|
|
return errorResponse(message, toImportStatusCode(message));
|
|
}
|
|
}
|
|
|
|
export async function seedDefaultBackupSettings(env: Env): Promise<void> {
|
|
const storage = new StorageService(env.DB);
|
|
const current = await storage.getConfigValue('backup.settings.v1');
|
|
if (current) {
|
|
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
|
return;
|
|
}
|
|
await saveBackupSettings(storage, env, getDefaultBackupSettings('UTC'));
|
|
}
|