Files
aliasvault/apps/mobile-app/utils/SqliteClient.ts
2026-01-01 20:04:37 +01:00

469 lines
16 KiB
TypeScript

import { Buffer } from 'buffer';
import type { EncryptionKeyDerivationParams, VaultMetadata } from '@/utils/dist/core/models/metadata';
import type { EncryptionKey, PasswordSettings } from '@/utils/dist/core/models/vault';
import { VaultSqlGenerator, VaultVersion, checkVersionCompatibility, extractVersionFromMigrationId } from '@/utils/dist/core/vault';
import { VaultVersionIncompatibleError } from '@/utils/types/errors/VaultVersionIncompatibleError';
import NativeVaultManager from '@/specs/NativeVaultManager';
import { ItemRepository } from '@/utils/db/repositories/ItemRepository';
import { SettingsRepository } from '@/utils/db/repositories/SettingsRepository';
import { LogoRepository } from '@/utils/db/repositories/LogoRepository';
import { FolderRepository } from '@/utils/db/repositories/FolderRepository';
import { PasskeyRepository } from '@/utils/db/repositories/PasskeyRepository';
import type { IDatabaseClient, SqliteBindValue } from '@/utils/db/BaseRepository';
type SQLiteBindValue = string | number | null | Uint8Array;
/**
* Client for interacting with the SQLite database through native code.
* Implements IDatabaseClient interface for repository pattern.
*/
class SqliteClient implements IDatabaseClient {
// 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 for Item CRUD operations.
*/
public get items(): ItemRepository {
if (!this._items) {
this._items = new ItemRepository(this);
}
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;
}
/**
* Store the vault metadata via the native code implementation.
*
* Metadata is stored in plain text in UserDefaults. The metadata consists of the following:
* - public email domains
* - private email domains
* - hidden private email domains
* - vault revision number
*/
public async storeMetadata(metadata: string): Promise<void> {
try {
await NativeVaultManager.storeMetadata(metadata);
} catch (error) {
console.error('Error storing vault metadata:', error);
throw error;
}
}
/**
* Retrieve the vault metadata from native storage
* @returns The parsed VaultMetadata object
* @throws Error if metadata is not found or cannot be parsed
*/
public async getVaultMetadata(): Promise<VaultMetadata> {
try {
const metadataJson = await NativeVaultManager.getVaultMetadata();
if (!metadataJson) {
throw new Error('No vault metadata found in native storage');
}
try {
return JSON.parse(metadataJson) as VaultMetadata;
} catch {
throw new Error('Failed to parse vault metadata from native storage');
}
} catch (error) {
console.error('Error retrieving vault metadata:', error);
throw error;
}
}
/**
* Get the default email domain from the vault metadata.
* Returns the first valid private domain if available, otherwise the first valid public domain.
* Returns null if no valid domains are found.
*/
public async getDefaultEmailDomain(): Promise<string | null> {
try {
const metadata = await this.getVaultMetadata();
if (!metadata) {
return null;
}
const { privateEmailDomains, publicEmailDomains, hiddenPrivateEmailDomains } = metadata;
/**
* Check if a domain is valid (not empty, not 'DISABLED.TLD', not hidden, and exists in either private or public domains)
*/
const isValidDomain = (domain: string): boolean => {
return Boolean(domain &&
domain !== 'DISABLED.TLD' &&
domain !== '' &&
!hiddenPrivateEmailDomains?.includes(domain) &&
(privateEmailDomains?.includes(domain) || publicEmailDomains?.includes(domain)));
};
// Get the default email domain from vault settings
const defaultEmailDomain = await this.getSetting('DefaultEmailDomain');
// First check if the default domain that is configured in the vault is still valid (not hidden)
if (defaultEmailDomain && isValidDomain(defaultEmailDomain)) {
return defaultEmailDomain;
}
// If default domain is not valid, fall back to first available private domain
// Filter out hidden private domains from the list of private domains
const firstPrivate = privateEmailDomains?.filter(domain => !hiddenPrivateEmailDomains?.includes(domain)).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;
} catch (error) {
console.error('Error getting default email domain:', error);
return null;
}
}
/**
* Get the private email domains supported by the AliasVault server from the vault metadata.
* @returns The private email domains.
*/
public async getPrivateEmailDomains(): Promise<string[]> {
const metadata = await this.getVaultMetadata();
return metadata?.privateEmailDomains ?? [];
}
/**
* Store the encryption key in the native keychain
*
* @param base64EncryptionKey The base64 encoded encryption key
*/
public async storeEncryptionKey(base64EncryptionKey: string): Promise<void> {
try {
// Store the encryption key in the native module
await NativeVaultManager.storeEncryptionKey(base64EncryptionKey);
} catch (error) {
console.error('Error storing encryption key:', error);
throw error;
}
}
/**
* Store the key derivation params in the native keychain
*
* @param keyDerivationParams The key derivation parameters
*/
public async storeEncryptionKeyDerivationParams(keyDerivationParams: EncryptionKeyDerivationParams): Promise<void> {
try {
const keyDerivationParamsJson = JSON.stringify(keyDerivationParams);
await NativeVaultManager.storeEncryptionKeyDerivationParams(keyDerivationParamsJson);
} catch (error) {
console.error('Error storing encryption key derivation params:', error);
throw error;
}
}
/**
* Execute a SELECT query
*/
public async executeQuery<T>(query: string, params: SQLiteBindValue[] = []): Promise<T[]> {
try {
/*
* Convert any Uint8Array parameters to base64 strings as the Native wrapper
* communication requires everything to be a string.
*/
const convertedParams = params.map(param => {
if (param instanceof Uint8Array) {
/*
* We prefix the base64 string with "av-base64:" to indicate that it is a base64 encoded Uint8Array.
* So the receiving end knows that it should convert this value back to a Uint8Array before using it in the query.
*/
return 'av-base64-to-blob:' + Buffer.from(param).toString('base64');
}
return param;
});
const results = await NativeVaultManager.executeQuery(query, convertedParams);
return results as T[];
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
/**
* Execute an INSERT, UPDATE, or DELETE query
*/
public async executeUpdate(query: string, params: SQLiteBindValue[] = []): Promise<number> {
try {
/*
* Convert any Uint8Array parameters to base64 strings as the Native wrapper
* communication requires everything to be a string.
*/
const convertedParams = params.map(param => {
if (param instanceof Uint8Array) {
/*
* We prefix the base64 string with "av-base64-to-blob:" to indicate that it is a base64 encoded Uint8Array.
* So the receiving end knows that it should convert this value back to a Uint8Array before using it in the query.
*/
return 'av-base64-to-blob:' + Buffer.from(param).toString('base64');
}
return param;
});
const result = await NativeVaultManager.executeUpdate(query, convertedParams);
return result as number;
} catch (error) {
console.error('Error executing update:', error);
throw error;
}
}
/**
* Fetch all encryption keys.
*/
public async getAllEncryptionKeys(): Promise<EncryptionKey[]> {
return this.executeQuery<EncryptionKey>(`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 async getSetting(key: string, defaultValue: string = ''): Promise<string> {
const results = await 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 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 async getDefaultIdentityLanguage(): Promise<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<string> {
const explicitLanguage = await 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');
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 async getDefaultIdentityGender(): Promise<string> {
return this.getSetting('DefaultIdentityGender', 'random');
}
/**
* Get the default identity age range from the database.
*/
public async getDefaultIdentityAgeRange(): Promise<string> {
return this.getSetting('DefaultIdentityAgeRange', 'random');
}
/**
* Update a setting in the database.
* @param key The setting key
* @param value The setting value
*/
public async updateSetting(key: string, value: string): Promise<void> {
await NativeVaultManager.beginTransaction();
const currentDateTime = new Date().toISOString()
.replace('T', ' ')
.replace('Z', '')
.substring(0, 23);
// First check if the setting already exists
const checkQuery = `SELECT COUNT(*) as count FROM Settings WHERE Key = ?`;
const checkResults = await this.executeQuery<{ count: number }>(checkQuery, [key]);
const exists = checkResults[0]?.count > 0;
if (exists) {
// Update existing record
const updateQuery = `
UPDATE Settings
SET Value = ?, UpdatedAt = ?
WHERE Key = ?`;
await this.executeUpdate(updateQuery, [value, currentDateTime, key]);
} else {
// Insert new record
const insertQuery = `
INSERT INTO Settings (Key, Value, CreatedAt, UpdatedAt, IsDeleted)
VALUES (?, ?, ?, ?, ?)`;
await this.executeUpdate(insertQuery, [key, value, currentDateTime, currentDateTime, 0]);
}
await NativeVaultManager.commitTransaction();
}
/**
* Get the password settings from the database.
*/
public async getPasswordSettings(): Promise<PasswordSettings> {
const settingsJson = await 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 internal version information that matches the current database version.
* Uses semantic versioning to allow backwards-compatible minor/patch versions.
*/
public async getDatabaseVersion(): Promise<VaultVersion> {
// Query the migrations history table for the latest migration
const results = await this.executeQuery<{ MigrationId: string }>(`
SELECT MigrationId
FROM __EFMigrationsHistory
ORDER BY MigrationId DESC
LIMIT 1`);
if (results.length === 0) {
throw new Error('No migrations found');
}
// Extract version from migration ID (e.g., "20240917191243_1.4.1-RenameAttachmentsPlural" -> "1.4.1")
const migrationId = results[0].MigrationId;
const databaseVersion = extractVersionFromMigrationId(migrationId);
if (!databaseVersion) {
throw new Error('Could not extract version from migration ID');
}
// Check version compatibility using semantic versioning
const compatibilityResult = checkVersionCompatibility(databaseVersion);
if (!compatibilityResult.isCompatible) {
throw new VaultVersionIncompatibleError('vault.errors.appOutdated');
}
// 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")
description: `Unknown version ${databaseVersion} (backwards compatible)`,
releaseVersion: latestClientVersion.releaseVersion,
compatibleUpToVersion: latestClientVersion.compatibleUpToVersion
};
}
/**
* Returns the version info of the latest available vault migration.
*/
public async getLatestDatabaseVersion(): Promise<VaultVersion> {
const vaultSqlGenerator = new VaultSqlGenerator();
const allVersions = vaultSqlGenerator.getAllVersions();
return allVersions[allVersions.length - 1];
}
}
export default SqliteClient;