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:
shuaiplus
2026-03-15 04:17:09 +08:00
parent 1fcfeb91d1
commit f0ace28bf2
30 changed files with 2697 additions and 2519 deletions
+2 -15
View File
@@ -4,7 +4,7 @@ export const BACKUP_DEFAULT_RETENTION_COUNT = 30;
export const BACKUP_DEFAULT_E3_REGION = 'auto'; export const BACKUP_DEFAULT_E3_REGION = 'auto';
export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden'; 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 type BackupScheduleFrequency = 'daily' | 'weekly' | 'monthly';
export interface E3BackupDestination { export interface E3BackupDestination {
@@ -23,15 +23,9 @@ export interface WebDavBackupDestination {
remotePath: string; remotePath: string;
} }
export interface PlaceholderBackupDestination {
providerName: string;
notes: string;
}
export type BackupDestinationConfig = export type BackupDestinationConfig =
| E3BackupDestination | E3BackupDestination
| WebDavBackupDestination | WebDavBackupDestination;
| PlaceholderBackupDestination;
export interface BackupRuntimeState { export interface BackupRuntimeState {
lastAttemptAt: string | null; lastAttemptAt: string | null;
@@ -110,12 +104,6 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
rootPath: BACKUP_DEFAULT_REMOTE_PATH, rootPath: BACKUP_DEFAULT_REMOTE_PATH,
}; };
} }
if (type === 'placeholder') {
return {
providerName: 'Reserved',
notes: '',
};
}
return { return {
baseUrl: '', baseUrl: '',
username: '', username: '',
@@ -126,7 +114,6 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType
export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string { export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string {
if (type === 'e3') return `E3 ${index}`; if (type === 'e3') return `E3 ${index}`;
if (type === 'placeholder') return `Reserved ${index}`;
return `WebDAV ${index}`; return `WebDAV ${index}`;
} }
-3
View File
@@ -75,9 +75,6 @@ async function executeConfiguredBackup(
): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> { ): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> {
const currentSettings = await loadBackupSettings(storage, env, 'UTC'); const currentSettings = await loadBackupSettings(storage, env, 'UTC');
const destination = requireBackupDestination(currentSettings, destinationId); const destination = requireBackupDestination(currentSettings, destinationId);
if (destination.type === 'placeholder') {
throw new Error('The reserved backup destination is not available yet');
}
const now = new Date(); const now = new Date();
destination.runtime.lastAttemptAt = now.toISOString(); destination.runtime.lastAttemptAt = now.toISOString();
+64
View File
@@ -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;
}
+51
View File
@@ -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;
}
+50
View File
@@ -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
View File
@@ -76,13 +76,6 @@ import { handleSync } from './handlers/sync';
import { handleSetupStatus } from './handlers/setup'; import { handleSetupStatus } from './handlers/setup';
import { import {
handleKnownDevice, handleKnownDevice,
handleGetAuthorizedDevices,
handleGetDevices,
handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice,
handleDeleteAllDevices,
handleDeleteDevice,
handleUpdateDeviceToken
} from './handlers/devices'; } from './handlers/devices';
// Import handler // Import handler
@@ -96,32 +89,12 @@ import {
handleDeleteAttachment, handleDeleteAttachment,
handlePublicDownloadAttachment, handlePublicDownloadAttachment,
} from './handlers/attachments'; } 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 { import {
handleNotificationsHub, handleNotificationsHub,
handleNotificationsNegotiate, handleNotificationsNegotiate,
} from './handlers/notifications'; } from './handlers/notifications';
import { handleAdminRoute } from './router-admin';
import { handleAuthenticatedDeviceRoute } from './router-devices';
function isSameOriginWriteRequest(request: Request): boolean { function isSameOriginWriteRequest(request: Request): boolean {
const targetOrigin = new URL(request.url).origin; const targetOrigin = new URL(request.url).origin;
@@ -801,100 +774,11 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
} }
// Devices endpoint const authenticatedDeviceResponse = await handleAuthenticatedDeviceRoute(request, env, userId, path, method);
if (path === '/api/devices') { if (authenticatedDeviceResponse) return authenticatedDeviceResponse;
if (method === 'GET') return handleGetDevices(request, env, userId);
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
}
if (path === '/api/devices/authorized') { const adminResponse = await handleAdminRoute(request, env, currentUser, path, method);
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId); if (adminResponse) return adminResponse;
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);
}
// Not found // Not found
return errorResponse('Not found', 404); return errorResponse('Not found', 404);
+5 -21
View File
@@ -17,13 +17,12 @@ import {
type BackupScheduleFrequency, type BackupScheduleFrequency,
type BackupSettings, type BackupSettings,
type E3BackupDestination, type E3BackupDestination,
type PlaceholderBackupDestination,
type WebDavBackupDestination, type WebDavBackupDestination,
createBackupRandomId, createBackupRandomId,
createDefaultBackupDestinationName, createDefaultBackupDestinationName,
createDefaultBackupScheduleConfig, createDefaultBackupScheduleConfig,
createDefaultBackupSettings as createSharedDefaultBackupSettings, createDefaultBackupSettings as createSharedDefaultBackupSettings,
} from '../../shared/backup'; } from '../../shared/backup-schema';
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1'; export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5; export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
@@ -37,9 +36,8 @@ export type {
BackupScheduleConfig, BackupScheduleConfig,
BackupSettings, BackupSettings,
E3BackupDestination, E3BackupDestination,
PlaceholderBackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from '../../shared/backup'; } from '../../shared/backup-schema';
export interface BackupSettingsInput { export interface BackupSettingsInput {
destinations?: unknown; 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( function normalizeDestination(
destinationType: BackupDestinationType, destinationType: BackupDestinationType,
destination: unknown, destination: unknown,
allowIncomplete = false allowIncomplete = false
): BackupDestinationConfig { ): BackupDestinationConfig {
if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete); if (destinationType === 'e3') return normalizeE3Destination(destination, allowIncomplete);
if (destinationType === 'webdav') return normalizeWebDavDestination(destination, allowIncomplete); return normalizeWebDavDestination(destination, allowIncomplete);
return normalizePlaceholderDestination(destination);
} }
function normalizeRuntime(value: unknown): BackupRuntimeState { function normalizeRuntime(value: unknown): BackupRuntimeState {
@@ -235,7 +224,7 @@ function defaultDestinationName(type: BackupDestinationType, index: number): str
function getDestinationType(raw: unknown): BackupDestinationType { function getDestinationType(raw: unknown): BackupDestinationType {
const value = asTrimmedString(raw); 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'); throw new Error('Backup destination type is invalid');
} }
@@ -269,10 +258,6 @@ function normalizeDestinationRecord(
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount), 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); const destination = normalizeDestination(type, input.destination, !schedule.enabled);
return { return {
@@ -288,7 +273,7 @@ function normalizeDestinationRecord(
function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings { function parseLegacyBackupSettings(rawValue: Record<string, unknown>, fallbackTimezone: string): BackupSettings {
const destinationTypeRaw = asTrimmedString(rawValue.destinationType); const destinationTypeRaw = asTrimmedString(rawValue.destinationType);
const destinationType: BackupDestinationType = const destinationType: BackupDestinationType =
destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav' || destinationTypeRaw === 'placeholder' destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav'
? destinationTypeRaw ? destinationTypeRaw
: 'webdav'; : 'webdav';
const destination = { const destination = {
@@ -570,7 +555,6 @@ export function isBackupDueNow(
windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES
): boolean { ): boolean {
if (!destination.schedule.enabled) return false; if (!destination.schedule.enabled) return false;
if (destination.type === 'placeholder') return false;
const currentMinutes = toMinutes(getBackupLocalTime(now, destination.schedule.timezone)); const currentMinutes = toMinutes(getBackupLocalTime(now, destination.schedule.timezone));
const scheduledMinutes = toMinutes(destination.schedule.scheduleTime); const scheduledMinutes = toMinutes(destination.schedule.scheduleTime);
+1 -6
View File
@@ -2,7 +2,6 @@ import {
BackupDestinationRecord, BackupDestinationRecord,
BackupDestinationType, BackupDestinationType,
E3BackupDestination, E3BackupDestination,
PlaceholderBackupDestination,
WebDavBackupDestination, WebDavBackupDestination,
} from './backup-config'; } 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 { interface ConfiguredDestinationAdapter {
provider: 'webdav' | 'e3'; provider: 'webdav' | 'e3';
config: WebDavBackupDestination | E3BackupDestination; 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( export async function uploadBackupArchive(
+130
View File
@@ -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
View File
@@ -1,103 +1,9 @@
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types'; import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { ensureStorageSchema } from './storage-schema';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; 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. // D1-backed storage.
// Contract: // Contract:
// - All methods are scoped by userId where applicable. // - All methods are scoped by userId where applicable.
@@ -167,45 +73,11 @@ export class StorageService {
// - Keep statements idempotent so updates are safe. // - Keep statements idempotent so updates are safe.
async initializeDatabase(): Promise<void> { async initializeDatabase(): Promise<void> {
if (StorageService.schemaVerified) return; if (StorageService.schemaVerified) return;
await ensureStorageSchema(this.db);
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();
StorageService.schemaVerified = true; 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 --- // --- Config / setup ---
async isRegistered(): Promise<boolean> { async isRegistered(): Promise<boolean> {
+213 -377
View File
@@ -1,89 +1,94 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
import { lazy, Suspense } from 'preact/compat'; import { Link, useLocation } from 'wouter';
import { Link, Route, Switch, useLocation } from 'wouter';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
import AppMainRoutes from '@/components/AppMainRoutes';
import AuthViews from '@/components/AuthViews'; import AuthViews from '@/components/AuthViews';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import ToastHost from '@/components/ToastHost'; import ToastHost from '@/components/ToastHost';
import VaultPage from '@/components/VaultPage';
import SendsPage from '@/components/SendsPage';
import PublicSendPage from '@/components/PublicSendPage'; import PublicSendPage from '@/components/PublicSendPage';
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage'; import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
import JwtWarningPage from '@/components/JwtWarningPage'; import JwtWarningPage from '@/components/JwtWarningPage';
import TotpCodesPage from '@/components/TotpCodesPage';
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
import { import {
buildCipherImportPayload,
bulkDeleteFolders,
changeMasterPassword, changeMasterPassword,
createFolder,
updateFolder,
deleteCipherAttachment,
deleteFolder,
deleteRemoteBackup,
bulkDeleteCiphers,
bulkPermanentDeleteCiphers,
bulkRestoreCiphers,
bulkDeleteSends,
createCipher,
createAuthedFetch, createAuthedFetch,
createInvite, deleteAllAuthorizedDevices,
downloadCipherAttachmentDecrypted, deleteAuthorizedDevice,
encryptFolderImportName,
exportAdminBackup,
getAdminBackupSettingsRepairState,
getAdminBackupSettings,
downloadRemoteBackup,
importAdminBackup,
importCiphers,
createSend,
deleteAllInvites,
deleteCipher,
deleteSend,
deleteUser,
deriveLoginHash, deriveLoginHash,
getAttachmentDownloadInfo,
bulkMoveCiphers,
getCiphers,
getFolders,
getPreloginKdfConfig,
getProfile,
getAuthorizedDevices, getAuthorizedDevices,
getCurrentDeviceIdentifier, getCurrentDeviceIdentifier,
getPreloginKdfConfig,
getProfile,
getSetupStatus, getSetupStatus,
getSends,
getTotpStatus,
getTotpRecoveryCode, getTotpRecoveryCode,
getTotpStatus,
getWebConfig, getWebConfig,
listAdminInvites,
listAdminUsers,
loadSession, loadSession,
loginWithPassword, loginWithPassword,
registerAccount, registerAccount,
recoverTwoFactor, recoverTwoFactor,
repairAdminBackupSettings,
revokeInvite,
revokeAuthorizedDeviceTrust, revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust,
restoreRemoteBackup,
runAdminBackupNow,
saveSession, saveSession,
saveAdminBackupSettings,
setTotp, setTotp,
setUserStatus,
deleteAllAuthorizedDevices,
deleteAuthorizedDevice,
listRemoteBackups,
uploadCipherAttachment,
updateCipher,
updateSend,
buildSendShareKey,
unlockVaultKey, unlockVaultKey,
verifyMasterPassword, 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, type ImportedCipherMapEntry,
} from '@/lib/api'; updateCipher,
import { decryptPortableBackupSettings } from '@/lib/admin-backup-portable'; updateFolder,
uploadCipherAttachment,
} from '@/lib/api/vault';
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto'; import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto';
import { import {
attachNodeWardenEncryptedAttachmentPayload, attachNodeWardenEncryptedAttachmentPayload,
@@ -99,15 +104,8 @@ import {
type ZipAttachmentEntry, type ZipAttachmentEntry,
} from '@/lib/export-formats'; } from '@/lib/export-formats';
import { t } from '@/lib/i18n'; 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'; import type { AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage'));
const AdminPage = lazy(() => import('@/components/AdminPage'));
const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage'));
const ImportPage = lazy(() => import('@/components/ImportPage'));
interface PendingTotp { interface PendingTotp {
email: string; email: string;
passwordHash: string; passwordHash: string;
@@ -149,10 +147,6 @@ function readInviteCodeFromUrl(): string {
return ''; return '';
} }
function RouteContentFallback() {
return <div className="loading-screen">{t('txt_loading_nodewarden')}</div>;
}
function summarizeImportResult( function summarizeImportResult(
ciphers: Array<Record<string, unknown>>, ciphers: Array<Record<string, unknown>>,
folderCount: number, folderCount: number,
@@ -452,21 +446,6 @@ export default function App() {
saveSession(next); saveSession(next);
} }
async function silentlyRepairBackupSettingsIfNeeded(activeSession: SessionState, activeProfile: Profile): Promise<void> {
if (activeProfile.role !== 'admin') return;
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
const tempFetch = createAuthedFetch(() => activeSession, () => {});
try {
const state = await getAdminBackupSettingsRepairState(tempFetch);
if (!state.needsRepair || !state.portable) return;
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
await repairAdminBackupSettings(tempFetch, repairedSettings);
} catch (error) {
console.error('Backup settings auto-repair failed:', error);
}
}
function pushToast(type: ToastMessage['type'], text: string) { function pushToast(type: ToastMessage['type'], text: string) {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
setToasts((prev) => [...prev.slice(-3), { id, type, text }]); setToasts((prev) => [...prev.slice(-3), { id, type, text }]);
@@ -1908,33 +1887,6 @@ export default function App() {
return t('nav_my_vault'); return t('nav_my_vault');
})(); })();
const importPageContent = (
<Suspense fallback={<RouteContentFallback />}>
<ImportPage
onImport={handleImportAction}
onImportEncryptedRaw={handleImportEncryptedRawAction}
accountKeys={session?.symEncKey && session?.symMacKey ? { encB64: session.symEncKey, macB64: session.symMacKey } : null}
onNotify={pushToast}
folders={decryptedFolders}
onExport={handleExportAction}
/>
</Suspense>
);
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(() => { useEffect(() => {
if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault'); if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault');
}, [phase, location, isPublicSendRoute, navigate]); }, [phase, location, isPublicSendRoute, navigate]);
@@ -2148,274 +2100,158 @@ export default function App() {
</Link> </Link>
</aside> </aside>
<main className="content"> <main className="content">
<Switch> <AppMainRoutes
<Route path="/sends"> profile={profile}
<SendsPage session={session}
sends={decryptedSends} mobileLayout={mobileLayout}
loading={sendsQuery.isFetching} importRoute={IMPORT_ROUTE}
onRefresh={refreshVault} settingsHomeRoute={SETTINGS_HOME_ROUTE}
onCreate={createSendItem} settingsAccountRoute={SETTINGS_ACCOUNT_ROUTE}
onUpdate={updateSendItem} decryptedCiphers={decryptedCiphers}
onDelete={deleteSendItem} decryptedFolders={decryptedFolders}
onBulkDelete={bulkDeleteSendItems} decryptedSends={decryptedSends}
onNotify={pushToast} ciphersLoading={ciphersQuery.isFetching}
/> foldersLoading={foldersQuery.isFetching}
</Route> sendsLoading={sendsQuery.isFetching}
<Route path="/vault/totp"> users={usersQuery.data || []}
<TotpCodesPage ciphers={decryptedCiphers} loading={ciphersQuery.isFetching} onNotify={pushToast} /> invites={invitesQuery.data || []}
</Route> totpEnabled={!!totpStatusQuery.data?.enabled}
<Route path="/vault"> authorizedDevices={authorizedDevicesQuery.data || []}
<VaultPage authorizedDevicesLoading={authorizedDevicesQuery.isFetching}
ciphers={decryptedCiphers} onNavigate={navigate}
folders={decryptedFolders} onLogout={handleLogout}
loading={ciphersQuery.isFetching || foldersQuery.isFetching} onNotify={pushToast}
emailForReprompt={profile?.email || session?.email || ''} onImport={handleImportAction}
onRefresh={refreshVault} onImportEncryptedRaw={handleImportEncryptedRawAction}
onCreate={createVaultItem} onExport={handleExportAction}
onUpdate={updateVaultItem} onCreateVaultItem={createVaultItem}
onDelete={deleteVaultItem} onUpdateVaultItem={updateVaultItem}
onBulkDelete={bulkDeleteVaultItems} onDeleteVaultItem={deleteVaultItem}
onBulkPermanentDelete={bulkPermanentDeleteVaultItems} onBulkDeleteVaultItems={bulkDeleteVaultItems}
onBulkRestore={bulkRestoreVaultItems} onBulkPermanentDeleteVaultItems={bulkPermanentDeleteVaultItems}
onBulkMove={bulkMoveVaultItems} onBulkRestoreVaultItems={bulkRestoreVaultItems}
onVerifyMasterPassword={verifyMasterPasswordAction} onBulkMoveVaultItems={bulkMoveVaultItems}
onNotify={pushToast} onVerifyMasterPassword={verifyMasterPasswordAction}
onCreateFolder={createFolderAction} onCreateFolder={createFolderAction}
onDeleteFolder={deleteFolderAction} onDeleteFolder={deleteFolderAction}
onBulkDeleteFolders={bulkDeleteFoldersAction} onBulkDeleteFolders={bulkDeleteFoldersAction}
onDownloadAttachment={downloadVaultAttachment} onDownloadVaultAttachment={downloadVaultAttachment}
/> onRefreshVault={refreshVault}
</Route> onCreateSend={createSendItem}
<Route path={SETTINGS_ACCOUNT_ROUTE}> onUpdateSend={updateSendItem}
{profile && ( onDeleteSend={deleteSendItem}
<div className="stack"> onBulkDeleteSends={bulkDeleteSendItems}
{mobileLayout && ( onChangePassword={changePasswordAction}
<div className="mobile-settings-subhead"> onEnableTotp={async (secret, token) => {
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}> await enableTotpAction(secret, token);
<span className="btn-icon" aria-hidden="true">{"<"}</span> await totpStatusQuery.refetch();
{t('txt_back')} }}
</button> onOpenDisableTotp={() => setDisableTotpOpen(true)}
</div> onGetRecoveryCode={getRecoveryCodeAction}
)} onRefreshAuthorizedDevices={refreshAuthorizedDevices}
<Suspense fallback={<RouteContentFallback />}> onRevokeDeviceTrust={(device) => {
<SettingsPage setConfirm({
profile={profile} title: t('txt_revoke_device_authorization'),
totpEnabled={!!totpStatusQuery.data?.enabled} message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }),
onChangePassword={changePasswordAction} danger: true,
onEnableTotp={async (secret, token) => { onConfirm: () => {
await enableTotpAction(secret, token); setConfirm(null);
await totpStatusQuery.refetch(); void revokeDeviceTrustAction(device);
}} },
onOpenDisableTotp={() => setDisableTotpOpen(true)} });
onGetRecoveryCode={getRecoveryCodeAction} }}
onNotify={pushToast} onRemoveDevice={(device) => {
/> setConfirm({
</Suspense> title: t('txt_remove_device'),
</div> message: t('txt_remove_device_and_sign_out_name', { name: device.name }),
)} danger: true,
</Route> onConfirm: () => {
<Route path="/settings"> setConfirm(null);
{profile && ( void removeDeviceAction(device);
<section className="card mobile-settings-card"> },
<div className="mobile-settings-links"> });
<Link href={SETTINGS_ACCOUNT_ROUTE} className="mobile-settings-link"> }}
<SettingsIcon size={18} /> onRevokeAllDeviceTrust={() => {
<span>{t('nav_account_settings')}</span> setConfirm({
</Link> title: t('txt_revoke_all_trusted_devices'),
<Link href="/security/devices" className="mobile-settings-link"> message: t('txt_revoke_30_day_totp_trust_from_all_devices'),
<Shield size={18} /> danger: true,
<span>{t('nav_device_management')}</span> onConfirm: () => {
</Link> setConfirm(null);
<Link href={IMPORT_ROUTE} className="mobile-settings-link"> void revokeAllDeviceTrustAction();
<ArrowUpDown size={18} /> },
<span>{t('nav_import_export')}</span> });
</Link> }}
{profile.role === 'admin' && ( onRemoveAllDevices={() => {
<Link href="/admin" className="mobile-settings-link"> setConfirm({
<ShieldUser size={18} /> title: t('txt_remove_all_devices'),
<span>{t('nav_admin_panel')}</span> message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
</Link> danger: true,
)} onConfirm: () => {
{profile.role === 'admin' && ( setConfirm(null);
<Link href="/help" className="mobile-settings-link"> void removeAllDevicesAction();
<Cloud size={18} /> },
<span>{t('nav_backup_strategy')}</span> });
</Link> }}
)} onRefreshAdmin={() => {
</div> void usersQuery.refetch();
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={handleLogout}> void invitesQuery.refetch();
<LogOut size={14} className="btn-icon" /> }}
{t('txt_sign_out')} onCreateInvite={async (hours) => {
</button> await createInvite(authedFetch, hours);
</section> await invitesQuery.refetch();
)} pushToast('success', t('txt_invite_created'));
</Route> }}
<Route path="/security/devices"> onDeleteAllInvites={async () => {
<div className="stack"> setConfirm({
{mobileLayout && ( title: t('txt_delete_all_invites'),
<div className="mobile-settings-subhead"> message: t('txt_delete_all_invite_codes_active_inactive'),
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => navigate(SETTINGS_HOME_ROUTE)}> danger: true,
<span className="btn-icon" aria-hidden="true">{"<"}</span> onConfirm: () => {
{t('txt_back')} setConfirm(null);
</button> void (async () => {
</div> await deleteAllInvites(authedFetch);
)} await invitesQuery.refetch();
<Suspense fallback={<RouteContentFallback />}> pushToast('success', t('txt_all_invites_deleted'));
<SecurityDevicesPage })();
devices={authorizedDevicesQuery.data || []} },
loading={authorizedDevicesQuery.isFetching} });
onRefresh={() => void refreshAuthorizedDevices()} }}
onRevokeTrust={(device) => { onToggleUserStatus={async (userId, status) => {
setConfirm({ await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
title: t('txt_revoke_device_authorization'), await usersQuery.refetch();
message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }), pushToast('success', t('txt_user_status_updated'));
danger: true, }}
onConfirm: () => { onDeleteUser={async (userId) => {
setConfirm(null); setConfirm({
void revokeDeviceTrustAction(device); title: t('txt_delete_user'),
}, message: t('txt_delete_this_user_and_all_user_data'),
}); danger: true,
}} onConfirm: () => {
onRemoveDevice={(device) => { setConfirm(null);
setConfirm({ void (async () => {
title: t('txt_remove_device'), await deleteUser(authedFetch, userId);
message: t('txt_remove_device_and_sign_out_name', { name: device.name }), await usersQuery.refetch();
danger: true, pushToast('success', t('txt_user_deleted'));
onConfirm: () => { })();
setConfirm(null); },
void removeDeviceAction(device); });
}, }}
}); onRevokeInvite={async (code) => {
}} await revokeInvite(authedFetch, code);
onRevokeAll={() => { await invitesQuery.refetch();
setConfirm({ pushToast('success', t('txt_invite_revoked'));
title: t('txt_revoke_all_trusted_devices'), }}
message: t('txt_revoke_30_day_totp_trust_from_all_devices'), onExportBackup={handleBackupExportAction}
danger: true, onImportBackup={handleBackupImportAction}
onConfirm: () => { onLoadBackupSettings={handleLoadBackupSettingsAction}
setConfirm(null); onSaveBackupSettings={handleSaveBackupSettingsAction}
void revokeAllDeviceTrustAction(); onRunRemoteBackup={handleRunRemoteBackupAction}
}, onListRemoteBackups={handleListRemoteBackupsAction}
}); onDownloadRemoteBackup={handleDownloadRemoteBackupAction}
}} onDeleteRemoteBackup={handleDeleteRemoteBackupAction}
onRemoveAll={() => { onRestoreRemoteBackup={handleRestoreRemoteBackupAction}
setConfirm({ />
title: t('txt_remove_all_devices'),
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
danger: true,
onConfirm: () => {
setConfirm(null);
void removeAllDevicesAction();
},
});
}}
/>
</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={() => {
void usersQuery.refetch();
void invitesQuery.refetch();
}}
onCreateInvite={async (hours) => {
await createInvite(authedFetch, hours);
await invitesQuery.refetch();
pushToast('success', t('txt_invite_created'));
}}
onDeleteAllInvites={async () => {
setConfirm({
title: t('txt_delete_all_invites'),
message: t('txt_delete_all_invite_codes_active_inactive'),
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteAllInvites(authedFetch);
await invitesQuery.refetch();
pushToast('success', t('txt_all_invites_deleted'));
})();
},
});
}}
onToggleUserStatus={async (userId, status) => {
await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active');
await usersQuery.refetch();
pushToast('success', t('txt_user_status_updated'));
}}
onDeleteUser={async (userId) => {
setConfirm({
title: t('txt_delete_user'),
message: t('txt_delete_this_user_and_all_user_data'),
danger: true,
onConfirm: () => {
setConfirm(null);
void (async () => {
await deleteUser(authedFetch, userId);
await usersQuery.refetch();
pushToast('success', t('txt_user_deleted'));
})();
},
});
}}
onRevokeInvite={async (code) => {
await revokeInvite(authedFetch, code);
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}
onListRemoteBackups={handleListRemoteBackupsAction}
onDownloadRemoteBackup={handleDownloadRemoteBackupAction}
onDeleteRemoteBackup={handleDeleteRemoteBackupAction}
onRestoreRemoteBackup={handleRestoreRemoteBackupAction}
onSaveSettings={handleSaveBackupSettingsAction}
onRunRemoteBackup={handleRunRemoteBackupAction}
onNotify={pushToast}
/>
</Suspense>
</div>
) : null}
</Route>
</Switch>
</main> </main>
</div> </div>
+312
View File
@@ -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>
);
}
+3 -9
View File
@@ -6,7 +6,7 @@ import {
type BackupDestinationRecord, type BackupDestinationRecord,
type BackupDestinationType, type BackupDestinationType,
type RemoteBackupBrowserResponse, type RemoteBackupBrowserResponse,
} from '@/lib/api'; } from '@/lib/api/backup';
import { import {
REMOTE_BROWSER_ITEMS_PER_PAGE, REMOTE_BROWSER_ITEMS_PER_PAGE,
compareRemoteItems, compareRemoteItems,
@@ -92,8 +92,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const selectedRecommendedProvider = RECOMMENDED_PROVIDERS.find((provider) => provider.id === selectedProviderId) || null; const selectedRecommendedProvider = RECOMMENDED_PROVIDERS.find((provider) => provider.id === selectedProviderId) || null;
const recommendedWebDavProviders = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 'webdav'); const recommendedWebDavProviders = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 'webdav');
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3'); const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
const canRunSelectedDestination = !!selectedDestination && selectedDestination.type !== 'placeholder' && selectedDestinationIsSaved; const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
const canBrowseSelectedDestination = !!savedSelectedDestination && savedSelectedDestination.type !== 'placeholder'; const canBrowseSelectedDestination = !!savedSelectedDestination;
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -135,12 +135,6 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}); });
}, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]); }, [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) { function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) {
setSettings((current) => { setSettings((current) => {
const next = mutator(current); const next = mutator(current);
+1 -1
View File
@@ -4,7 +4,7 @@ import { strFromU8, unzipSync } from 'fflate';
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js'; import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
import { Download, FileUp } from 'lucide-preact'; import { Download, FileUp } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog'; import ConfirmDialog from '@/components/ConfirmDialog';
import type { CiphersImportPayload } from '@/lib/api'; import type { CiphersImportPayload } from '@/lib/api/vault';
import { import {
type EncryptedJsonMode, type EncryptedJsonMode,
EXPORT_FORMATS, EXPORT_FORMATS,
+1 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'preact/hooks'; import { useEffect, useState } from 'preact/hooks';
import { Download, Eye, Lock } from 'lucide-preact'; 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 StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -4,7 +4,7 @@ import type {
E3BackupDestination, E3BackupDestination,
RemoteBackupBrowserResponse, RemoteBackupBrowserResponse,
WebDavBackupDestination, WebDavBackupDestination,
} from '@/lib/api'; } from '@/lib/api/backup';
import { COMMON_TIME_ZONES, WEEKDAY_OPTIONS, getDestinationTypeLabel } from '@/lib/backup-center'; import { COMMON_TIME_ZONES, WEEKDAY_OPTIONS, getDestinationTypeLabel } from '@/lib/backup-center';
import type { RecommendedProvider } from '@/lib/backup-recommendations'; import type { RecommendedProvider } from '@/lib/backup-recommendations';
import { RemoteBackupBrowser } from './RemoteBackupBrowser'; import { RemoteBackupBrowser } from './RemoteBackupBrowser';
@@ -1,5 +1,5 @@
import { Plus } from 'lucide-preact'; 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 { formatDateTime, getDestinationTypeLabel } from '@/lib/backup-center';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
@@ -1,5 +1,5 @@
import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact'; 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 { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
+1 -1
View File
@@ -1,5 +1,5 @@
import { base64ToBytes, decryptBw } from './crypto'; import { base64ToBytes, decryptBw } from './crypto';
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api'; import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
import type { Profile, SessionState } from './types'; import type { Profile, SessionState } from './types';
const PORTABLE_ALGORITHM = 'RSA-OAEP'; const PORTABLE_ALGORITHM = 'RSA-OAEP';
File diff suppressed because it is too large Load Diff
+53
View File
@@ -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');
}
+449
View File
@@ -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'));
}
+255
View File
@@ -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;
}
+322
View File
@@ -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));
}
+60
View File
@@ -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));
}
+686
View File
@@ -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');
}
}
+3 -5
View File
@@ -5,8 +5,8 @@ import {
type BackupSettings, type BackupSettings,
createBackupDestinationRecord, createBackupDestinationRecord,
createDefaultBackupSettings, createDefaultBackupSettings,
} from '@shared/backup'; } from '@shared/backup-schema';
import type { RemoteBackupBrowserResponse, RemoteBackupItem } from './api'; import type { RemoteBackupBrowserResponse, RemoteBackupItem } from './api/backup';
import { t } from './i18n'; import { t } from './i18n';
export interface PersistedRemoteBrowserState { export interface PersistedRemoteBrowserState {
@@ -52,7 +52,6 @@ export function detectBrowserTimeZone(): string {
function createLocalizedDestinationName(type: BackupDestinationType, index: number): string { function createLocalizedDestinationName(type: BackupDestinationType, index: number): string {
if (type === 'e3') return t('txt_backup_destination_name_default_e3', { index: String(index) }); 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) }); 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[] { 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 { 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 { export function getDestinationTypeLabel(type: BackupDestinationType): string {
if (type === 'e3') return t('txt_backup_protocol_e3'); if (type === 'e3') return t('txt_backup_protocol_e3');
if (type === 'placeholder') return t('txt_backup_destination_reserved');
return t('txt_backup_protocol_webdav'); return t('txt_backup_protocol_webdav');
} }
+22
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
import { argon2idAsync } from '@noble/hashes/argon2.js'; import { argon2idAsync } from '@noble/hashes/argon2.js';
import { strToU8, zipSync } from 'fflate'; import { strToU8, zipSync } from 'fflate';
import { Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter, configure as configureZipJs } from '@zip.js/zip.js'; 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 { base64ToBytes, bytesToBase64, decryptBw, decryptStr, encryptBw, hkdfExpand, pbkdf2 } from './crypto';
import type { Cipher, Folder } from './types'; import type { Cipher, Folder } from './types';
+1 -1
View File
@@ -1,4 +1,4 @@
import type { CiphersImportPayload } from '@/lib/api'; import type { CiphersImportPayload } from '@/lib/api/vault';
type ImportSourceEntry = { id: string; label: string }; type ImportSourceEntry = { id: string; label: string };