//-----------------------------------------------------------------------
//
// 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;
}
}