mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add backup recommendations and update backup strategy UI
- Introduced new backup recommendations feature with interfaces for recommended storage providers. - Updated i18n translations for backup strategy to reflect new terminology and improved descriptions. - Enhanced types with optional private and public keys in user profiles. - Redesigned backup-related styles for better layout and responsiveness. - Updated TypeScript configuration to include shared modules. - Configured Vite to resolve shared modules and allow filesystem access. - Added cron triggers for periodic tasks in Wrangler configuration.
This commit is contained in:
@@ -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,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
+392
-602
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ import { NotificationsHub } from './durable/notifications-hub';
|
|||||||
import { handleRequest } from './router';
|
import { handleRequest } from './router';
|
||||||
import { StorageService } from './services/storage';
|
import { StorageService } from './services/storage';
|
||||||
import { applyCors, jsonResponse } from './utils/response';
|
import { applyCors, jsonResponse } from './utils/response';
|
||||||
|
import { runScheduledBackupIfDue, seedDefaultBackupSettings } from './handlers/backup';
|
||||||
|
|
||||||
let dbInitialized = false;
|
let dbInitialized = false;
|
||||||
let dbInitError: string | null = null;
|
let dbInitError: string | null = null;
|
||||||
@@ -15,6 +16,7 @@ async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
|||||||
dbInitPromise = (async () => {
|
dbInitPromise = (async () => {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
await storage.initializeDatabase();
|
await storage.initializeDatabase();
|
||||||
|
await seedDefaultBackupSettings(env);
|
||||||
dbInitialized = true;
|
dbInitialized = true;
|
||||||
dbInitError = null;
|
dbInitError = null;
|
||||||
})()
|
})()
|
||||||
@@ -54,6 +56,18 @@ export default {
|
|||||||
const resp = await handleRequest(request, env);
|
const resp = await handleRequest(request, env);
|
||||||
return applyCors(request, resp);
|
return applyCors(request, resp);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
void controller;
|
||||||
|
await ensureDatabaseInitialized(env);
|
||||||
|
if (dbInitError) {
|
||||||
|
console.error('Skipping scheduled backup because DB init failed:', dbInitError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.waitUntil(runScheduledBackupIfDue(env).catch((error) => {
|
||||||
|
console.error('Scheduled backup failed:', error);
|
||||||
|
}));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export { NotificationsHub };
|
export { NotificationsHub };
|
||||||
|
|||||||
@@ -107,7 +107,16 @@ import {
|
|||||||
} from './handlers/admin';
|
} from './handlers/admin';
|
||||||
import {
|
import {
|
||||||
handleAdminExportBackup,
|
handleAdminExportBackup,
|
||||||
|
handleDownloadAdminRemoteBackup,
|
||||||
|
handleDeleteAdminRemoteBackup,
|
||||||
|
handleGetAdminBackupSettings,
|
||||||
|
handleGetAdminBackupSettingsRepairState,
|
||||||
handleAdminImportBackup,
|
handleAdminImportBackup,
|
||||||
|
handleListAdminRemoteBackups,
|
||||||
|
handleRepairAdminBackupSettings,
|
||||||
|
handleRestoreAdminRemoteBackup,
|
||||||
|
handleRunAdminConfiguredBackup,
|
||||||
|
handleUpdateAdminBackupSettings,
|
||||||
} from './handlers/backup';
|
} from './handlers/backup';
|
||||||
import {
|
import {
|
||||||
handleNotificationsHub,
|
handleNotificationsHub,
|
||||||
@@ -824,6 +833,36 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
return handleAdminExportBackup(request, env, currentUser);
|
return handleAdminExportBackup(request, env, currentUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/settings') {
|
||||||
|
if (method === 'GET') return handleGetAdminBackupSettings(request, env, currentUser);
|
||||||
|
if (method === 'PUT') return handleUpdateAdminBackupSettings(request, env, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/settings/repair') {
|
||||||
|
if (method === 'GET') return handleGetAdminBackupSettingsRepairState(request, env, currentUser);
|
||||||
|
if (method === 'POST') return handleRepairAdminBackupSettings(request, env, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/run' && method === 'POST') {
|
||||||
|
return handleRunAdminConfiguredBackup(request, env, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote' && method === 'GET') {
|
||||||
|
return handleListAdminRemoteBackups(request, env, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
|
||||||
|
return handleDownloadAdminRemoteBackup(request, env, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
|
||||||
|
return handleDeleteAdminRemoteBackup(request, env, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/restore' && method === 'POST') {
|
||||||
|
return handleRestoreAdminRemoteBackup(request, env, currentUser);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/backup/import' && method === 'POST') {
|
if (path === '/api/admin/backup/import' && method === 'POST') {
|
||||||
return handleAdminImportBackup(request, env, currentUser);
|
return handleAdminImportBackup(request, env, currentUser);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import { zipSync, unzipSync } from 'fflate';
|
||||||
|
import type { Env } from '../types';
|
||||||
|
import {
|
||||||
|
getAttachmentObjectKey,
|
||||||
|
getBlobObject,
|
||||||
|
getBlobStorageKind,
|
||||||
|
getSendFileObjectKey,
|
||||||
|
} from './blob-store';
|
||||||
|
|
||||||
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
|
||||||
|
const BACKUP_FORMAT_VERSION = 1;
|
||||||
|
const BACKUP_APP_VERSION = '1.3.0';
|
||||||
|
const BACKUP_ZIP_COMPRESSION_LEVEL = 6;
|
||||||
|
const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
|
||||||
|
const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000;
|
||||||
|
const MAX_BACKUP_EXTRACTED_BYTES = 128 * 1024 * 1024;
|
||||||
|
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface BackupManifest {
|
||||||
|
formatVersion: 1;
|
||||||
|
exportedAt: string;
|
||||||
|
appVersion: string;
|
||||||
|
storageKind: 'r2' | 'kv' | null;
|
||||||
|
tableCounts: Record<string, number>;
|
||||||
|
includes: {
|
||||||
|
attachments: boolean;
|
||||||
|
sendFiles: boolean;
|
||||||
|
};
|
||||||
|
blobSummary: {
|
||||||
|
attachmentFiles: number;
|
||||||
|
sendFiles: number;
|
||||||
|
totalBytes: number;
|
||||||
|
largestObjectBytes: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupPayload {
|
||||||
|
manifest: BackupManifest;
|
||||||
|
db: {
|
||||||
|
config: SqlRow[];
|
||||||
|
users: SqlRow[];
|
||||||
|
user_revisions: SqlRow[];
|
||||||
|
folders: SqlRow[];
|
||||||
|
ciphers: SqlRow[];
|
||||||
|
attachments: SqlRow[];
|
||||||
|
sends: SqlRow[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupArchiveBundle {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
fileName: string;
|
||||||
|
manifest: BackupManifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSendFileId(data: string | null): string | null {
|
||||||
|
if (!data) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data) as Record<string, unknown>;
|
||||||
|
return typeof parsed.id === 'string' && parsed.id.trim() ? parsed.id.trim() : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||||
|
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||||
|
return (result.results || []).map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function streamToBytes(stream: ReadableStream | null): Promise<Uint8Array> {
|
||||||
|
if (!stream) return new Uint8Array();
|
||||||
|
const buffer = await new Response(stream).arrayBuffer();
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFileName(date: Date = new Date()): string {
|
||||||
|
const parts = [
|
||||||
|
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||||
|
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||||
|
date.getUTCDate().toString().padStart(2, '0'),
|
||||||
|
date.getUTCHours().toString().padStart(2, '0'),
|
||||||
|
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||||
|
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||||
|
];
|
||||||
|
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateArchiveSize(bytes: Uint8Array): void {
|
||||||
|
if (bytes.byteLength > MAX_BACKUP_ARCHIVE_BYTES) {
|
||||||
|
throw new Error(`Backup archive is too large. The current restore limit is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))} MiB`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
|
||||||
|
const entries: string[] = [];
|
||||||
|
for (const row of db.attachments) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
entries.push(`attachments/${cipherId}/${attachmentId}.bin`);
|
||||||
|
}
|
||||||
|
for (const row of db.sends) {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!sendId || !fileId) continue;
|
||||||
|
entries.push(`send-files/${sendId}/${fileId}.bin`);
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRowArray(value: unknown, table: string): SqlRow[] {
|
||||||
|
if (!Array.isArray(value)) {
|
||||||
|
throw new Error(`Backup archive table ${table} is invalid`);
|
||||||
|
}
|
||||||
|
return value as SqlRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupArchive(bytes: Uint8Array): { payload: BackupPayload; files: Record<string, Uint8Array> } {
|
||||||
|
validateArchiveSize(bytes);
|
||||||
|
let zipped: Record<string, Uint8Array>;
|
||||||
|
try {
|
||||||
|
zipped = unzipSync(bytes);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid backup archive');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryNames = Object.keys(zipped);
|
||||||
|
if (entryNames.length > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
|
||||||
|
throw new Error('Backup archive contains too many files');
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalExtractedBytes = 0;
|
||||||
|
for (const entry of entryNames) {
|
||||||
|
const entryBytes = zipped[entry];
|
||||||
|
totalExtractedBytes += entryBytes.byteLength;
|
||||||
|
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
|
||||||
|
throw new Error('Backup archive database payload is too large');
|
||||||
|
}
|
||||||
|
if (totalExtractedBytes > MAX_BACKUP_EXTRACTED_BYTES) {
|
||||||
|
throw new Error('Backup archive expands beyond the current restore limit');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifestBytes = zipped['manifest.json'];
|
||||||
|
const dbBytes = zipped['db.json'];
|
||||||
|
if (!manifestBytes || !dbBytes) {
|
||||||
|
throw new Error('Backup archive is missing manifest.json or db.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let manifest: BackupManifest;
|
||||||
|
let db: BackupPayload['db'];
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
|
||||||
|
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db'];
|
||||||
|
} catch {
|
||||||
|
throw new Error('Backup archive contains invalid JSON metadata');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manifest?.formatVersion !== BACKUP_FORMAT_VERSION) {
|
||||||
|
throw new Error('Unsupported backup format version');
|
||||||
|
}
|
||||||
|
if (!db || typeof db !== 'object') {
|
||||||
|
throw new Error('Backup archive database payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredEntries = getRequiredZipEntries(db);
|
||||||
|
for (const entry of requiredEntries) {
|
||||||
|
if (!zipped[entry]) {
|
||||||
|
throw new Error(`Backup archive is missing required file: ${entry}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
payload: { manifest, db },
|
||||||
|
files: zipped,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateBackupPayloadContents(payload: BackupPayload, files: Record<string, Uint8Array>): void {
|
||||||
|
const configRows = ensureRowArray(payload.db.config, 'config');
|
||||||
|
const userRows = ensureRowArray(payload.db.users, 'users');
|
||||||
|
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
||||||
|
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||||
|
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||||
|
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||||
|
const sendRows = ensureRowArray(payload.db.sends, 'sends');
|
||||||
|
|
||||||
|
const userIds = new Set<string>();
|
||||||
|
for (const row of userRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const email = String(row.email || '').trim();
|
||||||
|
if (!id || !email) throw new Error('Backup archive contains an invalid user row');
|
||||||
|
if (userIds.has(id)) throw new Error(`Backup archive contains duplicate user id: ${id}`);
|
||||||
|
userIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of configRows) {
|
||||||
|
const key = String(row.key || '').trim();
|
||||||
|
if (!key) throw new Error('Backup archive contains an invalid config row');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of revisionRows) {
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!userId || !userIds.has(userId)) {
|
||||||
|
throw new Error(`Backup archive contains a revision for an unknown user: ${userId || '(empty)'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderIds = new Set<string>();
|
||||||
|
for (const row of folderRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid folder row');
|
||||||
|
if (folderIds.has(id)) throw new Error(`Backup archive contains duplicate folder id: ${id}`);
|
||||||
|
folderIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cipherIds = new Set<string>();
|
||||||
|
for (const row of cipherRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const folderId = String(row.folder_id || '').trim();
|
||||||
|
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid cipher row');
|
||||||
|
if (folderId && !folderIds.has(folderId)) {
|
||||||
|
throw new Error(`Backup archive contains a cipher for an unknown folder: ${folderId}`);
|
||||||
|
}
|
||||||
|
if (cipherIds.has(id)) throw new Error(`Backup archive contains duplicate cipher id: ${id}`);
|
||||||
|
cipherIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of attachmentRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
if (!id || !cipherId || !cipherIds.has(cipherId)) {
|
||||||
|
throw new Error('Backup archive contains an invalid attachment row');
|
||||||
|
}
|
||||||
|
if (!files[`attachments/${cipherId}/${id}.bin`]) {
|
||||||
|
throw new Error(`Backup archive is missing required file: attachments/${cipherId}/${id}.bin`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendIds = new Set<string>();
|
||||||
|
for (const row of sendRows) {
|
||||||
|
const id = String(row.id || '').trim();
|
||||||
|
const userId = String(row.user_id || '').trim();
|
||||||
|
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid send row');
|
||||||
|
if (sendIds.has(id)) throw new Error(`Backup archive contains duplicate send id: ${id}`);
|
||||||
|
sendIds.add(id);
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (fileId && !files[`send-files/${id}/${fileId}.bin`]) {
|
||||||
|
throw new Error(`Backup archive is missing required file: send-files/${id}/${fileId}.bin`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildBackupArchive(env: Env, date: Date = new Date()): Promise<BackupArchiveBundle> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows, sendRows] = await Promise.all([
|
||||||
|
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
|
queryRows(env.DB, 'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends ORDER BY created_at ASC'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let attachmentBlobCount = 0;
|
||||||
|
let sendFileBlobCount = 0;
|
||||||
|
let totalBlobBytes = 0;
|
||||||
|
let largestObjectBytes = 0;
|
||||||
|
const manifestBase = {
|
||||||
|
formatVersion: BACKUP_FORMAT_VERSION,
|
||||||
|
exportedAt: date.toISOString(),
|
||||||
|
appVersion: BACKUP_APP_VERSION,
|
||||||
|
storageKind: getBlobStorageKind(env),
|
||||||
|
tableCounts: {
|
||||||
|
config: configRows.length,
|
||||||
|
users: userRows.length,
|
||||||
|
user_revisions: revisionRows.length,
|
||||||
|
folders: folderRows.length,
|
||||||
|
ciphers: cipherRows.length,
|
||||||
|
attachments: attachmentRows.length,
|
||||||
|
sends: sendRows.length,
|
||||||
|
},
|
||||||
|
includes: {
|
||||||
|
attachments: true,
|
||||||
|
sendFiles: true,
|
||||||
|
},
|
||||||
|
blobSummary: {
|
||||||
|
attachmentFiles: 0,
|
||||||
|
sendFiles: 0,
|
||||||
|
totalBytes: 0,
|
||||||
|
largestObjectBytes: 0,
|
||||||
|
},
|
||||||
|
} satisfies BackupManifest;
|
||||||
|
|
||||||
|
const files: Record<string, Uint8Array> = {
|
||||||
|
'manifest.json': encoder.encode(JSON.stringify(manifestBase, null, 2)),
|
||||||
|
'db.json': encoder.encode(JSON.stringify({
|
||||||
|
config: configRows,
|
||||||
|
users: userRows,
|
||||||
|
user_revisions: revisionRows,
|
||||||
|
folders: folderRows,
|
||||||
|
ciphers: cipherRows,
|
||||||
|
attachments: attachmentRows,
|
||||||
|
sends: sendRows,
|
||||||
|
}, null, 2)),
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const row of attachmentRows) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
const object = await getBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId));
|
||||||
|
if (!object) {
|
||||||
|
throw new Error(`Attachment blob missing for ${cipherId}/${attachmentId}`);
|
||||||
|
}
|
||||||
|
const bytes = await streamToBytes(object.body);
|
||||||
|
files[`attachments/${cipherId}/${attachmentId}.bin`] = bytes;
|
||||||
|
attachmentBlobCount += 1;
|
||||||
|
totalBlobBytes += bytes.byteLength;
|
||||||
|
largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of sendRows) {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!sendId || !fileId) continue;
|
||||||
|
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||||
|
if (!object) {
|
||||||
|
throw new Error(`Send file blob missing for ${sendId}/${fileId}`);
|
||||||
|
}
|
||||||
|
const bytes = await streamToBytes(object.body);
|
||||||
|
files[`send-files/${sendId}/${fileId}.bin`] = bytes;
|
||||||
|
sendFileBlobCount += 1;
|
||||||
|
totalBlobBytes += bytes.byteLength;
|
||||||
|
largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest: BackupManifest = {
|
||||||
|
...manifestBase,
|
||||||
|
blobSummary: {
|
||||||
|
attachmentFiles: attachmentBlobCount,
|
||||||
|
sendFiles: sendFileBlobCount,
|
||||||
|
totalBytes: totalBlobBytes,
|
||||||
|
largestObjectBytes,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
files['manifest.json'] = encoder.encode(JSON.stringify(manifest, null, 2));
|
||||||
|
|
||||||
|
return {
|
||||||
|
bytes: zipSync(files, { level: BACKUP_ZIP_COMPRESSION_LEVEL }),
|
||||||
|
fileName: buildBackupFileName(date),
|
||||||
|
manifest,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import { StorageService } from './storage';
|
||||||
|
import {
|
||||||
|
type BackupSettingsPortableEnvelope,
|
||||||
|
decryptBackupSettingsRuntime,
|
||||||
|
encryptBackupSettingsEnvelope,
|
||||||
|
parseBackupSettingsEnvelope,
|
||||||
|
} from './backup-settings-crypto';
|
||||||
|
import {
|
||||||
|
BACKUP_DEFAULT_SCHEDULE_TIME,
|
||||||
|
BACKUP_DEFAULT_TIMEZONE,
|
||||||
|
type BackupDestinationConfig,
|
||||||
|
type BackupDestinationRecord,
|
||||||
|
type BackupDestinationType,
|
||||||
|
type BackupRuntimeState,
|
||||||
|
type BackupScheduleConfig,
|
||||||
|
type BackupScheduleFrequency,
|
||||||
|
type BackupSettings,
|
||||||
|
type E3BackupDestination,
|
||||||
|
type PlaceholderBackupDestination,
|
||||||
|
type WebDavBackupDestination,
|
||||||
|
createBackupRandomId,
|
||||||
|
createDefaultBackupDestinationName,
|
||||||
|
createDefaultBackupScheduleConfig,
|
||||||
|
createDefaultBackupSettings as createSharedDefaultBackupSettings,
|
||||||
|
} from '../../shared/backup';
|
||||||
|
|
||||||
|
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
||||||
|
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||||
|
const MAX_BACKUP_DESTINATIONS = 24;
|
||||||
|
|
||||||
|
export type {
|
||||||
|
BackupDestinationConfig,
|
||||||
|
BackupDestinationRecord,
|
||||||
|
BackupDestinationType,
|
||||||
|
BackupRuntimeState,
|
||||||
|
BackupScheduleConfig,
|
||||||
|
BackupSettings,
|
||||||
|
E3BackupDestination,
|
||||||
|
PlaceholderBackupDestination,
|
||||||
|
WebDavBackupDestination,
|
||||||
|
} from '../../shared/backup';
|
||||||
|
|
||||||
|
export interface BackupSettingsInput {
|
||||||
|
destinations?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsRepairState {
|
||||||
|
needsRepair: boolean;
|
||||||
|
portable: BackupSettingsPortableEnvelope | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultScheduleConfig(timezone: string = 'UTC'): BackupScheduleConfig {
|
||||||
|
return { ...createDefaultBackupScheduleConfig(assertValidTimeZone(timezone)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: unknown): string {
|
||||||
|
return String(value ?? '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(value: unknown): string {
|
||||||
|
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidTimeZone(timezone: string): string {
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
||||||
|
return timezone;
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid backup timezone');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidScheduleTime(value: string): string {
|
||||||
|
if (!/^\d{2}:\d{2}$/.test(value)) {
|
||||||
|
throw new Error('Backup time must use HH:MM format');
|
||||||
|
}
|
||||||
|
const [hoursRaw, minutesRaw] = value.split(':');
|
||||||
|
const hours = Number(hoursRaw);
|
||||||
|
const minutes = Number(minutesRaw);
|
||||||
|
if (!Number.isInteger(hours) || !Number.isInteger(minutes) || hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
||||||
|
throw new Error('Backup time is invalid');
|
||||||
|
}
|
||||||
|
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRetentionCount(value: unknown, fallback: number | null = 30): number | null {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
if (value === null || String(value).trim() === '') return null;
|
||||||
|
const count = Number(value);
|
||||||
|
if (!Number.isInteger(count) || count < 1 || count > 1000) {
|
||||||
|
throw new Error('Backup retention count must be between 1 and 1000');
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScheduleFrequency(
|
||||||
|
value: unknown,
|
||||||
|
fallback: BackupScheduleFrequency = 'daily'
|
||||||
|
): BackupScheduleFrequency {
|
||||||
|
const frequency = asTrimmedString(value) || fallback;
|
||||||
|
if (frequency !== 'daily' && frequency !== 'weekly' && frequency !== 'monthly') {
|
||||||
|
throw new Error('Backup frequency is invalid');
|
||||||
|
}
|
||||||
|
return frequency;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDayOfWeek(value: unknown, fallback: number = 1): number {
|
||||||
|
if (value === undefined || value === null || value === '') return fallback;
|
||||||
|
const day = Number(value);
|
||||||
|
if (!Number.isInteger(day) || day < 0 || day > 6) {
|
||||||
|
throw new Error('Backup day of week is invalid');
|
||||||
|
}
|
||||||
|
return day;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDayOfMonth(value: unknown, fallback: number = 1): number {
|
||||||
|
if (value === undefined || value === null || value === '') return fallback;
|
||||||
|
const day = Number(value);
|
||||||
|
if (!Number.isInteger(day) || day < 1 || day > 31) {
|
||||||
|
throw new Error('Backup day of month must be between 1 and 31');
|
||||||
|
}
|
||||||
|
return day;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeE3Destination(value: unknown, allowIncomplete = false): E3BackupDestination {
|
||||||
|
const source = isPlainObject(value) ? value : {};
|
||||||
|
const endpoint = asTrimmedString(source.endpoint);
|
||||||
|
const bucket = asTrimmedString(source.bucket);
|
||||||
|
const accessKeyId = asTrimmedString(source.accessKeyId);
|
||||||
|
const secretAccessKey = asTrimmedString(source.secretAccessKey);
|
||||||
|
const region = asTrimmedString(source.region) || 'auto';
|
||||||
|
const rootPath = normalizePath(source.rootPath);
|
||||||
|
|
||||||
|
if (!allowIncomplete || endpoint) {
|
||||||
|
if (!endpoint) throw new Error('E3 endpoint is required');
|
||||||
|
if (!/^https?:\/\//i.test(endpoint)) throw new Error('E3 endpoint must start with http:// or https://');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || bucket) {
|
||||||
|
if (!bucket) throw new Error('E3 bucket is required');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || accessKeyId) {
|
||||||
|
if (!accessKeyId) throw new Error('E3 access key is required');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || secretAccessKey) {
|
||||||
|
if (!secretAccessKey) throw new Error('E3 secret key is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
||||||
|
bucket,
|
||||||
|
region,
|
||||||
|
accessKeyId,
|
||||||
|
secretAccessKey,
|
||||||
|
rootPath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeWebDavDestination(value: unknown, allowIncomplete = false): WebDavBackupDestination {
|
||||||
|
const source = isPlainObject(value) ? value : {};
|
||||||
|
const baseUrl = asTrimmedString(source.baseUrl);
|
||||||
|
const username = asTrimmedString(source.username);
|
||||||
|
const password = String(source.password ?? '');
|
||||||
|
const remotePath = normalizePath(source.remotePath);
|
||||||
|
|
||||||
|
if (!allowIncomplete || baseUrl) {
|
||||||
|
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
||||||
|
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || username) {
|
||||||
|
if (!username) throw new Error('WebDAV username is required');
|
||||||
|
}
|
||||||
|
if (!allowIncomplete || password) {
|
||||||
|
if (!password) throw new Error('WebDAV password is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
remotePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlaceholderDestination(value: unknown): PlaceholderBackupDestination {
|
||||||
|
const source = isPlainObject(value) ? value : {};
|
||||||
|
return {
|
||||||
|
providerName: asTrimmedString(source.providerName) || 'Reserved',
|
||||||
|
notes: asTrimmedString(source.notes),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDestination(
|
||||||
|
destinationType: BackupDestinationType,
|
||||||
|
destination: unknown,
|
||||||
|
allowIncomplete = false
|
||||||
|
): BackupDestinationConfig {
|
||||||
|
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
|
||||||
|
if (destinationType === 'webdav') return normalizeWebDavDestination(destination, allowIncomplete);
|
||||||
|
return normalizePlaceholderDestination(destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuntime(value: unknown): BackupRuntimeState {
|
||||||
|
const source = isPlainObject(value) ? value : {};
|
||||||
|
const asIso = (input: unknown): string | null => {
|
||||||
|
const raw = asTrimmedString(input);
|
||||||
|
if (!raw) return null;
|
||||||
|
const date = new Date(raw);
|
||||||
|
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||||
|
};
|
||||||
|
const asMaybeNumber = (input: unknown): number | null => {
|
||||||
|
if (input === null || input === undefined || input === '') return null;
|
||||||
|
const n = Number(input);
|
||||||
|
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
lastAttemptAt: asIso(source.lastAttemptAt),
|
||||||
|
lastAttemptLocalDate: asTrimmedString(source.lastAttemptLocalDate) || null,
|
||||||
|
lastSuccessAt: asIso(source.lastSuccessAt),
|
||||||
|
lastErrorAt: asIso(source.lastErrorAt),
|
||||||
|
lastErrorMessage: asTrimmedString(source.lastErrorMessage) || null,
|
||||||
|
lastUploadedFileName: asTrimmedString(source.lastUploadedFileName) || null,
|
||||||
|
lastUploadedSizeBytes: asMaybeNumber(source.lastUploadedSizeBytes),
|
||||||
|
lastUploadedDestination: asTrimmedString(source.lastUploadedDestination) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultDestinationName(type: BackupDestinationType, index: number): string {
|
||||||
|
return createDefaultBackupDestinationName(type, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDestinationType(raw: unknown): BackupDestinationType {
|
||||||
|
const value = asTrimmedString(raw);
|
||||||
|
if (value === 'e3' || value === 'webdav' || value === 'placeholder') return value;
|
||||||
|
throw new Error('Backup destination type is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDestinationRecord(
|
||||||
|
input: unknown,
|
||||||
|
previousById: Map<string, BackupDestinationRecord>,
|
||||||
|
index: number,
|
||||||
|
fallbackTimezone: string
|
||||||
|
): BackupDestinationRecord {
|
||||||
|
if (!isPlainObject(input)) {
|
||||||
|
throw new Error('Backup destination is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = asTrimmedString(input.id) || createBackupRandomId();
|
||||||
|
const type = getDestinationType(input.type);
|
||||||
|
const previous = previousById.get(id);
|
||||||
|
const runtime = previous?.runtime ? normalizeRuntime(previous.runtime) : normalizeRuntime(input.runtime);
|
||||||
|
const name = asTrimmedString(input.name) || previous?.name || defaultDestinationName(type, index + 1);
|
||||||
|
const scheduleSource = isPlainObject(input.schedule) ? input.schedule : {};
|
||||||
|
const previousSchedule = previous?.schedule || defaultScheduleConfig(fallbackTimezone);
|
||||||
|
const retentionSource = Object.prototype.hasOwnProperty.call(scheduleSource, 'retentionCount')
|
||||||
|
? scheduleSource.retentionCount
|
||||||
|
: previousSchedule.retentionCount;
|
||||||
|
const schedule: BackupScheduleConfig = {
|
||||||
|
enabled: !!(scheduleSource.enabled ?? previousSchedule.enabled),
|
||||||
|
frequency: normalizeScheduleFrequency(scheduleSource.frequency ?? previousSchedule.frequency, previousSchedule.frequency),
|
||||||
|
scheduleTime: assertValidScheduleTime(asTrimmedString(scheduleSource.scheduleTime ?? previousSchedule.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME),
|
||||||
|
timezone: assertValidTimeZone(asTrimmedString(scheduleSource.timezone ?? previousSchedule.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
|
dayOfWeek: normalizeDayOfWeek(scheduleSource.dayOfWeek ?? previousSchedule.dayOfWeek, previousSchedule.dayOfWeek),
|
||||||
|
dayOfMonth: normalizeDayOfMonth(scheduleSource.dayOfMonth ?? previousSchedule.dayOfMonth, previousSchedule.dayOfMonth),
|
||||||
|
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (schedule.enabled && type === 'placeholder') {
|
||||||
|
throw new Error('The reserved backup destination is not available yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
const destination = normalizeDestination(type, input.destination, !schedule.enabled);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
destination,
|
||||||
|
schedule,
|
||||||
|
runtime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings {
|
||||||
|
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
||||||
|
const destinationType: BackupDestinationType =
|
||||||
|
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav' || destinationTypeRaw === 'placeholder'
|
||||||
|
? destinationTypeRaw
|
||||||
|
: 'webdav';
|
||||||
|
const destination = {
|
||||||
|
id: createBackupRandomId(),
|
||||||
|
name: defaultDestinationName(destinationType, 1),
|
||||||
|
type: destinationType,
|
||||||
|
destination: normalizeDestination(destinationType, rawValue.destination),
|
||||||
|
schedule: {
|
||||||
|
enabled: !!rawValue.enabled,
|
||||||
|
frequency: 'daily',
|
||||||
|
scheduleTime: assertValidScheduleTime(asTrimmedString(rawValue.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME),
|
||||||
|
timezone: assertValidTimeZone(asTrimmedString(rawValue.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE),
|
||||||
|
dayOfWeek: 1,
|
||||||
|
dayOfMonth: 1,
|
||||||
|
retentionCount: 30,
|
||||||
|
},
|
||||||
|
runtime: normalizeRuntime(rawValue.runtime),
|
||||||
|
} satisfies BackupDestinationRecord;
|
||||||
|
|
||||||
|
return {
|
||||||
|
destinations: [destination],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDestinations(
|
||||||
|
rawDestinations: unknown,
|
||||||
|
previousById: Map<string, BackupDestinationRecord>,
|
||||||
|
fallbackTimezone: string
|
||||||
|
): BackupDestinationRecord[] {
|
||||||
|
if (!Array.isArray(rawDestinations)) {
|
||||||
|
throw new Error('Backup destinations are invalid');
|
||||||
|
}
|
||||||
|
if (rawDestinations.length > MAX_BACKUP_DESTINATIONS) {
|
||||||
|
throw new Error(`You can save up to ${MAX_BACKUP_DESTINATIONS} backup destinations`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinations = rawDestinations.map((entry, index) => normalizeDestinationRecord(entry, previousById, index, fallbackTimezone));
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const destination of destinations) {
|
||||||
|
if (ids.has(destination.id)) {
|
||||||
|
throw new Error('Backup destination ids must be unique');
|
||||||
|
}
|
||||||
|
ids.add(destination.id);
|
||||||
|
}
|
||||||
|
return destinations;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<string, BackupDestinationRecord> {
|
||||||
|
return new Map(destinations.map((destination) => [destination.id, destination]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
|
||||||
|
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupSettings(raw: string | null, fallbackTimezone: string = 'UTC'): BackupSettings {
|
||||||
|
if (!raw) return getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
if (Array.isArray(parsed.destinations)) {
|
||||||
|
const globalScheduleTime = assertValidScheduleTime(asTrimmedString(parsed.scheduleTime) || BACKUP_DEFAULT_SCHEDULE_TIME);
|
||||||
|
const globalTimezone = assertValidTimeZone(asTrimmedString(parsed.timezone) || fallbackTimezone || BACKUP_DEFAULT_TIMEZONE);
|
||||||
|
const globalEnabled = !!parsed.enabled;
|
||||||
|
const activeDestinationIdRaw = asTrimmedString(parsed.activeDestinationId);
|
||||||
|
const previousById = new Map<string, BackupDestinationRecord>();
|
||||||
|
const normalizedEntries = (parsed.destinations as unknown[]).map((entry) => {
|
||||||
|
if (!isPlainObject(entry)) return entry;
|
||||||
|
if (isPlainObject(entry.schedule)) return entry;
|
||||||
|
const entryId = asTrimmedString(entry.id);
|
||||||
|
const scheduleEnabled = globalEnabled && (!activeDestinationIdRaw || entryId === activeDestinationIdRaw);
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
schedule: {
|
||||||
|
enabled: scheduleEnabled,
|
||||||
|
frequency: 'daily',
|
||||||
|
scheduleTime: globalScheduleTime,
|
||||||
|
timezone: globalTimezone,
|
||||||
|
dayOfWeek: 1,
|
||||||
|
dayOfMonth: 1,
|
||||||
|
retentionCount: 30,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
destinations: parseDestinations(normalizedEntries, previousById, fallbackTimezone),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return parseLegacyBackupSettings(parsed, fallbackTimezone);
|
||||||
|
} catch {
|
||||||
|
return getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBackupSettingsInput(
|
||||||
|
input: BackupSettingsInput,
|
||||||
|
previous: BackupSettings
|
||||||
|
): BackupSettings {
|
||||||
|
if (!isPlainObject(input)) {
|
||||||
|
throw new Error('Backup settings payload is invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousById = mapDestinationsById(previous.destinations);
|
||||||
|
const rawDestinations = input.destinations ?? previous.destinations;
|
||||||
|
const destinations = parseDestinations(rawDestinations, previousById, BACKUP_DEFAULT_TIMEZONE);
|
||||||
|
|
||||||
|
return {
|
||||||
|
destinations,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeBackupSettings(settings: BackupSettings): string {
|
||||||
|
return JSON.stringify(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
||||||
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) {
|
||||||
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
|
return parseBackupSettings(decrypted, fallbackTimezone);
|
||||||
|
} catch {
|
||||||
|
throw new Error('Backup settings need administrator reactivation after restore');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
||||||
|
const users = await storage.getAllUsers();
|
||||||
|
const hasPortableAdmins = users.some(
|
||||||
|
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||||
|
);
|
||||||
|
if (!hasPortableAdmins) {
|
||||||
|
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, serializeBackupSettings(settings));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
|
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
||||||
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (envelope) {
|
||||||
|
try {
|
||||||
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
|
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
|
||||||
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return { needsRepair: false, portable: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) {
|
||||||
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
return { needsRepair: false, portable: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await decryptBackupSettingsRuntime(raw, env);
|
||||||
|
return { needsRepair: false, portable: null };
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
needsRepair: true,
|
||||||
|
portable: envelope.portable,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function repairBackupSettings(storage: StorageService, env: Env, settings: BackupSettings): Promise<void> {
|
||||||
|
await saveBackupSettings(storage, env, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBackupDestination(
|
||||||
|
settings: BackupSettings,
|
||||||
|
destinationId: string | null | undefined
|
||||||
|
): BackupDestinationRecord | null {
|
||||||
|
const normalizedId = asTrimmedString(destinationId);
|
||||||
|
if (!normalizedId) return null;
|
||||||
|
return settings.destinations.find((destination) => destination.id === normalizedId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireBackupDestination(settings: BackupSettings, destinationId?: string | null): BackupDestinationRecord {
|
||||||
|
const destination = destinationId ? findBackupDestination(settings, destinationId) : settings.destinations[0] || null;
|
||||||
|
if (!destination) {
|
||||||
|
throw new Error('Backup destination not found');
|
||||||
|
}
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateTimeParts(date: Date, timezone: string): { year: string; month: string; day: string; hour: string; minute: string } {
|
||||||
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: timezone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||||
|
return {
|
||||||
|
year: pick('year'),
|
||||||
|
month: pick('month'),
|
||||||
|
day: pick('day'),
|
||||||
|
hour: pick('hour'),
|
||||||
|
minute: pick('minute'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalWeekday(date: Date, timezone: string): number {
|
||||||
|
const value = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: timezone,
|
||||||
|
weekday: 'short',
|
||||||
|
}).format(date);
|
||||||
|
const map: Record<string, number> = {
|
||||||
|
Sun: 0,
|
||||||
|
Mon: 1,
|
||||||
|
Tue: 2,
|
||||||
|
Wed: 3,
|
||||||
|
Thu: 4,
|
||||||
|
Fri: 5,
|
||||||
|
Sat: 6,
|
||||||
|
};
|
||||||
|
return map[value] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMonthLastDay(date: Date, timezone: string): number {
|
||||||
|
const { year, month } = getDateTimeParts(date, timezone);
|
||||||
|
const utcDate = new Date(Date.UTC(Number(year), Number(month), 0));
|
||||||
|
return utcDate.getUTCDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupLocalDateKey(date: Date, timezone: string): string {
|
||||||
|
const parts = getDateTimeParts(date, timezone);
|
||||||
|
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupLocalTime(date: Date, timezone: string): string {
|
||||||
|
const parts = getDateTimeParts(date, timezone);
|
||||||
|
return `${parts.hour}:${parts.minute}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMinutes(value: string): number {
|
||||||
|
const [hoursRaw, minutesRaw] = value.split(':');
|
||||||
|
return Number(hoursRaw) * 60 + Number(minutesRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBackupDueNow(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
now: Date,
|
||||||
|
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
||||||
|
): boolean {
|
||||||
|
if (!destination.schedule.enabled) return false;
|
||||||
|
if (destination.type === 'placeholder') return false;
|
||||||
|
|
||||||
|
const currentMinutes = toMinutes(getBackupLocalTime(now, destination.schedule.timezone));
|
||||||
|
const scheduledMinutes = toMinutes(destination.schedule.scheduleTime);
|
||||||
|
const delta = currentMinutes - scheduledMinutes;
|
||||||
|
if (delta < 0 || delta >= windowMinutes) return false;
|
||||||
|
|
||||||
|
if (destination.schedule.frequency === 'weekly') {
|
||||||
|
return getLocalWeekday(now, destination.schedule.timezone) === destination.schedule.dayOfWeek;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (destination.schedule.frequency === 'monthly') {
|
||||||
|
const currentDay = Number(getDateTimeParts(now, destination.schedule.timezone).day);
|
||||||
|
const scheduledDay = Math.min(destination.schedule.dayOfMonth, getMonthLastDay(now, destination.schedule.timezone));
|
||||||
|
return currentDay === scheduledDay;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
import { StorageService } from './storage';
|
||||||
|
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, getSendFileObjectKey, putBlobObject } from './blob-store';
|
||||||
|
import { normalizeImportedBackupSettings } from './backup-config';
|
||||||
|
import { type BackupPayload, parseBackupArchive, parseSendFileId, validateBackupPayloadContents } from './backup-archive';
|
||||||
|
|
||||||
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
|
||||||
|
export interface BackupImportResultBody {
|
||||||
|
object: 'instance-backup-import';
|
||||||
|
imported: {
|
||||||
|
config: number;
|
||||||
|
users: number;
|
||||||
|
userRevisions: number;
|
||||||
|
folders: number;
|
||||||
|
ciphers: number;
|
||||||
|
attachments: number;
|
||||||
|
sends: number;
|
||||||
|
attachmentFiles: number;
|
||||||
|
sendFiles: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupImportExecutionResult {
|
||||||
|
result: BackupImportResultBody;
|
||||||
|
auditActorUserId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||||
|
const response = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||||
|
return (response.results || []).map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
|
||||||
|
const counts = await Promise.all([
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM folders').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM attachments').first<{ count: number }>(),
|
||||||
|
db.prepare('SELECT COUNT(*) AS count FROM sends').first<{ count: number }>(),
|
||||||
|
]);
|
||||||
|
const total = counts.reduce((sum, row) => sum + Number(row?.count || 0), 0);
|
||||||
|
if (total > 0) {
|
||||||
|
throw new Error('Backup import requires a fresh instance with no vault or send data');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[] {
|
||||||
|
return [
|
||||||
|
'DELETE FROM attachments',
|
||||||
|
'DELETE FROM ciphers',
|
||||||
|
'DELETE FROM folders',
|
||||||
|
'DELETE FROM sends',
|
||||||
|
'DELETE FROM trusted_two_factor_device_tokens',
|
||||||
|
'DELETE FROM devices',
|
||||||
|
'DELETE FROM refresh_tokens',
|
||||||
|
'DELETE FROM invites',
|
||||||
|
'DELETE FROM audit_logs',
|
||||||
|
'DELETE FROM user_revisions',
|
||||||
|
'DELETE FROM users',
|
||||||
|
'DELETE FROM config',
|
||||||
|
'DELETE FROM login_attempts_ip',
|
||||||
|
'DELETE FROM api_rate_limits',
|
||||||
|
'DELETE FROM used_attachment_download_tokens',
|
||||||
|
].map((sql) => db.prepare(sql));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectCurrentBlobKeys(db: D1Database): Promise<Set<string>> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
const attachmentRows = await queryRows(
|
||||||
|
db,
|
||||||
|
`SELECT a.id, a.cipher_id
|
||||||
|
FROM attachments a
|
||||||
|
INNER JOIN ciphers c ON c.id = a.cipher_id`
|
||||||
|
);
|
||||||
|
for (const row of attachmentRows) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
keys.add(getAttachmentObjectKey(cipherId, attachmentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendRows = await queryRows(db, 'SELECT id, data FROM sends');
|
||||||
|
for (const row of sendRows) {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!sendId || !fileId) continue;
|
||||||
|
keys.add(getSendFileObjectKey(sendId, fileId));
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectImportedBlobKeys(db: BackupPayload['db']): Set<string> {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const row of db.attachments) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
keys.add(getAttachmentObjectKey(cipherId, attachmentId));
|
||||||
|
}
|
||||||
|
for (const row of db.sends) {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!sendId || !fileId) continue;
|
||||||
|
keys.add(getSendFileObjectKey(sendId, fileId));
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateImportBlobLimits(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): void {
|
||||||
|
if (getBlobStorageKind(env) !== 'kv') return;
|
||||||
|
for (const entry of Object.keys(files)) {
|
||||||
|
if (!entry.endsWith('.bin')) continue;
|
||||||
|
if (files[entry].byteLength > KV_MAX_OBJECT_BYTES) {
|
||||||
|
throw new Error(`Backup file ${entry} exceeds the Cloudflare KV object size limit`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((payload.db.attachments || []).length > 0 || (payload.db.sends || []).length > 0) {
|
||||||
|
if (!env.ATTACHMENTS_KV) {
|
||||||
|
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
||||||
|
if (!rows.length) return [];
|
||||||
|
const placeholders = `(${columns.map(() => '?').join(', ')})`;
|
||||||
|
const sql = `INSERT ${upsert ? 'OR REPLACE ' : ''}INTO ${table} (${columns.join(', ')}) VALUES ${placeholders}`;
|
||||||
|
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<{ attachments: number; sendFiles: number }> {
|
||||||
|
let attachmentCount = 0;
|
||||||
|
let sendFileCount = 0;
|
||||||
|
|
||||||
|
for (const row of db.attachments || []) {
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
if (!cipherId || !attachmentId) continue;
|
||||||
|
const key = `attachments/${cipherId}/${attachmentId}.bin`;
|
||||||
|
const bytes = files[key];
|
||||||
|
if (!bytes) throw new Error(`Backup archive is missing required file: ${key}`);
|
||||||
|
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
|
||||||
|
size: bytes.byteLength,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
});
|
||||||
|
attachmentCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of db.sends || []) {
|
||||||
|
const sendId = String(row.id || '').trim();
|
||||||
|
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||||
|
if (!sendId || !fileId) continue;
|
||||||
|
const key = `send-files/${sendId}/${fileId}.bin`;
|
||||||
|
const bytes = files[key];
|
||||||
|
if (!bytes) throw new Error(`Backup archive is missing required file: ${key}`);
|
||||||
|
await putBlobObject(env, getSendFileObjectKey(sendId, fileId), bytes, {
|
||||||
|
size: bytes.byteLength,
|
||||||
|
contentType: 'application/octet-stream',
|
||||||
|
});
|
||||||
|
sendFileCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachments: attachmentCount,
|
||||||
|
sendFiles: sendFileCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, afterKeys: Set<string>): Promise<void> {
|
||||||
|
const staleKeys = Array.from(beforeKeys).filter((key) => !afterKeys.has(key));
|
||||||
|
for (const key of staleKeys) {
|
||||||
|
await deleteBlobObject(env, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importBackupRows(db: D1Database, payload: BackupPayload['db']): Promise<void> {
|
||||||
|
const statements: D1PreparedStatement[] = [
|
||||||
|
...buildResetImportTargetStatements(db),
|
||||||
|
...buildInsertStatements(db, 'config', ['key', 'value'], payload.config || [], true),
|
||||||
|
...buildInsertStatements(
|
||||||
|
db,
|
||||||
|
'users',
|
||||||
|
['id', 'email', 'name', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||||
|
payload.users || []
|
||||||
|
),
|
||||||
|
...buildInsertStatements(db, 'user_revisions', ['user_id', 'revision_date'], payload.user_revisions || [], true),
|
||||||
|
...buildInsertStatements(db, 'folders', ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || []),
|
||||||
|
...buildInsertStatements(
|
||||||
|
db,
|
||||||
|
'ciphers',
|
||||||
|
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
|
||||||
|
payload.ciphers || []
|
||||||
|
),
|
||||||
|
...buildInsertStatements(db, 'attachments', ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []),
|
||||||
|
...buildInsertStatements(
|
||||||
|
db,
|
||||||
|
'sends',
|
||||||
|
['id', 'user_id', 'type', 'name', 'notes', 'data', 'key', 'password_hash', 'password_salt', 'password_iterations', 'auth_type', 'emails', 'max_access_count', 'access_count', 'disabled', 'hide_email', 'created_at', 'updated_at', 'expiration_date', 'deletion_date'],
|
||||||
|
payload.sends || []
|
||||||
|
),
|
||||||
|
];
|
||||||
|
await db.batch(statements);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importBackupArchiveBytes(
|
||||||
|
archiveBytes: Uint8Array,
|
||||||
|
env: Env,
|
||||||
|
actorUserId: string,
|
||||||
|
replaceExisting: boolean
|
||||||
|
): Promise<BackupImportExecutionResult> {
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
const parsed = parseBackupArchive(archiveBytes);
|
||||||
|
validateBackupPayloadContents(parsed.payload, parsed.files);
|
||||||
|
validateImportBlobLimits(env, parsed.payload, parsed.files);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ensureImportTargetIsFresh(env.DB);
|
||||||
|
} catch (error) {
|
||||||
|
if (!replaceExisting) {
|
||||||
|
throw error instanceof Error ? error : new Error('Backup import requires a fresh instance');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||||
|
const { db } = parsed.payload;
|
||||||
|
await importBackupRows(env.DB, db);
|
||||||
|
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
||||||
|
|
||||||
|
const blobCounts = await restoreBlobFiles(env, db, parsed.files);
|
||||||
|
if (replaceExisting && previousBlobKeys.size) {
|
||||||
|
await cleanupOrphanedBlobFiles(env, previousBlobKeys, collectImportedBlobKeys(db));
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.setRegistered();
|
||||||
|
|
||||||
|
return {
|
||||||
|
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||||
|
result: {
|
||||||
|
object: 'instance-backup-import',
|
||||||
|
imported: {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: (db.attachments || []).length,
|
||||||
|
sends: (db.sends || []).length,
|
||||||
|
attachmentFiles: blobCounts.attachments,
|
||||||
|
sendFiles: blobCounts.sendFiles,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import type { Env, User } from '../types';
|
||||||
|
|
||||||
|
const RUNTIME_SALT = 'nodewarden.backup-settings.runtime.v2';
|
||||||
|
const RUNTIME_INFO = 'runtime';
|
||||||
|
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
||||||
|
const PORTABLE_HASH = 'SHA-1';
|
||||||
|
const AES_GCM_ALGORITHM = 'AES-GCM';
|
||||||
|
const AES_GCM_IV_BYTES = 12;
|
||||||
|
const PORTABLE_DEK_BYTES = 32;
|
||||||
|
|
||||||
|
export interface BackupSettingsRuntimeEnvelope {
|
||||||
|
iv: string;
|
||||||
|
ciphertext: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsPortableWrap {
|
||||||
|
userId: string;
|
||||||
|
wrappedKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsPortableEnvelope {
|
||||||
|
iv: string;
|
||||||
|
ciphertext: string;
|
||||||
|
wraps: BackupSettingsPortableWrap[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BackupSettingsEnvelopeV2 {
|
||||||
|
version: 2;
|
||||||
|
runtime: BackupSettingsRuntimeEnvelope;
|
||||||
|
portable: BackupSettingsPortableEnvelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let text = '';
|
||||||
|
for (let index = 0; index < bytes.length; index += 1) {
|
||||||
|
text += String.fromCharCode(bytes[index]);
|
||||||
|
}
|
||||||
|
return btoa(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBytes(value: string): Uint8Array {
|
||||||
|
const normalized = String(value || '').trim();
|
||||||
|
const binary = atob(normalized);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveRuntimeKey(secret: string): Promise<CryptoKey> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
encoder.encode(secret),
|
||||||
|
'HKDF',
|
||||||
|
false,
|
||||||
|
['deriveBits']
|
||||||
|
);
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: 'HKDF',
|
||||||
|
hash: 'SHA-256',
|
||||||
|
salt: encoder.encode(RUNTIME_SALT),
|
||||||
|
info: encoder.encode(RUNTIME_INFO),
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
256
|
||||||
|
);
|
||||||
|
return crypto.subtle.importKey('raw', bits, { name: AES_GCM_ALGORITHM }, false, ['encrypt', 'decrypt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptAesGcm(plaintext: Uint8Array, key: CryptoKey): Promise<{ iv: Uint8Array; ciphertext: Uint8Array }> {
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_BYTES));
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{ name: AES_GCM_ALGORITHM, iv },
|
||||||
|
key,
|
||||||
|
plaintext
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return { iv, ciphertext };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decryptAesGcm(ciphertext: Uint8Array, iv: Uint8Array, key: CryptoKey): Promise<Uint8Array> {
|
||||||
|
return new Uint8Array(
|
||||||
|
await crypto.subtle.decrypt(
|
||||||
|
{ name: AES_GCM_ALGORITHM, iv },
|
||||||
|
key,
|
||||||
|
ciphertext
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPortablePublicKey(publicKeyBase64: string): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
'spki',
|
||||||
|
base64ToBytes(publicKeyBase64),
|
||||||
|
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEligiblePortableUsers(users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]): Array<Pick<User, 'id' | 'publicKey'>> {
|
||||||
|
return users
|
||||||
|
.filter(
|
||||||
|
(user) =>
|
||||||
|
user.role === 'admin' &&
|
||||||
|
user.status === 'active' &&
|
||||||
|
typeof user.publicKey === 'string' &&
|
||||||
|
user.publicKey.trim().length > 0
|
||||||
|
)
|
||||||
|
.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
publicKey: user.publicKey!,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseBackupSettingsEnvelope(raw: string | null): BackupSettingsEnvelopeV2 | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
if (!isPlainObject(parsed) || Number(parsed.version) !== 2) return null;
|
||||||
|
const runtime = parsed.runtime;
|
||||||
|
const portable = parsed.portable;
|
||||||
|
if (!isPlainObject(runtime) || !isPlainObject(portable)) return null;
|
||||||
|
if (!Array.isArray(portable.wraps)) return null;
|
||||||
|
if (typeof runtime.iv !== 'string' || typeof runtime.ciphertext !== 'string') return null;
|
||||||
|
if (typeof portable.iv !== 'string' || typeof portable.ciphertext !== 'string') return null;
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
runtime: {
|
||||||
|
iv: runtime.iv,
|
||||||
|
ciphertext: runtime.ciphertext,
|
||||||
|
},
|
||||||
|
portable: {
|
||||||
|
iv: portable.iv,
|
||||||
|
ciphertext: portable.ciphertext,
|
||||||
|
wraps: portable.wraps
|
||||||
|
.filter((entry): entry is Record<string, unknown> => isPlainObject(entry))
|
||||||
|
.map((entry) => ({
|
||||||
|
userId: String(entry.userId || '').trim(),
|
||||||
|
wrappedKey: String(entry.wrappedKey || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((entry) => entry.userId && entry.wrappedKey),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encryptBackupSettingsEnvelope(
|
||||||
|
plaintext: string,
|
||||||
|
env: Env,
|
||||||
|
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[]
|
||||||
|
): Promise<string> {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const eligibleUsers = getEligiblePortableUsers(users);
|
||||||
|
if (!eligibleUsers.length) {
|
||||||
|
throw new Error('No active administrator public keys are available for backup settings recovery');
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
||||||
|
const runtime = await encryptAesGcm(encoder.encode(plaintext), runtimeKey);
|
||||||
|
|
||||||
|
const portableDek = crypto.getRandomValues(new Uint8Array(PORTABLE_DEK_BYTES));
|
||||||
|
const portableKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
portableDek,
|
||||||
|
{ name: AES_GCM_ALGORITHM },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
const portableCipher = await encryptAesGcm(encoder.encode(plaintext), portableKey);
|
||||||
|
|
||||||
|
const wraps: BackupSettingsPortableWrap[] = [];
|
||||||
|
for (const user of eligibleUsers) {
|
||||||
|
const publicKey = await importPortablePublicKey(user.publicKey!);
|
||||||
|
const wrappedKey = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{ name: PORTABLE_ALGORITHM },
|
||||||
|
publicKey,
|
||||||
|
portableDek
|
||||||
|
)
|
||||||
|
);
|
||||||
|
wraps.push({
|
||||||
|
userId: user.id,
|
||||||
|
wrappedKey: bytesToBase64(wrappedKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const envelope: BackupSettingsEnvelopeV2 = {
|
||||||
|
version: 2,
|
||||||
|
runtime: {
|
||||||
|
iv: bytesToBase64(runtime.iv),
|
||||||
|
ciphertext: bytesToBase64(runtime.ciphertext),
|
||||||
|
},
|
||||||
|
portable: {
|
||||||
|
iv: bytesToBase64(portableCipher.iv),
|
||||||
|
ciphertext: bytesToBase64(portableCipher.ciphertext),
|
||||||
|
wraps,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(envelope);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptBackupSettingsRuntime(raw: string, env: Env): Promise<string> {
|
||||||
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
|
if (!envelope) {
|
||||||
|
throw new Error('Backup settings envelope is invalid');
|
||||||
|
}
|
||||||
|
const runtimeKey = await deriveRuntimeKey(env.JWT_SECRET);
|
||||||
|
const plaintext = await decryptAesGcm(
|
||||||
|
base64ToBytes(envelope.runtime.ciphertext),
|
||||||
|
base64ToBytes(envelope.runtime.iv),
|
||||||
|
runtimeKey
|
||||||
|
);
|
||||||
|
return new TextDecoder().decode(plaintext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,640 @@
|
|||||||
|
import {
|
||||||
|
BackupDestinationRecord,
|
||||||
|
BackupDestinationType,
|
||||||
|
E3BackupDestination,
|
||||||
|
PlaceholderBackupDestination,
|
||||||
|
WebDavBackupDestination,
|
||||||
|
} from './backup-config';
|
||||||
|
|
||||||
|
export interface BackupUploadResult {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
remotePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupItem {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
isDirectory: boolean;
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupListResult {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
currentPath: string;
|
||||||
|
parentPath: string | null;
|
||||||
|
items: RemoteBackupItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupFile {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
remotePath: string;
|
||||||
|
fileName: string;
|
||||||
|
contentType: string;
|
||||||
|
bytes: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBackupArchiveName(name: string): boolean {
|
||||||
|
return /\.zip$/i.test(String(name || '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePathSegments(path: string): string {
|
||||||
|
return path
|
||||||
|
.split('/')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => encodeURIComponent(segment))
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimSlashes(value: string): string {
|
||||||
|
return String(value || '').replace(/^\/+|\/+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJoinedPath(...segments: string[]): string {
|
||||||
|
return segments.map(trimSlashes).filter(Boolean).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelativePath(path: string): string {
|
||||||
|
const normalized = trimSlashes(path).replace(/\\/g, '/');
|
||||||
|
if (!normalized) return '';
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
if (parts.some((part) => part === '.' || part === '..')) {
|
||||||
|
throw new Error('Invalid remote backup path');
|
||||||
|
}
|
||||||
|
return parts.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function basename(path: string): string {
|
||||||
|
const normalized = trimSlashes(path);
|
||||||
|
if (!normalized) return '';
|
||||||
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentPath(path: string): string | null {
|
||||||
|
const normalized = normalizeRelativePath(path);
|
||||||
|
if (!normalized) return null;
|
||||||
|
const parts = normalized.split('/');
|
||||||
|
parts.pop();
|
||||||
|
return parts.length ? parts.join('/') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRemoteItems(items: RemoteBackupItem[]): RemoteBackupItem[] {
|
||||||
|
return items.slice().sort((a, b) => {
|
||||||
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||||
|
return a.name.localeCompare(b.name, 'en');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeXmlText(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHttpDate(value: string): string | null {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isFinite(parsed.getTime()) ? parsed.toISOString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractXmlBlocks(xml: string, tagName: string): string[] {
|
||||||
|
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'gi');
|
||||||
|
const blocks: string[] = [];
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = pattern.exec(xml))) {
|
||||||
|
blocks.push(match[1]);
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractXmlFirst(xml: string, tagName: string): string | null {
|
||||||
|
const pattern = new RegExp(`<(?:[^:>]+:)?${tagName}\\b[^>]*>([\\s\\S]*?)</(?:[^:>]+:)?${tagName}>`, 'i');
|
||||||
|
const match = xml.match(pattern);
|
||||||
|
return match?.[1] ? decodeXmlText(match[1].trim()) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(value: Uint8Array | string): Promise<string> {
|
||||||
|
const bytes = typeof value === 'string' ? new TextEncoder().encode(value) : value;
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacSha256Raw(keyBytes: Uint8Array, message: string): Promise<Uint8Array> {
|
||||||
|
const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||||
|
const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(message));
|
||||||
|
return new Uint8Array(signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBasicAuthHeader(username: string, password: string): string {
|
||||||
|
const token = btoa(`${username}:${password}`);
|
||||||
|
return `Basic ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCanonicalQueryString(url: URL): string {
|
||||||
|
const params = Array.from(url.searchParams.entries()).sort(([aKey, aValue], [bKey, bValue]) => {
|
||||||
|
if (aKey === bKey) return aValue.localeCompare(bValue);
|
||||||
|
return aKey.localeCompare(bKey);
|
||||||
|
});
|
||||||
|
return params
|
||||||
|
.map(([key, value]) => `${encodeURIComponent(key).replace(/%20/g, '%20')}=${encodeURIComponent(value).replace(/%20/g, '%20')}`)
|
||||||
|
.join('&');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildAwsV4Authorization(
|
||||||
|
method: string,
|
||||||
|
url: URL,
|
||||||
|
headers: Record<string, string>,
|
||||||
|
payloadHashHex: string,
|
||||||
|
accessKeyId: string,
|
||||||
|
secretAccessKey: string,
|
||||||
|
region: string
|
||||||
|
): Promise<string> {
|
||||||
|
const amzDate = headers['x-amz-date'];
|
||||||
|
const shortDate = amzDate.slice(0, 8);
|
||||||
|
const headerEntries = Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value] as const).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const canonicalHeaders = headerEntries
|
||||||
|
.map(([name, value]) => `${name}:${String(value).trim().replace(/\s+/g, ' ')}`)
|
||||||
|
.join('\n');
|
||||||
|
const signedHeaders = headerEntries.map(([name]) => name).join(';');
|
||||||
|
const canonicalRequest = [
|
||||||
|
method.toUpperCase(),
|
||||||
|
url.pathname || '/',
|
||||||
|
buildCanonicalQueryString(url),
|
||||||
|
`${canonicalHeaders}\n`,
|
||||||
|
signedHeaders,
|
||||||
|
payloadHashHex,
|
||||||
|
].join('\n');
|
||||||
|
const credentialScope = `${shortDate}/${region}/s3/aws4_request`;
|
||||||
|
const stringToSign = [
|
||||||
|
'AWS4-HMAC-SHA256',
|
||||||
|
amzDate,
|
||||||
|
credentialScope,
|
||||||
|
await sha256Hex(canonicalRequest),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const kDate = await hmacSha256Raw(new TextEncoder().encode(`AWS4${secretAccessKey}`), shortDate);
|
||||||
|
const kRegion = await hmacSha256Raw(kDate, region);
|
||||||
|
const kService = await hmacSha256Raw(kRegion, 's3');
|
||||||
|
const kSigning = await hmacSha256Raw(kService, 'aws4_request');
|
||||||
|
const signatureBytes = await hmacSha256Raw(kSigning, stringToSign);
|
||||||
|
const signature = Array.from(signatureBytes).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
return `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDestinationConfigReady(destination: BackupDestinationRecord): void {
|
||||||
|
if (destination.type === 'webdav') {
|
||||||
|
const config = destination.destination as WebDavBackupDestination;
|
||||||
|
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
|
||||||
|
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://');
|
||||||
|
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
|
||||||
|
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (destination.type === 'e3') {
|
||||||
|
const config = destination.destination as E3BackupDestination;
|
||||||
|
if (!String(config.endpoint || '').trim()) throw new Error('E3 endpoint is required');
|
||||||
|
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('E3 endpoint must start with http:// or https://');
|
||||||
|
if (!String(config.bucket || '').trim()) throw new Error('E3 bucket is required');
|
||||||
|
if (!String(config.accessKeyId || '').trim()) throw new Error('E3 access key is required');
|
||||||
|
if (!String(config.secretAccessKey || '')) throw new Error('E3 secret key is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWebDavUrl(baseUrl: string, relativePath: string): string {
|
||||||
|
const trimmedBase = baseUrl.replace(/\/+$/, '');
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
return normalized ? `${trimmedBase}/${encodePathSegments(normalized)}` : trimmedBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
function webDavFullPath(config: WebDavBackupDestination, relativePath: string): string {
|
||||||
|
return buildJoinedPath(config.remotePath, normalizeRelativePath(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, authHeader: string): Promise<void> {
|
||||||
|
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
|
||||||
|
let current = '';
|
||||||
|
for (const segment of segments) {
|
||||||
|
current = buildJoinedPath(current, segment);
|
||||||
|
const url = buildWebDavUrl(baseUrl, current);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'MKCOL',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue;
|
||||||
|
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToWebDav(config: WebDavBackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const remoteFilePath = buildJoinedPath(config.remotePath, fileName);
|
||||||
|
const remoteDir = parentPath(remoteFilePath);
|
||||||
|
|
||||||
|
if (remoteDir) {
|
||||||
|
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Length': String(archive.byteLength),
|
||||||
|
},
|
||||||
|
body: archive,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV upload failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
remotePath: remoteFilePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseWebDavResponsePath(baseUrl: string, href: string): string {
|
||||||
|
const base = new URL(baseUrl);
|
||||||
|
const target = new URL(href, base);
|
||||||
|
const basePath = trimSlashes(decodeURIComponent(base.pathname));
|
||||||
|
const entryPath = trimSlashes(decodeURIComponent(target.pathname));
|
||||||
|
if (!basePath) return entryPath;
|
||||||
|
if (entryPath === basePath) return '';
|
||||||
|
return entryPath.startsWith(`${basePath}/`) ? entryPath.slice(basePath.length + 1) : entryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listWebDavEntries(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
|
const targetFullPath = webDavFullPath(config, currentPath);
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, targetFullPath), {
|
||||||
|
method: 'PROPFIND',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
Depth: '1',
|
||||||
|
'Content-Type': 'application/xml; charset=utf-8',
|
||||||
|
},
|
||||||
|
body: `<?xml version="1.0" encoding="utf-8"?><propfind xmlns="DAV:"><prop><resourcetype/><getcontentlength/><getlastmodified/></prop></propfind>`,
|
||||||
|
});
|
||||||
|
if (response.status === 404) {
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
currentPath,
|
||||||
|
parentPath: parentPath(currentPath),
|
||||||
|
items: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV listing failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
const rootFullPath = trimSlashes(config.remotePath);
|
||||||
|
const items: RemoteBackupItem[] = [];
|
||||||
|
for (const block of extractXmlBlocks(xml, 'response')) {
|
||||||
|
const href = extractXmlFirst(block, 'href');
|
||||||
|
if (!href) continue;
|
||||||
|
const fullPath = trimSlashes(parseWebDavResponsePath(config.baseUrl, href));
|
||||||
|
if (!fullPath) continue;
|
||||||
|
if (fullPath === targetFullPath) continue;
|
||||||
|
if (rootFullPath && !(fullPath === rootFullPath || fullPath.startsWith(`${rootFullPath}/`))) continue;
|
||||||
|
const relative = rootFullPath
|
||||||
|
? fullPath === rootFullPath
|
||||||
|
? ''
|
||||||
|
: fullPath.slice(rootFullPath.length + 1)
|
||||||
|
: fullPath;
|
||||||
|
if (!relative) continue;
|
||||||
|
const directParent = parentPath(relative);
|
||||||
|
if ((directParent || '') !== currentPath) continue;
|
||||||
|
|
||||||
|
const resourceTypeBlock = extractXmlFirst(block, 'resourcetype') || '';
|
||||||
|
const isDirectory = /<(?:[^:>]+:)?collection\b/i.test(resourceTypeBlock);
|
||||||
|
const sizeRaw = extractXmlFirst(block, 'getcontentlength');
|
||||||
|
const modifiedAtRaw = extractXmlFirst(block, 'getlastmodified');
|
||||||
|
items.push({
|
||||||
|
path: relative,
|
||||||
|
name: basename(relative) || relative,
|
||||||
|
isDirectory,
|
||||||
|
size: !isDirectory && sizeRaw && Number.isFinite(Number(sizeRaw)) ? Number(sizeRaw) : null,
|
||||||
|
modifiedAt: modifiedAtRaw ? parseHttpDate(modifiedAtRaw) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
currentPath,
|
||||||
|
parentPath: parentPath(currentPath),
|
||||||
|
items: sortRemoteItems(items),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (!normalized || normalized.endsWith('/')) {
|
||||||
|
throw new Error('Please select a backup file');
|
||||||
|
}
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const remotePath = webDavFullPath(config, normalized);
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`WebDAV download failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
remotePath: normalized,
|
||||||
|
fileName: basename(normalized) || 'backup.zip',
|
||||||
|
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||||
|
bytes: new Uint8Array(await response.arrayBuffer()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: string): Promise<void> {
|
||||||
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
|
const remotePath = webDavFullPath(config, relativePath);
|
||||||
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
throw new Error(`WebDAV delete failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function e3BucketBaseUrl(config: E3BackupDestination): URL {
|
||||||
|
return new URL(`${config.endpoint.replace(/\/+$/, '')}/${encodeURIComponent(config.bucket)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeE3ObjectKey(config: E3BackupDestination, relativePath: string): string {
|
||||||
|
return buildJoinedPath(config.rootPath, normalizeRelativePath(relativePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signedE3Request(
|
||||||
|
config: E3BackupDestination,
|
||||||
|
method: 'GET' | 'PUT' | 'DELETE',
|
||||||
|
url: URL,
|
||||||
|
body?: Uint8Array
|
||||||
|
): Promise<Response> {
|
||||||
|
const payloadHashHex = await sha256Hex(body || new Uint8Array());
|
||||||
|
const amzDate = new Date().toISOString().replace(/[:-]|\.\d{3}/g, '');
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
host: url.host,
|
||||||
|
'x-amz-content-sha256': payloadHashHex,
|
||||||
|
'x-amz-date': amzDate,
|
||||||
|
};
|
||||||
|
if (method === 'PUT') headers['content-type'] = 'application/zip';
|
||||||
|
|
||||||
|
const authorization = await buildAwsV4Authorization(
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
payloadHashHex,
|
||||||
|
config.accessKeyId,
|
||||||
|
config.secretAccessKey,
|
||||||
|
config.region || 'auto'
|
||||||
|
);
|
||||||
|
|
||||||
|
return fetch(url.toString(), {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: authorization,
|
||||||
|
'X-Amz-Content-Sha256': headers['x-amz-content-sha256'],
|
||||||
|
'X-Amz-Date': headers['x-amz-date'],
|
||||||
|
...(method === 'PUT' ? { 'Content-Type': headers['content-type'] } : {}),
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadToE3(config: E3BackupDestination, archive: Uint8Array, fileName: string): Promise<BackupUploadResult> {
|
||||||
|
const objectKey = normalizeE3ObjectKey(config, fileName);
|
||||||
|
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
|
const response = await signedE3Request(config, 'PUT', url, archive);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`E3 upload failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
remotePath: objectKey,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listE3Entries(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
|
const targetPrefixBase = normalizeE3ObjectKey(config, currentPath);
|
||||||
|
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||||
|
const url = e3BucketBaseUrl(config);
|
||||||
|
url.searchParams.set('list-type', '2');
|
||||||
|
url.searchParams.set('delimiter', '/');
|
||||||
|
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||||
|
|
||||||
|
const response = await signedE3Request(config, 'GET', url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`E3 listing failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
const rootPrefix = trimSlashes(config.rootPath);
|
||||||
|
const items: RemoteBackupItem[] = [];
|
||||||
|
|
||||||
|
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
||||||
|
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
||||||
|
if (!fullPrefix) continue;
|
||||||
|
const relative = rootPrefix
|
||||||
|
? fullPrefix === rootPrefix
|
||||||
|
? ''
|
||||||
|
: fullPrefix.startsWith(`${rootPrefix}/`)
|
||||||
|
? fullPrefix.slice(rootPrefix.length + 1)
|
||||||
|
: ''
|
||||||
|
: fullPrefix;
|
||||||
|
const normalizedRelative = trimSlashes(relative);
|
||||||
|
if (!normalizedRelative) continue;
|
||||||
|
const itemPath = normalizedRelative.replace(/\/+$/, '');
|
||||||
|
if ((parentPath(itemPath) || '') !== currentPath) continue;
|
||||||
|
items.push({
|
||||||
|
path: itemPath,
|
||||||
|
name: basename(itemPath) || itemPath,
|
||||||
|
isDirectory: true,
|
||||||
|
size: null,
|
||||||
|
modifiedAt: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const content of extractXmlBlocks(xml, 'Contents')) {
|
||||||
|
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
|
||||||
|
if (!fullKey || (targetPrefix && fullKey === trimSlashes(targetPrefix))) continue;
|
||||||
|
const relative = rootPrefix
|
||||||
|
? fullKey.startsWith(`${rootPrefix}/`)
|
||||||
|
? fullKey.slice(rootPrefix.length + 1)
|
||||||
|
: ''
|
||||||
|
: fullKey;
|
||||||
|
const normalizedRelative = trimSlashes(relative);
|
||||||
|
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
|
||||||
|
items.push({
|
||||||
|
path: normalizedRelative,
|
||||||
|
name: basename(normalizedRelative) || normalizedRelative,
|
||||||
|
isDirectory: false,
|
||||||
|
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
|
||||||
|
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = new Map<string, RemoteBackupItem>();
|
||||||
|
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
currentPath,
|
||||||
|
parentPath: parentPath(currentPath),
|
||||||
|
items: sortRemoteItems(Array.from(deduped.values())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFromE3(config: E3BackupDestination, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (!normalized || normalized.endsWith('/')) {
|
||||||
|
throw new Error('Please select a backup file');
|
||||||
|
}
|
||||||
|
const objectKey = normalizeE3ObjectKey(config, normalized);
|
||||||
|
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
|
const response = await signedE3Request(config, 'GET', url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`E3 download failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
remotePath: normalized,
|
||||||
|
fileName: basename(normalized) || 'backup.zip',
|
||||||
|
contentType: String(response.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip',
|
||||||
|
bytes: new Uint8Array(await response.arrayBuffer()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFromE3(config: E3BackupDestination, relativePath: string): Promise<void> {
|
||||||
|
const objectKey = normalizeE3ObjectKey(config, relativePath);
|
||||||
|
const url = new URL(`${e3BucketBaseUrl(config).toString()}/${encodePathSegments(objectKey)}`);
|
||||||
|
const response = await signedE3Request(config, 'DELETE', url);
|
||||||
|
if (!response.ok && response.status !== 404) {
|
||||||
|
throw new Error(`E3 delete failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSupportedPlaceholder(_config: PlaceholderBackupDestination): never {
|
||||||
|
throw new Error('The reserved backup destination is not available yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfiguredDestinationAdapter {
|
||||||
|
provider: 'webdav' | 'e3';
|
||||||
|
config: WebDavBackupDestination | E3BackupDestination;
|
||||||
|
upload: (config: WebDavBackupDestination | E3BackupDestination, archive: Uint8Array, fileName: string) => Promise<BackupUploadResult>;
|
||||||
|
list: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupListResult>;
|
||||||
|
download: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||||
|
deleteFile: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveConfiguredDestinationAdapter(
|
||||||
|
destination: BackupDestinationRecord
|
||||||
|
): ConfiguredDestinationAdapter {
|
||||||
|
ensureDestinationConfigReady(destination);
|
||||||
|
|
||||||
|
if (destination.type === 'webdav') {
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
config: destination.destination as WebDavBackupDestination,
|
||||||
|
upload: (config, archive, fileName) => uploadToWebDav(config as WebDavBackupDestination, archive, fileName),
|
||||||
|
list: (config, relativePath) => listWebDavEntries(config as WebDavBackupDestination, relativePath),
|
||||||
|
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
|
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (destination.type === 'e3') {
|
||||||
|
return {
|
||||||
|
provider: 'e3',
|
||||||
|
config: destination.destination as E3BackupDestination,
|
||||||
|
upload: (config, archive, fileName) => uploadToE3(config as E3BackupDestination, archive, fileName),
|
||||||
|
list: (config, relativePath) => listE3Entries(config as E3BackupDestination, relativePath),
|
||||||
|
download: (config, relativePath) => downloadFromE3(config as E3BackupDestination, relativePath),
|
||||||
|
deleteFile: (config, relativePath) => deleteFromE3(config as E3BackupDestination, relativePath),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return assertSupportedPlaceholder(destination.destination as PlaceholderBackupDestination);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadBackupArchive(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
archive: Uint8Array,
|
||||||
|
fileName: string
|
||||||
|
): Promise<BackupUploadResult> {
|
||||||
|
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||||
|
return adapter.upload(adapter.config, archive, fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
|
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||||
|
return adapter.list(adapter.config, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
|
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||||
|
return adapter.download(adapter.config, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
|
||||||
|
const normalized = ensureRemoteRestoreCandidate(relativePath);
|
||||||
|
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||||
|
await adapter.deleteFile(adapter.config, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
|
||||||
|
if (preferredFileName) {
|
||||||
|
const aPreferred = a.name === preferredFileName ? 1 : 0;
|
||||||
|
const bPreferred = b.name === preferredFileName ? 1 : 0;
|
||||||
|
if (aPreferred !== bPreferred) return bPreferred - aPreferred;
|
||||||
|
}
|
||||||
|
const aTime = a.modifiedAt ? new Date(a.modifiedAt).getTime() : 0;
|
||||||
|
const bTime = b.modifiedAt ? new Date(b.modifiedAt).getTime() : 0;
|
||||||
|
if (aTime !== bTime) return bTime - aTime;
|
||||||
|
return b.name.localeCompare(a.name, 'en');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pruneRemoteBackupArchives(
|
||||||
|
destination: BackupDestinationRecord,
|
||||||
|
retentionCount: number | null,
|
||||||
|
preferredFileName?: string
|
||||||
|
): Promise<number> {
|
||||||
|
if (retentionCount === null) return 0;
|
||||||
|
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||||
|
const listing = await adapter.list(adapter.config, '');
|
||||||
|
const backupFiles = listing.items
|
||||||
|
.filter((item) => !item.isDirectory && isBackupArchiveName(item.name))
|
||||||
|
.sort((a, b) => compareBackupItemsByRecency(a, b, preferredFileName));
|
||||||
|
if (backupFiles.length <= retentionCount) return 0;
|
||||||
|
for (const item of backupFiles.slice(retentionCount)) {
|
||||||
|
await adapter.deleteFile(adapter.config, item.path);
|
||||||
|
}
|
||||||
|
return backupFiles.length - retentionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureRemoteRestoreCandidate(relativePath: string): string {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (!normalized || !/\.zip$/i.test(normalized)) {
|
||||||
|
throw new Error('Please select a backup ZIP file');
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -213,6 +213,18 @@ export class StorageService {
|
|||||||
return row?.value === 'true';
|
return row?.value === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getConfigValue(key: string): Promise<string | null> {
|
||||||
|
const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind(key).first<{ value: string }>();
|
||||||
|
return typeof row?.value === 'string' ? row.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setConfigValue(key: string, value: string): Promise<void> {
|
||||||
|
await this.db
|
||||||
|
.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
|
.bind(key, value)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
async setRegistered(): Promise<void> {
|
async setRegistered(): Promise<void> {
|
||||||
await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value')
|
||||||
.bind('registered', 'true')
|
.bind('registered', 'true')
|
||||||
|
|||||||
+1
-1
@@ -15,6 +15,6 @@
|
|||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false
|
"noUnusedParameters": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*", "shared/**/*"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
+232
-176
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
|
import { lazy, Suspense } from 'preact/compat';
|
||||||
import { Link, Route, Switch, useLocation } from 'wouter';
|
import { Link, Route, Switch, useLocation } from 'wouter';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
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';
|
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 PublicSendPage from '@/components/PublicSendPage';
|
||||||
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
||||||
import JwtWarningPage from '@/components/JwtWarningPage';
|
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 TotpCodesPage from '@/components/TotpCodesPage';
|
||||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +21,7 @@ import {
|
|||||||
updateFolder,
|
updateFolder,
|
||||||
deleteCipherAttachment,
|
deleteCipherAttachment,
|
||||||
deleteFolder,
|
deleteFolder,
|
||||||
|
deleteRemoteBackup,
|
||||||
bulkDeleteCiphers,
|
bulkDeleteCiphers,
|
||||||
bulkPermanentDeleteCiphers,
|
bulkPermanentDeleteCiphers,
|
||||||
bulkRestoreCiphers,
|
bulkRestoreCiphers,
|
||||||
@@ -35,6 +32,9 @@ import {
|
|||||||
downloadCipherAttachmentDecrypted,
|
downloadCipherAttachmentDecrypted,
|
||||||
encryptFolderImportName,
|
encryptFolderImportName,
|
||||||
exportAdminBackup,
|
exportAdminBackup,
|
||||||
|
getAdminBackupSettingsRepairState,
|
||||||
|
getAdminBackupSettings,
|
||||||
|
downloadRemoteBackup,
|
||||||
importAdminBackup,
|
importAdminBackup,
|
||||||
importCiphers,
|
importCiphers,
|
||||||
createSend,
|
createSend,
|
||||||
@@ -62,14 +62,19 @@ import {
|
|||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
registerAccount,
|
registerAccount,
|
||||||
recoverTwoFactor,
|
recoverTwoFactor,
|
||||||
|
repairAdminBackupSettings,
|
||||||
revokeInvite,
|
revokeInvite,
|
||||||
revokeAuthorizedDeviceTrust,
|
revokeAuthorizedDeviceTrust,
|
||||||
revokeAllAuthorizedDeviceTrust,
|
revokeAllAuthorizedDeviceTrust,
|
||||||
|
restoreRemoteBackup,
|
||||||
|
runAdminBackupNow,
|
||||||
saveSession,
|
saveSession,
|
||||||
|
saveAdminBackupSettings,
|
||||||
setTotp,
|
setTotp,
|
||||||
setUserStatus,
|
setUserStatus,
|
||||||
deleteAllAuthorizedDevices,
|
deleteAllAuthorizedDevices,
|
||||||
deleteAuthorizedDevice,
|
deleteAuthorizedDevice,
|
||||||
|
listRemoteBackups,
|
||||||
uploadCipherAttachment,
|
uploadCipherAttachment,
|
||||||
updateCipher,
|
updateCipher,
|
||||||
updateSend,
|
updateSend,
|
||||||
@@ -78,6 +83,7 @@ import {
|
|||||||
verifyMasterPassword,
|
verifyMasterPassword,
|
||||||
type ImportedCipherMapEntry,
|
type ImportedCipherMapEntry,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
|
import { decryptPortableBackupSettings } from '@/lib/admin-backup-portable';
|
||||||
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto';
|
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto';
|
||||||
import {
|
import {
|
||||||
attachNodeWardenEncryptedAttachmentPayload,
|
attachNodeWardenEncryptedAttachmentPayload,
|
||||||
@@ -96,6 +102,12 @@ import { t } from '@/lib/i18n';
|
|||||||
import type { CiphersImportPayload } from '@/lib/api';
|
import type { CiphersImportPayload } from '@/lib/api';
|
||||||
import type { AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
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 {
|
interface PendingTotp {
|
||||||
email: string;
|
email: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
@@ -136,6 +148,10 @@ function readInviteCodeFromUrl(): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteContentFallback() {
|
||||||
|
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
function summarizeImportResult(
|
function summarizeImportResult(
|
||||||
ciphers: Array<Record<string, unknown>>,
|
ciphers: Array<Record<string, unknown>>,
|
||||||
folderCount: number,
|
folderCount: number,
|
||||||
@@ -435,6 +451,21 @@ export default function App() {
|
|||||||
saveSession(next);
|
saveSession(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function silentlyRepairBackupSettingsIfNeeded(activeSession: SessionState, activeProfile: Profile): Promise<void> {
|
||||||
|
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) {
|
function pushToast(type: ToastMessage['type'], text: string) {
|
||||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
setToasts((prev) => [...prev.slice(-3), { id, type, text }]);
|
setToasts((prev) => [...prev.slice(-3), { id, type, text }]);
|
||||||
@@ -528,6 +559,7 @@ export default function App() {
|
|||||||
const nextSession = { ...baseSession, ...keys };
|
const nextSession = { ...baseSession, ...keys };
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
setProfile(profileResp);
|
setProfile(profileResp);
|
||||||
|
await silentlyRepairBackupSettingsIfNeeded(nextSession, profileResp);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setPhase('app');
|
setPhase('app');
|
||||||
@@ -651,7 +683,9 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations);
|
const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations);
|
||||||
const keys = await unlockVaultKey(profile.key, derived.masterKey);
|
const keys = await unlockVaultKey(profile.key, derived.masterKey);
|
||||||
setSession({ ...session, ...keys });
|
const nextSession = { ...session, ...keys };
|
||||||
|
setSession(nextSession);
|
||||||
|
await silentlyRepairBackupSettingsIfNeeded(nextSession, profile);
|
||||||
setUnlockPassword('');
|
setUnlockPassword('');
|
||||||
setPhase('app');
|
setPhase('app');
|
||||||
if (location === '/' || location === '/lock') navigate('/vault');
|
if (location === '/' || location === '/lock') navigate('/vault');
|
||||||
@@ -1808,6 +1842,38 @@ export default function App() {
|
|||||||
}, 200);
|
}, 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 hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
||||||
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
|
||||||
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
|
||||||
@@ -1841,6 +1907,19 @@ export default function App() {
|
|||||||
return t('nav_my_vault');
|
return t('nav_my_vault');
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
const importPageContent = (
|
||||||
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<ImportPage
|
||||||
|
onImport={handleImportAction}
|
||||||
|
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
||||||
|
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
||||||
|
onNotify={pushToast}
|
||||||
|
folders={decryptedFolders}
|
||||||
|
onExport={handleExportAction}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
||||||
}, [phase, location, isPublicSendRoute, navigate]);
|
}, [phase, location, isPublicSendRoute, navigate]);
|
||||||
@@ -2099,22 +2178,24 @@ export default function App() {
|
|||||||
<div className="mobile-settings-subhead">
|
<div className="mobile-settings-subhead">
|
||||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
{t('txt_back')}
|
{t('txt_back')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SettingsPage
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
profile={profile}
|
<SettingsPage
|
||||||
totpEnabled={!!totpStatusQuery.data?.enabled}
|
profile={profile}
|
||||||
onChangePassword={changePasswordAction}
|
totpEnabled={!!totpStatusQuery.data?.enabled}
|
||||||
onEnableTotp={async (secret, token) => {
|
onChangePassword={changePasswordAction}
|
||||||
await enableTotpAction(secret, token);
|
onEnableTotp={async (secret, token) => {
|
||||||
await totpStatusQuery.refetch();
|
await enableTotpAction(secret, token);
|
||||||
}}
|
await totpStatusQuery.refetch();
|
||||||
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
}}
|
||||||
onGetRecoveryCode={getRecoveryCodeAction}
|
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
||||||
onNotify={pushToast}
|
onGetRecoveryCode={getRecoveryCodeAction}
|
||||||
/>
|
onNotify={pushToast}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Route>
|
</Route>
|
||||||
@@ -2164,55 +2245,57 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<SecurityDevicesPage
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
devices={authorizedDevicesQuery.data || []}
|
<SecurityDevicesPage
|
||||||
loading={authorizedDevicesQuery.isFetching}
|
devices={authorizedDevicesQuery.data || []}
|
||||||
onRefresh={() => void refreshAuthorizedDevices()}
|
loading={authorizedDevicesQuery.isFetching}
|
||||||
onRevokeTrust={(device) => {
|
onRefresh={() => void refreshAuthorizedDevices()}
|
||||||
setConfirm({
|
onRevokeTrust={(device) => {
|
||||||
title: t('txt_revoke_device_authorization'),
|
setConfirm({
|
||||||
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
|
title: t('txt_revoke_device_authorization'),
|
||||||
danger: true,
|
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
|
||||||
onConfirm: () => {
|
danger: true,
|
||||||
setConfirm(null);
|
onConfirm: () => {
|
||||||
void revokeDeviceTrustAction(device);
|
setConfirm(null);
|
||||||
},
|
void revokeDeviceTrustAction(device);
|
||||||
});
|
},
|
||||||
}}
|
});
|
||||||
onRemoveDevice={(device) => {
|
}}
|
||||||
setConfirm({
|
onRemoveDevice={(device) => {
|
||||||
title: t('txt_remove_device'),
|
setConfirm({
|
||||||
message: t('txt_remove_device_and_sign_out_name', { name: device.name }),
|
title: t('txt_remove_device'),
|
||||||
danger: true,
|
message: t('txt_remove_device_and_sign_out_name', { name: device.name }),
|
||||||
onConfirm: () => {
|
danger: true,
|
||||||
setConfirm(null);
|
onConfirm: () => {
|
||||||
void removeDeviceAction(device);
|
setConfirm(null);
|
||||||
},
|
void removeDeviceAction(device);
|
||||||
});
|
},
|
||||||
}}
|
});
|
||||||
onRevokeAll={() => {
|
}}
|
||||||
setConfirm({
|
onRevokeAll={() => {
|
||||||
title: t('txt_revoke_all_trusted_devices'),
|
setConfirm({
|
||||||
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
|
title: t('txt_revoke_all_trusted_devices'),
|
||||||
danger: true,
|
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
|
||||||
onConfirm: () => {
|
danger: true,
|
||||||
setConfirm(null);
|
onConfirm: () => {
|
||||||
void revokeAllDeviceTrustAction();
|
setConfirm(null);
|
||||||
},
|
void revokeAllDeviceTrustAction();
|
||||||
});
|
},
|
||||||
}}
|
});
|
||||||
onRemoveAll={() => {
|
}}
|
||||||
setConfirm({
|
onRemoveAll={() => {
|
||||||
title: t('txt_remove_all_devices'),
|
setConfirm({
|
||||||
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
|
title: t('txt_remove_all_devices'),
|
||||||
danger: true,
|
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
|
||||||
onConfirm: () => {
|
danger: true,
|
||||||
setConfirm(null);
|
onConfirm: () => {
|
||||||
void removeAllDevicesAction();
|
setConfirm(null);
|
||||||
},
|
void removeAllDevicesAction();
|
||||||
});
|
},
|
||||||
}}
|
});
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/admin">
|
<Route path="/admin">
|
||||||
@@ -2225,60 +2308,62 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<AdminPage
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
currentUserId={profile?.id || ''}
|
<AdminPage
|
||||||
users={usersQuery.data || []}
|
currentUserId={profile?.id || ''}
|
||||||
invites={invitesQuery.data || []}
|
users={usersQuery.data || []}
|
||||||
onRefresh={() => {
|
invites={invitesQuery.data || []}
|
||||||
void usersQuery.refetch();
|
onRefresh={() => {
|
||||||
void invitesQuery.refetch();
|
void usersQuery.refetch();
|
||||||
}}
|
void invitesQuery.refetch();
|
||||||
onCreateInvite={async (hours) => {
|
}}
|
||||||
await createInvite(authedFetch, hours);
|
onCreateInvite={async (hours) => {
|
||||||
await invitesQuery.refetch();
|
await createInvite(authedFetch, hours);
|
||||||
pushToast('success', t('txt_invite_created'));
|
await invitesQuery.refetch();
|
||||||
}}
|
pushToast('success', t('txt_invite_created'));
|
||||||
onDeleteAllInvites={async () => {
|
}}
|
||||||
setConfirm({
|
onDeleteAllInvites={async () => {
|
||||||
title: t('txt_delete_all_invites'),
|
setConfirm({
|
||||||
message: t('txt_delete_all_invite_codes_active_inactive'),
|
title: t('txt_delete_all_invites'),
|
||||||
danger: true,
|
message: t('txt_delete_all_invite_codes_active_inactive'),
|
||||||
onConfirm: () => {
|
danger: true,
|
||||||
setConfirm(null);
|
onConfirm: () => {
|
||||||
void (async () => {
|
setConfirm(null);
|
||||||
await deleteAllInvites(authedFetch);
|
void (async () => {
|
||||||
await invitesQuery.refetch();
|
await deleteAllInvites(authedFetch);
|
||||||
pushToast('success', t('txt_all_invites_deleted'));
|
await invitesQuery.refetch();
|
||||||
})();
|
pushToast('success', t('txt_all_invites_deleted'));
|
||||||
},
|
})();
|
||||||
});
|
},
|
||||||
}}
|
});
|
||||||
onToggleUserStatus={async (userId, status) => {
|
}}
|
||||||
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
onToggleUserStatus={async (userId, status) => {
|
||||||
await usersQuery.refetch();
|
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
|
||||||
pushToast('success', t('txt_user_status_updated'));
|
await usersQuery.refetch();
|
||||||
}}
|
pushToast('success', t('txt_user_status_updated'));
|
||||||
onDeleteUser={async (userId) => {
|
}}
|
||||||
setConfirm({
|
onDeleteUser={async (userId) => {
|
||||||
title: t('txt_delete_user'),
|
setConfirm({
|
||||||
message: t('txt_delete_this_user_and_all_user_data'),
|
title: t('txt_delete_user'),
|
||||||
danger: true,
|
message: t('txt_delete_this_user_and_all_user_data'),
|
||||||
onConfirm: () => {
|
danger: true,
|
||||||
setConfirm(null);
|
onConfirm: () => {
|
||||||
void (async () => {
|
setConfirm(null);
|
||||||
await deleteUser(authedFetch, userId);
|
void (async () => {
|
||||||
await usersQuery.refetch();
|
await deleteUser(authedFetch, userId);
|
||||||
pushToast('success', t('txt_user_deleted'));
|
await usersQuery.refetch();
|
||||||
})();
|
pushToast('success', t('txt_user_deleted'));
|
||||||
},
|
})();
|
||||||
});
|
},
|
||||||
}}
|
});
|
||||||
onRevokeInvite={async (code) => {
|
}}
|
||||||
await revokeInvite(authedFetch, code);
|
onRevokeInvite={async (code) => {
|
||||||
await invitesQuery.refetch();
|
await revokeInvite(authedFetch, code);
|
||||||
pushToast('success', t('txt_invite_revoked'));
|
await invitesQuery.refetch();
|
||||||
}}
|
pushToast('success', t('txt_invite_revoked'));
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path={IMPORT_ROUTE}>
|
<Route path={IMPORT_ROUTE}>
|
||||||
@@ -2291,65 +2376,23 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<ImportPage
|
{importPageContent}
|
||||||
onImport={handleImportAction}
|
|
||||||
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
||||||
onNotify={pushToast}
|
|
||||||
folders={decryptedFolders}
|
|
||||||
onExport={handleExportAction}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/tools/import">
|
<Route path="/tools/import">
|
||||||
<ImportPage
|
{importPageContent}
|
||||||
onImport={handleImportAction}
|
|
||||||
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
||||||
onNotify={pushToast}
|
|
||||||
folders={decryptedFolders}
|
|
||||||
onExport={handleExportAction}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/tools/import-export">
|
<Route path="/tools/import-export">
|
||||||
<ImportPage
|
{importPageContent}
|
||||||
onImport={handleImportAction}
|
|
||||||
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
||||||
onNotify={pushToast}
|
|
||||||
folders={decryptedFolders}
|
|
||||||
onExport={handleExportAction}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/tools/import-data">
|
<Route path="/tools/import-data">
|
||||||
<ImportPage
|
{importPageContent}
|
||||||
onImport={handleImportAction}
|
|
||||||
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
||||||
onNotify={pushToast}
|
|
||||||
folders={decryptedFolders}
|
|
||||||
onExport={handleExportAction}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/import">
|
<Route path="/import">
|
||||||
<ImportPage
|
{importPageContent}
|
||||||
onImport={handleImportAction}
|
|
||||||
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
||||||
onNotify={pushToast}
|
|
||||||
folders={decryptedFolders}
|
|
||||||
onExport={handleExportAction}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/import-export">
|
<Route path="/import-export">
|
||||||
<ImportPage
|
{importPageContent}
|
||||||
onImport={handleImportAction}
|
|
||||||
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
|
||||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
|
||||||
onNotify={pushToast}
|
|
||||||
folders={decryptedFolders}
|
|
||||||
onExport={handleExportAction}
|
|
||||||
/>
|
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/help">
|
<Route path="/help">
|
||||||
{profile?.role === 'admin' ? (
|
{profile?.role === 'admin' ? (
|
||||||
@@ -2358,11 +2401,24 @@ export default function App() {
|
|||||||
<div className="mobile-settings-subhead">
|
<div className="mobile-settings-subhead">
|
||||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||||
{t('txt_back')}
|
{t('txt_back')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<HelpPage onExport={handleBackupExportAction} onImport={handleBackupImportAction} onNotify={pushToast} />
|
<Suspense fallback={<RouteContentFallback />}>
|
||||||
|
<BackupCenterPage
|
||||||
|
onExport={handleBackupExportAction}
|
||||||
|
onImport={handleBackupImportAction}
|
||||||
|
onLoadSettings={handleLoadBackupSettingsAction}
|
||||||
|
onListRemoteBackups={handleListRemoteBackupsAction}
|
||||||
|
onDownloadRemoteBackup={handleDownloadRemoteBackupAction}
|
||||||
|
onDeleteRemoteBackup={handleDeleteRemoteBackupAction}
|
||||||
|
onRestoreRemoteBackup={handleRestoreRemoteBackupAction}
|
||||||
|
onSaveSettings={handleSaveBackupSettingsAction}
|
||||||
|
onRunRemoteBackup={handleRunRemoteBackupAction}
|
||||||
|
onNotify={pushToast}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
onImport: (file: File, replaceExisting?: boolean) => Promise<void>;
|
||||||
|
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||||
|
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
|
onDownloadRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
|
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>;
|
||||||
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||||
|
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState());
|
||||||
|
const persistedRemoteState = persistedRemoteStateRef.current;
|
||||||
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(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<AdminBackupSettings | null>(null);
|
||||||
|
const [settings, setSettings] = useState<AdminBackupSettings>(createDraftBackupSettings);
|
||||||
|
const [selectedDestinationId, setSelectedDestinationId] = useState<string | null>(persistedRemoteState.selectedDestinationId);
|
||||||
|
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(null);
|
||||||
|
const [remoteBrowserCache, setRemoteBrowserCache] = useState<Record<string, RemoteBackupBrowserResponse>>(persistedRemoteState.cache);
|
||||||
|
const [remoteBrowserPathByDestination, setRemoteBrowserPathByDestination] = useState<Record<string, string>>(persistedRemoteState.pathByDestination);
|
||||||
|
const [remoteBrowserPageByKey, setRemoteBrowserPageByKey] = useState<Record<string, number>>(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<void> {
|
||||||
|
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 (
|
||||||
|
<div className="backup-grid">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
hidden
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
disabled={disableWhileBusy}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
||||||
|
setSelectedFile(nextFile);
|
||||||
|
setLocalError('');
|
||||||
|
if (nextFile) setConfirmLocalRestoreOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BackupOperationsSidebar
|
||||||
|
disableWhileBusy={disableWhileBusy}
|
||||||
|
exporting={exporting}
|
||||||
|
importing={importing}
|
||||||
|
selectedProviderId={selectedProviderId}
|
||||||
|
recommendedWebDavProviders={recommendedWebDavProviders}
|
||||||
|
recommendedS3Providers={recommendedS3Providers}
|
||||||
|
onExport={() => void handleExport()}
|
||||||
|
onImport={() => fileInputRef.current?.click()}
|
||||||
|
onSelectProvider={(providerId) => setSelectedProviderId(providerId)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BackupDestinationSidebar
|
||||||
|
destinations={visibleDestinations}
|
||||||
|
selectedDestinationId={selectedDestinationId}
|
||||||
|
disableWhileBusy={disableWhileBusy}
|
||||||
|
showAddChooser={showAddChooser}
|
||||||
|
onSelectDestination={(destinationId) => {
|
||||||
|
setSelectedProviderId(null);
|
||||||
|
setSelectedDestinationId(destinationId);
|
||||||
|
}}
|
||||||
|
onToggleAddChooser={() => setShowAddChooser((current) => !current)}
|
||||||
|
onAddDestination={handleAddDestination}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BackupDestinationDetail
|
||||||
|
selectedRecommendedProvider={selectedRecommendedProvider}
|
||||||
|
selectedDestination={selectedDestination}
|
||||||
|
selectedDestinationIsSaved={selectedDestinationIsSaved}
|
||||||
|
canRunSelectedDestination={canRunSelectedDestination}
|
||||||
|
canBrowseSelectedDestination={canBrowseSelectedDestination}
|
||||||
|
disableWhileBusy={disableWhileBusy}
|
||||||
|
loadingSettings={loadingSettings}
|
||||||
|
savingSettings={savingSettings}
|
||||||
|
runningRemoteBackup={runningRemoteBackup}
|
||||||
|
availableTimeZones={selectedDestination?.schedule.timezone ? [selectedDestination.schedule.timezone] : []}
|
||||||
|
remoteBrowser={remoteBrowser}
|
||||||
|
remoteBrowserVisibleItems={remoteBrowserVisibleItems}
|
||||||
|
remoteBrowserCurrentPage={currentRemoteBrowserPage}
|
||||||
|
remoteBrowserTotalPages={remoteBrowserTotalPages}
|
||||||
|
loadingRemoteBrowser={loadingRemoteBrowser}
|
||||||
|
downloadingRemotePath={downloadingRemotePath}
|
||||||
|
restoringRemotePath={restoringRemotePath}
|
||||||
|
deletingRemotePath={deletingRemotePath}
|
||||||
|
onSaveSettings={() => 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 ? <div className="local-error">{localError}</div> : null}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmLocalRestoreOpen}
|
||||||
|
title={t('txt_backup_import')}
|
||||||
|
message={selectedFile ? t('txt_backup_selected_file_name', { name: selectedFile.name }) : t('txt_backup_restore_note')}
|
||||||
|
confirmText={t('txt_backup_import')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={() => void runLocalRestore(false)}
|
||||||
|
onCancel={() => {
|
||||||
|
setConfirmLocalRestoreOpen(false);
|
||||||
|
resetSelectedFile();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmReplaceOpen}
|
||||||
|
title={t('txt_backup_replace_confirm_title')}
|
||||||
|
message={t('txt_backup_replace_confirm_message')}
|
||||||
|
confirmText={t('txt_backup_clear_and_restore')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={() => void runLocalRestore(true)}
|
||||||
|
onCancel={() => {
|
||||||
|
setConfirmReplaceOpen(false);
|
||||||
|
resetSelectedFile();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmRemoteReplaceOpen}
|
||||||
|
title={t('txt_backup_replace_confirm_title')}
|
||||||
|
message={t('txt_backup_replace_confirm_message')}
|
||||||
|
confirmText={t('txt_backup_clear_and_restore')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={() => void runRemoteRestore(pendingRemoteRestorePath, true)}
|
||||||
|
onCancel={() => {
|
||||||
|
setConfirmRemoteReplaceOpen(false);
|
||||||
|
setPendingRemoteRestorePath('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmRemoteDeleteOpen}
|
||||||
|
title={t('txt_delete')}
|
||||||
|
message={t('txt_backup_remote_delete_confirm_message', { name: pendingRemoteDeletePath.split('/').pop() || pendingRemoteDeletePath })}
|
||||||
|
confirmText={t('txt_delete')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={() => void handleDeleteRemote(pendingRemoteDeletePath)}
|
||||||
|
onCancel={() => {
|
||||||
|
if (deletingRemotePath) return;
|
||||||
|
setConfirmRemoteDeleteOpen(false);
|
||||||
|
setPendingRemoteDeletePath('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDeleteDestinationOpen}
|
||||||
|
title={t('txt_delete')}
|
||||||
|
message={t('txt_backup_delete_destination_confirm_message', {
|
||||||
|
name: selectedDestination?.name || t('txt_backup_delete_destination'),
|
||||||
|
})}
|
||||||
|
confirmText={t('txt_delete')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={() => void handleDeleteDestination()}
|
||||||
|
onCancel={() => {
|
||||||
|
if (savingSettings) return;
|
||||||
|
setConfirmDeleteDestinationOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<void>;
|
|
||||||
onImport: (file: File, replaceExisting?: boolean) => Promise<void>;
|
|
||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HelpPage(props: HelpPageProps) {
|
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(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 (
|
|
||||||
<div className="stack backup-page">
|
|
||||||
<div className="import-export-panels">
|
|
||||||
<section className="card backup-panel">
|
|
||||||
<div className="section-head">
|
|
||||||
<h3>{t('txt_backup_export')}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="backup-inline-note">{t('txt_backup_export_description')}</p>
|
|
||||||
<div className="actions">
|
|
||||||
<button type="button" className="btn btn-primary" disabled={exporting || importing} onClick={() => void handleExport()}>
|
|
||||||
<Download size={14} className="btn-icon" />
|
|
||||||
{exporting ? t('txt_backup_exporting') : t('txt_backup_export')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card backup-panel">
|
|
||||||
<div className="section-head">
|
|
||||||
<h3>{t('txt_backup_import')}</h3>
|
|
||||||
</div>
|
|
||||||
<p className="backup-inline-note">{t('txt_backup_import_description')}</p>
|
|
||||||
<label className="field">
|
|
||||||
<span>{t('txt_backup_file')}</span>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
className="input"
|
|
||||||
type="file"
|
|
||||||
accept=".zip,application/zip"
|
|
||||||
disabled={importing || exporting}
|
|
||||||
onChange={(event) => {
|
|
||||||
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
|
||||||
setSelectedFile(nextFile);
|
|
||||||
setLocalError('');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="backup-file-meta">
|
|
||||||
{selectedFile ? (
|
|
||||||
<span>{t('txt_backup_selected_file_name', { name: selectedFile.name })}</span>
|
|
||||||
) : (
|
|
||||||
<span>{t('txt_backup_no_file_selected')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="backup-inline-note">{t('txt_backup_restore_note')}</p>
|
|
||||||
<div className="actions">
|
|
||||||
<button type="button" className="btn btn-primary" disabled={importing || exporting} onClick={() => void handleImport()}>
|
|
||||||
<FileUp size={14} className="btn-icon" />
|
|
||||||
{importing ? t('txt_backup_importing') : t('txt_backup_import')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{localError && <div className="local-error">{localError}</div>}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={confirmReplaceOpen}
|
|
||||||
title={t('txt_backup_replace_confirm_title')}
|
|
||||||
message={t('txt_backup_replace_confirm_message')}
|
|
||||||
confirmText={t('txt_backup_clear_and_import')}
|
|
||||||
cancelText={t('txt_cancel')}
|
|
||||||
danger
|
|
||||||
onConfirm={() => void runImport(true)}
|
|
||||||
onCancel={() => setConfirmReplaceOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="backup-recommendation-steps">
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
|
||||||
|
<a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
|
||||||
|
{t('txt_backup_recommend_koofr_step_2_suffix')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_3')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_4')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>5.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
|
||||||
|
<a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
|
||||||
|
{t('txt_backup_recommend_koofr_step_5_suffix')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-inline-note">{t('txt_backup_recommend_koofr_dav_intro')}</div>
|
||||||
|
<div className="backup-recommendation-dav-list">
|
||||||
|
<div className="backup-recommendation-dav-item">
|
||||||
|
<strong>{t('txt_backup_recommend_koofr_dav_self')}</strong>
|
||||||
|
<code>https://app.koofr.net/dav/Koofr</code>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-dav-item">
|
||||||
|
<strong>Google Drive</strong>
|
||||||
|
<code>https://app.koofr.net/dav/Google Drive</code>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-dav-item">
|
||||||
|
<strong>OneDrive</strong>
|
||||||
|
<code>https://app.koofr.net/dav/OneDrive</code>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-dav-item">
|
||||||
|
<strong>Dropbox</strong>
|
||||||
|
<code>https://app.koofr.net/dav/Dropbox</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case 'pcloud':
|
||||||
|
return (
|
||||||
|
<div className="backup-recommendation-steps">
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_1')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'infinicloud':
|
||||||
|
return (
|
||||||
|
<div className="backup-recommendation-steps">
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
|
||||||
|
<a href="https://infini-cloud.net/en/modules/mypage/usage/" target="_blank" rel="noreferrer">My Page</a>
|
||||||
|
{t('txt_backup_recommend_infinicloud_step_2_suffix')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>4.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
||||||
|
const timeZones = Array.from(new Set([
|
||||||
|
...COMMON_TIME_ZONES,
|
||||||
|
...props.availableTimeZones,
|
||||||
|
]));
|
||||||
|
|
||||||
|
if (props.selectedRecommendedProvider) {
|
||||||
|
return (
|
||||||
|
<section className="backup-detail-panel">
|
||||||
|
<div className="backup-recommendation-card">
|
||||||
|
<div className="backup-recommendation-header">
|
||||||
|
<div>
|
||||||
|
<strong>{props.selectedRecommendedProvider.name}</strong>
|
||||||
|
<div className="backup-inline-note">
|
||||||
|
{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')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="backup-destination-type">{props.selectedRecommendedProvider.capacity}</span>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-actions">
|
||||||
|
<a className="btn btn-primary small" href={props.selectedRecommendedProvider.signupUrl} target="_blank" rel="noreferrer">
|
||||||
|
{props.selectedRecommendedProvider.hasAffiliateLink ? t('txt_backup_recommend_open_signup_aff') : t('txt_backup_recommend_open_signup')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{renderRecommendedProviderDetails(props.selectedRecommendedProvider)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="backup-detail-panel">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_backup_destination_detail_title')}</h3>
|
||||||
|
{props.selectedDestination ? (
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-primary small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onSaveSettings}>
|
||||||
|
<Save size={14} className="btn-icon" />
|
||||||
|
{props.savingSettings ? t('txt_backup_saving') : t('txt_backup_save_settings')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onToggleSchedule}>
|
||||||
|
{props.selectedDestination.schedule.enabled ? t('txt_backup_disable_action') : t('txt_backup_enable_action')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || !props.canRunSelectedDestination} onClick={props.onRunRemoteBackup}>
|
||||||
|
<CloudUpload size={14} className="btn-icon" />
|
||||||
|
{props.runningRemoteBackup ? t('txt_backup_running_now') : t('txt_backup_run_manual')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={props.loadingSettings || props.disableWhileBusy} onClick={props.onPromptDeleteDestination}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_delete_destination')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!props.selectedDestination ? (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_select_destination')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="backup-name-row">
|
||||||
|
<label className="field backup-name-field">
|
||||||
|
<span>{t('txt_backup_destination_name')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={props.selectedDestination.name}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({ ...destination, name: (event.currentTarget as HTMLInputElement).value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field backup-type-field">
|
||||||
|
<span>{t('txt_backup_type')}</span>
|
||||||
|
<input className="input" value={getDestinationTypeLabel(props.selectedDestination.type)} disabled />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field-grid backup-detail-schedule-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_frequency')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={props.selectedDestination.schedule.frequency}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
frequency: (event.currentTarget as HTMLSelectElement).value as 'daily' | 'weekly' | 'monthly',
|
||||||
|
dayOfWeek: destination.schedule.dayOfWeek ?? 1,
|
||||||
|
dayOfMonth: destination.schedule.dayOfMonth ?? 1,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<option value="daily">{t('txt_backup_frequency_daily')}</option>
|
||||||
|
<option value="weekly">{t('txt_backup_frequency_weekly')}</option>
|
||||||
|
<option value="monthly">{t('txt_backup_frequency_monthly')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_time')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="time"
|
||||||
|
value={props.selectedDestination.schedule.scheduleTime}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
scheduleTime: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_timezone')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={props.selectedDestination.schedule.timezone}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
timezone: (event.currentTarget as HTMLSelectElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{timeZones.map((timezone) => (
|
||||||
|
<option key={timezone} value={timezone}>{timezone}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_retention_count')}</span>
|
||||||
|
<div className="backup-retention-input">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
value={props.selectedDestination.schedule.retentionCount === null ? '' : String(props.selectedDestination.schedule.retentionCount)}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="30"
|
||||||
|
onInput={(event) => {
|
||||||
|
const nextValue = (event.currentTarget as HTMLInputElement).value.trim();
|
||||||
|
props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
retentionCount: nextValue ? Number(nextValue) : null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="backup-retention-suffix">{t('txt_backup_retention_count_suffix')}</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.selectedDestination.schedule.frequency === 'weekly' ? (
|
||||||
|
<div className="field-grid backup-detail-schedule-extra-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_day_of_week')}</span>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={String(props.selectedDestination.schedule.dayOfWeek)}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onChange={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
dayOfWeek: Number((event.currentTarget as HTMLSelectElement).value),
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
{WEEKDAY_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={String(option.value)}>{t(option.label)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{props.selectedDestination.schedule.frequency === 'monthly' ? (
|
||||||
|
<div className="field-grid backup-detail-schedule-extra-grid">
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_day_of_month')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="31"
|
||||||
|
step="1"
|
||||||
|
value={String(props.selectedDestination.schedule.dayOfMonth || 1)}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
schedule: {
|
||||||
|
...destination.schedule,
|
||||||
|
dayOfMonth: Math.min(31, Math.max(1, Number((event.currentTarget as HTMLInputElement).value) || 1)),
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{props.selectedDestination.type === 'webdav' ? (
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_backup_webdav_url')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as WebDavBackupDestination).baseUrl}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="https://dav.example.com/remote.php/dav/files/admin"
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as WebDavBackupDestination),
|
||||||
|
baseUrl: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_webdav_username')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as WebDavBackupDestination).username}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as WebDavBackupDestination),
|
||||||
|
username: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_webdav_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={(props.selectedDestination.destination as WebDavBackupDestination).password}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as WebDavBackupDestination),
|
||||||
|
password: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_backup_webdav_path')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="nodewarden/backups"
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as WebDavBackupDestination),
|
||||||
|
remotePath: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{props.selectedDestination.type === 'e3' ? (
|
||||||
|
<div className="field-grid">
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_backup_e3_endpoint')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).endpoint}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="https://s3.example.com"
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
endpoint: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_e3_bucket')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).bucket}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
bucket: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_e3_region')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).region}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="auto"
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
region: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_e3_access_key')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).accessKeyId}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
accessKeyId: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_backup_e3_secret_key')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).secretAccessKey}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
secretAccessKey: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="field field-span-2">
|
||||||
|
<span>{t('txt_backup_e3_path')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={(props.selectedDestination.destination as E3BackupDestination).rootPath}
|
||||||
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
|
placeholder="nodewarden/backups"
|
||||||
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
|
...destination,
|
||||||
|
destination: {
|
||||||
|
...(destination.destination as E3BackupDestination),
|
||||||
|
rootPath: (event.currentTarget as HTMLInputElement).value,
|
||||||
|
},
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<RemoteBackupBrowser
|
||||||
|
canBrowse={props.canBrowseSelectedDestination}
|
||||||
|
destinationIsSaved={props.selectedDestinationIsSaved}
|
||||||
|
disableWhileBusy={props.disableWhileBusy}
|
||||||
|
loadingRemoteBrowser={props.loadingRemoteBrowser}
|
||||||
|
remoteBrowser={props.remoteBrowser}
|
||||||
|
visibleItems={props.remoteBrowserVisibleItems}
|
||||||
|
currentPage={props.remoteBrowserCurrentPage}
|
||||||
|
totalPages={props.remoteBrowserTotalPages}
|
||||||
|
downloadingRemotePath={props.downloadingRemotePath}
|
||||||
|
restoringRemotePath={props.restoringRemotePath}
|
||||||
|
deletingRemotePath={props.deletingRemotePath}
|
||||||
|
onRefresh={props.onRefreshRemoteBrowser}
|
||||||
|
onShowPath={props.onShowRemoteBrowserPath}
|
||||||
|
onDownload={props.onDownloadRemoteBackup}
|
||||||
|
onRestore={props.onRestoreRemoteBackup}
|
||||||
|
onPromptDelete={props.onPromptDeleteRemoteBackup}
|
||||||
|
onChangePage={props.onChangeRemoteBrowserPage}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<aside className="backup-destination-sidebar">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_backup_destinations_title')}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="backup-destination-list">
|
||||||
|
{props.destinations.map((destination) => {
|
||||||
|
const isSelected = destination.id === props.selectedDestinationId;
|
||||||
|
const isScheduled = destination.schedule.enabled;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={destination.id}
|
||||||
|
type="button"
|
||||||
|
className={`backup-destination-item ${isSelected ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onSelectDestination(destination.id)}
|
||||||
|
>
|
||||||
|
<span className="backup-destination-top">
|
||||||
|
<span className="backup-destination-name">{destination.name || getDestinationTypeLabel(destination.type)}</span>
|
||||||
|
<span className="backup-destination-type">{getDestinationTypeLabel(destination.type)}</span>
|
||||||
|
</span>
|
||||||
|
<span className="backup-destination-meta">
|
||||||
|
{isScheduled ? t('txt_backup_destination_active_badge') : t('txt_backup_destination_idle_badge')}
|
||||||
|
</span>
|
||||||
|
<span className="backup-destination-meta">
|
||||||
|
{destination.runtime.lastSuccessAt
|
||||||
|
? t('txt_backup_destination_last_success', { time: formatDateTime(destination.runtime.lastSuccessAt) })
|
||||||
|
: t('txt_backup_destination_never_run')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions backup-destination-addbar">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy} onClick={props.onToggleAddChooser}>
|
||||||
|
<Plus size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_add_destination')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.showAddChooser ? (
|
||||||
|
<div className="backup-add-chooser">
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('webdav')}>
|
||||||
|
{t('txt_backup_protocol_webdav')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onAddDestination('e3')}>
|
||||||
|
{t('txt_backup_protocol_e3')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<aside className="backup-operations-sidebar">
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_backup_manual')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="backup-actions-stack">
|
||||||
|
<button type="button" className="btn btn-primary" disabled={props.disableWhileBusy} onClick={props.onExport}>
|
||||||
|
<Download size={14} className="btn-icon" />
|
||||||
|
{props.exporting ? t('txt_backup_exporting') : t('txt_backup_export')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={props.disableWhileBusy} onClick={props.onImport}>
|
||||||
|
<FileUp size={14} className="btn-icon" />
|
||||||
|
{props.importing ? t('txt_backup_restoring') : t('txt_backup_import')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="backup-divider" />
|
||||||
|
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_backup_recommend_title')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-group">
|
||||||
|
<h4 className="backup-recommendation-group-title">{t('txt_backup_recommend_group_webdav')}</h4>
|
||||||
|
<div className="backup-recommendation-list">
|
||||||
|
{props.recommendedWebDavProviders.map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
type="button"
|
||||||
|
className={`backup-destination-item ${props.selectedProviderId === provider.id ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onSelectProvider(provider.id)}
|
||||||
|
>
|
||||||
|
<span className="backup-recommendation-row">
|
||||||
|
<span className="backup-destination-name">{provider.name}</span>
|
||||||
|
<span className="backup-destination-meta">{provider.capacity}</span>
|
||||||
|
</span>
|
||||||
|
{hasLinkedStorages(provider) && provider.linkedStorages.length ? (
|
||||||
|
<span className="backup-recommendation-linked">
|
||||||
|
{provider.linkedStorages.map((storage) => (
|
||||||
|
<span key={`${provider.id}-${storage.name}`} className="backup-recommendation-linked-item">
|
||||||
|
<span>{storage.name}</span>
|
||||||
|
<span>{storage.capacity}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-group">
|
||||||
|
<h4 className="backup-recommendation-group-title">{t('txt_backup_recommend_group_s3')}</h4>
|
||||||
|
{props.recommendedS3Providers.length ? (
|
||||||
|
<div className="backup-recommendation-list">
|
||||||
|
{props.recommendedS3Providers.map((provider) => (
|
||||||
|
<button
|
||||||
|
key={provider.id}
|
||||||
|
type="button"
|
||||||
|
className={`backup-destination-item ${props.selectedProviderId === provider.id ? 'active' : ''}`}
|
||||||
|
onClick={() => props.onSelectProvider(provider.id)}
|
||||||
|
>
|
||||||
|
<span className="backup-recommendation-row">
|
||||||
|
<span className="backup-destination-name">{provider.name}</span>
|
||||||
|
<span className="backup-destination-meta">{provider.capacity}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_recommend_empty')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="backup-divider" />
|
||||||
|
|
||||||
|
<div className="section-head">
|
||||||
|
<h3>{t('txt_backup_remote_title')}</h3>
|
||||||
|
{props.canBrowse ? (
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_remote_refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!props.destinationIsSaved ? (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_remote_save_first')}</div>
|
||||||
|
) : !props.remoteBrowser ? (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_remote_cached_empty')}</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="backup-browser-path">
|
||||||
|
<strong>{t('txt_backup_remote_current_path')}</strong>
|
||||||
|
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="actions backup-browser-nav">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
|
||||||
|
<FolderOpen size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_remote_root')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
|
||||||
|
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_remote_up')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{props.loadingRemoteBrowser ? (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
|
||||||
|
) : props.remoteBrowser.items.length ? (
|
||||||
|
<>
|
||||||
|
<div className="backup-browser-list">
|
||||||
|
{props.visibleItems.map((item) => (
|
||||||
|
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`backup-browser-entry ${item.isDirectory ? 'dir' : 'file'}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (item.isDirectory) props.onShowPath(item.path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
|
||||||
|
<span className="backup-browser-name">{item.name}</span>
|
||||||
|
</button>
|
||||||
|
<div className="backup-browser-meta">
|
||||||
|
<span>{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}</span>
|
||||||
|
<span>{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="actions backup-browser-actions">
|
||||||
|
{item.isDirectory ? (
|
||||||
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
|
||||||
|
<FolderOpen size={14} className="btn-icon" />
|
||||||
|
{t('txt_backup_remote_open')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.disableWhileBusy || props.downloadingRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onDownload(item.path)}>
|
||||||
|
<Download size={14} className="btn-icon" />
|
||||||
|
{props.downloadingRemotePath === item.path ? t('txt_backup_remote_downloading') : t('txt_backup_remote_download')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-primary small" disabled={props.disableWhileBusy || props.restoringRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onRestore(item.path)}>
|
||||||
|
<RotateCcw size={14} className="btn-icon" />
|
||||||
|
{props.restoringRemotePath === item.path ? t('txt_backup_restoring') : t('txt_backup_remote_restore')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" disabled={props.disableWhileBusy || props.deletingRemotePath === item.path || !isZipCandidate(item)} onClick={() => props.onPromptDelete(item.path)}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{props.deletingRemotePath === item.path ? t('txt_backup_remote_deleting') : t('txt_delete')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{props.totalPages > 1 ? (
|
||||||
|
<div className="backup-browser-pagination">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.currentPage <= 1} onClick={() => props.onChangePage(props.currentPage - 1)}>
|
||||||
|
{t('txt_prev')}
|
||||||
|
</button>
|
||||||
|
<span className="backup-browser-page-indicator">
|
||||||
|
{props.currentPage} / {props.totalPages}
|
||||||
|
</span>
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.currentPage >= props.totalPages} onClick={() => props.onChangePage(props.currentPage + 1)}>
|
||||||
|
{t('txt_next')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="backup-browser-empty">{t('txt_backup_remote_empty')}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<CryptoKey> {
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
'pkcs8',
|
||||||
|
pkcs8,
|
||||||
|
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||||
|
false,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.importKey('raw', keyBytes, { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptPortableBackupSettings(
|
||||||
|
portable: BackupSettingsPortablePayload,
|
||||||
|
profile: Profile,
|
||||||
|
session: SessionState
|
||||||
|
): Promise<AdminBackupSettings> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
+198
-5
@@ -18,6 +18,13 @@ import type {
|
|||||||
VaultDraftField,
|
VaultDraftField,
|
||||||
WebConfigResponse,
|
WebConfigResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import type {
|
||||||
|
BackupDestinationRecord,
|
||||||
|
BackupDestinationType,
|
||||||
|
BackupSettings as AdminBackupSettings,
|
||||||
|
E3BackupDestination,
|
||||||
|
WebDavBackupDestination,
|
||||||
|
} from '@shared/backup';
|
||||||
|
|
||||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
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');
|
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 {
|
export interface AdminBackupImportCounts {
|
||||||
config: number;
|
config: number;
|
||||||
users: number;
|
users: number;
|
||||||
@@ -959,21 +1024,149 @@ export async function exportAdminBackup(
|
|||||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
|
||||||
): Promise<AdminBackupExportPayload> {
|
): Promise<AdminBackupExportPayload> {
|
||||||
const resp = await authedFetch('/api/admin/backup/export', { method: 'POST' });
|
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 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());
|
const bytes = new Uint8Array(await resp.arrayBuffer());
|
||||||
return { fileName, mimeType, bytes };
|
return { fileName, mimeType, bytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAdminBackupSettings(
|
||||||
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
|
||||||
|
): Promise<AdminBackupSettings> {
|
||||||
|
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<AdminBackupSettings>(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<Response>,
|
||||||
|
settings: AdminBackupSettings
|
||||||
|
): Promise<AdminBackupSettings> {
|
||||||
|
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<AdminBackupSettings>(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<Response>
|
||||||
|
): Promise<BackupSettingsRepairStateResponse> {
|
||||||
|
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<BackupSettingsRepairStateResponse>(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<Response>,
|
||||||
|
settings: AdminBackupSettings
|
||||||
|
): Promise<AdminBackupSettings> {
|
||||||
|
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<AdminBackupSettings>(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<Response>,
|
||||||
|
destinationId?: string | null
|
||||||
|
): Promise<AdminBackupRunResponse> {
|
||||||
|
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<AdminBackupRunResponse>(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<Response>,
|
||||||
|
destinationId: string,
|
||||||
|
path: string = ''
|
||||||
|
): Promise<RemoteBackupBrowserResponse> {
|
||||||
|
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<RemoteBackupBrowserResponse>(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<Response>,
|
||||||
|
destinationId: string,
|
||||||
|
path: string
|
||||||
|
): Promise<AdminBackupExportPayload> {
|
||||||
|
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<Response>,
|
||||||
|
destinationId: string,
|
||||||
|
path: string
|
||||||
|
): Promise<void> {
|
||||||
|
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<Response>,
|
||||||
|
destinationId: string,
|
||||||
|
path: string,
|
||||||
|
replaceExisting: boolean = false
|
||||||
|
): Promise<AdminBackupImportResponse> {
|
||||||
|
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<AdminBackupImportResponse>(resp);
|
||||||
|
if (!body?.imported) throw new Error(t('txt_backup_remote_restore_invalid_response'));
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
export async function importAdminBackup(
|
export async function importAdminBackup(
|
||||||
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
|
||||||
file: File,
|
file: File,
|
||||||
replaceExisting: boolean = false
|
replaceExisting: boolean = false
|
||||||
): Promise<AdminBackupImportResponse> {
|
): Promise<AdminBackupImportResponse> {
|
||||||
const formData = new FormData();
|
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) {
|
if (replaceExisting) {
|
||||||
formData.set('replaceExisting', '1');
|
formData.set('replaceExisting', '1');
|
||||||
}
|
}
|
||||||
@@ -982,10 +1175,10 @@ export async function importAdminBackup(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData,
|
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<AdminBackupImportResponse>(resp);
|
const body = await parseJson<AdminBackupImportResponse>(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;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<string, RemoteBackupBrowserResponse>;
|
||||||
|
pathByDestination: Record<string, string>;
|
||||||
|
pageByKey: Record<string, number>;
|
||||||
|
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<PersistedRemoteBrowserState>;
|
||||||
|
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<string, RemoteBackupBrowserResponse>,
|
||||||
|
pathByDestination: Record<string, string>,
|
||||||
|
pageByKey: Record<string, number>
|
||||||
|
): 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');
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
+330
-18
@@ -9,29 +9,185 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
nav_device_management: "Device Management",
|
nav_device_management: "Device Management",
|
||||||
nav_my_vault: "My Vault",
|
nav_my_vault: "My Vault",
|
||||||
nav_sends: "Sends",
|
nav_sends: "Sends",
|
||||||
nav_backup_strategy: "Backup Strategy",
|
nav_backup_strategy: "Backup Center",
|
||||||
nav_import_export: "Import & Export",
|
nav_import_export: "Import & Export",
|
||||||
backup_strategy_title: "Backup Strategy",
|
backup_strategy_title: "Backup Center",
|
||||||
backup_strategy_under_construction: "Under construction.",
|
backup_strategy_under_construction: "Under construction.",
|
||||||
import_export_title: "Import & Export",
|
import_export_title: "Import & Export",
|
||||||
import_export_under_construction: "Under construction.",
|
import_export_under_construction: "Under construction.",
|
||||||
txt_backup_export: "Backup Export",
|
txt_backup_export: "Export Backup",
|
||||||
txt_backup_import: "Backup Import",
|
txt_backup_import: "Restore",
|
||||||
txt_backup_export_description: "Download a full instance backup ZIP for manual safekeeping.",
|
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_exporting: "Exporting...",
|
||||||
txt_backup_importing: "Importing...",
|
txt_backup_importing: "Restoring...",
|
||||||
|
txt_backup_restoring: "Restoring...",
|
||||||
txt_backup_export_success: "Backup exported",
|
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_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: "Backup File",
|
||||||
txt_backup_file_required: "Please select a backup file",
|
txt_backup_file_required: "Please select a backup file",
|
||||||
txt_backup_no_file_selected: "No backup file selected",
|
txt_backup_no_file_selected: "No backup file selected",
|
||||||
txt_backup_selected_file_name: "Selected file: {name}",
|
txt_backup_selected_file_name: "Selected file: {name}",
|
||||||
txt_backup_replace_confirm_title: "Replace Current Instance Data",
|
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_import: "Clear and Import",
|
||||||
|
txt_backup_clear_and_restore: "Clear and Restore",
|
||||||
txt_access_count: "Access Count",
|
txt_access_count: "Access Count",
|
||||||
txt_accessed_count_times: "Accessed {count} times",
|
txt_accessed_count_times: "Accessed {count} times",
|
||||||
txt_actions: "Actions",
|
txt_actions: "Actions",
|
||||||
@@ -425,29 +581,185 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
nav_admin_panel: '用户管理',
|
nav_admin_panel: '用户管理',
|
||||||
nav_account_settings: '账户设置',
|
nav_account_settings: '账户设置',
|
||||||
nav_device_management: '设备管理',
|
nav_device_management: '设备管理',
|
||||||
nav_backup_strategy: '备份策略',
|
nav_backup_strategy: '备份中心',
|
||||||
nav_import_export: '导入导出',
|
nav_import_export: '导入导出',
|
||||||
backup_strategy_title: '备份策略',
|
backup_strategy_title: '备份中心',
|
||||||
backup_strategy_under_construction: '正在搭建中',
|
backup_strategy_under_construction: '正在搭建中',
|
||||||
import_export_title: '导入导出',
|
import_export_title: '导入导出',
|
||||||
import_export_under_construction: '正在搭建中',
|
import_export_under_construction: '正在搭建中',
|
||||||
txt_backup_export: '备份导出',
|
txt_backup_export: '导出备份',
|
||||||
txt_backup_import: '备份导入',
|
txt_backup_import: '还原',
|
||||||
txt_backup_export_description: '下载一个完整的实例备份 ZIP,手动保管即可。',
|
txt_backup_export_description: '下载一个完整的实例备份 ZIP,手动保管即可。',
|
||||||
txt_backup_import_description: '上传之前导出的备份 ZIP,并恢复到全新实例空壳。',
|
txt_backup_import_description: '上传之前导出的备份 ZIP,并还原到当前实例。',
|
||||||
txt_backup_exporting: '正在导出...',
|
txt_backup_exporting: '正在导出...',
|
||||||
txt_backup_importing: '正在导入...',
|
txt_backup_importing: '正在还原...',
|
||||||
|
txt_backup_restoring: '正在还原...',
|
||||||
txt_backup_export_success: '备份已导出',
|
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_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: '备份文件',
|
||||||
txt_backup_file_required: '请选择备份文件',
|
txt_backup_file_required: '请选择备份文件',
|
||||||
txt_backup_no_file_selected: '尚未选择备份文件',
|
txt_backup_no_file_selected: '尚未选择备份文件',
|
||||||
txt_backup_selected_file_name: '已选择文件:{name}',
|
txt_backup_selected_file_name: '已选择文件:{name}',
|
||||||
txt_backup_replace_confirm_title: '替换当前实例数据',
|
txt_backup_replace_confirm_title: '替换当前实例数据',
|
||||||
txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再导入新的备份吗?',
|
txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再还原所选备份吗?',
|
||||||
txt_backup_clear_and_import: '清空后导入',
|
txt_backup_clear_and_import: '清空后导入',
|
||||||
|
txt_backup_clear_and_restore: '清空后还原',
|
||||||
txt_sign_out: '退出登录',
|
txt_sign_out: '退出登录',
|
||||||
txt_log_in: '登录',
|
txt_log_in: '登录',
|
||||||
txt_log_out: '退出',
|
txt_log_out: '退出',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export interface Profile {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
key: string;
|
key: string;
|
||||||
|
privateKey?: string | null;
|
||||||
|
publicKey?: string | null;
|
||||||
role: 'admin' | 'user';
|
role: 'admin' | 'user';
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|||||||
+439
-8
@@ -311,6 +311,7 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
@@ -1341,8 +1342,401 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-panel {
|
.backup-grid {
|
||||||
min-height: 100%;
|
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 {
|
.backup-list {
|
||||||
@@ -1921,7 +2315,10 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.import-export-feature-grid,
|
.import-export-feature-grid,
|
||||||
.import-export-panels {
|
.import-export-panels,
|
||||||
|
.backup-combined-grid,
|
||||||
|
.backup-status-grid,
|
||||||
|
.backup-browser-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2386,6 +2783,8 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.import-export-panels,
|
.import-export-panels,
|
||||||
|
.backup-combined-grid,
|
||||||
|
.backup-status-grid,
|
||||||
.settings-twofactor-grid {
|
.settings-twofactor-grid {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
@@ -2541,10 +2940,42 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast-stack {
|
.toast-stack {
|
||||||
top: 10px;
|
top: 10px;
|
||||||
left: 10px;
|
left: 10px;
|
||||||
right: 10px;
|
right: 10px;
|
||||||
width: auto;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
"jsxImportSource": "preact",
|
"jsxImportSource": "preact",
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"]
|
"@/*": ["src/*"],
|
||||||
|
"@shared/*": ["../shared/*"]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
@@ -18,5 +19,5 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*", "vite.config.ts"]
|
"include": ["src/**/*", "../shared/**/*", "vite.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(rootDir, 'src'),
|
'@': path.resolve(rootDir, 'src'),
|
||||||
|
'@shared': path.resolve(rootDir, '../shared'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
@@ -30,6 +31,9 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
fs: {
|
||||||
|
allow: [path.resolve(rootDir, '..')],
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://127.0.0.1:8787',
|
'/api': 'http://127.0.0.1:8787',
|
||||||
'/identity': 'http://127.0.0.1:8787',
|
'/identity': 'http://127.0.0.1:8787',
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ run_worker_first = [ "/api/*", "/identity/*", "/icons/*", "/setup/*", "/config",
|
|||||||
[build]
|
[build]
|
||||||
command = "npm run build"
|
command = "npm run build"
|
||||||
|
|
||||||
|
[triggers]
|
||||||
|
crons = [ "*/5 * * * *" ]
|
||||||
|
|
||||||
[[d1_databases]]
|
[[d1_databases]]
|
||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "nodewarden-db"
|
database_name = "nodewarden-db"
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ run_worker_first = [ "/api/*", "/identity/*", "/icons/*", "/setup/*", "/config",
|
|||||||
[build]
|
[build]
|
||||||
command = "npm run build"
|
command = "npm run build"
|
||||||
|
|
||||||
|
[triggers]
|
||||||
|
crons = [ "*/5 * * * *" ]
|
||||||
|
|
||||||
[[d1_databases]]
|
[[d1_databases]]
|
||||||
binding = "DB"
|
binding = "DB"
|
||||||
database_name = "nodewarden-db"
|
database_name = "nodewarden-db"
|
||||||
|
|||||||
Reference in New Issue
Block a user