mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
1035 lines
37 KiB
TypeScript
1035 lines
37 KiB
TypeScript
import type { Env, User } from '../types';
|
|
import { errorResponse, jsonResponse } from '../utils/response';
|
|
import { generateUUID } from '../utils/uuid';
|
|
import {
|
|
type BackupArchiveBundle,
|
|
buildBackupArchive,
|
|
inspectBackupArchiveFileNameChecksum,
|
|
verifyBackupArchiveFileNameChecksum,
|
|
} from '../services/backup-archive';
|
|
import {
|
|
type BackupDestinationRecord,
|
|
type BackupSettingsInput,
|
|
BACKUP_SCHEDULER_WINDOW_MINUTES,
|
|
getBackupLocalDateKey,
|
|
getDefaultBackupSettings,
|
|
getBackupSettingsRepairState,
|
|
hasBackupSlotBetween,
|
|
isBackupDueNow,
|
|
loadBackupSettings,
|
|
normalizeBackupSettingsInput,
|
|
normalizeImportedBackupSettings,
|
|
repairBackupSettings,
|
|
requireBackupDestination,
|
|
saveBackupSettings,
|
|
} from '../services/backup-config';
|
|
import {
|
|
type BackupImportExecutionResult,
|
|
type BackupRestoreProgressReporter,
|
|
importBackupArchiveBytes,
|
|
importRemoteBackupArchiveBytes,
|
|
} from '../services/backup-import';
|
|
import {
|
|
type RemoteBackupTransferSession,
|
|
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';
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
const BACKUP_RUNNER_LOCK_KEY = 'backup.runner.lock.v1';
|
|
const BACKUP_RUNNER_LEASE_MS = 10 * 60 * 1000;
|
|
const BACKUP_RUNNER_HEARTBEAT_MS = 30 * 1000;
|
|
|
|
// CONTRACT:
|
|
// The runner lock is a config-row lease, not a queue. It only prevents two
|
|
// backup/restore jobs from overlapping. Manual runs return conflict when the
|
|
// lease is held; scheduled runs skip quietly. Never export this row in backups.
|
|
interface BackupRunnerLease {
|
|
token: string;
|
|
touch: () => Promise<void>;
|
|
release: () => Promise<void>;
|
|
}
|
|
|
|
async function acquireBackupRunnerLease(env: Env, reason: string): Promise<BackupRunnerLease | null> {
|
|
const token = generateUUID();
|
|
const nowMs = Date.now();
|
|
const expiresAtMs = nowMs + BACKUP_RUNNER_LEASE_MS;
|
|
const value = JSON.stringify({
|
|
token,
|
|
reason,
|
|
acquiredAt: new Date(nowMs).toISOString(),
|
|
touchedAt: new Date(nowMs).toISOString(),
|
|
expiresAtMs,
|
|
});
|
|
const result = await env.DB
|
|
.prepare(
|
|
`INSERT INTO config(key, value) VALUES(?, ?)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
WHERE COALESCE(CAST(json_extract(config.value, '$.expiresAtMs') AS INTEGER), 0) <= ?`
|
|
)
|
|
.bind(BACKUP_RUNNER_LOCK_KEY, value, nowMs)
|
|
.run();
|
|
|
|
if ((result.meta?.changes || 0) < 1) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
token,
|
|
touch: async () => {
|
|
const nextNowMs = Date.now();
|
|
const nextValue = JSON.stringify({
|
|
token,
|
|
reason,
|
|
acquiredAt: new Date(nowMs).toISOString(),
|
|
touchedAt: new Date(nextNowMs).toISOString(),
|
|
expiresAtMs: nextNowMs + BACKUP_RUNNER_LEASE_MS,
|
|
});
|
|
await env.DB
|
|
.prepare(
|
|
`UPDATE config
|
|
SET value = ?
|
|
WHERE key = ?
|
|
AND json_extract(value, '$.token') = ?`
|
|
)
|
|
.bind(nextValue, BACKUP_RUNNER_LOCK_KEY, token)
|
|
.run();
|
|
},
|
|
release: async () => {
|
|
await env.DB
|
|
.prepare(
|
|
`DELETE FROM config
|
|
WHERE key = ?
|
|
AND json_extract(value, '$.token') = ?`
|
|
)
|
|
.bind(BACKUP_RUNNER_LOCK_KEY, token)
|
|
.run();
|
|
},
|
|
};
|
|
}
|
|
|
|
async function withBackupRunnerLease<T>(
|
|
env: Env,
|
|
reason: string,
|
|
task: (keepAlive: () => Promise<void>) => Promise<T>
|
|
): Promise<T | null> {
|
|
const lease = await acquireBackupRunnerLease(env, reason);
|
|
if (!lease) return null;
|
|
|
|
let lastHeartbeatAt = 0;
|
|
const keepAlive = async () => {
|
|
const nowMs = Date.now();
|
|
if (nowMs - lastHeartbeatAt < BACKUP_RUNNER_HEARTBEAT_MS) return;
|
|
lastHeartbeatAt = nowMs;
|
|
await lease.touch();
|
|
};
|
|
|
|
try {
|
|
await keepAlive();
|
|
return await task(keepAlive);
|
|
} finally {
|
|
await lease.release();
|
|
}
|
|
}
|
|
|
|
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 }>;
|
|
}
|
|
|
|
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 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);
|
|
let attachmentIndexChanged = false;
|
|
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
|
await touchLease();
|
|
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
|
continue;
|
|
}
|
|
const remotePath = `attachments/${attachment.blobName}`;
|
|
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 remoteSession.putFile(remotePath, bytes, {
|
|
contentType: object.contentType,
|
|
});
|
|
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
|
attachmentIndexChanged = true;
|
|
}
|
|
if (attachmentIndexChanged) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
function toImportStatusCode(message: string): number {
|
|
const lower = message.toLowerCase();
|
|
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;
|
|
}
|
|
|
|
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 withBackupRunnerLease(env, 'scheduled', async (keepAlive) => {
|
|
const storage = new StorageService(env.DB);
|
|
let scanStartMs = Date.now();
|
|
|
|
while (true) {
|
|
await keepAlive();
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
const now = new Date();
|
|
const dueDestinations = settings.destinations.filter((destination) =>
|
|
isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)
|
|
|| hasBackupSlotBetween(destination, new Date(scanStartMs), now)
|
|
);
|
|
|
|
if (!dueDestinations.length) {
|
|
return;
|
|
}
|
|
|
|
scanStartMs = now.getTime();
|
|
for (const destination of dueDestinations) {
|
|
await keepAlive();
|
|
await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id, keepAlive);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
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 targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
|
const progress = async (event: {
|
|
operation: 'backup-remote-run';
|
|
step: string;
|
|
fileName: string;
|
|
stageTitle: string;
|
|
stageDetail: string;
|
|
done?: boolean;
|
|
ok?: boolean;
|
|
error?: string | null;
|
|
}) => {
|
|
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
|
|
};
|
|
const outcome = await withBackupRunnerLease(env, `manual:${actorUser.id}`, async (keepAlive) => {
|
|
const storage = new StorageService(env.DB);
|
|
const result = await executeConfiguredBackup(
|
|
env,
|
|
storage,
|
|
actorUser.id,
|
|
'manual',
|
|
body?.destinationId || null,
|
|
keepAlive,
|
|
progress,
|
|
auditRequestMetadata(request)
|
|
);
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
return { result, settings };
|
|
});
|
|
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);
|
|
}
|
|
|
|
const storage = new StorageService(env.DB);
|
|
try {
|
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
|
const restoreFileNameFromPath = path.split('/').pop() || path;
|
|
await notifyUserBackupRestoreProgress(
|
|
env,
|
|
actorUser.id,
|
|
{
|
|
operation: 'backup-restore',
|
|
source: 'remote',
|
|
step: 'remote_fetch_archive',
|
|
fileName: restoreFileNameFromPath,
|
|
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
|
|
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
|
|
replaceExisting: !!body.replaceExisting,
|
|
},
|
|
targetDeviceIdentifier
|
|
);
|
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
|
if (!checksumOk && !body.allowChecksumMismatch) {
|
|
return errorResponse('Remote backup file checksum does not match its filename', 400);
|
|
}
|
|
const restoreFileName = remoteFile.fileName || path.split('/').pop() || path;
|
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
|
await notifyUserBackupRestoreProgress(
|
|
env,
|
|
actorUser.id,
|
|
{
|
|
operation: 'backup-restore',
|
|
...event,
|
|
},
|
|
targetDeviceIdentifier
|
|
);
|
|
};
|
|
const imported = await (async () => {
|
|
const storage = new StorageService(env.DB);
|
|
const result = await importRemoteBackupArchiveBytes(
|
|
remoteFile.bytes,
|
|
env,
|
|
actorUser.id,
|
|
!!body.replaceExisting,
|
|
{
|
|
loadAttachment: async (blobName) => {
|
|
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
|
|
return file?.bytes || 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: !!body.replaceExisting,
|
|
...getBackupDestinationSummary(destination),
|
|
remotePath: path,
|
|
bytes: remoteFile.bytes.byteLength,
|
|
trigger: 'remote',
|
|
checksumMismatchAccepted: !checksumOk,
|
|
}, request);
|
|
return result;
|
|
})();
|
|
return jsonResponse(imported.result);
|
|
} 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'));
|
|
}
|