//----------------------------------------------------------------------- // // Copyright (c) aliasvault. All rights reserved. // Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information. // //----------------------------------------------------------------------- namespace AliasVault.Client.Services.Database; using System.Data; using System.Net.Http.Json; using System.Text.Json; using AliasClientDb; using AliasClientDb.Models; using AliasVault.Client.Services; using AliasVault.Client.Services.Auth; using AliasVault.Client.Services.JsInterop.Models; using AliasVault.Client.Services.JsInterop.RustCore; using AliasVault.Client.Utilities; using AliasVault.Shared.Models.Enums; using AliasVault.Shared.Models.WebApi.Vault; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; /// /// Class to manage the in-memory AliasClientDb service. The reason for this service is to provide a way to interact /// with a AliasClientDb database instance that is only persisted in memory due to the encryption requirements of the /// database itself. The database should not be persisted to disk when in un-encrypted form. /// public sealed class DbService : IDisposable { private const string _UNKNOWN_VERSION = "Unknown"; private readonly AuthService _authService; private readonly JsInteropService _jsInteropService; private readonly RustCoreService _rustCore; private readonly HttpClient _httpClient; private readonly DbServiceState _state = new(); private readonly Config _config; private readonly ILogger _logger; private readonly GlobalNotificationService _globalNotificationService; private readonly IStringLocalizer _sharedLocalizer; private SettingsService _settingsService = new(); private SqliteConnection? _sqlConnection; private AliasClientDbContext _dbContext; private long _vaultRevisionNumber; private bool _isSuccessfullyInitialized; private int _retryCount; private bool _disposed; /// /// Initializes a new instance of the class. /// /// AuthService. /// JsInteropService. /// RustCoreService for WASM interop. /// HttpClient. /// Config instance. /// Global notification service. /// IStringLocalizerFactory instance. /// ILogger instance. public DbService(AuthService authService, JsInteropService jsInteropService, RustCoreService rustCore, HttpClient httpClient, Config config, GlobalNotificationService globalNotificationService, IStringLocalizerFactory localizerFactory, ILogger logger) { _authService = authService; _jsInteropService = jsInteropService; _rustCore = rustCore; _httpClient = httpClient; _config = config; _globalNotificationService = globalNotificationService; _sharedLocalizer = localizerFactory.Create("SharedResources", "AliasVault.Client"); _logger = logger; // Set the initial state of the database service. _state.UpdateState(DbServiceState.DatabaseStatus.Uninitialized); // Create an in-memory SQLite database connection which stays open for the lifetime of the service. (_sqlConnection, _dbContext) = InitializeEmptyDatabase(); } /// /// Gets the settings service instance which can be used to interact with general settings stored in the database. /// /// SettingsService. public SettingsService Settings => _settingsService; /// /// Gets database service state object which can be subscribed to. /// /// DbServiceState instance. public DbServiceState GetState() { return _state; } /// /// Initializes the database, either by creating a new one or loading an existing one from the server. /// /// Task. public async Task InitializeDatabaseAsync() { // Check that encryption key is set. If not, do nothing. if (!_authService.IsEncryptionKeySet()) { return; } // Attempt to fill the local database with a previously saved database stored on the server. var loaded = await LoadDatabaseFromServerAsync(); if (loaded) { _retryCount = 0; } } /// /// Stores / updates the vault revision number. Should be called after a successful vault update to the server. /// /// New revision number. public void StoreVaultRevisionNumber(long newRevisionNumber) { _vaultRevisionNumber = newRevisionNumber; } /// /// Returns the AliasClientDbContext instance. /// /// AliasClientDbContext. public async Task GetDbContextAsync() { if (!_isSuccessfullyInitialized) { // Retry initialization up to 5 times before giving up. if (_retryCount < 5) { _retryCount++; await InitializeDatabaseAsync(); } else { throw new DataException("Failed to initialize database."); } } return _dbContext; } /// /// Generate encrypted base64 string representation of current state of database in order to save it /// to the server. /// /// Base64 encoded vault blob. public async Task GetEncryptedDatabaseBase64String() { // Save the actual dbContext. await _dbContext.SaveChangesAsync(); string base64String = await ExportSqliteToBase64Async(); // SymmetricEncrypt base64 string using IJSInterop. return await _jsInteropService.SymmetricEncrypt(base64String, _authService.GetEncryptionKeyAsBase64Async()); } /// /// Saves the database to the remote server. /// /// Bool which indicates if saving database to server was successful. public async Task SaveDatabaseAsync() { if (_state.CurrentState.Status != DbServiceState.DatabaseStatus.Creating) { // If database is not in the process of being created, update status to saving which is reflected in the UI. _state.UpdateState(DbServiceState.DatabaseStatus.SavingToServer); } // Prune expired items from trash before saving. await PruneExpiredTrashItemsAsync(); // Make sure a public/private RSA encryption key exists before saving the database. await GetOrCreateEncryptionKeyAsync(); var encryptedBase64String = await GetEncryptedDatabaseBase64String(); // Save to webapi. var success = await SaveToServerAsync(encryptedBase64String); if (success) { _logger.LogInformation("Database successfully saved to server."); if (_state.CurrentState.Status != DbServiceState.DatabaseStatus.Creating) { // If database is not in the process of being created, update status to ready which is reflected in the UI. _state.UpdateState(DbServiceState.DatabaseStatus.Ready); } } return success; } /// /// Saves the database to the remote server in the background without blocking the caller. /// The local database state is immediately persisted (in-memory), and the server sync happens asynchronously. /// If the sync fails, a notification is shown to the user. /// /// /// This method is useful for operations where blocking the UI is undesirable, such as /// folder creation, settings changes, etc. The local mutation is considered immediately /// successful, and server sync happens in the background. /// public void SaveDatabaseInBackground() { // Set state to indicate background sync is pending _state.UpdateState(DbServiceState.DatabaseStatus.BackgroundSyncPending); // Fire and forget the background save operation _ = Task.Run(async () => { try { // Prune expired items from trash before saving. await PruneExpiredTrashItemsAsync(); // Make sure a public/private RSA encryption key exists before saving the database. await GetOrCreateEncryptionKeyAsync(); var encryptedBase64String = await GetEncryptedDatabaseBase64String(); // Update state to show we're actively syncing _state.UpdateState(DbServiceState.DatabaseStatus.SavingToServer); // Save to webapi. var success = await SaveToServerAsync(encryptedBase64String); if (success) { _logger.LogInformation("Database successfully saved to server (background sync)."); _state.UpdateState(DbServiceState.DatabaseStatus.Ready); } else { _logger.LogWarning("Background sync to server failed."); _globalNotificationService.AddErrorMessage( "Failed to sync changes to server. Your changes are saved locally and will be synced on next refresh."); _state.UpdateState(DbServiceState.DatabaseStatus.Ready); } } catch (Exception ex) { _logger.LogError(ex, "Error during background database sync."); _globalNotificationService.AddErrorMessage(_sharedLocalizer["ErrorUnknown"]); _state.UpdateState(DbServiceState.DatabaseStatus.Ready); } }); } /// /// Export the in-memory SQLite database to a base64 string. /// /// Base64 encoded string that represents SQLite database. public async Task ExportSqliteToBase64Async() { var tempFileName = Path.GetRandomFileName(); // Export SQLite memory database to a temp file. using var memoryStream = new MemoryStream(); await using var command = _sqlConnection!.CreateCommand(); command.CommandText = "VACUUM main INTO @fileName"; command.Parameters.Add(new SqliteParameter("@fileName", tempFileName)); await command.ExecuteNonQueryAsync(); // Get bytes. var bytes = await File.ReadAllBytesAsync(tempFileName); string base64String = Convert.ToBase64String(bytes); // Delete temp file. File.Delete(tempFileName); return base64String; } /// /// Creates a new vault with the latest schema. /// /// Bool which indicates if creating a new vault was successful. public async Task CreateNewVaultAsync() { try { // Call JS interop to get SQL commands to create a new vault with the latest schema. var sqlCommands = await _jsInteropService.GetCreateVaultSqlAsync(); // Execute the SQL commands to create a new vault with the latest schema. foreach (var sqlCommand in sqlCommands.SqlCommands) { await _dbContext.Database.ExecuteSqlRawAsync(sqlCommand); } // Init settings service. _isSuccessfullyInitialized = true; await _settingsService.InitializeAsync(this); } catch (Exception ex) { _logger.LogError(ex, "Error creating new vault."); return false; } return true; } /// /// Migrate the database structure to the latest version. /// /// Bool which indicates if migration was successful. public async Task MigrateDatabaseAsync() { try { // Get current version of database. var currentVersion = await GetCurrentDatabaseVersionAsync(); // Get latest version from JsInteropService. var latestVersion = await _jsInteropService.GetLatestVaultVersionAsync(); // Call JS interop to get SQL commands to create a new vault with the latest schema. var sqlCommands = await _jsInteropService.GetUpgradeVaultSqlAsync(currentVersion.Revision, latestVersion.Revision); // Execute the SQL commands to create a new vault with the latest schema. foreach (var sqlCommand in sqlCommands.SqlCommands) { await _dbContext.Database.ExecuteSqlRawAsync(sqlCommand); } _isSuccessfullyInitialized = true; await _settingsService.InitializeAsync(this); } catch (Exception ex) { _logger.LogError(ex, "Error migrating database."); return false; } return true; } /// /// Get the current version (applied migration) of the database that is loaded in memory. /// Uses semantic versioning to allow backwards-compatible minor/patch versions. /// /// Version as string. public async Task GetCurrentDatabaseVersionAsync() { var migrations = await _dbContext.Database.GetAppliedMigrationsAsync(); var lastMigration = migrations.LastOrDefault(); var currentVersion = _UNKNOWN_VERSION; // Convert migration Id in the form of "20240708094944_1.0.0-InitialMigration" to "1.0.0". if (lastMigration is not null) { var parts = lastMigration.Split('_'); if (parts.Length > 1) { var versionPart = parts[1].Split('-')[0]; if (Version.TryParse(versionPart, out _)) { currentVersion = versionPart; } } } // Check version compatibility using semantic versioning var isCompatible = await _jsInteropService.IsVersionCompatibleAsync(currentVersion); if (!isCompatible) { // Version is incompatible (different major version) return new SqlVaultVersion { Revision = 0, Version = _UNKNOWN_VERSION, Description = _UNKNOWN_VERSION, ReleaseVersion = _UNKNOWN_VERSION, CompatibleUpToVersion = _UNKNOWN_VERSION, }; } // Get all available vault versions to get the revision number of the current version. var allVersions = await _jsInteropService.GetAllVaultVersionsAsync(); var currentVersionRevision = allVersions.FirstOrDefault(v => v.Version == currentVersion); // If the version is known, return it if (currentVersionRevision is not null) { return currentVersionRevision; } /* * Version is unknown but compatible (same major version). * Create a version 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. */ var latestClientVersion = await _jsInteropService.GetLatestVaultVersionAsync(); // Return a version object with the actual database version string but the latest known revision return new SqlVaultVersion { Revision = latestClientVersion.Revision, Version = currentVersion, // Use the actual database version (e.g., "1.7.0") Description = $"Unknown version {currentVersion} (backwards compatible)", ReleaseVersion = latestClientVersion.ReleaseVersion, CompatibleUpToVersion = latestClientVersion.CompatibleUpToVersion, }; } /// /// Get the latest available version (EF migration) as defined in code. /// /// Version as string. public async Task GetLatestDatabaseVersionAsync() { var allVersions = await _jsInteropService.GetAllVaultVersionsAsync(); var latestVersion = allVersions.LastOrDefault(); return latestVersion ?? new SqlVaultVersion { Revision = 0, Version = _UNKNOWN_VERSION, Description = _UNKNOWN_VERSION, ReleaseVersion = _UNKNOWN_VERSION, CompatibleUpToVersion = _UNKNOWN_VERSION, }; } /// /// Prepare a vault object for upload to the server. /// /// Encrypted database as string. /// Vault object. public async Task PrepareVaultForUploadAsync(string encryptedDatabase) { var username = _authService.GetUsername(); var databaseVersion = await GetCurrentDatabaseVersionAsync(); var encryptionKey = await GetOrCreateEncryptionKeyAsync(); var credentialsCount = await _dbContext.Items.Where(x => !x.IsDeleted && x.DeletedAt == null).CountAsync(); var emailAddresses = await GetEmailClaimListAsync(); var currentDateTime = DateTime.UtcNow; return new Vault { Username = username, Blob = encryptedDatabase, Version = databaseVersion.Version, CurrentRevisionNumber = _vaultRevisionNumber, EncryptionPublicKey = encryptionKey.PublicKey, CredentialsCount = credentialsCount, EmailAddressList = emailAddresses, PrivateEmailDomainList = [], HiddenPrivateEmailDomainList = [], PublicEmailDomainList = [], CreatedAt = currentDateTime, UpdatedAt = currentDateTime, }; } /// /// Clears the database connection and creates a new one so that the database is empty. /// /// SqliteConnection and AliasClientDbContext. public (SqliteConnection SqliteConnection, AliasClientDbContext AliasClientDbContext) InitializeEmptyDatabase() { if (_sqlConnection?.State == ConnectionState.Open) { _sqlConnection.Close(); _sqlConnection.Dispose(); } _sqlConnection = new SqliteConnection("Data Source=:memory:"); _sqlConnection.Open(); _dbContext = new AliasClientDbContext(_sqlConnection, log => _logger.LogDebug("{Message}", log)); // Reset the database state. _state.UpdateState(DbServiceState.DatabaseStatus.Uninitialized); _isSuccessfullyInitialized = false; // Reset settings. _settingsService = new(); return (_sqlConnection, _dbContext); } /// /// Get a list of private email addresses that are used in items by this vault. /// /// List of email addresses. public async Task> GetEmailClaimListAsync() { // Send list of email addresses that are used in items by this vault, so they can be // claimed on the server. var emailAddresses = await _dbContext.FieldValues .Where(fv => fv.FieldKey == FieldKey.LoginEmail) .Where(fv => fv.Value != null) .Where(fv => !fv.IsDeleted) .Where(fv => !fv.Item.IsDeleted && fv.Item.DeletedAt == null) .Select(fv => fv.Value) .Distinct() .Select(email => email!) .ToListAsync(); if (_config.PrivateEmailDomains.Count == 0) { return []; } if (_config.PrivateEmailDomains.Count == 1) { if (string.IsNullOrWhiteSpace(_config.PrivateEmailDomains[0])) { return []; } // TODO: "DISABLED.TLD" was a placeholder used < 0.22.0 that has been replaced by an empty string. // That value is still here for legacy purposes, but it can be removed from the codebase in a future release. if (_config.PrivateEmailDomains[0] == "DISABLED.TLD") { return []; } } // Filter the list of email addresses to only include those that are in the supported private email domains. return emailAddresses.Where(email => _config.PrivateEmailDomains.Exists(domain => email.EndsWith(domain))).ToList(); } /// /// Implements the IDisposable interface. /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Loads a SQLite database from a base64 string which represents a .sqlite file. /// /// Base64 string representation of a .sqlite file. /// The connection to the database that should be used for the import. private static async Task ImportDbContextFromBase64Async(string base64String, SqliteConnection connection) { var bytes = Convert.FromBase64String(base64String); var tempFileName = Path.GetRandomFileName(); await File.WriteAllBytesAsync(tempFileName, bytes); await using (var command = connection.CreateCommand()) { // Disable foreign key constraints command.CommandText = "PRAGMA foreign_keys = OFF;"; await command.ExecuteNonQueryAsync(); // Drop all tables in the original database command.CommandText = @" SELECT 'DROP TABLE IF EXISTS ' || name || ';' FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%';"; var dropTableCommands = new List(); await using (var reader = await command.ExecuteReaderAsync()) { while (await reader.ReadAsync()) { dropTableCommands.Add(reader.GetString(0)); } } foreach (var dropTableCommand in dropTableCommands) { command.CommandText = dropTableCommand; await command.ExecuteNonQueryAsync(); } // Attach the imported database command.CommandText = "ATTACH DATABASE @fileName AS importDb"; command.Parameters.Add(new SqliteParameter("@fileName", tempFileName)); await command.ExecuteNonQueryAsync(); // Get CREATE TABLE statements from the imported database command.CommandText = @" SELECT sql FROM importDb.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%';"; var createTableCommands = new List(); await using (var reader = await command.ExecuteReaderAsync()) { while (await reader.ReadAsync()) { createTableCommands.Add(reader.GetString(0)); } } // Create tables in the main database foreach (var createTableCommand in createTableCommands) { command.CommandText = createTableCommand; await command.ExecuteNonQueryAsync(); } // Copy data from imported database to main database command.CommandText = @" SELECT 'INSERT INTO main.' || name || ' SELECT * FROM importDb.' || name || ';' FROM importDb.sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%';"; var tableInsertCommands = new List(); await using (var reader = await command.ExecuteReaderAsync()) { while (await reader.ReadAsync()) { tableInsertCommands.Add(reader.GetString(0)); } } foreach (var tableInsertCommand in tableInsertCommands) { command.CommandText = tableInsertCommand; await command.ExecuteNonQueryAsync(); } // Detach the imported database command.CommandText = "DETACH DATABASE importDb"; await command.ExecuteNonQueryAsync(); // Re-enable foreign key constraints command.CommandText = "PRAGMA foreign_keys = ON;"; await command.ExecuteNonQueryAsync(); } File.Delete(tempFileName); } /// /// Converts a JsonElement to its appropriate .NET value for SQLite parameters. /// /// The JsonElement to convert. /// The converted value. private static object? ConvertJsonElementToValue(JsonElement element) { return element.ValueKind switch { JsonValueKind.String => element.GetString(), JsonValueKind.Number => element.TryGetInt64(out var longVal) ? longVal : element.GetDouble(), JsonValueKind.True => 1L, // SQLite stores booleans as integers JsonValueKind.False => 0L, JsonValueKind.Null => null, JsonValueKind.Undefined => null, _ => element.ToString(), }; } /// /// Replace first occurrence of a string. /// /// The text to search in. /// The string to search for. /// The replacement string. /// The modified string. private static string ReplaceFirst(string text, string search, string replace) { int pos = text.IndexOf(search, StringComparison.Ordinal); if (pos < 0) { return text; } return text[..pos] + replace + text[(pos + search.Length)..]; } /// /// Fetches the latest vault from server, merges with local changes using Rust WASM, and saves the merged result. /// Called when server responds with "Outdated" status, indicating another client has uploaded a newer vault. /// /// Bool which indicates if merge and save was successful. private async Task MergeWithServerAndSaveAsync() { try { _logger.LogInformation("Local vault is outdated. Fetching latest vault from server for merge..."); // Fetch the latest vault from server. var response = await _httpClient.GetFromJsonAsync("v1/Vault"); if (response?.Vault == null || string.IsNullOrEmpty(response.Vault.Blob)) { _logger.LogError("Failed to fetch vault from server for merge."); _globalNotificationService.AddErrorMessage(_sharedLocalizer["ErrorUnknown"]); return false; } var serverVault = response.Vault; _logger.LogInformation("Fetched server vault at revision {Revision}.", serverVault.CurrentRevisionNumber); // Store username of the loaded vault in memory to send to server as sanity check when updating the vault later. _authService.StoreUsername(serverVault.Username); // Decrypt server vault. var decryptedBase64String = await _jsInteropService.SymmetricDecrypt(serverVault.Blob, _authService.GetEncryptionKeyAsBase64Async()); // Get the list of syncable table names from Rust core. var tableNames = await _rustCore.GetSyncableTableNamesAsync(); // Read local tables as JSON. var localTables = await ReadTablesAsJsonAsync(_sqlConnection!, tableNames); _logger.LogDebug("Read {Count} local tables.", localTables.Count); // Create a temporary in-memory SQLite database for the server vault. await using var serverConnection = new SqliteConnection("Data Source=:memory:"); await serverConnection.OpenAsync(); await ImportDbContextFromBase64Async(decryptedBase64String, serverConnection); // Read server tables as JSON. var serverTables = await ReadTablesAsJsonAsync(serverConnection, tableNames); _logger.LogDebug("Read {Count} server tables.", serverTables.Count); // Create the merge input (local has our pending changes, server has the latest). var mergeInput = new MergeInput { LocalTables = localTables, ServerTables = serverTables, }; // Call Rust WASM merge (LWW - Last Write Wins based on UpdatedAt). var mergeOutput = await _rustCore.MergeVaultsAsync(mergeInput); _logger.LogInformation( "Merge completed: {TablesProcessed} tables, {FromLocal} kept local, {FromServer} from server, {Inserted} inserted, {Conflicts} conflicts.", mergeOutput.Stats.TablesProcessed, mergeOutput.Stats.RecordsFromLocal, mergeOutput.Stats.RecordsFromServer, mergeOutput.Stats.RecordsInserted, mergeOutput.Stats.Conflicts); // Execute the SQL statements returned by the merge to update local database. await ExecuteMergeSqlStatementsAsync(mergeOutput.Statements); // Verify foreign key integrity after merge. await using (var command = _sqlConnection!.CreateCommand()) { command.CommandText = "PRAGMA foreign_key_check;"; await using var reader = await command.ExecuteReaderAsync(); if (await reader.ReadAsync()) { _logger.LogError("Foreign key violation detected after merge."); _globalNotificationService.AddErrorMessage(_sharedLocalizer["ErrorUnknown"]); return false; } } // Update the db context with the merged database. _dbContext = new AliasClientDbContext(_sqlConnection, log => _logger.LogDebug("{Message}", log)); // Update the local revision number to the server's revision. // When we upload, server will calculate new revision = this + 1. StoreVaultRevisionNumber(serverVault.CurrentRevisionNumber); _logger.LogInformation("Local merge completed. Uploading merged vault to server..."); // Now save the merged vault to server. This recursive call handles the case where // another client uploaded during our merge (returns Outdated again). return await SaveDatabaseAsync(); } catch (Exception ex) { _globalNotificationService.AddErrorMessage(_sharedLocalizer["ErrorUnknown"]); _logger.LogError(ex, "Error merging with server vault."); return false; } } /// /// Checks if there are any pending migrations. /// /// Bool which indicates if there are any pending migrations. private async Task HasPendingMigrationsAsync() { // Get current version of database. var currentVersion = await GetCurrentDatabaseVersionAsync(); if (currentVersion.Revision == 0) { // Revision 0 means current version could not be found because it's unknown // by the current client, most likely a newer version. Throw error. throw new DataException("Current vault version could not be determined."); } // Get latest version from JsInteropService. var latestVersion = await _jsInteropService.GetLatestVaultVersionAsync(); return currentVersion.Revision < latestVersion.Revision; } /// /// Loads the database from the server. /// /// Task. private async Task LoadDatabaseFromServerAsync() { _state.UpdateState(DbServiceState.DatabaseStatus.Loading); _logger.LogInformation("Loading database from server..."); // Load from webapi. try { var response = await _httpClient.GetFromJsonAsync("v1/Vault"); if (response is not null) { var vault = response.Vault!; StoreVaultRevisionNumber(vault.CurrentRevisionNumber); // Store username of the loaded vault in memory to send to server as sanity check when updating the vault later. _authService.StoreUsername(vault.Username); // Check if vault blob is empty, if so, we don't need to do anything and the initial vault created // on client is sufficient. if (string.IsNullOrEmpty(vault.Blob)) { // Create the database structure from scratch to get an empty ready-to-use database. _state.UpdateState(DbServiceState.DatabaseStatus.Creating); return false; } // Attempt to decrypt the database blob. string decryptedBase64String = await _jsInteropService.SymmetricDecrypt(vault.Blob, _authService.GetEncryptionKeyAsBase64Async()); await ImportDbContextFromBase64Async(decryptedBase64String, _sqlConnection!); // Refresh the db context with the new database to invalidate any cached data if the _dbContext was already used. _dbContext = new AliasClientDbContext(_sqlConnection!, log => _logger.LogDebug("{Message}", log)); // Check if database is up-to-date with migrations. try { if (await HasPendingMigrationsAsync()) { _state.UpdateState(DbServiceState.DatabaseStatus.PendingMigrations); return false; } } catch (DataException) { _state.UpdateState(DbServiceState.DatabaseStatus.VaultVersionUnrecognized); return false; } _isSuccessfullyInitialized = true; await _settingsService.InitializeAsync(this); _state.UpdateState(DbServiceState.DatabaseStatus.Ready); return true; } } catch (Exception ex) { _logger.LogError(ex, "Error loading database from server."); _state.UpdateState(DbServiceState.DatabaseStatus.DecryptionFailed); return false; } return false; } /// /// Saves encrypted database blob to server and updates the local revision number. /// /// Encrypted database as string. /// True if save action succeeded and revision number was updated, false otherwise. private async Task SaveToServerAsync(string encryptedDatabase) { var vaultObject = await PrepareVaultForUploadAsync(encryptedDatabase); try { var response = await _httpClient.PostAsJsonAsync("v1/Vault", vaultObject); // Ensure the request was successful response.EnsureSuccessStatusCode(); // Deserialize the response content var vaultUpdateResponse = await response.Content.ReadFromJsonAsync(); if (vaultUpdateResponse != null) { if (vaultUpdateResponse.Status == VaultStatus.Outdated) { // Server has a newer vault. Fetch it, merge with our local changes, and re-upload. // The merge uses LWW (Last Write Wins) based on UpdatedAt timestamps. return await MergeWithServerAndSaveAsync(); } _vaultRevisionNumber = vaultUpdateResponse.NewRevisionNumber; return true; } _logger.LogError("Error during save: server response was empty or could not be deserialized."); return false; } catch (Exception ex) { _logger.LogError(ex, "Error saving database to server."); return false; } } /// /// Prunes expired items from the trash. /// Items that have been in trash (DeletedAt set) for longer than 30 days /// are permanently deleted (IsDeleted = true). /// /// A representing the asynchronous operation. private async Task PruneExpiredTrashItemsAsync() { try { // Read table data for prune operation var tableNames = new[] { "Items", "FieldValues", "Attachments", "TotpCodes", "Passkeys" }; var tables = await ReadTablesAsJsonAsync(_sqlConnection!, tableNames); var pruneInput = new JsInterop.RustCore.PruneInput { Tables = tables, RetentionDays = 30, CurrentTime = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ"), }; var pruneOutput = await _rustCore.PruneVaultAsync(pruneInput); if (pruneOutput.Success && pruneOutput.Statements.Count > 0) { _logger.LogInformation("Pruning {StatementCount} expired items from trash.", pruneOutput.Statements.Count); // Execute the SQL statements returned by Rust foreach (var stmt in pruneOutput.Statements) { await using var command = _sqlConnection!.CreateCommand(); command.CommandText = stmt.Sql; for (int i = 0; i < stmt.Params.Count; i++) { var param = stmt.Params[i]; command.Parameters.AddWithValue($"@p{i}", param?.ToString() ?? (object)DBNull.Value); } // Replace ? placeholders with @p0, @p1, etc. var parameterizedSql = stmt.Sql; for (int i = 0; i < stmt.Params.Count; i++) { parameterizedSql = ReplaceFirst(parameterizedSql, "?", $"@p{i}"); } command.CommandText = parameterizedSql; await command.ExecuteNonQueryAsync(); } } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to prune expired trash items. Continuing with save."); } } /// /// Get the default public/private encryption key, if it does not yet exist, create it. /// /// A representing the asynchronous operation. private async Task GetOrCreateEncryptionKeyAsync() { var encryptionKey = await _dbContext.EncryptionKeys.FirstOrDefaultAsync(x => x.IsPrimary); if (encryptionKey is not null) { return encryptionKey; } // Create a new encryption key via JSInterop, .NET WASM does not support crypto operations natively (yet). var keyPair = await _jsInteropService.GenerateRsaKeyPair(); var currentDateTime = DateTime.UtcNow; encryptionKey = new EncryptionKey { PublicKey = keyPair.PublicKey, PrivateKey = keyPair.PrivateKey, IsPrimary = true, CreatedAt = currentDateTime, UpdatedAt = currentDateTime, }; _dbContext.EncryptionKeys.Add(encryptionKey); return encryptionKey; } /// /// Reads all specified tables from a SQLite connection as JSON data for the Rust merge. /// /// The SQLite connection to read from. /// The names of tables to read. /// List of TableData objects containing the table records. private async Task> ReadTablesAsJsonAsync(SqliteConnection connection, string[] tableNames) { var tables = new List(); foreach (var tableName in tableNames) { var tableData = new TableData { Name = tableName }; // Check if table exists in the database. await using var checkCommand = connection.CreateCommand(); checkCommand.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name=@tableName"; checkCommand.Parameters.AddWithValue("@tableName", tableName); var exists = await checkCommand.ExecuteScalarAsync(); if (exists == null) { // Table doesn't exist, add empty table data. tables.Add(tableData); continue; } // Get column names for the table. await using var columnsCommand = connection.CreateCommand(); columnsCommand.CommandText = $"PRAGMA table_info({tableName})"; var columns = new List(); await using (var columnsReader = await columnsCommand.ExecuteReaderAsync()) { while (await columnsReader.ReadAsync()) { columns.Add(columnsReader.GetString(1)); } } // Read all records from the table. await using var selectCommand = connection.CreateCommand(); selectCommand.CommandText = $"SELECT * FROM {tableName}"; await using var reader = await selectCommand.ExecuteReaderAsync(); while (await reader.ReadAsync()) { var record = new Dictionary(); for (var i = 0; i < columns.Count; i++) { var value = reader.GetValue(i); // Convert DBNull to null for proper JSON serialization. record[columns[i]] = value == DBNull.Value ? null : value; } tableData.Records.Add(record); } tables.Add(tableData); } return tables; } /// /// Executes the SQL statements returned by the Rust merge operation. /// /// The SQL statements to execute. /// Task. private async Task ExecuteMergeSqlStatementsAsync(List statements) { if (statements.Count == 0) { _logger.LogDebug("No SQL statements to execute from merge."); return; } _logger.LogDebug("Executing {Count} SQL statements from merge.", statements.Count); // Disable foreign key checks during merge execution. await using (var pragmaCommand = _sqlConnection!.CreateCommand()) { pragmaCommand.CommandText = "PRAGMA foreign_keys = OFF;"; await pragmaCommand.ExecuteNonQueryAsync(); } try { foreach (var statement in statements) { await using var command = _sqlConnection!.CreateCommand(); command.CommandText = statement.Sql; // Add parameters in order (SQLite uses positional parameters with ?). for (var i = 0; i < statement.Params.Count; i++) { var value = statement.Params[i]; // Handle JsonElement values from deserialization. if (value is JsonElement jsonElement) { value = ConvertJsonElementToValue(jsonElement); } command.Parameters.AddWithValue($"@p{i}", value ?? DBNull.Value); } // Replace ? placeholders with named parameters. var paramIndex = 0; var sql = statement.Sql; while (sql.Contains('?')) { var pos = sql.IndexOf('?'); sql = sql[..pos] + $"@p{paramIndex}" + sql[(pos + 1)..]; paramIndex++; } command.CommandText = sql; await command.ExecuteNonQueryAsync(); } } finally { // Re-enable foreign key checks. await using var pragmaCommand = _sqlConnection!.CreateCommand(); pragmaCommand.CommandText = "PRAGMA foreign_keys = ON;"; await pragmaCommand.ExecuteNonQueryAsync(); } } /// /// Disposes the service. /// /// True if disposing. private void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { _sqlConnection?.Dispose(); } _disposed = true; } }