mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d4e95ef66 | |||
| 2a7879efaa | |||
| bd8e26d2ab | |||
| 783fcbbe4b |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.4.1';
|
export const APP_VERSION = '1.4.2';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const SIGNALR_HANDSHAKE_ACK = new Uint8Array([0x7b, 0x7d, SIGNALR_RECORD_SEPARAT
|
|||||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||||
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||||
const SIGNALR_PING_INTERVAL_MS = 15_000;
|
const SIGNALR_PING_INTERVAL_MS = 15_000;
|
||||||
|
|
||||||
type HubProtocol = 'json' | 'messagepack';
|
type HubProtocol = 'json' | 'messagepack';
|
||||||
@@ -127,25 +128,21 @@ function frameSignalRBinary(payload: Uint8Array): Uint8Array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRJsonInvocation(
|
function buildSignalRJsonInvocation(
|
||||||
userId: string,
|
|
||||||
updateType: number,
|
updateType: number,
|
||||||
revisionDate: string,
|
payload: Record<string, unknown>,
|
||||||
contextId: string | null
|
contextId: string | null
|
||||||
): string {
|
): string {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
type: 1,
|
type: 1,
|
||||||
target: 'ReceiveMessage',
|
target: 'ReceiveMessage',
|
||||||
arguments: [
|
arguments: [
|
||||||
{
|
{
|
||||||
ContextId: contextId,
|
ContextId: contextId,
|
||||||
Type: updateType,
|
Type: updateType,
|
||||||
Payload: {
|
Payload: payload,
|
||||||
UserId: userId,
|
|
||||||
Date: revisionDate,
|
|
||||||
},
|
},
|
||||||
},
|
],
|
||||||
],
|
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
||||||
}) + String.fromCharCode(SIGNALR_RECORD_SEPARATOR);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRJsonPing(): string {
|
function buildSignalRJsonPing(): string {
|
||||||
@@ -153,14 +150,13 @@ function buildSignalRJsonPing(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRMessagePackInvocation(
|
function buildSignalRMessagePackInvocation(
|
||||||
userId: string,
|
|
||||||
updateType: number,
|
updateType: number,
|
||||||
revisionDate: string,
|
messagePayload: Record<string, unknown>,
|
||||||
contextId: string | null
|
contextId: string | null
|
||||||
): Uint8Array {
|
): Uint8Array {
|
||||||
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
// SignalR MessagePack hub protocol uses an array-based invocation shape:
|
||||||
// [type, headers, invocationId, target, arguments]
|
// [type, headers, invocationId, target, arguments]
|
||||||
const payload = encodeMsgPack([
|
const encodedPayload = encodeMsgPack([
|
||||||
1,
|
1,
|
||||||
{},
|
{},
|
||||||
null,
|
null,
|
||||||
@@ -169,14 +165,11 @@ function buildSignalRMessagePackInvocation(
|
|||||||
{
|
{
|
||||||
ContextId: contextId,
|
ContextId: contextId,
|
||||||
Type: updateType,
|
Type: updateType,
|
||||||
Payload: {
|
Payload: messagePayload,
|
||||||
UserId: userId,
|
|
||||||
Date: new Date(revisionDate),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
return frameSignalRBinary(payload);
|
return frameSignalRBinary(encodedPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSignalRMessagePackPing(): Uint8Array {
|
function buildSignalRMessagePackPing(): Uint8Array {
|
||||||
@@ -209,13 +202,20 @@ export class NotificationsHub {
|
|||||||
contextId?: string | null;
|
contextId?: string | null;
|
||||||
updateType?: number;
|
updateType?: number;
|
||||||
targetDeviceIdentifier?: string | null;
|
targetDeviceIdentifier?: string | null;
|
||||||
|
payload?: Record<string, unknown> | null;
|
||||||
} | null;
|
} | null;
|
||||||
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
const revisionDate = String(body?.revisionDate || '').trim() || new Date().toISOString();
|
||||||
this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim();
|
this.userId = String(request.headers.get('X-NodeWarden-UserId') || body?.userId || this.userId).trim();
|
||||||
const contextId = String(body?.contextId || '').trim() || null;
|
const contextId = String(body?.contextId || '').trim() || null;
|
||||||
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
const updateType = Number(body?.updateType || SIGNALR_UPDATE_TYPE_SYNC_VAULT) || SIGNALR_UPDATE_TYPE_SYNC_VAULT;
|
||||||
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
const targetDeviceIdentifier = String(body?.targetDeviceIdentifier || '').trim() || null;
|
||||||
this.broadcastMessage(updateType, revisionDate, contextId, targetDeviceIdentifier);
|
const payload = body?.payload && typeof body.payload === 'object'
|
||||||
|
? body.payload
|
||||||
|
: {
|
||||||
|
UserId: this.userId,
|
||||||
|
Date: revisionDate,
|
||||||
|
};
|
||||||
|
this.broadcastMessage(updateType, payload, contextId, targetDeviceIdentifier);
|
||||||
return new Response(null, { status: 204 });
|
return new Response(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ export class NotificationsHub {
|
|||||||
|
|
||||||
private broadcastMessage(
|
private broadcastMessage(
|
||||||
updateType: number,
|
updateType: number,
|
||||||
revisionDate: string,
|
payload: Record<string, unknown>,
|
||||||
contextId: string | null,
|
contextId: string | null,
|
||||||
targetDeviceIdentifier: string | null
|
targetDeviceIdentifier: string | null
|
||||||
): void {
|
): void {
|
||||||
@@ -371,9 +371,9 @@ export class NotificationsHub {
|
|||||||
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
|
if (targetDeviceIdentifier && connection.deviceIdentifier !== targetDeviceIdentifier) continue;
|
||||||
try {
|
try {
|
||||||
if (connection.protocol === 'json') {
|
if (connection.protocol === 'json') {
|
||||||
socket.send(buildSignalRJsonInvocation(this.userId, updateType, revisionDate, contextId));
|
socket.send(buildSignalRJsonInvocation(updateType, payload, contextId));
|
||||||
} else {
|
} else {
|
||||||
socket.send(buildSignalRMessagePackInvocation(this.userId, updateType, revisionDate, contextId));
|
socket.send(buildSignalRMessagePackInvocation(updateType, payload, contextId));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.connections.delete(socket);
|
this.connections.delete(socket);
|
||||||
@@ -389,7 +389,15 @@ export class NotificationsHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private broadcastDeviceStatus(): void {
|
private broadcastDeviceStatus(): void {
|
||||||
this.broadcastMessage(SIGNALR_UPDATE_TYPE_DEVICE_STATUS, new Date().toISOString(), null, null);
|
this.broadcastMessage(
|
||||||
|
SIGNALR_UPDATE_TYPE_DEVICE_STATUS,
|
||||||
|
{
|
||||||
|
UserId: this.userId,
|
||||||
|
Date: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,9 +453,79 @@ async function notifyUserUpdate(
|
|||||||
contextId: contextId || null,
|
contextId: contextId || null,
|
||||||
updateType,
|
updateType,
|
||||||
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||||
|
payload: {
|
||||||
|
UserId: userId,
|
||||||
|
Date: revisionDate,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to broadcast realtime notification:', error);
|
console.error('Failed to broadcast realtime notification:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function notifyUserBackupProgress(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
progress: {
|
||||||
|
operation: 'backup-restore' | 'backup-export' | 'backup-remote-run';
|
||||||
|
source?: 'local' | 'remote';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle?: string;
|
||||||
|
stageDetail?: string;
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
timestamp?: string;
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
const revisionDate = progress.timestamp || new Date().toISOString();
|
||||||
|
try {
|
||||||
|
const id = env.NOTIFICATIONS_HUB.idFromName(userId);
|
||||||
|
const stub = env.NOTIFICATIONS_HUB.get(id);
|
||||||
|
await stub.fetch('https://notifications/internal/notify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-NodeWarden-UserId': userId,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
revisionDate,
|
||||||
|
contextId: null,
|
||||||
|
updateType: SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS,
|
||||||
|
targetDeviceIdentifier: targetDeviceIdentifier || null,
|
||||||
|
payload: {
|
||||||
|
UserId: userId,
|
||||||
|
Date: revisionDate,
|
||||||
|
...progress,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to broadcast backup progress:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function notifyUserBackupRestoreProgress(
|
||||||
|
env: Env,
|
||||||
|
userId: string,
|
||||||
|
progress: {
|
||||||
|
operation: 'backup-restore';
|
||||||
|
source: 'local' | 'remote';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle?: string;
|
||||||
|
stageDetail?: string;
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
timestamp?: string;
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier?: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
return notifyUserBackupProgress(env, userId, progress, targetDeviceIdentifier);
|
||||||
|
}
|
||||||
|
|||||||
+333
-18
@@ -1,7 +1,12 @@
|
|||||||
import type { Env, User } from '../types';
|
import type { Env, User } from '../types';
|
||||||
import { errorResponse, jsonResponse } from '../utils/response';
|
import { errorResponse, jsonResponse } from '../utils/response';
|
||||||
import { generateUUID } from '../utils/uuid';
|
import { generateUUID } from '../utils/uuid';
|
||||||
import { type BackupArchiveBundle, buildBackupArchive } from '../services/backup-archive';
|
import {
|
||||||
|
type BackupArchiveBundle,
|
||||||
|
buildBackupArchive,
|
||||||
|
inspectBackupArchiveFileNameChecksum,
|
||||||
|
verifyBackupArchiveFileNameChecksum,
|
||||||
|
} from '../services/backup-archive';
|
||||||
import {
|
import {
|
||||||
type BackupDestinationRecord,
|
type BackupDestinationRecord,
|
||||||
type BackupSettingsInput,
|
type BackupSettingsInput,
|
||||||
@@ -17,19 +22,25 @@ import {
|
|||||||
requireBackupDestination,
|
requireBackupDestination,
|
||||||
saveBackupSettings,
|
saveBackupSettings,
|
||||||
} from '../services/backup-config';
|
} from '../services/backup-config';
|
||||||
import { type BackupImportExecutionResult, importBackupArchiveBytes, importRemoteBackupArchiveBytes } from '../services/backup-import';
|
|
||||||
import {
|
import {
|
||||||
|
type BackupImportExecutionResult,
|
||||||
|
type BackupRestoreProgressReporter,
|
||||||
|
importBackupArchiveBytes,
|
||||||
|
importRemoteBackupArchiveBytes,
|
||||||
|
} from '../services/backup-import';
|
||||||
|
import {
|
||||||
|
type RemoteBackupTransferSession,
|
||||||
|
createRemoteBackupTransferSession,
|
||||||
deleteRemoteBackupFile,
|
deleteRemoteBackupFile,
|
||||||
downloadRemoteBackupFile,
|
downloadRemoteBackupFile,
|
||||||
ensureRemoteRestoreCandidate,
|
ensureRemoteRestoreCandidate,
|
||||||
listRemoteBackupEntries,
|
listRemoteBackupEntries,
|
||||||
pruneRemoteBackupArchives,
|
pruneRemoteBackupArchives,
|
||||||
remoteBackupFileExists,
|
|
||||||
uploadRemoteBackupFile,
|
|
||||||
uploadBackupArchive,
|
uploadBackupArchive,
|
||||||
} from '../services/backup-uploader';
|
} from '../services/backup-uploader';
|
||||||
import { StorageService } from '../services/storage';
|
import { StorageService } from '../services/storage';
|
||||||
import { getBlobObject } from '../services/blob-store';
|
import { getBlobObject } from '../services/blob-store';
|
||||||
|
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
|
||||||
|
|
||||||
function isAdmin(user: User): boolean {
|
function isAdmin(user: User): boolean {
|
||||||
return user.role === 'admin' && user.status === 'active';
|
return user.role === 'admin' && user.status === 'active';
|
||||||
@@ -81,13 +92,74 @@ function ensureBackupBlobName(value: string): string {
|
|||||||
return parts.join('/');
|
return parts.join('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
|
||||||
|
|
||||||
|
interface RemoteAttachmentIndexPayload {
|
||||||
|
version: 1;
|
||||||
|
blobs: Record<string, { sizeBytes: number; updatedAt: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRemoteAttachmentIndex(session: RemoteBackupTransferSession): Promise<Map<string, number>> {
|
||||||
|
try {
|
||||||
|
const file = await session.download(REMOTE_ATTACHMENT_INDEX_PATH);
|
||||||
|
const payload = JSON.parse(new TextDecoder().decode(file.bytes)) as RemoteAttachmentIndexPayload;
|
||||||
|
if (payload?.version !== 1 || !payload.blobs || typeof payload.blobs !== 'object') {
|
||||||
|
return new Map<string, number>();
|
||||||
|
}
|
||||||
|
return new Map(
|
||||||
|
Object.entries(payload.blobs)
|
||||||
|
.filter(([key, value]) => !!String(key || '').trim() && Number.isFinite(Number(value?.sizeBytes || 0)))
|
||||||
|
.map(([key, value]) => [key, Number(value.sizeBytes || 0)])
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes('404') || message.includes('Please select a backup file')) {
|
||||||
|
return new Map<string, number>();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRemoteAttachmentIndex(
|
||||||
|
session: RemoteBackupTransferSession,
|
||||||
|
index: Map<string, number>
|
||||||
|
): Promise<void> {
|
||||||
|
const payload: RemoteAttachmentIndexPayload = {
|
||||||
|
version: 1,
|
||||||
|
blobs: Object.fromEntries(
|
||||||
|
Array.from(index.entries()).map(([blobName, sizeBytes]) => [
|
||||||
|
blobName,
|
||||||
|
{
|
||||||
|
sizeBytes,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(payload));
|
||||||
|
await session.putFile(REMOTE_ATTACHMENT_INDEX_PATH, bytes, {
|
||||||
|
contentType: 'application/json; charset=utf-8',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function executeConfiguredBackup(
|
async function executeConfiguredBackup(
|
||||||
env: Env,
|
env: Env,
|
||||||
storage: StorageService,
|
storage: StorageService,
|
||||||
actorUserId: string | null,
|
actorUserId: string | null,
|
||||||
trigger: 'manual' | 'scheduled',
|
trigger: 'manual' | 'scheduled',
|
||||||
destinationId?: string | null
|
destinationId?: string | null,
|
||||||
|
progress?: ((event: {
|
||||||
|
operation: 'backup-remote-run';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}) => Promise<void>) | null
|
||||||
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
||||||
|
const maxArchiveUploadAttempts = 3;
|
||||||
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const destination = requireBackupDestination(currentSettings, destinationId);
|
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||||
|
|
||||||
@@ -99,25 +171,109 @@ async function executeConfiguredBackup(
|
|||||||
await saveBackupSettings(storage, env, currentSettings);
|
await saveBackupSettings(storage, env, currentSettings);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_prepare',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_prepare_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_prepare_detail',
|
||||||
|
});
|
||||||
const archive = await buildBackupArchive(env, now, {
|
const archive = await buildBackupArchive(env, now, {
|
||||||
includeAttachments: destination.includeAttachments,
|
includeAttachments: destination.includeAttachments,
|
||||||
|
progress: progress
|
||||||
|
? async (event) => {
|
||||||
|
if (event.step === 'archive_ready') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await progress({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: `remote_run_${event.step}`,
|
||||||
|
fileName: event.fileName || '',
|
||||||
|
stageTitle: event.stageTitle,
|
||||||
|
stageDetail: event.stageDetail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_sync_attachments',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_sync_attachments_title',
|
||||||
|
stageDetail: destination.includeAttachments
|
||||||
|
? 'txt_backup_remote_run_progress_sync_attachments_detail'
|
||||||
|
: 'txt_backup_remote_run_progress_sync_attachments_skipped_detail',
|
||||||
|
});
|
||||||
|
const remoteSession = createRemoteBackupTransferSession(destination);
|
||||||
|
const remoteAttachmentIndex = await loadRemoteAttachmentIndex(remoteSession);
|
||||||
|
let attachmentIndexChanged = false;
|
||||||
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
for (const attachment of archive.manifest.attachmentBlobs || []) {
|
||||||
|
if (remoteAttachmentIndex.get(attachment.blobName) === attachment.sizeBytes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const remotePath = `attachments/${attachment.blobName}`;
|
const remotePath = `attachments/${attachment.blobName}`;
|
||||||
if (await remoteBackupFileExists(destination, remotePath)) continue;
|
|
||||||
const object = await getBlobObject(env, attachment.blobName);
|
const object = await getBlobObject(env, attachment.blobName);
|
||||||
if (!object) {
|
if (!object) {
|
||||||
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
|
throw new Error(`Attachment blob missing for ${attachment.blobName}`);
|
||||||
}
|
}
|
||||||
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
const bytes = new Uint8Array(await new Response(object.body).arrayBuffer());
|
||||||
await uploadRemoteBackupFile(destination, remotePath, bytes, {
|
await remoteSession.putFile(remotePath, bytes, {
|
||||||
contentType: object.contentType,
|
contentType: object.contentType,
|
||||||
});
|
});
|
||||||
|
remoteAttachmentIndex.set(attachment.blobName, attachment.sizeBytes);
|
||||||
|
attachmentIndexChanged = true;
|
||||||
|
}
|
||||||
|
if (attachmentIndexChanged) {
|
||||||
|
await saveRemoteAttachmentIndex(remoteSession, remoteAttachmentIndex);
|
||||||
|
}
|
||||||
|
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
|
||||||
|
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_upload_archive',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_upload_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_upload_detail',
|
||||||
|
});
|
||||||
|
upload = await remoteSession.uploadArchive(archive.bytes, archive.fileName);
|
||||||
|
try {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_verify_archive',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_verify_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_verify_detail',
|
||||||
|
});
|
||||||
|
const remoteFile = await remoteSession.download(archive.fileName);
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
|
||||||
|
if (!checksumOk) {
|
||||||
|
throw new Error('Remote backup ZIP checksum verification failed');
|
||||||
|
}
|
||||||
|
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
|
||||||
|
throw new Error('Remote backup ZIP size verification failed');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
} catch (error) {
|
||||||
|
await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
|
||||||
|
if (attempt === maxArchiveUploadAttempts) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Remote backup ZIP verification failed';
|
||||||
|
throw new Error(`Backup archive upload verification failed after ${maxArchiveUploadAttempts} attempts: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!upload) {
|
||||||
|
throw new Error('Backup archive upload failed');
|
||||||
}
|
}
|
||||||
const upload = await uploadBackupArchive(destination, archive.bytes, archive.fileName);
|
|
||||||
let prunedFileCount = 0;
|
let prunedFileCount = 0;
|
||||||
let pruneErrorMessage: string | null = null;
|
let pruneErrorMessage: string | null = null;
|
||||||
try {
|
try {
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_cleanup',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_cleanup_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_cleanup_detail',
|
||||||
|
});
|
||||||
prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName);
|
prunedFileCount = await pruneRemoteBackupArchives(destination, destination.schedule.retentionCount, archive.fileName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
|
||||||
@@ -137,10 +293,21 @@ async function executeConfiguredBackup(
|
|||||||
remotePath: upload.remotePath,
|
remotePath: upload.remotePath,
|
||||||
fileName: archive.fileName,
|
fileName: archive.fileName,
|
||||||
fileBytes: archive.bytes.byteLength,
|
fileBytes: archive.bytes.byteLength,
|
||||||
|
uploadVerificationAttempts: maxArchiveUploadAttempts,
|
||||||
prunedFileCount,
|
prunedFileCount,
|
||||||
pruneError: pruneErrorMessage,
|
pruneError: pruneErrorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_complete',
|
||||||
|
fileName: archive.fileName,
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_complete_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_complete_detail',
|
||||||
|
done: true,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fileName: archive.fileName,
|
fileName: archive.fileName,
|
||||||
fileSize: archive.bytes.byteLength,
|
fileSize: archive.bytes.byteLength,
|
||||||
@@ -156,6 +323,16 @@ async function executeConfiguredBackup(
|
|||||||
...getBackupDestinationSummary(destination),
|
...getBackupDestinationSummary(destination),
|
||||||
error: destination.runtime.lastErrorMessage,
|
error: destination.runtime.lastErrorMessage,
|
||||||
});
|
});
|
||||||
|
await progress?.({
|
||||||
|
operation: 'backup-remote-run',
|
||||||
|
step: 'remote_run_failed',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_remote_run_progress_failed_title',
|
||||||
|
stageDetail: 'txt_backup_remote_run_progress_failed_detail',
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: destination.runtime.lastErrorMessage,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,13 +347,35 @@ function toImportStatusCode(message: string): number {
|
|||||||
|
|
||||||
async function runImportAndAudit(
|
async function runImportAndAudit(
|
||||||
env: Env,
|
env: Env,
|
||||||
|
request: Request,
|
||||||
actorUser: User,
|
actorUser: User,
|
||||||
archiveBytes: Uint8Array,
|
archiveBytes: Uint8Array,
|
||||||
|
fileName: string,
|
||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
metadata: Record<string, unknown>
|
metadata: Record<string, unknown>
|
||||||
): Promise<BackupImportExecutionResult> {
|
): Promise<BackupImportExecutionResult> {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting);
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||||
|
await notifyUserBackupRestoreProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-restore',
|
||||||
|
...event,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
};
|
||||||
|
await progress({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_upload_received',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_upload_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_upload_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const imported = await importBackupArchiveBytes(archiveBytes, env, actorUser.id, replaceExisting, progress, fileName);
|
||||||
await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
await writeAuditLog(storage, imported.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||||
users: imported.result.imported.users,
|
users: imported.result.imported.users,
|
||||||
ciphers: imported.result.imported.ciphers,
|
ciphers: imported.result.imported.ciphers,
|
||||||
@@ -309,7 +508,20 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
|
|||||||
return errorResponse('Backup run payload is invalid', 400);
|
return errorResponse('Backup run payload is invalid', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null);
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
|
const progress = async (event: {
|
||||||
|
operation: 'backup-remote-run';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}) => {
|
||||||
|
await notifyUserBackupProgress(env, actorUser.id, event, targetDeviceIdentifier);
|
||||||
|
};
|
||||||
|
const result = await executeConfiguredBackup(env, storage, actorUser.id, 'manual', body?.destinationId || null, progress);
|
||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
object: 'backup-run',
|
object: 'backup-run',
|
||||||
@@ -369,6 +581,29 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
|
const storage = new StorageService(env.DB);
|
||||||
|
try {
|
||||||
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
|
||||||
|
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
|
||||||
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
|
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||||
|
return jsonResponse({
|
||||||
|
object: 'backup-remote-integrity',
|
||||||
|
destinationId: destination.id,
|
||||||
|
path,
|
||||||
|
fileName: remoteFile.fileName || path.split('/').pop() || path,
|
||||||
|
integrity,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error instanceof Error ? error.message : 'Remote backup integrity inspection failed', 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
@@ -392,7 +627,7 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
|
|||||||
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
let body: { destinationId?: string; path?: string; replaceExisting?: boolean };
|
let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean };
|
||||||
try {
|
try {
|
||||||
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -404,7 +639,39 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
|
|||||||
const settings = await loadBackupSettings(storage, env, 'UTC');
|
const settings = await loadBackupSettings(storage, env, 'UTC');
|
||||||
const destination = requireBackupDestination(settings, body.destinationId || null);
|
const destination = requireBackupDestination(settings, body.destinationId || null);
|
||||||
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
|
||||||
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
|
const restoreFileNameFromPath = path.split('/').pop() || path;
|
||||||
|
await notifyUserBackupRestoreProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-restore',
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_fetch_archive',
|
||||||
|
fileName: restoreFileNameFromPath,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_fetch_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_fetch_detail',
|
||||||
|
replaceExisting: !!body.replaceExisting,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||||
|
if (!checksumOk && !body.allowChecksumMismatch) {
|
||||||
|
return errorResponse('Remote backup file checksum does not match its filename', 400);
|
||||||
|
}
|
||||||
|
const restoreFileName = remoteFile.fileName || path.split('/').pop() || path;
|
||||||
|
const progress: BackupRestoreProgressReporter = async (event) => {
|
||||||
|
await notifyUserBackupRestoreProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-restore',
|
||||||
|
...event,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
};
|
||||||
const imported = await (async () => {
|
const imported = await (async () => {
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const result = await importRemoteBackupArchiveBytes(
|
const result = await importRemoteBackupArchiveBytes(
|
||||||
@@ -413,12 +680,13 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
|
|||||||
actorUser.id,
|
actorUser.id,
|
||||||
!!body.replaceExisting,
|
!!body.replaceExisting,
|
||||||
{
|
{
|
||||||
hasAttachment: async (blobName) => remoteBackupFileExists(destination, `attachments/${blobName}`),
|
loadAttachment: async (blobName) => {
|
||||||
loadAttachment: async (blobName) => {
|
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
|
||||||
const file = await downloadRemoteBackupFile(destination, `attachments/${blobName}`).catch(() => null);
|
return file?.bytes || null;
|
||||||
return file?.bytes || null;
|
},
|
||||||
},
|
},
|
||||||
}
|
progress,
|
||||||
|
restoreFileName
|
||||||
);
|
);
|
||||||
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
await writeAuditLog(storage, result.auditActorUserId, 'admin.backup.import', 'backup', null, {
|
||||||
users: result.result.imported.users,
|
users: result.result.imported.users,
|
||||||
@@ -431,6 +699,7 @@ export async function handleRestoreAdminRemoteBackup(request: Request, env: Env,
|
|||||||
remotePath: path,
|
remotePath: path,
|
||||||
bytes: remoteFile.bytes.byteLength,
|
bytes: remoteFile.bytes.byteLength,
|
||||||
trigger: 'remote',
|
trigger: 'remote',
|
||||||
|
checksumMismatchAccepted: !checksumOk,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
})();
|
})();
|
||||||
@@ -445,6 +714,7 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
|||||||
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
|
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
|
||||||
let body: { includeAttachments?: boolean } | null = null;
|
let body: { includeAttachments?: boolean } | null = null;
|
||||||
try {
|
try {
|
||||||
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
|
||||||
@@ -455,11 +725,49 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
|
|||||||
}
|
}
|
||||||
let archive: BackupArchiveBundle;
|
let archive: BackupArchiveBundle;
|
||||||
try {
|
try {
|
||||||
|
const progress = async (event: {
|
||||||
|
step: string;
|
||||||
|
fileName?: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
includeAttachments: boolean;
|
||||||
|
}) => {
|
||||||
|
await notifyUserBackupProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: `export_${event.step}`,
|
||||||
|
fileName: event.fileName || '',
|
||||||
|
stageTitle: event.stageTitle,
|
||||||
|
stageDetail: event.stageDetail,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
|
};
|
||||||
archive = await buildBackupArchive(env, new Date(), {
|
archive = await buildBackupArchive(env, new Date(), {
|
||||||
includeAttachments: !!body?.includeAttachments,
|
includeAttachments: !!body?.includeAttachments,
|
||||||
|
progress,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Backup export failed';
|
const message = error instanceof Error ? error.message : 'Backup export failed';
|
||||||
|
await notifyUserBackupProgress(
|
||||||
|
env,
|
||||||
|
actorUser.id,
|
||||||
|
{
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_failed',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_export_progress_failed_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_failed_detail',
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: message,
|
||||||
|
},
|
||||||
|
targetDeviceIdentifier
|
||||||
|
);
|
||||||
return errorResponse(message, message.includes('blob missing') ? 409 : 500);
|
return errorResponse(message, message.includes('blob missing') ? 409 : 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,6 +828,7 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
|
|||||||
}
|
}
|
||||||
|
|
||||||
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
||||||
|
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
|
||||||
let archiveBytes: Uint8Array;
|
let archiveBytes: Uint8Array;
|
||||||
try {
|
try {
|
||||||
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
|
archiveBytes = new Uint8Array(await (file as { arrayBuffer(): Promise<ArrayBuffer> }).arrayBuffer());
|
||||||
@@ -528,9 +837,15 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const imported = await runImportAndAudit(env, actorUser, archiveBytes, replaceExisting, {
|
const fileName = 'name' in file ? String((file as File).name || '') : '';
|
||||||
|
const checksumOk = await verifyBackupArchiveFileNameChecksum(archiveBytes, fileName);
|
||||||
|
if (!checksumOk && !allowChecksumMismatch) {
|
||||||
|
return errorResponse('Backup file checksum does not match its filename', 400);
|
||||||
|
}
|
||||||
|
const imported = await runImportAndAudit(env, request, actorUser, archiveBytes, fileName || 'nodewarden_backup.zip', replaceExisting, {
|
||||||
trigger: 'local',
|
trigger: 'local',
|
||||||
bytes: archiveBytes.byteLength,
|
bytes: archiveBytes.byteLength,
|
||||||
|
checksumMismatchAccepted: !checksumOk,
|
||||||
});
|
});
|
||||||
return jsonResponse(imported.result);
|
return jsonResponse(imported.result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+16
-6
@@ -7,6 +7,12 @@ import { deleteAllAttachmentsForCipher } from './attachments';
|
|||||||
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
import { parsePagination, encodeContinuationToken } from '../utils/pagination';
|
||||||
import { readActingDeviceIdentifier } from '../utils/device';
|
import { readActingDeviceIdentifier } from '../utils/device';
|
||||||
|
|
||||||
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function notifyVaultSyncForRequest(
|
async function notifyVaultSyncForRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
env: Env,
|
env: Env,
|
||||||
@@ -47,6 +53,7 @@ function syncCipherComputedAliases(cipher: Cipher): Cipher {
|
|||||||
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
||||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||||
|
cipher.folderId = normalizeOptionalId(cipher.folderId);
|
||||||
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
|
const hasArchivedAt = Object.prototype.hasOwnProperty.call(cipher as object, 'archivedAt');
|
||||||
cipher.archivedAt = hasArchivedAt
|
cipher.archivedAt = hasArchivedAt
|
||||||
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
|
? normalizeCipherTimestamp(cipher.archivedAt) ?? null
|
||||||
@@ -185,6 +192,7 @@ export function cipherToResponse(
|
|||||||
// Pass through ALL stored cipher fields (known + unknown)
|
// Pass through ALL stored cipher fields (known + unknown)
|
||||||
...passthrough,
|
...passthrough,
|
||||||
// Server-computed / enforced fields (always override)
|
// Server-computed / enforced fields (always override)
|
||||||
|
folderId: normalizeOptionalId(cipher.folderId),
|
||||||
type: Number(cipher.type) || 1,
|
type: Number(cipher.type) || 1,
|
||||||
organizationId: null,
|
organizationId: null,
|
||||||
organizationUseTotp: false,
|
organizationUseTotp: false,
|
||||||
@@ -499,11 +507,12 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (body.folderId !== undefined) {
|
if (body.folderId !== undefined) {
|
||||||
if (body.folderId) {
|
const folderId = normalizeOptionalId(body.folderId);
|
||||||
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
|
if (folderId) {
|
||||||
|
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
|
||||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
cipher.folderId = body.folderId;
|
cipher.folderId = folderId;
|
||||||
}
|
}
|
||||||
if (body.favorite !== undefined) {
|
if (body.favorite !== undefined) {
|
||||||
cipher.favorite = body.favorite;
|
cipher.favorite = body.favorite;
|
||||||
@@ -537,12 +546,13 @@ export async function handleBulkMoveCiphers(request: Request, env: Env, userId:
|
|||||||
return errorResponse('ids array is required', 400);
|
return errorResponse('ids array is required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.folderId) {
|
const folderId = normalizeOptionalId(body.folderId);
|
||||||
const folderOk = await verifyFolderOwnership(storage, body.folderId, userId);
|
if (folderId) {
|
||||||
|
const folderOk = await verifyFolderOwnership(storage, folderId, userId);
|
||||||
if (!folderOk) return errorResponse('Folder not found', 404);
|
if (!folderOk) return errorResponse('Folder not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const revisionDate = await storage.bulkMoveCiphers(body.ids, body.folderId || null, userId);
|
const revisionDate = await storage.bulkMoveCiphers(body.ids, folderId, userId);
|
||||||
if (revisionDate) {
|
if (revisionDate) {
|
||||||
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
await notifyVaultSyncForRequest(request, env, userId, revisionDate);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
handleDownloadAdminBackupAttachment,
|
handleDownloadAdminBackupAttachment,
|
||||||
handleGetAdminBackupSettings,
|
handleGetAdminBackupSettings,
|
||||||
handleGetAdminBackupSettingsRepairState,
|
handleGetAdminBackupSettingsRepairState,
|
||||||
|
handleInspectAdminRemoteBackup,
|
||||||
handleAdminImportBackup,
|
handleAdminImportBackup,
|
||||||
handleListAdminRemoteBackups,
|
handleListAdminRemoteBackups,
|
||||||
handleRepairAdminBackupSettings,
|
handleRepairAdminBackupSettings,
|
||||||
@@ -53,6 +54,10 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
|
||||||
|
return handleInspectAdminRemoteBackup(request, env, actorUser);
|
||||||
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
|
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
|
||||||
return handleDeleteAdminRemoteBackup(request, env, actorUser);
|
return handleDeleteAdminRemoteBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type SqlRow = Record<string, string | number | null>;
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
|
||||||
const BACKUP_FORMAT_VERSION = 1;
|
const BACKUP_FORMAT_VERSION = 1;
|
||||||
|
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||||
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
// Worker-side backup export must stay well below Cloudflare CPU limits.
|
||||||
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
// Prefer store-only ZIP entries over heavier compression to keep exports reliable.
|
||||||
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
|
||||||
@@ -60,16 +61,39 @@ export interface BackupArchiveBundle {
|
|||||||
manifest: BackupManifest;
|
manifest: BackupManifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupFileIntegrityCheckResult {
|
||||||
|
hasChecksumPrefix: boolean;
|
||||||
|
expectedPrefix: string | null;
|
||||||
|
actualPrefix: string;
|
||||||
|
matches: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BuildBackupArchiveOptions {
|
export interface BuildBackupArchiveOptions {
|
||||||
includeAttachments?: boolean;
|
includeAttachments?: boolean;
|
||||||
|
progress?: BackupArchiveBuildProgressReporter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupArchiveBuildProgressEvent {
|
||||||
|
step: string;
|
||||||
|
fileName?: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
includeAttachments: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupArchiveBuildProgressReporter = (event: BackupArchiveBuildProgressEvent) => Promise<void>;
|
||||||
|
|
||||||
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||||
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
|
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||||
return (result.results || []).map((row) => ({ ...row }));
|
return (result.results || []).map((row) => ({ ...row }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBackupFileName(date: Date = new Date()): string {
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFileName(date: Date = new Date(), checksumPrefix: string | null = null): string {
|
||||||
const parts = [
|
const parts = [
|
||||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||||
@@ -78,7 +102,34 @@ function buildBackupFileName(date: Date = new Date()): string {
|
|||||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||||
];
|
];
|
||||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}.zip`;
|
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
||||||
|
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}${suffix}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||||
|
const normalized = String(fileName || '').trim();
|
||||||
|
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
|
||||||
|
return match ? match[1].toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectBackupArchiveFileNameChecksum(
|
||||||
|
bytes: Uint8Array,
|
||||||
|
fileName: string
|
||||||
|
): Promise<BackupFileIntegrityCheckResult> {
|
||||||
|
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
|
||||||
|
const actualHash = await sha256Hex(bytes);
|
||||||
|
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||||
|
return {
|
||||||
|
hasChecksumPrefix: !!expectedPrefix,
|
||||||
|
expectedPrefix,
|
||||||
|
actualPrefix,
|
||||||
|
matches: !expectedPrefix || actualPrefix === expectedPrefix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyBackupArchiveFileNameChecksum(bytes: Uint8Array, fileName: string): Promise<boolean> {
|
||||||
|
const result = await inspectBackupArchiveFileNameChecksum(bytes, fileName);
|
||||||
|
return result.matches;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateArchiveSize(bytes: Uint8Array): void {
|
function validateArchiveSize(bytes: Uint8Array): void {
|
||||||
@@ -269,16 +320,25 @@ export async function buildBackupArchive(
|
|||||||
date: Date = new Date(),
|
date: Date = new Date(),
|
||||||
options: BuildBackupArchiveOptions = {}
|
options: BuildBackupArchiveOptions = {}
|
||||||
): Promise<BackupArchiveBundle> {
|
): Promise<BackupArchiveBundle> {
|
||||||
|
const includeAttachments = options.includeAttachments !== false;
|
||||||
|
await options.progress?.({
|
||||||
|
step: 'collect_data',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_archive_progress_collect_title',
|
||||||
|
stageDetail: includeAttachments
|
||||||
|
? 'txt_backup_archive_progress_collect_with_attachments_detail'
|
||||||
|
: 'txt_backup_archive_progress_collect_detail',
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows] = await Promise.all([
|
||||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, verify_devices, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
]);
|
]);
|
||||||
const includeAttachments = options.includeAttachments !== false;
|
|
||||||
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
const exportedAttachmentRows = includeAttachments ? attachmentRows : [];
|
||||||
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
const attachmentBlobs: BackupManifestAttachmentBlob[] = exportedAttachmentRows.map((row) => {
|
||||||
const cipherId = String(row.cipher_id || '').trim();
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
@@ -327,9 +387,29 @@ export async function buildBackupArchive(
|
|||||||
}, null, BACKUP_JSON_INDENT)),
|
}, null, BACKUP_JSON_INDENT)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await options.progress?.({
|
||||||
|
step: 'package_archive',
|
||||||
|
fileName: '',
|
||||||
|
stageTitle: 'txt_backup_archive_progress_package_title',
|
||||||
|
stageDetail: includeAttachments
|
||||||
|
? 'txt_backup_archive_progress_package_with_attachments_detail'
|
||||||
|
: 'txt_backup_archive_progress_package_detail',
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
|
const bytes = zipSync(createZipEntries(files));
|
||||||
|
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||||
|
const fileName = buildBackupFileName(date, fileHashPrefix);
|
||||||
|
await options.progress?.({
|
||||||
|
step: 'archive_ready',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_archive_progress_ready_title',
|
||||||
|
stageDetail: 'txt_backup_archive_progress_ready_detail',
|
||||||
|
includeAttachments,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bytes: zipSync(createZipEntries(files)),
|
bytes,
|
||||||
fileName: buildBackupFileName(date),
|
fileName,
|
||||||
manifest: manifestBase,
|
manifest: manifestBase,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Env } from '../types';
|
import type { Env, User } from '../types';
|
||||||
import { StorageService } from './storage';
|
import { StorageService } from './storage';
|
||||||
import {
|
import {
|
||||||
type BackupSettingsPortableEnvelope,
|
type BackupSettingsPortableEnvelope,
|
||||||
@@ -422,20 +422,45 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
|
|||||||
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> {
|
||||||
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
if (!raw) return;
|
if (!raw) return;
|
||||||
|
const users = await storage.getAllUsers();
|
||||||
|
const normalized = await normalizeImportedBackupSettingsValue(raw, env, users, fallbackTimezone);
|
||||||
|
if (normalized !== null) {
|
||||||
|
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeImportedBackupSettingsValue(
|
||||||
|
raw: string | null,
|
||||||
|
env: Env,
|
||||||
|
users: Pick<User, 'id' | 'publicKey' | 'role' | 'status'>[],
|
||||||
|
fallbackTimezone: string = 'UTC'
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!raw) return null;
|
||||||
const envelope = parseBackupSettingsEnvelope(raw);
|
const envelope = parseBackupSettingsEnvelope(raw);
|
||||||
if (envelope) {
|
if (envelope) {
|
||||||
try {
|
try {
|
||||||
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
const decrypted = await decryptBackupSettingsRuntime(raw, env);
|
||||||
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
const settings = parseBackupSettings(decrypted, fallbackTimezone);
|
||||||
await saveBackupSettings(storage, env, settings);
|
const hasPortableAdmins = users.some(
|
||||||
return;
|
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||||
|
);
|
||||||
|
if (!hasPortableAdmins) {
|
||||||
|
return serializeBackupSettings(settings);
|
||||||
|
}
|
||||||
|
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
// Keep imported portable recovery data intact until an admin signs in and repairs it.
|
||||||
return;
|
return raw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const settings = parseBackupSettings(raw, fallbackTimezone);
|
const settings = parseBackupSettings(raw, fallbackTimezone);
|
||||||
await saveBackupSettings(storage, env, settings);
|
const hasPortableAdmins = users.some(
|
||||||
|
(user) => user.role === 'admin' && user.status === 'active' && typeof user.publicKey === 'string' && user.publicKey.trim().length > 0
|
||||||
|
);
|
||||||
|
if (!hasPortableAdmins) {
|
||||||
|
return serializeBackupSettings(settings);
|
||||||
|
}
|
||||||
|
return encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
|
export async function getBackupSettingsRepairState(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettingsRepairState> {
|
||||||
|
|||||||
+464
-110
@@ -1,7 +1,6 @@
|
|||||||
import type { Env } from '../types';
|
import type { Env, User } from '../types';
|
||||||
import { StorageService } from './storage';
|
|
||||||
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobStorageKind, putBlobObject } from './blob-store';
|
||||||
import { normalizeImportedBackupSettings } from './backup-config';
|
import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from './backup-config';
|
||||||
import {
|
import {
|
||||||
type BackupManifestAttachmentBlob,
|
type BackupManifestAttachmentBlob,
|
||||||
type BackupPayload,
|
type BackupPayload,
|
||||||
@@ -10,6 +9,26 @@ import {
|
|||||||
} from './backup-archive';
|
} from './backup-archive';
|
||||||
|
|
||||||
type SqlRow = Record<string, string | number | null>;
|
type SqlRow = Record<string, string | number | null>;
|
||||||
|
type BackupTableName =
|
||||||
|
| 'config'
|
||||||
|
| 'users'
|
||||||
|
| 'user_revisions'
|
||||||
|
| 'folders'
|
||||||
|
| 'ciphers'
|
||||||
|
| 'attachments';
|
||||||
|
|
||||||
|
const BACKUP_TABLES: BackupTableName[] = [
|
||||||
|
'config',
|
||||||
|
'users',
|
||||||
|
'user_revisions',
|
||||||
|
'folders',
|
||||||
|
'ciphers',
|
||||||
|
'attachments',
|
||||||
|
];
|
||||||
|
|
||||||
|
function shadowTableName(table: BackupTableName): string {
|
||||||
|
return `${table}__restore`;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BackupImportResultBody {
|
export interface BackupImportResultBody {
|
||||||
object: 'instance-backup-import';
|
object: 'instance-backup-import';
|
||||||
@@ -43,6 +62,81 @@ async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Pro
|
|||||||
return (response.results || []).map((row) => ({ ...row }));
|
return (response.results || []).map((row) => ({ ...row }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getTableCreateSql(db: D1Database, table: BackupTableName): Promise<string> {
|
||||||
|
const row = await db
|
||||||
|
.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||||
|
.bind(table)
|
||||||
|
.first<{ sql: string | null }>();
|
||||||
|
const sql = String(row?.sql || '').trim();
|
||||||
|
if (!sql) {
|
||||||
|
throw new Error(`Restore shadow schema is missing table definition for ${table}`);
|
||||||
|
}
|
||||||
|
return sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildShadowTableCreateSql(createSql: string, table: BackupTableName): string {
|
||||||
|
const tablePattern = new RegExp(`^CREATE TABLE(?:\\s+IF NOT EXISTS)?\\s+(?:\"${table}\"|${table})(?=\\s*\\()`, 'i');
|
||||||
|
let next = createSql.replace(tablePattern, `CREATE TABLE "${shadowTableName(table)}"`);
|
||||||
|
if (next === createSql) {
|
||||||
|
throw new Error(`Restore shadow schema could not rewrite CREATE TABLE statement for ${table}`);
|
||||||
|
}
|
||||||
|
for (const currentTable of BACKUP_TABLES) {
|
||||||
|
const referencePattern = new RegExp(`\\bREFERENCES\\s+(?:\"${currentTable}\"|${currentTable})(?=\\s*\\()`, 'gi');
|
||||||
|
next = next.replace(
|
||||||
|
referencePattern,
|
||||||
|
`REFERENCES "${shadowTableName(currentTable)}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetRestoreArtifacts(db: D1Database): Promise<void> {
|
||||||
|
const dropStatements = BACKUP_TABLES
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((table) => db.prepare(`DROP TABLE IF EXISTS ${shadowTableName(table)}`));
|
||||||
|
if (dropStatements.length) {
|
||||||
|
await db.batch(dropStatements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createShadowTables(db: D1Database): Promise<void> {
|
||||||
|
const createStatements: D1PreparedStatement[] = [];
|
||||||
|
for (const table of BACKUP_TABLES) {
|
||||||
|
const createSql = await getTableCreateSql(db, table);
|
||||||
|
createStatements.push(db.prepare(buildShadowTableCreateSql(createSql, table)));
|
||||||
|
}
|
||||||
|
await db.batch(createStatements);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateShadowTableCounts(
|
||||||
|
db: D1Database,
|
||||||
|
expectedCounts: Partial<Record<BackupTableName, number>>
|
||||||
|
): Promise<void> {
|
||||||
|
await Promise.all(BACKUP_TABLES.map(async (table) => {
|
||||||
|
const expected = expectedCounts[table] ?? 0;
|
||||||
|
const row = await db.prepare(`SELECT COUNT(*) AS count FROM ${shadowTableName(table)}`).first<{ count: number }>();
|
||||||
|
const actual = Number(row?.count || 0);
|
||||||
|
if (actual !== expected) {
|
||||||
|
throw new Error(`Restore shadow validation failed for ${table}: expected ${expected}, received ${actual}`);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function swapShadowTablesIntoPlace(db: D1Database): Promise<void> {
|
||||||
|
const statements: D1PreparedStatement[] = [];
|
||||||
|
// Commit by replacing live table contents from validated shadow tables.
|
||||||
|
// This avoids D1 schema-rename edge cases while keeping current data intact
|
||||||
|
// until the final batch succeeds.
|
||||||
|
for (const sql of buildResetImportTargetStatements(db)) {
|
||||||
|
statements.push(sql);
|
||||||
|
}
|
||||||
|
for (const table of BACKUP_TABLES) {
|
||||||
|
statements.push(db.prepare(`INSERT INTO ${table} SELECT * FROM ${shadowTableName(table)}`));
|
||||||
|
}
|
||||||
|
await db.batch(statements);
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
|
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
|
||||||
const counts = await Promise.all([
|
const counts = await Promise.all([
|
||||||
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
||||||
@@ -61,18 +155,9 @@ function buildResetImportTargetStatements(db: D1Database): D1PreparedStatement[]
|
|||||||
'DELETE FROM attachments',
|
'DELETE FROM attachments',
|
||||||
'DELETE FROM ciphers',
|
'DELETE FROM ciphers',
|
||||||
'DELETE FROM folders',
|
'DELETE FROM folders',
|
||||||
'DELETE FROM sends',
|
|
||||||
'DELETE FROM trusted_two_factor_device_tokens',
|
|
||||||
'DELETE FROM devices',
|
|
||||||
'DELETE FROM refresh_tokens',
|
|
||||||
'DELETE FROM invites',
|
|
||||||
'DELETE FROM audit_logs',
|
|
||||||
'DELETE FROM user_revisions',
|
'DELETE FROM user_revisions',
|
||||||
'DELETE FROM users',
|
'DELETE FROM users',
|
||||||
'DELETE FROM config',
|
'DELETE FROM config',
|
||||||
'DELETE FROM login_attempts_ip',
|
|
||||||
'DELETE FROM api_rate_limits',
|
|
||||||
'DELETE FROM used_attachment_download_tokens',
|
|
||||||
].map((sql) => db.prepare(sql));
|
].map((sql) => db.prepare(sql));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,10 +204,90 @@ interface AttachmentRestoreResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface RemoteAttachmentSource {
|
interface RemoteAttachmentSource {
|
||||||
hasAttachment(blobName: string): Promise<boolean>;
|
|
||||||
loadAttachment(blobName: string): Promise<Uint8Array | null>;
|
loadAttachment(blobName: string): Promise<Uint8Array | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupRestoreProgressEvent {
|
||||||
|
source: 'local' | 'remote';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
replaceExisting: boolean;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupRestoreProgressReporter = (event: BackupRestoreProgressEvent) => Promise<void> | void;
|
||||||
|
|
||||||
|
function attachmentRowKey(row: SqlRow): string {
|
||||||
|
const attachmentId = String(row.id || '').trim();
|
||||||
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
|
return `${cipherId}/${attachmentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneRows(rows: SqlRow[]): SqlRow[] {
|
||||||
|
return rows.map((row) => ({ ...row }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
||||||
|
let replaced = false;
|
||||||
|
const nextRows = rows.map((row) => {
|
||||||
|
if (String(row.key || '').trim() !== key) return { ...row };
|
||||||
|
replaced = true;
|
||||||
|
return { ...row, key, value };
|
||||||
|
});
|
||||||
|
if (!replaced) {
|
||||||
|
nextRows.push({ key, value });
|
||||||
|
}
|
||||||
|
return nextRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareImportedConfigRows(
|
||||||
|
env: Env,
|
||||||
|
configRows: SqlRow[],
|
||||||
|
userRows: SqlRow[]
|
||||||
|
): Promise<SqlRow[]> {
|
||||||
|
let nextConfigRows = cloneRows(configRows || []);
|
||||||
|
const rawBackupSettings = nextConfigRows.find((row) => String(row.key || '').trim() === BACKUP_SETTINGS_CONFIG_KEY);
|
||||||
|
const normalizedBackupSettings = await normalizeImportedBackupSettingsValue(
|
||||||
|
typeof rawBackupSettings?.value === 'string' ? rawBackupSettings.value : null,
|
||||||
|
env,
|
||||||
|
userRows.map((row) => ({
|
||||||
|
id: String(row.id || '').trim(),
|
||||||
|
publicKey: typeof row.public_key === 'string' ? row.public_key : null,
|
||||||
|
role: String(row.role || '').trim() as User['role'],
|
||||||
|
status: String(row.status || '').trim() as User['status'],
|
||||||
|
})),
|
||||||
|
'UTC'
|
||||||
|
);
|
||||||
|
if (normalizedBackupSettings !== null) {
|
||||||
|
nextConfigRows = upsertConfigRow(nextConfigRows, BACKUP_SETTINGS_CONFIG_KEY, normalizedBackupSettings);
|
||||||
|
}
|
||||||
|
nextConfigRows = upsertConfigRow(nextConfigRows, 'registered', 'true');
|
||||||
|
return nextConfigRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['db'], env: Env): Promise<BackupPayload['db']> {
|
||||||
|
const preparedDb: BackupPayload['db'] = {
|
||||||
|
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
|
||||||
|
users: cloneRows(payload.users || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
verify_devices: row.verify_devices ?? 1,
|
||||||
|
})),
|
||||||
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
|
folders: cloneRows(payload.folders || []),
|
||||||
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
archived_at: row.archived_at ?? null,
|
||||||
|
})),
|
||||||
|
attachments: cloneRows(payload.attachments || []),
|
||||||
|
};
|
||||||
|
await importBackupRows(db, preparedDb, true);
|
||||||
|
return preparedDb;
|
||||||
|
}
|
||||||
|
|
||||||
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
|
function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): PreparedBackupImportPayload {
|
||||||
const storageKind = getBlobStorageKind(env);
|
const storageKind = getBlobStorageKind(env);
|
||||||
if (storageKind === 'r2') {
|
if (storageKind === 'r2') {
|
||||||
@@ -147,7 +312,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
payload: {
|
payload: {
|
||||||
...payload,
|
...payload,
|
||||||
db: {
|
db: {
|
||||||
@@ -161,6 +326,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
|||||||
items: skippedItems,
|
items: skippedItems,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oversizedAttachmentPaths = new Set<string>();
|
const oversizedAttachmentPaths = new Set<string>();
|
||||||
@@ -197,7 +363,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
|||||||
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
throw new Error('Backup restore requires ATTACHMENTS_KV when using KV blob storage');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
payload: nextPayload,
|
payload: nextPayload,
|
||||||
skipped: {
|
skipped: {
|
||||||
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
|
reason: skippedItems.length ? KV_BLOB_SKIP_REASON : null,
|
||||||
@@ -205,6 +371,7 @@ function prepareImportPayloadForTarget(env: Env, payload: BackupPayload, files:
|
|||||||
items: skippedItems,
|
items: skippedItems,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
function buildInsertStatements(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): D1PreparedStatement[] {
|
||||||
@@ -214,6 +381,16 @@ function buildInsertStatements(db: D1Database, table: string, columns: string[],
|
|||||||
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
|
return rows.map((row) => db.prepare(sql).bind(...columns.map((column) => row[column] ?? null)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runInsertBatch(db: D1Database, table: string, statements: D1PreparedStatement[]): Promise<void> {
|
||||||
|
if (!statements.length) return;
|
||||||
|
try {
|
||||||
|
await db.batch(statements);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`Restore insert failed for ${table}: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
|
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<AttachmentRestoreResult> {
|
||||||
const restoredAttachments: SqlRow[] = [];
|
const restoredAttachments: SqlRow[] = [];
|
||||||
const skippedItems: BackupImportSkipSummary['items'] = [];
|
const skippedItems: BackupImportSkipSummary['items'] = [];
|
||||||
@@ -300,14 +477,10 @@ async function prepareRemoteAttachmentPayload(
|
|||||||
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!(await source.hasAttachment(ref.blobName))) {
|
|
||||||
skippedItems.push({ kind: 'attachment', path, sizeBytes });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
nextAttachments.push(row);
|
nextAttachments.push(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const result = {
|
||||||
payload: {
|
payload: {
|
||||||
...payload,
|
...payload,
|
||||||
db: {
|
db: {
|
||||||
@@ -321,16 +494,18 @@ async function prepareRemoteAttachmentPayload(
|
|||||||
items: skippedItems,
|
items: skippedItems,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[]): Promise<void> {
|
async function removeAttachmentRows(db: D1Database, attachmentRows: SqlRow[], useShadowTable: boolean = false): Promise<void> {
|
||||||
if (!attachmentRows.length) return;
|
if (!attachmentRows.length) return;
|
||||||
|
const tableName = useShadowTable ? shadowTableName('attachments') : 'attachments';
|
||||||
const statements = attachmentRows
|
const statements = attachmentRows
|
||||||
.map((row) => {
|
.map((row) => {
|
||||||
const attachmentId = String(row.id || '').trim();
|
const attachmentId = String(row.id || '').trim();
|
||||||
const cipherId = String(row.cipher_id || '').trim();
|
const cipherId = String(row.cipher_id || '').trim();
|
||||||
if (!attachmentId || !cipherId) return null;
|
if (!attachmentId || !cipherId) return null;
|
||||||
return db.prepare('DELETE FROM attachments WHERE id = ? AND cipher_id = ?').bind(attachmentId, cipherId);
|
return db.prepare(`DELETE FROM ${tableName} WHERE id = ? AND cipher_id = ?`).bind(attachmentId, cipherId);
|
||||||
})
|
})
|
||||||
.filter((statement): statement is D1PreparedStatement => !!statement);
|
.filter((statement): statement is D1PreparedStatement => !!statement);
|
||||||
if (!statements.length) return;
|
if (!statements.length) return;
|
||||||
@@ -406,36 +581,58 @@ async function cleanupOrphanedBlobFiles(env: Env, beforeKeys: Set<string>, after
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importBackupRows(db: D1Database, payload: BackupPayload['db']): Promise<void> {
|
async function importBackupRows(db: D1Database, payload: BackupPayload['db'], useShadowTables: boolean = false): Promise<void> {
|
||||||
const statements: D1PreparedStatement[] = [
|
const tableName = (table: BackupTableName): string => (useShadowTables ? shadowTableName(table) : table);
|
||||||
...buildResetImportTargetStatements(db),
|
await runInsertBatch(
|
||||||
...buildInsertStatements(db, 'config', ['key', 'value'], payload.config || [], true),
|
db,
|
||||||
...buildInsertStatements(
|
tableName('config'),
|
||||||
|
buildInsertStatements(db, tableName('config'), ['key', 'value'], payload.config || [], true)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('users'),
|
||||||
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
'users',
|
tableName('users'),
|
||||||
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
['id', 'email', 'name', 'master_password_hint', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'verify_devices', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||||
payload.users || []
|
payload.users || []
|
||||||
),
|
)
|
||||||
...buildInsertStatements(db, 'user_revisions', ['user_id', 'revision_date'], payload.user_revisions || [], true),
|
);
|
||||||
...buildInsertStatements(db, 'folders', ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || []),
|
await runInsertBatch(
|
||||||
...buildInsertStatements(
|
db,
|
||||||
|
tableName('user_revisions'),
|
||||||
|
buildInsertStatements(db, tableName('user_revisions'), ['user_id', 'revision_date'], payload.user_revisions || [], true)
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('folders'),
|
||||||
|
buildInsertStatements(db, tableName('folders'), ['id', 'user_id', 'name', 'created_at', 'updated_at'], payload.folders || [])
|
||||||
|
);
|
||||||
|
await runInsertBatch(
|
||||||
|
db,
|
||||||
|
tableName('ciphers'),
|
||||||
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
'ciphers',
|
tableName('ciphers'),
|
||||||
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
|
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'archived_at', 'deleted_at'],
|
||||||
payload.ciphers || []
|
payload.ciphers || []
|
||||||
),
|
)
|
||||||
...buildInsertStatements(db, 'attachments', ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || []),
|
);
|
||||||
];
|
await runInsertBatch(
|
||||||
await db.batch(statements);
|
db,
|
||||||
|
tableName('attachments'),
|
||||||
|
buildInsertStatements(db, tableName('attachments'), ['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'], payload.attachments || [])
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importBackupArchiveBytes(
|
export async function importBackupArchiveBytes(
|
||||||
archiveBytes: Uint8Array,
|
archiveBytes: Uint8Array,
|
||||||
env: Env,
|
env: Env,
|
||||||
actorUserId: string,
|
actorUserId: string,
|
||||||
replaceExisting: boolean
|
replaceExisting: boolean,
|
||||||
|
progress?: BackupRestoreProgressReporter,
|
||||||
|
fileName: string = 'nodewarden_backup.zip'
|
||||||
): Promise<BackupImportExecutionResult> {
|
): Promise<BackupImportExecutionResult> {
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const parsed = parseBackupArchive(archiveBytes);
|
const parsed = parseBackupArchive(archiveBytes);
|
||||||
validateBackupPayloadContents(parsed.payload, parsed.files);
|
validateBackupPayloadContents(parsed.payload, parsed.files);
|
||||||
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
|
const prepared = prepareImportPayloadForTarget(env, parsed.payload, parsed.files);
|
||||||
@@ -448,40 +645,118 @@ export async function importBackupArchiveBytes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await resetRestoreArtifacts(env.DB);
|
||||||
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||||
const { db } = prepared.payload;
|
try {
|
||||||
await importBackupRows(env.DB, db);
|
await progress?.({
|
||||||
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
source: 'local',
|
||||||
|
step: 'local_create_shadow',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_shadow_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_shadow_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await createShadowTables(env.DB);
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_import_data',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_data_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_data_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const db = await importPreparedBackupRows(env.DB, prepared.payload.db, env);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: (db.attachments || []).length,
|
||||||
|
});
|
||||||
|
|
||||||
const restored = await restoreBlobFiles(env, db, parsed.files);
|
await progress?.({
|
||||||
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
|
source: 'local',
|
||||||
await removeAttachmentRows(env.DB, failedRestoreRows);
|
step: 'local_restore_files',
|
||||||
if (replaceExisting && previousBlobKeys.size) {
|
fileName,
|
||||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
|
stageTitle: 'txt_backup_restore_progress_local_files_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_files_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const restored = await restoreBlobFiles(env, db, parsed.files);
|
||||||
|
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
|
||||||
|
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
|
||||||
|
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
});
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_finalize',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await swapShadowTablesIntoPlace(env.DB);
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
if (replaceExisting && previousBlobKeys.size) {
|
||||||
|
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||||
|
if (nextBlobKeys) {
|
||||||
|
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_complete',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||||
|
result: {
|
||||||
|
object: 'instance-backup-import',
|
||||||
|
imported: {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
attachmentFiles: restored.imported,
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
reason: restored.skipped.reason || prepared.skipped.reason,
|
||||||
|
attachments: prepared.skipped.attachments + restored.skipped.attachments,
|
||||||
|
items: [...prepared.skipped.items, ...restored.skipped.items],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await progress?.({
|
||||||
|
source: 'local',
|
||||||
|
step: 'local_failed',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_local_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_local_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
await storage.setRegistered();
|
|
||||||
|
|
||||||
return {
|
|
||||||
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
|
||||||
result: {
|
|
||||||
object: 'instance-backup-import',
|
|
||||||
imported: {
|
|
||||||
config: (db.config || []).length,
|
|
||||||
users: (db.users || []).length,
|
|
||||||
userRevisions: (db.user_revisions || []).length,
|
|
||||||
folders: (db.folders || []).length,
|
|
||||||
ciphers: (db.ciphers || []).length,
|
|
||||||
attachments: restored.restoredAttachments.length,
|
|
||||||
attachmentFiles: restored.imported,
|
|
||||||
},
|
|
||||||
skipped: {
|
|
||||||
reason: restored.skipped.reason || prepared.skipped.reason,
|
|
||||||
attachments: prepared.skipped.attachments + restored.skipped.attachments,
|
|
||||||
items: [...prepared.skipped.items, ...restored.skipped.items],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importRemoteBackupArchiveBytes(
|
export async function importRemoteBackupArchiveBytes(
|
||||||
@@ -489,9 +764,10 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
env: Env,
|
env: Env,
|
||||||
actorUserId: string,
|
actorUserId: string,
|
||||||
replaceExisting: boolean,
|
replaceExisting: boolean,
|
||||||
source: RemoteAttachmentSource
|
source: RemoteAttachmentSource,
|
||||||
|
progress?: BackupRestoreProgressReporter,
|
||||||
|
fileName: string = 'nodewarden_backup.zip'
|
||||||
): Promise<BackupImportExecutionResult> {
|
): Promise<BackupImportExecutionResult> {
|
||||||
const storage = new StorageService(env.DB);
|
|
||||||
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
|
const parsed = parseBackupArchive(archiveBytes, { allowExternalAttachmentBlobs: true });
|
||||||
const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source);
|
const preparedRemote = await prepareRemoteAttachmentPayload(env, parsed.payload, parsed.files, source);
|
||||||
validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true });
|
validateBackupPayloadContents(preparedRemote.payload, parsed.files, { allowExternalAttachmentBlobs: true });
|
||||||
@@ -504,44 +780,122 @@ export async function importRemoteBackupArchiveBytes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await resetRestoreArtifacts(env.DB);
|
||||||
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
const previousBlobKeys = replaceExisting ? await collectCurrentBlobKeys(env.DB) : new Set<string>();
|
||||||
const { db } = preparedRemote.payload;
|
try {
|
||||||
await importBackupRows(env.DB, db);
|
await progress?.({
|
||||||
await normalizeImportedBackupSettings(storage, env, 'UTC');
|
source: 'remote',
|
||||||
|
step: 'remote_create_shadow',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_shadow_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_shadow_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await createShadowTables(env.DB);
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_import_data',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_data_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_data_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const db = await importPreparedBackupRows(env.DB, preparedRemote.payload.db, env);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: (db.attachments || []).length,
|
||||||
|
});
|
||||||
|
|
||||||
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
|
await progress?.({
|
||||||
const failedRestoreRows = (db.attachments || []).filter((row) => !restored.restoredAttachments.includes(row));
|
source: 'remote',
|
||||||
await removeAttachmentRows(env.DB, failedRestoreRows);
|
step: 'remote_restore_files',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_files_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_files_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
const restored = await restoreRemoteAttachmentFiles(env, preparedRemote.payload, parsed.files, source);
|
||||||
|
const restoredAttachmentKeys = new Set((restored.restoredAttachments || []).map(attachmentRowKey));
|
||||||
|
const failedRestoreRows = (db.attachments || []).filter((row) => !restoredAttachmentKeys.has(attachmentRowKey(row)));
|
||||||
|
await removeAttachmentRows(env.DB, failedRestoreRows, true).catch(() => undefined);
|
||||||
|
await validateShadowTableCounts(env.DB, {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
user_revisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
});
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_finalize',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
});
|
||||||
|
await swapShadowTablesIntoPlace(env.DB);
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
|
||||||
if (replaceExisting && previousBlobKeys.size) {
|
if (replaceExisting && previousBlobKeys.size) {
|
||||||
await cleanupOrphanedBlobFiles(env, previousBlobKeys, await collectCurrentBlobKeys(env.DB));
|
const nextBlobKeys = await collectCurrentBlobKeys(env.DB).catch(() => null);
|
||||||
|
if (nextBlobKeys) {
|
||||||
|
await cleanupOrphanedBlobFiles(env, previousBlobKeys, nextBlobKeys).catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_complete',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
|
const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items];
|
||||||
|
const finalSkippedReason = finalSkippedItems.length
|
||||||
|
? restored.skipped.reason || preparedRemote.skipped.reason
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
||||||
|
result: {
|
||||||
|
object: 'instance-backup-import',
|
||||||
|
imported: {
|
||||||
|
config: (db.config || []).length,
|
||||||
|
users: (db.users || []).length,
|
||||||
|
userRevisions: (db.user_revisions || []).length,
|
||||||
|
folders: (db.folders || []).length,
|
||||||
|
ciphers: (db.ciphers || []).length,
|
||||||
|
attachments: restored.restoredAttachments.length,
|
||||||
|
attachmentFiles: restored.imported,
|
||||||
|
},
|
||||||
|
skipped: {
|
||||||
|
reason: finalSkippedReason,
|
||||||
|
attachments: finalSkippedItems.length,
|
||||||
|
items: finalSkippedItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
await progress?.({
|
||||||
|
source: 'remote',
|
||||||
|
step: 'remote_failed',
|
||||||
|
fileName,
|
||||||
|
stageTitle: 'txt_backup_restore_progress_remote_finalize_title',
|
||||||
|
stageDetail: 'txt_backup_restore_progress_remote_finalize_detail',
|
||||||
|
replaceExisting,
|
||||||
|
done: true,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
await resetRestoreArtifacts(env.DB).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
await storage.setRegistered();
|
|
||||||
|
|
||||||
const finalSkippedItems = [...preparedRemote.skipped.items, ...restored.skipped.items];
|
|
||||||
const finalSkippedReason = finalSkippedItems.length
|
|
||||||
? restored.skipped.reason || preparedRemote.skipped.reason
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
auditActorUserId: (db.users || []).some((row) => String(row.id || '').trim() === actorUserId) ? actorUserId : null,
|
|
||||||
result: {
|
|
||||||
object: 'instance-backup-import',
|
|
||||||
imported: {
|
|
||||||
config: (db.config || []).length,
|
|
||||||
users: (db.users || []).length,
|
|
||||||
userRevisions: (db.user_revisions || []).length,
|
|
||||||
folders: (db.folders || []).length,
|
|
||||||
ciphers: (db.ciphers || []).length,
|
|
||||||
attachments: restored.restoredAttachments.length,
|
|
||||||
attachmentFiles: restored.imported,
|
|
||||||
},
|
|
||||||
skipped: {
|
|
||||||
reason: finalSkippedReason,
|
|
||||||
attachments: finalSkippedItems.length,
|
|
||||||
items: finalSkippedItems,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,18 +250,49 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureWebDavDirectoryCached(
|
||||||
|
baseUrl: string,
|
||||||
|
directoryPath: string,
|
||||||
|
authHeader: string,
|
||||||
|
ensuredDirectories: Set<string>
|
||||||
|
): Promise<void> {
|
||||||
|
const segments = trimSlashes(directoryPath).split('/').filter(Boolean);
|
||||||
|
let current = '';
|
||||||
|
for (const segment of segments) {
|
||||||
|
current = buildJoinedPath(current, segment);
|
||||||
|
if (ensuredDirectories.has(current)) continue;
|
||||||
|
const url = buildWebDavUrl(baseUrl, current);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'MKCOL',
|
||||||
|
headers: {
|
||||||
|
Authorization: authHeader,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
|
||||||
|
ensuredDirectories.add(current);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function putToWebDav(
|
async function putToWebDav(
|
||||||
config: WebDavBackupDestination,
|
config: WebDavBackupDestination,
|
||||||
relativePath: string,
|
relativePath: string,
|
||||||
bytes: Uint8Array,
|
bytes: Uint8Array,
|
||||||
options: RemoteBackupFilePutOptions = {}
|
options: RemoteBackupFilePutOptions = {},
|
||||||
|
ensuredDirectories?: Set<string>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const authHeader = toBasicAuthHeader(config.username, config.password);
|
const authHeader = toBasicAuthHeader(config.username, config.password);
|
||||||
const remoteFilePath = buildJoinedPath(config.remotePath, relativePath);
|
const remoteFilePath = buildJoinedPath(config.remotePath, relativePath);
|
||||||
const remoteDir = parentPath(remoteFilePath);
|
const remoteDir = parentPath(remoteFilePath);
|
||||||
|
|
||||||
if (remoteDir) {
|
if (remoteDir) {
|
||||||
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
|
if (ensuredDirectories) {
|
||||||
|
await ensureWebDavDirectoryCached(config.baseUrl, remoteDir, authHeader, ensuredDirectories);
|
||||||
|
} else {
|
||||||
|
await ensureWebDavDirectory(config.baseUrl, remoteDir, authHeader);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
|
const response = await fetch(buildWebDavUrl(config.baseUrl, remoteFilePath), {
|
||||||
@@ -608,6 +639,16 @@ interface ConfiguredDestinationAdapter {
|
|||||||
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
|
exists: (config: WebDavBackupDestination | E3BackupDestination, relativePath: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupTransferSession {
|
||||||
|
provider: BackupDestinationType;
|
||||||
|
uploadArchive(archive: Uint8Array, fileName: string): Promise<BackupUploadResult>;
|
||||||
|
putFile(relativePath: string, bytes: Uint8Array, options?: RemoteBackupFilePutOptions): Promise<void>;
|
||||||
|
list(relativePath: string): Promise<RemoteBackupListResult>;
|
||||||
|
download(relativePath: string): Promise<RemoteBackupFile>;
|
||||||
|
deleteFile(relativePath: string): Promise<void>;
|
||||||
|
exists(relativePath: string): Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveConfiguredDestinationAdapter(
|
function resolveConfiguredDestinationAdapter(
|
||||||
destination: BackupDestinationRecord
|
destination: BackupDestinationRecord
|
||||||
): ConfiguredDestinationAdapter {
|
): ConfiguredDestinationAdapter {
|
||||||
@@ -641,35 +682,62 @@ function resolveConfiguredDestinationAdapter(
|
|||||||
throw new Error('Unsupported backup destination type');
|
throw new Error('Unsupported backup destination type');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createRemoteBackupTransferSession(destination: BackupDestinationRecord): RemoteBackupTransferSession {
|
||||||
|
const adapter = resolveConfiguredDestinationAdapter(destination);
|
||||||
|
const ensuredDirectories = adapter.provider === 'webdav' ? new Set<string>() : null;
|
||||||
|
|
||||||
|
const putFile = async (relativePath: string, bytes: Uint8Array, options: RemoteBackupFilePutOptions = {}): Promise<void> => {
|
||||||
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
|
if (adapter.provider === 'webdav' && ensuredDirectories) {
|
||||||
|
await putToWebDav(adapter.config as WebDavBackupDestination, normalized, bytes, options, ensuredDirectories);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await adapter.putFile(adapter.config, normalized, bytes, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider: adapter.provider,
|
||||||
|
uploadArchive: async (archive: Uint8Array, fileName: string) => {
|
||||||
|
await putFile(fileName, archive, { contentType: 'application/zip' });
|
||||||
|
return {
|
||||||
|
provider: adapter.provider,
|
||||||
|
remotePath: adapter.provider === 'webdav'
|
||||||
|
? buildJoinedPath((adapter.config as WebDavBackupDestination).remotePath, fileName)
|
||||||
|
: normalizeE3ObjectKey(adapter.config as E3BackupDestination, fileName),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
putFile,
|
||||||
|
list: async (relativePath: string) => adapter.list(adapter.config, relativePath),
|
||||||
|
download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
|
||||||
|
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
|
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function uploadBackupArchive(
|
export async function uploadBackupArchive(
|
||||||
destination: BackupDestinationRecord,
|
destination: BackupDestinationRecord,
|
||||||
archive: Uint8Array,
|
archive: Uint8Array,
|
||||||
fileName: string
|
fileName: string
|
||||||
): Promise<BackupUploadResult> {
|
): Promise<BackupUploadResult> {
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
return createRemoteBackupTransferSession(destination).uploadArchive(archive, fileName);
|
||||||
return adapter.upload(adapter.config, archive, fileName);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
|
export async function listRemoteBackupEntries(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupListResult> {
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
return createRemoteBackupTransferSession(destination).list(relativePath);
|
||||||
return adapter.list(adapter.config, relativePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
|
export async function downloadRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<RemoteBackupFile> {
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
return createRemoteBackupTransferSession(destination).download(relativePath);
|
||||||
return adapter.download(adapter.config, relativePath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
|
export async function deleteRemoteBackupFile(destination: BackupDestinationRecord, relativePath: string): Promise<void> {
|
||||||
const normalized = ensureRemoteRestoreCandidate(relativePath);
|
const normalized = ensureRemoteRestoreCandidate(relativePath);
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
await createRemoteBackupTransferSession(destination).deleteFile(normalized);
|
||||||
await adapter.deleteFile(adapter.config, normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise<boolean> {
|
export async function remoteBackupFileExists(destination: BackupDestinationRecord, relativePath: string): Promise<boolean> {
|
||||||
const normalized = normalizeRelativePath(relativePath);
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
return createRemoteBackupTransferSession(destination).exists(normalized);
|
||||||
return adapter.exists(adapter.config, normalized);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadRemoteBackupFile(
|
export async function uploadRemoteBackupFile(
|
||||||
@@ -679,8 +747,7 @@ export async function uploadRemoteBackupFile(
|
|||||||
options: RemoteBackupFilePutOptions = {}
|
options: RemoteBackupFilePutOptions = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const normalized = normalizeRelativePath(relativePath);
|
const normalized = normalizeRelativePath(relativePath);
|
||||||
const adapter = resolveConfiguredDestinationAdapter(destination);
|
await createRemoteBackupTransferSession(destination).putFile(normalized, bytes, options);
|
||||||
await adapter.putFile(adapter.config, normalized, bytes, options);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
|
function compareBackupItemsByRecency(a: RemoteBackupItem, b: RemoteBackupItem, preferredFileName?: string): number {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import type { Cipher } from '../types';
|
import type { Cipher } from '../types';
|
||||||
|
|
||||||
|
function normalizeOptionalId(value: unknown): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement;
|
||||||
type SqlChunkSize = (fixedBindCount: number) => number;
|
type SqlChunkSize = (fixedBindCount: number) => number;
|
||||||
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
type UpdateRevisionDate = (userId: string) => Promise<string>;
|
||||||
@@ -25,12 +31,13 @@ function parseCipherRow(row: CipherRow | null | undefined): Cipher | null {
|
|||||||
if (!row?.data) return null;
|
if (!row?.data) return null;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(row.data) as Cipher;
|
const parsed = JSON.parse(row.data) as Cipher;
|
||||||
|
const folderId = normalizeOptionalId(row.folder_id ?? parsed.folderId ?? null);
|
||||||
return {
|
return {
|
||||||
...parsed,
|
...parsed,
|
||||||
id: row.id,
|
id: row.id,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
type: Number(row.type) || Number(parsed.type) || 1,
|
type: Number(row.type) || Number(parsed.type) || 1,
|
||||||
folderId: row.folder_id ?? parsed.folderId ?? null,
|
folderId,
|
||||||
name: row.name ?? parsed.name ?? null,
|
name: row.name ?? parsed.name ?? null,
|
||||||
notes: row.notes ?? parsed.notes ?? null,
|
notes: row.notes ?? parsed.notes ?? null,
|
||||||
favorite: row.favorite != null ? !!row.favorite : !!parsed.favorite,
|
favorite: row.favorite != null ? !!row.favorite : !!parsed.favorite,
|
||||||
@@ -60,7 +67,11 @@ export async function getCipher(db: D1Database, id: string): Promise<Cipher | nu
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise<void> {
|
||||||
const data = JSON.stringify(cipher);
|
const folderId = normalizeOptionalId(cipher.folderId);
|
||||||
|
const data = JSON.stringify({
|
||||||
|
...cipher,
|
||||||
|
folderId,
|
||||||
|
});
|
||||||
const stmt = db.prepare(
|
const stmt = db.prepare(
|
||||||
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at) ' +
|
||||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||||
@@ -72,7 +83,7 @@ export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cip
|
|||||||
cipher.id,
|
cipher.id,
|
||||||
cipher.userId,
|
cipher.userId,
|
||||||
Number(cipher.type) || 1,
|
Number(cipher.type) || 1,
|
||||||
cipher.folderId,
|
folderId,
|
||||||
cipher.name,
|
cipher.name,
|
||||||
cipher.notes,
|
cipher.notes,
|
||||||
cipher.favorite ? 1 : 0,
|
cipher.favorite ? 1 : 0,
|
||||||
@@ -249,8 +260,9 @@ export async function bulkMoveCiphers(
|
|||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
if (ids.length === 0) return null;
|
if (ids.length === 0) return null;
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
const normalizedFolderId = normalizeOptionalId(folderId);
|
||||||
const uniqueIds = sanitizeIds(ids);
|
const uniqueIds = sanitizeIds(ids);
|
||||||
const patch = JSON.stringify({ folderId, updatedAt: now });
|
const patch = JSON.stringify({ folderId: normalizedFolderId, updatedAt: now });
|
||||||
const chunkSize = sqlChunkSize(4);
|
const chunkSize = sqlChunkSize(4);
|
||||||
|
|
||||||
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
for (let i = 0; i < uniqueIds.length; i += chunkSize) {
|
||||||
@@ -262,7 +274,7 @@ export async function bulkMoveCiphers(
|
|||||||
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
SET folder_id = ?, updated_at = ?, data = json_patch(data, ?)
|
||||||
WHERE user_id = ? AND id IN (${placeholders})`
|
WHERE user_id = ? AND id IN (${placeholders})`
|
||||||
)
|
)
|
||||||
.bind(folderId, now, patch, userId, ...chunk)
|
.bind(normalizedFolderId, now, patch, userId, ...chunk)
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
import { User, UserDecryptionOptions } from '../types';
|
import { User, UserDecryptionOptions } from '../types';
|
||||||
|
|
||||||
|
function normalizeOptionalPublicKey(value: unknown): string {
|
||||||
|
if (value == null) return '';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
|
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
|
||||||
if (!user.privateKey || !user.publicKey) {
|
if (!user.privateKey) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const publicKey = normalizeOptionalPublicKey(user.publicKey);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
publicKeyEncryptionKeyPair: {
|
publicKeyEncryptionKeyPair: {
|
||||||
wrappedPrivateKey: user.privateKey,
|
wrappedPrivateKey: user.privateKey,
|
||||||
publicKey: user.publicKey,
|
publicKey,
|
||||||
Object: 'publicKeyEncryptionKeyPair',
|
Object: 'publicKeyEncryptionKeyPair',
|
||||||
},
|
},
|
||||||
Object: 'privateKeys',
|
Object: 'privateKeys',
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import useVaultSendActions from '@/hooks/useVaultSendActions';
|
|||||||
import { useToastManager } from '@/hooks/useToastManager';
|
import { useToastManager } from '@/hooks/useToastManager';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||||
|
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||||
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||||
|
|
||||||
const IMPORT_ROUTE = '/backup/import-export';
|
const IMPORT_ROUTE = '/backup/import-export';
|
||||||
@@ -62,6 +63,7 @@ const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e);
|
|||||||
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5;
|
||||||
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
|
||||||
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
|
||||||
|
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
||||||
|
|
||||||
type ThemePreference = 'system' | 'light' | 'dark';
|
type ThemePreference = 'system' | 'light' | 'dark';
|
||||||
const MAGNETIC_SELECTOR = '.topbar .btn, .topbar .user-chip, .side-link, .mobile-tab';
|
const MAGNETIC_SELECTOR = '.topbar .btn, .topbar .user-chip, .side-link, .mobile-tab';
|
||||||
@@ -908,6 +910,21 @@ export default function App() {
|
|||||||
void refreshAuthorizedDevicesRef.current();
|
void refreshAuthorizedDevicesRef.current();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
||||||
|
const payload = frame.arguments?.[0]?.Payload;
|
||||||
|
if (
|
||||||
|
payload
|
||||||
|
&& typeof payload === 'object'
|
||||||
|
&& (
|
||||||
|
payload.operation === 'backup-restore'
|
||||||
|
|| payload.operation === 'backup-export'
|
||||||
|
|| payload.operation === 'backup-remote-run'
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
dispatchBackupProgress(payload as BackupProgressDetail);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
||||||
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
|
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
|
||||||
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
|
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
|
||||||
@@ -1113,13 +1130,16 @@ export default function App() {
|
|||||||
onRevokeInvite: adminActions.revokeInvite,
|
onRevokeInvite: adminActions.revokeInvite,
|
||||||
onExportBackup: backupActions.exportBackup,
|
onExportBackup: backupActions.exportBackup,
|
||||||
onImportBackup: backupActions.importBackup,
|
onImportBackup: backupActions.importBackup,
|
||||||
|
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
|
||||||
onLoadBackupSettings: backupActions.loadSettings,
|
onLoadBackupSettings: backupActions.loadSettings,
|
||||||
onSaveBackupSettings: backupActions.saveSettings,
|
onSaveBackupSettings: backupActions.saveSettings,
|
||||||
onRunRemoteBackup: backupActions.runRemoteBackup,
|
onRunRemoteBackup: backupActions.runRemoteBackup,
|
||||||
onListRemoteBackups: backupActions.listRemoteBackups,
|
onListRemoteBackups: backupActions.listRemoteBackups,
|
||||||
onDownloadRemoteBackup: backupActions.downloadRemoteBackup,
|
onDownloadRemoteBackup: backupActions.downloadRemoteBackup,
|
||||||
|
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
|
||||||
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
|
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
|
||||||
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
|
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (jwtWarning) {
|
if (jwtWarning) {
|
||||||
|
|||||||
@@ -106,13 +106,16 @@ export interface AppMainRoutesProps {
|
|||||||
onRevokeInvite: (code: string) => Promise<void>;
|
onRevokeInvite: (code: string) => Promise<void>;
|
||||||
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
|
||||||
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||||
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||||
|
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
|
||||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||||
@@ -333,11 +336,14 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
currentUserId={props.profile?.id || null}
|
currentUserId={props.profile?.id || null}
|
||||||
onExport={props.onExportBackup}
|
onExport={props.onExportBackup}
|
||||||
onImport={props.onImportBackup}
|
onImport={props.onImportBackup}
|
||||||
|
onImportAllowingChecksumMismatch={props.onImportBackupAllowingChecksumMismatch}
|
||||||
onLoadSettings={props.onLoadBackupSettings}
|
onLoadSettings={props.onLoadBackupSettings}
|
||||||
onListRemoteBackups={props.onListRemoteBackups}
|
onListRemoteBackups={props.onListRemoteBackups}
|
||||||
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
|
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
|
||||||
|
onInspectRemoteBackup={props.onInspectRemoteBackup}
|
||||||
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
|
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
|
||||||
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
|
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch={props.onRestoreRemoteBackupAllowingChecksumMismatch}
|
||||||
onSaveSettings={props.onSaveBackupSettings}
|
onSaveSettings={props.onSaveBackupSettings}
|
||||||
onRunRemoteBackup={props.onRunRemoteBackup}
|
onRunRemoteBackup={props.onRunRemoteBackup}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
|
import { createPortal } from 'preact/compat';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
import {
|
import {
|
||||||
type AdminBackupImportResponse,
|
type AdminBackupImportResponse,
|
||||||
type AdminBackupRunResponse,
|
type AdminBackupRunResponse,
|
||||||
type AdminBackupSettings,
|
type AdminBackupSettings,
|
||||||
|
type BackupFileIntegrityCheckResult,
|
||||||
type BackupDestinationRecord,
|
type BackupDestinationRecord,
|
||||||
type BackupDestinationType,
|
type BackupDestinationType,
|
||||||
type RemoteBackupBrowserResponse,
|
type RemoteBackupBrowserResponse,
|
||||||
|
verifyBackupFileIntegrity,
|
||||||
} from '@/lib/api/backup';
|
} from '@/lib/api/backup';
|
||||||
import {
|
import {
|
||||||
REMOTE_BROWSER_ITEMS_PER_PAGE,
|
REMOTE_BROWSER_ITEMS_PER_PAGE,
|
||||||
@@ -22,6 +25,7 @@ import {
|
|||||||
loadPersistedRemoteBrowserState,
|
loadPersistedRemoteBrowserState,
|
||||||
persistRemoteBrowserState,
|
persistRemoteBrowserState,
|
||||||
} from '@/lib/backup-center';
|
} from '@/lib/backup-center';
|
||||||
|
import { BACKUP_PROGRESS_EVENT, type BackupProgressDetail, type BackupProgressOperation } from '@/lib/backup-restore-progress';
|
||||||
import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations';
|
import { RECOMMENDED_PROVIDERS, type RecommendedProvider } from '@/lib/backup-recommendations';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail';
|
import { BackupDestinationDetail } from './backup-center/BackupDestinationDetail';
|
||||||
@@ -32,16 +36,82 @@ interface BackupCenterPageProps {
|
|||||||
currentUserId: string | null;
|
currentUserId: string | null;
|
||||||
onExport: (includeAttachments?: boolean) => Promise<void>;
|
onExport: (includeAttachments?: boolean) => Promise<void>;
|
||||||
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onLoadSettings: () => Promise<AdminBackupSettings>;
|
onLoadSettings: () => Promise<AdminBackupSettings>;
|
||||||
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||||
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
|
||||||
|
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
|
||||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
|
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
|
||||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PendingRestoreIntegrity =
|
||||||
|
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
|
||||||
|
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
|
||||||
|
|
||||||
|
interface BackupProgressPhase {
|
||||||
|
titleKey: string;
|
||||||
|
detailKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackupProgressState {
|
||||||
|
operation: BackupProgressOperation;
|
||||||
|
source: 'local' | 'remote' | null;
|
||||||
|
includeAttachments: boolean;
|
||||||
|
fileLabel: string;
|
||||||
|
startedAt: number;
|
||||||
|
phaseIndex: number;
|
||||||
|
phases: BackupProgressPhase[];
|
||||||
|
currentTitleKey: string;
|
||||||
|
currentDetailKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_RESTORE_PHASES: BackupProgressPhase[] = [
|
||||||
|
{ titleKey: 'txt_backup_restore_progress_local_upload_title', detailKey: 'txt_backup_restore_progress_local_upload_detail' },
|
||||||
|
{ titleKey: 'txt_backup_restore_progress_local_shadow_title', detailKey: 'txt_backup_restore_progress_local_shadow_detail' },
|
||||||
|
{ titleKey: 'txt_backup_restore_progress_local_data_title', detailKey: 'txt_backup_restore_progress_local_data_detail' },
|
||||||
|
{ titleKey: 'txt_backup_restore_progress_local_files_title', detailKey: 'txt_backup_restore_progress_local_files_detail' },
|
||||||
|
{ titleKey: 'txt_backup_restore_progress_local_finalize_title', detailKey: 'txt_backup_restore_progress_local_finalize_detail' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const REMOTE_RESTORE_PHASES: BackupProgressPhase[] = [
|
||||||
|
{ titleKey: 'txt_backup_restore_progress_remote_fetch_title', detailKey: 'txt_backup_restore_progress_remote_fetch_detail' },
|
||||||
|
{ titleKey: 'txt_backup_restore_progress_remote_shadow_title', detailKey: 'txt_backup_restore_progress_remote_shadow_detail' },
|
||||||
|
{ titleKey: 'txt_backup_restore_progress_remote_data_title', detailKey: 'txt_backup_restore_progress_remote_data_detail' },
|
||||||
|
{ titleKey: 'txt_backup_restore_progress_remote_files_title', detailKey: 'txt_backup_restore_progress_remote_files_detail' },
|
||||||
|
{ titleKey: 'txt_backup_restore_progress_remote_finalize_title', detailKey: 'txt_backup_restore_progress_remote_finalize_detail' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXPORT_PROGRESS_PHASES: BackupProgressPhase[] = [
|
||||||
|
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_detail' },
|
||||||
|
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_detail' },
|
||||||
|
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
|
||||||
|
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES: BackupProgressPhase[] = [
|
||||||
|
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
|
||||||
|
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
|
||||||
|
{ titleKey: 'txt_backup_archive_progress_ready_title', detailKey: 'txt_backup_archive_progress_ready_detail' },
|
||||||
|
{ titleKey: 'txt_backup_export_progress_fetch_attachments_title', detailKey: 'txt_backup_export_progress_fetch_attachments_detail' },
|
||||||
|
{ titleKey: 'txt_backup_export_progress_rebuild_title', detailKey: 'txt_backup_export_progress_rebuild_detail' },
|
||||||
|
{ titleKey: 'txt_backup_export_progress_save_title', detailKey: 'txt_backup_export_progress_save_detail' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const REMOTE_RUN_PROGRESS_PHASES: BackupProgressPhase[] = [
|
||||||
|
{ titleKey: 'txt_backup_remote_run_progress_prepare_title', detailKey: 'txt_backup_remote_run_progress_prepare_detail' },
|
||||||
|
{ titleKey: 'txt_backup_archive_progress_collect_title', detailKey: 'txt_backup_archive_progress_collect_with_attachments_detail' },
|
||||||
|
{ titleKey: 'txt_backup_archive_progress_package_title', detailKey: 'txt_backup_archive_progress_package_with_attachments_detail' },
|
||||||
|
{ titleKey: 'txt_backup_remote_run_progress_sync_attachments_title', detailKey: 'txt_backup_remote_run_progress_sync_attachments_detail' },
|
||||||
|
{ titleKey: 'txt_backup_remote_run_progress_upload_title', detailKey: 'txt_backup_remote_run_progress_upload_detail' },
|
||||||
|
{ titleKey: 'txt_backup_remote_run_progress_verify_title', detailKey: 'txt_backup_remote_run_progress_verify_detail' },
|
||||||
|
{ titleKey: 'txt_backup_remote_run_progress_cleanup_title', detailKey: 'txt_backup_remote_run_progress_cleanup_detail' },
|
||||||
|
];
|
||||||
|
|
||||||
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
|
function buildSkippedImportMessage(result: AdminBackupImportResponse): string | null {
|
||||||
const skipped = result.skipped;
|
const skipped = result.skipped;
|
||||||
if (!skipped || !skipped.attachments) return null;
|
if (!skipped || !skipped.attachments) return null;
|
||||||
@@ -51,10 +121,56 @@ function buildSkippedImportMessage(result: AdminBackupImportResponse): string |
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildIntegrityStatusMessage(result: BackupFileIntegrityCheckResult, options?: { remote?: boolean }): string {
|
||||||
|
if (!result.hasChecksumPrefix) {
|
||||||
|
return t(options?.remote ? 'txt_backup_remote_restore_completed_without_checksum' : 'txt_backup_restore_completed_without_checksum');
|
||||||
|
}
|
||||||
|
return t(options?.remote ? 'txt_backup_remote_restore_completed_verified' : 'txt_backup_restore_completed_verified');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIntegrityWarningMessage(entry: PendingRestoreIntegrity): string {
|
||||||
|
if (entry.source === 'remote') {
|
||||||
|
return t('txt_backup_remote_restore_checksum_warning_message', {
|
||||||
|
name: entry.fileName,
|
||||||
|
expected: entry.result.expectedPrefix || '-----',
|
||||||
|
actual: entry.result.actualPrefix,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return t('txt_backup_restore_checksum_warning_message', {
|
||||||
|
name: entry.fileName,
|
||||||
|
expected: entry.result.expectedPrefix || '-----',
|
||||||
|
actual: entry.result.actualPrefix,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupProgressPhases(
|
||||||
|
operation: BackupProgressOperation,
|
||||||
|
source: 'local' | 'remote' | null,
|
||||||
|
includeAttachments: boolean
|
||||||
|
): BackupProgressPhase[] {
|
||||||
|
if (operation === 'backup-restore') {
|
||||||
|
return source === 'remote' ? REMOTE_RESTORE_PHASES : LOCAL_RESTORE_PHASES;
|
||||||
|
}
|
||||||
|
if (operation === 'backup-export') {
|
||||||
|
return includeAttachments ? EXPORT_WITH_ATTACHMENTS_PROGRESS_PHASES : EXPORT_PROGRESS_PHASES;
|
||||||
|
}
|
||||||
|
return REMOTE_RUN_PROGRESS_PHASES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBackupProgressTitleKey(state: BackupProgressState): string {
|
||||||
|
if (state.operation === 'backup-export') return 'txt_backup_export_progress_title';
|
||||||
|
if (state.operation === 'backup-remote-run') return 'txt_backup_remote_run_progress_title';
|
||||||
|
return state.source === 'remote'
|
||||||
|
? 'txt_backup_restore_progress_remote_title'
|
||||||
|
: 'txt_backup_restore_progress_local_title';
|
||||||
|
}
|
||||||
|
|
||||||
export default function BackupCenterPage(props: BackupCenterPageProps) {
|
export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||||
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId));
|
const persistedRemoteStateRef = useRef(loadPersistedRemoteBrowserState(props.currentUserId));
|
||||||
const persistedRemoteState = persistedRemoteStateRef.current;
|
const persistedRemoteState = persistedRemoteStateRef.current;
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const restoreProgressTimerRef = useRef<number | null>(null);
|
||||||
|
const restoreProgressPendingRef = useRef<BackupProgressState | null>(null);
|
||||||
|
|
||||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
@@ -67,14 +183,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
|
const [downloadingRemotePath, setDownloadingRemotePath] = useState('');
|
||||||
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
const [downloadingRemotePercent, setDownloadingRemotePercent] = useState<number | null>(null);
|
||||||
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
const [restoringRemotePath, setRestoringRemotePath] = useState('');
|
||||||
const [remoteRestoreStatusText, setRemoteRestoreStatusText] = useState('');
|
|
||||||
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
const [deletingRemotePath, setDeletingRemotePath] = useState('');
|
||||||
const [localError, setLocalError] = useState('');
|
const [localError, setLocalError] = useState('');
|
||||||
|
const [restoreProgress, setRestoreProgress] = useState<BackupProgressState | null>(null);
|
||||||
|
const [restoreElapsedSeconds, setRestoreElapsedSeconds] = useState(0);
|
||||||
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
const [confirmLocalRestoreOpen, setConfirmLocalRestoreOpen] = useState(false);
|
||||||
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
|
const [confirmReplaceOpen, setConfirmReplaceOpen] = useState(false);
|
||||||
const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false);
|
const [confirmRemoteReplaceOpen, setConfirmRemoteReplaceOpen] = useState(false);
|
||||||
|
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
|
||||||
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
|
||||||
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
|
||||||
|
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
|
||||||
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
|
||||||
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
|
||||||
const [savedSettings, setSavedSettings] = useState<AdminBackupSettings | null>(null);
|
const [savedSettings, setSavedSettings] = useState<AdminBackupSettings | null>(null);
|
||||||
@@ -148,6 +267,59 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
});
|
});
|
||||||
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]);
|
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!restoreProgress) {
|
||||||
|
setRestoreElapsedSeconds(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
|
||||||
|
const tickTimer = window.setInterval(() => {
|
||||||
|
setRestoreElapsedSeconds(Math.max(0, Math.floor((Date.now() - restoreProgress.startedAt) / 1000)));
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(tickTimer);
|
||||||
|
}, [restoreProgress]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleProgress = (event: Event) => {
|
||||||
|
const detail = (event as CustomEvent<BackupProgressDetail>).detail;
|
||||||
|
if (!detail) return;
|
||||||
|
const pending = restoreProgressPendingRef.current;
|
||||||
|
const operation = detail.operation || pending?.operation || 'backup-restore';
|
||||||
|
const source = (detail.source || pending?.source || null) as 'local' | 'remote' | null;
|
||||||
|
const includeAttachments = pending?.includeAttachments || false;
|
||||||
|
const phases = getBackupProgressPhases(operation, source, includeAttachments);
|
||||||
|
const matchedPhaseIndex = phases.findIndex((phase) => phase.titleKey === detail.stageTitle);
|
||||||
|
const phaseIndex = matchedPhaseIndex >= 0 ? matchedPhaseIndex : 0;
|
||||||
|
const nextState: BackupProgressState = {
|
||||||
|
operation,
|
||||||
|
source,
|
||||||
|
includeAttachments,
|
||||||
|
fileLabel: detail.fileName || pending?.fileLabel || '',
|
||||||
|
startedAt: pending?.operation === operation
|
||||||
|
? pending.startedAt
|
||||||
|
: Date.now(),
|
||||||
|
phaseIndex,
|
||||||
|
phases,
|
||||||
|
currentTitleKey: detail.stageTitle || phases[Math.max(0, phaseIndex)].titleKey,
|
||||||
|
currentDetailKey: detail.stageDetail || phases[Math.max(0, phaseIndex)].detailKey,
|
||||||
|
};
|
||||||
|
restoreProgressPendingRef.current = nextState;
|
||||||
|
if (restoreProgressTimerRef.current === null) {
|
||||||
|
setRestoreProgress(nextState);
|
||||||
|
}
|
||||||
|
if (detail.done) {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setRestoreProgress((current) => (
|
||||||
|
current && current.fileLabel === (detail.fileName || current.fileLabel) ? null : current
|
||||||
|
));
|
||||||
|
setRestoreElapsedSeconds(0);
|
||||||
|
}, detail.ok === false ? 1200 : 900);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
|
||||||
|
return () => window.removeEventListener(BACKUP_PROGRESS_EVENT, handleProgress as EventListener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) {
|
function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) {
|
||||||
setSettings((current) => {
|
setSettings((current) => {
|
||||||
const next = mutator(current);
|
const next = mutator(current);
|
||||||
@@ -225,6 +397,67 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetPendingIntegrityWarning() {
|
||||||
|
setPendingRestoreIntegrity(null);
|
||||||
|
setConfirmIntegrityWarningOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRestoreProgress(
|
||||||
|
operation: BackupProgressOperation,
|
||||||
|
fileLabel: string,
|
||||||
|
options?: { source?: 'local' | 'remote' | null; includeAttachments?: boolean; delayMs?: number }
|
||||||
|
) {
|
||||||
|
if (restoreProgressTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(restoreProgressTimerRef.current);
|
||||||
|
restoreProgressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setRestoreElapsedSeconds(0);
|
||||||
|
const source = options?.source || null;
|
||||||
|
const includeAttachments = !!options?.includeAttachments;
|
||||||
|
const phases = getBackupProgressPhases(operation, source, includeAttachments);
|
||||||
|
restoreProgressPendingRef.current = {
|
||||||
|
operation,
|
||||||
|
source,
|
||||||
|
includeAttachments,
|
||||||
|
fileLabel,
|
||||||
|
startedAt: Date.now(),
|
||||||
|
phaseIndex: 0,
|
||||||
|
phases,
|
||||||
|
currentTitleKey: phases[0].titleKey,
|
||||||
|
currentDetailKey: phases[0].detailKey,
|
||||||
|
};
|
||||||
|
restoreProgressTimerRef.current = window.setTimeout(() => {
|
||||||
|
restoreProgressTimerRef.current = null;
|
||||||
|
if (!restoreProgressPendingRef.current) return;
|
||||||
|
setRestoreProgress(restoreProgressPendingRef.current);
|
||||||
|
}, options?.delayMs ?? 480);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRestoreProgress() {
|
||||||
|
if (restoreProgressTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(restoreProgressTimerRef.current);
|
||||||
|
restoreProgressTimerRef.current = null;
|
||||||
|
}
|
||||||
|
restoreProgressPendingRef.current = null;
|
||||||
|
setRestoreProgress(null);
|
||||||
|
setRestoreElapsedSeconds(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectLocalBackupFile(file: File): Promise<BackupFileIntegrityCheckResult> {
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
return verifyBackupFileIntegrity(bytes, file.name || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
||||||
|
const payload = await props.onInspectRemoteBackup(destinationId, path);
|
||||||
|
return {
|
||||||
|
source: 'remote',
|
||||||
|
path,
|
||||||
|
fileName: payload.fileName || path.split('/').pop() || path,
|
||||||
|
result: payload.integrity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function handleAddDestination(type: BackupDestinationType) {
|
function handleAddDestination(type: BackupDestinationType) {
|
||||||
updateSettings((current) => {
|
updateSettings((current) => {
|
||||||
const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1);
|
const nextDestination = createDraftDestinationRecord(type, current.destinations.filter((destination) => destination.type === type).length + 1);
|
||||||
@@ -277,18 +510,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setLocalError('');
|
setLocalError('');
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
|
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
|
||||||
await props.onExport(exportIncludeAttachments);
|
await props.onExport(exportIncludeAttachments);
|
||||||
props.onNotify('success', t('txt_backup_export_success'));
|
props.onNotify('success', t('txt_backup_export_success'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLocalRestore(replaceExisting: boolean) {
|
async function runLocalRestore(
|
||||||
|
replaceExisting: boolean,
|
||||||
|
allowChecksumMismatch: boolean = false,
|
||||||
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
|
) {
|
||||||
if (!selectedFile) {
|
if (!selectedFile) {
|
||||||
const message = t('txt_backup_file_required');
|
const message = t('txt_backup_file_required');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
@@ -296,17 +535,29 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
|
setConfirmLocalRestoreOpen(false);
|
||||||
|
setConfirmReplaceOpen(false);
|
||||||
|
setConfirmIntegrityWarningOpen(false);
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
try {
|
try {
|
||||||
const result = await props.onImport(selectedFile, replaceExisting);
|
const integrity = knownIntegrity || await inspectLocalBackupFile(selectedFile);
|
||||||
props.onNotify('success', t('txt_backup_restore_success_relogin'));
|
startRestoreProgress('backup-restore', selectedFile.name || t('txt_backup_import'), {
|
||||||
|
source: 'local',
|
||||||
|
delayMs: replaceExisting ? 480 : 1400,
|
||||||
|
});
|
||||||
|
const result = allowChecksumMismatch
|
||||||
|
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting)
|
||||||
|
: await props.onImport(selectedFile, replaceExisting);
|
||||||
|
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
|
||||||
const skippedMessage = buildSkippedImportMessage(result);
|
const skippedMessage = buildSkippedImportMessage(result);
|
||||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||||
resetSelectedFile();
|
resetSelectedFile();
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
setConfirmReplaceOpen(false);
|
setConfirmReplaceOpen(false);
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||||
|
clearRestoreProgress();
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
setConfirmReplaceOpen(true);
|
setConfirmReplaceOpen(true);
|
||||||
return;
|
return;
|
||||||
@@ -314,6 +565,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
} finally {
|
} finally {
|
||||||
setImporting(false);
|
setImporting(false);
|
||||||
}
|
}
|
||||||
@@ -364,16 +616,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setRunningRemoteBackup(true);
|
setRunningRemoteBackup(true);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
|
startRestoreProgress('backup-remote-run', selectedDestination.name || t('txt_backup_run_now'), {
|
||||||
|
source: 'remote',
|
||||||
|
includeAttachments: !!selectedDestination.includeAttachments,
|
||||||
|
});
|
||||||
const result = await props.onRunRemoteBackup(selectedDestination.id);
|
const result = await props.onRunRemoteBackup(selectedDestination.id);
|
||||||
setSavedSettings(result.settings);
|
setSavedSettings(result.settings);
|
||||||
setSettings(result.settings);
|
setSettings(result.settings);
|
||||||
setSelectedDestinationId(selectedDestination.id);
|
setSelectedDestinationId(selectedDestination.id);
|
||||||
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||||
props.onNotify('success', t('txt_backup_remote_run_success'));
|
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.fileName }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
} finally {
|
} finally {
|
||||||
setRunningRemoteBackup(false);
|
setRunningRemoteBackup(false);
|
||||||
}
|
}
|
||||||
@@ -415,30 +672,88 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRemoteRestore(path: string, replaceExisting: boolean) {
|
async function handleSelectedLocalFile(nextFile: File | null) {
|
||||||
|
setSelectedFile(nextFile);
|
||||||
|
setLocalError('');
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
|
setConfirmLocalRestoreOpen(false);
|
||||||
|
if (!nextFile) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const integrity = await inspectLocalBackupFile(nextFile);
|
||||||
|
if (!integrity.matches) {
|
||||||
|
setPendingRestoreIntegrity({
|
||||||
|
source: 'local',
|
||||||
|
fileName: nextFile.name,
|
||||||
|
result: integrity,
|
||||||
|
});
|
||||||
|
setConfirmIntegrityWarningOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmLocalRestoreOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
|
||||||
|
setLocalError(message);
|
||||||
|
props.onNotify('error', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePromptRemoteRestore(path: string) {
|
||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
|
setLocalError('');
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
|
try {
|
||||||
|
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
||||||
|
if (!integrity.result.matches) {
|
||||||
|
setPendingRestoreIntegrity(integrity);
|
||||||
|
setConfirmIntegrityWarningOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runRemoteRestore(path, false, false, integrity.result);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
|
||||||
|
setLocalError(message);
|
||||||
|
props.onNotify('error', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runRemoteRestore(
|
||||||
|
path: string,
|
||||||
|
replaceExisting: boolean,
|
||||||
|
allowChecksumMismatch: boolean = false,
|
||||||
|
knownIntegrity?: BackupFileIntegrityCheckResult
|
||||||
|
) {
|
||||||
|
if (!savedSelectedDestination) return;
|
||||||
|
setConfirmRemoteReplaceOpen(false);
|
||||||
|
setConfirmIntegrityWarningOpen(false);
|
||||||
setRestoringRemotePath(path);
|
setRestoringRemotePath(path);
|
||||||
setRemoteRestoreStatusText(replaceExisting ? t('txt_backup_remote_restore_stage_replace') : t('txt_backup_remote_restore_stage_prepare'));
|
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
const result = await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
||||||
|
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
|
||||||
|
source: 'remote',
|
||||||
|
delayMs: replaceExisting ? 480 : 1400,
|
||||||
|
});
|
||||||
|
const result = allowChecksumMismatch
|
||||||
|
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting)
|
||||||
|
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
|
||||||
setConfirmRemoteReplaceOpen(false);
|
setConfirmRemoteReplaceOpen(false);
|
||||||
setPendingRemoteRestorePath('');
|
setPendingRemoteRestorePath('');
|
||||||
setRemoteRestoreStatusText('');
|
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
|
||||||
props.onNotify('success', t('txt_backup_restore_success_relogin'));
|
|
||||||
const skippedMessage = buildSkippedImportMessage(result);
|
const skippedMessage = buildSkippedImportMessage(result);
|
||||||
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
if (skippedMessage) props.onNotify('warning', skippedMessage);
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!replaceExisting && isReplaceRequiredError(error)) {
|
if (!replaceExisting && isReplaceRequiredError(error)) {
|
||||||
setPendingRemoteRestorePath(path);
|
setPendingRemoteRestorePath(path);
|
||||||
setConfirmRemoteReplaceOpen(true);
|
setConfirmRemoteReplaceOpen(true);
|
||||||
setRemoteRestoreStatusText('');
|
clearRestoreProgress();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed');
|
||||||
setRemoteRestoreStatusText('');
|
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
props.onNotify('error', message);
|
props.onNotify('error', message);
|
||||||
|
window.setTimeout(() => clearRestoreProgress(), 1200);
|
||||||
} finally {
|
} finally {
|
||||||
setRestoringRemotePath('');
|
setRestoringRemotePath('');
|
||||||
}
|
}
|
||||||
@@ -454,9 +769,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
disabled={disableWhileBusy}
|
disabled={disableWhileBusy}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
const nextFile = (event.currentTarget as HTMLInputElement).files?.[0] || null;
|
||||||
setSelectedFile(nextFile);
|
void handleSelectedLocalFile(nextFile);
|
||||||
setLocalError('');
|
|
||||||
if (nextFile) setConfirmLocalRestoreOpen(true);
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -521,7 +834,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path);
|
if (savedSelectedDestination) showRemoteBrowserPath(savedSelectedDestination.id, path);
|
||||||
}}
|
}}
|
||||||
onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)}
|
onDownloadRemoteBackup={(path) => void handleDownloadRemote(path)}
|
||||||
onRestoreRemoteBackup={(path) => void runRemoteRestore(path, false)}
|
onRestoreRemoteBackup={(path) => void handlePromptRemoteRestore(path)}
|
||||||
onPromptDeleteRemoteBackup={(path) => {
|
onPromptDeleteRemoteBackup={(path) => {
|
||||||
setPendingRemoteDeletePath(path);
|
setPendingRemoteDeletePath(path);
|
||||||
setConfirmRemoteDeleteOpen(true);
|
setConfirmRemoteDeleteOpen(true);
|
||||||
@@ -533,7 +846,49 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{localError ? <div className="local-error">{localError}</div> : null}
|
{localError ? <div className="local-error">{localError}</div> : null}
|
||||||
{!localError && remoteRestoreStatusText ? <div className="status-ok">{remoteRestoreStatusText}</div> : null}
|
{restoreProgress && typeof document !== 'undefined' ? createPortal((
|
||||||
|
<div className="restore-progress-overlay" aria-live="polite">
|
||||||
|
<section className="restore-progress-card restore-progress-modal">
|
||||||
|
<div className="restore-progress-head">
|
||||||
|
<div>
|
||||||
|
<div className="restore-progress-kicker">{t('txt_backup_progress_kicker')}</div>
|
||||||
|
<h3 className="restore-progress-title">
|
||||||
|
{t(getBackupProgressTitleKey(restoreProgress))}
|
||||||
|
</h3>
|
||||||
|
<p className="restore-progress-subtitle">
|
||||||
|
{t('txt_backup_progress_subject', { name: restoreProgress.fileLabel })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="restore-progress-elapsed">
|
||||||
|
{t('txt_backup_restore_progress_elapsed', { seconds: String(restoreElapsedSeconds) })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="restore-progress-meter">
|
||||||
|
<span
|
||||||
|
className="restore-progress-meter-bar"
|
||||||
|
style={{
|
||||||
|
width: `${((restoreProgress.phaseIndex + 1) / restoreProgress.phases.length) * 100}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="restore-progress-current">
|
||||||
|
<strong>{t(restoreProgress.currentTitleKey)}</strong>
|
||||||
|
<p>{t(restoreProgress.currentDetailKey)}</p>
|
||||||
|
</div>
|
||||||
|
<ol className="restore-progress-list">
|
||||||
|
{restoreProgress.phases.map((phase, index) => {
|
||||||
|
const status = index < restoreProgress.phaseIndex ? 'done' : index === restoreProgress.phaseIndex ? 'active' : 'pending';
|
||||||
|
return (
|
||||||
|
<li key={phase.titleKey} className={`restore-progress-item ${status}`}>
|
||||||
|
<span className="restore-progress-dot" />
|
||||||
|
<span className="restore-progress-item-text">{t(phase.titleKey)}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
), document.body) : null}
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmLocalRestoreOpen}
|
open={confirmLocalRestoreOpen}
|
||||||
@@ -546,6 +901,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setConfirmLocalRestoreOpen(false);
|
setConfirmLocalRestoreOpen(false);
|
||||||
resetSelectedFile();
|
resetSelectedFile();
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -558,11 +914,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
confirmDisabled={importing}
|
confirmDisabled={importing}
|
||||||
cancelDisabled={importing}
|
cancelDisabled={importing}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void runLocalRestore(true)}
|
onConfirm={() => void runLocalRestore(
|
||||||
|
true,
|
||||||
|
pendingRestoreIntegrity?.source === 'local',
|
||||||
|
pendingRestoreIntegrity?.source === 'local' ? pendingRestoreIntegrity.result : undefined
|
||||||
|
)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (importing) return;
|
if (importing) return;
|
||||||
setConfirmReplaceOpen(false);
|
setConfirmReplaceOpen(false);
|
||||||
resetSelectedFile();
|
resetSelectedFile();
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -575,11 +936,45 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
confirmDisabled={!!restoringRemotePath}
|
confirmDisabled={!!restoringRemotePath}
|
||||||
cancelDisabled={!!restoringRemotePath}
|
cancelDisabled={!!restoringRemotePath}
|
||||||
danger
|
danger
|
||||||
onConfirm={() => void runRemoteRestore(pendingRemoteRestorePath, true)}
|
onConfirm={() => void runRemoteRestore(
|
||||||
|
pendingRemoteRestorePath,
|
||||||
|
true,
|
||||||
|
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath,
|
||||||
|
pendingRestoreIntegrity?.source === 'remote' && pendingRestoreIntegrity.path === pendingRemoteRestorePath
|
||||||
|
? pendingRestoreIntegrity.result
|
||||||
|
: undefined
|
||||||
|
)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
if (restoringRemotePath) return;
|
if (restoringRemotePath) return;
|
||||||
setConfirmRemoteReplaceOpen(false);
|
setConfirmRemoteReplaceOpen(false);
|
||||||
setPendingRemoteRestorePath('');
|
setPendingRemoteRestorePath('');
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmIntegrityWarningOpen}
|
||||||
|
title={t('txt_backup_restore_checksum_warning_title')}
|
||||||
|
message={pendingRestoreIntegrity ? buildIntegrityWarningMessage(pendingRestoreIntegrity) : t('txt_backup_restore_checksum_warning_message_fallback')}
|
||||||
|
variant="warning"
|
||||||
|
confirmText={t('txt_backup_restore_checksum_warning_confirm')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!pendingRestoreIntegrity) return;
|
||||||
|
setConfirmIntegrityWarningOpen(false);
|
||||||
|
if (pendingRestoreIntegrity.source === 'local') {
|
||||||
|
void runLocalRestore(false, true, pendingRestoreIntegrity.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runRemoteRestore(pendingRestoreIntegrity.path, false, true, pendingRestoreIntegrity.result);
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
if (importing || restoringRemotePath) return;
|
||||||
|
resetPendingIntegrityWarning();
|
||||||
|
setPendingRemoteRestorePath('');
|
||||||
|
setConfirmLocalRestoreOpen(false);
|
||||||
|
resetSelectedFile();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
|
import { createPortal } from 'preact/compat';
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import { TriangleAlert } from 'lucide-preact';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
variant?: 'default' | 'warning';
|
||||||
showIcon?: boolean;
|
showIcon?: boolean;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
@@ -19,9 +22,49 @@ interface ConfirmDialogProps {
|
|||||||
afterActions?: ComponentChildren;
|
afterActions?: ComponentChildren;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function incrementDialogBodyLock() {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const body = document.body;
|
||||||
|
const nextCount = Number(body.dataset.dialogCount || '0') + 1;
|
||||||
|
body.dataset.dialogCount = String(nextCount);
|
||||||
|
body.classList.add('dialog-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function decrementDialogBodyLock() {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const body = document.body;
|
||||||
|
const nextCount = Math.max(0, Number(body.dataset.dialogCount || '0') - 1);
|
||||||
|
if (nextCount === 0) {
|
||||||
|
delete body.dataset.dialogCount;
|
||||||
|
body.classList.remove('dialog-open');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.dataset.dialogCount = String(nextCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDialogLifecycle(active: boolean, onCancel?: (() => void) | null) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
incrementDialogBodyLock();
|
||||||
|
return () => decrementDialogBodyLock();
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active || !onCancel || typeof window === 'undefined') return;
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
event.preventDefault();
|
||||||
|
onCancel();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [active, onCancel]);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||||
const [present, setPresent] = useState(props.open);
|
const [present, setPresent] = useState(props.open);
|
||||||
const [closing, setClosing] = useState(false);
|
const [closing, setClosing] = useState(false);
|
||||||
|
const canDismiss = !props.cancelDisabled && !closing && !props.hideCancel;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.open) {
|
if (props.open) {
|
||||||
@@ -38,19 +81,41 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [props.open, present]);
|
}, [props.open, present]);
|
||||||
|
|
||||||
if (!present) return null;
|
useDialogLifecycle(present, canDismiss ? props.onCancel : null);
|
||||||
return (
|
|
||||||
<div className={`dialog-mask ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}>
|
if (!present || typeof document === 'undefined') return null;
|
||||||
|
return createPortal((
|
||||||
|
<div
|
||||||
|
className={`dialog-mask ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target !== event.currentTarget || !canDismiss) return;
|
||||||
|
props.onCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<form
|
<form
|
||||||
className={`dialog-card ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
className={`dialog-card ${props.variant === 'warning' ? 'warning' : ''} ${props.open && !closing ? 'open' : ''} ${closing ? 'closing' : ''}`}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={props.title}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (props.confirmDisabled || closing) return;
|
if (props.confirmDisabled || closing) return;
|
||||||
props.onConfirm();
|
props.onConfirm();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{props.variant === 'warning' ? (
|
||||||
|
<>
|
||||||
|
<div className="dialog-warning-strip" aria-hidden="true" />
|
||||||
|
<div className="dialog-warning-head">
|
||||||
|
<div className="dialog-warning-badge" aria-hidden="true">
|
||||||
|
<TriangleAlert size={24} />
|
||||||
|
</div>
|
||||||
|
<div className="dialog-warning-kicker">{t('txt_warning')}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
<h3 className="dialog-title">{props.title}</h3>
|
<h3 className="dialog-title">{props.title}</h3>
|
||||||
<div className="dialog-message">{props.message}</div>
|
<div className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||||
{props.children}
|
{props.children}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -75,5 +140,5 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
|||||||
{props.afterActions}
|
{props.afterActions}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
), document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'preact/hooks';
|
import { useState } from 'preact/hooks';
|
||||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||||
|
import { createPortal } from 'preact/compat';
|
||||||
import { strFromU8, unzipSync } from 'fflate';
|
import { strFromU8, unzipSync } from 'fflate';
|
||||||
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||||
import { Download, FileUp } from 'lucide-preact';
|
import { Download, FileUp } from 'lucide-preact';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||||
import {
|
import {
|
||||||
type EncryptedJsonMode,
|
type EncryptedJsonMode,
|
||||||
@@ -311,6 +312,8 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
|
const [exportAuthDialogOpen, setExportAuthDialogOpen] = useState(false);
|
||||||
const [exportAuthPassword, setExportAuthPassword] = useState('');
|
const [exportAuthPassword, setExportAuthPassword] = useState('');
|
||||||
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
|
const [importSummary, setImportSummary] = useState<ImportResultSummary | null>(null);
|
||||||
|
|
||||||
|
useDialogLifecycle(!!importSummary, importSummary ? () => setImportSummary(null) : null);
|
||||||
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
|
const commonSourceSet = new Set<ImportSourceId>(COMMON_IMPORT_SOURCE_IDS);
|
||||||
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
|
const commonSources = IMPORT_SOURCES.filter((item) => commonSourceSet.has(item.id as ImportSourceId));
|
||||||
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
|
const otherSources = IMPORT_SOURCES.filter((item) => !commonSourceSet.has(item.id as ImportSourceId));
|
||||||
@@ -803,9 +806,15 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
{importSummary && (
|
{importSummary && typeof document !== 'undefined' ? createPortal((
|
||||||
<div className="dialog-mask">
|
<div
|
||||||
<section className="dialog-card import-summary-dialog">
|
className="dialog-mask"
|
||||||
|
onClick={(event) => {
|
||||||
|
if (event.target !== event.currentTarget) return;
|
||||||
|
setImportSummary(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section className="dialog-card import-summary-dialog" role="dialog" aria-modal="true" aria-label={t('txt_import_success')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="import-summary-close"
|
className="import-summary-close"
|
||||||
@@ -866,7 +875,7 @@ export default function ImportPage({ onImport, onImportEncryptedRaw, accountKeys
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)}
|
), document.body) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -828,6 +828,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
sortMenuRef={sortMenuRef}
|
sortMenuRef={sortMenuRef}
|
||||||
listPanelRef={listPanelRef}
|
listPanelRef={listPanelRef}
|
||||||
onSearchInput={setSearchInput}
|
onSearchInput={setSearchInput}
|
||||||
|
onClearSearch={() => setSearchInput('')}
|
||||||
onSearchCompositionStart={() => setSearchComposing(true)}
|
onSearchCompositionStart={() => setSearchComposing(true)}
|
||||||
onSearchCompositionEnd={(value) => {
|
onSearchCompositionEnd={(value) => {
|
||||||
setSearchComposing(false);
|
setSearchComposing(false);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ interface VaultListPanelProps {
|
|||||||
sortMenuRef: RefObject<HTMLDivElement>;
|
sortMenuRef: RefObject<HTMLDivElement>;
|
||||||
listPanelRef: RefObject<HTMLDivElement>;
|
listPanelRef: RefObject<HTMLDivElement>;
|
||||||
onSearchInput: (value: string) => void;
|
onSearchInput: (value: string) => void;
|
||||||
|
onClearSearch: () => void;
|
||||||
onSearchCompositionStart: () => void;
|
onSearchCompositionStart: () => void;
|
||||||
onSearchCompositionEnd: (value: string) => void;
|
onSearchCompositionEnd: (value: string) => void;
|
||||||
onToggleSortMenu: () => void;
|
onToggleSortMenu: () => void;
|
||||||
@@ -62,14 +63,32 @@ export default function VaultListPanel(props: VaultListPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="list-col">
|
<section className="list-col">
|
||||||
<div className="list-head">
|
<div className="list-head">
|
||||||
<input
|
<div className="search-input-wrap">
|
||||||
className="search-input"
|
<input
|
||||||
placeholder={t('txt_search_your_secure_vault')}
|
className="search-input"
|
||||||
value={props.searchInput}
|
placeholder={t('txt_search_your_secure_vault')}
|
||||||
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
value={props.searchInput}
|
||||||
onCompositionStart={props.onSearchCompositionStart}
|
onInput={(e) => props.onSearchInput((e.currentTarget as HTMLInputElement).value)}
|
||||||
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
onCompositionStart={props.onSearchCompositionStart}
|
||||||
/>
|
onCompositionEnd={(e) => props.onSearchCompositionEnd((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key !== 'Escape' || !props.searchInput) return;
|
||||||
|
e.preventDefault();
|
||||||
|
props.onClearSearch();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!!props.searchInput && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-clear-btn"
|
||||||
|
aria-label={t('txt_clear_search')}
|
||||||
|
title={t('txt_clear_search_esc')}
|
||||||
|
onClick={props.onClearSearch}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
<div className="sort-menu-wrap" ref={props.sortMenuRef}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
import {
|
import {
|
||||||
|
type BackupExportClientProgressEvent,
|
||||||
buildCompleteAdminBackupExport,
|
buildCompleteAdminBackupExport,
|
||||||
deleteRemoteBackup,
|
deleteRemoteBackup,
|
||||||
downloadRemoteBackup,
|
downloadRemoteBackup as fetchRemoteBackupPayload,
|
||||||
getAdminBackupSettings,
|
getAdminBackupSettings,
|
||||||
importAdminBackup,
|
importAdminBackup,
|
||||||
|
inspectRemoteBackupIntegrity,
|
||||||
listRemoteBackups,
|
listRemoteBackups,
|
||||||
restoreRemoteBackup,
|
restoreRemoteBackup as restoreRemoteBackupRequest,
|
||||||
runAdminBackupNow,
|
runAdminBackupNow,
|
||||||
saveAdminBackupSettings,
|
saveAdminBackupSettings,
|
||||||
} from '@/lib/api/backup';
|
} from '@/lib/api/backup';
|
||||||
import { downloadBytesAsFile } from '@/lib/download';
|
import { downloadBytesAsFile } from '@/lib/download';
|
||||||
|
import { dispatchBackupProgress } from '@/lib/backup-restore-progress';
|
||||||
import type { AuthedFetch } from '@/lib/api/shared';
|
import type { AuthedFetch } from '@/lib/api/shared';
|
||||||
|
|
||||||
interface UseBackupActionsOptions {
|
interface UseBackupActionsOptions {
|
||||||
@@ -25,8 +28,24 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
return useMemo(
|
return useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
async exportBackup(includeAttachments: boolean = false) {
|
async exportBackup(includeAttachments: boolean = false) {
|
||||||
const payload = await buildCompleteAdminBackupExport(authedFetch, includeAttachments);
|
const payload = await buildCompleteAdminBackupExport(
|
||||||
|
authedFetch,
|
||||||
|
includeAttachments,
|
||||||
|
async (event: BackupExportClientProgressEvent) => {
|
||||||
|
dispatchBackupProgress(event);
|
||||||
|
}
|
||||||
|
);
|
||||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||||
|
dispatchBackupProgress({
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_complete',
|
||||||
|
fileName: payload.fileName,
|
||||||
|
stageTitle: 'txt_backup_export_progress_complete_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_complete_detail',
|
||||||
|
done: true,
|
||||||
|
ok: true,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async importBackup(file: File, replaceExisting: boolean = false) {
|
async importBackup(file: File, replaceExisting: boolean = false) {
|
||||||
@@ -35,6 +54,12 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) {
|
||||||
|
const result = await importAdminBackup(authedFetch, file, replaceExisting, true);
|
||||||
|
onImported?.();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
async loadSettings() {
|
async loadSettings() {
|
||||||
return getAdminBackupSettings(authedFetch);
|
return getAdminBackupSettings(authedFetch);
|
||||||
},
|
},
|
||||||
@@ -52,16 +77,26 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
|
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
|
||||||
const payload = await downloadRemoteBackup(authedFetch, destinationId, path, onProgress);
|
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress);
|
||||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async inspectRemoteBackup(destinationId: string, path: string) {
|
||||||
|
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
|
||||||
|
},
|
||||||
|
|
||||||
async deleteRemoteBackup(destinationId: string, path: string) {
|
async deleteRemoteBackup(destinationId: string, path: string) {
|
||||||
await deleteRemoteBackup(authedFetch, destinationId, path);
|
await deleteRemoteBackup(authedFetch, destinationId, path);
|
||||||
},
|
},
|
||||||
|
|
||||||
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
|
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||||
const result = await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting);
|
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting);
|
||||||
|
onRestored?.();
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) {
|
||||||
|
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true);
|
||||||
onRestored?.();
|
onRestored?.();
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,6 +57,21 @@ export interface AdminBackupRunResponse {
|
|||||||
settings: AdminBackupSettings;
|
settings: AdminBackupSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupFileIntegrityCheckResult {
|
||||||
|
hasChecksumPrefix: boolean;
|
||||||
|
expectedPrefix: string | null;
|
||||||
|
actualPrefix: string;
|
||||||
|
matches: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteBackupIntegrityResponse {
|
||||||
|
object: 'backup-remote-integrity';
|
||||||
|
destinationId: string;
|
||||||
|
path: string;
|
||||||
|
fileName: string;
|
||||||
|
integrity: BackupFileIntegrityCheckResult;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RemoteBackupItem {
|
export interface RemoteBackupItem {
|
||||||
path: string;
|
path: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -109,6 +124,18 @@ export interface AdminBackupExportPayload {
|
|||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BackupExportClientProgressEvent {
|
||||||
|
operation: 'backup-export';
|
||||||
|
source: 'local';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle: string;
|
||||||
|
stageDetail: string;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface BackupExportManifestAttachmentBlob {
|
interface BackupExportManifestAttachmentBlob {
|
||||||
cipherId: string;
|
cipherId: string;
|
||||||
attachmentId: string;
|
attachmentId: string;
|
||||||
@@ -119,6 +146,36 @@ interface BackupExportManifest {
|
|||||||
attachmentBlobs?: BackupExportManifestAttachmentBlob[];
|
attachmentBlobs?: BackupExportManifestAttachmentBlob[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||||
|
|
||||||
|
function parseBackupTimestampFromFileName(fileName: string): Date | null {
|
||||||
|
const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i);
|
||||||
|
if (!match) return null;
|
||||||
|
const datePart = match[1];
|
||||||
|
const timePart = match[2];
|
||||||
|
const iso = `${datePart.slice(0, 4)}-${datePart.slice(4, 6)}-${datePart.slice(6, 8)}T${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}.000Z`;
|
||||||
|
const parsed = new Date(iso);
|
||||||
|
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFileName(date: Date, checksumPrefix: string): string {
|
||||||
|
const parts = [
|
||||||
|
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||||
|
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||||
|
date.getUTCDate().toString().padStart(2, '0'),
|
||||||
|
date.getUTCHours().toString().padStart(2, '0'),
|
||||||
|
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||||
|
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||||
|
];
|
||||||
|
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}_${checksumPrefix}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
|
||||||
|
const integrity = await verifyBackupFileIntegrity(bytes, fileName);
|
||||||
|
const effectiveDate = parseBackupTimestampFromFileName(fileName) || new Date();
|
||||||
|
return buildBackupFileName(effectiveDate, integrity.actualPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
export async function exportAdminBackup(
|
export async function exportAdminBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
includeAttachments: boolean = false
|
includeAttachments: boolean = false
|
||||||
@@ -149,10 +206,21 @@ export async function downloadAdminBackupAttachmentBlob(
|
|||||||
|
|
||||||
export async function buildCompleteAdminBackupExport(
|
export async function buildCompleteAdminBackupExport(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
includeAttachments: boolean = false
|
includeAttachments: boolean = false,
|
||||||
|
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
|
||||||
): Promise<AdminBackupExportPayload> {
|
): Promise<AdminBackupExportPayload> {
|
||||||
const payload = await exportAdminBackup(authedFetch, includeAttachments);
|
const payload = await exportAdminBackup(authedFetch, includeAttachments);
|
||||||
if (!includeAttachments) return payload;
|
if (!includeAttachments) {
|
||||||
|
await onProgress?.({
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_client_save',
|
||||||
|
fileName: payload.fileName,
|
||||||
|
stageTitle: 'txt_backup_export_progress_save_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_save_detail',
|
||||||
|
});
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
const zipped = unzipSync(payload.bytes);
|
const zipped = unzipSync(payload.bytes);
|
||||||
const manifestBytes = zipped['manifest.json'];
|
const manifestBytes = zipped['manifest.json'];
|
||||||
@@ -167,14 +235,41 @@ export async function buildCompleteAdminBackupExport(
|
|||||||
throw new Error(t('txt_backup_export_failed'));
|
throw new Error(t('txt_backup_export_failed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await onProgress?.({
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_client_fetch_attachments',
|
||||||
|
fileName: payload.fileName,
|
||||||
|
stageTitle: 'txt_backup_export_progress_fetch_attachments_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
||||||
|
});
|
||||||
for (const attachment of manifest.attachmentBlobs || []) {
|
for (const attachment of manifest.attachmentBlobs || []) {
|
||||||
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
|
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
|
||||||
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await onProgress?.({
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_client_rebuild',
|
||||||
|
fileName: payload.fileName,
|
||||||
|
stageTitle: 'txt_backup_export_progress_rebuild_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_rebuild_detail',
|
||||||
|
});
|
||||||
|
const rebuiltBytes = zipSync(zipped, { level: 0 });
|
||||||
|
const rebuiltFileName = await applyBackupFileIntegrityName(payload.fileName, rebuiltBytes);
|
||||||
|
await onProgress?.({
|
||||||
|
operation: 'backup-export',
|
||||||
|
source: 'local',
|
||||||
|
step: 'export_client_save',
|
||||||
|
fileName: rebuiltFileName,
|
||||||
|
stageTitle: 'txt_backup_export_progress_save_title',
|
||||||
|
stageDetail: 'txt_backup_export_progress_save_detail',
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...payload,
|
...payload,
|
||||||
bytes: zipSync(zipped, { level: 0 }),
|
bytes: rebuiltBytes,
|
||||||
|
fileName: rebuiltFileName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +371,29 @@ export async function downloadRemoteBackup(
|
|||||||
return { fileName, mimeType, bytes };
|
return { fileName, mimeType, bytes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||||
|
const normalized = String(fileName || '').trim();
|
||||||
|
const match = normalized.match(/_([0-9a-f]{5})\.zip$/i);
|
||||||
|
return match ? match[1].toLowerCase() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||||
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: string): Promise<BackupFileIntegrityCheckResult> {
|
||||||
|
const expectedPrefix = extractBackupFileChecksumPrefix(fileName);
|
||||||
|
const actualHash = await sha256Hex(bytes);
|
||||||
|
const actualPrefix = actualHash.slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||||
|
return {
|
||||||
|
hasChecksumPrefix: !!expectedPrefix,
|
||||||
|
expectedPrefix,
|
||||||
|
actualPrefix,
|
||||||
|
matches: !expectedPrefix || expectedPrefix === actualPrefix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteRemoteBackup(
|
export async function deleteRemoteBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
@@ -288,16 +406,32 @@ export async function deleteRemoteBackup(
|
|||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function inspectRemoteBackupIntegrity(
|
||||||
|
authedFetch: AuthedFetch,
|
||||||
|
destinationId: string,
|
||||||
|
path: string
|
||||||
|
): Promise<RemoteBackupIntegrityResponse> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('destinationId', destinationId);
|
||||||
|
params.set('path', path);
|
||||||
|
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
|
||||||
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
||||||
|
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
|
||||||
|
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
export async function restoreRemoteBackup(
|
export async function restoreRemoteBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
path: string,
|
path: string,
|
||||||
replaceExisting: boolean = false
|
replaceExisting: boolean = false,
|
||||||
|
allowChecksumMismatch: boolean = false
|
||||||
): Promise<AdminBackupImportResponse> {
|
): Promise<AdminBackupImportResponse> {
|
||||||
const resp = await authedFetch('/api/admin/backup/remote/restore', {
|
const resp = await authedFetch('/api/admin/backup/remote/restore', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ destinationId, path, replaceExisting }),
|
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
|
||||||
const body = await parseJson<AdminBackupImportResponse>(resp);
|
const body = await parseJson<AdminBackupImportResponse>(resp);
|
||||||
@@ -308,13 +442,17 @@ export async function restoreRemoteBackup(
|
|||||||
export async function importAdminBackup(
|
export async function importAdminBackup(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
file: File,
|
file: File,
|
||||||
replaceExisting: boolean = false
|
replaceExisting: boolean = false,
|
||||||
|
allowChecksumMismatch: boolean = false
|
||||||
): Promise<AdminBackupImportResponse> {
|
): Promise<AdminBackupImportResponse> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.set('file', file, file.name || 'nodewarden_backup.zip');
|
formData.set('file', file, file.name || 'nodewarden_backup.zip');
|
||||||
if (replaceExisting) {
|
if (replaceExisting) {
|
||||||
formData.set('replaceExisting', '1');
|
formData.set('replaceExisting', '1');
|
||||||
}
|
}
|
||||||
|
if (allowChecksumMismatch) {
|
||||||
|
formData.set('allowChecksumMismatch', '1');
|
||||||
|
}
|
||||||
|
|
||||||
const resp = await authedFetch('/api/admin/backup/import', {
|
const resp = await authedFetch('/api/admin/backup/import', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface WebVaultSignalRInvocation {
|
|||||||
UserId?: string;
|
UserId?: string;
|
||||||
Date?: string;
|
Date?: string;
|
||||||
RevisionDate?: string;
|
RevisionDate?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export type BackupProgressOperation = 'backup-restore' | 'backup-export' | 'backup-remote-run';
|
||||||
|
|
||||||
|
export interface BackupProgressDetail {
|
||||||
|
operation: BackupProgressOperation;
|
||||||
|
source?: 'local' | 'remote';
|
||||||
|
step: string;
|
||||||
|
fileName: string;
|
||||||
|
stageTitle?: string;
|
||||||
|
stageDetail?: string;
|
||||||
|
replaceExisting?: boolean;
|
||||||
|
done?: boolean;
|
||||||
|
ok?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
Date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BackupRestoreProgressDetail = BackupProgressDetail;
|
||||||
|
|
||||||
|
export const BACKUP_PROGRESS_EVENT = 'nodewarden:backup-progress';
|
||||||
|
export const BACKUP_RESTORE_PROGRESS_EVENT = BACKUP_PROGRESS_EVENT;
|
||||||
|
|
||||||
|
export function dispatchBackupProgress(detail: BackupProgressDetail): void {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.dispatchEvent(new CustomEvent<BackupProgressDetail>(BACKUP_PROGRESS_EVENT, { detail }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dispatchBackupRestoreProgress = dispatchBackupProgress;
|
||||||
+158
-6
@@ -26,11 +26,16 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_backup_export_success: "Backup exported",
|
txt_backup_export_success: "Backup exported",
|
||||||
txt_backup_import_success_relogin: "Backup restored. Please sign in again.",
|
txt_backup_import_success_relogin: "Backup restored. Please sign in again.",
|
||||||
txt_backup_restore_success_relogin: "Backup restored. Please sign in again.",
|
txt_backup_restore_success_relogin: "Backup restored. Please sign in again.",
|
||||||
|
txt_backup_restore_completed_verified: "Backup file integrity verification passed.",
|
||||||
|
txt_backup_restore_completed_without_checksum: "Backup restored. No filename integrity marker was available for verification.",
|
||||||
|
txt_backup_remote_restore_completed_verified: "Remote backup integrity verification passed.",
|
||||||
|
txt_backup_remote_restore_completed_without_checksum: "Remote backup restored. No filename integrity marker was available for verification.",
|
||||||
txt_backup_restore_skipped_summary: "{reason}. Skipped {attachments} attachment(s).",
|
txt_backup_restore_skipped_summary: "{reason}. Skipped {attachments} attachment(s).",
|
||||||
txt_backup_restore_skipped_reason_default: "Some files could not be restored",
|
txt_backup_restore_skipped_reason_default: "Some files could not be restored",
|
||||||
txt_backup_export_failed: "Backup export failed",
|
txt_backup_export_failed: "Backup export failed",
|
||||||
txt_backup_import_failed: "Backup restore failed",
|
txt_backup_import_failed: "Backup restore failed",
|
||||||
txt_backup_restore_failed: "Backup restore failed",
|
txt_backup_restore_failed: "Backup restore failed",
|
||||||
|
txt_backup_integrity_check_failed: "Backup integrity verification failed",
|
||||||
txt_backup_center_title: "Instance Backup",
|
txt_backup_center_title: "Instance Backup",
|
||||||
txt_backup_center_description: "Keep local exports for manual restore, and configure one daily remote backup target for unattended protection.",
|
txt_backup_center_description: "Keep local exports for manual restore, and configure one daily remote backup target for unattended protection.",
|
||||||
txt_backup_restore_note: "Restoring will overwrite the current instance if you choose the replace flow.",
|
txt_backup_restore_note: "Restoring will overwrite the current instance if you choose the replace flow.",
|
||||||
@@ -99,6 +104,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_backup_run_manual: "Run Manually",
|
txt_backup_run_manual: "Run Manually",
|
||||||
txt_backup_running_now: "Running...",
|
txt_backup_running_now: "Running...",
|
||||||
txt_backup_remote_run_success: "Remote backup completed",
|
txt_backup_remote_run_success: "Remote backup completed",
|
||||||
|
txt_backup_remote_run_success_verified: "Remote backup completed and integrity verification passed.",
|
||||||
txt_backup_remote_run_failed: "Remote backup failed",
|
txt_backup_remote_run_failed: "Remote backup failed",
|
||||||
txt_backup_remote_title: "Remote Backups",
|
txt_backup_remote_title: "Remote Backups",
|
||||||
txt_backup_remote_note: "Browse the saved destination and choose a backup ZIP to download or restore.",
|
txt_backup_remote_note: "Browse the saved destination and choose a backup ZIP to download or restore.",
|
||||||
@@ -112,6 +118,68 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_backup_remote_restore: "Restore",
|
txt_backup_remote_restore: "Restore",
|
||||||
txt_backup_remote_restore_stage_prepare: "Preparing remote backup restore...",
|
txt_backup_remote_restore_stage_prepare: "Preparing remote backup restore...",
|
||||||
txt_backup_remote_restore_stage_replace: "Clearing current data and restoring remote backup...",
|
txt_backup_remote_restore_stage_replace: "Clearing current data and restoring remote backup...",
|
||||||
|
txt_backup_progress_kicker: "Backup Task",
|
||||||
|
txt_backup_progress_subject: "Current item: {name}",
|
||||||
|
txt_backup_restore_progress_kicker: "Restore Progress",
|
||||||
|
txt_backup_restore_progress_local_title: "Restoring local backup",
|
||||||
|
txt_backup_restore_progress_remote_title: "Restoring remote backup",
|
||||||
|
txt_backup_export_progress_title: "Exporting backup",
|
||||||
|
txt_backup_remote_run_progress_title: "Running remote backup",
|
||||||
|
txt_backup_restore_progress_file: "Current file: {name}",
|
||||||
|
txt_backup_restore_progress_elapsed: "{seconds}s elapsed",
|
||||||
|
txt_backup_archive_progress_collect_title: "Collecting vault data",
|
||||||
|
txt_backup_archive_progress_collect_detail: "The server is reading database tables and assembling the backup payload.",
|
||||||
|
txt_backup_archive_progress_collect_with_attachments_detail: "The server is reading database tables and collecting attachment metadata for the backup payload.",
|
||||||
|
txt_backup_archive_progress_package_title: "Packaging backup archive",
|
||||||
|
txt_backup_archive_progress_package_detail: "The server is generating the backup ZIP and computing its checksum prefix.",
|
||||||
|
txt_backup_archive_progress_package_with_attachments_detail: "The server is generating the backup ZIP metadata and computing its checksum prefix for the attachment-aware export.",
|
||||||
|
txt_backup_archive_progress_ready_title: "Preparing download",
|
||||||
|
txt_backup_archive_progress_ready_detail: "The backup archive is ready and is being returned to the browser.",
|
||||||
|
txt_backup_export_progress_fetch_attachments_title: "Downloading attachment files",
|
||||||
|
txt_backup_export_progress_fetch_attachments_detail: "The browser is fetching attachment objects and adding them into the export package.",
|
||||||
|
txt_backup_export_progress_rebuild_title: "Rebuilding export archive",
|
||||||
|
txt_backup_export_progress_rebuild_detail: "The browser is rebuilding the final ZIP and refreshing its checksum suffix.",
|
||||||
|
txt_backup_export_progress_save_title: "Saving export file",
|
||||||
|
txt_backup_export_progress_save_detail: "The browser is preparing the final backup file for download.",
|
||||||
|
txt_backup_export_progress_complete_title: "Export completed",
|
||||||
|
txt_backup_export_progress_complete_detail: "The backup export is ready.",
|
||||||
|
txt_backup_export_progress_failed_title: "Export failed",
|
||||||
|
txt_backup_export_progress_failed_detail: "The backup export could not be completed.",
|
||||||
|
txt_backup_remote_run_progress_prepare_title: "Preparing remote backup",
|
||||||
|
txt_backup_remote_run_progress_prepare_detail: "The server is loading the selected destination and preparing this backup run.",
|
||||||
|
txt_backup_remote_run_progress_sync_attachments_title: "Checking attachment index",
|
||||||
|
txt_backup_remote_run_progress_sync_attachments_detail: "The server is comparing attachment metadata so only missing attachment objects are uploaded.",
|
||||||
|
txt_backup_remote_run_progress_sync_attachments_skipped_detail: "This backup does not include attachments, so attachment synchronization is skipped.",
|
||||||
|
txt_backup_remote_run_progress_upload_title: "Uploading backup archive",
|
||||||
|
txt_backup_remote_run_progress_upload_detail: "The server is uploading the backup ZIP to the remote destination.",
|
||||||
|
txt_backup_remote_run_progress_verify_title: "Verifying uploaded archive",
|
||||||
|
txt_backup_remote_run_progress_verify_detail: "The server is downloading the uploaded ZIP back and verifying its checksum and size.",
|
||||||
|
txt_backup_remote_run_progress_cleanup_title: "Cleaning older backups",
|
||||||
|
txt_backup_remote_run_progress_cleanup_detail: "The server is pruning older backup files according to the retention policy.",
|
||||||
|
txt_backup_remote_run_progress_complete_title: "Remote backup completed",
|
||||||
|
txt_backup_remote_run_progress_complete_detail: "The remote backup has been uploaded and verified successfully.",
|
||||||
|
txt_backup_remote_run_progress_failed_title: "Remote backup failed",
|
||||||
|
txt_backup_remote_run_progress_failed_detail: "The remote backup could not be completed.",
|
||||||
|
txt_backup_restore_progress_local_upload_title: "Uploading backup archive",
|
||||||
|
txt_backup_restore_progress_local_upload_detail: "The selected ZIP is being sent to the server for processing.",
|
||||||
|
txt_backup_restore_progress_local_shadow_title: "Creating shadow workspace",
|
||||||
|
txt_backup_restore_progress_local_shadow_detail: "The server is preparing an isolated restore area so the current data remains untouched until validation passes.",
|
||||||
|
txt_backup_restore_progress_local_data_title: "Writing vault data",
|
||||||
|
txt_backup_restore_progress_local_data_detail: "The server is importing users, folders, vault items, and related metadata into shadow tables.",
|
||||||
|
txt_backup_restore_progress_local_files_title: "Restoring attachment files",
|
||||||
|
txt_backup_restore_progress_local_files_detail: "The server is writing attachment objects back to storage and removing any attachment rows that cannot be restored.",
|
||||||
|
txt_backup_restore_progress_local_finalize_title: "Validating and switching data",
|
||||||
|
txt_backup_restore_progress_local_finalize_detail: "The server is performing final validation and then swapping the verified restore data into the live tables.",
|
||||||
|
txt_backup_restore_progress_remote_fetch_title: "Reading remote backup",
|
||||||
|
txt_backup_restore_progress_remote_fetch_detail: "The server is downloading the selected backup package from the remote destination.",
|
||||||
|
txt_backup_restore_progress_remote_shadow_title: "Creating shadow workspace",
|
||||||
|
txt_backup_restore_progress_remote_shadow_detail: "The server is preparing an isolated restore area so the current data remains untouched until validation passes.",
|
||||||
|
txt_backup_restore_progress_remote_data_title: "Writing vault data",
|
||||||
|
txt_backup_restore_progress_remote_data_detail: "The server is importing users, folders, vault items, and related metadata into shadow tables.",
|
||||||
|
txt_backup_restore_progress_remote_files_title: "Restoring remote attachments",
|
||||||
|
txt_backup_restore_progress_remote_files_detail: "The server is fetching required attachment objects from remote storage and writing them back into local storage.",
|
||||||
|
txt_backup_restore_progress_remote_finalize_title: "Validating and switching data",
|
||||||
|
txt_backup_restore_progress_remote_finalize_detail: "The server is performing final validation and then switching the verified restore data into the live tables.",
|
||||||
txt_backup_remote_loading: "Loading remote backups...",
|
txt_backup_remote_loading: "Loading remote backups...",
|
||||||
txt_backup_remote_cached_empty: "Click Refresh to load this destination.",
|
txt_backup_remote_cached_empty: "Click Refresh to load this destination.",
|
||||||
txt_backup_remote_empty: "No backup files found in this folder.",
|
txt_backup_remote_empty: "No backup files found in this folder.",
|
||||||
@@ -126,6 +194,11 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_backup_remote_delete_confirm_message: "Delete backup file \"{name}\"? This cannot be undone.",
|
txt_backup_remote_delete_confirm_message: "Delete backup file \"{name}\"? This cannot be undone.",
|
||||||
txt_backup_remote_deleting: "Deleting...",
|
txt_backup_remote_deleting: "Deleting...",
|
||||||
txt_backup_remote_restore_failed: "Restoring remote backup failed",
|
txt_backup_remote_restore_failed: "Restoring remote backup failed",
|
||||||
|
txt_backup_restore_checksum_warning_title: "Backup Integrity Warning",
|
||||||
|
txt_backup_restore_checksum_warning_message: "The selected backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be incomplete or corrupted. Continuing may restore damaged data.",
|
||||||
|
txt_backup_remote_restore_checksum_warning_message: "The remote backup file \"{name}\" failed filename integrity verification. Expected prefix {expected}, actual prefix {actual}. The file may be corrupted during upload or storage. Continuing may restore damaged data and may cause serious data loss.",
|
||||||
|
txt_backup_restore_checksum_warning_message_fallback: "The selected backup file failed integrity verification. Continuing may restore damaged data.",
|
||||||
|
txt_backup_restore_checksum_warning_confirm: "Continue Restore",
|
||||||
txt_backup_remote_restore_invalid_response: "Invalid remote backup restore response",
|
txt_backup_remote_restore_invalid_response: "Invalid remote backup restore response",
|
||||||
txt_backup_remote_run_invalid_response: "Invalid remote backup run response",
|
txt_backup_remote_run_invalid_response: "Invalid remote backup run response",
|
||||||
txt_backup_settings_invalid_response: "Invalid backup settings response",
|
txt_backup_settings_invalid_response: "Invalid backup settings response",
|
||||||
@@ -197,9 +270,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_backup_no_file_selected: "No backup file selected",
|
txt_backup_no_file_selected: "No backup file selected",
|
||||||
txt_backup_selected_file_name: "Selected file: {name}",
|
txt_backup_selected_file_name: "Selected file: {name}",
|
||||||
txt_backup_replace_confirm_title: "Replace Current Instance Data",
|
txt_backup_replace_confirm_title: "Replace Current Instance Data",
|
||||||
txt_backup_replace_confirm_message: "The current instance already contains data. Clear it and restore the selected backup?",
|
txt_backup_replace_confirm_message: "The current instance already contains data. Continue restoring and replace the current instance data with the selected backup after verification succeeds?",
|
||||||
txt_backup_clear_and_import: "Clear and Import",
|
txt_backup_clear_and_import: "Replace and Import",
|
||||||
txt_backup_clear_and_restore: "Clear and Restore",
|
txt_backup_clear_and_restore: "Replace and Restore",
|
||||||
txt_access_count: "Access Count",
|
txt_access_count: "Access Count",
|
||||||
txt_accessed_count_times: "Accessed {count} times",
|
txt_accessed_count_times: "Accessed {count} times",
|
||||||
txt_actions: "Actions",
|
txt_actions: "Actions",
|
||||||
@@ -546,6 +619,8 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_save_profile_failed: "Save profile failed",
|
txt_save_profile_failed: "Save profile failed",
|
||||||
txt_search_sends: "Search sends...",
|
txt_search_sends: "Search sends...",
|
||||||
txt_search_your_secure_vault: "Search your secure vault...",
|
txt_search_your_secure_vault: "Search your secure vault...",
|
||||||
|
txt_clear_search: "Clear search",
|
||||||
|
txt_clear_search_esc: "Clear search (Esc)",
|
||||||
txt_sort: "Sort",
|
txt_sort: "Sort",
|
||||||
txt_sort_last_edited: "Modified",
|
txt_sort_last_edited: "Modified",
|
||||||
txt_sort_created: "Created",
|
txt_sort_created: "Created",
|
||||||
@@ -641,6 +716,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_vault_synced: "Vault synced",
|
txt_vault_synced: "Vault synced",
|
||||||
txt_verification_code: "Verification Code",
|
txt_verification_code: "Verification Code",
|
||||||
txt_verify: "Verify",
|
txt_verify: "Verify",
|
||||||
|
txt_warning: "Warning",
|
||||||
txt_view_recovery_code: "View Recovery Code",
|
txt_view_recovery_code: "View Recovery Code",
|
||||||
txt_web: "Web",
|
txt_web: "Web",
|
||||||
txt_website: "Website",
|
txt_website: "Website",
|
||||||
@@ -674,11 +750,16 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_backup_export_success: '备份已导出',
|
txt_backup_export_success: '备份已导出',
|
||||||
txt_backup_import_success_relogin: '备份已还原,请重新登录',
|
txt_backup_import_success_relogin: '备份已还原,请重新登录',
|
||||||
txt_backup_restore_success_relogin: '备份已还原,请重新登录',
|
txt_backup_restore_success_relogin: '备份已还原,请重新登录',
|
||||||
|
txt_backup_restore_completed_verified: '备份文件完整性校验已通过。',
|
||||||
|
txt_backup_restore_completed_without_checksum: '备份已还原,但文件名中未提供可校验的完整性标记。',
|
||||||
|
txt_backup_remote_restore_completed_verified: '远程备份完整性校验已通过。',
|
||||||
|
txt_backup_remote_restore_completed_without_checksum: '远程备份已还原,但文件名中未提供可校验的完整性标记。',
|
||||||
txt_backup_restore_skipped_summary: '{reason},已跳过 {attachments} 个附件',
|
txt_backup_restore_skipped_summary: '{reason},已跳过 {attachments} 个附件',
|
||||||
txt_backup_restore_skipped_reason_default: '部分文件无法还原',
|
txt_backup_restore_skipped_reason_default: '部分文件无法还原',
|
||||||
txt_backup_export_failed: '备份导出失败',
|
txt_backup_export_failed: '备份导出失败',
|
||||||
txt_backup_import_failed: '备份还原失败',
|
txt_backup_import_failed: '备份还原失败',
|
||||||
txt_backup_restore_failed: '备份还原失败',
|
txt_backup_restore_failed: '备份还原失败',
|
||||||
|
txt_backup_integrity_check_failed: '备份完整性校验失败',
|
||||||
txt_backup_center_title: '实例备份',
|
txt_backup_center_title: '实例备份',
|
||||||
txt_backup_center_description: '把本地导出和远程自动备份放在一起管理,既方便手动恢复,也能每天自动留一份。',
|
txt_backup_center_description: '把本地导出和远程自动备份放在一起管理,既方便手动恢复,也能每天自动留一份。',
|
||||||
txt_backup_restore_note: '还原会覆盖当前实例;如果当前已有数据,系统会要求你确认“清空后还原”。',
|
txt_backup_restore_note: '还原会覆盖当前实例;如果当前已有数据,系统会要求你确认“清空后还原”。',
|
||||||
@@ -747,6 +828,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_backup_run_manual: '手动执行',
|
txt_backup_run_manual: '手动执行',
|
||||||
txt_backup_running_now: '执行中...',
|
txt_backup_running_now: '执行中...',
|
||||||
txt_backup_remote_run_success: '远程备份已完成',
|
txt_backup_remote_run_success: '远程备份已完成',
|
||||||
|
txt_backup_remote_run_success_verified: '远程备份已完成,且完整性校验已通过。',
|
||||||
txt_backup_remote_run_failed: '远程备份失败',
|
txt_backup_remote_run_failed: '远程备份失败',
|
||||||
txt_backup_remote_title: '远端备份',
|
txt_backup_remote_title: '远端备份',
|
||||||
txt_backup_remote_note: '浏览已保存的备份地点,选择某个备份 ZIP 后可以下载,也可以直接还原。',
|
txt_backup_remote_note: '浏览已保存的备份地点,选择某个备份 ZIP 后可以下载,也可以直接还原。',
|
||||||
@@ -760,6 +842,68 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_backup_remote_restore: '还原',
|
txt_backup_remote_restore: '还原',
|
||||||
txt_backup_remote_restore_stage_prepare: '正在读取远端备份并检查可恢复内容...',
|
txt_backup_remote_restore_stage_prepare: '正在读取远端备份并检查可恢复内容...',
|
||||||
txt_backup_remote_restore_stage_replace: '正在清空当前数据并还原远端备份,请稍候...',
|
txt_backup_remote_restore_stage_replace: '正在清空当前数据并还原远端备份,请稍候...',
|
||||||
|
txt_backup_progress_kicker: '备份任务',
|
||||||
|
txt_backup_progress_subject: '当前对象:{name}',
|
||||||
|
txt_backup_restore_progress_kicker: '还原进度',
|
||||||
|
txt_backup_restore_progress_local_title: '正在还原本地备份',
|
||||||
|
txt_backup_restore_progress_remote_title: '正在还原远端备份',
|
||||||
|
txt_backup_export_progress_title: '正在导出备份',
|
||||||
|
txt_backup_remote_run_progress_title: '正在执行远程备份',
|
||||||
|
txt_backup_restore_progress_file: '当前文件:{name}',
|
||||||
|
txt_backup_restore_progress_elapsed: '已耗时 {seconds} 秒',
|
||||||
|
txt_backup_archive_progress_collect_title: '正在收集密码库数据',
|
||||||
|
txt_backup_archive_progress_collect_detail: '服务器正在读取数据库表,并整理备份所需的数据内容。',
|
||||||
|
txt_backup_archive_progress_collect_with_attachments_detail: '服务器正在读取数据库表,并整理附件元数据与备份内容。',
|
||||||
|
txt_backup_archive_progress_package_title: '正在打包备份压缩包',
|
||||||
|
txt_backup_archive_progress_package_detail: '服务器正在生成备份 ZIP,并计算文件名校验前缀。',
|
||||||
|
txt_backup_archive_progress_package_with_attachments_detail: '服务器正在生成带附件信息的备份 ZIP 元数据,并计算文件名校验前缀。',
|
||||||
|
txt_backup_archive_progress_ready_title: '正在准备下载',
|
||||||
|
txt_backup_archive_progress_ready_detail: '备份压缩包已经生成,服务器正在把它返回给浏览器。',
|
||||||
|
txt_backup_export_progress_fetch_attachments_title: '正在下载附件文件',
|
||||||
|
txt_backup_export_progress_fetch_attachments_detail: '浏览器正在读取附件对象,并把它们补入导出备份包。',
|
||||||
|
txt_backup_export_progress_rebuild_title: '正在重建导出压缩包',
|
||||||
|
txt_backup_export_progress_rebuild_detail: '浏览器正在重建最终 ZIP,并刷新文件名里的校验后缀。',
|
||||||
|
txt_backup_export_progress_save_title: '正在保存导出文件',
|
||||||
|
txt_backup_export_progress_save_detail: '浏览器正在准备最终的备份文件下载。',
|
||||||
|
txt_backup_export_progress_complete_title: '备份导出已完成',
|
||||||
|
txt_backup_export_progress_complete_detail: '导出备份已经准备完成。',
|
||||||
|
txt_backup_export_progress_failed_title: '备份导出失败',
|
||||||
|
txt_backup_export_progress_failed_detail: '导出备份未能完成。',
|
||||||
|
txt_backup_remote_run_progress_prepare_title: '正在准备远程备份',
|
||||||
|
txt_backup_remote_run_progress_prepare_detail: '服务器正在读取当前备份目标,并准备执行这次远程备份。',
|
||||||
|
txt_backup_remote_run_progress_sync_attachments_title: '正在检查附件索引',
|
||||||
|
txt_backup_remote_run_progress_sync_attachments_detail: '服务器正在比对附件索引,只会上传缺失或不一致的附件对象。',
|
||||||
|
txt_backup_remote_run_progress_sync_attachments_skipped_detail: '当前备份未包含附件,因此跳过附件同步。',
|
||||||
|
txt_backup_remote_run_progress_upload_title: '正在上传备份压缩包',
|
||||||
|
txt_backup_remote_run_progress_upload_detail: '服务器正在把备份 ZIP 上传到远程备份目标。',
|
||||||
|
txt_backup_remote_run_progress_verify_title: '正在校验已上传压缩包',
|
||||||
|
txt_backup_remote_run_progress_verify_detail: '服务器正在回读刚上传的 ZIP,并校验它的哈希和大小。',
|
||||||
|
txt_backup_remote_run_progress_cleanup_title: '正在清理旧备份',
|
||||||
|
txt_backup_remote_run_progress_cleanup_detail: '服务器正在按保留策略清理旧备份文件。',
|
||||||
|
txt_backup_remote_run_progress_complete_title: '远程备份已完成',
|
||||||
|
txt_backup_remote_run_progress_complete_detail: '远程备份已上传完成,并通过完整性校验。',
|
||||||
|
txt_backup_remote_run_progress_failed_title: '远程备份失败',
|
||||||
|
txt_backup_remote_run_progress_failed_detail: '远程备份未能完成。',
|
||||||
|
txt_backup_restore_progress_local_upload_title: '正在上传备份包',
|
||||||
|
txt_backup_restore_progress_local_upload_detail: '已选 ZIP 正在发送到服务器,服务器收到后会开始执行还原。',
|
||||||
|
txt_backup_restore_progress_local_shadow_title: '正在创建影子恢复区',
|
||||||
|
txt_backup_restore_progress_local_shadow_detail: '服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。',
|
||||||
|
txt_backup_restore_progress_local_data_title: '正在写入密码库数据',
|
||||||
|
txt_backup_restore_progress_local_data_detail: '服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。',
|
||||||
|
txt_backup_restore_progress_local_files_title: '正在恢复附件文件',
|
||||||
|
txt_backup_restore_progress_local_files_detail: '服务器正在把附件对象写回存储,并剔除无法恢复的附件记录。',
|
||||||
|
txt_backup_restore_progress_local_finalize_title: '正在校验并完成切换',
|
||||||
|
txt_backup_restore_progress_local_finalize_detail: '服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。',
|
||||||
|
txt_backup_restore_progress_remote_fetch_title: '正在读取远端备份包',
|
||||||
|
txt_backup_restore_progress_remote_fetch_detail: '服务器正在从远端备份目标下载你选中的备份包。',
|
||||||
|
txt_backup_restore_progress_remote_shadow_title: '正在创建影子恢复区',
|
||||||
|
txt_backup_restore_progress_remote_shadow_detail: '服务器正在准备独立的影子数据区,只有校验通过后才会替换正式数据。',
|
||||||
|
txt_backup_restore_progress_remote_data_title: '正在写入密码库数据',
|
||||||
|
txt_backup_restore_progress_remote_data_detail: '服务器正在把用户、文件夹、密码条目和相关元数据写入影子表。',
|
||||||
|
txt_backup_restore_progress_remote_files_title: '正在恢复远端附件',
|
||||||
|
txt_backup_restore_progress_remote_files_detail: '服务器正在从远端存储读取所需附件,并写回到当前实例的附件存储。',
|
||||||
|
txt_backup_restore_progress_remote_finalize_title: '正在校验并完成切换',
|
||||||
|
txt_backup_restore_progress_remote_finalize_detail: '服务器正在执行最终校验,校验通过后会把已验证的数据切换为正式数据。',
|
||||||
txt_backup_remote_loading: '正在读取远端备份...',
|
txt_backup_remote_loading: '正在读取远端备份...',
|
||||||
txt_backup_remote_cached_empty: '点击“刷新”后读取',
|
txt_backup_remote_cached_empty: '点击“刷新”后读取',
|
||||||
txt_backup_remote_empty: '这个目录下还没有备份文件',
|
txt_backup_remote_empty: '这个目录下还没有备份文件',
|
||||||
@@ -774,6 +918,11 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_backup_remote_delete_confirm_message: '删除备份文件“{name}”?此操作不可撤销。',
|
txt_backup_remote_delete_confirm_message: '删除备份文件“{name}”?此操作不可撤销。',
|
||||||
txt_backup_remote_deleting: '删除中...',
|
txt_backup_remote_deleting: '删除中...',
|
||||||
txt_backup_remote_restore_failed: '还原远端备份失败',
|
txt_backup_remote_restore_failed: '还原远端备份失败',
|
||||||
|
txt_backup_restore_checksum_warning_title: '备份完整性警告',
|
||||||
|
txt_backup_restore_checksum_warning_message: '所选备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能不完整或已经损坏。继续还原可能会导入受损数据。',
|
||||||
|
txt_backup_remote_restore_checksum_warning_message: '远程备份文件“{name}”未通过文件名完整性校验。期望前缀为 {expected},实际计算结果为 {actual}。该文件可能在上传或存储过程中损坏。继续还原可能会导入受损数据,并可能造成严重后果。',
|
||||||
|
txt_backup_restore_checksum_warning_message_fallback: '所选备份文件未通过完整性校验。继续还原可能会导入受损数据。',
|
||||||
|
txt_backup_restore_checksum_warning_confirm: '继续还原',
|
||||||
txt_backup_remote_restore_invalid_response: '远端备份还原响应无效',
|
txt_backup_remote_restore_invalid_response: '远端备份还原响应无效',
|
||||||
txt_backup_remote_run_invalid_response: '远端备份执行响应无效',
|
txt_backup_remote_run_invalid_response: '远端备份执行响应无效',
|
||||||
txt_backup_settings_invalid_response: '备份设置响应无效',
|
txt_backup_settings_invalid_response: '备份设置响应无效',
|
||||||
@@ -845,9 +994,9 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_backup_no_file_selected: '尚未选择备份文件',
|
txt_backup_no_file_selected: '尚未选择备份文件',
|
||||||
txt_backup_selected_file_name: '已选择文件:{name}',
|
txt_backup_selected_file_name: '已选择文件:{name}',
|
||||||
txt_backup_replace_confirm_title: '替换当前实例数据',
|
txt_backup_replace_confirm_title: '替换当前实例数据',
|
||||||
txt_backup_replace_confirm_message: '当前实例里已经有数据。要先清空当前数据库和文件,再还原所选备份吗?',
|
txt_backup_replace_confirm_message: '当前实例里已经有数据。确认后,系统会先完成校验与恢复准备,只有在恢复成功后才会用所选备份替换当前实例数据。是否继续?',
|
||||||
txt_backup_clear_and_import: '清空后导入',
|
txt_backup_clear_and_import: '替换并导入',
|
||||||
txt_backup_clear_and_restore: '清空后还原',
|
txt_backup_clear_and_restore: '替换并还原',
|
||||||
txt_sign_out: '退出登录',
|
txt_sign_out: '退出登录',
|
||||||
txt_log_in: '登录',
|
txt_log_in: '登录',
|
||||||
txt_logging_in: '正在登录...',
|
txt_logging_in: '正在登录...',
|
||||||
@@ -872,6 +1021,8 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_loading_nodewarden: '正在加载 NodeWarden...',
|
txt_loading_nodewarden: '正在加载 NodeWarden...',
|
||||||
txt_search_sends: '搜索发送...',
|
txt_search_sends: '搜索发送...',
|
||||||
txt_search_your_secure_vault: '搜索你的密码库...',
|
txt_search_your_secure_vault: '搜索你的密码库...',
|
||||||
|
txt_clear_search: '清空搜索',
|
||||||
|
txt_clear_search_esc: '清空搜索(Esc)',
|
||||||
txt_refresh: '刷新',
|
txt_refresh: '刷新',
|
||||||
txt_sync: '同步',
|
txt_sync: '同步',
|
||||||
txt_sync_vault: '同步',
|
txt_sync_vault: '同步',
|
||||||
@@ -1239,6 +1390,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_user_status_updated: '用户状态已更新',
|
txt_user_status_updated: '用户状态已更新',
|
||||||
txt_vault_synced: '密码库已同步',
|
txt_vault_synced: '密码库已同步',
|
||||||
txt_verify: '验证',
|
txt_verify: '验证',
|
||||||
|
txt_warning: '警告',
|
||||||
txt_web: '网页',
|
txt_web: '网页',
|
||||||
txt_windows_desktop: 'Windows 桌面端',
|
txt_windows_desktop: 'Windows 桌面端',
|
||||||
txt_jwt_warning_title: 'JWT_SECRET 配置警告',
|
txt_jwt_warning_title: 'JWT_SECRET 配置警告',
|
||||||
|
|||||||
+316
-9
@@ -80,6 +80,11 @@ body {
|
|||||||
color var(--dur-medium) var(--ease-smooth);
|
color var(--dur-medium) var(--ease-smooth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.dialog-open {
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
body::before {
|
body::before {
|
||||||
content: none;
|
content: none;
|
||||||
}
|
}
|
||||||
@@ -1021,12 +1026,15 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 48px;
|
||||||
border: 1px solid var(--line);
|
border: 1px solid rgba(74, 103, 150, 0.42);
|
||||||
border-radius: 12px;
|
border-radius: 14px;
|
||||||
padding: 0 12px;
|
padding: 10px 14px;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9);
|
||||||
transition:
|
transition:
|
||||||
border-color var(--dur-fast) var(--ease-smooth),
|
border-color var(--dur-fast) var(--ease-smooth),
|
||||||
box-shadow var(--dur-fast) var(--ease-out-soft),
|
box-shadow var(--dur-fast) var(--ease-out-soft),
|
||||||
@@ -1034,13 +1042,52 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
transform var(--dur-fast) var(--ease-out-soft);
|
transform var(--dur-fast) var(--ease-out-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-input-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.search-input:focus {
|
.search-input:focus {
|
||||||
border-color: rgba(43, 102, 217, 0.28);
|
border-color: rgba(43, 102, 217, 0.6);
|
||||||
background: #fbfdff;
|
background-color: #fbfdff;
|
||||||
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.08), 0 8px 18px rgba(37, 99, 235, 0.06);
|
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.11), 0 10px 20px rgba(37, 99, 235, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.95);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-input-wrap .search-input {
|
||||||
|
padding-right: 42px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 9px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(148, 163, 184, 0.18);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: background-color var(--dur-fast) var(--ease-out-soft), color var(--dur-fast) var(--ease-out-soft), transform var(--dur-fast) var(--ease-out-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn:hover {
|
||||||
|
background: rgba(59, 130, 246, 0.18);
|
||||||
|
color: var(--brand);
|
||||||
|
transform: translateY(-50%) scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
.tree-btn {
|
.tree-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -1151,10 +1198,13 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head .search-input {
|
.list-head .search-input-wrap {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
height: 36px;
|
}
|
||||||
|
|
||||||
|
.list-head .search-input {
|
||||||
|
height: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-head .btn {
|
.list-head .btn {
|
||||||
@@ -2884,6 +2934,148 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.restore-progress-card {
|
||||||
|
margin: 8px 0 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #d7e2f1;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1250;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: rgba(15, 23, 42, 0.30);
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-modal {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-kicker {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-title {
|
||||||
|
margin: 4px 0 2px;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-elapsed {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 88px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fbff;
|
||||||
|
border: 1px solid #d7e2f1;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-meter {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7eef8;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-meter-bar {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #3a71d8;
|
||||||
|
transition: width 280ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-current {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f8fbff;
|
||||||
|
border: 1px solid #d7e2f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-current strong {
|
||||||
|
display: block;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-current p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 30px;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-item.active {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-item.done {
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-item.active .restore-progress-dot {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.restore-progress-item.done .restore-progress-dot {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
.kv-line strong {
|
.kv-line strong {
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
@@ -2977,6 +3169,8 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
.dialog-mask {
|
.dialog-mask {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
background: rgba(15, 23, 42, 0.5);
|
background: rgba(15, 23, 42, 0.5);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -2984,6 +3178,8 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
padding: 20px;
|
padding: 20px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: fade-in var(--dur-medium) var(--ease-smooth) both;
|
animation: fade-in var(--dur-medium) var(--ease-smooth) both;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-card {
|
.dialog-card {
|
||||||
@@ -2998,6 +3194,54 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
animation: dialog-in 240ms var(--ease-out-strong) both;
|
animation: dialog-in 240ms var(--ease-out-strong) both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-mask.warning {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(255, 237, 213, 0.32), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgba(127, 29, 29, 0.36), rgba(15, 23, 42, 0.72));
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-card.warning {
|
||||||
|
width: min(520px, 100%);
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.22);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 246, 246, 0.98), rgba(255, 255, 255, 0.99));
|
||||||
|
box-shadow:
|
||||||
|
0 36px 90px rgba(69, 10, 10, 0.28),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.7) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-warning-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-warning-badge {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(180deg, #fff1f2, #ffe4e6);
|
||||||
|
color: #dc2626;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 30px rgba(220, 38, 38, 0.18),
|
||||||
|
0 0 0 1px rgba(220, 38, 38, 0.08) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-warning-kicker {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-mask.closing {
|
.dialog-mask.closing {
|
||||||
animation: fade-out 220ms var(--ease-smooth) both;
|
animation: fade-out 220ms var(--ease-smooth) both;
|
||||||
}
|
}
|
||||||
@@ -3025,6 +3269,22 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-card.warning .dialog-title {
|
||||||
|
color: #7f1d1d;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-message.warning {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid rgba(220, 38, 38, 0.16);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 241, 242, 0.94), rgba(255, 247, 237, 0.9));
|
||||||
|
color: #7a2832;
|
||||||
|
line-height: 1.65;
|
||||||
|
box-shadow: 0 10px 28px rgba(248, 113, 113, 0.08) inset;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-btn {
|
.dialog-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
@@ -3733,6 +3993,11 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-head .search-input-wrap {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.list-head .search-input {
|
.list-head .search-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -4082,6 +4347,14 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
|
padding: 18px 16px calc(18px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-card.warning {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-warning-strip {
|
||||||
|
margin: -18px -16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-title {
|
.dialog-title {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
@@ -4202,6 +4475,40 @@ input[type='file'].input::file-selector-button:hover {
|
|||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .dialog-card.warning {
|
||||||
|
border-color: rgba(248, 113, 113, 0.36);
|
||||||
|
background: linear-gradient(180deg, rgba(39, 16, 16, 0.98), rgba(27, 12, 12, 0.98));
|
||||||
|
box-shadow:
|
||||||
|
0 36px 90px rgba(5, 5, 5, 0.56),
|
||||||
|
0 0 0 1px rgba(248, 113, 113, 0.12) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .dialog-mask.warning {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(127, 29, 29, 0.28), transparent 34%),
|
||||||
|
linear-gradient(180deg, rgba(20, 12, 12, 0.64), rgba(2, 6, 23, 0.82));
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .dialog-warning-badge {
|
||||||
|
background: linear-gradient(180deg, rgba(127, 29, 29, 0.8), rgba(69, 10, 10, 0.86));
|
||||||
|
color: #fda4af;
|
||||||
|
box-shadow:
|
||||||
|
0 12px 30px rgba(0, 0, 0, 0.32),
|
||||||
|
0 0 0 1px rgba(248, 113, 113, 0.14) inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .dialog-warning-kicker,
|
||||||
|
:root[data-theme='dark'] .dialog-card.warning .dialog-title {
|
||||||
|
color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .dialog-message.warning {
|
||||||
|
border-color: rgba(248, 113, 113, 0.18);
|
||||||
|
background: linear-gradient(180deg, rgba(69, 10, 10, 0.54), rgba(67, 20, 7, 0.46));
|
||||||
|
color: #fecdd3;
|
||||||
|
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18) inset;
|
||||||
|
}
|
||||||
|
|
||||||
:root[data-theme='dark'] .app-side,
|
:root[data-theme='dark'] .app-side,
|
||||||
:root[data-theme='dark'] .sidebar,
|
:root[data-theme='dark'] .sidebar,
|
||||||
:root[data-theme='dark'] .mobile-sidebar-sheet .sidebar-block {
|
:root[data-theme='dark'] .mobile-sidebar-sheet .sidebar-block {
|
||||||
|
|||||||
Reference in New Issue
Block a user