Refactor SettingsService structure so it initializes when the DbService itself is ready (#145)

This commit is contained in:
Leendert de Borst
2024-08-05 11:05:51 +02:00
parent d4a773fc2c
commit eacfee78cc
5 changed files with 162 additions and 143 deletions

View File

@@ -1,62 +1,63 @@
@page "/settings/general"
@inherits MainBase
@inject CredentialService CredentialService
<LayoutPageTitle>General settings</LayoutPageTitle>
<div class="grid grid-cols-1 px-4 pt-6 xl:grid-cols-3 xl:gap-4 dark:bg-gray-900">
<div class="mb-4 col-span-full xl:mb-2">
<Breadcrumb BreadcrumbItems="BreadcrumbItems" />
<div class="flex items-center justify-between">
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">General settings</h1>
</div>
<p>On this page you can configure general AliasVault settings.</p>
<h1 class="text-xl font-semibold text-gray-900 sm:text-2xl dark:text-white">General settings</h1>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Configure general AliasVault settings.</p>
</div>
</div>
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Export vault</h3>
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-lg font-medium text-gray-900 dark:text-white">Email Settings</h3>
<div class="mb-4">
<div>
<button @onclick="ExportVaultSqlite" type="button" class="px-4 py-2 text-white bg-primary-600 rounded-lg hover:bg-primary-700 focus:ring-4 focus:ring-primary-300 dark:bg-primary-500 dark:hover:bg-primary-600 dark:focus:ring-primary-800">
Export vault to unencrypted SQLite file
</button>
</div>
<div class="mt-6">
<button @onclick="ExportVaultCsv" type="button" class="px-4 py-2 text-white bg-primary-600 rounded-lg hover:bg-primary-700 focus:ring-4 focus:ring-primary-300 dark:bg-primary-500 dark:hover:bg-primary-600 dark:focus:ring-primary-800">
Export vault to unencrypted CSV file
</button>
</div>
<label for="defaultEmailDomain" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Default Email Domain</label>
<select @bind="DefaultEmailDomain" id="defaultEmailDomain" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500">
@if (ShowPrivateDomains)
{
<optgroup label="Private Domains">
@foreach (var domain in PrivateDomains)
{
<option value="@domain">@domain</option>
}
</optgroup>
}
<optgroup label="Public Domains">
@foreach (var domain in PublicDomains)
{
<option value="@domain">@domain</option>
}
</optgroup>
</select>
</div>
<div class="flex items-center mb-4">
<input @bind="AutoEmailRefresh" id="autoEmailRefresh" type="checkbox" class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600">
<label for="autoEmailRefresh" class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-300">Auto email refresh on credential page</label>
</div>
</div>
<div class="p-4 mb-4 bg-white border border-gray-200 rounded-lg shadow-sm 2xl:col-span-2 dark:border-gray-700 sm:p-6 dark:bg-gray-800">
<h3 class="mb-4 text-xl font-semibold dark:text-white">Import vault</h3>
<div class="mb-4">
<div>
Import unencrypted CSV file:
<InputFile OnChange="@LoadFiles" />
</div>
</div>
</div>
@if (IsImporting)
{
<p>Loading...</p>
}
else if (!string.IsNullOrEmpty(ImportErrorMessage))
{
<p class="text-danger">@ImportErrorMessage</p>
}
else if (!string.IsNullOrEmpty(ImportSuccessMessage))
{
<p class="text-success">@ImportSuccessMessage</p>
}
@code {
private bool IsImporting = false;
private string ImportErrorMessage = string.Empty;
private string ImportSuccessMessage = string.Empty;
private List<string> PrivateDomains => Config.PrivateEmailDomains;
private List<string> PublicDomains => Config.PublicEmailDomains;
private bool ShowPrivateDomains => PrivateDomains.Count > 0 && !(PrivateDomains.Count == 1 && PrivateDomains[0] == "DISABLED.TLD");
private string DefaultEmailDomain
{
get => DbService.Settings.DefaultEmailDomain;
set => DbService.Settings.SetDefaultEmailDomain(value).Wait();
}
private bool AutoEmailRefresh
{
get => DbService.Settings.AutoEmailRefresh;
set => DbService.Settings.SetAutoEmailRefreshAsync(value).Wait();
}
/// <inheritdoc />
protected override async Task OnInitializedAsync()
@@ -64,83 +65,4 @@ else if (!string.IsNullOrEmpty(ImportSuccessMessage))
await base.OnInitializedAsync();
BreadcrumbItems.Add(new BreadcrumbItem { DisplayName = "Vault settings" });
}
private async Task ExportVaultSqlite()
{
try
{
// Decode the base64 string to a byte array
byte[] fileBytes = Convert.FromBase64String(await DbService.ExportSqliteToBase64Async());
// Create a memory stream from the byte array
using (MemoryStream memoryStream = new MemoryStream(fileBytes))
{
// Invoke JavaScript to initiate the download
await JsInteropService.DownloadFileFromStream("aliasvault-client.sqlite", memoryStream.ToArray());
}
}
catch (Exception ex)
{
Console.WriteLine($"Error downloading file: {ex.Message}");
}
}
private async Task ExportVaultCsv()
{
try
{
var credentials = await CredentialService.LoadAllAsync();
var csvBytes = CsvImportExport.CredentialCsvService.ExportCredentialsToCsv(credentials);
// Create a memory stream from the byte array
using (MemoryStream memoryStream = new MemoryStream(csvBytes))
{
// Invoke JavaScript to initiate the download
await JsInteropService.DownloadFileFromStream("aliasvault-client.csv", memoryStream.ToArray());
}
}
catch (Exception ex)
{
Console.WriteLine($"Error downloading file: {ex.Message}");
}
}
private async Task LoadFiles(InputFileChangeEventArgs e)
{
IsImporting = true;
StateHasChanged();
ImportErrorMessage = string.Empty;
ImportSuccessMessage = string.Empty;
try
{
var file = e.File;
var buffer = new byte[file.Size];
await file.OpenReadStream().ReadAsync(buffer);
var fileContent = System.Text.Encoding.UTF8.GetString(buffer);
var importedCredentials = CsvImportExport.CredentialCsvService.ImportCredentialsFromCsv(fileContent);
// Loop through the imported credentials and actually add them to the database
foreach (var importedCredential in importedCredentials)
{
await CredentialService.InsertEntryAsync(importedCredential, false);
}
// Save the database.
await DbService.SaveDatabaseAsync();
ImportSuccessMessage = $"Succesfully imported {importedCredentials.Count} credentials.";
}
catch (Exception ex)
{
ImportErrorMessage = $"Error importing file: {ex.Message}";
}
finally
{
IsImporting = false;
StateHasChanged();
}
}
}

View File

@@ -68,7 +68,6 @@ builder.Services.AddScoped<DbService>();
builder.Services.AddScoped<GlobalNotificationService>();
builder.Services.AddScoped<GlobalLoadingService>();
builder.Services.AddScoped<JsInteropService>();
builder.Services.AddScoped<SettingsService>();
builder.Services.AddSingleton<ClipboardCopyService>();
builder.Services.AddAuthorizationCore();

View File

@@ -27,6 +27,7 @@ public class DbService : IDisposable
private readonly HttpClient _httpClient;
private readonly DbServiceState _state = new();
private readonly Config _config;
private SettingsService _settingsService = new();
private SqliteConnection _sqlConnection;
private AliasClientDbContext _dbContext;
private bool _isSuccessfullyInitialized;
@@ -54,6 +55,12 @@ public class DbService : IDisposable
(_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>
@@ -416,7 +423,7 @@ public class DbService : IDisposable
string decryptedBase64String = await _jsInteropService.SymmetricDecrypt(vault.Blob, _authService.GetEncryptionKeyAsBase64Async());
await ImportDbContextFromBase64Async(decryptedBase64String);
// Check if database is up to date with migrations.
// Check if database is up-to-date with migrations.
var pendingMigrations = await _dbContext.Database.GetPendingMigrationsAsync();
if (pendingMigrations.Any())
{
@@ -425,6 +432,10 @@ public class DbService : IDisposable
}
_isSuccessfullyInitialized = true;
// Initialize child settings service if it's not already.
await _settingsService.InitializeAsync(this);
_state.UpdateState(DbServiceState.DatabaseStatus.Ready);
return true;
}

View File

@@ -11,30 +11,39 @@ using System;
using System.Text.Json;
using System.Threading.Tasks;
using AliasClientDb;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Service class for accessing and mutating general settings stored in database.
/// </summary>
public class SettingsService(DbService dbService)
/// <remarks>Note: this service does not use DI but instead is initialized by and can be accessed through the DbService.
/// This is done because the SettingsService requires a DbContext during initialization and the context is not yet
/// available during application boot because of encryption/decryption of remote database file. When accessing the
/// settings through the DbService we can ensure proper data flow.</remarks>
public class SettingsService
{
private readonly Dictionary<string, string?> _settings = new();
private DbService? _dbService;
private bool _initialized;
/// <summary>
/// Gets the DefaultEmailDomain setting asynchronously.
/// </summary>
/// <returns>Default email domain as string.</returns>
public Task<string> GetDefaultEmailDomainAsync() => GetSettingAsync("DefaultEmailDomain");
public string DefaultEmailDomain => GetSetting("DefaultEmailDomain");
/// <summary>
/// Gets a value indicating whether email refresh should be done automatically on the credentials page.
/// </summary>
/// <returns>AutoEmailRefresh setting as string.</returns>
public bool AutoEmailRefresh => GetSetting<bool>("AutoEmailRefresh", true);
/// <summary>
/// Sets the DefaultEmailDomain setting asynchronously.
/// </summary>
/// <param name="value">The new DeafultEmailDomain setting.</param>
/// <returns>Task.</returns>
public Task SetDefaultEmailDomainAsync(string value) => SetSettingAsync("DefaultEmailDomain", value);
/// <summary>
/// Gets the AutoEmailRefresh setting asynchronously as a string.
/// </summary>
/// <returns>AutoEmailRefresh setting as string.</returns>
public Task<bool> GetAutoEmailRefreshAsync() => GetSettingAsync<bool>("AutoEmailRefresh");
public Task SetDefaultEmailDomain(string value) => SetSettingAsync("DefaultEmailDomain", value);
/// <summary>
/// Sets the AutoEmailRefresh setting asynchronously as a string.
@@ -43,28 +52,68 @@ public class SettingsService(DbService dbService)
/// <returns>Task.</returns>
public Task SetAutoEmailRefreshAsync(bool value) => SetSettingAsync<bool>("AutoEmailRefresh", value);
/// <summary>
/// Initializes the settings service asynchronously.
/// </summary>
/// <param name="dbService">DbService instance.</param>
/// <returns>Task.</returns>
public async Task InitializeAsync(DbService dbService)
{
if (_initialized)
{
return;
}
// Store the DbService instance for later use.
_dbService = dbService;
var db = await _dbService.GetDbContextAsync();
var settings = await db.Settings.ToListAsync();
foreach (var setting in settings)
{
_settings[setting.Key] = setting.Value;
}
_initialized = true;
}
/// <summary>
/// Get setting value from database.
/// </summary>
/// <param name="key">Key of setting to retrieve.</param>
/// <returns>Setting as string value.</returns>
private async Task<string> GetSettingAsync(string key)
private string GetSetting(string key)
{
var db = await dbService.GetDbContextAsync();
var setting = await db.Settings.FindAsync(key);
return setting?.Value ?? string.Empty;
var setting = _settings.GetValueOrDefault(key);
return setting ?? string.Empty;
}
/// <summary>
/// Gets a setting asynchronously and casts it to the specified type.
/// Gets a setting and casts it to the specified type.
/// </summary>
/// <typeparam name="T">The type to cast the setting to.</typeparam>
/// <param name="key">The key of the setting.</param>
/// <param name="defaultValue">The default value to use if no setting is set in database.</param>
/// <returns>The setting value cast to type T.</returns>
private async Task<T?> GetSettingAsync<T>(string key)
private T? GetSetting<T>(string key, T? defaultValue = default)
{
string value = await GetSettingAsync(key);
return CastSetting<T>(value);
string value = GetSetting(key);
try
{
return CastSetting<T>(value);
}
catch (InvalidOperationException ex)
{
// If no value is available in database but default value is set, return default value.
if (defaultValue is not null)
{
return defaultValue;
}
// No value in database and no default value set, throw exception.
throw new InvalidOperationException($"Failed to cast setting {key} to type {typeof(T)}", ex);
}
}
/// <summary>
@@ -88,7 +137,7 @@ public class SettingsService(DbService dbService)
/// <returns>Task.</returns>
private async Task SetSettingAsync(string key, string value)
{
var db = await dbService.GetDbContextAsync();
var db = await _dbService!.GetDbContextAsync();
var setting = await db.Settings.FindAsync(key);
if (setting == null)
{
@@ -108,7 +157,7 @@ public class SettingsService(DbService dbService)
db.Settings.Update(setting);
}
await dbService.SaveDatabaseAsync();
await _dbService.SaveDatabaseAsync();
}
/// <summary>
@@ -126,7 +175,7 @@ public class SettingsService(DbService dbService)
return default;
}
throw new ArgumentException($"Cannot cast null or empty string to non-nullable type {typeof(T)}");
throw new InvalidOperationException($"Setting value is null or empty for non-nullable type {typeof(T)}");
}
if (typeof(T) == typeof(bool))

View File

@@ -772,6 +772,10 @@ video {
margin-right: 0.25rem;
}
.ml-2 {
margin-left: 0.5rem;
}
.block {
display: block;
}
@@ -1608,6 +1612,11 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.text-red-600 {
--tw-text-opacity: 1;
color: rgb(220 38 38 / var(--tw-text-opacity));
}
.opacity-0 {
opacity: 0;
}
@@ -1777,6 +1786,11 @@ video {
border-color: rgb(244 149 65 / var(--tw-border-opacity));
}
.focus\:border-blue-500:focus {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
}
.focus\:outline-none:focus {
outline: 2px solid transparent;
outline-offset: 2px;
@@ -1839,6 +1853,11 @@ video {
--tw-ring-color: rgb(156 163 175 / var(--tw-ring-opacity));
}
.focus\:ring-blue-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.focus\:ring-offset-2:focus {
--tw-ring-offset-width: 2px;
}
@@ -2041,6 +2060,11 @@ video {
border-color: rgb(244 149 65 / var(--tw-border-opacity));
}
.dark\:focus\:border-blue-500:focus:is(.dark *) {
--tw-border-opacity: 1;
border-color: rgb(59 130 246 / var(--tw-border-opacity));
}
.dark\:focus\:ring-blue-800:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(30 64 175 / var(--tw-ring-opacity));
@@ -2091,6 +2115,16 @@ video {
--tw-ring-color: rgb(127 29 29 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-blue-500:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity));
}
.dark\:focus\:ring-blue-600:focus:is(.dark *) {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(37 99 235 / var(--tw-ring-opacity));
}
@media (min-width: 640px) {
.sm\:col-span-3 {
grid-column: span 3 / span 3;
@@ -2108,6 +2142,10 @@ video {
width: auto;
}
.sm\:flex-row {
flex-direction: row;
}
.sm\:space-x-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1rem * var(--tw-space-x-reverse));