mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add backup recommendations and update backup strategy UI
- Introduced new backup recommendations feature with interfaces for recommended storage providers. - Updated i18n translations for backup strategy to reflect new terminology and improved descriptions. - Enhanced types with optional private and public keys in user profiles. - Redesigned backup-related styles for better layout and responsiveness. - Updated TypeScript configuration to include shared modules. - Configured Vite to resolve shared modules and allow filesystem access. - Added cron triggers for periodic tasks in Wrangler configuration.
This commit is contained in:
+392
-602
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { NotificationsHub } from './durable/notifications-hub';
|
||||
import { handleRequest } from './router';
|
||||
import { StorageService } from './services/storage';
|
||||
import { applyCors, jsonResponse } from './utils/response';
|
||||
import { runScheduledBackupIfDue, seedDefaultBackupSettings } from './handlers/backup';
|
||||
|
||||
let dbInitialized = false;
|
||||
let dbInitError: string | null = null;
|
||||
@@ -15,6 +16,7 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||
dbInitPromise = (async () => {
|
||||
const storage = new StorageService(env.DB);
|
||||
await storage.initializeDatabase();
|
||||
await seedDefaultBackupSettings(env);
|
||||
dbInitialized = true;
|
||||
dbInitError = null;
|
||||
})()
|
||||
@@ -54,6 +56,18 @@ export default {
|
||||
const resp = await handleRequest(request, env);
|
||||
return applyCors(request, resp);
|
||||
},
|
||||
|
||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||
void controller;
|
||||
await ensureDatabaseInitialized(env);
|
||||
if (dbInitError) {
|
||||
console.error('Skipping scheduled backup because DB init failed:', dbInitError);
|
||||
return;
|
||||
}
|
||||
ctx.waitUntil(runScheduledBackupIfDue(env).catch((error) => {
|
||||
console.error('Scheduled backup failed:', error);
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
export { NotificationsHub };
|
||||
|
||||
@@ -107,7 +107,16 @@ import {
|
||||
} from './handlers/admin';
|
||||
import {
|
||||
handleAdminExportBackup,
|
||||
handleDownloadAdminRemoteBackup,
|
||||
handleDeleteAdminRemoteBackup,
|
||||
handleGetAdminBackupSettings,
|
||||
handleGetAdminBackupSettingsRepairState,
|
||||
handleAdminImportBackup,
|
||||
handleListAdminRemoteBackups,
|
||||
handleRepairAdminBackupSettings,
|
||||
handleRestoreAdminRemoteBackup,
|
||||
handleRunAdminConfiguredBackup,
|
||||
handleUpdateAdminBackupSettings,
|
||||
} from './handlers/backup';
|
||||
import {
|
||||
handleNotificationsHub,
|
||||
@@ -824,6 +833,36 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
return handleAdminExportBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/settings') {
|
||||
if (method === 'GET') return handleGetAdminBackupSettings(request, env, currentUser);
|
||||
if (method === 'PUT') return handleUpdateAdminBackupSettings(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/settings/repair') {
|
||||
if (method === 'GET') return handleGetAdminBackupSettingsRepairState(request, env, currentUser);
|
||||
if (method === 'POST') return handleRepairAdminBackupSettings(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/run' && method === 'POST') {
|
||||
return handleRunAdminConfiguredBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote' && method === 'GET') {
|
||||
return handleListAdminRemoteBackups(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
|
||||
return handleDownloadAdminRemoteBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
|
||||
return handleDeleteAdminRemoteBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/restore' && method === 'POST') {
|
||||
return handleRestoreAdminRemoteBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/import' && method === 'POST') {
|
||||
return handleAdminImportBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { zipSync, unzipSync } from 'fflate';
|
||||
import type { Env } from '../types';
|
||||
import {
|
||||
getAttachmentObjectKey,
|
||||
getBlobObject,
|
||||
getBlobStorageKind,
|
||||
getSendFileObjectKey,
|
||||
} from './blob-store';
|
||||
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
|
||||
const BACKUP_FORMAT_VERSION = 1;
|
||||
const BACKUP_APP_VERSION = '1.3.0';
|
||||
const BACKUP_ZIP_COMPRESSION_LEVEL = 6;
|
||||
const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000;
|
||||
const MAX_BACKUP_EXTRACTED_BYTES = 128 * 1024 * 1024;
|
||||
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
export interface BackupManifest {
|
||||
formatVersion: 1;
|
||||
exportedAt: string;
|
||||
appVersion: string;
|
||||
storageKind: 'r2' | 'kv' | null;
|
||||
tableCounts: Record<string, number>;
|
||||
includes: {
|
||||
attachments: boolean;
|
||||
sendFiles: boolean;
|
||||
};
|
||||
blobSummary: {
|
||||
attachmentFiles: number;
|
||||
sendFiles: number;
|
||||
totalBytes: number;
|
||||
largestObjectBytes: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BackupPayload {
|
||||
manifest: BackupManifest;
|
||||
db: {
|
||||
config: SqlRow[];
|
||||
users: SqlRow[];
|
||||
user_revisions: SqlRow[];
|
||||
folders: SqlRow[];
|
||||
ciphers: SqlRow[];
|
||||
attachments: SqlRow[];
|
||||
sends: SqlRow[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface BackupArchiveBundle {
|
||||
bytes: Uint8Array;
|
||||
fileName: string;
|
||||
manifest: BackupManifest;
|
||||
}
|
||||
|
||||
export function parseSendFileId(data: string | null): string | null {
|
||||
if (!data) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
return typeof parsed.id === 'string' && parsed.id.trim() ? parsed.id.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
async function streamToBytes(stream: ReadableStream | null): Promise<Uint8Array> {
|
||||
if (!stream) return new Uint8Array();
|
||||
const buffer = await new Response(stream).arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
function buildBackupFileName(date: Date = new Date()): string {
|
||||
const parts = [
|
||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||
date.getUTCDate().toString().padStart(2, '0'),
|
||||
date.getUTCHours().toString().padStart(2, '0'),
|
||||
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`;
|
||||
}
|
||||
|
||||
function validateArchiveSize(bytes: Uint8Array): void {
|
||||
if (bytes.byteLength > MAX_BACKUP_ARCHIVE_BYTES) {
|
||||
throw new Error(`Backup archive is too large. The current restore limit is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))} MiB`);
|
||||
}
|
||||
}
|
||||
|
||||
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
|
||||
const entries: string[] = [];
|
||||
for (const row of db.attachments) {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
if (!cipherId || !attachmentId) continue;
|
||||
entries.push(`attachments/${cipherId}/${attachmentId}.bin`);
|
||||
}
|
||||
for (const row of db.sends) {
|
||||
const sendId = String(row.id || '').trim();
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (!sendId || !fileId) continue;
|
||||
entries.push(`send-files/${sendId}/${fileId}.bin`);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function ensureRowArray(value: unknown, table: string): SqlRow[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Backup archive table ${table} is invalid`);
|
||||
}
|
||||
return value as SqlRow[];
|
||||
}
|
||||
|
||||
export function parseBackupArchive(bytes: Uint8Array): { payload: BackupPayload; files: Record<string, Uint8Array> } {
|
||||
validateArchiveSize(bytes);
|
||||
let zipped: Record<string, Uint8Array>;
|
||||
try {
|
||||
zipped = unzipSync(bytes);
|
||||
} catch {
|
||||
throw new Error('Invalid backup archive');
|
||||
}
|
||||
|
||||
const entryNames = Object.keys(zipped);
|
||||
if (entryNames.length > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
|
||||
throw new Error('Backup archive contains too many files');
|
||||
}
|
||||
|
||||
let totalExtractedBytes = 0;
|
||||
for (const entry of entryNames) {
|
||||
const entryBytes = zipped[entry];
|
||||
totalExtractedBytes += entryBytes.byteLength;
|
||||
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
|
||||
throw new Error('Backup archive database payload is too large');
|
||||
}
|
||||
if (totalExtractedBytes > MAX_BACKUP_EXTRACTED_BYTES) {
|
||||
throw new Error('Backup archive expands beyond the current restore limit');
|
||||
}
|
||||
}
|
||||
|
||||
const manifestBytes = zipped['manifest.json'];
|
||||
const dbBytes = zipped['db.json'];
|
||||
if (!manifestBytes || !dbBytes) {
|
||||
throw new Error('Backup archive is missing manifest.json or db.json');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let manifest: BackupManifest;
|
||||
let db: BackupPayload['db'];
|
||||
try {
|
||||
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
|
||||
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db'];
|
||||
} catch {
|
||||
throw new Error('Backup archive contains invalid JSON metadata');
|
||||
}
|
||||
|
||||
if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) {
|
||||
throw new Error('Unsupported backup format version');
|
||||
}
|
||||
if (!db || typeof db !== 'object') {
|
||||
throw new Error('Backup archive database payload is invalid');
|
||||
}
|
||||
|
||||
const requiredEntries = getRequiredZipEntries(db);
|
||||
for (const entry of requiredEntries) {
|
||||
if (!zipped[entry]) {
|
||||
throw new Error(`Backup archive is missing required file: ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
payload: { manifest, db },
|
||||
files: zipped,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateBackupPayloadContents(payload: BackupPayload, files: Record<string, Uint8Array>): void {
|
||||
const configRows = ensureRowArray(payload.db.config, 'config');
|
||||
const userRows = ensureRowArray(payload.db.users, 'users');
|
||||
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
||||
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||
const sendRows = ensureRowArray(payload.db.sends, 'sends');
|
||||
|
||||
const userIds = new Set<string>();
|
||||
for (const row of userRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const email = String(row.email || '').trim();
|
||||
if (!id || !email) throw new Error('Backup archive contains an invalid user row');
|
||||
if (userIds.has(id)) throw new Error(`Backup archive contains duplicate user id: ${id}`);
|
||||
userIds.add(id);
|
||||
}
|
||||
|
||||
for (const row of configRows) {
|
||||
const key = String(row.key || '').trim();
|
||||
if (!key) throw new Error('Backup archive contains an invalid config row');
|
||||
}
|
||||
|
||||
for (const row of revisionRows) {
|
||||
const userId = String(row.user_id || '').trim();
|
||||
if (!userId || !userIds.has(userId)) {
|
||||
throw new Error(`Backup archive contains a revision for an unknown user: ${userId || '(empty)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
const folderIds = new Set<string>();
|
||||
for (const row of folderRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const userId = String(row.user_id || '').trim();
|
||||
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid folder row');
|
||||
if (folderIds.has(id)) throw new Error(`Backup archive contains duplicate folder id: ${id}`);
|
||||
folderIds.add(id);
|
||||
}
|
||||
|
||||
const cipherIds = new Set<string>();
|
||||
for (const row of cipherRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const userId = String(row.user_id || '').trim();
|
||||
const folderId = String(row.folder_id || '').trim();
|
||||
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid cipher row');
|
||||
if (folderId && !folderIds.has(folderId)) {
|
||||
throw new Error(`Backup archive contains a cipher for an unknown folder: ${folderId}`);
|
||||
}
|
||||
if (cipherIds.has(id)) throw new Error(`Backup archive contains duplicate cipher id: ${id}`);
|
||||
cipherIds.add(id);
|
||||
}
|
||||
|
||||
for (const row of attachmentRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
if (!id || !cipherId || !cipherIds.has(cipherId)) {
|
||||
throw new Error('Backup archive contains an invalid attachment row');
|
||||
}
|
||||
if (!files[`attachments/${cipherId}/${id}.bin`]) {
|
||||
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
|
||||
}
|
||||
}
|
||||
|
||||
const sendIds = new Set<string>();
|
||||
for (const row of sendRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const userId = String(row.user_id || '').trim();
|
||||
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid send row');
|
||||
if (sendIds.has(id)) throw new Error(`Backup archive contains duplicate send id: ${id}`);
|
||||
sendIds.add(id);
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (fileId && !files[`send-files/${id}/${fileId}.bin`]) {
|
||||
throw new Error(`Backup archive is missing required file: send-files/${id}/${fileId}.bin`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildBackupArchive(env: Env, date: Date = new Date()): Promise<BackupArchiveBundle> {
|
||||
const encoder = new TextEncoder();
|
||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows, sendRows] = await Promise.all([
|
||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends ORDER BY created_at ASC'),
|
||||
]);
|
||||
|
||||
let attachmentBlobCount = 0;
|
||||
let sendFileBlobCount = 0;
|
||||
let totalBlobBytes = 0;
|
||||
let largestObjectBytes = 0;
|
||||
const manifestBase = {
|
||||
formatVersion: BACKUP_FORMAT_VERSION,
|
||||
exportedAt: date.toISOString(),
|
||||
appVersion: BACKUP_APP_VERSION,
|
||||
storageKind: getBlobStorageKind(env),
|
||||
tableCounts: {
|
||||
config: configRows.length,
|
||||
users: userRows.length,
|
||||
user_revisions: revisionRows.length,
|
||||
folders: folderRows.length,
|
||||
ciphers: cipherRows.length,
|
||||
attachments: attachmentRows.length,
|
||||
sends: sendRows.length,
|
||||
},
|
||||
includes: {
|
||||
attachments: true,
|
||||
sendFiles: true,
|
||||
},
|
||||
blobSummary: {
|
||||
attachmentFiles: 0,
|
||||
sendFiles: 0,
|
||||
totalBytes: 0,
|
||||
largestObjectBytes: 0,
|
||||
},
|
||||
} satisfies BackupManifest;
|
||||
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, 2)),
|
||||
'db.json': encoder.encode(JSON.stringify({
|
||||
config: configRows,
|
||||
users: userRows,
|
||||
user_revisions: revisionRows,
|
||||
folders: folderRows,
|
||||
ciphers: cipherRows,
|
||||
attachments: attachmentRows,
|
||||
sends: sendRows,
|
||||
}, null, 2)),
|
||||
};
|
||||
|
||||
for (const row of attachmentRows) {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
if (!cipherId || !attachmentId) continue;
|
||||
const object = await getBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId));
|
||||
if (!object) {
|
||||
throw new Error(`Attachment blob missing for ${cipherId}/${attachmentId}`);
|
||||
}
|
||||
const bytes = await streamToBytes(object.body);
|
||||
files[`attachments/${cipherId}/${attachmentId}.bin`] = bytes;
|
||||
attachmentBlobCount += 1;
|
||||
totalBlobBytes += bytes.byteLength;
|
||||
largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength);
|
||||
}
|
||||
|
||||
for (const row of sendRows) {
|
||||
const sendId = String(row.id || '').trim();
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (!sendId || !fileId) continue;
|
||||
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||
if (!object) {
|
||||
throw new Error(`Send file blob missing for ${sendId}/${fileId}`);
|
||||
}
|
||||
const bytes = await streamToBytes(object.body);
|
||||
files[`send-files/${sendId}/${fileId}.bin`] = bytes;
|
||||
sendFileBlobCount += 1;
|
||||
totalBlobBytes += bytes.byteLength;
|
||||
largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength);
|
||||
}
|
||||
|
||||
const manifest: BackupManifest = {
|
||||
...manifestBase,
|
||||
blobSummary: {
|
||||
attachmentFiles: attachmentBlobCount,
|
||||
sendFiles: sendFileBlobCount,
|
||||
totalBytes: totalBlobBytes,
|
||||
largestObjectBytes,
|
||||
},
|
||||
};
|
||||
files['manifest.json'] = encoder.encode(JSON.stringify(manifest, null, 2));
|
||||
|
||||
return {
|
||||
bytes: zipSync(files, { level: BACKUP_ZIP_COMPRESSION_LEVEL }),
|
||||
fileName: buildBackupFileName(date),
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import type { Env } from '../types';
|
||||
import { StorageService } from './storage';
|
||||
import {
|
||||
type BackupSettingsPortableEnvelope,
|
||||
decryptBackupSettingsRuntime,
|
||||
encryptBackupSettingsEnvelope,
|
||||
parseBackupSettingsEnvelope,
|
||||
} from './backup-settings-crypto';
|
||||
import {
|
||||
BACKUP_DEFAULT_SCHEDULE_TIME,
|
||||
BACKUP_DEFAULT_TIMEZONE,
|
||||
type BackupDestinationConfig,
|
||||
type BackupDestinationRecord,
|
||||
type BackupDestinationType,
|
||||
type BackupRuntimeState,
|
||||
type BackupScheduleConfig,
|
||||
type BackupScheduleFrequency,
|
||||
type BackupSettings,
|
||||
type E3BackupDestination,
|
||||
type PlaceholderBackupDestination,
|
||||
type WebDavBackupDestination,
|
||||
createBackupRandomId,
|
||||
createDefaultBackupDestinationName,
|
||||
createDefaultBackupScheduleConfig,
|
||||
createDefaultBackupSettings as createSharedDefaultBackupSettings,
|
||||
} from '../../shared/backup';
|
||||
|
||||
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
||||
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||
const MAX_BACKUP_DESTINATIONS = 24;
|
||||
|
||||
export type {
|
||||
BackupDestinationConfig,
|
||||
BackupDestinationRecord,
|
||||
BackupDestinationType,
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
BackupSettings,
|
||||
E3BackupDestination,
|
||||
PlaceholderBackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '../../shared/backup';
|
||||
|
||||
export interface BackupSettingsInput {
|
||||
destinations?: unknown;
|
||||
}
|
||||
|
||||
export interface BackupSettingsRepairState {
|
||||
needsRepair: boolean;
|
||||
portable: BackupSettingsPortableEnvelope | null;
|
||||
}
|
||||
|
||||
function defaultScheduleConfig(timezone: string = 'UTC'): BackupScheduleConfig {
|
||||
return { ...createDefaultBackupScheduleConfig(assertValidTimeZone(timezone)) };
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return String(value ?? '').trim();
|
||||
}
|
||||
|
||||
function normalizePath(value: unknown): string {
|
||||
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
function assertValidTimeZone(timezone: string): string {
|
||||
try {
|
||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
||||
return timezone;
|
||||
} catch {
|
||||
throw new Error('Invalid backup timezone');
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidScheduleTime(value: string): string {
|
||||
if (!/^\d{2}:\d{2}$/.test(value)) {
|
||||
throw new Error('Backup time must use HH:MM format');
|
||||
}
|
||||
const [hoursRaw, minutesRaw] = value.split(':');
|
||||
const hours = Number(hoursRaw);
|
||||
const minutes = Number(minutesRaw);
|
||||
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
||||
throw new Error('Backup time is invalid');
|
||||
}
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function normalizeRetentionCount(value: unknown, fallback: number | null = 30): number | null {
|
||||
if (value === undefined) return fallback;
|
||||
if (value === null || String(value).trim() === '') return null;
|
||||
const count = Number(value);
|
||||
if (!Number.isInteger(count) || count < 1 || count > 1000) {
|
||||
throw new Error('Backup retention count must be between 1 and 1000');
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function normalizeScheduleFrequency(
|
||||
value: unknown,
|
||||
fallback: BackupScheduleFrequency = 'daily'
|
||||
): BackupScheduleFrequency {
|
||||
const frequency = asTrimmedString(value) || fallback;
|
||||
if (frequency !== 'daily' && frequency !== 'weekly' && frequency !== 'monthly') {
|
||||
throw new Error('Backup frequency is invalid');
|
||||
}
|
||||
return frequency;
|
||||
}
|
||||
|
||||
function normalizeDayOfWeek(value: unknown, fallback: number = 1): number {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
const day = Number(value);
|
||||
if (!Number.isInteger(day) || day < 0 || day > 6) {
|
||||
throw new Error('Backup day of week is invalid');
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
function normalizeDayOfMonth(value: unknown, fallback: number = 1): number {
|
||||
if (value === undefined || value === null || value === '') return fallback;
|
||||
const day = Number(value);
|
||||
if (!Number.isInteger(day) || day < 1 || day > 31) {
|
||||
throw new Error('Backup day of month must be between 1 and 31');
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
const endpoint = asTrimmedString(source.endpoint);
|
||||
const bucket = asTrimmedString(source.bucket);
|
||||
const accessKeyId = asTrimmedString(source.accessKeyId);
|
||||
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
||||
const region = asTrimmedString(source.region) || 'auto';
|
||||
const rootPath = normalizePath(source.rootPath);
|
||||
|
||||
if (!allowIncomplete || endpoint) {
|
||||
if (!endpoint) throw new Error('E3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('E3 endpoint must start with http:// or https://');
|
||||
}
|
||||
if (!allowIncomplete || bucket) {
|
||||
if (!bucket) throw new Error('E3 bucket is required');
|
||||
}
|
||||
if (!allowIncomplete || accessKeyId) {
|
||||
if (!accessKeyId) throw new Error('E3 access key is required');
|
||||
}
|
||||
if (!allowIncomplete || secretAccessKey) {
|
||||
if (!secretAccessKey) throw new Error('E3 secret key is required');
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
||||
bucket,
|
||||
region,
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
rootPath,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeWebDavDestination(value: unknown, allowIncomplete = false): WebDavBackupDestination {
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
const baseUrl = asTrimmedString(source.baseUrl);
|
||||
const username = asTrimmedString(source.username);
|
||||
const password = String(source.password ?? '');
|
||||
const remotePath = normalizePath(source.remotePath);
|
||||
|
||||
if (!allowIncomplete || baseUrl) {
|
||||
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
||||
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
|
||||
}
|
||||
if (!allowIncomplete || username) {
|
||||
if (!username) throw new Error('WebDAV username is required');
|
||||
}
|
||||
if (!allowIncomplete || password) {
|
||||
if (!password) throw new Error('WebDAV password is required');
|
||||
}
|
||||
|
||||
return {
|
||||
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
|
||||
username,
|
||||
password,
|
||||
remotePath,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlaceholderDestination(value: unknown): PlaceholderBackupDestination {
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
return {
|
||||
providerName: asTrimmedString(source.providerName) || 'Reserved',
|
||||
notes: asTrimmedString(source.notes),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDestination(
|
||||
destinationType: BackupDestinationType,
|
||||
destination: unknown,
|
||||
allowIncomplete = false
|
||||
): BackupDestinationConfig {
|
||||
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
|
||||
if (destinationType === 'webdav') return normalizeWebDavDestination(destination, allowIncomplete);
|
||||
return normalizePlaceholderDestination(destination);
|
||||
}
|
||||
|
||||
function normalizeRuntime(value: unknown): BackupRuntimeState {
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
const asIso = (input: unknown): string | null => {
|
||||
const raw = asTrimmedString(input);
|
||||
if (!raw) return null;
|
||||
const date = new Date(raw);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
};
|
||||
const asMaybeNumber = (input: unknown): number | null => {
|
||||
if (input === null || input === undefined || input === '') return null;
|
||||
const n = Number(input);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
||||
};
|
||||
return {
|
||||
lastAttemptAt: asIso(source.lastAttemptAt),
|
||||
lastAttemptLocalDate: asTrimmedString(source.lastAttemptLocalDate) || null,
|
||||
lastSuccessAt: asIso(source.lastSuccessAt),
|
||||
lastErrorAt: asIso(source.lastErrorAt),
|
||||
lastErrorMessage: asTrimmedString(source.lastErrorMessage) || null,
|
||||
lastUploadedFileName: asTrimmedString(source.lastUploadedFileName) || null,
|
||||
lastUploadedSizeBytes: asMaybeNumber(source.lastUploadedSizeBytes),
|
||||
lastUploadedDestination: asTrimmedString(source.lastUploadedDestination) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultDestinationName(type: BackupDestinationType, index: number): string {
|
||||
return createDefaultBackupDestinationName(type, index);
|
||||
}
|
||||
|
||||
function getDestinationType(raw: unknown): BackupDestinationType {
|
||||
const value = asTrimmedString(raw);
|
||||
if (value === 'e3' || value === 'webdav' || value === 'placeholder') return value;
|
||||
throw new Error('Backup destination type is invalid');
|
||||
}
|
||||
|
||||
function normalizeDestinationRecord(
|
||||
input: unknown,
|
||||
previousById: Map<string, BackupDestinationRecord>,
|
||||
index: number,
|
||||
fallbackTimezone: string
|
||||
): BackupDestinationRecord {
|
||||
if (!isPlainObject(input)) {
|
||||
throw new Error('Backup destination is invalid');
|
||||
}
|
||||
|
||||
const id = asTrimmedString(input.id) || createBackupRandomId();
|
||||
const type = getDestinationType(input.type);
|
||||
const previous = previousById.get(id);
|
||||
const runtime = previous?.runtime ? normalizeRuntime(previous.runtime) : normalizeRuntime(input.runtime);
|
||||
const name = asTrimmedString(input.name) || previous?.name || defaultDestinationName(type, index + 1);
|
||||
const scheduleSource = isPlainObject(input.schedule) ? input.schedule : {};
|
||||
const previousSchedule = previous?.schedule || defaultScheduleConfig(fallbackTimezone);
|
||||
const retentionSource = Object.prototype.hasOwnProperty.call(scheduleSource, 'retentionCount')
|
||||
? scheduleSource.retentionCount
|
||||
: previousSchedule.retentionCount;
|
||||
const schedule: BackupScheduleConfig = {
|
||||
enabled: !!(scheduleSource.enabled ?? previousSchedule.enabled),
|
||||
frequency: normalizeScheduleFrequency(scheduleSource.frequency ?? previousSchedule.frequency, previousSchedule.frequency),
|
||||
scheduleTime: assertValidScheduleTime(asTrimmedString(scheduleSource.scheduleTime ?? previousSchedule.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME),
|
||||
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||
dayOfWeek: normalizeDayOfWeek(scheduleSource.dayOfWeek ?? previousSchedule.dayOfWeek, previousSchedule.dayOfWeek),
|
||||
dayOfMonth: normalizeDayOfMonth(scheduleSource.dayOfMonth ?? previousSchedule.dayOfMonth, previousSchedule.dayOfMonth),
|
||||
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||
};
|
||||
|
||||
if (schedule.enabled && type === 'placeholder') {
|
||||
throw new Error('The reserved backup destination is not available yet');
|
||||
}
|
||||
|
||||
const destination = normalizeDestination(type, input.destination, !schedule.enabled);
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
destination,
|
||||
schedule,
|
||||
runtime,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings {
|
||||
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
||||
const destinationType: BackupDestinationType =
|
||||
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav' || destinationTypeRaw === 'placeholder'
|
||||
? destinationTypeRaw
|
||||
: 'webdav';
|
||||
const destination = {
|
||||
id: createBackupRandomId(),
|
||||
name: defaultDestinationName(destinationType, 1),
|
||||
type: destinationType,
|
||||
destination: normalizeDestination(destinationType, rawValue.destination),
|
||||
schedule: {
|
||||
enabled: !!rawValue.enabled,
|
||||
frequency: 'daily',
|
||||
scheduleTime: assertValidScheduleTime(asTrimmedString(rawValue.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME),
|
||||
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||
dayOfWeek: 1,
|
||||
dayOfMonth: 1,
|
||||
retentionCount: 30,
|
||||
},
|
||||
runtime: normalizeRuntime(rawValue.runtime),
|
||||
} satisfies BackupDestinationRecord;
|
||||
|
||||
return {
|
||||
destinations: [destination],
|
||||
};
|
||||
}
|
||||
|
||||
function parseDestinations(
|
||||
rawDestinations: unknown,
|
||||
previousById: Map<string, BackupDestinationRecord>,
|
||||
fallbackTimezone: string
|
||||
): BackupDestinationRecord[] {
|
||||
if (!Array.isArray(rawDestinations)) {
|
||||
throw new Error('Backup destinations are invalid');
|
||||
}
|
||||
if (rawDestinations.length > MAX_BACKUP_DESTINATIONS) {
|
||||
throw new Error(`You can save up to ${MAX_BACKUP_DESTINATIONS} backup destinations`);
|
||||
}
|
||||
|
||||
const destinations = rawDestinations.map((entry, index) => normalizeDestinationRecord(entry, previousById, index, fallbackTimezone));
|
||||
const ids = new Set<string>();
|
||||
for (const destination of destinations) {
|
||||
if (ids.has(destination.id)) {
|
||||
throw new Error('Backup destination ids must be unique');
|
||||
}
|
||||
ids.add(destination.id);
|
||||
}
|
||||
return destinations;
|
||||
}
|
||||
|
||||
function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<string, BackupDestinationRecord> {
|
||||
return new Map(destinations.map((destination) => [destination.id, destination]));
|
||||
}
|
||||
|
||||
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
|
||||
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
||||
}
|
||||
|
||||
export function parseBackupSettings(raw: string | null, fallbackTimezone: string = 'UTC'): BackupSettings {
|
||||
if (!raw) return getDefaultBackupSettings(fallbackTimezone);
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (Array.isArray(parsed.destinations)) {
|
||||
const globalScheduleTime = assertValidScheduleTime(asTrimmedString(parsed.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME);
|
||||
const globalTimezone = assertValidTimeZone(asTrimmedString(parsed.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE);
|
||||
const globalEnabled = !!parsed.enabled;
|
||||
const activeDestinationIdRaw = asTrimmedString(parsed.activeDestinationId);
|
||||
const previousById = new Map<string, BackupDestinationRecord>();
|
||||
const normalizedEntries = (parsed.destinations as unknown[]).map((entry) => {
|
||||
if (!isPlainObject(entry)) return entry;
|
||||
if (isPlainObject(entry.schedule)) return entry;
|
||||
const entryId = asTrimmedString(entry.id);
|
||||
const scheduleEnabled = globalEnabled && (!activeDestinationIdRaw || entryId === activeDestinationIdRaw);
|
||||
return {
|
||||
...entry,
|
||||
schedule: {
|
||||
enabled: scheduleEnabled,
|
||||
frequency: 'daily',
|
||||
scheduleTime: globalScheduleTime,
|
||||
timezone: globalTimezone,
|
||||
dayOfWeek: 1,
|
||||
dayOfMonth: 1,
|
||||
retentionCount: 30,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
destinations: parseDestinations(normalizedEntries, previousById, fallbackTimezone),
|
||||
};
|
||||
}
|
||||
return parseLegacyBackupSettings(parsed, fallbackTimezone);
|
||||
} catch {
|
||||
return getDefaultBackupSettings(fallbackTimezone);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeBackupSettingsInput(
|
||||
input: BackupSettingsInput,
|
||||
previous: BackupSettings
|
||||
): BackupSettings {
|
||||
if (!isPlainObject(input)) {
|
||||
throw new Error('Backup settings payload is invalid');
|
||||
}
|
||||
|
||||
const previousById = mapDestinationsById(previous.destinations);
|
||||
const rawDestinations = input.destinations ?? previous.destinations;
|
||||
const destinations = parseDestinations(rawDestinations, previousById, BACKUP_DEFAULT_TIMEZONE);
|
||||
|
||||
return {
|
||||
destinations,
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeBackupSettings(settings: BackupSettings): string {
|
||||
return JSON.stringify(settings);
|
||||
}
|
||||
|
||||
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||
if (!raw) {
|
||||
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
const envelope = parseBackupSettingsEnvelope(raw);
|
||||
if (!envelope) {
|
||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return settings;
|
||||
}
|
||||
|
||||
try {
|
||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||
return parseBackupSettings(decrypted, fallbackTimezone);
|
||||
} catch {
|
||||
throw new Error('Backup settings need administrator reactivation after restore');
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
||||
const users = await storage.getAllUsers();
|
||||
const hasPortableAdmins = users.some(
|
||||
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||
);
|
||||
if (!hasPortableAdmins) {
|
||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
|
||||
return;
|
||||
}
|
||||
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
||||
}
|
||||
|
||||
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 envelope = parseBackupSettingsEnvelope(raw);
|
||||
if (envelope) {
|
||||
try {
|
||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return;
|
||||
} catch {
|
||||
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
||||
return;
|
||||
}
|
||||
}
|
||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
}
|
||||
|
||||
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
|
||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||
if (!raw) {
|
||||
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return { needsRepair: false, portable: null };
|
||||
}
|
||||
|
||||
const envelope = parseBackupSettingsEnvelope(raw);
|
||||
if (!envelope) {
|
||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
return { needsRepair: false, portable: null };
|
||||
}
|
||||
|
||||
try {
|
||||
await decryptBackupSettingsRuntime(raw, env);
|
||||
return { needsRepair: false, portable: null };
|
||||
} catch {
|
||||
return {
|
||||
needsRepair: true,
|
||||
portable: envelope.portable,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function repairBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
||||
await saveBackupSettings(storage, env, settings);
|
||||
}
|
||||
|
||||
export function findBackupDestination(
|
||||
settings: BackupSettings,
|
||||
destinationId: string | null | undefined
|
||||
): BackupDestinationRecord | null {
|
||||
const normalizedId = asTrimmedString(destinationId);
|
||||
if (!normalizedId) return null;
|
||||
return settings.destinations.find((destination) => destination.id === normalizedId) || null;
|
||||
}
|
||||
|
||||
export function requireBackupDestination(settings: BackupSettings, destinationId?: string | null): BackupDestinationRecord {
|
||||
const destination = destinationId ? findBackupDestination(settings, destinationId) : settings.destinations[0] || null;
|
||||
if (!destination) {
|
||||
throw new Error('Backup destination not found');
|
||||
}
|
||||
return destination;
|
||||
}
|
||||
|
||||
function getDateTimeParts(date: Date, timezone: string): { year: string; month: string; day: string; hour: string; minute: string } {
|
||||
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
});
|
||||
const parts = formatter.formatToParts(date);
|
||||
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||
return {
|
||||
year: pick('year'),
|
||||
month: pick('month'),
|
||||
day: pick('day'),
|
||||
hour: pick('hour'),
|
||||
minute: pick('minute'),
|
||||
};
|
||||
}
|
||||
|
||||
function getLocalWeekday(date: Date, timezone: string): number {
|
||||
const value = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: timezone,
|
||||
weekday: 'short',
|
||||
}).format(date);
|
||||
const map: Record<string, number> = {
|
||||
Sun: 0,
|
||||
Mon: 1,
|
||||
Tue: 2,
|
||||
Wed: 3,
|
||||
Thu: 4,
|
||||
Fri: 5,
|
||||
Sat: 6,
|
||||
};
|
||||
return map[value] ?? 0;
|
||||
}
|
||||
|
||||
function getMonthLastDay(date: Date, timezone: string): number {
|
||||
const { year, month } = getDateTimeParts(date, timezone);
|
||||
const utcDate = new Date(Date.UTC(Number(year), Number(month), 0));
|
||||
return utcDate.getUTCDate();
|
||||
}
|
||||
|
||||
export function getBackupLocalDateKey(date: Date, timezone: string): string {
|
||||
const parts = getDateTimeParts(date, timezone);
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
export function getBackupLocalTime(date: Date, timezone: string): string {
|
||||
const parts = getDateTimeParts(date, timezone);
|
||||
return `${parts.hour}:${parts.minute}`;
|
||||
}
|
||||
|
||||
function toMinutes(value: string): number {
|
||||
const [hoursRaw, minutesRaw] = value.split(':');
|
||||
return Number(hoursRaw) * 60 + Number(minutesRaw);
|
||||
}
|
||||
|
||||
export function isBackupDueNow(
|
||||
destination: BackupDestinationRecord,
|
||||
now: Date,
|
||||
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
||||
): boolean {
|
||||
if (!destination.schedule.enabled) return false;
|
||||
if (destination.type === 'placeholder') return false;
|
||||
|
||||
const currentMinutes = toMinutes(getBackupLocalTime(now, destination.schedule.timezone));
|
||||
const scheduledMinutes = toMinutes(destination.schedule.scheduleTime);
|
||||
const delta = currentMinutes - scheduledMinutes;
|
||||
if (delta < 0 || delta >= windowMinutes) return false;
|
||||
|
||||
if (destination.schedule.frequency === 'weekly') {
|
||||
return getLocalWeekday(now, destination.schedule.timezone) === destination.schedule.dayOfWeek;
|
||||
}
|
||||
|
||||
if (destination.schedule.frequency === 'monthly') {
|
||||
const currentDay = Number(getDateTimeParts(now, destination.schedule.timezone).day);
|
||||
const scheduledDay = Math.min(destination.schedule.dayOfMonth, getMonthLastDay(now, destination.schedule.timezone));
|
||||
return currentDay === scheduledDay;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { Env } from '../types';
|
||||
import { StorageService } from './storage';
|
||||
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, getSendFileObjectKey, putBlobObject } from './blob-store';
|
||||
import { normalizeImportedBackupSettings } from './backup-config';
|
||||
import { type BackupPayload, parseBackupArchive, parseSendFileId, validateBackupPayloadContents } from './backup-archive';
|
||||
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
|
||||
export interface BackupImportResultBody {
|
||||
object: 'instance-backup-import';
|
||||
imported: {
|
||||
config: number;
|
||||
users: number;
|
||||
userRevisions: number;
|
||||
folders: number;
|
||||
ciphers: number;
|
||||
attachments: number;
|
||||
sends: number;
|
||||
attachmentFiles: number;
|
||||
sendFiles: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BackupImportExecutionResult {
|
||||
result: BackupImportResultBody;
|
||||
auditActorUserId: string | null;
|
||||
}
|
||||
|
||||
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||
const response = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||
return (response.results || []).map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
|
||||
const counts = await Promise.all([
|
||||
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
||||
db.prepare('SELECT COUNT(*) AS count FROM folders').first<{ count: number }>(),
|
||||
db.prepare('SELECT COUNT(*) AS count FROM attachments').first<{ count: number }>(),
|
||||
db.prepare('SELECT COUNT(*) AS count FROM sends').first<{ count: number }>(),
|
||||
]);
|
||||
const total = counts.reduce((sum, row) => sum + Number(row?.count || 0), 0);
|
||||
if (total > 0) {
|
||||
throw new Error('Backup import requires a fresh instance with no vault or send data');
|
||||
}
|
||||
}
|
||||
|
||||
function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[] {
|
||||
return [
|
||||
'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));
|
||||
}
|
||||
|
||||
async function collectCurrentBlobKeys(db: D1Database): Promise<Set<string>> {
|
||||
const keys = new Set<string>();
|
||||
const attachmentRows = await queryRows(
|
||||
db,
|
||||
`SELECT a.id, a.cipher_id
|
||||
FROM attachments a
|
||||
INNER JOIN ciphers c ON c.id = a.cipher_id`
|
||||
);
|
||||
for (const row of attachmentRows) {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
if (!cipherId || !attachmentId) continue;
|
||||
keys.add(getAttachmentObjectKey(cipherId, attachmentId));
|
||||
}
|
||||
|
||||
const sendRows = await queryRows(db, 'SELECT id, data FROM sends');
|
||||
for (const row of sendRows) {
|
||||
const sendId = String(row.id || '').trim();
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (!sendId || !fileId) continue;
|
||||
keys.add(getSendFileObjectKey(sendId, fileId));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function collectImportedBlobKeys(db: BackupPayload['db']): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const row of db.attachments) {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
if (!cipherId || !attachmentId) continue;
|
||||
keys.add(getAttachmentObjectKey(cipherId, attachmentId));
|
||||
}
|
||||
for (const row of db.sends) {
|
||||
const sendId = String(row.id || '').trim();
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (!sendId || !fileId) continue;
|
||||
keys.add(getSendFileObjectKey(sendId, fileId));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function validateImportBlobLimits(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): void {
|
||||
if (getBlobStorageKind(env) !== 'kv') return;
|
||||
for (const entry of Object.keys(files)) {
|
||||
if (!entry.endsWith('.bin')) continue;
|
||||
if (files[entry].byteLength > KV_MAX_OBJECT_BYTES) {
|
||||
throw new Error(`Backup file ${entry} exceeds the Cloudflare KV object size limit`);
|
||||
}
|
||||
}
|
||||
if ((payload.db.attachments || []).length > 0 || (payload.db.sends || []).length > 0) {
|
||||
if (!env.ATTACHMENTS_KV) {
|
||||
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
||||
if (!rows.length) return [];
|
||||
const placeholders = `(${columns.map(() => '?').join(', ')})`;
|
||||
const sql = `INSERT ${upsert ? 'OR REPLACE ' : ''}INTO ${table} (${columns.join(', ')}) VALUES ${placeholders}`;
|
||||
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
|
||||
}
|
||||
|
||||
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<{ attachments: number; sendFiles: number }> {
|
||||
let attachmentCount = 0;
|
||||
let sendFileCount = 0;
|
||||
|
||||
for (const row of db.attachments || []) {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
if (!cipherId || !attachmentId) continue;
|
||||
const key = `attachments/${cipherId}/${attachmentId}.bin`;
|
||||
const bytes = files[key];
|
||||
if (!bytes) throw new Error(`Backup archive is missing required file: ${key}`);
|
||||
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
|
||||
size: bytes.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
});
|
||||
attachmentCount += 1;
|
||||
}
|
||||
|
||||
for (const row of db.sends || []) {
|
||||
const sendId = String(row.id || '').trim();
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (!sendId || !fileId) continue;
|
||||
const key = `send-files/${sendId}/${fileId}.bin`;
|
||||
const bytes = files[key];
|
||||
if (!bytes) throw new Error(`Backup archive is missing required file: ${key}`);
|
||||
await putBlobObject(env, getSendFileObjectKey(sendId, fileId), bytes, {
|
||||
size: bytes.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
});
|
||||
sendFileCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
attachments: attachmentCount,
|
||||
sendFiles: sendFileCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, afterKeys: Set<string>): Promise<void> {
|
||||
const staleKeys = Array.from(beforeKeys).filter((key) => !afterKeys.has(key));
|
||||
for (const key of staleKeys) {
|
||||
await deleteBlobObject(env, key);
|
||||
}
|
||||
}
|
||||
|
||||
async function importBackupRows(db: D1Database, payload: BackupPayload['db']): Promise<void> {
|
||||
const statements: D1PreparedStatement[] = [
|
||||
...buildResetImportTargetStatements(db),
|
||||
...buildInsertStatements(db, 'config', ['key', 'value'], payload.config || [], true),
|
||||
...buildInsertStatements(
|
||||
db,
|
||||
'users',
|
||||
['id', 'email', 'name', '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'],
|
||||
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(
|
||||
db,
|
||||
'ciphers',
|
||||
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
|
||||
payload.ciphers || []
|
||||
),
|
||||
...buildInsertStatements(db, 'attachments', ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []),
|
||||
...buildInsertStatements(
|
||||
db,
|
||||
'sends',
|
||||
['id', 'user_id', 'type', 'name', 'notes', 'data', 'key', 'password_hash', 'password_salt', 'password_iterations', 'auth_type', 'emails', 'max_access_count', 'access_count', 'disabled', 'hide_email', 'created_at', 'updated_at', 'expiration_date', 'deletion_date'],
|
||||
payload.sends || []
|
||||
),
|
||||
];
|
||||
await db.batch(statements);
|
||||
}
|
||||
|
||||
export async function importBackupArchiveBytes(
|
||||
archiveBytes: Uint8Array,
|
||||
env: Env,
|
||||
actorUserId: string,
|
||||
replaceExisting: boolean
|
||||
): Promise<BackupImportExecutionResult> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const parsed = parseBackupArchive(archiveBytes);
|
||||
validateBackupPayloadContents(parsed.payload, parsed.files);
|
||||
validateImportBlobLimits(env, parsed.payload, parsed.files);
|
||||
|
||||
try {
|
||||
await ensureImportTargetIsFresh(env.DB);
|
||||
} catch (error) {
|
||||
if (!replaceExisting) {
|
||||
throw error instanceof Error ? error : new Error('Backup import requires a fresh instance');
|
||||
}
|
||||
}
|
||||
|
||||
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||
const { db } = parsed.payload;
|
||||
await importBackupRows(env.DB, db);
|
||||
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
||||
|
||||
const blobCounts = await restoreBlobFiles(env, db, parsed.files);
|
||||
if (replaceExisting && previousBlobKeys.size) {
|
||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, collectImportedBlobKeys(db));
|
||||
}
|
||||
|
||||
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: (db.attachments || []).length,
|
||||
sends: (db.sends || []).length,
|
||||
attachmentFiles: blobCounts.attachments,
|
||||
sendFiles: blobCounts.sendFiles,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { Env, User } from '../types';
|
||||
|
||||
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
||||
const RUNTIME_INFO = 'runtime';
|
||||
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
||||
const PORTABLE_HASH = 'SHA-1';
|
||||
const AES_GCM_ALGORITHM = 'AES-GCM';
|
||||
const AES_GCM_IV_BYTES = 12;
|
||||
const PORTABLE_DEK_BYTES = 32;
|
||||
|
||||
export interface BackupSettingsRuntimeEnvelope {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
}
|
||||
|
||||
export interface BackupSettingsPortableWrap {
|
||||
userId: string;
|
||||
wrappedKey: string;
|
||||
}
|
||||
|
||||
export interface BackupSettingsPortableEnvelope {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
wraps: BackupSettingsPortableWrap[];
|
||||
}
|
||||
|
||||
export interface BackupSettingsEnvelopeV2 {
|
||||
version: 2;
|
||||
runtime: BackupSettingsRuntimeEnvelope;
|
||||
portable: BackupSettingsPortableEnvelope;
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let text = '';
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
text += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return btoa(text);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string): Uint8Array {
|
||||
const normalized = String(value || '').trim();
|
||||
const binary = atob(normalized);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function deriveRuntimeKey(secret: string): Promise<CryptoKey> {
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
'HKDF',
|
||||
false,
|
||||
['deriveBits']
|
||||
);
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'HKDF',
|
||||
hash: 'SHA-256',
|
||||
salt: encoder.encode(RUNTIME_SALT),
|
||||
info: encoder.encode(RUNTIME_INFO),
|
||||
},
|
||||
keyMaterial,
|
||||
256
|
||||
);
|
||||
return crypto.subtle.importKey('raw', bits, { name: AES_GCM_ALGORITHM }, false, ['encrypt', 'decrypt']);
|
||||
}
|
||||
|
||||
async function encryptAesGcm(plaintext: Uint8Array, key: CryptoKey): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
|
||||
const ciphertext = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: AES_GCM_ALGORITHM, iv },
|
||||
key,
|
||||
plaintext
|
||||
)
|
||||
);
|
||||
return { iv, ciphertext };
|
||||
}
|
||||
|
||||
async function decryptAesGcm(ciphertext: Uint8Array, iv: Uint8Array, key: CryptoKey): Promise<Uint8Array> {
|
||||
return new Uint8Array(
|
||||
await crypto.subtle.decrypt(
|
||||
{ name: AES_GCM_ALGORITHM, iv },
|
||||
key,
|
||||
ciphertext
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function importPortablePublicKey(publicKeyBase64: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'spki',
|
||||
base64ToBytes(publicKeyBase64),
|
||||
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
}
|
||||
|
||||
function getEligiblePortableUsers(users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]): Array<Pick<User, 'id' | 'publicKey'>> {
|
||||
return users
|
||||
.filter(
|
||||
(user) =>
|
||||
user.role === 'admin' &&
|
||||
user.status === 'active' &&
|
||||
typeof user.publicKey === 'string' &&
|
||||
user.publicKey.trim().length > 0
|
||||
)
|
||||
.map((user) => ({
|
||||
id: user.id,
|
||||
publicKey: user.publicKey!,
|
||||
}));
|
||||
}
|
||||
|
||||
export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsEnvelopeV2 | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||
if (!isPlainObject(parsed) || Number(parsed.version) !== 2) return null;
|
||||
const runtime = parsed.runtime;
|
||||
const portable = parsed.portable;
|
||||
if (!isPlainObject(runtime) || !isPlainObject(portable)) return null;
|
||||
if (!Array.isArray(portable.wraps)) return null;
|
||||
if (typeof runtime.iv !== 'string' || typeof runtime.ciphertext !== 'string') return null;
|
||||
if (typeof portable.iv !== 'string' || typeof portable.ciphertext !== 'string') return null;
|
||||
return {
|
||||
version: 2,
|
||||
runtime: {
|
||||
iv: runtime.iv,
|
||||
ciphertext: runtime.ciphertext,
|
||||
},
|
||||
portable: {
|
||||
iv: portable.iv,
|
||||
ciphertext: portable.ciphertext,
|
||||
wraps: portable.wraps
|
||||
.filter((entry): entry is Record<string, unknown> => isPlainObject(entry))
|
||||
.map((entry) => ({
|
||||
userId: String(entry.userId || '').trim(),
|
||||
wrappedKey: String(entry.wrappedKey || '').trim(),
|
||||
}))
|
||||
.filter((entry) => entry.userId && entry.wrappedKey),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function encryptBackupSettingsEnvelope(
|
||||
plaintext: string,
|
||||
env: Env,
|
||||
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]
|
||||
): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
const eligibleUsers = getEligiblePortableUsers(users);
|
||||
if (!eligibleUsers.length) {
|
||||
throw new Error('No active administrator public keys are available for backup settings recovery');
|
||||
}
|
||||
|
||||
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
||||
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
|
||||
|
||||
const portableDek = crypto.getRandomValues(new Uint8Array(PORTABLE_DEK_BYTES));
|
||||
const portableKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
portableDek,
|
||||
{ name: AES_GCM_ALGORITHM },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
const portableCipher = await encryptAesGcm(encoder.encode(plaintext), portableKey);
|
||||
|
||||
const wraps: BackupSettingsPortableWrap[] = [];
|
||||
for (const user of eligibleUsers) {
|
||||
const publicKey = await importPortablePublicKey(user.publicKey!);
|
||||
const wrappedKey = new Uint8Array(
|
||||
await crypto.subtle.encrypt(
|
||||
{ name: PORTABLE_ALGORITHM },
|
||||
publicKey,
|
||||
portableDek
|
||||
)
|
||||
);
|
||||
wraps.push({
|
||||
userId: user.id,
|
||||
wrappedKey: bytesToBase64(wrappedKey),
|
||||
});
|
||||
}
|
||||
|
||||
const envelope: BackupSettingsEnvelopeV2 = {
|
||||
version: 2,
|
||||
runtime: {
|
||||
iv: bytesToBase64(runtime.iv),
|
||||
ciphertext: bytesToBase64(runtime.ciphertext),
|
||||
},
|
||||
portable: {
|
||||
iv: bytesToBase64(portableCipher.iv),
|
||||
ciphertext: bytesToBase64(portableCipher.ciphertext),
|
||||
wraps,
|
||||
},
|
||||
};
|
||||
|
||||
return JSON.stringify(envelope);
|
||||
}
|
||||
|
||||
export async function decryptBackupSettingsRuntime(raw: string, env: Env): Promise<string> {
|
||||
const envelope = parseBackupSettingsEnvelope(raw);
|
||||
if (!envelope) {
|
||||
throw new Error('Backup settings envelope is invalid');
|
||||
}
|
||||
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
||||
const plaintext = await decryptAesGcm(
|
||||
base64ToBytes(envelope.runtime.ciphertext),
|
||||
base64ToBytes(envelope.runtime.iv),
|
||||
runtimeKey
|
||||
);
|
||||
return new TextDecoder().decode(plaintext);
|
||||
}
|
||||
@@ -0,0 +1,640 @@
|
||||
import {
|
||||
BackupDestinationRecord,
|
||||
BackupDestinationType,
|
||||
E3BackupDestination,
|
||||
PlaceholderBackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from './backup-config';
|
||||
|
||||
export interface BackupUploadResult {
|
||||
provider: BackupDestinationType;
|
||||
remotePath: string;
|
||||
}
|
||||
|
||||
export interface RemoteBackupItem {
|
||||
path: string;
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
size: number | null;
|
||||
modifiedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteBackupListResult {
|
||||
provider: BackupDestinationType;
|
||||
currentPath: string;
|
||||
parentPath: string | null;
|
||||
items: RemoteBackupItem[];
|
||||
}
|
||||
|
||||
export interface RemoteBackupFile {
|
||||
provider: BackupDestinationType;
|
||||
remotePath: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
function isBackupArchiveName(name: string): boolean {
|
||||
return /\.zip$/i.test(String(name || '').trim());
|
||||
}
|
||||
|
||||
function encodePathSegments(path: string): string {
|
||||
return path
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function trimSlashes(value: string): string {
|
||||
return String(value || '').replace(/^\/+|\/+$/g, '');
|
||||
}
|
||||
|
||||
function buildJoinedPath(...segments: string[]): string {
|
||||
return segments.map(trimSlashes).filter(Boolean).join('/');
|
||||
}
|
||||
|
||||
function normalizeRelativePath(path: string): string {
|
||||
const normalized = trimSlashes(path).replace(/\\/g, '/');
|
||||
if (!normalized) return '';
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
if (parts.some((part) => part === '.' || part === '..')) {
|
||||
throw new Error('Invalid remote backup path');
|
||||
}
|
||||
return parts.join('/');
|
||||
}
|
||||
|
||||
function basename(path: string): string {
|
||||
const normalized = trimSlashes(path);
|
||||
if (!normalized) return '';
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
function parentPath(path: string): string | null {
|
||||
const normalized = normalizeRelativePath(path);
|
||||
if (!normalized) return null;
|
||||
const parts = normalized.split('/');
|
||||
parts.pop();
|
||||
return parts.length ? parts.join('/') : '';
|
||||
}
|
||||
|
||||
function sortRemoteItems(items: RemoteBackupItem[]): RemoteBackupItem[] {
|
||||
return items.slice().sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name, 'en');
|
||||
});
|
||||
}
|
||||
|
||||
function decodeXmlText(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function parseHttpDate(value: string): string | null {
|
||||
const parsed = new Date(value);
|
||||
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
||||
}
|
||||
|
||||
function extractXmlBlocks(xml: string, tagName: string): string[] {
|
||||
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'gi');
|
||||
const blocks: string[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(xml))) {
|
||||
blocks.push(match[1]);
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function extractXmlFirst(xml: string, tagName: string): string | null {
|
||||
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'i');
|
||||
const match = xml.match(pattern);
|
||||
return match?.[1] ? decodeXmlText(match[1].trim()) : null;
|
||||
}
|
||||
|
||||
async function sha256Hex(value: Uint8Array | string): Promise<string> {
|
||||
const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
async function hmacSha256Raw(keyBytes: Uint8Array, message: string): Promise<Uint8Array> {
|
||||
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
|
||||
return new Uint8Array(signature);
|
||||
}
|
||||
|
||||
function toBasicAuthHeader(username: string, password: string): string {
|
||||
const token = btoa(`${username}:${password}`);
|
||||
return `Basic ${token}`;
|
||||
}
|
||||
|
||||
function buildCanonicalQueryString(url: URL): string {
|
||||
const params = Array.from(url.searchParams.entries()).sort(([aKey, aValue], [bKey, bValue]) => {
|
||||
if (aKey === bKey) return aValue.localeCompare(bValue);
|
||||
return aKey.localeCompare(bKey);
|
||||
});
|
||||
return params
|
||||
.map(([key, value]) => `${encodeURIComponent(key).replace(/%20/g, '%20')}=${encodeURIComponent(value).replace(/%20/g, '%20')}`)
|
||||
.join('&');
|
||||
}
|
||||
|
||||
async function buildAwsV4Authorization(
|
||||
method: string,
|
||||
url: URL,
|
||||
headers: Record<string, string>,
|
||||
payloadHashHex: string,
|
||||
accessKeyId: string,
|
||||
secretAccessKey: string,
|
||||
region: string
|
||||
): Promise<string> {
|
||||
const amzDate = headers['x-amz-date'];
|
||||
const shortDate = amzDate.slice(0, 8);
|
||||
const headerEntries = Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value] as const).sort(([a], [b]) => a.localeCompare(b));
|
||||
const canonicalHeaders = headerEntries
|
||||
.map(([name, value]) => `${name}:${String(value).trim().replace(/\s+/g, ' ')}`)
|
||||
.join('\n');
|
||||
const signedHeaders = headerEntries.map(([name]) => name).join(';');
|
||||
const canonicalRequest = [
|
||||
method.toUpperCase(),
|
||||
url.pathname || '/',
|
||||
buildCanonicalQueryString(url),
|
||||
`${canonicalHeaders}\n`,
|
||||
signedHeaders,
|
||||
payloadHashHex,
|
||||
].join('\n');
|
||||
const credentialScope = `${shortDate}/${region}/s3/aws4_request`;
|
||||
const stringToSign = [
|
||||
'AWS4-HMAC-SHA256',
|
||||
amzDate,
|
||||
credentialScope,
|
||||
await sha256Hex(canonicalRequest),
|
||||
].join('\n');
|
||||
|
||||
const kDate = await hmacSha256Raw(new TextEncoder().encode(`AWS4${secretAccessKey}`), shortDate);
|
||||
const kRegion = await hmacSha256Raw(kDate, region);
|
||||
const kService = await hmacSha256Raw(kRegion, 's3');
|
||||
const kSigning = await hmacSha256Raw(kService, 'aws4_request');
|
||||
const signatureBytes = await hmacSha256Raw(kSigning, stringToSign);
|
||||
const signature = Array.from(signatureBytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
return `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||
}
|
||||
|
||||
function ensureDestinationConfigReady(destination: BackupDestinationRecord): void {
|
||||
if (destination.type === 'webdav') {
|
||||
const config = destination.destination as WebDavBackupDestination;
|
||||
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
|
||||
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://');
|
||||
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
|
||||
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
||||
return;
|
||||
}
|
||||
if (destination.type === 'e3') {
|
||||
const config = destination.destination as E3BackupDestination;
|
||||
if (!String(config.endpoint || '').trim()) throw new Error('E3 endpoint is required');
|
||||
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('E3 endpoint must start with http:// or https://');
|
||||
if (!String(config.bucket || '').trim()) throw new Error('E3 bucket is required');
|
||||
if (!String(config.accessKeyId || '').trim()) throw new Error('E3 access key is required');
|
||||
if (!String(config.secretAccessKey || '')) throw new Error('E3 secret key is required');
|
||||
}
|
||||
}
|
||||
|
||||
function buildWebDavUrl(baseUrl: string, relativePath: string): string {
|
||||
const trimmedBase = baseUrl.replace(/\/+$/, '');
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
return normalized ? `${trimmedBase}/${encodePathSegments(normalized)}` : trimmedBase;
|
||||
}
|
||||
|
||||
function webDavFullPath(config: WebDavBackupDestination, relativePath: string): string {
|
||||
return buildJoinedPath(config.remotePath, normalizeRelativePath(relativePath));
|
||||
}
|
||||
|
||||
async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, authHeader: string): Promise<void> {
|
||||
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
|
||||
let current = '';
|
||||
for (const segment of segments) {
|
||||
current = buildJoinedPath(current, segment);
|
||||
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)) continue;
|
||||
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadToWebDav(config: WebDavBackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||
const remoteFilePath = buildJoinedPath(config.remotePath, fileName);
|
||||
const remoteDir = parentPath(remoteFilePath);
|
||||
|
||||
if (remoteDir) {
|
||||
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
|
||||
}
|
||||
|
||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Length': String(archive.byteLength),
|
||||
},
|
||||
body: archive,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`WebDAV upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'webdav',
|
||||
remotePath: remoteFilePath,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWebDavResponsePath(baseUrl: string, href: string): string {
|
||||
const base = new URL(baseUrl);
|
||||
const target = new URL(href, base);
|
||||
const basePath = trimSlashes(decodeURIComponent(base.pathname));
|
||||
const entryPath = trimSlashes(decodeURIComponent(target.pathname));
|
||||
if (!basePath) return entryPath;
|
||||
if (entryPath === basePath) return '';
|
||||
return entryPath.startsWith(`${basePath}/`) ? entryPath.slice(basePath.length + 1) : entryPath;
|
||||
}
|
||||
|
||||
async function listWebDavEntries(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||
const currentPath = normalizeRelativePath(relativePath);
|
||||
const targetFullPath = webDavFullPath(config, currentPath);
|
||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||
const response = await fetch(buildWebDavUrl(config.baseUrl, targetFullPath), {
|
||||
method: 'PROPFIND',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
Depth: '1',
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
},
|
||||
body: `<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop><resourcetype/><getcontentlength/><getlastmodified/></prop></propfind>`,
|
||||
});
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
provider: 'webdav',
|
||||
currentPath,
|
||||
parentPath: parentPath(currentPath),
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`WebDAV listing failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const rootFullPath = trimSlashes(config.remotePath);
|
||||
const items: RemoteBackupItem[] = [];
|
||||
for (const block of extractXmlBlocks(xml, 'response')) {
|
||||
const href = extractXmlFirst(block, 'href');
|
||||
if (!href) continue;
|
||||
const fullPath = trimSlashes(parseWebDavResponsePath(config.baseUrl, href));
|
||||
if (!fullPath) continue;
|
||||
if (fullPath === targetFullPath) continue;
|
||||
if (rootFullPath && !(fullPath === rootFullPath || fullPath.startsWith(`${rootFullPath}/`))) continue;
|
||||
const relative = rootFullPath
|
||||
? fullPath === rootFullPath
|
||||
? ''
|
||||
: fullPath.slice(rootFullPath.length + 1)
|
||||
: fullPath;
|
||||
if (!relative) continue;
|
||||
const directParent = parentPath(relative);
|
||||
if ((directParent || '') !== currentPath) continue;
|
||||
|
||||
const resourceTypeBlock = extractXmlFirst(block, 'resourcetype') || '';
|
||||
const isDirectory = /<(?:[^:>]+:)?collection\b/i.test(resourceTypeBlock);
|
||||
const sizeRaw = extractXmlFirst(block, 'getcontentlength');
|
||||
const modifiedAtRaw = extractXmlFirst(block, 'getlastmodified');
|
||||
items.push({
|
||||
path: relative,
|
||||
name: basename(relative) || relative,
|
||||
isDirectory,
|
||||
size: !isDirectory && sizeRaw && Number.isFinite(Number(sizeRaw)) ? Number(sizeRaw) : null,
|
||||
modifiedAt: modifiedAtRaw ? parseHttpDate(modifiedAtRaw) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'webdav',
|
||||
currentPath,
|
||||
parentPath: parentPath(currentPath),
|
||||
items: sortRemoteItems(items),
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
if (!normalized || normalized.endsWith('/')) {
|
||||
throw new Error('Please select a backup file');
|
||||
}
|
||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||
const remotePath = webDavFullPath(config, normalized);
|
||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`WebDAV download failed: ${response.status}`);
|
||||
}
|
||||
return {
|
||||
provider: 'webdav',
|
||||
remotePath: normalized,
|
||||
fileName: basename(normalized) || 'backup.zip',
|
||||
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||
bytes: new Uint8Array(await response.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<void> {
|
||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||
const remotePath = webDavFullPath(config, relativePath);
|
||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`WebDAV delete failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function e3BucketBaseUrl(config: E3BackupDestination): URL {
|
||||
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
||||
}
|
||||
|
||||
function normalizeE3ObjectKey(config: E3BackupDestination, relativePath: string): string {
|
||||
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
|
||||
}
|
||||
|
||||
async function signedE3Request(
|
||||
config: E3BackupDestination,
|
||||
method: 'GET' | 'PUT' | 'DELETE',
|
||||
url: URL,
|
||||
body?: Uint8Array
|
||||
): Promise<Response> {
|
||||
const payloadHashHex = await sha256Hex(body || new Uint8Array());
|
||||
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||
const headers: Record<string, string> = {
|
||||
host: url.host,
|
||||
'x-amz-content-sha256': payloadHashHex,
|
||||
'x-amz-date': amzDate,
|
||||
};
|
||||
if (method === 'PUT') headers['content-type'] = 'application/zip';
|
||||
|
||||
const authorization = await buildAwsV4Authorization(
|
||||
method,
|
||||
url,
|
||||
headers,
|
||||
payloadHashHex,
|
||||
config.accessKeyId,
|
||||
config.secretAccessKey,
|
||||
config.region || 'auto'
|
||||
);
|
||||
|
||||
return fetch(url.toString(), {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: authorization,
|
||||
'X-Amz-Content-Sha256': headers['x-amz-content-sha256'],
|
||||
'X-Amz-Date': headers['x-amz-date'],
|
||||
...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}),
|
||||
},
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||
const objectKey = normalizeE3ObjectKey(config, fileName);
|
||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedE3Request(config, 'PUT', url, archive);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`E3 upload failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: 'e3',
|
||||
remotePath: objectKey,
|
||||
};
|
||||
}
|
||||
|
||||
async function listE3Entries(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||
const currentPath = normalizeRelativePath(relativePath);
|
||||
const targetPrefixBase = normalizeE3ObjectKey(config, currentPath);
|
||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||
const url = e3BucketBaseUrl(config);
|
||||
url.searchParams.set('list-type', '2');
|
||||
url.searchParams.set('delimiter', '/');
|
||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||
|
||||
const response = await signedE3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`E3 listing failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const rootPrefix = trimSlashes(config.rootPath);
|
||||
const items: RemoteBackupItem[] = [];
|
||||
|
||||
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
||||
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
||||
if (!fullPrefix) continue;
|
||||
const relative = rootPrefix
|
||||
? fullPrefix === rootPrefix
|
||||
? ''
|
||||
: fullPrefix.startsWith(`${rootPrefix}/`)
|
||||
? fullPrefix.slice(rootPrefix.length + 1)
|
||||
: ''
|
||||
: fullPrefix;
|
||||
const normalizedRelative = trimSlashes(relative);
|
||||
if (!normalizedRelative) continue;
|
||||
const itemPath = normalizedRelative.replace(/\/+$/, '');
|
||||
if ((parentPath(itemPath) || '') !== currentPath) continue;
|
||||
items.push({
|
||||
path: itemPath,
|
||||
name: basename(itemPath) || itemPath,
|
||||
isDirectory: true,
|
||||
size: null,
|
||||
modifiedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
for (const content of extractXmlBlocks(xml, 'Contents')) {
|
||||
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
|
||||
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
|
||||
const relative = rootPrefix
|
||||
? fullKey.startsWith(`${rootPrefix}/`)
|
||||
? fullKey.slice(rootPrefix.length + 1)
|
||||
: ''
|
||||
: fullKey;
|
||||
const normalizedRelative = trimSlashes(relative);
|
||||
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
|
||||
items.push({
|
||||
path: normalizedRelative,
|
||||
name: basename(normalizedRelative) || normalizedRelative,
|
||||
isDirectory: false,
|
||||
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
|
||||
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
|
||||
});
|
||||
}
|
||||
|
||||
const deduped = new Map<string, RemoteBackupItem>();
|
||||
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
||||
|
||||
return {
|
||||
provider: 'e3',
|
||||
currentPath,
|
||||
parentPath: parentPath(currentPath),
|
||||
items: sortRemoteItems(Array.from(deduped.values())),
|
||||
};
|
||||
}
|
||||
|
||||
async function downloadFromE3(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
if (!normalized || normalized.endsWith('/')) {
|
||||
throw new Error('Please select a backup file');
|
||||
}
|
||||
const objectKey = normalizeE3ObjectKey(config, normalized);
|
||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedE3Request(config, 'GET', url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`E3 download failed: ${response.status}`);
|
||||
}
|
||||
return {
|
||||
provider: 'e3',
|
||||
remotePath: normalized,
|
||||
fileName: basename(normalized) || 'backup.zip',
|
||||
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||
bytes: new Uint8Array(await response.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
|
||||
async function deleteFromE3(config: E3BackupDestination, relativePath: string): Promise<void> {
|
||||
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||
const response = await signedE3Request(config, 'DELETE', url);
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`E3 delete failed: ${response.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSupportedPlaceholder(_config: PlaceholderBackupDestination): never {
|
||||
throw new Error('The reserved backup destination is not available yet');
|
||||
}
|
||||
|
||||
interface ConfiguredDestinationAdapter {
|
||||
provider: 'webdav' | 'e3';
|
||||
config: WebDavBackupDestination | E3BackupDestination;
|
||||
upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
||||
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
||||
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
function resolveConfiguredDestinationAdapter(
|
||||
destination: BackupDestinationRecord
|
||||
): ConfiguredDestinationAdapter {
|
||||
ensureDestinationConfigReady(destination);
|
||||
|
||||
if (destination.type === 'webdav') {
|
||||
return {
|
||||
provider: 'webdav',
|
||||
config: destination.destination as WebDavBackupDestination,
|
||||
upload: (config, archive, fileName) => uploadToWebDav(config as WebDavBackupDestination, archive, fileName),
|
||||
list: (config, relativePath) => listWebDavEntries(config as WebDavBackupDestination, relativePath),
|
||||
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||
};
|
||||
}
|
||||
if (destination.type === 'e3') {
|
||||
return {
|
||||
provider: 'e3',
|
||||
config: destination.destination as E3BackupDestination,
|
||||
upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName),
|
||||
list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath),
|
||||
download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath),
|
||||
deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath),
|
||||
};
|
||||
}
|
||||
|
||||
return assertSupportedPlaceholder(destination.destination as PlaceholderBackupDestination);
|
||||
}
|
||||
|
||||
export async function uploadBackupArchive(
|
||||
destination: BackupDestinationRecord,
|
||||
archive: Uint8Array,
|
||||
fileName: string
|
||||
): Promise<BackupUploadResult> {
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
return adapter.upload(adapter.config, archive, fileName);
|
||||
}
|
||||
|
||||
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
return adapter.list(adapter.config, relativePath);
|
||||
}
|
||||
|
||||
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
return adapter.download(adapter.config, 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);
|
||||
}
|
||||
|
||||
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
|
||||
if (preferredFileName) {
|
||||
const aPreferred = a.name === preferredFileName ? 1 : 0;
|
||||
const bPreferred = b.name === preferredFileName ? 1 : 0;
|
||||
if (aPreferred !== bPreferred) return bPreferred - aPreferred;
|
||||
}
|
||||
const aTime = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0;
|
||||
const bTime = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0;
|
||||
if (aTime !== bTime) return bTime - aTime;
|
||||
return b.name.localeCompare(a.name, 'en');
|
||||
}
|
||||
|
||||
export async function pruneRemoteBackupArchives(
|
||||
destination: BackupDestinationRecord,
|
||||
retentionCount: number | null,
|
||||
preferredFileName?: string
|
||||
): Promise<number> {
|
||||
if (retentionCount === null) return 0;
|
||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||
const listing = await adapter.list(adapter.config, '');
|
||||
const backupFiles = listing.items
|
||||
.filter((item) => !item.isDirectory && isBackupArchiveName(item.name))
|
||||
.sort((a, b) => compareBackupItemsByRecency(a, b, preferredFileName));
|
||||
if (backupFiles.length <= retentionCount) return 0;
|
||||
for (const item of backupFiles.slice(retentionCount)) {
|
||||
await adapter.deleteFile(adapter.config, item.path);
|
||||
}
|
||||
return backupFiles.length - retentionCount;
|
||||
}
|
||||
|
||||
export function ensureRemoteRestoreCandidate(relativePath: string): string {
|
||||
const normalized = normalizeRelativePath(relativePath);
|
||||
if (!normalized || !/\.zip$/i.test(normalized)) {
|
||||
throw new Error('Please select a backup ZIP file');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
@@ -213,6 +213,18 @@ export class StorageService {
|
||||
return row?.value === 'true';
|
||||
}
|
||||
|
||||
async getConfigValue(key: string): Promise<string | null> {
|
||||
const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind(key).first<{ value: string }>();
|
||||
return typeof row?.value === 'string' ? row.value : null;
|
||||
}
|
||||
|
||||
async setConfigValue(key: string, value: string): Promise<void> {
|
||||
await this.db
|
||||
.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.bind(key, value)
|
||||
.run();
|
||||
}
|
||||
|
||||
async setRegistered(): Promise<void> {
|
||||
await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||
.bind('registered', 'true')
|
||||
|
||||
Reference in New Issue
Block a user