mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-09-08 11:42:15 -04:00
Move all apps to subfolder in monorepo (#771)
This commit is contained in:
1155 files changed
+25955
-198
No files matched your search
@@ -0,0 +1,162 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="DbMergeUtility.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Client.Services.Database;
|
||||
|
||||
using System.Globalization;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
/// <summary>
|
||||
/// Class with helper methods to merge two or more vaults.
|
||||
/// </summary>
|
||||
public static class DbMergeUtility
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves the names of all tables in the SQLite database.
|
||||
/// </summary>
|
||||
/// <param name="connection">The SQLite connection to use.</param>
|
||||
/// <returns>A list of table names.</returns>
|
||||
/// <returns>List of table names.</returns>
|
||||
public static async Task<List<string>> GetTableNames(SqliteConnection connection)
|
||||
{
|
||||
var tables = new List<string>();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return tables;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges data from a source table into a base table.
|
||||
/// </summary>
|
||||
/// <param name="baseConnection">The connection to the base database.</param>
|
||||
/// <param name="sourceConnection">The connection to the source database.</param>
|
||||
/// <param name="tableName">The name of the table to merge.</param>
|
||||
/// <param name="logger">ILogger instance.</param>
|
||||
/// <returns>Task.</returns>
|
||||
public static async Task MergeTable(SqliteConnection baseConnection, SqliteConnection sourceConnection, string tableName, ILogger<DbService> logger)
|
||||
{
|
||||
await using var baseCommand = baseConnection.CreateCommand();
|
||||
await using var sourceCommand = sourceConnection.CreateCommand();
|
||||
|
||||
baseCommand.CommandText = $"PRAGMA table_info({tableName})";
|
||||
var columns = new List<string>();
|
||||
|
||||
// Get column names from the base table.
|
||||
await using (var reader = await baseCommand.ExecuteReaderAsync())
|
||||
{
|
||||
while (await reader.ReadAsync())
|
||||
{
|
||||
string columnName = reader.GetString(1);
|
||||
columns.Add(columnName);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the table has Id, UpdatedAt and IsDeleted columns which are required in order to merge.
|
||||
// If columns are missing, skip the table.
|
||||
if (!columns.Contains("Id") || !columns.Contains("UpdatedAt") || !columns.Contains("IsDeleted"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all records from the source table.
|
||||
sourceCommand.CommandText = $"SELECT * FROM {tableName}";
|
||||
await using var sourceReader = await sourceCommand.ExecuteReaderAsync();
|
||||
|
||||
logger.LogDebug("Got records for {tableName}.", tableName);
|
||||
while (await sourceReader.ReadAsync())
|
||||
{
|
||||
var id = sourceReader.GetValue(0);
|
||||
var updatedAt = sourceReader.GetDateTime(columns.IndexOf("UpdatedAt"));
|
||||
|
||||
// Check if the record exists in the base table.
|
||||
baseCommand.CommandText = $"SELECT UpdatedAt FROM {tableName} WHERE Id = @Id";
|
||||
baseCommand.Parameters.Clear();
|
||||
baseCommand.Parameters.AddWithValue("@Id", id);
|
||||
|
||||
logger.LogDebug("Checking if record exists in {tableName}.", tableName);
|
||||
|
||||
var existingRecord = await baseCommand.ExecuteScalarAsync();
|
||||
if (existingRecord != null)
|
||||
{
|
||||
logger.LogDebug("Record exists in {tableName}.", tableName);
|
||||
|
||||
// Record exists, compare UpdatedAt if it exists.
|
||||
logger.LogDebug("Comparing UpdatedAt in {tableName}.", tableName);
|
||||
logger.LogDebug("UpdatedAt: {existingRecord}", existingRecord);
|
||||
var baseUpdatedAt = DateTime.Parse((string)existingRecord, CultureInfo.InvariantCulture);
|
||||
if (updatedAt > baseUpdatedAt)
|
||||
{
|
||||
// Source record is newer, update the base record.
|
||||
await UpdateRecord(baseConnection, tableName, sourceReader, columns);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Base record is newer, skip.
|
||||
logger.LogDebug("Base record is newer, skipping {tableName}.", tableName);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Record doesn't exist in base, add it.
|
||||
await InsertRecord(baseConnection, tableName, sourceReader, columns);
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogDebug("Finished merging {tableName}.", tableName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts a new record into the specified table with data from the source reader.
|
||||
/// </summary>
|
||||
/// <param name="connection">The SQLite connection to use.</param>
|
||||
/// <param name="tableName">The name of the table to insert into.</param>
|
||||
/// <param name="sourceReader">The data reader containing the source record.</param>
|
||||
/// <param name="columns">The list of column names in the table.</param>
|
||||
/// <returns>Task.</returns>
|
||||
private static async Task InsertRecord(SqliteConnection connection, string tableName, SqliteDataReader sourceReader, List<string> columns)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
var columnNames = string.Join(", ", columns);
|
||||
var parameterNames = string.Join(", ", columns.Select(c => $"@{c}"));
|
||||
command.CommandText = $"INSERT INTO {tableName} ({columnNames}) VALUES ({parameterNames})";
|
||||
|
||||
for (int i = 0; i < columns.Count; i++)
|
||||
{
|
||||
command.Parameters.AddWithValue($"@{columns[i]}", sourceReader.GetValue(i));
|
||||
}
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a record in the specified table with data from the source reader.
|
||||
/// </summary>
|
||||
/// <param name="connection">The SQLite connection to use.</param>
|
||||
/// <param name="tableName">The name of the table to update.</param>
|
||||
/// <param name="sourceReader">The data reader containing the source record.</param>
|
||||
/// <param name="columns">The list of column names in the table.</param>
|
||||
/// <returns>Task.</returns>
|
||||
private static async Task UpdateRecord(SqliteConnection connection, string tableName, SqliteDataReader sourceReader, List<string> columns)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
var updateColumns = string.Join(", ", columns.Select(c => $"{c} = @{c}"));
|
||||
command.CommandText = $"UPDATE {tableName} SET {updateColumns} WHERE Id = @Id";
|
||||
|
||||
for (int i = 0; i < columns.Count; i++)
|
||||
{
|
||||
command.Parameters.AddWithValue($"@{columns[i]}", sourceReader.GetValue(i));
|
||||
}
|
||||
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,762 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="DbService.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Client.Services.Database;
|
||||
|
||||
using System.Data;
|
||||
using System.Net.Http.Json;
|
||||
using AliasClientDb;
|
||||
using AliasVault.Client.Services.Auth;
|
||||
using AliasVault.Shared.Models.Enums;
|
||||
using AliasVault.Shared.Models.WebApi.Vault;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class DbService : IDisposable
|
||||
{
|
||||
private readonly AuthService _authService;
|
||||
private readonly JsInteropService _jsInteropService;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly DbServiceState _state = new();
|
||||
private readonly Config _config;
|
||||
private readonly ILogger<DbService> _logger;
|
||||
private readonly GlobalNotificationService _globalNotificationService;
|
||||
private SettingsService _settingsService = new();
|
||||
private SqliteConnection? _sqlConnection;
|
||||
private AliasClientDbContext _dbContext;
|
||||
private long _vaultRevisionNumber;
|
||||
private bool _isSuccessfullyInitialized;
|
||||
private int _retryCount;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DbService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="authService">AuthService.</param>
|
||||
/// <param name="jsInteropService">JsInteropService.</param>
|
||||
/// <param name="httpClient">HttpClient.</param>
|
||||
/// <param name="config">Config instance.</param>
|
||||
/// <param name="globalNotificationService">Global notification service.</param>
|
||||
/// <param name="logger">ILogger instance.</param>
|
||||
public DbService(AuthService authService, JsInteropService jsInteropService, HttpClient httpClient, Config config, GlobalNotificationService globalNotificationService, ILogger<DbService> logger)
|
||||
{
|
||||
_authService = authService;
|
||||
_jsInteropService = jsInteropService;
|
||||
_httpClient = httpClient;
|
||||
_config = config;
|
||||
_globalNotificationService = globalNotificationService;
|
||||
_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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the settings service instance which can be used to interact with general settings stored in the database.
|
||||
/// </summary>
|
||||
/// <returns>SettingsService.</returns>
|
||||
public SettingsService Settings => _settingsService;
|
||||
|
||||
/// <summary>
|
||||
/// Gets database service state object which can be subscribed to.
|
||||
/// </summary>
|
||||
/// <returns>DbServiceState instance.</returns>
|
||||
public DbServiceState GetState()
|
||||
{
|
||||
return _state;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the database, either by creating a new one or loading an existing one from the server.
|
||||
/// </summary>
|
||||
/// <returns>Task.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two or more databases into one.
|
||||
/// </summary>
|
||||
/// <returns>Bool which indicates if merging was successful.</returns>
|
||||
public async Task<bool> MergeDatabasesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var vaultsToMerge = await _httpClient.GetFromJsonAsync<VaultMergeResponse>($"v1/Vault/merge?currentRevisionNumber={_vaultRevisionNumber}");
|
||||
if (vaultsToMerge == null || vaultsToMerge.Vaults.Count == 0)
|
||||
{
|
||||
// No vaults to merge found, set error state.
|
||||
_state.UpdateState(DbServiceState.DatabaseStatus.MergeFailed, "No vaults to merge found.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var sqlConnections = new List<SqliteConnection>();
|
||||
_logger.LogInformation("Merging databases...");
|
||||
|
||||
// Decrypt and instantiate each vault as a separate in-memory SQLite database.
|
||||
foreach (var vault in vaultsToMerge.Vaults)
|
||||
{
|
||||
// Store username of the loaded vault in memory to send to server as sanity check when updating the vault later.
|
||||
_authService.StoreUsername(vault.Username);
|
||||
|
||||
var decryptedBase64String = await _jsInteropService.SymmetricDecrypt(vault.Blob, _authService.GetEncryptionKeyAsBase64Async());
|
||||
|
||||
_logger.LogInformation("Decrypted vault {VaultUpdatedAt}.", vault.UpdatedAt);
|
||||
var connection = new SqliteConnection("Data Source=:memory:");
|
||||
await connection.OpenAsync();
|
||||
await ImportDbContextFromBase64Async(decryptedBase64String, connection);
|
||||
sqlConnections.Add(connection);
|
||||
}
|
||||
|
||||
// Get all table names from the current base database.
|
||||
var tables = await DbMergeUtility.GetTableNames(_sqlConnection!);
|
||||
|
||||
// Disable foreign key checks on the base connection.
|
||||
await using (var command = _sqlConnection!.CreateCommand())
|
||||
{
|
||||
command.CommandText = "PRAGMA foreign_keys = OFF;";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
}
|
||||
|
||||
// Merge every remote database into the current database.
|
||||
foreach (var connection in sqlConnections)
|
||||
{
|
||||
foreach (var table in tables)
|
||||
{
|
||||
_logger.LogInformation("Merging table {Table}.", table);
|
||||
await DbMergeUtility.MergeTable(_sqlConnection, connection, table, _logger);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enable foreign key checks and verify integrity.
|
||||
await using (var command = _sqlConnection.CreateCommand())
|
||||
{
|
||||
command.CommandText = "PRAGMA foreign_keys = ON;";
|
||||
await command.ExecuteNonQueryAsync();
|
||||
|
||||
// Verify foreign key integrity.
|
||||
command.CommandText = "PRAGMA foreign_key_check;";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
if (await reader.ReadAsync())
|
||||
{
|
||||
// Foreign key violation detected.
|
||||
_state.UpdateState(DbServiceState.DatabaseStatus.MergeFailed, "Foreign key violation detected after merge.");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the db context with the new merged database.
|
||||
_dbContext = new AliasClientDbContext(_sqlConnection, log => _logger.LogDebug("{Message}", log));
|
||||
|
||||
// Clean up other connections.
|
||||
foreach (var connection in sqlConnections)
|
||||
{
|
||||
await connection.CloseAsync();
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
|
||||
await _dbContext.Database.MigrateAsync();
|
||||
|
||||
// Update the current vault revision number to the highest revision number in the merged database(s).
|
||||
// This is important so the server knows that the local client has successfully merged the databases
|
||||
// and should overwrite the existing database on the server with the new merged database.
|
||||
_vaultRevisionNumber = vaultsToMerge.Vaults.Max(v => v.CurrentRevisionNumber);
|
||||
|
||||
_isSuccessfullyInitialized = true;
|
||||
await _settingsService.InitializeAsync(this);
|
||||
_state.UpdateState(DbServiceState.DatabaseStatus.Ready);
|
||||
_logger.LogInformation("Databases merged successfully.");
|
||||
|
||||
// Save the newly merged database to the server.
|
||||
return await SaveDatabaseAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_globalNotificationService.AddErrorMessage(
|
||||
"Unable to save changes: Your vault has been updated elsewhere. " +
|
||||
"The automatic merge was unsuccessful, possibly due to a password change or vault upgrade. " +
|
||||
"Please log out and log back in to retrieve the latest version of your vault.");
|
||||
_logger.LogError(ex, "Error merging databases.");
|
||||
_state.UpdateState(DbServiceState.DatabaseStatus.Ready);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the AliasClientDbContext instance.
|
||||
/// </summary>
|
||||
/// <returns>AliasClientDbContext.</returns>
|
||||
public async Task<AliasClientDbContext> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate encrypted base64 string representation of current state of database in order to save it
|
||||
/// to the server.
|
||||
/// </summary>
|
||||
/// <returns>Base64 encoded vault blob.</returns>
|
||||
public async Task<string> GetEncryptedDatabaseBase64String()
|
||||
{
|
||||
// Save the actual dbContext.
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
string base64String = await ExportSqliteToBase64Async();
|
||||
|
||||
// SymmetricEncrypt base64 string using IJSInterop.
|
||||
return await _jsInteropService.SymmetricEncrypt(base64String, _authService.GetEncryptionKeyAsBase64Async());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves the database to the remote server.
|
||||
/// </summary>
|
||||
/// <returns>Bool which indicates if saving database to server was successful.</returns>
|
||||
public async Task<bool> 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);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export the in-memory SQLite database to a base64 string.
|
||||
/// </summary>
|
||||
/// <returns>Base64 encoded string that represents SQLite database.</returns>
|
||||
public async Task<string> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Migrate the database structure to the latest version.
|
||||
/// </summary>
|
||||
/// <returns>Bool which indicates if migration was successful.</returns>
|
||||
public async Task<bool> MigrateDatabaseAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _dbContext.Database.MigrateAsync();
|
||||
_isSuccessfullyInitialized = true;
|
||||
await _settingsService.InitializeAsync(this);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error migrating database.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the current version (applied migration) of the database that is loaded in memory.
|
||||
/// </summary>
|
||||
/// <returns>Version as string.</returns>
|
||||
public async Task<string> GetCurrentDatabaseVersionAsync()
|
||||
{
|
||||
var migrations = await _dbContext.Database.GetAppliedMigrationsAsync();
|
||||
var lastMigration = migrations.LastOrDefault();
|
||||
|
||||
// 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 _))
|
||||
{
|
||||
return versionPart;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the latest available version (EF migration) as defined in code.
|
||||
/// </summary>
|
||||
/// <returns>Version as string.</returns>
|
||||
public async Task<string> GetLatestDatabaseVersionAsync()
|
||||
{
|
||||
var migrations = await _dbContext.Database.GetPendingMigrationsAsync();
|
||||
var lastMigration = migrations.LastOrDefault();
|
||||
|
||||
// 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 _))
|
||||
{
|
||||
return versionPart;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prepare a vault object for upload to the server.
|
||||
/// </summary>
|
||||
/// <param name="encryptedDatabase">Encrypted database as string.</param>
|
||||
/// <returns>Vault object.</returns>
|
||||
public async Task<Vault> PrepareVaultForUploadAsync(string encryptedDatabase)
|
||||
{
|
||||
var username = _authService.GetUsername();
|
||||
var databaseVersion = await GetCurrentDatabaseVersionAsync();
|
||||
var encryptionKey = await GetOrCreateEncryptionKeyAsync();
|
||||
var credentialsCount = await _dbContext.Credentials.Where(x => !x.IsDeleted).CountAsync();
|
||||
var emailAddresses = await GetEmailClaimListAsync();
|
||||
return new Vault
|
||||
{
|
||||
Username = username,
|
||||
Blob = encryptedDatabase,
|
||||
Version = databaseVersion,
|
||||
CurrentRevisionNumber = _vaultRevisionNumber,
|
||||
EncryptionPublicKey = encryptionKey.PublicKey,
|
||||
CredentialsCount = credentialsCount,
|
||||
EmailAddressList = emailAddresses,
|
||||
PrivateEmailDomainList = [],
|
||||
PublicEmailDomainList = [],
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears the database connection and creates a new one so that the database is empty.
|
||||
/// </summary>
|
||||
/// <returns>SqliteConnection and AliasClientDbContext.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get a list of private email addresses that are used in aliases by this vault.
|
||||
/// </summary>
|
||||
/// <returns>List of email addresses.</returns>
|
||||
public async Task<List<string>> GetEmailClaimListAsync()
|
||||
{
|
||||
// Send list of email addresses that are used in aliases by this vault, so they can be
|
||||
// claimed on the server.
|
||||
var emailAddresses = await _dbContext.Aliases
|
||||
.Where(a => a.Email != null)
|
||||
.Where(a => !a.IsDeleted)
|
||||
.Select(a => a.Email)
|
||||
.Distinct()
|
||||
.Select(email => email!)
|
||||
.ToListAsync();
|
||||
|
||||
// Filter the list of email addresses to only include those that are in the supported private email domains.
|
||||
emailAddresses = emailAddresses
|
||||
.Where(email => _config.PrivateEmailDomains.Exists(domain => email.EndsWith(domain)))
|
||||
.ToList();
|
||||
|
||||
return emailAddresses;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implements the IDisposable interface.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a SQLite database from a base64 string which represents a .sqlite file.
|
||||
/// </summary>
|
||||
/// <param name="base64String">Base64 string representation of a .sqlite file.</param>
|
||||
/// <param name="connection">The connection to the database that should be used for the import.</param>
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads the database from the server.
|
||||
/// </summary>
|
||||
/// <returns>Task.</returns>
|
||||
private async Task<bool> LoadDatabaseFromServerAsync()
|
||||
{
|
||||
_state.UpdateState(DbServiceState.DatabaseStatus.Loading);
|
||||
_logger.LogInformation("Loading database from server...");
|
||||
|
||||
// Load from webapi.
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.GetFromJsonAsync<VaultGetResponse>("v1/Vault");
|
||||
if (response is not null)
|
||||
{
|
||||
if (response.Status == VaultStatus.MergeRequired)
|
||||
{
|
||||
_state.UpdateState(DbServiceState.DatabaseStatus.MergeRequired);
|
||||
return false;
|
||||
}
|
||||
|
||||
var vault = response.Vault!;
|
||||
_vaultRevisionNumber = 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!);
|
||||
|
||||
// Check if database is up-to-date with migrations.
|
||||
var pendingMigrations = await _dbContext.Database.GetPendingMigrationsAsync();
|
||||
if (pendingMigrations.Any())
|
||||
{
|
||||
_state.UpdateState(DbServiceState.DatabaseStatus.PendingMigrations);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if any soft-deleted records exist that are older than 7 days. If so, permanently delete them.
|
||||
await VaultCleanupSoftDeletedRecords();
|
||||
|
||||
_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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves encrypted database blob to server and updates the local revision number.
|
||||
/// </summary>
|
||||
/// <param name="encryptedDatabase">Encrypted database as string.</param>
|
||||
/// <returns>True if save action succeeded and revision number was updated, false otherwise.</returns>
|
||||
private async Task<bool> 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<VaultUpdateResponse>();
|
||||
|
||||
if (vaultUpdateResponse != null)
|
||||
{
|
||||
// If the server responds with a merge required status, we need to merge the local database
|
||||
// with the one on the server before we can continue.
|
||||
if (vaultUpdateResponse.Status == VaultStatus.MergeRequired)
|
||||
{
|
||||
_state.UpdateState(DbServiceState.DatabaseStatus.MergeRequired);
|
||||
return await MergeDatabasesAsync();
|
||||
}
|
||||
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the default public/private encryption key, if it does not yet exist, create it.
|
||||
/// </summary>
|
||||
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
|
||||
private async Task<EncryptionKey> 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();
|
||||
|
||||
encryptionKey = new EncryptionKey
|
||||
{
|
||||
PublicKey = keyPair.PublicKey,
|
||||
PrivateKey = keyPair.PrivateKey,
|
||||
IsPrimary = true,
|
||||
CreatedAt = DateTime.Now,
|
||||
UpdatedAt = DateTime.Now,
|
||||
};
|
||||
_dbContext.EncryptionKeys.Add(encryptionKey);
|
||||
return encryptionKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if any soft-deleted records exist that are older than 7 days. If so, permanently delete them.
|
||||
/// </summary>
|
||||
/// <returns>Task.</returns>
|
||||
private async Task VaultCleanupSoftDeletedRecords()
|
||||
{
|
||||
var deleteCount = 0;
|
||||
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-7);
|
||||
var softDeletedCredentials = await _dbContext.Credentials
|
||||
.Where(c => c.IsDeleted && c.UpdatedAt <= cutoffDate)
|
||||
.ToListAsync();
|
||||
|
||||
// Hard delete all soft-deleted credentials that are older than 7 days.
|
||||
foreach (var credential in softDeletedCredentials)
|
||||
{
|
||||
var login = await _dbContext.Credentials
|
||||
.Where(x => x.Id == credential.Id)
|
||||
.FirstAsync();
|
||||
_dbContext.Credentials.Remove(login);
|
||||
|
||||
deleteCount++;
|
||||
}
|
||||
|
||||
// Attachments
|
||||
var softDeletedAttachments = await _dbContext.Attachments
|
||||
.Where(a => a.IsDeleted && a.UpdatedAt <= cutoffDate)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var attachment in softDeletedAttachments)
|
||||
{
|
||||
_dbContext.Attachments.Remove(attachment);
|
||||
deleteCount++;
|
||||
}
|
||||
|
||||
if (deleteCount > 0)
|
||||
{
|
||||
// Save the database to the server to persist the cleanup.
|
||||
var success = await SaveDatabaseAsync();
|
||||
if (!success)
|
||||
{
|
||||
throw new DataException("Error saving database to server after attachment deletion.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the service.
|
||||
/// </summary>
|
||||
/// <param name="disposing">True if disposing.</param>
|
||||
private void Dispose(bool disposing)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_sqlConnection?.Dispose();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="DbServiceState.cs" company="lanedirt">
|
||||
// Copyright (c) lanedirt. All rights reserved.
|
||||
// Licensed under the MIT license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.Client.Services.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Class to manage the state of the DbService that others can subscribe to events for.
|
||||
/// </summary>
|
||||
public sealed class DbServiceState
|
||||
{
|
||||
private DatabaseState _currentState = new();
|
||||
|
||||
/// <summary>
|
||||
/// Subscribe to this event to get notified when the state of the database changes.
|
||||
/// </summary>
|
||||
public event EventHandler<DatabaseState> StateChanged = (sender, e) => { };
|
||||
|
||||
/// <summary>
|
||||
/// Database status enum.
|
||||
/// </summary>
|
||||
public enum DatabaseStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Database not initialized (yet).
|
||||
/// </summary>
|
||||
Uninitialized,
|
||||
|
||||
/// <summary>
|
||||
/// Database is loading from server.
|
||||
/// </summary>
|
||||
Loading,
|
||||
|
||||
/// <summary>
|
||||
/// Database is being created.
|
||||
/// </summary>
|
||||
Creating,
|
||||
|
||||
/// <summary>
|
||||
/// Database requires a merge because of multiple vaults with the same revision number.
|
||||
/// This happens when multiple clients have made changes to the same vault without syncing in between.
|
||||
/// </summary>
|
||||
MergeRequired,
|
||||
|
||||
/// <summary>
|
||||
/// Database merge failed.
|
||||
/// </summary>
|
||||
MergeFailed,
|
||||
|
||||
/// <summary>
|
||||
/// Database failed to decrypt. No data is accessible.
|
||||
/// </summary>
|
||||
DecryptionFailed,
|
||||
|
||||
/// <summary>
|
||||
/// Database has been decrypted but has pending migrations and needs to be updated.
|
||||
/// </summary>
|
||||
PendingMigrations,
|
||||
|
||||
/// <summary>
|
||||
/// Database is ready but no task is currently in progress.
|
||||
/// </summary>
|
||||
Ready,
|
||||
|
||||
/// <summary>
|
||||
/// Database is saving to server.
|
||||
/// </summary>
|
||||
SavingToServer,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current state of the database.
|
||||
/// </summary>
|
||||
public DatabaseState CurrentState
|
||||
{
|
||||
get => _currentState;
|
||||
private set
|
||||
{
|
||||
if (_currentState != value)
|
||||
{
|
||||
_currentState = value;
|
||||
OnStateChanged(_currentState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the state of the database.
|
||||
/// </summary>
|
||||
/// <param name="status">New status.</param>
|
||||
public void UpdateState(DatabaseStatus status)
|
||||
{
|
||||
CurrentState = new DatabaseState
|
||||
{
|
||||
Status = status,
|
||||
Message = string.Empty,
|
||||
LastUpdated = DateTime.Now,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update the state of the database with an additional message.
|
||||
/// </summary>
|
||||
/// <param name="status">New status.</param>
|
||||
/// <param name="message">Status message.</param>
|
||||
public void UpdateState(DatabaseStatus status, string message)
|
||||
{
|
||||
CurrentState = new DatabaseState
|
||||
{
|
||||
Status = status,
|
||||
Message = message,
|
||||
LastUpdated = DateTime.Now,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// OnStateChanged event handler.
|
||||
/// </summary>
|
||||
/// <param name="newState">The new state.</param>
|
||||
private void OnStateChanged(DatabaseState newState)
|
||||
{
|
||||
StateChanged?.Invoke(this, newState);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Database state class.
|
||||
/// </summary>
|
||||
public class DatabaseState
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current status of the database.
|
||||
/// </summary>
|
||||
public DatabaseStatus Status { get; set; } = DatabaseStatus.Uninitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the message associated with the current status.
|
||||
/// </summary>
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the last time the state was updated.
|
||||
/// </summary>
|
||||
public DateTime LastUpdated { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the database state represents an initialized state.
|
||||
/// </summary>
|
||||
/// <returns>Bool.</returns>
|
||||
public bool IsInitialized()
|
||||
{
|
||||
return Status is DatabaseStatus.Ready or DatabaseStatus.SavingToServer;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user