From 21e04571b3ca6f0eb4e2d877eebaaf0821cadc1a Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 15 Dec 2025 23:10:16 +0100 Subject: [PATCH] Refactor browser extension sqliteclient to use custom ORM similar to .NET EF repository pattern (#1404) --- .../entrypoints/background/PasskeyHandler.ts | 5 +- .../background/VaultMessageHandler.ts | 27 +- .../Credentials/Details/AttachmentBlock.tsx | 2 +- .../Credentials/Details/FieldBlock.tsx | 2 +- .../Credentials/Details/FieldHistoryModal.tsx | 2 +- .../Credentials/Details/PasskeyBlock.tsx | 2 +- .../Credentials/Details/PasskeyEditor.tsx | 2 +- .../Credentials/Details/TotpBlock.tsx | 2 +- .../popup/components/EmailPreview.tsx | 2 +- .../popup/components/Forms/PasswordField.tsx | 2 +- .../popup/hooks/useAliasGenerator.ts | 10 +- .../popup/pages/credentials/ItemAddEdit.tsx | 24 +- .../popup/pages/credentials/ItemDetails.tsx | 2 +- .../popup/pages/emails/EmailDetails.tsx | 4 +- .../popup/pages/emails/EmailsList.tsx | 4 +- .../popup/pages/items/ItemsList.tsx | 32 +- .../popup/pages/items/RecentlyDeleted.tsx | 8 +- .../pages/passkeys/PasskeyAuthenticate.tsx | 12 +- .../popup/pages/passkeys/PasskeyCreate.tsx | 26 +- .../src/utils/FaviconService.ts | 4 +- .../src/utils/SqliteClient.ts | 2459 ++--------------- .../src/utils/db/BaseRepository.ts | 137 + apps/browser-extension/src/utils/db/index.ts | 22 + .../src/utils/db/mappers/FieldMapper.ts | 191 ++ .../src/utils/db/mappers/ItemMapper.ts | 149 + .../src/utils/db/mappers/PasskeyMapper.ts | 102 + .../src/utils/db/queries/ItemQueries.ts | 352 +++ .../utils/db/repositories/FolderRepository.ts | 228 ++ .../utils/db/repositories/ItemRepository.ts | 808 ++++++ .../utils/db/repositories/LogoRepository.ts | 159 ++ .../db/repositories/PasskeyRepository.ts | 295 ++ .../db/repositories/SettingsRepository.ts | 192 ++ 32 files changed, 2887 insertions(+), 2381 deletions(-) create mode 100644 apps/browser-extension/src/utils/db/BaseRepository.ts create mode 100644 apps/browser-extension/src/utils/db/index.ts create mode 100644 apps/browser-extension/src/utils/db/mappers/FieldMapper.ts create mode 100644 apps/browser-extension/src/utils/db/mappers/ItemMapper.ts create mode 100644 apps/browser-extension/src/utils/db/mappers/PasskeyMapper.ts create mode 100644 apps/browser-extension/src/utils/db/queries/ItemQueries.ts create mode 100644 apps/browser-extension/src/utils/db/repositories/FolderRepository.ts create mode 100644 apps/browser-extension/src/utils/db/repositories/ItemRepository.ts create mode 100644 apps/browser-extension/src/utils/db/repositories/LogoRepository.ts create mode 100644 apps/browser-extension/src/utils/db/repositories/PasskeyRepository.ts create mode 100644 apps/browser-extension/src/utils/db/repositories/SettingsRepository.ts diff --git a/apps/browser-extension/src/entrypoints/background/PasskeyHandler.ts b/apps/browser-extension/src/entrypoints/background/PasskeyHandler.ts index 58978cb6e..381dea23e 100644 --- a/apps/browser-extension/src/entrypoints/background/PasskeyHandler.ts +++ b/apps/browser-extension/src/entrypoints/background/PasskeyHandler.ts @@ -24,6 +24,7 @@ import type { WebAuthnPublicKeyGetPayload } from '@/utils/passkey/types'; import { SqliteClient } from '@/utils/SqliteClient'; +import type { PasskeyWithItem } from '@/utils/db/mappers/PasskeyMapper'; import { browser, storage } from '#imports'; @@ -232,7 +233,7 @@ async function checkForMatchingPasskeys(publicKey: any, origin: string): Promise const rpId = publicKey.rpId || new URL(origin).hostname; // Get passkeys for this rpId - const passkeys = sqliteClient.getPasskeysByRpId(rpId); + const passkeys = sqliteClient.passkeys.getByRpId(rpId); // If allowCredentials is specified, filter by those specific credentials if (publicKey.allowCredentials && publicKey.allowCredentials.length > 0) { @@ -249,7 +250,7 @@ async function checkForMatchingPasskeys(publicKey: any, origin: string): Promise ); // Check if we have any of the allowed credentials - const matchingPasskeys = passkeys.filter(pk => allowedGuids.has(pk.Id)); + const matchingPasskeys = passkeys.filter((pk: PasskeyWithItem) => allowedGuids.has(pk.Id)); return matchingPasskeys.length > 0; } diff --git a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts index 20c1fa1b9..87de0a6e3 100644 --- a/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts +++ b/apps/browser-extension/src/entrypoints/background/VaultMessageHandler.ts @@ -2,6 +2,7 @@ import { storage } from 'wxt/utils/storage'; import type { EncryptionKeyDerivationParams } from '@/utils/dist/core/models/metadata'; +import type { Item } from '@/utils/dist/core/models/vault'; import type { Vault, VaultResponse, VaultPostResponse } from '@/utils/dist/core/models/webapi'; import { EncryptionUtility } from '@/utils/EncryptionUtility'; import { SqliteClient } from '@/utils/SqliteClient'; @@ -299,7 +300,7 @@ export async function handleCreateItem( const sqliteClient = await createVaultSqliteClient(); // Add the new item to the vault/database. - await sqliteClient.createItem(message.item, message.attachments || [], message.totpCodes || []); + await sqliteClient.items.create(message.item, message.attachments || [], message.totpCodes || []); // Upload the new vault to the server. await uploadNewVaultToServer(sqliteClient); @@ -328,7 +329,7 @@ export async function handleGetFilteredItems( try { const sqliteClient = await createVaultSqliteClient(); - const allItems = sqliteClient.getAllItems(); + const allItems = sqliteClient.items.getAll(); const { filterItems, AutofillMatchingMode } = await import('@/utils/credentialMatcher/CredentialMatcher'); @@ -370,7 +371,7 @@ export async function handleGetSearchItems( try { const sqliteClient = await createVaultSqliteClient(); - const allItems = sqliteClient.getAllItems(); + const allItems = sqliteClient.items.getAll(); // If search term is empty, return empty array if (!message.searchTerm || message.searchTerm.trim() === '') { @@ -381,7 +382,7 @@ export async function handleGetSearchItems( const { FieldKey } = await import('@/utils/dist/core/models/vault'); // Filter items by search term across multiple fields - const searchResults = allItems.filter(item => { + const searchResults = allItems.filter((item: Item) => { // Search in item name if (item.Name?.toLowerCase().includes(searchTerm)) { return true; @@ -396,14 +397,14 @@ export async function handleGetSearchItems( FieldKey.AliasLastName ]; - return item.Fields?.some(field => { - if (searchableFieldKeys.includes(field.FieldKey as any)) { + return item.Fields?.some((field: { FieldKey: string; Value: string | string[] }) => { + if ((searchableFieldKeys as string[]).includes(field.FieldKey)) { const value = Array.isArray(field.Value) ? field.Value.join(' ') : field.Value; return value?.toLowerCase().includes(searchTerm); } return false; }); - }).sort((a, b) => { + }).sort((a: Item, b: Item) => { // Sort by name return (a.Name ?? '').localeCompare(b.Name ?? ''); }); @@ -421,7 +422,7 @@ export async function handleGetSearchItems( export async function getEmailAddressesForVault( sqliteClient: SqliteClient ): Promise { - const emailAddresses = sqliteClient.getAllEmailAddresses(); + const emailAddresses = sqliteClient.items.getAllEmailAddresses(); // Get metadata from local: storage const privateEmailDomains = await getItemWithFallback('local:privateEmailDomains') ?? []; @@ -439,7 +440,7 @@ export function handleGetDefaultEmailDomain(): Promise { return (async (): Promise => { try { const sqliteClient = await createVaultSqliteClient(); - const defaultEmailDomain = await sqliteClient.getDefaultEmailDomain(); + const defaultEmailDomain = sqliteClient.settings.getDefaultEmailDomain(); return { success: true, value: defaultEmailDomain ?? undefined }; } catch (error) { @@ -457,8 +458,8 @@ export async function handleGetDefaultIdentitySettings( ) : Promise { try { const sqliteClient = await createVaultSqliteClient(); - const language = await sqliteClient.getEffectiveIdentityLanguage(); - const gender = sqliteClient.getDefaultIdentityGender(); + const language = sqliteClient.settings.getEffectiveIdentityLanguage(); + const gender = sqliteClient.settings.getDefaultIdentityGender(); return { success: true, @@ -480,7 +481,7 @@ export async function handleGetPasswordSettings( ) : Promise { try { const sqliteClient = await createVaultSqliteClient(); - const passwordSettings = sqliteClient.getPasswordSettings(); + const passwordSettings = sqliteClient.settings.getPasswordSettings(); return { success: true, settings: passwordSettings }; } catch (error) { @@ -623,7 +624,7 @@ async function uploadNewVaultToServer(sqliteClient: SqliteClient) : Promise = ({ itemId }) => { } try { - const attachmentList = dbContext.sqliteClient.getAttachmentsForItem(itemId); + const attachmentList = dbContext.sqliteClient.settings.getAttachmentsForItem(itemId); setAttachments(attachmentList); } catch (error) { console.error('Error loading attachments:', error); diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldBlock.tsx index a350b702f..165eab43c 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldBlock.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldBlock.tsx @@ -49,7 +49,7 @@ const FieldBlock: React.FC = ({ field, itemId }) => { useEffect(() => { if (hasHistoryEnabled && itemId && dbContext?.sqliteClient) { try { - const history = dbContext.sqliteClient.getFieldHistory(itemId, field.FieldKey); + const history = dbContext.sqliteClient.items.getFieldHistory(itemId, field.FieldKey); setHistoryCount(history.length); } catch (error) { console.error('[FieldBlock] Error checking history:', error); diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldHistoryModal.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldHistoryModal.tsx index 586c0ddfa..d73838595 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldHistoryModal.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/FieldHistoryModal.tsx @@ -48,7 +48,7 @@ const FieldHistoryModal: React.FC = ({ try { setLoading(true); - const historyRecords = dbContext.sqliteClient.getFieldHistory(itemId, fieldKey); + const historyRecords = dbContext.sqliteClient.items.getFieldHistory(itemId, fieldKey); setHistory(historyRecords); } catch (error) { console.error('Error loading field history:', error); diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/PasskeyBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/PasskeyBlock.tsx index b5c8844a8..38d76cd86 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/PasskeyBlock.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/PasskeyBlock.tsx @@ -43,7 +43,7 @@ const PasskeyBlock: React.FC = ({ itemId }) => { } try { - const itemPasskeys = dbContext.sqliteClient.getPasskeysByItemId(itemId); + const itemPasskeys = dbContext.sqliteClient.passkeys.getByItemId(itemId); setPasskeys(itemPasskeys); } catch (err) { console.error('Error loading passkeys:', err); diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/PasskeyEditor.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/PasskeyEditor.tsx index b19ad08e2..2b6cba5a2 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/PasskeyEditor.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/PasskeyEditor.tsx @@ -35,7 +35,7 @@ const PasskeyEditor: React.FC = ({ } try { - const itemPasskeys = dbContext.sqliteClient.getPasskeysByItemId(itemId); + const itemPasskeys = dbContext.sqliteClient.passkeys.getByItemId(itemId); setPasskeys(itemPasskeys); } catch (err) { console.error('Error loading passkeys:', err); diff --git a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/TotpBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/TotpBlock.tsx index 888fae703..33d4ac1e2 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/TotpBlock.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Credentials/Details/TotpBlock.tsx @@ -92,7 +92,7 @@ const TotpBlock: React.FC = ({ itemId }) => { } try { - const codes = dbContext.sqliteClient.getTotpCodesForItem(itemId); + const codes = dbContext.sqliteClient.settings.getTotpCodesForItem(itemId); setTotpCodes(codes); } catch (error) { console.error('Error loading TOTP codes:', error); diff --git a/apps/browser-extension/src/entrypoints/popup/components/EmailPreview.tsx b/apps/browser-extension/src/entrypoints/popup/components/EmailPreview.tsx index 52f46ff32..f7edebf1e 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/EmailPreview.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/EmailPreview.tsx @@ -140,7 +140,7 @@ export const EmailPreview: React.FC = ({ email }) => { // Loop through all emails and decrypt them locally const decryptedEmails: MailboxEmail[] = await EncryptionUtility.decryptEmailList( allMails, - dbContext.sqliteClient!.getAllEncryptionKeys() + dbContext.sqliteClient!.settings.getAllEncryptionKeys() ); if (loading && decryptedEmails.length > 0) { diff --git a/apps/browser-extension/src/entrypoints/popup/components/Forms/PasswordField.tsx b/apps/browser-extension/src/entrypoints/popup/components/Forms/PasswordField.tsx index a1d519792..f050f227e 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Forms/PasswordField.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Forms/PasswordField.tsx @@ -60,7 +60,7 @@ const PasswordField: React.FC = ({ const loadSettings = async (): Promise => { try { if (dbContext.sqliteClient) { - const settings = dbContext.sqliteClient.getPasswordSettings(); + const settings = dbContext.sqliteClient.settings.getPasswordSettings(); setCurrentSettings(settings); setIsLoaded(true); } diff --git a/apps/browser-extension/src/entrypoints/popup/hooks/useAliasGenerator.ts b/apps/browser-extension/src/entrypoints/popup/hooks/useAliasGenerator.ts index 24f5ddccf..693a9d5ae 100644 --- a/apps/browser-extension/src/entrypoints/popup/hooks/useAliasGenerator.ts +++ b/apps/browser-extension/src/entrypoints/popup/hooks/useAliasGenerator.ts @@ -53,13 +53,13 @@ const useAliasGenerator = (): { } // Get effective identity language (smart default based on UI language if no explicit override) - const identityLanguage = await dbContext.sqliteClient.getEffectiveIdentityLanguage(); + const identityLanguage = dbContext.sqliteClient.settings.getEffectiveIdentityLanguage(); // Initialize identity generator based on language const identityGenerator = CreateIdentityGenerator(identityLanguage); // Initialize password generator with settings from vault - const passwordSettings = dbContext.sqliteClient.getPasswordSettings(); + const passwordSettings = dbContext.sqliteClient.settings.getPasswordSettings(); const passwordGenerator = CreatePasswordGenerator(passwordSettings); return { identityGenerator, passwordGenerator }; @@ -78,17 +78,17 @@ const useAliasGenerator = (): { const { identityGenerator, passwordGenerator } = await initializeGenerators(); // Get gender preference from database - const genderPreference = dbContext.sqliteClient.getDefaultIdentityGender(); + const genderPreference = dbContext.sqliteClient.settings.getDefaultIdentityGender(); // Get age range preference and convert to birthdate options - const ageRange = dbContext.sqliteClient.getDefaultIdentityAgeRange(); + const ageRange = dbContext.sqliteClient.settings.getDefaultIdentityAgeRange(); const birthdateOptions = convertAgeRangeToBirthdateOptions(ageRange); // Generate identity with gender preference and birthdate options const identity = identityGenerator.generateRandomIdentity(genderPreference, birthdateOptions); const password = passwordGenerator.generateRandomPassword(); - const defaultEmailDomain = await dbContext.sqliteClient.getDefaultEmailDomain(); + const defaultEmailDomain = dbContext.sqliteClient.settings.getDefaultEmailDomain(); const email = defaultEmailDomain ? `${identity.emailPrefix}@${defaultEmailDomain}` : identity.emailPrefix; const generatedData: GeneratedAliasData = { diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx index e5d42bf25..3b700677b 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemAddEdit.tsx @@ -429,7 +429,7 @@ const ItemAddEdit: React.FC = () => { // Load folders if (dbContext?.sqliteClient) { - const allFolders = dbContext.sqliteClient.getAllFolders(); + const allFolders = dbContext.sqliteClient.folders.getAll(); setFolders(allFolders); } @@ -453,12 +453,12 @@ const ItemAddEdit: React.FC = () => { } try { - const result = dbContext.sqliteClient.getItemById(id); + const result = dbContext.sqliteClient.items.getById(id); if (result) { setItem(result); // Load folders - const allFolders = dbContext.sqliteClient.getAllFolders(); + const allFolders = dbContext.sqliteClient.folders.getAll(); setFolders(allFolders); // Initialize field values from existing fields @@ -466,7 +466,7 @@ const ItemAddEdit: React.FC = () => { const existingCustomFields: CustomFieldDefinition[] = []; const fieldsWithValues = new Set(); - result.Fields.forEach(field => { + result.Fields.forEach((field) => { initialValues[field.FieldKey] = field.Value; // Track fields that have values so they stay visible even if cleared fieldsWithValues.add(field.FieldKey); @@ -488,17 +488,17 @@ const ItemAddEdit: React.FC = () => { setInitiallyVisibleFields(fieldsWithValues); // Load TOTP codes for this item - const itemTotpCodes = dbContext.sqliteClient.getTotpCodesForItem(id); + const itemTotpCodes = dbContext.sqliteClient.settings.getTotpCodesForItem(id); setTotpCodes(itemTotpCodes); - setOriginalTotpCodeIds(itemTotpCodes.map(tc => tc.Id)); + setOriginalTotpCodeIds(itemTotpCodes.map((tc) => tc.Id)); if (itemTotpCodes.length > 0) { setShow2FA(true); } // Load attachments for this item - const itemAttachments = dbContext.sqliteClient.getAttachmentsForItem(id); + const itemAttachments = dbContext.sqliteClient.settings.getAttachmentsForItem(id); setAttachments(itemAttachments); - setOriginalAttachmentIds(itemAttachments.map(a => a.Id)); + setOriginalAttachmentIds(itemAttachments.map((a) => a.Id)); if (itemAttachments.length > 0) { setShowAttachments(true); } @@ -720,7 +720,7 @@ const ItemAddEdit: React.FC = () => { setLocalLoading(false); if (isEditMode) { - await dbContext.sqliteClient!.updateItem( + await dbContext.sqliteClient!.items.update( updatedItem, originalAttachmentIds, attachments, @@ -731,11 +731,11 @@ const ItemAddEdit: React.FC = () => { // Delete passkeys marked for deletion if (passkeyIdsMarkedForDeletion.length > 0) { for (const passkeyId of passkeyIdsMarkedForDeletion) { - await dbContext.sqliteClient!.deletePasskeyById(passkeyId); + await dbContext.sqliteClient!.passkeys.deleteById(passkeyId); } } } else { - await dbContext.sqliteClient!.createItem(updatedItem, attachments, totpCodes); + await dbContext.sqliteClient!.items.create(updatedItem, attachments, totpCodes); } }); @@ -762,7 +762,7 @@ const ItemAddEdit: React.FC = () => { try { await executeVaultMutationAsync(async () => { - await dbContext.sqliteClient!.deleteItemById(item.Id); + await dbContext.sqliteClient!.items.trash(item.Id); }); navigate('/items'); diff --git a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx index e309a4453..71067bc1f 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/credentials/ItemDetails.tsx @@ -59,7 +59,7 @@ const ItemDetails: React.FC = (): React.ReactElement => { } try { - const result = dbContext.sqliteClient.getItemById(id); + const result = dbContext.sqliteClient.items.getById(id); if (result) { setItem(result); setIsInitialLoading(false); diff --git a/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailDetails.tsx b/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailDetails.tsx index cc0e0197d..6fe531cd4 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailDetails.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailDetails.tsx @@ -60,7 +60,7 @@ const EmailDetails: React.FC = (): React.ReactElement => { const response = await webApi.get(`Email/${id}`); // Decrypt email locally using public/private key pairs - const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys(); + const encryptionKeys = dbContext.sqliteClient.settings.getAllEncryptionKeys(); const decryptedEmail = await EncryptionUtility.decryptEmail(response, encryptionKeys); setEmail(decryptedEmail); } catch (err) { @@ -111,7 +111,7 @@ const EmailDetails: React.FC = (): React.ReactElement => { } // Get encryption keys for decryption - const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys(); + const encryptionKeys = dbContext.sqliteClient.settings.getAllEncryptionKeys(); // Decrypt the attachment using raw bytes const decryptedBytes = await EncryptionUtility.decryptAttachment(encryptedBytes, email, encryptionKeys); diff --git a/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailsList.tsx index cb20e2683..55ea8b754 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/emails/EmailsList.tsx @@ -47,7 +47,7 @@ const EmailsList: React.FC = () => { } // Get unique email addresses from all credentials. - const emailAddresses = dbContext.sqliteClient.getAllEmailAddresses(); + const emailAddresses = dbContext.sqliteClient.items.getAllEmailAddresses(); try { // For now we only show the latest 50 emails. No pagination. @@ -58,7 +58,7 @@ const EmailsList: React.FC = () => { }); // Decrypt emails locally using private key associated with the email address. - const encryptionKeys = dbContext.sqliteClient.getAllEncryptionKeys(); + const encryptionKeys = dbContext.sqliteClient.settings.getAllEncryptionKeys(); // Decrypt emails locally using public/private key pairs. const decryptedEmails = await EncryptionUtility.decryptEmailList(data.mails, encryptionKeys); diff --git a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx index d06ab08ba..909e5c6f8 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/items/ItemsList.tsx @@ -105,8 +105,8 @@ const ItemsList: React.FC = () => { if (!currentFolderId || !dbContext?.sqliteClient) { return null; } - const folders = dbContext.sqliteClient.getAllFolders(); - const folder = folders.find(f => f.Id === currentFolderId); + const folders = dbContext.sqliteClient.folders.getAll(); + const folder = folders.find((f: { Id: string; Name: string }) => f.Id === currentFolderId); return folder?.Name ?? null; }, [currentFolderId, dbContext?.sqliteClient]); @@ -154,11 +154,11 @@ const ItemsList: React.FC = () => { } await executeVaultMutationAsync(async () => { - await dbContext.sqliteClient!.createFolder(folderName, currentFolderId); + await dbContext.sqliteClient!.folders.create(folderName, currentFolderId); }); // Refresh items to show the new folder - const results = dbContext.sqliteClient!.getAllItems(); + const results = dbContext.sqliteClient!.items.getAll(); setItems(results); }, [dbContext, currentFolderId, executeVaultMutationAsync]); @@ -171,13 +171,13 @@ const ItemsList: React.FC = () => { } await executeVaultMutationAsync(async () => { - await dbContext.sqliteClient!.deleteFolder(currentFolderId); + await dbContext.sqliteClient!.folders.delete(currentFolderId); }); // Refresh items list to reflect changes - const results = dbContext.sqliteClient!.getAllItems(); + const results = dbContext.sqliteClient!.items.getAll(); setItems(results); - const deletedCount = dbContext.sqliteClient!.getRecentlyDeletedCount(); + const deletedCount = dbContext.sqliteClient!.items.getRecentlyDeletedCount(); setRecentlyDeletedCount(deletedCount); // Navigate back to root @@ -193,13 +193,13 @@ const ItemsList: React.FC = () => { } await executeVaultMutationAsync(async () => { - await dbContext.sqliteClient!.deleteFolderWithContents(currentFolderId); + await dbContext.sqliteClient!.folders.deleteWithContents(currentFolderId); }); // Refresh items list to reflect changes - const results = dbContext.sqliteClient!.getAllItems(); + const results = dbContext.sqliteClient!.items.getAll(); setItems(results); - const deletedCount = dbContext.sqliteClient!.getRecentlyDeletedCount(); + const deletedCount = dbContext.sqliteClient!.items.getRecentlyDeletedCount(); setRecentlyDeletedCount(deletedCount); // Navigate back to root @@ -284,10 +284,10 @@ const ItemsList: React.FC = () => { const refreshItems = async () : Promise => { if (dbContext?.sqliteClient) { setIsLoading(true); - const results = dbContext.sqliteClient?.getAllItems() ?? []; + const results = dbContext.sqliteClient?.items.getAll() ?? []; setItems(results); // Also get recently deleted count - const deletedCount = dbContext.sqliteClient?.getRecentlyDeletedCount() ?? 0; + const deletedCount = dbContext.sqliteClient?.items.getRecentlyDeletedCount() ?? 0; setRecentlyDeletedCount(deletedCount); setIsLoading(false); setIsInitialLoading(false); @@ -336,22 +336,22 @@ const ItemsList: React.FC = () => { } // Get all folders directly from the database - const allFolders = dbContext.sqliteClient.getAllFolders(); + const allFolders = dbContext.sqliteClient.folders.getAll(); // Count items per folder const folderCounts = new Map(); - items.forEach(item => { + items.forEach((item: Item) => { if (item.FolderId) { folderCounts.set(item.FolderId, (folderCounts.get(item.FolderId) || 0) + 1); } }); // Build result with counts - const result = allFolders.map(folder => ({ + const result = allFolders.map((folder: { Id: string; Name: string }) => ({ id: folder.Id, name: folder.Name, itemCount: folderCounts.get(folder.Id) || 0 - })).sort((a, b) => a.name.localeCompare(b.name)); + })).sort((a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name)); return result; }; diff --git a/apps/browser-extension/src/entrypoints/popup/pages/items/RecentlyDeleted.tsx b/apps/browser-extension/src/entrypoints/popup/pages/items/RecentlyDeleted.tsx index c8c25c28b..17b723fd5 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/items/RecentlyDeleted.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/items/RecentlyDeleted.tsx @@ -48,7 +48,7 @@ const RecentlyDeleted: React.FC = () => { */ const loadItems = useCallback(() => { if (dbContext?.sqliteClient) { - const results = dbContext.sqliteClient.getRecentlyDeletedItems(); + const results = dbContext.sqliteClient.items.getRecentlyDeleted(); setItems(results); } }, [dbContext?.sqliteClient]); @@ -62,7 +62,7 @@ const RecentlyDeleted: React.FC = () => { } await executeVaultMutationAsync(async () => { - await dbContext.sqliteClient!.restoreItem(itemId); + await dbContext.sqliteClient!.items.restore(itemId); }); loadItems(); @@ -77,7 +77,7 @@ const RecentlyDeleted: React.FC = () => { } await executeVaultMutationAsync(async () => { - await dbContext.sqliteClient!.permanentlyDeleteItem(selectedItemId); + await dbContext.sqliteClient!.items.permanentlyDelete(selectedItemId); }); loadItems(); @@ -95,7 +95,7 @@ const RecentlyDeleted: React.FC = () => { await executeVaultMutationAsync(async () => { for (const item of items) { - await dbContext.sqliteClient!.permanentlyDeleteItem(item.Id); + await dbContext.sqliteClient!.items.permanentlyDelete(item.Id); } }); diff --git a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyAuthenticate.tsx b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyAuthenticate.tsx index 8c3556272..f25a5a491 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyAuthenticate.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyAuthenticate.tsx @@ -63,27 +63,27 @@ const PasskeyAuthenticate: React.FC = () => { // Get passkeys for this rpId from the vault const rpId = data.publicKey.rpId || new URL(data.origin).hostname; - const passkeys = dbContext.sqliteClient!.getPasskeysByRpId(rpId); + const passkeys = dbContext.sqliteClient!.passkeys.getByRpId(rpId); // Filter by allowCredentials if specified let filteredPasskeys = passkeys; if (data.publicKey.allowCredentials && data.publicKey.allowCredentials.length > 0) { // Convert the RP's base64url credential IDs to GUIDs for comparison const allowedGuids = new Set( - data.publicKey.allowCredentials.map(c => { + data.publicKey.allowCredentials.map((c: { id: string }) => { try { return PasskeyHelper.base64urlToGuid(c.id); } catch (e) { console.warn('Failed to convert credential ID to GUID:', c.id, e); return null; } - }).filter((id): id is string => id !== null) + }).filter((id: string | null): id is string => id !== null) ); - filteredPasskeys = passkeys.filter(pk => allowedGuids.has(pk.Id)); + filteredPasskeys = passkeys.filter((pk: { Id: string }) => allowedGuids.has(pk.Id)); } // Map to display format - setAvailablePasskeys(filteredPasskeys.map(pk => ({ + setAvailablePasskeys(filteredPasskeys.map((pk: { Id: string; DisplayName: string; ServiceName?: string | null; RpId: string; Username?: string | null }) => ({ id: pk.Id, displayName: pk.DisplayName, serviceName: pk.ServiceName, @@ -147,7 +147,7 @@ const PasskeyAuthenticate: React.FC = () => { try { // Get the stored passkey from vault - const storedPasskey = dbContext.sqliteClient.getPasskeyById(passkeyId); + const storedPasskey = dbContext.sqliteClient.passkeys.getById(passkeyId); if (!storedPasskey) { throw new Error(t('common.errors.unknownError')); } diff --git a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx index 417195c3b..66ea2a54f 100644 --- a/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx +++ b/apps/browser-extension/src/entrypoints/popup/pages/passkeys/PasskeyCreate.tsx @@ -84,7 +84,7 @@ const PasskeyCreate: React.FC = () => { // Check for existing passkeys for this RP ID and user if (dbContext.sqliteClient && data.publicKey?.rp?.id) { - const allPasskeysForRpId = dbContext.sqliteClient.getPasskeysByRpId(data.publicKey.rp.id); + const allPasskeysForRpId = dbContext.sqliteClient.passkeys.getByRpId(data.publicKey.rp.id); /** * Filter by user ID and/or username if provided @@ -93,7 +93,7 @@ const PasskeyCreate: React.FC = () => { let filtered = allPasskeysForRpId; if (data.publicKey.user?.id || data.publicKey.user?.name) { - filtered = allPasskeysForRpId.filter(passkey => { + filtered = allPasskeysForRpId.filter((passkey) => { /** * Match by user handle if both are available * The request has base64url encoded user.id, passkey has UserHandle as byte array @@ -131,7 +131,7 @@ const PasskeyCreate: React.FC = () => { // If no existing passkeys for this user, check for matching items if (filtered.length === 0) { // Get all items and filter for matches - const allItems = dbContext.sqliteClient.getAllItems(); + const allItems = dbContext.sqliteClient.items.getAll(); /* * Filter items that: @@ -139,7 +139,7 @@ const PasskeyCreate: React.FC = () => { * 2. Have username/password (are login items) * 3. Don't already have a passkey */ - const itemsWithoutPasskeys = allItems.filter(item => { + const itemsWithoutPasskeys = allItems.filter((item) => { // Must have username or password to be a login item const username = getFieldValue(item, FieldKey.LoginUsername); const password = getFieldValue(item, FieldKey.LoginPassword); @@ -313,19 +313,19 @@ const PasskeyCreate: React.FC = () => { await executeVaultMutationAsync(async () => { if (selectedPasskeyToReplace) { // Replace existing passkey: update the item and passkey - const existingPasskey = dbContext.sqliteClient!.getPasskeyById(selectedPasskeyToReplace); + const existingPasskey = dbContext.sqliteClient!.passkeys.getById(selectedPasskeyToReplace); if (existingPasskey) { // Get existing item to preserve its data - const existingItem = dbContext.sqliteClient!.getItemById(existingPasskey.ItemId); + const existingItem = dbContext.sqliteClient!.items.getById(existingPasskey.ItemId); if (existingItem) { // Update the parent item with new favicon and user-provided display name - await dbContext.sqliteClient!.updateItem( + await dbContext.sqliteClient!.items.update( { ...existingItem, Name: displayName, Logo: faviconLogo ?? existingItem.Logo, Fields: [ - ...(existingItem.Fields || []).filter(f => f.FieldKey !== FieldKey.LoginUrl && f.FieldKey !== FieldKey.LoginUsername), + ...(existingItem.Fields || []).filter((f) => f.FieldKey !== FieldKey.LoginUrl && f.FieldKey !== FieldKey.LoginUsername), { FieldKey: FieldKey.LoginUrl, Label: 'URL', FieldType: FieldTypes.URL, Value: request.origin, IsHidden: false, DisplayOrder: 0 }, { FieldKey: FieldKey.LoginUsername, Label: 'Username', FieldType: FieldTypes.Text, Value: request.publicKey.user.name, IsHidden: false, DisplayOrder: 1 } ] @@ -336,7 +336,7 @@ const PasskeyCreate: React.FC = () => { } // Delete the old passkey - await dbContext.sqliteClient!.deletePasskeyById(selectedPasskeyToReplace); + await dbContext.sqliteClient!.passkeys.deleteById(selectedPasskeyToReplace); /** * Create new passkey with same item @@ -352,7 +352,7 @@ const PasskeyCreate: React.FC = () => { } } - await dbContext.sqliteClient!.createPasskey({ + await dbContext.sqliteClient!.passkeys.create({ Id: newPasskeyGuid, ItemId: existingPasskey.ItemId, RpId: stored.rpId, @@ -380,7 +380,7 @@ const PasskeyCreate: React.FC = () => { } } - await dbContext.sqliteClient!.createPasskey({ + await dbContext.sqliteClient!.passkeys.create({ Id: newPasskeyGuid, ItemId: selectedItemToAttach, RpId: stored.rpId, @@ -406,7 +406,7 @@ const PasskeyCreate: React.FC = () => { UpdatedAt: new Date().toISOString() }; - const itemId = await dbContext.sqliteClient!.createItem(newItem, []); + const itemId = await dbContext.sqliteClient!.items.create(newItem, []); /** * Create the Passkey linked to the item @@ -423,7 +423,7 @@ const PasskeyCreate: React.FC = () => { } } - await dbContext.sqliteClient!.createPasskey({ + await dbContext.sqliteClient!.passkeys.create({ Id: newPasskeyGuid, ItemId: itemId, RpId: stored.rpId, diff --git a/apps/browser-extension/src/utils/FaviconService.ts b/apps/browser-extension/src/utils/FaviconService.ts index 02d612fff..08c59f875 100644 --- a/apps/browser-extension/src/utils/FaviconService.ts +++ b/apps/browser-extension/src/utils/FaviconService.ts @@ -111,7 +111,7 @@ export class FaviconService { return false; } - return sqliteClient.hasLogoForSource(source); + return sqliteClient.logos.hasLogoForSource(source); } /** @@ -142,7 +142,7 @@ export class FaviconService { } // Check if logo already exists (deduplication) - if (sqliteClient.hasLogoForSource(source)) { + if (sqliteClient.logos.hasLogoForSource(source)) { return { success: false, skipped: true }; } diff --git a/apps/browser-extension/src/utils/SqliteClient.ts b/apps/browser-extension/src/utils/SqliteClient.ts index c230b897a..86564bf35 100644 --- a/apps/browser-extension/src/utils/SqliteClient.ts +++ b/apps/browser-extension/src/utils/SqliteClient.ts @@ -1,30 +1,104 @@ import initSqlJs, { Database } from 'sql.js'; -import * as dateFormatter from '@/utils/dateFormatter'; -import type { EncryptionKey, PasswordSettings, TotpCode, Passkey, Item, ItemField, ItemTagRef, FieldType, FieldHistory } from '@/utils/dist/core/models/vault'; -import type { Attachment } from '@/utils/dist/core/models/vault'; -import { FieldKey, FieldTypes, getSystemField, MAX_FIELD_HISTORY_RECORDS } from '@/utils/dist/core/models/vault'; import type { VaultVersion } from '@/utils/dist/core/vault'; import { VaultSqlGenerator, checkVersionCompatibility, extractVersionFromMigrationId } from '@/utils/dist/core/vault'; -import { getItemWithFallback } from '@/utils/StorageUtility'; import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError'; import { t } from '@/i18n/StandaloneI18n'; +import { + ItemRepository, + PasskeyRepository, + FolderRepository, + SettingsRepository, + LogoRepository +} from './db'; +import type { IDatabaseClient, SqliteBindValue } from './db/BaseRepository'; + /** * Placeholder base64 image for credentials without a logo. */ const placeholderBase64 = 'UklGRjoEAABXRUJQVlA4IC4EAAAwFwCdASqAAIAAPpFCm0olo6Ihp5IraLASCWUA0eb/0s56RrLtCnYfLPiBshdXWMx8j1Ez65f169iA4xUDBTEV6ylMQeCIj2b7RngGi7gKZ9WjKdSoy9R8JcgOmjCMlDmLG20KhNo/i/Dc/Ah5GAvGfm8kfniV3AkR6fxN6eKwjDc6xrDgSfS48G5uGV6WzQt24YAVlLSK9BMwndzfHnePK1KFchFrL7O3ulB8cGNCeomu4o+l0SrS/JKblJ4WTzj0DAD++lCUEouSfgRKdiV2TiYCD+H+l3tANKSPQFPQuzi7rbvxqGeRmXB9kDwURaoSTTpYjA9REMUi9uA6aV7PWtBNXgUzMLowYMZeos6Xvyhb34GmufswMHA5ZyYpxzjTphOak4ZjNOiz8aScO5ygiTx99SqwX/uL+HSeVOSraHw8IymrMwm+jLxqN8BS8dGcItLlm/ioulqH2j4V8glDgSut+ExkxiD7m8TGPrrjCQNJbRDzpOFsyCyfBZupvp8QjGKW2KGziSZeIWes4aTB9tRmeEBhnUrmTDZQuXcc67Fg82KHrSfaeeOEq6jjuUjQ8wUnzM4Zz3dhrwSyslVz/WvnKqYkr4V/TTXPFF5EjF4rM1bHZ8bK63EfTnK41+n3n4gEFoYP4mXkNH0hntnYcdTqiE7Gn+q0BpRRxnkpBSZlA6Wa70jpW0FGqkw5e591A5/H+OV+60WAo+4Mi+NlsKrvLZ9EiVaPnoEFZlJQx1fA777AJ2MjXJ4KSsrWDWJi1lE8yPs8V6XvcC0chDTYt8456sKXAagCZyY+fzQriFMaddXyKQdG8qBqcdYjAsiIcjzaRFBBoOK9sU+sFY7N6B6+xtrlu3c37rQKkI3O2EoiJOris54EjJ5OFuumA0M6riNUuBf/MEPFBVx1JRcUEs+upEBsCnwYski7FT3TTqHrx7v5AjgFN97xhPTkmVpu6sxRnWBi1fxIRp8eWZeFM6mUcGgVk1WeVb1yhdV9hoMo2TsNEPE0tHo/wvuSJSzbZo7wibeXM9v/rRfKcx7X93rfiXVnyQ9f/5CaAQ4lxedPp/6uzLtOS4FyL0bCNeZ6L5w+AiuyWCTDFIYaUzhwfG+/YTQpWyeZCdQIKzhV+3GeXI2cxoP0ER/DlOKymf1gm+zRU3sqf1lBVQ0y+mK/Awl9bS3uaaQmI0FUyUwHUKP7PKuXnO+LcwDv4OfPT6hph8smc1EtMe5ib/apar/qZ9dyaEaElALJ1KKxnHziuvVl8atk1fINSQh7OtXDyqbPw9o/nGIpTnv5iFmwmWJLis2oyEgPkJqyx0vYI8rjkVEzKc8eQavAJBYSpjMwM193Swt+yJyjvaGYWPnqExxKiNarpB2WSO7soCAZXhS1uEYHryrK47BH6W1dRiruqT0xpLih3MXiwU3VDwAAAA=='; /** - * Client for interacting with the SQLite database. + * Core SQLite database client. + * Provides low-level database operations and exposes repositories for domain-specific operations. */ -export class SqliteClient { +export class SqliteClient implements IDatabaseClient { private db: Database | null = null; private isInTransaction: boolean = false; + // Lazy-initialized repositories + private _items: ItemRepository | null = null; + private _passkeys: PasskeyRepository | null = null; + private _folders: FolderRepository | null = null; + private _settings: SettingsRepository | null = null; + private _logos: LogoRepository | null = null; + + // ===== Repository Accessors ===== + /** - * Initialize the SQLite database from a base64 string + * Repository for Item CRUD operations. + */ + public get items(): ItemRepository { + if (!this._items) { + this._items = new ItemRepository(this, this.logos); + } + return this._items; + } + + /** + * Repository for Passkey operations. + */ + public get passkeys(): PasskeyRepository { + if (!this._passkeys) { + this._passkeys = new PasskeyRepository(this); + } + return this._passkeys; + } + + /** + * Repository for Folder operations. + */ + public get folders(): FolderRepository { + if (!this._folders) { + this._folders = new FolderRepository(this); + } + return this._folders; + } + + /** + * Repository for Settings and auxiliary data operations. + */ + public get settings(): SettingsRepository { + if (!this._settings) { + this._settings = new SettingsRepository(this); + } + return this._settings; + } + + /** + * Repository for Logo management operations. + */ + public get logos(): LogoRepository { + if (!this._logos) { + this._logos = new LogoRepository(this); + } + return this._logos; + } + + // ===== IDatabaseClient Implementation ===== + + /** + * Get the underlying database instance. + */ + public getDb(): Database | null { + return this.db; + } + + /** + * Initialize the SQLite database from a base64 string. + * @param base64String - Base64 encoded SQLite database */ public async initializeFromBase64(base64String: string): Promise { try { @@ -35,18 +109,25 @@ export class SqliteClient { bytes[i] = binaryString.charCodeAt(i); } - // Initialize SQL.js with the WASM file from the local file system. + // Initialize SQL.js with the WASM file const SQL = await initSqlJs({ /** * Locates SQL.js files from the local file system. * @param file - The name of the file to locate * @returns The complete URL path to the file */ - locateFile: (file: string) => `src/${file}` + locateFile: (file: string): string => `src/${file}` }); // Create database from the binary data this.db = new SQL.Database(bytes); + + // Reset repository instances when database changes + this._items = null; + this._passkeys = null; + this._folders = null; + this._settings = null; + this._logos = null; } catch (error) { console.error('Error initializing SQLite database:', error); throw error; @@ -54,7 +135,7 @@ export class SqliteClient { } /** - * Begin a new transaction + * Begin a new transaction. */ public beginTransaction(): void { if (!this.db) { @@ -75,7 +156,7 @@ export class SqliteClient { } /** - * Commit the current transaction and persist changes to the vault + * Commit the current transaction. */ public async commitTransaction(): Promise { if (!this.db) { @@ -96,7 +177,7 @@ export class SqliteClient { } /** - * Rollback the current transaction + * Rollback the current transaction. */ public rollbackTransaction(): void { if (!this.db) { @@ -117,7 +198,7 @@ export class SqliteClient { } /** - * Export the SQLite database to a base64 string + * Export the SQLite database to a base64 string. * @returns Base64 encoded string of the database */ public exportToBase64(): string { @@ -126,13 +207,11 @@ export class SqliteClient { } try { - // Execute vacuum command to free up space before exporting (because of potential temp tables etc.) + // Vacuum to free up space before exporting this.db.run('VACUUM'); - // Export database to Uint8Array const binaryArray = this.db.export(); - // Convert Uint8Array to base64 string let binaryString = ''; for (let i = 0; i < binaryArray.length; i++) { binaryString += String.fromCharCode(binaryArray[i]); @@ -145,9 +224,12 @@ export class SqliteClient { } /** - * Execute a SELECT query + * Execute a SELECT query. + * @param query - SQL query string + * @param params - Query parameters + * @returns Array of result objects */ - public executeQuery(query: string, params: (string | number | null | Uint8Array)[] = []): T[] { + public executeQuery(query: string, params: SqliteBindValue[] = []): T[] { if (!this.db) { throw new Error('Database not initialized'); } @@ -158,8 +240,7 @@ export class SqliteClient { const results: T[] = []; while (stmt.step()) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - results.push(stmt.getAsObject() as any); + results.push(stmt.getAsObject() as T); } stmt.free(); @@ -171,9 +252,12 @@ export class SqliteClient { } /** - * Execute an INSERT, UPDATE, or DELETE query + * Execute an INSERT, UPDATE, or DELETE query. + * @param query - SQL query string + * @param params - Query parameters + * @returns Number of rows affected */ - public executeUpdate(query: string, params: (string | number | null | Uint8Array)[] = []): number { + public executeUpdate(query: string, params: SqliteBindValue[] = []): number { if (!this.db) { throw new Error('Database not initialized'); } @@ -191,6 +275,37 @@ export class SqliteClient { } } + /** + * Execute raw SQL command(s). + * @param query - SQL command(s) to execute (may contain multiple statements separated by semicolons) + */ + public executeRaw(query: string): void { + if (!this.db) { + throw new Error('Database not initialized'); + } + + try { + const statements = query.split(';'); + + for (const statement of statements) { + const trimmedStatement = statement.trim(); + + // Skip empty statements and transaction control statements + if (trimmedStatement.length === 0 || + trimmedStatement.toUpperCase().startsWith('BEGIN TRANSACTION') || + trimmedStatement.toUpperCase().startsWith('COMMIT') || + trimmedStatement.toUpperCase().startsWith('ROLLBACK')) { + continue; + } + + this.db.run(trimmedStatement); + } + } catch (error) { + console.error('Error executing raw SQL:', error); + throw error; + } + } + /** * Close the database connection and free resources. */ @@ -201,522 +316,9 @@ export class SqliteClient { } } - /** - * Fetch all items with their dynamic fields and tags. - * @returns Array of Item objects with field-based data (empty array if Items table doesn't exist yet). - */ - public getAllItems(): Item[] { - let items; - try { - const query = ` - SELECT DISTINCT - i.Id, - i.Name, - i.ItemType, - i.FolderId, - f.Name as FolderPath, - l.FileData as Logo, - CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, - CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, - CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, - i.CreatedAt, - i.UpdatedAt - FROM Items i - LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id - WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL - ORDER BY i.CreatedAt DESC`; - - items = this.executeQuery(query); - } catch (error) { - // Items table may not exist in older vault versions - return empty array - if (error instanceof Error && error.message.includes('no such table')) { - return []; - } - throw error; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const itemIds = items.map((i: any) => i.Id); - if (itemIds.length === 0) { - return []; - } - - // Get all field values (both system fields and custom fields) - const fieldsQuery = ` - SELECT - fv.ItemId, - fv.FieldKey, - fv.FieldDefinitionId, - fd.Label as CustomLabel, - fd.FieldType as CustomFieldType, - fd.IsHidden as CustomIsHidden, - fv.Value, - fv.Weight as DisplayOrder - FROM FieldValues fv - LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id - WHERE fv.ItemId IN (${itemIds.map(() => '?').join(',')}) - AND fv.IsDeleted = 0 - ORDER BY fv.ItemId, fv.Weight`; - - const fieldRows = this.executeQuery<{ - ItemId: string; - FieldKey: string | null; - FieldDefinitionId: string | null; - CustomLabel: string | null; - CustomFieldType: string | null; - CustomIsHidden: number | null; - Value: string; - DisplayOrder: number; - }>(fieldsQuery, itemIds); - - /* Process fields - handle system fields vs custom fields. */ - const fields = fieldRows.map(row => { - // System field: has FieldKey, get metadata from SystemFieldRegistry - if (row.FieldKey) { - const systemField = getSystemField(row.FieldKey); - return { - ItemId: row.ItemId, - FieldKey: row.FieldKey, - Label: row.FieldKey, // Use FieldKey as label; UI layer translates via fieldLabels.* - FieldType: systemField?.FieldType || FieldTypes.Text, - IsHidden: systemField?.IsHidden ? 1 : 0, - Value: row.Value, - DisplayOrder: row.DisplayOrder - }; - } else { - // Custom field: has FieldDefinitionId, get metadata from FieldDefinitions - return { - ItemId: row.ItemId, - FieldKey: row.FieldDefinitionId || '', // Use FieldDefinitionId as the key for custom fields - Label: row.CustomLabel || '', - FieldType: row.CustomFieldType || FieldTypes.Text, - IsHidden: row.CustomIsHidden || 0, - Value: row.Value, - DisplayOrder: row.DisplayOrder - }; - } - }); - - // Get all tags - const tagsQuery = ` - SELECT - it.ItemId, - t.Id, - t.Name, - t.Color - FROM ItemTags it - INNER JOIN Tags t ON it.TagId = t.Id - WHERE it.ItemId IN (${itemIds.map(() => '?').join(',')}) - AND it.IsDeleted = 0 - AND t.IsDeleted = 0 - ORDER BY t.DisplayOrder, t.Name`; - - const tags = this.executeQuery<{ - ItemId: string; - Id: string; - Name: string; - Color: string | null; - }>(tagsQuery, itemIds); - - // Group by ItemId and FieldKey (to handle multi-value fields) - const fieldsByItem: {[itemId: string]: ItemField[]} = {}; - const fieldValuesByKey: {[itemId_fieldKey: string]: string[]} = {}; - - fields.forEach(f => { - const key = `${f.ItemId}_${f.FieldKey}`; - - // Accumulate values for the same field - if (!fieldValuesByKey[key]) { - fieldValuesByKey[key] = []; - } - fieldValuesByKey[key].push(f.Value); - - // Create ItemField entry only once per unique FieldKey - if (!fieldsByItem[f.ItemId]) { - fieldsByItem[f.ItemId] = []; - } - - const existingField = fieldsByItem[f.ItemId].find(field => field.FieldKey === f.FieldKey); - if (!existingField) { - fieldsByItem[f.ItemId].push({ - FieldKey: f.FieldKey, - Label: f.Label, - FieldType: f.FieldType as FieldType, - Value: '', // Will be set below - IsHidden: f.IsHidden === 1, - DisplayOrder: f.DisplayOrder - }); - } - }); - - // Set Values (single value or array for multi-value fields) - Object.keys(fieldsByItem).forEach(itemId => { - fieldsByItem[itemId].forEach(field => { - const key = `${itemId}_${field.FieldKey}`; - const values = fieldValuesByKey[key]; - - if (values.length === 1) { - field.Value = values[0]; - } else { - field.Value = values; - } - }); - }); - - const tagsByItem: {[itemId: string]: ItemTagRef[]} = {}; - tags.forEach(t => { - if (!tagsByItem[t.ItemId]) { - tagsByItem[t.ItemId] = []; - } - tagsByItem[t.ItemId].push({ - Id: t.Id, - Name: t.Name, - Color: t.Color || undefined - }); - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return items.map((row: any) => ({ - Id: row.Id, - Name: row.Name, - ItemType: row.ItemType, - Logo: row.Logo, - FolderId: row.FolderId, - FolderPath: row.FolderPath || null, - Tags: tagsByItem[row.Id] || [], - Fields: fieldsByItem[row.Id] || [], - HasPasskey: row.HasPasskey === 1, - HasAttachment: row.HasAttachment === 1, - HasTotp: row.HasTotp === 1, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt - })); - } - - /** - * Fetch a single item by ID with its dynamic fields and tags. - * @param itemId - The ID of the item to fetch. - * @returns Item object or null if not found. - */ - public getItemById(itemId: string): Item | null { - const query = ` - SELECT - i.Id, - i.Name, - i.ItemType, - i.FolderId, - l.FileData as Logo, - CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, - CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, - CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, - i.CreatedAt, - i.UpdatedAt - FROM Items i - LEFT JOIN Logos l ON i.LogoId = l.Id - WHERE i.Id = ? AND i.IsDeleted = 0`; - - const results = this.executeQuery(query, [itemId]); - if (results.length === 0) { - return null; - } - - // Get field values (both system fields and custom fields) - const fieldsQuery = ` - SELECT - fv.FieldKey, - fv.FieldDefinitionId, - fd.Label as CustomLabel, - fd.FieldType as CustomFieldType, - fd.IsHidden as CustomIsHidden, - fv.Value, - fv.Weight as DisplayOrder - FROM FieldValues fv - LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id - WHERE fv.ItemId = ? AND fv.IsDeleted = 0 - ORDER BY fv.Weight`; - - const fieldRows = this.executeQuery<{ - FieldKey: string | null; - FieldDefinitionId: string | null; - CustomLabel: string | null; - CustomFieldType: string | null; - CustomIsHidden: number | null; - Value: string; - DisplayOrder: number; - }>(fieldsQuery, [itemId]); - - // Process fields - handle system fields vs custom fields AND group multi-value fields - const fieldValuesByKey: {[fieldKey: string]: string[]} = {}; - const uniqueFields: {[fieldKey: string]: { - FieldKey: string; - Label: string; - FieldType: string; - IsHidden: number; - DisplayOrder: number; - }} = {}; - - fieldRows.forEach(row => { - const fieldKey = row.FieldKey || row.FieldDefinitionId || ''; - - // Accumulate values - if (!fieldValuesByKey[fieldKey]) { - fieldValuesByKey[fieldKey] = []; - } - fieldValuesByKey[fieldKey].push(row.Value); - - /* Store field metadata (only once per FieldKey). */ - if (!uniqueFields[fieldKey]) { - if (row.FieldKey) { - // System field - const systemField = getSystemField(row.FieldKey); - uniqueFields[fieldKey] = { - FieldKey: row.FieldKey, - Label: row.FieldKey, // Use FieldKey as label; UI layer translates via fieldLabels.* - FieldType: systemField?.FieldType || FieldTypes.Text, - IsHidden: systemField?.IsHidden ? 1 : 0, - DisplayOrder: row.DisplayOrder - }; - } else { - // Custom field - uniqueFields[fieldKey] = { - FieldKey: fieldKey, - Label: row.CustomLabel || '', - FieldType: row.CustomFieldType || FieldTypes.Text, - IsHidden: row.CustomIsHidden || 0, - DisplayOrder: row.DisplayOrder - }; - } - } - }); - - // Build fields array with proper single/multi values - const fields = Object.keys(uniqueFields).map(fieldKey => ({ - ...uniqueFields[fieldKey], - Value: fieldValuesByKey[fieldKey].length === 1 - ? fieldValuesByKey[fieldKey][0] - : fieldValuesByKey[fieldKey] - })); - - // Get tags - const tagsQuery = ` - SELECT - t.Id, - t.Name, - t.Color - FROM ItemTags it - INNER JOIN Tags t ON it.TagId = t.Id - WHERE it.ItemId = ? AND it.IsDeleted = 0 AND t.IsDeleted = 0 - ORDER BY t.DisplayOrder, t.Name`; - - const tags = this.executeQuery<{ - Id: string; - Name: string; - Color: string | null; - }>(tagsQuery, [itemId]); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const row = results[0] as any; - return { - Id: row.Id, - Name: row.Name, - ItemType: row.ItemType, - Logo: row.Logo, - FolderId: row.FolderId, - FolderPath: null, - Tags: tags.map(t => ({ - Id: t.Id, - Name: t.Name, - Color: t.Color || undefined - })), - Fields: fields.map(f => ({ - FieldKey: f.FieldKey, - Label: f.Label, - FieldType: f.FieldType as FieldType, - Value: f.Value, - IsHidden: f.IsHidden === 1, - DisplayOrder: f.DisplayOrder - })), - HasPasskey: row.HasPasskey === 1, - HasAttachment: row.HasAttachment === 1, - HasTotp: row.HasTotp === 1, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt - }; - } - - /** - * Fetch all unique email addresses from all credentials. - * @returns Array of email addresses. - */ - public getAllEmailAddresses(): string[] { - const query = ` - SELECT DISTINCT fv.Value as Email - FROM FieldValues fv - INNER JOIN Items i ON fv.ItemId = i.Id - WHERE fv.FieldKey = ? - AND fv.Value IS NOT NULL - AND fv.Value != '' - AND fv.IsDeleted = 0 - AND i.IsDeleted = 0 - `; - - const results = this.executeQuery(query, [FieldKey.LoginEmail]); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return results.map((row: any) => row.Email); - } - - /** - * Fetch all encryption keys. - */ - public getAllEncryptionKeys(): EncryptionKey[] { - return this.executeQuery(`SELECT - x.PublicKey, - x.PrivateKey, - x.IsPrimary - FROM EncryptionKeys x`); - } - - /** - * Get setting from database for a given key. - * Returns default value (empty string by default) if setting is not found. - */ - public getSetting(key: string, defaultValue: string = ''): string { - const results = this.executeQuery<{ Value: string }>(`SELECT - s.Value - FROM Settings s - WHERE s.Key = ?`, [key]); - - return results.length > 0 ? results[0].Value : defaultValue; - } - - /** - * Get the default email domain from the database. - * @param privateEmailDomains - Array of private email domains - * @param publicEmailDomains - Array of public email domains - * @param hiddenPrivateEmailDomains - Array of hidden private email domains (optional) - * @returns The default email domain or null if no valid domain is found - */ - public async getDefaultEmailDomain(): Promise { - // Use fallback for keys migrated from session: to local: in v0.26.0 - const publicEmailDomains = await getItemWithFallback('local:publicEmailDomains') ?? []; - const privateEmailDomains = await getItemWithFallback('local:privateEmailDomains') ?? []; - const hiddenPrivateEmailDomains = await getItemWithFallback('local:hiddenPrivateEmailDomains') ?? []; - - const defaultEmailDomain = this.getSetting('DefaultEmailDomain'); - - /** - * Check if a domain is valid (not disabled, not hidden, and exists in domain lists). - */ - const isValidDomain = (domain: string): boolean => { - return Boolean(domain && - domain !== 'DISABLED.TLD' && - domain !== '' && - !hiddenPrivateEmailDomains.includes(domain) && - (privateEmailDomains.includes(domain) || publicEmailDomains.includes(domain))); - }; - - // First check if the default domain that is configured in the vault is still valid. - if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) { - return defaultEmailDomain; - } - - // If default domain is not valid, fall back to first available private domain (excluding hidden ones). - const firstPrivate = privateEmailDomains.find(isValidDomain); - if (firstPrivate) { - return firstPrivate; - } - - // Return first valid public domain if no private domains are available. - const firstPublic = publicEmailDomains.find(isValidDomain); - if (firstPublic) { - return firstPublic; - } - - // Return null if no valid domains are found - return null; - } - - /** - * Get the default identity language from the database. - * Returns the stored override value if set, otherwise returns empty string to indicate no explicit preference. - * Use getEffectiveIdentityLanguage() to get the language with smart defaults based on UI language. - */ - public getDefaultIdentityLanguage(): string { - return this.getSetting('DefaultIdentityLanguage'); - } - - /** - * Get the effective identity generator language to use. - * If user has explicitly set a language preference, use that. - * Otherwise, intelligently match the UI language to an available identity generator language. - * Falls back to "en" if no match is found. - */ - public async getEffectiveIdentityLanguage(): Promise { - const explicitLanguage = this.getDefaultIdentityLanguage(); - - // If user has explicitly set a language preference, use it - if (explicitLanguage) { - return explicitLanguage; - } - - // Otherwise, try to match UI language to an identity generator language - const { mapUiLanguageToIdentityLanguage } = await import('@/utils/dist/core/identity-generator'); - const { default: i18n } = await import('@/i18n/i18n'); - - const uiLanguage = i18n.language; - const mappedLanguage = mapUiLanguageToIdentityLanguage(uiLanguage); - - // Return the mapped language, or fall back to "en" if no match found - return mappedLanguage ?? 'en'; - } - - /** - * Get the default identity gender preference from the database. - */ - public getDefaultIdentityGender(): string { - return this.getSetting('DefaultIdentityGender', 'random'); - } - - /** - * Get the default identity age range from the database. - */ - public getDefaultIdentityAgeRange(): string { - return this.getSetting('DefaultIdentityAgeRange', 'random'); - } - - /** - * Get the password settings from the database. - */ - public getPasswordSettings(): PasswordSettings { - const settingsJson = this.getSetting('PasswordGenerationSettings'); - - // Default settings if none found or parsing fails - const defaultSettings: PasswordSettings = { - Length: 18, - UseLowercase: true, - UseUppercase: true, - UseNumbers: true, - UseSpecialChars: true, - UseNonAmbiguousChars: false - }; - - try { - if (settingsJson) { - return { ...defaultSettings, ...JSON.parse(settingsJson) }; - } - } catch (error) { - console.warn('Failed to parse password settings:', error); - } - - return defaultSettings; - } - /** * Get the current database version from the migrations history. - * Returns the semantic version (e.g., "1.4.1") from the latest migration. - * Uses semantic versioning to allow backwards-compatible minor/patch versions. + * @returns The database version information */ public async getDatabaseVersion(): Promise { if (!this.db) { @@ -724,7 +326,6 @@ export class SqliteClient { } try { - // Query the migrations history table for the latest migration const results = this.executeQuery<{ MigrationId: string }>(` SELECT MigrationId FROM __EFMigrationsHistory @@ -735,7 +336,6 @@ export class SqliteClient { throw new Error('No migrations found in the database.'); } - // Extract version from migration ID (e.g., "20240917191243_1.4.1-RenameAttachmentsPlural" -> "1.4.1") const migrationId = results[0].MigrationId; const databaseVersion = extractVersionFromMigrationId(migrationId); @@ -743,7 +343,6 @@ export class SqliteClient { throw new Error('Could not extract version from migration ID'); } - // Check version compatibility using semantic versioning const compatibilityResult = checkVersionCompatibility(databaseVersion); if (!compatibilityResult.isCompatible) { @@ -751,23 +350,16 @@ export class SqliteClient { throw new VaultVersionIncompatibleError(errorMessage); } - // If the version is known, return the full version info if (compatibilityResult.isKnownVersion && compatibilityResult.clientVersion) { return compatibilityResult.clientVersion; } - /* - * Version is unknown but compatible (same major version). - * Create a VaultVersion object with the actual database version but use the latest client's revision number. - * This allows older clients to work with newer backwards-compatible database versions. - */ const vaultSqlGenerator = new VaultSqlGenerator(); const latestClientVersion = vaultSqlGenerator.getLatestVersion(); - // Return a version object with the actual database version string but the latest known revision return { revision: latestClientVersion.revision, - version: databaseVersion, // Use the actual database version (e.g., "1.7.0") + version: databaseVersion, description: `Unknown version ${databaseVersion} (backwards compatible)`, releaseVersion: latestClientVersion.releaseVersion, compatibleUpToVersion: latestClientVersion.compatibleUpToVersion @@ -779,7 +371,7 @@ export class SqliteClient { } /** - * Get the latest available database version + * Get the latest available database version. * @returns The latest VaultVersion */ public async getLatestDatabaseVersion(): Promise { @@ -789,8 +381,8 @@ export class SqliteClient { } /** - * Check if there are pending migrations - * @returns True if there are pending migrations, false otherwise + * Check if there are pending migrations. + * @returns True if there are pending migrations */ public async hasPendingMigrations(): Promise { try { @@ -804,80 +396,16 @@ export class SqliteClient { } } - /** - * Get TOTP codes for an item - * @param itemId - The ID of the item to get TOTP codes for - * @returns Array of TotpCode objects - */ - public getTotpCodesForItem(itemId: string): TotpCode[] { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - if (!this.tableExists('TotpCodes')) { - return []; - } - - const query = ` - SELECT - Id, - Name, - SecretKey, - ItemId - FROM TotpCodes - WHERE ItemId = ? AND IsDeleted = 0`; - - return this.executeQuery(query, [itemId]); - } catch (error) { - console.error('Error getting TOTP codes for item:', error); - return []; - } - } - - /** - * Get attachments for an item - * @param itemId - The ID of the item - * @returns Array of attachments for the item - */ - public getAttachmentsForItem(itemId: string): Attachment[] { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - if (!this.tableExists('Attachments')) { - return []; - } - - const query = ` - SELECT - Id, - Filename, - Blob, - ItemId, - CreatedAt, - UpdatedAt, - IsDeleted - FROM Attachments - WHERE ItemId = ? AND IsDeleted = 0`; - return this.executeQuery(query, [itemId]); - } catch (error) { - console.error('Error getting attachments for item:', error); - return []; - } - } - /** * Convert binary data to a base64 encoded image source. + * @param bytes - Binary image data + * @returns Data URL for the image */ public static imgSrcFromBytes(bytes: Uint8Array | number[] | undefined): string { - // Handle base64 image data if (bytes) { try { const logoBytes = this.toUint8Array(bytes); const base64Logo = this.base64Encode(logoBytes); - // Detect image type from first few bytes const mimeType = this.detectMimeType(logoBytes); return `data:${mimeType};base64,${base64Logo}`; } catch (error) { @@ -889,78 +417,9 @@ export class SqliteClient { } } - /** - * Check if a logo exists for the given source domain. - * @param source The normalized source domain (e.g., 'github.com') - * @returns True if a logo exists for this source, false otherwise - */ - public hasLogoForSource(source: string): boolean { - const existingLogoQuery = ` - SELECT Id FROM Logos - WHERE Source = ? AND IsDeleted = 0 - LIMIT 1`; - const existingLogos = this.executeQuery<{ Id: string }>(existingLogoQuery, [source]); - return existingLogos.length > 0; - } - - /** - * Get the logo ID for a given source domain if it exists. - * @param source The normalized source domain (e.g., 'github.com') - * @returns The logo ID if found, null otherwise - */ - public getLogoIdForSource(source: string): string | null { - const existingLogoQuery = ` - SELECT Id FROM Logos - WHERE Source = ? AND IsDeleted = 0 - LIMIT 1`; - const existingLogos = this.executeQuery<{ Id: string }>(existingLogoQuery, [source]); - return existingLogos.length > 0 ? existingLogos[0].Id : null; - } - - /** - * Get or create a logo ID for the given source domain. - * If a logo for this source already exists, returns its ID. - * Otherwise, creates a new logo entry and returns its ID. - * @param source The normalized source domain (e.g., 'github.com') - * @param logoData The logo image data as Uint8Array - * @param currentDateTime The current date/time string for timestamps - * @returns The logo ID (existing or newly created) - */ - private getOrCreateLogoId(source: string, logoData: Uint8Array, currentDateTime: string): string { - // Check if a logo for this source already exists - const existingLogoQuery = ` - SELECT Id FROM Logos - WHERE Source = ? AND IsDeleted = 0 - LIMIT 1`; - const existingLogos = this.executeQuery<{ Id: string }>(existingLogoQuery, [source]); - - if (existingLogos.length > 0) { - // Reuse existing logo - return existingLogos[0].Id; - } - - // Create new logo entry - const logoId = crypto.randomUUID().toUpperCase(); - const logoQuery = ` - INSERT INTO Logos (Id, Source, FileData, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?)`; - this.executeUpdate(logoQuery, [ - logoId, - source, - logoData, - currentDateTime, - currentDateTime, - 0 - ]); - - return logoId; - } - /** * Extract and normalize source domain from a URL string. - * Uses lowercase and removes www. prefix for case-insensitive matching. - * This matches the server-side migration logic for consistent deduplication. - * @param urlString The URL to extract the domain from + * @param urlString - The URL to extract the domain from * @returns The normalized source domain (e.g., 'github.com'), or 'unknown' if extraction fails */ public static extractSourceFromUrl(urlString: string | undefined | null): string { @@ -970,7 +429,6 @@ export class SqliteClient { try { const url = new URL(urlString.startsWith('http') ? urlString : `https://${urlString}`); - // Normalize hostname: lowercase and remove www. prefix return url.hostname.toLowerCase().replace(/^www\./, ''); } catch { return 'unknown'; @@ -978,55 +436,33 @@ export class SqliteClient { } /** - * Convert logo data from various formats to Uint8Array. - * @param logo The logo data in various possible formats - * @returns Uint8Array of logo data, or null if conversion fails - */ - private static convertLogoToUint8Array(logo: unknown): Uint8Array | null { - if (!logo) { - return null; - } - - try { - // Handle object-like array conversion (from JSON deserialization) - if (typeof logo === 'object' && !ArrayBuffer.isView(logo) && !Array.isArray(logo)) { - const values = Object.values(logo as Record); - return new Uint8Array(values); - } - // Handle existing array types - if (Array.isArray(logo) || logo instanceof ArrayBuffer || logo instanceof Uint8Array) { - return new Uint8Array(logo as ArrayLike); - } - } catch (error) { - console.warn('Failed to convert logo to Uint8Array:', error); - } - - return null; - } - - /** - * Detect MIME type from file signature (magic numbers) + * Detect MIME type from file signature (magic numbers). + * @param bytes - Binary data to analyze + * @returns MIME type string */ private static detectMimeType(bytes: Uint8Array): string { /** * Check if the file is an SVG file. + * @returns True if the file is an SVG */ - const isSvg = () : boolean => { + const isSvg = (): boolean => { const header = new TextDecoder().decode(bytes.slice(0, 5)).toLowerCase(); return header.includes(' { + const isIco = (): boolean => { return bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00; }; /** - * Check if the file is an PNG file. + * Check if the file is a PNG file. + * @returns True if the file is a PNG */ - const isPng = () : boolean => { + const isPng = (): boolean => { return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47; }; @@ -1044,9 +480,11 @@ export class SqliteClient { } /** - * Convert various binary data formats to Uint8Array + * Convert various binary data formats to Uint8Array. + * @param buffer - Binary data in various formats + * @returns Normalized Uint8Array */ - private static toUint8Array(buffer: Uint8Array | number[] | {[key: number]: number}): Uint8Array { + private static toUint8Array(buffer: Uint8Array | number[] | { [key: number]: number }): Uint8Array { if (buffer instanceof Uint8Array) { return buffer; } @@ -1066,8 +504,10 @@ export class SqliteClient { /** * Base64 encode binary data. + * @param buffer - Binary data to encode + * @returns Base64 encoded string or null on error */ - private static base64Encode(buffer: Uint8Array | number[] | {[key: number]: number}): string | null { + private static base64Encode(buffer: Uint8Array | number[] | { [key: number]: number }): string | null { try { const arr = Array.from(this.toUint8Array(buffer)); return btoa(arr.reduce((data, byte) => data + String.fromCharCode(byte), '')); @@ -1076,1577 +516,6 @@ export class SqliteClient { return null; } } - - /** - * Check if a table exists in the database - * @param tableName - The name of the table to check - * @returns True if the table exists, false otherwise - */ - private tableExists(tableName: string): boolean { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - const query = ` - SELECT name FROM sqlite_master - WHERE type='table' AND name=?`; - - const results = this.executeQuery(query, [tableName]); - return results.length > 0; - } catch (error) { - console.error(`Error checking if table ${tableName} exists:`, error); - return false; - } - } - - /** - * Execute raw SQL command - * @param query - The SQL command to execute - */ - public executeRaw(query: string): void { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - // Split the query by semicolons to handle multiple statements - const statements = query.split(';'); - - for (const statement of statements) { - const trimmedStatement = statement.trim(); - - // Skip empty statements and transaction control statements (handled externally) - if (trimmedStatement.length === 0 || - trimmedStatement.toUpperCase().startsWith('BEGIN TRANSACTION') || - trimmedStatement.toUpperCase().startsWith('COMMIT') || - trimmedStatement.toUpperCase().startsWith('ROLLBACK')) { - continue; - } - - this.db.run(trimmedStatement); - } - } catch (error) { - console.error('Error executing raw SQL:', error); - throw error; - } - } - - /** - * Get all passkeys for a specific relying party (rpId) - * @param rpId - The relying party identifier (domain) - * @returns Array of passkey objects with credential info - */ - public getPasskeysByRpId(rpId: string): Array { - if (!this.db) { - throw new Error('Database not initialized'); - } - - const query = ` - SELECT - p.Id, - p.ItemId, - p.RpId, - p.UserHandle, - p.PublicKey, - p.PrivateKey, - p.DisplayName, - p.PrfKey, - p.AdditionalData, - p.CreatedAt, - p.UpdatedAt, - p.IsDeleted, - i.Name as ServiceName, - (SELECT fv.Value FROM FieldValues fv WHERE fv.ItemId = i.Id AND fv.FieldKey = '${FieldKey.LoginUsername}' AND fv.IsDeleted = 0 LIMIT 1) as Username - FROM Passkeys p - INNER JOIN Items i ON p.ItemId = i.Id - WHERE p.RpId = ? AND p.IsDeleted = 0 - AND i.IsDeleted = 0 AND i.DeletedAt IS NULL - ORDER BY p.CreatedAt DESC - `; - - const results = this.executeQuery(query, [rpId]); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return results.map((row: any) => ({ - Id: row.Id, - ItemId: row.ItemId, - RpId: row.RpId, - UserHandle: row.UserHandle, - PublicKey: row.PublicKey, - PrivateKey: row.PrivateKey, - DisplayName: row.DisplayName, - PrfKey: row.PrfKey, - AdditionalData: row.AdditionalData, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - IsDeleted: row.IsDeleted, - Username: row.Username, - ServiceName: row.ServiceName - })); - } - - /** - * Get a passkey by its ID - * @param passkeyId - The passkey ID - * @returns The passkey object or null if not found - */ - public getPasskeyById(passkeyId: string): (Passkey & { Username?: string | null; ServiceName?: string | null }) | null { - if (!this.db) { - throw new Error('Database not initialized'); - } - - const query = ` - SELECT - p.Id, - p.ItemId, - p.RpId, - p.UserHandle, - p.PublicKey, - p.PrivateKey, - p.DisplayName, - p.PrfKey, - p.AdditionalData, - p.CreatedAt, - p.UpdatedAt, - p.IsDeleted, - i.Name as ServiceName, - (SELECT fv.Value FROM FieldValues fv WHERE fv.ItemId = i.Id AND fv.FieldKey = '${FieldKey.LoginUsername}' AND fv.IsDeleted = 0 LIMIT 1) as Username - FROM Passkeys p - INNER JOIN Items i ON p.ItemId = i.Id - WHERE p.Id = ? AND p.IsDeleted = 0 - AND i.IsDeleted = 0 AND i.DeletedAt IS NULL - `; - - const results = this.executeQuery(query, [passkeyId]); - - if (results.length === 0) { - return null; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const row: any = results[0]; - return { - Id: row.Id, - ItemId: row.ItemId, - RpId: row.RpId, - UserHandle: row.UserHandle, - PublicKey: row.PublicKey, - PrivateKey: row.PrivateKey, - DisplayName: row.DisplayName, - PrfKey: row.PrfKey, - AdditionalData: row.AdditionalData, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - IsDeleted: row.IsDeleted, - Username: row.Username, - ServiceName: row.ServiceName - }; - } - - /** - * Get all passkeys for a specific item - * @param itemId - The item ID - * @returns Array of passkey objects - */ - public getPasskeysByItemId(itemId: string): Passkey[] { - if (!this.db) { - throw new Error('Database not initialized'); - } - - const query = ` - SELECT - p.Id, - p.ItemId, - p.RpId, - p.UserHandle, - p.PublicKey, - p.PrivateKey, - p.DisplayName, - p.PrfKey, - p.AdditionalData, - p.CreatedAt, - p.UpdatedAt, - p.IsDeleted - FROM Passkeys p - WHERE p.ItemId = ? AND p.IsDeleted = 0 - ORDER BY p.CreatedAt DESC - `; - - const results = this.executeQuery(query, [itemId]); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return results.map((row: any) => ({ - Id: row.Id, - ItemId: row.ItemId, - RpId: row.RpId, - UserHandle: row.UserHandle, - PublicKey: row.PublicKey, - PrivateKey: row.PrivateKey, - DisplayName: row.DisplayName, - PrfKey: row.PrfKey, - AdditionalData: row.AdditionalData, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - IsDeleted: row.IsDeleted - })); - } - - /** - * Create a new passkey linked to an item - * @param passkey - The passkey object to create - */ - public async createPasskey(passkey: Omit): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - const query = ` - INSERT INTO Passkeys ( - Id, ItemId, RpId, UserHandle, PublicKey, PrivateKey, - PrfKey, DisplayName, AdditionalData, CreatedAt, UpdatedAt, IsDeleted - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `; - - // Convert PrfKey to Uint8Array if it's a number array - let prfKeyData: Uint8Array | null = null; - if (passkey.PrfKey) { - prfKeyData = passkey.PrfKey instanceof Uint8Array ? passkey.PrfKey : new Uint8Array(passkey.PrfKey); - } - - // Convert UserHandle to Uint8Array if it's a number array - let userHandleData: Uint8Array | null = null; - if (passkey.UserHandle) { - userHandleData = passkey.UserHandle instanceof Uint8Array ? passkey.UserHandle : new Uint8Array(passkey.UserHandle); - } - - this.executeUpdate(query, [ - passkey.Id, - passkey.ItemId, - passkey.RpId, - userHandleData, - passkey.PublicKey, - passkey.PrivateKey, - prfKeyData, - passkey.DisplayName, - passkey.AdditionalData ?? null, - currentDateTime, - currentDateTime, - 0 - ]); - - await this.commitTransaction(); - } catch (error) { - this.rollbackTransaction(); - console.error('Error creating passkey:', error); - throw error; - } - } - - /** - * Delete a passkey by its ID (soft delete) - * @param passkeyId - The ID of the passkey to delete - * @returns The number of rows updated - */ - public async deletePasskeyById(passkeyId: string): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - const query = ` - UPDATE Passkeys - SET IsDeleted = 1, - UpdatedAt = ? - WHERE Id = ? - `; - - const result = this.executeUpdate(query, [currentDateTime, passkeyId]); - - await this.commitTransaction(); - return result; - } catch (error) { - this.rollbackTransaction(); - console.error('Error deleting passkey:', error); - throw error; - } - } - - /** - * Delete all passkeys for a specific item (soft delete) - * @param itemId - The ID of the item - * @returns The number of rows updated - */ - public async deletePasskeysByItemId(itemId: string): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - const query = ` - UPDATE Passkeys - SET IsDeleted = 1, - UpdatedAt = ? - WHERE ItemId = ? - `; - - const result = this.executeUpdate(query, [currentDateTime, itemId]); - - await this.commitTransaction(); - return result; - } catch (error) { - this.rollbackTransaction(); - console.error('Error deleting passkeys for item:', error); - throw error; - } - } - - /** - * Update a passkey's display name - * @param passkeyId - The ID of the passkey to update - * @param displayName - The new display name - * @returns The number of rows updated - */ - public async updatePasskeyDisplayName(passkeyId: string, displayName: string): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - const query = ` - UPDATE Passkeys - SET DisplayName = ?, - UpdatedAt = ? - WHERE Id = ? - `; - - const result = this.executeUpdate(query, [displayName, currentDateTime, passkeyId]); - - await this.commitTransaction(); - return result; - } catch (error) { - this.rollbackTransaction(); - console.error('Error updating passkey display name:', error); - throw error; - } - } - - /** - * Create a new item with field-based structure - * @param item The item object to insert - * @param attachments Optional attachments to associate with the item - * @param totpCodes Optional TOTP codes to associate with the item - * @returns The ID of the created item - */ - public async createItem(item: Item, attachments: Attachment[] = [], totpCodes: TotpCode[] = []): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - const itemId = item.Id || crypto.randomUUID().toUpperCase(); - - // 1. Handle Logo - get or create logo entry, or link to existing logo by source - let logoId: string | null = null; - const urlField = item.Fields?.find(f => f.FieldKey === 'login.url'); - const urlValue = urlField?.Value; - const urlString = Array.isArray(urlValue) ? urlValue[0] : urlValue; - const source = SqliteClient.extractSourceFromUrl(urlString); - - if (item.Logo) { - const logoData = SqliteClient.convertLogoToUint8Array(item.Logo); - if (logoData) { - logoId = this.getOrCreateLogoId(source, logoData, currentDateTime); - } - } else if (source !== 'unknown') { - // No new logo provided, but check if an existing logo exists for this source - logoId = this.getLogoIdForSource(source); - } - - // 2. Insert Item - const itemQuery = ` - INSERT INTO Items (Id, Name, ItemType, LogoId, FolderId, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`; - - this.executeUpdate(itemQuery, [ - itemId, - item.Name ?? null, - item.ItemType, - logoId, - item.FolderId ?? null, - currentDateTime, - currentDateTime, - 0 - ]); - - // 3. Insert FieldValues for all fields - if (item.Fields && item.Fields.length > 0) { - for (const field of item.Fields) { - // Skip empty fields - if (!field.Value || (typeof field.Value === 'string' && field.Value.trim() === '')) { - continue; - } - - const isCustomField = field.FieldKey.startsWith('custom_'); - let fieldDefinitionId = null; - - // For custom fields, create or get FieldDefinition - if (isCustomField) { - // Check if FieldDefinition already exists for this custom field - const existingDefQuery = ` - SELECT Id FROM FieldDefinitions - WHERE Id = ?`; - - const existingDef = this.executeQuery<{ Id: string }>(existingDefQuery, [field.FieldKey]); - - if (existingDef.length === 0) { - // Create new FieldDefinition for custom field - const fieldDefQuery = ` - INSERT INTO FieldDefinitions (Id, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; - - this.executeUpdate(fieldDefQuery, [ - field.FieldKey, // Use the custom_ ID as the FieldDefinition ID - field.FieldType, - field.Label, - 0, // IsMultiValue - field.IsHidden ? 1 : 0, - 0, // EnableHistory - field.DisplayOrder ?? 0, - item.ItemType, // ApplicableToTypes (single type for now) - currentDateTime, - currentDateTime, - 0 - ]); - } - - fieldDefinitionId = field.FieldKey; // FieldDefinitionId = custom field ID - } - - // Handle multi-value fields by creating separate FieldValue records - const values = Array.isArray(field.Value) ? field.Value : [field.Value]; - - for (let i = 0; i < values.length; i++) { - const value = values[i]; - - // Skip empty values - if (!value || (typeof value === 'string' && value.trim() === '')) { - continue; - } - - const fieldValueId = crypto.randomUUID().toUpperCase(); - const fieldQuery = ` - INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; - - this.executeUpdate(fieldQuery, [ - fieldValueId, - itemId, - fieldDefinitionId, // NULL for system fields, custom field ID for custom fields - isCustomField ? null : field.FieldKey, // FieldKey set for system fields only - value, // Store each value separately, not as JSON - field.DisplayOrder ?? 0, - currentDateTime, - currentDateTime, - 0 - ]); - } - } - } - - // 3. Insert TOTP codes - for (const totpCode of totpCodes) { - const totpQuery = ` - INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?)`; - - this.executeUpdate(totpQuery, [ - totpCode.Id || crypto.randomUUID().toUpperCase(), - totpCode.Name, - totpCode.SecretKey, - itemId, - currentDateTime, - currentDateTime, - 0 - ]); - } - - // 4. Insert attachments - for (const attachment of attachments) { - const attachmentQuery = ` - INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?)`; - - // Convert number[] to Uint8Array if needed - const blobData = attachment.Blob instanceof Uint8Array - ? attachment.Blob - : new Uint8Array(attachment.Blob); - - this.executeUpdate(attachmentQuery, [ - attachment.Id || crypto.randomUUID().toUpperCase(), - attachment.Filename, - blobData, - itemId, - currentDateTime, - currentDateTime, - 0 - ]); - } - - await this.commitTransaction(); - return itemId; - } catch (error) { - this.rollbackTransaction(); - console.error('Error creating item:', error); - throw error; - } - } - - /** - * Update an existing item with field-based structure - * @param item The item object to update - * @param originalAttachmentIds Original attachment IDs for tracking changes - * @param attachments Current attachments list - * @param originalTotpCodeIds Original TOTP code IDs for tracking changes - * @param totpCodes Current TOTP codes list - * @returns The number of rows modified - */ - public async updateItem( - item: Item, - originalAttachmentIds: string[] = [], - attachments: Attachment[] = [], - originalTotpCodeIds: string[] = [], - totpCodes: TotpCode[] = [] - ): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - // 1. Handle Logo - get or create logo entry, or link to existing logo by source - let logoId: string | null = null; - const urlField = item.Fields?.find(f => f.FieldKey === 'login.url'); - const urlValue = urlField?.Value; - const urlString = Array.isArray(urlValue) ? urlValue[0] : urlValue; - const source = SqliteClient.extractSourceFromUrl(urlString); - - if (item.Logo) { - const logoData = SqliteClient.convertLogoToUint8Array(item.Logo); - if (logoData) { - logoId = this.getOrCreateLogoId(source, logoData, currentDateTime); - } - } else if (source !== 'unknown') { - /* - * No new logo provided, but check if an existing logo exists for this source. - * This handles the case where URL was changed to a domain we already have a logo for. - */ - logoId = this.getLogoIdForSource(source); - } - - // 2. Update Item (including LogoId if a new logo was provided or existing one found) - const itemQuery = ` - UPDATE Items - SET Name = ?, - ItemType = ?, - FolderId = ?, - LogoId = COALESCE(?, LogoId), - UpdatedAt = ? - WHERE Id = ?`; - - this.executeUpdate(itemQuery, [ - item.Name ?? null, - item.ItemType, - item.FolderId ?? null, - logoId, - currentDateTime, - item.Id - ]); - - // 3. Track history for fields that have EnableHistory=true before updating - await this.trackFieldHistory(item.Id, item.Fields, currentDateTime); - - // 3. Get existing FieldValues for this item (to update in place, preserving IDs for merge) - const existingFieldValuesQuery = ` - SELECT Id, FieldKey, FieldDefinitionId, Value - FROM FieldValues - WHERE ItemId = ? AND IsDeleted = 0`; - - const existingFieldValues = this.executeQuery<{ - Id: string; - FieldKey: string | null; - FieldDefinitionId: string | null; - Value: string; - }>(existingFieldValuesQuery, [item.Id]); - - /* - * Build a map of existing FieldValues by their effective key (FieldKey for system fields, FieldDefinitionId for custom) - * Key format: "fieldKey:index" to handle multi-value fields - */ - const existingByKey = new Map(); - const fieldValueCounts = new Map(); - - for (const fv of existingFieldValues) { - const key = fv.FieldKey || fv.FieldDefinitionId || ''; - const count = fieldValueCounts.get(key) || 0; - existingByKey.set(`${key}:${count}`, { Id: fv.Id, Value: fv.Value }); - fieldValueCounts.set(key, count + 1); - } - - // Track which existing FieldValue IDs we've processed (to know which to soft-delete) - const processedIds = new Set(); - - // 4. Update existing or insert new FieldValues - if (item.Fields && item.Fields.length > 0) { - for (const field of item.Fields) { - // Skip empty fields - if (!field.Value || (typeof field.Value === 'string' && field.Value.trim() === '')) { - continue; - } - - const isCustomField = field.FieldKey.startsWith('custom_'); - let fieldDefinitionId = null; - - // For custom fields, create or update FieldDefinition - if (isCustomField) { - // Check if FieldDefinition already exists - const existingDefQuery = ` - SELECT Id FROM FieldDefinitions - WHERE Id = ? AND IsDeleted = 0`; - - const existingDef = this.executeQuery<{ Id: string }>(existingDefQuery, [field.FieldKey]); - - if (existingDef.length === 0) { - // Create new FieldDefinition - const fieldDefQuery = ` - INSERT INTO FieldDefinitions (Id, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; - - this.executeUpdate(fieldDefQuery, [ - field.FieldKey, - field.FieldType, - field.Label, - 0, // IsMultiValue - field.IsHidden ? 1 : 0, - 0, // EnableHistory - field.DisplayOrder ?? 0, - item.ItemType, - currentDateTime, - currentDateTime, - 0 - ]); - } else { - // Update existing FieldDefinition (label might have changed) - const updateDefQuery = ` - UPDATE FieldDefinitions - SET Label = ?, - FieldType = ?, - IsHidden = ?, - Weight = ?, - UpdatedAt = ? - WHERE Id = ?`; - - this.executeUpdate(updateDefQuery, [ - field.Label, - field.FieldType, - field.IsHidden ? 1 : 0, - field.DisplayOrder ?? 0, - currentDateTime, - field.FieldKey - ]); - } - - fieldDefinitionId = field.FieldKey; - } - - // Handle multi-value fields by creating separate FieldValue records - const values = Array.isArray(field.Value) ? field.Value : [field.Value]; - const effectiveKey = isCustomField ? field.FieldKey : field.FieldKey; - - for (let i = 0; i < values.length; i++) { - const value = values[i]; - - // Skip empty values - if (!value || (typeof value === 'string' && value.trim() === '')) { - continue; - } - - const lookupKey = `${effectiveKey}:${i}`; - const existing = existingByKey.get(lookupKey); - - if (existing) { - // Update existing FieldValue in place (preserves ID for merge) - processedIds.add(existing.Id); - - // Only update if value actually changed - if (existing.Value !== value) { - const updateQuery = ` - UPDATE FieldValues - SET Value = ?, - Weight = ?, - UpdatedAt = ? - WHERE Id = ?`; - - this.executeUpdate(updateQuery, [ - value, - field.DisplayOrder ?? 0, - currentDateTime, - existing.Id - ]); - } - } else { - // Insert new FieldValue - const fieldValueId = crypto.randomUUID().toUpperCase(); - const fieldQuery = ` - INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; - - this.executeUpdate(fieldQuery, [ - fieldValueId, - item.Id, - fieldDefinitionId, // NULL for system fields, custom field ID for custom fields - isCustomField ? null : field.FieldKey, // FieldKey set for system fields only - value, // Store each value separately, not as JSON - field.DisplayOrder ?? 0, - currentDateTime, - currentDateTime, - 0 - ]); - } - } - } - } - - // 5. Soft-delete any FieldValues that were not processed (removed fields) - for (const fv of existingFieldValues) { - if (!processedIds.has(fv.Id)) { - const deleteQuery = ` - UPDATE FieldValues - SET IsDeleted = 1, - UpdatedAt = ? - WHERE Id = ?`; - - this.executeUpdate(deleteQuery, [currentDateTime, fv.Id]); - } - } - - // 6. Handle TOTP codes - for (const totpCode of totpCodes) { - const wasOriginal = originalTotpCodeIds.includes(totpCode.Id); - - if (totpCode.IsDeleted) { - // Soft-delete existing TOTP codes - if (wasOriginal) { - const deleteTotpQuery = ` - UPDATE TotpCodes - SET IsDeleted = 1, - UpdatedAt = ? - WHERE Id = ?`; - this.executeUpdate(deleteTotpQuery, [currentDateTime, totpCode.Id]); - } - } else if (wasOriginal) { - // Update existing TOTP code - const updateTotpQuery = ` - UPDATE TotpCodes - SET Name = ?, - SecretKey = ?, - UpdatedAt = ? - WHERE Id = ?`; - this.executeUpdate(updateTotpQuery, [ - totpCode.Name, - totpCode.SecretKey, - currentDateTime, - totpCode.Id - ]); - } else { - // Insert new TOTP code - const insertTotpQuery = ` - INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?)`; - this.executeUpdate(insertTotpQuery, [ - totpCode.Id || crypto.randomUUID().toUpperCase(), - totpCode.Name, - totpCode.SecretKey, - item.Id, - currentDateTime, - currentDateTime, - 0 - ]); - } - } - - // 7. Handle attachments - for (const attachment of attachments) { - const wasOriginal = originalAttachmentIds.includes(attachment.Id); - - if (attachment.IsDeleted) { - // Soft-delete existing attachments - if (wasOriginal) { - const deleteAttachmentQuery = ` - UPDATE Attachments - SET IsDeleted = 1, - UpdatedAt = ? - WHERE Id = ?`; - this.executeUpdate(deleteAttachmentQuery, [currentDateTime, attachment.Id]); - } - } else if (!wasOriginal) { - // Insert new attachment - const blobData = attachment.Blob instanceof Uint8Array - ? attachment.Blob - : new Uint8Array(attachment.Blob); - - const insertAttachmentQuery = ` - INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?)`; - this.executeUpdate(insertAttachmentQuery, [ - attachment.Id || crypto.randomUUID().toUpperCase(), - attachment.Filename, - blobData, - item.Id, - currentDateTime, - currentDateTime, - 0 - ]); - } - // Note: Existing attachments are not updated (blob data doesn't change) - } - - await this.commitTransaction(); - return 1; - } catch (error) { - this.rollbackTransaction(); - console.error('Error updating item:', error); - throw error; - } - } - - /** - * Track field history for fields that have EnableHistory=true. - * This method should be called before updating/deleting field values. - * @param itemId - The ID of the item - * @param newFields - The new field values - * @param currentDateTime - The current timestamp - */ - private async trackFieldHistory(itemId: string, newFields: ItemField[], currentDateTime: string): Promise { - // Get existing field values from database - const existingFieldsQuery = ` - SELECT FieldKey, Value - FROM FieldValues - WHERE ItemId = ? AND IsDeleted = 0 AND FieldKey IS NOT NULL`; - - const existingFields = this.executeQuery<{FieldKey: string, Value: string}>(existingFieldsQuery, [itemId]); - - // Create a map of existing values by FieldKey - const existingValuesMap: {[key: string]: string[]} = {}; - existingFields.forEach(field => { - if (!existingValuesMap[field.FieldKey]) { - existingValuesMap[field.FieldKey] = []; - } - existingValuesMap[field.FieldKey].push(field.Value); - }); - - // Check each new field to see if it has EnableHistory and if the value changed - for (const newField of newFields) { - // Skip custom fields (only track system fields for now) - if (newField.FieldKey.startsWith('custom_')) { - continue; - } - - // Get system field definition - const systemField = getSystemField(newField.FieldKey); - if (!systemField || !systemField.EnableHistory) { - continue; - } - - // Get old and new values - const oldValues = existingValuesMap[newField.FieldKey] || []; - const newValues = Array.isArray(newField.Value) ? newField.Value : [newField.Value]; - - // Check if values changed - const valuesChanged = oldValues.length !== newValues.length || - !oldValues.every((val, idx) => val === newValues[idx]); - - if (valuesChanged && oldValues.length > 0) { - // Create history record for the old value - const historyId = crypto.randomUUID().toUpperCase(); - // Store just the values as JSON array - const valueSnapshot = JSON.stringify(oldValues); - - const historyQuery = ` - INSERT INTO FieldHistories (Id, ItemId, FieldDefinitionId, FieldKey, ValueSnapshot, ChangedAt, CreatedAt, UpdatedAt, IsDeleted) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; - - this.executeUpdate(historyQuery, [ - historyId, - itemId, - null, // FieldDefinitionId is NULL for system fields - newField.FieldKey, // FieldKey for system fields - valueSnapshot, - currentDateTime, - currentDateTime, - currentDateTime, - 0 - ]); - - // Prune old history records if we exceed the limit - await this.pruneFieldHistory(itemId, newField.FieldKey, currentDateTime); - } - } - } - - /** - * Prune old field history records, keeping only the most recent MAX_FIELD_HISTORY_RECORDS. - * @param itemId - The ID of the item - * @param fieldKey - The field key to prune history for - * @param currentDateTime - The current timestamp - */ - private async pruneFieldHistory(itemId: string, fieldKey: string, currentDateTime: string): Promise { - // Get all history records for this field - const historyQuery = ` - SELECT Id, ChangedAt - FROM FieldHistories - WHERE ItemId = ? AND FieldKey = ? AND IsDeleted = 0 - ORDER BY ChangedAt DESC`; - - const matchingHistory = this.executeQuery<{Id: string, ChangedAt: string}>(historyQuery, [itemId, fieldKey]); - - if (matchingHistory.length > MAX_FIELD_HISTORY_RECORDS) { - // Soft delete the oldest records beyond the limit - const recordsToDelete = matchingHistory.slice(MAX_FIELD_HISTORY_RECORDS); - const idsToDelete = recordsToDelete.map(r => r.Id); - - if (idsToDelete.length > 0) { - const placeholders = idsToDelete.map(() => '?').join(','); - const deleteQuery = ` - UPDATE FieldHistories - SET IsDeleted = 1, UpdatedAt = ? - WHERE Id IN (${placeholders})`; - - this.executeUpdate(deleteQuery, [currentDateTime, ...idsToDelete]); - } - } - } - - /** - * Get field history for a specific field. - * Returns history records ordered by ChangedAt descending (most recent first). - * @param itemId - The ID of the item - * @param fieldKey - The field key to get history for - * @returns Array of field history records - */ - public getFieldHistory(itemId: string, fieldKey: string): FieldHistory[] { - if (!this.db) { - throw new Error('Database not initialized'); - } - - const query = ` - SELECT - Id, - ItemId, - FieldKey, - ValueSnapshot, - ChangedAt, - CreatedAt, - UpdatedAt - FROM FieldHistories - WHERE ItemId = ? AND FieldKey = ? AND IsDeleted = 0 - ORDER BY ChangedAt DESC - LIMIT ?`; - - const results = this.executeQuery<{ - Id: string; - ItemId: string; - FieldKey: string; - ValueSnapshot: string; - ChangedAt: string; - CreatedAt: string; - UpdatedAt: string; - }>(query, [itemId, fieldKey, MAX_FIELD_HISTORY_RECORDS]); - - return results.map(row => ({ - Id: row.Id, - ItemId: row.ItemId, - FieldKey: row.FieldKey, - ValueSnapshot: row.ValueSnapshot, - ChangedAt: row.ChangedAt, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt - })); - } - - /** - * Move an item to "Recently Deleted" (trash) by setting DeletedAt timestamp. - * Item can be restored within retention period (default 30 days). - * @param itemId - The ID of the item to trash - * @returns The number of rows updated - */ - public async trashItem(itemId: string): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - const query = ` - UPDATE Items - SET DeletedAt = ?, - UpdatedAt = ? - WHERE Id = ? AND IsDeleted = 0`; - - const result = this.executeUpdate(query, [currentDateTime, currentDateTime, itemId]); - - await this.commitTransaction(); - return result; - } catch (error) { - this.rollbackTransaction(); - console.error('Error trashing item:', error); - throw error; - } - } - - /** - * Restore an item from "Recently Deleted" by clearing DeletedAt timestamp. - * @param itemId - The ID of the item to restore - * @returns The number of rows updated - */ - public async restoreItem(itemId: string): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - const query = ` - UPDATE Items - SET DeletedAt = NULL, - UpdatedAt = ? - WHERE Id = ? AND IsDeleted = 0 AND DeletedAt IS NOT NULL`; - - const result = this.executeUpdate(query, [currentDateTime, itemId]); - - await this.commitTransaction(); - return result; - } catch (error) { - this.rollbackTransaction(); - console.error('Error restoring item:', error); - throw error; - } - } - - /** - * Permanently delete an item - converts to tombstone for sync. - * Hard deletes all child entities and marks item as IsDeleted=1. - * Also cleans up orphaned logos (logos no longer referenced by any item). - * @param itemId - The ID of the item to permanently delete - * @returns The number of rows updated - */ - public async permanentlyDeleteItem(itemId: string): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - // 0. Get the LogoId before we clear it (for orphan cleanup) - const logoQuery = `SELECT LogoId FROM Items WHERE Id = ?`; - const logoResult = this.executeQuery<{ LogoId: string | null }>(logoQuery, [itemId]); - const logoId = logoResult.length > 0 ? logoResult[0].LogoId : null; - - // 1. Hard delete all FieldValues for this item - this.executeUpdate(`DELETE FROM FieldValues WHERE ItemId = ?`, [itemId]); - - // 2. Hard delete all FieldHistories for this item - this.executeUpdate(`DELETE FROM FieldHistories WHERE ItemId = ?`, [itemId]); - - // 3. Hard delete all Passkeys for this item - this.executeUpdate(`DELETE FROM Passkeys WHERE ItemId = ?`, [itemId]); - - // 4. Hard delete all TotpCodes for this item - this.executeUpdate(`DELETE FROM TotpCodes WHERE ItemId = ?`, [itemId]); - - // 5. Hard delete all Attachments for this item (including blob data) - this.executeUpdate(`DELETE FROM Attachments WHERE ItemId = ?`, [itemId]); - - // 6. Hard delete all ItemTags for this item - this.executeUpdate(`DELETE FROM ItemTags WHERE ItemId = ?`, [itemId]); - - // 7. Convert item to tombstone (gut it, keep shell for sync) - const itemQuery = ` - UPDATE Items - SET IsDeleted = 1, - Name = NULL, - LogoId = NULL, - FolderId = NULL, - UpdatedAt = ? - WHERE Id = ?`; - - const result = this.executeUpdate(itemQuery, [currentDateTime, itemId]); - - // 8. Clean up orphaned logo if this was the last item using it - if (logoId) { - const logoUsageQuery = ` - SELECT COUNT(*) as count FROM Items - WHERE LogoId = ? AND IsDeleted = 0`; - const usageResult = this.executeQuery<{ count: number }>(logoUsageQuery, [logoId]); - const usageCount = usageResult.length > 0 ? usageResult[0].count : 0; - - if (usageCount === 0) { - // No other items reference this logo, hard delete it - this.executeUpdate(`DELETE FROM Logos WHERE Id = ?`, [logoId]); - console.debug(`[SqliteClient] Deleted orphaned logo: ${logoId}`); - } - } - - await this.commitTransaction(); - return result; - } catch (error) { - this.rollbackTransaction(); - console.error('Error permanently deleting item:', error); - throw error; - } - } - - /** - * Get all items in "Recently Deleted" (where DeletedAt is set but not permanently deleted). - * @returns Array of trashed Item objects (empty array if Items table doesn't exist yet) - */ - public getRecentlyDeletedItems(): Item[] { - let items; - try { - const query = ` - SELECT DISTINCT - i.Id, - i.Name, - i.ItemType, - i.FolderId, - f.Name as FolderPath, - l.FileData as Logo, - i.DeletedAt, - CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, - CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, - CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, - i.CreatedAt, - i.UpdatedAt - FROM Items i - LEFT JOIN Logos l ON i.LogoId = l.Id - LEFT JOIN Folders f ON i.FolderId = f.Id - WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL - ORDER BY i.DeletedAt DESC`; - - items = this.executeQuery(query); - } catch (error) { - // Items table may not exist in older vault versions - return empty array - if (error instanceof Error && error.message.includes('no such table')) { - return []; - } - throw error; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const itemIds = items.map((i: any) => i.Id); - if (itemIds.length === 0) { - return []; - } - - // Get all field values - const fieldsQuery = ` - SELECT - fv.ItemId, - fv.FieldKey, - fv.FieldDefinitionId, - fd.Label as CustomLabel, - fd.FieldType as CustomFieldType, - fd.IsHidden as CustomIsHidden, - fv.Value, - fv.Weight as DisplayOrder - FROM FieldValues fv - LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id - WHERE fv.ItemId IN (${itemIds.map(() => '?').join(',')}) - AND fv.IsDeleted = 0 - ORDER BY fv.ItemId, fv.Weight`; - - const fieldRows = this.executeQuery<{ - ItemId: string; - FieldKey: string | null; - FieldDefinitionId: string | null; - CustomLabel: string | null; - CustomFieldType: string | null; - CustomIsHidden: number | null; - Value: string; - DisplayOrder: number; - }>(fieldsQuery, itemIds); - - /* Process fields. System fields use FieldKey for Label (translation happens in UI layer) */ - const fields = fieldRows.map(row => { - if (row.FieldKey) { - const systemField = getSystemField(row.FieldKey); - return { - ItemId: row.ItemId, - FieldKey: row.FieldKey, - Label: row.FieldKey, // UI layer translates via fieldLabels.* - FieldType: systemField?.FieldType || FieldTypes.Text, - IsHidden: systemField?.IsHidden ? 1 : 0, - Value: row.Value, - DisplayOrder: row.DisplayOrder - }; - } else { - return { - ItemId: row.ItemId, - FieldKey: row.FieldDefinitionId || '', - Label: row.CustomLabel || '', - FieldType: row.CustomFieldType || FieldTypes.Text, - IsHidden: row.CustomIsHidden || 0, - Value: row.Value, - DisplayOrder: row.DisplayOrder - }; - } - }); - - // Group fields by item ID - const fieldsByItem = new Map(); - fields.forEach(field => { - const existing = fieldsByItem.get(field.ItemId) || []; - existing.push(field); - fieldsByItem.set(field.ItemId, existing); - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return items.map((row: any) => { - const itemFields = fieldsByItem.get(row.Id) || []; - return { - Id: row.Id, - Name: row.Name, - ItemType: row.ItemType, - FolderId: row.FolderId, - FolderPath: row.FolderPath, - Logo: row.Logo ? new Uint8Array(row.Logo) : undefined, - DeletedAt: row.DeletedAt, - HasPasskey: row.HasPasskey === 1, - HasAttachment: row.HasAttachment === 1, - HasTotp: row.HasTotp === 1, - Fields: itemFields.map(f => ({ - FieldKey: f.FieldKey, - Label: f.Label, - FieldType: f.FieldType as FieldType, - Value: f.Value, - IsHidden: f.IsHidden === 1, - DisplayOrder: f.DisplayOrder - })), - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt - }; - }); - } - - /** - * Get count of items in "Recently Deleted". - * @returns Number of trashed items (0 if Items table doesn't exist yet) - */ - public getRecentlyDeletedCount(): number { - try { - const query = ` - SELECT COUNT(*) as count - FROM Items - WHERE IsDeleted = 0 AND DeletedAt IS NOT NULL`; - - const result = this.executeQuery<{ count: number }>(query); - return result[0]?.count || 0; - } catch (error) { - // Table may not exist in older vault versions - return 0 - if (error instanceof Error && error.message.includes('no such table')) { - return 0; - } - throw error; - } - } - - /** - * Delete an item by ID (soft delete) - DEPRECATED, use trashItem instead. - * Kept for backwards compatibility. - * @param itemId - The ID of the item to delete - * @returns The number of rows updated - * @deprecated Use trashItem() for new code - */ - public async deleteItemById(itemId: string): Promise { - // Redirect to new trash functionality - return this.trashItem(itemId); - } - - /** - * ===== FOLDER OPERATIONS ===== - */ - - /** - * Create a new folder - * @param name - The name of the folder - * @param parentFolderId - Optional parent folder ID for nested folders - * @returns The ID of the created folder - */ - public async createFolder(name: string, parentFolderId?: string | null): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const folderId = crypto.randomUUID(); - const currentDateTime = dateFormatter.now(); - - const query = ` - INSERT INTO Folders (Id, Name, ParentFolderId, Weight, IsDeleted, CreatedAt, UpdatedAt) - VALUES (?, ?, ?, 0, 0, ?, ?)`; - - this.executeUpdate(query, [ - folderId, - name, - parentFolderId || null, - currentDateTime, - currentDateTime - ]); - - await this.commitTransaction(); - return folderId; - } catch (error) { - this.rollbackTransaction(); - console.error('Error creating folder:', error); - throw error; - } - } - - /** - * Get all folders - * @returns Array of folder objects (empty array if Folders table doesn't exist yet) - */ - public getAllFolders(): Array<{ Id: string; Name: string; ParentFolderId: string | null; Weight: number }> { - try { - const query = ` - SELECT Id, Name, ParentFolderId, Weight - FROM Folders - WHERE IsDeleted = 0 - ORDER BY Weight, Name`; - - return this.executeQuery(query); - } catch (error) { - // Table may not exist in older vault versions - return empty array - if (error instanceof Error && error.message.includes('no such table')) { - return []; - } - throw error; - } - } - - /** - * Update a folder's name - * @param folderId - The ID of the folder to update - * @param name - The new name for the folder - * @returns The number of rows updated - */ - public async updateFolder(folderId: string, name: string): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - const query = ` - UPDATE Folders - SET Name = ?, - UpdatedAt = ? - WHERE Id = ?`; - - const result = this.executeUpdate(query, [name, currentDateTime, folderId]); - - await this.commitTransaction(); - return result; - } catch (error) { - this.rollbackTransaction(); - console.error('Error updating folder:', error); - throw error; - } - } - - /** - * Delete a folder (soft delete) - * Note: Items in the folder will have their FolderId set to NULL - * @param folderId - The ID of the folder to delete - * @returns The number of rows updated - */ - public async deleteFolder(folderId: string): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - // 1. Remove folder reference from all items in this folder - const itemsQuery = ` - UPDATE Items - SET FolderId = NULL, - UpdatedAt = ? - WHERE FolderId = ?`; - - this.executeUpdate(itemsQuery, [currentDateTime, folderId]); - - // 2. Soft delete the folder - const folderQuery = ` - UPDATE Folders - SET IsDeleted = 1, - UpdatedAt = ? - WHERE Id = ?`; - - const result = this.executeUpdate(folderQuery, [currentDateTime, folderId]); - - await this.commitTransaction(); - return result; - } catch (error) { - this.rollbackTransaction(); - console.error('Error deleting folder:', error); - throw error; - } - } - - /** - * Delete a folder and all items within it (soft delete both folder and items) - * Items are moved to "Recently Deleted" (trash) - * @param folderId - The ID of the folder to delete - * @returns The number of items trashed - */ - public async deleteFolderWithContents(folderId: string): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - /* - * 1. Move all items in this folder to trash (set DeletedAt) and clear FolderId - * so that when restored, items won't reference a deleted folder - */ - const itemsQuery = ` - UPDATE Items - SET DeletedAt = ?, - UpdatedAt = ?, - FolderId = NULL - WHERE FolderId = ? AND IsDeleted = 0 AND DeletedAt IS NULL`; - - const itemsDeleted = this.executeUpdate(itemsQuery, [currentDateTime, currentDateTime, folderId]); - - // 2. Soft delete the folder - const folderQuery = ` - UPDATE Folders - SET IsDeleted = 1, - UpdatedAt = ? - WHERE Id = ?`; - - this.executeUpdate(folderQuery, [currentDateTime, folderId]); - - await this.commitTransaction(); - return itemsDeleted; - } catch (error) { - this.rollbackTransaction(); - console.error('Error deleting folder with contents:', error); - throw error; - } - } - - /** - * Move an item to a folder - * @param itemId - The ID of the item to move - * @param folderId - The ID of the destination folder (null to remove from folder) - * @returns The number of rows updated - */ - public async moveItemToFolder(itemId: string, folderId: string | null): Promise { - if (!this.db) { - throw new Error('Database not initialized'); - } - - try { - this.beginTransaction(); - - const currentDateTime = dateFormatter.now(); - - const query = ` - UPDATE Items - SET FolderId = ?, - UpdatedAt = ? - WHERE Id = ?`; - - const result = this.executeUpdate(query, [folderId, currentDateTime, itemId]); - - await this.commitTransaction(); - return result; - } catch (error) { - this.rollbackTransaction(); - console.error('Error moving item to folder:', error); - throw error; - } - } - - /** - * Get folder by ID - * @param folderId - The ID of the folder to fetch - * @returns Folder object or null if not found (or if Folders table doesn't exist) - */ - public getFolderById(folderId: string): { Id: string; Name: string; ParentFolderId: string | null } | null { - try { - const query = ` - SELECT Id, Name, ParentFolderId - FROM Folders - WHERE Id = ? AND IsDeleted = 0`; - - const results = this.executeQuery<{ Id: string; Name: string; ParentFolderId: string | null }>(query, [folderId]); - return results.length > 0 ? results[0] : null; - } catch (error) { - // Table may not exist in older vault versions - return null - if (error instanceof Error && error.message.includes('no such table')) { - return null; - } - throw error; - } - } } -export default SqliteClient; \ No newline at end of file +export default SqliteClient; diff --git a/apps/browser-extension/src/utils/db/BaseRepository.ts b/apps/browser-extension/src/utils/db/BaseRepository.ts new file mode 100644 index 000000000..85a3796a0 --- /dev/null +++ b/apps/browser-extension/src/utils/db/BaseRepository.ts @@ -0,0 +1,137 @@ +import type { Database } from 'sql.js'; + +import * as dateFormatter from '@/utils/dateFormatter'; + +export type SqliteBindValue = string | number | null | Uint8Array; + +/** + * Interface for the core database operations needed by repositories. + */ +export interface IDatabaseClient { + getDb(): Database | null; + executeQuery(query: string, params?: SqliteBindValue[]): T[]; + executeUpdate(query: string, params?: SqliteBindValue[]): number; + beginTransaction(): void; + commitTransaction(): Promise; + rollbackTransaction(): void; +} + +/** + * Base repository class with common database operations. + * Provides transaction handling, soft delete, and other shared functionality. + */ +export abstract class BaseRepository { + constructor(protected client: IDatabaseClient) {} + + /** + * Execute a function within a transaction. + * Automatically handles begin, commit, and rollback. + * @param fn - The function to execute within the transaction + * @returns The result of the function + */ + protected async withTransaction(fn: () => T | Promise): Promise { + this.client.beginTransaction(); + try { + const result = await fn(); + await this.client.commitTransaction(); + return result; + } catch (error) { + this.client.rollbackTransaction(); + throw error; + } + } + + /** + * Soft delete a record by setting IsDeleted = 1. + * @param table - The table name + * @param id - The record ID + * @returns Number of rows affected + */ + protected softDelete(table: string, id: string): number { + const now = dateFormatter.now(); + return this.client.executeUpdate( + `UPDATE ${table} SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`, + [now, id] + ); + } + + /** + * Soft delete records by a foreign key. + * @param table - The table name + * @param foreignKey - The foreign key column name + * @param foreignKeyValue - The foreign key value + * @returns Number of rows affected + */ + protected softDeleteByForeignKey(table: string, foreignKey: string, foreignKeyValue: string): number { + const now = dateFormatter.now(); + return this.client.executeUpdate( + `UPDATE ${table} SET IsDeleted = 1, UpdatedAt = ? WHERE ${foreignKey} = ?`, + [now, foreignKeyValue] + ); + } + + /** + * Hard delete a record permanently. + * @param table - The table name + * @param id - The record ID + * @returns Number of rows affected + */ + protected hardDelete(table: string, id: string): number { + return this.client.executeUpdate(`DELETE FROM ${table} WHERE Id = ?`, [id]); + } + + /** + * Hard delete records by a foreign key. + * @param table - The table name + * @param foreignKey - The foreign key column name + * @param foreignKeyValue - The foreign key value + * @returns Number of rows affected + */ + protected hardDeleteByForeignKey(table: string, foreignKey: string, foreignKeyValue: string): number { + return this.client.executeUpdate( + `DELETE FROM ${table} WHERE ${foreignKey} = ?`, + [foreignKeyValue] + ); + } + + /** + * Check if a table exists in the database. + * @param tableName - The name of the table to check + * @returns True if the table exists + */ + protected tableExists(tableName: string): boolean { + const results = this.client.executeQuery<{ name: string }>( + `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, + [tableName] + ); + return results.length > 0; + } + + /** + * Generate a new UUID in uppercase format. + * @returns A new UUID string + */ + protected generateId(): string { + return crypto.randomUUID().toUpperCase(); + } + + /** + * Get the current timestamp in the standard format. + * @returns Current timestamp string + */ + protected now(): string { + return dateFormatter.now(); + } + + /** + * Build a parameterized IN clause for SQL queries. + * @param values - Array of values for the IN clause + * @returns Object with placeholders string and values array + */ + protected buildInClause(values: string[]): { placeholders: string; values: string[] } { + return { + placeholders: values.map(() => '?').join(','), + values + }; + } +} diff --git a/apps/browser-extension/src/utils/db/index.ts b/apps/browser-extension/src/utils/db/index.ts new file mode 100644 index 000000000..206f5f2e4 --- /dev/null +++ b/apps/browser-extension/src/utils/db/index.ts @@ -0,0 +1,22 @@ +// Base +export { BaseRepository, type IDatabaseClient, type SqliteBindValue } from './BaseRepository'; + +// Mappers +export { FieldMapper, type FieldRow } from './mappers/FieldMapper'; +export { ItemMapper, type ItemRow, type TagRow } from './mappers/ItemMapper'; +export { PasskeyMapper, type PasskeyRow, type PasskeyWithItemRow, type PasskeyWithItem } from './mappers/PasskeyMapper'; + +// Queries +export { + ItemQueries, + FieldValueQueries, + FieldDefinitionQueries, + FieldHistoryQueries +} from './queries/ItemQueries'; + +// Repositories +export { ItemRepository } from './repositories/ItemRepository'; +export { PasskeyRepository } from './repositories/PasskeyRepository'; +export { FolderRepository, type Folder } from './repositories/FolderRepository'; +export { SettingsRepository } from './repositories/SettingsRepository'; +export { LogoRepository } from './repositories/LogoRepository'; diff --git a/apps/browser-extension/src/utils/db/mappers/FieldMapper.ts b/apps/browser-extension/src/utils/db/mappers/FieldMapper.ts new file mode 100644 index 000000000..54ac09e44 --- /dev/null +++ b/apps/browser-extension/src/utils/db/mappers/FieldMapper.ts @@ -0,0 +1,191 @@ +import type { ItemField, FieldType } from '@/utils/dist/core/models/vault'; +import { FieldTypes, getSystemField } from '@/utils/dist/core/models/vault'; + +/** + * Raw field row from database query. + */ +export interface FieldRow { + ItemId: string; + FieldKey: string | null; + FieldDefinitionId: string | null; + CustomLabel: string | null; + CustomFieldType: string | null; + CustomIsHidden: number | null; + Value: string; + DisplayOrder: number; +} + +/** + * Intermediate field representation before grouping. + */ +interface ProcessedField { + ItemId: string; + FieldKey: string; + Label: string; + FieldType: string; + IsHidden: number; + Value: string; + DisplayOrder: number; +} + +/** + * Mapper class for processing database field rows into ItemField objects. + * Handles both system fields (with FieldKey) and custom fields (with FieldDefinitionId). + */ +export class FieldMapper { + /** + * Process raw field rows from database into a map of ItemId -> ItemField[]. + * Handles system vs custom fields and multi-value field grouping. + * @param rows - Raw field rows from database + * @returns Map of ItemId to array of ItemField objects + */ + public static processFieldRows(rows: FieldRow[]): Map { + // First, convert rows to processed fields with proper metadata + const processedFields = rows.map(row => this.processFieldRow(row)); + + // Group by ItemId and FieldKey (to handle multi-value fields) + const fieldsByItem = new Map(); + const fieldValuesByKey = new Map(); + + for (const field of processedFields) { + const key = `${field.ItemId}_${field.FieldKey}`; + + // Accumulate values for the same field + if (!fieldValuesByKey.has(key)) { + fieldValuesByKey.set(key, []); + } + fieldValuesByKey.get(key)!.push(field.Value); + + // Create ItemField entry only once per unique FieldKey per item + if (!fieldsByItem.has(field.ItemId)) { + fieldsByItem.set(field.ItemId, []); + } + + const itemFields = fieldsByItem.get(field.ItemId)!; + const existingField = itemFields.find(f => f.FieldKey === field.FieldKey); + + if (!existingField) { + itemFields.push({ + FieldKey: field.FieldKey, + Label: field.Label, + FieldType: field.FieldType as FieldType, + Value: '', // Will be set below + IsHidden: field.IsHidden === 1, + DisplayOrder: field.DisplayOrder + }); + } + } + + // Set Values (single value or array for multi-value fields) + for (const [itemId, fields] of fieldsByItem) { + for (const field of fields) { + const key = `${itemId}_${field.FieldKey}`; + const values = fieldValuesByKey.get(key) || []; + + if (values.length === 1) { + field.Value = values[0]; + } else { + field.Value = values; + } + } + } + + return fieldsByItem; + } + + /** + * Process a single field row to extract proper metadata. + * System fields use FieldKey and get metadata from SystemFieldRegistry. + * Custom fields use FieldDefinitionId and get metadata from the row. + * @param row - Raw field row + * @returns Processed field with proper metadata + */ + private static processFieldRow(row: FieldRow): ProcessedField { + if (row.FieldKey) { + // System field: has FieldKey, get metadata from SystemFieldRegistry + const systemField = getSystemField(row.FieldKey); + return { + ItemId: row.ItemId, + FieldKey: row.FieldKey, + Label: row.FieldKey, // Use FieldKey as label; UI layer translates via fieldLabels.* + FieldType: systemField?.FieldType || FieldTypes.Text, + IsHidden: systemField?.IsHidden ? 1 : 0, + Value: row.Value, + DisplayOrder: row.DisplayOrder + }; + } else { + // Custom field: has FieldDefinitionId, get metadata from FieldDefinitions + return { + ItemId: row.ItemId, + FieldKey: row.FieldDefinitionId || '', // Use FieldDefinitionId as the key for custom fields + Label: row.CustomLabel || '', + FieldType: row.CustomFieldType || FieldTypes.Text, + IsHidden: row.CustomIsHidden || 0, + Value: row.Value, + DisplayOrder: row.DisplayOrder + }; + } + } + + /** + * Process field rows for a single item (without ItemId in result). + * Used when fetching a single item by ID. + * @param rows - Raw field rows for a single item + * @returns Array of ItemField objects + */ + public static processFieldRowsForSingleItem(rows: Omit[]): ItemField[] { + const fieldValuesByKey = new Map(); + const uniqueFields = new Map(); + + for (const row of rows) { + const fieldKey = row.FieldKey || row.FieldDefinitionId || ''; + + // Accumulate values + if (!fieldValuesByKey.has(fieldKey)) { + fieldValuesByKey.set(fieldKey, []); + } + fieldValuesByKey.get(fieldKey)!.push(row.Value); + + // Store field metadata (only once per FieldKey) + if (!uniqueFields.has(fieldKey)) { + if (row.FieldKey) { + // System field + const systemField = getSystemField(row.FieldKey); + uniqueFields.set(fieldKey, { + FieldKey: row.FieldKey, + Label: row.FieldKey, // Use FieldKey as label; UI layer translates via fieldLabels.* + FieldType: systemField?.FieldType || FieldTypes.Text, + IsHidden: systemField?.IsHidden ? 1 : 0, + DisplayOrder: row.DisplayOrder + }); + } else { + // Custom field + uniqueFields.set(fieldKey, { + FieldKey: fieldKey, + Label: row.CustomLabel || '', + FieldType: row.CustomFieldType || FieldTypes.Text, + IsHidden: row.CustomIsHidden || 0, + DisplayOrder: row.DisplayOrder + }); + } + } + } + + // Build fields array with proper single/multi values + return Array.from(uniqueFields.entries()).map(([fieldKey, metadata]) => { + const values = fieldValuesByKey.get(fieldKey) || []; + return { + ...metadata, + FieldType: metadata.FieldType as FieldType, + Value: values.length === 1 ? values[0] : values, + IsHidden: metadata.IsHidden === 1 + }; + }); + } +} diff --git a/apps/browser-extension/src/utils/db/mappers/ItemMapper.ts b/apps/browser-extension/src/utils/db/mappers/ItemMapper.ts new file mode 100644 index 000000000..5bda5a4fe --- /dev/null +++ b/apps/browser-extension/src/utils/db/mappers/ItemMapper.ts @@ -0,0 +1,149 @@ +import type { Item, ItemField, ItemTagRef, ItemType } from '@/utils/dist/core/models/vault'; + +/** + * Item with optional DeletedAt field for recently deleted items. + */ +export type ItemWithDeletedAt = Item & { DeletedAt?: string }; + +/** + * Raw item row from database query. + */ +export interface ItemRow { + Id: string; + Name: string; + ItemType: string; + FolderId: string | null; + FolderPath: string | null; + Logo: Uint8Array | null; + HasPasskey: number; + HasAttachment: number; + HasTotp: number; + CreatedAt: string; + UpdatedAt: string; + DeletedAt?: string | null; +} + +/** + * Raw tag row from database query. + */ +export interface TagRow { + ItemId: string; + Id: string; + Name: string; + Color: string | null; +} + +/** + * Mapper class for converting database rows to Item objects. + */ +export class ItemMapper { + /** + * Map a single database row to an Item object. + * @param row - Raw item row from database + * @param fields - Processed fields for this item + * @param tags - Tags for this item + * @returns Item object + */ + public static mapRow( + row: ItemRow, + fields: ItemField[] = [], + tags: ItemTagRef[] = [] + ): Item { + return { + Id: row.Id, + Name: row.Name, + ItemType: row.ItemType as ItemType, + Logo: row.Logo ?? undefined, + FolderId: row.FolderId, + FolderPath: row.FolderPath || null, + Tags: tags, + Fields: fields, + HasPasskey: row.HasPasskey === 1, + HasAttachment: row.HasAttachment === 1, + HasTotp: row.HasTotp === 1, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt + }; + } + + /** + * Map multiple database rows to Item objects with their fields and tags. + * @param rows - Raw item rows from database + * @param fieldsByItem - Map of ItemId to array of fields + * @param tagsByItem - Map of ItemId to array of tags + * @returns Array of Item objects + */ + public static mapRows( + rows: ItemRow[], + fieldsByItem: Map, + tagsByItem: Map + ): Item[] { + return rows.map(row => this.mapRow( + row, + fieldsByItem.get(row.Id) || [], + tagsByItem.get(row.Id) || [] + )); + } + + /** + * Group tag rows by ItemId into a map. + * @param tagRows - Raw tag rows from database + * @returns Map of ItemId to array of ItemTagRef + */ + public static groupTagsByItem(tagRows: TagRow[]): Map { + const tagsByItem = new Map(); + + for (const tag of tagRows) { + if (!tagsByItem.has(tag.ItemId)) { + tagsByItem.set(tag.ItemId, []); + } + tagsByItem.get(tag.ItemId)!.push({ + Id: tag.Id, + Name: tag.Name, + Color: tag.Color || undefined + }); + } + + return tagsByItem; + } + + /** + * Map tag rows to ItemTagRef array (for single item). + * @param tagRows - Raw tag rows without ItemId + * @returns Array of ItemTagRef + */ + public static mapTagRows(tagRows: Omit[]): ItemTagRef[] { + return tagRows.map(tag => ({ + Id: tag.Id, + Name: tag.Name, + Color: tag.Color || undefined + })); + } + + /** + * Map a single item row for recently deleted items (includes DeletedAt). + * @param row - Raw item row with DeletedAt + * @param fields - Processed fields for this item + * @returns Item object with DeletedAt + */ + public static mapDeletedItemRow( + row: ItemRow & { DeletedAt: string }, + fields: ItemField[] = [] + ): ItemWithDeletedAt { + return { + Id: row.Id, + Name: row.Name, + ItemType: row.ItemType as ItemType, + Logo: row.Logo ? new Uint8Array(row.Logo) : undefined, + FolderId: row.FolderId, + FolderPath: row.FolderPath, + DeletedAt: row.DeletedAt, + HasPasskey: row.HasPasskey === 1, + HasAttachment: row.HasAttachment === 1, + HasTotp: row.HasTotp === 1, + Fields: fields, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt + }; + } +} diff --git a/apps/browser-extension/src/utils/db/mappers/PasskeyMapper.ts b/apps/browser-extension/src/utils/db/mappers/PasskeyMapper.ts new file mode 100644 index 000000000..aebca16b8 --- /dev/null +++ b/apps/browser-extension/src/utils/db/mappers/PasskeyMapper.ts @@ -0,0 +1,102 @@ +import type { Passkey } from '@/utils/dist/core/models/vault'; + +/** + * Raw passkey row from database query. + */ +export interface PasskeyRow { + Id: string; + ItemId: string; + RpId: string; + UserHandle: Uint8Array | null; + PublicKey: string; + PrivateKey: string; + DisplayName: string; + PrfKey: Uint8Array | null; + AdditionalData: string | null; + CreatedAt: string; + UpdatedAt: string; + IsDeleted: number; +} + +/** + * Extended passkey row with item information. + */ +export interface PasskeyWithItemRow extends PasskeyRow { + Username?: string | null; + ServiceName?: string | null; +} + +/** + * Passkey with optional item information. + */ +export type PasskeyWithItem = Passkey & { + Username?: string | null; + ServiceName?: string | null; +}; + +/** + * Convert a date string to epoch milliseconds. + * @param dateString - Date string in ISO format + * @returns Epoch milliseconds + */ +function dateToEpoch(dateString: string): number { + return new Date(dateString).getTime(); +} + +/** + * Mapper class for converting database rows to Passkey objects. + */ +export class PasskeyMapper { + /** + * Map a single database row to a Passkey object. + * @param row - Raw passkey row from database + * @returns Passkey object + */ + public static mapRow(row: PasskeyRow): Passkey { + return { + Id: row.Id, + ItemId: row.ItemId, + RpId: row.RpId, + UserHandle: row.UserHandle ?? undefined, + PublicKey: row.PublicKey, + PrivateKey: row.PrivateKey, + DisplayName: row.DisplayName, + PrfKey: row.PrfKey ?? undefined, + AdditionalData: row.AdditionalData, + CreatedAt: dateToEpoch(row.CreatedAt), + UpdatedAt: dateToEpoch(row.UpdatedAt), + IsDeleted: row.IsDeleted + }; + } + + /** + * Map a single database row to a Passkey with item information. + * @param row - Raw passkey row with item data + * @returns Passkey with Username and ServiceName + */ + public static mapRowWithItem(row: PasskeyWithItemRow): PasskeyWithItem { + return { + ...this.mapRow(row), + Username: row.Username, + ServiceName: row.ServiceName + }; + } + + /** + * Map multiple database rows to Passkey objects. + * @param rows - Raw passkey rows from database + * @returns Array of Passkey objects + */ + public static mapRows(rows: PasskeyRow[]): Passkey[] { + return rows.map(row => this.mapRow(row)); + } + + /** + * Map multiple database rows to Passkey objects with item information. + * @param rows - Raw passkey rows with item data + * @returns Array of Passkey with Username and ServiceName + */ + public static mapRowsWithItem(rows: PasskeyWithItemRow[]): PasskeyWithItem[] { + return rows.map(row => this.mapRowWithItem(row)); + } +} diff --git a/apps/browser-extension/src/utils/db/queries/ItemQueries.ts b/apps/browser-extension/src/utils/db/queries/ItemQueries.ts new file mode 100644 index 000000000..0fafbae9b --- /dev/null +++ b/apps/browser-extension/src/utils/db/queries/ItemQueries.ts @@ -0,0 +1,352 @@ +import { FieldKey } from '@/utils/dist/core/models/vault'; + +/** + * SQL query constants for Item operations. + * Centralizes all item-related queries to avoid duplication. + */ +export class ItemQueries { + /** + * Base SELECT for items with common fields. + * Includes LEFT JOIN to Logos and Folders, and subqueries for HasPasskey/HasAttachment/HasTotp. + */ + public static readonly BASE_SELECT = ` + SELECT DISTINCT + i.Id, + i.Name, + i.ItemType, + i.FolderId, + f.Name as FolderPath, + l.FileData as Logo, + CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, + CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, + CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, + i.CreatedAt, + i.UpdatedAt + FROM Items i + LEFT JOIN Logos l ON i.LogoId = l.Id + LEFT JOIN Folders f ON i.FolderId = f.Id`; + + /** + * Get all active items (not deleted, not in trash). + */ + public static readonly GET_ALL_ACTIVE = ` + ${ItemQueries.BASE_SELECT} + WHERE i.IsDeleted = 0 AND i.DeletedAt IS NULL + ORDER BY i.CreatedAt DESC`; + + /** + * Get a single item by ID. + */ + public static readonly GET_BY_ID = ` + SELECT + i.Id, + i.Name, + i.ItemType, + i.FolderId, + l.FileData as Logo, + CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, + CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, + CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, + i.CreatedAt, + i.UpdatedAt + FROM Items i + LEFT JOIN Logos l ON i.LogoId = l.Id + WHERE i.Id = ? AND i.IsDeleted = 0`; + + /** + * Get all recently deleted items (in trash). + */ + public static readonly GET_RECENTLY_DELETED = ` + ${ItemQueries.BASE_SELECT}, + i.DeletedAt + FROM Items i + LEFT JOIN Logos l ON i.LogoId = l.Id + LEFT JOIN Folders f ON i.FolderId = f.Id + WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL + ORDER BY i.DeletedAt DESC`; + + /** + * Count of recently deleted items. + */ + public static readonly COUNT_RECENTLY_DELETED = ` + SELECT COUNT(*) as count + FROM Items + WHERE IsDeleted = 0 AND DeletedAt IS NOT NULL`; + + /** + * Get field values for multiple items. + * @param itemCount - Number of items (for placeholder generation) + * @returns Query with placeholders + */ + public static getFieldValuesForItems(itemCount: number): string { + const placeholders = Array(itemCount).fill('?').join(','); + return ` + SELECT + fv.ItemId, + fv.FieldKey, + fv.FieldDefinitionId, + fd.Label as CustomLabel, + fd.FieldType as CustomFieldType, + fd.IsHidden as CustomIsHidden, + fv.Value, + fv.Weight as DisplayOrder + FROM FieldValues fv + LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id + WHERE fv.ItemId IN (${placeholders}) + AND fv.IsDeleted = 0 + ORDER BY fv.ItemId, fv.Weight`; + } + + /** + * Get field values for a single item. + */ + public static readonly GET_FIELD_VALUES_FOR_ITEM = ` + SELECT + fv.FieldKey, + fv.FieldDefinitionId, + fd.Label as CustomLabel, + fd.FieldType as CustomFieldType, + fd.IsHidden as CustomIsHidden, + fv.Value, + fv.Weight as DisplayOrder + FROM FieldValues fv + LEFT JOIN FieldDefinitions fd ON fv.FieldDefinitionId = fd.Id + WHERE fv.ItemId = ? AND fv.IsDeleted = 0 + ORDER BY fv.Weight`; + + /** + * Get tags for multiple items. + * @param itemCount - Number of items (for placeholder generation) + * @returns Query with placeholders + */ + public static getTagsForItems(itemCount: number): string { + const placeholders = Array(itemCount).fill('?').join(','); + return ` + SELECT + it.ItemId, + t.Id, + t.Name, + t.Color + FROM ItemTags it + INNER JOIN Tags t ON it.TagId = t.Id + WHERE it.ItemId IN (${placeholders}) + AND it.IsDeleted = 0 + AND t.IsDeleted = 0 + ORDER BY t.DisplayOrder, t.Name`; + } + + /** + * Get tags for a single item. + */ + public static readonly GET_TAGS_FOR_ITEM = ` + SELECT + t.Id, + t.Name, + t.Color + FROM ItemTags it + INNER JOIN Tags t ON it.TagId = t.Id + WHERE it.ItemId = ? AND it.IsDeleted = 0 AND t.IsDeleted = 0 + ORDER BY t.DisplayOrder, t.Name`; + + /** + * Insert a new item. + */ + public static readonly INSERT_ITEM = ` + INSERT INTO Items (Id, Name, ItemType, LogoId, FolderId, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`; + + /** + * Update an existing item. + */ + public static readonly UPDATE_ITEM = ` + UPDATE Items + SET Name = ?, + ItemType = ?, + FolderId = ?, + LogoId = COALESCE(?, LogoId), + UpdatedAt = ? + WHERE Id = ?`; + + /** + * Move item to trash (set DeletedAt). + */ + public static readonly TRASH_ITEM = ` + UPDATE Items + SET DeletedAt = ?, + UpdatedAt = ? + WHERE Id = ? AND IsDeleted = 0`; + + /** + * Restore item from trash (clear DeletedAt). + */ + public static readonly RESTORE_ITEM = ` + UPDATE Items + SET DeletedAt = NULL, + UpdatedAt = ? + WHERE Id = ? AND IsDeleted = 0 AND DeletedAt IS NOT NULL`; + + /** + * Convert item to tombstone for permanent deletion. + */ + public static readonly TOMBSTONE_ITEM = ` + UPDATE Items + SET IsDeleted = 1, + Name = NULL, + LogoId = NULL, + FolderId = NULL, + UpdatedAt = ? + WHERE Id = ?`; + + /** + * Get LogoId for an item. + */ + public static readonly GET_LOGO_ID = ` + SELECT LogoId FROM Items WHERE Id = ?`; + + /** + * Get all unique email addresses from field values. + */ + public static readonly GET_ALL_EMAIL_ADDRESSES = ` + SELECT DISTINCT fv.Value as Email + FROM FieldValues fv + INNER JOIN Items i ON fv.ItemId = i.Id + WHERE fv.FieldKey = ? + AND fv.Value IS NOT NULL + AND fv.Value != '' + AND fv.IsDeleted = 0 + AND i.IsDeleted = 0`; +} + +/** + * SQL query constants for FieldValue operations. + */ +export class FieldValueQueries { + /** + * Get existing field values for an item. + */ + public static readonly GET_EXISTING_FOR_ITEM = ` + SELECT Id, FieldKey, FieldDefinitionId, Value + FROM FieldValues + WHERE ItemId = ? AND IsDeleted = 0`; + + /** + * Insert a new field value. + */ + public static readonly INSERT = ` + INSERT INTO FieldValues (Id, ItemId, FieldDefinitionId, FieldKey, Value, Weight, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; + + /** + * Update an existing field value. + */ + public static readonly UPDATE = ` + UPDATE FieldValues + SET Value = ?, + Weight = ?, + UpdatedAt = ? + WHERE Id = ?`; + + /** + * Soft delete a field value. + */ + public static readonly SOFT_DELETE = ` + UPDATE FieldValues + SET IsDeleted = 1, + UpdatedAt = ? + WHERE Id = ?`; + + /** + * Get existing field values for history tracking. + */ + public static readonly GET_FOR_HISTORY = ` + SELECT FieldKey, Value + FROM FieldValues + WHERE ItemId = ? AND IsDeleted = 0 AND FieldKey IS NOT NULL`; +} + +/** + * SQL query constants for FieldDefinition operations. + */ +export class FieldDefinitionQueries { + /** + * Check if a field definition exists. + */ + public static readonly EXISTS = ` + SELECT Id FROM FieldDefinitions WHERE Id = ?`; + + /** + * Check if a field definition exists and is not deleted. + */ + public static readonly EXISTS_ACTIVE = ` + SELECT Id FROM FieldDefinitions WHERE Id = ? AND IsDeleted = 0`; + + /** + * Insert a new field definition. + */ + public static readonly INSERT = ` + INSERT INTO FieldDefinitions (Id, FieldType, Label, IsMultiValue, IsHidden, EnableHistory, Weight, ApplicableToTypes, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`; + + /** + * Update an existing field definition. + */ + public static readonly UPDATE = ` + UPDATE FieldDefinitions + SET Label = ?, + FieldType = ?, + IsHidden = ?, + Weight = ?, + UpdatedAt = ? + WHERE Id = ?`; +} + +/** + * SQL query constants for FieldHistory operations. + */ +export class FieldHistoryQueries { + /** + * Insert a history record. + */ + public static readonly INSERT = ` + INSERT INTO FieldHistories (Id, ItemId, FieldDefinitionId, FieldKey, ValueSnapshot, ChangedAt, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; + + /** + * Get history records for a field. + */ + public static readonly GET_FOR_FIELD = ` + SELECT + Id, + ItemId, + FieldKey, + ValueSnapshot, + ChangedAt, + CreatedAt, + UpdatedAt + FROM FieldHistories + WHERE ItemId = ? AND FieldKey = ? AND IsDeleted = 0 + ORDER BY ChangedAt DESC + LIMIT ?`; + + /** + * Get all history records for pruning. + */ + public static readonly GET_FOR_PRUNING = ` + SELECT Id, ChangedAt + FROM FieldHistories + WHERE ItemId = ? AND FieldKey = ? AND IsDeleted = 0 + ORDER BY ChangedAt DESC`; + + /** + * Soft delete old history records. + * @param count - Number of records to delete + * @returns Query with placeholders + */ + public static softDeleteOld(count: number): string { + const placeholders = Array(count).fill('?').join(','); + return ` + UPDATE FieldHistories + SET IsDeleted = 1, UpdatedAt = ? + WHERE Id IN (${placeholders})`; + } +} diff --git a/apps/browser-extension/src/utils/db/repositories/FolderRepository.ts b/apps/browser-extension/src/utils/db/repositories/FolderRepository.ts new file mode 100644 index 000000000..095739adb --- /dev/null +++ b/apps/browser-extension/src/utils/db/repositories/FolderRepository.ts @@ -0,0 +1,228 @@ +import { BaseRepository } from '../BaseRepository'; + +/** + * Folder entity type. + */ +export interface Folder { + Id: string; + Name: string; + ParentFolderId: string | null; + Weight: number; +} + +/** + * SQL query constants for Folder operations. + */ +const FolderQueries = { + /** + * Get all active folders. + */ + GET_ALL: ` + SELECT Id, Name, ParentFolderId, Weight + FROM Folders + WHERE IsDeleted = 0 + ORDER BY Weight, Name`, + + /** + * Get folder by ID. + */ + GET_BY_ID: ` + SELECT Id, Name, ParentFolderId + FROM Folders + WHERE Id = ? AND IsDeleted = 0`, + + /** + * Insert a new folder. + */ + INSERT: ` + INSERT INTO Folders (Id, Name, ParentFolderId, Weight, IsDeleted, CreatedAt, UpdatedAt) + VALUES (?, ?, ?, 0, 0, ?, ?)`, + + /** + * Update folder name. + */ + UPDATE_NAME: ` + UPDATE Folders + SET Name = ?, + UpdatedAt = ? + WHERE Id = ?`, + + /** + * Soft delete folder. + */ + SOFT_DELETE: ` + UPDATE Folders + SET IsDeleted = 1, + UpdatedAt = ? + WHERE Id = ?`, + + /** + * Clear folder reference from items. + */ + CLEAR_ITEMS_FOLDER: ` + UPDATE Items + SET FolderId = NULL, + UpdatedAt = ? + WHERE FolderId = ?`, + + /** + * Trash items in folder. + */ + TRASH_ITEMS_IN_FOLDER: ` + UPDATE Items + SET DeletedAt = ?, + UpdatedAt = ?, + FolderId = NULL + WHERE FolderId = ? AND IsDeleted = 0 AND DeletedAt IS NULL`, + + /** + * Move item to folder. + */ + MOVE_ITEM: ` + UPDATE Items + SET FolderId = ?, + UpdatedAt = ? + WHERE Id = ?` +}; + +/** + * Repository for Folder CRUD operations. + */ +export class FolderRepository extends BaseRepository { + /** + * Create a new folder. + * @param name - The name of the folder + * @param parentFolderId - Optional parent folder ID for nested folders + * @returns The ID of the created folder + */ + public async create(name: string, parentFolderId?: string | null): Promise { + return this.withTransaction(async () => { + const folderId = crypto.randomUUID(); + const currentDateTime = this.now(); + + this.client.executeUpdate(FolderQueries.INSERT, [ + folderId, + name, + parentFolderId || null, + currentDateTime, + currentDateTime + ]); + + return folderId; + }); + } + + /** + * Get all folders. + * @returns Array of folder objects (empty array if Folders table doesn't exist yet) + */ + public getAll(): Folder[] { + try { + return this.client.executeQuery(FolderQueries.GET_ALL); + } catch (error) { + // Table may not exist in older vault versions - return empty array + if (error instanceof Error && error.message.includes('no such table')) { + return []; + } + throw error; + } + } + + /** + * Get a folder by ID. + * @param folderId - The ID of the folder + * @returns Folder object or null if not found + */ + public getById(folderId: string): Omit | null { + const results = this.client.executeQuery>( + FolderQueries.GET_BY_ID, + [folderId] + ); + return results.length > 0 ? results[0] : null; + } + + /** + * Update a folder's name. + * @param folderId - The ID of the folder to update + * @param name - The new name for the folder + * @returns The number of rows updated + */ + public async update(folderId: string, name: string): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + return this.client.executeUpdate(FolderQueries.UPDATE_NAME, [ + name, + currentDateTime, + folderId + ]); + }); + } + + /** + * Delete a folder (soft delete). + * Note: Items in the folder will have their FolderId set to NULL. + * @param folderId - The ID of the folder to delete + * @returns The number of rows updated + */ + public async delete(folderId: string): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + + // Remove folder reference from all items in this folder + this.client.executeUpdate(FolderQueries.CLEAR_ITEMS_FOLDER, [ + currentDateTime, + folderId + ]); + + // Soft delete the folder + return this.client.executeUpdate(FolderQueries.SOFT_DELETE, [ + currentDateTime, + folderId + ]); + }); + } + + /** + * Delete a folder and all items within it (soft delete both folder and items). + * Items are moved to "Recently Deleted" (trash). + * @param folderId - The ID of the folder to delete + * @returns The number of items trashed + */ + public async deleteWithContents(folderId: string): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + + // Move all items in this folder to trash and clear FolderId + const itemsDeleted = this.client.executeUpdate(FolderQueries.TRASH_ITEMS_IN_FOLDER, [ + currentDateTime, + currentDateTime, + folderId + ]); + + // Soft delete the folder + this.client.executeUpdate(FolderQueries.SOFT_DELETE, [ + currentDateTime, + folderId + ]); + + return itemsDeleted; + }); + } + + /** + * Move an item to a folder. + * @param itemId - The ID of the item to move + * @param folderId - The ID of the destination folder (null to remove from folder) + * @returns The number of rows updated + */ + public async moveItem(itemId: string, folderId: string | null): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + return this.client.executeUpdate(FolderQueries.MOVE_ITEM, [ + folderId, + currentDateTime, + itemId + ]); + }); + } +} diff --git a/apps/browser-extension/src/utils/db/repositories/ItemRepository.ts b/apps/browser-extension/src/utils/db/repositories/ItemRepository.ts new file mode 100644 index 000000000..3dbf33116 --- /dev/null +++ b/apps/browser-extension/src/utils/db/repositories/ItemRepository.ts @@ -0,0 +1,808 @@ +import type { Item, ItemField, Attachment, TotpCode, FieldHistory } from '@/utils/dist/core/models/vault'; +import { FieldKey, getSystemField, MAX_FIELD_HISTORY_RECORDS } from '@/utils/dist/core/models/vault'; + +import { BaseRepository, type IDatabaseClient } from '../BaseRepository'; +import { FieldMapper, type FieldRow } from '../mappers/FieldMapper'; +import { ItemMapper, type ItemRow, type TagRow, type ItemWithDeletedAt } from '../mappers/ItemMapper'; +import { + ItemQueries, + FieldValueQueries, + FieldDefinitionQueries, + FieldHistoryQueries +} from '../queries/ItemQueries'; +import type { LogoRepository } from './LogoRepository'; + +/** + * Repository for Item CRUD operations. + * Handles items, field values, field definitions, and field history. + */ +export class ItemRepository extends BaseRepository { + constructor( + client: IDatabaseClient, + private logoRepository: LogoRepository + ) { + super(client); + } + + /** + * Fetch all active items with their dynamic fields and tags. + * @returns Array of Item objects (empty array if Items table doesn't exist yet) + */ + public getAll(): Item[] { + let itemRows: ItemRow[]; + try { + itemRows = this.client.executeQuery(ItemQueries.GET_ALL_ACTIVE); + } catch (error) { + // Items table may not exist in older vault versions - return empty array + if (error instanceof Error && error.message.includes('no such table')) { + return []; + } + throw error; + } + + if (itemRows.length === 0) { + return []; + } + + const itemIds = itemRows.map(i => i.Id); + + // Get all field values + const fieldRows = this.client.executeQuery( + ItemQueries.getFieldValuesForItems(itemIds.length), + itemIds + ); + const fieldsByItem = FieldMapper.processFieldRows(fieldRows); + + // Get all tags + const tagRows = this.client.executeQuery( + ItemQueries.getTagsForItems(itemIds.length), + itemIds + ); + const tagsByItem = ItemMapper.groupTagsByItem(tagRows); + + return ItemMapper.mapRows(itemRows, fieldsByItem, tagsByItem); + } + + /** + * Fetch a single item by ID with its dynamic fields and tags. + * @param itemId - The ID of the item to fetch + * @returns Item object or null if not found + */ + public getById(itemId: string): Item | null { + const results = this.client.executeQuery(ItemQueries.GET_BY_ID, [itemId]); + if (results.length === 0) { + return null; + } + + // Get field values + const fieldRows = this.client.executeQuery>( + ItemQueries.GET_FIELD_VALUES_FOR_ITEM, + [itemId] + ); + const fields = FieldMapper.processFieldRowsForSingleItem(fieldRows); + + // Get tags + const tagRows = this.client.executeQuery>( + ItemQueries.GET_TAGS_FOR_ITEM, + [itemId] + ); + const tags = ItemMapper.mapTagRows(tagRows); + + return ItemMapper.mapRow(results[0], fields, tags); + } + + /** + * Fetch all unique email addresses from all items. + * @returns Array of email addresses + */ + public getAllEmailAddresses(): string[] { + const results = this.client.executeQuery<{ Email: string }>( + ItemQueries.GET_ALL_EMAIL_ADDRESSES, + [FieldKey.LoginEmail] + ); + return results.map(row => row.Email); + } + + /** + * Create a new item with field-based structure. + * @param item The item object to insert + * @param attachments Optional attachments to associate with the item + * @param totpCodes Optional TOTP codes to associate with the item + * @returns The ID of the created item + */ + public async create( + item: Item, + attachments: Attachment[] = [], + totpCodes: TotpCode[] = [] + ): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + const itemId = item.Id || this.generateId(); + + // 1. Handle Logo + const logoId = this.resolveLogoId(item, currentDateTime); + + // 2. Insert Item + this.client.executeUpdate(ItemQueries.INSERT_ITEM, [ + itemId, + item.Name ?? null, + item.ItemType, + logoId, + item.FolderId ?? null, + currentDateTime, + currentDateTime, + 0 + ]); + + // 3. Insert FieldValues for all fields + if (item.Fields && item.Fields.length > 0) { + this.insertFieldValues(itemId, item.Fields, item.ItemType, currentDateTime); + } + + // 4. Insert TOTP codes + this.insertTotpCodes(itemId, totpCodes, currentDateTime); + + // 5. Insert attachments + this.insertAttachments(itemId, attachments, currentDateTime); + + return itemId; + }); + } + + /** + * Update an existing item with field-based structure. + * @param item The item object to update + * @param originalAttachmentIds Original attachment IDs for tracking changes + * @param attachments Current attachments list + * @param originalTotpCodeIds Original TOTP code IDs for tracking changes + * @param totpCodes Current TOTP codes list + * @returns The number of rows modified + */ + public async update( + item: Item, + originalAttachmentIds: string[] = [], + attachments: Attachment[] = [], + originalTotpCodeIds: string[] = [], + totpCodes: TotpCode[] = [] + ): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + + // 1. Handle Logo + const logoId = this.resolveLogoId(item, currentDateTime); + + // 2. Update Item + this.client.executeUpdate(ItemQueries.UPDATE_ITEM, [ + item.Name ?? null, + item.ItemType, + item.FolderId ?? null, + logoId, + currentDateTime, + item.Id + ]); + + // 3. Track history for fields that have EnableHistory=true before updating + await this.trackFieldHistory(item.Id, item.Fields, currentDateTime); + + // 4. Update field values + this.updateFieldValues(item, currentDateTime); + + // 5. Handle TOTP codes + this.handleTotpCodes(item.Id, totpCodes, originalTotpCodeIds, currentDateTime); + + // 6. Handle attachments + this.handleAttachments(item.Id, attachments, originalAttachmentIds, currentDateTime); + + return 1; + }); + } + + /** + * Move an item to "Recently Deleted" (trash). + * @param itemId - The ID of the item to trash + * @returns The number of rows updated + */ + public async trash(itemId: string): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + return this.client.executeUpdate(ItemQueries.TRASH_ITEM, [ + currentDateTime, + currentDateTime, + itemId + ]); + }); + } + + /** + * Restore an item from "Recently Deleted". + * @param itemId - The ID of the item to restore + * @returns The number of rows updated + */ + public async restore(itemId: string): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + return this.client.executeUpdate(ItemQueries.RESTORE_ITEM, [ + currentDateTime, + itemId + ]); + }); + } + + /** + * Permanently delete an item - converts to tombstone for sync. + * @param itemId - The ID of the item to permanently delete + * @returns The number of rows updated + */ + public async permanentlyDelete(itemId: string): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + + // Get the LogoId before we clear it + const logoResult = this.client.executeQuery<{ LogoId: string | null }>( + ItemQueries.GET_LOGO_ID, + [itemId] + ); + const logoId = logoResult.length > 0 ? logoResult[0].LogoId : null; + + // Hard delete all related entities + this.hardDeleteByForeignKey('FieldValues', 'ItemId', itemId); + this.hardDeleteByForeignKey('FieldHistories', 'ItemId', itemId); + this.hardDeleteByForeignKey('Passkeys', 'ItemId', itemId); + this.hardDeleteByForeignKey('TotpCodes', 'ItemId', itemId); + this.hardDeleteByForeignKey('Attachments', 'ItemId', itemId); + this.hardDeleteByForeignKey('ItemTags', 'ItemId', itemId); + + // Convert item to tombstone + const result = this.client.executeUpdate(ItemQueries.TOMBSTONE_ITEM, [ + currentDateTime, + itemId + ]); + + // Clean up orphaned logo + if (logoId) { + this.logoRepository.cleanupOrphanedLogo(logoId); + } + + return result; + }); + } + + /** + * Get all items in "Recently Deleted". + * @returns Array of trashed Item objects with DeletedAt + */ + public getRecentlyDeleted(): ItemWithDeletedAt[] { + let itemRows: (ItemRow & { DeletedAt: string })[]; + try { + // Need a modified query that includes DeletedAt in the SELECT + const query = ` + SELECT DISTINCT + i.Id, + i.Name, + i.ItemType, + i.FolderId, + f.Name as FolderPath, + l.FileData as Logo, + i.DeletedAt, + CASE WHEN EXISTS (SELECT 1 FROM Passkeys pk WHERE pk.ItemId = i.Id AND pk.IsDeleted = 0) THEN 1 ELSE 0 END as HasPasskey, + CASE WHEN EXISTS (SELECT 1 FROM Attachments att WHERE att.ItemId = i.Id AND att.IsDeleted = 0) THEN 1 ELSE 0 END as HasAttachment, + CASE WHEN EXISTS (SELECT 1 FROM TotpCodes tc WHERE tc.ItemId = i.Id AND tc.IsDeleted = 0) THEN 1 ELSE 0 END as HasTotp, + i.CreatedAt, + i.UpdatedAt + FROM Items i + LEFT JOIN Logos l ON i.LogoId = l.Id + LEFT JOIN Folders f ON i.FolderId = f.Id + WHERE i.IsDeleted = 0 AND i.DeletedAt IS NOT NULL + ORDER BY i.DeletedAt DESC`; + + itemRows = this.client.executeQuery(query); + } catch (error) { + if (error instanceof Error && error.message.includes('no such table')) { + return []; + } + throw error; + } + + if (itemRows.length === 0) { + return []; + } + + const itemIds = itemRows.map(i => i.Id); + + // Get all field values + const fieldRows = this.client.executeQuery( + ItemQueries.getFieldValuesForItems(itemIds.length), + itemIds + ); + const fieldsByItem = FieldMapper.processFieldRows(fieldRows); + + return itemRows.map(row => ItemMapper.mapDeletedItemRow(row, fieldsByItem.get(row.Id) || [])); + } + + /** + * Get count of items in "Recently Deleted". + * @returns Number of trashed items + */ + public getRecentlyDeletedCount(): number { + try { + const result = this.client.executeQuery<{ count: number }>(ItemQueries.COUNT_RECENTLY_DELETED); + return result[0]?.count || 0; + } catch (error) { + if (error instanceof Error && error.message.includes('no such table')) { + return 0; + } + throw error; + } + } + + /** + * Get field history for a specific field. + * @param itemId - The ID of the item + * @param fieldKey - The field key to get history for + * @returns Array of field history records + */ + public getFieldHistory(itemId: string, fieldKey: string): FieldHistory[] { + const results = this.client.executeQuery<{ + Id: string; + ItemId: string; + FieldKey: string; + ValueSnapshot: string; + ChangedAt: string; + CreatedAt: string; + UpdatedAt: string; + }>(FieldHistoryQueries.GET_FOR_FIELD, [itemId, fieldKey, MAX_FIELD_HISTORY_RECORDS]); + + return results.map(row => ({ + Id: row.Id, + ItemId: row.ItemId, + FieldKey: row.FieldKey, + ValueSnapshot: row.ValueSnapshot, + ChangedAt: row.ChangedAt, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt + })); + } + + // ===== Private Helper Methods ===== + + /** + * Resolve the logo ID for an item (create new or reuse existing). + */ + private resolveLogoId(item: Item, currentDateTime: string): string | null { + const urlField = item.Fields?.find(f => f.FieldKey === 'login.url'); + const urlValue = urlField?.Value; + const urlString = Array.isArray(urlValue) ? urlValue[0] : urlValue; + const source = this.logoRepository.extractSourceFromUrl(urlString); + + if (item.Logo) { + const logoData = this.logoRepository.convertLogoToUint8Array(item.Logo); + if (logoData) { + return this.logoRepository.getOrCreate(source, logoData, currentDateTime); + } + } else if (source !== 'unknown') { + return this.logoRepository.getIdForSource(source); + } + + return null; + } + + /** + * Insert field values for a new item. + */ + private insertFieldValues( + itemId: string, + fields: ItemField[], + itemType: string, + currentDateTime: string + ): void { + for (const field of fields) { + // Skip empty fields + if (!field.Value || (typeof field.Value === 'string' && field.Value.trim() === '')) { + continue; + } + + const isCustomField = field.FieldKey.startsWith('custom_'); + let fieldDefinitionId = null; + + // For custom fields, create or get FieldDefinition + if (isCustomField) { + fieldDefinitionId = this.ensureFieldDefinition(field, itemType, currentDateTime); + } + + // Handle multi-value fields + const values = Array.isArray(field.Value) ? field.Value : [field.Value]; + + for (const value of values) { + if (!value || (typeof value === 'string' && value.trim() === '')) { + continue; + } + + this.client.executeUpdate(FieldValueQueries.INSERT, [ + this.generateId(), + itemId, + fieldDefinitionId, + isCustomField ? null : field.FieldKey, + value, + field.DisplayOrder ?? 0, + currentDateTime, + currentDateTime, + 0 + ]); + } + } + } + + /** + * Ensure a field definition exists for a custom field. + */ + private ensureFieldDefinition( + field: ItemField, + itemType: string, + currentDateTime: string + ): string { + const existingDef = this.client.executeQuery<{ Id: string }>( + FieldDefinitionQueries.EXISTS, + [field.FieldKey] + ); + + if (existingDef.length === 0) { + this.client.executeUpdate(FieldDefinitionQueries.INSERT, [ + field.FieldKey, + field.FieldType, + field.Label, + 0, // IsMultiValue + field.IsHidden ? 1 : 0, + 0, // EnableHistory + field.DisplayOrder ?? 0, + itemType, + currentDateTime, + currentDateTime, + 0 + ]); + } + + return field.FieldKey; + } + + /** + * Update field values for an existing item. + */ + private updateFieldValues(item: Item, currentDateTime: string): void { + // Get existing FieldValues + const existingFieldValues = this.client.executeQuery<{ + Id: string; + FieldKey: string | null; + FieldDefinitionId: string | null; + Value: string; + }>(FieldValueQueries.GET_EXISTING_FOR_ITEM, [item.Id]); + + // Build a map of existing FieldValues by key:index + const existingByKey = new Map(); + const fieldValueCounts = new Map(); + + for (const fv of existingFieldValues) { + const key = fv.FieldKey || fv.FieldDefinitionId || ''; + const count = fieldValueCounts.get(key) || 0; + existingByKey.set(`${key}:${count}`, { Id: fv.Id, Value: fv.Value }); + fieldValueCounts.set(key, count + 1); + } + + const processedIds = new Set(); + + // Update existing or insert new FieldValues + if (item.Fields && item.Fields.length > 0) { + for (const field of item.Fields) { + if (!field.Value || (typeof field.Value === 'string' && field.Value.trim() === '')) { + continue; + } + + const isCustomField = field.FieldKey.startsWith('custom_'); + let fieldDefinitionId = null; + + if (isCustomField) { + fieldDefinitionId = this.ensureOrUpdateFieldDefinition(field, item.ItemType, currentDateTime); + } + + const values = Array.isArray(field.Value) ? field.Value : [field.Value]; + const effectiveKey = field.FieldKey; + + for (let i = 0; i < values.length; i++) { + const value = values[i]; + if (!value || (typeof value === 'string' && value.trim() === '')) { + continue; + } + + const lookupKey = `${effectiveKey}:${i}`; + const existing = existingByKey.get(lookupKey); + + if (existing) { + processedIds.add(existing.Id); + if (existing.Value !== value) { + this.client.executeUpdate(FieldValueQueries.UPDATE, [ + value, + field.DisplayOrder ?? 0, + currentDateTime, + existing.Id + ]); + } + } else { + this.client.executeUpdate(FieldValueQueries.INSERT, [ + this.generateId(), + item.Id, + fieldDefinitionId, + isCustomField ? null : field.FieldKey, + value, + field.DisplayOrder ?? 0, + currentDateTime, + currentDateTime, + 0 + ]); + } + } + } + } + + // Soft-delete any FieldValues that were not processed + for (const fv of existingFieldValues) { + if (!processedIds.has(fv.Id)) { + this.client.executeUpdate(FieldValueQueries.SOFT_DELETE, [currentDateTime, fv.Id]); + } + } + } + + /** + * Ensure a field definition exists and is up-to-date. + */ + private ensureOrUpdateFieldDefinition( + field: ItemField, + itemType: string, + currentDateTime: string + ): string { + const existingDef = this.client.executeQuery<{ Id: string }>( + FieldDefinitionQueries.EXISTS_ACTIVE, + [field.FieldKey] + ); + + if (existingDef.length === 0) { + this.client.executeUpdate(FieldDefinitionQueries.INSERT, [ + field.FieldKey, + field.FieldType, + field.Label, + 0, + field.IsHidden ? 1 : 0, + 0, + field.DisplayOrder ?? 0, + itemType, + currentDateTime, + currentDateTime, + 0 + ]); + } else { + this.client.executeUpdate(FieldDefinitionQueries.UPDATE, [ + field.Label, + field.FieldType, + field.IsHidden ? 1 : 0, + field.DisplayOrder ?? 0, + currentDateTime, + field.FieldKey + ]); + } + + return field.FieldKey; + } + + /** + * Track field history for fields with EnableHistory=true. + */ + private async trackFieldHistory( + itemId: string, + newFields: ItemField[], + currentDateTime: string + ): Promise { + const existingFields = this.client.executeQuery<{ FieldKey: string; Value: string }>( + FieldValueQueries.GET_FOR_HISTORY, + [itemId] + ); + + // Create a map of existing values by FieldKey + const existingValuesMap: { [key: string]: string[] } = {}; + for (const field of existingFields) { + if (!existingValuesMap[field.FieldKey]) { + existingValuesMap[field.FieldKey] = []; + } + existingValuesMap[field.FieldKey].push(field.Value); + } + + for (const newField of newFields) { + // Skip custom fields + if (newField.FieldKey.startsWith('custom_')) { + continue; + } + + const systemField = getSystemField(newField.FieldKey); + if (!systemField || !systemField.EnableHistory) { + continue; + } + + const oldValues = existingValuesMap[newField.FieldKey] || []; + const newValues = Array.isArray(newField.Value) ? newField.Value : [newField.Value]; + + const valuesChanged = oldValues.length !== newValues.length || + !oldValues.every((val, idx) => val === newValues[idx]); + + if (valuesChanged && oldValues.length > 0) { + const historyId = this.generateId(); + const valueSnapshot = JSON.stringify(oldValues); + + this.client.executeUpdate(FieldHistoryQueries.INSERT, [ + historyId, + itemId, + null, + newField.FieldKey, + valueSnapshot, + currentDateTime, + currentDateTime, + currentDateTime, + 0 + ]); + + await this.pruneFieldHistory(itemId, newField.FieldKey, currentDateTime); + } + } + } + + /** + * Prune old field history records. + */ + private async pruneFieldHistory( + itemId: string, + fieldKey: string, + currentDateTime: string + ): Promise { + const matchingHistory = this.client.executeQuery<{ Id: string; ChangedAt: string }>( + FieldHistoryQueries.GET_FOR_PRUNING, + [itemId, fieldKey] + ); + + if (matchingHistory.length > MAX_FIELD_HISTORY_RECORDS) { + const recordsToDelete = matchingHistory.slice(MAX_FIELD_HISTORY_RECORDS); + const idsToDelete = recordsToDelete.map(r => r.Id); + + if (idsToDelete.length > 0) { + this.client.executeUpdate( + FieldHistoryQueries.softDeleteOld(idsToDelete.length), + [currentDateTime, ...idsToDelete] + ); + } + } + } + + /** + * Insert TOTP codes for a new item. + */ + private insertTotpCodes(itemId: string, totpCodes: TotpCode[], currentDateTime: string): void { + for (const totpCode of totpCodes) { + this.client.executeUpdate( + `INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + totpCode.Id || this.generateId(), + totpCode.Name, + totpCode.SecretKey, + itemId, + currentDateTime, + currentDateTime, + 0 + ] + ); + } + } + + /** + * Handle TOTP code updates. + */ + private handleTotpCodes( + itemId: string, + totpCodes: TotpCode[], + originalIds: string[], + currentDateTime: string + ): void { + for (const totpCode of totpCodes) { + const wasOriginal = originalIds.includes(totpCode.Id); + + if (totpCode.IsDeleted) { + if (wasOriginal) { + this.client.executeUpdate( + `UPDATE TotpCodes SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`, + [currentDateTime, totpCode.Id] + ); + } + } else if (wasOriginal) { + this.client.executeUpdate( + `UPDATE TotpCodes SET Name = ?, SecretKey = ?, UpdatedAt = ? WHERE Id = ?`, + [totpCode.Name, totpCode.SecretKey, currentDateTime, totpCode.Id] + ); + } else { + this.client.executeUpdate( + `INSERT INTO TotpCodes (Id, Name, SecretKey, ItemId, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + totpCode.Id || this.generateId(), + totpCode.Name, + totpCode.SecretKey, + itemId, + currentDateTime, + currentDateTime, + 0 + ] + ); + } + } + } + + /** + * Insert attachments for a new item. + */ + private insertAttachments(itemId: string, attachments: Attachment[], currentDateTime: string): void { + for (const attachment of attachments) { + const blobData = attachment.Blob instanceof Uint8Array + ? attachment.Blob + : new Uint8Array(attachment.Blob); + + this.client.executeUpdate( + `INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + attachment.Id || this.generateId(), + attachment.Filename, + blobData, + itemId, + currentDateTime, + currentDateTime, + 0 + ] + ); + } + } + + /** + * Handle attachment updates. + */ + private handleAttachments( + itemId: string, + attachments: Attachment[], + originalIds: string[], + currentDateTime: string + ): void { + for (const attachment of attachments) { + const wasOriginal = originalIds.includes(attachment.Id); + + if (attachment.IsDeleted) { + if (wasOriginal) { + this.client.executeUpdate( + `UPDATE Attachments SET IsDeleted = 1, UpdatedAt = ? WHERE Id = ?`, + [currentDateTime, attachment.Id] + ); + } + } else if (!wasOriginal) { + const blobData = attachment.Blob instanceof Uint8Array + ? attachment.Blob + : new Uint8Array(attachment.Blob); + + this.client.executeUpdate( + `INSERT INTO Attachments (Id, Filename, Blob, ItemId, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + attachment.Id || this.generateId(), + attachment.Filename, + blobData, + itemId, + currentDateTime, + currentDateTime, + 0 + ] + ); + } + } + } +} diff --git a/apps/browser-extension/src/utils/db/repositories/LogoRepository.ts b/apps/browser-extension/src/utils/db/repositories/LogoRepository.ts new file mode 100644 index 000000000..3fb91f94a --- /dev/null +++ b/apps/browser-extension/src/utils/db/repositories/LogoRepository.ts @@ -0,0 +1,159 @@ +import { BaseRepository } from '../BaseRepository'; + +/** + * SQL query constants for Logo operations. + */ +const LogoQueries = { + /** + * Check if logo exists for source. + */ + GET_ID_FOR_SOURCE: ` + SELECT Id FROM Logos + WHERE Source = ? AND IsDeleted = 0 + LIMIT 1`, + + /** + * Insert new logo. + */ + INSERT: ` + INSERT INTO Logos (Id, Source, FileData, CreatedAt, UpdatedAt, IsDeleted) + VALUES (?, ?, ?, ?, ?, ?)`, + + /** + * Count items using a logo. + */ + COUNT_USAGE: ` + SELECT COUNT(*) as count FROM Items + WHERE LogoId = ? AND IsDeleted = 0`, + + /** + * Hard delete logo. + */ + HARD_DELETE: ` + DELETE FROM Logos WHERE Id = ?` +}; + +/** + * Repository for Logo management operations. + */ +export class LogoRepository extends BaseRepository { + /** + * Check if a logo exists for the given source domain. + * @param source The normalized source domain (e.g., 'github.com') + * @returns True if a logo exists for this source + */ + public hasLogoForSource(source: string): boolean { + const existingLogos = this.client.executeQuery<{ Id: string }>( + LogoQueries.GET_ID_FOR_SOURCE, + [source] + ); + return existingLogos.length > 0; + } + + /** + * Get the logo ID for a given source domain if it exists. + * @param source The normalized source domain (e.g., 'github.com') + * @returns The logo ID if found, null otherwise + */ + public getIdForSource(source: string): string | null { + const existingLogos = this.client.executeQuery<{ Id: string }>( + LogoQueries.GET_ID_FOR_SOURCE, + [source] + ); + return existingLogos.length > 0 ? existingLogos[0].Id : null; + } + + /** + * Get or create a logo ID for the given source domain. + * If a logo for this source already exists, returns its ID. + * Otherwise, creates a new logo entry and returns its ID. + * @param source The normalized source domain (e.g., 'github.com') + * @param logoData The logo image data as Uint8Array + * @param currentDateTime The current date/time string for timestamps + * @returns The logo ID (existing or newly created) + */ + public getOrCreate(source: string, logoData: Uint8Array, currentDateTime: string): string { + // Check if a logo for this source already exists + const existingId = this.getIdForSource(source); + if (existingId) { + return existingId; + } + + // Create new logo entry + const logoId = this.generateId(); + this.client.executeUpdate(LogoQueries.INSERT, [ + logoId, + source, + logoData, + currentDateTime, + currentDateTime, + 0 + ]); + + return logoId; + } + + /** + * Clean up orphaned logo if no items reference it. + * @param logoId - The ID of the logo to potentially clean up + */ + public cleanupOrphanedLogo(logoId: string): void { + const usageResult = this.client.executeQuery<{ count: number }>( + LogoQueries.COUNT_USAGE, + [logoId] + ); + const usageCount = usageResult.length > 0 ? usageResult[0].count : 0; + + if (usageCount === 0) { + this.client.executeUpdate(LogoQueries.HARD_DELETE, [logoId]); + console.debug(`[LogoRepository] Deleted orphaned logo: ${logoId}`); + } + } + + /** + * Extract and normalize source domain from a URL string. + * Uses lowercase and removes www. prefix for case-insensitive matching. + * @param urlString The URL to extract the domain from + * @returns The normalized source domain (e.g., 'github.com'), or 'unknown' if extraction fails + */ + public extractSourceFromUrl(urlString: string | undefined | null): string { + if (!urlString) { + return 'unknown'; + } + + try { + const url = new URL(urlString.startsWith('http') ? urlString : `https://${urlString}`); + // Normalize hostname: lowercase and remove www. prefix + return url.hostname.toLowerCase().replace(/^www\./, ''); + } catch { + return 'unknown'; + } + } + + /** + * Convert logo data from various formats to Uint8Array. + * @param logo The logo data in various possible formats + * @returns Uint8Array of logo data, or null if conversion fails + */ + public convertLogoToUint8Array(logo: unknown): Uint8Array | null { + if (!logo) { + return null; + } + + try { + // Handle object-like array conversion (from JSON deserialization) + if (typeof logo === 'object' && !ArrayBuffer.isView(logo) && !Array.isArray(logo)) { + const values = Object.values(logo as Record); + return new Uint8Array(values); + } + // Handle existing array types + if (Array.isArray(logo) || logo instanceof ArrayBuffer || logo instanceof Uint8Array) { + return new Uint8Array(logo as ArrayLike); + } + } catch (error) { + console.warn('Failed to convert logo to Uint8Array:', error); + } + + return null; + } +} diff --git a/apps/browser-extension/src/utils/db/repositories/PasskeyRepository.ts b/apps/browser-extension/src/utils/db/repositories/PasskeyRepository.ts new file mode 100644 index 000000000..d26623963 --- /dev/null +++ b/apps/browser-extension/src/utils/db/repositories/PasskeyRepository.ts @@ -0,0 +1,295 @@ +import type { Passkey } from '@/utils/dist/core/models/vault'; +import { FieldKey } from '@/utils/dist/core/models/vault'; + +import { BaseRepository } from '../BaseRepository'; +import { PasskeyMapper, type PasskeyRow, type PasskeyWithItemRow, type PasskeyWithItem } from '../mappers/PasskeyMapper'; + +/** + * SQL query constants for Passkey operations. + */ +const PasskeyQueries = { + /** + * Base SELECT for passkeys with item information. + */ + BASE_SELECT_WITH_ITEM: ` + SELECT + p.Id, + p.ItemId, + p.RpId, + p.UserHandle, + p.PublicKey, + p.PrivateKey, + p.DisplayName, + p.PrfKey, + p.AdditionalData, + p.CreatedAt, + p.UpdatedAt, + p.IsDeleted, + i.Name as ServiceName, + (SELECT fv.Value FROM FieldValues fv WHERE fv.ItemId = i.Id AND fv.FieldKey = '${FieldKey.LoginUsername}' AND fv.IsDeleted = 0 LIMIT 1) as Username + FROM Passkeys p + INNER JOIN Items i ON p.ItemId = i.Id`, + + /** + * Base SELECT for passkeys without item information. + */ + BASE_SELECT: ` + SELECT + p.Id, + p.ItemId, + p.RpId, + p.UserHandle, + p.PublicKey, + p.PrivateKey, + p.DisplayName, + p.PrfKey, + p.AdditionalData, + p.CreatedAt, + p.UpdatedAt, + p.IsDeleted + FROM Passkeys p`, + + /** + * Get passkeys by relying party ID. + */ + GET_BY_RP_ID: ` + SELECT + p.Id, + p.ItemId, + p.RpId, + p.UserHandle, + p.PublicKey, + p.PrivateKey, + p.DisplayName, + p.PrfKey, + p.AdditionalData, + p.CreatedAt, + p.UpdatedAt, + p.IsDeleted, + i.Name as ServiceName, + (SELECT fv.Value FROM FieldValues fv WHERE fv.ItemId = i.Id AND fv.FieldKey = '${FieldKey.LoginUsername}' AND fv.IsDeleted = 0 LIMIT 1) as Username + FROM Passkeys p + INNER JOIN Items i ON p.ItemId = i.Id + WHERE p.RpId = ? AND p.IsDeleted = 0 + AND i.IsDeleted = 0 AND i.DeletedAt IS NULL + ORDER BY p.CreatedAt DESC`, + + /** + * Get passkey by ID with item information. + */ + GET_BY_ID_WITH_ITEM: ` + SELECT + p.Id, + p.ItemId, + p.RpId, + p.UserHandle, + p.PublicKey, + p.PrivateKey, + p.DisplayName, + p.PrfKey, + p.AdditionalData, + p.CreatedAt, + p.UpdatedAt, + p.IsDeleted, + i.Name as ServiceName, + (SELECT fv.Value FROM FieldValues fv WHERE fv.ItemId = i.Id AND fv.FieldKey = '${FieldKey.LoginUsername}' AND fv.IsDeleted = 0 LIMIT 1) as Username + FROM Passkeys p + INNER JOIN Items i ON p.ItemId = i.Id + WHERE p.Id = ? AND p.IsDeleted = 0 + AND i.IsDeleted = 0 AND i.DeletedAt IS NULL`, + + /** + * Get passkeys by item ID. + */ + GET_BY_ITEM_ID: ` + SELECT + p.Id, + p.ItemId, + p.RpId, + p.UserHandle, + p.PublicKey, + p.PrivateKey, + p.DisplayName, + p.PrfKey, + p.AdditionalData, + p.CreatedAt, + p.UpdatedAt, + p.IsDeleted + FROM Passkeys p + WHERE p.ItemId = ? AND p.IsDeleted = 0 + ORDER BY p.CreatedAt DESC`, + + /** + * Insert a new passkey. + */ + INSERT: ` + INSERT INTO Passkeys ( + Id, ItemId, RpId, UserHandle, PublicKey, PrivateKey, + PrfKey, DisplayName, AdditionalData, CreatedAt, UpdatedAt, IsDeleted + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + + /** + * Update passkey display name. + */ + UPDATE_DISPLAY_NAME: ` + UPDATE Passkeys + SET DisplayName = ?, + UpdatedAt = ? + WHERE Id = ?`, + + /** + * Soft delete passkey by ID. + */ + SOFT_DELETE: ` + UPDATE Passkeys + SET IsDeleted = 1, + UpdatedAt = ? + WHERE Id = ?`, + + /** + * Soft delete passkeys by item ID. + */ + SOFT_DELETE_BY_ITEM: ` + UPDATE Passkeys + SET IsDeleted = 1, + UpdatedAt = ? + WHERE ItemId = ?` +}; + +/** + * Repository for Passkey CRUD operations. + */ +export class PasskeyRepository extends BaseRepository { + /** + * Get all passkeys for a specific relying party (rpId). + * @param rpId - The relying party identifier (domain) + * @returns Array of passkey objects with credential info + */ + public getByRpId(rpId: string): PasskeyWithItem[] { + const results = this.client.executeQuery( + PasskeyQueries.GET_BY_RP_ID, + [rpId] + ); + return PasskeyMapper.mapRowsWithItem(results); + } + + /** + * Get a passkey by its ID. + * @param passkeyId - The passkey ID + * @returns The passkey object or null if not found + */ + public getById(passkeyId: string): PasskeyWithItem | null { + const results = this.client.executeQuery( + PasskeyQueries.GET_BY_ID_WITH_ITEM, + [passkeyId] + ); + + if (results.length === 0) { + return null; + } + + return PasskeyMapper.mapRowWithItem(results[0]); + } + + /** + * Get all passkeys for a specific item. + * @param itemId - The item ID + * @returns Array of passkey objects + */ + public getByItemId(itemId: string): Passkey[] { + const results = this.client.executeQuery( + PasskeyQueries.GET_BY_ITEM_ID, + [itemId] + ); + return PasskeyMapper.mapRows(results); + } + + /** + * Create a new passkey linked to an item. + * @param passkey - The passkey object to create + */ + public async create(passkey: Omit): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + + // Convert PrfKey to Uint8Array if it's a number array + let prfKeyData: Uint8Array | null = null; + if (passkey.PrfKey) { + prfKeyData = passkey.PrfKey instanceof Uint8Array + ? passkey.PrfKey + : new Uint8Array(passkey.PrfKey); + } + + // Convert UserHandle to Uint8Array if it's a number array + let userHandleData: Uint8Array | null = null; + if (passkey.UserHandle) { + userHandleData = passkey.UserHandle instanceof Uint8Array + ? passkey.UserHandle + : new Uint8Array(passkey.UserHandle); + } + + this.client.executeUpdate(PasskeyQueries.INSERT, [ + passkey.Id, + passkey.ItemId, + passkey.RpId, + userHandleData, + passkey.PublicKey, + passkey.PrivateKey, + prfKeyData, + passkey.DisplayName, + passkey.AdditionalData ?? null, + currentDateTime, + currentDateTime, + 0 + ]); + }); + } + + /** + * Delete a passkey by its ID (soft delete). + * @param passkeyId - The ID of the passkey to delete + * @returns The number of rows updated + */ + public async deleteById(passkeyId: string): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + return this.client.executeUpdate(PasskeyQueries.SOFT_DELETE, [ + currentDateTime, + passkeyId + ]); + }); + } + + /** + * Delete all passkeys for a specific item (soft delete). + * @param itemId - The ID of the item + * @returns The number of rows updated + */ + public async deleteByItemId(itemId: string): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + return this.client.executeUpdate(PasskeyQueries.SOFT_DELETE_BY_ITEM, [ + currentDateTime, + itemId + ]); + }); + } + + /** + * Update a passkey's display name. + * @param passkeyId - The ID of the passkey to update + * @param displayName - The new display name + * @returns The number of rows updated + */ + public async updateDisplayName(passkeyId: string, displayName: string): Promise { + return this.withTransaction(async () => { + const currentDateTime = this.now(); + return this.client.executeUpdate(PasskeyQueries.UPDATE_DISPLAY_NAME, [ + displayName, + currentDateTime, + passkeyId + ]); + }); + } +} diff --git a/apps/browser-extension/src/utils/db/repositories/SettingsRepository.ts b/apps/browser-extension/src/utils/db/repositories/SettingsRepository.ts new file mode 100644 index 000000000..b14cd0e86 --- /dev/null +++ b/apps/browser-extension/src/utils/db/repositories/SettingsRepository.ts @@ -0,0 +1,192 @@ +import type { EncryptionKey, PasswordSettings, TotpCode, Attachment } from '@/utils/dist/core/models/vault'; + +import { BaseRepository } from '../BaseRepository'; + +/** + * SQL query constants for Settings and related operations. + */ +const SettingsQueries = { + /** + * Get setting by key. + */ + GET_SETTING: ` + SELECT s.Value + FROM Settings s + WHERE s.Key = ?`, + + /** + * Get all encryption keys. + */ + GET_ENCRYPTION_KEYS: ` + SELECT + x.PublicKey, + x.PrivateKey, + x.IsPrimary + FROM EncryptionKeys x`, + + /** + * Get TOTP codes for an item. + */ + GET_TOTP_FOR_ITEM: ` + SELECT + Id, + Name, + SecretKey, + ItemId + FROM TotpCodes + WHERE ItemId = ? AND IsDeleted = 0`, + + /** + * Get attachments for an item. + */ + GET_ATTACHMENTS_FOR_ITEM: ` + SELECT + Id, + Filename, + Blob, + ItemId, + CreatedAt, + UpdatedAt, + IsDeleted + FROM Attachments + WHERE ItemId = ? AND IsDeleted = 0` +}; + +/** + * Repository for Settings and auxiliary data operations. + */ +export class SettingsRepository extends BaseRepository { + /** + * Get setting from database for a given key. + * Returns default value (empty string by default) if setting is not found. + * @param key - The setting key + * @param defaultValue - Default value if setting not found + * @returns The setting value + */ + public getSetting(key: string, defaultValue: string = ''): string { + const results = this.client.executeQuery<{ Value: string }>( + SettingsQueries.GET_SETTING, + [key] + ); + return results.length > 0 ? results[0].Value : defaultValue; + } + + /** + * Get the default identity language from the database. + * @returns The stored override value if set, otherwise empty string + */ + public getDefaultIdentityLanguage(): string { + return this.getSetting('DefaultIdentityLanguage'); + } + + /** + * Get the default identity gender preference from the database. + * @returns The gender preference or 'random' if not set + */ + public getDefaultIdentityGender(): string { + return this.getSetting('DefaultIdentityGender', 'random'); + } + + /** + * Get the default identity age range from the database. + * @returns The age range preference or 'random' if not set + */ + public getDefaultIdentityAgeRange(): string { + return this.getSetting('DefaultIdentityAgeRange', 'random'); + } + + /** + * Get the password settings from the database. + * @returns Password settings object + */ + public getPasswordSettings(): PasswordSettings { + const settingsJson = this.getSetting('PasswordGenerationSettings'); + + const defaultSettings: PasswordSettings = { + Length: 18, + UseLowercase: true, + UseUppercase: true, + UseNumbers: true, + UseSpecialChars: true, + UseNonAmbiguousChars: false + }; + + try { + if (settingsJson) { + return { ...defaultSettings, ...JSON.parse(settingsJson) }; + } + } catch (error) { + console.warn('Failed to parse password settings:', error); + } + + return defaultSettings; + } + + /** + * Fetch all encryption keys. + * @returns Array of encryption keys + */ + public getAllEncryptionKeys(): EncryptionKey[] { + return this.client.executeQuery(SettingsQueries.GET_ENCRYPTION_KEYS); + } + + /** + * Get TOTP codes for an item. + * @param itemId - The ID of the item to get TOTP codes for + * @returns Array of TotpCode objects + */ + public getTotpCodesForItem(itemId: string): TotpCode[] { + try { + if (!this.tableExists('TotpCodes')) { + return []; + } + + return this.client.executeQuery(SettingsQueries.GET_TOTP_FOR_ITEM, [itemId]); + } catch (error) { + console.error('Error getting TOTP codes for item:', error); + return []; + } + } + + /** + * Get attachments for an item. + * @param itemId - The ID of the item + * @returns Array of attachments for the item + */ + public getAttachmentsForItem(itemId: string): Attachment[] { + try { + if (!this.tableExists('Attachments')) { + return []; + } + + return this.client.executeQuery( + SettingsQueries.GET_ATTACHMENTS_FOR_ITEM, + [itemId] + ); + } catch (error) { + console.error('Error getting attachments for item:', error); + return []; + } + } + + /** + * Get the default email domain for new aliases. + * @returns The default email domain or empty string if not set + */ + public getDefaultEmailDomain(): string { + return this.getSetting('DefaultEmailDomain'); + } + + /** + * Get the effective identity language, falling back to browser language. + * @returns The effective language code + */ + public getEffectiveIdentityLanguage(): string { + const storedLanguage = this.getDefaultIdentityLanguage(); + if (storedLanguage) { + return storedLanguage; + } + // Fall back to browser language (first two characters) + return navigator.language.substring(0, 2); + } +}