From 05f1b2f9a82cfe87ceecc678c84a7f3d4aa384b0 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 15 Mar 2026 03:34:16 +0800 Subject: [PATCH] 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. --- shared/backup.ts | 166 +++ src/handlers/backup.ts | 994 +++++++----------- src/index.ts | 14 + src/router.ts | 39 + src/services/backup-archive.ts | 360 +++++++ src/services/backup-config.ts | 591 +++++++++++ src/services/backup-import.ts | 253 +++++ src/services/backup-settings-crypto.ts | 226 ++++ src/services/backup-uploader.ts | 640 +++++++++++ src/services/storage.ts | 12 + tsconfig.json | 2 +- webapp/src/App.tsx | 408 +++---- webapp/src/components/BackupCenterPage.tsx | 590 +++++++++++ webapp/src/components/HelpPage.tsx | 139 --- .../backup-center/BackupDestinationDetail.tsx | 527 ++++++++++ .../BackupDestinationSidebar.tsx | 70 ++ .../backup-center/BackupOperationsSidebar.tsx | 92 ++ .../backup-center/RemoteBackupBrowser.tsx | 138 +++ webapp/src/lib/admin-backup-portable.ts | 65 ++ webapp/src/lib/api.ts | 203 +++- webapp/src/lib/backup-center.ts | 204 ++++ webapp/src/lib/backup-recommendations.ts | 68 ++ webapp/src/lib/i18n.ts | 348 +++++- webapp/src/lib/types.ts | 2 + webapp/src/styles.css | 447 +++++++- webapp/tsconfig.json | 5 +- webapp/vite.config.ts | 4 + wrangler.kv.toml | 3 + wrangler.toml | 3 + 29 files changed, 5662 insertions(+), 951 deletions(-) create mode 100644 shared/backup.ts create mode 100644 src/services/backup-archive.ts create mode 100644 src/services/backup-config.ts create mode 100644 src/services/backup-import.ts create mode 100644 src/services/backup-settings-crypto.ts create mode 100644 src/services/backup-uploader.ts create mode 100644 webapp/src/components/BackupCenterPage.tsx delete mode 100644 webapp/src/components/HelpPage.tsx create mode 100644 webapp/src/components/backup-center/BackupDestinationDetail.tsx create mode 100644 webapp/src/components/backup-center/BackupDestinationSidebar.tsx create mode 100644 webapp/src/components/backup-center/BackupOperationsSidebar.tsx create mode 100644 webapp/src/components/backup-center/RemoteBackupBrowser.tsx create mode 100644 webapp/src/lib/admin-backup-portable.ts create mode 100644 webapp/src/lib/backup-center.ts create mode 100644 webapp/src/lib/backup-recommendations.ts diff --git a/shared/backup.ts b/shared/backup.ts new file mode 100644 index 0000000..562617d --- /dev/null +++ b/shared/backup.ts @@ -0,0 +1,166 @@ +export const BACKUP_DEFAULT_TIMEZONE = 'UTC'; +export const BACKUP_DEFAULT_SCHEDULE_TIME = '03:00'; +export const BACKUP_DEFAULT_RETENTION_COUNT = 30; +export const BACKUP_DEFAULT_E3_REGION = 'auto'; +export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden'; + +export type BackupDestinationType = 'e3' | 'webdav' | 'placeholder'; +export type BackupScheduleFrequency = 'daily' | 'weekly' | 'monthly'; + +export interface E3BackupDestination { + endpoint: string; + bucket: string; + region: string; + accessKeyId: string; + secretAccessKey: string; + rootPath: string; +} + +export interface WebDavBackupDestination { + baseUrl: string; + username: string; + password: string; + remotePath: string; +} + +export interface PlaceholderBackupDestination { + providerName: string; + notes: string; +} + +export type BackupDestinationConfig = + | E3BackupDestination + | WebDavBackupDestination + | PlaceholderBackupDestination; + +export interface BackupRuntimeState { + lastAttemptAt: string | null; + lastAttemptLocalDate: string | null; + lastSuccessAt: string | null; + lastErrorAt: string | null; + lastErrorMessage: string | null; + lastUploadedFileName: string | null; + lastUploadedSizeBytes: number | null; + lastUploadedDestination: string | null; +} + +export interface BackupScheduleConfig { + enabled: boolean; + frequency: BackupScheduleFrequency; + scheduleTime: string; + timezone: string; + dayOfWeek: number; + dayOfMonth: number; + retentionCount: number | null; +} + +export interface BackupDestinationRecord { + id: string; + name: string; + type: BackupDestinationType; + destination: BackupDestinationConfig; + schedule: BackupScheduleConfig; + runtime: BackupRuntimeState; +} + +export interface BackupSettings { + destinations: BackupDestinationRecord[]; +} + +export function createBackupRandomId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `backup-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; +} + +export function createDefaultBackupRuntimeState(): BackupRuntimeState { + return { + lastAttemptAt: null, + lastAttemptLocalDate: null, + lastSuccessAt: null, + lastErrorAt: null, + lastErrorMessage: null, + lastUploadedFileName: null, + lastUploadedSizeBytes: null, + lastUploadedDestination: null, + }; +} + +export function createDefaultBackupScheduleConfig(timezone: string = BACKUP_DEFAULT_TIMEZONE): BackupScheduleConfig { + return { + enabled: false, + frequency: 'daily', + scheduleTime: BACKUP_DEFAULT_SCHEDULE_TIME, + timezone, + dayOfWeek: 1, + dayOfMonth: 1, + retentionCount: BACKUP_DEFAULT_RETENTION_COUNT, + }; +} + +export function createDefaultBackupDestinationConfig(type: BackupDestinationType): BackupDestinationConfig { + if (type === 'e3') { + return { + endpoint: '', + bucket: '', + region: BACKUP_DEFAULT_E3_REGION, + accessKeyId: '', + secretAccessKey: '', + rootPath: BACKUP_DEFAULT_REMOTE_PATH, + }; + } + if (type === 'placeholder') { + return { + providerName: 'Reserved', + notes: '', + }; + } + return { + baseUrl: '', + username: '', + password: '', + remotePath: BACKUP_DEFAULT_REMOTE_PATH, + }; +} + +export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string { + if (type === 'e3') return `E3 ${index}`; + if (type === 'placeholder') return `Reserved ${index}`; + return `WebDAV ${index}`; +} + +export interface CreateBackupDestinationRecordOptions { + id?: string; + name?: string; + timezone?: string; +} + +export function createBackupDestinationRecord( + type: BackupDestinationType, + index: number, + options: CreateBackupDestinationRecordOptions = {} +): BackupDestinationRecord { + return { + id: options.id || createBackupRandomId(), + name: options.name || createDefaultBackupDestinationName(type, index), + type, + destination: createDefaultBackupDestinationConfig(type), + schedule: createDefaultBackupScheduleConfig(options.timezone || BACKUP_DEFAULT_TIMEZONE), + runtime: createDefaultBackupRuntimeState(), + }; +} + +export function createDefaultBackupSettings( + timezone: string = BACKUP_DEFAULT_TIMEZONE, + options: { destinationName?: string } = {} +): BackupSettings { + return { + destinations: [ + createBackupDestinationRecord('webdav', 1, { + timezone, + name: options.destinationName, + }), + ], + }; +} diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index 91e8e11..6903ab2 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -1,42 +1,32 @@ -import { zipSync, unzipSync } from 'fflate'; -import { Env, User } from '../types'; -import { StorageService } from '../services/storage'; +import type { Env, User } from '../types'; import { errorResponse, jsonResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; -import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobObject, getBlobStorageKind, getSendFileObjectKey, putBlobObject } from '../services/blob-store'; - -type SqlRow = Record; - -interface BackupManifest { - formatVersion: 1; - exportedAt: string; - appVersion: string; - storageKind: 'r2' | 'kv' | null; - tableCounts: Record; - includes: { - attachments: boolean; - sendFiles: boolean; - }; - blobSummary: { - attachmentFiles: number; - sendFiles: number; - totalBytes: number; - largestObjectBytes: number; - }; -} - -interface BackupPayload { - manifest: BackupManifest; - db: { - config: SqlRow[]; - users: SqlRow[]; - user_revisions: SqlRow[]; - folders: SqlRow[]; - ciphers: SqlRow[]; - attachments: SqlRow[]; - sends: SqlRow[]; - }; -} +import { type BackupArchiveBundle, buildBackupArchive } from '../services/backup-archive'; +import { + type BackupDestinationRecord, + type BackupSettingsInput, + BACKUP_SCHEDULER_WINDOW_MINUTES, + getBackupLocalDateKey, + getDefaultBackupSettings, + getBackupSettingsRepairState, + isBackupDueNow, + loadBackupSettings, + normalizeBackupSettingsInput, + normalizeImportedBackupSettings, + repairBackupSettings, + requireBackupDestination, + saveBackupSettings, +} from '../services/backup-config'; +import { type BackupImportExecutionResult, importBackupArchiveBytes } from '../services/backup-import'; +import { + deleteRemoteBackupFile, + downloadRemoteBackupFile, + ensureRemoteRestoreCandidate, + listRemoteBackupEntries, + pruneRemoteBackupArchives, + uploadBackupArchive, +} from '../services/backup-uploader'; +import { StorageService } from '../services/storage'; function isAdmin(user: User): boolean { return user.role === 'admin' && user.status === 'active'; @@ -61,505 +51,379 @@ async function writeAuditLog( }); } -async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise { - const result = await db.prepare(sql).bind(...values).all(); - return (result.results || []).map((row) => ({ ...row })); -} - -async function streamToBytes(stream: ReadableStream | null): Promise { - if (!stream) return new Uint8Array(); - const buffer = await new Response(stream).arrayBuffer(); - return new Uint8Array(buffer); -} - -function parseSendFileId(data: string | null): string | null { - if (!data) return null; - try { - const parsed = JSON.parse(data) as Record; - return typeof parsed.id === 'string' && parsed.id.trim() ? parsed.id.trim() : null; - } catch { - return null; +function getBackupDestinationSummary(destination: BackupDestinationRecord | null): Record { + if (!destination) { + return { + destinationId: null, + destinationName: null, + destinationType: null, + }; } -} - -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_instance_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}.zip`; -} - -async function ensureImportTargetIsFresh(db: D1Database): Promise { - 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'); - } -} - -async function clearExistingBlobFiles(env: Env, db: D1Database): Promise { - 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; - await deleteBlobObject(env, 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; - await deleteBlobObject(env, getSendFileObjectKey(sendId, fileId)); - } -} - -async function resetImportTarget(db: D1Database): Promise { - const statements = [ - '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)); - await db.batch(statements); -} - -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 parseBackupArchive(bytes: Uint8Array): { payload: BackupPayload; files: Record } { - let zipped: Record; - try { - zipped = unzipSync(bytes); - } catch { - throw new Error('Invalid backup archive'); - } - - 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 !== 1) { - 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, + destinationId: destination.id, + destinationName: destination.name, + destinationType: destination.type, }; } -function ensureRowArray(value: unknown, table: string): SqlRow[] { - if (!Array.isArray(value)) { - throw new Error(`Backup archive table ${table} is invalid`); - } - return value as SqlRow[]; -} - -function validateBackupPayloadContents(payload: BackupPayload, files: Record): 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(); - 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(); - 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(); - 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 that references a missing folder: ${id}`); - } - if (cipherIds.has(id)) throw new Error(`Backup archive contains duplicate cipher id: ${id}`); - cipherIds.add(id); - } - - const attachmentIds = new Set(); - for (const row of attachmentRows) { - const id = String(row.id || '').trim(); - const cipherId = String(row.cipher_id || '').trim(); - if (!id || !cipherIds.has(cipherId)) throw new Error('Backup archive contains an invalid attachment row'); - if (attachmentIds.has(id)) throw new Error(`Backup archive contains duplicate attachment id: ${id}`); - attachmentIds.add(id); - - const path = `attachments/${cipherId}/${id}.bin`; - const entry = files[path]; - if (!(entry instanceof Uint8Array)) { - throw new Error(`Backup archive is missing required file: ${path}`); - } - } - - const sendIds = new Set(); - 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) continue; - const path = `send-files/${id}/${fileId}.bin`; - const entry = files[path]; - if (!(entry instanceof Uint8Array)) { - throw new Error(`Backup archive is missing required file: ${path}`); - } - } -} - -function validateImportBlobLimits(env: Env, payload: BackupPayload, files: Record): void { - const storageKind = getBlobStorageKind(env); - const hasBlobFiles = - payload.db.attachments.length > 0 || - payload.db.sends.some((row) => !!parseSendFileId(typeof row.data === 'string' ? row.data : null)); - - if (!storageKind && hasBlobFiles) { - throw new Error('Backup contains files but attachment storage is not configured on the target instance'); - } - - if (storageKind !== 'kv') return; - - let largestObjectBytes = 0; - for (const row of payload.db.attachments) { - const cipherId = String(row.cipher_id || '').trim(); - const attachmentId = String(row.id || '').trim(); - if (!cipherId || !attachmentId) continue; - const entry = files[`attachments/${cipherId}/${attachmentId}.bin`]; - if (!entry) continue; - largestObjectBytes = Math.max(largestObjectBytes, entry.byteLength); - } - - for (const row of payload.db.sends) { - const sendId = String(row.id || '').trim(); - const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null); - if (!sendId || !fileId) continue; - const entry = files[`send-files/${sendId}/${fileId}.bin`]; - if (!entry) continue; - largestObjectBytes = Math.max(largestObjectBytes, entry.byteLength); - } - - if (largestObjectBytes > KV_MAX_OBJECT_BYTES) { - throw new Error(`Backup contains a file larger than the Workers KV ${Math.floor(KV_MAX_OBJECT_BYTES / (1024 * 1024))} MiB per-object limit`); - } -} - -async function insertRows(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): Promise { - if (!rows.length) return; - const placeholders = columns.map(() => '?').join(', '); - const updateSql = upsert - ? ' ON CONFLICT(key) DO UPDATE SET value = excluded.value' - : ''; - const sql = `INSERT INTO ${table}(${columns.join(', ')}) VALUES(${placeholders})${updateSql}`; - const statements: D1PreparedStatement[] = rows.map((row) => - db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)) - ); - const chunkSize = 32; - for (let i = 0; i < statements.length; i += chunkSize) { - await db.batch(statements.slice(i, i + chunkSize)); - } -} - -async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record): 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 zipPath = `attachments/${cipherId}/${attachmentId}.bin`; - const bytes = files[zipPath]; - await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, { - size: bytes.byteLength, - contentType: 'application/octet-stream', - customMetadata: { cipherId, attachmentId }, - }); - 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 zipPath = `send-files/${sendId}/${fileId}.bin`; - const bytes = files[zipPath]; - await putBlobObject(env, getSendFileObjectKey(sendId, fileId), bytes, { - size: bytes.byteLength, - contentType: 'application/octet-stream', - customMetadata: { sendId, fileId }, - }); - sendFileCount += 1; - } - - return { attachments: attachmentCount, sendFiles: sendFileCount }; -} - -// POST /api/admin/backup/export -export async function handleAdminExportBackup( - request: Request, +async function executeConfiguredBackup( env: Env, - actorUser: User -): Promise { + storage: StorageService, + actorUserId: string | null, + trigger: 'manual' | 'scheduled', + destinationId?: string | null +): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> { + const currentSettings = await loadBackupSettings(storage, env, 'UTC'); + const destination = requireBackupDestination(currentSettings, destinationId); + if (destination.type === 'placeholder') { + throw new Error('The reserved backup destination is not available yet'); + } + + 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 saveBackupSettings(storage, env, currentSettings); + + try { + const archive = await buildBackupArchive(env, now); + const upload = await uploadBackupArchive(destination, archive.bytes, archive.fileName); + let prunedFileCount = 0; + let pruneErrorMessage: string | null = null; + try { + 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 saveBackupSettings(storage, env, currentSettings); + + 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, + prunedFileCount, + pruneError: pruneErrorMessage, + }); + + 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 saveBackupSettings(storage, env, currentSettings); + + await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, { + ...getBackupDestinationSummary(destination), + 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, + actorUser: User, + archiveBytes: Uint8Array, + replaceExisting: boolean, + metadata: Record +): Promise { + const storage = new StorageService(env.DB); + const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting); + 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, + sendFiles: imported.result.imported.sendFiles, + replaceExisting, + ...metadata, + }); + return imported; +} + +export async function runScheduledBackupIfDue(env: Env): Promise { + const storage = new StorageService(env.DB); + const settings = await loadBackupSettings(storage, env, 'UTC'); + const now = new Date(); + for (const destination of settings.destinations) { + if (!isBackupDueNow(destination, now, BACKUP_SCHEDULER_WINDOW_MINUTES)) continue; + await executeConfiguredBackup(env, storage, null, 'scheduled', destination.id); + } +} + +export async function handleGetAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise { void request; - if (!isAdmin(actorUser)) { - return errorResponse('Forbidden', 403); + 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 { + if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); + + let body: BackupSettingsInput; + try { + body = await request.json(); + } catch { + return errorResponse('Backup settings payload is invalid', 400); } const storage = new StorageService(env.DB); - 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 files: Record = { - 'manifest.json': encoder.encode( - JSON.stringify( - { - formatVersion: 1, - exportedAt: new Date().toISOString(), - appVersion: '1.0', - 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, - 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) { - return errorResponse(`Attachment blob missing for ${cipherId}/${attachmentId}`, 409); - } - const bytes = await streamToBytes(object.body); - files[`attachments/${cipherId}/${attachmentId}.bin`] = bytes; - attachmentBlobCount += 1; - totalBlobBytes += bytes.byteLength; - largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength); + let previous; + try { + previous = await loadBackupSettings(storage, env, 'UTC'); + } catch { + previous = getDefaultBackupSettings('UTC'); } - 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) { - return errorResponse(`Send file blob missing for ${sendId}/${fileId}`, 409); - } - const bytes = await streamToBytes(object.body); - files[`send-files/${sendId}/${fileId}.bin`] = bytes; - sendFileBlobCount += 1; - totalBlobBytes += bytes.byteLength; - largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength); + let next; + try { + next = normalizeBackupSettingsInput(body, previous); + } catch (error) { + return errorResponse(error instanceof Error ? error.message : 'Backup settings are invalid', 400); } - files['manifest.json'] = encoder.encode( - JSON.stringify( - { - formatVersion: 1, - exportedAt: new Date().toISOString(), - appVersion: '1.0', - 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: attachmentBlobCount, - sendFiles: sendFileBlobCount, - totalBytes: totalBlobBytes, - largestObjectBytes, - }, - } satisfies BackupManifest, - null, - 2 - ) - ); + 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, + }); + return jsonResponse(next); +} + +export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise { + 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 { + if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); + + let body: BackupSettingsInput; + try { + body = await request.json(); + } 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, + }); + return jsonResponse(next); +} + +export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise { + if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); + + const storage = new StorageService(env.DB); + 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 result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null); + const settings = await loadBackupSettings(storage, env, 'UTC'); + return jsonResponse({ + object: 'backup-run', + result: { + fileName: result.fileName, + fileSize: result.fileSize, + provider: result.provider, + remotePath: result.remotePath, + }, + 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 { + 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 { + 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 handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise { + 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, + }); + 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 { + if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); + + let body: { destinationId?: string; path?: string; replaceExisting?: 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 remoteFile = await downloadRemoteBackupFile(destination, path); + const imported = await runImportAndAudit(env, actorUser, remoteFile.bytes, !!body.replaceExisting, { + ...getBackupDestinationSummary(destination), + remotePath: path, + bytes: remoteFile.bytes.byteLength, + trigger: 'remote', + }); + 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 { + void request; + if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); + + const storage = new StorageService(env.DB); + let archive: BackupArchiveBundle; + try { + archive = await buildBackupArchive(env); + } catch (error) { + const message = error instanceof Error ? error.message : 'Backup export failed'; + return errorResponse(message, message.includes('blob missing') ? 409 : 500); + } - const zipped = zipSync(files, { level: 0 }); await writeAuditLog(storage, actorUser.id, 'admin.backup.export', 'backup', null, { - users: userRows.length, - ciphers: cipherRows.length, - attachments: attachmentRows.length, - sends: sendRows.length, + users: archive.manifest.tableCounts.users, + ciphers: archive.manifest.tableCounts.ciphers, + attachments: archive.manifest.tableCounts.attachments, + sends: archive.manifest.tableCounts.sends, + compressedBytes: archive.bytes.byteLength, }); - return new Response(zipped, { + return new Response(archive.bytes, { status: 200, headers: { 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${buildBackupFileName()}"`, + 'Content-Disposition': `attachment; filename="${archive.fileName}"`, 'Cache-Control': 'no-store', }, }); } -// POST /api/admin/backup/import -export async function handleAdminImportBackup( - request: Request, - env: Env, - actorUser: User -): Promise { - if (!isAdmin(actorUser)) { - return errorResponse('Forbidden', 403); - } - - const storage = new StorageService(env.DB); +export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise { + if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); let formData: FormData; try { @@ -572,107 +436,33 @@ export async function handleAdminImportBackup( if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) { return errorResponse('Backup file is required', 400); } - const backupFile = file as { arrayBuffer(): Promise }; - const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1'; + const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1'; let archiveBytes: Uint8Array; try { - archiveBytes = new Uint8Array(await backupFile.arrayBuffer()); + archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise }).arrayBuffer()); } catch { return errorResponse('Unable to read backup file', 400); } - let parsed: { payload: BackupPayload; files: Record }; try { - parsed = parseBackupArchive(archiveBytes); - } catch (error) { - return errorResponse(error instanceof Error ? error.message : 'Invalid backup archive', 400); - } - - try { - validateBackupPayloadContents(parsed.payload, parsed.files); - } catch (error) { - return errorResponse(error instanceof Error ? error.message : 'Backup archive contents are invalid', 400); - } - - try { - validateImportBlobLimits(env, parsed.payload, parsed.files); - } catch (error) { - return errorResponse(error instanceof Error ? error.message : 'Backup import is not supported by the current storage backend', 409); - } - - let targetIsFresh = true; - try { - await ensureImportTargetIsFresh(env.DB); - } catch (error) { - targetIsFresh = false; - if (!replaceExisting) { - return errorResponse(error instanceof Error ? error.message : 'Backup import requires a fresh instance', 409); - } - } - - const { db } = parsed.payload; - try { - if (!targetIsFresh) { - await clearExistingBlobFiles(env, env.DB); - await resetImportTarget(env.DB); - } else { - await resetImportTarget(env.DB); - } - await insertRows(env.DB, 'config', ['key', 'value'], db.config || [], true); - await insertRows( - env.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'], - db.users || [] - ); - await insertRows(env.DB, 'user_revisions', ['user_id', 'revision_date'], db.user_revisions || []); - await insertRows(env.DB, 'folders', ['id', 'user_id', 'name', 'created_at', 'updated_at'], db.folders || []); - await insertRows( - env.DB, - 'ciphers', - ['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'], - db.ciphers || [] - ); - await insertRows( - env.DB, - 'attachments', - ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], - db.attachments || [] - ); - await insertRows( - env.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'], - db.sends || [] - ); - - const blobCounts = await restoreBlobFiles(env, db, parsed.files); - await storage.setRegistered(); - const importedActorUserId = (db.users || []).some((row) => String(row.id || '').trim() === actorUser.id) ? actorUser.id : null; - await writeAuditLog(storage, importedActorUserId, 'admin.backup.import', 'backup', null, { - users: (db.users || []).length, - ciphers: (db.ciphers || []).length, - attachments: blobCounts.attachments, - sendFiles: blobCounts.sendFiles, - replaceExisting, - }); - - return jsonResponse({ - 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, - }, + const imported = await runImportAndAudit(env, actorUser, archiveBytes, replaceExisting, { + trigger: 'local', + bytes: archiveBytes.byteLength, }); + return jsonResponse(imported.result); } catch (error) { - return errorResponse(error instanceof Error ? error.message : 'Backup import failed', 500); + const message = error instanceof Error ? error.message : 'Backup import failed'; + return errorResponse(message, toImportStatusCode(message)); } } + +export async function seedDefaultBackupSettings(env: Env): Promise { + 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')); +} diff --git a/src/index.ts b/src/index.ts index 1cbb3ec..069ba21 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { 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 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 }; diff --git a/src/router.ts b/src/router.ts index 6e21218..88c1aa6 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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; + +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; + 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; + 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 { + const result = await db.prepare(sql).bind(...values).all(); + return (result.results || []).map((row) => ({ ...row })); +} + +async function streamToBytes(stream: ReadableStream | null): Promise { + 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 } { + validateArchiveSize(bytes); + let zipped: Record; + 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): 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(); + 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(); + 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(); + 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(); + 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 { + 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 = { + '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, + }; +} diff --git a/src/services/backup-config.ts b/src/services/backup-config.ts new file mode 100644 index 0000000..4d0722b --- /dev/null +++ b/src/services/backup-config.ts @@ -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 { + 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, + 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, 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, + 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(); + 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 { + 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; + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = { + 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; +} diff --git a/src/services/backup-import.ts b/src/services/backup-import.ts new file mode 100644 index 0000000..6e5cf30 --- /dev/null +++ b/src/services/backup-import.ts @@ -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; + +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 { + const response = await db.prepare(sql).bind(...values).all(); + return (response.results || []).map((row) => ({ ...row })); +} + +async function ensureImportTargetIsFresh(db: D1Database): Promise { + 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> { + const keys = new Set(); + 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 { + const keys = new Set(); + 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): 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): 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, afterKeys: Set): Promise { + 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 { + 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 { + 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(); + 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, + }, + }, + }; +} diff --git a/src/services/backup-settings-crypto.ts b/src/services/backup-settings-crypto.ts new file mode 100644 index 0000000..cd2e722 --- /dev/null +++ b/src/services/backup-settings-crypto.ts @@ -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 { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +async function deriveRuntimeKey(secret: string): Promise { + 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 { + return new Uint8Array( + await crypto.subtle.decrypt( + { name: AES_GCM_ALGORITHM, iv }, + key, + ciphertext + ) + ); +} + +async function importPortablePublicKey(publicKeyBase64: string): Promise { + return crypto.subtle.importKey( + 'spki', + base64ToBytes(publicKeyBase64), + { name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH }, + false, + ['encrypt'] + ); +} + +function getEligiblePortableUsers(users: Pick[]): Array> { + 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; + 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 => 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[] +): Promise { + 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 { + 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); +} diff --git a/src/services/backup-uploader.ts b/src/services/backup-uploader.ts new file mode 100644 index 0000000..91551b8 --- /dev/null +++ b/src/services/backup-uploader.ts @@ -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 { + 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 { + 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, + payloadHashHex: string, + accessKeyId: string, + secretAccessKey: string, + region: string +): Promise { + 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 { + 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 { + 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 { + 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: ``, + }); + 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 { + 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 { + 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 { + const payloadHashHex = await sha256Hex(body || new Uint8Array()); + const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, ''); + const headers: Record = { + 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 { + 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 { + 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(); + 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 { + 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 { + 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; + list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise; + download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise; + deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise; +} + +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 { + const adapter = resolveConfiguredDestinationAdapter(destination); + return adapter.upload(adapter.config, archive, fileName); +} + +export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise { + const adapter = resolveConfiguredDestinationAdapter(destination); + return adapter.list(adapter.config, relativePath); +} + +export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise { + const adapter = resolveConfiguredDestinationAdapter(destination); + return adapter.download(adapter.config, relativePath); +} + +export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise { + 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 { + 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; +} diff --git a/src/services/storage.ts b/src/services/storage.ts index ca11de5..a748574 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -213,6 +213,18 @@ export class StorageService { return row?.value === 'true'; } + async getConfigValue(key: string): Promise { + 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 { + 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 { await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') .bind('registered', 'true') diff --git a/tsconfig.json b/tsconfig.json index 71c5846..d231ee3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,6 @@ "noUnusedLocals": false, "noUnusedParameters": false }, - "include": ["src/**/*"], + "include": ["src/**/*", "shared/**/*"], "exclude": ["node_modules"] } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 6633848..60176d4 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { lazy, Suspense } from 'preact/compat'; import { Link, Route, Switch, useLocation } from 'wouter'; import { useQuery } from '@tanstack/react-query'; import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; @@ -10,11 +11,6 @@ import SendsPage from '@/components/SendsPage'; import PublicSendPage from '@/components/PublicSendPage'; import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage'; import JwtWarningPage from '@/components/JwtWarningPage'; -import SettingsPage from '@/components/SettingsPage'; -import SecurityDevicesPage from '@/components/SecurityDevicesPage'; -import AdminPage from '@/components/AdminPage'; -import HelpPage from '@/components/HelpPage'; -import ImportPage from '@/components/ImportPage'; import TotpCodesPage from '@/components/TotpCodesPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import { @@ -25,6 +21,7 @@ import { updateFolder, deleteCipherAttachment, deleteFolder, + deleteRemoteBackup, bulkDeleteCiphers, bulkPermanentDeleteCiphers, bulkRestoreCiphers, @@ -35,6 +32,9 @@ import { downloadCipherAttachmentDecrypted, encryptFolderImportName, exportAdminBackup, + getAdminBackupSettingsRepairState, + getAdminBackupSettings, + downloadRemoteBackup, importAdminBackup, importCiphers, createSend, @@ -62,14 +62,19 @@ import { loginWithPassword, registerAccount, recoverTwoFactor, + repairAdminBackupSettings, revokeInvite, revokeAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust, + restoreRemoteBackup, + runAdminBackupNow, saveSession, + saveAdminBackupSettings, setTotp, setUserStatus, deleteAllAuthorizedDevices, deleteAuthorizedDevice, + listRemoteBackups, uploadCipherAttachment, updateCipher, updateSend, @@ -78,6 +83,7 @@ import { verifyMasterPassword, type ImportedCipherMapEntry, } from '@/lib/api'; +import { decryptPortableBackupSettings } from '@/lib/admin-backup-portable'; import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto'; import { attachNodeWardenEncryptedAttachmentPayload, @@ -96,6 +102,12 @@ import { t } from '@/lib/i18n'; import type { CiphersImportPayload } from '@/lib/api'; import type { AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; +const SettingsPage = lazy(() => import('@/components/SettingsPage')); +const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage')); +const AdminPage = lazy(() => import('@/components/AdminPage')); +const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage')); +const ImportPage = lazy(() => import('@/components/ImportPage')); + interface PendingTotp { email: string; passwordHash: string; @@ -136,6 +148,10 @@ function readInviteCodeFromUrl(): string { return ''; } +function RouteContentFallback() { + return
{t('txt_loading_nodewarden')}
; +} + function summarizeImportResult( ciphers: Array>, folderCount: number, @@ -435,6 +451,21 @@ export default function App() { saveSession(next); } + async function silentlyRepairBackupSettingsIfNeeded(activeSession: SessionState, activeProfile: Profile): Promise { + if (activeProfile.role !== 'admin') return; + if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return; + + const tempFetch = createAuthedFetch(() => activeSession, () => {}); + try { + const state = await getAdminBackupSettingsRepairState(tempFetch); + if (!state.needsRepair || !state.portable) return; + const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession); + await repairAdminBackupSettings(tempFetch, repairedSettings); + } catch (error) { + console.error('Backup settings auto-repair failed:', error); + } + } + function pushToast(type: ToastMessage['type'], text: string) { const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; setToasts((prev) => [...prev.slice(-3), { id, type, text }]); @@ -528,6 +559,7 @@ export default function App() { const nextSession = { ...baseSession, ...keys }; setSession(nextSession); setProfile(profileResp); + await silentlyRepairBackupSettingsIfNeeded(nextSession, profileResp); setPendingTotp(null); setTotpCode(''); setPhase('app'); @@ -651,7 +683,9 @@ export default function App() { try { const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations); const keys = await unlockVaultKey(profile.key, derived.masterKey); - setSession({ ...session, ...keys }); + const nextSession = { ...session, ...keys }; + setSession(nextSession); + await silentlyRepairBackupSettingsIfNeeded(nextSession, profile); setUnlockPassword(''); setPhase('app'); if (location === '/' || location === '/lock') navigate('/vault'); @@ -1808,6 +1842,38 @@ export default function App() { }, 200); } + async function handleLoadBackupSettingsAction() { + return getAdminBackupSettings(authedFetch); + } + + async function handleSaveBackupSettingsAction(settings: any) { + return saveAdminBackupSettings(authedFetch, settings); + } + + async function handleRunRemoteBackupAction(destinationId?: string | null) { + return runAdminBackupNow(authedFetch, destinationId); + } + + async function handleListRemoteBackupsAction(destinationId: string, path: string) { + return listRemoteBackups(authedFetch, destinationId, path); + } + + async function handleDownloadRemoteBackupAction(destinationId: string, path: string) { + const payload = await downloadRemoteBackup(authedFetch, destinationId, path); + downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); + } + + async function handleDeleteRemoteBackupAction(destinationId: string, path: string) { + await deleteRemoteBackup(authedFetch, destinationId, path); + } + + async function handleRestoreRemoteBackupAction(destinationId: string, path: string, replaceExisting: boolean = false) { + await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting); + window.setTimeout(() => { + logoutNow(); + }, 200); + } + const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0]; @@ -1841,6 +1907,19 @@ export default function App() { return t('nav_my_vault'); })(); + const importPageContent = ( + }> + + + ); + useEffect(() => { if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault'); }, [phase, location, isPublicSendRoute, navigate]); @@ -2099,22 +2178,24 @@ export default function App() {
-
+ {t('txt_back')} + + )} - { - await enableTotpAction(secret, token); - await totpStatusQuery.refetch(); - }} - onOpenDisableTotp={() => setDisableTotpOpen(true)} - onGetRecoveryCode={getRecoveryCodeAction} - onNotify={pushToast} - /> + }> + { + await enableTotpAction(secret, token); + await totpStatusQuery.refetch(); + }} + onOpenDisableTotp={() => setDisableTotpOpen(true)} + onGetRecoveryCode={getRecoveryCodeAction} + onNotify={pushToast} + /> + )} @@ -2164,55 +2245,57 @@ export default function App() { )} - void refreshAuthorizedDevices()} - onRevokeTrust={(device) => { - setConfirm({ - title: t('txt_revoke_device_authorization'), - message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }), - danger: true, - onConfirm: () => { - setConfirm(null); - void revokeDeviceTrustAction(device); - }, - }); - }} - onRemoveDevice={(device) => { - setConfirm({ - title: t('txt_remove_device'), - message: t('txt_remove_device_and_sign_out_name', { name: device.name }), - danger: true, - onConfirm: () => { - setConfirm(null); - void removeDeviceAction(device); - }, - }); - }} - onRevokeAll={() => { - setConfirm({ - title: t('txt_revoke_all_trusted_devices'), - message: t('txt_revoke_30_day_totp_trust_from_all_devices'), - danger: true, - onConfirm: () => { - setConfirm(null); - void revokeAllDeviceTrustAction(); - }, - }); - }} - onRemoveAll={() => { - setConfirm({ - title: t('txt_remove_all_devices'), - message: t('txt_remove_all_devices_and_sign_out_all_sessions'), - danger: true, - onConfirm: () => { - setConfirm(null); - void removeAllDevicesAction(); - }, - }); - }} - /> + }> + void refreshAuthorizedDevices()} + onRevokeTrust={(device) => { + setConfirm({ + title: t('txt_revoke_device_authorization'), + message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }), + danger: true, + onConfirm: () => { + setConfirm(null); + void revokeDeviceTrustAction(device); + }, + }); + }} + onRemoveDevice={(device) => { + setConfirm({ + title: t('txt_remove_device'), + message: t('txt_remove_device_and_sign_out_name', { name: device.name }), + danger: true, + onConfirm: () => { + setConfirm(null); + void removeDeviceAction(device); + }, + }); + }} + onRevokeAll={() => { + setConfirm({ + title: t('txt_revoke_all_trusted_devices'), + message: t('txt_revoke_30_day_totp_trust_from_all_devices'), + danger: true, + onConfirm: () => { + setConfirm(null); + void revokeAllDeviceTrustAction(); + }, + }); + }} + onRemoveAll={() => { + setConfirm({ + title: t('txt_remove_all_devices'), + message: t('txt_remove_all_devices_and_sign_out_all_sessions'), + danger: true, + onConfirm: () => { + setConfirm(null); + void removeAllDevicesAction(); + }, + }); + }} + /> + @@ -2225,60 +2308,62 @@ export default function App() { )} - { - void usersQuery.refetch(); - void invitesQuery.refetch(); - }} - onCreateInvite={async (hours) => { - await createInvite(authedFetch, hours); - await invitesQuery.refetch(); - pushToast('success', t('txt_invite_created')); - }} - onDeleteAllInvites={async () => { - setConfirm({ - title: t('txt_delete_all_invites'), - message: t('txt_delete_all_invite_codes_active_inactive'), - danger: true, - onConfirm: () => { - setConfirm(null); - void (async () => { - await deleteAllInvites(authedFetch); - await invitesQuery.refetch(); - pushToast('success', t('txt_all_invites_deleted')); - })(); - }, - }); - }} - onToggleUserStatus={async (userId, status) => { - await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); - await usersQuery.refetch(); - pushToast('success', t('txt_user_status_updated')); - }} - onDeleteUser={async (userId) => { - setConfirm({ - title: t('txt_delete_user'), - message: t('txt_delete_this_user_and_all_user_data'), - danger: true, - onConfirm: () => { - setConfirm(null); - void (async () => { - await deleteUser(authedFetch, userId); - await usersQuery.refetch(); - pushToast('success', t('txt_user_deleted')); - })(); - }, - }); - }} - onRevokeInvite={async (code) => { - await revokeInvite(authedFetch, code); - await invitesQuery.refetch(); - pushToast('success', t('txt_invite_revoked')); - }} - /> + }> + { + void usersQuery.refetch(); + void invitesQuery.refetch(); + }} + onCreateInvite={async (hours) => { + await createInvite(authedFetch, hours); + await invitesQuery.refetch(); + pushToast('success', t('txt_invite_created')); + }} + onDeleteAllInvites={async () => { + setConfirm({ + title: t('txt_delete_all_invites'), + message: t('txt_delete_all_invite_codes_active_inactive'), + danger: true, + onConfirm: () => { + setConfirm(null); + void (async () => { + await deleteAllInvites(authedFetch); + await invitesQuery.refetch(); + pushToast('success', t('txt_all_invites_deleted')); + })(); + }, + }); + }} + onToggleUserStatus={async (userId, status) => { + await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); + await usersQuery.refetch(); + pushToast('success', t('txt_user_status_updated')); + }} + onDeleteUser={async (userId) => { + setConfirm({ + title: t('txt_delete_user'), + message: t('txt_delete_this_user_and_all_user_data'), + danger: true, + onConfirm: () => { + setConfirm(null); + void (async () => { + await deleteUser(authedFetch, userId); + await usersQuery.refetch(); + pushToast('success', t('txt_user_deleted')); + })(); + }, + }); + }} + onRevokeInvite={async (code) => { + await revokeInvite(authedFetch, code); + await invitesQuery.refetch(); + pushToast('success', t('txt_invite_revoked')); + }} + /> + @@ -2291,65 +2376,23 @@ export default function App() { )} - + {importPageContent} - + {importPageContent} - + {importPageContent} - + {importPageContent} - + {importPageContent} - + {importPageContent} {profile?.role === 'admin' ? ( @@ -2358,11 +2401,24 @@ export default function App() {
-
+ {t('txt_back')} + + )} - + }> + + ) : null}
diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx new file mode 100644 index 0000000..731d0f6 --- /dev/null +++ b/webapp/src/components/BackupCenterPage.tsx @@ -0,0 +1,590 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; +import ConfirmDialog from '@/components/ConfirmDialog'; +import { + type AdminBackupRunResponse, + type AdminBackupSettings, + type BackupDestinationRecord, + type BackupDestinationType, + type RemoteBackupBrowserResponse, +} from '@/lib/api'; +import { + REMOTE_BROWSER_ITEMS_PER_PAGE, + compareRemoteItems, + createDraftBackupSettings, + createDraftDestinationRecord, + getDestinationById, + getFirstVisibleDestinationId, + getRemoteBrowserCacheKey, + getVisibleDestinations, + invalidateRemoteBrowserCacheForDestination, + isReplaceRequiredError, + loadPersistedRemoteBrowserState, + persistRemoteBrowserState, +} from '@/lib/backup-center'; +import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations'; +import { t } from '@/lib/i18n'; +import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail'; +import { BackupDestinationSidebar } from './backup-center/BackupDestinationSidebar'; +import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar'; + +interface BackupCenterPageProps { + onExport: () => Promise; + onImport: (file: File, replaceExisting?: boolean) => Promise; + onLoadSettings: () => Promise; + onSaveSettings: (settings: AdminBackupSettings) => Promise; + onRunRemoteBackup: (destinationId?: string | null) => Promise; + onListRemoteBackups: (destinationId: string, path: string) => Promise; + onDownloadRemoteBackup: (destinationId: string, path: string) => Promise; + onDeleteRemoteBackup: (destinationId: string, path: string) => Promise; + onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; + onNotify: (type: 'success' | 'error', text: string) => void; +} + +export default function BackupCenterPage(props: BackupCenterPageProps) { + const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState()); + const persistedRemoteState = persistedRemoteStateRef.current; + const fileInputRef = useRef(null); + + const [selectedFile, setSelectedFile] = useState(null); + const [exporting, setExporting] = useState(false); + const [importing, setImporting] = useState(false); + const [loadingSettings, setLoadingSettings] = useState(true); + const [savingSettings, setSavingSettings] = useState(false); + const [runningRemoteBackup, setRunningRemoteBackup] = useState(false); + const [loadingRemoteBrowser, setLoadingRemoteBrowser] = useState(false); + const [downloadingRemotePath, setDownloadingRemotePath] = useState(''); + const [restoringRemotePath, setRestoringRemotePath] = useState(''); + const [deletingRemotePath, setDeletingRemotePath] = useState(''); + const [localError, setLocalError] = useState(''); + const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false); + const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false); + const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false); + const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false); + const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false); + const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState(''); + const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState(''); + const [savedSettings, setSavedSettings] = useState(null); + const [settings, setSettings] = useState(createDraftBackupSettings); + const [selectedDestinationId, setSelectedDestinationId] = useState(persistedRemoteState.selectedDestinationId); + const [selectedProviderId, setSelectedProviderId] = useState(null); + const [remoteBrowserCache, setRemoteBrowserCache] = useState>(persistedRemoteState.cache); + const [remoteBrowserPathByDestination, setRemoteBrowserPathByDestination] = useState>(persistedRemoteState.pathByDestination); + const [remoteBrowserPageByKey, setRemoteBrowserPageByKey] = useState>(persistedRemoteState.pageByKey); + const [showAddChooser, setShowAddChooser] = useState(false); + + const visibleDestinations = getVisibleDestinations(settings); + const selectedDestination = getDestinationById(settings, selectedDestinationId); + const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId); + const selectedDestinationIsSaved = !!savedSelectedDestination; + const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup; + const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : ''; + const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : ''; + const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null; + const remoteBrowserItems = remoteBrowser?.items || []; + const remoteBrowserTotalPages = Math.max(1, Math.ceil(remoteBrowserItems.length / REMOTE_BROWSER_ITEMS_PER_PAGE)); + const currentRemoteBrowserPage = Math.min(remoteBrowserPageByKey[currentRemoteBrowserKey] || 1, remoteBrowserTotalPages); + const remoteBrowserVisibleItems = remoteBrowserItems.slice( + (currentRemoteBrowserPage - 1) * REMOTE_BROWSER_ITEMS_PER_PAGE, + currentRemoteBrowserPage * REMOTE_BROWSER_ITEMS_PER_PAGE + ); + + const selectedRecommendedProvider = RECOMMENDED_PROVIDERS.find((provider) => provider.id === selectedProviderId) || null; + const recommendedWebDavProviders = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 'webdav'); + const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3'); + const canRunSelectedDestination = !!selectedDestination && selectedDestination.type !== 'placeholder' && selectedDestinationIsSaved; + const canBrowseSelectedDestination = !!savedSelectedDestination && savedSelectedDestination.type !== 'placeholder'; + + useEffect(() => { + let cancelled = false; + setLoadingSettings(true); + void props.onLoadSettings() + .then((loaded) => { + if (cancelled) return; + setSavedSettings(loaded); + setSettings(loaded); + const nextSelectedDestinationId = + (persistedRemoteState.selectedDestinationId + && getVisibleDestinations(loaded).some((destination) => destination.id === persistedRemoteState.selectedDestinationId) + ? persistedRemoteState.selectedDestinationId + : null) + || getFirstVisibleDestinationId(loaded); + setSelectedDestinationId(nextSelectedDestinationId); + setLocalError(''); + }) + .catch((error) => { + if (cancelled) return; + const message = error instanceof Error ? error.message : t('txt_backup_settings_load_failed'); + setLocalError(message); + props.onNotify('error', message); + }) + .finally(() => { + if (!cancelled) setLoadingSettings(false); + }); + return () => { + cancelled = true; + }; + }, []); + + useEffect(() => { + persistRemoteBrowserState({ + cache: remoteBrowserCache, + pathByDestination: remoteBrowserPathByDestination, + pageByKey: remoteBrowserPageByKey, + selectedDestinationId, + }); + }, [remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]); + + useEffect(() => { + if (selectedDestination?.type === 'placeholder') { + setSelectedDestinationId(getFirstVisibleDestinationId(settings)); + } + }, [selectedDestination?.id, selectedDestination?.type, settings]); + + function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) { + setSettings((current) => { + const next = mutator(current); + if (selectedDestinationId && !next.destinations.some((destination) => destination.id === selectedDestinationId)) { + setSelectedDestinationId(getFirstVisibleDestinationId(next)); + } + return next; + }); + } + + function updateSelectedDestination(mutator: (destination: BackupDestinationRecord) => BackupDestinationRecord) { + if (!selectedDestinationId) return; + updateSettings((current) => ({ + ...current, + destinations: current.destinations.map((destination) => ( + destination.id === selectedDestinationId ? mutator(destination) : destination + )), + })); + } + + async function loadRemoteBrowser(destinationId: string, path: string = '', options?: { force?: boolean }): Promise { + const cacheKey = getRemoteBrowserCacheKey(destinationId, path); + setRemoteBrowserPathByDestination((current) => ({ ...current, [destinationId]: path })); + if (!options?.force && remoteBrowserCache[cacheKey]) return; + + setLoadingRemoteBrowser(true); + try { + const browser = await props.onListRemoteBackups(destinationId, path); + const nextBrowser = { + ...browser, + items: browser.items.slice().sort(compareRemoteItems), + }; + setRemoteBrowserCache((current) => ({ ...current, [cacheKey]: nextBrowser })); + setRemoteBrowserPageByKey((current) => ({ ...current, [cacheKey]: 1 })); + } catch (error) { + const message = error instanceof Error ? error.message : t('txt_backup_remote_load_failed'); + setLocalError(message); + props.onNotify('error', message); + } finally { + setLoadingRemoteBrowser(false); + } + } + + function showRemoteBrowserPath(destinationId: string, path: string = ''): void { + setRemoteBrowserPathByDestination((current) => ({ ...current, [destinationId]: path })); + } + + function buildSettingsPayloadForSelectedDestination(): AdminBackupSettings { + if (!selectedDestinationId || !selectedDestination) { + return savedSettings || { destinations: [] }; + } + const persistedDestinations = (savedSettings?.destinations || []).filter((destination) => destination.id !== selectedDestinationId); + return { + destinations: [...persistedDestinations, selectedDestination], + }; + } + + function applySavedDestinationToDrafts(saved: AdminBackupSettings, destinationId: string | null) { + if (!destinationId) { + setSettings((current) => ({ + destinations: current.destinations.filter((destination) => !savedSettings?.destinations.some((savedDestination) => savedDestination.id === destination.id)), + })); + return; + } + const savedDestination = getDestinationById(saved, destinationId); + setSettings((current) => ({ + destinations: current.destinations.map((destination) => ( + destination.id === destinationId && savedDestination ? savedDestination : destination + )), + })); + } + + function resetSelectedFile() { + setSelectedFile(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + + function handleAddDestination(type: BackupDestinationType) { + updateSettings((current) => { + const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1); + setSelectedProviderId(null); + setSelectedDestinationId(nextDestination.id); + return { + ...current, + destinations: [...current.destinations, nextDestination], + }; + }); + setShowAddChooser(false); + } + + async function handleDeleteDestination() { + if (!selectedDestinationId || savingSettings) return; + const destinationIdToDelete = selectedDestinationId; + const nextSettings: AdminBackupSettings = { + destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete), + }; + + setSavingSettings(true); + setLocalError(''); + try { + const saved = await props.onSaveSettings(nextSettings); + const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete); + const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved); + setSavedSettings(saved); + setSettings({ destinations: nextDraftDestinations }); + setRemoteBrowserCache((current) => invalidateRemoteBrowserCacheForDestination( + destinationIdToDelete, + current, + remoteBrowserPathByDestination, + remoteBrowserPageByKey + ).cache); + setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToDelete))); + setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToDelete}:`)))); + setSelectedDestinationId(nextSelected); + setConfirmDeleteDestinationOpen(false); + props.onNotify('success', t('txt_backup_destination_deleted')); + } catch (error) { + const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed'); + setLocalError(message); + props.onNotify('error', message); + } finally { + setSavingSettings(false); + } + } + + async function handleExport() { + setLocalError(''); + setExporting(true); + try { + await props.onExport(); + props.onNotify('success', t('txt_backup_export_success')); + } catch (error) { + const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); + setLocalError(message); + props.onNotify('error', message); + } finally { + setExporting(false); + } + } + + async function runLocalRestore(replaceExisting: boolean) { + if (!selectedFile) { + const message = t('txt_backup_file_required'); + setLocalError(message); + props.onNotify('error', message); + return; + } + setLocalError(''); + setImporting(true); + try { + await props.onImport(selectedFile, replaceExisting); + props.onNotify('success', t('txt_backup_restore_success_relogin')); + resetSelectedFile(); + setConfirmLocalRestoreOpen(false); + setConfirmReplaceOpen(false); + } catch (error) { + if (!replaceExisting && isReplaceRequiredError(error)) { + setConfirmLocalRestoreOpen(false); + setConfirmReplaceOpen(true); + return; + } + const message = error instanceof Error ? error.message : t('txt_backup_restore_failed'); + setLocalError(message); + props.onNotify('error', message); + } finally { + setImporting(false); + } + } + + async function handleSaveSettings() { + const payload = buildSettingsPayloadForSelectedDestination(); + const destinationIdToInvalidate = selectedDestinationId; + setSavingSettings(true); + setLocalError(''); + try { + const saved = await props.onSaveSettings(payload); + const nextSelected = + (selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId) + || getFirstVisibleDestinationId(saved) + || null; + setSavedSettings(saved); + applySavedDestinationToDrafts(saved, nextSelected); + if (destinationIdToInvalidate) { + setRemoteBrowserCache((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`)))); + setRemoteBrowserPathByDestination((current) => Object.fromEntries(Object.entries(current).filter(([key]) => key !== destinationIdToInvalidate))); + setRemoteBrowserPageByKey((current) => Object.fromEntries(Object.entries(current).filter(([key]) => !key.startsWith(`${destinationIdToInvalidate}:`)))); + } + setSelectedDestinationId(nextSelected); + props.onNotify('success', t('txt_backup_settings_saved')); + } catch (error) { + const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed'); + setLocalError(message); + props.onNotify('error', message); + } finally { + setSavingSettings(false); + } + } + + function handleToggleSelectedSchedule() { + if (!selectedDestination) return; + updateSelectedDestination((destination) => ({ + ...destination, + schedule: { + ...destination.schedule, + enabled: !destination.schedule.enabled, + }, + })); + } + + async function handleRunRemoteBackup() { + if (!selectedDestination) return; + setRunningRemoteBackup(true); + setLocalError(''); + try { + const result = await props.onRunRemoteBackup(selectedDestination.id); + setSavedSettings(result.settings); + setSettings(result.settings); + setSelectedDestinationId(selectedDestination.id); + await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true }); + props.onNotify('success', t('txt_backup_remote_run_success')); + } catch (error) { + const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed'); + setLocalError(message); + props.onNotify('error', message); + } finally { + setRunningRemoteBackup(false); + } + } + + async function handleDownloadRemote(path: string) { + if (!savedSelectedDestination) return; + setDownloadingRemotePath(path); + setLocalError(''); + try { + await props.onDownloadRemoteBackup(savedSelectedDestination.id, path); + } catch (error) { + const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed'); + setLocalError(message); + props.onNotify('error', message); + } finally { + setDownloadingRemotePath(''); + } + } + + async function handleDeleteRemote(path: string) { + if (!savedSelectedDestination) return; + setDeletingRemotePath(path); + setLocalError(''); + try { + await props.onDeleteRemoteBackup(savedSelectedDestination.id, path); + setConfirmRemoteDeleteOpen(false); + setPendingRemoteDeletePath(''); + await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true }); + props.onNotify('success', t('txt_backup_remote_delete_success')); + } catch (error) { + const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed'); + setLocalError(message); + props.onNotify('error', message); + } finally { + setDeletingRemotePath(''); + } + } + + async function runRemoteRestore(path: string, replaceExisting: boolean) { + if (!savedSelectedDestination) return; + setRestoringRemotePath(path); + setLocalError(''); + try { + await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); + setConfirmRemoteReplaceOpen(false); + setPendingRemoteRestorePath(''); + props.onNotify('success', t('txt_backup_restore_success_relogin')); + } catch (error) { + if (!replaceExisting && isReplaceRequiredError(error)) { + setPendingRemoteRestorePath(path); + setConfirmRemoteReplaceOpen(true); + return; + } + const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed'); + setLocalError(message); + props.onNotify('error', message); + } finally { + setRestoringRemotePath(''); + } + } + + return ( +
+ { + const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null; + setSelectedFile(nextFile); + setLocalError(''); + if (nextFile) setConfirmLocalRestoreOpen(true); + }} + /> + + void handleExport()} + onImport={() => fileInputRef.current?.click()} + onSelectProvider={(providerId) => setSelectedProviderId(providerId)} + /> + + { + setSelectedProviderId(null); + setSelectedDestinationId(destinationId); + }} + onToggleAddChooser={() => setShowAddChooser((current) => !current)} + onAddDestination={handleAddDestination} + /> + + void handleSaveSettings()} + onToggleSchedule={handleToggleSelectedSchedule} + onRunRemoteBackup={() => void handleRunRemoteBackup()} + onPromptDeleteDestination={() => setConfirmDeleteDestinationOpen(true)} + onUpdateDestination={updateSelectedDestination} + onRefreshRemoteBrowser={() => { + if (savedSelectedDestination) { + void loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true }); + } + }} + onShowRemoteBrowserPath={(path) => { + if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path); + }} + onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)} + onRestoreRemoteBackup={(path) => void runRemoteRestore(path, false)} + onPromptDeleteRemoteBackup={(path) => { + setPendingRemoteDeletePath(path); + setConfirmRemoteDeleteOpen(true); + }} + onChangeRemoteBrowserPage={(page) => { + if (!currentRemoteBrowserKey) return; + setRemoteBrowserPageByKey((current) => ({ ...current, [currentRemoteBrowserKey]: page })); + }} + /> + + {localError ?
{localError}
: null} + + void runLocalRestore(false)} + onCancel={() => { + setConfirmLocalRestoreOpen(false); + resetSelectedFile(); + }} + /> + + void runLocalRestore(true)} + onCancel={() => { + setConfirmReplaceOpen(false); + resetSelectedFile(); + }} + /> + + void runRemoteRestore(pendingRemoteRestorePath, true)} + onCancel={() => { + setConfirmRemoteReplaceOpen(false); + setPendingRemoteRestorePath(''); + }} + /> + + void handleDeleteRemote(pendingRemoteDeletePath)} + onCancel={() => { + if (deletingRemotePath) return; + setConfirmRemoteDeleteOpen(false); + setPendingRemoteDeletePath(''); + }} + /> + + void handleDeleteDestination()} + onCancel={() => { + if (savingSettings) return; + setConfirmDeleteDestinationOpen(false); + }} + /> +
+ ); +} diff --git a/webapp/src/components/HelpPage.tsx b/webapp/src/components/HelpPage.tsx deleted file mode 100644 index bef7298..0000000 --- a/webapp/src/components/HelpPage.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useRef, useState } from 'preact/hooks'; -import { Download, FileUp } from 'lucide-preact'; -import ConfirmDialog from '@/components/ConfirmDialog'; -import { t } from '@/lib/i18n'; - -interface HelpPageProps { - onExport: () => Promise; - onImport: (file: File, replaceExisting?: boolean) => Promise; - onNotify: (type: 'success' | 'error', text: string) => void; -} - -export default function HelpPage(props: HelpPageProps) { - const fileInputRef = useRef(null); - const [selectedFile, setSelectedFile] = useState(null); - const [exporting, setExporting] = useState(false); - const [importing, setImporting] = useState(false); - const [localError, setLocalError] = useState(''); - const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false); - - function isReplaceRequiredError(error: unknown): boolean { - const message = error instanceof Error ? String(error.message || '') : ''; - return message.toLowerCase().includes('fresh instance'); - } - - async function handleExport() { - setLocalError(''); - setExporting(true); - try { - await props.onExport(); - props.onNotify('success', t('txt_backup_export_success')); - } catch (error) { - const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); - setLocalError(message); - props.onNotify('error', message); - } finally { - setExporting(false); - } - } - - async function runImport(replaceExisting: boolean) { - if (!selectedFile) { - const message = t('txt_backup_file_required'); - setLocalError(message); - props.onNotify('error', message); - return; - } - - setLocalError(''); - setImporting(true); - try { - await props.onImport(selectedFile, replaceExisting); - props.onNotify('success', t('txt_backup_import_success_relogin')); - setSelectedFile(null); - if (fileInputRef.current) fileInputRef.current.value = ''; - setConfirmReplaceOpen(false); - } catch (error) { - if (!replaceExisting && isReplaceRequiredError(error)) { - setConfirmReplaceOpen(true); - return; - } - const message = error instanceof Error ? error.message : t('txt_backup_import_failed'); - setLocalError(message); - props.onNotify('error', message); - } finally { - setImporting(false); - } - } - - async function handleImport() { - await runImport(false); - } - - return ( -
-
-
-
-

{t('txt_backup_export')}

-
-

{t('txt_backup_export_description')}

-
- -
-
- -
-
-

{t('txt_backup_import')}

-
-

{t('txt_backup_import_description')}

- -
- {selectedFile ? ( - {t('txt_backup_selected_file_name', { name: selectedFile.name })} - ) : ( - {t('txt_backup_no_file_selected')} - )} -
-

{t('txt_backup_restore_note')}

-
- -
- {localError &&
{localError}
} -
-
- - void runImport(true)} - onCancel={() => setConfirmReplaceOpen(false)} - /> -
- ); -} diff --git a/webapp/src/components/backup-center/BackupDestinationDetail.tsx b/webapp/src/components/backup-center/BackupDestinationDetail.tsx new file mode 100644 index 0000000..5cb9506 --- /dev/null +++ b/webapp/src/components/backup-center/BackupDestinationDetail.tsx @@ -0,0 +1,527 @@ +import { CloudUpload, Save, Trash2 } from 'lucide-preact'; +import type { + BackupDestinationRecord, + E3BackupDestination, + RemoteBackupBrowserResponse, + WebDavBackupDestination, +} from '@/lib/api'; +import { COMMON_TIME_ZONES, WEEKDAY_OPTIONS, getDestinationTypeLabel } from '@/lib/backup-center'; +import type { RecommendedProvider } from '@/lib/backup-recommendations'; +import { RemoteBackupBrowser } from './RemoteBackupBrowser'; +import { t } from '@/lib/i18n'; + +interface BackupDestinationDetailProps { + selectedRecommendedProvider: RecommendedProvider | null; + selectedDestination: BackupDestinationRecord | null; + selectedDestinationIsSaved: boolean; + canRunSelectedDestination: boolean; + canBrowseSelectedDestination: boolean; + disableWhileBusy: boolean; + loadingSettings: boolean; + savingSettings: boolean; + runningRemoteBackup: boolean; + availableTimeZones: string[]; + remoteBrowser: RemoteBackupBrowserResponse | null; + remoteBrowserVisibleItems: RemoteBackupBrowserResponse['items']; + remoteBrowserCurrentPage: number; + remoteBrowserTotalPages: number; + loadingRemoteBrowser: boolean; + downloadingRemotePath: string; + restoringRemotePath: string; + deletingRemotePath: string; + onSaveSettings: () => void; + onToggleSchedule: () => void; + onRunRemoteBackup: () => void; + onPromptDeleteDestination: () => void; + onUpdateDestination: (mutator: (destination: BackupDestinationRecord) => BackupDestinationRecord) => void; + onRefreshRemoteBrowser: () => void; + onShowRemoteBrowserPath: (path: string) => void; + onDownloadRemoteBackup: (path: string) => void; + onRestoreRemoteBackup: (path: string) => void; + onPromptDeleteRemoteBackup: (path: string) => void; + onChangeRemoteBrowserPage: (page: number) => void; +} + +function renderRecommendedProviderDetails(provider: RecommendedProvider) { + switch (provider.id) { + case 'koofr': + return ( + <> +
+
+ 1. {t('txt_backup_recommend_koofr_step_1')} +
+
+ 2. {t('txt_backup_recommend_koofr_step_2_prefix')}{' '} + {t('txt_backup_recommend_koofr_password_link')} + {t('txt_backup_recommend_koofr_step_2_suffix')} +
+
+ 3. {t('txt_backup_recommend_koofr_step_3')} +
+
+ 4. {t('txt_backup_recommend_koofr_step_4')} +
+
+ 5. {t('txt_backup_recommend_koofr_step_5_prefix')}{' '} + {t('txt_backup_recommend_koofr_storage_link')} + {t('txt_backup_recommend_koofr_step_5_suffix')} +
+
+
{t('txt_backup_recommend_koofr_dav_intro')}
+
+
+ {t('txt_backup_recommend_koofr_dav_self')} + https://app.koofr.net/dav/Koofr +
+
+ Google Drive + https://app.koofr.net/dav/Google Drive +
+
+ OneDrive + https://app.koofr.net/dav/OneDrive +
+
+ Dropbox + https://app.koofr.net/dav/Dropbox +
+
+ + ); + case 'pcloud': + return ( +
+
+ 1. {t('txt_backup_recommend_pcloud_step_1')} +
+
+ 2. {t('txt_backup_recommend_pcloud_step_2')} +
+
+ 3. {t('txt_backup_recommend_pcloud_step_3')} +
+
+ ); + case 'infinicloud': + return ( +
+
+ 1. {t('txt_backup_recommend_infinicloud_step_1')} +
+
+ 2. {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '} + My Page + {t('txt_backup_recommend_infinicloud_step_2_suffix')} +
+
+ 3. {t('txt_backup_recommend_infinicloud_step_3')} +
+
+ 4. {t('txt_backup_recommend_infinicloud_step_4')} +
+
+ ); + } +} + +export function BackupDestinationDetail(props: BackupDestinationDetailProps) { + const timeZones = Array.from(new Set([ + ...COMMON_TIME_ZONES, + ...props.availableTimeZones, + ])); + + if (props.selectedRecommendedProvider) { + return ( +
+
+
+
+ {props.selectedRecommendedProvider.name} +
+ {props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary') + : props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary') + : t('txt_backup_recommend_pcloud_summary')} +
+
+ {props.selectedRecommendedProvider.capacity} +
+ + {renderRecommendedProviderDetails(props.selectedRecommendedProvider)} +
+
+ ); + } + + return ( +
+
+

{t('txt_backup_destination_detail_title')}

+ {props.selectedDestination ? ( +
+ + + + +
+ ) : null} +
+ + {!props.selectedDestination ? ( +
{t('txt_backup_select_destination')}
+ ) : ( + <> +
+ + +
+ +
+ + + + +
+ + {props.selectedDestination.schedule.frequency === 'weekly' ? ( +
+ +
+ ) : null} + + {props.selectedDestination.schedule.frequency === 'monthly' ? ( +
+ +
+ ) : null} + + {props.selectedDestination.type === 'webdav' ? ( +
+ + + + +
+ ) : null} + + {props.selectedDestination.type === 'e3' ? ( +
+ + + + + + +
+ ) : null} + + + + )} +
+ ); +} diff --git a/webapp/src/components/backup-center/BackupDestinationSidebar.tsx b/webapp/src/components/backup-center/BackupDestinationSidebar.tsx new file mode 100644 index 0000000..f61bb4a --- /dev/null +++ b/webapp/src/components/backup-center/BackupDestinationSidebar.tsx @@ -0,0 +1,70 @@ +import { Plus } from 'lucide-preact'; +import type { BackupDestinationRecord, BackupDestinationType } from '@/lib/api'; +import { formatDateTime, getDestinationTypeLabel } from '@/lib/backup-center'; +import { t } from '@/lib/i18n'; + +interface BackupDestinationSidebarProps { + destinations: BackupDestinationRecord[]; + selectedDestinationId: string | null; + disableWhileBusy: boolean; + showAddChooser: boolean; + onSelectDestination: (destinationId: string) => void; + onToggleAddChooser: () => void; + onAddDestination: (type: BackupDestinationType) => void; +} + +export function BackupDestinationSidebar(props: BackupDestinationSidebarProps) { + return ( + + ); +} diff --git a/webapp/src/components/backup-center/BackupOperationsSidebar.tsx b/webapp/src/components/backup-center/BackupOperationsSidebar.tsx new file mode 100644 index 0000000..e711a16 --- /dev/null +++ b/webapp/src/components/backup-center/BackupOperationsSidebar.tsx @@ -0,0 +1,92 @@ +import { Download, FileUp } from 'lucide-preact'; +import type { RecommendedProvider } from '@/lib/backup-recommendations'; +import { hasLinkedStorages } from '@/lib/backup-recommendations'; +import { t } from '@/lib/i18n'; + +interface BackupOperationsSidebarProps { + disableWhileBusy: boolean; + exporting: boolean; + importing: boolean; + selectedProviderId: string | null; + recommendedWebDavProviders: RecommendedProvider[]; + recommendedS3Providers: RecommendedProvider[]; + onExport: () => void; + onImport: () => void; + onSelectProvider: (providerId: string) => void; +} + +export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) { + return ( + + ); +} diff --git a/webapp/src/components/backup-center/RemoteBackupBrowser.tsx b/webapp/src/components/backup-center/RemoteBackupBrowser.tsx new file mode 100644 index 0000000..ae3f625 --- /dev/null +++ b/webapp/src/components/backup-center/RemoteBackupBrowser.tsx @@ -0,0 +1,138 @@ +import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact'; +import type { RemoteBackupBrowserResponse } from '@/lib/api'; +import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center'; +import { t } from '@/lib/i18n'; + +interface RemoteBackupBrowserProps { + canBrowse: boolean; + destinationIsSaved: boolean; + disableWhileBusy: boolean; + loadingRemoteBrowser: boolean; + remoteBrowser: RemoteBackupBrowserResponse | null; + visibleItems: RemoteBackupBrowserResponse['items']; + currentPage: number; + totalPages: number; + downloadingRemotePath: string; + restoringRemotePath: string; + deletingRemotePath: string; + onRefresh: () => void; + onShowPath: (path: string) => void; + onDownload: (path: string) => void; + onRestore: (path: string) => void; + onPromptDelete: (path: string) => void; + onChangePage: (page: number) => void; +} + +export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) { + return ( + <> +
+ +
+

{t('txt_backup_remote_title')}

+ {props.canBrowse ? ( +
+ +
+ ) : null} +
+ + {!props.destinationIsSaved ? ( +
{t('txt_backup_remote_save_first')}
+ ) : !props.remoteBrowser ? ( +
{t('txt_backup_remote_cached_empty')}
+ ) : ( + <> +
+ {t('txt_backup_remote_current_path')} + {props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'} +
+ +
+ + +
+ + {props.loadingRemoteBrowser ? ( +
{t('txt_backup_remote_loading')}
+ ) : props.remoteBrowser.items.length ? ( + <> +
+ {props.visibleItems.map((item) => ( +
+ +
+ {item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')} + {item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)} +
+
+ {item.isDirectory ? ( + + ) : ( + <> + + + + + )} +
+
+ ))} +
+ {props.totalPages > 1 ? ( +
+ + + {props.currentPage} / {props.totalPages} + + +
+ ) : null} + + ) : ( +
{t('txt_backup_remote_empty')}
+ )} + + )} + + ); +} diff --git a/webapp/src/lib/admin-backup-portable.ts b/webapp/src/lib/admin-backup-portable.ts new file mode 100644 index 0000000..1b12f30 --- /dev/null +++ b/webapp/src/lib/admin-backup-portable.ts @@ -0,0 +1,65 @@ +import { base64ToBytes, decryptBw } from './crypto'; +import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api'; +import type { Profile, SessionState } from './types'; + +const PORTABLE_ALGORITHM = 'RSA-OAEP'; +const PORTABLE_HASH = 'SHA-1'; +const AES_GCM_ALGORITHM = 'AES-GCM'; + +async function importPortablePrivateKey(pkcs8: Uint8Array): Promise { + return crypto.subtle.importKey( + 'pkcs8', + pkcs8, + { name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH }, + false, + ['decrypt'] + ); +} + +async function importPortableAesKey(keyBytes: Uint8Array): Promise { + return crypto.subtle.importKey('raw', keyBytes, { name: AES_GCM_ALGORITHM }, false, ['decrypt']); +} + +export async function decryptPortableBackupSettings( + portable: BackupSettingsPortablePayload, + profile: Profile, + session: SessionState +): Promise { + if (!profile.id) { + throw new Error('Current administrator profile is missing an id'); + } + if (!profile.privateKey) { + throw new Error('Current administrator profile is missing a private key'); + } + if (!session.symEncKey || !session.symMacKey) { + throw new Error('Current session is missing unlocked vault keys'); + } + + const wrap = portable.wraps.find((entry) => entry.userId === profile.id); + if (!wrap) { + throw new Error('No portable backup settings wrap is available for the current administrator'); + } + + const privateKeyBytes = await decryptBw( + profile.privateKey, + base64ToBytes(session.symEncKey), + base64ToBytes(session.symMacKey) + ); + const privateKey = await importPortablePrivateKey(privateKeyBytes); + const portableDek = new Uint8Array( + await crypto.subtle.decrypt( + { name: PORTABLE_ALGORITHM }, + privateKey, + base64ToBytes(wrap.wrappedKey) + ) + ); + const aesKey = await importPortableAesKey(portableDek); + const plaintext = new Uint8Array( + await crypto.subtle.decrypt( + { name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) }, + aesKey, + base64ToBytes(portable.ciphertext) + ) + ); + return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings; +} diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index 23547e0..1b51321 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -18,6 +18,13 @@ import type { VaultDraftField, WebConfigResponse, } from './types'; +import type { + BackupDestinationRecord, + BackupDestinationType, + BackupSettings as AdminBackupSettings, + E3BackupDestination, + WebDavBackupDestination, +} from '@shared/backup'; const SESSION_KEY = 'nodewarden.web.session.v4'; const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1'; @@ -932,6 +939,64 @@ export async function deleteUser(authedFetch: (input: string, init?: RequestInit if (!resp.ok) throw new Error('Delete user failed'); } +export type { + BackupDestinationConfig, + BackupDestinationRecord, + BackupDestinationType, + BackupRuntimeState, + BackupScheduleConfig, + BackupSettings as AdminBackupSettings, + E3BackupDestination, + PlaceholderBackupDestination, + WebDavBackupDestination, +} from '@shared/backup'; + +export interface BackupSettingsPortableWrap { + userId: string; + wrappedKey: string; +} + +export interface BackupSettingsPortablePayload { + iv: string; + ciphertext: string; + wraps: BackupSettingsPortableWrap[]; +} + +export interface BackupSettingsRepairStateResponse { + object: 'backup-settings-repair'; + needsRepair: boolean; + portable: BackupSettingsPortablePayload | null; +} + +export interface AdminBackupRunResponse { + object: 'backup-run'; + result: { + fileName: string; + fileSize: number; + provider: string; + remotePath: string; + }; + settings: AdminBackupSettings; +} + +export interface RemoteBackupItem { + path: string; + name: string; + isDirectory: boolean; + size: number | null; + modifiedAt: string | null; +} + +export interface RemoteBackupBrowserResponse { + object: 'backup-remote-browser'; + destinationId: string; + destinationName: string; + provider: BackupDestinationType; + currentPath: string; + parentPath: string | null; + items: RemoteBackupItem[]; +} + export interface AdminBackupImportCounts { config: number; users: number; @@ -959,21 +1024,149 @@ export async function exportAdminBackup( authedFetch: (input: string, init?: RequestInit) => Promise ): Promise { const resp = await authedFetch('/api/admin/backup/export', { method: 'POST' }); - if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Backup export failed')); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed'))); const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip'; - const fileName = parseContentDispositionFileName(resp, 'nodewarden_instance_backup.zip'); + const fileName = parseContentDispositionFileName(resp, 'nodewarden_backup.zip'); const bytes = new Uint8Array(await resp.arrayBuffer()); return { fileName, mimeType, bytes }; } +export async function getAdminBackupSettings( + authedFetch: (input: string, init?: RequestInit) => Promise +): Promise { + const resp = await authedFetch('/api/admin/backup/settings', { method: 'GET' }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_load_failed'))); + const body = await parseJson(resp); + if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response')); + return body; +} + +export async function saveAdminBackupSettings( + authedFetch: (input: string, init?: RequestInit) => Promise, + settings: AdminBackupSettings +): Promise { + const resp = await authedFetch('/api/admin/backup/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); + const body = await parseJson(resp); + if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response')); + return body; +} + +export async function getAdminBackupSettingsRepairState( + authedFetch: (input: string, init?: RequestInit) => Promise +): Promise { + const resp = await authedFetch('/api/admin/backup/settings/repair', { method: 'GET' }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_load_failed'))); + const body = await parseJson(resp); + if (!body || typeof body.needsRepair !== 'boolean') { + throw new Error(t('txt_backup_settings_invalid_response')); + } + return body; +} + +export async function repairAdminBackupSettings( + authedFetch: (input: string, init?: RequestInit) => Promise, + settings: AdminBackupSettings +): Promise { + const resp = await authedFetch('/api/admin/backup/settings/repair', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); + const body = await parseJson(resp); + if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response')); + return body; +} + +export async function runAdminBackupNow( + authedFetch: (input: string, init?: RequestInit) => Promise, + destinationId?: string | null +): Promise { + const resp = await authedFetch('/api/admin/backup/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(destinationId ? { destinationId } : {}), + }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed'))); + const body = await parseJson(resp); + if (!body?.result || !body?.settings) throw new Error(t('txt_backup_remote_run_invalid_response')); + return body; +} + +export async function listRemoteBackups( + authedFetch: (input: string, init?: RequestInit) => Promise, + destinationId: string, + path: string = '' +): Promise { + const params = new URLSearchParams(); + params.set('destinationId', destinationId); + if (path) params.set('path', path); + const query = `?${params.toString()}`; + const resp = await authedFetch(`/api/admin/backup/remote${query}`, { method: 'GET' }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_load_failed'))); + const body = await parseJson(resp); + if (!body?.items || typeof body.currentPath !== 'string' || !body.destinationId) throw new Error(t('txt_backup_remote_invalid_response')); + return body; +} + +export async function downloadRemoteBackup( + authedFetch: (input: string, init?: RequestInit) => Promise, + destinationId: string, + path: string +): Promise { + const params = new URLSearchParams(); + params.set('destinationId', destinationId); + params.set('path', path); + const resp = await authedFetch(`/api/admin/backup/remote/download?${params.toString()}`, { method: 'GET' }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed'))); + const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip'; + const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip'); + const bytes = new Uint8Array(await resp.arrayBuffer()); + return { fileName, mimeType, bytes }; +} + +export async function deleteRemoteBackup( + authedFetch: (input: string, init?: RequestInit) => Promise, + destinationId: string, + path: string +): Promise { + const params = new URLSearchParams(); + params.set('destinationId', destinationId); + params.set('path', path); + const resp = await authedFetch(`/api/admin/backup/remote/file?${params.toString()}`, { method: 'DELETE' }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed'))); +} + +export async function restoreRemoteBackup( + authedFetch: (input: string, init?: RequestInit) => Promise, + destinationId: string, + path: string, + replaceExisting: boolean = false +): Promise { + const resp = await authedFetch('/api/admin/backup/remote/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destinationId, path, replaceExisting }), + }); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed'))); + const body = await parseJson(resp); + if (!body?.imported) throw new Error(t('txt_backup_remote_restore_invalid_response')); + return body; +} + export async function importAdminBackup( authedFetch: (input: string, init?: RequestInit) => Promise, file: File, replaceExisting: boolean = false ): Promise { const formData = new FormData(); - formData.set('file', file, file.name || 'nodewarden_instance_backup.zip'); + formData.set('file', file, file.name || 'nodewarden_backup.zip'); if (replaceExisting) { formData.set('replaceExisting', '1'); } @@ -982,10 +1175,10 @@ export async function importAdminBackup( method: 'POST', body: formData, }); - if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Backup import failed')); + if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_import_failed'))); const body = await parseJson(resp); - if (!body?.imported) throw new Error('Invalid backup import response'); + if (!body?.imported) throw new Error(t('txt_backup_import_invalid_response')); return body; } diff --git a/webapp/src/lib/backup-center.ts b/webapp/src/lib/backup-center.ts new file mode 100644 index 0000000..40cbb22 --- /dev/null +++ b/webapp/src/lib/backup-center.ts @@ -0,0 +1,204 @@ +import { + type BackupDestinationRecord, + type BackupDestinationType, + type BackupRuntimeState, + type BackupSettings, + createBackupDestinationRecord, + createDefaultBackupSettings, +} from '@shared/backup'; +import type { RemoteBackupBrowserResponse, RemoteBackupItem } from './api'; +import { t } from './i18n'; + +export interface PersistedRemoteBrowserState { + cache: Record; + pathByDestination: Record; + pageByKey: Record; + selectedDestinationId: string | null; +} + +export const REMOTE_BROWSER_STORAGE_KEY = 'nodewarden.backup.remote-browser.v1'; +export const REMOTE_BROWSER_ITEMS_PER_PAGE = 10; + +export const COMMON_TIME_ZONES = [ + 'UTC', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Asia/Singapore', + 'Europe/London', + 'Europe/Berlin', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', +]; + +export const WEEKDAY_OPTIONS = [ + { value: 1, label: 'txt_backup_weekday_monday' }, + { value: 2, label: 'txt_backup_weekday_tuesday' }, + { value: 3, label: 'txt_backup_weekday_wednesday' }, + { value: 4, label: 'txt_backup_weekday_thursday' }, + { value: 5, label: 'txt_backup_weekday_friday' }, + { value: 6, label: 'txt_backup_weekday_saturday' }, + { value: 0, label: 'txt_backup_weekday_sunday' }, +] as const; + +export function detectBrowserTimeZone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; + } catch { + return 'UTC'; + } +} + +function createLocalizedDestinationName(type: BackupDestinationType, index: number): string { + if (type === 'e3') return t('txt_backup_destination_name_default_e3', { index: String(index) }); + if (type === 'placeholder') return `${t('txt_backup_destination_reserved')} ${index}`; + return t('txt_backup_destination_name_default_webdav', { index: String(index) }); +} + +export function createDraftDestinationRecord(type: BackupDestinationType, index: number): BackupDestinationRecord { + return createBackupDestinationRecord(type, index, { + timezone: detectBrowserTimeZone(), + name: createLocalizedDestinationName(type, index), + }); +} + +export function createDraftBackupSettings(): BackupSettings { + return createDefaultBackupSettings(detectBrowserTimeZone(), { + destinationName: createLocalizedDestinationName('webdav', 1), + }); +} + +export function formatDateTime(value: string | null | undefined): string { + if (!value) return t('txt_backup_never'); + const parsed = new Date(value); + if (!Number.isFinite(parsed.getTime())) return value; + return parsed.toLocaleString(); +} + +export function formatBytes(value: number | null | undefined): string { + const n = Number(value || 0); + if (!Number.isFinite(n) || n <= 0) return t('txt_backup_unknown_size'); + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`; + return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +export function isReplaceRequiredError(error: unknown): boolean { + const message = error instanceof Error ? String(error.message || '') : ''; + return message.toLowerCase().includes('fresh instance'); +} + +export function isZipCandidate(item: RemoteBackupItem): boolean { + return !item.isDirectory && /\.zip$/i.test(item.name || ''); +} + +function getRemoteItemSortTime(item: RemoteBackupItem): number { + if (!item.modifiedAt) return 0; + const parsed = new Date(item.modifiedAt); + return Number.isFinite(parsed.getTime()) ? parsed.getTime() : 0; +} + +export function compareRemoteItems(a: RemoteBackupItem, b: RemoteBackupItem): number { + const timeDiff = getRemoteItemSortTime(b) - getRemoteItemSortTime(a); + if (timeDiff !== 0) return timeDiff; + if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1; + return b.name.localeCompare(a.name, 'en'); +} + +export function getRemoteBrowserCacheKey(destinationId: string, path: string = ''): string { + return `${destinationId}:${path}`; +} + +function getRemoteBrowserStorage(): Storage | null { + try { + if (typeof window !== 'undefined' && window.localStorage) { + return window.localStorage; + } + } catch { + // Ignore storage access failures. + } + try { + if (typeof window !== 'undefined' && window.sessionStorage) { + return window.sessionStorage; + } + } catch { + // Ignore storage access failures. + } + return null; +} + +export function loadPersistedRemoteBrowserState(): PersistedRemoteBrowserState { + try { + const storage = getRemoteBrowserStorage(); + const raw = storage?.getItem(REMOTE_BROWSER_STORAGE_KEY); + if (!raw) { + return { + cache: {}, + pathByDestination: {}, + pageByKey: {}, + selectedDestinationId: null, + }; + } + const parsed = JSON.parse(raw) as Partial; + return { + cache: parsed.cache && typeof parsed.cache === 'object' ? parsed.cache : {}, + pathByDestination: parsed.pathByDestination && typeof parsed.pathByDestination === 'object' ? parsed.pathByDestination : {}, + pageByKey: parsed.pageByKey && typeof parsed.pageByKey === 'object' ? parsed.pageByKey : {}, + selectedDestinationId: typeof parsed.selectedDestinationId === 'string' ? parsed.selectedDestinationId : null, + }; + } catch { + return { + cache: {}, + pathByDestination: {}, + pageByKey: {}, + selectedDestinationId: null, + }; + } +} + +export function persistRemoteBrowserState(state: PersistedRemoteBrowserState): void { + try { + const storage = getRemoteBrowserStorage(); + storage?.setItem(REMOTE_BROWSER_STORAGE_KEY, JSON.stringify(state)); + } catch { + // Ignore cache persistence failures. + } +} + +export function invalidateRemoteBrowserCacheForDestination( + destinationId: string, + cache: Record, + pathByDestination: Record, + pageByKey: Record +): PersistedRemoteBrowserState { + return { + cache: Object.fromEntries(Object.entries(cache).filter(([key]) => !key.startsWith(`${destinationId}:`))), + pathByDestination: Object.fromEntries(Object.entries(pathByDestination).filter(([key]) => key !== destinationId)), + pageByKey: Object.fromEntries(Object.entries(pageByKey).filter(([key]) => !key.startsWith(`${destinationId}:`))), + selectedDestinationId: destinationId, + }; +} + +export function getDestinationById( + settings: BackupSettings | null, + destinationId: string | null | undefined +): BackupDestinationRecord | null { + if (!settings || !destinationId) return null; + return settings.destinations.find((destination) => destination.id === destinationId) || null; +} + +export function getVisibleDestinations(settings: BackupSettings | null | undefined): BackupDestinationRecord[] { + return (settings?.destinations || []).filter((destination) => destination.type !== 'placeholder'); +} + +export function getFirstVisibleDestinationId(settings: BackupSettings | null | undefined): string | null { + return getVisibleDestinations(settings)[0]?.id || null; +} + +export function getDestinationTypeLabel(type: BackupDestinationType): string { + if (type === 'e3') return t('txt_backup_protocol_e3'); + if (type === 'placeholder') return t('txt_backup_destination_reserved'); + return t('txt_backup_protocol_webdav'); +} diff --git a/webapp/src/lib/backup-recommendations.ts b/webapp/src/lib/backup-recommendations.ts new file mode 100644 index 0000000..1cc2f36 --- /dev/null +++ b/webapp/src/lib/backup-recommendations.ts @@ -0,0 +1,68 @@ +export interface RecommendedStorageLink { + name: string; + capacity: string; +} + +export interface RecommendedProviderBase { + id: 'infinicloud' | 'koofr' | 'pcloud'; + name: string; + capacity: string; + protocol: 'webdav' | 's3'; + signupUrl: string; + hasAffiliateLink?: boolean; +} + +export interface InfinicloudProvider extends RecommendedProviderBase { + id: 'infinicloud'; + referralCode: string; +} + +export interface KoofrProvider extends RecommendedProviderBase { + id: 'koofr'; + passwordUrl: string; + storageUrl: string; + linkedStorages: RecommendedStorageLink[]; +} + +export interface PcloudProvider extends RecommendedProviderBase { + id: 'pcloud'; +} + +export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider; + +export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [ + { + id: 'infinicloud', + name: 'InfiniCLOUD', + capacity: '25G', + protocol: 'webdav', + signupUrl: 'https://infini-cloud.net/en/', + referralCode: '2HC5E', + }, + { + id: 'koofr', + name: 'Koofr', + capacity: '10G', + protocol: 'webdav', + signupUrl: 'https://app.koofr.net/signup', + passwordUrl: 'https://app.koofr.net/app/admin/preferences/password', + storageUrl: 'https://app.koofr.net/app/storage/', + linkedStorages: [ + { name: 'Google Drive', capacity: '15G' }, + { name: 'OneDrive', capacity: '5G' }, + { name: 'Dropbox', capacity: '2G' }, + ], + }, + { + id: 'pcloud', + name: 'pCloud', + capacity: '10G', + protocol: 'webdav', + signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7', + hasAffiliateLink: true, + }, +]; + +export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider { + return provider.id === 'koofr'; +} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index 234db16..0422279 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -9,29 +9,185 @@ const messages: Record> = { nav_device_management: "Device Management", nav_my_vault: "My Vault", nav_sends: "Sends", - nav_backup_strategy: "Backup Strategy", + nav_backup_strategy: "Backup Center", nav_import_export: "Import & Export", - backup_strategy_title: "Backup Strategy", + backup_strategy_title: "Backup Center", backup_strategy_under_construction: "Under construction.", import_export_title: "Import & Export", import_export_under_construction: "Under construction.", - txt_backup_export: "Backup Export", - txt_backup_import: "Backup Import", + txt_backup_export: "Export Backup", + txt_backup_import: "Restore", txt_backup_export_description: "Download a full instance backup ZIP for manual safekeeping.", - txt_backup_import_description: "Upload a previously exported backup ZIP and restore it into a fresh instance shell.", + txt_backup_import_description: "Upload a previously exported backup ZIP and restore it into this instance.", txt_backup_exporting: "Exporting...", - txt_backup_importing: "Importing...", + txt_backup_importing: "Restoring...", + txt_backup_restoring: "Restoring...", txt_backup_export_success: "Backup exported", - txt_backup_import_success_relogin: "Backup imported. Please sign in again.", + txt_backup_import_success_relogin: "Backup restored. Please sign in again.", + txt_backup_restore_success_relogin: "Backup restored. Please sign in again.", txt_backup_export_failed: "Backup export failed", - txt_backup_import_failed: "Backup import failed", + txt_backup_import_failed: "Backup restore failed", + txt_backup_restore_failed: "Backup restore failed", + txt_backup_center_title: "Instance Backup", + txt_backup_center_description: "Keep local exports for manual restore, and configure one daily remote backup target for unattended protection.", + txt_backup_restore_note: "Restoring will overwrite the current instance if you choose the replace flow.", + txt_backup_manual: "Manual Backup", + txt_backup_manual_description: "Export a ZIP right now, or import a ZIP back into this instance.", + txt_backup_destinations_title: "Backup Destinations", + txt_backup_destinations_description: "Keep multiple WebDAV and E3 targets here. Select one on the left to edit or browse it.", + txt_backup_recommend_title: "Recommended Storage", + txt_backup_recommend_open_signup: "Open Signup", + txt_backup_recommend_open_signup_aff: "Open Signup (AFF)", + txt_backup_recommend_open_guide: "Open Guide", + txt_backup_recommend_empty: "No recommendations yet.", + txt_backup_recommend_referral_label: "Referral Code", + txt_backup_recommend_referral_note: "Use it during signup to get 5 GB extra. The author receives 2 GB.", + txt_backup_recommend_infinicloud_summary: "Only an email address is needed. 20 GB free, 25 GB total with the referral code.", + txt_backup_recommend_infinicloud_step_1: "Register an InfiniCLOUD account with just your email address.", + txt_backup_recommend_infinicloud_step_2_prefix: "Open", + txt_backup_recommend_infinicloud_step_2_suffix: "and turn on Apps Connection.", + txt_backup_recommend_infinicloud_step_3: "Use Connection ID as your WebDAV username and Apps Password as your WebDAV password.", + txt_backup_recommend_infinicloud_step_4: "Enter referral code 2HC5E in Referral Bonus at the bottom of My Page to receive 5 GB extra.", + txt_backup_recommend_open_password: "Password Settings", + txt_backup_recommend_open_storage: "Open Storage", + txt_backup_recommend_koofr_summary: "Only an email address is needed. 10 GB free, and it can bridge Google Drive, OneDrive, and Dropbox through WebDAV.", + txt_backup_recommend_koofr_password_link: "Password Settings", + txt_backup_recommend_koofr_storage_link: "Storage", + txt_backup_recommend_koofr_step_1: "Register a Koofr account with just your email address.", + txt_backup_recommend_koofr_step_2_prefix: "Open", + txt_backup_recommend_koofr_step_2_suffix: ", generate a new app password, use your email address as the WebDAV username, and use the app password as the WebDAV password.", + txt_backup_recommend_koofr_step_3: "Koofr's own WebDAV address is https://app.koofr.net/dav/Koofr.", + txt_backup_recommend_koofr_step_4: "Koofr can also connect Google Drive, OneDrive, and Dropbox. Free users can connect up to two storage accounts.", + txt_backup_recommend_koofr_step_5_prefix: "Open", + txt_backup_recommend_koofr_step_5_suffix: ", click Connect in the left sidebar, and choose the cloud storage you want to attach.", + txt_backup_recommend_koofr_dav_intro: "After a storage account is connected, keep the same email and app password, and only switch the WebDAV address:", + txt_backup_recommend_koofr_dav_self: "Koofr", + txt_backup_recommend_pcloud_summary: "Only an email address is needed. Up to 10 GB free, with standard WebDAV access.", + txt_backup_recommend_pcloud_step_1: "Register a pCloud account with just your email address.", + txt_backup_recommend_pcloud_step_2: "Use https://webdav.pcloud.com/ as the WebDAV server URL.", + txt_backup_recommend_pcloud_step_3: "Use your registration email as the WebDAV username and your account password as the WebDAV password.", + txt_backup_add_destination: "Add Destination", + txt_backup_schedule_panel_title: "Automatic Schedule", + txt_backup_schedule_panel_note: "Each destination can keep its own daily backup schedule.", + txt_backup_scheduled_target: "Scheduled Target", + txt_backup_destination_active_badge: "Auto On", + txt_backup_destination_idle_badge: "Auto Off", + txt_backup_destination_last_success: "Last success: {time}", + txt_backup_destination_never_run: "No successful run yet", + txt_backup_destination_detail_title: "Destination Details", + txt_backup_destination_detail_note: "", + txt_backup_destination_name: "Destination Name", + txt_backup_set_scheduled_target: "Use For Daily Backup", + txt_backup_delete_destination: "Delete", + txt_backup_destination_deleted: "Backup destination deleted", + txt_backup_delete_destination_confirm_message: "Delete backup destination \"{name}\"? This cannot be undone.", + txt_backup_select_destination: "Select a backup destination from the list first.", + txt_backup_remote_save_first: "Save this destination first before browsing its remote backup files.", + txt_backup_automation: "Automatic Backup", + txt_backup_automation_description: "Pick a destination, save the credentials, and let the worker upload one backup every day.", + txt_backup_settings_saved: "Backup settings saved", + txt_backup_settings_save_failed: "Saving backup settings failed", + txt_backup_settings_load_failed: "Loading backup settings failed", + txt_backup_save_settings: "Save Settings", + txt_backup_saving: "Saving...", + txt_backup_enable_action: "Enable", + txt_backup_disable_action: "Disable", + txt_backup_run_now: "Run Remote Backup Now", + txt_backup_run_manual: "Run Manually", + txt_backup_running_now: "Running...", + txt_backup_remote_run_success: "Remote backup completed", + txt_backup_remote_run_failed: "Remote backup failed", + txt_backup_remote_title: "Remote Backups", + txt_backup_remote_note: "Browse the saved destination and choose a backup ZIP to download or restore.", + txt_backup_remote_saved_basis: "Remote browsing uses the last saved destination settings, not unsaved form edits.", + txt_backup_remote_refresh: "Refresh", + txt_backup_remote_root: "Root", + txt_backup_remote_up: "Up", + txt_backup_remote_open: "Open", + txt_backup_remote_download: "Download", + txt_backup_remote_downloading: "Downloading...", + txt_backup_remote_restore: "Restore", + txt_backup_remote_loading: "Loading remote backups...", + txt_backup_remote_cached_empty: "Click Refresh to load this destination.", + txt_backup_remote_empty: "No backup files found in this folder.", + txt_backup_remote_folder: "Folder", + txt_backup_remote_unknown_time: "Unknown time", + txt_backup_remote_current_path: "Current Folder", + txt_backup_remote_load_failed: "Loading remote backups failed", + txt_backup_remote_invalid_response: "Invalid remote backup response", + txt_backup_remote_download_failed: "Downloading remote backup failed", + txt_backup_remote_delete_success: "Remote backup deleted", + txt_backup_remote_delete_failed: "Deleting remote backup failed", + txt_backup_remote_delete_confirm_message: "Delete backup file \"{name}\"? This cannot be undone.", + txt_backup_remote_deleting: "Deleting...", + txt_backup_remote_restore_failed: "Restoring remote backup failed", + txt_backup_remote_restore_invalid_response: "Invalid remote backup restore response", + txt_backup_remote_run_invalid_response: "Invalid remote backup run response", + txt_backup_settings_invalid_response: "Invalid backup settings response", + txt_backup_import_invalid_response: "Invalid backup import response", + txt_backup_destination: "Backup Destination", + txt_backup_protocol_webdav: "WebDAV", + txt_backup_protocol_e3: "E3", + txt_backup_recommend_group_webdav: "WebDAV", + txt_backup_recommend_group_s3: "S3", + txt_backup_destination_name_default_webdav: "WebDAV {index}", + txt_backup_destination_name_default_e3: "E3 {index}", + txt_backup_type: "Backup Type", + txt_backup_destination_reserved: "Reserved Slot", + txt_backup_time: "Backup Time", + txt_backup_timezone: "Timezone", + txt_backup_frequency: "Frequency", + txt_backup_frequency_daily: "Daily", + txt_backup_frequency_weekly: "Weekly", + txt_backup_frequency_monthly: "Monthly", + txt_backup_day_of_week: "Day of Week", + txt_backup_day_of_month: "Day of Month", + txt_backup_weekday_monday: "Monday", + txt_backup_weekday_tuesday: "Tuesday", + txt_backup_weekday_wednesday: "Wednesday", + txt_backup_weekday_thursday: "Thursday", + txt_backup_weekday_friday: "Friday", + txt_backup_weekday_saturday: "Saturday", + txt_backup_weekday_sunday: "Sunday", + txt_backup_retention_count: "Keep", + txt_backup_retention_count_suffix: "items", + txt_backup_retention_count_hint: "Leave empty to keep all backup files. New destinations default to 30.", + txt_backup_enable_schedule: "Enable automatic daily backup", + txt_backup_schedule_note: "The worker checks the schedule every 5 minutes and runs the backup as soon as the selected time window is reached.", + txt_backup_schedule_disabled: "Disabled", + txt_backup_schedule_status: "Schedule", + txt_backup_schedule_summary: "Daily at {time} ({timezone})", + txt_backup_schedule_empty: "No automatic backup plans are enabled yet.", + txt_backup_last_success: "Last Success", + txt_backup_last_target: "Last Target", + txt_backup_last_file: "Last File", + txt_backup_last_error_prefix: "Last Error", + txt_backup_none_yet: "No remote backup has completed yet", + txt_backup_not_configured: "Not configured", + txt_backup_never: "Never", + txt_backup_unknown_size: "Unknown size", + txt_backup_webdav_url: "WebDAV Server URL", + txt_backup_webdav_username: "WebDAV Username", + txt_backup_webdav_password: "WebDAV Password", + txt_backup_webdav_path: "Remote Folder", + txt_backup_e3_endpoint: "E3 Endpoint", + txt_backup_e3_bucket: "Bucket", + txt_backup_e3_region: "Region", + txt_backup_e3_access_key: "Access Key", + txt_backup_e3_secret_key: "Secret Key", + txt_backup_e3_path: "Remote Path", + txt_backup_reserved_name: "Reserved Provider Name", + txt_backup_reserved_notes: "Reserved Notes", + txt_backup_reserved_notes_placeholder: "Leave a note for the next destination type", + txt_backup_reserved_hint: "This slot is reserved for a future destination. You can save notes now, but automatic uploads stay disabled.", txt_backup_file: "Backup File", txt_backup_file_required: "Please select a backup file", txt_backup_no_file_selected: "No backup file selected", txt_backup_selected_file_name: "Selected file: {name}", txt_backup_replace_confirm_title: "Replace Current Instance Data", - txt_backup_replace_confirm_message: "The current instance already contains data. Clear it and import the new backup?", + txt_backup_replace_confirm_message: "The current instance already contains data. Clear it and restore the selected backup?", txt_backup_clear_and_import: "Clear and Import", + txt_backup_clear_and_restore: "Clear and Restore", txt_access_count: "Access Count", txt_accessed_count_times: "Accessed {count} times", txt_actions: "Actions", @@ -425,29 +581,185 @@ const zhCNOverrides: Record = { nav_admin_panel: '用户管理', nav_account_settings: '账户设置', nav_device_management: '设备管理', - nav_backup_strategy: '备份策略', + nav_backup_strategy: '备份中心', nav_import_export: '导入导出', - backup_strategy_title: '备份策略', + backup_strategy_title: '备份中心', backup_strategy_under_construction: '正在搭建中', import_export_title: '导入导出', import_export_under_construction: '正在搭建中', - txt_backup_export: '备份导出', - txt_backup_import: '备份导入', + txt_backup_export: '导出备份', + txt_backup_import: '还原', txt_backup_export_description: '下载一个完整的实例备份 ZIP,手动保管即可。', - txt_backup_import_description: '上传之前导出的备份 ZIP,并恢复到全新实例空壳。', + txt_backup_import_description: '上传之前导出的备份 ZIP,并还原到当前实例。', txt_backup_exporting: '正在导出...', - txt_backup_importing: '正在导入...', + txt_backup_importing: '正在还原...', + txt_backup_restoring: '正在还原...', txt_backup_export_success: '备份已导出', - txt_backup_import_success_relogin: '备份已导入,请重新登录', + txt_backup_import_success_relogin: '备份已还原,请重新登录', + txt_backup_restore_success_relogin: '备份已还原,请重新登录', txt_backup_export_failed: '备份导出失败', - txt_backup_import_failed: '备份导入失败', + txt_backup_import_failed: '备份还原失败', + txt_backup_restore_failed: '备份还原失败', + txt_backup_center_title: '实例备份', + txt_backup_center_description: '把本地导出和远程自动备份放在一起管理,既方便手动恢复,也能每天自动留一份。', + txt_backup_restore_note: '还原会覆盖当前实例;如果当前已有数据,系统会要求你确认“清空后还原”。', + txt_backup_manual: '手动备份', + txt_backup_manual_description: '现在就导出 ZIP,或者把之前导出的 ZIP 恢复到当前实例。', + txt_backup_destinations_title: '备份地点', + txt_backup_destinations_description: '把多个 WebDAV、E3 地点统一放在这里。左侧选一个,右侧编辑和浏览它。', + txt_backup_recommend_title: '推荐储存库', + txt_backup_recommend_open_signup: '前往注册', + txt_backup_recommend_open_signup_aff: '前往注册(含 AFF)', + txt_backup_recommend_open_guide: '查看教程', + txt_backup_recommend_empty: '暂时没有推荐', + txt_backup_recommend_referral_label: '推荐码', + txt_backup_recommend_referral_note: '注册时填写可额外获得 5 GB,作者会收到 2 GB。', + txt_backup_recommend_infinicloud_summary: '只需邮箱即可注册。免费 20 GB;填写推荐码后总计 25 GB。', + txt_backup_recommend_infinicloud_step_1: '先用邮箱注册一个 InfiniCLOUD 账号。', + txt_backup_recommend_infinicloud_step_2_prefix: '进入', + txt_backup_recommend_infinicloud_step_2_suffix: ',然后开启 Turn on Apps Connection。', + txt_backup_recommend_infinicloud_step_3: 'Connection ID 用作 WebDAV 用户名,Apps Password 用作 WebDAV 密码。', + txt_backup_recommend_infinicloud_step_4: '在 My Page 最下面的 Referral Bonus 填入推荐码 2HC5E,可额外获得 5 GB。', + txt_backup_recommend_open_password: '密码设置', + txt_backup_recommend_open_storage: '打开储存连接', + txt_backup_recommend_koofr_summary: '只需邮箱即可注册使用。免费 10 GB,并且可以通过 WebDAV 接到 Google Drive、OneDrive、Dropbox。', + txt_backup_recommend_koofr_password_link: '密码设置', + txt_backup_recommend_koofr_storage_link: 'Storage', + txt_backup_recommend_koofr_step_1: '先用邮箱注册一个 Koofr 账号。', + txt_backup_recommend_koofr_step_2_prefix: '打开', + txt_backup_recommend_koofr_step_2_suffix: ',生成新的应用密码。注册邮箱用作 WebDAV 用户名,应用密码用作 WebDAV 密码。', + txt_backup_recommend_koofr_step_3: 'Koofr 自己的 WebDAV 地址是 https://app.koofr.net/dav/Koofr。', + txt_backup_recommend_koofr_step_4: 'Koofr 最方便的地方,是还能接 Google Drive、OneDrive、Dropbox 这三大云盘;免费用户最多能连接两个。', + txt_backup_recommend_koofr_step_5_prefix: '打开', + txt_backup_recommend_koofr_step_5_suffix: ',在左侧栏点击“连接”,选择你要连接的储存即可。', + txt_backup_recommend_koofr_dav_intro: '连接好储存后,账号和应用密码都不变,只需要切换 WebDAV 地址:', + txt_backup_recommend_koofr_dav_self: 'Koofr', + txt_backup_recommend_pcloud_summary: '只需邮箱即可注册。免费最高 10 GB,并且自带标准 WebDAV 访问。', + txt_backup_recommend_pcloud_step_1: '先用邮箱注册一个 pCloud 账号。', + txt_backup_recommend_pcloud_step_2: 'WebDAV 地址填写 https://webdav.pcloud.com/ 。', + txt_backup_recommend_pcloud_step_3: '注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。', + txt_backup_add_destination: '新增地点', + txt_backup_schedule_panel_title: '自动备份计划', + txt_backup_schedule_panel_note: '每个备份地点都可以单独配置自己的每日自动备份计划。', + txt_backup_scheduled_target: '当前计划目标', + txt_backup_destination_active_badge: '已启用计划', + txt_backup_destination_idle_badge: '未启用计划', + txt_backup_destination_last_success: '上次成功:{time}', + txt_backup_destination_never_run: '还没有成功执行过', + txt_backup_destination_detail_title: '地点详情', + txt_backup_destination_detail_note: '', + txt_backup_destination_name: '地点名称', + txt_backup_set_scheduled_target: '设为每日备份目标', + txt_backup_delete_destination: '删除', + txt_backup_destination_deleted: '备份地点已删除', + txt_backup_delete_destination_confirm_message: '删除备份地点“{name}”?此操作不可撤销。', + txt_backup_select_destination: '请先从左侧列表选择一个备份地点', + txt_backup_remote_save_first: '请先保存这个备份地点,再浏览它的远端备份文件', + txt_backup_automation: '自动备份', + txt_backup_automation_description: '选择备份地点,保存连接信息后,系统会按设定时间每天自动上传一份备份。', + txt_backup_settings_saved: '备份设置已保存', + txt_backup_settings_save_failed: '备份设置保存失败', + txt_backup_settings_load_failed: '备份设置加载失败', + txt_backup_save_settings: '保存设置', + txt_backup_saving: '正在保存...', + txt_backup_enable_action: '启用', + txt_backup_disable_action: '停用', + txt_backup_run_now: '立即执行远程备份', + txt_backup_run_manual: '手动执行', + txt_backup_running_now: '执行中...', + txt_backup_remote_run_success: '远程备份已完成', + txt_backup_remote_run_failed: '远程备份失败', + txt_backup_remote_title: '远端备份', + txt_backup_remote_note: '浏览已保存的备份地点,选择某个备份 ZIP 后可以下载,也可以直接还原。', + txt_backup_remote_saved_basis: '远端浏览使用的是“已保存”的备份地点配置,不会读取你当前未保存的表单内容。', + txt_backup_remote_refresh: '刷新', + txt_backup_remote_root: '根目录', + txt_backup_remote_up: '上一级', + txt_backup_remote_open: '打开', + txt_backup_remote_download: '下载', + txt_backup_remote_downloading: '下载中...', + txt_backup_remote_restore: '还原', + txt_backup_remote_loading: '正在读取远端备份...', + txt_backup_remote_cached_empty: '点击“刷新”后读取', + txt_backup_remote_empty: '这个目录下还没有备份文件', + txt_backup_remote_folder: '文件夹', + txt_backup_remote_unknown_time: '未知时间', + txt_backup_remote_current_path: '当前目录', + txt_backup_remote_load_failed: '读取远端备份失败', + txt_backup_remote_invalid_response: '远端备份响应无效', + txt_backup_remote_download_failed: '下载远端备份失败', + txt_backup_remote_delete_success: '远端备份已删除', + txt_backup_remote_delete_failed: '删除远端备份失败', + txt_backup_remote_delete_confirm_message: '删除备份文件“{name}”?此操作不可撤销。', + txt_backup_remote_deleting: '删除中...', + txt_backup_remote_restore_failed: '还原远端备份失败', + txt_backup_remote_restore_invalid_response: '远端备份还原响应无效', + txt_backup_remote_run_invalid_response: '远端备份执行响应无效', + txt_backup_settings_invalid_response: '备份设置响应无效', + txt_backup_import_invalid_response: '备份还原响应无效', + txt_backup_destination: '备份地点', + txt_backup_protocol_webdav: 'WebDAV', + txt_backup_protocol_e3: 'E3', + txt_backup_recommend_group_webdav: 'WebDAV', + txt_backup_recommend_group_s3: 'S3', + txt_backup_destination_name_default_webdav: 'WebDAV {index}', + txt_backup_destination_name_default_e3: 'E3 {index}', + txt_backup_type: '备份类型', + txt_backup_destination_reserved: '预留位置', + txt_backup_time: '备份时间', + txt_backup_timezone: '时区', + txt_backup_frequency: '备份频率', + txt_backup_frequency_daily: '每天', + txt_backup_frequency_weekly: '每周', + txt_backup_frequency_monthly: '每月', + txt_backup_day_of_week: '星期', + txt_backup_day_of_month: '日期', + txt_backup_weekday_monday: '周一', + txt_backup_weekday_tuesday: '周二', + txt_backup_weekday_wednesday: '周三', + txt_backup_weekday_thursday: '周四', + txt_backup_weekday_friday: '周五', + txt_backup_weekday_saturday: '周六', + txt_backup_weekday_sunday: '周日', + txt_backup_retention_count: '只保留', + txt_backup_retention_count_suffix: '个', + txt_backup_retention_count_hint: '留空表示不限,新建备份地点默认保留 30 个', + txt_backup_enable_schedule: '启用每日自动备份', + txt_backup_schedule_note: 'Worker 每 5 分钟检查一次计划,到达你设定的时间窗口后会尽快执行备份。', + txt_backup_schedule_disabled: '未启用', + txt_backup_schedule_status: '计划状态', + txt_backup_schedule_summary: '每天 {time}({timezone})', + txt_backup_schedule_empty: '还没有启用任何自动备份计划', + txt_backup_last_success: '上次成功时间', + txt_backup_last_target: '上次备份位置', + txt_backup_last_file: '上次备份文件', + txt_backup_last_error_prefix: '上次错误', + txt_backup_none_yet: '还没有成功完成过远程备份', + txt_backup_not_configured: '尚未配置', + txt_backup_never: '从未', + txt_backup_unknown_size: '大小未知', + txt_backup_webdav_url: 'WebDAV 服务地址', + txt_backup_webdav_username: 'WebDAV 用户名', + txt_backup_webdav_password: 'WebDAV 密码', + txt_backup_webdav_path: '远程目录', + txt_backup_e3_endpoint: 'E3 Endpoint', + txt_backup_e3_bucket: 'Bucket', + txt_backup_e3_region: 'Region', + txt_backup_e3_access_key: 'Access Key', + txt_backup_e3_secret_key: 'Secret Key', + txt_backup_e3_path: '远程路径', + txt_backup_reserved_name: '预留类型名称', + txt_backup_reserved_notes: '预留备注', + txt_backup_reserved_notes_placeholder: '给下一个备份地点先留个说明', + txt_backup_reserved_hint: '这个位置先预留给后续备份地点。你现在可以先保存备注,但自动上传不会启用。', txt_backup_file: '备份文件', txt_backup_file_required: '请选择备份文件', txt_backup_no_file_selected: '尚未选择备份文件', txt_backup_selected_file_name: '已选择文件:{name}', txt_backup_replace_confirm_title: '替换当前实例数据', - txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再导入新的备份吗?', + txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再还原所选备份吗?', txt_backup_clear_and_import: '清空后导入', + txt_backup_clear_and_restore: '清空后还原', txt_sign_out: '退出登录', txt_log_in: '登录', txt_log_out: '退出', diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 8b4290d..6cb02c2 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -13,6 +13,8 @@ export interface Profile { email: string; name: string; key: string; + privateKey?: string | null; + publicKey?: string | null; role: 'admin' | 'user'; [k: string]: unknown; } diff --git a/webapp/src/styles.css b/webapp/src/styles.css index 260973a..64579e6 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -311,6 +311,7 @@ input[type='file'].input::file-selector-button:hover { align-items: center; justify-content: center; gap: 6px; + text-decoration: none; } .btn-icon { @@ -1341,8 +1342,401 @@ input[type='file'].input::file-selector-button:hover { align-items: start; } -.backup-panel { - min-height: 100%; +.backup-grid { + display: grid; + grid-template-columns: 280px 280px minmax(0, 1fr); + gap: 12px; + align-items: start; + padding: 2px; +} + +.backup-operations-sidebar, +.backup-destination-sidebar, +.backup-detail-panel { + min-width: 0; + background: #fff; + border: 1px solid #d8dee8; + border-radius: 12px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05); + padding: 12px; +} + +.backup-actions-stack { + display: grid; + gap: 10px; +} + +.backup-manual-inline-actions { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.backup-schedule-list { + display: grid; + gap: 8px; +} + +.backup-recommendation-list { + display: grid; + gap: 8px; +} + +.backup-recommendation-group + .backup-recommendation-group { + margin-top: 12px; +} + +.backup-recommendation-group-title { + margin: 0 0 8px; + font-size: 14px; + font-weight: 700; + color: #0f172a; +} + +.backup-recommendation-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.backup-recommendation-linked { + display: grid; + gap: 4px; +} + +.backup-recommendation-linked-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 12px; + color: #475467; +} + +.backup-recommendation-card { + border: 1px solid var(--line); + border-radius: 12px; + background: #f8fbff; + padding: 14px; + display: grid; + gap: 12px; +} + +.backup-recommendation-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.backup-recommendation-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.backup-recommendation-steps { + display: grid; + gap: 8px; +} + +.backup-recommendation-step { + color: #475467; + line-height: 1.5; +} + +.backup-recommendation-inline-note { + color: #475467; + line-height: 1.5; +} + +.backup-recommendation-dav-list { + display: grid; + gap: 8px; +} + +.backup-recommendation-dav-item { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; +} + +.backup-recommendation-dav-item code { + overflow-wrap: anywhere; +} + +.backup-recommendation-referral { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + color: #475467; +} + +.backup-destination-list { + display: grid; + gap: 8px; +} + +.backup-destination-item { + width: 100%; + border: 1px solid var(--line); + border-radius: 12px; + background: #fff; + padding: 12px; + text-align: left; + display: grid; + gap: 6px; + cursor: pointer; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; +} + +.backup-destination-item:hover { + border-color: #93c5fd; + background: #f8fbff; +} + +.backup-destination-item.active { + border-color: var(--primary); + background: #eff6ff; + box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.08); +} + +.backup-destination-top { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: center; +} + +.backup-destination-name { + font-weight: 700; + color: #0f172a; + overflow-wrap: anywhere; +} + +.backup-destination-type { + border-radius: 999px; + padding: 2px 8px; + background: #e2e8f0; + color: #334155; + font-size: 12px; + white-space: nowrap; +} + +.backup-destination-meta { + color: #64748b; + font-size: 13px; + line-height: 1.4; +} + +.backup-destination-addbar { + margin-top: 10px; +} + +.backup-add-chooser { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.backup-schedule-current { + display: grid; + gap: 4px; + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 12px; + background: #f8fafc; + color: #475467; +} + +.backup-schedule-current strong { + color: #0f172a; +} + +.backup-name-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + align-items: end; + margin-bottom: 8px; +} + +.backup-name-field { + margin: 0; + grid-column: 1 / span 3; +} + +.backup-type-field { + margin: 0; + grid-column: 4; +} + +.backup-detail-schedule-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)) !important; + margin-bottom: 12px; +} + +.backup-retention-input { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + width: 100%; +} + +.backup-retention-input .input { + min-width: 0; + width: 100%; +} + +.backup-retention-suffix { + color: #475467; + white-space: nowrap; +} + +.backup-combined-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + align-items: start; +} + +.backup-status-card { + border: 1px solid var(--line); + border-radius: 12px; + padding: 12px; + background: #f8fbff; + margin-bottom: 12px; +} + +.backup-status-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 14px; + color: #475467; + line-height: 1.45; +} + +.backup-status-grid strong { + display: block; + margin-bottom: 4px; + color: #0f172a; +} + +.backup-status-error { + margin-top: 12px; +} + +.backup-divider { + height: 1px; + background: var(--line); + margin: 14px 0; +} + +.backup-remote-panel { + margin-top: 0; +} + +.backup-browser-path { + display: flex; + gap: 8px; + align-items: center; + padding: 10px 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: #f8fafc; + margin-bottom: 10px; + overflow-wrap: anywhere; +} + +.backup-browser-path strong { + color: #0f172a; +} + +.backup-browser-nav { + margin-bottom: 10px; +} + +.backup-browser-list { + border: 1px solid var(--line); + border-radius: 12px; + overflow: hidden; + background: #fff; +} + +.backup-browser-pagination { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + margin-top: 10px; +} + +.backup-browser-page-indicator { + min-width: 48px; + text-align: center; + color: #64748b; + font-size: 13px; + font-weight: 700; +} + +.backup-browser-row + .backup-browser-row { + border-top: 1px solid var(--line); +} + +.backup-browser-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 10px; + align-items: center; + padding: 10px 12px; +} + +.backup-browser-entry { + border: none; + background: transparent; + text-align: left; + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0; + color: #0f172a; + cursor: pointer; +} + +.backup-browser-entry.file { + cursor: default; +} + +.backup-browser-name { + font-weight: 700; + overflow-wrap: anywhere; +} + +.backup-browser-meta { + display: grid; + justify-items: end; + gap: 4px; + color: #64748b; + font-size: 13px; + text-align: right; +} + +.backup-browser-actions { + justify-content: flex-end; +} + +.backup-browser-empty { + border: 1px dashed var(--line); + border-radius: 12px; + padding: 18px 14px; + text-align: center; + color: #64748b; } .backup-list { @@ -1921,7 +2315,10 @@ input[type='file'].input::file-selector-button:hover { } .import-export-feature-grid, - .import-export-panels { + .import-export-panels, + .backup-combined-grid, + .backup-status-grid, + .backup-browser-row { grid-template-columns: 1fr; } @@ -2386,6 +2783,8 @@ input[type='file'].input::file-selector-button:hover { } .import-export-panels, + .backup-combined-grid, + .backup-status-grid, .settings-twofactor-grid { gap: 10px; } @@ -2541,10 +2940,42 @@ input[type='file'].input::file-selector-button:hover { font-size: 16px; } - .toast-stack { - top: 10px; - left: 10px; - right: 10px; - width: auto; +.toast-stack { + top: 10px; + left: 10px; + right: 10px; + width: auto; +} +} + +@media (max-width: 900px) { + .backup-grid { + grid-template-columns: 1fr; + } + + .backup-operations-sidebar, + .backup-destination-sidebar { + position: static; + } +} + +@media (max-width: 640px) { + .backup-status-grid, + .backup-browser-row, + .field-grid { + grid-template-columns: 1fr; + } + + .backup-destination-top { + align-items: flex-start; + flex-direction: column; + } + + .backup-add-chooser { + flex-direction: column; + } + + .backup-name-row { + grid-template-columns: 1fr; } } diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json index d1744c5..d9d7377 100644 --- a/webapp/tsconfig.json +++ b/webapp/tsconfig.json @@ -8,7 +8,8 @@ "jsxImportSource": "preact", "baseUrl": ".", "paths": { - "@/*": ["src/*"] + "@/*": ["src/*"], + "@shared/*": ["../shared/*"] }, "strict": true, "noEmit": true, @@ -18,5 +19,5 @@ "resolveJsonModule": true, "types": ["vite/client"] }, - "include": ["src/**/*", "vite.config.ts"] + "include": ["src/**/*", "../shared/**/*", "vite.config.ts"] } diff --git a/webapp/vite.config.ts b/webapp/vite.config.ts index 5390b7a..da015a1 100644 --- a/webapp/vite.config.ts +++ b/webapp/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ resolve: { alias: { '@': path.resolve(rootDir, 'src'), + '@shared': path.resolve(rootDir, '../shared'), }, }, build: { @@ -30,6 +31,9 @@ export default defineConfig({ }, server: { port: 5173, + fs: { + allow: [path.resolve(rootDir, '..')], + }, proxy: { '/api': 'http://127.0.0.1:8787', '/identity': 'http://127.0.0.1:8787', diff --git a/wrangler.kv.toml b/wrangler.kv.toml index cf837cb..d1f9688 100644 --- a/wrangler.kv.toml +++ b/wrangler.kv.toml @@ -10,6 +10,9 @@ run_worker_first = [ "/api/*", "/identity/*", "/icons/*", "/setup/*", "/config", [build] command = "npm run build" +[triggers] +crons = [ "*/5 * * * *" ] + [[d1_databases]] binding = "DB" database_name = "nodewarden-db" diff --git a/wrangler.toml b/wrangler.toml index 2092244..3b5d7ed 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -10,6 +10,9 @@ run_worker_first = [ "/api/*", "/identity/*", "/icons/*", "/setup/*", "/config", [build] command = "npm run build" +[triggers] +crons = [ "*/5 * * * *" ] + [[d1_databases]] binding = "DB" database_name = "nodewarden-db"