Compare commits

...
16 Commits
Author SHA1 Message Date
shuaiplus 31dcc76ee2 Merge branch 'main' of https://github.com/shuaiplus/nodewarden 2026-06-26 20:58:22 +08:00
soncmsandShuai bf6ac7b405 Enable WebAuthn related origins support 2026-06-26 20:51:33 +08:00
shuaiplus 1bfb9a647d feat: refine app-shell styles for improved layout and dark mode consistency 2026-06-26 19:12:20 +08:00
shuaiplus e9272ec29a feat: enhance backup import functionality with locking mechanism and checksum support 2026-06-26 18:45:23 +08:00
shuaiplus 8942e5bd49 feat: add support for PRF extension request based on browser compatibility 2026-06-26 11:58:49 +08:00
shuaiplus d722815999 feat: add fullscreen layout support with toggle and localization updates 2026-06-26 11:26:02 +08:00
shuaiplus ff85698edb feat: add Tigris backup provider support with recommendations and localization updates 2026-06-25 21:11:57 +08:00
shuaiplus c3dc53bac1 feat: add Cloudflare R2 support with detailed backup recommendations and localization updates 2026-06-25 19:45:09 +08:00
shuaiplus 1acc31eda0 feat: add Backblaze B2 support with recommendations and styling updates 2026-06-25 18:42:34 +08:00
shuaiplusandClaude c694f1bfce refactor: consolidate security devices UI and remove pending auth requests from settings
- Merge device management and authorized devices sections into a single card in SecurityDevicesPage
- Remove PendingAuthRequestsPanel from SettingsPage and its related props
- Clean up unused auth request prop drilling in AppMainRoutes

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 01:49:17 +08:00
shuaiplus bf51309fbb fix: update version to v1.7.1 2026-06-24 01:55:09 +08:00
shuaiplusandShuai 23b23f39b9 fix: require reauthentication for auth request approval 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 0daad46591 chore: add package overrides for undici, @babel/core, and esbuild 2026-06-24 01:44:50 +08:00
shuaiplusandShuai a2a8f1c7b6 fix:Harden authentication and sensitive file handling 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 850fe0f044 fix: two-phase invite consumption to prevent registration race condition 2026-06-24 01:44:50 +08:00
shuaiplusandShuai 7279668955 fix: address security issue 2026-06-24 01:44:50 +08:00
52 changed files with 2270 additions and 835 deletions
+10
View File
@@ -228,6 +228,16 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
ON trusted_two_factor_device_tokens(user_id, device_identifier); ON trusted_two_factor_device_tokens(user_id, device_identifier);
CREATE TABLE IF NOT EXISTS totp_login_replays (
user_id TEXT NOT NULL,
time_counter INTEGER NOT NULL,
consumed_at INTEGER NOT NULL,
PRIMARY KEY (user_id, time_counter),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
ON totp_login_replays(consumed_at);
CREATE TABLE IF NOT EXISTS webauthn_credentials ( CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
+639 -426
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.0", "version": "1.7.1",
"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",
@@ -42,6 +42,11 @@
} }
} }
}, },
"overrides": {
"undici": ">=7.28.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1"
},
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3", "@preact/preset-vite": "^2.10.3",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.0'; export const APP_VERSION = '1.7.1';
+4 -3
View File
@@ -9,7 +9,8 @@
export const BACKUP_DEFAULT_TIMEZONE = 'UTC'; export const BACKUP_DEFAULT_TIMEZONE = 'UTC';
export const BACKUP_DEFAULT_RETENTION_COUNT = 30; export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
export const BACKUP_DEFAULT_S3_REGION = 'auto'; export const BACKUP_DEFAULT_S3_REGION = 'auto';
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden'; export const BACKUP_DEFAULT_S3_ROOT_PATH = '';
export const BACKUP_DEFAULT_WEBDAV_REMOTE_PATH = 'nodewarden';
export const BACKUP_DEFAULT_INTERVAL_HOURS = 24; export const BACKUP_DEFAULT_INTERVAL_HOURS = 24;
export const BACKUP_DEFAULT_START_TIME = '03:00'; export const BACKUP_DEFAULT_START_TIME = '03:00';
@@ -109,14 +110,14 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
region: BACKUP_DEFAULT_S3_REGION, region: BACKUP_DEFAULT_S3_REGION,
accessKeyId: '', accessKeyId: '',
secretAccessKey: '', secretAccessKey: '',
rootPath: BACKUP_DEFAULT_REMOTE_PATH, rootPath: BACKUP_DEFAULT_S3_ROOT_PATH,
}; };
} }
return { return {
baseUrl: '', baseUrl: '',
username: '', username: '',
password: '', password: '',
remotePath: BACKUP_DEFAULT_REMOTE_PATH, remotePath: BACKUP_DEFAULT_WEBDAV_REMOTE_PATH,
}; };
} }
+12 -1
View File
@@ -209,6 +209,7 @@ export class BackupTransferRunner {
} }
let completed = 0; let completed = 0;
const failures: Array<{ destinationId: string; error: string }> = [];
try { try {
await this.touchJob(token); await this.touchJob(token);
const storage = new StorageService(this.env.DB); const storage = new StorageService(this.env.DB);
@@ -230,6 +231,7 @@ export class BackupTransferRunner {
scanStartMs = now.getTime(); scanStartMs = now.getTime();
for (const destination of dueDestinations) { for (const destination of dueDestinations) {
await this.touchJob(token); await this.touchJob(token);
try {
await executeConfiguredBackup( await executeConfiguredBackup(
this.env, this.env,
storage, storage,
@@ -239,12 +241,20 @@ export class BackupTransferRunner {
() => this.touchJob(token) () => this.touchJob(token)
); );
completed += 1; completed += 1;
} catch (error) {
failures.push({
destinationId: destination.id,
error: error instanceof Error ? error.message : 'Scheduled backup failed',
});
}
} }
} }
return new Response(JSON.stringify({ return new Response(JSON.stringify({
ok: true, ok: true,
completed, completed,
failed: failures.length,
failures,
}), { }), {
status: 200, status: 200,
headers: { headers: {
@@ -318,7 +328,8 @@ export class BackupTransferRunner {
replaceExisting, replaceExisting,
!checksumOk, !checksumOk,
body.auditMetadata || null, body.auditMetadata || null,
targetDeviceIdentifier targetDeviceIdentifier,
() => this.touchJob(token)
); );
return new Response(JSON.stringify(result.result), { return new Response(JSON.stringify(result.result), {
+35 -6
View File
@@ -353,20 +353,31 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invite code is required', 403); return errorResponse('Invite code is required', 403);
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
return errorResponse('Invite code is invalid or expired', 403);
}
try { try {
await storage.createUser(user); await storage.createUser(user);
} catch (error) { } catch (error) {
await storage.revertInviteUsed(inviteCode, user.id);
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) { if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409); return errorResponse('Email already registered', 409);
} }
console.error('Registration failed after invite reservation:', error);
throw error; throw error;
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id); try {
if (!inviteMarked) { const assigned = await storage.assignInviteUsedBy(inviteCode, user.id);
await storage.deleteUserById(user.id); if (!assigned) {
return errorResponse('Invite code is invalid or expired', 403); console.warn('Invite used_by was not assigned after registration', { inviteCode, userId: user.id });
}
} catch (error) {
// The invite is already consumed. Do not reactivate it after the user row exists.
console.error('Invite used_by assignment failed after registration:', error);
} }
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
@@ -891,7 +902,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
} }
// PUT /api/accounts/totp // PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" } // enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
// disable: { enabled: false, masterPasswordHash: "..." } // disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -899,7 +910,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string }; let body: {
enabled?: boolean;
secret?: string;
token?: string;
masterPasswordHash?: string;
userVerificationToken?: string;
};
try { try {
body = await request.json(); body = await request.json();
} catch { } catch {
@@ -908,12 +925,24 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
if (body.enabled === true) { if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || ''); const normalizedSecret = normalizeTotpSecret(body.secret || '');
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
if (!isTotpEnabled(normalizedSecret)) { if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400); return errorResponse('Invalid TOTP secret', 400);
} }
if (!body.token) { if (!body.token) {
return errorResponse('TOTP token is required', 400); return errorResponse('TOTP token is required', 400);
} }
let verifiedUser = false;
if (userVerificationToken) {
verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken);
}
if (!verifiedUser && masterPasswordHash) {
verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
}
if (!verifiedUser) {
return errorResponse('User verification failed.', 400);
}
const verified = await verifyTotpToken(normalizedSecret, body.token); const verified = await verifyTotpToken(normalizedSecret, body.token);
if (!verified) { if (!verified) {
return errorResponse('Invalid TOTP token', 400); return errorResponse('Invalid TOTP token', 400);
+2 -1
View File
@@ -4,6 +4,7 @@ import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload'; import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { import {
createAttachmentUploadToken, createAttachmentUploadToken,
createFileDownloadToken, createFileDownloadToken,
@@ -449,7 +450,7 @@ export async function handlePublicDownloadAttachment(
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(attachment.fileName), 'Content-Disposition': contentDispositionAttachment(attachment.fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
+16
View File
@@ -14,6 +14,19 @@ function normalizeText(value: unknown, maxLength: number): string {
return String(value ?? '').trim().slice(0, maxLength); return String(value ?? '').trim().slice(0, maxLength);
} }
function isSerializedEncString(value: unknown): value is string {
const text = String(value || '').trim();
if (!text) return false;
const parts = text.split('.');
if (parts.length !== 2) return false;
const type = Number(parts[0]);
const bodyParts = parts[1].split('|');
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
return false;
}
function getClientIp(request: Request): string | null { function getClientIp(request: Request): string | null {
return ( return (
request.headers.get('CF-Connecting-IP') || request.headers.get('CF-Connecting-IP') ||
@@ -251,6 +264,9 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
if (approved && !key) { if (approved && !key) {
return errorResponse('Encrypted key is required to approve the request.', 400); return errorResponse('Encrypted key is required to approve the request.', 400);
} }
if (approved && !isSerializedEncString(key)) {
return errorResponse('Encrypted key is not a valid encrypted string.', 400);
}
const updated = await storage.updateAuthRequestResponse(id, userId, { const updated = await storage.updateAuthRequestResponse(id, userId, {
approved, approved,
+141 -38
View File
@@ -21,6 +21,7 @@ import {
repairBackupSettings, repairBackupSettings,
requireBackupDestination, requireBackupDestination,
saveBackupSettings, saveBackupSettings,
updateBackupDestinationRuntime,
} from '../services/backup-config'; } from '../services/backup-config';
import { import {
type BackupImportExecutionResult, type BackupImportExecutionResult,
@@ -40,15 +41,51 @@ import {
uploadBackupArchive, uploadBackupArchive,
} from '../services/backup-uploader'; } from '../services/backup-uploader';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store'; import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { unzipSync } from 'fflate'; import { unzipSync } from 'fflate';
function isAdmin(user: User): boolean { function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
} }
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function requireBackupRepairVerification(
actorUser: User,
body: { masterPasswordHash?: string; userVerificationToken?: string },
env: Env
): Promise<Response | null> {
const masterPasswordHash = String(body.masterPasswordHash || '').trim();
if (masterPasswordHash) {
return requireBackupUserVerification(actorUser, masterPasswordHash, env);
}
const userVerificationToken = String(body.userVerificationToken || '').trim();
if (!userVerificationToken) {
return errorResponse('masterPasswordHash or userVerificationToken is required', 400);
}
const valid = await verifyPasskeyUserVerificationToken(env, userVerificationToken, actorUser.id, 'backup.settings.repair');
if (!valid) {
return errorResponse('Invalid user verification token', 400);
}
return null;
}
async function writeAuditLog( async function writeAuditLog(
storage: StorageService, storage: StorageService,
actorUserId: string | null, actorUserId: string | null,
@@ -224,6 +261,30 @@ async function uploadRemoteAttachmentChunk(
} }
} }
async function verifyUploadedBackupArchive(
session: RemoteBackupTransferSession,
archive: BackupArchiveBundle
): Promise<'metadata' | 'download'> {
try {
const stat = await session.stat(archive.fileName);
if (stat?.size === archive.bytes.byteLength) {
return 'metadata';
}
} catch {
// Fall through to a full read-back verification when lightweight metadata is unavailable.
}
const remoteFile = await session.download(archive.fileName);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
if (!checksumOk) {
throw new Error('Remote backup ZIP checksum verification failed');
}
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
throw new Error('Remote backup ZIP size verification failed');
}
return 'download';
}
export async function executeConfiguredBackup( export async function executeConfiguredBackup(
env: Env, env: Env,
storage: StorageService, storage: StorageService,
@@ -251,12 +312,14 @@ export async function executeConfiguredBackup(
const destination = requireBackupDestination(currentSettings, destinationId); const destination = requireBackupDestination(currentSettings, destinationId);
const now = new Date(); const now = new Date();
destination.runtime.lastAttemptAt = now.toISOString();
destination.runtime.lastAttemptLocalDate = getBackupLocalDateKey(now, destination.schedule.timezone);
destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null;
await touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastAttemptAt: now.toISOString(),
lastAttemptLocalDate: getBackupLocalDateKey(now, destination.schedule.timezone),
lastErrorAt: null,
lastErrorMessage: null,
}));
try { try {
await touchLease(); await touchLease();
@@ -318,6 +381,7 @@ export async function executeConfiguredBackup(
} }
} }
let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null; let upload: Awaited<ReturnType<typeof uploadBackupArchive>> | null = null;
let uploadVerificationMethod: 'metadata' | 'download' | null = null;
for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) { for (let attempt = 1; attempt <= maxArchiveUploadAttempts; attempt++) {
await touchLease(); await touchLease();
await progress?.({ await progress?.({
@@ -337,14 +401,7 @@ export async function executeConfiguredBackup(
stageTitle: 'txt_backup_remote_run_progress_verify_title', stageTitle: 'txt_backup_remote_run_progress_verify_title',
stageDetail: 'txt_backup_remote_run_progress_verify_detail', stageDetail: 'txt_backup_remote_run_progress_verify_detail',
}); });
const remoteFile = await remoteSession.download(archive.fileName); uploadVerificationMethod = await verifyUploadedBackupArchive(remoteSession, archive);
const checksumOk = await verifyBackupArchiveFileNameChecksum(remoteFile.bytes, archive.fileName);
if (!checksumOk) {
throw new Error('Remote backup ZIP checksum verification failed');
}
if (remoteFile.bytes.byteLength !== archive.bytes.byteLength) {
throw new Error('Remote backup ZIP size verification failed');
}
break; break;
} catch (error) { } catch (error) {
await remoteSession.deleteFile(archive.fileName).catch(() => undefined); await remoteSession.deleteFile(archive.fileName).catch(() => undefined);
@@ -373,14 +430,16 @@ export async function executeConfiguredBackup(
pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed'; pruneErrorMessage = error instanceof Error ? error.message : 'Old backup cleanup failed';
} }
destination.runtime.lastSuccessAt = new Date().toISOString();
destination.runtime.lastErrorAt = null;
destination.runtime.lastErrorMessage = null;
destination.runtime.lastUploadedFileName = archive.fileName;
destination.runtime.lastUploadedSizeBytes = archive.bytes.byteLength;
destination.runtime.lastUploadedDestination = upload.remotePath;
await touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastSuccessAt: new Date().toISOString(),
lastErrorAt: null,
lastErrorMessage: null,
lastUploadedFileName: archive.fileName,
lastUploadedSizeBytes: archive.bytes.byteLength,
lastUploadedDestination: upload.remotePath,
}));
await touchLease(); await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, { await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}`, 'backup', null, {
@@ -390,6 +449,7 @@ export async function executeConfiguredBackup(
fileName: archive.fileName, fileName: archive.fileName,
fileBytes: archive.bytes.byteLength, fileBytes: archive.bytes.byteLength,
uploadVerificationAttempts: maxArchiveUploadAttempts, uploadVerificationAttempts: maxArchiveUploadAttempts,
uploadVerificationMethod,
prunedFileCount, prunedFileCount,
pruneError: pruneErrorMessage, pruneError: pruneErrorMessage,
...(auditMetadata || {}), ...(auditMetadata || {}),
@@ -412,15 +472,18 @@ export async function executeConfiguredBackup(
provider: upload.provider, provider: upload.provider,
}; };
} catch (error) { } catch (error) {
destination.runtime.lastErrorAt = new Date().toISOString(); const errorMessage = error instanceof Error ? error.message : 'Backup upload failed';
destination.runtime.lastErrorMessage = error instanceof Error ? error.message : 'Backup upload failed';
await touchLease(); await touchLease();
await saveBackupSettings(storage, env, currentSettings); destination.runtime = await updateBackupDestinationRuntime(storage, destination.id, (runtime) => ({
...runtime,
lastErrorAt: new Date().toISOString(),
lastErrorMessage: errorMessage,
}));
await touchLease(); await touchLease();
await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, { await writeAuditLog(storage, actorUserId, `admin.backup.remote.${trigger}.failed`, 'backup', null, {
...getBackupDestinationSummary(destination), ...getBackupDestinationSummary(destination),
error: destination.runtime.lastErrorMessage, error: errorMessage,
...(auditMetadata || {}), ...(auditMetadata || {}),
}); });
await progress?.({ await progress?.({
@@ -431,7 +494,7 @@ export async function executeConfiguredBackup(
stageDetail: 'txt_backup_remote_run_progress_failed_detail', stageDetail: 'txt_backup_remote_run_progress_failed_detail',
done: true, done: true,
ok: false, ok: false,
error: destination.runtime.lastErrorMessage, error: errorMessage,
}); });
throw error; throw error;
} }
@@ -619,12 +682,18 @@ export async function importAndAuditRemoteBackupFile(
replaceExisting: boolean, replaceExisting: boolean,
checksumMismatchAccepted: boolean, checksumMismatchAccepted: boolean,
auditMetadata: Record<string, unknown> | null = null, auditMetadata: Record<string, unknown> | null = null,
targetDeviceIdentifier: string | null = null targetDeviceIdentifier: string | null = null,
keepAlive?: (() => Promise<void>) | null
): Promise<BackupImportExecutionResult> { ): Promise<BackupImportExecutionResult> {
const touchLease = async () => {
await keepAlive?.();
};
const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath; const restoreFileName = remoteFile.fileName || remotePath.split('/').pop() || remotePath;
await touchLease();
const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes); const externalAttachmentBlobNames = collectExternalRemoteAttachmentBlobNames(remoteFile.bytes);
const externalAttachmentCache = new Map<string, Uint8Array | null>(); const externalAttachmentCache = new Map<string, Uint8Array | null>();
const progress: BackupRestoreProgressReporter = async (event) => { const progress: BackupRestoreProgressReporter = async (event) => {
await touchLease();
await notifyUserBackupRestoreProgress( await notifyUserBackupRestoreProgress(
env, env,
actorUserId, actorUserId,
@@ -642,6 +711,7 @@ export async function importAndAuditRemoteBackupFile(
replaceExisting, replaceExisting,
{ {
loadAttachment: async (blobName) => { loadAttachment: async (blobName) => {
await touchLease();
const normalized = String(blobName || '').trim(); const normalized = String(blobName || '').trim();
if (!normalized) return null; if (!normalized) return null;
if (externalAttachmentCache.has(normalized)) { if (externalAttachmentCache.has(normalized)) {
@@ -664,6 +734,7 @@ export async function importAndAuditRemoteBackupFile(
} catch { } catch {
externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null)); externalAttachmentCache.set(normalized, await downloadRemoteAttachmentViaDurableObject(env, destination, normalized).catch(() => null));
} }
await touchLease();
return externalAttachmentCache.get(normalized) || null; return externalAttachmentCache.get(normalized) || null;
}, },
}, },
@@ -787,13 +858,16 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput; let body: BackupSettingsInput & { masterPasswordHash?: string };
try { try {
body = await request.json<BackupSettingsInput>(); body = await request.json<BackupSettingsInput & { masterPasswordHash?: string }>();
} catch { } catch {
return errorResponse('Backup settings payload is invalid', 400); return errorResponse('Backup settings payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let previous; let previous;
try { try {
@@ -837,13 +911,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request,
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput; let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string };
try { try {
body = await request.json<BackupSettingsInput>(); body = await request.json<BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }>();
} catch { } catch {
return errorResponse('Backup settings repair payload is invalid', 400); return errorResponse('Backup settings repair payload is invalid', 400);
} }
const verificationError = await requireBackupRepairVerification(actorUser, body, env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let previous; let previous;
try { try {
@@ -871,15 +948,18 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try { try {
let body: { destinationId?: string } | null = null; let body: { destinationId?: string; masterPasswordHash?: string } | null = null;
try { try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) { if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ destinationId?: string }>(); body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>();
} }
} catch { } catch {
return errorResponse('Backup run payload is invalid', 400); return errorResponse('Backup run payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const outcome = await runConfiguredBackupInDurableObject(env, { const outcome = await runConfiguredBackupInDurableObject(env, {
actorUserId: actorUser.id, actorUserId: actorUser.id,
auditMetadata: auditRequestMetadata(request), auditMetadata: auditRequestMetadata(request),
@@ -928,12 +1008,21 @@ export async function handleListAdminRemoteBackups(request: Request, env: Env, a
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleDownloadAdminRemoteBackup(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; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup download payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try { try {
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || ''); const destination = requireBackupDestination(settings, body.destinationId || null);
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const remoteFile = await downloadRemoteBackupFile(destination, path); const remoteFile = await downloadRemoteBackupFile(destination, path);
return new Response(remoteFile.bytes, { return new Response(remoteFile.bytes, {
status: 200, status: 200,
@@ -994,13 +1083,22 @@ 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; allowChecksumMismatch?: boolean }; let body: {
destinationId?: string;
path?: string;
replaceExisting?: boolean;
allowChecksumMismatch?: boolean;
masterPasswordHash?: string;
};
try { try {
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>(); body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
} catch { } catch {
return errorResponse('Remote restore payload is invalid', 400); return errorResponse('Remote restore payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
try { try {
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 targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
@@ -1028,14 +1126,16 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
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; const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
let body: { includeAttachments?: boolean } | null = null; let body: { includeAttachments?: boolean; masterPasswordHash?: string } | null = null;
try { try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) { if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ includeAttachments?: boolean }>(); body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>();
} }
} catch { } catch {
return errorResponse('Backup export payload is invalid', 400); return errorResponse('Backup export payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
let archive: BackupArchiveBundle; let archive: BackupArchiveBundle;
try { try {
const progress = async (event: { const progress = async (event: {
@@ -1140,6 +1240,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
return errorResponse('Backup file is required', 400); return errorResponse('Backup file is required', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
if (verificationError) return verificationError;
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1'; const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1'; const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
let archiveBytes: Uint8Array; let archiveBytes: Uint8Array;
+15 -4
View File
@@ -4,7 +4,7 @@ import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt'; import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device'; import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
@@ -21,6 +21,7 @@ import {
buildAccountPasskeyTokenUserDecryptionOption, buildAccountPasskeyTokenUserDecryptionOption,
} from './account-passkeys'; } from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -336,6 +337,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
let validatedAuthRequestId: string | null = null; let validatedAuthRequestId: string | null = null;
let authRequestLoginKey: string | null = null;
let valid = false; let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim(); const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) { if (normalizedAuthRequestId) {
@@ -348,10 +350,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
authRequest.responseDate && authRequest.responseDate &&
!authRequest.authenticationDate && !authRequest.authenticationDate &&
!isAuthRequestExpired(authRequest) && !isAuthRequestExpired(authRequest) &&
!!authRequest.key &&
constantTimeEquals(authRequest.accessCode, passwordHash) constantTimeEquals(authRequest.accessCode, passwordHash)
); );
if (valid) { if (valid) {
validatedAuthRequestId = authRequest!.id; validatedAuthRequestId = authRequest!.id;
authRequestLoginKey = authRequest!.key;
} }
} else { } else {
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email); valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
@@ -408,8 +412,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return twoFactorRequiredResponse('Two factor required.'); return twoFactorRequiredResponse('Two factor required.');
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken); const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) { if (matchedCounter == null) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter);
if (!consumed) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
} else if ( } else if (
@@ -488,7 +496,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
token_type: 'Bearer', token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: user.key, Key: authRequestLoginKey || user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: accountKeys, AccountKeys: accountKeys,
accountKeys: accountKeys, accountKeys: accountKeys,
@@ -583,6 +591,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
@@ -621,6 +630,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
UserVerificationToken: userVerificationToken,
userVerificationToken,
UserDecryptionOptions: userDecryptionOptions, UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
+2 -1
View File
@@ -2,6 +2,7 @@ import { Env, SendType } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { import {
createSendAccessToken, createSendAccessToken,
@@ -306,7 +307,7 @@ export async function handleDownloadSendFile(
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(fileName), 'Content-Disposition': contentDispositionAttachment(fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
+1 -1
View File
@@ -50,7 +50,7 @@ export async function handleAdminBackupRoute(
return handleListAdminRemoteBackups(request, env, actorUser); return handleListAdminRemoteBackups(request, env, actorUser);
} }
if (path === '/api/admin/backup/remote/download' && method === 'GET') { if (path === '/api/admin/backup/remote/download' && method === 'POST') {
return handleDownloadAdminRemoteBackup(request, env, actorUser); return handleDownloadAdminRemoteBackup(request, env, actorUser);
} }
+5 -2
View File
@@ -27,6 +27,7 @@ import {
handleNotificationsNegotiate, handleNotificationsNegotiate,
} from './handlers/notifications'; } from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends'; import { handlePublicUploadSendFile } from './handlers/sends';
import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse } from './utils/response'; import { jsonResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
@@ -126,6 +127,7 @@ function buildConfigResponse(origin: string) {
'email-verification': true, 'email-verification': true,
'pm-19051-send-email-verification': false, 'pm-19051-send-email-verification': false,
'pm-19148-innovation-archive': true, 'pm-19148-innovation-archive': true,
'pm-30529-webauthn-related-origins': true,
'unauth-ui-refresh': true, 'unauth-ui-refresh': true,
'web-push': false, 'web-push': false,
}, },
@@ -241,6 +243,7 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
headers: { headers: {
'Content-Type': contentType || 'image/png', 'Content-Type': contentType || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`, 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
}, },
}); });
} }
@@ -272,7 +275,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
if (!resp.ok) continue; if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue; if (!isSafeWebsiteIconContentType(contentType)) continue;
const contentLength = getPositiveContentLength(resp.headers); const contentLength = getPositiveContentLength(resp.headers);
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue; if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
@@ -467,7 +470,7 @@ export async function handlePublicRoute(
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
const origin = new URL(request.url).origin; const origin = new URL(request.url).origin;
return jsonResponse(buildConfigResponse(origin)); return jsonResponse(buildConfigResponse(origin), 200, { 'Cache-Control': 'no-store' });
} }
if (path === '/api/version' && method === 'GET') { if (path === '/api/version' && method === 'GET') {
+78 -12
View File
@@ -26,6 +26,7 @@ import {
} from '../../shared/backup-schema'; } from '../../shared/backup-schema';
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1'; export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5; export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
const MAX_BACKUP_DESTINATIONS = 24; const MAX_BACKUP_DESTINATIONS = 24;
@@ -324,6 +325,47 @@ function mapDestinationsById(destinations: BackupDestinationRecord[]): Map<strin
return new Map(destinations.map((destination) => [destination.id, destination])); return new Map(destinations.map((destination) => [destination.id, destination]));
} }
function stripRuntimeFromSettings(settings: BackupSettings): BackupSettings {
return {
destinations: settings.destinations.map((destination) => ({
...destination,
runtime: normalizeRuntime(null),
})),
};
}
function serializeRuntimeState(settings: BackupSettings): string {
return JSON.stringify({
version: 1,
destinations: Object.fromEntries(
settings.destinations.map((destination) => [destination.id, normalizeRuntime(destination.runtime)])
),
});
}
async function loadBackupRuntimeStates(storage: StorageService): Promise<Map<string, BackupRuntimeState>> {
const raw = await storage.getConfigValue(BACKUP_RUNTIME_CONFIG_KEY);
if (!raw) return new Map();
try {
const parsed = JSON.parse(raw) as { destinations?: Record<string, unknown> };
const entries = Object.entries(parsed.destinations || {})
.filter(([id]) => !!asTrimmedString(id))
.map(([id, runtime]) => [id, normalizeRuntime(runtime)] as const);
return new Map(entries);
} catch {
return new Map();
}
}
function mergeRuntimeStates(settings: BackupSettings, runtimes: Map<string, BackupRuntimeState>): BackupSettings {
return {
destinations: settings.destinations.map((destination) => ({
...destination,
runtime: runtimes.get(destination.id) || normalizeRuntime(destination.runtime),
})),
};
}
export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings { export function getDefaultBackupSettings(timezone: string = 'UTC'): BackupSettings {
return createSharedDefaultBackupSettings(assertValidTimeZone(timezone)); return createSharedDefaultBackupSettings(assertValidTimeZone(timezone));
} }
@@ -387,27 +429,30 @@ export function normalizeBackupSettingsInput(
} }
export function serializeBackupSettings(settings: BackupSettings): string { export function serializeBackupSettings(settings: BackupSettings): string {
return JSON.stringify(settings); return JSON.stringify(stripRuntimeFromSettings(settings));
} }
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> { export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY); const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
mergeRuntimeStates(settings, await loadBackupRuntimeStates(storage))
);
if (!raw) { if (!raw) {
const settings = getDefaultBackupSettings(fallbackTimezone); const settings = getDefaultBackupSettings(fallbackTimezone);
await saveBackupSettings(storage, env, settings); await saveBackupSettings(storage, env, settings);
return settings; return mergeRuntime(settings);
} }
const envelope = parseBackupSettingsEnvelope(raw); const envelope = parseBackupSettingsEnvelope(raw);
if (!envelope) { if (!envelope) {
const settings = parseBackupSettings(raw, fallbackTimezone); const settings = parseBackupSettings(raw, fallbackTimezone);
await saveBackupSettings(storage, env, settings); await saveBackupSettings(storage, env, settings);
return settings; return mergeRuntime(settings);
} }
try { try {
const decrypted = await decryptBackupSettingsRuntime(raw, env); const decrypted = await decryptBackupSettingsRuntime(raw, env);
return parseBackupSettings(decrypted, fallbackTimezone); return mergeRuntime(parseBackupSettings(decrypted, fallbackTimezone));
} catch { } catch {
throw new Error('Backup settings need administrator reactivation after restore'); throw new Error('Backup settings need administrator reactivation after restore');
} }
@@ -417,6 +462,27 @@ export async function saveBackupSettings(storage: StorageService, env: Env, sett
const users = await storage.getAllUsers(); const users = await storage.getAllUsers();
const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users); const encrypted = await encryptBackupSettingsEnvelope(serializeBackupSettings(settings), env, users);
await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted); await storage.setConfigValue(BACKUP_SETTINGS_CONFIG_KEY, encrypted);
await saveBackupRuntimeStates(storage, settings);
}
export async function saveBackupRuntimeStates(storage: StorageService, settings: BackupSettings): Promise<void> {
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, serializeRuntimeState(settings));
}
export async function updateBackupDestinationRuntime(
storage: StorageService,
destinationId: string,
mutator: (runtime: BackupRuntimeState) => BackupRuntimeState
): Promise<BackupRuntimeState> {
const runtimes = await loadBackupRuntimeStates(storage);
const current = runtimes.get(destinationId) || normalizeRuntime(null);
const next = normalizeRuntime(mutator(current));
runtimes.set(destinationId, next);
await storage.setConfigValue(BACKUP_RUNTIME_CONFIG_KEY, JSON.stringify({
version: 1,
destinations: Object.fromEntries(runtimes.entries()),
}));
return next;
} }
export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> { export async function normalizeImportedBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<void> {
@@ -596,9 +662,9 @@ export function hasBackupSlotBetween(
const endMs = endExclusive.getTime(); const endMs = endExclusive.getTime();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false; if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) return false;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null; const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime()) const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
? lastAttemptAt.getTime() ? lastSuccessAt.getTime()
: Number.NEGATIVE_INFINITY; : Number.NEGATIVE_INFINITY;
const dayCursor = new Date(startMs); const dayCursor = new Date(startMs);
@@ -620,7 +686,7 @@ export function hasBackupSlotBetween(
for (const slotStart of slotStarts) { for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime(); const slotStartMs = slotStart.getTime();
if (slotStartMs < startMs || slotStartMs >= endMs) continue; if (slotStartMs < startMs || slotStartMs >= endMs) continue;
if (lastAttemptMs >= slotStartMs) continue; if (lastSuccessMs >= slotStartMs) continue;
return true; return true;
} }
} }
@@ -637,9 +703,9 @@ export function isBackupDueNow(
): boolean { ): boolean {
if (!destination.schedule.enabled) return false; if (!destination.schedule.enabled) return false;
const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000; const toleranceMs = Math.max(1, windowMinutes) * 60 * 1000;
const lastAttemptAt = destination.runtime.lastAttemptAt ? new Date(destination.runtime.lastAttemptAt) : null; const lastSuccessAt = destination.runtime.lastSuccessAt ? new Date(destination.runtime.lastSuccessAt) : null;
const lastAttemptMs = lastAttemptAt && Number.isFinite(lastAttemptAt.getTime()) const lastSuccessMs = lastSuccessAt && Number.isFinite(lastSuccessAt.getTime())
? lastAttemptAt.getTime() ? lastSuccessAt.getTime()
: Number.NEGATIVE_INFINITY; : Number.NEGATIVE_INFINITY;
const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone); const localDateKey = getBackupLocalDateKey(now, destination.schedule.timezone);
const slotStarts = getBackupSlotStartsForLocalDay( const slotStarts = getBackupSlotStartsForLocalDay(
@@ -652,7 +718,7 @@ export function isBackupDueNow(
for (const slotStart of slotStarts) { for (const slotStart of slotStarts) {
const slotStartMs = slotStart.getTime(); const slotStartMs = slotStart.getTime();
if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue; if (now.getTime() < slotStartMs || now.getTime() >= slotStartMs + toleranceMs) continue;
if (lastAttemptMs >= slotStartMs) return false; if (lastSuccessMs >= slotStartMs) return false;
return true; return true;
} }
return false; return false;
+45 -6
View File
@@ -33,6 +33,13 @@ export interface RemoteBackupFile {
bytes: Uint8Array; bytes: Uint8Array;
} }
export interface RemoteBackupFileStat {
provider: BackupDestinationType;
remotePath: string;
size: number | null;
modifiedAt: string | null;
}
export interface RemoteBackupFilePutOptions { export interface RemoteBackupFilePutOptions {
contentType?: string; contentType?: string;
} }
@@ -433,6 +440,10 @@ async function deleteFromWebDav(config: WebDavBackupDestination, relativePath: s
} }
async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> { async function existsInWebDav(config: WebDavBackupDestination, relativePath: string): Promise<boolean> {
return (await statWebDavFile(config, relativePath)) !== null;
}
async function statWebDavFile(config: WebDavBackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
const authHeader = toBasicAuthHeader(config.username, config.password); const authHeader = toBasicAuthHeader(config.username, config.password);
const remotePath = webDavFullPath(config, relativePath); const remotePath = webDavFullPath(config, relativePath);
const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), { const response = await fetch(buildWebDavUrl(config.baseUrl, remotePath), {
@@ -441,11 +452,17 @@ async function existsInWebDav(config: WebDavBackupDestination, relativePath: str
Authorization: authHeader, Authorization: authHeader,
}, },
}); });
if (response.status === 404) return false; if (response.status === 404) return null;
if (!response.ok) { if (!response.ok) {
throw new Error(`WebDAV existence check failed: ${response.status}`); throw new Error(`WebDAV existence check failed: ${response.status}`);
} }
return true; const size = Number(response.headers.get('Content-Length') || '');
return {
provider: 'webdav',
remotePath: normalizeRelativePath(relativePath),
size: Number.isFinite(size) ? size : null,
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
};
} }
function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean { function isBucketHostedS3Endpoint(endpoint: URL, bucket: string): boolean {
@@ -540,10 +557,16 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
const currentPath = normalizeRelativePath(relativePath); const currentPath = normalizeRelativePath(relativePath);
const targetPrefixBase = normalizeS3ObjectKey(config, currentPath); const targetPrefixBase = normalizeS3ObjectKey(config, currentPath);
const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : ''; const targetPrefix = trimSlashes(targetPrefixBase) ? `${trimSlashes(targetPrefixBase)}/` : '';
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
let continuationToken = '';
do {
const url = s3BucketBaseUrl(config); const url = s3BucketBaseUrl(config);
url.searchParams.set('list-type', '2'); url.searchParams.set('list-type', '2');
url.searchParams.set('delimiter', '/'); url.searchParams.set('delimiter', '/');
if (targetPrefix) url.searchParams.set('prefix', targetPrefix); if (targetPrefix) url.searchParams.set('prefix', targetPrefix);
if (continuationToken) url.searchParams.set('continuation-token', continuationToken);
const response = await signedS3Request(config, 'GET', url); const response = await signedS3Request(config, 'GET', url);
if (!response.ok) { if (!response.ok) {
@@ -551,8 +574,6 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
} }
const xml = await response.text(); const xml = await response.text();
const rootPrefix = trimSlashes(config.rootPath);
const items: RemoteBackupItem[] = [];
for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) { for (const prefix of extractXmlBlocks(xml, 'CommonPrefixes')) {
const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || ''); const fullPrefix = trimSlashes(extractXmlFirst(prefix, 'Prefix') || '');
@@ -596,6 +617,9 @@ async function listS3Entries(config: S3BackupDestination, relativePath: string):
}); });
} }
continuationToken = extractXmlFirst(xml, 'NextContinuationToken') || '';
} while (continuationToken);
const deduped = new Map<string, RemoteBackupItem>(); const deduped = new Map<string, RemoteBackupItem>();
for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item); for (const item of items) deduped.set(`${item.isDirectory ? 'd' : 'f'}:${item.path}`, item);
@@ -637,14 +661,24 @@ async function deleteFromS3(config: S3BackupDestination, relativePath: string):
} }
async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> { async function existsInS3(config: S3BackupDestination, relativePath: string): Promise<boolean> {
return (await statS3File(config, relativePath)) !== null;
}
async function statS3File(config: S3BackupDestination, relativePath: string): Promise<RemoteBackupFileStat | null> {
const objectKey = normalizeS3ObjectKey(config, relativePath); const objectKey = normalizeS3ObjectKey(config, relativePath);
const url = s3ObjectUrl(config, objectKey); const url = s3ObjectUrl(config, objectKey);
const response = await signedS3Request(config, 'HEAD', url); const response = await signedS3Request(config, 'HEAD', url);
if (response.status === 404) return false; if (response.status === 404) return null;
if (!response.ok) { if (!response.ok) {
throw new Error(`S3 existence check failed: ${response.status}`); throw new Error(`S3 existence check failed: ${response.status}`);
} }
return true; const size = Number(response.headers.get('Content-Length') || '');
return {
provider: 's3',
remotePath: normalizeRelativePath(relativePath),
size: Number.isFinite(size) ? size : null,
modifiedAt: parseHttpDate(response.headers.get('Last-Modified') || ''),
};
} }
interface ConfiguredDestinationAdapter { interface ConfiguredDestinationAdapter {
@@ -656,6 +690,7 @@ interface ConfiguredDestinationAdapter {
download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>; download: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFile>;
deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>; deleteFile: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<void>;
exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>; exists: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<boolean>;
stat: (config: WebDavBackupDestination | S3BackupDestination, relativePath: string) => Promise<RemoteBackupFileStat | null>;
} }
export interface RemoteBackupTransferSession { export interface RemoteBackupTransferSession {
@@ -666,6 +701,7 @@ export interface RemoteBackupTransferSession {
download(relativePath: string): Promise<RemoteBackupFile>; download(relativePath: string): Promise<RemoteBackupFile>;
deleteFile(relativePath: string): Promise<void>; deleteFile(relativePath: string): Promise<void>;
exists(relativePath: string): Promise<boolean>; exists(relativePath: string): Promise<boolean>;
stat(relativePath: string): Promise<RemoteBackupFileStat | null>;
} }
function resolveConfiguredDestinationAdapter( function resolveConfiguredDestinationAdapter(
@@ -683,6 +719,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath), download: (config, relativePath) => downloadFromWebDav(config as WebDavBackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromWebDav(config as WebDavBackupDestination, relativePath),
exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath), exists: (config, relativePath) => existsInWebDav(config as WebDavBackupDestination, relativePath),
stat: (config, relativePath) => statWebDavFile(config as WebDavBackupDestination, relativePath),
}; };
} }
if (destination.type === 's3') { if (destination.type === 's3') {
@@ -695,6 +732,7 @@ function resolveConfiguredDestinationAdapter(
download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath), download: (config, relativePath) => downloadFromS3(config as S3BackupDestination, relativePath),
deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath), deleteFile: (config, relativePath) => deleteFromS3(config as S3BackupDestination, relativePath),
exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath), exists: (config, relativePath) => existsInS3(config as S3BackupDestination, relativePath),
stat: (config, relativePath) => statS3File(config as S3BackupDestination, relativePath),
}; };
} }
@@ -730,6 +768,7 @@ export function createRemoteBackupTransferSession(destination: BackupDestination
download: async (relativePath: string) => adapter.download(adapter.config, relativePath), download: async (relativePath: string) => adapter.download(adapter.config, relativePath),
deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)), deleteFile: async (relativePath: string) => adapter.deleteFile(adapter.config, normalizeRelativePath(relativePath)),
exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)), exists: async (relativePath: string) => adapter.exists(adapter.config, normalizeRelativePath(relativePath)),
stat: async (relativePath: string) => adapter.stat(adapter.config, normalizeRelativePath(relativePath)),
}; };
} }
+26 -2
View File
@@ -117,12 +117,36 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
} }
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> { export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString(); const now = new Date().toISOString();
const result = await db const result = await db
.prepare( .prepare(
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?" "UPDATE invites SET status = 'used', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
) )
.bind(userId, now, code, now) .bind(now, code, now)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function assignInviteUsedBy(db: D1Database, code: string, userId: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
"UPDATE invites SET used_by = ?, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(userId, now, code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString();
const result = await db
.prepare(
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(now, code)
.run(); .run();
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
+7
View File
@@ -78,6 +78,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' + 'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)', 'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
'ALTER TABLE invites ADD COLUMN used_by TEXT',
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)', 'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)', 'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
@@ -126,6 +127,12 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
'CREATE TABLE IF NOT EXISTS totp_login_replays (' +
'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' +
'PRIMARY KEY (user_id, time_counter), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' + 'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' + 'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
+35
View File
@@ -0,0 +1,35 @@
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
export async function consumeTotpLoginCounter(
db: D1Database,
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
lastCleanupAt: number,
cleanupIntervalMs: number,
userId: string,
timeCounter: number,
consumedAtMs: number,
markerTtlMs: number
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
let cleanedUpAt: number | null = null;
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
await db
.prepare('DELETE FROM totp_login_replays WHERE consumed_at < ?')
.bind(consumedAtMs - markerTtlMs)
.run();
cleanedUpAt = consumedAtMs;
}
const result = await db
.prepare(
'INSERT INTO totp_login_replays(user_id, time_counter, consumed_at) VALUES(?, ?, ?) ' +
'ON CONFLICT(user_id, time_counter) DO NOTHING'
)
.bind(userId, timeCounter, consumedAtMs)
.run();
return {
consumed: (result.meta.changes ?? 0) > 0,
cleanedUpAt,
};
}
+36 -2
View File
@@ -22,6 +22,7 @@ import {
type AuditLogListOptions, type AuditLogListOptions,
createAuditLog as createStoredAuditLog, createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs, clearAuditLogs as clearStoredAuditLogs,
assignInviteUsedBy as assignStoredInviteUsedBy,
createInvite as createStoredInvite, createInvite as createStoredInvite,
deleteAllInvites as deleteStoredInvites, deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite, getInvite as findStoredInvite,
@@ -30,6 +31,7 @@ import {
markInviteUsed as markStoredInviteUsed, markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs, pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax, pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revertInviteUsed as revertStoredInviteUsed,
revokeInvite as revokeStoredInvite, revokeInvite as revokeStoredInvite,
} from './storage-admin-repo'; } from './storage-admin-repo';
import { import {
@@ -121,6 +123,9 @@ import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken, consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
} from './storage-attachment-token-repo'; } from './storage-attachment-token-repo';
import {
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
} from './storage-totp-replay-repo';
import { import {
getRevisionDate as getStoredRevisionDate, getRevisionDate as getStoredRevisionDate,
updateRevisionDate as updateStoredRevisionDate, updateRevisionDate as updateStoredRevisionDate,
@@ -148,8 +153,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications'; const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -162,10 +167,13 @@ export class StorageService {
private static schemaVerified = false; private static schemaVerified = false;
private static lastRefreshTokenCleanupAt = 0; private static lastRefreshTokenCleanupAt = 0;
private static lastAttachmentTokenCleanupAt = 0; private static lastAttachmentTokenCleanupAt = 0;
private static lastTotpReplayCleanupAt = 0;
private static readonly MAX_D1_SQL_VARIABLES = 100; private static readonly MAX_D1_SQL_VARIABLES = 100;
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs; private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs; private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
private static readonly TOTP_REPLAY_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
private static readonly TOTP_REPLAY_MARKER_TTL_MS = 5 * 60 * 1000;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
@@ -313,6 +321,14 @@ export class StorageService {
return markStoredInviteUsed(this.db, code, userId); return markStoredInviteUsed(this.db, code, userId);
} }
async assignInviteUsedBy(code: string, userId: string): Promise<boolean> {
return assignStoredInviteUsedBy(this.db, code, userId);
}
async revertInviteUsed(code: string, userId: string): Promise<boolean> {
return revertStoredInviteUsed(this.db, code, userId);
}
async revokeInvite(code: string): Promise<boolean> { async revokeInvite(code: string): Promise<boolean> {
return revokeStoredInvite(this.db, code); return revokeStoredInvite(this.db, code);
} }
@@ -823,6 +839,24 @@ export class StorageService {
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier); return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
} }
async consumeTotpLoginCounter(userId: string, timeCounter: number, consumedAtMs: number = Date.now()): Promise<boolean> {
if (!Number.isSafeInteger(timeCounter) || timeCounter < 0) return false;
const result = await consumeStoredTotpLoginCounter(
this.db,
this.shouldRunPeriodicCleanup.bind(this),
StorageService.lastTotpReplayCleanupAt,
StorageService.TOTP_REPLAY_CLEANUP_INTERVAL_MS,
userId,
timeCounter,
consumedAtMs,
StorageService.TOTP_REPLAY_MARKER_TTL_MS
);
if (result.cleanedUpAt !== null) {
StorageService.lastTotpReplayCleanupAt = result.cleanedUpAt;
}
return result.consumed;
}
// --- Revision dates --- // --- Revision dates ---
async getRevisionDate(userId: string): Promise<string> { async getRevisionDate(userId: string): Promise<string> {
+2
View File
@@ -466,6 +466,8 @@ export interface TokenResponse {
ResetMasterPassword: boolean; ResetMasterPassword: boolean;
scope: string; scope: string;
unofficialServer: boolean; unofficialServer: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
MasterPasswordPolicy?: { MasterPasswordPolicy?: {
minComplexity: number; minComplexity: number;
minLength: number; minLength: number;
+38
View File
@@ -0,0 +1,38 @@
const ACTIVE_DOWNLOAD_MEDIA_TYPES = new Set([
'application/xhtml+xml',
'application/xml',
'image/svg+xml',
'text/html',
'text/xml',
]);
const SAFE_ICON_MEDIA_TYPES = new Set([
'image/avif',
'image/bmp',
'image/gif',
'image/jpeg',
'image/png',
'image/vnd.microsoft.icon',
'image/webp',
'image/x-icon',
]);
function normalizeMediaType(contentType: string | null | undefined): string {
return String(contentType || '')
.split(';', 1)[0]
.trim()
.toLowerCase();
}
export function isSafeWebsiteIconContentType(contentType: string | null | undefined): boolean {
return SAFE_ICON_MEDIA_TYPES.has(normalizeMediaType(contentType));
}
export function sanitizeDownloadContentType(contentType: string | null | undefined): string {
const mediaType = normalizeMediaType(contentType);
if (!mediaType) return 'application/octet-stream';
if (ACTIVE_DOWNLOAD_MEDIA_TYPES.has(mediaType)) {
return 'application/octet-stream';
}
return contentType || mediaType;
}
+2
View File
@@ -100,7 +100,9 @@ export function applyCors(
headers.set('X-Frame-Options', 'DENY'); headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff'); headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
if (!headers.has('Content-Security-Policy')) {
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
}
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
+16 -7
View File
@@ -70,17 +70,22 @@ function normalizeToken(token: string): string {
return token.replace(/\s+/g, ''); return token.replace(/\s+/g, '');
} }
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> { export async function findMatchingTotpCounter(
secretRaw: string,
tokenRaw: string,
nowMs: number = Date.now()
): Promise<number | null> {
const token = normalizeToken(tokenRaw); const token = normalizeToken(tokenRaw);
if (!/^\d{6}$/.test(token)) return false; if (!/^\d{6}$/.test(token)) return null;
const secret = base32Decode(secretRaw); const secret = base32Decode(secretRaw);
if (!secret) return false; if (!secret) return null;
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS); const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
let matched = false; let matchedCounter: number | null = null;
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) { for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
const expected = await hotp(secret, currentCounter + delta); const candidateCounter = currentCounter + delta;
const expected = await hotp(secret, candidateCounter);
// Constant-time comparison: always check all windows, never short-circuit. // Constant-time comparison: always check all windows, never short-circuit.
const a = new TextEncoder().encode(expected); const a = new TextEncoder().encode(expected);
const b = new TextEncoder().encode(token); const b = new TextEncoder().encode(token);
@@ -88,9 +93,13 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs
for (let i = 0; i < a.length && i < b.length; i++) { for (let i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i]; diff |= a[i] ^ b[i];
} }
if (diff === 0) matched = true; if (diff === 0 && matchedCounter == null) matchedCounter = candidateCounter;
} }
return matched; return matchedCounter;
}
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
return (await findMatchingTotpCounter(secretRaw, tokenRaw, nowMs)) != null;
} }
export function isTotpEnabled(secretRaw: string | undefined | null): boolean { export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
+89
View File
@@ -0,0 +1,89 @@
import type { Env } from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1';
const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000;
export type UserVerificationPurpose = 'backup.settings.repair';
interface UserVerificationTokenPayload {
typ: typeof USER_VERIFICATION_TOKEN_TYPE;
userId: string;
method: 'passkey';
purpose: UserVerificationPurpose;
iat: number;
exp: number;
}
function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await importHmacKey(secret);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
}
function encodeJson(value: unknown): string {
return bytesToBase64Url(textBytes(JSON.stringify(value)));
}
function decodeJson<T>(value: string): T | null {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
} catch {
return null;
}
}
export async function createPasskeyUserVerificationToken(
env: Env,
userId: string,
purpose: UserVerificationPurpose
): Promise<string> {
const now = Date.now();
const payload: UserVerificationTokenPayload = {
typ: USER_VERIFICATION_TOKEN_TYPE,
userId,
method: 'passkey',
purpose,
iat: now,
exp: now + USER_VERIFICATION_TOKEN_TTL_MS,
};
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
return `${data}.${signature}`;
}
export async function verifyPasskeyUserVerificationToken(
env: Env,
token: string,
userId: string,
purpose: UserVerificationPurpose
): Promise<boolean> {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) return false;
const data = `${parts[0]}.${parts[1]}`;
const expected = await hmacSha256(env.JWT_SECRET, data);
const actual = base64UrlToBytes(parts[2]);
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
if (diff !== 0) return false;
const payload = decodeJson<UserVerificationTokenPayload>(parts[1]);
if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false;
if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false;
if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false;
return true;
} catch {
return false;
}
}
+101 -19
View File
@@ -11,6 +11,7 @@ import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
import JwtWarningPage from '@/components/JwtWarningPage'; import JwtWarningPage from '@/components/JwtWarningPage';
import { import {
createAuthedFetch, createAuthedFetch,
deriveLoginHash,
getAuthorizedDevices, getAuthorizedDevices,
clearProfileSnapshot, clearProfileSnapshot,
getCurrentDeviceIdentifier, getCurrentDeviceIdentifier,
@@ -237,6 +238,7 @@ export default function App() {
const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false); const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null); const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
const [authRequestDialogSelectedId, setAuthRequestDialogSelectedId] = useState<string | null>(null);
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null); const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference()); const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
@@ -264,6 +266,11 @@ export default function App() {
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {}); const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>(''); const repairAttemptRef = useRef<string>('');
const loginScopedBackupRepairAuthRef = useRef<{
accessToken: string;
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
} | null>(null);
const uriChecksumRepairAttemptRef = useRef<string>(''); const uriChecksumRepairAttemptRef = useRef<string>('');
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null); const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null); const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
@@ -506,6 +513,14 @@ export default function App() {
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin) {
loginScopedBackupRepairAuthRef.current =
login.session.accessToken && (login.freshMasterPasswordHash || login.freshUserVerificationToken)
? {
accessToken: login.session.accessToken,
masterPasswordHash: login.freshMasterPasswordHash || null,
userVerificationToken: login.freshUserVerificationToken || null,
}
: null;
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
@@ -1085,6 +1100,15 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
async function deriveCurrentMasterPasswordHash(masterPassword: string): Promise<string> {
const email = String(profile?.email || session?.email || '').trim().toLowerCase();
if (!email) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(email, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]); const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
const pendingAuthRequestsQuery = useQuery({ const pendingAuthRequestsQuery = useQuery({
queryKey: pendingAuthRequestsQueryKey, queryKey: pendingAuthRequestsQueryKey,
@@ -1096,7 +1120,20 @@ export default function App() {
}); });
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest); const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
const latestPendingAuthRequest = pendingAuthRequests[0] || null; const latestPendingAuthRequest = pendingAuthRequests[0] || null;
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId; const selectedPendingAuthRequest = authRequestDialogSelectedId
? pendingAuthRequests.find((request) => request.id === authRequestDialogSelectedId) || null
: null;
const authRequestDialogRequest = selectedPendingAuthRequest || (
latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId
? latestPendingAuthRequest
: null
);
const authRequestDialogOpen = !!authRequestDialogRequest;
async function beginApproveAuthRequest(authRequest: AuthRequest): Promise<void> {
setAuthRequestDialogSelectedId(authRequest.id);
setAuthRequestDialogDismissedId(null);
}
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> { async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
@@ -1110,6 +1147,7 @@ export default function App() {
requestApproved: true, requestApproved: true,
}); });
setAuthRequestDialogDismissedId(null); setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_approved')); pushToast('success', t('txt_auth_request_approved'));
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
} finally { } finally {
@@ -1125,6 +1163,7 @@ export default function App() {
requestApproved: false, requestApproved: false,
}); });
setAuthRequestDialogDismissedId(null); setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_denied')); pushToast('success', t('txt_auth_request_denied'));
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
} finally { } finally {
@@ -1189,13 +1228,25 @@ export default function App() {
if (!isAdminProfile(profile)) return; if (!isAdminProfile(profile)) return;
if (repairAttemptRef.current === session.accessToken) return; if (repairAttemptRef.current === session.accessToken) return;
const loginScopedRepairAuth = loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken
? loginScopedBackupRepairAuthRef.current
: null;
repairAttemptRef.current = session.accessToken; repairAttemptRef.current = session.accessToken;
void silentlyRepairBackupSettingsIfNeeded(session, profile); void (async () => {
try {
await silentlyRepairBackupSettingsIfNeeded(session, profile, loginScopedRepairAuth);
} finally {
if (loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken) {
loginScopedBackupRepairAuthRef.current = null;
}
}
})();
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]); }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
useEffect(() => { useEffect(() => {
if (session?.accessToken) return; if (session?.accessToken) return;
repairAttemptRef.current = ''; repairAttemptRef.current = '';
loginScopedBackupRepairAuthRef.current = null;
uriChecksumRepairAttemptRef.current = ''; uriChecksumRepairAttemptRef.current = '';
}, [session?.accessToken]); }, [session?.accessToken]);
@@ -1950,8 +2001,8 @@ export default function App() {
sendUploadPercent: vaultSendActions.sendUploadPercent, sendUploadPercent: vaultSendActions.sendUploadPercent,
onChangePassword: accountSecurityActions.changePassword, onChangePassword: accountSecurityActions.changePassword,
onSavePasswordHint: accountSecurityActions.savePasswordHint, onSavePasswordHint: accountSecurityActions.savePasswordHint,
onEnableTotp: async (secret: string, token: string) => { onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
await accountSecurityActions.enableTotp(secret, token); await accountSecurityActions.enableTotp(secret, token, masterPassword);
await totpStatusQuery.refetch(); await totpStatusQuery.refetch();
}, },
onOpenDisableTotp: () => setDisableTotpOpen(true), onOpenDisableTotp: () => setDisableTotpOpen(true),
@@ -1967,7 +2018,7 @@ export default function App() {
onRefreshPendingAuthRequests: async () => { onRefreshPendingAuthRequests: async () => {
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
}, },
onApproveAuthRequest: approveAuthRequest, onApproveAuthRequest: beginApproveAuthRequest,
onDenyAuthRequest: denyAuthRequest, onDenyAuthRequest: denyAuthRequest,
onLockTimeoutChange: setLockTimeoutMinutes, onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction, onSessionTimeoutActionChange: setSessionTimeoutAction,
@@ -1992,22 +2043,50 @@ export default function App() {
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch), onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings), onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
onClearAuditLogs: () => clearAuditLogs(authedFetch), onClearAuditLogs: () => clearAuditLogs(authedFetch),
onExportBackup: backupActions.exportBackup, onExportBackup: async (masterPassword: string, includeAttachments?: boolean) => {
onImportBackup: backupActions.importBackup, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch, return backupActions.exportBackup(hash, includeAttachments);
},
onImportBackup: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackup(hash, file, replaceExisting);
},
onImportBackupAllowingChecksumMismatch: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackupAllowingChecksumMismatch(hash, file, replaceExisting);
},
onLoadBackupSettings: () => queryClient.ensureQueryData({ onLoadBackupSettings: () => queryClient.ensureQueryData({
queryKey: ['admin-backup-settings', vaultCacheKey], queryKey: ['admin-backup-settings', vaultCacheKey],
queryFn: () => backupActions.loadSettings(), queryFn: () => backupActions.loadSettings(),
staleTime: 30_000, staleTime: 30_000,
}), }),
onSaveBackupSettings: backupActions.saveSettings, onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
onRunRemoteBackup: backupActions.runRemoteBackup, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const saved = await backupActions.saveSettings(hash, settings);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], saved);
return saved;
},
onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const result = await backupActions.runRemoteBackup(hash, destinationId);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], result.settings);
return result;
},
onListRemoteBackups: backupActions.listRemoteBackups, onListRemoteBackups: backupActions.listRemoteBackups,
onDownloadRemoteBackup: backupActions.downloadRemoteBackup, onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
},
onInspectRemoteBackup: backupActions.inspectRemoteBackup, onInspectRemoteBackup: backupActions.inspectRemoteBackup,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup, onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
onRestoreRemoteBackup: backupActions.restoreRemoteBackup, onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
},
onRestoreRemoteBackupAllowingChecksumMismatch: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackupAllowingChecksumMismatch(hash, destinationId, path, replaceExisting);
},
}; };
const effectiveMainRoutesProps = IS_DEMO_MODE const effectiveMainRoutesProps = IS_DEMO_MODE
? createDemoMainRoutesProps(mainRoutesProps, pushToast, { ? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
@@ -2215,21 +2294,24 @@ export default function App() {
/> />
<AuthRequestApprovalDialog <AuthRequestApprovalDialog
open={authRequestDialogOpen} open={authRequestDialogOpen}
authRequest={latestPendingAuthRequest} authRequest={authRequestDialogRequest}
submitting={!!authRequestSubmittingId} submitting={!!authRequestSubmittingId}
onApprove={() => { onApprove={() => {
if (!latestPendingAuthRequest) return; if (!authRequestDialogRequest) return;
void approveAuthRequest(latestPendingAuthRequest).catch((error) => { void approveAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
}); });
}} }}
onDeny={() => { onDeny={() => {
if (!latestPendingAuthRequest) return; if (!authRequestDialogRequest) return;
void denyAuthRequest(latestPendingAuthRequest).catch((error) => { void denyAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
}); });
}} }}
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)} onClose={() => {
setAuthRequestDialogSelectedId(null);
setAuthRequestDialogDismissedId(authRequestDialogRequest?.id || null);
}}
/> />
</> </>
); );
+9 -14
View File
@@ -107,7 +107,7 @@ export interface AppMainRoutesProps {
sendUploadPercent: number | null; sendUploadPercent: number | null;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
@@ -142,18 +142,18 @@ export interface AppMainRoutesProps {
onLoadAuditLogSettings: () => Promise<AuditLogSettings>; onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>; onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
onClearAuditLogs: () => Promise<number>; onClearAuditLogs: () => Promise<number>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>; onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>; onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (masterPassword: string, 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: (masterPassword: string, 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 } }>; 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: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
} }
export default function AppMainRoutes(props: AppMainRoutesProps) { export default function AppMainRoutes(props: AppMainRoutesProps) {
@@ -275,11 +275,6 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onCreateAccountPasskey={props.onCreateAccountPasskey} onCreateAccountPasskey={props.onCreateAccountPasskey}
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock} onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
onDeleteAccountPasskey={props.onDeleteAccountPasskey} onDeleteAccountPasskey={props.onDeleteAccountPasskey}
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
onLockTimeoutChange={props.onLockTimeoutChange} onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange} onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify} onNotify={props.onNotify}
+171 -18
View File
@@ -34,18 +34,18 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
interface BackupCenterPageProps { interface BackupCenterPageProps {
currentUserId: string | null; currentUserId: string | null;
onExport: (includeAttachments?: boolean) => Promise<void>; onExport: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImport: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadSettings: () => Promise<AdminBackupSettings>; onLoadSettings: () => Promise<AdminBackupSettings>;
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>; onRunRemoteBackup: (masterPassword: string, 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: (masterPassword: string, 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 }>; 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: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
} }
@@ -53,6 +53,15 @@ type PendingRestoreIntegrity =
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult } | { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult }; | { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
type PendingBackupVerification =
| { action: 'export' }
| { action: 'saveSettings' }
| { action: 'deleteDestination'; destinationId: string; settings: AdminBackupSettings }
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
| { action: 'runRemoteBackup' }
| { action: 'downloadRemote'; path: string }
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
interface BackupProgressPhase { interface BackupProgressPhase {
titleKey: string; titleKey: string;
detailKey: string; detailKey: string;
@@ -193,6 +202,9 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = 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 [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
const [backupPasswordValue, setBackupPasswordValue] = useState('');
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null); const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState(''); const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState(''); const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
@@ -209,7 +221,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const selectedDestination = getDestinationById(settings, selectedDestinationId); const selectedDestination = getDestinationById(settings, selectedDestinationId);
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId); const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
const selectedDestinationIsSaved = !!savedSelectedDestination; const selectedDestinationIsSaved = !!savedSelectedDestination;
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup; const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting;
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : ''; const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : ''; const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null; const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
@@ -226,6 +238,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3'); const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved; const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
const canBrowseSelectedDestination = !!savedSelectedDestination; const canBrowseSelectedDestination = !!savedSelectedDestination;
const backupPasswordPromptTitle =
pendingBackupVerification?.action === 'export'
? t('txt_backup_export')
: pendingBackupVerification?.action === 'saveSettings' || pendingBackupVerification?.action === 'deleteDestination'
? t('txt_backup_save_settings')
: pendingBackupVerification?.action === 'runRemoteBackup'
? t('txt_backup_run_manual')
: pendingBackupVerification?.action === 'downloadRemote'
? t('txt_backup_remote_download')
: pendingBackupVerification?.action === 'restoreRemote'
? t('txt_backup_import')
: t('txt_backup_import');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -478,10 +502,16 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete), destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
}; };
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
setBackupPasswordValue('');
setConfirmDeleteDestinationOpen(false);
}
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) {
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
const saved = await props.onSaveSettings(nextSettings); const saved = await props.onSaveSettings(masterPassword, payload);
const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete); const nextDraftDestinations = settings.destinations.filter((destination) => destination.id !== destinationIdToDelete);
const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved); const nextSelected = getFirstVisibleDestinationId({ destinations: nextDraftDestinations }) || getFirstVisibleDestinationId(saved);
setSavedSettings(saved); setSavedSettings(saved);
@@ -507,11 +537,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleExport() { async function handleExport() {
if (exporting) return;
setPendingBackupVerification({ action: 'export' });
setBackupPasswordValue('');
}
async function executeExport(masterPassword: string) {
setLocalError(''); setLocalError('');
setExporting(true); setExporting(true);
try { try {
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments }); startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
await props.onExport(exportIncludeAttachments); await props.onExport(masterPassword, 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');
@@ -527,6 +563,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (importing) return;
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
props.onNotify('error', message);
return;
}
setPendingBackupVerification({
action: 'import',
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeLocalRestore(
masterPassword: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (importing) return; if (importing) return;
if (!selectedFile) { if (!selectedFile) {
@@ -547,8 +605,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting) ? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting)
: await props.onImport(selectedFile, replaceExisting); : await props.onImport(masterPassword, selectedFile, replaceExisting);
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`); 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);
@@ -573,12 +631,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleSaveSettings() { async function handleSaveSettings() {
if (savingSettings) return;
setPendingBackupVerification({ action: 'saveSettings' });
setBackupPasswordValue('');
}
async function executeSaveSettings(masterPassword: string) {
const payload = buildSettingsPayloadForSelectedDestination(); const payload = buildSettingsPayloadForSelectedDestination();
const destinationIdToInvalidate = selectedDestinationId; const destinationIdToInvalidate = selectedDestinationId;
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
const saved = await props.onSaveSettings(payload); const saved = await props.onSaveSettings(masterPassword, payload);
const nextSelected = const nextSelected =
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId) (selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|| getFirstVisibleDestinationId(saved) || getFirstVisibleDestinationId(saved)
@@ -613,6 +677,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleRunRemoteBackup() { async function handleRunRemoteBackup() {
if (!selectedDestination || runningRemoteBackup) return;
setPendingBackupVerification({ action: 'runRemoteBackup' });
setBackupPasswordValue('');
}
async function executeRunRemoteBackup(masterPassword: string) {
if (!selectedDestination) return; if (!selectedDestination) return;
setRunningRemoteBackup(true); setRunningRemoteBackup(true);
setLocalError(''); setLocalError('');
@@ -621,7 +691,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
source: 'remote', source: 'remote',
includeAttachments: !!selectedDestination.includeAttachments, includeAttachments: !!selectedDestination.includeAttachments,
}); });
const result = await props.onRunRemoteBackup(selectedDestination.id); const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id);
setSavedSettings(result.settings); setSavedSettings(result.settings);
setSettings(result.settings); setSettings(result.settings);
setSelectedDestinationId(selectedDestination.id); setSelectedDestinationId(selectedDestination.id);
@@ -638,12 +708,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleDownloadRemote(path: string) { async function handleDownloadRemote(path: string) {
setPendingBackupVerification({ action: 'downloadRemote', path });
setBackupPasswordValue('');
}
async function executeDownloadRemote(masterPassword: string, path: string) {
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
setDownloadingRemotePath(path); setDownloadingRemotePath(path);
setDownloadingRemotePercent(null); setDownloadingRemotePercent(null);
setLocalError(''); setLocalError('');
try { try {
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent); await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed'); const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
setLocalError(message); setLocalError(message);
@@ -724,6 +799,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (restoringRemotePath) return;
if (!savedSelectedDestination) return;
setPendingBackupVerification({
action: 'restoreRemote',
path,
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeRemoteRestore(
masterPassword: string,
path: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (restoringRemotePath) return; if (restoringRemotePath) return;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
@@ -738,8 +832,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting) ? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting)
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); : await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false); setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath(''); setPendingRemoteRestorePath('');
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`); props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
@@ -762,6 +856,38 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
} }
async function submitBackupPasswordPrompt(): Promise<void> {
const request = pendingBackupVerification;
const masterPassword = backupPasswordValue;
if (!request || backupPasswordSubmitting) return;
if (!masterPassword.trim()) {
props.onNotify('error', t('txt_master_password_is_required'));
return;
}
setBackupPasswordSubmitting(true);
setPendingBackupVerification(null);
setBackupPasswordValue('');
try {
if (request.action === 'export') {
await executeExport(masterPassword);
} else if (request.action === 'saveSettings') {
await executeSaveSettings(masterPassword);
} else if (request.action === 'deleteDestination') {
await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
} else if (request.action === 'import') {
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
} else if (request.action === 'runRemoteBackup') {
await executeRunRemoteBackup(masterPassword);
} else if (request.action === 'downloadRemote') {
await executeDownloadRemote(masterPassword, request.path);
} else if (request.action === 'restoreRemote') {
await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
}
} finally {
setBackupPasswordSubmitting(false);
}
}
return ( return (
<div className="backup-grid"> <div className="backup-grid">
<input <input
@@ -893,6 +1019,33 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
</div> </div>
), document.body) : null} ), document.body) : null}
<ConfirmDialog
open={pendingBackupVerification !== null}
title={backupPasswordPromptTitle}
message={t('txt_enter_master_password_to_continue')}
confirmText={t('txt_continue')}
cancelText={t('txt_cancel')}
confirmDisabled={backupPasswordSubmitting || !backupPasswordValue.trim()}
cancelDisabled={backupPasswordSubmitting}
onConfirm={() => void submitBackupPasswordPrompt()}
onCancel={() => {
if (backupPasswordSubmitting) return;
setPendingBackupVerification(null);
setBackupPasswordValue('');
}}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={backupPasswordValue}
onInput={(event) => setBackupPasswordValue((event.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={confirmLocalRestoreOpen} open={confirmLocalRestoreOpen}
title={t('txt_backup_import')} title={t('txt_backup_import')}
@@ -91,7 +91,7 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
<section className="card"> <section className="card">
<div className="section-head"> <div className="section-head">
<div> <div>
<h3 className="flush-title">{t('txt_device_management')}</h3> <h3 className="flush-title">{t('txt_authorized_devices')}</h3>
<div className="muted-inline section-note"> <div className="muted-inline section-note">
{t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')} {t('txt_manage_device_sessions_and_30_day_totp_trusted_sessions')}
</div> </div>
@@ -111,10 +111,6 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
</button> </button>
</div> </div>
</div> </div>
</section>
<section className="card">
<h3 className="section-title-flush">{t('txt_authorized_devices')}</h3>
{!!props.error && ( {!!props.error && (
<div className="local-error"> <div className="local-error">
<span>{props.error}</span> <span>{props.error}</span>
+15 -24
View File
@@ -2,10 +2,9 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact'; import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator'; import qrcode from 'qrcode-generator';
import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types'; import type { AccountPasskeyCredential, Profile } from '@/lib/types';
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n'; import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
interface SettingsPageProps { interface SettingsPageProps {
profile: Profile; profile: Profile;
@@ -14,7 +13,7 @@ interface SettingsPageProps {
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
@@ -23,17 +22,13 @@ interface SettingsPageProps {
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>; onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void; onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
} }
type MasterPasswordPromptAction = type MasterPasswordPromptAction =
| 'enableTotp'
| 'recovery' | 'recovery'
| 'apiKey' | 'apiKey'
| 'rotateApiKey' | 'rotateApiKey'
@@ -141,12 +136,12 @@ export default function SettingsPage(props: SettingsPageProps) {
}, [props.profile.email, secret]); }, [props.profile.email, secret]);
async function enableTotp(): Promise<void> { async function enableTotp(): Promise<void> {
try { if (totpLocked) return;
await props.onEnableTotp(secret, token); if (!secret.trim() || !token.trim()) {
setTotpLocked(true); props.onNotify?.('error', t('txt_secret_and_code_are_required'));
} catch { return;
// Keep inputs editable after a failed attempt.
} }
openMasterPasswordPrompt('enableTotp');
} }
async function refreshAccountPasskeys(): Promise<void> { async function refreshAccountPasskeys(): Promise<void> {
@@ -178,7 +173,10 @@ export default function SettingsPage(props: SettingsPageProps) {
const masterPassword = masterPasswordPromptValue; const masterPassword = masterPasswordPromptValue;
setMasterPasswordPromptSubmitting(true); setMasterPasswordPromptSubmitting(true);
try { try {
if (masterPasswordPrompt === 'recovery') { if (masterPasswordPrompt === 'enableTotp') {
await props.onEnableTotp(secret, token, masterPassword);
setTotpLocked(true);
} else if (masterPasswordPrompt === 'recovery') {
const code = await props.onGetRecoveryCode(masterPassword); const code = await props.onGetRecoveryCode(masterPassword);
setRecoveryCode(code); setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded')); props.onNotify?.('success', t('txt_recovery_code_loaded'));
@@ -214,7 +212,9 @@ export default function SettingsPage(props: SettingsPageProps) {
} }
const masterPasswordPromptTitle = const masterPasswordPromptTitle =
masterPasswordPrompt === 'recovery' masterPasswordPrompt === 'enableTotp'
? t('txt_enable_totp')
: masterPasswordPrompt === 'recovery'
? t('txt_view_recovery_code') ? t('txt_view_recovery_code')
: masterPasswordPrompt === 'rotateApiKey' : masterPasswordPrompt === 'rotateApiKey'
? t('txt_rotate_api_key') ? t('txt_rotate_api_key')
@@ -509,15 +509,6 @@ export default function SettingsPage(props: SettingsPageProps) {
)} )}
</div> </div>
</section> </section>
<PendingAuthRequestsPanel
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
/>
<section className="settings-module sensitive-actions-module"> <section className="settings-module sensitive-actions-module">
<div className="sensitive-actions-grid"> <div className="sensitive-actions-grid">
<div className="sensitive-action"> <div className="sensitive-action">
@@ -54,21 +54,18 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
<> <>
<div className="backup-recommendation-steps"> <div className="backup-recommendation-steps">
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_koofr_step_1')} <strong>1.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_koofr_step_2_prefix')}{' '}
<a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a> <a href={provider.passwordUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_password_link')}</a>
{t('txt_backup_recommend_koofr_step_2_suffix')} {t('txt_backup_recommend_koofr_step_2_suffix')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_koofr_step_3')} <strong>2.</strong> {t('txt_backup_recommend_koofr_step_3')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_koofr_step_4')} <strong>3.</strong> {t('txt_backup_recommend_koofr_step_4')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '} <strong>4.</strong> {t('txt_backup_recommend_koofr_step_5_prefix')}{' '}
<a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a> <a href={provider.storageUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_koofr_storage_link')}</a>
{t('txt_backup_recommend_koofr_step_5_suffix')} {t('txt_backup_recommend_koofr_step_5_suffix')}
</div> </div>
@@ -98,13 +95,10 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
return ( return (
<div className="backup-recommendation-steps"> <div className="backup-recommendation-steps">
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_pcloud_step_1')} <strong>1.</strong> {t('txt_backup_recommend_pcloud_step_2')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_pcloud_step_2')} <strong>2.</strong> {t('txt_backup_recommend_pcloud_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_pcloud_step_3')}
</div> </div>
</div> </div>
); );
@@ -112,18 +106,87 @@ function renderRecommendedProviderDetails(provider: RecommendedProvider) {
return ( return (
<div className="backup-recommendation-steps"> <div className="backup-recommendation-steps">
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_1')} <strong>1.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_2_prefix')}{' '}
<a href="https://infini-cloud.net/en/modules/mypage/usage/" target="_blank" rel="noreferrer">My Page</a> <a href="https://infini-cloud.net/en/modules/mypage/usage/" target="_blank" rel="noreferrer">My Page</a>
{t('txt_backup_recommend_infinicloud_step_2_suffix')} {t('txt_backup_recommend_infinicloud_step_2_suffix')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_3')} <strong>2.</strong> {t('txt_backup_recommend_infinicloud_step_3')}
</div> </div>
<div className="backup-recommendation-step"> <div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_infinicloud_step_4')} <strong>3.</strong> {t('txt_backup_recommend_infinicloud_step_4')}
</div>
</div>
);
case 'backblaze-b2':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_backblaze_step_2_prefix')}{' '}
<a href={provider.bucketsUrl} target="_blank" rel="noreferrer">Buckets</a>
{t('txt_backup_recommend_backblaze_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_backblaze_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_backblaze_step_4_prefix')}{' '}
<a href={provider.applicationKeysUrl} target="_blank" rel="noreferrer">Application Keys</a>
{t('txt_backup_recommend_backblaze_step_4_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_backblaze_step_5')}
</div>
<div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
</div>
</div>
);
case 'cloudflare-r2':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_cloudflare_r2_step_1_prefix')}{' '}
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_bucket_link')}</a>
{t('txt_backup_recommend_cloudflare_r2_step_1_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_cloudflare_r2_step_2_prefix')}{' '}
<a href={provider.apiTokenUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_cloudflare_r2_api_link')}</a>
{t('txt_backup_recommend_cloudflare_r2_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_cloudflare_r2_step_3')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_cloudflare_r2_step_4')}
</div>
<div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_cloudflare_r2_step_5')}
</div>
</div>
);
case 'tigris':
return (
<div className="backup-recommendation-steps">
<div className="backup-recommendation-step">
<strong>1.</strong> {t('txt_backup_recommend_tigris_step_2_prefix')}{' '}
<a href={provider.bucketUrl} target="_blank" rel="noreferrer">Create Bucket</a>
{t('txt_backup_recommend_tigris_step_2_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>2.</strong> {t('txt_backup_recommend_tigris_step_3_prefix')}{' '}
<a href={provider.accessKeyUrl} target="_blank" rel="noreferrer">{t('txt_backup_recommend_tigris_access_key_link')}</a>
{t('txt_backup_recommend_tigris_step_3_suffix')}
</div>
<div className="backup-recommendation-step">
<strong>3.</strong> {t('txt_backup_recommend_tigris_step_4')}
</div>
<div className="backup-recommendation-step">
<strong>4.</strong> {t('txt_backup_recommend_tigris_step_5')}
</div>
<div className="backup-recommendation-step">
<strong>5.</strong> {t('txt_backup_recommend_s3_path_prefix_step')}
</div> </div>
</div> </div>
); );
@@ -147,6 +210,9 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
<div className="backup-inline-note"> <div className="backup-inline-note">
{props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary') {props.selectedRecommendedProvider.id === 'infinicloud' ? t('txt_backup_recommend_infinicloud_summary')
: props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary') : props.selectedRecommendedProvider.id === 'koofr' ? t('txt_backup_recommend_koofr_summary')
: props.selectedRecommendedProvider.id === 'backblaze-b2' ? t('txt_backup_recommend_backblaze_summary')
: props.selectedRecommendedProvider.id === 'cloudflare-r2' ? t('txt_backup_recommend_cloudflare_r2_summary')
: props.selectedRecommendedProvider.id === 'tigris' ? t('txt_backup_recommend_tigris_summary')
: t('txt_backup_recommend_pcloud_summary')} : t('txt_backup_recommend_pcloud_summary')}
</div> </div>
</div> </div>
@@ -387,7 +453,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
className="input" className="input"
value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath} value={(props.selectedDestination.destination as WebDavBackupDestination).remotePath}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups" placeholder="nodewarden"
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
@@ -504,7 +570,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
className="input" className="input"
value={(props.selectedDestination.destination as S3BackupDestination).rootPath} value={(props.selectedDestination.destination as S3BackupDestination).rootPath}
disabled={props.loadingSettings || props.disableWhileBusy} disabled={props.loadingSettings || props.disableWhileBusy}
placeholder="nodewarden/backups" placeholder=""
onInput={(event) => props.onUpdateDestination((destination) => ({ onInput={(event) => props.onUpdateDestination((destination) => ({
...destination, ...destination,
destination: { destination: {
@@ -1,9 +1,12 @@
import { Download, FileUp } from 'lucide-preact'; import { Download, FileUp } from 'lucide-preact';
import { useEffect, useState } from 'preact/hooks';
import type { RecommendedProvider } from '@/lib/backup-recommendations'; import type { RecommendedProvider } from '@/lib/backup-recommendations';
import { hasLinkedStorages } from '@/lib/backup-recommendations'; import { hasLinkedStorages } from '@/lib/backup-recommendations';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField'; import { BackupIncludeAttachmentsField } from './BackupIncludeAttachmentsField';
const MOBILE_RECOMMENDATIONS_QUERY = '(max-width: 760px)';
interface BackupOperationsSidebarProps { interface BackupOperationsSidebarProps {
disableWhileBusy: boolean; disableWhileBusy: boolean;
exporting: boolean; exporting: boolean;
@@ -18,7 +21,30 @@ interface BackupOperationsSidebarProps {
onSelectProvider: (providerId: string) => void; onSelectProvider: (providerId: string) => void;
} }
function getDefaultRecommendationsOpen() {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return true;
}
return !window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY).matches;
}
export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) { export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
const [recommendationsOpen, setRecommendationsOpen] = useState(getDefaultRecommendationsOpen);
const [recommendationsTouched, setRecommendationsTouched] = useState(false);
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function' || recommendationsTouched) {
return;
}
const media = window.matchMedia(MOBILE_RECOMMENDATIONS_QUERY);
const syncOpenState = () => setRecommendationsOpen(!media.matches);
syncOpenState();
media.addEventListener('change', syncOpenState);
return () => media.removeEventListener('change', syncOpenState);
}, [recommendationsTouched]);
return ( return (
<aside className="backup-operations-sidebar"> <aside className="backup-operations-sidebar">
<div className="section-head"> <div className="section-head">
@@ -41,7 +67,14 @@ export function BackupOperationsSidebar(props: BackupOperationsSidebarProps) {
</button> </button>
</div> </div>
<details className="backup-recommendations-disclosure"> <details
className="backup-recommendations-disclosure"
open={recommendationsOpen}
onToggle={(event) => {
setRecommendationsTouched(true);
setRecommendationsOpen((event.currentTarget as HTMLDetailsElement).open);
}}
>
<summary className="backup-recommendations-summary"> <summary className="backup-recommendations-summary">
<span> <span>
<strong>{t('txt_backup_recommend_title')}</strong> <strong>{t('txt_backup_recommend_title')}</strong>
@@ -1,4 +1,4 @@
import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact'; import { Download, FileArchive, FolderOpen, FolderUp, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact';
import type { RemoteBackupBrowserResponse } from '@/lib/api/backup'; import type { RemoteBackupBrowserResponse } from '@/lib/api/backup';
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center'; import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -38,14 +38,6 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
<div className="section-head"> <div className="section-head">
<h3>{t('txt_backup_remote_title')}</h3> <h3>{t('txt_backup_remote_title')}</h3>
{props.canBrowse ? (
<div className="actions">
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_backup_remote_refresh')}
</button>
</div>
) : null}
</div> </div>
{!props.destinationIsSaved ? ( {!props.destinationIsSaved ? (
@@ -59,7 +51,8 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
<span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span> <span>{props.remoteBrowser.currentPath ? `/${props.remoteBrowser.currentPath}` : '/'}</span>
</div> </div>
<div className="actions backup-browser-nav"> <div className="backup-browser-nav">
<div className="actions backup-browser-nav-left">
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}> <button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={() => props.onShowPath('')}>
<FolderOpen size={14} className="btn-icon" /> <FolderOpen size={14} className="btn-icon" />
{t('txt_backup_remote_root')} {t('txt_backup_remote_root')}
@@ -70,16 +63,29 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null} disabled={props.loadingRemoteBrowser || props.disableWhileBusy || props.remoteBrowser.parentPath === null}
onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')} onClick={() => props.onShowPath(props.remoteBrowser?.parentPath || '')}
> >
<RotateCcw size={14} className="btn-icon" /> <FolderUp size={14} className="btn-icon" />
{t('txt_backup_remote_up')} {t('txt_backup_remote_up')}
</button> </button>
</div> </div>
{props.canBrowse ? (
<button type="button" className="btn btn-secondary small" disabled={props.loadingRemoteBrowser || props.disableWhileBusy} onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
{t('txt_backup_remote_refresh')}
</button>
) : null}
</div>
{props.loadingRemoteBrowser ? ( {props.loadingRemoteBrowser ? (
<div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div> <div className="backup-browser-empty">{t('txt_backup_remote_loading')}</div>
) : props.remoteBrowser.items.length ? ( ) : props.remoteBrowser.items.length ? (
<> <>
<div className="backup-browser-list"> <div className="backup-browser-list">
<div className="backup-browser-head" aria-hidden="true">
<span>{t('txt_name')}</span>
<span>{t('txt_backup_remote_modified')}</span>
<span>{t('txt_backup_remote_size')}</span>
<span>{t('txt_actions')}</span>
</div>
{props.visibleItems.map((item) => ( {props.visibleItems.map((item) => (
<div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row"> <div key={`${item.isDirectory ? 'd' : 'f'}:${item.path}`} className="backup-browser-row">
<button <button
@@ -92,10 +98,12 @@ export function RemoteBackupBrowser(props: RemoteBackupBrowserProps) {
{item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />} {item.isDirectory ? <FolderOpen size={16} className="btn-icon" /> : <FileArchive size={16} className="btn-icon" />}
<span className="backup-browser-name">{item.name}</span> <span className="backup-browser-name">{item.name}</span>
</button> </button>
<div className="backup-browser-meta"> <span className="backup-browser-meta backup-browser-modified">
<span>{item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}</span> {item.modifiedAt ? formatDateTime(item.modifiedAt) : t('txt_backup_remote_unknown_time')}
<span>{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}</span> </span>
</div> <span className="backup-browser-meta backup-browser-size">
{item.isDirectory ? t('txt_backup_remote_folder') : formatBytes(item.size)}
</span>
<div className="actions backup-browser-actions"> <div className="actions backup-browser-actions">
{item.isDirectory ? ( {item.isDirectory ? (
<button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}> <button type="button" className="btn btn-secondary small" onClick={() => props.onShowPath(item.path)}>
+28 -3
View File
@@ -145,14 +145,30 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
} }
}, },
async enableTotp(secret: string, token: string) { async enableTotp(secret: string, token: string, masterPassword: string) {
if (!profile) {
const error = new Error(t('txt_profile_unavailable'));
onNotify('error', error.message);
throw error;
}
if (!secret.trim() || !token.trim()) { if (!secret.trim() || !token.trim()) {
const error = new Error(t('txt_secret_and_code_are_required')); const error = new Error(t('txt_secret_and_code_are_required'));
onNotify('error', error.message); onNotify('error', error.message);
throw error; throw error;
} }
if (!masterPassword) {
const error = new Error(t('txt_master_password_is_required'));
onNotify('error', error.message);
throw error;
}
try { try {
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() }); const derived = await deriveLoginHash(profile.email, masterPassword, defaultKdfIterations);
await setTotp(authedFetch, {
enabled: true,
secret: secret.trim(),
token: token.trim(),
masterPasswordHash: derived.hash,
});
onNotify('success', t('txt_totp_enabled')); onNotify('success', t('txt_totp_enabled'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
@@ -218,11 +234,19 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
const normalizedName = String(name || '').trim() || t('txt_account_passkey'); const normalizedName = String(name || '').trim() || t('txt_account_passkey');
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations); const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash); const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
const pending = await createAccountPasskeyCredential(options); const pending = await createAccountPasskeyCredential(options, directUnlock);
let keySet = null; let keySet = null;
let savedWithoutDirectUnlock = false; let savedWithoutDirectUnlock = false;
if (directUnlock) { if (directUnlock) {
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
if (!pending.supportsPrf) {
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
if (!shouldSaveLoginOnly) {
onNotify('warning', t('txt_account_passkey_not_saved'));
return null;
}
savedWithoutDirectUnlock = true;
} else {
try { try {
keySet = await buildAccountPasskeyPrfKeySet(pending, { keySet = await buildAccountPasskeyPrfKeySet(pending, {
symEncKey: session.symEncKey, symEncKey: session.symEncKey,
@@ -238,6 +262,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
savedWithoutDirectUnlock = true; savedWithoutDirectUnlock = true;
} }
} }
}
const credential = await saveAccountPasskey(authedFetch, { const credential = await saveAccountPasskey(authedFetch, {
name: normalizedName, name: normalizedName,
token: pending.token, token: pending.token,
+16 -15
View File
@@ -27,9 +27,10 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return useMemo( return useMemo(
() => ({ () => ({
async exportBackup(includeAttachments: boolean = false) { async exportBackup(masterPasswordHash: string, includeAttachments: boolean = false) {
const payload = await buildCompleteAdminBackupExport( const payload = await buildCompleteAdminBackupExport(
authedFetch, authedFetch,
masterPasswordHash,
includeAttachments, includeAttachments,
async (event: BackupExportClientProgressEvent) => { async (event: BackupExportClientProgressEvent) => {
dispatchBackupProgress(event); dispatchBackupProgress(event);
@@ -48,14 +49,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
}); });
}, },
async importBackup(file: File, replaceExisting: boolean = false) { async importBackup(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting); const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting);
onImported?.(); onImported?.();
return result; return result;
}, },
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) { async importBackupAllowingChecksumMismatch(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting, true); const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting, true);
onImported?.(); onImported?.();
return result; return result;
}, },
@@ -64,20 +65,20 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return getAdminBackupSettings(authedFetch); return getAdminBackupSettings(authedFetch);
}, },
async saveSettings(settings: Parameters<typeof saveAdminBackupSettings>[1]) { async saveSettings(masterPasswordHash: string, settings: Parameters<typeof saveAdminBackupSettings>[2]) {
return saveAdminBackupSettings(authedFetch, settings); return saveAdminBackupSettings(authedFetch, masterPasswordHash, settings);
}, },
async runRemoteBackup(destinationId?: string | null) { async runRemoteBackup(masterPasswordHash: string, destinationId?: string | null) {
return runAdminBackupNow(authedFetch, destinationId); return runAdminBackupNow(authedFetch, masterPasswordHash, destinationId);
}, },
async listRemoteBackups(destinationId: string, path: string) { async listRemoteBackups(destinationId: string, path: string) {
return listRemoteBackups(authedFetch, destinationId, path); return listRemoteBackups(authedFetch, destinationId, path);
}, },
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) { async downloadRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress); const payload = await fetchRemoteBackupPayload(authedFetch, masterPasswordHash, destinationId, path, onProgress);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
}, },
@@ -89,14 +90,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
await deleteRemoteBackup(authedFetch, destinationId, path); await deleteRemoteBackup(authedFetch, destinationId, path);
}, },
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) { async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting); const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting);
onRestored?.(); onRestored?.();
return result; return result;
}, },
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) { async restoreRemoteBackupAllowingChecksumMismatch(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true); const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting, true);
onRestored?.(); onRestored?.();
return result; return result;
}, },
+40 -3
View File
@@ -150,6 +150,22 @@ function shouldRetryWithLegacyPrf(error: unknown): boolean {
return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError'; return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError';
} }
function shouldRetryCreateWithoutPrf(error: unknown): boolean {
const name = error instanceof DOMException || error instanceof Error ? error.name : '';
const message = error instanceof DOMException || error instanceof Error ? error.message : '';
return (
name === 'NotSupportedError' ||
name === 'SyntaxError' ||
name === 'TypeError' ||
(name === 'UnknownError' && /transient/i.test(message))
);
}
async function canRequestPrfExtension(): Promise<boolean> {
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
return true;
}
async function getPublicKeyCredentialWithPrf( async function getPublicKeyCredentialWithPrf(
options: PublicKeyCredentialRequestOptions, options: PublicKeyCredentialRequestOptions,
salt: Uint8Array, salt: Uint8Array,
@@ -265,17 +281,38 @@ export async function assertAccountPasskey(
} }
export async function createAccountPasskeyCredential( export async function createAccountPasskeyCredential(
response: { options: unknown; token: string } response: { options: unknown; token: string },
requestPrf: boolean = false
): Promise<PendingAccountPasskeyCredential> { ): Promise<PendingAccountPasskeyCredential> {
if (!window.PublicKeyCredential || !navigator.credentials) { if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error(t('txt_passkey_browser_not_supported')); throw new Error(t('txt_passkey_browser_not_supported'));
} }
const nativeOptions = cloneCreationOptions(response.options); const nativeOptions = cloneCreationOptions(response.options);
(nativeOptions as any).extensions = { const createWithOptions = async (options: PublicKeyCredentialCreationOptions): Promise<PublicKeyCredential> => {
const credential = await navigator.credentials.create({ publicKey: options });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created'));
}
return credential;
};
let credential: PublicKeyCredential;
if (requestPrf && await canRequestPrfExtension()) {
const prfOptions: PublicKeyCredentialCreationOptions = {
...nativeOptions,
extensions: {
...((nativeOptions as any).extensions || {}), ...((nativeOptions as any).extensions || {}),
prf: {}, prf: {},
} as any,
}; };
const credential = await navigator.credentials.create({ publicKey: nativeOptions }); try {
credential = await createWithOptions(prfOptions);
} catch (error) {
if (!shouldRetryCreateWithoutPrf(error)) throw error;
credential = await createWithOptions(nativeOptions);
}
} else {
credential = await createWithOptions(nativeOptions);
}
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created')); throw new Error(t('txt_no_passkey_created'));
} }
+25 -10
View File
@@ -49,6 +49,11 @@ export interface BackupSettingsRepairStateResponse {
portable: BackupSettingsPortablePayload | null; portable: BackupSettingsPortablePayload | null;
} }
export interface BackupUserVerificationPayload {
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
}
export interface AdminBackupRunResponse { export interface AdminBackupRunResponse {
object: 'backup-run'; object: 'backup-run';
result: { result: {
@@ -173,12 +178,13 @@ async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array)
export async function exportAdminBackup( export async function exportAdminBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
includeAttachments: boolean = false includeAttachments: boolean = false
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const resp = await authedFetch('/api/admin/backup/export', { const resp = await authedFetch('/api/admin/backup/export', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ includeAttachments }), body: JSON.stringify({ includeAttachments, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
@@ -201,10 +207,11 @@ export async function downloadAdminBackupAttachmentBlob(
export async function buildCompleteAdminBackupExport( export async function buildCompleteAdminBackupExport(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
includeAttachments: boolean = false, includeAttachments: boolean = false,
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void> onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const payload = await exportAdminBackup(authedFetch, includeAttachments); const payload = await exportAdminBackup(authedFetch, masterPasswordHash, includeAttachments);
if (!includeAttachments) { if (!includeAttachments) {
await onProgress?.({ await onProgress?.({
operation: 'backup-export', operation: 'backup-export',
@@ -278,12 +285,13 @@ export async function getAdminBackupSettings(authedFetch: AuthedFetch): Promise<
export async function saveAdminBackupSettings( export async function saveAdminBackupSettings(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
settings: AdminBackupSettings settings: AdminBackupSettings
): Promise<AdminBackupSettings> { ): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings', { const resp = await authedFetch('/api/admin/backup/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings), body: JSON.stringify({ ...settings, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
const body = await parseJson<AdminBackupSettings>(resp); const body = await parseJson<AdminBackupSettings>(resp);
@@ -305,12 +313,13 @@ export async function getAdminBackupSettingsRepairState(
export async function repairAdminBackupSettings( export async function repairAdminBackupSettings(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
verification: BackupUserVerificationPayload,
settings: AdminBackupSettings settings: AdminBackupSettings
): Promise<AdminBackupSettings> { ): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings/repair', { const resp = await authedFetch('/api/admin/backup/settings/repair', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings), body: JSON.stringify({ ...settings, ...verification }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
const body = await parseJson<AdminBackupSettings>(resp); const body = await parseJson<AdminBackupSettings>(resp);
@@ -320,12 +329,13 @@ export async function repairAdminBackupSettings(
export async function runAdminBackupNow( export async function runAdminBackupNow(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId?: string | null destinationId?: string | null
): Promise<AdminBackupRunResponse> { ): Promise<AdminBackupRunResponse> {
const resp = await authedFetch('/api/admin/backup/run', { const resp = await authedFetch('/api/admin/backup/run', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(destinationId ? { destinationId } : {}), body: JSON.stringify(destinationId ? { destinationId, masterPasswordHash } : { masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed')));
const body = await parseJson<AdminBackupRunResponse>(resp); const body = await parseJson<AdminBackupRunResponse>(resp);
@@ -351,14 +361,16 @@ export async function listRemoteBackups(
export async function downloadRemoteBackup( export async function downloadRemoteBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string, destinationId: string,
path: string, path: string,
onProgress?: (percent: number | null) => void onProgress?: (percent: number | null) => void
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const params = new URLSearchParams(); const resp = await authedFetch('/api/admin/backup/remote/download', {
params.set('destinationId', destinationId); method: 'POST',
params.set('path', path); headers: { 'Content-Type': 'application/json' },
const resp = await authedFetch(`/api/admin/backup/remote/download?${params.toString()}`, { method: 'GET' }); body: JSON.stringify({ destinationId, path, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip'; const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip'); const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip');
@@ -418,6 +430,7 @@ export async function inspectRemoteBackupIntegrity(
export async function restoreRemoteBackup( export async function restoreRemoteBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string, destinationId: string,
path: string, path: string,
replaceExisting: boolean = false, replaceExisting: boolean = false,
@@ -426,7 +439,7 @@ export async function restoreRemoteBackup(
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, allowChecksumMismatch }), body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch, masterPasswordHash }),
}); });
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);
@@ -436,12 +449,14 @@ export async function restoreRemoteBackup(
export async function importAdminBackup( export async function importAdminBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
file: File, file: File,
replaceExisting: boolean = false, replaceExisting: boolean = false,
allowChecksumMismatch: 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');
formData.set('masterPasswordHash', masterPasswordHash);
if (replaceExisting) { if (replaceExisting) {
formData.set('replaceExisting', '1'); formData.set('replaceExisting', '1');
} }
+20 -7
View File
@@ -66,6 +66,12 @@ export interface CompletedLogin {
session: SessionState; session: SessionState;
profile: Profile; profile: Profile;
profilePromise: Promise<Profile>; profilePromise: Promise<Profile>;
freshMasterPasswordHash?: string | null;
freshUserVerificationToken?: string | null;
}
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
} }
export type PasswordLoginResult = export type PasswordLoginResult =
@@ -319,7 +325,8 @@ export async function completeLogin(
token: TokenSuccess, token: TokenSuccess,
email: string, email: string,
masterKey: Uint8Array, masterKey: Uint8Array,
fallbackKdfIterations: number fallbackKdfIterations: number,
freshMasterPasswordHash?: string | null
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail); const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -348,6 +355,8 @@ export async function completeLogin(
session: { ...baseSession, ...keys }, session: { ...baseSession, ...keys },
profile, profile,
profilePromise: getProfile(tempFetch), profilePromise: getProfile(tempFetch),
freshMasterPasswordHash: freshMasterPasswordHash || null,
freshUserVerificationToken: readTokenUserVerificationToken(token),
}; };
} }
@@ -360,7 +369,8 @@ async function completeLoginWithVaultKeys(
token: TokenSuccess, token: TokenSuccess,
email: string, email: string,
keys: { symEncKey: string; symMacKey: string }, keys: { symEncKey: string; symMacKey: string },
fallbackKdfIterations: number fallbackKdfIterations: number,
freshMasterPasswordHash?: string | null
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail); const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -385,6 +395,8 @@ async function completeLoginWithVaultKeys(
session: { ...baseSession, ...keys }, session: { ...baseSession, ...keys },
profile, profile,
profilePromise: getProfile(tempFetch), profilePromise: getProfile(tempFetch),
freshMasterPasswordHash: freshMasterPasswordHash || null,
freshUserVerificationToken: readTokenUserVerificationToken(token),
}; };
} }
@@ -400,7 +412,7 @@ export async function performPasswordLogin(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
kind: 'success', kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
}; };
} }
@@ -476,7 +488,7 @@ export async function completePasskeyPasswordLogin(
password: string password: string
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations); const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations); return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations, derived.hash);
} }
export async function performTotpLogin( export async function performTotpLogin(
@@ -489,7 +501,7 @@ export async function performTotpLogin(
rememberDevice, rememberDevice,
}); });
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations); return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
} }
const tokenError = token as { error_description?: string; error?: string }; const tokenError = token as { error_description?: string; error?: string };
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed'))); throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
@@ -508,7 +520,7 @@ export async function performRecoverTwoFactorLogin(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
newRecoveryCode: recovered.newRecoveryCode || null, newRecoveryCode: recovered.newRecoveryCode || null,
}; };
} }
@@ -557,6 +569,7 @@ export async function performUnlock(
session: offline.session, session: offline.session,
profile: offline.profile, profile: offline.profile,
profilePromise: Promise.resolve(offline.profile), profilePromise: Promise.resolve(offline.profile),
freshMasterPasswordHash: null,
}, },
}; };
} catch { } catch {
@@ -589,7 +602,7 @@ export async function performUnlock(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
kind: 'success', kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
}; };
} }
+47 -2
View File
@@ -4,7 +4,7 @@ export interface RecommendedStorageLink {
} }
export interface RecommendedProviderBase { export interface RecommendedProviderBase {
id: 'infinicloud' | 'koofr' | 'pcloud'; id: 'infinicloud' | 'koofr' | 'pcloud' | 'backblaze-b2' | 'cloudflare-r2' | 'tigris';
name: string; name: string;
capacity: string; capacity: string;
protocol: 'webdav' | 's3'; protocol: 'webdav' | 's3';
@@ -28,7 +28,25 @@ export interface PcloudProvider extends RecommendedProviderBase {
id: 'pcloud'; id: 'pcloud';
} }
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider; export interface BackblazeB2Provider extends RecommendedProviderBase {
id: 'backblaze-b2';
bucketsUrl: string;
applicationKeysUrl: string;
}
export interface CloudflareR2Provider extends RecommendedProviderBase {
id: 'cloudflare-r2';
bucketUrl: string;
apiTokenUrl: string;
}
export interface TigrisProvider extends RecommendedProviderBase {
id: 'tigris';
bucketUrl: string;
accessKeyUrl: string;
}
export type RecommendedProvider = InfinicloudProvider | KoofrProvider | PcloudProvider | BackblazeB2Provider | CloudflareR2Provider | TigrisProvider;
export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [ export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
{ {
@@ -61,6 +79,33 @@ export const RECOMMENDED_PROVIDERS: RecommendedProvider[] = [
signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7', signupUrl: 'https://u.pcloud.com/#/register?invite=GITx7ZvEU1N7',
hasAffiliateLink: true, hasAffiliateLink: true,
}, },
{
id: 'backblaze-b2',
name: 'Backblaze B2',
capacity: '10G',
protocol: 's3',
signupUrl: 'https://secure.backblaze.com/user_signin.htm',
bucketsUrl: 'https://secure.backblaze.com/b2_buckets.htm',
applicationKeysUrl: 'https://secure.backblaze.com/app_keys.htm',
},
{
id: 'cloudflare-r2',
name: 'Cloudflare R2',
capacity: '10G',
protocol: 's3',
signupUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
bucketUrl: 'https://dash.cloudflare.com/?to=/:account/r2/new',
apiTokenUrl: 'https://dash.cloudflare.com/?to=/:account/r2/api-tokens/create?type=user',
},
{
id: 'tigris',
name: 'Tigris',
capacity: '5G',
protocol: 's3',
signupUrl: 'https://console.storage.dev/signup',
bucketUrl: 'https://console.storage.dev/createbucket',
accessKeyUrl: 'https://console.storage.dev/createaccesskey',
},
]; ];
export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider { export function hasLinkedStorages(provider: RecommendedProvider): provider is KoofrProvider {
+4 -2
View File
@@ -5,7 +5,8 @@ import type { Profile, SessionState } from './types';
export async function silentlyRepairBackupSettingsIfNeeded( export async function silentlyRepairBackupSettingsIfNeeded(
activeSession: SessionState, activeSession: SessionState,
activeProfile: Profile activeProfile: Profile,
verification?: { masterPasswordHash?: string | null; userVerificationToken?: string | null } | null
): Promise<void> { ): Promise<void> {
if (activeProfile.role !== 'admin') return; if (activeProfile.role !== 'admin') return;
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return; if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
@@ -14,8 +15,9 @@ export async function silentlyRepairBackupSettingsIfNeeded(
try { try {
const state = await getAdminBackupSettingsRepairState(tempFetch); const state = await getAdminBackupSettingsRepairState(tempFetch);
if (!state.needsRepair || !state.portable) return; if (!state.needsRepair || !state.portable) return;
if (!verification?.masterPasswordHash && !verification?.userVerificationToken) return;
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession); const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
await repairAdminBackupSettings(tempFetch, repairedSettings); await repairAdminBackupSettings(tempFetch, verification, repairedSettings);
} catch (error) { } catch (error) {
console.error('Backup settings auto-repair failed:', error); console.error('Backup settings auto-repair failed:', error);
} }
+8 -8
View File
@@ -1156,32 +1156,32 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
notify('success', t('txt_logs_cleared')); notify('success', t('txt_logs_cleared'));
return 0; return 0;
}, },
onExportBackup: async () => { onExportBackup: async (_masterPassword: string) => {
notify('success', t('txt_backup_export_success')); notify('success', t('txt_backup_export_success'));
}, },
onImportBackup: async () => { onImportBackup: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_import_success_relogin')); notify('success', t('txt_backup_import_success_relogin'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onImportBackupAllowingChecksumMismatch: async () => { onImportBackupAllowingChecksumMismatch: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_import_success_relogin')); notify('success', t('txt_backup_import_success_relogin'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onLoadBackupSettings: async () => state.backupSettings, onLoadBackupSettings: async () => state.backupSettings,
onSaveBackupSettings: async (settings) => { onSaveBackupSettings: async (_masterPassword: string, settings) => {
const next = cloneJson(settings); const next = cloneJson(settings);
state.setBackupSettings(next); state.setBackupSettings(next);
notify('success', t('txt_backup_settings_saved')); notify('success', t('txt_backup_settings_saved'));
return next; return next;
}, },
onRunRemoteBackup: async (destinationId?: string | null) => { onRunRemoteBackup: async (_masterPassword: string, destinationId?: string | null) => {
notify('success', t('txt_backup_remote_run_success')); notify('success', t('txt_backup_remote_run_success'));
return createDemoBackupRun(state.backupSettings, destinationId); return createDemoBackupRun(state.backupSettings, destinationId);
}, },
onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path), onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path),
onDownloadRemoteBackup: async () => { onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
notify('success', t('txt_demo_download_prepared')); notify('success', t('txt_demo_download_prepared'));
}, },
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({ onInspectRemoteBackup: async (_destinationId: string, path: string) => ({
@@ -1199,13 +1199,13 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onDeleteRemoteBackup: async () => { onDeleteRemoteBackup: async () => {
notify('success', t('txt_backup_remote_delete_success')); notify('success', t('txt_backup_remote_delete_success'));
}, },
onRestoreRemoteBackup: async (_destinationId, path) => { onRestoreRemoteBackup: async (_masterPassword: string, _destinationId, path) => {
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip'); await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_remote_restore_completed_verified')); notify('success', t('txt_backup_remote_restore_completed_verified'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onRestoreRemoteBackupAllowingChecksumMismatch: async (_destinationId, path) => { onRestoreRemoteBackupAllowingChecksumMismatch: async (_masterPassword: string, _destinationId, path) => {
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip'); await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_remote_restore_completed_verified')); notify('success', t('txt_backup_remote_restore_completed_verified'));
+33
View File
@@ -85,6 +85,37 @@ const en: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "Register a pCloud account with just your email address.", "txt_backup_recommend_pcloud_step_1": "Register a pCloud account with just your email address.",
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ as the WebDAV server URL.", "txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ as the WebDAV server URL.",
"txt_backup_recommend_pcloud_step_3": "Use your registration email as the WebDAV username and your account password as the WebDAV password.", "txt_backup_recommend_pcloud_step_3": "Use your registration email as the WebDAV username and your account password as the WebDAV password.",
"txt_backup_recommend_backblaze_summary": "S3-compatible object storage with 10 GB free and no credit card required.",
"txt_backup_recommend_backblaze_step_1": "Register or sign in to a Backblaze account.",
"txt_backup_recommend_backblaze_step_2_prefix": "Open",
"txt_backup_recommend_backblaze_step_2_suffix": ", click Create a Bucket, enter only the bucket name, leave the other settings unchanged, and create it.",
"txt_backup_recommend_backblaze_step_3": "After creation, put the displayed Endpoint into S3 Endpoint URL, use the bucket name for Bucket Name, and use the middle segment of the endpoint, such as us-west-004, for Region.",
"txt_backup_recommend_backblaze_step_4_prefix": "Open",
"txt_backup_recommend_backblaze_step_4_suffix": ", click Add a New Application Key, enter any Name of Key, leave the other settings unchanged, and create it.",
"txt_backup_recommend_backblaze_step_5": "Use keyID as the access key and applicationKey as the secret key.",
"txt_backup_recommend_cloudflare_r2_summary": "S3-compatible object storage with 10 GB free, but it requires credit card verification.",
"txt_backup_recommend_cloudflare_r2_bucket_link": "Create bucket page",
"txt_backup_recommend_cloudflare_r2_api_link": "API token page",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Open the",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", enter only the bucket name, and create it directly.",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Open the",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", select Object Read & Write for permissions, and create it directly.",
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore the token value after creation. Fill Access Key ID into Access ID, and Secret Access Key into Access Password.",
"txt_backup_recommend_cloudflare_r2_step_4": "Copy the address shown below into S3 Endpoint URL, fill Bucket Name exactly as shown, and leave Region as auto.",
"txt_backup_recommend_cloudflare_r2_step_5": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
"txt_backup_recommend_s3_path_prefix_step": "Set Path Prefix as needed, for example nodewarden, or leave it empty if you do not want a folder prefix.",
"txt_backup_recommend_tigris_summary": "S3-compatible object storage with 5 GB free and no credit card required.",
"txt_backup_recommend_tigris_signup_link": "signup page",
"txt_backup_recommend_tigris_bucket_link": "Create Bucket page",
"txt_backup_recommend_tigris_access_key_link": "Create Access Key page",
"txt_backup_recommend_tigris_step_1_prefix": "Open the",
"txt_backup_recommend_tigris_step_1_suffix": ", sign up, and log in to Tigris.",
"txt_backup_recommend_tigris_step_2_prefix": "Open",
"txt_backup_recommend_tigris_step_2_suffix": ", enter only the bucket name, leave everything else unchanged, and create it.",
"txt_backup_recommend_tigris_step_3_prefix": "Then open the",
"txt_backup_recommend_tigris_step_3_suffix": ", use any name you like, and create it.",
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM after creation. Fill the other displayed values into the backup page using the matching field names.",
"txt_backup_recommend_tigris_step_5": "Finally, click Manage Key Permissions and turn on Admin Access, otherwise writing backups will fail.",
"txt_backup_add_destination": "Add Destination", "txt_backup_add_destination": "Add Destination",
"txt_backup_schedule_panel_title": "Automatic Schedule", "txt_backup_schedule_panel_title": "Automatic Schedule",
"txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.", "txt_backup_schedule_panel_note": "Each destination can keep its own daily backup schedule.",
@@ -197,6 +228,8 @@ const en: Record<string, string> = {
"txt_backup_remote_folder": "Folder", "txt_backup_remote_folder": "Folder",
"txt_backup_remote_unknown_time": "Unknown time", "txt_backup_remote_unknown_time": "Unknown time",
"txt_backup_remote_current_path": "Current Folder", "txt_backup_remote_current_path": "Current Folder",
"txt_backup_remote_modified": "Modified",
"txt_backup_remote_size": "Size",
"txt_backup_remote_load_failed": "Loading remote backups failed", "txt_backup_remote_load_failed": "Loading remote backups failed",
"txt_backup_remote_invalid_response": "Invalid remote backup response", "txt_backup_remote_invalid_response": "Invalid remote backup response",
"txt_backup_remote_download_failed": "Downloading remote backup failed", "txt_backup_remote_download_failed": "Downloading remote backup failed",
+33
View File
@@ -85,6 +85,37 @@ const es: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.", "txt_backup_recommend_pcloud_step_1": "Registre una cuenta pCloud solo con su dirección de correo.",
"txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.", "txt_backup_recommend_pcloud_step_2": "Use https://webdav.pcloud.com/ como URL del servidor WebDAV.",
"txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.", "txt_backup_recommend_pcloud_step_3": "Use su correo de registro como nombre de usuario WebDAV y su contraseña de cuenta como contraseña WebDAV.",
"txt_backup_recommend_backblaze_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis y sin tarjeta de crédito.",
"txt_backup_recommend_backblaze_step_1": "Registre o inicie sesión en una cuenta de Backblaze.",
"txt_backup_recommend_backblaze_step_2_prefix": "Abra",
"txt_backup_recommend_backblaze_step_2_suffix": ", haga clic en Create a Bucket, introduzca solo el nombre del bucket, deje lo demás sin cambios y créelo.",
"txt_backup_recommend_backblaze_step_3": "Después de crearlo, ponga el Endpoint mostrado en S3 Endpoint URL, use el nombre del bucket en Bucket Name y la parte central del endpoint, como us-west-004, en Region.",
"txt_backup_recommend_backblaze_step_4_prefix": "Abra",
"txt_backup_recommend_backblaze_step_4_suffix": ", haga clic en Add a New Application Key, introduzca cualquier Name of Key, deje lo demás sin cambios y créelo.",
"txt_backup_recommend_backblaze_step_5": "Use keyID como clave de acceso y applicationKey como clave secreta.",
"txt_backup_recommend_cloudflare_r2_summary": "Almacenamiento de objetos compatible con S3 con 10 GB gratis, pero requiere verificación con tarjeta de crédito.",
"txt_backup_recommend_cloudflare_r2_bucket_link": "página para crear bucket",
"txt_backup_recommend_cloudflare_r2_api_link": "página de token API",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Abra la",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", introduzca solo el nombre del bucket y créelo directamente.",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Abra la",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", seleccione Object Read & Write en permisos y créelo directamente.",
"txt_backup_recommend_cloudflare_r2_step_3": "Ignore el valor del token después de crearlo. Use Access Key ID como ID de acceso y Secret Access Key como contraseña de acceso.",
"txt_backup_recommend_cloudflare_r2_step_4": "Copie la dirección mostrada abajo en S3 Endpoint URL, rellene Bucket Name tal como aparece y deje Region en auto.",
"txt_backup_recommend_cloudflare_r2_step_5": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
"txt_backup_recommend_s3_path_prefix_step": "Configure Path Prefix si lo necesita, por ejemplo nodewarden, o déjelo vacío si no quiere un prefijo de carpeta.",
"txt_backup_recommend_tigris_summary": "Almacenamiento de objetos compatible con S3 con 5 GB gratis y sin tarjeta de crédito.",
"txt_backup_recommend_tigris_signup_link": "página de registro",
"txt_backup_recommend_tigris_bucket_link": "página Create Bucket",
"txt_backup_recommend_tigris_access_key_link": "página Create Access Key",
"txt_backup_recommend_tigris_step_1_prefix": "Abra la",
"txt_backup_recommend_tigris_step_1_suffix": ", regístrese e inicie sesión en Tigris.",
"txt_backup_recommend_tigris_step_2_prefix": "Abra",
"txt_backup_recommend_tigris_step_2_suffix": ", introduzca solo el nombre del bucket, deje todo lo demás sin cambios y créelo.",
"txt_backup_recommend_tigris_step_3_prefix": "Luego abra la",
"txt_backup_recommend_tigris_step_3_suffix": ", use cualquier nombre y créela.",
"txt_backup_recommend_tigris_step_4": "Ignore Endpoint URL IAM después de crearla. Rellene los demás valores mostrados en la página de copia de seguridad usando los nombres correspondientes.",
"txt_backup_recommend_tigris_step_5": "Por último, haga clic en Manage Key Permissions y active Admin Access; de lo contrario, no podrá escribir copias de seguridad.",
"txt_backup_add_destination": "Añadir destino", "txt_backup_add_destination": "Añadir destino",
"txt_backup_schedule_panel_title": "Programación automática", "txt_backup_schedule_panel_title": "Programación automática",
"txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.", "txt_backup_schedule_panel_note": "Cada destino puede mantener su propia programación de copia de seguridad diaria.",
@@ -197,6 +228,8 @@ const es: Record<string, string> = {
"txt_backup_remote_folder": "Carpeta", "txt_backup_remote_folder": "Carpeta",
"txt_backup_remote_unknown_time": "Hora desconocida", "txt_backup_remote_unknown_time": "Hora desconocida",
"txt_backup_remote_current_path": "Carpeta actual", "txt_backup_remote_current_path": "Carpeta actual",
"txt_backup_remote_modified": "Modificado",
"txt_backup_remote_size": "Tamaño",
"txt_backup_remote_load_failed": "Error al cargar copias de seguridad remotas", "txt_backup_remote_load_failed": "Error al cargar copias de seguridad remotas",
"txt_backup_remote_invalid_response": "Respuesta de copia de seguridad remota no válida", "txt_backup_remote_invalid_response": "Respuesta de copia de seguridad remota no válida",
"txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota", "txt_backup_remote_download_failed": "Error al descargar copia de seguridad remota",
+33
View File
@@ -86,6 +86,37 @@ const ru: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.", "txt_backup_recommend_pcloud_step_1": "Зарегистрируйте учетную запись pCloud, используя только свой адрес электронной почты.",
"txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.", "txt_backup_recommend_pcloud_step_2": "Используйте https://webdav.ploud.com/ в качестве URL-адреса сервера WebDAV.",
"txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.", "txt_backup_recommend_pcloud_step_3": "Используйте свой регистрационный адрес электронной почты в качестве имени пользователя WebDAV и пароль своей учетной записи в качестве пароля WebDAV.",
"txt_backup_recommend_backblaze_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ и без кредитной карты.",
"txt_backup_recommend_backblaze_step_1": "Зарегистрируйте учетную запись Backblaze или войдите в нее.",
"txt_backup_recommend_backblaze_step_2_prefix": "Откройте",
"txt_backup_recommend_backblaze_step_2_suffix": ", нажмите Create a Bucket, введите только имя bucket, оставьте остальные настройки без изменений и создайте его.",
"txt_backup_recommend_backblaze_step_3": "После создания вставьте показанный Endpoint в S3 Endpoint URL, имя bucket укажите в Bucket Name, а среднюю часть endpoint, например us-west-004, используйте как Region.",
"txt_backup_recommend_backblaze_step_4_prefix": "Откройте",
"txt_backup_recommend_backblaze_step_4_suffix": ", нажмите Add a New Application Key, введите любое Name of Key, оставьте остальные настройки без изменений и создайте ключ.",
"txt_backup_recommend_backblaze_step_5": "Используйте keyID как ключ доступа, а applicationKey как секретный ключ.",
"txt_backup_recommend_cloudflare_r2_summary": "S3-совместимое объектное хранилище с бесплатными 10 ГБ, но с обязательной проверкой кредитной карты.",
"txt_backup_recommend_cloudflare_r2_bucket_link": "страницу создания bucket",
"txt_backup_recommend_cloudflare_r2_api_link": "страницу API token",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "Откройте",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ", введите только имя bucket и сразу создайте его.",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "Откройте",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ", выберите Object Read & Write в разрешениях и сразу создайте токен.",
"txt_backup_recommend_cloudflare_r2_step_3": "После создания игнорируйте token value. Введите Access Key ID как ID доступа, а Secret Access Key как пароль доступа.",
"txt_backup_recommend_cloudflare_r2_step_4": "Вставьте показанный ниже адрес в S3 Endpoint URL, заполните Bucket Name как показано и оставьте Region в значении auto.",
"txt_backup_recommend_cloudflare_r2_step_5": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
"txt_backup_recommend_s3_path_prefix_step": "Укажите Path Prefix при необходимости, например nodewarden, или оставьте пустым, если префикс папки не нужен.",
"txt_backup_recommend_tigris_summary": "S3-совместимое объектное хранилище с бесплатными 5 ГБ и без кредитной карты.",
"txt_backup_recommend_tigris_signup_link": "страницу регистрации",
"txt_backup_recommend_tigris_bucket_link": "страницу Create Bucket",
"txt_backup_recommend_tigris_access_key_link": "страницу Create Access Key",
"txt_backup_recommend_tigris_step_1_prefix": "Откройте",
"txt_backup_recommend_tigris_step_1_suffix": ", зарегистрируйтесь и войдите в Tigris.",
"txt_backup_recommend_tigris_step_2_prefix": "Откройте",
"txt_backup_recommend_tigris_step_2_suffix": ", введите только имя bucket, ничего больше не меняйте и создайте его.",
"txt_backup_recommend_tigris_step_3_prefix": "Затем откройте",
"txt_backup_recommend_tigris_step_3_suffix": ", введите любое имя и создайте ключ.",
"txt_backup_recommend_tigris_step_4": "После создания игнорируйте Endpoint URL IAM. Остальные показанные значения заполните на странице резервного копирования по совпадающим названиям полей.",
"txt_backup_recommend_tigris_step_5": "В конце нажмите Manage Key Permissions и включите Admin Access, иначе запись резервных копий не будет работать.",
"txt_backup_add_destination": "Добавить пункт назначения", "txt_backup_add_destination": "Добавить пункт назначения",
"txt_backup_schedule_panel_title": "Автоматическое расписание", "txt_backup_schedule_panel_title": "Автоматическое расписание",
"txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.", "txt_backup_schedule_panel_note": "Каждый пункт назначения может иметь собственный ежедневный график резервного копирования.",
@@ -197,6 +228,8 @@ const ru: Record<string, string> = {
"txt_backup_remote_folder": "Папка", "txt_backup_remote_folder": "Папка",
"txt_backup_remote_unknown_time": "Неизвестное время", "txt_backup_remote_unknown_time": "Неизвестное время",
"txt_backup_remote_current_path": "Текущая папка", "txt_backup_remote_current_path": "Текущая папка",
"txt_backup_remote_modified": "Изменено",
"txt_backup_remote_size": "Размер",
"txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.", "txt_backup_remote_load_failed": "Не удалось загрузить удаленные резервные копии.",
"txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования", "txt_backup_remote_invalid_response": "Неверный ответ удаленного резервного копирования",
"txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.", "txt_backup_remote_download_failed": "Не удалось загрузить удаленную резервную копию.",
+39 -6
View File
@@ -85,6 +85,37 @@ const zhCN: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。", "txt_backup_recommend_pcloud_step_1": "先用邮箱注册一个 pCloud 账号。",
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。", "txt_backup_recommend_pcloud_step_2": "WebDAV 地址填写 https://webdav.pcloud.com/ 。",
"txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。", "txt_backup_recommend_pcloud_step_3": "注册邮箱用作 WebDAV 用户名,注册密码用作 WebDAV 密码。",
"txt_backup_recommend_backblaze_summary": "兼容 S3 的对象存储,免费容量 10 GB,无需信用卡。",
"txt_backup_recommend_backblaze_step_1": "先注册或登录 Backblaze 账号。",
"txt_backup_recommend_backblaze_step_2_prefix": "打开",
"txt_backup_recommend_backblaze_step_2_suffix": ",点击创建一个桶,只输入桶名字,其他地方不修改,然后创建。",
"txt_backup_recommend_backblaze_step_3": "创建后显示的 Endpoint 填到 S3 端点 URL;桶名字填到存储桶名称;区域填 Endpoint 中间那段,例如 us-west-004。",
"txt_backup_recommend_backblaze_step_4_prefix": "打开",
"txt_backup_recommend_backblaze_step_4_suffix": ",点击 Add a New Application Key,随便输入 Name of Key,其他地方不动,然后创建。",
"txt_backup_recommend_backblaze_step_5": "生成结果里的 keyID 填到 访问 IDapplicationKey 填到 访问密码。",
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的对象存储,免费容量 10 GB,需要信用卡认证。",
"txt_backup_recommend_cloudflare_r2_bucket_link": "创建储存桶页面",
"txt_backup_recommend_cloudflare_r2_api_link": "API 创建页面",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打开",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只输入存储桶名称,直接创建。",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打开",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",权限全选“对象读和写”,直接创建。",
"txt_backup_recommend_cloudflare_r2_step_3": "创建后令牌值不用管;Access Key ID 填到 访问 IDSecret Access Key 填到 访问密码。",
"txt_backup_recommend_cloudflare_r2_step_4": "把下面显示的地址填到 S3 端点 URL;存储桶名称如实填写;区域保持 auto 不改。",
"txt_backup_recommend_cloudflare_r2_step_5": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
"txt_backup_recommend_s3_path_prefix_step": "路径前缀按需要填写,例如 nodewarden;不想分目录可以留空。",
"txt_backup_recommend_tigris_summary": "兼容 S3 的对象存储。免费容量 5 GB,无需信用卡。",
"txt_backup_recommend_tigris_signup_link": "注册页面",
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 页面",
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 页面",
"txt_backup_recommend_tigris_step_1_prefix": "打开",
"txt_backup_recommend_tigris_step_1_suffix": ",注册并登录 Tigris。",
"txt_backup_recommend_tigris_step_2_prefix": "打开",
"txt_backup_recommend_tigris_step_2_suffix": ",只输入桶的名字,其他地方不动,直接创建。",
"txt_backup_recommend_tigris_step_3_prefix": "然后打开",
"txt_backup_recommend_tigris_step_3_suffix": ",名字随意,直接创建。",
"txt_backup_recommend_tigris_step_4": "创建后显示的 Endpoint URL IAM 不用管;其余显示出来的内容按名称填写到备份页面里。",
"txt_backup_recommend_tigris_step_5": "最后点击 Manage Key Permissions,把 Admin Access 打开,否则无法写入。",
"txt_backup_add_destination": "新增地点", "txt_backup_add_destination": "新增地点",
"txt_backup_schedule_panel_title": "自动备份计划", "txt_backup_schedule_panel_title": "自动备份计划",
"txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。", "txt_backup_schedule_panel_note": "每个备份地点都可以单独配置自己的每日自动备份计划。",
@@ -197,6 +228,8 @@ const zhCN: Record<string, string> = {
"txt_backup_remote_folder": "文件夹", "txt_backup_remote_folder": "文件夹",
"txt_backup_remote_unknown_time": "未知时间", "txt_backup_remote_unknown_time": "未知时间",
"txt_backup_remote_current_path": "当前目录", "txt_backup_remote_current_path": "当前目录",
"txt_backup_remote_modified": "修改时间",
"txt_backup_remote_size": "大小",
"txt_backup_remote_load_failed": "读取远端备份失败", "txt_backup_remote_load_failed": "读取远端备份失败",
"txt_backup_remote_invalid_response": "远端备份响应无效", "txt_backup_remote_invalid_response": "远端备份响应无效",
"txt_backup_remote_download_failed": "下载远端备份失败", "txt_backup_remote_download_failed": "下载远端备份失败",
@@ -266,15 +299,15 @@ const zhCN: Record<string, string> = {
"txt_backup_webdav_username": "WebDAV 用户名", "txt_backup_webdav_username": "WebDAV 用户名",
"txt_backup_webdav_password": "WebDAV 密码", "txt_backup_webdav_password": "WebDAV 密码",
"txt_backup_webdav_path": "远程目录", "txt_backup_webdav_path": "远程目录",
"txt_backup_s3_endpoint": "S3 端点", "txt_backup_s3_endpoint": "S3 端点 URL",
"txt_backup_s3_addressing_style": "S3 寻址方式", "txt_backup_s3_addressing_style": "寻址方式",
"txt_backup_s3_addressing_path_style": "path-style(默认)", "txt_backup_s3_addressing_path_style": "path-style(默认)",
"txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style", "txt_backup_s3_addressing_virtual_hosted_style": "virtual-hosted-style",
"txt_backup_s3_bucket": "存储桶", "txt_backup_s3_bucket": "存储桶名称",
"txt_backup_s3_region": "区域", "txt_backup_s3_region": "区域",
"txt_backup_s3_access_key": "访问密钥", "txt_backup_s3_access_key": "访问 ID",
"txt_backup_s3_secret_key": "秘密密钥", "txt_backup_s3_secret_key": "访问密码",
"txt_backup_s3_path": "远程路径", "txt_backup_s3_path": "路径前缀",
"txt_backup_reserved_name": "预留类型名称", "txt_backup_reserved_name": "预留类型名称",
"txt_backup_reserved_notes": "预留备注", "txt_backup_reserved_notes": "预留备注",
"txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明", "txt_backup_reserved_notes_placeholder": "给下一个备份地点先留个说明",
+33
View File
@@ -85,6 +85,37 @@ const zhTW: Record<string, string> = {
"txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。", "txt_backup_recommend_pcloud_step_1": "先用郵箱註冊一個 pCloud 賬號。",
"txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。", "txt_backup_recommend_pcloud_step_2": "WebDAV 地址填寫 https://webdav.pcloud.com/ 。",
"txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。", "txt_backup_recommend_pcloud_step_3": "註冊郵箱用作 WebDAV 用戶名,註冊密碼用作 WebDAV 密碼。",
"txt_backup_recommend_backblaze_summary": "兼容 S3 的對象儲存,免費容量 10 GB,無需信用卡。",
"txt_backup_recommend_backblaze_step_1": "先註冊或登入 Backblaze 賬號。",
"txt_backup_recommend_backblaze_step_2_prefix": "打開",
"txt_backup_recommend_backblaze_step_2_suffix": ",點擊創建一個桶,只輸入桶名字,其他地方不修改,然後創建。",
"txt_backup_recommend_backblaze_step_3": "創建後顯示的 Endpoint 填到 S3 端點 URL;桶名字填到儲存桶名稱;區域填 Endpoint 中間那段,例如 us-west-004。",
"txt_backup_recommend_backblaze_step_4_prefix": "打開",
"txt_backup_recommend_backblaze_step_4_suffix": ",點擊 Add a New Application Key,隨便輸入 Name of Key,其他地方不動,然後創建。",
"txt_backup_recommend_backblaze_step_5": "生成結果裡的 keyID 填存取金鑰,applicationKey 填秘密金鑰。",
"txt_backup_recommend_cloudflare_r2_summary": "兼容 S3 的對象儲存,免費容量 10 GB,需要信用卡驗證。",
"txt_backup_recommend_cloudflare_r2_bucket_link": "創建儲存桶頁面",
"txt_backup_recommend_cloudflare_r2_api_link": "API 創建頁面",
"txt_backup_recommend_cloudflare_r2_step_1_prefix": "打開",
"txt_backup_recommend_cloudflare_r2_step_1_suffix": ",只輸入儲存桶名稱,直接創建。",
"txt_backup_recommend_cloudflare_r2_step_2_prefix": "打開",
"txt_backup_recommend_cloudflare_r2_step_2_suffix": ",權限全選「對象讀和寫」,直接創建。",
"txt_backup_recommend_cloudflare_r2_step_3": "創建後令牌值不用管;Access Key ID 填到存取 IDSecret Access Key 填到存取密碼。",
"txt_backup_recommend_cloudflare_r2_step_4": "把下面顯示的地址填到 S3 端點 URL;儲存桶名稱如實填寫;區域保持 auto 不改。",
"txt_backup_recommend_cloudflare_r2_step_5": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
"txt_backup_recommend_s3_path_prefix_step": "路徑前綴按需要填寫,例如 nodewarden;不想分目錄可以留空。",
"txt_backup_recommend_tigris_summary": "兼容 S3 的對象儲存。免費容量 5 GB,無需信用卡。",
"txt_backup_recommend_tigris_signup_link": "註冊頁面",
"txt_backup_recommend_tigris_bucket_link": "Create Bucket 頁面",
"txt_backup_recommend_tigris_access_key_link": "Create Access Key 頁面",
"txt_backup_recommend_tigris_step_1_prefix": "打開",
"txt_backup_recommend_tigris_step_1_suffix": ",註冊並登入 Tigris。",
"txt_backup_recommend_tigris_step_2_prefix": "打開",
"txt_backup_recommend_tigris_step_2_suffix": ",只輸入桶的名字,其他地方不動,直接創建。",
"txt_backup_recommend_tigris_step_3_prefix": "然後打開",
"txt_backup_recommend_tigris_step_3_suffix": ",名字隨意,直接創建。",
"txt_backup_recommend_tigris_step_4": "創建後顯示的 Endpoint URL IAM 不用管;其餘顯示出來的內容按名稱填寫到備份頁面裡。",
"txt_backup_recommend_tigris_step_5": "最後點擊 Manage Key Permissions,把 Admin Access 打開,否則無法寫入。",
"txt_backup_add_destination": "新增地點", "txt_backup_add_destination": "新增地點",
"txt_backup_schedule_panel_title": "自動備份計劃", "txt_backup_schedule_panel_title": "自動備份計劃",
"txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。", "txt_backup_schedule_panel_note": "每個備份地點都可以單獨配置自己的每日自動備份計劃。",
@@ -197,6 +228,8 @@ const zhTW: Record<string, string> = {
"txt_backup_remote_folder": "文件夾", "txt_backup_remote_folder": "文件夾",
"txt_backup_remote_unknown_time": "未知時間", "txt_backup_remote_unknown_time": "未知時間",
"txt_backup_remote_current_path": "當前目錄", "txt_backup_remote_current_path": "當前目錄",
"txt_backup_remote_modified": "修改時間",
"txt_backup_remote_size": "大小",
"txt_backup_remote_load_failed": "讀取遠端備份失敗", "txt_backup_remote_load_failed": "讀取遠端備份失敗",
"txt_backup_remote_invalid_response": "遠端備份響應無效", "txt_backup_remote_invalid_response": "遠端備份響應無效",
"txt_backup_remote_download_failed": "下載遠端備份失敗", "txt_backup_remote_download_failed": "下載遠端備份失敗",
+2
View File
@@ -314,6 +314,8 @@ export interface TokenSuccess {
ResetMasterPassword?: boolean; ResetMasterPassword?: boolean;
scope?: string; scope?: string;
unofficialServer?: boolean; unofficialServer?: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
UserDecryptionOptions?: unknown; UserDecryptionOptions?: unknown;
userDecryptionOptions?: unknown; userDecryptionOptions?: unknown;
VaultKeys?: { VaultKeys?: {
-20
View File
@@ -14,7 +14,6 @@
/* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */ /* Unified product polish: refined, smooth, comfortable surfaces across desktop, mobile, and dark mode. */
/* ── surface consistency ── */ /* ── surface consistency ── */
.app-shell,
.auth-card, .auth-card,
.dialog-card, .dialog-card,
.card, .card,
@@ -36,12 +35,6 @@
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
.app-shell {
background: var(--panel-soft);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-lg);
}
.topbar, .topbar,
.mobile-tabbar, .mobile-tabbar,
.app-side { .app-side {
@@ -104,7 +97,6 @@
} }
/* ── dark mode surface resets ── */ /* ── dark mode surface resets ── */
:root[data-theme='dark'] .app-shell,
:root[data-theme='dark'] .auth-card, :root[data-theme='dark'] .auth-card,
:root[data-theme='dark'] .dialog-card, :root[data-theme='dark'] .dialog-card,
:root[data-theme='dark'] .card, :root[data-theme='dark'] .card,
@@ -259,17 +251,6 @@ h4 {
letter-spacing: 0; letter-spacing: 0;
} }
.app-page {
padding: 18px;
}
.app-shell {
border-radius: var(--radius-xl);
border: 1px solid var(--line);
background: var(--panel);
box-shadow: var(--shadow-lg);
}
.topbar { .topbar {
height: 56px; height: 56px;
padding-inline: 16px; padding-inline: 16px;
@@ -916,7 +897,6 @@ textarea {
background: var(--bg-accent); background: var(--bg-accent);
} }
:root[data-theme='dark'] .app-shell,
:root[data-theme='dark'] .topbar, :root[data-theme='dark'] .topbar,
:root[data-theme='dark'] .app-side, :root[data-theme='dark'] .app-side,
:root[data-theme='dark'] .mobile-tabbar, :root[data-theme='dark'] .mobile-tabbar,
+14 -4
View File
@@ -200,10 +200,6 @@
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20), 0 8px 24px rgba(0, 0, 0, 0.16); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.20), 0 8px 24px rgba(0, 0, 0, 0.16);
} }
:root[data-theme='dark'] .app-shell {
box-shadow: 0 4px 40px rgba(0, 0, 0, 0.30);
}
:root[data-theme='dark'] .list-item:hover { :root[data-theme='dark'] .list-item:hover {
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
} }
@@ -340,6 +336,7 @@
:root[data-theme='dark'] .backup-recommendation-step, :root[data-theme='dark'] .backup-recommendation-step,
:root[data-theme='dark'] .backup-recommendation-inline-note, :root[data-theme='dark'] .backup-recommendation-inline-note,
:root[data-theme='dark'] .backup-recommendation-linked-item, :root[data-theme='dark'] .backup-recommendation-linked-item,
:root[data-theme='dark'] .backup-browser-head,
:root[data-theme='dark'] .backup-browser-meta, :root[data-theme='dark'] .backup-browser-meta,
:root[data-theme='dark'] .backup-browser-empty, :root[data-theme='dark'] .backup-browser-empty,
:root[data-theme='dark'] .backup-inline-note, :root[data-theme='dark'] .backup-inline-note,
@@ -351,6 +348,19 @@
color: var(--muted); color: var(--muted);
} }
:root[data-theme='dark'] .backup-recommendation-step a {
color: var(--primary);
}
:root[data-theme='dark'] .backup-recommendation-step a:hover,
:root[data-theme='dark'] .backup-recommendation-step a:focus-visible {
color: var(--primary-strong);
}
:root[data-theme='dark'] .backup-browser-head {
background: var(--panel-subtle);
}
:root[data-theme='dark'] .restore-progress-overlay { :root[data-theme='dark'] .restore-progress-overlay {
background: var(--overlay-strong); background: var(--overlay-strong);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
+43 -6
View File
@@ -193,6 +193,18 @@
line-height: 1.5; line-height: 1.5;
} }
.backup-recommendation-step a {
color: #1d4ed8;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
}
.backup-recommendation-step a:hover,
.backup-recommendation-step a:focus-visible {
color: #1742b0;
}
.backup-recommendation-inline-note { .backup-recommendation-inline-note {
color: #475467; color: #475467;
line-height: 1.5; line-height: 1.5;
@@ -351,16 +363,34 @@
} }
.backup-browser-nav { .backup-browser-nav {
@apply mb-2.5; @apply mb-2.5 flex items-center justify-between gap-2;
}
.backup-browser-nav-left {
@apply min-w-0;
} }
.backup-browser-list { .backup-browser-list {
@apply overflow-hidden rounded-xl border bg-white; @apply overflow-hidden rounded-lg border bg-white;
border: 1px solid var(--line); border: 1px solid var(--line);
} }
.backup-browser-head {
@apply grid items-center gap-3 px-3 py-2 text-[11px] font-bold uppercase tracking-[0.08em];
grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
border-bottom: 1px solid var(--line);
color: #64748b;
background: #f8fafc;
}
.backup-browser-head span:nth-child(2),
.backup-browser-head span:nth-child(3),
.backup-browser-head span:nth-child(4) {
text-align: right;
}
.backup-browser-pagination { .backup-browser-pagination {
@apply mt-2.5 flex items-center justify-end gap-2.5; @apply mt-2.5 flex items-center justify-center gap-2.5;
} }
.backup-browser-page-indicator { .backup-browser-page-indicator {
@@ -373,13 +403,15 @@
} }
.backup-browser-row { .backup-browser-row {
@apply grid items-center gap-2.5 px-3 py-2.5; @apply grid items-center gap-3 px-3 py-2;
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(180px, 1fr) minmax(150px, 0.75fr) minmax(92px, 0.4fr) minmax(220px, auto);
min-height: 48px;
} }
.backup-browser-entry { .backup-browser-entry {
@apply inline-flex cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left; @apply inline-flex cursor-pointer items-center gap-2 border-0 bg-transparent p-0 text-left;
color: #0f172a; color: #0f172a;
min-width: 0;
} }
.backup-browser-entry.file { .backup-browser-entry.file {
@@ -392,12 +424,17 @@
} }
.backup-browser-meta { .backup-browser-meta {
@apply grid justify-items-end gap-1 text-right text-[13px]; @apply block text-right text-[13px];
color: #64748b; color: #64748b;
} }
.backup-browser-size {
font-variant-numeric: tabular-nums;
}
.backup-browser-actions { .backup-browser-actions {
justify-content: flex-end; justify-content: flex-end;
flex-wrap: nowrap;
} }
.backup-browser-empty { .backup-browser-empty {
+36
View File
@@ -46,6 +46,24 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.backup-browser-head {
display: none;
}
.backup-browser-meta {
text-align: left;
}
.backup-browser-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.backup-browser-nav {
align-items: flex-start;
flex-direction: column;
}
.settings-twofactor-grid { .settings-twofactor-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1110,6 +1128,24 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.backup-browser-head {
display: none;
}
.backup-browser-meta {
text-align: left;
}
.backup-browser-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.backup-browser-nav {
align-items: flex-start;
flex-direction: column;
}
.backup-grid { .backup-grid {
gap: 8px; gap: 8px;
padding: 0; padding: 0;
+4 -10
View File
@@ -1,17 +1,11 @@
.app-page { .app-page {
@apply relative min-h-full bg-transparent p-5; @apply relative min-h-full bg-transparent;
} }
.app-shell { .app-shell {
@apply relative mx-auto flex max-w-[1600px] flex-col overflow-hidden border bg-panel-soft; @apply relative flex flex-col;
height: calc(100vh - 40px); height: 100vh;
border-color: var(--line); background: var(--bg-accent);
@apply rounded-3xl;
box-shadow:
0 20px 60px rgba(15, 23, 42, 0.12),
0 8px 24px rgba(15, 23, 42, 0.08),
0 0 0 1px rgba(15, 23, 42, 0.04);
transition: box-shadow var(--dur-medium) var(--ease-smooth);
} }
.topbar { .topbar {