From ca74e5597907020c271b99edc883d5592efef953 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 15 Mar 2026 18:15:28 +0800 Subject: [PATCH] refactor: enhance manual chunking in Vite config for better code splitting --- src/handlers/sends-private.ts | 644 +++++ src/handlers/sends-public.ts | 400 +++ src/handlers/sends-shared.ts | 451 +++ src/handlers/sends.ts | 1476 +--------- src/router-authenticated.ts | 276 ++ src/router-public.ts | 291 ++ src/router.ts | 744 +---- src/services/storage-admin-repo.ts | 84 + src/services/storage-attachment-repo.ts | 143 + src/services/storage-attachment-token-repo.ts | 46 + src/services/storage-cipher-repo.ts | 227 ++ src/services/storage-config-repo.ts | 22 + src/services/storage-device-repo.ts | 165 ++ src/services/storage-folder-repo.ts | 120 + src/services/storage-refresh-token-repo.ts | 135 + src/services/storage-revision-repo.ts | 31 + src/services/storage-send-repo.ts | 163 ++ src/services/storage-user-repo.ts | 135 + src/services/storage.ts | 1109 ++----- webapp/src/App.tsx | 1811 ++---------- .../src/components/AppAuthenticatedShell.tsx | 126 + webapp/src/components/AppGlobalOverlays.tsx | 95 + webapp/src/components/AppMainRoutes.tsx | 76 +- webapp/src/components/ImportPage.tsx | 12 +- webapp/src/components/VaultPage.tsx | 1570 ++-------- .../src/components/vault/VaultDetailView.tsx | 328 +++ webapp/src/components/vault/VaultDialogs.tsx | 148 + webapp/src/components/vault/VaultEditor.tsx | 327 +++ .../src/components/vault/VaultListPanel.tsx | 178 ++ webapp/src/components/vault/VaultSidebar.tsx | 127 + .../components/vault/vault-page-helpers.tsx | 324 +++ webapp/src/hooks/useAccountSecurityActions.ts | 210 ++ webapp/src/hooks/useAdminActions.ts | 79 + webapp/src/hooks/useBackupActions.ts | 69 + webapp/src/hooks/useToastManager.ts | 24 + webapp/src/hooks/useVaultSendActions.ts | 703 +++++ webapp/src/lib/app-auth.ts | 268 ++ webapp/src/lib/app-support.ts | 240 ++ webapp/src/lib/download.ts | 12 + webapp/src/lib/import-format-shared.ts | 225 ++ webapp/src/lib/import-format-sources.ts | 78 + webapp/src/lib/import-formats-advanced.ts | 584 ++++ webapp/src/lib/import-formats-bitwarden.ts | 157 + webapp/src/lib/import-formats-browser.ts | 194 ++ webapp/src/lib/import-formats-csv-misc.ts | 373 +++ webapp/src/lib/import-formats-onepassword.ts | 546 ++++ .../lib/import-formats-password-managers.ts | 416 +++ webapp/src/lib/import-formats.ts | 2546 +---------------- webapp/vite.config.ts | 44 +- 49 files changed, 10021 insertions(+), 8531 deletions(-) create mode 100644 src/handlers/sends-private.ts create mode 100644 src/handlers/sends-public.ts create mode 100644 src/handlers/sends-shared.ts create mode 100644 src/router-authenticated.ts create mode 100644 src/router-public.ts create mode 100644 src/services/storage-admin-repo.ts create mode 100644 src/services/storage-attachment-repo.ts create mode 100644 src/services/storage-attachment-token-repo.ts create mode 100644 src/services/storage-cipher-repo.ts create mode 100644 src/services/storage-config-repo.ts create mode 100644 src/services/storage-device-repo.ts create mode 100644 src/services/storage-folder-repo.ts create mode 100644 src/services/storage-refresh-token-repo.ts create mode 100644 src/services/storage-revision-repo.ts create mode 100644 src/services/storage-send-repo.ts create mode 100644 src/services/storage-user-repo.ts create mode 100644 webapp/src/components/AppAuthenticatedShell.tsx create mode 100644 webapp/src/components/AppGlobalOverlays.tsx create mode 100644 webapp/src/components/vault/VaultDetailView.tsx create mode 100644 webapp/src/components/vault/VaultDialogs.tsx create mode 100644 webapp/src/components/vault/VaultEditor.tsx create mode 100644 webapp/src/components/vault/VaultListPanel.tsx create mode 100644 webapp/src/components/vault/VaultSidebar.tsx create mode 100644 webapp/src/components/vault/vault-page-helpers.tsx create mode 100644 webapp/src/hooks/useAccountSecurityActions.ts create mode 100644 webapp/src/hooks/useAdminActions.ts create mode 100644 webapp/src/hooks/useBackupActions.ts create mode 100644 webapp/src/hooks/useToastManager.ts create mode 100644 webapp/src/hooks/useVaultSendActions.ts create mode 100644 webapp/src/lib/app-auth.ts create mode 100644 webapp/src/lib/app-support.ts create mode 100644 webapp/src/lib/download.ts create mode 100644 webapp/src/lib/import-format-shared.ts create mode 100644 webapp/src/lib/import-format-sources.ts create mode 100644 webapp/src/lib/import-formats-advanced.ts create mode 100644 webapp/src/lib/import-formats-bitwarden.ts create mode 100644 webapp/src/lib/import-formats-browser.ts create mode 100644 webapp/src/lib/import-formats-csv-misc.ts create mode 100644 webapp/src/lib/import-formats-onepassword.ts create mode 100644 webapp/src/lib/import-formats-password-managers.ts diff --git a/src/handlers/sends-private.ts b/src/handlers/sends-private.ts new file mode 100644 index 0000000..d3d19a0 --- /dev/null +++ b/src/handlers/sends-private.ts @@ -0,0 +1,644 @@ +import { Env, Send, SendAuthType, SendType } from '../types'; +import { StorageService } from '../services/storage'; +import { jsonResponse, errorResponse } from '../utils/response'; +import { generateUUID } from '../utils/uuid'; +import { parsePagination, encodeContinuationToken } from '../utils/pagination'; +import { LIMITS } from '../config/limits'; +import { + getBlobStorageMaxBytes, + getSendFileObjectKey, + putBlobObject, + deleteBlobObject, +} from '../services/blob-store'; +import { + formatSize, + getAliasedProp, + normalizeEmails, + notifyVaultSyncForRequest, + parseDate, + parseFileLength, + parseInteger, + parseMaxAccessCount, + parseSendAuthType, + parseSendType, + parseStoredSendData, + sanitizeSendData, + sendToResponse, + setSendPassword, + validateDeletionDate, +} from './sends-shared'; + +export async function handleGetSends(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const url = new URL(request.url); + const pagination = parsePagination(url); + + let sends: Send[]; + let continuationToken: string | null = null; + if (pagination) { + const pageRows = await storage.getSendsPage(userId, pagination.limit + 1, pagination.offset); + const hasNext = pageRows.length > pagination.limit; + sends = hasNext ? pageRows.slice(0, pagination.limit) : pageRows; + continuationToken = hasNext ? encodeContinuationToken(pagination.offset + sends.length) : null; + } else { + sends = await storage.getAllSends(userId); + } + + return jsonResponse({ + data: sends.map(sendToResponse), + object: 'list', + continuationToken, + }); +} + +export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise { + void request; + const storage = new StorageService(env.DB); + const send = await storage.getSend(sendId); + + if (!send || send.userId !== userId) { + return errorResponse('Send not found', 404); + } + + return jsonResponse(sendToResponse(send)); +} + +export async function handleCreateSend(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + + let body: unknown; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const typeRaw = getAliasedProp(body, ['type', 'Type']); + const sendType = parseSendType(typeRaw.value); + if (sendType === null) { + return errorResponse('Invalid Send type', 400); + } + if (sendType === SendType.File) { + return errorResponse('File sends should use /api/sends/file/v2', 400); + } + + const nameRaw = getAliasedProp(body, ['name', 'Name']); + const keyRaw = getAliasedProp(body, ['key', 'Key']); + const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']); + const textRaw = getAliasedProp(body, ['text', 'Text']); + + if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) { + return errorResponse('Name is required', 400); + } + if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) { + return errorResponse('Key is required', 400); + } + + const deletionDate = parseDate(deletionDateRaw.value); + if (!deletionDate) { + return errorResponse('Invalid deletionDate', 400); + } + + const deletionValidation = validateDeletionDate(deletionDate); + if (deletionValidation) return deletionValidation; + + const sendData = sanitizeSendData(textRaw.value); + if (!sendData) { + return errorResponse('Send data not provided', 400); + } + + const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']); + const maxAccess = parseMaxAccessCount(maxAccessRaw.value); + if (!maxAccess.ok) return maxAccess.response; + + const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']); + const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined + ? null + : parseDate(expirationRaw.value); + if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) { + return errorResponse('Invalid expirationDate', 400); + } + + const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']); + const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']); + const notesRaw = getAliasedProp(body, ['notes', 'Notes']); + const passwordRaw = getAliasedProp(body, ['password', 'Password']); + const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']); + const emailsRaw = getAliasedProp(body, ['emails', 'Emails']); + + const requestedAuthType = parseSendAuthType(authTypeRaw.value); + if (authTypeRaw.present && requestedAuthType === null) { + return errorResponse('Invalid authType', 400); + } + + const normalizedEmails = normalizeEmails(emailsRaw.value); + if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) { + return errorResponse('Invalid emails', 400); + } + + const now = new Date().toISOString(); + const send: Send = { + id: generateUUID(), + userId, + type: sendType, + name: nameRaw.value.trim(), + notes: typeof notesRaw.value === 'string' ? notesRaw.value : null, + data: JSON.stringify(sendData), + key: keyRaw.value, + passwordHash: null, + passwordSalt: null, + passwordIterations: null, + authType: requestedAuthType ?? SendAuthType.None, + emails: normalizedEmails, + maxAccessCount: maxAccess.value, + accessCount: 0, + disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false, + hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null, + createdAt: now, + updatedAt: now, + expirationDate: expirationDate ? expirationDate.toISOString() : null, + deletionDate: deletionDate.toISOString(), + }; + + if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) { + await setSendPassword(send, passwordRaw.value); + } else if (send.authType === SendAuthType.Password) { + return errorResponse('Password is required for password auth', 400); + } + + if (send.authType !== SendAuthType.Email) { + send.emails = null; + } + + await storage.saveSend(send); + const revisionDate = await storage.updateRevisionDate(userId); + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + + return jsonResponse(sendToResponse(send)); +} + +export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes); + + let body: unknown; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const typeRaw = getAliasedProp(body, ['type', 'Type']); + const sendType = parseSendType(typeRaw.value); + if (sendType !== SendType.File) { + return errorResponse('Send content is not a file', 400); + } + + const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']); + const fileLengthParsed = parseFileLength(fileLengthRaw.value); + if (!fileLengthParsed.ok) return fileLengthParsed.response; + if (fileLengthParsed.value > maxFileSize) { + return errorResponse('Send storage limit exceeded with this file', 400); + } + + const nameRaw = getAliasedProp(body, ['name', 'Name']); + const keyRaw = getAliasedProp(body, ['key', 'Key']); + const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']); + const fileRaw = getAliasedProp(body, ['file', 'File']); + + if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) { + return errorResponse('Name is required', 400); + } + if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) { + return errorResponse('Key is required', 400); + } + + const deletionDate = parseDate(deletionDateRaw.value); + if (!deletionDate) { + return errorResponse('Invalid deletionDate', 400); + } + const deletionValidation = validateDeletionDate(deletionDate); + if (deletionValidation) return deletionValidation; + + const fileData = sanitizeSendData(fileRaw.value); + if (!fileData) { + return errorResponse('Send data not provided', 400); + } + + const fileId = generateUUID(); + fileData.id = fileId; + fileData.size = fileLengthParsed.value; + fileData.sizeName = formatSize(fileLengthParsed.value); + + const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']); + const maxAccess = parseMaxAccessCount(maxAccessRaw.value); + if (!maxAccess.ok) return maxAccess.response; + + const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']); + const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined + ? null + : parseDate(expirationRaw.value); + if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) { + return errorResponse('Invalid expirationDate', 400); + } + + const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']); + const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']); + const notesRaw = getAliasedProp(body, ['notes', 'Notes']); + const passwordRaw = getAliasedProp(body, ['password', 'Password']); + const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']); + const emailsRaw = getAliasedProp(body, ['emails', 'Emails']); + + const requestedAuthType = parseSendAuthType(authTypeRaw.value); + if (authTypeRaw.present && requestedAuthType === null) { + return errorResponse('Invalid authType', 400); + } + + const normalizedEmails = normalizeEmails(emailsRaw.value); + if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) { + return errorResponse('Invalid emails', 400); + } + + const now = new Date().toISOString(); + const send: Send = { + id: generateUUID(), + userId, + type: sendType, + name: nameRaw.value.trim(), + notes: typeof notesRaw.value === 'string' ? notesRaw.value : null, + data: JSON.stringify(fileData), + key: keyRaw.value, + passwordHash: null, + passwordSalt: null, + passwordIterations: null, + authType: requestedAuthType ?? SendAuthType.None, + emails: normalizedEmails, + maxAccessCount: maxAccess.value, + accessCount: 0, + disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false, + hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null, + createdAt: now, + updatedAt: now, + expirationDate: expirationDate ? expirationDate.toISOString() : null, + deletionDate: deletionDate.toISOString(), + }; + + if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) { + await setSendPassword(send, passwordRaw.value); + } else if (send.authType === SendAuthType.Password) { + return errorResponse('Password is required for password auth', 400); + } + + if (send.authType !== SendAuthType.Email) { + send.emails = null; + } + + await storage.saveSend(send); + const revisionDate = await storage.updateRevisionDate(userId); + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + + return jsonResponse({ + fileUploadType: 0, + object: 'send-fileUpload', + url: `/api/sends/${send.id}/file/${fileId}`, + sendResponse: sendToResponse(send), + }); +} + +export async function handleGetSendFileUpload( + request: Request, + env: Env, + userId: string, + sendId: string, + fileId: string +): Promise { + void request; + const storage = new StorageService(env.DB); + const send = await storage.getSend(sendId); + if (!send || send.userId !== userId) { + return errorResponse('Send not found', 404); + } + if (send.type !== SendType.File) { + return errorResponse('Send is not a file type send.', 400); + } + + const sendData = parseStoredSendData(send); + const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null; + if (!expectedFileId || expectedFileId !== fileId) { + return errorResponse('Send file does not match send data.', 400); + } + + return jsonResponse({ + fileUploadType: 0, + object: 'send-fileUpload', + url: `/api/sends/${send.id}/file/${fileId}`, + sendResponse: sendToResponse(send), + }); +} + +export async function handleUploadSendFile( + request: Request, + env: Env, + userId: string, + sendId: string, + fileId: string +): Promise { + const storage = new StorageService(env.DB); + const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes); + const send = await storage.getSend(sendId); + if (!send || send.userId !== userId) { + return errorResponse('Send not found. Unable to save the file.', 404); + } + if (send.type !== SendType.File) { + return errorResponse('Send is not a file type send.', 400); + } + + const sendData = parseStoredSendData(send); + const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null; + if (!expectedFileId || expectedFileId !== fileId) { + return errorResponse('Send file does not match send data.', 400); + } + + const contentType = request.headers.get('content-type') || ''; + if (!contentType.includes('multipart/form-data')) { + return errorResponse('Content-Type must be multipart/form-data', 400); + } + + const formData = await request.formData(); + const file = formData.get('data') as File | null; + if (!file) { + return errorResponse('No file uploaded', 400); + } + + if (file.size > maxFileSize) { + return errorResponse('Send storage limit exceeded with this file', 413); + } + + const expectedFileName = typeof sendData.fileName === 'string' ? sendData.fileName : null; + if (expectedFileName && file.name !== expectedFileName) { + return errorResponse('Send file name does not match.', 400); + } + + const expectedSize = parseInteger(sendData.size); + if (expectedSize !== null && file.size !== expectedSize) { + return errorResponse('Send file size does not match.', 400); + } + + try { + await putBlobObject(env, getSendFileObjectKey(sendId, fileId), file.stream(), { + size: file.size, + contentType: 'application/octet-stream', + customMetadata: { + sendId, + fileId, + }, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('KV object too large')) { + return errorResponse('Send storage limit exceeded with this file', 413); + } + return errorResponse('Attachment storage is not configured', 500); + } + + const revisionDate = await storage.updateRevisionDate(userId); + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + + return new Response(null, { status: 200 }); +} + +export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise { + const storage = new StorageService(env.DB); + const send = await storage.getSend(sendId); + if (!send || send.userId !== userId) { + return errorResponse('Send not found', 404); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const typeRaw = getAliasedProp(body, ['type', 'Type']); + if (typeRaw.present) { + const incomingType = parseSendType(typeRaw.value); + if (incomingType === null) { + return errorResponse('Invalid Send type', 400); + } + if (incomingType !== send.type) { + return errorResponse("Sends can't change type", 400); + } + } + + const deletionRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']); + if (deletionRaw.present) { + const deletionDate = parseDate(deletionRaw.value); + if (!deletionDate) return errorResponse('Invalid deletionDate', 400); + const deletionValidation = validateDeletionDate(deletionDate); + if (deletionValidation) return deletionValidation; + send.deletionDate = deletionDate.toISOString(); + } + + const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']); + if (expirationRaw.present) { + if (expirationRaw.value === null || expirationRaw.value === '') { + send.expirationDate = null; + } else { + const expiration = parseDate(expirationRaw.value); + if (!expiration) return errorResponse('Invalid expirationDate', 400); + send.expirationDate = expiration.toISOString(); + } + } + + const nameRaw = getAliasedProp(body, ['name', 'Name']); + if (nameRaw.present) { + if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) { + return errorResponse('Name is required', 400); + } + send.name = nameRaw.value.trim(); + } + + const keyRaw = getAliasedProp(body, ['key', 'Key']); + if (keyRaw.present) { + if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) { + return errorResponse('Key is required', 400); + } + send.key = keyRaw.value; + } + + const notesRaw = getAliasedProp(body, ['notes', 'Notes']); + if (notesRaw.present) { + send.notes = typeof notesRaw.value === 'string' ? notesRaw.value : null; + } + + const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']); + if (disabledRaw.present) { + if (typeof disabledRaw.value !== 'boolean') { + return errorResponse('Invalid disabled', 400); + } + send.disabled = disabledRaw.value; + } + + const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']); + if (hideEmailRaw.present) { + if (hideEmailRaw.value === null) { + send.hideEmail = null; + } else if (typeof hideEmailRaw.value === 'boolean') { + send.hideEmail = hideEmailRaw.value; + } else { + return errorResponse('Invalid hideEmail', 400); + } + } + + const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']); + if (maxAccessRaw.present) { + const parsedMax = parseMaxAccessCount(maxAccessRaw.value); + if (!parsedMax.ok) return parsedMax.response; + send.maxAccessCount = parsedMax.value; + } + + if (send.type === SendType.Text) { + const textRaw = getAliasedProp(body, ['text', 'Text']); + if (textRaw.present) { + const textData = sanitizeSendData(textRaw.value); + if (!textData) { + return errorResponse('Send data not provided', 400); + } + send.data = JSON.stringify(textData); + } + } + + const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']); + if (authTypeRaw.present) { + const parsedAuthType = parseSendAuthType(authTypeRaw.value); + if (parsedAuthType === null) { + return errorResponse('Invalid authType', 400); + } + send.authType = parsedAuthType; + if (parsedAuthType !== SendAuthType.Email) { + send.emails = null; + } + } + + const emailsRaw = getAliasedProp(body, ['emails', 'Emails']); + if (emailsRaw.present) { + const normalizedEmails = normalizeEmails(emailsRaw.value); + if (emailsRaw.value !== null && normalizedEmails === null) { + return errorResponse('Invalid emails', 400); + } + send.emails = normalizedEmails; + if (send.emails) { + send.authType = SendAuthType.Email; + } else if (send.authType === SendAuthType.Email) { + send.authType = SendAuthType.None; + } + } + + const passwordRaw = getAliasedProp(body, ['password', 'Password']); + if (passwordRaw.present && typeof passwordRaw.value === 'string') { + await setSendPassword(send, passwordRaw.value); + } + + if (send.authType === SendAuthType.Password && !send.passwordHash) { + return errorResponse('Password is required for password auth', 400); + } + + send.updatedAt = new Date().toISOString(); + await storage.saveSend(send); + const revisionDate = await storage.updateRevisionDate(userId); + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + + return jsonResponse(sendToResponse(send)); +} + +export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise { + void request; + const storage = new StorageService(env.DB); + const send = await storage.getSend(sendId); + if (!send || send.userId !== userId) { + return errorResponse('Send not found', 404); + } + + if (send.type === SendType.File) { + const data = parseStoredSendData(send); + const fileId = typeof data.id === 'string' ? data.id : null; + if (fileId) { + await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId)); + } + } + + await storage.deleteSend(sendId, userId); + const revisionDate = await storage.updateRevisionDate(userId); + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + + return new Response(null, { status: 200 }); +} + +export async function handleBulkDeleteSends(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + + let body: { ids?: string[] }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + if (!body.ids || !Array.isArray(body.ids)) { + return errorResponse('ids array is required', 400); + } + + const sends = await storage.getSendsByIds(body.ids, userId); + for (const send of sends) { + if (send.type !== SendType.File) continue; + const data = parseStoredSendData(send); + const fileId = typeof data.id === 'string' ? data.id : null; + if (fileId) { + await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId)); + } + } + + const revisionDate = await storage.bulkDeleteSends(body.ids, userId); + if (revisionDate) { + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + } + + return new Response(null, { status: 200 }); +} + +export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise { + void request; + const storage = new StorageService(env.DB); + const send = await storage.getSend(sendId); + if (!send || send.userId !== userId) { + return errorResponse('Send not found', 404); + } + + await setSendPassword(send, null); + send.updatedAt = new Date().toISOString(); + await storage.saveSend(send); + const revisionDate = await storage.updateRevisionDate(userId); + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + + return jsonResponse(sendToResponse(send)); +} + +export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise { + void request; + const storage = new StorageService(env.DB); + const send = await storage.getSend(sendId); + if (!send || send.userId !== userId) { + return errorResponse('Send not found', 404); + } + + send.authType = SendAuthType.None; + send.emails = null; + send.updatedAt = new Date().toISOString(); + await storage.saveSend(send); + const revisionDate = await storage.updateRevisionDate(userId); + await notifyVaultSyncForRequest(request, env, userId, revisionDate); + + return jsonResponse(sendToResponse(send)); +} diff --git a/src/handlers/sends-public.ts b/src/handlers/sends-public.ts new file mode 100644 index 0000000..7537d53 --- /dev/null +++ b/src/handlers/sends-public.ts @@ -0,0 +1,400 @@ +import { Env, SendType } from '../types'; +import { StorageService } from '../services/storage'; +import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; +import { jsonResponse, errorResponse } from '../utils/response'; +import { LIMITS } from '../config/limits'; +import { + createSendAccessToken, + createSendFileDownloadToken, + verifySendAccessToken, + verifySendFileDownloadToken, +} from '../utils/jwt'; +import { + getBlobObject, + getSendFileObjectKey, +} from '../services/blob-store'; +import { + SEND_INACCESSIBLE_MSG, + extractBearerToken, + fromAccessId, + getCreatorIdentifier, + getSafeJwtSecret, + hasEmailAuth, + isSendAvailable, + notifyVaultSyncForRequest, + parseStoredSendData, + resolveSendFromIdOrAccessId, + sendPasswordLimitKey, + sendPasswordLockedErrorResponse, + sendPasswordLockedOAuthResponse, + sendToAccessResponse, + validatePublicSendAccess, + verifySendPassword, + verifySendPasswordHashB64, +} from './sends-shared'; + +export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise { + const storage = new StorageService(env.DB); + const sendId = fromAccessId(accessId); + if (!sendId) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + + const send = await storage.getSend(sendId); + if (!send || !isSendAvailable(send)) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + + let body: unknown = {}; + try { + body = await request.json(); + } catch { + body = {}; + } + + let sendPasswordLimitIpKey: string | null = null; + let sendPasswordRateLimit: RateLimitService | null = null; + if (send.passwordHash) { + const clientIdentifier = getClientIdentifier(request); + if (!clientIdentifier) { + return errorResponse('Client IP is required', 403); + } + sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier); + sendPasswordRateLimit = new RateLimitService(env.DB); + const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey); + if (!sendPasswordCheck.allowed) { + return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60); + } + } + + const validation = await validatePublicSendAccess(send, body); + if (!validation.ok) { + if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) { + const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey); + if (failed.locked) { + return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60); + } + } + return validation.response; + } + + if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) { + await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey); + } + + if (send.type === SendType.Text) { + const updated = await storage.incrementSendAccessCount(send.id); + if (!updated) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + send.accessCount += 1; + const revisionDate = await storage.updateRevisionDate(send.userId); + await notifyVaultSyncForRequest(request, env, send.userId, revisionDate); + } + + const creatorIdentifier = await getCreatorIdentifier(storage, send); + return jsonResponse(sendToAccessResponse(send, creatorIdentifier)); +} + +export async function handleAccessSendFile( + request: Request, + env: Env, + idOrAccessId: string, + fileId: string +): Promise { + const secret = (env.JWT_SECRET || '').trim(); + if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength) { + return errorResponse('Server configuration error', 500); + } + + const storage = new StorageService(env.DB); + const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId); + if (!send || !isSendAvailable(send) || send.type !== SendType.File) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + + const data = parseStoredSendData(send); + const expectedFileId = typeof data.id === 'string' ? data.id : null; + if (!expectedFileId || expectedFileId !== fileId) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + + let body: unknown = {}; + try { + body = await request.json(); + } catch { + body = {}; + } + + let sendPasswordLimitIpKey: string | null = null; + let sendPasswordRateLimit: RateLimitService | null = null; + if (send.passwordHash) { + const clientIdentifier = getClientIdentifier(request); + if (!clientIdentifier) { + return errorResponse('Client IP is required', 403); + } + sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier); + sendPasswordRateLimit = new RateLimitService(env.DB); + const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey); + if (!sendPasswordCheck.allowed) { + return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60); + } + } + + const validation = await validatePublicSendAccess(send, body); + if (!validation.ok) { + if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) { + const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey); + if (failed.locked) { + return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60); + } + } + return validation.response; + } + + if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) { + await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey); + } + + const updated = await storage.incrementSendAccessCount(send.id); + if (!updated) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + send.accessCount += 1; + const revisionDate = await storage.updateRevisionDate(send.userId); + await notifyVaultSyncForRequest(request, env, send.userId, revisionDate); + + const token = await createSendFileDownloadToken(send.id, fileId, secret); + const url = new URL(request.url); + const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${token}`; + + return jsonResponse({ + object: 'send-fileDownload', + id: fileId, + url: downloadUrl, + }); +} + +export async function handleAccessSendV2(request: Request, env: Env): Promise { + const jwt = getSafeJwtSecret(env); + if (!jwt.ok) return jwt.response; + + const token = extractBearerToken(request); + if (!token) { + return errorResponse('Unauthorized', 401); + } + + const claims = await verifySendAccessToken(token, jwt.secret); + if (!claims) { + return errorResponse('Unauthorized', 401); + } + + const storage = new StorageService(env.DB); + const send = await storage.getSend(claims.sub); + if (!send || !isSendAvailable(send)) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + + if (send.type === SendType.Text) { + const updated = await storage.incrementSendAccessCount(send.id); + if (!updated) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + send.accessCount += 1; + const revisionDate = await storage.updateRevisionDate(send.userId); + await notifyVaultSyncForRequest(request, env, send.userId, revisionDate); + } + + const creatorIdentifier = await getCreatorIdentifier(storage, send); + return jsonResponse(sendToAccessResponse(send, creatorIdentifier)); +} + +export async function handleAccessSendFileV2(request: Request, env: Env, fileId: string): Promise { + const jwt = getSafeJwtSecret(env); + if (!jwt.ok) return jwt.response; + + const token = extractBearerToken(request); + if (!token) { + return errorResponse('Unauthorized', 401); + } + + const claims = await verifySendAccessToken(token, jwt.secret); + if (!claims) { + return errorResponse('Unauthorized', 401); + } + + const storage = new StorageService(env.DB); + const send = await storage.getSend(claims.sub); + if (!send || !isSendAvailable(send) || send.type !== SendType.File) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + + const data = parseStoredSendData(send); + const expectedFileId = typeof data.id === 'string' ? data.id : null; + if (!expectedFileId || expectedFileId !== fileId) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + + const updated = await storage.incrementSendAccessCount(send.id); + if (!updated) { + return errorResponse(SEND_INACCESSIBLE_MSG, 404); + } + send.accessCount += 1; + const revisionDate = await storage.updateRevisionDate(send.userId); + await notifyVaultSyncForRequest(request, env, send.userId, revisionDate); + + const downloadToken = await createSendFileDownloadToken(send.id, fileId, jwt.secret); + const url = new URL(request.url); + const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${downloadToken}`; + + return jsonResponse({ + object: 'send-fileDownload', + id: fileId, + url: downloadUrl, + }); +} + +export async function handleDownloadSendFile( + request: Request, + env: Env, + sendId: string, + fileId: string +): Promise { + const jwt = getSafeJwtSecret(env); + if (!jwt.ok) return jwt.response; + + const url = new URL(request.url); + const token = url.searchParams.get('t') || url.searchParams.get('token'); + if (!token) { + return errorResponse('Token required', 401); + } + + const claims = await verifySendFileDownloadToken(token, jwt.secret); + if (!claims) { + return errorResponse('Invalid or expired token', 401); + } + if (claims.sendId !== sendId || claims.fileId !== fileId) { + return errorResponse('Token mismatch', 401); + } + + const storage = new StorageService(env.DB); + const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId)); + if (!object) { + return errorResponse('Send file not found', 404); + } + + const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp); + if (!firstUse) { + return errorResponse('Invalid or expired token', 401); + } + + return new Response(object.body, { + headers: { + 'Content-Type': object.contentType || 'application/octet-stream', + 'Content-Length': String(object.size), + 'Cache-Control': 'private, no-cache', + }, + }); +} + +export async function issueSendAccessToken( + env: Env, + sendIdOrAccessId: string, + passwordHashB64?: string | null, + password?: string | null, + rateLimit?: RateLimitService, + sendPasswordLimitIpKey?: string +): Promise<{ token: string } | { error: Response }> { + const jwt = getSafeJwtSecret(env); + if (!jwt.ok) { + return { error: jwt.response }; + } + + const storage = new StorageService(env.DB); + const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId); + + if (!send || !isSendAvailable(send)) { + return { + error: jsonResponse( + { + error: 'invalid_grant', + error_description: SEND_INACCESSIBLE_MSG, + send_access_error_type: 'send_not_available', + ErrorModel: { + Message: SEND_INACCESSIBLE_MSG, + Object: 'error', + }, + }, + 400 + ), + }; + } + + if (hasEmailAuth(send)) { + const message = 'Email verification for this Send is not supported by this server.'; + return { + error: jsonResponse( + { + error: 'invalid_grant', + error_description: message, + send_access_error_type: 'email_verification_not_supported', + ErrorModel: { + Message: message, + Object: 'error', + }, + }, + 400 + ), + }; + } + + if (send.passwordHash) { + if (rateLimit && sendPasswordLimitIpKey) { + const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey); + if (!sendPasswordCheck.allowed) { + return { + error: sendPasswordLockedOAuthResponse(sendPasswordCheck.retryAfterSeconds || 60), + }; + } + } + + let ok = false; + if (passwordHashB64) { + ok = verifySendPasswordHashB64(send, passwordHashB64); + } else if (password) { + ok = await verifySendPassword(send, password); + } + + if (!ok) { + if (rateLimit && sendPasswordLimitIpKey) { + const failed = await rateLimit.recordFailedLogin(sendPasswordLimitIpKey); + if (failed.locked) { + return { + error: sendPasswordLockedOAuthResponse(failed.retryAfterSeconds || 60), + }; + } + } + return { + error: jsonResponse( + { + error: 'invalid_grant', + error_description: 'Invalid password.', + send_access_error_type: 'invalid_password', + ErrorModel: { + Message: 'Invalid password.', + Object: 'error', + }, + }, + 400 + ), + }; + } + + if (rateLimit && sendPasswordLimitIpKey) { + await rateLimit.clearLoginAttempts(sendPasswordLimitIpKey); + } + } + + const token = await createSendAccessToken(send.id, jwt.secret); + return { token }; +} diff --git a/src/handlers/sends-shared.ts b/src/handlers/sends-shared.ts new file mode 100644 index 0000000..d4bb48e --- /dev/null +++ b/src/handlers/sends-shared.ts @@ -0,0 +1,451 @@ +import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types'; +import { notifyUserVaultSync } from '../durable/notifications-hub'; +import { StorageService } from '../services/storage'; +import { jsonResponse, errorResponse } from '../utils/response'; +import { readActingDeviceIdentifier } from '../utils/device'; +import { LIMITS } from '../config/limits'; + +export const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available'; +const SEND_PASSWORD_ITERATIONS = 100_000; +export const SEND_PASSWORD_LIMIT_SCOPE = 'send-password'; + +export async function notifyVaultSyncForRequest( + request: Request, + env: Env, + userId: string, + revisionDate: string +): Promise { + await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); +} + +export function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } { + if (!source || typeof source !== 'object') return { present: false, value: undefined }; + for (const key of aliases) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + const value = (source as Record)[key]; + return { present: true, value }; + } + } + return { present: false, value: undefined }; +} + +export function base64UrlEncode(data: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...data)); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function base64UrlDecode(input: string): Uint8Array | null { + try { + let normalized = input.replace(/-/g, '+').replace(/_/g, '/'); + while (normalized.length % 4) normalized += '='; + const raw = atob(normalized); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); + return out; + } catch { + return null; + } +} + +function uuidToBytes(uuid: string): Uint8Array | null { + const hex = uuid.replace(/-/g, '').toLowerCase(); + if (!/^[0-9a-f]{32}$/.test(hex)) return null; + const bytes = new Uint8Array(16); + for (let i = 0; i < 16; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function bytesToUuid(bytes: Uint8Array): string | null { + if (bytes.length !== 16) return null; + const hex = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); + return [ + hex.slice(0, 8), + hex.slice(8, 12), + hex.slice(12, 16), + hex.slice(16, 20), + hex.slice(20, 32), + ].join('-'); +} + +function toAccessId(sendId: string): string { + const bytes = uuidToBytes(sendId); + if (!bytes) return ''; + return base64UrlEncode(bytes); +} + +export function fromAccessId(accessId: string): string | null { + const bytes = base64UrlDecode(accessId); + if (!bytes || bytes.length !== 16) return null; + return bytesToUuid(bytes); +} + +function isLikelyUuid(value: string): boolean { + return /^[a-f0-9-]{36}$/i.test(value); +} + +export async function resolveSendFromIdOrAccessId(storage: StorageService, idOrAccessId: string): Promise { + if (isLikelyUuid(idOrAccessId)) { + const send = await storage.getSend(idOrAccessId); + if (send) return send; + } + + const sendId = fromAccessId(idOrAccessId); + if (!sendId) return null; + return storage.getSend(sendId); +} + +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} Bytes`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; +} + +export function parseDate(raw: unknown): Date | null { + if (typeof raw !== 'string' || !raw.trim()) return null; + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return null; + return date; +} + +export function parseInteger(raw: unknown): number | null { + if (raw === null || raw === undefined || raw === '') return null; + const value = typeof raw === 'string' ? Number(raw) : raw; + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) return null; + return value; +} + +export function sanitizeSendData(raw: unknown): Record | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const data = { ...(raw as Record) }; + delete data.response; + return data; +} + +export function parseStoredSendData(send: Send): Record { + try { + const parsed = JSON.parse(send.data) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return { ...(parsed as Record) }; + } + return {}; + } catch { + return {}; + } +} + +function normalizeSendDataSizeField(data: Record): Record { + const normalized = { ...data }; + if (typeof normalized.size === 'number' && Number.isFinite(normalized.size)) { + normalized.size = String(Math.trunc(normalized.size)); + } + return normalized; +} + +export function isSendAvailable(send: Send): boolean { + const now = Date.now(); + + if (send.maxAccessCount !== null && send.accessCount >= send.maxAccessCount) { + return false; + } + + if (send.expirationDate) { + const expirationMs = new Date(send.expirationDate).getTime(); + if (!Number.isNaN(expirationMs) && now >= expirationMs) { + return false; + } + } + + const deletionMs = new Date(send.deletionDate).getTime(); + if (!Number.isNaN(deletionMs) && now >= deletionMs) { + return false; + } + + if (send.disabled) { + return false; + } + + return true; +} + +async function deriveSendPasswordHash(password: string, salt: Uint8Array, iterations: number): Promise { + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']); + const bits = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt, + iterations, + hash: 'SHA-256', + }, + key, + 256 + ); + return new Uint8Array(bits); +} + +function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff === 0; +} + +function isLikelyHashB64(value: string): boolean { + const raw = String(value || '').trim(); + if (!raw) return false; + if (!/^[A-Za-z0-9+/_=-]+$/.test(raw)) return false; + const decoded = base64UrlDecode(raw); + return !!decoded && decoded.length === 32; +} + +export async function setSendPassword(send: Send, password: string | null): Promise { + if (!password) { + send.passwordHash = null; + send.passwordSalt = null; + send.passwordIterations = null; + if (send.authType === SendAuthType.Password) { + send.authType = SendAuthType.None; + } + return; + } + + if (isLikelyHashB64(password)) { + send.passwordHash = password.trim(); + send.passwordSalt = null; + send.passwordIterations = null; + send.authType = SendAuthType.Password; + return; + } + + const salt = crypto.getRandomValues(new Uint8Array(64)); + const hash = await deriveSendPasswordHash(password, salt, SEND_PASSWORD_ITERATIONS); + + send.passwordSalt = base64UrlEncode(salt); + send.passwordHash = base64UrlEncode(hash); + send.passwordIterations = SEND_PASSWORD_ITERATIONS; + send.authType = SendAuthType.Password; +} + +export async function verifySendPassword(send: Send, password: string): Promise { + if (!send.passwordHash) { + return false; + } + + if (!send.passwordSalt || !send.passwordIterations) { + return verifySendPasswordHashB64(send, password); + } + + const salt = base64UrlDecode(send.passwordSalt); + const expected = base64UrlDecode(send.passwordHash); + if (!salt || !expected) return false; + + const actual = await deriveSendPasswordHash(password, salt, send.passwordIterations); + return constantTimeEqual(actual, expected); +} + +export function verifySendPasswordHashB64(send: Send, passwordHashB64: string): boolean { + if (!send.passwordHash || !passwordHashB64) return false; + const expected = base64UrlDecode(send.passwordHash); + const provided = base64UrlDecode(passwordHashB64); + if (!expected || !provided) return false; + return constantTimeEqual(expected, provided); +} + +export function validateDeletionDate(date: Date): Response | null { + const maxMs = Date.now() + LIMITS.send.maxDeletionDays * 24 * 60 * 60 * 1000; + if (date.getTime() > maxMs) { + return errorResponse( + 'You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again.', + 400 + ); + } + return null; +} + +export function parseMaxAccessCount(value: unknown): { ok: true; value: number | null } | { ok: false; response: Response } { + const parsed = parseInteger(value); + if (value === undefined || value === null || value === '') { + return { ok: true, value: null }; + } + if (parsed === null || parsed < 0) { + return { ok: false, response: errorResponse('Invalid maxAccessCount', 400) }; + } + return { ok: true, value: parsed }; +} + +export function parseFileLength(value: unknown): { ok: true; value: number } | { ok: false; response: Response } { + const parsed = parseInteger(value); + if (parsed === null) { + return { ok: false, response: errorResponse('Invalid send length', 400) }; + } + if (parsed < 0) { + return { ok: false, response: errorResponse("Send size can't be negative", 400) }; + } + return { ok: true, value: parsed }; +} + +export function parseSendType(value: unknown): SendType | null { + const type = parseInteger(value); + if (type === SendType.Text || type === SendType.File) return type; + return null; +} + +export function parseSendAuthType(value: unknown): SendAuthType | null { + if (value === undefined || value === null || value === '') return null; + const parsed = parseInteger(value); + if (parsed === SendAuthType.Email || parsed === SendAuthType.Password || parsed === SendAuthType.None) { + return parsed; + } + return null; +} + +export function normalizeEmails(value: unknown): string | null { + if (value === null || value === undefined || value === '') return null; + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + const strings = value.filter((v) => typeof v === 'string').map((v) => String(v)); + if (strings.length === 0) return null; + return strings.join(','); + } + return null; +} + +export function hasEmailAuth(send: Send): boolean { + return send.authType === SendAuthType.Email; +} + +export function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } { + const secret = (env.JWT_SECRET || '').trim(); + if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { + return { ok: false, response: errorResponse('Server configuration error', 500) }; + } + return { ok: true, secret }; +} + +export function extractBearerToken(request: Request): string | null { + const authHeader = request.headers.get('Authorization'); + if (!authHeader) return null; + const match = authHeader.match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : null; +} + +export function sendToResponse(send: Send): SendResponse { + const data = normalizeSendDataSizeField(parseStoredSendData(send)); + return { + id: send.id, + accessId: toAccessId(send.id), + type: Number(send.type) || 0, + name: send.name, + notes: send.notes, + text: send.type === SendType.Text ? data : null, + file: send.type === SendType.File ? data : null, + key: send.key, + maxAccessCount: send.maxAccessCount, + accessCount: send.accessCount, + password: send.passwordHash, + emails: send.emails, + authType: send.authType, + disabled: send.disabled, + hideEmail: send.hideEmail, + revisionDate: send.updatedAt, + expirationDate: send.expirationDate, + deletionDate: send.deletionDate, + object: 'send', + }; +} + +export function sendToAccessResponse(send: Send, creatorIdentifier: string | null): Record { + const data = normalizeSendDataSizeField(parseStoredSendData(send)); + return { + id: send.id, + type: Number(send.type) || 0, + name: send.name, + text: send.type === SendType.Text ? data : null, + file: send.type === SendType.File ? data : null, + expirationDate: send.expirationDate, + deletionDate: send.deletionDate, + creatorIdentifier, + object: 'send-access', + }; +} + +export async function getCreatorIdentifier(storage: StorageService, send: Send): Promise { + if (send.hideEmail) return null; + const owner = await storage.getUserById(send.userId); + return owner?.email ?? null; +} + +export type PublicSendAccessValidationResult = + | { ok: true } + | { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' }; + +export function sendPasswordLimitKey(clientIdentifier: string): string { + return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`; +} + +function sendPasswordLockMessage(retryAfterSeconds: number): string { + return `Too many failed send password attempts. Try again in ${Math.ceil(retryAfterSeconds / 60)} minutes.`; +} + +export function sendPasswordLockedErrorResponse(retryAfterSeconds: number): Response { + return errorResponse(sendPasswordLockMessage(retryAfterSeconds), 429); +} + +export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Response { + const message = sendPasswordLockMessage(retryAfterSeconds); + return jsonResponse( + { + error: 'invalid_grant', + error_description: message, + send_access_error_type: 'too_many_password_attempts', + ErrorModel: { + Message: message, + Object: 'error', + }, + }, + 429 + ); +} + +export async function validatePublicSendAccess(send: Send, body: unknown): Promise { + if (hasEmailAuth(send)) { + return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' }; + } + + if (!send.passwordHash) return { ok: true }; + + const passwordRaw = getAliasedProp(body, ['password', 'Password']); + const passwordHashB64Raw = getAliasedProp(body, [ + 'password_hash_b64', + 'passwordHashB64', + 'passwordHash', + 'password_hash', + ]); + + let validPassword = false; + if (send.passwordSalt && send.passwordIterations) { + if (typeof passwordRaw.value !== 'string') { + return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' }; + } + validPassword = await verifySendPassword(send, passwordRaw.value); + } else { + const candidate = + typeof passwordHashB64Raw.value === 'string' + ? passwordHashB64Raw.value + : typeof passwordRaw.value === 'string' + ? passwordRaw.value + : ''; + if (!candidate) return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' }; + validPassword = verifySendPasswordHashB64(send, candidate); + } + if (!validPassword) { + return { ok: false, response: errorResponse('Invalid password', 400), reason: 'invalid_password' }; + } + + return { ok: true }; +} diff --git a/src/handlers/sends.ts b/src/handlers/sends.ts index 52517ed..fe3330a 100644 --- a/src/handlers/sends.ts +++ b/src/handlers/sends.ts @@ -1,1473 +1,3 @@ -import { Env, Send, SendAuthType, SendResponse, SendType, DEFAULT_DEV_SECRET } from '../types'; -import { notifyUserVaultSync } from '../durable/notifications-hub'; -import { StorageService } from '../services/storage'; -import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; -import { jsonResponse, errorResponse } from '../utils/response'; -import { readActingDeviceIdentifier } from '../utils/device'; -import { generateUUID } from '../utils/uuid'; -import { parsePagination, encodeContinuationToken } from '../utils/pagination'; -import { LIMITS } from '../config/limits'; -import { - createSendAccessToken, - createSendFileDownloadToken, - verifySendAccessToken, - verifySendFileDownloadToken, -} from '../utils/jwt'; -import { - deleteBlobObject, - getBlobObject, - getBlobStorageMaxBytes, - getSendFileObjectKey, - putBlobObject, -} from '../services/blob-store'; - -const SEND_INACCESSIBLE_MSG = 'Send does not exist or is no longer available'; -const SEND_PASSWORD_ITERATIONS = 100_000; -const SEND_PASSWORD_LIMIT_SCOPE = 'send-password'; - -async function notifyVaultSyncForRequest( - request: Request, - env: Env, - userId: string, - revisionDate: string -): Promise { - await notifyUserVaultSync(env, userId, revisionDate, readActingDeviceIdentifier(request)); -} - -function getAliasedProp(source: unknown, aliases: string[]): { present: boolean; value: unknown } { - if (!source || typeof source !== 'object') return { present: false, value: undefined }; - for (const key of aliases) { - if (Object.prototype.hasOwnProperty.call(source, key)) { - const value = (source as Record)[key]; - return { present: true, value }; - } - } - return { present: false, value: undefined }; -} - -function base64UrlEncode(data: Uint8Array): string { - const base64 = btoa(String.fromCharCode(...data)); - return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); -} - -function base64UrlDecode(input: string): Uint8Array | null { - try { - let normalized = input.replace(/-/g, '+').replace(/_/g, '/'); - while (normalized.length % 4) normalized += '='; - const raw = atob(normalized); - const out = new Uint8Array(raw.length); - for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); - return out; - } catch { - return null; - } -} - -function uuidToBytes(uuid: string): Uint8Array | null { - const hex = uuid.replace(/-/g, '').toLowerCase(); - if (!/^[0-9a-f]{32}$/.test(hex)) return null; - const bytes = new Uint8Array(16); - for (let i = 0; i < 16; i++) { - bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); - } - return bytes; -} - -function bytesToUuid(bytes: Uint8Array): string | null { - if (bytes.length !== 16) return null; - const hex = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); - return [ - hex.slice(0, 8), - hex.slice(8, 12), - hex.slice(12, 16), - hex.slice(16, 20), - hex.slice(20, 32), - ].join('-'); -} - -function toAccessId(sendId: string): string { - const bytes = uuidToBytes(sendId); - if (!bytes) return ''; - return base64UrlEncode(bytes); -} - -function fromAccessId(accessId: string): string | null { - const bytes = base64UrlDecode(accessId); - if (!bytes || bytes.length !== 16) return null; - return bytesToUuid(bytes); -} - -function isLikelyUuid(value: string): boolean { - return /^[a-f0-9-]{36}$/i.test(value); -} - -async function resolveSendFromIdOrAccessId(storage: StorageService, idOrAccessId: string): Promise { - if (isLikelyUuid(idOrAccessId)) { - const send = await storage.getSend(idOrAccessId); - if (send) return send; - } - - const sendId = fromAccessId(idOrAccessId); - if (!sendId) return null; - return storage.getSend(sendId); -} - -function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} Bytes`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; -} - -function parseDate(raw: unknown): Date | null { - if (typeof raw !== 'string' || !raw.trim()) return null; - const date = new Date(raw); - if (Number.isNaN(date.getTime())) return null; - return date; -} - -function parseInteger(raw: unknown): number | null { - if (raw === null || raw === undefined || raw === '') return null; - const value = typeof raw === 'string' ? Number(raw) : raw; - if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isInteger(value)) return null; - return value; -} - -function sanitizeSendData(raw: unknown): Record | null { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; - const data = { ...(raw as Record) }; - delete data.response; - return data; -} - -function parseStoredSendData(send: Send): Record { - try { - const parsed = JSON.parse(send.data) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return { ...(parsed as Record) }; - } - return {}; - } catch { - return {}; - } -} - -function normalizeSendDataSizeField(data: Record): Record { - const normalized = { ...data }; - if (typeof normalized.size === 'number' && Number.isFinite(normalized.size)) { - normalized.size = String(Math.trunc(normalized.size)); - } - return normalized; -} - -export function isSendAvailable(send: Send): boolean { - const now = Date.now(); - - if (send.maxAccessCount !== null && send.accessCount >= send.maxAccessCount) { - return false; - } - - if (send.expirationDate) { - const expirationMs = new Date(send.expirationDate).getTime(); - if (!Number.isNaN(expirationMs) && now >= expirationMs) { - return false; - } - } - - const deletionMs = new Date(send.deletionDate).getTime(); - if (!Number.isNaN(deletionMs) && now >= deletionMs) { - return false; - } - - if (send.disabled) { - return false; - } - - return true; -} - -async function deriveSendPasswordHash(password: string, salt: Uint8Array, iterations: number): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveBits']); - const bits = await crypto.subtle.deriveBits( - { - name: 'PBKDF2', - salt, - iterations, - hash: 'SHA-256', - }, - key, - 256 - ); - return new Uint8Array(bits); -} - -function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) { - diff |= a[i] ^ b[i]; - } - return diff === 0; -} - -function isLikelyHashB64(value: string): boolean { - const raw = String(value || '').trim(); - if (!raw) return false; - if (!/^[A-Za-z0-9+/_=-]+$/.test(raw)) return false; - const decoded = base64UrlDecode(raw); - return !!decoded && decoded.length === 32; -} - -async function setSendPassword(send: Send, password: string | null): Promise { - if (!password) { - send.passwordHash = null; - send.passwordSalt = null; - send.passwordIterations = null; - if (send.authType === SendAuthType.Password) { - send.authType = SendAuthType.None; - } - return; - } - - // Official client behavior: request.password already contains PBKDF2 hash (base64). - // Keep it as-is to remain interoperable. - if (isLikelyHashB64(password)) { - send.passwordHash = password.trim(); - send.passwordSalt = null; - send.passwordIterations = null; - send.authType = SendAuthType.Password; - return; - } - - const salt = crypto.getRandomValues(new Uint8Array(64)); - const hash = await deriveSendPasswordHash(password, salt, SEND_PASSWORD_ITERATIONS); - - send.passwordSalt = base64UrlEncode(salt); - send.passwordHash = base64UrlEncode(hash); - send.passwordIterations = SEND_PASSWORD_ITERATIONS; - send.authType = SendAuthType.Password; -} - -export async function verifySendPassword(send: Send, password: string): Promise { - if (!send.passwordHash) { - return false; - } - - // Official client behavior: password is already a hash in base64. - if (!send.passwordSalt || !send.passwordIterations) { - return verifySendPasswordHashB64(send, password); - } - - const salt = base64UrlDecode(send.passwordSalt); - const expected = base64UrlDecode(send.passwordHash); - if (!salt || !expected) return false; - - const actual = await deriveSendPasswordHash(password, salt, send.passwordIterations); - return constantTimeEqual(actual, expected); -} - -export function verifySendPasswordHashB64(send: Send, passwordHashB64: string): boolean { - if (!send.passwordHash || !passwordHashB64) return false; - const expected = base64UrlDecode(send.passwordHash); - const provided = base64UrlDecode(passwordHashB64); - if (!expected || !provided) return false; - return constantTimeEqual(expected, provided); -} - -function validateDeletionDate(date: Date): Response | null { - const maxMs = Date.now() + LIMITS.send.maxDeletionDays * 24 * 60 * 60 * 1000; - if (date.getTime() > maxMs) { - return errorResponse( - 'You cannot have a Send with a deletion date that far into the future. Adjust the Deletion Date to a value less than 31 days from now and try again.', - 400 - ); - } - return null; -} - -function parseMaxAccessCount(value: unknown): { ok: true; value: number | null } | { ok: false; response: Response } { - const parsed = parseInteger(value); - if (value === undefined || value === null || value === '') { - return { ok: true, value: null }; - } - if (parsed === null || parsed < 0) { - return { ok: false, response: errorResponse('Invalid maxAccessCount', 400) }; - } - return { ok: true, value: parsed }; -} - -function parseFileLength(value: unknown): { ok: true; value: number } | { ok: false; response: Response } { - const parsed = parseInteger(value); - if (parsed === null) { - return { ok: false, response: errorResponse('Invalid send length', 400) }; - } - if (parsed < 0) { - return { ok: false, response: errorResponse("Send size can't be negative", 400) }; - } - return { ok: true, value: parsed }; -} - -function parseSendType(value: unknown): SendType | null { - const type = parseInteger(value); - if (type === SendType.Text || type === SendType.File) return type; - return null; -} - -function parseSendAuthType(value: unknown): SendAuthType | null { - if (value === undefined || value === null || value === '') return null; - const parsed = parseInteger(value); - if (parsed === SendAuthType.Email || parsed === SendAuthType.Password || parsed === SendAuthType.None) { - return parsed; - } - return null; -} - -function normalizeEmails(value: unknown): string | null { - if (value === null || value === undefined || value === '') return null; - if (typeof value === 'string') return value; - if (Array.isArray(value)) { - const strings = value.filter(v => typeof v === 'string').map(v => String(v)); - if (strings.length === 0) return null; - return strings.join(','); - } - return null; -} - -function hasEmailAuth(send: Send): boolean { - return send.authType === SendAuthType.Email; -} - -function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } { - const secret = (env.JWT_SECRET || '').trim(); - if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { - return { ok: false, response: errorResponse('Server configuration error', 500) }; - } - return { ok: true, secret }; -} - -function extractBearerToken(request: Request): string | null { - const authHeader = request.headers.get('Authorization'); - if (!authHeader) return null; - const match = authHeader.match(/^Bearer\s+(.+)$/i); - return match ? match[1].trim() : null; -} - -export function sendToResponse(send: Send): SendResponse { - const data = normalizeSendDataSizeField(parseStoredSendData(send)); - return { - id: send.id, - accessId: toAccessId(send.id), - type: Number(send.type) || 0, - name: send.name, - notes: send.notes, - text: send.type === SendType.Text ? data : null, - file: send.type === SendType.File ? data : null, - key: send.key, - maxAccessCount: send.maxAccessCount, - accessCount: send.accessCount, - password: send.passwordHash, - emails: send.emails, - authType: send.authType, - disabled: send.disabled, - hideEmail: send.hideEmail, - revisionDate: send.updatedAt, - expirationDate: send.expirationDate, - deletionDate: send.deletionDate, - object: 'send', - }; -} - -function sendToAccessResponse(send: Send, creatorIdentifier: string | null): Record { - const data = normalizeSendDataSizeField(parseStoredSendData(send)); - return { - id: send.id, - type: Number(send.type) || 0, - name: send.name, - text: send.type === SendType.Text ? data : null, - file: send.type === SendType.File ? data : null, - expirationDate: send.expirationDate, - deletionDate: send.deletionDate, - creatorIdentifier, - object: 'send-access', - }; -} - -async function getCreatorIdentifier(storage: StorageService, send: Send): Promise { - if (send.hideEmail) return null; - const owner = await storage.getUserById(send.userId); - return owner?.email ?? null; -} - -type PublicSendAccessValidationResult = - | { ok: true } - | { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' }; - -function sendPasswordLimitKey(clientIdentifier: string): string { - return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`; -} - -function sendPasswordLockMessage(retryAfterSeconds: number): string { - return `Too many failed send password attempts. Try again in ${Math.ceil(retryAfterSeconds / 60)} minutes.`; -} - -function sendPasswordLockedErrorResponse(retryAfterSeconds: number): Response { - return errorResponse(sendPasswordLockMessage(retryAfterSeconds), 429); -} - -function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Response { - const message = sendPasswordLockMessage(retryAfterSeconds); - return jsonResponse( - { - error: 'invalid_grant', - error_description: message, - send_access_error_type: 'too_many_password_attempts', - ErrorModel: { - Message: message, - Object: 'error', - }, - }, - 429 - ); -} - -async function validatePublicSendAccess(send: Send, body: unknown): Promise { - if (hasEmailAuth(send)) { - return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' }; - } - - if (!send.passwordHash) return { ok: true }; - - const passwordRaw = getAliasedProp(body, ['password', 'Password']); - const passwordHashB64Raw = getAliasedProp(body, [ - 'password_hash_b64', - 'passwordHashB64', - 'passwordHash', - 'password_hash', - ]); - - let validPassword = false; - if (send.passwordSalt && send.passwordIterations) { - if (typeof passwordRaw.value !== 'string') { - return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' }; - } - validPassword = await verifySendPassword(send, passwordRaw.value); - } else { - const candidate = - typeof passwordHashB64Raw.value === 'string' - ? passwordHashB64Raw.value - : typeof passwordRaw.value === 'string' - ? passwordRaw.value - : ''; - if (!candidate) return { ok: false, response: errorResponse('Password not provided', 401), reason: 'password_missing' }; - validPassword = verifySendPasswordHashB64(send, candidate); - } - if (!validPassword) { - return { ok: false, response: errorResponse('Invalid password', 400), reason: 'invalid_password' }; - } - - return { ok: true }; -} - -// GET /api/sends -export async function handleGetSends(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.DB); - const url = new URL(request.url); - const pagination = parsePagination(url); - - let sends: Send[]; - let continuationToken: string | null = null; - if (pagination) { - const pageRows = await storage.getSendsPage(userId, pagination.limit + 1, pagination.offset); - const hasNext = pageRows.length > pagination.limit; - sends = hasNext ? pageRows.slice(0, pagination.limit) : pageRows; - continuationToken = hasNext ? encodeContinuationToken(pagination.offset + sends.length) : null; - } else { - sends = await storage.getAllSends(userId); - } - - return jsonResponse({ - data: sends.map(sendToResponse), - object: 'list', - continuationToken, - }); -} - -// GET /api/sends/:id -export async function handleGetSend(request: Request, env: Env, userId: string, sendId: string): Promise { - void request; - const storage = new StorageService(env.DB); - const send = await storage.getSend(sendId); - - if (!send || send.userId !== userId) { - return errorResponse('Send not found', 404); - } - - return jsonResponse(sendToResponse(send)); -} - -// POST /api/sends -export async function handleCreateSend(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.DB); - - let body: unknown; - try { - body = await request.json(); - } catch { - return errorResponse('Invalid JSON', 400); - } - - const typeRaw = getAliasedProp(body, ['type', 'Type']); - const sendType = parseSendType(typeRaw.value); - if (sendType === null) { - return errorResponse('Invalid Send type', 400); - } - if (sendType === SendType.File) { - return errorResponse('File sends should use /api/sends/file/v2', 400); - } - - const nameRaw = getAliasedProp(body, ['name', 'Name']); - const keyRaw = getAliasedProp(body, ['key', 'Key']); - const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']); - const textRaw = getAliasedProp(body, ['text', 'Text']); - - if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) { - return errorResponse('Name is required', 400); - } - if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) { - return errorResponse('Key is required', 400); - } - - const deletionDate = parseDate(deletionDateRaw.value); - if (!deletionDate) { - return errorResponse('Invalid deletionDate', 400); - } - - const deletionValidation = validateDeletionDate(deletionDate); - if (deletionValidation) return deletionValidation; - - const sendData = sanitizeSendData(textRaw.value); - if (!sendData) { - return errorResponse('Send data not provided', 400); - } - - const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']); - const maxAccess = parseMaxAccessCount(maxAccessRaw.value); - if (!maxAccess.ok) return maxAccess.response; - - const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']); - const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined - ? null - : parseDate(expirationRaw.value); - if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) { - return errorResponse('Invalid expirationDate', 400); - } - - const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']); - const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']); - const notesRaw = getAliasedProp(body, ['notes', 'Notes']); - const passwordRaw = getAliasedProp(body, ['password', 'Password']); - const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']); - const emailsRaw = getAliasedProp(body, ['emails', 'Emails']); - - const requestedAuthType = parseSendAuthType(authTypeRaw.value); - if (authTypeRaw.present && requestedAuthType === null) { - return errorResponse('Invalid authType', 400); - } - - const normalizedEmails = normalizeEmails(emailsRaw.value); - if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) { - return errorResponse('Invalid emails', 400); - } - - const now = new Date().toISOString(); - const send: Send = { - id: generateUUID(), - userId, - type: sendType, - name: nameRaw.value.trim(), - notes: typeof notesRaw.value === 'string' ? notesRaw.value : null, - data: JSON.stringify(sendData), - key: keyRaw.value, - passwordHash: null, - passwordSalt: null, - passwordIterations: null, - authType: requestedAuthType ?? SendAuthType.None, - emails: normalizedEmails, - maxAccessCount: maxAccess.value, - accessCount: 0, - disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false, - hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null, - createdAt: now, - updatedAt: now, - expirationDate: expirationDate ? expirationDate.toISOString() : null, - deletionDate: deletionDate.toISOString(), - }; - - if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) { - await setSendPassword(send, passwordRaw.value); - } else if (send.authType === SendAuthType.Password) { - return errorResponse('Password is required for password auth', 400); - } - - if (send.authType !== SendAuthType.Email) { - send.emails = null; - } - - await storage.saveSend(send); - let revisionDate = await storage.updateRevisionDate(userId); - await notifyVaultSyncForRequest(request, env, userId, revisionDate); - - return jsonResponse(sendToResponse(send)); -} - -// POST /api/sends/file/v2 -export async function handleCreateFileSendV2(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.DB); - const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes); - - let body: unknown; - try { - body = await request.json(); - } catch { - return errorResponse('Invalid JSON', 400); - } - - const typeRaw = getAliasedProp(body, ['type', 'Type']); - const sendType = parseSendType(typeRaw.value); - if (sendType !== SendType.File) { - return errorResponse('Send content is not a file', 400); - } - - const fileLengthRaw = getAliasedProp(body, ['fileLength', 'FileLength']); - const fileLengthParsed = parseFileLength(fileLengthRaw.value); - if (!fileLengthParsed.ok) return fileLengthParsed.response; - if (fileLengthParsed.value > maxFileSize) { - return errorResponse('Send storage limit exceeded with this file', 400); - } - - const nameRaw = getAliasedProp(body, ['name', 'Name']); - const keyRaw = getAliasedProp(body, ['key', 'Key']); - const deletionDateRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']); - const fileRaw = getAliasedProp(body, ['file', 'File']); - - if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) { - return errorResponse('Name is required', 400); - } - if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) { - return errorResponse('Key is required', 400); - } - - const deletionDate = parseDate(deletionDateRaw.value); - if (!deletionDate) { - return errorResponse('Invalid deletionDate', 400); - } - const deletionValidation = validateDeletionDate(deletionDate); - if (deletionValidation) return deletionValidation; - - const fileData = sanitizeSendData(fileRaw.value); - if (!fileData) { - return errorResponse('Send data not provided', 400); - } - - const fileId = generateUUID(); - fileData.id = fileId; - fileData.size = fileLengthParsed.value; - fileData.sizeName = formatSize(fileLengthParsed.value); - - const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']); - const maxAccess = parseMaxAccessCount(maxAccessRaw.value); - if (!maxAccess.ok) return maxAccess.response; - - const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']); - const expirationDate = expirationRaw.value === null || expirationRaw.value === undefined - ? null - : parseDate(expirationRaw.value); - if (expirationRaw.value !== null && expirationRaw.value !== undefined && !expirationDate) { - return errorResponse('Invalid expirationDate', 400); - } - - const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']); - const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']); - const notesRaw = getAliasedProp(body, ['notes', 'Notes']); - const passwordRaw = getAliasedProp(body, ['password', 'Password']); - const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']); - const emailsRaw = getAliasedProp(body, ['emails', 'Emails']); - - const requestedAuthType = parseSendAuthType(authTypeRaw.value); - if (authTypeRaw.present && requestedAuthType === null) { - return errorResponse('Invalid authType', 400); - } - - const normalizedEmails = normalizeEmails(emailsRaw.value); - if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) { - return errorResponse('Invalid emails', 400); - } - - const now = new Date().toISOString(); - const send: Send = { - id: generateUUID(), - userId, - type: sendType, - name: nameRaw.value.trim(), - notes: typeof notesRaw.value === 'string' ? notesRaw.value : null, - data: JSON.stringify(fileData), - key: keyRaw.value, - passwordHash: null, - passwordSalt: null, - passwordIterations: null, - authType: requestedAuthType ?? SendAuthType.None, - emails: normalizedEmails, - maxAccessCount: maxAccess.value, - accessCount: 0, - disabled: typeof disabledRaw.value === 'boolean' ? disabledRaw.value : false, - hideEmail: typeof hideEmailRaw.value === 'boolean' ? hideEmailRaw.value : null, - createdAt: now, - updatedAt: now, - expirationDate: expirationDate ? expirationDate.toISOString() : null, - deletionDate: deletionDate.toISOString(), - }; - - if (typeof passwordRaw.value === 'string' && passwordRaw.value.length > 0) { - await setSendPassword(send, passwordRaw.value); - } else if (send.authType === SendAuthType.Password) { - return errorResponse('Password is required for password auth', 400); - } - - if (send.authType !== SendAuthType.Email) { - send.emails = null; - } - - await storage.saveSend(send); - let revisionDate = await storage.updateRevisionDate(userId); - await notifyVaultSyncForRequest(request, env, userId, revisionDate); - - return jsonResponse({ - fileUploadType: 0, - object: 'send-fileUpload', - url: `/api/sends/${send.id}/file/${fileId}`, - sendResponse: sendToResponse(send), - }); -} - -// GET /api/sends/:id/file/:fileId -export async function handleGetSendFileUpload( - request: Request, - env: Env, - userId: string, - sendId: string, - fileId: string -): Promise { - void request; - const storage = new StorageService(env.DB); - const send = await storage.getSend(sendId); - if (!send || send.userId !== userId) { - return errorResponse('Send not found', 404); - } - if (send.type !== SendType.File) { - return errorResponse('Send is not a file type send.', 400); - } - - const sendData = parseStoredSendData(send); - const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null; - if (!expectedFileId || expectedFileId !== fileId) { - return errorResponse('Send file does not match send data.', 400); - } - - return jsonResponse({ - fileUploadType: 0, - object: 'send-fileUpload', - url: `/api/sends/${send.id}/file/${fileId}`, - sendResponse: sendToResponse(send), - }); -} - -// POST /api/sends/:id/file/:fileId -export async function handleUploadSendFile( - request: Request, - env: Env, - userId: string, - sendId: string, - fileId: string -): Promise { - const storage = new StorageService(env.DB); - const maxFileSize = getBlobStorageMaxBytes(env, LIMITS.send.maxFileSizeBytes); - const send = await storage.getSend(sendId); - if (!send || send.userId !== userId) { - return errorResponse('Send not found. Unable to save the file.', 404); - } - if (send.type !== SendType.File) { - return errorResponse('Send is not a file type send.', 400); - } - - const sendData = parseStoredSendData(send); - const expectedFileId = typeof sendData.id === 'string' ? sendData.id : null; - if (!expectedFileId || expectedFileId !== fileId) { - return errorResponse('Send file does not match send data.', 400); - } - - const contentType = request.headers.get('content-type') || ''; - if (!contentType.includes('multipart/form-data')) { - return errorResponse('Content-Type must be multipart/form-data', 400); - } - - const formData = await request.formData(); - const file = formData.get('data') as File | null; - if (!file) { - return errorResponse('No file uploaded', 400); - } - - if (file.size > maxFileSize) { - return errorResponse('Send storage limit exceeded with this file', 413); - } - - const expectedFileName = typeof sendData.fileName === 'string' ? sendData.fileName : null; - if (expectedFileName && file.name !== expectedFileName) { - return errorResponse('Send file name does not match.', 400); - } - - const expectedSize = parseInteger(sendData.size); - if (expectedSize !== null && file.size !== expectedSize) { - return errorResponse('Send file size does not match.', 400); - } - - try { - await putBlobObject(env, getSendFileObjectKey(sendId, fileId), file.stream(), { - size: file.size, - contentType: 'application/octet-stream', - customMetadata: { - sendId, - fileId, - }, - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes('KV object too large')) { - return errorResponse('Send storage limit exceeded with this file', 413); - } - return errorResponse('Attachment storage is not configured', 500); - } - - let revisionDate = await storage.updateRevisionDate(userId); - await notifyVaultSyncForRequest(request, env, userId, revisionDate); - - return new Response(null, { status: 200 }); -} - -// PUT /api/sends/:id -export async function handleUpdateSend(request: Request, env: Env, userId: string, sendId: string): Promise { - const storage = new StorageService(env.DB); - const send = await storage.getSend(sendId); - if (!send || send.userId !== userId) { - return errorResponse('Send not found', 404); - } - - let body: unknown; - try { - body = await request.json(); - } catch { - return errorResponse('Invalid JSON', 400); - } - - const typeRaw = getAliasedProp(body, ['type', 'Type']); - if (typeRaw.present) { - const incomingType = parseSendType(typeRaw.value); - if (incomingType === null) { - return errorResponse('Invalid Send type', 400); - } - if (incomingType !== send.type) { - return errorResponse("Sends can't change type", 400); - } - } - - const deletionRaw = getAliasedProp(body, ['deletionDate', 'DeletionDate']); - if (deletionRaw.present) { - const deletionDate = parseDate(deletionRaw.value); - if (!deletionDate) return errorResponse('Invalid deletionDate', 400); - const deletionValidation = validateDeletionDate(deletionDate); - if (deletionValidation) return deletionValidation; - send.deletionDate = deletionDate.toISOString(); - } - - const expirationRaw = getAliasedProp(body, ['expirationDate', 'ExpirationDate']); - if (expirationRaw.present) { - if (expirationRaw.value === null || expirationRaw.value === '') { - send.expirationDate = null; - } else { - const expiration = parseDate(expirationRaw.value); - if (!expiration) return errorResponse('Invalid expirationDate', 400); - send.expirationDate = expiration.toISOString(); - } - } - - const nameRaw = getAliasedProp(body, ['name', 'Name']); - if (nameRaw.present) { - if (typeof nameRaw.value !== 'string' || !nameRaw.value.trim()) { - return errorResponse('Name is required', 400); - } - send.name = nameRaw.value.trim(); - } - - const keyRaw = getAliasedProp(body, ['key', 'Key']); - if (keyRaw.present) { - if (typeof keyRaw.value !== 'string' || !keyRaw.value.trim()) { - return errorResponse('Key is required', 400); - } - send.key = keyRaw.value; - } - - const notesRaw = getAliasedProp(body, ['notes', 'Notes']); - if (notesRaw.present) { - send.notes = typeof notesRaw.value === 'string' ? notesRaw.value : null; - } - - const disabledRaw = getAliasedProp(body, ['disabled', 'Disabled']); - if (disabledRaw.present) { - if (typeof disabledRaw.value !== 'boolean') { - return errorResponse('Invalid disabled', 400); - } - send.disabled = disabledRaw.value; - } - - const hideEmailRaw = getAliasedProp(body, ['hideEmail', 'HideEmail']); - if (hideEmailRaw.present) { - if (hideEmailRaw.value === null) { - send.hideEmail = null; - } else if (typeof hideEmailRaw.value === 'boolean') { - send.hideEmail = hideEmailRaw.value; - } else { - return errorResponse('Invalid hideEmail', 400); - } - } - - const maxAccessRaw = getAliasedProp(body, ['maxAccessCount', 'MaxAccessCount']); - if (maxAccessRaw.present) { - const parsedMax = parseMaxAccessCount(maxAccessRaw.value); - if (!parsedMax.ok) return parsedMax.response; - send.maxAccessCount = parsedMax.value; - } - - if (send.type === SendType.Text) { - const textRaw = getAliasedProp(body, ['text', 'Text']); - if (textRaw.present) { - const textData = sanitizeSendData(textRaw.value); - if (!textData) { - return errorResponse('Send data not provided', 400); - } - send.data = JSON.stringify(textData); - } - } - - const authTypeRaw = getAliasedProp(body, ['authType', 'AuthType']); - if (authTypeRaw.present) { - const parsedAuthType = parseSendAuthType(authTypeRaw.value); - if (parsedAuthType === null) { - return errorResponse('Invalid authType', 400); - } - send.authType = parsedAuthType; - if (parsedAuthType !== SendAuthType.Email) { - send.emails = null; - } - } - - const emailsRaw = getAliasedProp(body, ['emails', 'Emails']); - if (emailsRaw.present) { - const normalizedEmails = normalizeEmails(emailsRaw.value); - if (emailsRaw.value !== null && normalizedEmails === null) { - return errorResponse('Invalid emails', 400); - } - send.emails = normalizedEmails; - if (send.emails) { - send.authType = SendAuthType.Email; - } else if (send.authType === SendAuthType.Email) { - send.authType = SendAuthType.None; - } - } - - const passwordRaw = getAliasedProp(body, ['password', 'Password']); - if (passwordRaw.present && typeof passwordRaw.value === 'string') { - await setSendPassword(send, passwordRaw.value); - } - - if (send.authType === SendAuthType.Password && !send.passwordHash) { - return errorResponse('Password is required for password auth', 400); - } - - send.updatedAt = new Date().toISOString(); - await storage.saveSend(send); - let revisionDate = await storage.updateRevisionDate(userId); - await notifyVaultSyncForRequest(request, env, userId, revisionDate); - - return jsonResponse(sendToResponse(send)); -} - -// DELETE /api/sends/:id -export async function handleDeleteSend(request: Request, env: Env, userId: string, sendId: string): Promise { - void request; - const storage = new StorageService(env.DB); - const send = await storage.getSend(sendId); - if (!send || send.userId !== userId) { - return errorResponse('Send not found', 404); - } - - if (send.type === SendType.File) { - const data = parseStoredSendData(send); - const fileId = typeof data.id === 'string' ? data.id : null; - if (fileId) { - await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId)); - } - } - - await storage.deleteSend(sendId, userId); - let revisionDate = await storage.updateRevisionDate(userId); - await notifyVaultSyncForRequest(request, env, userId, revisionDate); - - return new Response(null, { status: 200 }); -} - -// POST /api/sends/delete - Bulk delete -export async function handleBulkDeleteSends(request: Request, env: Env, userId: string): Promise { - const storage = new StorageService(env.DB); - - let body: { ids?: string[] }; - try { - body = await request.json(); - } catch { - return errorResponse('Invalid JSON', 400); - } - - if (!body.ids || !Array.isArray(body.ids)) { - return errorResponse('ids array is required', 400); - } - - const sends = await storage.getSendsByIds(body.ids, userId); - for (const send of sends) { - if (send.type !== SendType.File) continue; - const data = parseStoredSendData(send); - const fileId = typeof data.id === 'string' ? data.id : null; - if (fileId) { - await deleteBlobObject(env, getSendFileObjectKey(send.id, fileId)); - } - } - - const revisionDate = await storage.bulkDeleteSends(body.ids, userId); - if (revisionDate) { - await notifyVaultSyncForRequest(request, env, userId, revisionDate); - } - - return new Response(null, { status: 200 }); -} - -// PUT /api/sends/:id/remove-password -export async function handleRemoveSendPassword(request: Request, env: Env, userId: string, sendId: string): Promise { - void request; - const storage = new StorageService(env.DB); - const send = await storage.getSend(sendId); - if (!send || send.userId !== userId) { - return errorResponse('Send not found', 404); - } - - await setSendPassword(send, null); - send.updatedAt = new Date().toISOString(); - await storage.saveSend(send); - let revisionDate = await storage.updateRevisionDate(userId); - await notifyVaultSyncForRequest(request, env, userId, revisionDate); - - return jsonResponse(sendToResponse(send)); -} - -// PUT /api/sends/:id/remove-auth -export async function handleRemoveSendAuth(request: Request, env: Env, userId: string, sendId: string): Promise { - void request; - const storage = new StorageService(env.DB); - const send = await storage.getSend(sendId); - if (!send || send.userId !== userId) { - return errorResponse('Send not found', 404); - } - - send.authType = SendAuthType.None; - send.emails = null; - send.updatedAt = new Date().toISOString(); - await storage.saveSend(send); - let revisionDate = await storage.updateRevisionDate(userId); - await notifyVaultSyncForRequest(request, env, userId, revisionDate); - - return jsonResponse(sendToResponse(send)); -} - -// POST /api/sends/access/:accessId -export async function handleAccessSend(request: Request, env: Env, accessId: string): Promise { - const storage = new StorageService(env.DB); - const sendId = fromAccessId(accessId); - if (!sendId) { - return errorResponse(SEND_INACCESSIBLE_MSG, 404); - } - - const send = await storage.getSend(sendId); - if (!send || !isSendAvailable(send)) { - return errorResponse(SEND_INACCESSIBLE_MSG, 404); - } - - let body: unknown = {}; - try { - body = await request.json(); - } catch { - body = {}; - } - - let sendPasswordLimitIpKey: string | null = null; - let sendPasswordRateLimit: RateLimitService | null = null; - if (send.passwordHash) { - const clientIdentifier = getClientIdentifier(request); - if (!clientIdentifier) { - return errorResponse('Client IP is required', 403); - } - sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier); - sendPasswordRateLimit = new RateLimitService(env.DB); - const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey); - if (!sendPasswordCheck.allowed) { - return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60); - } - } - - const validation = await validatePublicSendAccess(send, body); - if (!validation.ok) { - if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) { - const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey); - if (failed.locked) { - return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60); - } - } - return validation.response; - } - - if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) { - await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey); - } - - if (send.type === SendType.Text) { - const updated = await storage.incrementSendAccessCount(send.id); - if (!updated) { - return errorResponse(SEND_INACCESSIBLE_MSG, 404); - } - send.accessCount += 1; - const revisionDate = await storage.updateRevisionDate(send.userId); - await notifyVaultSyncForRequest(request, env, send.userId, revisionDate); - } - - const creatorIdentifier = await getCreatorIdentifier(storage, send); - return jsonResponse(sendToAccessResponse(send, creatorIdentifier)); -} - -// POST /api/sends/:idOrAccess/access/file/:fileId -export async function handleAccessSendFile( - request: Request, - env: Env, - idOrAccessId: string, - fileId: string -): Promise { - const secret = (env.JWT_SECRET || '').trim(); - if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { - return errorResponse('Server configuration error', 500); - } - - const storage = new StorageService(env.DB); - const send = await resolveSendFromIdOrAccessId(storage, idOrAccessId); - if (!send || !isSendAvailable(send) || send.type !== SendType.File) { - return errorResponse(SEND_INACCESSIBLE_MSG, 404); - } - - const data = parseStoredSendData(send); - const expectedFileId = typeof data.id === 'string' ? data.id : null; - if (!expectedFileId || expectedFileId !== fileId) { - return errorResponse(SEND_INACCESSIBLE_MSG, 404); - } - - let body: unknown = {}; - try { - body = await request.json(); - } catch { - body = {}; - } - - let sendPasswordLimitIpKey: string | null = null; - let sendPasswordRateLimit: RateLimitService | null = null; - if (send.passwordHash) { - const clientIdentifier = getClientIdentifier(request); - if (!clientIdentifier) { - return errorResponse('Client IP is required', 403); - } - sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier); - sendPasswordRateLimit = new RateLimitService(env.DB); - const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey); - if (!sendPasswordCheck.allowed) { - return sendPasswordLockedErrorResponse(sendPasswordCheck.retryAfterSeconds || 60); - } - } - - const validation = await validatePublicSendAccess(send, body); - if (!validation.ok) { - if (validation.reason === 'invalid_password' && sendPasswordRateLimit && sendPasswordLimitIpKey) { - const failed = await sendPasswordRateLimit.recordFailedLogin(sendPasswordLimitIpKey); - if (failed.locked) { - return sendPasswordLockedErrorResponse(failed.retryAfterSeconds || 60); - } - } - return validation.response; - } - - if (send.passwordHash && sendPasswordRateLimit && sendPasswordLimitIpKey) { - await sendPasswordRateLimit.clearLoginAttempts(sendPasswordLimitIpKey); - } - - const updated = await storage.incrementSendAccessCount(send.id); - if (!updated) { - return errorResponse(SEND_INACCESSIBLE_MSG, 404); - } - send.accessCount += 1; - const revisionDate = await storage.updateRevisionDate(send.userId); - await notifyVaultSyncForRequest(request, env, send.userId, revisionDate); - - const token = await createSendFileDownloadToken(send.id, fileId, secret); - const url = new URL(request.url); - const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${token}`; - - return jsonResponse({ - object: 'send-fileDownload', - id: fileId, - url: downloadUrl, - }); -} - -// POST /api/sends/access (v2 bearer) -export async function handleAccessSendV2(request: Request, env: Env): Promise { - const jwt = getSafeJwtSecret(env); - if (!jwt.ok) return jwt.response; - - const token = extractBearerToken(request); - if (!token) { - return errorResponse('Unauthorized', 401); - } - - const claims = await verifySendAccessToken(token, jwt.secret); - if (!claims) { - return errorResponse('Unauthorized', 401); - } - - const storage = new StorageService(env.DB); - const send = await storage.getSend(claims.sub); - if (!send || !isSendAvailable(send)) { - return errorResponse(SEND_INACCESSIBLE_MSG, 404); - } - - if (send.type === SendType.Text) { - const updated = await storage.incrementSendAccessCount(send.id); - if (!updated) { - return errorResponse(SEND_INACCESSIBLE_MSG, 404); - } - send.accessCount += 1; - const revisionDate = await storage.updateRevisionDate(send.userId); - await notifyVaultSyncForRequest(request, env, send.userId, revisionDate); - } - - const creatorIdentifier = await getCreatorIdentifier(storage, send); - return jsonResponse(sendToAccessResponse(send, creatorIdentifier)); -} - -// POST /api/sends/access/file/:fileId (v2 bearer) -export async function handleAccessSendFileV2(request: Request, env: Env, fileId: string): Promise { - const secret = (env.JWT_SECRET || '').trim(); - if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { - return errorResponse('Server configuration error', 500); - } - - const token = extractBearerToken(request); - if (!token) { - return errorResponse('Unauthorized', 401); - } - - const claims = await verifySendAccessToken(token, secret); - if (!claims) { - return errorResponse('Unauthorized', 401); - } - - const storage = new StorageService(env.DB); - const send = await storage.getSend(claims.sub); - if (!send || !isSendAvailable(send) || send.type !== SendType.File) { - return errorResponse(SEND_INACCESSIBLE_MSG, 404); - } - - const data = parseStoredSendData(send); - const expectedFileId = typeof data.id === 'string' ? data.id : null; - if (!expectedFileId || expectedFileId !== fileId) { - return errorResponse(SEND_INACCESSIBLE_MSG, 404); - } - - const updated = await storage.incrementSendAccessCount(send.id); - if (!updated) { - return errorResponse(SEND_INACCESSIBLE_MSG, 404); - } - send.accessCount += 1; - const revisionDate = await storage.updateRevisionDate(send.userId); - await notifyVaultSyncForRequest(request, env, send.userId, revisionDate); - - const downloadToken = await createSendFileDownloadToken(send.id, fileId, secret); - const url = new URL(request.url); - const downloadUrl = `${url.origin}/api/sends/${send.id}/${fileId}?t=${downloadToken}`; - - return jsonResponse({ - object: 'send-fileDownload', - id: fileId, - url: downloadUrl, - }); -} - -// GET /api/sends/:sendId/:fileId?t=... -export async function handleDownloadSendFile( - request: Request, - env: Env, - sendId: string, - fileId: string -): Promise { - const secret = (env.JWT_SECRET || '').trim(); - if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { - return errorResponse('Server configuration error', 500); - } - - const url = new URL(request.url); - const token = url.searchParams.get('t') || url.searchParams.get('token'); - if (!token) { - return errorResponse('Token required', 401); - } - - const claims = await verifySendFileDownloadToken(token, secret); - if (!claims) { - return errorResponse('Invalid or expired token', 401); - } - if (claims.sendId !== sendId || claims.fileId !== fileId) { - return errorResponse('Token mismatch', 401); - } - - const storage = new StorageService(env.DB); - const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId)); - if (!object) { - return errorResponse('Send file not found', 404); - } - - // Reuse the existing one-time token store used by attachment downloads. - // Prefix avoids accidental cross-domain JTI collisions. - const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp); - if (!firstUse) { - return errorResponse('Invalid or expired token', 401); - } - - return new Response(object.body, { - headers: { - 'Content-Type': object.contentType || 'application/octet-stream', - 'Content-Length': String(object.size), - 'Cache-Control': 'private, no-cache', - }, - }); -} - -export async function issueSendAccessToken( - env: Env, - sendIdOrAccessId: string, - passwordHashB64?: string | null, - password?: string | null, - rateLimit?: RateLimitService, - sendPasswordLimitIpKey?: string -): Promise<{ token: string } | { error: Response }> { - const jwt = getSafeJwtSecret(env); - if (!jwt.ok) { - return { error: jwt.response }; - } - - const storage = new StorageService(env.DB); - const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId); - - if (!send || !isSendAvailable(send)) { - return { - error: jsonResponse( - { - error: 'invalid_grant', - error_description: SEND_INACCESSIBLE_MSG, - send_access_error_type: 'send_not_available', - ErrorModel: { - Message: SEND_INACCESSIBLE_MSG, - Object: 'error', - }, - }, - 400 - ), - }; - } - - if (hasEmailAuth(send)) { - const message = 'Email verification for this Send is not supported by this server.'; - return { - error: jsonResponse( - { - error: 'invalid_grant', - error_description: message, - send_access_error_type: 'email_verification_not_supported', - ErrorModel: { - Message: message, - Object: 'error', - }, - }, - 400 - ), - }; - } - - if (send.passwordHash) { - if (rateLimit && sendPasswordLimitIpKey) { - const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey); - if (!sendPasswordCheck.allowed) { - return { - error: sendPasswordLockedOAuthResponse(sendPasswordCheck.retryAfterSeconds || 60), - }; - } - } - - let ok = false; - if (passwordHashB64) { - ok = verifySendPasswordHashB64(send, passwordHashB64); - } else if (password) { - ok = await verifySendPassword(send, password); - } - - if (!ok) { - if (rateLimit && sendPasswordLimitIpKey) { - const failed = await rateLimit.recordFailedLogin(sendPasswordLimitIpKey); - if (failed.locked) { - return { - error: sendPasswordLockedOAuthResponse(failed.retryAfterSeconds || 60), - }; - } - } - return { - error: jsonResponse( - { - error: 'invalid_grant', - error_description: 'Invalid password.', - send_access_error_type: 'invalid_password', - ErrorModel: { - Message: 'Invalid password.', - Object: 'error', - }, - }, - 400 - ), - }; - } - - if (rateLimit && sendPasswordLimitIpKey) { - await rateLimit.clearLoginAttempts(sendPasswordLimitIpKey); - } - } - - const token = await createSendAccessToken(send.id, jwt.secret); - return { token }; -} +export * from './sends-shared'; +export * from './sends-private'; +export * from './sends-public'; diff --git a/src/router-authenticated.ts b/src/router-authenticated.ts new file mode 100644 index 0000000..5614b7f --- /dev/null +++ b/src/router-authenticated.ts @@ -0,0 +1,276 @@ +import type { Env, User } from './types'; +import { errorResponse, jsonResponse } from './utils/response'; +import { + handleGetProfile, + handleSetKeys, + handleGetRevisionDate, + handleVerifyPassword, + handleChangePassword, + handleGetTotpStatus, + handleSetTotpStatus, + handleGetTotpRecoveryCode, +} from './handlers/accounts'; +import { + handleGetCiphers, + handleGetCipher, + handleCreateCipher, + handleUpdateCipher, + handleDeleteCipher, + handleDeleteCipherCompat, + handlePermanentDeleteCipher, + handleRestoreCipher, + handlePartialUpdateCipher, + handleBulkMoveCiphers, + handleBulkDeleteCiphers, + handleBulkPermanentDeleteCiphers, + handleBulkRestoreCiphers, +} from './handlers/ciphers'; +import { + handleGetFolders, + handleGetFolder, + handleCreateFolder, + handleUpdateFolder, + handleDeleteFolder, + handleBulkDeleteFolders, +} from './handlers/folders'; +import { + handleGetSends, + handleGetSend, + handleCreateSend, + handleGetSendFileUpload, + handleUploadSendFile, + handleUpdateSend, + handleDeleteSend, + handleBulkDeleteSends, + handleRemoveSendPassword, + handleRemoveSendAuth, +} from './handlers/sends'; +import { handleSync } from './handlers/sync'; +import { handleCiphersImport } from './handlers/import'; +import { + handleCreateAttachment, + handleUploadAttachment, + handleGetAttachment, + handleDeleteAttachment, +} from './handlers/attachments'; +import { handleAuthenticatedDeviceRoute } from './router-devices'; +import { handleAdminRoute } from './router-admin'; + +export async function handleAuthenticatedRoute( + request: Request, + env: Env, + userId: string, + currentUser: User, + path: string, + method: string +): Promise { + if (method === 'POST' || method === 'PUT' || method === 'DELETE') { + const blockedAccountPaths = new Set([ + '/api/accounts/set-password', + '/api/accounts/delete', + '/api/accounts/delete-account', + '/api/accounts/delete-vault', + ]); + if (blockedAccountPaths.has(path)) { + return errorResponse('Not implemented', 501); + } + } + + if (path === '/api/accounts/profile') { + if (method === 'GET') return handleGetProfile(request, env, userId); + return errorResponse('Method not allowed', 405); + } + + if ((path === '/api/accounts/password' || path === '/api/accounts/change-password') && (method === 'POST' || method === 'PUT')) { + return handleChangePassword(request, env, userId); + } + + if (path === '/api/accounts/keys' && method === 'POST') { + return handleSetKeys(request, env, userId); + } + + if (path === '/api/accounts/totp') { + if (method === 'GET') return handleGetTotpStatus(request, env, userId); + if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId); + return null; + } + + if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') { + return handleGetTotpRecoveryCode(request, env, userId); + } + + if (path === '/api/accounts/revision-date' && method === 'GET') { + return handleGetRevisionDate(request, env, userId); + } + + if (path === '/api/accounts/verify-password' && method === 'POST') { + return handleVerifyPassword(request, env, userId); + } + + if (path === '/api/sync' && method === 'GET') { + return handleSync(request, env, userId); + } + + if (path.startsWith('/notifications/')) { + return errorResponse('Not found', 404); + } + + if (path === '/api/ciphers' || path === '/api/ciphers/create') { + if (method === 'GET') return handleGetCiphers(request, env, userId); + if (method === 'POST') return handleCreateCipher(request, env, userId); + return null; + } + + if (path === '/api/ciphers/import' && method === 'POST') { + return handleCiphersImport(request, env, userId); + } + + if (path === '/api/ciphers/delete' && method === 'POST') { + return handleBulkDeleteCiphers(request, env, userId); + } + + if (path === '/api/ciphers/delete-permanent' && method === 'POST') { + return handleBulkPermanentDeleteCiphers(request, env, userId); + } + + if (path === '/api/ciphers/restore' && method === 'POST') { + return handleBulkRestoreCiphers(request, env, userId); + } + + if (path === '/api/ciphers/move' && (method === 'POST' || method === 'PUT')) { + return handleBulkMoveCiphers(request, env, userId); + } + + const cipherMatch = path.match(/^\/api\/ciphers\/([a-f0-9-]+)(\/.*)?$/i); + if (cipherMatch) { + const cipherId = cipherMatch[1]; + const subPath = cipherMatch[2] || ''; + + if (subPath === '' || subPath === '/') { + if (method === 'GET') return handleGetCipher(request, env, userId, cipherId); + if (method === 'PUT' || method === 'POST') return handleUpdateCipher(request, env, userId, cipherId); + if (method === 'DELETE') return handleDeleteCipherCompat(request, env, userId, cipherId); + } + + if (subPath === '/delete' && method === 'PUT') return handleDeleteCipher(request, env, userId, cipherId); + if (subPath === '/delete' && method === 'DELETE') return handlePermanentDeleteCipher(request, env, userId, cipherId); + if (subPath === '/restore' && method === 'PUT') return handleRestoreCipher(request, env, userId, cipherId); + if (subPath === '/partial' && (method === 'PUT' || method === 'POST')) return handlePartialUpdateCipher(request, env, userId, cipherId); + if (subPath === '/share' && method === 'POST') return handleGetCipher(request, env, userId, cipherId); + if (subPath === '/details' && method === 'GET') return handleGetCipher(request, env, userId, cipherId); + if (subPath === '/attachment/v2' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId); + if (subPath === '/attachment' && method === 'POST') return handleCreateAttachment(request, env, userId, cipherId); + + const attachmentMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)$/i); + if (attachmentMatch) { + const attachmentId = attachmentMatch[1]; + if (method === 'POST') return handleUploadAttachment(request, env, userId, cipherId, attachmentId); + if (method === 'GET') return handleGetAttachment(request, env, userId, cipherId, attachmentId); + if (method === 'DELETE') return handleDeleteAttachment(request, env, userId, cipherId, attachmentId); + } + + const attachmentDeleteMatch = subPath.match(/^\/attachment\/([a-f0-9-]+)\/delete$/i); + if (attachmentDeleteMatch && method === 'POST') { + return handleDeleteAttachment(request, env, userId, cipherId, attachmentDeleteMatch[1]); + } + } + + if (path === '/api/folders') { + if (method === 'GET') return handleGetFolders(request, env, userId); + if (method === 'POST') return handleCreateFolder(request, env, userId); + return null; + } + + if (path === '/api/folders/delete' && method === 'POST') { + return handleBulkDeleteFolders(request, env, userId); + } + + const folderMatch = path.match(/^\/api\/folders\/([a-f0-9-]+)$/i); + if (folderMatch) { + const folderId = folderMatch[1]; + if (method === 'GET') return handleGetFolder(request, env, userId, folderId); + if (method === 'PUT') return handleUpdateFolder(request, env, userId, folderId); + if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId); + } + + if (path.startsWith('/api/auth-requests')) { + return jsonResponse({ data: [], object: 'list', continuationToken: null }); + } + + if (path === '/api/collections' || path.startsWith('/api/collections/')) { + if (method === 'GET') { + return jsonResponse({ data: [], object: 'list', continuationToken: null }); + } + return null; + } + + if (path === '/api/organizations' || path.startsWith('/api/organizations/')) { + if (method === 'GET') { + return jsonResponse({ data: [], object: 'list', continuationToken: null }); + } + return null; + } + + if (path === '/api/sends') { + if (method === 'GET') return handleGetSends(request, env, userId); + if (method === 'POST') return handleCreateSend(request, env, userId); + return null; + } + + if (path === '/api/sends/delete' && method === 'POST') { + return handleBulkDeleteSends(request, env, userId); + } + + const sendMatch = path.match(/^\/api\/sends\/([^/]+)(\/.*)?$/i); + if (sendMatch) { + const sendId = sendMatch[1]; + const subPath = sendMatch[2] || ''; + + if (subPath === '' || subPath === '/') { + if (method === 'GET') return handleGetSend(request, env, userId, sendId); + if (method === 'PUT') return handleUpdateSend(request, env, userId, sendId); + if (method === 'DELETE') return handleDeleteSend(request, env, userId, sendId); + } + + if (subPath === '/remove-password' && (method === 'PUT' || method === 'POST')) { + return handleRemoveSendPassword(request, env, userId, sendId); + } + + if (subPath === '/remove-auth' && (method === 'PUT' || method === 'POST')) { + return handleRemoveSendAuth(request, env, userId, sendId); + } + + const sendFileUploadMatch = subPath.match(/^\/file\/([^/]+)\/?$/i); + if (sendFileUploadMatch) { + const fileId = sendFileUploadMatch[1]; + if (method === 'GET') return handleGetSendFileUpload(request, env, userId, sendId, fileId); + if (method === 'POST' || method === 'PUT') return handleUploadSendFile(request, env, userId, sendId, fileId); + } + } + + if (path === '/api/policies' || path.startsWith('/api/policies/')) { + if (method === 'GET') { + return jsonResponse({ data: [], object: 'list', continuationToken: null }); + } + return null; + } + + if (path === '/api/settings/domains') { + if (method === 'GET' || method === 'PUT' || method === 'POST') { + return jsonResponse({ + equivalentDomains: [], + globalEquivalentDomains: [], + object: 'domains', + }); + } + return null; + } + + const authenticatedDeviceResponse = await handleAuthenticatedDeviceRoute(request, env, userId, path, method); + if (authenticatedDeviceResponse) return authenticatedDeviceResponse; + + const adminResponse = await handleAdminRoute(request, env, currentUser, path, method); + if (adminResponse) return adminResponse; + + return null; +} diff --git a/src/router-public.ts b/src/router-public.ts new file mode 100644 index 0000000..1c299f2 --- /dev/null +++ b/src/router-public.ts @@ -0,0 +1,291 @@ +import { LIMITS } from './config/limits'; +import { DEFAULT_DEV_SECRET } from './types'; +import { + handleAccessSend, + handleAccessSendFile, + handleAccessSendV2, + handleAccessSendFileV2, + handleDownloadSendFile, +} from './handlers/sends'; +import { handleSetupStatus } from './handlers/setup'; +import { handleKnownDevice } from './handlers/devices'; +import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; +import { + handleRegister, + handleRecoverTwoFactor, +} from './handlers/accounts'; +import { handlePublicDownloadAttachment } from './handlers/attachments'; +import { + handleNotificationsHub, + handleNotificationsNegotiate, +} from './handlers/notifications'; +import { jsonResponse } from './utils/response'; +import type { Env } from './types'; + +type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise; + +function isSameOriginWriteRequest(request: Request): boolean { + const targetOrigin = new URL(request.url).origin; + const origin = request.headers.get('Origin'); + if (origin) { + return origin === targetOrigin; + } + + const referer = request.headers.get('Referer'); + if (referer) { + try { + return new URL(referer).origin === targetOrigin; + } catch { + return false; + } + } + + return false; +} + +function getNwIconSvg(): string { + return `NW`; +} + +function handleNwFavicon(): Response { + return new Response(getNwIconSvg(), { + status: 200, + headers: { + 'Content-Type': 'image/svg+xml; charset=utf-8', + 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, + }, + }); +} + +function isValidIconHostname(hostname: string): boolean { + if (!hostname) return false; + if (hostname.length > 253) return false; + + const normalized = hostname.toLowerCase().replace(/\.$/, ''); + const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/; + const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/; + + if (domainPattern.test(normalized)) return true; + if (!ipv4Pattern.test(normalized)) return false; + + const parts = normalized.split('.'); + return parts.every((p) => { + const n = Number(p); + return Number.isInteger(n) && n >= 0 && n <= 255; + }); +} + +async function handleGetIcon(env: Env, hostname: string): Promise { + try { + void env; + const normalizedHostname = hostname.toLowerCase(); + if (!isValidIconHostname(normalizedHostname)) { + return new Response(null, { status: 204 }); + } + + const cache = caches.default; + const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' }); + const cached = await cache.match(cacheKey); + if (cached) return cached; + + const resp = await fetch(`https://favicon.im/${normalizedHostname}`, { + headers: { 'User-Agent': 'NodeWarden/1.0' }, + redirect: 'follow', + cf: { + cacheEverything: true, + cacheTtl: LIMITS.cache.iconTtlSeconds, + }, + }); + + if (!resp.ok) return new Response(null, { status: 204 }); + + const body = await resp.arrayBuffer(); + if (body.byteLength === 0) { + return new Response(null, { status: 204 }); + } + + const iconResponse = new Response(body, { + status: 200, + headers: { + 'Content-Type': resp.headers.get('Content-Type') || 'image/png', + 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, + }, + }); + await cache.put(cacheKey, iconResponse.clone()); + return iconResponse; + } catch { + return new Response(null, { status: 204 }); + } +} + +export function buildWebConfigResponse(env: Env) { + const secret = (env.JWT_SECRET || '').trim(); + const jwtUnsafeReason = + !secret + ? 'missing' + : secret === DEFAULT_DEV_SECRET + ? 'default' + : secret.length < LIMITS.auth.jwtSecretMinLength + ? 'too_short' + : null; + + return { + defaultKdfIterations: LIMITS.auth.defaultKdfIterations, + jwtUnsafeReason, + jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength, + }; +} + +export async function handlePublicRoute( + request: Request, + env: Env, + path: string, + method: string, + enforcePublicRateLimit: PublicRateLimiter +): Promise { + if (path === '/setup/status' && method === 'GET') { + const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); + if (blocked) return blocked; + return handleSetupStatus(request, env); + } + + if (path === '/api/web/config' && method === 'GET') { + const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); + if (blocked) return blocked; + return jsonResponse(buildWebConfigResponse(env)); + } + + if (path === '/.well-known/appspecific/com.chrome.devtools.json' && method === 'GET') { + return new Response('{}', { + status: 200, + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }); + } + + if ((path === '/favicon.ico' || path === '/favicon.svg') && method === 'GET') { + return handleNwFavicon(); + } + + const iconMatch = path.match(/^\/icons\/([^/]+)\/icon\.png$/i); + if (iconMatch) { + return handleGetIcon(env, iconMatch[1]); + } + + const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i); + if (publicAttachmentMatch && method === 'GET') { + return handlePublicDownloadAttachment(request, env, publicAttachmentMatch[1], publicAttachmentMatch[2]); + } + + const sendAccessMatch = path.match(/^\/api\/sends\/access\/([^/]+)$/i); + if (sendAccessMatch && method === 'POST') { + const blocked = await enforcePublicRateLimit(); + if (blocked) return blocked; + return handleAccessSend(request, env, sendAccessMatch[1]); + } + + if (path === '/api/sends/access' && method === 'POST') { + const blocked = await enforcePublicRateLimit(); + if (blocked) return blocked; + return handleAccessSendV2(request, env); + } + + const sendAccessFileV2Match = path.match(/^\/api\/sends\/access\/file\/([^/]+)\/?$/i); + if (sendAccessFileV2Match && method === 'POST') { + const blocked = await enforcePublicRateLimit(); + if (blocked) return blocked; + return handleAccessSendFileV2(request, env, sendAccessFileV2Match[1]); + } + + const sendAccessFileMatch = path.match(/^\/api\/sends\/([^/]+)\/access\/file\/([^/]+)\/?$/i); + if (sendAccessFileMatch && method === 'POST') { + const blocked = await enforcePublicRateLimit(); + if (blocked) return blocked; + return handleAccessSendFile(request, env, sendAccessFileMatch[1], sendAccessFileMatch[2]); + } + + const sendDownloadMatch = path.match(/^\/api\/sends\/([^/]+)\/([^/]+)\/?$/i); + if (sendDownloadMatch && method === 'GET') { + return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]); + } + + if (path === '/identity/connect/token' && method === 'POST') { + return handleToken(request, env); + } + + if (path === '/api/devices/knowndevice' && method === 'GET') { + const blocked = await enforcePublicRateLimit(); + if (blocked) return jsonResponse(false); + return handleKnownDevice(request, env); + } + + if ((path === '/identity/connect/revocation' || path === '/identity/connect/revoke') && method === 'POST') { + const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute); + if (blocked) return blocked; + return handleRevocation(request, env); + } + + if (path === '/identity/accounts/prelogin' && method === 'POST') { + const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute); + if (blocked) return blocked; + return handlePrelogin(request, env); + } + + if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') { + return handleRecoverTwoFactor(request, env); + } + + if ((path === '/config' || path === '/api/config') && method === 'GET') { + const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); + if (blocked) return blocked; + const origin = new URL(request.url).origin; + return jsonResponse({ + version: LIMITS.compatibility.bitwardenServerVersion, + gitHash: 'nodewarden', + server: null, + environment: { + vault: origin, + api: origin + '/api', + identity: origin + '/identity', + notifications: origin + '/notifications', + sso: '', + }, + featureStates: { + 'duo-redirect': true, + 'email-verification': true, + 'pm-19051-send-email-verification': false, + 'unauth-ui-refresh': true, + }, + object: 'config', + }); + } + + if (path === '/api/version' && method === 'GET') { + const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); + if (blocked) return blocked; + return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); + } + + if (path === '/api/accounts/register' && method === 'POST') { + const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute); + if (blocked) return blocked; + if (!isSameOriginWriteRequest(request)) { + return new Response(JSON.stringify({ error: 'Forbidden origin' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + return handleRegister(request, env); + } + + if (path === '/notifications/hub/negotiate' && method === 'POST') { + return handleNotificationsNegotiate(request, env); + } + + if (path === '/notifications/hub' && method === 'GET') { + return handleNotificationsHub(request, env); + } + return null; +} diff --git a/src/router.ts b/src/router.ts index f2ed927..1626f53 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,120 +1,11 @@ -import { Env, DEFAULT_DEV_SECRET } from './types'; +import { DEFAULT_DEV_SECRET, Env } from './types'; import { AuthService } from './services/auth'; import { StorageService } from './services/storage'; import { RateLimitService, getClientIdentifier } from './services/ratelimit'; -import { handleCors, errorResponse, jsonResponse } from './utils/response'; +import { handleCors, errorResponse } from './utils/response'; import { LIMITS } from './config/limits'; - -// Identity handlers -import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; - -// Account handlers -import { - handleRegister, - handleGetProfile, - handleSetKeys, - handleGetRevisionDate, - handleVerifyPassword, - handleChangePassword, - handleGetTotpStatus, - handleSetTotpStatus, - handleGetTotpRecoveryCode, - handleRecoverTwoFactor, -} from './handlers/accounts'; - -// Cipher handlers -import { - handleGetCiphers, - handleGetCipher, - handleCreateCipher, - handleUpdateCipher, - handleDeleteCipher, - handleDeleteCipherCompat, - handlePermanentDeleteCipher, - handleRestoreCipher, - handlePartialUpdateCipher, - handleBulkMoveCiphers, - handleBulkDeleteCiphers, - handleBulkPermanentDeleteCiphers, - handleBulkRestoreCiphers, -} from './handlers/ciphers'; - -// Folder handlers -import { - handleGetFolders, - handleGetFolder, - handleCreateFolder, - handleUpdateFolder, - handleDeleteFolder, - handleBulkDeleteFolders, -} from './handlers/folders'; - -// Send handlers -import { - handleGetSends, - handleGetSend, - handleCreateSend, - handleCreateFileSendV2, - handleGetSendFileUpload, - handleUploadSendFile, - handleUpdateSend, - handleDeleteSend, - handleBulkDeleteSends, - handleRemoveSendPassword, - handleRemoveSendAuth, - handleAccessSend, - handleAccessSendFile, - handleAccessSendV2, - handleAccessSendFileV2, - handleDownloadSendFile, -} from './handlers/sends'; - -// Sync handler -import { handleSync } from './handlers/sync'; - -// Setup handlers -import { handleSetupStatus } from './handlers/setup'; -import { - handleKnownDevice, -} from './handlers/devices'; - -// Import handler -import { handleCiphersImport } from './handlers/import'; - -// Attachment handlers -import { - handleCreateAttachment, - handleUploadAttachment, - handleGetAttachment, - handleDeleteAttachment, - handlePublicDownloadAttachment, -} from './handlers/attachments'; -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; - const origin = request.headers.get('Origin'); - if (origin) { - return origin === targetOrigin; - } - - const referer = request.headers.get('Referer'); - if (referer) { - try { - return new URL(referer).origin === targetOrigin; - } catch { - return false; - } - } - - // Require browser-origin evidence for setup/register write operations. - return false; -} +import { handleAuthenticatedRoute } from './router-authenticated'; +import { handlePublicRoute } from './router-public'; function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { const secret = (env.JWT_SECRET || '').trim(); @@ -124,10 +15,6 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | return null; } -function getNwIconSvg(): string { - return `NW`; -} - function isImportBypassRequest(request: Request, path: string, method: string): boolean { if (request.headers.get('X-NodeWarden-Import') !== '1') return false; @@ -140,85 +27,6 @@ function isImportBypassRequest(request: Request, path: string, method: string): return false; } -function handleNwFavicon(): Response { - return new Response(getNwIconSvg(), { - status: 200, - headers: { - 'Content-Type': 'image/svg+xml; charset=utf-8', - 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, - }, - }); -} - -function isValidIconHostname(hostname: string): boolean { - if (!hostname) return false; - if (hostname.length > 253) return false; - - const normalized = hostname.toLowerCase().replace(/\.$/, ''); - // Slightly relaxed domain validation: - // - keep strict label boundaries (no leading/trailing hyphen) - // - allow punycode TLD (e.g. xn--...) - const domainPattern = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z]{2,63}|xn--[a-z0-9-]{2,59})$/; - const ipv4Pattern = /^(?:\d{1,3}\.){3}\d{1,3}$/; - - if (domainPattern.test(normalized)) return true; - if (!ipv4Pattern.test(normalized)) return false; - - const parts = normalized.split('.'); - return parts.every(p => { - const n = Number(p); - return Number.isInteger(n) && n >= 0 && n <= 255; - }); -} - -// Icons handler - proxy to favicon.im -async function handleGetIcon(request: Request, env: Env, hostname: string): Promise { - try { - void env; - const normalizedHostname = hostname.toLowerCase(); - if (!isValidIconHostname(normalizedHostname)) { - return new Response(null, { status: 204 }); - } - - const cache = caches.default; - const cacheKey = new Request(`https://nodewarden-icons.local/icons/${normalizedHostname}/icon.png`, { method: 'GET' }); - const cached = await cache.match(cacheKey); - if (cached) { - return cached; - } - - const iconUrl = `https://favicon.im/${normalizedHostname}`; - const resp = await fetch(iconUrl, { - headers: { 'User-Agent': 'NodeWarden/1.0' }, - redirect: 'follow', - cf: { - cacheEverything: true, - cacheTtl: LIMITS.cache.iconTtlSeconds, - }, - }); - - if (resp.ok) { - const body = await resp.arrayBuffer(); - if (body.byteLength === 0) { - return new Response(null, { status: 204 }); - } - const iconResponse = new Response(body, { - status: 200, - headers: { - 'Content-Type': resp.headers.get('Content-Type') || 'image/png', - 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}`, // 7 days - }, - }); - await cache.put(cacheKey, iconResponse.clone()); - return iconResponse; - } - - return new Response(null, { status: 204 }); - } catch { - return new Response(null, { status: 204 }); - } -} - export async function handleRequest(request: Request, env: Env): Promise { const url = new URL(request.url); const path = url.pathname; @@ -230,42 +38,43 @@ export async function handleRequest(request: Request, env: Env): Promise { if (!clientId) { - return new Response(JSON.stringify({ - error: 'Forbidden', - error_description: 'Client IP is required', - }), { - status: 403, - headers: { - 'Content-Type': 'application/json', - }, - }); + return new Response( + JSON.stringify({ + error: 'Forbidden', + error_description: 'Client IP is required', + }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + } + ); } + const rateLimit = new RateLimitService(env.DB); const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests); if (check.allowed) return null; - return new Response(JSON.stringify({ - error: 'Too many requests', - error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`, - }), { - status: 429, - headers: { - 'Content-Type': 'application/json', - 'Retry-After': String(check.retryAfterSeconds || 60), - 'X-RateLimit-Remaining': '0', - }, - }); + + return new Response( + JSON.stringify({ + error: 'Too many requests', + error_description: `Rate limit exceeded. Try again in ${check.retryAfterSeconds} seconds.`, + }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(check.retryAfterSeconds || 60), + 'X-RateLimit-Remaining': '0', + }, + } + ); } - // Handle CORS preflight if (method === 'OPTIONS') { return handleCors(request); } - // Route matching try { - - // Reject oversized bodies before any path-specific parsing. - // Large file/archive upload paths enforce their own limits and are exempt here. const isLargeUploadPath = /^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) || /^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) || @@ -277,207 +86,17 @@ export async function handleRequest(request: Request, env: Env): Promise= 2024.2.0 - // (clients/libs/common/src/vault/services/cipher.service.ts: CIPHER_KEY_ENC_MIN_SERVER_VER) - // (android/.../FeatureFlagManagerImpl.kt: CIPHER_KEY_ENC_MIN_SERVER_VERSION) - // - MasterPasswordUnlockData (mobile): >= 2025.8.0 - // (documented in Vaultwarden source comments) - // There is NO global minimum version that blocks all client functionality. - // Keep this aligned with Vaultwarden's reported version to maintain compatibility. - // When Vaultwarden bumps their version, update this value accordingly. - // Vaultwarden source: src/api/core/mod.rs → fn config() - version: LIMITS.compatibility.bitwardenServerVersion, - gitHash: 'nodewarden', - server: null, - environment: { - vault: origin, - api: origin + '/api', - identity: origin + '/identity', - notifications: origin + '/notifications', - sso: '', - }, - // Feature flags control client behavior. Clients use server-provided values; - // flags not listed here fall back to DefaultFeatureFlagValue (all false). - // Only enable flags for features we actually support. - // Reference: clients/libs/common/src/enums/feature-flag.enum.ts - featureStates: { - 'duo-redirect': true, - 'email-verification': true, - 'pm-19051-send-email-verification': false, - 'unauth-ui-refresh': true, - }, - object: 'config', - }); - } - - // Version endpoint (some clients probe this to validate the server) - if (path === '/api/version' && method === 'GET') { - const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); - if (blocked) return blocked; - return jsonResponse(LIMITS.compatibility.bitwardenServerVersion); // Always same value as /config.version - } - - // Registration endpoint (no auth required): - // - first user can self-register and becomes admin - // - later registrations require inviteCode in request body - if (path === '/api/accounts/register' && method === 'POST') { - const blocked = await enforcePublicRateLimit('register', LIMITS.rateLimit.registerRequestsPerMinute); - if (blocked) return blocked; - if (!isSameOriginWriteRequest(request)) { - return errorResponse('Forbidden origin', 403); - } - return handleRegister(request, env); - } - - // If JWT_SECRET is not safely configured, block any other endpoints. - const secret = jwtSecretUnsafeReason(env); - if (secret) { + const secretIssue = jwtSecretUnsafeReason(env); + if (secretIssue) { return errorResponse('Server configuration error: JWT_SECRET is not set or too weak', 500); } - if (path === '/notifications/hub/negotiate' && method === 'POST') { - return handleNotificationsNegotiate(request, env); - } - - if (path === '/notifications/hub' && method === 'GET') { - return handleNotificationsHub(request, env); - } - - // All other API endpoints require authentication const auth = new AuthService(env); const authHeader = request.headers.get('Authorization'); const payload = await auth.verifyAccessToken(authHeader); - if (!payload) { return errorResponse('Unauthorized', 401); } @@ -498,291 +117,32 @@ export async function handleRequest(request: Request, env: Env): Promise { + await db + .prepare( + 'INSERT INTO invites(code, created_by, used_by, expires_at, status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)' + ) + .bind(invite.code, invite.createdBy, invite.usedBy, invite.expiresAt, invite.status, invite.createdAt, invite.updatedAt) + .run(); +} + +export async function getInvite(db: D1Database, code: string): Promise { + const row = await db + .prepare('SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites WHERE code = ?') + .bind(code) + .first(); + if (!row) return null; + return { + code: row.code, + createdBy: row.created_by, + usedBy: row.used_by ?? null, + expiresAt: row.expires_at, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export async function listInvites(db: D1Database, includeInactive: boolean = false): Promise { + const now = new Date().toISOString(); + const predicate = includeInactive + ? '1 = 1' + : "(status = 'active' AND expires_at > ?)"; + const query = + 'SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites ' + + `WHERE ${predicate} ORDER BY created_at DESC`; + const res = includeInactive + ? await db.prepare(query).all() + : await db.prepare(query).bind(now).all(); + + return (res.results || []).map((row) => ({ + code: row.code, + createdBy: row.created_by, + usedBy: row.used_by ?? null, + expiresAt: row.expires_at, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + })); +} + +export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise { + const now = new Date().toISOString(); + const result = await db + .prepare( + "UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?" + ) + .bind(userId, now, code, now) + .run(); + return (result.meta.changes ?? 0) > 0; +} + +export async function revokeInvite(db: D1Database, code: string): Promise { + const now = new Date().toISOString(); + const result = await db + .prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'") + .bind(now, code) + .run(); + return (result.meta.changes ?? 0) > 0; +} + +export async function deleteAllInvites(db: D1Database): Promise { + const result = await db.prepare('DELETE FROM invites').run(); + return Number(result.meta.changes ?? 0); +} + +export async function createAuditLog(db: D1Database, log: AuditLog): Promise { + await db + .prepare( + 'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)' + ) + .bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt) + .run(); +} diff --git a/src/services/storage-attachment-repo.ts b/src/services/storage-attachment-repo.ts new file mode 100644 index 0000000..a7c6744 --- /dev/null +++ b/src/services/storage-attachment-repo.ts @@ -0,0 +1,143 @@ +import type { Attachment, Cipher } from '../types'; + +type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement; +type SqlChunkSize = (fixedBindCount: number) => number; +type GetCipher = (id: string) => Promise; +type SaveCipher = (cipher: Cipher) => Promise; +type UpdateRevisionDate = (userId: string) => Promise; + +export async function getAttachment(db: D1Database, id: string): Promise { + const row = await db + .prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?') + .bind(id) + .first(); + if (!row) return null; + return { + id: row.id, + cipherId: row.cipher_id, + fileName: row.file_name, + size: row.size, + sizeName: row.size_name, + key: row.key, + }; +} + +export async function saveAttachment(db: D1Database, safeBind: SafeBind, attachment: Attachment): Promise { + const stmt = db.prepare( + 'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key' + ); + await safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run(); +} + +export async function deleteAttachment(db: D1Database, id: string): Promise { + await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run(); +} + +export async function getAttachmentsByCipher(db: D1Database, cipherId: string): Promise { + const res = await db + .prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?') + .bind(cipherId) + .all(); + return (res.results || []).map((r) => ({ + id: r.id, + cipherId: r.cipher_id, + fileName: r.file_name, + size: r.size, + sizeName: r.size_name, + key: r.key, + })); +} + +export async function getAttachmentsByCipherIds( + db: D1Database, + sqlChunkSize: SqlChunkSize, + cipherIds: string[] +): Promise> { + const grouped = new Map(); + if (cipherIds.length === 0) return grouped; + + const uniqueCipherIds = [...new Set(cipherIds)]; + const chunkSize = sqlChunkSize(0); + + for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) { + const chunk = uniqueCipherIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + const res = await db + .prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`) + .bind(...chunk) + .all(); + + for (const row of res.results || []) { + const item: Attachment = { + id: row.id, + cipherId: row.cipher_id, + fileName: row.file_name, + size: row.size, + sizeName: row.size_name, + key: row.key, + }; + const list = grouped.get(item.cipherId); + if (list) list.push(item); + else grouped.set(item.cipherId, [item]); + } + } + + return grouped; +} + +export async function getAttachmentsByUserId(db: D1Database, userId: string): Promise> { + const grouped = new Map(); + const res = await db + .prepare( + `SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key + FROM attachments a + INNER JOIN ciphers c ON c.id = a.cipher_id + WHERE c.user_id = ?` + ) + .bind(userId) + .all(); + + for (const row of res.results || []) { + const item: Attachment = { + id: row.id, + cipherId: row.cipher_id, + fileName: row.file_name, + size: row.size, + sizeName: row.size_name, + key: row.key, + }; + const list = grouped.get(item.cipherId); + if (list) list.push(item); + else grouped.set(item.cipherId, [item]); + } + + return grouped; +} + +export async function addAttachmentToCipher(db: D1Database, cipherId: string, attachmentId: string): Promise { + await db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run(); +} + +export async function removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise { + void cipherId; + void attachmentId; +} + +export async function deleteAllAttachmentsByCipher(db: D1Database, cipherId: string): Promise { + await db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run(); +} + +export async function updateCipherRevisionDate( + getCipherById: GetCipher, + saveCipherRecord: SaveCipher, + updateRevisionDate: UpdateRevisionDate, + cipherId: string +): Promise<{ userId: string; revisionDate: string } | null> { + const cipher = await getCipherById(cipherId); + if (!cipher) return null; + cipher.updatedAt = new Date().toISOString(); + await saveCipherRecord(cipher); + const revisionDate = await updateRevisionDate(cipher.userId); + return { userId: cipher.userId, revisionDate }; +} diff --git a/src/services/storage-attachment-token-repo.ts b/src/services/storage-attachment-token-repo.ts new file mode 100644 index 0000000..f932a2c --- /dev/null +++ b/src/services/storage-attachment-token-repo.ts @@ -0,0 +1,46 @@ +type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean; + +export async function ensureUsedAttachmentDownloadTokenTable(db: D1Database): Promise { + await db + .prepare( + 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + + 'jti TEXT PRIMARY KEY, ' + + 'expires_at INTEGER NOT NULL' + + ')' + ) + .run(); +} + +export async function consumeAttachmentDownloadToken( + db: D1Database, + shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup, + lastCleanupAt: number, + cleanupIntervalMs: number, + jti: string, + expUnixSeconds: number +): Promise<{ consumed: boolean; cleanedUpAt: number | null }> { + const nowMs = Date.now(); + let cleanedUpAt: number | null = null; + + if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) { + await db + .prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?') + .bind(nowMs) + .run(); + cleanedUpAt = nowMs; + } + + const expiresAtMs = expUnixSeconds * 1000; + const result = await db + .prepare( + 'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' + + 'ON CONFLICT(jti) DO NOTHING' + ) + .bind(jti, expiresAtMs) + .run(); + + return { + consumed: (result.meta.changes ?? 0) > 0, + cleanedUpAt, + }; +} diff --git a/src/services/storage-cipher-repo.ts b/src/services/storage-cipher-repo.ts new file mode 100644 index 0000000..e657ecb --- /dev/null +++ b/src/services/storage-cipher-repo.ts @@ -0,0 +1,227 @@ +import type { Cipher } from '../types'; + +type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement; +type SqlChunkSize = (fixedBindCount: number) => number; +type UpdateRevisionDate = (userId: string) => Promise; + +export async function getCipher(db: D1Database, id: string): Promise { + const row = await db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>(); + if (!row?.data) return null; + try { + return JSON.parse(row.data) as Cipher; + } catch { + console.error('Corrupted cipher data, id:', id); + return null; + } +} + +export async function saveCipher(db: D1Database, safeBind: SafeBind, cipher: Cipher): Promise { + const data = JSON.stringify(cipher); + const stmt = db.prepare( + 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET ' + + 'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at' + ); + await safeBind( + stmt, + cipher.id, + cipher.userId, + Number(cipher.type) || 1, + cipher.folderId, + cipher.name, + cipher.notes, + cipher.favorite ? 1 : 0, + data, + cipher.reprompt ?? 0, + cipher.key, + cipher.createdAt, + cipher.updatedAt, + cipher.deletedAt + ).run(); +} + +export async function deleteCipher(db: D1Database, id: string, userId: string): Promise { + await db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run(); +} + +export async function bulkSoftDeleteCiphers( + db: D1Database, + sqlChunkSize: SqlChunkSize, + updateRevisionDate: UpdateRevisionDate, + ids: string[], + userId: string +): Promise { + if (ids.length === 0) return null; + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return null; + + const now = new Date().toISOString(); + const patch = JSON.stringify({ deletedAt: now, updatedAt: now }); + const chunkSize = sqlChunkSize(4); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await db + .prepare( + `UPDATE ciphers + SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?) + WHERE user_id = ? AND id IN (${placeholders})` + ) + .bind(now, now, patch, userId, ...chunk) + .run(); + } + + return updateRevisionDate(userId); +} + +export async function bulkRestoreCiphers( + db: D1Database, + sqlChunkSize: SqlChunkSize, + updateRevisionDate: UpdateRevisionDate, + ids: string[], + userId: string +): Promise { + if (ids.length === 0) return null; + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return null; + + const now = new Date().toISOString(); + const patch = JSON.stringify({ deletedAt: null, updatedAt: now }); + const chunkSize = sqlChunkSize(3); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await db + .prepare( + `UPDATE ciphers + SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?) + WHERE user_id = ? AND id IN (${placeholders})` + ) + .bind(now, patch, userId, ...chunk) + .run(); + } + + return updateRevisionDate(userId); +} + +export async function bulkDeleteCiphers( + db: D1Database, + sqlChunkSize: SqlChunkSize, + updateRevisionDate: UpdateRevisionDate, + ids: string[], + userId: string +): Promise { + if (ids.length === 0) return null; + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return null; + + const chunkSize = sqlChunkSize(1); + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await db.prepare(`DELETE FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run(); + } + + return updateRevisionDate(userId); +} + +export async function getAllCiphers(db: D1Database, userId: string): Promise { + const res = await db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>(); + return (res.results || []).flatMap((r) => { + try { + return [JSON.parse(r.data) as Cipher]; + } catch { + return []; + } + }); +} + +export async function getCiphersPage( + db: D1Database, + userId: string, + includeDeleted: boolean, + limit: number, + offset: number +): Promise { + const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL'; + const res = await db + .prepare( + `SELECT data FROM ciphers + WHERE user_id = ? + ${whereDeleted} + ORDER BY updated_at DESC + LIMIT ? OFFSET ?` + ) + .bind(userId, limit, offset) + .all<{ data: string }>(); + return (res.results || []).flatMap((r) => { + try { + return [JSON.parse(r.data) as Cipher]; + } catch { + return []; + } + }); +} + +export async function getCiphersByIds( + db: D1Database, + sqlChunkSize: SqlChunkSize, + ids: string[], + userId: string +): Promise { + if (ids.length === 0) return []; + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return []; + + const chunkSize = sqlChunkSize(1); + const out: Cipher[] = []; + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + const stmt = db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`); + const res = await stmt.bind(userId, ...chunk).all<{ data: string }>(); + out.push( + ...(res.results || []).flatMap((r) => { + try { + return [JSON.parse(r.data) as Cipher]; + } catch { + return []; + } + }) + ); + } + return out; +} + +export async function bulkMoveCiphers( + db: D1Database, + sqlChunkSize: SqlChunkSize, + updateRevisionDate: UpdateRevisionDate, + ids: string[], + folderId: string | null, + userId: string +): Promise { + if (ids.length === 0) return null; + const now = new Date().toISOString(); + const uniqueIds = Array.from(new Set(ids)); + const patch = JSON.stringify({ folderId, updatedAt: now }); + const chunkSize = sqlChunkSize(4); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await db + .prepare( + `UPDATE ciphers + SET folder_id = ?, updated_at = ?, data = json_patch(data, ?) + WHERE user_id = ? AND id IN (${placeholders})` + ) + .bind(folderId, now, patch, userId, ...chunk) + .run(); + } + + return updateRevisionDate(userId); +} diff --git a/src/services/storage-config-repo.ts b/src/services/storage-config-repo.ts new file mode 100644 index 0000000..6163114 --- /dev/null +++ b/src/services/storage-config-repo.ts @@ -0,0 +1,22 @@ +export async function isRegistered(db: D1Database): Promise { + const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>(); + return row?.value === 'true'; +} + +export async function getConfigValue(db: D1Database, key: string): Promise { + const row = await db.prepare('SELECT value FROM config WHERE key = ?').bind(key).first<{ value: string }>(); + return typeof row?.value === 'string' ? row.value : null; +} + +export async function setConfigValue(db: D1Database, key: string, value: string): Promise { + await db + .prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') + .bind(key, value) + .run(); +} + +export async function setRegistered(db: D1Database): Promise { + await db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') + .bind('registered', 'true') + .run(); +} diff --git a/src/services/storage-device-repo.ts b/src/services/storage-device-repo.ts new file mode 100644 index 0000000..b9bbf8d --- /dev/null +++ b/src/services/storage-device-repo.ts @@ -0,0 +1,165 @@ +import type { Device, TrustedDeviceTokenSummary, User } from '../types'; + +type GetUserByEmail = (email: string) => Promise; +type TrustedTokenKeyFn = (token: string) => Promise; + +function mapDeviceRow(row: any): Device { + return { + userId: row.user_id, + deviceIdentifier: row.device_identifier, + name: row.name, + type: row.type, + sessionStamp: row.session_stamp || '', + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export async function upsertDevice( + db: D1Database, + getDeviceById: (userId: string, deviceIdentifier: string) => Promise, + userId: string, + deviceIdentifier: string, + name: string, + type: number, + sessionStamp?: string +): Promise { + const now = new Date().toISOString(); + const effectiveSessionStamp = String(sessionStamp || '').trim() || (await getDeviceById(userId, deviceIdentifier))?.sessionStamp || ''; + await db + .prepare( + 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, 0, NULL, ?, ?) ' + + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, updated_at=excluded.updated_at' + ) + .bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, now, now) + .run(); +} + +export async function isKnownDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { + const row = await db + .prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') + .bind(userId, deviceIdentifier) + .first<{ '1': number }>(); + return !!row; +} + +export async function isKnownDeviceByEmail( + getUserByEmail: GetUserByEmail, + isKnownDeviceForUser: (userId: string, deviceIdentifier: string) => Promise, + email: string, + deviceIdentifier: string +): Promise { + const user = await getUserByEmail(email); + if (!user) return false; + return isKnownDeviceForUser(user.id, deviceIdentifier); +} + +export async function getDevicesByUserId(db: D1Database, userId: string): Promise { + const res = await db + .prepare( + 'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' + + 'FROM devices WHERE user_id = ? ORDER BY updated_at DESC' + ) + .bind(userId) + .all(); + return (res.results || []).map(mapDeviceRow); +} + +export async function getDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { + const row = await db + .prepare( + 'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' + + 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' + ) + .bind(userId, deviceIdentifier) + .first(); + return row ? mapDeviceRow(row) : null; +} + +export async function deleteDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { + const result = await db + .prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?') + .bind(userId, deviceIdentifier) + .run(); + return Number(result.meta.changes ?? 0) > 0; +} + +export async function deleteDevicesByUserId(db: D1Database, userId: string): Promise { + const result = await db.prepare('DELETE FROM devices WHERE user_id = ?').bind(userId).run(); + return Number(result.meta.changes ?? 0); +} + +export async function getTrustedDeviceTokenSummariesByUserId(db: D1Database, userId: string): Promise { + const now = Date.now(); + await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run(); + + const res = await db + .prepare( + 'SELECT device_identifier, MAX(expires_at) AS expires_at, COUNT(*) AS token_count ' + + 'FROM trusted_two_factor_device_tokens WHERE user_id = ? GROUP BY device_identifier ORDER BY expires_at DESC' + ) + .bind(userId) + .all(); + + return (res.results || []).map((row) => ({ + deviceIdentifier: row.device_identifier, + expiresAt: Number(row.expires_at || 0), + tokenCount: Number(row.token_count || 0), + })); +} + +export async function deleteTrustedTwoFactorTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { + const result = await db + .prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ? AND device_identifier = ?') + .bind(userId, deviceIdentifier) + .run(); + return Number(result.meta.changes ?? 0); +} + +export async function deleteTrustedTwoFactorTokensByUserId(db: D1Database, userId: string): Promise { + const result = await db + .prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ?') + .bind(userId) + .run(); + return Number(result.meta.changes ?? 0); +} + +export async function saveTrustedTwoFactorDeviceToken( + db: D1Database, + trustedTokenKey: TrustedTokenKeyFn, + token: string, + userId: string, + deviceIdentifier: string, + expiresAtMs: number +): Promise { + const tokenKey = await trustedTokenKey(token); + await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run(); + await db + .prepare( + 'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' + + 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at' + ) + .bind(tokenKey, userId, deviceIdentifier, expiresAtMs) + .run(); +} + +export async function getTrustedTwoFactorDeviceTokenUserId( + db: D1Database, + trustedTokenKey: TrustedTokenKeyFn, + token: string, + deviceIdentifier: string +): Promise { + const now = Date.now(); + const tokenKey = await trustedTokenKey(token); + const row = await db + .prepare('SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?') + .bind(tokenKey, deviceIdentifier) + .first<{ user_id: string; expires_at: number }>(); + + if (!row) return null; + if (row.expires_at && row.expires_at < now) { + await db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run(); + return null; + } + return row.user_id; +} diff --git a/src/services/storage-folder-repo.ts b/src/services/storage-folder-repo.ts new file mode 100644 index 0000000..0366397 --- /dev/null +++ b/src/services/storage-folder-repo.ts @@ -0,0 +1,120 @@ +import type { Cipher, Folder } from '../types'; + +function mapFolderRow(row: any): Folder { + return { + id: row.id, + userId: row.user_id, + name: row.name, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export async function getFolder(db: D1Database, id: string): Promise { + const row = await db + .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?') + .bind(id) + .first(); + if (!row) return null; + return mapFolderRow(row); +} + +export async function saveFolder(db: D1Database, folder: Folder): Promise { + await db + .prepare( + 'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at' + ) + .bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt) + .run(); +} + +export async function deleteFolder(db: D1Database, id: string, userId: string): Promise { + await db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run(); +} + +export async function clearFolderFromCiphers( + db: D1Database, + userId: string, + folderId: string, + saveCipher: (cipher: Cipher) => Promise +): Promise { + const now = new Date().toISOString(); + const res = await db + .prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?') + .bind(userId, folderId) + .all<{ data: string }>(); + + for (const row of (res.results || [])) { + let cipher: Cipher; + try { + cipher = JSON.parse(row.data) as Cipher; + } catch { + continue; + } + cipher.folderId = null; + cipher.updatedAt = now; + await saveCipher(cipher); + } +} + +export async function bulkDeleteFolders( + db: D1Database, + userId: string, + ids: string[], + sqlChunkSize: (fixedBindCount: number) => number, + saveCipher: (cipher: Cipher) => Promise, + updateRevisionDate: (userId: string) => Promise +): Promise { + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return null; + + const chunkSize = sqlChunkSize(1); + const now = new Date().toISOString(); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + const res = await db + .prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`) + .bind(userId, ...chunk) + .all<{ data: string }>(); + + for (const row of res.results || []) { + let cipher: Cipher; + try { + cipher = JSON.parse(row.data) as Cipher; + } catch { + continue; + } + cipher.folderId = null; + cipher.updatedAt = now; + await saveCipher(cipher); + } + + await db + .prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`) + .bind(userId, ...chunk) + .run(); + } + + return updateRevisionDate(userId); +} + +export async function getAllFolders(db: D1Database, userId: string): Promise { + const res = await db + .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC') + .bind(userId) + .all(); + return (res.results || []).map((row) => mapFolderRow(row)); +} + +export async function getFoldersPage(db: D1Database, userId: string, limit: number, offset: number): Promise { + const res = await db + .prepare( + 'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?' + ) + .bind(userId, limit, offset) + .all(); + return (res.results || []).map((row) => mapFolderRow(row)); +} diff --git a/src/services/storage-refresh-token-repo.ts b/src/services/storage-refresh-token-repo.ts new file mode 100644 index 0000000..9e962c2 --- /dev/null +++ b/src/services/storage-refresh-token-repo.ts @@ -0,0 +1,135 @@ +import type { RefreshTokenRecord } from '../types'; + +type RefreshTokenKeyFn = (token: string) => Promise; +type CleanupExpiredFn = (nowMs: number) => Promise; + +export async function saveRefreshToken( + db: D1Database, + refreshTokenKey: RefreshTokenKeyFn, + maybeCleanupExpiredRefreshTokens: CleanupExpiredFn, + token: string, + userId: string, + expiresAtMs: number, + deviceIdentifier?: string | null, + deviceSessionStamp?: string | null +): Promise { + await maybeCleanupExpiredRefreshTokens(Date.now()); + const tokenKey = await refreshTokenKey(token); + await db + .prepare( + 'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' + + 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp' + ) + .bind(tokenKey, userId, expiresAtMs, deviceIdentifier ?? null, deviceSessionStamp ?? null) + .run(); +} + +export async function getRefreshTokenRecord( + db: D1Database, + refreshTokenKey: RefreshTokenKeyFn, + maybeCleanupExpiredRefreshTokens: CleanupExpiredFn, + saveRefreshTokenRecord: ( + token: string, + userId: string, + expiresAtMs?: number, + deviceIdentifier?: string | null, + deviceSessionStamp?: string | null + ) => Promise, + deleteRefreshTokenRecord: (token: string) => Promise, + token: string +): Promise { + const now = Date.now(); + await maybeCleanupExpiredRefreshTokens(now); + const tokenKey = await refreshTokenKey(token); + + let row = await db + .prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') + .bind(tokenKey) + .first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); + + if (!row) { + const legacyRow = await db + .prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') + .bind(token) + .first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); + + if (legacyRow) { + if (legacyRow.expires_at && legacyRow.expires_at < now) { + await deleteRefreshTokenRecord(token); + return null; + } + await saveRefreshTokenRecord( + token, + legacyRow.user_id, + legacyRow.expires_at, + legacyRow.device_identifier ?? null, + legacyRow.device_session_stamp ?? null + ); + await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); + return { + userId: legacyRow.user_id, + expiresAt: legacyRow.expires_at, + deviceIdentifier: legacyRow.device_identifier ?? null, + deviceSessionStamp: legacyRow.device_session_stamp ?? null, + }; + } + } + + if (!row) return null; + if (row.expires_at && row.expires_at < now) { + await deleteRefreshTokenRecord(token); + return null; + } + return { + userId: row.user_id, + expiresAt: row.expires_at, + deviceIdentifier: row.device_identifier ?? null, + deviceSessionStamp: row.device_session_stamp ?? null, + }; +} + +export async function deleteRefreshToken(db: D1Database, refreshTokenKey: RefreshTokenKeyFn, token: string): Promise { + const tokenKey = await refreshTokenKey(token); + await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); + await db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run(); +} + +export async function deleteRefreshTokensByUserId(db: D1Database, userId: string): Promise { + const result = await db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run(); + return Number(result.meta.changes ?? 0); +} + +export async function deleteRefreshTokensByDevice(db: D1Database, userId: string, deviceIdentifier: string): Promise { + const result = await db + .prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND device_identifier = ?') + .bind(userId, deviceIdentifier) + .run(); + return Number(result.meta.changes ?? 0); +} + +export async function constrainRefreshTokenExpiry( + db: D1Database, + refreshTokenKey: RefreshTokenKeyFn, + token: string, + maxExpiresAtMs: number +): Promise { + const tokenKey = await refreshTokenKey(token); + + await db + .prepare( + 'UPDATE refresh_tokens ' + + 'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' + + 'WHERE token = ?' + ) + .bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey) + .run(); + + await db + .prepare( + 'UPDATE refresh_tokens ' + + 'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' + + 'WHERE token = ?' + ) + .bind(maxExpiresAtMs, maxExpiresAtMs, token) + .run(); +} diff --git a/src/services/storage-revision-repo.ts b/src/services/storage-revision-repo.ts new file mode 100644 index 0000000..223158d --- /dev/null +++ b/src/services/storage-revision-repo.ts @@ -0,0 +1,31 @@ +export async function getRevisionDate(db: D1Database, userId: string): Promise { + const row = await db + .prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?') + .bind(userId) + .first<{ revision_date: string }>(); + + if (row?.revision_date) return row.revision_date; + + const date = new Date().toISOString(); + await db + .prepare( + 'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' + + 'ON CONFLICT(user_id) DO NOTHING' + ) + .bind(userId, date) + .run(); + + return date; +} + +export async function updateRevisionDate(db: D1Database, userId: string): Promise { + const date = new Date().toISOString(); + await db + .prepare( + 'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' + + 'ON CONFLICT(user_id) DO UPDATE SET revision_date = excluded.revision_date' + ) + .bind(userId, date) + .run(); + return date; +} diff --git a/src/services/storage-send-repo.ts b/src/services/storage-send-repo.ts new file mode 100644 index 0000000..590cb0f --- /dev/null +++ b/src/services/storage-send-repo.ts @@ -0,0 +1,163 @@ +import type { Send } from '../types'; + +type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement; +type SqlChunkSize = (fixedBindCount: number) => number; +type UpdateRevisionDate = (userId: string) => Promise; + +function mapSendRow(row: any): Send { + return { + id: row.id, + userId: row.user_id, + type: row.type, + name: row.name, + notes: row.notes, + data: row.data, + key: row.key, + passwordHash: row.password_hash, + passwordSalt: row.password_salt, + passwordIterations: row.password_iterations, + authType: row.auth_type ?? 0, + emails: row.emails ?? null, + maxAccessCount: row.max_access_count, + accessCount: row.access_count, + disabled: !!row.disabled, + hideEmail: row.hide_email === null || row.hide_email === undefined ? null : !!row.hide_email, + createdAt: row.created_at, + updatedAt: row.updated_at, + expirationDate: row.expiration_date, + deletionDate: row.deletion_date, + }; +} + +export async function getSend(db: D1Database, id: string): Promise { + const row = await db + .prepare( + 'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ?' + ) + .bind(id) + .first(); + if (!row) return null; + return mapSendRow(row); +} + +export async function saveSend(db: D1Database, safeBind: SafeBind, send: Send): Promise { + const stmt = db.prepare( + 'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET ' + + 'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' + + 'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' + + 'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' + + 'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date' + ); + + await safeBind( + stmt, + send.id, + send.userId, + Number(send.type) || 0, + send.name, + send.notes, + send.data, + send.key, + send.passwordHash, + send.passwordSalt, + send.passwordIterations, + send.authType, + send.emails, + send.maxAccessCount, + send.accessCount, + send.disabled ? 1 : 0, + send.hideEmail === null || send.hideEmail === undefined ? null : send.hideEmail ? 1 : 0, + send.createdAt, + send.updatedAt, + send.expirationDate, + send.deletionDate + ).run(); +} + +export async function incrementSendAccessCount(db: D1Database, sendId: string): Promise { + const now = new Date().toISOString(); + const result = await db + .prepare( + 'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' + + 'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)' + ) + .bind(now, sendId) + .run(); + return (result.meta.changes ?? 0) > 0; +} + +export async function deleteSend(db: D1Database, id: string, userId: string): Promise { + await db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run(); +} + +export async function getSendsByIds( + db: D1Database, + sqlChunkSize: SqlChunkSize, + ids: string[], + userId: string +): Promise { + if (ids.length === 0) return []; + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return []; + const chunkSize = sqlChunkSize(1); + const out: Send[] = []; + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + const res = await db + .prepare( + `SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date + FROM sends + WHERE user_id = ? AND id IN (${placeholders})` + ) + .bind(userId, ...chunk) + .all(); + out.push(...(res.results || []).map((row) => mapSendRow(row))); + } + + return out; +} + +export async function bulkDeleteSends( + db: D1Database, + sqlChunkSize: SqlChunkSize, + updateRevisionDate: UpdateRevisionDate, + ids: string[], + userId: string +): Promise { + if (ids.length === 0) return null; + const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); + if (!uniqueIds.length) return null; + const chunkSize = sqlChunkSize(1); + + for (let i = 0; i < uniqueIds.length; i += chunkSize) { + const chunk = uniqueIds.slice(i, i + chunkSize); + const placeholders = chunk.map(() => '?').join(','); + await db.prepare(`DELETE FROM sends WHERE user_id = ? AND id IN (${placeholders})`).bind(userId, ...chunk).run(); + } + + return updateRevisionDate(userId); +} + +export async function getAllSends(db: D1Database, userId: string): Promise { + const res = await db + .prepare( + 'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC' + ) + .bind(userId) + .all(); + return (res.results || []).map((row) => mapSendRow(row)); +} + +export async function getSendsPage(db: D1Database, userId: string, limit: number, offset: number): Promise { + const res = await db + .prepare( + 'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?' + ) + .bind(userId, limit, offset) + .all(); + return (res.results || []).map((row) => mapSendRow(row)); +} diff --git a/src/services/storage-user-repo.ts b/src/services/storage-user-repo.ts new file mode 100644 index 0000000..a91bd59 --- /dev/null +++ b/src/services/storage-user-repo.ts @@ -0,0 +1,135 @@ +import type { User } from '../types'; + +type SafeBind = (stmt: D1PreparedStatement, ...values: any[]) => D1PreparedStatement; + +function mapUserRow(row: any): User { + return { + id: row.id, + email: row.email, + name: row.name, + masterPasswordHash: row.master_password_hash, + key: row.key, + privateKey: row.private_key, + publicKey: row.public_key, + kdfType: row.kdf_type, + kdfIterations: row.kdf_iterations, + kdfMemory: row.kdf_memory ?? undefined, + kdfParallelism: row.kdf_parallelism ?? undefined, + securityStamp: row.security_stamp, + role: row.role === 'admin' ? 'admin' : 'user', + status: row.status === 'banned' ? 'banned' : 'active', + totpSecret: row.totp_secret ?? null, + totpRecoveryCode: row.totp_recovery_code ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export async function getUser(db: D1Database, email: string): Promise { + const row = await db + .prepare( + 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?' + ) + .bind(email.toLowerCase()) + .first(); + if (!row) return null; + return mapUserRow(row); +} + +export async function getUserById(db: D1Database, id: string): Promise { + const row = await db + .prepare( + 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?' + ) + .bind(id) + .first(); + if (!row) return null; + return mapUserRow(row); +} + +export async function getUserCount(db: D1Database): Promise { + const row = await db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>(); + return Number(row?.count || 0); +} + +export async function getAllUsers(db: D1Database): Promise { + const res = await db + .prepare( + 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC' + ) + .all(); + return (res.results || []).map((row) => mapUserRow(row)); +} + +export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise { + const email = user.email.toLowerCase(); + const stmt = db.prepare( + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'ON CONFLICT(id) DO UPDATE SET ' + + 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at' + ); + await safeBind( + stmt, + user.id, + email, + user.name, + user.masterPasswordHash, + user.key, + user.privateKey, + user.publicKey, + user.kdfType, + user.kdfIterations, + user.kdfMemory, + user.kdfParallelism, + user.securityStamp, + user.role, + user.status, + user.totpSecret, + user.totpRecoveryCode, + user.createdAt, + user.updatedAt + ).run(); +} + +export async function createUser(db: D1Database, safeBind: SafeBind, user: User): Promise { + await saveUser(db, safeBind, user); +} + +export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise { + const email = user.email.toLowerCase(); + const stmt = db.prepare( + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' + ); + const result = await safeBind( + stmt, + user.id, + email, + user.name, + user.masterPasswordHash, + user.key, + user.privateKey, + user.publicKey, + user.kdfType, + user.kdfIterations, + user.kdfMemory, + user.kdfParallelism, + user.securityStamp, + user.role, + user.status, + user.totpSecret, + user.totpRecoveryCode, + user.createdAt, + user.updatedAt + ).run(); + + return (result.meta.changes ?? 0) > 0; +} + +export async function deleteUserById(db: D1Database, id: string): Promise { + const result = await db.prepare('DELETE FROM users WHERE id = ?').bind(id).run(); + return (result.meta.changes ?? 0) > 0; +} diff --git a/src/services/storage.ts b/src/services/storage.ts index 07b4c8a..0292025 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,6 +1,104 @@ -import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types'; +import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord } from '../types'; import { LIMITS } from '../config/limits'; import { ensureStorageSchema } from './storage-schema'; +import { + getConfigValue as getStoredConfigValue, + isRegistered as getRegisteredFlag, + setConfigValue as saveConfigValue, + setRegistered as saveRegisteredFlag, +} from './storage-config-repo'; +import { + createFirstUser as createFirstStoredUser, + createUser as createStoredUser, + deleteUserById as deleteStoredUserById, + getAllUsers as listStoredUsers, + getUser as findStoredUserByEmail, + getUserById as findStoredUserById, + getUserCount as countStoredUsers, + saveUser as saveStoredUser, +} from './storage-user-repo'; +import { + createAuditLog as createStoredAuditLog, + createInvite as createStoredInvite, + deleteAllInvites as deleteStoredInvites, + getInvite as findStoredInvite, + listInvites as listStoredInvites, + markInviteUsed as markStoredInviteUsed, + revokeInvite as revokeStoredInvite, +} from './storage-admin-repo'; +import { + bulkDeleteFolders as deleteStoredFolders, + clearFolderFromCiphers as clearStoredFolderFromCiphers, + deleteFolder as deleteStoredFolder, + getAllFolders as listStoredFolders, + getFolder as findStoredFolder, + getFoldersPage as listStoredFoldersPage, + saveFolder as saveStoredFolder, +} from './storage-folder-repo'; +import { + bulkDeleteCiphers as deleteStoredCiphers, + bulkMoveCiphers as moveStoredCiphers, + bulkRestoreCiphers as restoreStoredCiphers, + bulkSoftDeleteCiphers as softDeleteStoredCiphers, + getAllCiphers as listStoredCiphers, + getCipher as findStoredCipher, + getCiphersByIds as listStoredCiphersByIds, + getCiphersPage as listStoredCiphersPage, + saveCipher as saveStoredCipher, + deleteCipher as deleteStoredCipher, +} from './storage-cipher-repo'; +import { + addAttachmentToCipher as attachStoredAttachmentToCipher, + deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher, + deleteAttachment as deleteStoredAttachment, + getAttachment as findStoredAttachment, + getAttachmentsByCipher as listStoredAttachmentsByCipher, + getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds, + getAttachmentsByUserId as listStoredAttachmentsByUserId, + removeAttachmentFromCipher as detachStoredAttachmentFromCipher, + saveAttachment as saveStoredAttachment, + updateCipherRevisionDate as updateStoredCipherRevisionDate, +} from './storage-attachment-repo'; +import { + bulkDeleteSends as deleteStoredSends, + deleteSend as deleteStoredSend, + getAllSends as listStoredSends, + getSend as findStoredSend, + getSendsByIds as listStoredSendsByIds, + getSendsPage as listStoredSendsPage, + incrementSendAccessCount as incrementStoredSendAccessCount, + saveSend as saveStoredSend, +} from './storage-send-repo'; +import { + constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry, + deleteRefreshToken as deleteStoredRefreshToken, + deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice, + deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId, + getRefreshTokenRecord as findStoredRefreshTokenRecord, + saveRefreshToken as saveStoredRefreshToken, +} from './storage-refresh-token-repo'; +import { + deleteDevice as deleteStoredDevice, + deleteDevicesByUserId as deleteStoredDevicesByUserId, + deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice, + deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId, + getDevice as findStoredDevice, + getDevicesByUserId as listStoredDevicesByUserId, + getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries, + getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId, + isKnownDevice as getKnownStoredDevice, + isKnownDeviceByEmail as getKnownStoredDeviceByEmail, + saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, + upsertDevice as saveStoredDevice, +} from './storage-device-repo'; +import { + ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, + consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken, +} from './storage-attachment-token-repo'; +import { + getRevisionDate as getStoredRevisionDate, + updateRevisionDate as updateStoredRevisionDate, +} from './storage-revision-repo'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; @@ -81,688 +179,209 @@ export class StorageService { // --- Config / setup --- async isRegistered(): Promise { - const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>(); - return row?.value === 'true'; + return getRegisteredFlag(this.db); } async getConfigValue(key: string): Promise { - const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind(key).first<{ value: string }>(); - return typeof row?.value === 'string' ? row.value : null; + return getStoredConfigValue(this.db, key); } async setConfigValue(key: string, value: string): Promise { - await this.db - .prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') - .bind(key, value) - .run(); + await saveConfigValue(this.db, key, value); } async setRegistered(): Promise { - await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') - .bind('registered', 'true') - .run(); + await saveRegisteredFlag(this.db); } // --- Users --- - private mapUserRow(row: any): User { - return { - id: row.id, - email: row.email, - name: row.name, - masterPasswordHash: row.master_password_hash, - key: row.key, - privateKey: row.private_key, - publicKey: row.public_key, - kdfType: row.kdf_type, - kdfIterations: row.kdf_iterations, - kdfMemory: row.kdf_memory ?? undefined, - kdfParallelism: row.kdf_parallelism ?? undefined, - securityStamp: row.security_stamp, - role: row.role === 'admin' ? 'admin' : 'user', - status: row.status === 'banned' ? 'banned' : 'active', - totpSecret: row.totp_secret ?? null, - totpRecoveryCode: row.totp_recovery_code ?? null, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; - } - async getUser(email: string): Promise { - const row = await this.db - .prepare( - 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?' - ) - .bind(email.toLowerCase()) - .first(); - if (!row) return null; - return this.mapUserRow(row); + return findStoredUserByEmail(this.db, email); } async getUserById(id: string): Promise { - const row = await this.db - .prepare( - 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?' - ) - .bind(id) - .first(); - if (!row) return null; - return this.mapUserRow(row); + return findStoredUserById(this.db, id); } async getUserCount(): Promise { - const row = await this.db.prepare('SELECT COUNT(*) AS count FROM users').first<{ count: number }>(); - return Number(row?.count || 0); + return countStoredUsers(this.db); } async getAllUsers(): Promise { - const res = await this.db - .prepare( - 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC' - ) - .all(); - return (res.results || []).map(row => this.mapUserRow(row)); + return listStoredUsers(this.db); } async saveUser(user: User): Promise { - const email = user.email.toLowerCase(); - const stmt = this.db.prepare( - 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + - 'ON CONFLICT(id) DO UPDATE SET ' + - 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + - 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at' - ); - await this.safeBind(stmt, - user.id, - email, - user.name, - user.masterPasswordHash, - user.key, - user.privateKey, - user.publicKey, - user.kdfType, - user.kdfIterations, - user.kdfMemory, - user.kdfParallelism, - user.securityStamp, - user.role, - user.status, - user.totpSecret, - user.totpRecoveryCode, - user.createdAt, - user.updatedAt - ).run(); + await saveStoredUser(this.db, this.safeBind.bind(this), user); } async createUser(user: User): Promise { - await this.saveUser(user); + await createStoredUser(this.db, this.safeBind.bind(this), user); } async createFirstUser(user: User): Promise { - const email = user.email.toLowerCase(); - const stmt = this.db.prepare( - 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + - 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + - 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' - ); - const result = await this.safeBind(stmt, - user.id, - email, - user.name, - user.masterPasswordHash, - user.key, - user.privateKey, - user.publicKey, - user.kdfType, - user.kdfIterations, - user.kdfMemory, - user.kdfParallelism, - user.securityStamp, - user.role, - user.status, - user.totpSecret, - user.totpRecoveryCode, - user.createdAt, - user.updatedAt - ).run(); - - return (result.meta.changes ?? 0) > 0; + return createFirstStoredUser(this.db, this.safeBind.bind(this), user); } async deleteUserById(id: string): Promise { - const result = await this.db.prepare('DELETE FROM users WHERE id = ?').bind(id).run(); - return (result.meta.changes ?? 0) > 0; + return deleteStoredUserById(this.db, id); } async createInvite(invite: Invite): Promise { - await this.db - .prepare( - 'INSERT INTO invites(code, created_by, used_by, expires_at, status, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?, ?)' - ) - .bind(invite.code, invite.createdBy, invite.usedBy, invite.expiresAt, invite.status, invite.createdAt, invite.updatedAt) - .run(); + await createStoredInvite(this.db, invite); } async getInvite(code: string): Promise { - const row = await this.db - .prepare('SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites WHERE code = ?') - .bind(code) - .first(); - if (!row) return null; - return { - code: row.code, - createdBy: row.created_by, - usedBy: row.used_by ?? null, - expiresAt: row.expires_at, - status: row.status, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; + return findStoredInvite(this.db, code); } async listInvites(includeInactive: boolean = false): Promise { - const now = new Date().toISOString(); - const predicate = includeInactive - ? '1 = 1' - : "(status = 'active' AND expires_at > ?)"; - const query = - 'SELECT code, created_by, used_by, expires_at, status, created_at, updated_at FROM invites ' + - `WHERE ${predicate} ORDER BY created_at DESC`; - const res = includeInactive - ? await this.db.prepare(query).all() - : await this.db.prepare(query).bind(now).all(); - - return (res.results || []).map(row => ({ - code: row.code, - createdBy: row.created_by, - usedBy: row.used_by ?? null, - expiresAt: row.expires_at, - status: row.status, - createdAt: row.created_at, - updatedAt: row.updated_at, - })); + return listStoredInvites(this.db, includeInactive); } async markInviteUsed(code: string, userId: string): Promise { - const now = new Date().toISOString(); - const result = await this.db - .prepare( - "UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?" - ) - .bind(userId, now, code, now) - .run(); - return (result.meta.changes ?? 0) > 0; + return markStoredInviteUsed(this.db, code, userId); } async revokeInvite(code: string): Promise { - const now = new Date().toISOString(); - const result = await this.db - .prepare("UPDATE invites SET status = 'revoked', updated_at = ? WHERE code = ? AND status = 'active'") - .bind(now, code) - .run(); - return (result.meta.changes ?? 0) > 0; + return revokeStoredInvite(this.db, code); } async deleteAllInvites(): Promise { - const result = await this.db.prepare('DELETE FROM invites').run(); - return Number(result.meta.changes ?? 0); + return deleteStoredInvites(this.db); } async createAuditLog(log: AuditLog): Promise { - await this.db - .prepare( - 'INSERT INTO audit_logs(id, actor_user_id, action, target_type, target_id, metadata, created_at) VALUES(?, ?, ?, ?, ?, ?, ?)' - ) - .bind(log.id, log.actorUserId, log.action, log.targetType, log.targetId, log.metadata, log.createdAt) - .run(); + await createStoredAuditLog(this.db, log); } // --- Ciphers --- async getCipher(id: string): Promise { - const row = await this.db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>(); - if (!row?.data) return null; - try { - return JSON.parse(row.data) as Cipher; - } catch { - console.error('Corrupted cipher data, id:', id); - return null; - } + return findStoredCipher(this.db, id); } async saveCipher(cipher: Cipher): Promise { - const data = JSON.stringify(cipher); - const stmt = this.db.prepare( - 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + - 'ON CONFLICT(id) DO UPDATE SET ' + - 'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at' - ); - await this.safeBind(stmt, - cipher.id, - cipher.userId, - Number(cipher.type) || 1, - cipher.folderId, - cipher.name, - cipher.notes, - cipher.favorite ? 1 : 0, - data, - cipher.reprompt ?? 0, - cipher.key, - cipher.createdAt, - cipher.updatedAt, - cipher.deletedAt - ).run(); + await saveStoredCipher(this.db, this.safeBind.bind(this), cipher); } async deleteCipher(id: string, userId: string): Promise { - // hard delete - await this.db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run(); + await deleteStoredCipher(this.db, id, userId); } async bulkSoftDeleteCiphers(ids: string[], userId: string): Promise { - if (ids.length === 0) return null; - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); - if (!uniqueIds.length) return null; - - const now = new Date().toISOString(); - const patch = JSON.stringify({ - deletedAt: now, - updatedAt: now, - }); - const chunkSize = this.sqlChunkSize(4); - - for (let i = 0; i < uniqueIds.length; i += chunkSize) { - const chunk = uniqueIds.slice(i, i + chunkSize); - const placeholders = chunk.map(() => '?').join(','); - await this.db - .prepare( - `UPDATE ciphers - SET deleted_at = ?, updated_at = ?, data = json_patch(data, ?) - WHERE user_id = ? AND id IN (${placeholders})` - ) - .bind(now, now, patch, userId, ...chunk) - .run(); - } - - return this.updateRevisionDate(userId); + return softDeleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } async bulkRestoreCiphers(ids: string[], userId: string): Promise { - if (ids.length === 0) return null; - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); - if (!uniqueIds.length) return null; - - const now = new Date().toISOString(); - const patch = JSON.stringify({ - deletedAt: null, - updatedAt: now, - }); - const chunkSize = this.sqlChunkSize(3); - - for (let i = 0; i < uniqueIds.length; i += chunkSize) { - const chunk = uniqueIds.slice(i, i + chunkSize); - const placeholders = chunk.map(() => '?').join(','); - await this.db - .prepare( - `UPDATE ciphers - SET deleted_at = NULL, updated_at = ?, data = json_patch(data, ?) - WHERE user_id = ? AND id IN (${placeholders})` - ) - .bind(now, patch, userId, ...chunk) - .run(); - } - - return this.updateRevisionDate(userId); + return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } async bulkDeleteCiphers(ids: string[], userId: string): Promise { - if (ids.length === 0) return null; - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); - if (!uniqueIds.length) return null; - - const chunkSize = this.sqlChunkSize(1); - - for (let i = 0; i < uniqueIds.length; i += chunkSize) { - const chunk = uniqueIds.slice(i, i + chunkSize); - const placeholders = chunk.map(() => '?').join(','); - await this.db - .prepare(`DELETE FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`) - .bind(userId, ...chunk) - .run(); - } - - return this.updateRevisionDate(userId); + return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } async getAllCiphers(userId: string): Promise { - const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>(); - return (res.results || []).flatMap(r => { - try { return [JSON.parse(r.data) as Cipher]; } catch { return []; } - }); + return listStoredCiphers(this.db, userId); } async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise { - const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL'; - const res = await this.db - .prepare( - `SELECT data FROM ciphers - WHERE user_id = ? - ${whereDeleted} - ORDER BY updated_at DESC - LIMIT ? OFFSET ?` - ) - .bind(userId, limit, offset) - .all<{ data: string }>(); - return (res.results || []).flatMap(r => { - try { return [JSON.parse(r.data) as Cipher]; } catch { return []; } - }); + return listStoredCiphersPage(this.db, userId, includeDeleted, limit, offset); } async getCiphersByIds(ids: string[], userId: string): Promise { - if (ids.length === 0) return []; - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); - if (!uniqueIds.length) return []; - const chunkSize = this.sqlChunkSize(1); - const out: Cipher[] = []; - for (let i = 0; i < uniqueIds.length; i += chunkSize) { - const chunk = uniqueIds.slice(i, i + chunkSize); - const placeholders = chunk.map(() => '?').join(','); - const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`); - const res = await stmt.bind(userId, ...chunk).all<{ data: string }>(); - out.push( - ...(res.results || []).flatMap(r => { - try { return [JSON.parse(r.data) as Cipher]; } catch { return []; } - }) - ); - } - return out; + return listStoredCiphersByIds(this.db, this.sqlChunkSize.bind(this), ids, userId); } async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise { - if (ids.length === 0) return null; - const now = new Date().toISOString(); - const uniqueIds = Array.from(new Set(ids)); - const patch = JSON.stringify({ - folderId, - updatedAt: now, - }); - const chunkSize = this.sqlChunkSize(4); - - for (let i = 0; i < uniqueIds.length; i += chunkSize) { - const chunk = uniqueIds.slice(i, i + chunkSize); - const placeholders = chunk.map(() => '?').join(','); - - await this.db - .prepare( - `UPDATE ciphers - SET folder_id = ?, updated_at = ?, data = json_patch(data, ?) - WHERE user_id = ? AND id IN (${placeholders})` - ) - .bind(folderId, now, patch, userId, ...chunk) - .run(); - } - - return this.updateRevisionDate(userId); + return moveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, folderId, userId); } // --- Folders --- async getFolder(id: string): Promise { - const row = await this.db - .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?') - .bind(id) - .first(); - if (!row) return null; - return { - id: row.id, - userId: row.user_id, - name: row.name, - createdAt: row.created_at, - updatedAt: row.updated_at, - }; + return findStoredFolder(this.db, id); } async saveFolder(folder: Folder): Promise { - await this.db - .prepare( - 'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' + - 'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at' - ) - .bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt) - .run(); + await saveStoredFolder(this.db, folder); } async deleteFolder(id: string, userId: string): Promise { - await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run(); + await deleteStoredFolder(this.db, id, userId); } async bulkDeleteFolders(ids: string[], userId: string): Promise { - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); - if (!uniqueIds.length) return null; - - const chunkSize = this.sqlChunkSize(1); - const now = new Date().toISOString(); - - for (let i = 0; i < uniqueIds.length; i += chunkSize) { - const chunk = uniqueIds.slice(i, i + chunkSize); - const placeholders = chunk.map(() => '?').join(','); - const res = await this.db - .prepare(`SELECT data FROM ciphers WHERE user_id = ? AND folder_id IN (${placeholders})`) - .bind(userId, ...chunk) - .all<{ data: string }>(); - - for (const row of res.results || []) { - let cipher: Cipher; - try { - cipher = JSON.parse(row.data) as Cipher; - } catch { - continue; - } - cipher.folderId = null; - cipher.updatedAt = now; - await this.saveCipher(cipher); - } - - await this.db - .prepare(`DELETE FROM folders WHERE user_id = ? AND id IN (${placeholders})`) - .bind(userId, ...chunk) - .run(); - } - - return this.updateRevisionDate(userId); + return deleteStoredFolders( + this.db, + userId, + ids, + this.sqlChunkSize.bind(this), + this.saveCipher.bind(this), + this.updateRevisionDate.bind(this) + ); } // Clear folder references from all ciphers owned by the user. // Without this, deleting a folder leaves stale folderId values in cipher JSON. async clearFolderFromCiphers(userId: string, folderId: string): Promise { - const now = new Date().toISOString(); - const res = await this.db - .prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?') - .bind(userId, folderId) - .all<{ data: string }>(); - - for (const row of (res.results || [])) { - let cipher: Cipher; - try { - cipher = JSON.parse(row.data) as Cipher; - } catch { - continue; - } - cipher.folderId = null; - cipher.updatedAt = now; - await this.saveCipher(cipher); - } + await clearStoredFolderFromCiphers(this.db, userId, folderId, this.saveCipher.bind(this)); } async getAllFolders(userId: string): Promise { - const res = await this.db - .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC') - .bind(userId) - .all(); - return (res.results || []).map(r => ({ - id: r.id, - userId: r.user_id, - name: r.name, - createdAt: r.created_at, - updatedAt: r.updated_at, - })); + return listStoredFolders(this.db, userId); } async getFoldersPage(userId: string, limit: number, offset: number): Promise { - const res = await this.db - .prepare( - 'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?' - ) - .bind(userId, limit, offset) - .all(); - return (res.results || []).map(r => ({ - id: r.id, - userId: r.user_id, - name: r.name, - createdAt: r.created_at, - updatedAt: r.updated_at, - })); + return listStoredFoldersPage(this.db, userId, limit, offset); } // --- Attachments --- async getAttachment(id: string): Promise { - const row = await this.db - .prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?') - .bind(id) - .first(); - if (!row) return null; - return { - id: row.id, - cipherId: row.cipher_id, - fileName: row.file_name, - size: row.size, - sizeName: row.size_name, - key: row.key, - }; + return findStoredAttachment(this.db, id); } async saveAttachment(attachment: Attachment): Promise { - const stmt = this.db.prepare( - 'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' + - 'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key' - ); - await this.safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run(); + await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment); } async deleteAttachment(id: string): Promise { - await this.db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run(); + await deleteStoredAttachment(this.db, id); } async getAttachmentsByCipher(cipherId: string): Promise { - const res = await this.db - .prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?') - .bind(cipherId) - .all(); - return (res.results || []).map(r => ({ - id: r.id, - cipherId: r.cipher_id, - fileName: r.file_name, - size: r.size, - sizeName: r.size_name, - key: r.key, - })); + return listStoredAttachmentsByCipher(this.db, cipherId); } async getAttachmentsByCipherIds(cipherIds: string[]): Promise> { - const grouped = new Map(); - if (cipherIds.length === 0) return grouped; - - const uniqueCipherIds = [...new Set(cipherIds)]; - const chunkSize = this.sqlChunkSize(0); - - for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) { - const chunk = uniqueCipherIds.slice(i, i + chunkSize); - const placeholders = chunk.map(() => '?').join(','); - const res = await this.db - .prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`) - .bind(...chunk) - .all(); - - for (const row of (res.results || [])) { - const item: Attachment = { - id: row.id, - cipherId: row.cipher_id, - fileName: row.file_name, - size: row.size, - sizeName: row.size_name, - key: row.key, - }; - const list = grouped.get(item.cipherId); - if (list) { - list.push(item); - } else { - grouped.set(item.cipherId, [item]); - } - } - } - - return grouped; + return listStoredAttachmentsByCipherIds(this.db, this.sqlChunkSize.bind(this), cipherIds); } async getAttachmentsByUserId(userId: string): Promise> { - const grouped = new Map(); - const res = await this.db - .prepare( - `SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key - FROM attachments a - INNER JOIN ciphers c ON c.id = a.cipher_id - WHERE c.user_id = ?` - ) - .bind(userId) - .all(); - - for (const row of (res.results || [])) { - const item: Attachment = { - id: row.id, - cipherId: row.cipher_id, - fileName: row.file_name, - size: row.size, - sizeName: row.size_name, - key: row.key, - }; - const list = grouped.get(item.cipherId); - if (list) { - list.push(item); - } else { - grouped.set(item.cipherId, [item]); - } - } - - return grouped; + return listStoredAttachmentsByUserId(this.db, userId); } async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise { - // Kept for API compatibility; no-op because attachments table already links cipher_id. - // We still validate that the attachment exists and belongs to cipher. - await this.db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run(); + await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId); } async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise { - // No-op: schema uses NOT NULL cipher_id. - // Callers always delete attachment row afterwards, so this method is kept for compatibility only. - void cipherId; - void attachmentId; + await detachStoredAttachmentFromCipher(cipherId, attachmentId); } async deleteAllAttachmentsByCipher(cipherId: string): Promise { - await this.db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run(); + await deleteStoredAttachmentsByCipher(this.db, cipherId); } async updateCipherRevisionDate(cipherId: string): Promise<{ userId: string; revisionDate: string } | null> { - const cipher = await this.getCipher(cipherId); - if (!cipher) return null; - cipher.updatedAt = new Date().toISOString(); - await this.saveCipher(cipher); - const revisionDate = await this.updateRevisionDate(cipher.userId); - return { userId: cipher.userId, revisionDate }; + return updateStoredCipherRevisionDate( + this.getCipher.bind(this), + this.saveCipher.bind(this), + this.updateRevisionDate.bind(this), + cipherId + ); } // --- Refresh tokens --- @@ -775,63 +394,27 @@ export class StorageService { deviceSessionStamp?: string | null ): Promise { const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs); - await this.maybeCleanupExpiredRefreshTokens(Date.now()); - const tokenKey = await this.refreshTokenKey(token); - await this.db.prepare( - 'INSERT INTO refresh_tokens(token, user_id, expires_at, device_identifier, device_session_stamp) VALUES(?, ?, ?, ?, ?) ' + - 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at, device_identifier=excluded.device_identifier, device_session_stamp=excluded.device_session_stamp' - ) - .bind(tokenKey, userId, expiresAt, deviceIdentifier ?? null, deviceSessionStamp ?? null) - .run(); + await saveStoredRefreshToken( + this.db, + this.refreshTokenKey.bind(this), + this.maybeCleanupExpiredRefreshTokens.bind(this), + token, + userId, + expiresAt, + deviceIdentifier, + deviceSessionStamp + ); } async getRefreshTokenRecord(token: string): Promise { - const now = Date.now(); - await this.maybeCleanupExpiredRefreshTokens(now); - const tokenKey = await this.refreshTokenKey(token); - - let row = await this.db.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') - .bind(tokenKey) - .first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); - - if (!row) { - const legacyRow = await this.db.prepare('SELECT user_id, expires_at, device_identifier, device_session_stamp FROM refresh_tokens WHERE token = ?') - .bind(token) - .first<{ user_id: string; expires_at: number; device_identifier: string | null; device_session_stamp: string | null }>(); - - if (legacyRow) { - if (legacyRow.expires_at && legacyRow.expires_at < now) { - await this.deleteRefreshToken(token); - return null; - } - await this.saveRefreshToken( - token, - legacyRow.user_id, - legacyRow.expires_at, - legacyRow.device_identifier ?? null, - legacyRow.device_session_stamp ?? null - ); - await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); - return { - userId: legacyRow.user_id, - expiresAt: legacyRow.expires_at, - deviceIdentifier: legacyRow.device_identifier ?? null, - deviceSessionStamp: legacyRow.device_session_stamp ?? null, - }; - } - } - - if (!row) return null; - if (row.expires_at && row.expires_at < now) { - await this.deleteRefreshToken(token); - return null; - } - return { - userId: row.user_id, - expiresAt: row.expires_at, - deviceIdentifier: row.device_identifier ?? null, - deviceSessionStamp: row.device_session_stamp ?? null, - }; + return findStoredRefreshTokenRecord( + this.db, + this.refreshTokenKey.bind(this), + this.maybeCleanupExpiredRefreshTokens.bind(this), + this.saveRefreshToken.bind(this), + this.deleteRefreshToken.bind(this), + token + ); } async getRefreshTokenUserId(token: string): Promise { @@ -840,83 +423,17 @@ export class StorageService { } async deleteRefreshToken(token: string): Promise { - const tokenKey = await this.refreshTokenKey(token); - await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); - await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run(); + await deleteStoredRefreshToken(this.db, this.refreshTokenKey.bind(this), token); } // --- Sends --- - private mapSendRow(row: any): Send { - return { - id: row.id, - userId: row.user_id, - type: row.type, - name: row.name, - notes: row.notes, - data: row.data, - key: row.key, - passwordHash: row.password_hash, - passwordSalt: row.password_salt, - passwordIterations: row.password_iterations, - authType: row.auth_type ?? SendAuthType.None, - emails: row.emails ?? null, - maxAccessCount: row.max_access_count, - accessCount: row.access_count, - disabled: !!row.disabled, - hideEmail: row.hide_email === null || row.hide_email === undefined ? null : !!row.hide_email, - createdAt: row.created_at, - updatedAt: row.updated_at, - expirationDate: row.expiration_date, - deletionDate: row.deletion_date, - }; - } - async getSend(id: string): Promise { - const row = await this.db - .prepare( - 'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE id = ?' - ) - .bind(id) - .first(); - if (!row) return null; - return this.mapSendRow(row); + return findStoredSend(this.db, id); } async saveSend(send: Send): Promise { - const stmt = this.db.prepare( - 'INSERT INTO sends(id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + - 'ON CONFLICT(id) DO UPDATE SET ' + - 'user_id=excluded.user_id, type=excluded.type, name=excluded.name, notes=excluded.notes, data=excluded.data, key=excluded.key, ' + - 'password_hash=excluded.password_hash, password_salt=excluded.password_salt, password_iterations=excluded.password_iterations, auth_type=excluded.auth_type, emails=excluded.emails, ' + - 'max_access_count=excluded.max_access_count, access_count=excluded.access_count, disabled=excluded.disabled, hide_email=excluded.hide_email, ' + - 'updated_at=excluded.updated_at, expiration_date=excluded.expiration_date, deletion_date=excluded.deletion_date' - ); - - await this.safeBind( - stmt, - send.id, - send.userId, - Number(send.type) || 0, - send.name, - send.notes, - send.data, - send.key, - send.passwordHash, - send.passwordSalt, - send.passwordIterations, - send.authType, - send.emails, - send.maxAccessCount, - send.accessCount, - send.disabled ? 1 : 0, - send.hideEmail === null || send.hideEmail === undefined ? null : (send.hideEmail ? 1 : 0), - send.createdAt, - send.updatedAt, - send.expirationDate, - send.deletionDate - ).run(); + await saveStoredSend(this.db, this.safeBind.bind(this), send); } /** @@ -925,112 +442,42 @@ export class StorageService { * false if max_access_count has already been reached. */ async incrementSendAccessCount(sendId: string): Promise { - const now = new Date().toISOString(); - const result = await this.db - .prepare( - 'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' + - 'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)' - ) - .bind(now, sendId) - .run(); - return (result.meta.changes ?? 0) > 0; + return incrementStoredSendAccessCount(this.db, sendId); } async deleteSend(id: string, userId: string): Promise { - await this.db.prepare('DELETE FROM sends WHERE id = ? AND user_id = ?').bind(id, userId).run(); + await deleteStoredSend(this.db, id, userId); } async getSendsByIds(ids: string[], userId: string): Promise { - if (ids.length === 0) return []; - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); - if (!uniqueIds.length) return []; - const chunkSize = this.sqlChunkSize(1); - const out: Send[] = []; - for (let i = 0; i < uniqueIds.length; i += chunkSize) { - const chunk = uniqueIds.slice(i, i + chunkSize); - const placeholders = chunk.map(() => '?').join(','); - const res = await this.db - .prepare( - `SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date - FROM sends - WHERE user_id = ? AND id IN (${placeholders})` - ) - .bind(userId, ...chunk) - .all(); - out.push(...(res.results || []).map((row) => this.mapSendRow(row))); - } - return out; + return listStoredSendsByIds(this.db, this.sqlChunkSize.bind(this), ids, userId); } async bulkDeleteSends(ids: string[], userId: string): Promise { - if (ids.length === 0) return null; - const uniqueIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); - if (!uniqueIds.length) return null; - const chunkSize = this.sqlChunkSize(1); - - for (let i = 0; i < uniqueIds.length; i += chunkSize) { - const chunk = uniqueIds.slice(i, i + chunkSize); - const placeholders = chunk.map(() => '?').join(','); - await this.db - .prepare(`DELETE FROM sends WHERE user_id = ? AND id IN (${placeholders})`) - .bind(userId, ...chunk) - .run(); - } - - return this.updateRevisionDate(userId); + return deleteStoredSends(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId); } async getAllSends(userId: string): Promise { - const res = await this.db - .prepare( - 'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC' - ) - .bind(userId) - .all(); - return (res.results || []).map(row => this.mapSendRow(row)); + return listStoredSends(this.db, userId); } async getSendsPage(userId: string, limit: number, offset: number): Promise { - const res = await this.db - .prepare( - 'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?' - ) - .bind(userId, limit, offset) - .all(); - return (res.results || []).map(row => this.mapSendRow(row)); + return listStoredSendsPage(this.db, userId, limit, offset); } async deleteRefreshTokensByUserId(userId: string): Promise { - const result = await this.db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').bind(userId).run(); - return Number(result.meta.changes ?? 0); + return deleteStoredRefreshTokensByUserId(this.db, userId); } async deleteRefreshTokensByDevice(userId: string, deviceIdentifier: string): Promise { - const result = await this.db - .prepare('DELETE FROM refresh_tokens WHERE user_id = ? AND device_identifier = ?') - .bind(userId, deviceIdentifier) - .run(); - return Number(result.meta.changes ?? 0); + return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier); } // Keep a short overlap window for rotated refresh token to reduce // multi-context refresh races (e.g. browser extension popup/background). // Expiry is only tightened, never extended. async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise { - const tokenKey = await this.refreshTokenKey(token); - - await this.db.prepare( - 'UPDATE refresh_tokens ' + - 'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' + - 'WHERE token = ?' - ).bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey).run(); - - // Best-effort legacy plaintext support for older rows. - await this.db.prepare( - 'UPDATE refresh_tokens ' + - 'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' + - 'WHERE token = ?' - ).bind(maxExpiresAtMs, maxExpiresAtMs, token).run(); + await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs); } private async trustedTwoFactorTokenKey(token: string): Promise { @@ -1041,118 +488,43 @@ export class StorageService { // --- Devices --- async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number, sessionStamp?: string): Promise { - const now = new Date().toISOString(); - const effectiveSessionStamp = String(sessionStamp || '').trim() || (await this.getDevice(userId, deviceIdentifier))?.sessionStamp || ''; - await this.db.prepare( - 'INSERT INTO devices(user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at) VALUES(?, ?, ?, ?, ?, 0, NULL, ?, ?) ' + - 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, session_stamp=excluded.session_stamp, updated_at=excluded.updated_at' - ) - .bind(userId, deviceIdentifier, name, type, effectiveSessionStamp, now, now) - .run(); + await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp); } async isKnownDevice(userId: string, deviceIdentifier: string): Promise { - const row = await this.db - .prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') - .bind(userId, deviceIdentifier) - .first<{ '1': number }>(); - return !!row; + return getKnownStoredDevice(this.db, userId, deviceIdentifier); } async isKnownDeviceByEmail(email: string, deviceIdentifier: string): Promise { - const user = await this.getUser(email); - if (!user) return false; - return this.isKnownDevice(user.id, deviceIdentifier); + return getKnownStoredDeviceByEmail(this.getUser.bind(this), this.isKnownDevice.bind(this), email, deviceIdentifier); } async getDevicesByUserId(userId: string): Promise { - const res = await this.db - .prepare( - 'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' + - 'FROM devices WHERE user_id = ? ORDER BY updated_at DESC' - ) - .bind(userId) - .all(); - return (res.results || []).map(row => ({ - userId: row.user_id, - deviceIdentifier: row.device_identifier, - name: row.name, - type: row.type, - sessionStamp: row.session_stamp || '', - createdAt: row.created_at, - updatedAt: row.updated_at, - })); + return listStoredDevicesByUserId(this.db, userId); } async getDevice(userId: string, deviceIdentifier: string): Promise { - const row = await this.db - .prepare( - 'SELECT user_id, device_identifier, name, type, session_stamp, banned, banned_at, created_at, updated_at ' + - 'FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1' - ) - .bind(userId, deviceIdentifier) - .first(); - if (!row) return null; - return { - userId: row.user_id, - deviceIdentifier: row.device_identifier, - name: row.name, - type: row.type, - sessionStamp: row.session_stamp || '', - createdAt: row.created_at, - updatedAt: row.updated_at, - }; + return findStoredDevice(this.db, userId, deviceIdentifier); } async deleteDevice(userId: string, deviceIdentifier: string): Promise { - const result = await this.db - .prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?') - .bind(userId, deviceIdentifier) - .run(); - return Number(result.meta.changes ?? 0) > 0; + return deleteStoredDevice(this.db, userId, deviceIdentifier); } async deleteDevicesByUserId(userId: string): Promise { - const result = await this.db - .prepare('DELETE FROM devices WHERE user_id = ?') - .bind(userId) - .run(); - return Number(result.meta.changes ?? 0); + return deleteStoredDevicesByUserId(this.db, userId); } async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise { - const now = Date.now(); - await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run(); - - const res = await this.db - .prepare( - 'SELECT device_identifier, MAX(expires_at) AS expires_at, COUNT(*) AS token_count ' + - 'FROM trusted_two_factor_device_tokens WHERE user_id = ? GROUP BY device_identifier ORDER BY expires_at DESC' - ) - .bind(userId) - .all(); - - return (res.results || []).map(row => ({ - deviceIdentifier: row.device_identifier, - expiresAt: Number(row.expires_at || 0), - tokenCount: Number(row.token_count || 0), - })); + return listStoredTrustedTokenSummaries(this.db, userId); } async deleteTrustedTwoFactorTokensByDevice(userId: string, deviceIdentifier: string): Promise { - const result = await this.db - .prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ? AND device_identifier = ?') - .bind(userId, deviceIdentifier) - .run(); - return Number(result.meta.changes ?? 0); + return deleteStoredTrustedTokensByDevice(this.db, userId, deviceIdentifier); } async deleteTrustedTwoFactorTokensByUserId(userId: string): Promise { - const result = await this.db - .prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ?') - .bind(userId) - .run(); - return Number(result.meta.changes ?? 0); + return deleteStoredTrustedTokensByUserId(this.db, userId); } // --- Trusted 2FA remember tokens (device-bound) --- @@ -1164,76 +536,28 @@ export class StorageService { expiresAtMs?: number ): Promise { const expiresAt = expiresAtMs ?? (Date.now() + TWO_FACTOR_REMEMBER_TTL_MS); - const tokenKey = await this.trustedTwoFactorTokenKey(token); - - await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run(); - await this.db.prepare( - 'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' + - 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at' - ) - .bind(tokenKey, userId, deviceIdentifier, expiresAt) - .run(); + await saveStoredTrustedDeviceToken(this.db, this.trustedTwoFactorTokenKey.bind(this), token, userId, deviceIdentifier, expiresAt); } async getTrustedTwoFactorDeviceTokenUserId(token: string, deviceIdentifier: string): Promise { - const now = Date.now(); - const tokenKey = await this.trustedTwoFactorTokenKey(token); - const row = await this.db - .prepare( - 'SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?' - ) - .bind(tokenKey, deviceIdentifier) - .first<{ user_id: string; expires_at: number }>(); - - if (!row) return null; - if (row.expires_at && row.expires_at < now) { - await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run(); - return null; - } - return row.user_id; + return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier); } // --- Revision dates --- async getRevisionDate(userId: string): Promise { - const row = await this.db.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?') - .bind(userId) - .first<{ revision_date: string }>(); - if (row?.revision_date) return row.revision_date; - - const date = new Date().toISOString(); - await this.db - .prepare( - 'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' + - 'ON CONFLICT(user_id) DO NOTHING' - ) - .bind(userId, date) - .run(); - return date; + return getStoredRevisionDate(this.db, userId); } async updateRevisionDate(userId: string): Promise { - const date = new Date().toISOString(); - await this.db.prepare( - 'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' + - 'ON CONFLICT(user_id) DO UPDATE SET revision_date = excluded.revision_date' - ) - .bind(userId, date) - .run(); - return date; + return updateStoredRevisionDate(this.db, userId); } // --- One-time attachment download tokens --- private async ensureUsedAttachmentDownloadTokenTable(): Promise { if (StorageService.attachmentTokenTableReady) return; - - await this.db.prepare( - 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + - 'jti TEXT PRIMARY KEY, ' + - 'expires_at INTEGER NOT NULL' + - ')' - ).run(); + await ensureStoredAttachmentTokenTable(this.db); StorageService.attachmentTokenTableReady = true; } @@ -1242,24 +566,17 @@ export class StorageService { // Returns true only on first use. Reuse returns false. async consumeAttachmentDownloadToken(jti: string, expUnixSeconds: number): Promise { await this.ensureUsedAttachmentDownloadTokenTable(); - - const nowMs = Date.now(); - if ( - this.shouldRunPeriodicCleanup( - StorageService.lastAttachmentTokenCleanupAt, - StorageService.ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS - ) - ) { - await this.db.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?').bind(nowMs).run(); - StorageService.lastAttachmentTokenCleanupAt = nowMs; + const result = await consumeStoredAttachmentDownloadToken( + this.db, + this.shouldRunPeriodicCleanup.bind(this), + StorageService.lastAttachmentTokenCleanupAt, + StorageService.ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS, + jti, + expUnixSeconds + ); + if (result.cleanedUpAt !== null) { + StorageService.lastAttachmentTokenCleanupAt = result.cleanedUpAt; } - - const expiresAtMs = expUnixSeconds * 1000; - const result = await this.db.prepare( - 'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' + - 'ON CONFLICT(jti) DO NOTHING' - ).bind(jti, expiresAtMs).run(); - - return (result.meta.changes ?? 0) > 0; + return result.consumed; } } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 449654c..cf5782f 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,360 +1,63 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; -import { Link, useLocation } from 'wouter'; +import { 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 AppAuthenticatedShell from '@/components/AppAuthenticatedShell'; +import AppGlobalOverlays, { type AppConfirmState } from '@/components/AppGlobalOverlays'; import AuthViews from '@/components/AuthViews'; -import ConfirmDialog from '@/components/ConfirmDialog'; -import ToastHost from '@/components/ToastHost'; import PublicSendPage from '@/components/PublicSendPage'; import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage'; import JwtWarningPage from '@/components/JwtWarningPage'; -import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage'; import { - changeMasterPassword, createAuthedFetch, - deleteAllAuthorizedDevices, - deleteAuthorizedDevice, - deriveLoginHash, getAuthorizedDevices, getCurrentDeviceIdentifier, - getPreloginKdfConfig, - getProfile, - getSetupStatus, - getTotpRecoveryCode, getTotpStatus, - getWebConfig, - loadSession, - loginWithPassword, - registerAccount, - recoverTwoFactor, - revokeAuthorizedDeviceTrust, - revokeAllAuthorizedDeviceTrust, saveSession, - setTotp, - unlockVaultKey, - verifyMasterPassword, } from '@/lib/api/auth'; +import { listAdminInvites, listAdminUsers } from '@/lib/api/admin'; +import { buildSendShareKey, getSends } from '@/lib/api/send'; 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, - updateCipher, updateFolder, - uploadCipherAttachment, } from '@/lib/api/vault'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; -import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, hkdf } from '@/lib/crypto'; +import { base64ToBytes, decryptBw, decryptStr } from '@/lib/crypto'; import { - attachNodeWardenEncryptedAttachmentPayload, - buildAccountEncryptedBitwardenJsonString, - buildBitwardenZipBytes, - buildExportFileName, - buildNodeWardenAttachmentRecords, - buildNodeWardenPlainJsonDocument, - buildPasswordProtectedBitwardenJsonString, - buildPlainBitwardenJsonString, - encryptZipBytesWithPassword, - type ExportRequest, - type ZipAttachmentEntry, -} from '@/lib/export-formats'; + buildPublicSendUrl, + deriveSendKeyParts, + looksLikeCipherString, + parseSignalRTextFrames, + readInviteCodeFromUrl, +} from '@/lib/app-support'; +import { + bootstrapAppSession, + performPasswordLogin, + performRecoverTwoFactorLogin, + performRegistration, + performTotpLogin, + performUnlock, + type JwtUnsafeReason, + type PendingTotp, +} from '@/lib/app-auth'; +import useAccountSecurityActions from '@/hooks/useAccountSecurityActions'; +import useAdminActions from '@/hooks/useAdminActions'; +import useBackupActions from '@/hooks/useBackupActions'; +import useVaultSendActions from '@/hooks/useVaultSendActions'; +import { useToastManager } from '@/hooks/useToastManager'; import { t } from '@/lib/i18n'; -import type { AppPhase, AuthorizedDevice, Cipher, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; +import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; -interface PendingTotp { - email: string; - passwordHash: string; - masterKey: Uint8Array; -} - -type JwtUnsafeReason = 'missing' | 'default' | 'too_short'; - -const SEND_KEY_SALT = 'bitwarden-send'; -const SEND_KEY_PURPOSE = 'send'; const IMPORT_ROUTE = '/help/import-export'; const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const; -const IMPORT_ROUTE_ALIASES = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); +const IMPORT_ROUTE_ALIASES: ReadonlySet = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); const SETTINGS_HOME_ROUTE = '/settings'; const SETTINGS_ACCOUNT_ROUTE = '/settings/account'; - -function looksLikeCipherString(value: string): boolean { - return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); -} - -function asText(value: unknown): string { - if (value === null || value === undefined) return ''; - return String(value); -} - -function readInviteCodeFromUrl(): string { - if (typeof window === 'undefined') return ''; - - const searchInvite = new URLSearchParams(window.location.search || '').get('invite'); - if (searchInvite && searchInvite.trim()) return searchInvite.trim(); - - const rawHash = String(window.location.hash || ''); - const queryIndex = rawHash.indexOf('?'); - if (queryIndex >= 0) { - const hashInvite = new URLSearchParams(rawHash.slice(queryIndex + 1)).get('invite'); - if (hashInvite && hashInvite.trim()) return hashInvite.trim(); - } - - return ''; -} - -function summarizeImportResult( - ciphers: Array>, - folderCount: number, - attachmentSummary?: { - total: number; - imported: number; - failed: Array<{ fileName: string; reason: string }>; - } -): ImportResultSummary { - const typeLabel = (type: number): string => { - if (type === 1) return t('txt_login'); - if (type === 2) return t('txt_secure_note'); - if (type === 3) return t('txt_card'); - if (type === 4) return t('txt_identity'); - if (type === 5) return t('txt_ssh_key'); - return t('txt_other'); - }; - const counter = new Map(); - for (const raw of ciphers) { - const cipherType = Number(raw?.type || 1) || 1; - counter.set(cipherType, (counter.get(cipherType) || 0) + 1); - } - const order = [1, 2, 3, 4, 5]; - const seen = new Set(order); - const typeCounts = order - .filter((type) => (counter.get(type) || 0) > 0) - .map((type) => ({ label: typeLabel(type), count: counter.get(type) || 0 })); - for (const [type, count] of counter.entries()) { - if (!seen.has(type) && count > 0) typeCounts.push({ label: typeLabel(type), count }); - } - return { - totalItems: ciphers.length, - folderCount: Math.max(0, folderCount), - typeCounts, - attachmentCount: Math.max(0, attachmentSummary?.total || 0), - importedAttachmentCount: Math.max(0, attachmentSummary?.imported || 0), - failedAttachments: attachmentSummary?.failed || [], - }; -} - -function buildEmptyImportDraft(type: number): VaultDraft { - return { - type, - favorite: false, - name: '', - folderId: '', - notes: '', - reprompt: false, - loginUsername: '', - loginPassword: '', - loginTotp: '', - loginUris: [''], - loginFido2Credentials: [], - cardholderName: '', - cardNumber: '', - cardBrand: '', - cardExpMonth: '', - cardExpYear: '', - cardCode: '', - identTitle: '', - identFirstName: '', - identMiddleName: '', - identLastName: '', - identUsername: '', - identCompany: '', - identSsn: '', - identPassportNumber: '', - identLicenseNumber: '', - identEmail: '', - identPhone: '', - identAddress1: '', - identAddress2: '', - identAddress3: '', - identCity: '', - identState: '', - identPostalCode: '', - identCountry: '', - sshPrivateKey: '', - sshPublicKey: '', - sshFingerprint: '', - customFields: [], - }; -} - -function importCipherToDraft(cipher: Record, folderId: string | null): VaultDraft { - const type = Number(cipher.type || 1) || 1; - const draft = buildEmptyImportDraft(type); - draft.name = asText(cipher.name).trim() || 'Untitled'; - draft.notes = asText(cipher.notes); - draft.favorite = !!cipher.favorite; - draft.reprompt = Number(cipher.reprompt || 0) === 1; - draft.folderId = folderId || ''; - - const customFieldsRaw = Array.isArray(cipher.fields) ? cipher.fields : []; - draft.customFields = customFieldsRaw - .map((raw) => { - const field = (raw || {}) as Record; - const label = asText(field.name).trim(); - if (!label) return null; - const parsedType = Number(field.type ?? 0); - const fieldType = parsedType === 1 || parsedType === 2 || parsedType === 3 ? (parsedType as 1 | 2 | 3) : 0; - return { - type: fieldType, - label, - value: asText(field.value), - }; - }) - .filter((x): x is VaultDraft['customFields'][number] => !!x); - - if (type === 1) { - const login = (cipher.login || {}) as Record; - draft.loginUsername = asText(login.username); - draft.loginPassword = asText(login.password); - draft.loginTotp = asText(login.totp); - draft.loginFido2Credentials = Array.isArray(login.fido2Credentials) - ? login.fido2Credentials - .filter((credential): credential is Record => !!credential && typeof credential === 'object') - .map((credential) => ({ ...credential })) - : []; - const urisRaw = Array.isArray(login.uris) ? login.uris : []; - const uris = urisRaw - .map((u) => asText((u as Record)?.uri).trim()) - .filter((u) => !!u); - draft.loginUris = uris.length ? uris : ['']; - } else if (type === 3) { - const card = (cipher.card || {}) as Record; - draft.cardholderName = asText(card.cardholderName); - draft.cardNumber = asText(card.number); - draft.cardBrand = asText(card.brand); - draft.cardExpMonth = asText(card.expMonth); - draft.cardExpYear = asText(card.expYear); - draft.cardCode = asText(card.code); - } else if (type === 4) { - const identity = (cipher.identity || {}) as Record; - draft.identTitle = asText(identity.title); - draft.identFirstName = asText(identity.firstName); - draft.identMiddleName = asText(identity.middleName); - draft.identLastName = asText(identity.lastName); - draft.identUsername = asText(identity.username); - draft.identCompany = asText(identity.company); - draft.identSsn = asText(identity.ssn); - draft.identPassportNumber = asText(identity.passportNumber); - draft.identLicenseNumber = asText(identity.licenseNumber); - draft.identEmail = asText(identity.email); - draft.identPhone = asText(identity.phone); - draft.identAddress1 = asText(identity.address1); - draft.identAddress2 = asText(identity.address2); - draft.identAddress3 = asText(identity.address3); - draft.identCity = asText(identity.city); - draft.identState = asText(identity.state); - draft.identPostalCode = asText(identity.postalCode); - draft.identCountry = asText(identity.country); - } else if (type === 5) { - const sshKey = (cipher.sshKey || {}) as Record; - draft.sshPrivateKey = asText(sshKey.privateKey); - draft.sshPublicKey = asText(sshKey.publicKey); - draft.sshFingerprint = asText(sshKey.keyFingerprint ?? sshKey.fingerprint); - } - - return draft; -} - -function buildPublicSendUrl(origin: string, accessId: string, keyPart: string): string { - return `${origin}/#/send/${accessId}/${keyPart}`; -} - const SIGNALR_RECORD_SEPARATOR = String.fromCharCode(0x1e); const SIGNALR_UPDATE_TYPE_SYNC_VAULT = 5; const SIGNALR_UPDATE_TYPE_LOG_OUT = 11; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; -interface WebVaultSignalRInvocation { - type?: number; - target?: string; - arguments?: Array<{ - ContextId?: string | null; - Type?: number; - Payload?: { - UserId?: string; - Date?: string; - RevisionDate?: string; - }; - }>; -} - -function parseSignalRTextFrames(raw: string): WebVaultSignalRInvocation[] { - return raw - .split(SIGNALR_RECORD_SEPARATOR) - .map((frame) => frame.trim()) - .filter(Boolean) - .map((frame) => { - try { - return JSON.parse(frame) as WebVaultSignalRInvocation; - } catch { - return null; - } - }) - .filter((frame): frame is WebVaultSignalRInvocation => !!frame); -} - -async function deriveSendKeyParts(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) }; -} - export default function App() { const [location, navigate] = useLocation(); const [phase, setPhase] = useState('loading'); @@ -382,15 +85,7 @@ export default function App() { const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); - const [confirm, setConfirm] = useState<{ - title: string; - message: string; - danger?: boolean; - showIcon?: boolean; - onConfirm: () => void; - } | null>(null); - - const [toasts, setToasts] = useState([]); + const [confirm, setConfirm] = useState(null); const [mobileLayout, setMobileLayout] = useState(false); const [decryptedFolders, setDecryptedFolders] = useState([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); @@ -398,6 +93,7 @@ export default function App() { const migratedPlainFolderIdsRef = useRef>(new Set()); const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); + const { toasts, pushToast, removeToast } = useToastManager(); useEffect(() => { const syncInviteFromUrl = () => { @@ -446,14 +142,6 @@ export default function App() { saveSession(next); } - 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 }]); - window.setTimeout(() => { - setToasts((prev) => prev.filter((x) => x.id !== id)); - }, 4500); - } - const authedFetch = useMemo( () => createAuthedFetch( @@ -476,51 +164,31 @@ export default function App() { }, [authedFetch] ); + const backupActions = useBackupActions({ + authedFetch, + onImported: () => { + window.setTimeout(() => { + logoutNow(); + }, 200); + }, + onRestored: () => { + window.setTimeout(() => { + logoutNow(); + }, 200); + }, + }); useEffect(() => { let mounted = true; (async () => { - const [setup, config] = await Promise.all([getSetupStatus(), getWebConfig()]); + const boot = await bootstrapAppSession(); if (!mounted) return; - setSetupRegistered(setup.registered); - setDefaultKdfIterations(Number(config.defaultKdfIterations || 600000)); - const jwtUnsafeReason = config.jwtUnsafeReason || null; - if (jwtUnsafeReason) { - setJwtWarning({ - reason: jwtUnsafeReason, - minLength: Number(config.jwtSecretMinLength || 32), - }); - setSession(null); - setProfile(null); - setPhase('login'); - return; - } - setJwtWarning(null); - - const loaded = loadSession(); - if (!loaded) { - setPhase(setup.registered ? 'login' : 'register'); - return; - } - setSession(loaded); - - try { - const profileResp = await getProfile( - createAuthedFetch( - () => loaded, - (next) => { - if (!next) return; - setSession(next); - } - ) - ); - if (!mounted) return; - setProfile(profileResp); - setPhase('locked'); - } catch { - setSession(null); - setPhase(setup.registered ? 'login' : 'register'); - } + setSetupRegistered(boot.setupRegistered); + setDefaultKdfIterations(boot.defaultKdfIterations); + setJwtWarning(boot.jwtWarning); + setSession(boot.session); + setProfile(boot.profile); + setPhase(boot.phase); })(); return () => { @@ -528,18 +196,10 @@ export default function App() { }; }, []); - async function finalizeLogin(tokenAccess: string, tokenRefresh: string, email: string, masterKey: Uint8Array) { - const baseSession: SessionState = { accessToken: tokenAccess, refreshToken: tokenRefresh, email }; - const tempFetch = createAuthedFetch( - () => baseSession, - () => {} - ); - const profileResp = await getProfile(tempFetch); - const keys = await unlockVaultKey(profileResp.key, masterKey); - const nextSession = { ...baseSession, ...keys }; + async function finalizeLogin(nextSession: SessionState, nextProfile: Profile) { setSession(nextSession); - setProfile(profileResp); - await silentlyRepairBackupSettingsIfNeeded(nextSession, profileResp); + setProfile(nextProfile); + await silentlyRepairBackupSettingsIfNeeded(nextSession, nextProfile); setPendingTotp(null); setTotpCode(''); setPhase('app'); @@ -555,24 +215,18 @@ export default function App() { return; } try { - const derived = await deriveLoginHash(loginValues.email, loginValues.password, defaultKdfIterations); - const token = await loginWithPassword(loginValues.email, derived.hash, { useRememberToken: true }); - if ('access_token' in token && token.access_token) { - await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey); + const result = await performPasswordLogin(loginValues.email, loginValues.password, defaultKdfIterations); + if (result.kind === 'success') { + await finalizeLogin(result.login.session, result.login.profile); return; } - const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string }; - if (tokenError.TwoFactorProviders) { - setPendingTotp({ - email: loginValues.email.toLowerCase(), - passwordHash: derived.hash, - masterKey: derived.masterKey, - }); + if (result.kind === 'totp') { + setPendingTotp(result.pendingTotp); setTotpCode(''); setRememberDevice(true); return; } - pushToast('error', tokenError.error_description || tokenError.error || t('txt_login_failed')); + pushToast('error', result.message || t('txt_login_failed')); } catch (error) { pushToast('error', error instanceof Error ? error.message : t('txt_login_failed')); } @@ -584,16 +238,12 @@ export default function App() { pushToast('error', t('txt_please_input_totp_code')); return; } - const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, { - totpCode: totpCode.trim(), - rememberDevice, - }); - if ('access_token' in token && token.access_token) { - await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey); - return; + try { + const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice); + await finalizeLogin(login.session, login.profile); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed')); } - const tokenError = token as { error_description?: string; error?: string }; - pushToast('error', tokenError.error_description || tokenError.error || t('txt_totp_verify_failed')); } async function handleRecoverTwoFactorSubmit() { @@ -605,11 +255,9 @@ export default function App() { return; } try { - const derived = await deriveLoginHash(email, password, defaultKdfIterations); - const recovered = await recoverTwoFactor(email, derived.hash, recoveryCode); - const token = await loginWithPassword(email, derived.hash, { useRememberToken: false }); - if ('access_token' in token && token.access_token) { - await finalizeLogin(token.access_token, token.refresh_token, email, derived.masterKey); + const recovered = await performRecoverTwoFactorLogin(email, password, recoveryCode, defaultKdfIterations); + if (recovered.login) { + await finalizeLogin(recovered.login.session, recovered.login.profile); if (recovered.newRecoveryCode) { pushToast('success', t('txt_text_2fa_recovered_new_recovery_code_code', { code: recovered.newRecoveryCode })); } else { @@ -637,11 +285,11 @@ export default function App() { pushToast('error', t('txt_passwords_do_not_match')); return; } - const resp = await registerAccount({ - email: registerValues.email.toLowerCase(), - name: registerValues.name.trim(), + const resp = await performRegistration({ + email: registerValues.email, + name: registerValues.name, password: registerValues.password, - inviteCode: registerValues.inviteCode.trim(), + inviteCode: registerValues.inviteCode, fallbackIterations: defaultKdfIterations, }); if (!resp.ok) { @@ -661,9 +309,7 @@ export default function App() { return; } try { - const derived = await deriveLoginHash(profile.email || session.email, unlockPassword, defaultKdfIterations); - const keys = await unlockVaultKey(profile.key, derived.masterKey); - const nextSession = { ...session, ...keys }; + const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); setSession(nextSession); await silentlyRepairBackupSettingsIfNeeded(nextSession, profile); setUnlockPassword(''); @@ -705,6 +351,30 @@ export default function App() { }); } + function renderPassiveOverlays() { + return ( + {}} + pendingTotpOpen={false} + totpCode="" + rememberDevice={false} + onTotpCodeChange={() => {}} + onRememberDeviceChange={() => {}} + onConfirmTotp={() => {}} + onCancelTotp={() => {}} + onUseRecoveryCode={() => {}} + disableTotpOpen={false} + disableTotpPassword="" + onDisableTotpPasswordChange={() => {}} + onConfirmDisableTotp={() => {}} + onCancelDisableTotp={() => {}} + /> + ); + } + const ciphersQuery = useQuery({ queryKey: ['ciphers', session?.accessToken], queryFn: () => getCiphers(authedFetch), @@ -950,74 +620,6 @@ export default function App() { }; }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, authedFetch]); - async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) { - if (!profile) return; - if (!currentPassword || !nextPassword) { - pushToast('error', t('txt_current_new_password_is_required')); - return; - } - if (nextPassword.length < 12) { - pushToast('error', t('txt_new_password_must_be_at_least_12_chars')); - return; - } - if (nextPassword !== nextPassword2) { - pushToast('error', t('txt_new_passwords_do_not_match')); - return; - } - try { - await changeMasterPassword(authedFetch, { - email: profile.email, - currentPassword, - newPassword: nextPassword, - currentIterations: defaultKdfIterations, - profileKey: profile.key, - }); - handleLogout(); - pushToast('success', t('txt_master_password_changed_please_login_again')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_change_password_failed')); - } - } - - async function enableTotpAction(secret: string, token: string) { - if (!secret.trim() || !token.trim()) { - const error = new Error(t('txt_secret_and_code_are_required')); - pushToast('error', error.message); - throw error; - } - try { - await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() }); - pushToast('success', t('txt_totp_enabled')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_enable_totp_failed')); - throw error; - } - } - - async function disableTotpAction() { - if (!profile) return; - if (!disableTotpPassword) { - pushToast('error', t('txt_please_input_master_password')); - return; - } - try { - const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations); - await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash }); - if (profile?.id) localStorage.removeItem(`nodewarden.totp.secret.${profile.id}`); - setDisableTotpOpen(false); - setDisableTotpPassword(''); - await totpStatusQuery.refetch(); - pushToast('success', t('txt_totp_disabled')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_disable_totp_failed')); - } - } - - async function refreshVault() { - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]); - pushToast('success', t('txt_vault_synced')); - } - async function refreshVaultSilently() { await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]); } @@ -1125,734 +727,46 @@ export default function App() { }; }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey]); - async function refreshAuthorizedDevices() { + const vaultSendActions = useVaultSendActions({ + authedFetch, + importAuthedFetch, + session, + profile, + defaultKdfIterations, + encryptedCiphers: ciphersQuery.data, + encryptedFolders: foldersQuery.data, + refetchCiphers: ciphersQuery.refetch, + refetchFolders: foldersQuery.refetch, + refetchSends: sendsQuery.refetch, + onNotify: pushToast, + }); + const accountSecurityActions = useAccountSecurityActions({ + authedFetch, + profile, + defaultKdfIterations, + disableTotpPassword, + clearDisableTotpDialog: () => { + setDisableTotpOpen(false); + setDisableTotpPassword(''); + }, + onPromptLogout: handleLogout, + onLogoutNow: logoutNow, + onNotify: pushToast, + onSetConfirm: setConfirm, + refetchTotpStatus: totpStatusQuery.refetch, + refetchAuthorizedDevices: authorizedDevicesQuery.refetch, + }); + const adminActions = useAdminActions({ + authedFetch, + onNotify: pushToast, + onSetConfirm: setConfirm, + refetchUsers: usersQuery.refetch, + refetchInvites: invitesQuery.refetch, + }); + + refreshAuthorizedDevicesRef.current = async () => { await authorizedDevicesQuery.refetch(); - } - - refreshAuthorizedDevicesRef.current = refreshAuthorizedDevices; - - async function revokeDeviceTrustAction(device: AuthorizedDevice) { - await revokeAuthorizedDeviceTrust(authedFetch, device.identifier); - await authorizedDevicesQuery.refetch(); - pushToast('success', t('txt_device_authorization_revoked')); - } - - async function revokeAllDeviceTrustAction() { - await revokeAllAuthorizedDeviceTrust(authedFetch); - await authorizedDevicesQuery.refetch(); - pushToast('success', t('txt_all_device_authorizations_revoked')); - } - - async function removeDeviceAction(device: AuthorizedDevice) { - await deleteAuthorizedDevice(authedFetch, device.identifier); - if (device.identifier === getCurrentDeviceIdentifier()) { - pushToast('success', t('txt_device_removed')); - logoutNow(); - return; - } - await authorizedDevicesQuery.refetch(); - pushToast('success', t('txt_device_removed')); - } - - async function removeAllDevicesAction() { - await deleteAllAuthorizedDevices(authedFetch); - pushToast('success', t('txt_all_devices_removed')); - logoutNow(); - } - - async function createVaultItem(draft: VaultDraft, attachments: File[] = []) { - if (!session) return; - try { - const created = await createCipher(authedFetch, session, draft); - for (const file of attachments) { - await uploadCipherAttachment(authedFetch, session, created.id, file); - } - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); - pushToast('success', t('txt_item_created')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_create_item_failed')); - throw error; - } - } - - async function updateVaultItem( - cipher: Cipher, - draft: VaultDraft, - options?: { addFiles?: File[]; removeAttachmentIds?: string[] } - ) { - if (!session) return; - const addFiles = Array.isArray(options?.addFiles) ? options.addFiles : []; - const removeAttachmentIds = Array.isArray(options?.removeAttachmentIds) ? options.removeAttachmentIds : []; - try { - await updateCipher(authedFetch, session, cipher, draft); - for (const attachmentId of removeAttachmentIds) { - const id = String(attachmentId || '').trim(); - if (!id) continue; - await deleteCipherAttachment(authedFetch, cipher.id, id); - } - for (const file of addFiles) { - await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher); - } - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); - pushToast('success', t('txt_item_updated')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_update_item_failed')); - throw error; - } - } - - async function downloadVaultAttachment(cipher: Cipher, attachmentId: string) { - if (!session) return; - try { - const file = await downloadCipherAttachmentDecrypted(authedFetch, session, cipher, attachmentId); - const fileName = String(file.fileName || '').trim() || 'attachment.bin'; - const payload = new ArrayBuffer(file.bytes.byteLength); - new Uint8Array(payload).set(file.bytes); - const blob = new Blob([payload], { type: 'application/octet-stream' }); - const href = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = href; - anchor.download = fileName; - anchor.rel = 'noopener'; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(href); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_download_failed')); - throw error; - } - } - - async function deleteVaultItem(cipher: Cipher) { - try { - await deleteCipher(authedFetch, cipher.id); - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); - pushToast('success', t('txt_item_deleted')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_delete_item_failed')); - throw error; - } - } - - async function bulkDeleteVaultItems(ids: string[]) { - try { - await bulkDeleteCiphers(authedFetch, ids); - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); - pushToast('success', t('txt_deleted_selected_items')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_bulk_delete_failed')); - throw error; - } - } - - async function bulkMoveVaultItems(ids: string[], folderId: string | null) { - try { - await bulkMoveCiphers(authedFetch, ids, folderId); - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); - pushToast('success', t('txt_moved_selected_items')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_bulk_move_failed')); - throw error; - } - } - - async function getRecoveryCodeAction(masterPassword: string): Promise { - if (!profile) throw new Error(t('txt_profile_unavailable')); - const normalized = String(masterPassword || ''); - if (!normalized) throw new Error(t('txt_master_password_is_required')); - const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations); - const code = await getTotpRecoveryCode(authedFetch, derived.hash); - if (!code) throw new Error(t('txt_recovery_code_is_empty')); - return code; - } - - async function createSendItem(draft: SendDraft, autoCopyLink: boolean) { - if (!session) return; - try { - const created = await createSend(authedFetch, session, draft); - await sendsQuery.refetch(); - if (autoCopyLink && created.key && session.symEncKey && session.symMacKey) { - const keyPart = await buildSendShareKey(created.key, session.symEncKey, session.symMacKey); - const shareUrl = buildPublicSendUrl(window.location.origin, created.accessId, keyPart); - await navigator.clipboard.writeText(shareUrl); - } - pushToast('success', t('txt_send_created')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_create_send_failed')); - throw error; - } - } - - async function updateSendItem(send: Send, draft: SendDraft, autoCopyLink: boolean) { - if (!session) return; - try { - const updated = await updateSend(authedFetch, session, send, draft); - await sendsQuery.refetch(); - if (autoCopyLink && updated.key && session.symEncKey && session.symMacKey) { - const keyPart = await buildSendShareKey(updated.key, session.symEncKey, session.symMacKey); - const shareUrl = buildPublicSendUrl(window.location.origin, updated.accessId, keyPart); - await navigator.clipboard.writeText(shareUrl); - } - pushToast('success', t('txt_send_updated')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_update_send_failed')); - throw error; - } - } - - async function deleteSendItem(send: Send) { - try { - await deleteSend(authedFetch, send.id); - await sendsQuery.refetch(); - pushToast('success', t('txt_send_deleted')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_delete_send_failed')); - throw error; - } - } - - async function bulkDeleteSendItems(ids: string[]) { - try { - await bulkDeleteSends(authedFetch, ids); - await sendsQuery.refetch(); - pushToast('success', t('txt_deleted_selected_sends')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_bulk_delete_sends_failed')); - throw error; - } - } - - async function verifyMasterPasswordAction(email: string, password: string) { - const derived = await deriveLoginHash(email, password, defaultKdfIterations); - await verifyMasterPassword(authedFetch, derived.hash); - } - - async function createFolderAction(name: string) { - const folderName = name.trim(); - if (!folderName) { - pushToast('error', t('txt_folder_name_is_required')); - return; - } - try { - if (!session) throw new Error(t('txt_vault_key_unavailable')); - await createFolder(authedFetch, session, folderName); - await foldersQuery.refetch(); - pushToast('success', t('txt_folder_created')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_create_folder_failed')); - throw error; - } - } - - async function deleteFolderAction(folderId: string) { - const id = String(folderId || '').trim(); - if (!id) { - pushToast('error', t('txt_folder_not_found')); - return; - } - try { - await deleteFolder(authedFetch, id); - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); - pushToast('success', t('txt_folder_deleted')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_delete_folder_failed')); - throw error; - } - } - - async function bulkRestoreVaultItems(ids: string[]) { - try { - await bulkRestoreCiphers(authedFetch, ids); - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); - pushToast('success', t('txt_restored_selected_items')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_bulk_restore_failed')); - throw error; - } - } - - async function bulkPermanentDeleteVaultItems(ids: string[]) { - try { - await bulkPermanentDeleteCiphers(authedFetch, ids); - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); - pushToast('success', t('txt_deleted_selected_items_permanently')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_bulk_permanent_delete_failed')); - throw error; - } - } - - async function bulkDeleteFoldersAction(ids: string[]) { - const folderIds = Array.from(new Set(ids.map((id) => String(id || '').trim()).filter(Boolean))); - if (!folderIds.length) return; - try { - await bulkDeleteFolders(authedFetch, folderIds); - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); - pushToast('success', t('txt_folders_deleted')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_delete_all_folders_failed')); - throw error; - } - } - - async function uploadImportedAttachments( - attachments: ImportAttachmentFile[], - idMaps: { byIndex: Map; bySourceId: Map } - ): Promise<{ total: number; imported: number; failed: Array<{ fileName: string; reason: string }> }> { - if (!attachments.length) { - return { total: 0, imported: 0, failed: [] }; - } - if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); - - const initialCiphers = (await ciphersQuery.refetch()).data || []; - const cipherById = new Map(initialCiphers.map((cipher) => [String(cipher.id || ''), cipher])); - const failed: Array<{ fileName: string; reason: string }> = []; - let imported = 0; - - for (const attachment of attachments) { - const sourceId = String(attachment.sourceCipherId || '').trim(); - const sourceIndex = Number(attachment.sourceCipherIndex); - const byId = sourceId ? idMaps.bySourceId.get(sourceId) : null; - const byIndex = Number.isFinite(sourceIndex) ? idMaps.byIndex.get(sourceIndex) : null; - const targetCipherId = byId || byIndex || null; - if (!targetCipherId) { - failed.push({ - fileName: String(attachment.fileName || '').trim() || 'attachment.bin', - reason: t('txt_import_attachment_target_not_found'), - }); - continue; - } - - const name = String(attachment.fileName || '').trim() || 'attachment.bin'; - const fileBytes = Uint8Array.from(attachment.bytes); - const file = new File([fileBytes], name, { type: 'application/octet-stream' }); - const cipher = cipherById.get(targetCipherId) || null; - try { - await uploadCipherAttachment(importAuthedFetch, session, targetCipherId, file, cipher); - imported += 1; - } catch (error) { - failed.push({ - fileName: name, - reason: error instanceof Error ? error.message : t('txt_upload_attachment_failed'), - }); - } - } - - await ciphersQuery.refetch(); - return { - total: attachments.length, - imported, - failed, - }; - } - - function toImportedCipherMapsFromResponse( - cipherMap: ImportedCipherMapEntry[] | null - ): { byIndex: Map; bySourceId: Map } { - const byIndex = new Map(); - const bySourceId = new Map(); - for (const row of cipherMap || []) { - const idx = Number(row?.index); - const id = String(row?.id || '').trim(); - if (!Number.isFinite(idx) || !id) continue; - byIndex.set(idx, id); - const sourceId = String(row?.sourceId || '').trim(); - if (sourceId) bySourceId.set(sourceId, id); - } - return { byIndex, bySourceId }; - } - - async function handleImportAction( - payload: CiphersImportPayload, - options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, - attachments: ImportAttachmentFile[] = [] - ): Promise { - if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); - - const mode = options.folderMode || 'original'; - const targetFolderId = (options.targetFolderId || '').trim() || null; - const nextPayload: CiphersImportPayload = { - ciphers: [], - folders: [], - folderRelationships: [], - }; - if (mode === 'original') { - const folderIndexByLegacyId = new Map(); - const folderIndexByName = new Map(); - for (let i = 0; i < payload.folders.length; i++) { - const folderRaw = (payload.folders[i] || {}) as Record; - const name = String(folderRaw.name || '').trim(); - if (!name) continue; - let folderIndex = folderIndexByName.get(name); - if (folderIndex == null) { - folderIndex = nextPayload.folders.length; - nextPayload.folders.push({ name: await encryptFolderImportName(session, name) }); - folderIndexByName.set(name, folderIndex); - } - const legacyId = String(folderRaw.id || '').trim(); - if (legacyId) folderIndexByLegacyId.set(legacyId, folderIndex); - } - for (let i = 0; i < payload.ciphers.length; i++) { - const raw = (payload.ciphers[i] || {}) as Record; - let folderIndex: number | undefined; - for (const relation of payload.folderRelationships || []) { - const cipherIndex = Number(relation?.key); - const relFolderIndex = Number(relation?.value); - if (cipherIndex !== i || !Number.isFinite(relFolderIndex)) continue; - const importedFolder = payload.folders[relFolderIndex] as Record | undefined; - const importedName = String(importedFolder?.name || '').trim(); - if (importedName) folderIndex = folderIndexByName.get(importedName); - if (folderIndex != null) break; - } - if (folderIndex == null) { - const rawFolderId = String(raw.folderId || '').trim(); - if (rawFolderId) folderIndex = folderIndexByLegacyId.get(rawFolderId); - } - if (folderIndex == null) { - const rawFolderName = String(raw.folder || '').trim(); - if (rawFolderName) folderIndex = folderIndexByName.get(rawFolderName); - } - if (folderIndex != null) { - nextPayload.folderRelationships.push({ key: i, value: folderIndex }); - } - } - } - for (let i = 0; i < payload.ciphers.length; i++) { - const raw = (payload.ciphers[i] || {}) as Record; - const draft = importCipherToDraft(raw, mode === 'target' ? targetFolderId : null); - nextPayload.ciphers.push(await buildCipherImportPayload(session, draft)); - } - const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, { - returnCipherMap: attachments.length > 0, - }); - await Promise.all([foldersQuery.refetch(), ciphersQuery.refetch()]); - const attachmentSummary = attachments.length - ? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap)) - : undefined; - return summarizeImportResult(payload.ciphers, mode === 'original' ? nextPayload.folders.length : 0, attachmentSummary); - } - - async function handleImportEncryptedRawAction( - payload: CiphersImportPayload, - options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null }, - attachments: ImportAttachmentFile[] = [] - ): Promise { - const mode = options.folderMode || 'original'; - const targetFolderId = (options.targetFolderId || '').trim() || null; - const nextPayload: CiphersImportPayload = { - ciphers: payload.ciphers.map((raw) => ({ ...(raw as Record) })), - folders: mode === 'original' ? payload.folders : [], - folderRelationships: mode === 'original' ? payload.folderRelationships : [], - }; - if (mode === 'none') { - for (const raw of nextPayload.ciphers) (raw as Record).folderId = null; - } else if (mode === 'target' && targetFolderId) { - for (const raw of nextPayload.ciphers) (raw as Record).folderId = targetFolderId; - } - - const importedCipherMap = await importCiphers(importAuthedFetch, nextPayload, { - returnCipherMap: attachments.length > 0, - }); - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch()]); - const attachmentSummary = attachments.length - ? await uploadImportedAttachments(attachments, toImportedCipherMapsFromResponse(importedCipherMap)) - : undefined; - return summarizeImportResult( - nextPayload.ciphers, - mode === 'original' ? nextPayload.folders.length : 0, - attachmentSummary - ); - } - - async function handleExportAction(request: ExportRequest) { - if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); - const masterPassword = String(request.masterPassword || '').trim(); - if (!masterPassword) throw new Error(t('txt_master_password_is_required')); - const email = String(profile?.email || session.email || '').trim().toLowerCase(); - if (!email) throw new Error(t('txt_profile_unavailable')); - const verifyDerived = await deriveLoginHash(email, masterPassword, defaultKdfIterations); - await verifyMasterPassword(authedFetch, verifyDerived.hash); - - const rawFolders = foldersQuery.data || []; - const rawCiphers = ciphersQuery.data || []; - if (!rawFolders || !rawCiphers) throw new Error(t('txt_vault_not_ready')); - - let plainJsonCache: string | null = null; - let plainJsonDocCache: Record | null = null; - let encryptedJsonCache: string | null = null; - let nodeWardenAttachmentsCache: ReturnType | null = null; - const getPlainJson = async () => { - if (!plainJsonCache) { - plainJsonCache = await buildPlainBitwardenJsonString({ - folders: rawFolders, - ciphers: rawCiphers, - userEncB64: session.symEncKey!, - userMacB64: session.symMacKey!, - }); - } - return plainJsonCache; - }; - const getPlainJsonDoc = async () => { - if (!plainJsonDocCache) { - plainJsonDocCache = JSON.parse(await getPlainJson()) as Record; - } - return plainJsonDocCache; - }; - const getEncryptedJson = async () => { - if (!encryptedJsonCache) { - encryptedJsonCache = await buildAccountEncryptedBitwardenJsonString({ - folders: rawFolders, - ciphers: rawCiphers, - userEncB64: session.symEncKey!, - userMacB64: session.symMacKey!, - }); - } - return encryptedJsonCache; - }; - - const zipAttachments = async (): Promise => { - const userEnc = base64ToBytes(session.symEncKey!); - const userMac = base64ToBytes(session.symMacKey!); - const out: ZipAttachmentEntry[] = []; - const activeCiphers = rawCiphers.filter((cipher) => !cipher.deletedDate && !(cipher as { organizationId?: unknown }).organizationId); - - for (const cipher of activeCiphers) { - const cipherId = String(cipher.id || '').trim(); - if (!cipherId) continue; - const attachments = Array.isArray(cipher.attachments) ? cipher.attachments : []; - if (!attachments.length) continue; - - let itemEnc = userEnc; - let itemMac = userMac; - const itemKey = String(cipher.key || '').trim(); - if (itemKey && looksLikeCipherString(itemKey)) { - try { - const rawItemKey = await decryptBw(itemKey, userEnc, userMac); - if (rawItemKey.length >= 64) { - itemEnc = rawItemKey.slice(0, 32); - itemMac = rawItemKey.slice(32, 64); - } - } catch { - // fallback to user key - } - } - - for (const attachment of attachments) { - const attachmentId = String(attachment?.id || '').trim(); - if (!attachmentId) continue; - const info = await getAttachmentDownloadInfo(authedFetch, cipherId, attachmentId); - const fileResp = await fetch(info.url, { cache: 'no-store' }); - if (!fileResp.ok) throw new Error(`Failed to download attachment ${attachmentId}`); - const encryptedBytes = new Uint8Array(await fileResp.arrayBuffer()); - - let fileEnc = itemEnc; - let fileMac = itemMac; - const attachmentKeyCipher = String(info.key || attachment?.key || '').trim(); - if (attachmentKeyCipher && looksLikeCipherString(attachmentKeyCipher)) { - try { - const rawAttachmentKey = await decryptBw(attachmentKeyCipher, itemEnc, itemMac); - if (rawAttachmentKey.length >= 64) { - fileEnc = rawAttachmentKey.slice(0, 32); - fileMac = rawAttachmentKey.slice(32, 64); - } - } catch { - // fallback to item key - } - } - - const plainBytes = await decryptBwFileData(encryptedBytes, fileEnc, fileMac); - - const fileNameRaw = String(info.fileName || attachment?.fileName || '').trim(); - let fileName = fileNameRaw || `attachment-${attachmentId}`; - if (fileNameRaw && looksLikeCipherString(fileNameRaw)) { - try { - fileName = (await decryptStr(fileNameRaw, itemEnc, itemMac)) || fileName; - } catch { - // fallback to raw encrypted name - } - } - - out.push({ - cipherId, - fileName, - bytes: plainBytes, - }); - } - } - return out; - }; - - const getNodeWardenAttachmentRecords = async () => { - if (nodeWardenAttachmentsCache) return nodeWardenAttachmentsCache; - const [doc, attachments] = await Promise.all([getPlainJsonDoc(), zipAttachments()]); - const cipherIndexById = new Map(); - const items = Array.isArray(doc.items) ? (doc.items as Array>) : []; - for (let i = 0; i < items.length; i++) { - const id = String(items[i]?.id || '').trim(); - if (id) cipherIndexById.set(id, i); - } - nodeWardenAttachmentsCache = buildNodeWardenAttachmentRecords(attachments, cipherIndexById); - return nodeWardenAttachmentsCache; - }; - - const format = request.format; - if (format === 'bitwarden_json') { - const bytes = new TextEncoder().encode(await getPlainJson()); - return { - fileName: buildExportFileName(format), - mimeType: 'application/json', - bytes, - }; - } - - if (format === 'bitwarden_encrypted_json') { - if (request.encryptedJsonMode === 'password') { - const plainJson = await getPlainJson(); - const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations); - const encrypted = await buildPasswordProtectedBitwardenJsonString({ - plaintextJson: plainJson, - password: String(request.filePassword || ''), - kdf, - }); - return { - fileName: buildExportFileName(format), - mimeType: 'application/json', - bytes: new TextEncoder().encode(encrypted), - }; - } - const bytes = new TextEncoder().encode(await getEncryptedJson()); - return { - fileName: buildExportFileName(format), - mimeType: 'application/json', - bytes, - }; - } - - if (format === 'nodewarden_json') { - const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]); - const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments); - return { - fileName: buildExportFileName(format), - mimeType: 'application/json', - bytes: new TextEncoder().encode(JSON.stringify(nodeWardenDoc, null, 2)), - }; - } - - if (format === 'nodewarden_encrypted_json') { - if (request.encryptedJsonMode === 'password') { - const [plainDoc, attachments] = await Promise.all([getPlainJsonDoc(), getNodeWardenAttachmentRecords()]); - const nodeWardenDoc = buildNodeWardenPlainJsonDocument(plainDoc, attachments); - const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations); - const encrypted = await buildPasswordProtectedBitwardenJsonString({ - plaintextJson: JSON.stringify(nodeWardenDoc, null, 2), - password: String(request.filePassword || ''), - kdf, - }); - return { - fileName: buildExportFileName(format), - mimeType: 'application/json', - bytes: new TextEncoder().encode(encrypted), - }; - } - - const [encryptedJson, attachments] = await Promise.all([getEncryptedJson(), getNodeWardenAttachmentRecords()]); - const withAttachments = await attachNodeWardenEncryptedAttachmentPayload( - encryptedJson, - attachments, - session.symEncKey!, - session.symMacKey! - ); - return { - fileName: buildExportFileName(format), - mimeType: 'application/json', - bytes: new TextEncoder().encode(withAttachments), - }; - } - - if (format === 'bitwarden_json_zip' || format === 'bitwarden_encrypted_json_zip') { - let dataJson = await getPlainJson(); - if (format === 'bitwarden_encrypted_json_zip') { - if (request.encryptedJsonMode === 'password') { - const kdf = await getPreloginKdfConfig(profile?.email || session.email, defaultKdfIterations); - dataJson = await buildPasswordProtectedBitwardenJsonString({ - plaintextJson: await getPlainJson(), - password: String(request.filePassword || ''), - kdf, - }); - } else { - dataJson = await getEncryptedJson(); - } - } - const attachments = await zipAttachments(); - const zipBytes = buildBitwardenZipBytes(dataJson, attachments); - const encryptedZip = await encryptZipBytesWithPassword(zipBytes, String(request.zipPassword || '')); - return { - fileName: buildExportFileName(format, encryptedZip.encrypted), - mimeType: 'application/zip', - bytes: encryptedZip.bytes, - }; - } - - throw new Error(t('txt_unsupported_export_format')); - } - - function downloadBytesAsFile(bytes: Uint8Array, fileName: string, mimeType: string) { - const payload = bytes.slice(); - const blob = new Blob([payload], { type: mimeType || 'application/octet-stream' }); - const objectUrl = URL.createObjectURL(blob); - const anchor = document.createElement('a'); - anchor.href = objectUrl; - anchor.download = fileName || 'download.bin'; - document.body.appendChild(anchor); - anchor.click(); - anchor.remove(); - window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); - } - - async function handleBackupExportAction() { - const payload = await exportAdminBackup(authedFetch); - downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); - } - - async function handleBackupImportAction(file: File, replaceExisting: boolean = false) { - await importAdminBackup(authedFetch, file, replaceExisting); - window.setTimeout(() => { - logoutNow(); - }, 200); - } - - async function handleLoadBackupSettingsAction() { - return getAdminBackupSettings(authedFetch); - } - - async function handleSaveBackupSettingsAction(settings: any) { - return saveAdminBackupSettings(authedFetch, settings); - } - - async function handleRunRemoteBackupAction(destinationId?: string | null) { - return runAdminBackupNow(authedFetch, destinationId); - } - - async function handleListRemoteBackupsAction(destinationId: string, path: string) { - return listRemoteBackups(authedFetch, destinationId, path); - } - - async function handleDownloadRemoteBackupAction(destinationId: string, path: string) { - const payload = await downloadRemoteBackup(authedFetch, destinationId, path); - downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); - } - - async function handleDeleteRemoteBackupAction(destinationId: string, path: string) { - await deleteRemoteBackup(authedFetch, destinationId, path); - } - - async function handleRestoreRemoteBackupAction(destinationId: string, path: string, replaceExisting: boolean = false) { - await restoreRemoteBackup(authedFetch, destinationId, path, replaceExisting); - window.setTimeout(() => { - logoutNow(); - }, 200); - } + }; const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; @@ -1909,6 +823,76 @@ export default function App() { } }, [phase, mobileLayout, location, navigate]); + const mainRoutesProps = { + profile, + session, + mobileLayout, + importRoute: IMPORT_ROUTE, + settingsHomeRoute: SETTINGS_HOME_ROUTE, + settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE, + decryptedCiphers, + decryptedFolders, + decryptedSends, + ciphersLoading: ciphersQuery.isFetching, + foldersLoading: foldersQuery.isFetching, + sendsLoading: sendsQuery.isFetching, + users: usersQuery.data || [], + invites: invitesQuery.data || [], + totpEnabled: !!totpStatusQuery.data?.enabled, + authorizedDevices: authorizedDevicesQuery.data || [], + authorizedDevicesLoading: authorizedDevicesQuery.isFetching, + onNavigate: navigate, + onLogout: handleLogout, + onNotify: pushToast, + onImport: vaultSendActions.importVault, + onImportEncryptedRaw: vaultSendActions.importEncryptedRaw, + onExport: vaultSendActions.exportVault, + onCreateVaultItem: vaultSendActions.createVaultItem, + onUpdateVaultItem: vaultSendActions.updateVaultItem, + onDeleteVaultItem: vaultSendActions.deleteVaultItem, + onBulkDeleteVaultItems: vaultSendActions.bulkDeleteVaultItems, + onBulkPermanentDeleteVaultItems: vaultSendActions.bulkPermanentDeleteVaultItems, + onBulkRestoreVaultItems: vaultSendActions.bulkRestoreVaultItems, + onBulkMoveVaultItems: vaultSendActions.bulkMoveVaultItems, + onVerifyMasterPassword: vaultSendActions.verifyMasterPassword, + onCreateFolder: vaultSendActions.createFolder, + onDeleteFolder: vaultSendActions.deleteFolder, + onBulkDeleteFolders: vaultSendActions.bulkDeleteFolders, + onDownloadVaultAttachment: vaultSendActions.downloadVaultAttachment, + onRefreshVault: vaultSendActions.refreshVault, + onCreateSend: vaultSendActions.createSend, + onUpdateSend: vaultSendActions.updateSend, + onDeleteSend: vaultSendActions.deleteSend, + onBulkDeleteSends: vaultSendActions.bulkDeleteSends, + onChangePassword: accountSecurityActions.changePassword, + onEnableTotp: async (secret: string, token: string) => { + await accountSecurityActions.enableTotp(secret, token); + await totpStatusQuery.refetch(); + }, + onOpenDisableTotp: () => setDisableTotpOpen(true), + onGetRecoveryCode: accountSecurityActions.getRecoveryCode, + onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, + onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, + onRemoveDevice: accountSecurityActions.openRemoveDevice, + onRevokeAllDeviceTrust: accountSecurityActions.openRevokeAllDeviceTrust, + onRemoveAllDevices: accountSecurityActions.openRemoveAllDevices, + onRefreshAdmin: adminActions.refreshAdmin, + onCreateInvite: adminActions.createInvite, + onDeleteAllInvites: adminActions.deleteAllInvites, + onToggleUserStatus: adminActions.toggleUserStatus, + onDeleteUser: adminActions.deleteUser, + onRevokeInvite: adminActions.revokeInvite, + onExportBackup: backupActions.exportBackup, + onImportBackup: backupActions.importBackup, + onLoadBackupSettings: backupActions.loadSettings, + onSaveBackupSettings: backupActions.saveSettings, + onRunRemoteBackup: backupActions.runRemoteBackup, + onListRemoteBackups: backupActions.listRemoteBackups, + onDownloadRemoteBackup: backupActions.downloadRemoteBackup, + onDeleteRemoteBackup: backupActions.deleteRemoteBackup, + onRestoreRemoteBackup: backupActions.restoreRemoteBackup, + }; + if (jwtWarning) { return ; } @@ -1917,7 +901,7 @@ export default function App() { return ( <> - setToasts((prev) => prev.filter((x) => x.id !== id))} /> + {renderPassiveOverlays()} ); } @@ -1934,7 +918,7 @@ export default function App() { navigate('/login'); }} /> - setToasts((prev) => prev.filter((x) => x.id !== id))} /> + {renderPassiveOverlays()} ); } @@ -1943,7 +927,7 @@ export default function App() { return ( <>
{t('txt_loading_nodewarden')}
- setToasts((prev) => prev.filter((x) => x.id !== id))} /> + {renderPassiveOverlays()} ); } @@ -1976,342 +960,77 @@ export default function App() { }} onLogout={logoutNow} /> - setToasts((prev) => prev.filter((x) => x.id !== id))} /> - - void handleTotpVerify()} - onCancel={() => { + {}} + pendingTotpOpen={!!pendingTotp} + totpCode={totpCode} + rememberDevice={rememberDevice} + onTotpCodeChange={setTotpCode} + onRememberDeviceChange={setRememberDevice} + onConfirmTotp={() => void handleTotpVerify()} + onCancelTotp={() => { setPendingTotp(null); setTotpCode(''); setRememberDevice(true); }} - afterActions={( -
-
- -
- )} - > - - - + onUseRecoveryCode={() => { + setPendingTotp(null); + setTotpCode(''); + setRememberDevice(true); + navigate('/recover-2fa'); + }} + disableTotpOpen={false} + disableTotpPassword="" + onDisableTotpPasswordChange={() => {}} + onConfirmDisableTotp={() => {}} + onCancelDisableTotp={() => {}} + /> ); } return ( <> -
-
-
-
- NodeWarden logo - NodeWarden - {currentPageTitle} -
-
-
- - {profile?.email} -
- - {showSidebarToggle && ( - - )} - - -
-
- -
- -
- { - 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} - /> -
-
- - -
-
- - confirm?.onConfirm()} - onCancel={() => setConfirm(null)} + - void disableTotpAction()} - onCancel={() => { + setConfirm(null)} + pendingTotpOpen={false} + totpCode="" + rememberDevice={false} + onTotpCodeChange={() => {}} + onRememberDeviceChange={() => {}} + onConfirmTotp={() => {}} + onCancelTotp={() => {}} + onUseRecoveryCode={() => {}} + disableTotpOpen={disableTotpOpen} + disableTotpPassword={disableTotpPassword} + onDisableTotpPasswordChange={setDisableTotpPassword} + onConfirmDisableTotp={() => void accountSecurityActions.disableTotp()} + onCancelDisableTotp={() => { setDisableTotpOpen(false); setDisableTotpPassword(''); }} - > - - - - setToasts((prev) => prev.filter((x) => x.id !== id))} /> + /> ); } diff --git a/webapp/src/components/AppAuthenticatedShell.tsx b/webapp/src/components/AppAuthenticatedShell.tsx new file mode 100644 index 0000000..b9abfed --- /dev/null +++ b/webapp/src/components/AppAuthenticatedShell.tsx @@ -0,0 +1,126 @@ +import { ArrowUpDown, Cloud, Clock3, Folder as FolderIcon, KeyRound, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact'; +import { Link } from 'wouter'; +import AppMainRoutes from '@/components/AppMainRoutes'; +import type { AppMainRoutesProps } from '@/components/AppMainRoutes'; +import { t } from '@/lib/i18n'; +import type { Profile } from '@/lib/types'; + +interface AppAuthenticatedShellProps { + profile: Profile | null; + location: string; + mobilePrimaryRoute: string; + currentPageTitle: string; + showSidebarToggle: boolean; + sidebarToggleTitle: string; + settingsAccountRoute: string; + importRoute: string; + isImportRoute: boolean; + onLock: () => void; + onLogout: () => void; + mainRoutesProps: AppMainRoutesProps; +} + +export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) { + return ( +
+
+
+
+ NodeWarden logo + NodeWarden + {props.currentPageTitle} +
+
+
+ + {props.profile?.email} +
+ + {props.showSidebarToggle && ( + + )} + + +
+
+ +
+ +
+ +
+
+ + +
+
+ ); +} diff --git a/webapp/src/components/AppGlobalOverlays.tsx b/webapp/src/components/AppGlobalOverlays.tsx new file mode 100644 index 0000000..544d8c4 --- /dev/null +++ b/webapp/src/components/AppGlobalOverlays.tsx @@ -0,0 +1,95 @@ +import ConfirmDialog from '@/components/ConfirmDialog'; +import ToastHost from '@/components/ToastHost'; +import { t } from '@/lib/i18n'; +import type { ToastMessage } from '@/lib/types'; + +export interface AppConfirmState { + title: string; + message: string; + danger?: boolean; + showIcon?: boolean; + onConfirm: () => void; +} + +interface AppGlobalOverlaysProps { + toasts: ToastMessage[]; + onCloseToast: (id: string) => void; + confirm: AppConfirmState | null; + onCancelConfirm: () => void; + pendingTotpOpen: boolean; + totpCode: string; + rememberDevice: boolean; + onTotpCodeChange: (value: string) => void; + onRememberDeviceChange: (checked: boolean) => void; + onConfirmTotp: () => void; + onCancelTotp: () => void; + onUseRecoveryCode: () => void; + disableTotpOpen: boolean; + disableTotpPassword: string; + onDisableTotpPasswordChange: (value: string) => void; + onConfirmDisableTotp: () => void; + onCancelDisableTotp: () => void; +} + +export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { + return ( + <> + props.confirm?.onConfirm()} + onCancel={props.onCancelConfirm} + /> + + +
+ +
+ )} + > + + +
+ + + + + + + + ); +} diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index d3d5865..f617ed9 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -2,15 +2,15 @@ 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 SendsPage = lazy(() => import('@/components/SendsPage')); +const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage')); +const VaultPage = lazy(() => import('@/components/VaultPage')); const SettingsPage = lazy(() => import('@/components/SettingsPage')); const SecurityDevicesPage = lazy(() => import('@/components/SecurityDevicesPage')); const AdminPage = lazy(() => import('@/components/AdminPage')); @@ -21,7 +21,7 @@ function RouteContentFallback() { return
{t('txt_loading_nodewarden')}
; } -interface AppMainRoutesProps { +export interface AppMainRoutesProps { profile: Profile | null; session: SessionState | null; mobileLayout: boolean; @@ -128,41 +128,47 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { return ( - + }> + + - + }> + + - + }> + + {props.profile && ( diff --git a/webapp/src/components/ImportPage.tsx b/webapp/src/components/ImportPage.tsx index 35b67ed..5855cd5 100644 --- a/webapp/src/components/ImportPage.tsx +++ b/webapp/src/components/ImportPage.tsx @@ -13,14 +13,14 @@ import { type ExportRequest, } from '@/lib/export-formats'; import { - getFileAcceptBySource, - IMPORT_SOURCES, - type BitwardenJsonInput, - type ImportSourceId, - normalizeBitwardenEncryptedAccountImport, - normalizeBitwardenImport, parseImportPayloadBySource, } from '@/lib/import-formats'; +import { getFileAcceptBySource, IMPORT_SOURCES, type ImportSourceId } from '@/lib/import-format-sources'; +import { + type BitwardenJsonInput, + normalizeBitwardenEncryptedAccountImport, + normalizeBitwardenImport, +} from '@/lib/import-formats-bitwarden'; import { base64ToBytes, decryptStr, hkdfExpand, pbkdf2 } from '@/lib/crypto'; import { t } from '@/lib/i18n'; import type { Folder } from '@/lib/types'; diff --git a/webapp/src/components/VaultPage.tsx b/webapp/src/components/VaultPage.tsx index 87752cf..eff3613 100644 --- a/webapp/src/components/VaultPage.tsx +++ b/webapp/src/components/VaultPage.tsx @@ -1,39 +1,39 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; -import ConfirmDialog from '@/components/ConfirmDialog'; +import VaultDialogs from '@/components/vault/VaultDialogs'; +import VaultDetailView from '@/components/vault/VaultDetailView'; +import VaultEditor from '@/components/vault/VaultEditor'; +import VaultListPanel from '@/components/vault/VaultListPanel'; +import VaultSidebar from '@/components/vault/VaultSidebar'; +import { + CREATE_TYPE_OPTIONS, + MOBILE_LAYOUT_QUERY, + TOTP_PERIOD_SECONDS, + TOTP_RING_CIRCUMFERENCE, + VAULT_LIST_OVERSCAN, + VAULT_LIST_ROW_HEIGHT, + VAULT_SORT_STORAGE_KEY, + cipherTypeKey, + cipherTypeLabel, + copyToClipboard, + createEmptyDraft, + creationTimeValue, + draftFromCipher, + firstCipherUri, + firstPasskeyCreationTime, + formatAttachmentSize, + formatHistoryTime, + formatTotp, + maskSecret, + openUri, + parseFieldType, + sortTimeValue, + type SidebarFilter, + type VaultSortMode, +} from '@/components/vault/vault-page-helpers'; import { calcTotpNow } from '@/lib/crypto'; import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh'; -import { - ArrowUpDown, - Check, - CheckCheck, - ChevronLeft, - Clipboard, - CreditCard, - Download, - Eye, - EyeOff, - ExternalLink, - FileKey2, - Folder as FolderIcon, - FolderPlus, - FolderX, - FolderInput, - Globe, - KeyRound, - LayoutGrid, - Paperclip, - Pencil, - Plus, - RefreshCw, - ShieldUser, - Star, - StarOff, - StickyNote, - Trash2, - Upload, - X, -} from 'lucide-preact'; -import type { Cipher, CipherAttachment, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; +import { ChevronLeft, X } from 'lucide-preact'; +import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types'; import { t } from '@/lib/i18n'; interface VaultPageProps { @@ -57,317 +57,6 @@ interface VaultPageProps { onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise; } -type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; -type VaultSortMode = 'edited' | 'created' | 'name'; -type SidebarFilter = - | { kind: 'all' } - | { kind: 'favorite' } - | { kind: 'trash' } - | { kind: 'type'; value: TypeFilter } - | { kind: 'folder'; folderId: string | null }; - -interface TypeOption { - type: number; - label: string; -} - -const CREATE_TYPE_OPTIONS: TypeOption[] = [ - { type: 1, label: t('txt_login') }, - { type: 3, label: t('txt_card') }, - { type: 4, label: t('txt_identity') }, - { type: 2, label: t('txt_note') }, - { type: 5, label: t('txt_ssh_key') }, -]; - -const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1'; -const MOBILE_LAYOUT_QUERY = '(max-width: 900px)'; -const VAULT_LIST_ROW_HEIGHT = 66; -const VAULT_LIST_OVERSCAN = 10; -const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [ - { value: 'edited', label: t('txt_sort_last_edited') }, - { value: 'created', label: t('txt_sort_created') }, - { value: 'name', label: t('txt_sort_name') }, -]; - -function CreateTypeIcon({ type }: { type: number }) { - if (type === 1) return ; - if (type === 3) return ; - if (type === 4) return ; - if (type === 2) return ; - if (type === 5) return ; - return ; -} - -const FIELD_TYPE_OPTIONS: Array<{ value: CustomFieldType; label: string }> = [ - { value: 0, label: t('txt_text') }, - { value: 1, label: t('txt_hidden') }, - { value: 2, label: t('txt_boolean') }, -]; - -function cipherTypeKey(type: number): TypeFilter { - if (type === 1) return 'login'; - if (type === 3) return 'card'; - if (type === 4) return 'identity'; - if (type === 2) return 'note'; - return 'ssh'; -} - -function cipherTypeLabel(type: number): string { - if (type === 1) return t('txt_login'); - if (type === 3) return t('txt_card'); - if (type === 4) return t('txt_identity'); - if (type === 2) return t('txt_secure_note'); - if (type === 5) return t('txt_ssh_key'); - return t('txt_item'); -} - -function TypeIcon({ type }: { type: number }) { - if (type === 1) return ; - if (type === 3) return ; - if (type === 4) return ; - if (type === 2) return ; - if (type === 5) return ; - return ; -} - -function parseFieldType(value: number | string | null | undefined): CustomFieldType { - if (value === 1 || value === 2 || value === 3) return value; - if (value === '1' || String(value).toLowerCase() === 'hidden') return 1; - if (value === '2' || String(value).toLowerCase() === 'boolean') return 2; - if (value === '3' || String(value).toLowerCase() === 'linked') return 3; - return 0; -} - -function toBooleanFieldValue(raw: string): boolean { - const v = String(raw || '').trim().toLowerCase(); - return v === '1' || v === 'true' || v === 'yes' || v === 'on'; -} - -function firstCipherUri(cipher: Cipher): string { - const uris = cipher.login?.uris || []; - for (const uri of uris) { - const raw = uri.decUri || uri.uri || ''; - if (raw.trim()) return raw.trim(); - } - return ''; -} - -function hostFromUri(uri: string): string { - if (!uri.trim()) return ''; - try { - const normalized = /^https?:\/\//i.test(uri) ? uri : `https://${uri}`; - return new URL(normalized).hostname || ''; - } catch { - return ''; - } -} - -function createEmptyDraft(type: number): VaultDraft { - return { - type, - favorite: false, - name: '', - folderId: '', - notes: '', - reprompt: false, - loginUsername: '', - loginPassword: '', - loginTotp: '', - loginUris: [''], - loginFido2Credentials: [], - cardholderName: '', - cardNumber: '', - cardBrand: '', - cardExpMonth: '', - cardExpYear: '', - cardCode: '', - identTitle: '', - identFirstName: '', - identMiddleName: '', - identLastName: '', - identUsername: '', - identCompany: '', - identSsn: '', - identPassportNumber: '', - identLicenseNumber: '', - identEmail: '', - identPhone: '', - identAddress1: '', - identAddress2: '', - identAddress3: '', - identCity: '', - identState: '', - identPostalCode: '', - identCountry: '', - sshPrivateKey: '', - sshPublicKey: '', - sshFingerprint: '', - customFields: [], - }; -} - -function draftFromCipher(cipher: Cipher): VaultDraft { - const draft = createEmptyDraft(Number(cipher.type || 1)); - draft.id = cipher.id; - draft.favorite = !!cipher.favorite; - draft.name = cipher.decName || ''; - draft.folderId = cipher.folderId || ''; - draft.notes = cipher.decNotes || ''; - draft.reprompt = Number(cipher.reprompt || 0) === 1; - - if (cipher.login) { - draft.loginUsername = cipher.login.decUsername || ''; - draft.loginPassword = cipher.login.decPassword || ''; - draft.loginTotp = cipher.login.decTotp || ''; - draft.loginUris = (cipher.login.uris || []).map((x) => x.decUri || x.uri || ''); - draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials) - ? cipher.login.fido2Credentials.map((credential) => ({ ...credential })) - : []; - if (!draft.loginUris.length) draft.loginUris = ['']; - } - if (cipher.card) { - draft.cardholderName = cipher.card.decCardholderName || ''; - draft.cardNumber = cipher.card.decNumber || ''; - draft.cardBrand = cipher.card.decBrand || ''; - draft.cardExpMonth = cipher.card.decExpMonth || ''; - draft.cardExpYear = cipher.card.decExpYear || ''; - draft.cardCode = cipher.card.decCode || ''; - } - if (cipher.identity) { - draft.identTitle = cipher.identity.decTitle || ''; - draft.identFirstName = cipher.identity.decFirstName || ''; - draft.identMiddleName = cipher.identity.decMiddleName || ''; - draft.identLastName = cipher.identity.decLastName || ''; - draft.identUsername = cipher.identity.decUsername || ''; - draft.identCompany = cipher.identity.decCompany || ''; - draft.identSsn = cipher.identity.decSsn || ''; - draft.identPassportNumber = cipher.identity.decPassportNumber || ''; - draft.identLicenseNumber = cipher.identity.decLicenseNumber || ''; - draft.identEmail = cipher.identity.decEmail || ''; - draft.identPhone = cipher.identity.decPhone || ''; - draft.identAddress1 = cipher.identity.decAddress1 || ''; - draft.identAddress2 = cipher.identity.decAddress2 || ''; - draft.identAddress3 = cipher.identity.decAddress3 || ''; - draft.identCity = cipher.identity.decCity || ''; - draft.identState = cipher.identity.decState || ''; - draft.identPostalCode = cipher.identity.decPostalCode || ''; - draft.identCountry = cipher.identity.decCountry || ''; - } - if (cipher.sshKey) { - draft.sshPrivateKey = cipher.sshKey.decPrivateKey || ''; - draft.sshPublicKey = cipher.sshKey.decPublicKey || ''; - draft.sshFingerprint = cipher.sshKey.decFingerprint || ''; - } - draft.customFields = (cipher.fields || []).map((field) => ({ - type: parseFieldType(field.type), - label: field.decName || '', - value: field.decValue || '', - })); - - return draft; -} - -function maskSecret(value: string): string { - if (!value) return ''; - return '*'.repeat(Math.max(8, Math.min(24, value.length))); -} - -function formatTotp(code: string): string { - if (!code || code.length < 6) return code; - return `${code.slice(0, 3)} ${code.slice(3, 6)}`; -} - -function formatHistoryTime(value: string | null | undefined): string { - if (!value) return t('txt_dash'); - const date = new Date(value); - if (!Number.isFinite(date.getTime())) return value; - return date.toLocaleString(); -} - -function parseAttachmentSizeBytes(attachment: CipherAttachment): number { - const raw = attachment?.size; - if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw; - const parsed = Number(raw); - if (Number.isFinite(parsed) && parsed >= 0) return parsed; - return 0; -} - -function formatAttachmentSize(attachment: CipherAttachment): string { - const sizeName = String(attachment?.sizeName || '').trim(); - if (sizeName) return sizeName; - const bytes = parseAttachmentSizeBytes(attachment); - if (bytes <= 0) return '0 B'; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; -} - -function sortTimeValue(cipher: Cipher): number { - const candidates = [cipher.revisionDate, cipher.creationDate]; - for (const value of candidates) { - const time = new Date(String(value || '')).getTime(); - if (Number.isFinite(time)) return time; - } - return 0; -} - -function creationTimeValue(cipher: Cipher): number { - const time = new Date(String(cipher.creationDate || '')).getTime(); - return Number.isFinite(time) ? time : 0; -} - -function firstPasskeyCreationTime(cipher: Cipher | null): string | null { - const credentials = cipher?.login?.fido2Credentials; - if (!Array.isArray(credentials) || credentials.length === 0) return null; - for (const credential of credentials) { - const raw = String(credential?.creationDate || '').trim(); - if (raw) return raw; - } - return null; -} - -const TOTP_PERIOD_SECONDS = 30; -const TOTP_RING_RADIUS = 14; -const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS; -const failedIconHosts = new Set(); - -function VaultListIcon({ cipher }: { cipher: Cipher }) { - const uri = firstCipherUri(cipher); - const host = hostFromUri(uri); - const [errored, setErrored] = useState(() => (host ? failedIconHosts.has(host) : false)); - if (host && !errored) { - return ( - { - failedIconHosts.add(host); - setErrored(true); - }} - /> - ); - } - return ( - - - - ); -} - -function copyToClipboard(value: string): void { - if (!value.trim()) return; - void navigator.clipboard.writeText(value); -} - -function openUri(raw: string): void { - const value = raw.trim(); - if (!value) return; - const url = /^https?:\/\//i.test(value) ? value : `https://${value}`; - window.open(url, '_blank', 'noopener'); -} export default function VaultPage(props: VaultPageProps) { const [searchInput, setSearchInput] = useState(''); @@ -991,260 +680,81 @@ function folderName(id: string | null | undefined): string { <>
{isMobileLayout && mobileSidebarOpen &&
setMobileSidebarOpen(false)} />} - - -
-
- setSearchInput((e.currentTarget as HTMLInputElement).value)} - onCompositionStart={() => setSearchComposing(true)} - onCompositionEnd={(e) => { - setSearchComposing(false); - setSearchInput((e.currentTarget as HTMLInputElement).value); - }} - /> -
- - {sortMenuOpen && ( -
- {VAULT_SORT_OPTIONS.map((option) => ( - - ))} -
- )} -
-
- {t('txt_total_items_count', { count: totalCipherCount })} -
- -
-
- - -
- - {createMenuOpen && ( -
- {CREATE_TYPE_OPTIONS.map((option) => ( - - ))} -
- )} -
- {selectedCount > 0 && sidebarFilter.kind === 'trash' && ( - - )} - {selectedCount > 0 && sidebarFilter.kind !== 'trash' && ( - - )} - {selectedCount > 0 && ( - - )} -
- -
setListScrollTop((event.currentTarget as HTMLDivElement).scrollTop)} - > - {!!filteredCiphers.length && ( -
- {visibleCiphers.map((cipher) => ( -
- - setSelectedMap((prev) => ({ - ...prev, - [cipher.id]: (e.currentTarget as HTMLInputElement).checked, - })) - } - /> - -
- ))} -
- )} - {!filteredCiphers.length &&
{t('txt_no_items')}
} -
-
+ setSearchComposing(true)} + onSearchCompositionEnd={(value) => { + setSearchComposing(false); + setSearchInput(value); + }} + onToggleSortMenu={() => setSortMenuOpen((open) => !open)} + onSelectSortMode={(value) => { + setSortMode(value); + setSortMenuOpen(false); + }} + onSyncVault={() => void syncVault()} + onOpenBulkDelete={() => setBulkDeleteOpen(true)} + onSelectAll={() => { + const map: Record = {}; + for (const cipher of filteredCiphers) map[cipher.id] = true; + setSelectedMap(map); + }} + onToggleCreateMenu={() => setCreateMenuOpen((open) => !open)} + onStartCreate={startCreate} + onBulkRestore={() => void confirmBulkRestore()} + onOpenMove={() => { + setMoveFolderId('__none__'); + setMoveOpen(true); + }} + onClearSelection={() => setSelectedMap({})} + onScroll={setListScrollTop} + onToggleSelected={(cipherId, checked) => + setSelectedMap((prev) => ({ + ...prev, + [cipherId]: checked, + })) + } + onSelectCipher={(cipherId) => { + if (isEditing || isCreating) { + cancelEdit(); + } + setSelectedCipherId(cipherId); + setRepromptApprovedCipherId(null); + if (isMobileLayout) setMobilePanel('detail'); + setMobileSidebarOpen(false); + }} + listSubtitle={listSubtitle} + />
{isMobileLayout && mobilePanel !== 'list' && ( @@ -1263,667 +773,76 @@ function folderName(id: string | null | undefined): string {
)} {isEditing && draft && ( - <> -
-
-

{isCreating ? t('txt_new_type_header', { type: cipherTypeLabel(draft.type) }) : t('txt_edit_type_header', { type: cipherTypeLabel(draft.type) })}

- -
-
- - -
- -
- - {draft.type === 1 && ( -
-

{t('txt_login_credentials')}

-
- - -
- -
-

{t('txt_websites')}

- -
- {draft.loginUris.map((uri, index) => ( -
- updateDraftLoginUri(index, (e.currentTarget as HTMLInputElement).value)} /> - {draft.loginUris.length > 1 && ( - - )} -
- ))} -
- )} - - {draft.type === 3 && ( -
-

{t('txt_card_details')}

-
- - - - - - -
-
- )} - - {draft.type === 4 && ( -
-

{t('txt_identity_details')}

-
- - - - - - - - - - - - - - - - - - -
-
- )} - {draft.type === 5 && ( -
-
-

{t('txt_ssh_key')}

- -
-