mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add shared API utilities for handling requests and responses
- Introduced `shared.ts` with utility functions for API interactions, including JSON parsing, error handling, and content disposition parsing. - Added `vault.ts` to manage vault-related operations such as folder and cipher management, including creation, deletion, and bulk operations. - Implemented encryption and decryption methods for secure data handling within the vault. - Created `backup-settings-repair.ts` to automatically repair backup settings for admin profiles if needed.
This commit is contained in:
@@ -4,7 +4,7 @@ export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
|
||||
export const BACKUP_DEFAULT_E3_REGION = 'auto';
|
||||
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden';
|
||||
|
||||
export type BackupDestinationType = 'e3' | 'webdav' | 'placeholder';
|
||||
export type BackupDestinationType = 'e3' | 'webdav';
|
||||
export type BackupScheduleFrequency = 'daily' | 'weekly' | 'monthly';
|
||||
|
||||
export interface E3BackupDestination {
|
||||
@@ -23,15 +23,9 @@ export interface WebDavBackupDestination {
|
||||
remotePath: string;
|
||||
}
|
||||
|
||||
export interface PlaceholderBackupDestination {
|
||||
providerName: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export type BackupDestinationConfig =
|
||||
| E3BackupDestination
|
||||
| WebDavBackupDestination
|
||||
| PlaceholderBackupDestination;
|
||||
| WebDavBackupDestination;
|
||||
|
||||
export interface BackupRuntimeState {
|
||||
lastAttemptAt: string | null;
|
||||
@@ -110,12 +104,6 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
||||
rootPath: BACKUP_DEFAULT_REMOTE_PATH,
|
||||
};
|
||||
}
|
||||
if (type === 'placeholder') {
|
||||
return {
|
||||
providerName: 'Reserved',
|
||||
notes: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
baseUrl: '',
|
||||
username: '',
|
||||
@@ -126,7 +114,6 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
|
||||
|
||||
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
|
||||
if (type === 'e3') return `E3 ${index}`;
|
||||
if (type === 'placeholder') return `Reserved ${index}`;
|
||||
return `WebDAV ${index}`;
|
||||
}
|
||||
|
||||
@@ -75,9 +75,6 @@ async function executeConfiguredBackup(
|
||||
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
|
||||
const currentSettings = await loadBackupSettings(storage, env, 'UTC');
|
||||
const destination = requireBackupDestination(currentSettings, destinationId);
|
||||
if (destination.type === 'placeholder') {
|
||||
throw new Error('The reserved backup destination is not available yet');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
destination.runtime.lastAttemptAt = now.toISOString();
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Env, User } from './types';
|
||||
import {
|
||||
handleAdminExportBackup,
|
||||
handleDownloadAdminRemoteBackup,
|
||||
handleDeleteAdminRemoteBackup,
|
||||
handleGetAdminBackupSettings,
|
||||
handleGetAdminBackupSettingsRepairState,
|
||||
handleAdminImportBackup,
|
||||
handleListAdminRemoteBackups,
|
||||
handleRepairAdminBackupSettings,
|
||||
handleRestoreAdminRemoteBackup,
|
||||
handleRunAdminConfiguredBackup,
|
||||
handleUpdateAdminBackupSettings,
|
||||
} from './handlers/backup';
|
||||
|
||||
export async function handleAdminBackupRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Response | null> {
|
||||
if (path === '/api/admin/backup/export' && method === 'POST') {
|
||||
return handleAdminExportBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/settings') {
|
||||
if (method === 'GET') return handleGetAdminBackupSettings(request, env, actorUser);
|
||||
if (method === 'PUT') return handleUpdateAdminBackupSettings(request, env, actorUser);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/settings/repair') {
|
||||
if (method === 'GET') return handleGetAdminBackupSettingsRepairState(request, env, actorUser);
|
||||
if (method === 'POST') return handleRepairAdminBackupSettings(request, env, actorUser);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/run' && method === 'POST') {
|
||||
return handleRunAdminConfiguredBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote' && method === 'GET') {
|
||||
return handleListAdminRemoteBackups(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
|
||||
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
|
||||
return handleDeleteAdminRemoteBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/restore' && method === 'POST') {
|
||||
return handleRestoreAdminRemoteBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/import' && method === 'POST') {
|
||||
return handleAdminImportBackup(request, env, actorUser);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Env, User } from './types';
|
||||
import {
|
||||
handleAdminListUsers,
|
||||
handleAdminCreateInvite,
|
||||
handleAdminListInvites,
|
||||
handleAdminDeleteAllInvites,
|
||||
handleAdminRevokeInvite,
|
||||
handleAdminSetUserStatus,
|
||||
handleAdminDeleteUser,
|
||||
} from './handlers/admin';
|
||||
import { handleAdminBackupRoute } from './router-admin-backup';
|
||||
|
||||
export async function handleAdminRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User,
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Response | null> {
|
||||
if (path === '/api/admin/users' && method === 'GET') {
|
||||
return handleAdminListUsers(request, env, actorUser);
|
||||
}
|
||||
|
||||
const adminBackupResponse = await handleAdminBackupRoute(request, env, actorUser, path, method);
|
||||
if (adminBackupResponse) return adminBackupResponse;
|
||||
|
||||
if (path === '/api/admin/invites') {
|
||||
if (method === 'GET') return handleAdminListInvites(request, env, actorUser);
|
||||
if (method === 'POST') return handleAdminCreateInvite(request, env, actorUser);
|
||||
if (method === 'DELETE') return handleAdminDeleteAllInvites(request, env, actorUser);
|
||||
return null;
|
||||
}
|
||||
|
||||
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
||||
if (adminInviteMatch && method === 'DELETE') {
|
||||
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
|
||||
return handleAdminRevokeInvite(request, env, actorUser, inviteCode);
|
||||
}
|
||||
|
||||
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
|
||||
if (adminUserStatusMatch && (method === 'PUT' || method === 'POST')) {
|
||||
return handleAdminSetUserStatus(request, env, actorUser, adminUserStatusMatch[1]);
|
||||
}
|
||||
|
||||
const adminUserDeleteMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)$/i);
|
||||
if (adminUserDeleteMatch && method === 'DELETE') {
|
||||
return handleAdminDeleteUser(request, env, actorUser, adminUserDeleteMatch[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Env } from './types';
|
||||
import {
|
||||
handleGetAuthorizedDevices,
|
||||
handleGetDevices,
|
||||
handleRevokeAllTrustedDevices,
|
||||
handleRevokeTrustedDevice,
|
||||
handleDeleteAllDevices,
|
||||
handleDeleteDevice,
|
||||
handleUpdateDeviceToken,
|
||||
} from './handlers/devices';
|
||||
|
||||
export async function handleAuthenticatedDeviceRoute(
|
||||
request: Request,
|
||||
env: Env,
|
||||
userId: string,
|
||||
path: string,
|
||||
method: string
|
||||
): Promise<Response | null> {
|
||||
if (path === '/api/devices') {
|
||||
if (method === 'GET') return handleGetDevices(request, env, userId);
|
||||
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (path === '/api/devices/authorized') {
|
||||
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
||||
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
||||
return null;
|
||||
}
|
||||
|
||||
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
|
||||
if (authorizedDeviceMatch && method === 'DELETE') {
|
||||
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
||||
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
||||
if (deleteDeviceMatch && method === 'DELETE') {
|
||||
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const deviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
||||
if (deviceTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(deviceTokenMatch[1]);
|
||||
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+6
-122
@@ -76,13 +76,6 @@ import { handleSync } from './handlers/sync';
|
||||
import { handleSetupStatus } from './handlers/setup';
|
||||
import {
|
||||
handleKnownDevice,
|
||||
handleGetAuthorizedDevices,
|
||||
handleGetDevices,
|
||||
handleRevokeAllTrustedDevices,
|
||||
handleRevokeTrustedDevice,
|
||||
handleDeleteAllDevices,
|
||||
handleDeleteDevice,
|
||||
handleUpdateDeviceToken
|
||||
} from './handlers/devices';
|
||||
|
||||
// Import handler
|
||||
@@ -96,32 +89,12 @@ import {
|
||||
handleDeleteAttachment,
|
||||
handlePublicDownloadAttachment,
|
||||
} from './handlers/attachments';
|
||||
import {
|
||||
handleAdminListUsers,
|
||||
handleAdminCreateInvite,
|
||||
handleAdminListInvites,
|
||||
handleAdminDeleteAllInvites,
|
||||
handleAdminRevokeInvite,
|
||||
handleAdminSetUserStatus,
|
||||
handleAdminDeleteUser,
|
||||
} from './handlers/admin';
|
||||
import {
|
||||
handleAdminExportBackup,
|
||||
handleDownloadAdminRemoteBackup,
|
||||
handleDeleteAdminRemoteBackup,
|
||||
handleGetAdminBackupSettings,
|
||||
handleGetAdminBackupSettingsRepairState,
|
||||
handleAdminImportBackup,
|
||||
handleListAdminRemoteBackups,
|
||||
handleRepairAdminBackupSettings,
|
||||
handleRestoreAdminRemoteBackup,
|
||||
handleRunAdminConfiguredBackup,
|
||||
handleUpdateAdminBackupSettings,
|
||||
} from './handlers/backup';
|
||||
import {
|
||||
handleNotificationsHub,
|
||||
handleNotificationsNegotiate,
|
||||
} from './handlers/notifications';
|
||||
import { handleAdminRoute } from './router-admin';
|
||||
import { handleAuthenticatedDeviceRoute } from './router-devices';
|
||||
|
||||
function isSameOriginWriteRequest(request: Request): boolean {
|
||||
const targetOrigin = new URL(request.url).origin;
|
||||
@@ -801,100 +774,11 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
}
|
||||
|
||||
// Devices endpoint
|
||||
if (path === '/api/devices') {
|
||||
if (method === 'GET') return handleGetDevices(request, env, userId);
|
||||
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
|
||||
}
|
||||
const authenticatedDeviceResponse = await handleAuthenticatedDeviceRoute(request, env, userId, path, method);
|
||||
if (authenticatedDeviceResponse) return authenticatedDeviceResponse;
|
||||
|
||||
if (path === '/api/devices/authorized') {
|
||||
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
|
||||
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
|
||||
}
|
||||
|
||||
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
|
||||
if (authorizedDeviceMatch && method === 'DELETE') {
|
||||
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
|
||||
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
|
||||
if (deleteDeviceMatch && method === 'DELETE') {
|
||||
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
|
||||
return handleDeleteDevice(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
|
||||
// Admin endpoints
|
||||
if (path === '/api/admin/users' && method === 'GET') {
|
||||
return handleAdminListUsers(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/export' && method === 'POST') {
|
||||
return handleAdminExportBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/settings') {
|
||||
if (method === 'GET') return handleGetAdminBackupSettings(request, env, currentUser);
|
||||
if (method === 'PUT') return handleUpdateAdminBackupSettings(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/settings/repair') {
|
||||
if (method === 'GET') return handleGetAdminBackupSettingsRepairState(request, env, currentUser);
|
||||
if (method === 'POST') return handleRepairAdminBackupSettings(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/run' && method === 'POST') {
|
||||
return handleRunAdminConfiguredBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote' && method === 'GET') {
|
||||
return handleListAdminRemoteBackups(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/download' && method === 'GET') {
|
||||
return handleDownloadAdminRemoteBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/file' && method === 'DELETE') {
|
||||
return handleDeleteAdminRemoteBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/remote/restore' && method === 'POST') {
|
||||
return handleRestoreAdminRemoteBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/import' && method === 'POST') {
|
||||
return handleAdminImportBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/invites') {
|
||||
if (method === 'GET') return handleAdminListInvites(request, env, currentUser);
|
||||
if (method === 'POST') return handleAdminCreateInvite(request, env, currentUser);
|
||||
if (method === 'DELETE') return handleAdminDeleteAllInvites(request, env, currentUser);
|
||||
}
|
||||
|
||||
const adminInviteMatch = path.match(/^\/api\/admin\/invites\/([^/]+)$/i);
|
||||
if (adminInviteMatch && method === 'DELETE') {
|
||||
const inviteCode = decodeURIComponent(adminInviteMatch[1]);
|
||||
return handleAdminRevokeInvite(request, env, currentUser, inviteCode);
|
||||
}
|
||||
|
||||
const adminUserStatusMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)\/status$/i);
|
||||
if (adminUserStatusMatch && (method === 'PUT' || method === 'POST')) {
|
||||
return handleAdminSetUserStatus(request, env, currentUser, adminUserStatusMatch[1]);
|
||||
}
|
||||
|
||||
const adminUserDeleteMatch = path.match(/^\/api\/admin\/users\/([a-f0-9-]+)$/i);
|
||||
if (adminUserDeleteMatch && method === 'DELETE') {
|
||||
return handleAdminDeleteUser(request, env, currentUser, adminUserDeleteMatch[1]);
|
||||
}
|
||||
|
||||
// Device push token endpoint (no-op compatibility handler)
|
||||
const deviceTokenMatch = path.match(/^\/api\/devices\/identifier\/([^/]+)\/token$/i);
|
||||
if (deviceTokenMatch && (method === 'PUT' || method === 'POST')) {
|
||||
const deviceIdentifier = decodeURIComponent(deviceTokenMatch[1]);
|
||||
return handleUpdateDeviceToken(request, env, userId, deviceIdentifier);
|
||||
}
|
||||
const adminResponse = await handleAdminRoute(request, env, currentUser, path, method);
|
||||
if (adminResponse) return adminResponse;
|
||||
|
||||
// Not found
|
||||
return errorResponse('Not found', 404);
|
||||
|
||||
@@ -17,13 +17,12 @@ import {
|
||||
type BackupScheduleFrequency,
|
||||
type BackupSettings,
|
||||
type E3BackupDestination,
|
||||
type PlaceholderBackupDestination,
|
||||
type WebDavBackupDestination,
|
||||
createBackupRandomId,
|
||||
createDefaultBackupDestinationName,
|
||||
createDefaultBackupScheduleConfig,
|
||||
createDefaultBackupSettings as createSharedDefaultBackupSettings,
|
||||
} from '../../shared/backup';
|
||||
} from '../../shared/backup-schema';
|
||||
|
||||
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
|
||||
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
|
||||
@@ -37,9 +36,8 @@ export type {
|
||||
BackupScheduleConfig,
|
||||
BackupSettings,
|
||||
E3BackupDestination,
|
||||
PlaceholderBackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '../../shared/backup';
|
||||
} from '../../shared/backup-schema';
|
||||
|
||||
export interface BackupSettingsInput {
|
||||
destinations?: unknown;
|
||||
@@ -186,22 +184,13 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlaceholderDestination(value: unknown): PlaceholderBackupDestination {
|
||||
const source = isPlainObject(value) ? value : {};
|
||||
return {
|
||||
providerName: asTrimmedString(source.providerName) || 'Reserved',
|
||||
notes: asTrimmedString(source.notes),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDestination(
|
||||
destinationType: BackupDestinationType,
|
||||
destination: unknown,
|
||||
allowIncomplete = false
|
||||
): BackupDestinationConfig {
|
||||
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
|
||||
if (destinationType === 'webdav') return normalizeWebDavDestination(destination, allowIncomplete);
|
||||
return normalizePlaceholderDestination(destination);
|
||||
return normalizeWebDavDestination(destination, allowIncomplete);
|
||||
}
|
||||
|
||||
function normalizeRuntime(value: unknown): BackupRuntimeState {
|
||||
@@ -235,7 +224,7 @@ function defaultDestinationName(type: BackupDestinationType, index: number): str
|
||||
|
||||
function getDestinationType(raw: unknown): BackupDestinationType {
|
||||
const value = asTrimmedString(raw);
|
||||
if (value === 'e3' || value === 'webdav' || value === 'placeholder') return value;
|
||||
if (value === 'e3' || value === 'webdav') return value;
|
||||
throw new Error('Backup destination type is invalid');
|
||||
}
|
||||
|
||||
@@ -269,10 +258,6 @@ function normalizeDestinationRecord(
|
||||
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
|
||||
};
|
||||
|
||||
if (schedule.enabled && type === 'placeholder') {
|
||||
throw new Error('The reserved backup destination is not available yet');
|
||||
}
|
||||
|
||||
const destination = normalizeDestination(type, input.destination, !schedule.enabled);
|
||||
|
||||
return {
|
||||
@@ -288,7 +273,7 @@ function normalizeDestinationRecord(
|
||||
function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings {
|
||||
const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
|
||||
const destinationType: BackupDestinationType =
|
||||
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav' || destinationTypeRaw === 'placeholder'
|
||||
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
|
||||
? destinationTypeRaw
|
||||
: 'webdav';
|
||||
const destination = {
|
||||
@@ -570,7 +555,6 @@ export function isBackupDueNow(
|
||||
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
|
||||
): boolean {
|
||||
if (!destination.schedule.enabled) return false;
|
||||
if (destination.type === 'placeholder') return false;
|
||||
|
||||
const currentMinutes = toMinutes(getBackupLocalTime(now, destination.schedule.timezone));
|
||||
const scheduledMinutes = toMinutes(destination.schedule.scheduleTime);
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
BackupDestinationRecord,
|
||||
BackupDestinationType,
|
||||
E3BackupDestination,
|
||||
PlaceholderBackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from './backup-config';
|
||||
|
||||
@@ -534,10 +533,6 @@ async function deleteFromE3(config: E3BackupDestination, relativePath: string):
|
||||
}
|
||||
}
|
||||
|
||||
function assertSupportedPlaceholder(_config: PlaceholderBackupDestination): never {
|
||||
throw new Error('The reserved backup destination is not available yet');
|
||||
}
|
||||
|
||||
interface ConfiguredDestinationAdapter {
|
||||
provider: 'webdav' | 'e3';
|
||||
config: WebDavBackupDestination | E3BackupDestination;
|
||||
@@ -573,7 +568,7 @@ function resolveConfiguredDestinationAdapter(
|
||||
};
|
||||
}
|
||||
|
||||
return assertSupportedPlaceholder(destination.destination as PlaceholderBackupDestination);
|
||||
throw new Error('Unsupported backup destination type');
|
||||
}
|
||||
|
||||
export async function uploadBackupArchive(
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// IMPORTANT:
|
||||
// Keep this schema list in sync with migrations/0001_init.sql.
|
||||
// Any new table/column/index must be added to both places together.
|
||||
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE TABLE IF NOT EXISTS users (' +
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
||||
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS attachments (' +
|
||||
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
|
||||
'size_name TEXT NOT NULL, key TEXT, ' +
|
||||
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS sends (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, data TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, password_hash TEXT, password_salt TEXT, password_iterations INTEGER, auth_type INTEGER NOT NULL DEFAULT 2, emails TEXT, ' +
|
||||
'max_access_count INTEGER, access_count INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, hide_email INTEGER, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expiration_date TEXT, deletion_date TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
||||
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
||||
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
|
||||
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS invites (' +
|
||||
'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 (used_by) REFERENCES users(id) ON DELETE SET NULL)',
|
||||
'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 TABLE IF NOT EXISTS audit_logs (' +
|
||||
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
|
||||
'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
'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 TABLE IF NOT EXISTS api_rate_limits (' +
|
||||
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
||||
'PRIMARY KEY (identifier, window_start))',
|
||||
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
||||
];
|
||||
|
||||
async function executeSchemaStatement(db: D1Database, statement: string): Promise<void> {
|
||||
try {
|
||||
await db.prepare(statement).run();
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||
if (msg.includes('already exists') || msg.includes('duplicate column name')) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAdminUserExists(db: D1Database): Promise<void> {
|
||||
const admin = await db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").first<{ id: string }>();
|
||||
if (admin?.id) return;
|
||||
|
||||
const firstUser = await db
|
||||
.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1')
|
||||
.first<{ id: string }>();
|
||||
if (!firstUser?.id) return;
|
||||
|
||||
await db
|
||||
.prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?")
|
||||
.bind(new Date().toISOString(), firstUser.id)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function ensureStorageSchema(db: D1Database): Promise<void> {
|
||||
await db.prepare('PRAGMA foreign_keys = ON').run();
|
||||
await db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||
for (const stmt of SCHEMA_STATEMENTS) {
|
||||
await executeSchemaStatement(db, stmt);
|
||||
}
|
||||
await ensureAdminUserExists(db);
|
||||
}
|
||||
+2
-130
@@ -1,103 +1,9 @@
|
||||
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { ensureStorageSchema } from './storage-schema';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// IMPORTANT:
|
||||
// Keep this schema list in sync with migrations/0001_init.sql.
|
||||
// Any new table/column/index must be added to both places together.
|
||||
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE TABLE IF NOT EXISTS users (' +
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
|
||||
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
||||
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS attachments (' +
|
||||
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
|
||||
'size_name TEXT NOT NULL, key TEXT, ' +
|
||||
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS sends (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, data TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, password_hash TEXT, password_salt TEXT, password_iterations INTEGER, auth_type INTEGER NOT NULL DEFAULT 2, emails TEXT, ' +
|
||||
'max_access_count INTEGER, access_count INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, hide_email INTEGER, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expiration_date TEXT, deletion_date TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)',
|
||||
'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2',
|
||||
'ALTER TABLE sends ADD COLUMN emails TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
||||
'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT',
|
||||
'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS invites (' +
|
||||
'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 (used_by) REFERENCES users(id) ON DELETE SET NULL)',
|
||||
'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 TABLE IF NOT EXISTS audit_logs (' +
|
||||
'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
|
||||
'ALTER TABLE devices ADD COLUMN session_stamp TEXT',
|
||||
'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0',
|
||||
'ALTER TABLE devices ADD COLUMN banned_at TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
'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 TABLE IF NOT EXISTS api_rate_limits (' +
|
||||
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
||||
'PRIMARY KEY (identifier, window_start))',
|
||||
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
||||
];
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
// - All methods are scoped by userId where applicable.
|
||||
@@ -167,45 +73,11 @@ export class StorageService {
|
||||
// - Keep statements idempotent so updates are safe.
|
||||
async initializeDatabase(): Promise<void> {
|
||||
if (StorageService.schemaVerified) return;
|
||||
|
||||
await this.db.prepare('PRAGMA foreign_keys = ON').run();
|
||||
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||
for (const stmt of SCHEMA_STATEMENTS) {
|
||||
await this.executeSchemaStatement(stmt);
|
||||
}
|
||||
await this.ensureAdminUserExists();
|
||||
await ensureStorageSchema(this.db);
|
||||
|
||||
StorageService.schemaVerified = true;
|
||||
}
|
||||
|
||||
private async executeSchemaStatement(statement: string): Promise<void> {
|
||||
try {
|
||||
await this.db.prepare(statement).run();
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||
// Keep migration resilient if a future non-idempotent DDL is retried.
|
||||
if (msg.includes('already exists') || msg.includes('duplicate column name')) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAdminUserExists(): Promise<void> {
|
||||
const admin = await this.db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").first<{ id: string }>();
|
||||
if (admin?.id) return;
|
||||
|
||||
const firstUser = await this.db
|
||||
.prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1')
|
||||
.first<{ id: string }>();
|
||||
if (!firstUser?.id) return;
|
||||
|
||||
await this.db
|
||||
.prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?")
|
||||
.bind(new Date().toISOString(), firstUser.id)
|
||||
.run();
|
||||
}
|
||||
|
||||
// --- Config / setup ---
|
||||
|
||||
async isRegistered(): Promise<boolean> {
|
||||
|
||||
+107
-271
@@ -1,89 +1,94 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { lazy, Suspense } from 'preact/compat';
|
||||
import { Link, Route, Switch, useLocation } from 'wouter';
|
||||
import { Link, useLocation } from 'wouter';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import AppMainRoutes from '@/components/AppMainRoutes';
|
||||
import AuthViews from '@/components/AuthViews';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ToastHost from '@/components/ToastHost';
|
||||
import VaultPage from '@/components/VaultPage';
|
||||
import SendsPage from '@/components/SendsPage';
|
||||
import PublicSendPage from '@/components/PublicSendPage';
|
||||
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
||||
import JwtWarningPage from '@/components/JwtWarningPage';
|
||||
import TotpCodesPage from '@/components/TotpCodesPage';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import {
|
||||
buildCipherImportPayload,
|
||||
bulkDeleteFolders,
|
||||
changeMasterPassword,
|
||||
createFolder,
|
||||
updateFolder,
|
||||
deleteCipherAttachment,
|
||||
deleteFolder,
|
||||
deleteRemoteBackup,
|
||||
bulkDeleteCiphers,
|
||||
bulkPermanentDeleteCiphers,
|
||||
bulkRestoreCiphers,
|
||||
bulkDeleteSends,
|
||||
createCipher,
|
||||
createAuthedFetch,
|
||||
createInvite,
|
||||
downloadCipherAttachmentDecrypted,
|
||||
encryptFolderImportName,
|
||||
exportAdminBackup,
|
||||
getAdminBackupSettingsRepairState,
|
||||
getAdminBackupSettings,
|
||||
downloadRemoteBackup,
|
||||
importAdminBackup,
|
||||
importCiphers,
|
||||
createSend,
|
||||
deleteAllInvites,
|
||||
deleteCipher,
|
||||
deleteSend,
|
||||
deleteUser,
|
||||
deleteAllAuthorizedDevices,
|
||||
deleteAuthorizedDevice,
|
||||
deriveLoginHash,
|
||||
getAttachmentDownloadInfo,
|
||||
bulkMoveCiphers,
|
||||
getCiphers,
|
||||
getFolders,
|
||||
getPreloginKdfConfig,
|
||||
getProfile,
|
||||
getAuthorizedDevices,
|
||||
getCurrentDeviceIdentifier,
|
||||
getPreloginKdfConfig,
|
||||
getProfile,
|
||||
getSetupStatus,
|
||||
getSends,
|
||||
getTotpStatus,
|
||||
getTotpRecoveryCode,
|
||||
getTotpStatus,
|
||||
getWebConfig,
|
||||
listAdminInvites,
|
||||
listAdminUsers,
|
||||
loadSession,
|
||||
loginWithPassword,
|
||||
registerAccount,
|
||||
recoverTwoFactor,
|
||||
repairAdminBackupSettings,
|
||||
revokeInvite,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
restoreRemoteBackup,
|
||||
runAdminBackupNow,
|
||||
saveSession,
|
||||
saveAdminBackupSettings,
|
||||
setTotp,
|
||||
setUserStatus,
|
||||
deleteAllAuthorizedDevices,
|
||||
deleteAuthorizedDevice,
|
||||
listRemoteBackups,
|
||||
uploadCipherAttachment,
|
||||
updateCipher,
|
||||
updateSend,
|
||||
buildSendShareKey,
|
||||
unlockVaultKey,
|
||||
verifyMasterPassword,
|
||||
} from '@/lib/api/auth';
|
||||
import {
|
||||
createInvite,
|
||||
deleteAllInvites,
|
||||
deleteUser,
|
||||
listAdminInvites,
|
||||
listAdminUsers,
|
||||
revokeInvite,
|
||||
setUserStatus,
|
||||
} from '@/lib/api/admin';
|
||||
import {
|
||||
deleteRemoteBackup,
|
||||
downloadRemoteBackup,
|
||||
exportAdminBackup,
|
||||
getAdminBackupSettings,
|
||||
importAdminBackup,
|
||||
listRemoteBackups,
|
||||
restoreRemoteBackup,
|
||||
runAdminBackupNow,
|
||||
saveAdminBackupSettings,
|
||||
type AdminBackupSettings,
|
||||
} from '@/lib/api/backup';
|
||||
import {
|
||||
buildSendShareKey,
|
||||
bulkDeleteSends,
|
||||
createSend,
|
||||
deleteSend,
|
||||
getSends,
|
||||
updateSend,
|
||||
} from '@/lib/api/send';
|
||||
import {
|
||||
buildCipherImportPayload,
|
||||
bulkDeleteCiphers,
|
||||
bulkDeleteFolders,
|
||||
bulkMoveCiphers,
|
||||
bulkPermanentDeleteCiphers,
|
||||
bulkRestoreCiphers,
|
||||
createCipher,
|
||||
createFolder,
|
||||
deleteCipher,
|
||||
deleteCipherAttachment,
|
||||
deleteFolder,
|
||||
downloadCipherAttachmentDecrypted,
|
||||
encryptFolderImportName,
|
||||
getAttachmentDownloadInfo,
|
||||
getCiphers,
|
||||
getFolders,
|
||||
importCiphers,
|
||||
type CiphersImportPayload,
|
||||
type ImportedCipherMapEntry,
|
||||
} from '@/lib/api';
|
||||
import { decryptPortableBackupSettings } from '@/lib/admin-backup-portable';
|
||||
updateCipher,
|
||||
updateFolder,
|
||||
uploadCipherAttachment,
|
||||
} from '@/lib/api/vault';
|
||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto';
|
||||
import {
|
||||
attachNodeWardenEncryptedAttachmentPayload,
|
||||
@@ -99,15 +104,8 @@ import {
|
||||
type ZipAttachmentEntry,
|
||||
} from '@/lib/export-formats';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { CiphersImportPayload } from '@/lib/api';
|
||||
import type { AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
|
||||
|
||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
||||
const ImportPage = lazy(() => import('@/components/ImportPage'));
|
||||
|
||||
interface PendingTotp {
|
||||
email: string;
|
||||
passwordHash: string;
|
||||
@@ -149,10 +147,6 @@ function readInviteCodeFromUrl(): string {
|
||||
return '';
|
||||
}
|
||||
|
||||
function RouteContentFallback() {
|
||||
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
|
||||
}
|
||||
|
||||
function summarizeImportResult(
|
||||
ciphers: Array<Record<string, unknown>>,
|
||||
folderCount: number,
|
||||
@@ -452,21 +446,6 @@ export default function App() {
|
||||
saveSession(next);
|
||||
}
|
||||
|
||||
async function silentlyRepairBackupSettingsIfNeeded(activeSession: SessionState, activeProfile: Profile): Promise<void> {
|
||||
if (activeProfile.role !== 'admin') return;
|
||||
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
|
||||
|
||||
const tempFetch = createAuthedFetch(() => activeSession, () => {});
|
||||
try {
|
||||
const state = await getAdminBackupSettingsRepairState(tempFetch);
|
||||
if (!state.needsRepair || !state.portable) return;
|
||||
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
|
||||
await repairAdminBackupSettings(tempFetch, repairedSettings);
|
||||
} catch (error) {
|
||||
console.error('Backup settings auto-repair failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function pushToast(type: ToastMessage['type'], text: string) {
|
||||
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
setToasts((prev) => [...prev.slice(-3), { id, type, text }]);
|
||||
@@ -1908,33 +1887,6 @@ export default function App() {
|
||||
return t('nav_my_vault');
|
||||
})();
|
||||
|
||||
const importPageContent = (
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<ImportPage
|
||||
onImport={handleImportAction}
|
||||
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
||||
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
|
||||
onNotify={pushToast}
|
||||
folders={decryptedFolders}
|
||||
onExport={handleExportAction}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const renderImportPageRoute = () => (
|
||||
<div className="stack">
|
||||
{mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{importPageContent}
|
||||
</div>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
|
||||
}, [phase, location, isPublicSendRoute, navigate]);
|
||||
@@ -2148,59 +2100,47 @@ export default function App() {
|
||||
</Link>
|
||||
</aside>
|
||||
<main className="content">
|
||||
<Switch>
|
||||
<Route path="/sends">
|
||||
<SendsPage
|
||||
sends={decryptedSends}
|
||||
loading={sendsQuery.isFetching}
|
||||
onRefresh={refreshVault}
|
||||
onCreate={createSendItem}
|
||||
onUpdate={updateSendItem}
|
||||
onDelete={deleteSendItem}
|
||||
onBulkDelete={bulkDeleteSendItems}
|
||||
<AppMainRoutes
|
||||
profile={profile}
|
||||
session={session}
|
||||
mobileLayout={mobileLayout}
|
||||
importRoute={IMPORT_ROUTE}
|
||||
settingsHomeRoute={SETTINGS_HOME_ROUTE}
|
||||
settingsAccountRoute={SETTINGS_ACCOUNT_ROUTE}
|
||||
decryptedCiphers={decryptedCiphers}
|
||||
decryptedFolders={decryptedFolders}
|
||||
decryptedSends={decryptedSends}
|
||||
ciphersLoading={ciphersQuery.isFetching}
|
||||
foldersLoading={foldersQuery.isFetching}
|
||||
sendsLoading={sendsQuery.isFetching}
|
||||
users={usersQuery.data || []}
|
||||
invites={invitesQuery.data || []}
|
||||
totpEnabled={!!totpStatusQuery.data?.enabled}
|
||||
authorizedDevices={authorizedDevicesQuery.data || []}
|
||||
authorizedDevicesLoading={authorizedDevicesQuery.isFetching}
|
||||
onNavigate={navigate}
|
||||
onLogout={handleLogout}
|
||||
onNotify={pushToast}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/vault/totp">
|
||||
<TotpCodesPage ciphers={decryptedCiphers} loading={ciphersQuery.isFetching} onNotify={pushToast} />
|
||||
</Route>
|
||||
<Route path="/vault">
|
||||
<VaultPage
|
||||
ciphers={decryptedCiphers}
|
||||
folders={decryptedFolders}
|
||||
loading={ciphersQuery.isFetching || foldersQuery.isFetching}
|
||||
emailForReprompt={profile?.email || session?.email || ''}
|
||||
onRefresh={refreshVault}
|
||||
onCreate={createVaultItem}
|
||||
onUpdate={updateVaultItem}
|
||||
onDelete={deleteVaultItem}
|
||||
onBulkDelete={bulkDeleteVaultItems}
|
||||
onBulkPermanentDelete={bulkPermanentDeleteVaultItems}
|
||||
onBulkRestore={bulkRestoreVaultItems}
|
||||
onBulkMove={bulkMoveVaultItems}
|
||||
onImport={handleImportAction}
|
||||
onImportEncryptedRaw={handleImportEncryptedRawAction}
|
||||
onExport={handleExportAction}
|
||||
onCreateVaultItem={createVaultItem}
|
||||
onUpdateVaultItem={updateVaultItem}
|
||||
onDeleteVaultItem={deleteVaultItem}
|
||||
onBulkDeleteVaultItems={bulkDeleteVaultItems}
|
||||
onBulkPermanentDeleteVaultItems={bulkPermanentDeleteVaultItems}
|
||||
onBulkRestoreVaultItems={bulkRestoreVaultItems}
|
||||
onBulkMoveVaultItems={bulkMoveVaultItems}
|
||||
onVerifyMasterPassword={verifyMasterPasswordAction}
|
||||
onNotify={pushToast}
|
||||
onCreateFolder={createFolderAction}
|
||||
onDeleteFolder={deleteFolderAction}
|
||||
onBulkDeleteFolders={bulkDeleteFoldersAction}
|
||||
onDownloadAttachment={downloadVaultAttachment}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={SETTINGS_ACCOUNT_ROUTE}>
|
||||
{profile && (
|
||||
<div className="stack">
|
||||
{mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SettingsPage
|
||||
profile={profile}
|
||||
totpEnabled={!!totpStatusQuery.data?.enabled}
|
||||
onDownloadVaultAttachment={downloadVaultAttachment}
|
||||
onRefreshVault={refreshVault}
|
||||
onCreateSend={createSendItem}
|
||||
onUpdateSend={updateSendItem}
|
||||
onDeleteSend={deleteSendItem}
|
||||
onBulkDeleteSends={bulkDeleteSendItems}
|
||||
onChangePassword={changePasswordAction}
|
||||
onEnableTotp={async (secret, token) => {
|
||||
await enableTotpAction(secret, token);
|
||||
@@ -2208,64 +2148,8 @@ export default function App() {
|
||||
}}
|
||||
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
||||
onGetRecoveryCode={getRecoveryCodeAction}
|
||||
onNotify={pushToast}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
{profile && (
|
||||
<section className="card mobile-settings-card">
|
||||
<div className="mobile-settings-links">
|
||||
<Link href={SETTINGS_ACCOUNT_ROUTE} className="mobile-settings-link">
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
<Link href="/security/devices" className="mobile-settings-link">
|
||||
<Shield size={18} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
<Link href={IMPORT_ROUTE} className="mobile-settings-link">
|
||||
<ArrowUpDown size={18} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
</Link>
|
||||
{profile.role === 'admin' && (
|
||||
<Link href="/admin" className="mobile-settings-link">
|
||||
<ShieldUser size={18} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
</Link>
|
||||
)}
|
||||
{profile.role === 'admin' && (
|
||||
<Link href="/help" className="mobile-settings-link">
|
||||
<Cloud size={18} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={handleLogout}>
|
||||
<LogOut size={14} className="btn-icon" />
|
||||
{t('txt_sign_out')}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/security/devices">
|
||||
<div className="stack">
|
||||
{mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SecurityDevicesPage
|
||||
devices={authorizedDevicesQuery.data || []}
|
||||
loading={authorizedDevicesQuery.isFetching}
|
||||
onRefresh={() => void refreshAuthorizedDevices()}
|
||||
onRevokeTrust={(device) => {
|
||||
onRefreshAuthorizedDevices={refreshAuthorizedDevices}
|
||||
onRevokeDeviceTrust={(device) => {
|
||||
setConfirm({
|
||||
title: t('txt_revoke_device_authorization'),
|
||||
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
|
||||
@@ -2287,7 +2171,7 @@ export default function App() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRevokeAll={() => {
|
||||
onRevokeAllDeviceTrust={() => {
|
||||
setConfirm({
|
||||
title: t('txt_revoke_all_trusted_devices'),
|
||||
message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
|
||||
@@ -2298,7 +2182,7 @@ export default function App() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
onRemoveAll={() => {
|
||||
onRemoveAllDevices={() => {
|
||||
setConfirm({
|
||||
title: t('txt_remove_all_devices'),
|
||||
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
|
||||
@@ -2309,26 +2193,7 @@ export default function App() {
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Route>
|
||||
<Route path="/admin">
|
||||
<div className="stack">
|
||||
{mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<AdminPage
|
||||
currentUserId={profile?.id || ''}
|
||||
users={usersQuery.data || []}
|
||||
invites={invitesQuery.data || []}
|
||||
onRefresh={() => {
|
||||
onRefreshAdmin={() => {
|
||||
void usersQuery.refetch();
|
||||
void invitesQuery.refetch();
|
||||
}}
|
||||
@@ -2377,45 +2242,16 @@ export default function App() {
|
||||
await invitesQuery.refetch();
|
||||
pushToast('success', t('txt_invite_revoked'));
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Route>
|
||||
{IMPORT_ROUTE_PATHS.map((path) => (
|
||||
<Route key={path} path={path}>
|
||||
{renderImportPageRoute()}
|
||||
</Route>
|
||||
))}
|
||||
<Route path="/help">
|
||||
{profile?.role === 'admin' ? (
|
||||
<div className="stack">
|
||||
{mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<BackupCenterPage
|
||||
currentUserId={profile?.id || null}
|
||||
onExport={handleBackupExportAction}
|
||||
onImport={handleBackupImportAction}
|
||||
onLoadSettings={handleLoadBackupSettingsAction}
|
||||
onExportBackup={handleBackupExportAction}
|
||||
onImportBackup={handleBackupImportAction}
|
||||
onLoadBackupSettings={handleLoadBackupSettingsAction}
|
||||
onSaveBackupSettings={handleSaveBackupSettingsAction}
|
||||
onRunRemoteBackup={handleRunRemoteBackupAction}
|
||||
onListRemoteBackups={handleListRemoteBackupsAction}
|
||||
onDownloadRemoteBackup={handleDownloadRemoteBackupAction}
|
||||
onDeleteRemoteBackup={handleDeleteRemoteBackupAction}
|
||||
onRestoreRemoteBackup={handleRestoreRemoteBackupAction}
|
||||
onSaveSettings={handleSaveBackupSettingsAction}
|
||||
onRunRemoteBackup={handleRunRemoteBackupAction}
|
||||
onNotify={pushToast}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : null}
|
||||
</Route>
|
||||
</Switch>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { lazy, Suspense } from 'preact/compat';
|
||||
import { Link, Route, Switch } from 'wouter';
|
||||
import { ArrowUpDown, Cloud, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import SendsPage from '@/components/SendsPage';
|
||||
import TotpCodesPage from '@/components/TotpCodesPage';
|
||||
import VaultPage from '@/components/VaultPage';
|
||||
import type { AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AdminInvite, AdminUser, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
|
||||
const AdminPage = lazy(() => import('@/components/AdminPage'));
|
||||
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
|
||||
const ImportPage = lazy(() => import('@/components/ImportPage'));
|
||||
|
||||
function RouteContentFallback() {
|
||||
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
|
||||
}
|
||||
|
||||
interface AppMainRoutesProps {
|
||||
profile: Profile | null;
|
||||
session: SessionState | null;
|
||||
mobileLayout: boolean;
|
||||
importRoute: string;
|
||||
settingsHomeRoute: string;
|
||||
settingsAccountRoute: string;
|
||||
decryptedCiphers: Cipher[];
|
||||
decryptedFolders: VaultFolder[];
|
||||
decryptedSends: Send[];
|
||||
ciphersLoading: boolean;
|
||||
foldersLoading: boolean;
|
||||
sendsLoading: boolean;
|
||||
users: AdminUser[];
|
||||
invites: AdminInvite[];
|
||||
totpEnabled: boolean;
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
authorizedDevicesLoading: boolean;
|
||||
onNavigate: (path: string) => void;
|
||||
onLogout: () => void;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
onImport: (
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
attachments?: ImportAttachmentFile[]
|
||||
) => Promise<ImportResultSummary>;
|
||||
onImportEncryptedRaw: (
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
attachments?: ImportAttachmentFile[]
|
||||
) => Promise<ImportResultSummary>;
|
||||
onExport: (request: ExportRequest) => Promise<void>;
|
||||
onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise<void>;
|
||||
onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise<void>;
|
||||
onDeleteVaultItem: (cipher: Cipher) => Promise<void>;
|
||||
onBulkDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkRestoreVaultItems: (ids: string[]) => Promise<void>;
|
||||
onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise<void>;
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||
onBulkDeleteFolders: (folderIds: string[]) => Promise<void>;
|
||||
onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||
onRefreshVault: () => Promise<void>;
|
||||
onCreateSend: (draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||
onUpdateSend: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise<void>;
|
||||
onDeleteSend: (send: Send) => Promise<void>;
|
||||
onBulkDeleteSends: (ids: string[]) => Promise<void>;
|
||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||
onRemoveDevice: (device: AuthorizedDevice) => void;
|
||||
onRevokeAllDeviceTrust: () => void;
|
||||
onRemoveAllDevices: () => void;
|
||||
onCreateInvite: (hours: number) => Promise<void>;
|
||||
onRefreshAdmin: () => void;
|
||||
onDeleteAllInvites: () => Promise<void>;
|
||||
onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise<void>;
|
||||
onDeleteUser: (userId: string) => Promise<void>;
|
||||
onRevokeInvite: (code: string) => Promise<void>;
|
||||
onExportBackup: () => Promise<void>;
|
||||
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<void>;
|
||||
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
|
||||
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
|
||||
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
|
||||
onDownloadRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
|
||||
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||
const importPageContent = (
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<ImportPage
|
||||
onImport={props.onImport}
|
||||
onImportEncryptedRaw={props.onImportEncryptedRaw}
|
||||
accountKeys={props.session?.symEncKey && props.session?.symMacKey ? { encB64: props.session.symEncKey, macB64: props.session.symMacKey } : null}
|
||||
onNotify={props.onNotify}
|
||||
folders={props.decryptedFolders}
|
||||
onExport={props.onExport}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
const renderImportPageRoute = () => (
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{importPageContent}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/sends">
|
||||
<SendsPage
|
||||
sends={props.decryptedSends}
|
||||
loading={props.sendsLoading}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateSend}
|
||||
onUpdate={props.onUpdateSend}
|
||||
onDelete={props.onDeleteSend}
|
||||
onBulkDelete={props.onBulkDeleteSends}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/vault/totp">
|
||||
<TotpCodesPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} onNotify={props.onNotify} />
|
||||
</Route>
|
||||
<Route path="/vault">
|
||||
<VaultPage
|
||||
ciphers={props.decryptedCiphers}
|
||||
folders={props.decryptedFolders}
|
||||
loading={props.ciphersLoading || props.foldersLoading}
|
||||
emailForReprompt={props.profile?.email || props.session?.email || ''}
|
||||
onRefresh={props.onRefreshVault}
|
||||
onCreate={props.onCreateVaultItem}
|
||||
onUpdate={props.onUpdateVaultItem}
|
||||
onDelete={props.onDeleteVaultItem}
|
||||
onBulkDelete={props.onBulkDeleteVaultItems}
|
||||
onBulkPermanentDelete={props.onBulkPermanentDeleteVaultItems}
|
||||
onBulkRestore={props.onBulkRestoreVaultItems}
|
||||
onBulkMove={props.onBulkMoveVaultItems}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onNotify={props.onNotify}
|
||||
onCreateFolder={props.onCreateFolder}
|
||||
onDeleteFolder={props.onDeleteFolder}
|
||||
onBulkDeleteFolders={props.onBulkDeleteFolders}
|
||||
onDownloadAttachment={props.onDownloadVaultAttachment}
|
||||
/>
|
||||
</Route>
|
||||
<Route path={props.settingsAccountRoute}>
|
||||
{props.profile && (
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SettingsPage
|
||||
profile={props.profile}
|
||||
totpEnabled={props.totpEnabled}
|
||||
onChangePassword={props.onChangePassword}
|
||||
onEnableTotp={props.onEnableTotp}
|
||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/settings">
|
||||
{props.profile && (
|
||||
<section className="card mobile-settings-card">
|
||||
<div className="mobile-settings-links">
|
||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
<Link href="/security/devices" className="mobile-settings-link">
|
||||
<Shield size={18} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
</Link>
|
||||
<Link href={props.importRoute} className="mobile-settings-link">
|
||||
<ArrowUpDown size={18} />
|
||||
<span>{t('nav_import_export')}</span>
|
||||
</Link>
|
||||
{props.profile.role === 'admin' && (
|
||||
<Link href="/admin" className="mobile-settings-link">
|
||||
<ShieldUser size={18} />
|
||||
<span>{t('nav_admin_panel')}</span>
|
||||
</Link>
|
||||
)}
|
||||
{props.profile.role === 'admin' && (
|
||||
<Link href="/help" className="mobile-settings-link">
|
||||
<Cloud size={18} />
|
||||
<span>{t('nav_backup_strategy')}</span>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
|
||||
<LogOut size={14} className="btn-icon" />
|
||||
{t('txt_sign_out')}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
</Route>
|
||||
<Route path="/security/devices">
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SecurityDevicesPage
|
||||
devices={props.authorizedDevices}
|
||||
loading={props.authorizedDevicesLoading}
|
||||
onRefresh={() => void props.onRefreshAuthorizedDevices()}
|
||||
onRevokeTrust={props.onRevokeDeviceTrust}
|
||||
onRemoveDevice={props.onRemoveDevice}
|
||||
onRevokeAll={props.onRevokeAllDeviceTrust}
|
||||
onRemoveAll={props.onRemoveAllDevices}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Route>
|
||||
<Route path="/admin">
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<AdminPage
|
||||
currentUserId={props.profile?.id || ''}
|
||||
users={props.users}
|
||||
invites={props.invites}
|
||||
onRefresh={props.onRefreshAdmin}
|
||||
onCreateInvite={props.onCreateInvite}
|
||||
onDeleteAllInvites={props.onDeleteAllInvites}
|
||||
onToggleUserStatus={props.onToggleUserStatus}
|
||||
onDeleteUser={props.onDeleteUser}
|
||||
onRevokeInvite={props.onRevokeInvite}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</Route>
|
||||
{importRoutePaths.map((path) => (
|
||||
<Route key={path} path={path}>
|
||||
{renderImportPageRoute()}
|
||||
</Route>
|
||||
))}
|
||||
<Route path="/help">
|
||||
{props.profile?.role === 'admin' ? (
|
||||
<div className="stack">
|
||||
{props.mobileLayout && (
|
||||
<div className="mobile-settings-subhead">
|
||||
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
|
||||
<span className="btn-icon" aria-hidden="true">{"<"}</span>
|
||||
{t('txt_back')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<BackupCenterPage
|
||||
currentUserId={props.profile?.id || null}
|
||||
onExport={props.onExportBackup}
|
||||
onImport={props.onImportBackup}
|
||||
onLoadSettings={props.onLoadBackupSettings}
|
||||
onListRemoteBackups={props.onListRemoteBackups}
|
||||
onDownloadRemoteBackup={props.onDownloadRemoteBackup}
|
||||
onDeleteRemoteBackup={props.onDeleteRemoteBackup}
|
||||
onRestoreRemoteBackup={props.onRestoreRemoteBackup}
|
||||
onSaveSettings={props.onSaveBackupSettings}
|
||||
onRunRemoteBackup={props.onRunRemoteBackup}
|
||||
onNotify={props.onNotify}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
) : null}
|
||||
</Route>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type BackupDestinationRecord,
|
||||
type BackupDestinationType,
|
||||
type RemoteBackupBrowserResponse,
|
||||
} from '@/lib/api';
|
||||
} from '@/lib/api/backup';
|
||||
import {
|
||||
REMOTE_BROWSER_ITEMS_PER_PAGE,
|
||||
compareRemoteItems,
|
||||
@@ -92,8 +92,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
const selectedRecommendedProvider = RECOMMENDED_PROVIDERS.find((provider) => provider.id === selectedProviderId) || null;
|
||||
const recommendedWebDavProviders = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 'webdav');
|
||||
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
|
||||
const canRunSelectedDestination = !!selectedDestination && selectedDestination.type !== 'placeholder' && selectedDestinationIsSaved;
|
||||
const canBrowseSelectedDestination = !!savedSelectedDestination && savedSelectedDestination.type !== 'placeholder';
|
||||
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
|
||||
const canBrowseSelectedDestination = !!savedSelectedDestination;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -135,12 +135,6 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
||||
});
|
||||
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDestination?.type === 'placeholder') {
|
||||
setSelectedDestinationId(getFirstVisibleDestinationId(settings));
|
||||
}
|
||||
}, [selectedDestination?.id, selectedDestination?.type, settings]);
|
||||
|
||||
function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) {
|
||||
setSettings((current) => {
|
||||
const next = mutator(current);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { strFromU8, unzipSync } from 'fflate';
|
||||
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
|
||||
import { Download, FileUp } from 'lucide-preact';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import type { CiphersImportPayload } from '@/lib/api';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import {
|
||||
type EncryptedJsonMode,
|
||||
EXPORT_FORMATS,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'preact/hooks';
|
||||
import { Download, Eye, Lock } from 'lucide-preact';
|
||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api';
|
||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
E3BackupDestination,
|
||||
RemoteBackupBrowserResponse,
|
||||
WebDavBackupDestination,
|
||||
} from '@/lib/api';
|
||||
} from '@/lib/api/backup';
|
||||
import { COMMON_TIME_ZONES, WEEKDAY_OPTIONS, getDestinationTypeLabel } from '@/lib/backup-center';
|
||||
import type { RecommendedProvider } from '@/lib/backup-recommendations';
|
||||
import { RemoteBackupBrowser } from './RemoteBackupBrowser';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Plus } from 'lucide-preact';
|
||||
import type { BackupDestinationRecord, BackupDestinationType } from '@/lib/api';
|
||||
import type { BackupDestinationRecord, BackupDestinationType } from '@/lib/api/backup';
|
||||
import { formatDateTime, getDestinationTypeLabel } from '@/lib/backup-center';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact';
|
||||
import type { RemoteBackupBrowserResponse } from '@/lib/api';
|
||||
import type { RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||
import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { base64ToBytes, decryptBw } from './crypto';
|
||||
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api';
|
||||
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
||||
import type { Profile, SessionState } from './types';
|
||||
|
||||
const PORTABLE_ALGORITHM = 'RSA-OAEP';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
import type { AdminInvite, AdminUser, ListResponse } from '../types';
|
||||
import { parseJson, type AuthedFetch } from './shared';
|
||||
|
||||
export async function listAdminUsers(authedFetch: AuthedFetch): Promise<AdminUser[]> {
|
||||
const resp = await authedFetch('/api/admin/users');
|
||||
if (!resp.ok) throw new Error('Failed to load users');
|
||||
const body = await parseJson<ListResponse<AdminUser>>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function listAdminInvites(authedFetch: AuthedFetch): Promise<AdminInvite[]> {
|
||||
const resp = await authedFetch('/api/admin/invites?includeInactive=true');
|
||||
if (!resp.ok) throw new Error('Failed to load invites');
|
||||
const body = await parseJson<ListResponse<AdminInvite>>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function createInvite(authedFetch: AuthedFetch, hours: number): Promise<void> {
|
||||
const resp = await authedFetch('/api/admin/invites', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ expiresInHours: hours }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Create invite failed');
|
||||
}
|
||||
|
||||
export async function revokeInvite(authedFetch: AuthedFetch, code: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Revoke invite failed');
|
||||
}
|
||||
|
||||
export async function deleteAllInvites(authedFetch: AuthedFetch): Promise<void> {
|
||||
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Delete all invites failed');
|
||||
}
|
||||
|
||||
export async function setUserStatus(
|
||||
authedFetch: AuthedFetch,
|
||||
userId: string,
|
||||
status: 'active' | 'banned'
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Update user status failed');
|
||||
}
|
||||
|
||||
export async function deleteUser(authedFetch: AuthedFetch, userId: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Delete user failed');
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../crypto';
|
||||
import { t } from '../i18n';
|
||||
import type { AuthorizedDevice } from '../types';
|
||||
import type {
|
||||
Profile,
|
||||
SessionState,
|
||||
SetupStatusResponse,
|
||||
TokenError,
|
||||
TokenSuccess,
|
||||
WebConfigResponse,
|
||||
} from '../types';
|
||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||
|
||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
||||
|
||||
export interface PreloginResult {
|
||||
hash: string;
|
||||
masterKey: Uint8Array;
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
export interface PreloginKdfConfig {
|
||||
kdfType: number;
|
||||
kdfIterations: number;
|
||||
kdfMemory: number | null;
|
||||
kdfParallelism: number | null;
|
||||
}
|
||||
|
||||
function randomHex(length: number): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||
}
|
||||
|
||||
function getOrCreateDeviceIdentifier(): string {
|
||||
const current = (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||
if (current) return current;
|
||||
const next = `${randomHex(8)}-${randomHex(4)}-${randomHex(4)}-${randomHex(4)}-${randomHex(12)}`;
|
||||
localStorage.setItem(DEVICE_IDENTIFIER_KEY, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function guessDeviceName(): string {
|
||||
const ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '').toLowerCase();
|
||||
const platform = (typeof navigator !== 'undefined' ? navigator.platform : '').trim();
|
||||
const browser = ua.includes('edg/') ? 'Edge' : ua.includes('chrome/') ? 'Chrome' : ua.includes('firefox/') ? 'Firefox' : ua.includes('safari/') ? 'Safari' : 'Browser';
|
||||
const os = ua.includes('windows') ? 'Windows' : ua.includes('mac os') ? 'macOS' : ua.includes('linux') ? 'Linux' : ua.includes('android') ? 'Android' : ua.includes('iphone') || ua.includes('ipad') ? 'iOS' : platform || 'Unknown OS';
|
||||
return `${browser} on ${os}`.slice(0, 128);
|
||||
}
|
||||
|
||||
function getRememberTwoFactorToken(): string | null {
|
||||
const token = (localStorage.getItem(TOTP_REMEMBER_TOKEN_KEY) || '').trim();
|
||||
return token || null;
|
||||
}
|
||||
|
||||
function saveRememberTwoFactorToken(token: string | undefined): void {
|
||||
const normalized = String(token || '').trim();
|
||||
if (!normalized) return;
|
||||
localStorage.setItem(TOTP_REMEMBER_TOKEN_KEY, normalized);
|
||||
}
|
||||
|
||||
function clearRememberTwoFactorToken(): void {
|
||||
localStorage.removeItem(TOTP_REMEMBER_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function loadSession(): SessionState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as SessionState;
|
||||
if (!parsed.accessToken || !parsed.refreshToken) return null;
|
||||
return {
|
||||
accessToken: parsed.accessToken,
|
||||
refreshToken: parsed.refreshToken,
|
||||
email: parsed.email,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveSession(session: SessionState | null): void {
|
||||
if (!session) {
|
||||
localStorage.removeItem(SESSION_KEY);
|
||||
return;
|
||||
}
|
||||
const persisted: SessionState = {
|
||||
accessToken: session.accessToken,
|
||||
refreshToken: session.refreshToken,
|
||||
email: session.email,
|
||||
};
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
||||
}
|
||||
|
||||
export async function getSetupStatus(): Promise<SetupStatusResponse> {
|
||||
const resp = await fetch('/setup/status');
|
||||
const body = await parseJson<SetupStatusResponse>(resp);
|
||||
return { registered: !!body?.registered };
|
||||
}
|
||||
|
||||
export async function getWebConfig(): Promise<WebConfigResponse> {
|
||||
const resp = await fetch('/api/web/config');
|
||||
return (await parseJson<WebConfigResponse>(resp)) || {};
|
||||
}
|
||||
|
||||
export function getCurrentDeviceIdentifier(): string {
|
||||
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||
}
|
||||
|
||||
export async function deriveLoginHash(email: string, password: string, fallbackIterations: number): Promise<PreloginResult> {
|
||||
const pre = await fetch('/identity/accounts/prelogin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.toLowerCase() }),
|
||||
});
|
||||
if (!pre.ok) throw new Error('prelogin failed');
|
||||
const data = (await parseJson<{ kdfIterations?: number }>(pre)) || {};
|
||||
const iterations = Number(data.kdfIterations || fallbackIterations);
|
||||
const masterKey = await pbkdf2(password, email.toLowerCase(), iterations, 32);
|
||||
const hash = await pbkdf2(masterKey, password, 1, 32);
|
||||
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
|
||||
}
|
||||
|
||||
export async function getPreloginKdfConfig(email: string, fallbackIterations: number): Promise<PreloginKdfConfig> {
|
||||
const normalized = String(email || '').trim().toLowerCase();
|
||||
if (!normalized) throw new Error('Email is required');
|
||||
const pre = await fetch('/identity/accounts/prelogin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: normalized }),
|
||||
});
|
||||
if (!pre.ok) throw new Error('prelogin failed');
|
||||
const data = (await parseJson<{ kdf?: number; kdfIterations?: number; kdfMemory?: number | null; kdfParallelism?: number | null }>(pre)) || {};
|
||||
return {
|
||||
kdfType: Number(data.kdf ?? 0) || 0,
|
||||
kdfIterations: Number(data.kdfIterations || fallbackIterations),
|
||||
kdfMemory: data.kdfMemory == null ? null : Number(data.kdfMemory),
|
||||
kdfParallelism: data.kdfParallelism == null ? null : Number(data.kdfParallelism),
|
||||
};
|
||||
}
|
||||
|
||||
export async function loginWithPassword(
|
||||
email: string,
|
||||
passwordHash: string,
|
||||
options?: {
|
||||
totpCode?: string;
|
||||
rememberDevice?: boolean;
|
||||
useRememberToken?: boolean;
|
||||
}
|
||||
): Promise<TokenSuccess | TokenError> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'password');
|
||||
body.set('username', email.toLowerCase());
|
||||
body.set('password', passwordHash);
|
||||
body.set('scope', 'api offline_access');
|
||||
body.set('deviceIdentifier', getOrCreateDeviceIdentifier());
|
||||
body.set('deviceName', guessDeviceName());
|
||||
body.set('deviceType', '14');
|
||||
|
||||
const rememberedToken = options?.useRememberToken ? getRememberTwoFactorToken() : null;
|
||||
if (rememberedToken) {
|
||||
body.set('twoFactorProvider', '5');
|
||||
body.set('twoFactorToken', rememberedToken);
|
||||
} else if (options?.totpCode) {
|
||||
body.set('twoFactorProvider', '0');
|
||||
body.set('twoFactorToken', options.totpCode);
|
||||
if (options.rememberDevice) {
|
||||
body.set('twoFactorRemember', '1');
|
||||
}
|
||||
}
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
if (resp.ok) {
|
||||
saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken);
|
||||
} else if (rememberedToken) {
|
||||
clearRememberTwoFactorToken();
|
||||
}
|
||||
if (!resp.ok) return json;
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<TokenSuccess | null> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'refresh_token');
|
||||
body.set('refresh_token', refreshToken);
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const json = await parseJson<TokenSuccess>(resp);
|
||||
return json || null;
|
||||
}
|
||||
|
||||
export async function registerAccount(args: {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
inviteCode?: string;
|
||||
fallbackIterations: number;
|
||||
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
try {
|
||||
const { email, name, password, inviteCode, fallbackIterations } = args;
|
||||
const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
|
||||
const masterHash = await pbkdf2(masterKey, password, 1, 32);
|
||||
const encKey = await hkdfExpand(masterKey, 'enc', 32);
|
||||
const macKey = await hkdfExpand(masterKey, 'mac', 32);
|
||||
const sym = crypto.getRandomValues(new Uint8Array(64));
|
||||
const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
|
||||
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-1',
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
|
||||
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
|
||||
const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
|
||||
|
||||
const resp = await fetch('/api/accounts/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email.toLowerCase(),
|
||||
name,
|
||||
masterPasswordHash: bytesToBase64(masterHash),
|
||||
key: encryptedVaultKey,
|
||||
kdf: 0,
|
||||
kdfIterations: fallbackIterations,
|
||||
inviteCode: inviteCode || undefined,
|
||||
keys: {
|
||||
publicKey: bytesToBase64(publicKey),
|
||||
encryptedPrivateKey,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const json = await parseJson<TokenError>(resp);
|
||||
return { ok: false, message: json?.error_description || json?.error || 'Register failed' };
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return { ok: false, message: error instanceof Error ? error.message : 'Register failed' };
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
|
||||
return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
const session = getSession();
|
||||
if (!session?.accessToken) throw new Error('Unauthorized');
|
||||
const headers = new Headers(init.headers || {});
|
||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||
|
||||
let resp = await fetch(input, { ...init, headers });
|
||||
if (resp.status !== 401 || !session.refreshToken) return resp;
|
||||
|
||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
||||
if (!refreshed?.access_token) {
|
||||
setSession(null);
|
||||
throw new Error('Session expired');
|
||||
}
|
||||
|
||||
const nextSession: SessionState = {
|
||||
...session,
|
||||
accessToken: refreshed.access_token,
|
||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
||||
};
|
||||
setSession(nextSession);
|
||||
saveSession(nextSession);
|
||||
|
||||
const retryHeaders = new Headers(init.headers || {});
|
||||
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
|
||||
resp = await fetch(input, { ...init, headers: retryHeaders });
|
||||
return resp;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProfile(authedFetch: AuthedFetch): Promise<Profile> {
|
||||
const resp = await authedFetch('/api/accounts/profile');
|
||||
if (!resp.ok) throw new Error('Failed to load profile');
|
||||
const body = await parseJson<Profile>(resp);
|
||||
if (!body) throw new Error('Invalid profile');
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function unlockVaultKey(profileKey: string, masterKey: Uint8Array): Promise<{ symEncKey: string; symMacKey: string }> {
|
||||
const encKey = await hkdfExpand(masterKey, 'enc', 32);
|
||||
const macKey = await hkdfExpand(masterKey, 'mac', 32);
|
||||
const keyBytes = await decryptBw(profileKey, encKey, macKey);
|
||||
if (!keyBytes || keyBytes.length < 64) throw new Error('Invalid profile key');
|
||||
return {
|
||||
symEncKey: bytesToBase64(keyBytes.slice(0, 32)),
|
||||
symMacKey: bytesToBase64(keyBytes.slice(32, 64)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function changeMasterPassword(
|
||||
authedFetch: AuthedFetch,
|
||||
args: {
|
||||
email: string;
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
currentIterations: number;
|
||||
profileKey: string;
|
||||
}
|
||||
): Promise<void> {
|
||||
const current = await deriveLoginHash(args.email, args.currentPassword, args.currentIterations);
|
||||
const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32);
|
||||
const oldMac = await hkdfExpand(current.masterKey, 'mac', 32);
|
||||
const userSym = await decryptBw(args.profileKey, oldEnc, oldMac);
|
||||
const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32);
|
||||
const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32);
|
||||
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
|
||||
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
|
||||
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac);
|
||||
|
||||
const resp = await authedFetch('/api/accounts/password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
currentPasswordHash: current.hash,
|
||||
newMasterPasswordHash: bytesToBase64(nextHash),
|
||||
newKey,
|
||||
kdf: 0,
|
||||
kdfIterations: current.kdfIterations,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Change master password failed');
|
||||
}
|
||||
|
||||
export async function setTotp(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { enabled: boolean; token?: string; secret?: string; masterPasswordHash?: string }
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/accounts/totp', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'TOTP update failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyMasterPassword(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/accounts/verify-password', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'Master password verify failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> {
|
||||
const resp = await authedFetch('/api/accounts/totp');
|
||||
if (!resp.ok) throw new Error('Failed to load TOTP status');
|
||||
const body = (await parseJson<{ enabled?: boolean }>(resp)) || {};
|
||||
return { enabled: !!body.enabled };
|
||||
}
|
||||
|
||||
export async function getTotpRecoveryCode(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<string> {
|
||||
const resp = await authedFetch('/api/accounts/totp/recovery-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'Failed to get recovery code');
|
||||
}
|
||||
const body = (await parseJson<{ code?: string }>(resp)) || {};
|
||||
return String(body.code || '');
|
||||
}
|
||||
|
||||
export async function recoverTwoFactor(
|
||||
email: string,
|
||||
masterPasswordHash: string,
|
||||
recoveryCode: string
|
||||
): Promise<{ newRecoveryCode?: string }> {
|
||||
const resp = await fetch('/identity/accounts/recover-2fa', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
email: email.toLowerCase().trim(),
|
||||
masterPasswordHash,
|
||||
recoveryCode,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'Recover 2FA failed');
|
||||
}
|
||||
return (await parseJson<{ newRecoveryCode?: string }>(resp)) || {};
|
||||
}
|
||||
|
||||
export async function getAuthorizedDevices(authedFetch: AuthedFetch): Promise<AuthorizedDevice[]> {
|
||||
const resp = await authedFetch('/api/devices/authorized');
|
||||
if (!resp.ok) throw new Error(t('txt_load_devices_failed'));
|
||||
const body = await parseJson<{ object: 'list'; data: AuthorizedDevice[] }>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function revokeAuthorizedDeviceTrust(
|
||||
authedFetch: AuthedFetch,
|
||||
deviceIdentifier: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch(`/api/devices/authorized/${encodeURIComponent(deviceIdentifier)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(t('txt_revoke_device_trust_failed'));
|
||||
}
|
||||
|
||||
export async function revokeAllAuthorizedDeviceTrust(authedFetch: AuthedFetch): Promise<void> {
|
||||
const resp = await authedFetch('/api/devices/authorized', { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(t('txt_revoke_all_device_trust_failed'));
|
||||
}
|
||||
|
||||
export async function deleteAuthorizedDevice(
|
||||
authedFetch: AuthedFetch,
|
||||
deviceIdentifier: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
|
||||
}
|
||||
|
||||
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
|
||||
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import { t } from '../i18n';
|
||||
import type {
|
||||
BackupDestinationConfig,
|
||||
BackupDestinationRecord,
|
||||
BackupDestinationType,
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
BackupSettings as AdminBackupSettings,
|
||||
E3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
} from '@shared/backup-schema';
|
||||
import {
|
||||
parseContentDispositionFileName,
|
||||
parseErrorMessage,
|
||||
parseJson,
|
||||
type AuthedFetch,
|
||||
} from './shared';
|
||||
|
||||
export type {
|
||||
BackupDestinationConfig,
|
||||
BackupDestinationRecord,
|
||||
BackupDestinationType,
|
||||
BackupRuntimeState,
|
||||
BackupScheduleConfig,
|
||||
AdminBackupSettings,
|
||||
E3BackupDestination,
|
||||
WebDavBackupDestination,
|
||||
};
|
||||
|
||||
export interface BackupSettingsPortableWrap {
|
||||
userId: string;
|
||||
wrappedKey: string;
|
||||
}
|
||||
|
||||
export interface BackupSettingsPortablePayload {
|
||||
iv: string;
|
||||
ciphertext: string;
|
||||
wraps: BackupSettingsPortableWrap[];
|
||||
}
|
||||
|
||||
export interface BackupSettingsRepairStateResponse {
|
||||
object: 'backup-settings-repair';
|
||||
needsRepair: boolean;
|
||||
portable: BackupSettingsPortablePayload | null;
|
||||
}
|
||||
|
||||
export interface AdminBackupRunResponse {
|
||||
object: 'backup-run';
|
||||
result: {
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
provider: string;
|
||||
remotePath: string;
|
||||
};
|
||||
settings: AdminBackupSettings;
|
||||
}
|
||||
|
||||
export interface RemoteBackupItem {
|
||||
path: string;
|
||||
name: string;
|
||||
isDirectory: boolean;
|
||||
size: number | null;
|
||||
modifiedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RemoteBackupBrowserResponse {
|
||||
object: 'backup-remote-browser';
|
||||
destinationId: string;
|
||||
destinationName: string;
|
||||
provider: BackupDestinationType;
|
||||
currentPath: string;
|
||||
parentPath: string | null;
|
||||
items: RemoteBackupItem[];
|
||||
}
|
||||
|
||||
export interface AdminBackupImportCounts {
|
||||
config: number;
|
||||
users: number;
|
||||
userRevisions: number;
|
||||
folders: number;
|
||||
ciphers: number;
|
||||
attachments: number;
|
||||
sends: number;
|
||||
attachmentFiles: number;
|
||||
sendFiles: number;
|
||||
}
|
||||
|
||||
export interface AdminBackupImportResponse {
|
||||
object: 'instance-backup-import';
|
||||
imported: AdminBackupImportCounts;
|
||||
}
|
||||
|
||||
export interface AdminBackupExportPayload {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export async function exportAdminBackup(authedFetch: AuthedFetch): Promise<AdminBackupExportPayload> {
|
||||
const resp = await authedFetch('/api/admin/backup/export', { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
||||
|
||||
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
|
||||
const fileName = parseContentDispositionFileName(resp, 'nodewarden_backup.zip');
|
||||
const bytes = new Uint8Array(await resp.arrayBuffer());
|
||||
return { fileName, mimeType, bytes };
|
||||
}
|
||||
|
||||
export async function getAdminBackupSettings(authedFetch: AuthedFetch): Promise<AdminBackupSettings> {
|
||||
const resp = await authedFetch('/api/admin/backup/settings', { method: 'GET' });
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_load_failed')));
|
||||
const body = await parseJson<AdminBackupSettings>(resp);
|
||||
if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response'));
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function saveAdminBackupSettings(
|
||||
authedFetch: AuthedFetch,
|
||||
settings: AdminBackupSettings
|
||||
): Promise<AdminBackupSettings> {
|
||||
const resp = await authedFetch('/api/admin/backup/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
|
||||
const body = await parseJson<AdminBackupSettings>(resp);
|
||||
if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response'));
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function getAdminBackupSettingsRepairState(
|
||||
authedFetch: AuthedFetch
|
||||
): Promise<BackupSettingsRepairStateResponse> {
|
||||
const resp = await authedFetch('/api/admin/backup/settings/repair', { method: 'GET' });
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_load_failed')));
|
||||
const body = await parseJson<BackupSettingsRepairStateResponse>(resp);
|
||||
if (!body || typeof body.needsRepair !== 'boolean') {
|
||||
throw new Error(t('txt_backup_settings_invalid_response'));
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function repairAdminBackupSettings(
|
||||
authedFetch: AuthedFetch,
|
||||
settings: AdminBackupSettings
|
||||
): Promise<AdminBackupSettings> {
|
||||
const resp = await authedFetch('/api/admin/backup/settings/repair', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
|
||||
const body = await parseJson<AdminBackupSettings>(resp);
|
||||
if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response'));
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function runAdminBackupNow(
|
||||
authedFetch: AuthedFetch,
|
||||
destinationId?: string | null
|
||||
): Promise<AdminBackupRunResponse> {
|
||||
const resp = await authedFetch('/api/admin/backup/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(destinationId ? { destinationId } : {}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed')));
|
||||
const body = await parseJson<AdminBackupRunResponse>(resp);
|
||||
if (!body?.result || !body?.settings) throw new Error(t('txt_backup_remote_run_invalid_response'));
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function listRemoteBackups(
|
||||
authedFetch: AuthedFetch,
|
||||
destinationId: string,
|
||||
path: string = ''
|
||||
): Promise<RemoteBackupBrowserResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('destinationId', destinationId);
|
||||
if (path) params.set('path', path);
|
||||
const query = `?${params.toString()}`;
|
||||
const resp = await authedFetch(`/api/admin/backup/remote${query}`, { method: 'GET' });
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_load_failed')));
|
||||
const body = await parseJson<RemoteBackupBrowserResponse>(resp);
|
||||
if (!body?.items || typeof body.currentPath !== 'string' || !body.destinationId) throw new Error(t('txt_backup_remote_invalid_response'));
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function downloadRemoteBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
destinationId: string,
|
||||
path: string
|
||||
): Promise<AdminBackupExportPayload> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('destinationId', destinationId);
|
||||
params.set('path', path);
|
||||
const resp = await authedFetch(`/api/admin/backup/remote/download?${params.toString()}`, { method: 'GET' });
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
||||
const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
|
||||
const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip');
|
||||
const bytes = new Uint8Array(await resp.arrayBuffer());
|
||||
return { fileName, mimeType, bytes };
|
||||
}
|
||||
|
||||
export async function deleteRemoteBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
destinationId: string,
|
||||
path: string
|
||||
): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('destinationId', destinationId);
|
||||
params.set('path', path);
|
||||
const resp = await authedFetch(`/api/admin/backup/remote/file?${params.toString()}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
|
||||
}
|
||||
|
||||
export async function restoreRemoteBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
destinationId: string,
|
||||
path: string,
|
||||
replaceExisting: boolean = false
|
||||
): Promise<AdminBackupImportResponse> {
|
||||
const resp = await authedFetch('/api/admin/backup/remote/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ destinationId, path, replaceExisting }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
|
||||
const body = await parseJson<AdminBackupImportResponse>(resp);
|
||||
if (!body?.imported) throw new Error(t('txt_backup_remote_restore_invalid_response'));
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function importAdminBackup(
|
||||
authedFetch: AuthedFetch,
|
||||
file: File,
|
||||
replaceExisting: boolean = false
|
||||
): Promise<AdminBackupImportResponse> {
|
||||
const formData = new FormData();
|
||||
formData.set('file', file, file.name || 'nodewarden_backup.zip');
|
||||
if (replaceExisting) {
|
||||
formData.set('replaceExisting', '1');
|
||||
}
|
||||
|
||||
const resp = await authedFetch('/api/admin/backup/import', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_import_failed')));
|
||||
|
||||
const body = await parseJson<AdminBackupImportResponse>(resp);
|
||||
if (!body?.imported) throw new Error(t('txt_backup_import_invalid_response'));
|
||||
return body;
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, pbkdf2 } from '../crypto';
|
||||
import type { Send, SendDraft, SessionState } from '../types';
|
||||
import { chunkArray, createApiError, parseErrorMessage, parseJson, type AuthedFetch } from './shared';
|
||||
|
||||
function toIsoDateFromDays(value: string, required: boolean): string | null {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
if (required) throw new Error('Deletion days is required');
|
||||
return null;
|
||||
}
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
if (required) throw new Error('Invalid deletion days');
|
||||
throw new Error('Invalid expiration days');
|
||||
}
|
||||
if (!required && n === 0) return null;
|
||||
const date = new Date(Date.now() + Math.floor(n) * 24 * 60 * 60 * 1000);
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string): Uint8Array {
|
||||
const raw = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = raw + '='.repeat((4 - (raw.length % 4)) % 4);
|
||||
return base64ToBytes(padded);
|
||||
}
|
||||
|
||||
const SEND_KEY_SALT = 'bitwarden-send';
|
||||
const SEND_KEY_PURPOSE = 'send';
|
||||
const SEND_KEY_SEED_BYTES = 16;
|
||||
const SEND_PASSWORD_ITERATIONS = 100000;
|
||||
|
||||
async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array): Promise<string | null> {
|
||||
const s = String(value || '');
|
||||
if (!s.trim()) return null;
|
||||
return encryptBw(new TextEncoder().encode(s), enc, mac);
|
||||
}
|
||||
|
||||
async function toSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> {
|
||||
if (sendKeyMaterial.length >= 64) {
|
||||
return { enc: sendKeyMaterial.slice(0, 32), mac: sendKeyMaterial.slice(32, 64) };
|
||||
}
|
||||
const derived = await hkdf(sendKeyMaterial, SEND_KEY_SALT, SEND_KEY_PURPOSE, 64);
|
||||
return { enc: derived.slice(0, 32), mac: derived.slice(32, 64) };
|
||||
}
|
||||
|
||||
async function hashSendPasswordB64(password: string, sendKeyMaterial: Uint8Array): Promise<string> {
|
||||
const hash = await pbkdf2(password, sendKeyMaterial, SEND_PASSWORD_ITERATIONS, 32);
|
||||
return bytesToBase64(hash);
|
||||
}
|
||||
|
||||
function parseMaxAccessCountRaw(value: string): number | null {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return null;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || n < 0) throw new Error('Invalid max access count');
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
export async function getSends(authedFetch: AuthedFetch): Promise<Send[]> {
|
||||
const resp = await authedFetch('/api/sends');
|
||||
if (!resp.ok) throw new Error('Failed to load sends');
|
||||
const body = await parseJson<{ object: 'list'; data: Send[] }>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function createSend(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
draft: SendDraft
|
||||
): Promise<Send> {
|
||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||
const userEnc = base64ToBytes(session.symEncKey);
|
||||
const userMac = base64ToBytes(session.symMacKey);
|
||||
const sendKeyMaterial = crypto.getRandomValues(new Uint8Array(SEND_KEY_SEED_BYTES));
|
||||
const sendKeyForUser = await encryptBw(sendKeyMaterial, userEnc, userMac);
|
||||
const sendKey = await toSendKeyParts(sendKeyMaterial);
|
||||
const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac);
|
||||
const notesCipher = await encryptTextValue(draft.notes || '', sendKey.enc, sendKey.mac);
|
||||
|
||||
const deletionIso = toIsoDateFromDays(draft.deletionDays, true)!;
|
||||
const expirationIso = toIsoDateFromDays(draft.expirationDays, false);
|
||||
const maxAccessCount = parseMaxAccessCountRaw(draft.maxAccessCount);
|
||||
const password = String(draft.password || '');
|
||||
const passwordHash = password ? await hashSendPasswordB64(password, sendKeyMaterial) : null;
|
||||
|
||||
if (draft.type === 'text') {
|
||||
const text = String(draft.text || '').trim();
|
||||
if (!text) throw new Error('Send text is required');
|
||||
const textCipher = await encryptTextValue(text, sendKey.enc, sendKey.mac);
|
||||
|
||||
const payload = {
|
||||
type: 0,
|
||||
name: nameCipher,
|
||||
notes: notesCipher,
|
||||
key: sendKeyForUser,
|
||||
text: {
|
||||
text: textCipher,
|
||||
hidden: false,
|
||||
},
|
||||
maxAccessCount,
|
||||
password: passwordHash,
|
||||
hideEmail: false,
|
||||
disabled: !!draft.disabled,
|
||||
deletionDate: deletionIso,
|
||||
expirationDate: expirationIso,
|
||||
};
|
||||
|
||||
const resp = await authedFetch('/api/sends', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Create send failed'));
|
||||
const body = await parseJson<Send>(resp);
|
||||
if (!body?.id) throw new Error('Create send failed');
|
||||
return body;
|
||||
}
|
||||
|
||||
if (!draft.file) throw new Error('File is required');
|
||||
const fileNameCipher = await encryptTextValue(draft.file.name, sendKey.enc, sendKey.mac);
|
||||
if (!fileNameCipher) throw new Error('Invalid file name');
|
||||
const plainFileBytes = new Uint8Array(await draft.file.arrayBuffer());
|
||||
const encryptedFileBytes = await encryptBwFileData(plainFileBytes, sendKey.enc, sendKey.mac);
|
||||
|
||||
const fileResp = await authedFetch('/api/sends/file/v2', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 1,
|
||||
name: nameCipher,
|
||||
notes: notesCipher,
|
||||
key: sendKeyForUser,
|
||||
file: {
|
||||
fileName: fileNameCipher,
|
||||
},
|
||||
fileLength: encryptedFileBytes.byteLength,
|
||||
maxAccessCount,
|
||||
password: passwordHash,
|
||||
hideEmail: false,
|
||||
disabled: !!draft.disabled,
|
||||
deletionDate: deletionIso,
|
||||
expirationDate: expirationIso,
|
||||
}),
|
||||
});
|
||||
if (!fileResp.ok) throw new Error(await parseErrorMessage(fileResp, 'Create file send failed'));
|
||||
|
||||
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send }>(fileResp);
|
||||
const uploadUrl = uploadInfo?.url;
|
||||
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
||||
|
||||
const formData = new FormData();
|
||||
const encryptedBlob = new Blob([encryptedFileBytes as unknown as BlobPart], { type: 'application/octet-stream' });
|
||||
formData.set('data', encryptedBlob, fileNameCipher);
|
||||
const uploadResp = await authedFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!uploadResp.ok) throw new Error(await parseErrorMessage(uploadResp, 'Upload send file failed'));
|
||||
if (!uploadInfo?.sendResponse?.id) throw new Error('Create file send failed');
|
||||
return uploadInfo.sendResponse;
|
||||
}
|
||||
|
||||
export async function updateSend(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
send: Send,
|
||||
draft: SendDraft
|
||||
): Promise<Send> {
|
||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||
if (!send.key) throw new Error('Send key unavailable');
|
||||
const userEnc = base64ToBytes(session.symEncKey);
|
||||
const userMac = base64ToBytes(session.symMacKey);
|
||||
const sendKeyMaterial = await decryptBw(send.key, userEnc, userMac);
|
||||
const sendKey = await toSendKeyParts(sendKeyMaterial);
|
||||
const nameCipher = await encryptTextValue(draft.name || '', sendKey.enc, sendKey.mac);
|
||||
const notesCipher = await encryptTextValue(draft.notes || '', sendKey.enc, sendKey.mac);
|
||||
|
||||
const deletionIso = toIsoDateFromDays(draft.deletionDays, true)!;
|
||||
const expirationIso = toIsoDateFromDays(draft.expirationDays, false);
|
||||
const maxAccessCount = parseMaxAccessCountRaw(draft.maxAccessCount);
|
||||
|
||||
if (draft.type === 'file' && draft.file) {
|
||||
throw new Error('Updating file content is not supported yet');
|
||||
}
|
||||
|
||||
const textCipher = await encryptTextValue(String(draft.text || ''), sendKey.enc, sendKey.mac);
|
||||
|
||||
const passwordRaw = String(draft.password || '');
|
||||
const passwordHash = passwordRaw ? await hashSendPasswordB64(passwordRaw, sendKeyMaterial) : null;
|
||||
|
||||
const payload = {
|
||||
id: send.id,
|
||||
type: draft.type === 'file' ? 1 : 0,
|
||||
name: nameCipher,
|
||||
notes: notesCipher,
|
||||
key: send.key,
|
||||
text: {
|
||||
text: textCipher,
|
||||
hidden: false,
|
||||
},
|
||||
maxAccessCount,
|
||||
password: passwordHash,
|
||||
hideEmail: false,
|
||||
disabled: !!draft.disabled,
|
||||
deletionDate: deletionIso,
|
||||
expirationDate: expirationIso,
|
||||
};
|
||||
|
||||
const resp = await authedFetch(`/api/sends/${encodeURIComponent(send.id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Update send failed'));
|
||||
const body = await parseJson<Send>(resp);
|
||||
if (!body?.id) throw new Error('Update send failed');
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function deleteSend(authedFetch: AuthedFetch, sendId: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/sends/${encodeURIComponent(sendId)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete send failed'));
|
||||
}
|
||||
|
||||
export async function bulkDeleteSends(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, 200)) {
|
||||
const resp = await authedFetch('/api/sends/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk delete sends failed');
|
||||
}
|
||||
}
|
||||
|
||||
async function buildPublicSendAccessPayload(password?: string, keyPart?: string | null): Promise<Record<string, unknown>> {
|
||||
const payload: Record<string, unknown> = {};
|
||||
const plainPassword = String(password || '').trim();
|
||||
if (!plainPassword) return payload;
|
||||
|
||||
if (keyPart) {
|
||||
try {
|
||||
const sendKeyMaterial = base64UrlToBytes(keyPart);
|
||||
const passwordHashB64 = await hashSendPasswordB64(plainPassword, sendKeyMaterial);
|
||||
payload.passwordHash = passwordHashB64;
|
||||
payload.password_hash_b64 = passwordHashB64;
|
||||
payload.passwordHashB64 = passwordHashB64;
|
||||
} catch {
|
||||
// Key material invalid; server will reject as unauthorized.
|
||||
}
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function accessPublicSend(accessId: string, keyPart?: string | null, password?: string): Promise<any> {
|
||||
const payload = await buildPublicSendAccessPayload(password, keyPart);
|
||||
const resp = await fetch(`/api/sends/access/${encodeURIComponent(accessId)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const message = await parseErrorMessage(resp, 'Failed to access send');
|
||||
throw createApiError(message, resp.status);
|
||||
}
|
||||
return (await parseJson<any>(resp)) || null;
|
||||
}
|
||||
|
||||
export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise<string> {
|
||||
const payload = await buildPublicSendAccessPayload(password, keyPart);
|
||||
const resp = await fetch(`/api/sends/${encodeURIComponent(sendId)}/access/file/${encodeURIComponent(fileId)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const message = await parseErrorMessage(resp, 'Failed to access send file');
|
||||
throw createApiError(message, resp.status);
|
||||
}
|
||||
const body = await parseJson<{ url?: string }>(resp);
|
||||
if (!body?.url) throw new Error('Missing file URL');
|
||||
return body.url;
|
||||
}
|
||||
|
||||
export async function decryptPublicSend(accessData: any, urlSafeKey: string): Promise<any> {
|
||||
const sendKeyMaterial = base64UrlToBytes(urlSafeKey);
|
||||
const sendKey = await toSendKeyParts(sendKeyMaterial);
|
||||
const out: any = { ...accessData };
|
||||
out.decName = await decryptStr(accessData?.name || '', sendKey.enc, sendKey.mac);
|
||||
if (accessData?.text?.text) {
|
||||
out.decText = await decryptStr(accessData.text.text, sendKey.enc, sendKey.mac);
|
||||
}
|
||||
if (accessData?.file?.fileName) {
|
||||
try {
|
||||
out.decFileName = await decryptStr(accessData.file.fileName, sendKey.enc, sendKey.mac);
|
||||
} catch {
|
||||
out.decFileName = String(accessData.file.fileName);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptPublicSendFileBytes(
|
||||
encryptedBytes: ArrayBuffer | Uint8Array,
|
||||
urlSafeKey: string
|
||||
): Promise<Uint8Array> {
|
||||
const sendKeyMaterial = base64UrlToBytes(urlSafeKey);
|
||||
const sendKey = await toSendKeyParts(sendKeyMaterial);
|
||||
const encrypted = encryptedBytes instanceof Uint8Array ? encryptedBytes : new Uint8Array(encryptedBytes);
|
||||
return decryptBwFileData(encrypted, sendKey.enc, sendKey.mac);
|
||||
}
|
||||
|
||||
export function buildSendShareKey(sendKeyEncrypted: string, userEncB64: string, userMacB64: string): Promise<string> {
|
||||
const userEnc = base64ToBytes(userEncB64);
|
||||
const userMac = base64ToBytes(userMacB64);
|
||||
return decryptBw(sendKeyEncrypted, userEnc, userMac).then((keyMaterial) => bytesToBase64Url(keyMaterial));
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { t } from '../i18n';
|
||||
import type { SessionState, TokenError } from '../types';
|
||||
|
||||
export type AuthedFetch = (input: string, init?: RequestInit) => Promise<Response>;
|
||||
export type SessionSetter = (next: SessionState | null) => void;
|
||||
|
||||
export const BULK_API_CHUNK_SIZE = 200;
|
||||
|
||||
export function chunkArray<T>(items: T[], size: number): T[][] {
|
||||
if (items.length <= size) return [items];
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
chunks.push(items.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export async function parseJson<T>(response: Response): Promise<T | null> {
|
||||
const text = await response.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseContentDispositionFileName(response: Response, fallback: string): string {
|
||||
const header = String(response.headers.get('Content-Disposition') || '').trim();
|
||||
if (!header) return fallback;
|
||||
|
||||
const utf8Match = header.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
|
||||
if (utf8Match?.[1]) {
|
||||
try {
|
||||
return decodeURIComponent(utf8Match[1]);
|
||||
} catch {
|
||||
// Ignore malformed filename*= values and fall back to the plain filename.
|
||||
}
|
||||
}
|
||||
|
||||
const plainMatch = header.match(/filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i);
|
||||
const raw = plainMatch?.[1] || plainMatch?.[2] || '';
|
||||
const normalized = String(raw).trim().replace(/^"+|"+$/g, '');
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
export async function parseErrorMessage(resp: Response, fallback: string): Promise<string> {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
return body?.error_description || body?.error || fallback;
|
||||
}
|
||||
|
||||
export function createApiError(message: string, status?: number): Error & { status?: number } {
|
||||
const error = new Error(message) as Error & { status?: number };
|
||||
if (status !== undefined) error.status = status;
|
||||
return error;
|
||||
}
|
||||
|
||||
export function requiredError(messageKey: string): never {
|
||||
throw new Error(t(messageKey));
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto';
|
||||
import type {
|
||||
Cipher,
|
||||
Folder,
|
||||
ListResponse,
|
||||
SessionState,
|
||||
VaultDraft,
|
||||
VaultDraftField,
|
||||
} from '../types';
|
||||
import {
|
||||
BULK_API_CHUNK_SIZE,
|
||||
chunkArray,
|
||||
parseErrorMessage,
|
||||
parseJson,
|
||||
type AuthedFetch,
|
||||
} from './shared';
|
||||
|
||||
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
||||
const resp = await authedFetch('/api/folders');
|
||||
if (!resp.ok) throw new Error('Failed to load folders');
|
||||
const body = await parseJson<ListResponse<Folder>>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
name: string
|
||||
): Promise<{ id: string; name?: string | null }> {
|
||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||
const enc = base64ToBytes(session.symEncKey);
|
||||
const mac = base64ToBytes(session.symMacKey);
|
||||
const encryptedName = await encryptBw(new TextEncoder().encode(name), enc, mac);
|
||||
const resp = await authedFetch('/api/folders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: encryptedName }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Create folder failed');
|
||||
const body = await parseJson<{ id?: string; name?: string | null }>(resp);
|
||||
if (!body?.id) throw new Error('Create folder failed');
|
||||
return { id: body.id, name: body.name ?? null };
|
||||
}
|
||||
|
||||
export async function encryptFolderImportName(session: SessionState, name: string): Promise<string> {
|
||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||
const enc = base64ToBytes(session.symEncKey);
|
||||
const mac = base64ToBytes(session.symMacKey);
|
||||
return encryptBw(new TextEncoder().encode(name), enc, mac);
|
||||
}
|
||||
|
||||
export async function deleteFolder(authedFetch: AuthedFetch, folderId: string): Promise<void> {
|
||||
const id = String(folderId || '').trim();
|
||||
if (!id) throw new Error('Folder id is required');
|
||||
const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!resp.ok) throw new Error('Delete folder failed');
|
||||
}
|
||||
|
||||
export async function bulkDeleteFolders(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
|
||||
const resp = await authedFetch('/api/folders/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk delete folders failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateFolder(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
folderId: string,
|
||||
name: string
|
||||
): Promise<void> {
|
||||
const id = String(folderId || '').trim();
|
||||
if (!id) throw new Error('Folder id is required');
|
||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||
const enc = base64ToBytes(session.symEncKey);
|
||||
const mac = base64ToBytes(session.symMacKey);
|
||||
const encryptedName = await encryptBw(new TextEncoder().encode(name), enc, mac);
|
||||
const resp = await authedFetch(`/api/folders/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: encryptedName }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Update folder failed');
|
||||
}
|
||||
|
||||
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
|
||||
const resp = await authedFetch('/api/ciphers?deleted=true');
|
||||
if (!resp.ok) throw new Error('Failed to load ciphers');
|
||||
const body = await parseJson<ListResponse<Cipher>>(resp);
|
||||
return body?.data || [];
|
||||
}
|
||||
|
||||
export interface CiphersImportPayload {
|
||||
ciphers: Array<Record<string, unknown>>;
|
||||
folders: Array<{ name: string }>;
|
||||
folderRelationships: Array<{ key: number; value: number }>;
|
||||
}
|
||||
|
||||
export interface ImportedCipherMapEntry {
|
||||
index: number;
|
||||
sourceId: string | null;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export async function importCiphers(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: CiphersImportPayload,
|
||||
options?: { returnCipherMap?: boolean }
|
||||
): Promise<ImportedCipherMapEntry[] | null> {
|
||||
const returnCipherMap = !!options?.returnCipherMap;
|
||||
const url = returnCipherMap ? '/api/ciphers/import?returnCipherMap=1' : '/api/ciphers/import';
|
||||
const totalItems = (payload.folders?.length || 0) + (payload.ciphers?.length || 0);
|
||||
const responses: ImportedCipherMapEntry[] = [];
|
||||
const folderChunkSize = Math.min(BULK_API_CHUNK_SIZE, Math.max(0, BULK_API_CHUNK_SIZE - 1));
|
||||
|
||||
if (totalItems <= BULK_API_CHUNK_SIZE || payload.folders.length > folderChunkSize) {
|
||||
const resp = await authedFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed'));
|
||||
if (!returnCipherMap) return null;
|
||||
const body =
|
||||
(await parseJson<{
|
||||
cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>;
|
||||
}>(resp)) || {};
|
||||
if (!Array.isArray(body.cipherMap)) return [];
|
||||
for (const row of body.cipherMap) {
|
||||
const index = Number(row?.index);
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!Number.isFinite(index) || !id) continue;
|
||||
const sourceRaw = String(row?.sourceId || '').trim();
|
||||
responses.push({
|
||||
index,
|
||||
id,
|
||||
sourceId: sourceRaw || null,
|
||||
});
|
||||
}
|
||||
return responses;
|
||||
}
|
||||
|
||||
const folders = payload.folders || [];
|
||||
const relationshipsByCipher = new Map<number, number | null>();
|
||||
for (const relation of payload.folderRelationships || []) {
|
||||
relationshipsByCipher.set(Number(relation.key), Number(relation.value));
|
||||
}
|
||||
|
||||
for (const cipherChunkStart of Array.from({ length: Math.ceil(payload.ciphers.length / BULK_API_CHUNK_SIZE) }, (_, i) => i * BULK_API_CHUNK_SIZE)) {
|
||||
const cipherChunk = payload.ciphers.slice(cipherChunkStart, cipherChunkStart + BULK_API_CHUNK_SIZE);
|
||||
const usedFolderIndices = Array.from(
|
||||
new Set(
|
||||
cipherChunk
|
||||
.map((_, localIndex) => relationshipsByCipher.get(cipherChunkStart + localIndex))
|
||||
.filter((value): value is number => Number.isFinite(value as number) && (value as number) >= 0)
|
||||
)
|
||||
);
|
||||
const folderIndexMap = new Map<number, number>();
|
||||
const chunkFolders = usedFolderIndices.map((folderIndex, localIndex) => {
|
||||
folderIndexMap.set(folderIndex, localIndex);
|
||||
return folders[folderIndex];
|
||||
});
|
||||
const chunkRelationships = cipherChunk
|
||||
.map((_, localIndex) => {
|
||||
const originalCipherIndex = cipherChunkStart + localIndex;
|
||||
const originalFolderIndex = relationshipsByCipher.get(originalCipherIndex);
|
||||
if (!Number.isFinite(originalFolderIndex as number)) return null;
|
||||
const localFolderIndex = folderIndexMap.get(Number(originalFolderIndex));
|
||||
if (!Number.isFinite(localFolderIndex as number)) return null;
|
||||
return { key: localIndex, value: Number(localFolderIndex) };
|
||||
})
|
||||
.filter((value): value is { key: number; value: number } => !!value);
|
||||
|
||||
const resp = await authedFetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ciphers: cipherChunk,
|
||||
folders: chunkFolders,
|
||||
folderRelationships: chunkRelationships,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Import failed'));
|
||||
if (!returnCipherMap) continue;
|
||||
const body =
|
||||
(await parseJson<{
|
||||
cipherMap?: Array<{ index?: number; sourceId?: string | null; id?: string }>;
|
||||
}>(resp)) || {};
|
||||
for (const row of body.cipherMap || []) {
|
||||
const localIndex = Number(row?.index);
|
||||
const id = String(row?.id || '').trim();
|
||||
if (!Number.isFinite(localIndex) || !id) continue;
|
||||
const sourceRaw = String(row?.sourceId || '').trim();
|
||||
responses.push({
|
||||
index: cipherChunkStart + localIndex,
|
||||
id,
|
||||
sourceId: sourceRaw || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
return returnCipherMap ? responses : null;
|
||||
}
|
||||
|
||||
export interface AttachmentDownloadInfo {
|
||||
id: string;
|
||||
url: string;
|
||||
fileName: string | null;
|
||||
key: string | null;
|
||||
size: string | null;
|
||||
sizeName: string | null;
|
||||
}
|
||||
|
||||
export async function getAttachmentDownloadInfo(
|
||||
authedFetch: AuthedFetch,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<AttachmentDownloadInfo> {
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}/attachment/${encodeURIComponent(attachmentId)}`);
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Failed to load attachment'));
|
||||
const body =
|
||||
(await parseJson<{
|
||||
id?: string;
|
||||
url?: string;
|
||||
fileName?: string | null;
|
||||
key?: string | null;
|
||||
size?: string | null;
|
||||
sizeName?: string | null;
|
||||
}>(resp)) || {};
|
||||
const id = String(body.id || attachmentId || '').trim();
|
||||
const url = String(body.url || '').trim();
|
||||
if (!id || !url) throw new Error('Invalid attachment download response');
|
||||
return {
|
||||
id,
|
||||
url,
|
||||
fileName: body.fileName ?? null,
|
||||
key: body.key ?? null,
|
||||
size: body.size ?? null,
|
||||
sizeName: body.sizeName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function looksLikeCipherString(value: unknown): boolean {
|
||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
export async function uploadCipherAttachment(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
cipherId: string,
|
||||
file: File,
|
||||
cipherForKey?: Cipher | null
|
||||
): Promise<void> {
|
||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||
const id = String(cipherId || '').trim();
|
||||
if (!id) throw new Error('Cipher id is required');
|
||||
if (!file) throw new Error('File is required');
|
||||
|
||||
const userEnc = base64ToBytes(session.symEncKey);
|
||||
const userMac = base64ToBytes(session.symMacKey);
|
||||
const itemKeys = await getCipherKeys(cipherForKey || null, userEnc, userMac);
|
||||
|
||||
const encryptedFileName = await encryptTextValue(file.name, itemKeys.enc, itemKeys.mac);
|
||||
if (!encryptedFileName) throw new Error('Invalid attachment name');
|
||||
|
||||
const attachmentRawKey = crypto.getRandomValues(new Uint8Array(64));
|
||||
const attachmentWrappedKey = await encryptBw(attachmentRawKey, itemKeys.enc, itemKeys.mac);
|
||||
const fileBytes = new Uint8Array(await file.arrayBuffer());
|
||||
const encryptedBytes = await encryptBwFileData(fileBytes, attachmentRawKey.slice(0, 32), attachmentRawKey.slice(32, 64));
|
||||
|
||||
const metaResp = await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/attachment/v2`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
fileName: encryptedFileName,
|
||||
key: attachmentWrappedKey,
|
||||
fileSize: encryptedBytes.byteLength,
|
||||
}),
|
||||
});
|
||||
if (!metaResp.ok) throw new Error(await parseErrorMessage(metaResp, 'Create attachment failed'));
|
||||
|
||||
const meta =
|
||||
(await parseJson<{
|
||||
attachmentId?: string;
|
||||
url?: string;
|
||||
}>(metaResp)) || {};
|
||||
const attachmentId = String(meta.attachmentId || '').trim();
|
||||
const uploadUrl = String(meta.url || '').trim();
|
||||
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
|
||||
|
||||
const payload = new ArrayBuffer(encryptedBytes.byteLength);
|
||||
new Uint8Array(payload).set(encryptedBytes);
|
||||
const formData = new FormData();
|
||||
formData.set('data', new Blob([payload], { type: 'application/octet-stream' }), encryptedFileName);
|
||||
|
||||
const uploadResp = await authedFetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (!uploadResp.ok) {
|
||||
try {
|
||||
await authedFetch(`/api/ciphers/${encodeURIComponent(id)}/attachment/${encodeURIComponent(attachmentId)}`, { method: 'DELETE' });
|
||||
} catch {
|
||||
// ignore rollback failure
|
||||
}
|
||||
throw new Error(await parseErrorMessage(uploadResp, 'Upload attachment failed'));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCipherAttachment(
|
||||
authedFetch: AuthedFetch,
|
||||
cipherId: string,
|
||||
attachmentId: string
|
||||
): Promise<void> {
|
||||
const cid = String(cipherId || '').trim();
|
||||
const aid = String(attachmentId || '').trim();
|
||||
if (!cid || !aid) throw new Error('Attachment id is required');
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cid)}/attachment/${encodeURIComponent(aid)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, 'Delete attachment failed'));
|
||||
}
|
||||
|
||||
export async function downloadCipherAttachmentDecrypted(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
cipher: Cipher,
|
||||
attachmentId: string
|
||||
): Promise<{ fileName: string; bytes: Uint8Array }> {
|
||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||
const cid = String(cipher?.id || '').trim();
|
||||
const aid = String(attachmentId || '').trim();
|
||||
if (!cid || !aid) throw new Error('Attachment id is required');
|
||||
|
||||
const info = await getAttachmentDownloadInfo(authedFetch, cid, aid);
|
||||
const rawResp = await fetch(info.url, { cache: 'no-store' });
|
||||
if (!rawResp.ok) throw new Error('Download attachment failed');
|
||||
const encryptedBytes = new Uint8Array(await rawResp.arrayBuffer());
|
||||
|
||||
const userEnc = base64ToBytes(session.symEncKey);
|
||||
const userMac = base64ToBytes(session.symMacKey);
|
||||
const itemKeys = await getCipherKeys(cipher, userEnc, userMac);
|
||||
|
||||
let fileEnc = itemKeys.enc;
|
||||
let fileMac = itemKeys.mac;
|
||||
const keyCipher = String(info.key || '').trim();
|
||||
if (keyCipher && looksLikeCipherString(keyCipher)) {
|
||||
try {
|
||||
const fileRawKey = await decryptBw(keyCipher, itemKeys.enc, itemKeys.mac);
|
||||
if (fileRawKey.length >= 64) {
|
||||
fileEnc = fileRawKey.slice(0, 32);
|
||||
fileMac = fileRawKey.slice(32, 64);
|
||||
}
|
||||
} catch {
|
||||
// fallback to item key
|
||||
}
|
||||
}
|
||||
|
||||
const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac);
|
||||
|
||||
const fileNameRaw = String(info.fileName || '').trim();
|
||||
let fileName = fileNameRaw || `attachment-${aid}`;
|
||||
if (fileNameRaw && looksLikeCipherString(fileNameRaw)) {
|
||||
try {
|
||||
fileName = (await decryptStr(fileNameRaw, itemKeys.enc, itemKeys.mac)) || fileName;
|
||||
} catch {
|
||||
// keep fallback name
|
||||
}
|
||||
}
|
||||
|
||||
return { fileName, bytes: plainBytes };
|
||||
}
|
||||
|
||||
function asNullable(v: string): string | null {
|
||||
const s = String(v || '').trim();
|
||||
return s ? s : null;
|
||||
}
|
||||
|
||||
function parseFieldType(v: number | string): 0 | 1 | 2 | 3 {
|
||||
if (typeof v === 'number') {
|
||||
if (v === 1 || v === 2 || v === 3) return v;
|
||||
return 0;
|
||||
}
|
||||
const s = String(v).trim().toLowerCase();
|
||||
if (s === '1' || s === 'hidden') return 1;
|
||||
if (s === '2' || s === 'boolean' || s === 'checkbox') return 2;
|
||||
if (s === '3' || s === 'linked' || s === 'link') return 3;
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array): Promise<string | null> {
|
||||
const s = String(value || '');
|
||||
if (!s.trim()) return null;
|
||||
return encryptBw(new TextEncoder().encode(s), enc, mac);
|
||||
}
|
||||
|
||||
async function encryptCustomFields(
|
||||
fields: VaultDraftField[],
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<Array<{ type: number; name: string | null; value: string | null }>> {
|
||||
const out: Array<{ type: number; name: string | null; value: string | null }> = [];
|
||||
for (const field of fields || []) {
|
||||
const label = String(field.label || '').trim();
|
||||
if (!label) continue;
|
||||
out.push({
|
||||
type: parseFieldType(field.type),
|
||||
name: await encryptTextValue(label, enc, mac),
|
||||
value: await encryptTextValue(String(field.value || ''), enc, mac),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function encryptUris(uris: string[], enc: Uint8Array, mac: Uint8Array): Promise<Array<{ uri: string | null; match: null }>> {
|
||||
const out: Array<{ uri: string | null; match: null }> = [];
|
||||
for (const uri of uris || []) {
|
||||
const trimmed = String(uri || '').trim();
|
||||
if (!trimmed) continue;
|
||||
out.push({ uri: await encryptTextValue(trimmed, enc, mac), match: null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function toIsoDateOrNow(value: unknown): string {
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return new Date().toISOString();
|
||||
const parsed = new Date(raw);
|
||||
if (!Number.isFinite(parsed.getTime())) return new Date().toISOString();
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
async function encryptMaybeFidoValue(
|
||||
value: unknown,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array,
|
||||
fallback = ''
|
||||
): Promise<string> {
|
||||
const normalized = String(value ?? '').trim() || fallback;
|
||||
if (looksLikeCipherString(normalized)) return normalized;
|
||||
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
||||
}
|
||||
|
||||
async function encryptMaybeNullableFidoValue(
|
||||
value: unknown,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<string | null> {
|
||||
const normalized = String(value ?? '').trim();
|
||||
if (!normalized) return null;
|
||||
if (looksLikeCipherString(normalized)) return normalized;
|
||||
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
||||
}
|
||||
|
||||
async function normalizeFido2Credentials(
|
||||
credentials: Array<Record<string, unknown>> | null | undefined,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<Array<Record<string, unknown>> | null> {
|
||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
for (const credential of credentials) {
|
||||
if (!credential || typeof credential !== 'object') continue;
|
||||
out.push({
|
||||
credentialId: await encryptMaybeFidoValue(credential.credentialId, enc, mac),
|
||||
keyType: await encryptMaybeFidoValue(credential.keyType, enc, mac, 'public-key'),
|
||||
keyAlgorithm: await encryptMaybeFidoValue(credential.keyAlgorithm, enc, mac, 'ECDSA'),
|
||||
keyCurve: await encryptMaybeFidoValue(credential.keyCurve, enc, mac, 'P-256'),
|
||||
keyValue: await encryptMaybeFidoValue(credential.keyValue, enc, mac),
|
||||
rpId: await encryptMaybeFidoValue(credential.rpId, enc, mac),
|
||||
rpName: await encryptMaybeNullableFidoValue(credential.rpName, enc, mac),
|
||||
userHandle: await encryptMaybeNullableFidoValue(credential.userHandle, enc, mac),
|
||||
userName: await encryptMaybeNullableFidoValue(credential.userName, enc, mac),
|
||||
userDisplayName: await encryptMaybeNullableFidoValue(credential.userDisplayName, enc, mac),
|
||||
counter: await encryptMaybeFidoValue(credential.counter, enc, mac, '0'),
|
||||
discoverable: await encryptMaybeFidoValue(credential.discoverable, enc, mac, 'false'),
|
||||
creationDate: toIsoDateOrNow(credential.creationDate),
|
||||
});
|
||||
}
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
async function getCipherKeys(
|
||||
cipher: Cipher | null,
|
||||
userEnc: Uint8Array,
|
||||
userMac: Uint8Array
|
||||
): Promise<{ enc: Uint8Array; mac: Uint8Array; key: string | null }> {
|
||||
if (cipher?.key) {
|
||||
try {
|
||||
const raw = await decryptBw(cipher.key, userEnc, userMac);
|
||||
if (raw.length >= 64) return { enc: raw.slice(0, 32), mac: raw.slice(32, 64), key: cipher.key };
|
||||
} catch {
|
||||
// use user key
|
||||
}
|
||||
}
|
||||
return { enc: userEnc, mac: userMac, key: null };
|
||||
}
|
||||
|
||||
async function buildCipherPayload(
|
||||
session: SessionState,
|
||||
draft: VaultDraft,
|
||||
cipher: Cipher | null
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!session.symEncKey || !session.symMacKey) throw new Error('Vault key unavailable');
|
||||
const userEnc = base64ToBytes(session.symEncKey);
|
||||
const userMac = base64ToBytes(session.symMacKey);
|
||||
const keys = await getCipherKeys(cipher, userEnc, userMac);
|
||||
const type = Number(draft.type || cipher?.type || 1);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
type,
|
||||
favorite: !!draft.favorite,
|
||||
folderId: asNullable(draft.folderId),
|
||||
reprompt: draft.reprompt ? 1 : 0,
|
||||
name: await encryptTextValue(draft.name, keys.enc, keys.mac),
|
||||
notes: await encryptTextValue(draft.notes, keys.enc, keys.mac),
|
||||
login: null,
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: null,
|
||||
sshKey: null,
|
||||
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
|
||||
};
|
||||
|
||||
if (cipher?.id) {
|
||||
payload.id = cipher.id;
|
||||
payload.key = keys.key;
|
||||
}
|
||||
|
||||
if (type === 1) {
|
||||
const existingFido2 =
|
||||
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
|
||||
? (cipher.login as any).fido2Credentials
|
||||
: draft.loginFido2Credentials;
|
||||
payload.login = {
|
||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
||||
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
|
||||
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
||||
};
|
||||
} else if (type === 3) {
|
||||
payload.card = {
|
||||
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
|
||||
number: await encryptTextValue(draft.cardNumber, keys.enc, keys.mac),
|
||||
brand: await encryptTextValue(draft.cardBrand, keys.enc, keys.mac),
|
||||
expMonth: await encryptTextValue(draft.cardExpMonth, keys.enc, keys.mac),
|
||||
expYear: await encryptTextValue(draft.cardExpYear, keys.enc, keys.mac),
|
||||
code: await encryptTextValue(draft.cardCode, keys.enc, keys.mac),
|
||||
};
|
||||
} else if (type === 4) {
|
||||
payload.identity = {
|
||||
title: await encryptTextValue(draft.identTitle, keys.enc, keys.mac),
|
||||
firstName: await encryptTextValue(draft.identFirstName, keys.enc, keys.mac),
|
||||
middleName: await encryptTextValue(draft.identMiddleName, keys.enc, keys.mac),
|
||||
lastName: await encryptTextValue(draft.identLastName, keys.enc, keys.mac),
|
||||
username: await encryptTextValue(draft.identUsername, keys.enc, keys.mac),
|
||||
company: await encryptTextValue(draft.identCompany, keys.enc, keys.mac),
|
||||
ssn: await encryptTextValue(draft.identSsn, keys.enc, keys.mac),
|
||||
passportNumber: await encryptTextValue(draft.identPassportNumber, keys.enc, keys.mac),
|
||||
licenseNumber: await encryptTextValue(draft.identLicenseNumber, keys.enc, keys.mac),
|
||||
email: await encryptTextValue(draft.identEmail, keys.enc, keys.mac),
|
||||
phone: await encryptTextValue(draft.identPhone, keys.enc, keys.mac),
|
||||
address1: await encryptTextValue(draft.identAddress1, keys.enc, keys.mac),
|
||||
address2: await encryptTextValue(draft.identAddress2, keys.enc, keys.mac),
|
||||
address3: await encryptTextValue(draft.identAddress3, keys.enc, keys.mac),
|
||||
city: await encryptTextValue(draft.identCity, keys.enc, keys.mac),
|
||||
state: await encryptTextValue(draft.identState, keys.enc, keys.mac),
|
||||
postalCode: await encryptTextValue(draft.identPostalCode, keys.enc, keys.mac),
|
||||
country: await encryptTextValue(draft.identCountry, keys.enc, keys.mac),
|
||||
};
|
||||
} else if (type === 5) {
|
||||
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac);
|
||||
payload.sshKey = {
|
||||
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
|
||||
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
|
||||
keyFingerprint: encryptedFingerprint,
|
||||
fingerprint: encryptedFingerprint,
|
||||
};
|
||||
} else if (type === 2) {
|
||||
payload.secureNote = { type: 0 };
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function buildCipherImportPayload(session: SessionState, draft: VaultDraft): Promise<Record<string, unknown>> {
|
||||
return buildCipherPayload(session, draft, null);
|
||||
}
|
||||
|
||||
export async function createCipher(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
draft: VaultDraft
|
||||
): Promise<{ id: string }> {
|
||||
const payload = await buildCipherPayload(session, draft, null);
|
||||
|
||||
const resp = await authedFetch('/api/ciphers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Create item failed');
|
||||
const body = await parseJson<{ id?: string }>(resp);
|
||||
if (!body?.id) throw new Error('Create item failed');
|
||||
return { id: body.id };
|
||||
}
|
||||
|
||||
export async function updateCipher(
|
||||
authedFetch: AuthedFetch,
|
||||
session: SessionState,
|
||||
cipher: Cipher,
|
||||
draft: VaultDraft
|
||||
): Promise<void> {
|
||||
const payload = await buildCipherPayload(session, draft, cipher);
|
||||
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipher.id)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Update item failed');
|
||||
}
|
||||
|
||||
export async function deleteCipher(authedFetch: AuthedFetch, cipherId: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error('Delete item failed');
|
||||
}
|
||||
|
||||
export async function bulkDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
|
||||
const resp = await authedFetch('/api/ciphers/delete', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk delete failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkPermanentDeleteCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
|
||||
const resp = await authedFetch('/api/ciphers/delete-permanent', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk permanent delete failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkRestoreCiphers(authedFetch: AuthedFetch, ids: string[]): Promise<void> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
|
||||
const resp = await authedFetch('/api/ciphers/restore', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk restore failed');
|
||||
}
|
||||
}
|
||||
|
||||
export async function bulkMoveCiphers(
|
||||
authedFetch: AuthedFetch,
|
||||
ids: string[],
|
||||
folderId: string | null
|
||||
): Promise<void> {
|
||||
const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean)));
|
||||
for (const chunk of chunkArray(uniqueIds, BULK_API_CHUNK_SIZE)) {
|
||||
const resp = await authedFetch('/api/ciphers/move', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids: chunk, folderId }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Bulk move failed');
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
type BackupSettings,
|
||||
createBackupDestinationRecord,
|
||||
createDefaultBackupSettings,
|
||||
} from '@shared/backup';
|
||||
import type { RemoteBackupBrowserResponse, RemoteBackupItem } from './api';
|
||||
} from '@shared/backup-schema';
|
||||
import type { RemoteBackupBrowserResponse, RemoteBackupItem } from './api/backup';
|
||||
import { t } from './i18n';
|
||||
|
||||
export interface PersistedRemoteBrowserState {
|
||||
@@ -52,7 +52,6 @@ export function detectBrowserTimeZone(): string {
|
||||
|
||||
function createLocalizedDestinationName(type: BackupDestinationType, index: number): string {
|
||||
if (type === 'e3') return t('txt_backup_destination_name_default_e3', { index: String(index) });
|
||||
if (type === 'placeholder') return `${t('txt_backup_destination_reserved')} ${index}`;
|
||||
return t('txt_backup_destination_name_default_webdav', { index: String(index) });
|
||||
}
|
||||
|
||||
@@ -197,7 +196,7 @@ export function getDestinationById(
|
||||
}
|
||||
|
||||
export function getVisibleDestinations(settings: BackupSettings | null | undefined): BackupDestinationRecord[] {
|
||||
return (settings?.destinations || []).filter((destination) => destination.type !== 'placeholder');
|
||||
return settings?.destinations || [];
|
||||
}
|
||||
|
||||
export function getFirstVisibleDestinationId(settings: BackupSettings | null | undefined): string | null {
|
||||
@@ -206,6 +205,5 @@ export function getFirstVisibleDestinationId(settings: BackupSettings | null | u
|
||||
|
||||
export function getDestinationTypeLabel(type: BackupDestinationType): string {
|
||||
if (type === 'e3') return t('txt_backup_protocol_e3');
|
||||
if (type === 'placeholder') return t('txt_backup_destination_reserved');
|
||||
return t('txt_backup_protocol_webdav');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { createAuthedFetch } from './api/auth';
|
||||
import { getAdminBackupSettingsRepairState, repairAdminBackupSettings } from './api/backup';
|
||||
import { decryptPortableBackupSettings } from './admin-backup-portable';
|
||||
import type { Profile, SessionState } from './types';
|
||||
|
||||
export async function silentlyRepairBackupSettingsIfNeeded(
|
||||
activeSession: SessionState,
|
||||
activeProfile: Profile
|
||||
): Promise<void> {
|
||||
if (activeProfile.role !== 'admin') return;
|
||||
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
|
||||
|
||||
const tempFetch = createAuthedFetch(() => activeSession, () => {});
|
||||
try {
|
||||
const state = await getAdminBackupSettingsRepairState(tempFetch);
|
||||
if (!state.needsRepair || !state.portable) return;
|
||||
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
|
||||
await repairAdminBackupSettings(tempFetch, repairedSettings);
|
||||
} catch (error) {
|
||||
console.error('Backup settings auto-repair failed:', error);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { argon2idAsync } from '@noble/hashes/argon2.js';
|
||||
import { strToU8, zipSync } from 'fflate';
|
||||
import { Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter, configure as configureZipJs } from '@zip.js/zip.js';
|
||||
import type { PreloginKdfConfig } from './api';
|
||||
import type { PreloginKdfConfig } from './api/auth';
|
||||
import { base64ToBytes, bytesToBase64, decryptBw, decryptStr, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
|
||||
import type { Cipher, Folder } from './types';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CiphersImportPayload } from '@/lib/api';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
|
||||
type ImportSourceEntry = { id: string; label: string };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user