From 1cef45e37318b4bd92ae9b8dc34bca87aabe629e Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 15 Mar 2026 04:17:09 +0800 Subject: [PATCH] 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. --- shared/{backup.ts => backup-schema.ts} | 17 +- src/handlers/backup.ts | 3 - src/router-admin-backup.ts | 64 + src/router-admin.ts | 51 + src/router-devices.ts | 50 + src/router.ts | 128 +- src/services/backup-config.ts | 26 +- src/services/backup-uploader.ts | 7 +- src/services/storage-schema.ts | 130 ++ src/services/storage.ts | 132 +- webapp/src/App.tsx | 590 ++---- webapp/src/components/AppMainRoutes.tsx | 312 +++ webapp/src/components/BackupCenterPage.tsx | 12 +- webapp/src/components/ImportPage.tsx | 2 +- webapp/src/components/PublicSendPage.tsx | 2 +- .../backup-center/BackupDestinationDetail.tsx | 2 +- .../BackupDestinationSidebar.tsx | 2 +- .../backup-center/RemoteBackupBrowser.tsx | 2 +- webapp/src/lib/admin-backup-portable.ts | 2 +- webapp/src/lib/api.ts | 1823 ----------------- webapp/src/lib/api/admin.ts | 53 + webapp/src/lib/api/auth.ts | 449 ++++ webapp/src/lib/api/backup.ts | 255 +++ webapp/src/lib/api/send.ts | 322 +++ webapp/src/lib/api/shared.ts | 60 + webapp/src/lib/api/vault.ts | 686 +++++++ webapp/src/lib/backup-center.ts | 8 +- webapp/src/lib/backup-settings-repair.ts | 22 + webapp/src/lib/export-formats.ts | 2 +- webapp/src/lib/import-formats.ts | 2 +- 30 files changed, 2697 insertions(+), 2519 deletions(-) rename shared/{backup.ts => backup-schema.ts} (91%) create mode 100644 src/router-admin-backup.ts create mode 100644 src/router-admin.ts create mode 100644 src/router-devices.ts create mode 100644 src/services/storage-schema.ts create mode 100644 webapp/src/components/AppMainRoutes.tsx delete mode 100644 webapp/src/lib/api.ts create mode 100644 webapp/src/lib/api/admin.ts create mode 100644 webapp/src/lib/api/auth.ts create mode 100644 webapp/src/lib/api/backup.ts create mode 100644 webapp/src/lib/api/send.ts create mode 100644 webapp/src/lib/api/shared.ts create mode 100644 webapp/src/lib/api/vault.ts create mode 100644 webapp/src/lib/backup-settings-repair.ts diff --git a/shared/backup.ts b/shared/backup-schema.ts similarity index 91% rename from shared/backup.ts rename to shared/backup-schema.ts index 562617d..dc9ebdb 100644 --- a/shared/backup.ts +++ b/shared/backup-schema.ts @@ -4,7 +4,7 @@ export const BACKUP_DEFAULT_RETENTION_COUNT = 30; export const BACKUP_DEFAULT_E3_REGION = 'auto'; export const BACKUP_DEFAULT_REMOTE_PATH = 'nodewarden'; -export type BackupDestinationType = 'e3' | 'webdav' | 'placeholder'; +export type BackupDestinationType = 'e3' | 'webdav'; export type BackupScheduleFrequency = 'daily' | 'weekly' | 'monthly'; export interface E3BackupDestination { @@ -23,15 +23,9 @@ export interface WebDavBackupDestination { remotePath: string; } -export interface PlaceholderBackupDestination { - providerName: string; - notes: string; -} - export type BackupDestinationConfig = | E3BackupDestination - | WebDavBackupDestination - | PlaceholderBackupDestination; + | WebDavBackupDestination; export interface BackupRuntimeState { lastAttemptAt: string | null; @@ -110,12 +104,6 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType rootPath: BACKUP_DEFAULT_REMOTE_PATH, }; } - if (type === 'placeholder') { - return { - providerName: 'Reserved', - notes: '', - }; - } return { baseUrl: '', username: '', @@ -126,7 +114,6 @@ export function createDefaultBackupDestinationConfig(type: BackupDestinationType export function createDefaultBackupDestinationName(type: BackupDestinationType, index: number): string { if (type === 'e3') return `E3 ${index}`; - if (type === 'placeholder') return `Reserved ${index}`; return `WebDAV ${index}`; } diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index 6903ab2..bad5ecb 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -75,9 +75,6 @@ async function executeConfiguredBackup( ): Promise<{ fileName: string; fileSize: number; remotePath: string; provider: string }> { const currentSettings = await loadBackupSettings(storage, env, 'UTC'); const destination = requireBackupDestination(currentSettings, destinationId); - if (destination.type === 'placeholder') { - throw new Error('The reserved backup destination is not available yet'); - } const now = new Date(); destination.runtime.lastAttemptAt = now.toISOString(); diff --git a/src/router-admin-backup.ts b/src/router-admin-backup.ts new file mode 100644 index 0000000..93bec93 --- /dev/null +++ b/src/router-admin-backup.ts @@ -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 { + 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; +} diff --git a/src/router-admin.ts b/src/router-admin.ts new file mode 100644 index 0000000..2477274 --- /dev/null +++ b/src/router-admin.ts @@ -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 { + 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; +} diff --git a/src/router-devices.ts b/src/router-devices.ts new file mode 100644 index 0000000..38b2cc1 --- /dev/null +++ b/src/router-devices.ts @@ -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 { + 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; +} diff --git a/src/router.ts b/src/router.ts index 88c1aa6..f2ed927 100644 --- a/src/router.ts +++ b/src/router.ts @@ -76,13 +76,6 @@ import { handleSync } from './handlers/sync'; import { handleSetupStatus } from './handlers/setup'; import { handleKnownDevice, - handleGetAuthorizedDevices, - handleGetDevices, - handleRevokeAllTrustedDevices, - handleRevokeTrustedDevice, - handleDeleteAllDevices, - handleDeleteDevice, - handleUpdateDeviceToken } from './handlers/devices'; // Import handler @@ -96,32 +89,12 @@ import { handleDeleteAttachment, handlePublicDownloadAttachment, } from './handlers/attachments'; -import { - handleAdminListUsers, - handleAdminCreateInvite, - handleAdminListInvites, - handleAdminDeleteAllInvites, - handleAdminRevokeInvite, - handleAdminSetUserStatus, - handleAdminDeleteUser, -} from './handlers/admin'; -import { - handleAdminExportBackup, - handleDownloadAdminRemoteBackup, - handleDeleteAdminRemoteBackup, - handleGetAdminBackupSettings, - handleGetAdminBackupSettingsRepairState, - handleAdminImportBackup, - handleListAdminRemoteBackups, - handleRepairAdminBackupSettings, - handleRestoreAdminRemoteBackup, - handleRunAdminConfiguredBackup, - handleUpdateAdminBackupSettings, -} from './handlers/backup'; import { handleNotificationsHub, handleNotificationsNegotiate, } from './handlers/notifications'; +import { handleAdminRoute } from './router-admin'; +import { handleAuthenticatedDeviceRoute } from './router-devices'; function isSameOriginWriteRequest(request: Request): boolean { const targetOrigin = new URL(request.url).origin; @@ -801,100 +774,11 @@ export async function handleRequest(request: Request, env: Env): Promise, fallbackTimezone: string): BackupSettings { const destinationTypeRaw = asTrimmedString(rawValue.destinationType); const destinationType: BackupDestinationType = - destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav' || destinationTypeRaw === 'placeholder' + destinationTypeRaw === 'e3' || destinationTypeRaw === 'webdav' ? destinationTypeRaw : 'webdav'; const destination = { @@ -570,7 +555,6 @@ export function isBackupDueNow( windowMinutes: number = BACKUP_SCHEDULER_WINDOW_MINUTES ): boolean { if (!destination.schedule.enabled) return false; - if (destination.type === 'placeholder') return false; const currentMinutes = toMinutes(getBackupLocalTime(now, destination.schedule.timezone)); const scheduledMinutes = toMinutes(destination.schedule.scheduleTime); diff --git a/src/services/backup-uploader.ts b/src/services/backup-uploader.ts index 91551b8..5ef10ed 100644 --- a/src/services/backup-uploader.ts +++ b/src/services/backup-uploader.ts @@ -2,7 +2,6 @@ import { BackupDestinationRecord, BackupDestinationType, E3BackupDestination, - PlaceholderBackupDestination, WebDavBackupDestination, } from './backup-config'; @@ -534,10 +533,6 @@ async function deleteFromE3(config: E3BackupDestination, relativePath: string): } } -function assertSupportedPlaceholder(_config: PlaceholderBackupDestination): never { - throw new Error('The reserved backup destination is not available yet'); -} - interface ConfiguredDestinationAdapter { provider: 'webdav' | 'e3'; config: WebDavBackupDestination | E3BackupDestination; @@ -573,7 +568,7 @@ function resolveConfiguredDestinationAdapter( }; } - return assertSupportedPlaceholder(destination.destination as PlaceholderBackupDestination); + throw new Error('Unsupported backup destination type'); } export async function uploadBackupArchive( diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts new file mode 100644 index 0000000..0be0b6d --- /dev/null +++ b/src/services/storage-schema.ts @@ -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 { + 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 { + 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 { + 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); +} diff --git a/src/services/storage.ts b/src/services/storage.ts index a748574..07b4c8a 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,103 +1,9 @@ import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types'; import { LIMITS } from '../config/limits'; +import { ensureStorageSchema } from './storage-schema'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; -// IMPORTANT: -// Keep this schema list in sync with migrations/0001_init.sql. -// Any new table/column/index must be added to both places together. -const SCHEMA_STATEMENTS: readonly string[] = [ - 'CREATE TABLE IF NOT EXISTS users (' + - 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' + - 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + - 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + - 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', - 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', - 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', - 'ALTER TABLE users ADD COLUMN totp_secret TEXT', - 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', - - 'CREATE TABLE IF NOT EXISTS user_revisions (' + - 'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' + - 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', - - 'CREATE TABLE IF NOT EXISTS ciphers (' + - 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' + - 'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' + - 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' + - 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', - 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)', - 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)', - - 'CREATE TABLE IF NOT EXISTS folders (' + - 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + - 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', - 'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)', - - 'CREATE TABLE IF NOT EXISTS attachments (' + - 'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' + - 'size_name TEXT NOT NULL, key TEXT, ' + - 'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)', - 'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)', - - 'CREATE TABLE IF NOT EXISTS sends (' + - 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, name TEXT NOT NULL, notes TEXT, data TEXT NOT NULL, ' + - 'key TEXT NOT NULL, password_hash TEXT, password_salt TEXT, password_iterations INTEGER, auth_type INTEGER NOT NULL DEFAULT 2, emails TEXT, ' + - 'max_access_count INTEGER, access_count INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0, hide_email INTEGER, ' + - 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, expiration_date TEXT, deletion_date TEXT NOT NULL, ' + - 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', - 'CREATE INDEX IF NOT EXISTS idx_sends_user_updated ON sends(user_id, updated_at)', - 'CREATE INDEX IF NOT EXISTS idx_sends_user_deletion ON sends(user_id, deletion_date)', - 'ALTER TABLE sends ADD COLUMN auth_type INTEGER NOT NULL DEFAULT 2', - 'ALTER TABLE sends ADD COLUMN emails TEXT', - - 'CREATE TABLE IF NOT EXISTS refresh_tokens (' + - 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, device_identifier TEXT, device_session_stamp TEXT, ' + - 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', - 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', - 'ALTER TABLE refresh_tokens ADD COLUMN device_identifier TEXT', - 'ALTER TABLE refresh_tokens ADD COLUMN device_session_stamp TEXT', - - 'CREATE TABLE IF NOT EXISTS invites (' + - 'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + - 'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' + - 'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)', - 'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)', - 'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)', - - 'CREATE TABLE IF NOT EXISTS audit_logs (' + - 'id TEXT PRIMARY KEY, actor_user_id TEXT, action TEXT NOT NULL, target_type TEXT, target_id TEXT, metadata TEXT, created_at TEXT NOT NULL, ' + - 'FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL)', - 'CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)', - 'CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created ON audit_logs(actor_user_id, created_at)', - - 'CREATE TABLE IF NOT EXISTS devices (' + - 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, session_stamp TEXT, banned INTEGER NOT NULL DEFAULT 0, banned_at TEXT, ' + - 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + - 'PRIMARY KEY (user_id, device_identifier), ' + - 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', - 'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)', - 'ALTER TABLE devices ADD COLUMN session_stamp TEXT', - 'ALTER TABLE devices ADD COLUMN banned INTEGER NOT NULL DEFAULT 0', - 'ALTER TABLE devices ADD COLUMN banned_at TEXT', - - 'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' + - 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + - 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', - 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', - - 'CREATE TABLE IF NOT EXISTS api_rate_limits (' + - 'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' + - 'PRIMARY KEY (identifier, window_start))', - 'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)', - - 'CREATE TABLE IF NOT EXISTS login_attempts_ip (' + - 'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)', - - 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + - 'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)', -]; - // D1-backed storage. // Contract: // - All methods are scoped by userId where applicable. @@ -167,45 +73,11 @@ export class StorageService { // - Keep statements idempotent so updates are safe. async initializeDatabase(): Promise { if (StorageService.schemaVerified) return; - - await this.db.prepare('PRAGMA foreign_keys = ON').run(); - await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run(); - for (const stmt of SCHEMA_STATEMENTS) { - await this.executeSchemaStatement(stmt); - } - await this.ensureAdminUserExists(); + await ensureStorageSchema(this.db); StorageService.schemaVerified = true; } - private async executeSchemaStatement(statement: string): Promise { - 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 { - const admin = await this.db.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1").first<{ id: string }>(); - if (admin?.id) return; - - const firstUser = await this.db - .prepare('SELECT id FROM users ORDER BY created_at ASC LIMIT 1') - .first<{ id: string }>(); - if (!firstUser?.id) return; - - await this.db - .prepare("UPDATE users SET role = 'admin', updated_at = ? WHERE id = ?") - .bind(new Date().toISOString(), firstUser.id) - .run(); - } - // --- Config / setup --- async isRegistered(): Promise { diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 3dc2598..449654c 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,89 +1,94 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; -import { lazy, Suspense } from 'preact/compat'; -import { Link, Route, Switch, useLocation } from 'wouter'; +import { Link, useLocation } from 'wouter'; import { useQuery } from '@tanstack/react-query'; import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; +import AppMainRoutes from '@/components/AppMainRoutes'; import AuthViews from '@/components/AuthViews'; import ConfirmDialog from '@/components/ConfirmDialog'; import ToastHost from '@/components/ToastHost'; -import VaultPage from '@/components/VaultPage'; -import SendsPage from '@/components/SendsPage'; import PublicSendPage from '@/components/PublicSendPage'; import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage'; import JwtWarningPage from '@/components/JwtWarningPage'; -import TotpCodesPage from '@/components/TotpCodesPage'; import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import { - buildCipherImportPayload, - bulkDeleteFolders, changeMasterPassword, - createFolder, - updateFolder, - deleteCipherAttachment, - deleteFolder, - deleteRemoteBackup, - bulkDeleteCiphers, - bulkPermanentDeleteCiphers, - bulkRestoreCiphers, - bulkDeleteSends, - createCipher, createAuthedFetch, - createInvite, - downloadCipherAttachmentDecrypted, - encryptFolderImportName, - exportAdminBackup, - getAdminBackupSettingsRepairState, - getAdminBackupSettings, - downloadRemoteBackup, - importAdminBackup, - importCiphers, - createSend, - deleteAllInvites, - deleteCipher, - deleteSend, - deleteUser, + deleteAllAuthorizedDevices, + deleteAuthorizedDevice, deriveLoginHash, - getAttachmentDownloadInfo, - bulkMoveCiphers, - getCiphers, - getFolders, - getPreloginKdfConfig, - getProfile, getAuthorizedDevices, getCurrentDeviceIdentifier, + getPreloginKdfConfig, + getProfile, getSetupStatus, - getSends, - getTotpStatus, getTotpRecoveryCode, + getTotpStatus, getWebConfig, - listAdminInvites, - listAdminUsers, loadSession, loginWithPassword, registerAccount, recoverTwoFactor, - repairAdminBackupSettings, - revokeInvite, revokeAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust, - restoreRemoteBackup, - runAdminBackupNow, saveSession, - saveAdminBackupSettings, setTotp, - setUserStatus, - deleteAllAuthorizedDevices, - deleteAuthorizedDevice, - listRemoteBackups, - uploadCipherAttachment, - updateCipher, - updateSend, - buildSendShareKey, unlockVaultKey, verifyMasterPassword, +} from '@/lib/api/auth'; +import { + createInvite, + deleteAllInvites, + deleteUser, + listAdminInvites, + listAdminUsers, + revokeInvite, + setUserStatus, +} from '@/lib/api/admin'; +import { + deleteRemoteBackup, + downloadRemoteBackup, + exportAdminBackup, + getAdminBackupSettings, + importAdminBackup, + listRemoteBackups, + restoreRemoteBackup, + runAdminBackupNow, + saveAdminBackupSettings, + type AdminBackupSettings, +} from '@/lib/api/backup'; +import { + buildSendShareKey, + bulkDeleteSends, + createSend, + deleteSend, + getSends, + updateSend, +} from '@/lib/api/send'; +import { + buildCipherImportPayload, + bulkDeleteCiphers, + bulkDeleteFolders, + bulkMoveCiphers, + bulkPermanentDeleteCiphers, + bulkRestoreCiphers, + createCipher, + createFolder, + deleteCipher, + deleteCipherAttachment, + deleteFolder, + downloadCipherAttachmentDecrypted, + encryptFolderImportName, + getAttachmentDownloadInfo, + getCiphers, + getFolders, + importCiphers, + type CiphersImportPayload, type ImportedCipherMapEntry, -} from '@/lib/api'; -import { decryptPortableBackupSettings } from '@/lib/admin-backup-portable'; + updateCipher, + updateFolder, + uploadCipherAttachment, +} from '@/lib/api/vault'; +import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto'; import { attachNodeWardenEncryptedAttachmentPayload, @@ -99,15 +104,8 @@ import { type ZipAttachmentEntry, } from '@/lib/export-formats'; import { t } from '@/lib/i18n'; -import type { CiphersImportPayload } from '@/lib/api'; import type { AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; -const SettingsPage = lazy(() => import('@/components/SettingsPage')); -const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage')); -const AdminPage = lazy(() => import('@/components/AdminPage')); -const BackupCenterPage = lazy(() => import('@/components/BackupCenterPage')); -const ImportPage = lazy(() => import('@/components/ImportPage')); - interface PendingTotp { email: string; passwordHash: string; @@ -149,10 +147,6 @@ function readInviteCodeFromUrl(): string { return ''; } -function RouteContentFallback() { - return
{t('txt_loading_nodewarden')}
; -} - function summarizeImportResult( ciphers: Array>, folderCount: number, @@ -452,21 +446,6 @@ export default function App() { saveSession(next); } - async function silentlyRepairBackupSettingsIfNeeded(activeSession: SessionState, activeProfile: Profile): Promise { - if (activeProfile.role !== 'admin') return; - if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return; - - const tempFetch = createAuthedFetch(() => activeSession, () => {}); - try { - const state = await getAdminBackupSettingsRepairState(tempFetch); - if (!state.needsRepair || !state.portable) return; - const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession); - await repairAdminBackupSettings(tempFetch, repairedSettings); - } catch (error) { - console.error('Backup settings auto-repair failed:', error); - } - } - function pushToast(type: ToastMessage['type'], text: string) { const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; setToasts((prev) => [...prev.slice(-3), { id, type, text }]); @@ -1908,33 +1887,6 @@ export default function App() { return t('nav_my_vault'); })(); - const importPageContent = ( - }> - - - ); - - const renderImportPageRoute = () => ( -
- {mobileLayout && ( -
- -
- )} - {importPageContent} -
- ); - useEffect(() => { if (phase === 'app' && location === '/' && !isPublicSendRoute) navigate('/vault'); }, [phase, location, isPublicSendRoute, navigate]); @@ -2148,274 +2100,158 @@ export default function App() {
- - - - - - - - - - - - {profile && ( -
- {mobileLayout && ( -
- -
- )} - }> - { - await enableTotpAction(secret, token); - await totpStatusQuery.refetch(); - }} - onOpenDisableTotp={() => setDisableTotpOpen(true)} - onGetRecoveryCode={getRecoveryCodeAction} - onNotify={pushToast} - /> - -
- )} -
- - {profile && ( -
-
- - - {t('nav_account_settings')} - - - - {t('nav_device_management')} - - - - {t('nav_import_export')} - - {profile.role === 'admin' && ( - - - {t('nav_admin_panel')} - - )} - {profile.role === 'admin' && ( - - - {t('nav_backup_strategy')} - - )} -
- -
- )} -
- -
- {mobileLayout && ( -
- -
- )} - }> - void refreshAuthorizedDevices()} - onRevokeTrust={(device) => { - setConfirm({ - title: t('txt_revoke_device_authorization'), - message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }), - danger: true, - onConfirm: () => { - setConfirm(null); - void revokeDeviceTrustAction(device); - }, - }); - }} - onRemoveDevice={(device) => { - setConfirm({ - title: t('txt_remove_device'), - message: t('txt_remove_device_and_sign_out_name', { name: device.name }), - danger: true, - onConfirm: () => { - setConfirm(null); - void removeDeviceAction(device); - }, - }); - }} - onRevokeAll={() => { - setConfirm({ - title: t('txt_revoke_all_trusted_devices'), - message: t('txt_revoke_30_day_totp_trust_from_all_devices'), - danger: true, - onConfirm: () => { - setConfirm(null); - void revokeAllDeviceTrustAction(); - }, - }); - }} - onRemoveAll={() => { - 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(); - }, - }); - }} - /> - -
-
- -
- {mobileLayout && ( -
- -
- )} - }> - { - 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')); - }} - /> - -
-
- {IMPORT_ROUTE_PATHS.map((path) => ( - - {renderImportPageRoute()} - - ))} - - {profile?.role === 'admin' ? ( -
- {mobileLayout && ( -
- -
- )} - }> - - -
- ) : null} -
-
+ { + await enableTotpAction(secret, token); + await totpStatusQuery.refetch(); + }} + onOpenDisableTotp={() => setDisableTotpOpen(true)} + onGetRecoveryCode={getRecoveryCodeAction} + onRefreshAuthorizedDevices={refreshAuthorizedDevices} + onRevokeDeviceTrust={(device) => { + setConfirm({ + title: t('txt_revoke_device_authorization'), + message: t('txt_revoke_30_day_totp_trust_for_name', { name: device.name }), + danger: true, + onConfirm: () => { + setConfirm(null); + void revokeDeviceTrustAction(device); + }, + }); + }} + onRemoveDevice={(device) => { + setConfirm({ + title: t('txt_remove_device'), + message: t('txt_remove_device_and_sign_out_name', { name: device.name }), + danger: true, + onConfirm: () => { + setConfirm(null); + void removeDeviceAction(device); + }, + }); + }} + onRevokeAllDeviceTrust={() => { + setConfirm({ + title: t('txt_revoke_all_trusted_devices'), + message: t('txt_revoke_30_day_totp_trust_from_all_devices'), + danger: true, + onConfirm: () => { + setConfirm(null); + void revokeAllDeviceTrustAction(); + }, + }); + }} + onRemoveAllDevices={() => { + 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(); + }, + }); + }} + onRefreshAdmin={() => { + 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')); + }} + onExportBackup={handleBackupExportAction} + onImportBackup={handleBackupImportAction} + onLoadBackupSettings={handleLoadBackupSettingsAction} + onSaveBackupSettings={handleSaveBackupSettingsAction} + onRunRemoteBackup={handleRunRemoteBackupAction} + onListRemoteBackups={handleListRemoteBackupsAction} + onDownloadRemoteBackup={handleDownloadRemoteBackupAction} + onDeleteRemoteBackup={handleDeleteRemoteBackupAction} + onRestoreRemoteBackup={handleRestoreRemoteBackupAction} + />
diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx new file mode 100644 index 0000000..d3d5865 --- /dev/null +++ b/webapp/src/components/AppMainRoutes.tsx @@ -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
{t('txt_loading_nodewarden')}
; +} + +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; + onImportEncryptedRaw: ( + payload: CiphersImportPayload, + options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, + attachments?: ImportAttachmentFile[] + ) => Promise; + onExport: (request: ExportRequest) => Promise; + onCreateVaultItem: (draft: VaultDraft, attachments?: File[]) => Promise; + onUpdateVaultItem: (cipher: Cipher, draft: VaultDraft, options?: { addFiles?: File[]; removeAttachmentIds?: string[] }) => Promise; + onDeleteVaultItem: (cipher: Cipher) => Promise; + onBulkDeleteVaultItems: (ids: string[]) => Promise; + onBulkPermanentDeleteVaultItems: (ids: string[]) => Promise; + onBulkRestoreVaultItems: (ids: string[]) => Promise; + onBulkMoveVaultItems: (ids: string[], folderId: string | null) => Promise; + onVerifyMasterPassword: (email: string, password: string) => Promise; + onCreateFolder: (name: string) => Promise; + onDeleteFolder: (folderId: string) => Promise; + onBulkDeleteFolders: (folderIds: string[]) => Promise; + onDownloadVaultAttachment: (cipher: Cipher, attachmentId: string) => Promise; + onRefreshVault: () => Promise; + onCreateSend: (draft: SendDraft, autoCopyLink: boolean) => Promise; + onUpdateSend: (send: Send, draft: SendDraft, autoCopyLink: boolean) => Promise; + onDeleteSend: (send: Send) => Promise; + onBulkDeleteSends: (ids: string[]) => Promise; + onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; + onEnableTotp: (secret: string, token: string) => Promise; + onOpenDisableTotp: () => void; + onGetRecoveryCode: (masterPassword: string) => Promise; + onRefreshAuthorizedDevices: () => Promise; + onRevokeDeviceTrust: (device: AuthorizedDevice) => void; + onRemoveDevice: (device: AuthorizedDevice) => void; + onRevokeAllDeviceTrust: () => void; + onRemoveAllDevices: () => void; + onCreateInvite: (hours: number) => Promise; + onRefreshAdmin: () => void; + onDeleteAllInvites: () => Promise; + onToggleUserStatus: (userId: string, status: 'active' | 'banned') => Promise; + onDeleteUser: (userId: string) => Promise; + onRevokeInvite: (code: string) => Promise; + onExportBackup: () => Promise; + onImportBackup: (file: File, replaceExisting?: boolean) => Promise; + onLoadBackupSettings: () => Promise; + onSaveBackupSettings: (settings: AdminBackupSettings) => Promise; + onRunRemoteBackup: (destinationId?: string | null) => Promise; + onListRemoteBackups: (destinationId: string, path: string) => Promise; + onDownloadRemoteBackup: (destinationId: string, path: string) => Promise; + onDeleteRemoteBackup: (destinationId: string, path: string) => Promise; + onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; +} + +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 = ( + }> + + + ); + + const renderImportPageRoute = () => ( +
+ {props.mobileLayout && ( +
+ +
+ )} + {importPageContent} +
+ ); + + return ( + + + + + + + + + + + + {props.profile && ( +
+ {props.mobileLayout && ( +
+ +
+ )} + }> + + +
+ )} +
+ + {props.profile && ( +
+
+ + + {t('nav_account_settings')} + + + + {t('nav_device_management')} + + + + {t('nav_import_export')} + + {props.profile.role === 'admin' && ( + + + {t('nav_admin_panel')} + + )} + {props.profile.role === 'admin' && ( + + + {t('nav_backup_strategy')} + + )} +
+ +
+ )} +
+ +
+ {props.mobileLayout && ( +
+ +
+ )} + }> + void props.onRefreshAuthorizedDevices()} + onRevokeTrust={props.onRevokeDeviceTrust} + onRemoveDevice={props.onRemoveDevice} + onRevokeAll={props.onRevokeAllDeviceTrust} + onRemoveAll={props.onRemoveAllDevices} + /> + +
+
+ +
+ {props.mobileLayout && ( +
+ +
+ )} + }> + + +
+
+ {importRoutePaths.map((path) => ( + + {renderImportPageRoute()} + + ))} + + {props.profile?.role === 'admin' ? ( +
+ {props.mobileLayout && ( +
+ +
+ )} + }> + + +
+ ) : null} +
+
+ ); +} diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx index 6b7344e..44447e9 100644 --- a/webapp/src/components/BackupCenterPage.tsx +++ b/webapp/src/components/BackupCenterPage.tsx @@ -6,7 +6,7 @@ import { type BackupDestinationRecord, type BackupDestinationType, type RemoteBackupBrowserResponse, -} from '@/lib/api'; +} from '@/lib/api/backup'; import { REMOTE_BROWSER_ITEMS_PER_PAGE, compareRemoteItems, @@ -92,8 +92,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { const selectedRecommendedProvider = RECOMMENDED_PROVIDERS.find((provider) => provider.id === selectedProviderId) || null; const recommendedWebDavProviders = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 'webdav'); const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3'); - const canRunSelectedDestination = !!selectedDestination && selectedDestination.type !== 'placeholder' && selectedDestinationIsSaved; - const canBrowseSelectedDestination = !!savedSelectedDestination && savedSelectedDestination.type !== 'placeholder'; + const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved; + const canBrowseSelectedDestination = !!savedSelectedDestination; useEffect(() => { let cancelled = false; @@ -135,12 +135,6 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { }); }, [props.currentUserId, remoteBrowserCache, remoteBrowserPageByKey, remoteBrowserPathByDestination, selectedDestinationId]); - useEffect(() => { - if (selectedDestination?.type === 'placeholder') { - setSelectedDestinationId(getFirstVisibleDestinationId(settings)); - } - }, [selectedDestination?.id, selectedDestination?.type, settings]); - function updateSettings(mutator: (current: AdminBackupSettings) => AdminBackupSettings) { setSettings((current) => { const next = mutator(current); diff --git a/webapp/src/components/ImportPage.tsx b/webapp/src/components/ImportPage.tsx index 077c75c..35b67ed 100644 --- a/webapp/src/components/ImportPage.tsx +++ b/webapp/src/components/ImportPage.tsx @@ -4,7 +4,7 @@ import { strFromU8, unzipSync } from 'fflate'; import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js'; import { Download, FileUp } from 'lucide-preact'; import ConfirmDialog from '@/components/ConfirmDialog'; -import type { CiphersImportPayload } from '@/lib/api'; +import type { CiphersImportPayload } from '@/lib/api/vault'; import { type EncryptedJsonMode, EXPORT_FORMATS, diff --git a/webapp/src/components/PublicSendPage.tsx b/webapp/src/components/PublicSendPage.tsx index c000fdb..b769be4 100644 --- a/webapp/src/components/PublicSendPage.tsx +++ b/webapp/src/components/PublicSendPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'preact/hooks'; import { Download, Eye, Lock } from 'lucide-preact'; -import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api'; +import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send'; import StandalonePageFrame from '@/components/StandalonePageFrame'; import { t } from '@/lib/i18n'; diff --git a/webapp/src/components/backup-center/BackupDestinationDetail.tsx b/webapp/src/components/backup-center/BackupDestinationDetail.tsx index 5cb9506..98d7d38 100644 --- a/webapp/src/components/backup-center/BackupDestinationDetail.tsx +++ b/webapp/src/components/backup-center/BackupDestinationDetail.tsx @@ -4,7 +4,7 @@ import type { E3BackupDestination, RemoteBackupBrowserResponse, WebDavBackupDestination, -} from '@/lib/api'; +} from '@/lib/api/backup'; import { COMMON_TIME_ZONES, WEEKDAY_OPTIONS, getDestinationTypeLabel } from '@/lib/backup-center'; import type { RecommendedProvider } from '@/lib/backup-recommendations'; import { RemoteBackupBrowser } from './RemoteBackupBrowser'; diff --git a/webapp/src/components/backup-center/BackupDestinationSidebar.tsx b/webapp/src/components/backup-center/BackupDestinationSidebar.tsx index f61bb4a..9cf6ebd 100644 --- a/webapp/src/components/backup-center/BackupDestinationSidebar.tsx +++ b/webapp/src/components/backup-center/BackupDestinationSidebar.tsx @@ -1,5 +1,5 @@ import { Plus } from 'lucide-preact'; -import type { BackupDestinationRecord, BackupDestinationType } from '@/lib/api'; +import type { BackupDestinationRecord, BackupDestinationType } from '@/lib/api/backup'; import { formatDateTime, getDestinationTypeLabel } from '@/lib/backup-center'; import { t } from '@/lib/i18n'; diff --git a/webapp/src/components/backup-center/RemoteBackupBrowser.tsx b/webapp/src/components/backup-center/RemoteBackupBrowser.tsx index ae3f625..7e9f7b2 100644 --- a/webapp/src/components/backup-center/RemoteBackupBrowser.tsx +++ b/webapp/src/components/backup-center/RemoteBackupBrowser.tsx @@ -1,5 +1,5 @@ import { Download, FileArchive, FolderOpen, RefreshCw, RotateCcw, Trash2 } from 'lucide-preact'; -import type { RemoteBackupBrowserResponse } from '@/lib/api'; +import type { RemoteBackupBrowserResponse } from '@/lib/api/backup'; import { formatBytes, formatDateTime, isZipCandidate } from '@/lib/backup-center'; import { t } from '@/lib/i18n'; diff --git a/webapp/src/lib/admin-backup-portable.ts b/webapp/src/lib/admin-backup-portable.ts index 1b12f30..75915e1 100644 --- a/webapp/src/lib/admin-backup-portable.ts +++ b/webapp/src/lib/admin-backup-portable.ts @@ -1,5 +1,5 @@ import { base64ToBytes, decryptBw } from './crypto'; -import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api'; +import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup'; import type { Profile, SessionState } from './types'; const PORTABLE_ALGORITHM = 'RSA-OAEP'; diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts deleted file mode 100644 index 1b51321..0000000 --- a/webapp/src/lib/api.ts +++ /dev/null @@ -1,1823 +0,0 @@ -import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, hkdfExpand, pbkdf2 } from './crypto'; -import { t } from './i18n'; -import type { - AuthorizedDevice, - AdminInvite, - AdminUser, - Cipher, - Folder, - ListResponse, - Profile, - SessionState, - Send, - SendDraft, - SetupStatusResponse, - TokenError, - TokenSuccess, - VaultDraft, - VaultDraftField, - WebConfigResponse, -} from './types'; -import type { - BackupDestinationRecord, - BackupDestinationType, - BackupSettings as AdminBackupSettings, - E3BackupDestination, - WebDavBackupDestination, -} from '@shared/backup'; - -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'; -const BULK_API_CHUNK_SIZE = 200; - -type SessionSetter = (next: SessionState | null) => void; - -function chunkArray(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 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)); -} - -async function parseJson(response: Response): Promise { - const text = await response.text(); - if (!text) return null; - try { - return JSON.parse(text) as T; - } catch { - return null; - } -} - -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 getSetupStatus(): Promise { - const resp = await fetch('/setup/status'); - const body = await parseJson(resp); - return { registered: !!body?.registered }; -} - -export async function getWebConfig(): Promise { - const resp = await fetch('/api/web/config'); - return (await parseJson(resp)) || {}; -} - -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; -} - -export function getCurrentDeviceIdentifier(): string { - return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); -} - -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 async function deriveLoginHash(email: string, password: string, fallbackIterations: number): Promise { - 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 { - 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 { - 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(resp)) || {}; - if (resp.ok) { - saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken); - } else if (rememberedToken) { - // Remember-token login failed; force the next attempt to use real TOTP. - clearRememberTwoFactorToken(); - } - if (!resp.ok) return json; - return json; -} - -export async function refreshAccessToken(refreshToken: string): Promise { - 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(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(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 { - 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: (input: string, init?: RequestInit) => Promise): Promise { - const resp = await authedFetch('/api/accounts/profile'); - if (!resp.ok) throw new Error('Failed to load profile'); - const body = await parseJson(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 getFolders(authedFetch: (input: string, init?: RequestInit) => Promise): Promise { - const resp = await authedFetch('/api/folders'); - if (!resp.ok) throw new Error('Failed to load folders'); - const body = await parseJson>(resp); - return body?.data || []; -} - -export async function createFolder( - authedFetch: (input: string, init?: RequestInit) => Promise, - 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 { - 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: (input: string, init?: RequestInit) => Promise, - folderId: string -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - ids: string[] -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - session: SessionState, - folderId: string, - name: string -): Promise { - 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: (input: string, init?: RequestInit) => Promise): Promise { - const resp = await authedFetch('/api/ciphers?deleted=true'); - if (!resp.ok) throw new Error('Failed to load ciphers'); - const body = await parseJson>(resp); - return body?.data || []; -} - -export interface CiphersImportPayload { - ciphers: Array>; - 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: (input: string, init?: RequestInit) => Promise, - payload: CiphersImportPayload, - options?: { returnCipherMap?: boolean } -): Promise { - 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(); - 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(); - 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: (input: string, init?: RequestInit) => Promise, - cipherId: string, - attachmentId: string -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - session: SessionState, - cipherId: string, - file: File, - cipherForKey?: Cipher | null -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - cipherId: string, - attachmentId: string -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - 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 }; -} - -export async function getSends(authedFetch: (input: string, init?: RequestInit) => Promise): Promise { - const resp = await authedFetch('/api/sends'); - if (!resp.ok) throw new Error('Failed to load sends'); - const body = await parseJson>(resp); - return body?.data || []; -} - -export async function changeMasterPassword( - authedFetch: (input: string, init?: RequestInit) => Promise, - args: { - email: string; - currentPassword: string; - newPassword: string; - currentIterations: number; - profileKey: string; - } -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - payload: { enabled: boolean; token?: string; secret?: string; masterPasswordHash?: string } -): Promise { - 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(resp); - throw new Error(body?.error_description || body?.error || 'TOTP update failed'); - } -} - -export async function verifyMasterPassword( - authedFetch: (input: string, init?: RequestInit) => Promise, - masterPasswordHash: string -): Promise { - 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(resp); - throw new Error(body?.error_description || body?.error || 'Master password verify failed'); - } -} - -export async function getTotpStatus( - authedFetch: (input: string, init?: RequestInit) => Promise -): 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: (input: string, init?: RequestInit) => Promise, - masterPasswordHash: string -): Promise { - 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(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(resp); - throw new Error(body?.error_description || body?.error || 'Recover 2FA failed'); - } - return (await parseJson<{ newRecoveryCode?: string }>(resp)) || {}; -} - -export async function getAuthorizedDevices( - authedFetch: (input: string, init?: RequestInit) => Promise -): Promise { - const resp = await authedFetch('/api/devices/authorized'); - if (!resp.ok) throw new Error(t('txt_load_devices_failed')); - const body = await parseJson>(resp); - return body?.data || []; -} - -export async function revokeAuthorizedDeviceTrust( - authedFetch: (input: string, init?: RequestInit) => Promise, - deviceIdentifier: string -): Promise { - 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: (input: string, init?: RequestInit) => Promise -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - deviceIdentifier: string -): Promise { - 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: (input: string, init?: RequestInit) => Promise -): Promise { - const resp = await authedFetch('/api/devices', { method: 'DELETE' }); - if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); -} - -export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise): Promise { - const resp = await authedFetch('/api/admin/users'); - if (!resp.ok) throw new Error('Failed to load users'); - const body = await parseJson>(resp); - return body?.data || []; -} - -export async function listAdminInvites(authedFetch: (input: string, init?: RequestInit) => Promise): Promise { - const resp = await authedFetch('/api/admin/invites?includeInactive=true'); - if (!resp.ok) throw new Error('Failed to load invites'); - const body = await parseJson>(resp); - return body?.data || []; -} - -export async function createInvite(authedFetch: (input: string, init?: RequestInit) => Promise, hours: number): Promise { - 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: (input: string, init?: RequestInit) => Promise, code: string): Promise { - 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: (input: string, init?: RequestInit) => Promise): Promise { - const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' }); - if (!resp.ok) throw new Error('Delete all invites failed'); -} - -export async function setUserStatus( - authedFetch: (input: string, init?: RequestInit) => Promise, - userId: string, - status: 'active' | 'banned' -): Promise { - 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: (input: string, init?: RequestInit) => Promise, userId: string): Promise { - const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }); - if (!resp.ok) throw new Error('Delete user failed'); -} - -export type { - BackupDestinationConfig, - BackupDestinationRecord, - BackupDestinationType, - BackupRuntimeState, - BackupScheduleConfig, - BackupSettings as AdminBackupSettings, - E3BackupDestination, - PlaceholderBackupDestination, - WebDavBackupDestination, -} from '@shared/backup'; - -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: (input: string, init?: RequestInit) => Promise -): Promise { - 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: (input: string, init?: RequestInit) => Promise -): Promise { - 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(resp); - if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response')); - return body; -} - -export async function saveAdminBackupSettings( - authedFetch: (input: string, init?: RequestInit) => Promise, - settings: AdminBackupSettings -): Promise { - 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(resp); - if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response')); - return body; -} - -export async function getAdminBackupSettingsRepairState( - authedFetch: (input: string, init?: RequestInit) => Promise -): Promise { - 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(resp); - if (!body || typeof body.needsRepair !== 'boolean') { - throw new Error(t('txt_backup_settings_invalid_response')); - } - return body; -} - -export async function repairAdminBackupSettings( - authedFetch: (input: string, init?: RequestInit) => Promise, - settings: AdminBackupSettings -): Promise { - 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(resp); - if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response')); - return body; -} - -export async function runAdminBackupNow( - authedFetch: (input: string, init?: RequestInit) => Promise, - destinationId?: string | null -): Promise { - 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(resp); - if (!body?.result || !body?.settings) throw new Error(t('txt_backup_remote_run_invalid_response')); - return body; -} - -export async function listRemoteBackups( - authedFetch: (input: string, init?: RequestInit) => Promise, - destinationId: string, - path: string = '' -): Promise { - 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(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: (input: string, init?: RequestInit) => Promise, - destinationId: string, - path: string -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - destinationId: string, - path: string -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - destinationId: string, - path: string, - replaceExisting: boolean = false -): Promise { - 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(resp); - if (!body?.imported) throw new Error(t('txt_backup_remote_restore_invalid_response')); - return body; -} - -export async function importAdminBackup( - authedFetch: (input: string, init?: RequestInit) => Promise, - file: File, - replaceExisting: boolean = false -): Promise { - 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(resp); - if (!body?.imported) throw new Error(t('txt_backup_import_invalid_response')); - return body; -} - -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 { - 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> { - 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> { - 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 { - 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 { - 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> | null | undefined - , - enc: Uint8Array, - mac: Uint8Array -): Promise> | null> { - if (!Array.isArray(credentials) || credentials.length === 0) return null; - const out: Array> = []; - 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> { - 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 = { - 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> { - return buildCipherPayload(session, draft, null); -} - -export async function createCipher( - authedFetch: (input: string, init?: RequestInit) => Promise, - 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: (input: string, init?: RequestInit) => Promise, - session: SessionState, - cipher: Cipher, - draft: VaultDraft -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - cipherId: string -): Promise { - const resp = await authedFetch(`/api/ciphers/${encodeURIComponent(cipherId)}`, { method: 'DELETE' }); - if (!resp.ok) throw new Error('Delete item failed'); -} - -export async function bulkDeleteCiphers( - authedFetch: (input: string, init?: RequestInit) => Promise, - ids: string[] -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - ids: string[] -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - ids: string[] -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - ids: string[], - folderId: string | null -): Promise { - 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'); - } -} - -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 parseErrorMessage(resp: Response, fallback: string): Promise { - const body = await parseJson(resp); - return body?.error_description || body?.error || fallback; -} - -async function toSendKeyParts(sendKeyMaterial: Uint8Array): Promise<{ enc: Uint8Array; mac: Uint8Array }> { - // Legacy compatibility: early NodeWarden builds stored a full 64-byte key material. - if (sendKeyMaterial.length >= 64) { - return { enc: sendKeyMaterial.slice(0, 32), mac: sendKeyMaterial.slice(32, 64) }; - } - // Official behavior: send URL key is seed material; derive 64-byte key via HKDF. - 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 { - 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 createSend( - authedFetch: (input: string, init?: RequestInit) => Promise, - session: SessionState, - draft: SendDraft -): Promise { - 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(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: (input: string, init?: RequestInit) => Promise, - session: SessionState, - send: Send, - draft: SendDraft -): Promise { - 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(resp); - if (!body?.id) throw new Error('Update send failed'); - return body; -} - -export async function deleteSend( - authedFetch: (input: string, init?: RequestInit) => Promise, - sendId: string -): Promise { - 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: (input: string, init?: RequestInit) => Promise, - ids: string[] -): Promise { - 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/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> { - const payload: Record = {}; - const plainPassword = String(password || '').trim(); - if (!plainPassword) return payload; - - // Only send the PBKDF2 hash bound to the send key material — never send plaintext password. - 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; cannot compute hash — server will reject as unauthorized. - } - } - return payload; -} - -export async function accessPublicSend(accessId: string, keyPart?: string | null, password?: string): Promise { - 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'); - const error = new Error(message) as Error & { status?: number }; - error.status = resp.status; - throw error; - } - return (await parseJson(resp)) || null; -} - -export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise { - 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'); - const error = new Error(message) as Error & { status?: number }; - error.status = resp.status; - throw error; - } - 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 { - 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 { - 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 { - const userEnc = base64ToBytes(userEncB64); - const userMac = base64ToBytes(userMacB64); - return decryptBw(sendKeyEncrypted, userEnc, userMac).then((keyMaterial) => bytesToBase64Url(keyMaterial)); -} diff --git a/webapp/src/lib/api/admin.ts b/webapp/src/lib/api/admin.ts new file mode 100644 index 0000000..b34038c --- /dev/null +++ b/webapp/src/lib/api/admin.ts @@ -0,0 +1,53 @@ +import type { AdminInvite, AdminUser, ListResponse } from '../types'; +import { parseJson, type AuthedFetch } from './shared'; + +export async function listAdminUsers(authedFetch: AuthedFetch): Promise { + const resp = await authedFetch('/api/admin/users'); + if (!resp.ok) throw new Error('Failed to load users'); + const body = await parseJson>(resp); + return body?.data || []; +} + +export async function listAdminInvites(authedFetch: AuthedFetch): Promise { + const resp = await authedFetch('/api/admin/invites?includeInactive=true'); + if (!resp.ok) throw new Error('Failed to load invites'); + const body = await parseJson>(resp); + return body?.data || []; +} + +export async function createInvite(authedFetch: AuthedFetch, hours: number): Promise { + 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 { + 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 { + 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 { + 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 { + const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }); + if (!resp.ok) throw new Error('Delete user failed'); +} diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts new file mode 100644 index 0000000..94e0353 --- /dev/null +++ b/webapp/src/lib/api/auth.ts @@ -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 { + const resp = await fetch('/setup/status'); + const body = await parseJson(resp); + return { registered: !!body?.registered }; +} + +export async function getWebConfig(): Promise { + const resp = await fetch('/api/web/config'); + return (await parseJson(resp)) || {}; +} + +export function getCurrentDeviceIdentifier(): string { + return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); +} + +export async function deriveLoginHash(email: string, password: string, fallbackIterations: number): Promise { + 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 { + 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 { + 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(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 { + 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(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(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 { + 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 { + const resp = await authedFetch('/api/accounts/profile'); + if (!resp.ok) throw new Error('Failed to load profile'); + const body = await parseJson(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 { + 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 { + 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(resp); + throw new Error(body?.error_description || body?.error || 'TOTP update failed'); + } +} + +export async function verifyMasterPassword( + authedFetch: AuthedFetch, + masterPasswordHash: string +): Promise { + 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(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 { + 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(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(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 { + 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 { + 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 { + 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 { + 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 { + const resp = await authedFetch('/api/devices', { method: 'DELETE' }); + if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); +} diff --git a/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts new file mode 100644 index 0000000..e6f459a --- /dev/null +++ b/webapp/src/lib/api/backup.ts @@ -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 { + 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 { + 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(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 { + 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(resp); + if (!Array.isArray(body?.destinations)) throw new Error(t('txt_backup_settings_invalid_response')); + return body; +} + +export async function getAdminBackupSettingsRepairState( + authedFetch: AuthedFetch +): Promise { + 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(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 { + 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(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 { + 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(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 { + 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(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 { + 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 { + 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 { + 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(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 { + 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(resp); + if (!body?.imported) throw new Error(t('txt_backup_import_invalid_response')); + return body; +} diff --git a/webapp/src/lib/api/send.ts b/webapp/src/lib/api/send.ts new file mode 100644 index 0000000..6b23310 --- /dev/null +++ b/webapp/src/lib/api/send.ts @@ -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 { + 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 { + 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 { + 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 { + 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(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 { + 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(resp); + if (!body?.id) throw new Error('Update send failed'); + return body; +} + +export async function deleteSend(authedFetch: AuthedFetch, sendId: string): Promise { + 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 { + 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> { + const payload: Record = {}; + 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 { + 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(resp)) || null; +} + +export async function accessPublicSendFile(sendId: string, fileId: string, keyPart?: string | null, password?: string): Promise { + 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 { + 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 { + 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 { + const userEnc = base64ToBytes(userEncB64); + const userMac = base64ToBytes(userMacB64); + return decryptBw(sendKeyEncrypted, userEnc, userMac).then((keyMaterial) => bytesToBase64Url(keyMaterial)); +} diff --git a/webapp/src/lib/api/shared.ts b/webapp/src/lib/api/shared.ts new file mode 100644 index 0000000..94f9050 --- /dev/null +++ b/webapp/src/lib/api/shared.ts @@ -0,0 +1,60 @@ +import { t } from '../i18n'; +import type { SessionState, TokenError } from '../types'; + +export type AuthedFetch = (input: string, init?: RequestInit) => Promise; +export type SessionSetter = (next: SessionState | null) => void; + +export const BULK_API_CHUNK_SIZE = 200; + +export function chunkArray(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(response: Response): Promise { + 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 { + const body = await parseJson(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)); +} diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts new file mode 100644 index 0000000..9d66cda --- /dev/null +++ b/webapp/src/lib/api/vault.ts @@ -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 { + const resp = await authedFetch('/api/folders'); + if (!resp.ok) throw new Error('Failed to load folders'); + const body = await parseJson>(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 { + 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 { + 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 { + 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 { + 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 { + const resp = await authedFetch('/api/ciphers?deleted=true'); + if (!resp.ok) throw new Error('Failed to load ciphers'); + const body = await parseJson>(resp); + return body?.data || []; +} + +export interface CiphersImportPayload { + ciphers: Array>; + 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 { + 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(); + 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(); + 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 { + 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 { + 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 { + 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 { + 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> { + 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> { + 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 { + 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 { + 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> | null | undefined, + enc: Uint8Array, + mac: Uint8Array +): Promise> | null> { + if (!Array.isArray(credentials) || credentials.length === 0) return null; + const out: Array> = []; + 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> { + 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 = { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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'); + } +} diff --git a/webapp/src/lib/backup-center.ts b/webapp/src/lib/backup-center.ts index aa99bf1..dac0a81 100644 --- a/webapp/src/lib/backup-center.ts +++ b/webapp/src/lib/backup-center.ts @@ -5,8 +5,8 @@ import { type BackupSettings, createBackupDestinationRecord, createDefaultBackupSettings, -} from '@shared/backup'; -import type { RemoteBackupBrowserResponse, RemoteBackupItem } from './api'; +} from '@shared/backup-schema'; +import type { RemoteBackupBrowserResponse, RemoteBackupItem } from './api/backup'; import { t } from './i18n'; export interface PersistedRemoteBrowserState { @@ -52,7 +52,6 @@ export function detectBrowserTimeZone(): string { function createLocalizedDestinationName(type: BackupDestinationType, index: number): string { if (type === 'e3') return t('txt_backup_destination_name_default_e3', { index: String(index) }); - if (type === 'placeholder') return `${t('txt_backup_destination_reserved')} ${index}`; return t('txt_backup_destination_name_default_webdav', { index: String(index) }); } @@ -197,7 +196,7 @@ export function getDestinationById( } export function getVisibleDestinations(settings: BackupSettings | null | undefined): BackupDestinationRecord[] { - return (settings?.destinations || []).filter((destination) => destination.type !== 'placeholder'); + return settings?.destinations || []; } export function getFirstVisibleDestinationId(settings: BackupSettings | null | undefined): string | null { @@ -206,6 +205,5 @@ export function getFirstVisibleDestinationId(settings: BackupSettings | null | u export function getDestinationTypeLabel(type: BackupDestinationType): string { if (type === 'e3') return t('txt_backup_protocol_e3'); - if (type === 'placeholder') return t('txt_backup_destination_reserved'); return t('txt_backup_protocol_webdav'); } diff --git a/webapp/src/lib/backup-settings-repair.ts b/webapp/src/lib/backup-settings-repair.ts new file mode 100644 index 0000000..7e44978 --- /dev/null +++ b/webapp/src/lib/backup-settings-repair.ts @@ -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 { + 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); + } +} diff --git a/webapp/src/lib/export-formats.ts b/webapp/src/lib/export-formats.ts index 7e890df..a218d83 100644 --- a/webapp/src/lib/export-formats.ts +++ b/webapp/src/lib/export-formats.ts @@ -1,7 +1,7 @@ import { argon2idAsync } from '@noble/hashes/argon2.js'; import { strToU8, zipSync } from 'fflate'; import { Uint8ArrayReader, Uint8ArrayWriter, ZipReader, ZipWriter, configure as configureZipJs } from '@zip.js/zip.js'; -import type { PreloginKdfConfig } from './api'; +import type { PreloginKdfConfig } from './api/auth'; import { base64ToBytes, bytesToBase64, decryptBw, decryptStr, encryptBw, hkdfExpand, pbkdf2 } from './crypto'; import type { Cipher, Folder } from './types'; diff --git a/webapp/src/lib/import-formats.ts b/webapp/src/lib/import-formats.ts index 6e61435..437f21e 100644 --- a/webapp/src/lib/import-formats.ts +++ b/webapp/src/lib/import-formats.ts @@ -1,4 +1,4 @@ -import type { CiphersImportPayload } from '@/lib/api'; +import type { CiphersImportPayload } from '@/lib/api/vault'; type ImportSourceEntry = { id: string; label: string };