mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 14:50:11 +00:00
Compare commits
10
Commits
v1.7.1
..
31dcc76ee2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31dcc76ee2 | ||
|
|
bf6ac7b405 | ||
|
|
1bfb9a647d | ||
|
|
e9272ec29a | ||
|
|
8942e5bd49 | ||
|
|
d722815999 | ||
|
|
ff85698edb | ||
|
|
c3dc53bac1 | ||
|
|
1acc31eda0 | ||
|
|
c694f1bfce |
@@ -9,7 +9,8 @@
|
|||||||
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
|
||||||
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||||
export const BACKUP_DEFAULT_S3_REGION = 'auto';
|
export const BACKUP_DEFAULT_S3_REGION = 'auto';
|
||||||
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
export const BACKUP_DEFAULT_S3_ROOT_PATH = '';
|
||||||
|
export const BACKUP_DEFAULT_WEBDAV_REMOTE_PATH = 'nodewarden';
|
||||||
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
|
||||||
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
export const BACKUP_DEFAULT_START_TIME = '03:00';
|
||||||
|
|
||||||
@@ -109,14 +110,14 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
|||||||
region: BACKUP_DEFAULT_S3_REGION,
|
region: BACKUP_DEFAULT_S3_REGION,
|
||||||
accessKeyId: '',
|
accessKeyId: '',
|
||||||
secretAccessKey: '',
|
secretAccessKey: '',
|
||||||
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
baseUrl: '',
|
baseUrl: '',
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
remotePath: BACKUP_DEFAULT_REMOTE_PATH,
|
remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,7 @@ export class BackupTransferRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let completed = 0;
|
let completed = 0;
|
||||||
|
const failures: Array<{ destinationId: string; error: string }> = [];
|
||||||
try {
|
try {
|
||||||
await this.touchJob(token);
|
await this.touchJob(token);
|
||||||
const storage = new StorageService(this.env.DB);
|
const storage = new StorageService(this.env.DB);
|
||||||
@@ -230,21 +231,30 @@ export class BackupTransferRunner {
|
|||||||
scanStartMs = now.getTime();
|
scanStartMs = now.getTime();
|
||||||
for (const destination of dueDestinations) {
|
for (const destination of dueDestinations) {
|
||||||
await this.touchJob(token);
|
await this.touchJob(token);
|
||||||
await executeConfiguredBackup(
|
try {
|
||||||
this.env,
|
await executeConfiguredBackup(
|
||||||
storage,
|
this.env,
|
||||||
null,
|
storage,
|
||||||
'scheduled',
|
null,
|
||||||
destination.id,
|
'scheduled',
|
||||||
() => this.touchJob(token)
|
destination.id,
|
||||||
);
|
() => this.touchJob(token)
|
||||||
completed += 1;
|
);
|
||||||
|
completed += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failures.push({
|
||||||
|
destinationId: destination.id,
|
||||||
|
error: error instanceof Error ? error.message : 'Scheduled backup failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
ok: true,
|
ok: true,
|
||||||
completed,
|
completed,
|
||||||
|
failed: failures.length,
|
||||||
|
failures,
|
||||||
}), {
|
}), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -318,7 +328,8 @@ export class BackupTransferRunner {
|
|||||||
replaceExisting,
|
replaceExisting,
|
||||||
!checksumOk,
|
!checksumOk,
|
||||||
body.auditMetadata || null,
|
body.auditMetadata || null,
|
||||||
targetDeviceIdentifier
|
targetDeviceIdentifier,
|
||||||
|
() => this.touchJob(token)
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(JSON.stringify(result.result), {
|
return new Response(JSON.stringify(result.result), {
|
||||||
|
|||||||
+61
-26
@@ -21,6 +21,7 @@ import {
|
|||||||
repairBackupSettings,
|
repairBackupSettings,
|
||||||
requireBackupDestination,
|
requireBackupDestination,
|
||||||
saveBackupSettings,
|
saveBackupSettings,
|
||||||
|
updateBackupDestinationRuntime,
|
||||||
} from '../services/backup-config';
|
} from '../services/backup-config';
|
||||||
import {
|
import {
|
||||||
type BackupImportExecutionResult,
|
type BackupImportExecutionResult,
|
||||||
@@ -260,6 +261,30 @@ async function uploadRemoteAttachmentChunk(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function verifyUploadedBackupArchive(
|
||||||
|
session: RemoteBackupTransferSession,
|
||||||
|
archive: BackupArchiveBundle
|
||||||
|
): Promise<'metadata' | 'download'> {
|
||||||
|
try {
|
||||||
|
const stat = await session.stat(archive.fileName);
|
||||||
|
if (stat?.size === archive.bytes.byteLength) {
|
||||||
|
return 'metadata';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall through to a full read-back verification when lightweight metadata is unavailable.
|
||||||
|
}
|
||||||
|
|
||||||
|
const remoteFile = await session.download(archive.fileName);
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
|
||||||
|
if (!checksumOk) {
|
||||||
|
throw new Error('Remote backup ZIP checksum verification failed');
|
||||||
|
}
|
||||||
|
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
|
||||||
|
throw new Error('Remote backup ZIP size verification failed');
|
||||||
|
}
|
||||||
|
return 'download';
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeConfiguredBackup(
|
export async function executeConfiguredBackup(
|
||||||
env: Env,
|
env: Env,
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
@@ -287,12 +312,14 @@ export async function executeConfiguredBackup(
|
|||||||
const destination = requireBackupDestination(currentSettings, destinationId);
|
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
destination.runtime.lastAttemptAt = now.toISOString();
|
|
||||||
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
|
|
||||||
destination.runtime.lastErrorAt = null;
|
|
||||||
destination.runtime.lastErrorMessage = null;
|
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
|
||||||
|
...runtime,
|
||||||
|
lastAttemptAt: now.toISOString(),
|
||||||
|
lastAttemptLocalDate: getBackupLocalDateKey(now, destination.schedule.timezone),
|
||||||
|
lastErrorAt: null,
|
||||||
|
lastErrorMessage: null,
|
||||||
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await touchLease();
|
await touchLease();
|
||||||
@@ -354,6 +381,7 @@ export async function executeConfiguredBackup(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||||
|
let uploadVerificationMethod: 'metadata' | 'download' | null = null;
|
||||||
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await progress?.({
|
await progress?.({
|
||||||
@@ -373,14 +401,7 @@ export async function executeConfiguredBackup(
|
|||||||
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
||||||
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
||||||
});
|
});
|
||||||
const remoteFile = await remoteSession.download(archive.fileName);
|
uploadVerificationMethod = await verifyUploadedBackupArchive(remoteSession, archive);
|
||||||
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
|
|
||||||
if (!checksumOk) {
|
|
||||||
throw new Error('Remote backup ZIP checksum verification failed');
|
|
||||||
}
|
|
||||||
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
|
|
||||||
throw new Error('Remote backup ZIP size verification failed');
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
||||||
@@ -409,14 +430,16 @@ export async function executeConfiguredBackup(
|
|||||||
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
destination.runtime.lastSuccessAt = new Date().toISOString();
|
|
||||||
destination.runtime.lastErrorAt = null;
|
|
||||||
destination.runtime.lastErrorMessage = null;
|
|
||||||
destination.runtime.lastUploadedFileName = archive.fileName;
|
|
||||||
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
|
|
||||||
destination.runtime.lastUploadedDestination = upload.remotePath;
|
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
|
||||||
|
...runtime,
|
||||||
|
lastSuccessAt: new Date().toISOString(),
|
||||||
|
lastErrorAt: null,
|
||||||
|
lastErrorMessage: null,
|
||||||
|
lastUploadedFileName: archive.fileName,
|
||||||
|
lastUploadedSizeBytes: archive.bytes.byteLength,
|
||||||
|
lastUploadedDestination: upload.remotePath,
|
||||||
|
}));
|
||||||
|
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
|
||||||
@@ -426,6 +449,7 @@ export async function executeConfiguredBackup(
|
|||||||
fileName: archive.fileName,
|
fileName: archive.fileName,
|
||||||
fileBytes: archive.bytes.byteLength,
|
fileBytes: archive.bytes.byteLength,
|
||||||
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
||||||
|
uploadVerificationMethod,
|
||||||
prunedFileCount,
|
prunedFileCount,
|
||||||
pruneError: pruneErrorMessage,
|
pruneError: pruneErrorMessage,
|
||||||
...(auditMetadata || {}),
|
...(auditMetadata || {}),
|
||||||
@@ -448,15 +472,18 @@ export async function executeConfiguredBackup(
|
|||||||
provider: upload.provider,
|
provider: upload.provider,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
destination.runtime.lastErrorAt = new Date().toISOString();
|
const errorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
||||||
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
|
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await saveBackupSettings(storage, env, currentSettings);
|
destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
|
||||||
|
...runtime,
|
||||||
|
lastErrorAt: new Date().toISOString(),
|
||||||
|
lastErrorMessage: errorMessage,
|
||||||
|
}));
|
||||||
|
|
||||||
await touchLease();
|
await touchLease();
|
||||||
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
|
||||||
...getBackupDestinationSummary(destination),
|
...getBackupDestinationSummary(destination),
|
||||||
error: destination.runtime.lastErrorMessage,
|
error: errorMessage,
|
||||||
...(auditMetadata || {}),
|
...(auditMetadata || {}),
|
||||||
});
|
});
|
||||||
await progress?.({
|
await progress?.({
|
||||||
@@ -467,7 +494,7 @@ export async function executeConfiguredBackup(
|
|||||||
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
||||||
done: true,
|
done: true,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: destination.runtime.lastErrorMessage,
|
error: errorMessage,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -655,12 +682,18 @@ export async function importAndAuditRemoteBackupFile(
|
|||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
checksumMismatchAccepted: boolean,
|
checksumMismatchAccepted: boolean,
|
||||||
auditMetadata: Record<string, unknown> | null = null,
|
auditMetadata: Record<string, unknown> | null = null,
|
||||||
targetDeviceIdentifier: string | null = null
|
targetDeviceIdentifier: string | null = null,
|
||||||
|
keepAlive?: (() => Promise<void>) | null
|
||||||
): Promise<BackupImportExecutionResult> {
|
): Promise<BackupImportExecutionResult> {
|
||||||
|
const touchLease = async () => {
|
||||||
|
await keepAlive?.();
|
||||||
|
};
|
||||||
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
|
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
|
||||||
|
await touchLease();
|
||||||
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
|
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
|
||||||
const externalAttachmentCache = new Map<string, Uint8Array | null>();
|
const externalAttachmentCache = new Map<string, Uint8Array | null>();
|
||||||
const progress: BackupRestoreProgressReporter = async (event) => {
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||||
|
await touchLease();
|
||||||
await notifyUserBackupRestoreProgress(
|
await notifyUserBackupRestoreProgress(
|
||||||
env,
|
env,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
@@ -678,6 +711,7 @@ export async function importAndAuditRemoteBackupFile(
|
|||||||
replaceExisting,
|
replaceExisting,
|
||||||
{
|
{
|
||||||
loadAttachment: async (blobName) => {
|
loadAttachment: async (blobName) => {
|
||||||
|
await touchLease();
|
||||||
const normalized = String(blobName || '').trim();
|
const normalized = String(blobName || '').trim();
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
if (externalAttachmentCache.has(normalized)) {
|
if (externalAttachmentCache.has(normalized)) {
|
||||||
@@ -700,6 +734,7 @@ export async function importAndAuditRemoteBackupFile(
|
|||||||
} catch {
|
} catch {
|
||||||
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
|
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
|
||||||
}
|
}
|
||||||
|
await touchLease();
|
||||||
return externalAttachmentCache.get(normalized) || null;
|
return externalAttachmentCache.get(normalized) || null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ function buildConfigResponse(origin: string) {
|
|||||||
'email-verification': true,
|
'email-verification': true,
|
||||||
'pm-19051-send-email-verification': false,
|
'pm-19051-send-email-verification': false,
|
||||||
'pm-19148-innovation-archive': true,
|
'pm-19148-innovation-archive': true,
|
||||||
|
'pm-30529-webauthn-related-origins': true,
|
||||||
'unauth-ui-refresh': true,
|
'unauth-ui-refresh': true,
|
||||||
'web-push': false,
|
'web-push': false,
|
||||||
},
|
},
|
||||||
@@ -469,7 +470,7 @@ export async function handlePublicRoute(
|
|||||||
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const origin = new URL(request.url).origin;
|
const origin = new URL(request.url).origin;
|
||||||
return jsonResponse(buildConfigResponse(origin));
|
return jsonResponse(buildConfigResponse(origin), 200, { 'Cache-Control': 'no-store' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/version' && method === 'GET') {
|
if (path === '/api/version' && method === 'GET') {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '../../shared/backup-schema';
|
} from '../../shared/backup-schema';
|
||||||
|
|
||||||
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
||||||
|
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
|
||||||
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||||
const MAX_BACKUP_DESTINATIONS = 24;
|
const MAX_BACKUP_DESTINATIONS = 24;
|
||||||
|
|
||||||
@@ -324,6 +325,47 @@ function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<strin
|
|||||||
return new Map(destinations.map((destination) => [destination.id, destination]));
|
return new Map(destinations.map((destination) => [destination.id, destination]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripRuntimeFromSettings(settings: BackupSettings): BackupSettings {
|
||||||
|
return {
|
||||||
|
destinations: settings.destinations.map((destination) => ({
|
||||||
|
...destination,
|
||||||
|
runtime: normalizeRuntime(null),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeRuntimeState(settings: BackupSettings): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
destinations: Object.fromEntries(
|
||||||
|
settings.destinations.map((destination) => [destination.id, normalizeRuntime(destination.runtime)])
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBackupRuntimeStates(storage: StorageService): Promise<Map<string, BackupRuntimeState>> {
|
||||||
|
const raw = await storage.getConfigValue(BACKUP_RUNTIME_CONFIG_KEY);
|
||||||
|
if (!raw) return new Map();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { destinations?: Record<string, unknown> };
|
||||||
|
const entries = Object.entries(parsed.destinations || {})
|
||||||
|
.filter(([id]) => !!asTrimmedString(id))
|
||||||
|
.map(([id, runtime]) => [id, normalizeRuntime(runtime)] as const);
|
||||||
|
return new Map(entries);
|
||||||
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeRuntimeStates(settings: BackupSettings, runtimes: Map<string, BackupRuntimeState>): BackupSettings {
|
||||||
|
return {
|
||||||
|
destinations: settings.destinations.map((destination) => ({
|
||||||
|
...destination,
|
||||||
|
runtime: runtimes.get(destination.id) || normalizeRuntime(destination.runtime),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
|
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
|
||||||
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
|
||||||
}
|
}
|
||||||
@@ -387,27 +429,30 @@ export function normalizeBackupSettingsInput(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function serializeBackupSettings(settings: BackupSettings): string {
|
export function serializeBackupSettings(settings: BackupSettings): string {
|
||||||
return JSON.stringify(settings);
|
return JSON.stringify(stripRuntimeFromSettings(settings));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
|
||||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
|
||||||
|
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
|
||||||
|
);
|
||||||
if (!raw) {
|
if (!raw) {
|
||||||
const settings = getDefaultBackupSettings(fallbackTimezone);
|
const settings = getDefaultBackupSettings(fallbackTimezone);
|
||||||
await saveBackupSettings(storage, env, settings);
|
await saveBackupSettings(storage, env, settings);
|
||||||
return settings;
|
return mergeRuntime(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
const envelope = parseBackupSettingsEnvelope(raw);
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
if (!envelope) {
|
if (!envelope) {
|
||||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
await saveBackupSettings(storage, env, settings);
|
await saveBackupSettings(storage, env, settings);
|
||||||
return settings;
|
return mergeRuntime(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
return parseBackupSettings(decrypted, fallbackTimezone);
|
return mergeRuntime(parseBackupSettings(decrypted, fallbackTimezone));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Backup settings need administrator reactivation after restore');
|
throw new Error('Backup settings need administrator reactivation after restore');
|
||||||
}
|
}
|
||||||
@@ -417,6 +462,27 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
|
|||||||
const users = await storage.getAllUsers();
|
const users = await storage.getAllUsers();
|
||||||
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
|
||||||
|
await saveBackupRuntimeStates(storage, settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveBackupRuntimeStates(storage: StorageService, settings: BackupSettings): Promise<void> {
|
||||||
|
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, serializeRuntimeState(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBackupDestinationRuntime(
|
||||||
|
storage: StorageService,
|
||||||
|
destinationId: string,
|
||||||
|
mutator: (runtime: BackupRuntimeState) => BackupRuntimeState
|
||||||
|
): Promise<BackupRuntimeState> {
|
||||||
|
const runtimes = await loadBackupRuntimeStates(storage);
|
||||||
|
const current = runtimes.get(destinationId) || normalizeRuntime(null);
|
||||||
|
const next = normalizeRuntime(mutator(current));
|
||||||
|
runtimes.set(destinationId, next);
|
||||||
|
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
destinations: Object.fromEntries(runtimes.entries()),
|
||||||
|
}));
|
||||||
|
return next;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
|
||||||
@@ -596,9 +662,9 @@ export function hasBackupSlotBetween(
|
|||||||
const endMs = endExclusive.getTime();
|
const endMs = endExclusive.getTime();
|
||||||
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
|
||||||
|
|
||||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||||
? lastAttemptAt.getTime()
|
? lastSuccessAt.getTime()
|
||||||
: Number.NEGATIVE_INFINITY;
|
: Number.NEGATIVE_INFINITY;
|
||||||
|
|
||||||
const dayCursor = new Date(startMs);
|
const dayCursor = new Date(startMs);
|
||||||
@@ -620,7 +686,7 @@ export function hasBackupSlotBetween(
|
|||||||
for (const slotStart of slotStarts) {
|
for (const slotStart of slotStarts) {
|
||||||
const slotStartMs = slotStart.getTime();
|
const slotStartMs = slotStart.getTime();
|
||||||
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
if (slotStartMs < startMs || slotStartMs >= endMs) continue;
|
||||||
if (lastAttemptMs >= slotStartMs) continue;
|
if (lastSuccessMs >= slotStartMs) continue;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,9 +703,9 @@ export function isBackupDueNow(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!destination.schedule.enabled) return false;
|
if (!destination.schedule.enabled) return false;
|
||||||
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
|
||||||
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null;
|
const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
|
||||||
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime())
|
const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
|
||||||
? lastAttemptAt.getTime()
|
? lastSuccessAt.getTime()
|
||||||
: Number.NEGATIVE_INFINITY;
|
: Number.NEGATIVE_INFINITY;
|
||||||
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
|
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
|
||||||
const slotStarts = getBackupSlotStartsForLocalDay(
|
const slotStarts = getBackupSlotStartsForLocalDay(
|
||||||
@@ -652,7 +718,7 @@ export function isBackupDueNow(
|
|||||||
for (const slotStart of slotStarts) {
|
for (const slotStart of slotStarts) {
|
||||||
const slotStartMs = slotStart.getTime();
|
const slotStartMs = slotStart.getTime();
|
||||||
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
|
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
|
||||||
if (lastAttemptMs >= slotStartMs) return false;
|
if (lastSuccessMs >= slotStartMs) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ export interface RemoteBackupFile {
|
|||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupFileStat {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
remotePath: string;
|
||||||
|
size: number | null;
|
||||||
|
modifiedAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RemoteBackupFilePutOptions {
|
export interface RemoteBackupFilePutOptions {
|
||||||
contentType?: string;
|
contentType?: string;
|
||||||
}
|
}
|
||||||
@@ -433,6 +440,10 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
|
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
|
||||||
|
return (await statWebDavFile(config, relativePath)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
|
||||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
const remotePath = webDavFullPath(config, relativePath);
|
const remotePath = webDavFullPath(config, relativePath);
|
||||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
|
||||||
@@ -441,11 +452,17 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
|
|||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (response.status === 404) return false;
|
if (response.status === 404) return null;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`WebDAV existence check failed: ${response.status}`);
|
throw new Error(`WebDAV existence check failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
return true;
|
const size = Number(response.headers.get('Content-Length') || '');
|
||||||
|
return {
|
||||||
|
provider: 'webdav',
|
||||||
|
remotePath: normalizeRelativePath(relativePath),
|
||||||
|
size: Number.isFinite(size) ? size : null,
|
||||||
|
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
|
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
|
||||||
@@ -540,61 +557,68 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
|
|||||||
const currentPath = normalizeRelativePath(relativePath);
|
const currentPath = normalizeRelativePath(relativePath);
|
||||||
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
|
||||||
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
|
||||||
const url = s3BucketBaseUrl(config);
|
|
||||||
url.searchParams.set('list-type', '2');
|
|
||||||
url.searchParams.set('delimiter', '/');
|
|
||||||
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
|
||||||
|
|
||||||
const response = await signedS3Request(config, 'GET', url);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`S3 listing failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const xml = await response.text();
|
|
||||||
const rootPrefix = trimSlashes(config.rootPath);
|
const rootPrefix = trimSlashes(config.rootPath);
|
||||||
const items: RemoteBackupItem[] = [];
|
const items: RemoteBackupItem[] = [];
|
||||||
|
let continuationToken = '';
|
||||||
|
|
||||||
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
|
do {
|
||||||
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
|
const url = s3BucketBaseUrl(config);
|
||||||
if (!fullPrefix) continue;
|
url.searchParams.set('list-type', '2');
|
||||||
const relative = rootPrefix
|
url.searchParams.set('delimiter', '/');
|
||||||
? fullPrefix === rootPrefix
|
if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
|
||||||
? ''
|
if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
|
||||||
: fullPrefix.startsWith(`${rootPrefix}/`)
|
|
||||||
? fullPrefix.slice(rootPrefix.length + 1)
|
const response = await signedS3Request(config, 'GET', url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`S3 listing failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const xml = await response.text();
|
||||||
|
|
||||||
|
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)
|
||||||
: ''
|
: ''
|
||||||
: fullPrefix;
|
: fullKey;
|
||||||
const normalizedRelative = trimSlashes(relative);
|
const normalizedRelative = trimSlashes(relative);
|
||||||
if (!normalizedRelative) continue;
|
if (!normalizedRelative || (parentPath(normalizedRelative) || '') !== currentPath) continue;
|
||||||
const itemPath = normalizedRelative.replace(/\/+$/, '');
|
items.push({
|
||||||
if ((parentPath(itemPath) || '') !== currentPath) continue;
|
path: normalizedRelative,
|
||||||
items.push({
|
name: basename(normalizedRelative) || normalizedRelative,
|
||||||
path: itemPath,
|
isDirectory: false,
|
||||||
name: basename(itemPath) || itemPath,
|
size: Number(extractXmlFirst(content, 'Size') || 0) || null,
|
||||||
isDirectory: true,
|
modifiedAt: parseHttpDate(extractXmlFirst(content, 'LastModified') || '') || null,
|
||||||
size: null,
|
});
|
||||||
modifiedAt: null,
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const content of extractXmlBlocks(xml, 'Contents')) {
|
continuationToken = extractXmlFirst(xml, 'NextContinuationToken') || '';
|
||||||
const fullKey = trimSlashes(extractXmlFirst(content, 'Key') || '');
|
} while (continuationToken);
|
||||||
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>();
|
const deduped = new Map<string, RemoteBackupItem>();
|
||||||
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
|
||||||
@@ -637,14 +661,24 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
|
||||||
|
return (await statS3File(config, relativePath)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function statS3File(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
|
||||||
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
const objectKey = normalizeS3ObjectKey(config, relativePath);
|
||||||
const url = s3ObjectUrl(config, objectKey);
|
const url = s3ObjectUrl(config, objectKey);
|
||||||
const response = await signedS3Request(config, 'HEAD', url);
|
const response = await signedS3Request(config, 'HEAD', url);
|
||||||
if (response.status === 404) return false;
|
if (response.status === 404) return null;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`S3 existence check failed: ${response.status}`);
|
throw new Error(`S3 existence check failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
return true;
|
const size = Number(response.headers.get('Content-Length') || '');
|
||||||
|
return {
|
||||||
|
provider: 's3',
|
||||||
|
remotePath: normalizeRelativePath(relativePath),
|
||||||
|
size: Number.isFinite(size) ? size : null,
|
||||||
|
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConfiguredDestinationAdapter {
|
interface ConfiguredDestinationAdapter {
|
||||||
@@ -656,6 +690,7 @@ interface ConfiguredDestinationAdapter {
|
|||||||
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
|
||||||
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
|
||||||
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
|
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||||
|
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteBackupTransferSession {
|
export interface RemoteBackupTransferSession {
|
||||||
@@ -666,6 +701,7 @@ export interface RemoteBackupTransferSession {
|
|||||||
download(relativePath: string): Promise<RemoteBackupFile>;
|
download(relativePath: string): Promise<RemoteBackupFile>;
|
||||||
deleteFile(relativePath: string): Promise<void>;
|
deleteFile(relativePath: string): Promise<void>;
|
||||||
exists(relativePath: string): Promise<boolean>;
|
exists(relativePath: string): Promise<boolean>;
|
||||||
|
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveConfiguredDestinationAdapter(
|
function resolveConfiguredDestinationAdapter(
|
||||||
@@ -683,6 +719,7 @@ function resolveConfiguredDestinationAdapter(
|
|||||||
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
|
||||||
|
stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (destination.type === 's3') {
|
if (destination.type === 's3') {
|
||||||
@@ -695,6 +732,7 @@ function resolveConfiguredDestinationAdapter(
|
|||||||
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
|
||||||
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
|
||||||
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
|
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
|
||||||
|
stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,6 +768,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
|
|||||||
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
||||||
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
|
stat: async (relativePath: string) => adapter.stat(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -275,11 +275,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
||||||
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
||||||
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
||||||
pendingAuthRequests={props.pendingAuthRequests}
|
|
||||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
|
||||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
|
||||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
|
||||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
|
||||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ type PendingRestoreIntegrity =
|
|||||||
type PendingBackupVerification =
|
type PendingBackupVerification =
|
||||||
| { action: 'export' }
|
| { action: 'export' }
|
||||||
| { action: 'saveSettings' }
|
| { action: 'saveSettings' }
|
||||||
|
| { action: 'deleteDestination'; destinationId: string; settings: AdminBackupSettings }
|
||||||
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
|
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
|
||||||
| { action: 'runRemoteBackup' }
|
| { action: 'runRemoteBackup' }
|
||||||
| { action: 'downloadRemote'; path: string }
|
| { action: 'downloadRemote'; path: string }
|
||||||
@@ -240,7 +241,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const backupPasswordPromptTitle =
|
const backupPasswordPromptTitle =
|
||||||
pendingBackupVerification?.action === 'export'
|
pendingBackupVerification?.action === 'export'
|
||||||
? t('txt_backup_export')
|
? t('txt_backup_export')
|
||||||
: pendingBackupVerification?.action === 'saveSettings'
|
: pendingBackupVerification?.action === 'saveSettings' || pendingBackupVerification?.action === 'deleteDestination'
|
||||||
? t('txt_backup_save_settings')
|
? t('txt_backup_save_settings')
|
||||||
: pendingBackupVerification?.action === 'runRemoteBackup'
|
: pendingBackupVerification?.action === 'runRemoteBackup'
|
||||||
? t('txt_backup_run_manual')
|
? t('txt_backup_run_manual')
|
||||||
@@ -501,10 +502,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
|
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
|
||||||
|
setBackupPasswordValue('');
|
||||||
|
setConfirmDeleteDestinationOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) {
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
const saved = await props.onSaveSettings(nextSettings);
|
const saved = await props.onSaveSettings(masterPassword, payload);
|
||||||
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
|
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
|
||||||
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
|
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
|
||||||
setSavedSettings(saved);
|
setSavedSettings(saved);
|
||||||
@@ -865,6 +872,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
await executeExport(masterPassword);
|
await executeExport(masterPassword);
|
||||||
} else if (request.action === 'saveSettings') {
|
} else if (request.action === 'saveSettings') {
|
||||||
await executeSaveSettings(masterPassword);
|
await executeSaveSettings(masterPassword);
|
||||||
|
} else if (request.action === 'deleteDestination') {
|
||||||
|
await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
|
||||||
} else if (request.action === 'import') {
|
} else if (request.action === 'import') {
|
||||||
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
|
||||||
} else if (request.action === 'runRemoteBackup') {
|
} else if (request.action === 'runRemoteBackup') {
|
||||||
|
|||||||
@@ -89,42 +89,38 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="flush-title">{t('txt_device_management')}</h3>
|
<h3 className="flush-title">{t('txt_authorized_devices')}</h3>
|
||||||
<div className="muted-inline section-note">
|
<div className="muted-inline section-note">
|
||||||
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="actions">
|
||||||
|
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_refresh')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
|
||||||
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
|
{t('txt_revoke_all_trusted')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}>
|
||||||
|
<Trash2 size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove_all_devices')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
{!!props.error && (
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
<div className="local-error">
|
||||||
<RefreshCw size={14} className="btn-icon" />
|
<span>{props.error}</span>
|
||||||
{t('txt_refresh')}
|
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
||||||
</button>
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
|
{t('txt_refresh')}
|
||||||
<ShieldOff size={14} className="btn-icon" />
|
</button>
|
||||||
{t('txt_revoke_all_trusted')}
|
</div>
|
||||||
</button>
|
)}
|
||||||
<button type="button" className="btn btn-danger small" onClick={props.onRemoveAll}>
|
<table className="table authorized-devices-table">
|
||||||
<Trash2 size={14} className="btn-icon" />
|
|
||||||
{t('txt_remove_all_devices')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card">
|
|
||||||
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
|
|
||||||
{!!props.error && (
|
|
||||||
<div className="local-error">
|
|
||||||
<span>{props.error}</span>
|
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.loading} onClick={props.onRefresh}>
|
|
||||||
<RefreshCw size={14} className="btn-icon" />
|
|
||||||
{t('txt_refresh')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<table className="table authorized-devices-table">
|
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="authorized-devices-col-device" />
|
<col className="authorized-devices-col-device" />
|
||||||
<col className="authorized-devices-col-type" />
|
<col className="authorized-devices-col-type" />
|
||||||
@@ -233,7 +229,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
|
|||||||
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import qrcode from 'qrcode-generator';
|
import qrcode from 'qrcode-generator';
|
||||||
import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types';
|
import type { AccountPasskeyCredential, Profile } from '@/lib/types';
|
||||||
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
|
|
||||||
|
|
||||||
interface SettingsPageProps {
|
interface SettingsPageProps {
|
||||||
profile: Profile;
|
profile: Profile;
|
||||||
@@ -23,11 +22,6 @@ interface SettingsPageProps {
|
|||||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
||||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||||
pendingAuthRequests: AuthRequest[];
|
|
||||||
pendingAuthRequestsLoading: boolean;
|
|
||||||
onRefreshPendingAuthRequests: () => Promise<void>;
|
|
||||||
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
|
|
||||||
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
|
|
||||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||||
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
@@ -515,15 +509,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<PendingAuthRequestsPanel
|
|
||||||
pendingAuthRequests={props.pendingAuthRequests}
|
|
||||||
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
|
|
||||||
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
|
|
||||||
onApproveAuthRequest={props.onApproveAuthRequest}
|
|
||||||
onDenyAuthRequest={props.onDenyAuthRequest}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section className="settings-module sensitive-actions-module">
|
<section className="settings-module sensitive-actions-module">
|
||||||
<div className="sensitive-actions-grid">
|
<div className="sensitive-actions-grid">
|
||||||
<div className="sensitive-action">
|
<div className="sensitive-action">
|
||||||
|
|||||||
@@ -54,21 +54,18 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
|||||||
<>
|
<>
|
||||||
<div className="backup-recommendation-steps">
|
<div className="backup-recommendation-steps">
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')}
|
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
|
||||||
</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>
|
<a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
|
||||||
{t('txt_backup_recommend_koofr_step_2_suffix')}
|
{t('txt_backup_recommend_koofr_step_2_suffix')}
|
||||||
</div>
|
</div>
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_3')}
|
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_3')}
|
||||||
</div>
|
</div>
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_4')}
|
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_4')}
|
||||||
</div>
|
</div>
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>5.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
|
<strong>4.</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>
|
<a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
|
||||||
{t('txt_backup_recommend_koofr_step_5_suffix')}
|
{t('txt_backup_recommend_koofr_step_5_suffix')}
|
||||||
</div>
|
</div>
|
||||||
@@ -98,13 +95,10 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
|||||||
return (
|
return (
|
||||||
<div className="backup-recommendation-steps">
|
<div className="backup-recommendation-steps">
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_1')}
|
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_2')}
|
||||||
</div>
|
</div>
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')}
|
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_3')}
|
||||||
</div>
|
|
||||||
<div className="backup-recommendation-step">
|
|
||||||
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -112,18 +106,87 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
|
|||||||
return (
|
return (
|
||||||
<div className="backup-recommendation-steps">
|
<div className="backup-recommendation-steps">
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')}
|
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
|
||||||
</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>
|
<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')}
|
{t('txt_backup_recommend_infinicloud_step_2_suffix')}
|
||||||
</div>
|
</div>
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
|
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
|
||||||
</div>
|
</div>
|
||||||
<div className="backup-recommendation-step">
|
<div className="backup-recommendation-step">
|
||||||
<strong>4.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
|
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'backblaze-b2':
|
||||||
|
return (
|
||||||
|
<div className="backup-recommendation-steps">
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>1.</strong> {t('txt_backup_recommend_backblaze_step_2_prefix')}{' '}
|
||||||
|
<a href={provider.bucketsUrl} target="_blank" rel="noreferrer">Buckets</a>
|
||||||
|
{t('txt_backup_recommend_backblaze_step_2_suffix')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>2.</strong> {t('txt_backup_recommend_backblaze_step_3')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>3.</strong> {t('txt_backup_recommend_backblaze_step_4_prefix')}{' '}
|
||||||
|
<a href={provider.applicationKeysUrl} target="_blank" rel="noreferrer">Application Keys</a>
|
||||||
|
{t('txt_backup_recommend_backblaze_step_4_suffix')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>4.</strong> {t('txt_backup_recommend_backblaze_step_5')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'cloudflare-r2':
|
||||||
|
return (
|
||||||
|
<div className="backup-recommendation-steps">
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>1.</strong> {t('txt_backup_recommend_cloudflare_r2_step_1_prefix')}{' '}
|
||||||
|
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_bucket_link')}</a>
|
||||||
|
{t('txt_backup_recommend_cloudflare_r2_step_1_suffix')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>2.</strong> {t('txt_backup_recommend_cloudflare_r2_step_2_prefix')}{' '}
|
||||||
|
<a href={provider.apiTokenUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_api_link')}</a>
|
||||||
|
{t('txt_backup_recommend_cloudflare_r2_step_2_suffix')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>3.</strong> {t('txt_backup_recommend_cloudflare_r2_step_3')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>4.</strong> {t('txt_backup_recommend_cloudflare_r2_step_4')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>5.</strong> {t('txt_backup_recommend_cloudflare_r2_step_5')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'tigris':
|
||||||
|
return (
|
||||||
|
<div className="backup-recommendation-steps">
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>1.</strong> {t('txt_backup_recommend_tigris_step_2_prefix')}{' '}
|
||||||
|
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">Create Bucket</a>
|
||||||
|
{t('txt_backup_recommend_tigris_step_2_suffix')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>2.</strong> {t('txt_backup_recommend_tigris_step_3_prefix')}{' '}
|
||||||
|
<a href={provider.accessKeyUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_tigris_access_key_link')}</a>
|
||||||
|
{t('txt_backup_recommend_tigris_step_3_suffix')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>3.</strong> {t('txt_backup_recommend_tigris_step_4')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>4.</strong> {t('txt_backup_recommend_tigris_step_5')}
|
||||||
|
</div>
|
||||||
|
<div className="backup-recommendation-step">
|
||||||
|
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -147,6 +210,9 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
<div className="backup-inline-note">
|
<div className="backup-inline-note">
|
||||||
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
|
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
|
||||||
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary')
|
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary')
|
||||||
|
: props.selectedRecommendedProvider.id === 'backblaze-b2' ? t('txt_backup_recommend_backblaze_summary')
|
||||||
|
: props.selectedRecommendedProvider.id === 'cloudflare-r2' ? t('txt_backup_recommend_cloudflare_r2_summary')
|
||||||
|
: props.selectedRecommendedProvider.id === 'tigris' ? t('txt_backup_recommend_tigris_summary')
|
||||||
: t('txt_backup_recommend_pcloud_summary')}
|
: t('txt_backup_recommend_pcloud_summary')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -387,7 +453,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
|
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
placeholder="nodewarden/backups"
|
placeholder="nodewarden"
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
destination: {
|
destination: {
|
||||||
@@ -504,7 +570,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
className="input"
|
className="input"
|
||||||
value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
|
value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
placeholder="nodewarden/backups"
|
placeholder=""
|
||||||
onInput={(event) => props.onUpdateDestination((destination) => ({
|
onInput={(event) => props.onUpdateDestination((destination) => ({
|
||||||
...destination,
|
...destination,
|
||||||
destination: {
|
destination: {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Download, FileUp } from 'lucide-preact';
|
import { Download, FileUp } from 'lucide-preact';
|
||||||
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||||
import { hasLinkedStorages } from '@/lib/backup-recommendations';
|
import { hasLinkedStorages } from '@/lib/backup-recommendations';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
|
||||||
|
|
||||||
|
const MOBILE_RECOMMENDATIONS_QUERY = '(max-width: 760px)';
|
||||||
|
|
||||||
interface BackupOperationsSidebarProps {
|
interface BackupOperationsSidebarProps {
|
||||||
disableWhileBusy: boolean;
|
disableWhileBusy: boolean;
|
||||||
exporting: boolean;
|
exporting: boolean;
|
||||||
@@ -18,7 +21,30 @@ interface BackupOperationsSidebarProps {
|
|||||||
onSelectProvider: (providerId: string) => void;
|
onSelectProvider: (providerId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getDefaultRecommendationsOpen() {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return !window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY).matches;
|
||||||
|
}
|
||||||
|
|
||||||
export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
||||||
|
const [recommendationsOpen, setRecommendationsOpen] = useState(getDefaultRecommendationsOpen);
|
||||||
|
const [recommendationsTouched, setRecommendationsTouched] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function' || recommendationsTouched) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const media = window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY);
|
||||||
|
const syncOpenState = () => setRecommendationsOpen(!media.matches);
|
||||||
|
|
||||||
|
syncOpenState();
|
||||||
|
media.addEventListener('change', syncOpenState);
|
||||||
|
return () => media.removeEventListener('change', syncOpenState);
|
||||||
|
}, [recommendationsTouched]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="backup-operations-sidebar">
|
<aside className="backup-operations-sidebar">
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
@@ -41,7 +67,14 @@ export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<details className="backup-recommendations-disclosure">
|
<details
|
||||||
|
className="backup-recommendations-disclosure"
|
||||||
|
open={recommendationsOpen}
|
||||||
|
onToggle={(event) => {
|
||||||
|
setRecommendationsTouched(true);
|
||||||
|
setRecommendationsOpen((event.currentTarget as HTMLDetailsElement).open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<summary className="backup-recommendations-summary">
|
<summary className="backup-recommendations-summary">
|
||||||
<span>
|
<span>
|
||||||
<strong>{t('txt_backup_recommend_title')}</strong>
|
<strong>{t('txt_backup_recommend_title')}</strong>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact';
|
import { Download, FileArchive, FolderOpen, FolderUp, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact';
|
||||||
import type { RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
import type { RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||||
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
|
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -38,14 +38,6 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
|||||||
|
|
||||||
<div className="section-head">
|
<div className="section-head">
|
||||||
<h3>{t('txt_backup_remote_title')}</h3>
|
<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>
|
</div>
|
||||||
|
|
||||||
{!props.destinationIsSaved ? (
|
{!props.destinationIsSaved ? (
|
||||||
@@ -59,20 +51,28 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
|||||||
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
|
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="actions backup-browser-nav">
|
<div className="backup-browser-nav">
|
||||||
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
|
<div className="actions backup-browser-nav-left">
|
||||||
<FolderOpen size={14} className="btn-icon" />
|
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
|
||||||
{t('txt_backup_remote_root')}
|
<FolderOpen size={14} className="btn-icon" />
|
||||||
</button>
|
{t('txt_backup_remote_root')}
|
||||||
<button
|
</button>
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-secondary small"
|
type="button"
|
||||||
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
|
className="btn btn-secondary small"
|
||||||
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
|
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')}
|
<FolderUp size={14} className="btn-icon" />
|
||||||
</button>
|
{t('txt_backup_remote_up')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{props.canBrowse ? (
|
||||||
|
<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>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{props.loadingRemoteBrowser ? (
|
{props.loadingRemoteBrowser ? (
|
||||||
@@ -80,6 +80,12 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
|||||||
) : props.remoteBrowser.items.length ? (
|
) : props.remoteBrowser.items.length ? (
|
||||||
<>
|
<>
|
||||||
<div className="backup-browser-list">
|
<div className="backup-browser-list">
|
||||||
|
<div className="backup-browser-head" aria-hidden="true">
|
||||||
|
<span>{t('txt_name')}</span>
|
||||||
|
<span>{t('txt_backup_remote_modified')}</span>
|
||||||
|
<span>{t('txt_backup_remote_size')}</span>
|
||||||
|
<span>{t('txt_actions')}</span>
|
||||||
|
</div>
|
||||||
{props.visibleItems.map((item) => (
|
{props.visibleItems.map((item) => (
|
||||||
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
|
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
|
||||||
<button
|
<button
|
||||||
@@ -92,10 +98,12 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
|
|||||||
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
|
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
|
||||||
<span className="backup-browser-name">{item.name}</span>
|
<span className="backup-browser-name">{item.name}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="backup-browser-meta">
|
<span className="backup-browser-meta backup-browser-modified">
|
||||||
<span>{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}</span>
|
{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}
|
||||||
<span>{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}</span>
|
</span>
|
||||||
</div>
|
<span className="backup-browser-meta backup-browser-size">
|
||||||
|
{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}
|
||||||
|
</span>
|
||||||
<div className="actions backup-browser-actions">
|
<div className="actions backup-browser-actions">
|
||||||
{item.isDirectory ? (
|
{item.isDirectory ? (
|
||||||
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
|
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
|
||||||
|
|||||||
@@ -234,24 +234,33 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
const normalizedName = String(name || '').trim() || t('txt_account_passkey');
|
const normalizedName = String(name || '').trim() || t('txt_account_passkey');
|
||||||
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
|
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
|
||||||
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
|
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
|
||||||
const pending = await createAccountPasskeyCredential(options);
|
const pending = await createAccountPasskeyCredential(options, directUnlock);
|
||||||
let keySet = null;
|
let keySet = null;
|
||||||
let savedWithoutDirectUnlock = false;
|
let savedWithoutDirectUnlock = false;
|
||||||
if (directUnlock) {
|
if (directUnlock) {
|
||||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||||
try {
|
if (!pending.supportsPrf) {
|
||||||
keySet = await buildAccountPasskeyPrfKeySet(pending, {
|
|
||||||
symEncKey: session.symEncKey,
|
|
||||||
symMacKey: session.symMacKey,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof AccountPasskeyPrfUnavailableError)) throw error;
|
|
||||||
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
|
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
|
||||||
if (!shouldSaveLoginOnly) {
|
if (!shouldSaveLoginOnly) {
|
||||||
onNotify('warning', t('txt_account_passkey_not_saved'));
|
onNotify('warning', t('txt_account_passkey_not_saved'));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
savedWithoutDirectUnlock = true;
|
savedWithoutDirectUnlock = true;
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
keySet = await buildAccountPasskeyPrfKeySet(pending, {
|
||||||
|
symEncKey: session.symEncKey,
|
||||||
|
symMacKey: session.symMacKey,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof AccountPasskeyPrfUnavailableError)) throw error;
|
||||||
|
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
|
||||||
|
if (!shouldSaveLoginOnly) {
|
||||||
|
onNotify('warning', t('txt_account_passkey_not_saved'));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
savedWithoutDirectUnlock = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const credential = await saveAccountPasskey(authedFetch, {
|
const credential = await saveAccountPasskey(authedFetch, {
|
||||||
|
|||||||
@@ -150,6 +150,22 @@ function shouldRetryWithLegacyPrf(error: unknown): boolean {
|
|||||||
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
|
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
|
||||||
|
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
|
||||||
|
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
|
||||||
|
return (
|
||||||
|
name === 'NotSupportedError' ||
|
||||||
|
name === 'SyntaxError' ||
|
||||||
|
name === 'TypeError' ||
|
||||||
|
(name === 'UnknownError' && /transient/i.test(message))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function canRequestPrfExtension(): Promise<boolean> {
|
||||||
|
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
async function getPublicKeyCredentialWithPrf(
|
async function getPublicKeyCredentialWithPrf(
|
||||||
options: PublicKeyCredentialRequestOptions,
|
options: PublicKeyCredentialRequestOptions,
|
||||||
salt: Uint8Array,
|
salt: Uint8Array,
|
||||||
@@ -265,17 +281,38 @@ export async function assertAccountPasskey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createAccountPasskeyCredential(
|
export async function createAccountPasskeyCredential(
|
||||||
response: { options: unknown; token: string }
|
response: { options: unknown; token: string },
|
||||||
|
requestPrf: boolean = false
|
||||||
): Promise<PendingAccountPasskeyCredential> {
|
): Promise<PendingAccountPasskeyCredential> {
|
||||||
if (!window.PublicKeyCredential || !navigator.credentials) {
|
if (!window.PublicKeyCredential || !navigator.credentials) {
|
||||||
throw new Error(t('txt_passkey_browser_not_supported'));
|
throw new Error(t('txt_passkey_browser_not_supported'));
|
||||||
}
|
}
|
||||||
const nativeOptions = cloneCreationOptions(response.options);
|
const nativeOptions = cloneCreationOptions(response.options);
|
||||||
(nativeOptions as any).extensions = {
|
const createWithOptions = async (options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential> => {
|
||||||
...((nativeOptions as any).extensions || {}),
|
const credential = await navigator.credentials.create({ publicKey: options });
|
||||||
prf: {},
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
|
throw new Error(t('txt_no_passkey_created'));
|
||||||
|
}
|
||||||
|
return credential;
|
||||||
};
|
};
|
||||||
const credential = await navigator.credentials.create({ publicKey: nativeOptions });
|
let credential: PublicKeyCredential;
|
||||||
|
if (requestPrf && await canRequestPrfExtension()) {
|
||||||
|
const prfOptions: PublicKeyCredentialCreationOptions = {
|
||||||
|
...nativeOptions,
|
||||||
|
extensions: {
|
||||||
|
...((nativeOptions as any).extensions || {}),
|
||||||
|
prf: {},
|
||||||
|
} as any,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
credential = await createWithOptions(prfOptions);
|
||||||
|
} catch (error) {
|
||||||
|
if (!shouldRetryCreateWithoutPrf(error)) throw error;
|
||||||
|
credential = await createWithOptions(nativeOptions);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
credential = await createWithOptions(nativeOptions);
|
||||||
|
}
|
||||||
if (!(credential instanceof PublicKeyCredential)) {
|
if (!(credential instanceof PublicKeyCredential)) {
|
||||||
throw new Error(t('txt_no_passkey_created'));
|
throw new Error(t('txt_no_passkey_created'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export interface RecommendedStorageLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RecommendedProviderBase {
|
export interface RecommendedProviderBase {
|
||||||
id: 'infinicloud' | 'koofr' | 'pcloud';
|
id: 'infinicloud' | 'koofr' | 'pcloud' | 'backblaze-b2' | 'cloudflare-r2' | 'tigris';
|
||||||
name: string;
|
name: string;
|
||||||
capacity: string;
|
capacity: string;
|
||||||
protocol: 'webdav' | 's3';
|
protocol: 'webdav' | 's3';
|
||||||
@@ -28,7 +28,25 @@ export interface PcloudProvider extends RecommendedProviderBase {
|
|||||||
id: 'pcloud';
|
id: 'pcloud';
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider;
|
export interface BackblazeB2Provider extends RecommendedProviderBase {
|
||||||
|
id: 'backblaze-b2';
|
||||||
|
bucketsUrl: string;
|
||||||
|
applicationKeysUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CloudflareR2Provider extends RecommendedProviderBase {
|
||||||
|
id: 'cloudflare-r2';
|
||||||
|
bucketUrl: string;
|
||||||
|
apiTokenUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TigrisProvider extends RecommendedProviderBase {
|
||||||
|
id: 'tigris';
|
||||||
|
bucketUrl: string;
|
||||||
|
accessKeyUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider | BackblazeB2Provider | CloudflareR2Provider | TigrisProvider;
|
||||||
|
|
||||||
export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
|
export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
|
||||||
{
|
{
|
||||||
@@ -61,6 +79,33 @@ export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
|
|||||||
signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7',
|
signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7',
|
||||||
hasAffiliateLink: true,
|
hasAffiliateLink: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'backblaze-b2',
|
||||||
|
name: 'Backblaze B2',
|
||||||
|
capacity: '10G',
|
||||||
|
protocol: 's3',
|
||||||
|
signupUrl: 'https://secure.backblaze.com/user_signin.htm',
|
||||||
|
bucketsUrl: 'https://secure.backblaze.com/b2_buckets.htm',
|
||||||
|
applicationKeysUrl: 'https://secure.backblaze.com/app_keys.htm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cloudflare-r2',
|
||||||
|
name: 'Cloudflare R2',
|
||||||
|
capacity: '10G',
|
||||||
|
protocol: 's3',
|
||||||
|
signupUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
|
||||||
|
bucketUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
|
||||||
|
apiTokenUrl: 'https://dash.cloudflare.com/?to=/:account/r2/api-tokens/create?type=user',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tigris',
|
||||||
|
name: 'Tigris',
|
||||||
|
capacity: '5G',
|
||||||
|
protocol: 's3',
|
||||||
|
signupUrl: 'https://console.storage.dev/signup',
|
||||||
|
bucketUrl: 'https://console.storage.dev/createbucket',
|
||||||
|
accessKeyUrl: 'https://console.storage.dev/createaccesskey',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider {
|
export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider {
|
||||||
|
|||||||
@@ -85,6 +85,37 @@ const en: Record<string, string> = {
|
|||||||
"txt_backup_recommend_pcloud_step_1": "Register a pCloud account with just your email address.",
|
"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_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_recommend_pcloud_step_3": "Use your registration email as the WebDAV username and your account password as the WebDAV password.",
|
||||||
|
"txt_backup_recommend_backblaze_summary": "S3-compatible object storage with 10 GB free and no credit card required.",
|
||||||
|
"txt_backup_recommend_backblaze_step_1": "Register or sign in to a Backblaze account.",
|
||||||
|
"txt_backup_recommend_backblaze_step_2_prefix": "Open",
|
||||||
|
"txt_backup_recommend_backblaze_step_2_suffix": ", click Create a Bucket, enter only the bucket name, leave the other settings unchanged, and create it.",
|
||||||
|
"txt_backup_recommend_backblaze_step_3": "After creation, put the displayed Endpoint into S3 Endpoint URL, use the bucket name for Bucket Name, and use the middle segment of the endpoint, such as us-west-004, for Region.",
|
||||||
|
"txt_backup_recommend_backblaze_step_4_prefix": "Open",
|
||||||
|
"txt_backup_recommend_backblaze_step_4_suffix": ", click Add a New Application Key, enter any Name of Key, leave the other settings unchanged, and create it.",
|
||||||
|
"txt_backup_recommend_backblaze_step_5": "Use keyID as the access key and applicationKey as the secret key.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_summary": "S3-compatible object storage with 10 GB free, but it requires credit card verification.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_bucket_link": "Create bucket page",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_api_link": "API token page",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Open the",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", enter only the bucket name, and create it directly.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Open the",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", select Object Read & Write for permissions, and create it directly.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore the token value after creation. Fill Access Key ID into Access ID, and Secret Access Key into Access Password.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_4": "Copy the address shown below into S3 Endpoint URL, fill Bucket Name exactly as shown, and leave Region as auto.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_5": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
|
||||||
|
"txt_backup_recommend_s3_path_prefix_step": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
|
||||||
|
"txt_backup_recommend_tigris_summary": "S3-compatible object storage with 5 GB free and no credit card required.",
|
||||||
|
"txt_backup_recommend_tigris_signup_link": "signup page",
|
||||||
|
"txt_backup_recommend_tigris_bucket_link": "Create Bucket page",
|
||||||
|
"txt_backup_recommend_tigris_access_key_link": "Create Access Key page",
|
||||||
|
"txt_backup_recommend_tigris_step_1_prefix": "Open the",
|
||||||
|
"txt_backup_recommend_tigris_step_1_suffix": ", sign up, and log in to Tigris.",
|
||||||
|
"txt_backup_recommend_tigris_step_2_prefix": "Open",
|
||||||
|
"txt_backup_recommend_tigris_step_2_suffix": ", enter only the bucket name, leave everything else unchanged, and create it.",
|
||||||
|
"txt_backup_recommend_tigris_step_3_prefix": "Then open the",
|
||||||
|
"txt_backup_recommend_tigris_step_3_suffix": ", use any name you like, and create it.",
|
||||||
|
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM after creation. Fill the other displayed values into the backup page using the matching field names.",
|
||||||
|
"txt_backup_recommend_tigris_step_5": "Finally, click Manage Key Permissions and turn on Admin Access, otherwise writing backups will fail.",
|
||||||
"txt_backup_add_destination": "Add Destination",
|
"txt_backup_add_destination": "Add Destination",
|
||||||
"txt_backup_schedule_panel_title": "Automatic Schedule",
|
"txt_backup_schedule_panel_title": "Automatic Schedule",
|
||||||
"txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.",
|
"txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.",
|
||||||
@@ -197,6 +228,8 @@ const en: Record<string, string> = {
|
|||||||
"txt_backup_remote_folder": "Folder",
|
"txt_backup_remote_folder": "Folder",
|
||||||
"txt_backup_remote_unknown_time": "Unknown time",
|
"txt_backup_remote_unknown_time": "Unknown time",
|
||||||
"txt_backup_remote_current_path": "Current Folder",
|
"txt_backup_remote_current_path": "Current Folder",
|
||||||
|
"txt_backup_remote_modified": "Modified",
|
||||||
|
"txt_backup_remote_size": "Size",
|
||||||
"txt_backup_remote_load_failed": "Loading remote backups failed",
|
"txt_backup_remote_load_failed": "Loading remote backups failed",
|
||||||
"txt_backup_remote_invalid_response": "Invalid remote backup response",
|
"txt_backup_remote_invalid_response": "Invalid remote backup response",
|
||||||
"txt_backup_remote_download_failed": "Downloading remote backup failed",
|
"txt_backup_remote_download_failed": "Downloading remote backup failed",
|
||||||
|
|||||||
@@ -85,6 +85,37 @@ const es: Record<string, string> = {
|
|||||||
"txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.",
|
"txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.",
|
||||||
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.",
|
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.",
|
||||||
"txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.",
|
"txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.",
|
||||||
|
"txt_backup_recommend_backblaze_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis y sin tarjeta de crédito.",
|
||||||
|
"txt_backup_recommend_backblaze_step_1": "Registre o inicie sesión en una cuenta de Backblaze.",
|
||||||
|
"txt_backup_recommend_backblaze_step_2_prefix": "Abra",
|
||||||
|
"txt_backup_recommend_backblaze_step_2_suffix": ", haga clic en Create a Bucket, introduzca solo el nombre del bucket, deje lo demás sin cambios y créelo.",
|
||||||
|
"txt_backup_recommend_backblaze_step_3": "Después de crearlo, ponga el Endpoint mostrado en S3 Endpoint URL, use el nombre del bucket en Bucket Name y la parte central del endpoint, como us-west-004, en Region.",
|
||||||
|
"txt_backup_recommend_backblaze_step_4_prefix": "Abra",
|
||||||
|
"txt_backup_recommend_backblaze_step_4_suffix": ", haga clic en Add a New Application Key, introduzca cualquier Name of Key, deje lo demás sin cambios y créelo.",
|
||||||
|
"txt_backup_recommend_backblaze_step_5": "Use keyID como clave de acceso y applicationKey como clave secreta.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis, pero requiere verificación con tarjeta de crédito.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_bucket_link": "página para crear bucket",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_api_link": "página de token API",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Abra la",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", introduzca solo el nombre del bucket y créelo directamente.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Abra la",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", seleccione Object Read & Write en permisos y créelo directamente.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore el valor del token después de crearlo. Use Access Key ID como ID de acceso y Secret Access Key como contraseña de acceso.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_4": "Copie la dirección mostrada abajo en S3 Endpoint URL, rellene Bucket Name tal como aparece y deje Region en auto.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_5": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
|
||||||
|
"txt_backup_recommend_s3_path_prefix_step": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
|
||||||
|
"txt_backup_recommend_tigris_summary": "Almacenamiento de objetos compatible con S3 con 5 GB gratis y sin tarjeta de crédito.",
|
||||||
|
"txt_backup_recommend_tigris_signup_link": "página de registro",
|
||||||
|
"txt_backup_recommend_tigris_bucket_link": "página Create Bucket",
|
||||||
|
"txt_backup_recommend_tigris_access_key_link": "página Create Access Key",
|
||||||
|
"txt_backup_recommend_tigris_step_1_prefix": "Abra la",
|
||||||
|
"txt_backup_recommend_tigris_step_1_suffix": ", regístrese e inicie sesión en Tigris.",
|
||||||
|
"txt_backup_recommend_tigris_step_2_prefix": "Abra",
|
||||||
|
"txt_backup_recommend_tigris_step_2_suffix": ", introduzca solo el nombre del bucket, deje todo lo demás sin cambios y créelo.",
|
||||||
|
"txt_backup_recommend_tigris_step_3_prefix": "Luego abra la",
|
||||||
|
"txt_backup_recommend_tigris_step_3_suffix": ", use cualquier nombre y créela.",
|
||||||
|
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM después de crearla. Rellene los demás valores mostrados en la página de copia de seguridad usando los nombres correspondientes.",
|
||||||
|
"txt_backup_recommend_tigris_step_5": "Por último, haga clic en Manage Key Permissions y active Admin Access; de lo contrario, no podrá escribir copias de seguridad.",
|
||||||
"txt_backup_add_destination": "Añadir destino",
|
"txt_backup_add_destination": "Añadir destino",
|
||||||
"txt_backup_schedule_panel_title": "Programación automática",
|
"txt_backup_schedule_panel_title": "Programación automática",
|
||||||
"txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.",
|
"txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.",
|
||||||
@@ -197,6 +228,8 @@ const es: Record<string, string> = {
|
|||||||
"txt_backup_remote_folder": "Carpeta",
|
"txt_backup_remote_folder": "Carpeta",
|
||||||
"txt_backup_remote_unknown_time": "Hora desconocida",
|
"txt_backup_remote_unknown_time": "Hora desconocida",
|
||||||
"txt_backup_remote_current_path": "Carpeta actual",
|
"txt_backup_remote_current_path": "Carpeta actual",
|
||||||
|
"txt_backup_remote_modified": "Modificado",
|
||||||
|
"txt_backup_remote_size": "Tamaño",
|
||||||
"txt_backup_remote_load_failed": "Error al cargar copias de seguridad remotas",
|
"txt_backup_remote_load_failed": "Error al cargar copias de seguridad remotas",
|
||||||
"txt_backup_remote_invalid_response": "Respuesta de copia de seguridad remota no válida",
|
"txt_backup_remote_invalid_response": "Respuesta de copia de seguridad remota no válida",
|
||||||
"txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota",
|
"txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota",
|
||||||
|
|||||||
@@ -86,6 +86,37 @@ const ru: Record<string, string> = {
|
|||||||
"txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.",
|
"txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.",
|
||||||
"txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.",
|
"txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.",
|
||||||
"txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.",
|
"txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.",
|
||||||
|
"txt_backup_recommend_backblaze_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ и без кредитной карты.",
|
||||||
|
"txt_backup_recommend_backblaze_step_1": "Зарегистрируйте учетную запись Backblaze или войдите в нее.",
|
||||||
|
"txt_backup_recommend_backblaze_step_2_prefix": "Откройте",
|
||||||
|
"txt_backup_recommend_backblaze_step_2_suffix": ", нажмите Create a Bucket, введите только имя bucket, оставьте остальные настройки без изменений и создайте его.",
|
||||||
|
"txt_backup_recommend_backblaze_step_3": "После создания вставьте показанный Endpoint в S3 Endpoint URL, имя bucket укажите в Bucket Name, а среднюю часть endpoint, например us-west-004, используйте как Region.",
|
||||||
|
"txt_backup_recommend_backblaze_step_4_prefix": "Откройте",
|
||||||
|
"txt_backup_recommend_backblaze_step_4_suffix": ", нажмите Add a New Application Key, введите любое Name of Key, оставьте остальные настройки без изменений и создайте ключ.",
|
||||||
|
"txt_backup_recommend_backblaze_step_5": "Используйте keyID как ключ доступа, а applicationKey как секретный ключ.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ, но с обязательной проверкой кредитной карты.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_bucket_link": "страницу создания bucket",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_api_link": "страницу API token",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Откройте",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", введите только имя bucket и сразу создайте его.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Откройте",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", выберите Object Read & Write в разрешениях и сразу создайте токен.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_3": "После создания игнорируйте token value. Введите Access Key ID как ID доступа, а Secret Access Key как пароль доступа.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_4": "Вставьте показанный ниже адрес в S3 Endpoint URL, заполните Bucket Name как показано и оставьте Region в значении auto.",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_5": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
|
||||||
|
"txt_backup_recommend_s3_path_prefix_step": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
|
||||||
|
"txt_backup_recommend_tigris_summary": "S3-совместимое объектное хранилище с бесплатными 5 ГБ и без кредитной карты.",
|
||||||
|
"txt_backup_recommend_tigris_signup_link": "страницу регистрации",
|
||||||
|
"txt_backup_recommend_tigris_bucket_link": "страницу Create Bucket",
|
||||||
|
"txt_backup_recommend_tigris_access_key_link": "страницу Create Access Key",
|
||||||
|
"txt_backup_recommend_tigris_step_1_prefix": "Откройте",
|
||||||
|
"txt_backup_recommend_tigris_step_1_suffix": ", зарегистрируйтесь и войдите в Tigris.",
|
||||||
|
"txt_backup_recommend_tigris_step_2_prefix": "Откройте",
|
||||||
|
"txt_backup_recommend_tigris_step_2_suffix": ", введите только имя bucket, ничего больше не меняйте и создайте его.",
|
||||||
|
"txt_backup_recommend_tigris_step_3_prefix": "Затем откройте",
|
||||||
|
"txt_backup_recommend_tigris_step_3_suffix": ", введите любое имя и создайте ключ.",
|
||||||
|
"txt_backup_recommend_tigris_step_4": "После создания игнорируйте Endpoint URL IAM. Остальные показанные значения заполните на странице резервного копирования по совпадающим названиям полей.",
|
||||||
|
"txt_backup_recommend_tigris_step_5": "В конце нажмите Manage Key Permissions и включите Admin Access, иначе запись резервных копий не будет работать.",
|
||||||
"txt_backup_add_destination": "Добавить пункт назначения",
|
"txt_backup_add_destination": "Добавить пункт назначения",
|
||||||
"txt_backup_schedule_panel_title": "Автоматическое расписание",
|
"txt_backup_schedule_panel_title": "Автоматическое расписание",
|
||||||
"txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.",
|
"txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.",
|
||||||
@@ -197,6 +228,8 @@ const ru: Record<string, string> = {
|
|||||||
"txt_backup_remote_folder": "Папка",
|
"txt_backup_remote_folder": "Папка",
|
||||||
"txt_backup_remote_unknown_time": "Неизвестное время",
|
"txt_backup_remote_unknown_time": "Неизвестное время",
|
||||||
"txt_backup_remote_current_path": "Текущая папка",
|
"txt_backup_remote_current_path": "Текущая папка",
|
||||||
|
"txt_backup_remote_modified": "Изменено",
|
||||||
|
"txt_backup_remote_size": "Размер",
|
||||||
"txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.",
|
"txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.",
|
||||||
"txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования",
|
"txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования",
|
||||||
"txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.",
|
"txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.",
|
||||||
|
|||||||
@@ -85,6 +85,37 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。",
|
"txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。",
|
||||||
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。",
|
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。",
|
||||||
"txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。",
|
"txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。",
|
||||||
|
"txt_backup_recommend_backblaze_summary": "兼容 S3 的对象存储,免费容量 10 GB,无需信用卡。",
|
||||||
|
"txt_backup_recommend_backblaze_step_1": "先注册或登录 Backblaze 账号。",
|
||||||
|
"txt_backup_recommend_backblaze_step_2_prefix": "打开",
|
||||||
|
"txt_backup_recommend_backblaze_step_2_suffix": ",点击创建一个桶,只输入桶名字,其他地方不修改,然后创建。",
|
||||||
|
"txt_backup_recommend_backblaze_step_3": "创建后显示的 Endpoint 填到 S3 端点 URL;桶名字填到存储桶名称;区域填 Endpoint 中间那段,例如 us-west-004。",
|
||||||
|
"txt_backup_recommend_backblaze_step_4_prefix": "打开",
|
||||||
|
"txt_backup_recommend_backblaze_step_4_suffix": ",点击 Add a New Application Key,随便输入 Name of Key,其他地方不动,然后创建。",
|
||||||
|
"txt_backup_recommend_backblaze_step_5": "生成结果里的 keyID 填到 访问 ID,applicationKey 填到 访问密码。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的对象存储,免费容量 10 GB,需要信用卡认证。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_bucket_link": "创建储存桶页面",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_api_link": "API 创建页面",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打开",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只输入存储桶名称,直接创建。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打开",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",权限全选“对象读和写”,直接创建。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_3": "创建后令牌值不用管;Access Key ID 填到 访问 ID,Secret Access Key 填到 访问密码。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_4": "把下面显示的地址填到 S3 端点 URL;存储桶名称如实填写;区域保持 auto 不改。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_5": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
|
||||||
|
"txt_backup_recommend_s3_path_prefix_step": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
|
||||||
|
"txt_backup_recommend_tigris_summary": "兼容 S3 的对象存储。免费容量 5 GB,无需信用卡。",
|
||||||
|
"txt_backup_recommend_tigris_signup_link": "注册页面",
|
||||||
|
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 页面",
|
||||||
|
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 页面",
|
||||||
|
"txt_backup_recommend_tigris_step_1_prefix": "打开",
|
||||||
|
"txt_backup_recommend_tigris_step_1_suffix": ",注册并登录 Tigris。",
|
||||||
|
"txt_backup_recommend_tigris_step_2_prefix": "打开",
|
||||||
|
"txt_backup_recommend_tigris_step_2_suffix": ",只输入桶的名字,其他地方不动,直接创建。",
|
||||||
|
"txt_backup_recommend_tigris_step_3_prefix": "然后打开",
|
||||||
|
"txt_backup_recommend_tigris_step_3_suffix": ",名字随意,直接创建。",
|
||||||
|
"txt_backup_recommend_tigris_step_4": "创建后显示的 Endpoint URL IAM 不用管;其余显示出来的内容按名称填写到备份页面里。",
|
||||||
|
"txt_backup_recommend_tigris_step_5": "最后点击 Manage Key Permissions,把 Admin Access 打开,否则无法写入。",
|
||||||
"txt_backup_add_destination": "新增地点",
|
"txt_backup_add_destination": "新增地点",
|
||||||
"txt_backup_schedule_panel_title": "自动备份计划",
|
"txt_backup_schedule_panel_title": "自动备份计划",
|
||||||
"txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。",
|
"txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。",
|
||||||
@@ -197,6 +228,8 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_backup_remote_folder": "文件夹",
|
"txt_backup_remote_folder": "文件夹",
|
||||||
"txt_backup_remote_unknown_time": "未知时间",
|
"txt_backup_remote_unknown_time": "未知时间",
|
||||||
"txt_backup_remote_current_path": "当前目录",
|
"txt_backup_remote_current_path": "当前目录",
|
||||||
|
"txt_backup_remote_modified": "修改时间",
|
||||||
|
"txt_backup_remote_size": "大小",
|
||||||
"txt_backup_remote_load_failed": "读取远端备份失败",
|
"txt_backup_remote_load_failed": "读取远端备份失败",
|
||||||
"txt_backup_remote_invalid_response": "远端备份响应无效",
|
"txt_backup_remote_invalid_response": "远端备份响应无效",
|
||||||
"txt_backup_remote_download_failed": "下载远端备份失败",
|
"txt_backup_remote_download_failed": "下载远端备份失败",
|
||||||
@@ -266,15 +299,15 @@ const zhCN: Record<string, string> = {
|
|||||||
"txt_backup_webdav_username": "WebDAV 用户名",
|
"txt_backup_webdav_username": "WebDAV 用户名",
|
||||||
"txt_backup_webdav_password": "WebDAV 密码",
|
"txt_backup_webdav_password": "WebDAV 密码",
|
||||||
"txt_backup_webdav_path": "远程目录",
|
"txt_backup_webdav_path": "远程目录",
|
||||||
"txt_backup_s3_endpoint": "S3 端点",
|
"txt_backup_s3_endpoint": "S3 端点 URL",
|
||||||
"txt_backup_s3_addressing_style": "S3 寻址方式",
|
"txt_backup_s3_addressing_style": "寻址方式",
|
||||||
"txt_backup_s3_addressing_path_style": "path-style(默认)",
|
"txt_backup_s3_addressing_path_style": "path-style(默认)",
|
||||||
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
|
||||||
"txt_backup_s3_bucket": "存储桶",
|
"txt_backup_s3_bucket": "存储桶名称",
|
||||||
"txt_backup_s3_region": "区域",
|
"txt_backup_s3_region": "区域",
|
||||||
"txt_backup_s3_access_key": "访问密钥",
|
"txt_backup_s3_access_key": "访问 ID",
|
||||||
"txt_backup_s3_secret_key": "秘密密钥",
|
"txt_backup_s3_secret_key": "访问密码",
|
||||||
"txt_backup_s3_path": "远程路径",
|
"txt_backup_s3_path": "路径前缀",
|
||||||
"txt_backup_reserved_name": "预留类型名称",
|
"txt_backup_reserved_name": "预留类型名称",
|
||||||
"txt_backup_reserved_notes": "预留备注",
|
"txt_backup_reserved_notes": "预留备注",
|
||||||
"txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明",
|
"txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明",
|
||||||
|
|||||||
@@ -85,6 +85,37 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。",
|
"txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。",
|
||||||
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。",
|
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。",
|
||||||
"txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。",
|
"txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。",
|
||||||
|
"txt_backup_recommend_backblaze_summary": "兼容 S3 的對象儲存,免費容量 10 GB,無需信用卡。",
|
||||||
|
"txt_backup_recommend_backblaze_step_1": "先註冊或登入 Backblaze 賬號。",
|
||||||
|
"txt_backup_recommend_backblaze_step_2_prefix": "打開",
|
||||||
|
"txt_backup_recommend_backblaze_step_2_suffix": ",點擊創建一個桶,只輸入桶名字,其他地方不修改,然後創建。",
|
||||||
|
"txt_backup_recommend_backblaze_step_3": "創建後顯示的 Endpoint 填到 S3 端點 URL;桶名字填到儲存桶名稱;區域填 Endpoint 中間那段,例如 us-west-004。",
|
||||||
|
"txt_backup_recommend_backblaze_step_4_prefix": "打開",
|
||||||
|
"txt_backup_recommend_backblaze_step_4_suffix": ",點擊 Add a New Application Key,隨便輸入 Name of Key,其他地方不動,然後創建。",
|
||||||
|
"txt_backup_recommend_backblaze_step_5": "生成結果裡的 keyID 填存取金鑰,applicationKey 填秘密金鑰。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的對象儲存,免費容量 10 GB,需要信用卡驗證。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_bucket_link": "創建儲存桶頁面",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_api_link": "API 創建頁面",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打開",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只輸入儲存桶名稱,直接創建。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打開",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",權限全選「對象讀和寫」,直接創建。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_3": "創建後令牌值不用管;Access Key ID 填到存取 ID,Secret Access Key 填到存取密碼。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_4": "把下面顯示的地址填到 S3 端點 URL;儲存桶名稱如實填寫;區域保持 auto 不改。",
|
||||||
|
"txt_backup_recommend_cloudflare_r2_step_5": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
|
||||||
|
"txt_backup_recommend_s3_path_prefix_step": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
|
||||||
|
"txt_backup_recommend_tigris_summary": "兼容 S3 的對象儲存。免費容量 5 GB,無需信用卡。",
|
||||||
|
"txt_backup_recommend_tigris_signup_link": "註冊頁面",
|
||||||
|
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 頁面",
|
||||||
|
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 頁面",
|
||||||
|
"txt_backup_recommend_tigris_step_1_prefix": "打開",
|
||||||
|
"txt_backup_recommend_tigris_step_1_suffix": ",註冊並登入 Tigris。",
|
||||||
|
"txt_backup_recommend_tigris_step_2_prefix": "打開",
|
||||||
|
"txt_backup_recommend_tigris_step_2_suffix": ",只輸入桶的名字,其他地方不動,直接創建。",
|
||||||
|
"txt_backup_recommend_tigris_step_3_prefix": "然後打開",
|
||||||
|
"txt_backup_recommend_tigris_step_3_suffix": ",名字隨意,直接創建。",
|
||||||
|
"txt_backup_recommend_tigris_step_4": "創建後顯示的 Endpoint URL IAM 不用管;其餘顯示出來的內容按名稱填寫到備份頁面裡。",
|
||||||
|
"txt_backup_recommend_tigris_step_5": "最後點擊 Manage Key Permissions,把 Admin Access 打開,否則無法寫入。",
|
||||||
"txt_backup_add_destination": "新增地點",
|
"txt_backup_add_destination": "新增地點",
|
||||||
"txt_backup_schedule_panel_title": "自動備份計劃",
|
"txt_backup_schedule_panel_title": "自動備份計劃",
|
||||||
"txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。",
|
"txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。",
|
||||||
@@ -197,6 +228,8 @@ const zhTW: Record<string, string> = {
|
|||||||
"txt_backup_remote_folder": "文件夾",
|
"txt_backup_remote_folder": "文件夾",
|
||||||
"txt_backup_remote_unknown_time": "未知時間",
|
"txt_backup_remote_unknown_time": "未知時間",
|
||||||
"txt_backup_remote_current_path": "當前目錄",
|
"txt_backup_remote_current_path": "當前目錄",
|
||||||
|
"txt_backup_remote_modified": "修改時間",
|
||||||
|
"txt_backup_remote_size": "大小",
|
||||||
"txt_backup_remote_load_failed": "讀取遠端備份失敗",
|
"txt_backup_remote_load_failed": "讀取遠端備份失敗",
|
||||||
"txt_backup_remote_invalid_response": "遠端備份響應無效",
|
"txt_backup_remote_invalid_response": "遠端備份響應無效",
|
||||||
"txt_backup_remote_download_failed": "下載遠端備份失敗",
|
"txt_backup_remote_download_failed": "下載遠端備份失敗",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
/* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */
|
/* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */
|
||||||
|
|
||||||
/* ── surface consistency ── */
|
/* ── surface consistency ── */
|
||||||
.app-shell,
|
|
||||||
.auth-card,
|
.auth-card,
|
||||||
.dialog-card,
|
.dialog-card,
|
||||||
.card,
|
.card,
|
||||||
@@ -36,12 +35,6 @@
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
|
||||||
background: var(--panel-soft);
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar,
|
.topbar,
|
||||||
.mobile-tabbar,
|
.mobile-tabbar,
|
||||||
.app-side {
|
.app-side {
|
||||||
@@ -104,7 +97,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── dark mode surface resets ── */
|
/* ── dark mode surface resets ── */
|
||||||
:root[data-theme='dark'] .app-shell,
|
|
||||||
:root[data-theme='dark'] .auth-card,
|
:root[data-theme='dark'] .auth-card,
|
||||||
:root[data-theme='dark'] .dialog-card,
|
:root[data-theme='dark'] .dialog-card,
|
||||||
:root[data-theme='dark'] .card,
|
:root[data-theme='dark'] .card,
|
||||||
@@ -259,17 +251,6 @@ h4 {
|
|||||||
letter-spacing: 0;
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-page {
|
|
||||||
padding: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell {
|
|
||||||
border-radius: var(--radius-xl);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
background: var(--panel);
|
|
||||||
box-shadow: var(--shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
padding-inline: 16px;
|
padding-inline: 16px;
|
||||||
@@ -916,7 +897,6 @@ textarea {
|
|||||||
background: var(--bg-accent);
|
background: var(--bg-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .app-shell,
|
|
||||||
:root[data-theme='dark'] .topbar,
|
:root[data-theme='dark'] .topbar,
|
||||||
:root[data-theme='dark'] .app-side,
|
:root[data-theme='dark'] .app-side,
|
||||||
:root[data-theme='dark'] .mobile-tabbar,
|
:root[data-theme='dark'] .mobile-tabbar,
|
||||||
|
|||||||
@@ -200,10 +200,6 @@
|
|||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20), 0 8px 24px rgba(0, 0, 0, 0.16);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20), 0 8px 24px rgba(0, 0, 0, 0.16);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .app-shell {
|
|
||||||
box-shadow: 0 4px 40px rgba(0, 0, 0, 0.30);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root[data-theme='dark'] .list-item:hover {
|
:root[data-theme='dark'] .list-item:hover {
|
||||||
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
|
||||||
}
|
}
|
||||||
@@ -340,6 +336,7 @@
|
|||||||
:root[data-theme='dark'] .backup-recommendation-step,
|
:root[data-theme='dark'] .backup-recommendation-step,
|
||||||
:root[data-theme='dark'] .backup-recommendation-inline-note,
|
:root[data-theme='dark'] .backup-recommendation-inline-note,
|
||||||
:root[data-theme='dark'] .backup-recommendation-linked-item,
|
:root[data-theme='dark'] .backup-recommendation-linked-item,
|
||||||
|
:root[data-theme='dark'] .backup-browser-head,
|
||||||
:root[data-theme='dark'] .backup-browser-meta,
|
:root[data-theme='dark'] .backup-browser-meta,
|
||||||
:root[data-theme='dark'] .backup-browser-empty,
|
:root[data-theme='dark'] .backup-browser-empty,
|
||||||
:root[data-theme='dark'] .backup-inline-note,
|
:root[data-theme='dark'] .backup-inline-note,
|
||||||
@@ -351,6 +348,19 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-step a {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-step a:hover,
|
||||||
|
:root[data-theme='dark'] .backup-recommendation-step a:focus-visible {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .backup-browser-head {
|
||||||
|
background: var(--panel-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .restore-progress-overlay {
|
:root[data-theme='dark'] .restore-progress-overlay {
|
||||||
background: var(--overlay-strong);
|
background: var(--overlay-strong);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
|
|||||||
@@ -193,6 +193,18 @@
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-recommendation-step a {
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-recommendation-step a:hover,
|
||||||
|
.backup-recommendation-step a:focus-visible {
|
||||||
|
color: #1742b0;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-recommendation-inline-note {
|
.backup-recommendation-inline-note {
|
||||||
color: #475467;
|
color: #475467;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
@@ -351,16 +363,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-nav {
|
.backup-browser-nav {
|
||||||
@apply mb-2.5;
|
@apply mb-2.5 flex items-center justify-between gap-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-nav-left {
|
||||||
|
@apply min-w-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-list {
|
.backup-browser-list {
|
||||||
@apply overflow-hidden rounded-xl border bg-white;
|
@apply overflow-hidden rounded-lg border bg-white;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-browser-head {
|
||||||
|
@apply grid items-center gap-3 px-3 py-2 text-[11px] font-bold uppercase tracking-[0.08em];
|
||||||
|
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
color: #64748b;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-head span:nth-child(2),
|
||||||
|
.backup-browser-head span:nth-child(3),
|
||||||
|
.backup-browser-head span:nth-child(4) {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-browser-pagination {
|
.backup-browser-pagination {
|
||||||
@apply mt-2.5 flex items-center justify-end gap-2.5;
|
@apply mt-2.5 flex items-center justify-center gap-2.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-page-indicator {
|
.backup-browser-page-indicator {
|
||||||
@@ -373,13 +403,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-row {
|
.backup-browser-row {
|
||||||
@apply grid items-center gap-2.5 px-3 py-2.5;
|
@apply grid items-center gap-3 px-3 py-2;
|
||||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
|
||||||
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-entry {
|
.backup-browser-entry {
|
||||||
@apply inline-flex cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left;
|
@apply inline-flex cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left;
|
||||||
color: #0f172a;
|
color: #0f172a;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-entry.file {
|
.backup-browser-entry.file {
|
||||||
@@ -392,12 +424,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-meta {
|
.backup-browser-meta {
|
||||||
@apply grid justify-items-end gap-1 text-right text-[13px];
|
@apply block text-right text-[13px];
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-browser-size {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-browser-actions {
|
.backup-browser-actions {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.backup-browser-empty {
|
.backup-browser-empty {
|
||||||
|
|||||||
@@ -46,6 +46,24 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-browser-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-meta {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-nav {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.settings-twofactor-grid {
|
.settings-twofactor-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1110,6 +1128,24 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.backup-browser-head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-meta {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backup-browser-nav {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.backup-grid {
|
.backup-grid {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
.app-page {
|
.app-page {
|
||||||
@apply relative min-h-full bg-transparent p-5;
|
@apply relative min-h-full bg-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
@apply relative mx-auto flex max-w-[1600px] flex-col overflow-hidden border bg-panel-soft;
|
@apply relative flex flex-col;
|
||||||
height: calc(100vh - 40px);
|
height: 100vh;
|
||||||
border-color: var(--line);
|
background: var(--bg-accent);
|
||||||
@apply rounded-3xl;
|
|
||||||
box-shadow:
|
|
||||||
0 20px 60px rgba(15, 23, 42, 0.12),
|
|
||||||
0 8px 24px rgba(15, 23, 42, 0.08),
|
|
||||||
0 0 0 1px rgba(15, 23, 42, 0.04);
|
|
||||||
transition: box-shadow var(--dur-medium) var(--ease-smooth);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.topbar {
|
.topbar {
|
||||||
|
|||||||
Reference in New Issue
Block a user