Improve client DB sync status indicators (#74)

This commit is contained in:
Leendert de Borst
2024-07-08 16:31:24 +02:00
parent db62eeec22
commit 98dea2c4bf
27 changed files with 969 additions and 426 deletions

1
.gitignore vendored
View File

@@ -377,6 +377,5 @@ src/AliasVault.WebApp/wwwroot/index.html
# appsettings.Development.json is generated by the build process from appsettings.Development.template.json and therefore should be ignored
src/AliasVault.WebApp/wwwroot/appsettings.Development.json
# .env is generated by init.sh and therefore should be ignored
.env

View File

@@ -0,0 +1,12 @@
To upgrade the AliasClientDb EF model, follow these steps:
1. Make changes to the AliasClientDb EF model in the `AliasClientDb` project.
2. Create a new migration by running the following command in the `AliasClientDb` project:
```bash
# Important: make sure the migration name is prefixed by the Semver version number of the release.
# For example, if the release version is 1.0.0, the migration name should be `1.0.0-<migration-name>`.
dotnet ef migrations add "1.0.0-<migration-name>"
```
4. On the next login of a user, they will be prompted (required) to upgrade their database schema to the latest version.
Make sure to manually test this.

View File

@@ -28,13 +28,14 @@ public class VaultController(AliasServerDbContext context, UserManager<AliasVaul
/// <summary>
/// Default retention policy for vaults.
/// </summary>
private readonly RetentionPolicy _retentionPolicy = new RetentionPolicy
private readonly RetentionPolicy _retentionPolicy = new()
{
Rules = new List<IRetentionRule>
{
new DailyRetentionRule() { DaysToKeep = 3 },
new WeeklyRetentionRule() { WeeksToKeep = 1 },
new MonthlyRetentionRule() { MonthsToKeep = 1 },
new DailyRetentionRule { DaysToKeep = 3 },
new WeeklyRetentionRule { WeeksToKeep = 1 },
new MonthlyRetentionRule { MonthsToKeep = 1 },
new VersionRetentionRule { VersionsToKeep = 3 },
},
};
@@ -61,10 +62,10 @@ public class VaultController(AliasServerDbContext context, UserManager<AliasVaul
// as starting point.
if (vault == null)
{
return Ok(new Shared.Models.WebApi.Vault(string.Empty, DateTime.MinValue, DateTime.MinValue));
return Ok(new Shared.Models.WebApi.Vault(string.Empty, string.Empty, DateTime.MinValue, DateTime.MinValue));
}
return Ok(new Shared.Models.WebApi.Vault(vault.VaultBlob, vault.CreatedAt, vault.UpdatedAt));
return Ok(new Shared.Models.WebApi.Vault(vault.VaultBlob, vault.Version, vault.CreatedAt, vault.UpdatedAt));
}
/// <summary>
@@ -86,6 +87,7 @@ public class VaultController(AliasServerDbContext context, UserManager<AliasVaul
{
UserId = user.Id,
VaultBlob = model.Blob,
Version = model.Version,
CreatedAt = timeProvider.UtcNow,
UpdatedAt = timeProvider.UtcNow,
};

View File

@@ -0,0 +1,32 @@
//-----------------------------------------------------------------------
// <copyright file="VersionRetentionRule.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.Api.Vault.RetentionRules;
using AliasServerDb;
/// <summary>
/// Version retention rule that keeps the latest X unique versions of the vault.
/// </summary>
public class VersionRetentionRule : IRetentionRule
{
/// <summary>
/// Gets or sets amount of versions to keep the vault.
/// </summary>
public int VersionsToKeep { get; set; }
/// <inheritdoc cref="IRetentionRule.ApplyRule"/>
public IEnumerable<Vault> ApplyRule(List<Vault> vaults, DateTime now)
{
// For the specified amount of versions, take last vault per version.
return vaults
.GroupBy(x => x.Version)
.Select(g => g.OrderByDescending(x => x.UpdatedAt).First())
.OrderByDescending(x => x.UpdatedAt)
.Take(VersionsToKeep);
}
}

View File

@@ -16,11 +16,13 @@ public class Vault
/// Initializes a new instance of the <see cref="Vault"/> class.
/// </summary>
/// <param name="blob">Blob.</param>
/// <param name="version">Version of the vault data model (migration).</param>
/// <param name="createdAt">CreatedAt.</param>
/// <param name="updatedAt">UpdatedAt.</param>
public Vault(string blob, DateTime createdAt, DateTime updatedAt)
public Vault(string blob, string version, DateTime createdAt, DateTime updatedAt)
{
Blob = blob;
Version = version;
CreatedAt = createdAt;
UpdatedAt = updatedAt;
}
@@ -30,6 +32,11 @@ public class Vault
/// </summary>
public string Blob { get; set; }
/// <summary>
/// Gets or sets the vault version.
/// </summary>
public string Version { get; set; }
/// <summary>
/// Gets or sets the date and time of creation.
/// </summary>

View File

@@ -60,6 +60,9 @@
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="Main\Pages\Sync\StatusMessages\ErrorVaultDecrypt.razor" />
<AdditionalFiles Include="Main\Pages\Sync\StatusMessages\PendingMigrations.razor" />
<AdditionalFiles Include="Main\Pages\Sync\StatusMessages\VaultDecryptionProgress.razor" />
<None Include="wwwroot\appsettings.Development.json" Condition="Exists('wwwroot\appsettings.Development.json')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
@@ -89,4 +92,10 @@
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
</ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="Main\Layout\StatusMessages\ErrorVaultDecrypt.razor" />
<_ContentIncludedByDefault Remove="Main\Layout\StatusMessages\PendingMigrations.razor" />
<_ContentIncludedByDefault Remove="Main\Layout\StatusMessages\VaultDecryptionProgress.razor" />
</ItemGroup>
</Project>

View File

@@ -40,11 +40,6 @@ else
Message = "Saving...";
await ShowLoadingIndicatorAsync();
}
else if (newState.Status == DbServiceState.DatabaseStatus.LoadingFromServer)
{
Message = "Loading...";
await ShowLoadingIndicatorAsync();
}
LoadingIndicatorMessage = Message + " - " + newState.LastUpdated;
}

View File

@@ -0,0 +1,16 @@
@inherits LayoutComponentBase
<CascadingAuthenticationState>
<AuthorizeView>
<Authorized>
<main>
@Body
</main>
</Authorized>
<NotAuthorized>
<main>
@Body
</main>
</NotAuthorized>
</AuthorizeView>
</CascadingAuthenticationState>

View File

@@ -1,8 +1,4 @@
@inherits LayoutComponentBase
@implements IDisposable
@inject DbService DbService
@inject AuthenticationStateProvider AuthStateProvider
@using AliasVault.WebApp.Main.Layout.StatusMessages
<CascadingAuthenticationState>
<AuthorizeView>
@@ -10,25 +6,10 @@
<TopMenu />
<div class="flex pt-16 overflow-hidden bg-gray-50 dark:bg-gray-900">
<div id="main-content" class="relative w-full max-w-screen-2xl mx-auto h-full overflow-y-auto bg-gray-50 dark:bg-gray-900">
@if (IsDbInitialized)
{
<main>
@Body
</main>
<Footer />
}
else if(IsDbDecryptionError)
{
<ErrorVaultDecrypt />
}
else if(IsPendingMigrations)
{
<PendingMigrations />
}
else
{
<VaultDecryptionProgress />
}
<main>
@Body
</main>
<Footer />
</div>
</div>
</Authorized>
@@ -41,119 +22,5 @@
</CascadingAuthenticationState>
@code {
private bool IsDbInitialized { get; set; } = false;
private bool IsDbDecryptionError { get; set; } = false;
private bool IsPendingMigrations { get; set; } = false;
private const int MinimumLoadingTimeMs = 800;
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Reset local state
IsDbInitialized = false;
IsDbDecryptionError = false;
IsPendingMigrations = false;
DbService.GetState().StateChanged += OnDatabaseStateChanged;
AuthStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged;
await CheckAndInitializeDatabase();
}
protected override async Task OnParametersSetAsync()
{
if (AuthState != null)
{
var authState = await AuthState;
if (authState.User.Identity?.IsAuthenticated == true)
{
await CheckAndInitializeDatabase();
}
}
}
private async void OnAuthenticationStateChanged(Task<AuthenticationState> task)
{
var authState = await task;
if (authState.User.Identity?.IsAuthenticated == true)
{
await CheckAndInitializeDatabase();
}
StateHasChanged();
}
private async Task CheckAndInitializeDatabase()
{
var currentState = DbService.GetState().CurrentState;
if (currentState.Status == DbServiceState.DatabaseStatus.Uninitialized)
{
await InitializeDatabaseWithProgress();
}
else if (currentState.Status == DbServiceState.DatabaseStatus.Ready)
{
IsDbInitialized = true;
StateHasChanged();
}
else if (currentState.Status == DbServiceState.DatabaseStatus.DecryptionFailed)
{
IsDbDecryptionError = true;
StateHasChanged();
}
else if (currentState.Status == DbServiceState.DatabaseStatus.PendingMigrations)
{
IsPendingMigrations = true;
StateHasChanged();
}
}
private async void OnDatabaseStateChanged(object? sender, DbServiceState.DatabaseState newState)
{
if (newState.Status == DbServiceState.DatabaseStatus.Uninitialized)
{
await InitializeDatabaseWithProgress();
}
else if (newState.Status == DbServiceState.DatabaseStatus.Ready)
{
IsDbInitialized = true;
}
else if (newState.Status == DbServiceState.DatabaseStatus.DecryptionFailed)
{
IsDbDecryptionError = true;
}
else if (newState.Status == DbServiceState.DatabaseStatus.PendingMigrations)
{
IsPendingMigrations = true;
}
StateHasChanged();
}
private async Task InitializeDatabaseWithProgress()
{
IsDbInitialized = false;
StateHasChanged();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
await DbService.InitializeDatabaseAsync();
stopwatch.Stop();
var elapsedMs = (int)stopwatch.ElapsedMilliseconds;
if (elapsedMs < MinimumLoadingTimeMs)
{
await Task.Delay(MinimumLoadingTimeMs - elapsedMs);
}
await CheckAndInitializeDatabase();
StateHasChanged();
}
public void Dispose()
{
DbService.GetState().StateChanged -= OnDatabaseStateChanged;
AuthStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
}
}

View File

@@ -1,77 +0,0 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}

View File

@@ -1,8 +0,0 @@
<div class="fixed inset-0 overflow-y-auto h-full w-full flex items-center justify-center">
<div class="relative p-8 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md mx-auto">
<div class="text-center">
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Vault decryption error.</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">An error occured while locally decrypting your vault. Your data is not accessible at this moment. Please try again (later) or contact support.</p>
</div>
</div>
</div>

View File

@@ -1,65 +0,0 @@
@inject DbService DbService
@inject GlobalNotificationService GlobalNotificationService
<div class="fixed inset-0 overflow-y-auto h-full w-full flex items-center justify-center">
<div class="relative p-8 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md mx-auto">
<div class="text-center">
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Vault needs to be upgraded.</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">AliasVault has been updated which requires your vault to be upgraded in order to be compatible with the new datastructure.
This upgrade should only take a few seconds.</p>
<div class="mt-4">
@if (ErrorMessage.Length > 0)
{
<AlertMessageError Message="@ErrorMessage" />
}
@if (IsPendingMigrations)
{
<svg class="mx-auto animate-spin h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
}
else
{
<button @onclick="MigrateDatabase" 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">
Start upgrade process
</button>
}
</div>
</div>
</div>
</div>
@code {
private bool IsPendingMigrations { get; set; } = false;
private string ErrorMessage { get; set; } = string.Empty;
private async Task MigrateDatabase()
{
// Show loading indicator
IsPendingMigrations = true;
ErrorMessage = String.Empty;
StateHasChanged();
// Simulate a delay.
await Task.Delay(1000);
// Migrate the database
if (await DbService.MigrateDatabaseAsync())
{
// Migration successful
GlobalNotificationService.AddSuccessMessage("Database upgrade successful.", true);
}
else
{
// Migration failed
ErrorMessage = "Database upgrade failed. Please try again or contact support.";
}
// Reset local state
IsPendingMigrations = false;
StateHasChanged();
}
}

View File

@@ -1,12 +0,0 @@
<div class="fixed inset-0 overflow-y-auto h-full w-full flex items-center justify-center">
<div class="relative p-8 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md mx-auto">
<div class="text-center">
<svg class="mx-auto animate-spin h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Vault decryption in progress</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Please wait while your vault is initialized. This may take a moment.</p>
</div>
</div>
</div>

View File

@@ -91,6 +91,19 @@ public class MainBase : OwningComponentBase
await Task.Delay(200);
}
}
// Check if DB is initialized, if not, redirect to setup page.
if (!DbService.GetState().CurrentState.IsInitialized())
{
var currentUrl = NavigationManager.Uri;
await LocalStorage.SetItemAsync("returnUrl", currentUrl);
NavigationManager.NavigateTo("/sync");
while (true)
{
await Task.Delay(200);
}
}
}
/// <inheritdoc />
@@ -106,6 +119,19 @@ public class MainBase : OwningComponentBase
await Task.Delay(200);
}
}
// Check if DB is initialized, if not, redirect to setup page.
if (!DbService.GetState().CurrentState.IsInitialized())
{
var currentUrl = NavigationManager.Uri;
await LocalStorage.SetItemAsync("returnUrl", currentUrl);
NavigationManager.NavigateTo("/sync");
while (true)
{
await Task.Delay(200);
}
}
}
/// <summary>

View File

@@ -0,0 +1,66 @@
@inject DbService DbService
@inject GlobalNotificationService GlobalNotificationService
<div class="relative p-8 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md mx-auto">
<div class="text-center">
<svg class="mx-auto animate-spin h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<div class="space-y-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Welcome to AliasVault!</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Your new encrypted vault is being created. This process may take a moment. Please wait.
</p>
<div>
@if (ErrorMessage.Length > 0)
{
<AlertMessageError Message="@ErrorMessage" />
}
</div>
</div>
</div>
</div>
@code {
private string ErrorMessage { get; set; } = string.Empty;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
}
/// <inheritdoc />
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
// Start the database migration process
await MigrateDatabase();
}
}
private async Task MigrateDatabase()
{
// Simulate a delay.
await Task.Delay(3000);
// Migrate the database
if (await DbService.MigrateDatabaseAsync())
{
// Migration successful
GlobalNotificationService.AddSuccessMessage("Vault successfully created.", true);
}
else
{
// Migration failed
ErrorMessage = "Vault creation failed. Please try again or contact support.";
}
StateHasChanged();
}
}

View File

@@ -0,0 +1,6 @@
<div class="relative p-8 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md mx-auto">
<div class="text-center">
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Vault decryption error.</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">An error occured while locally decrypting your vault. Your data is not accessible at this moment. Please try again (later) or contact support.</p>
</div>
</div>

View File

@@ -0,0 +1,92 @@
@inject DbService DbService
@inject GlobalNotificationService GlobalNotificationService
<div class="relative p-8 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md mx-auto">
<div class="text-center">
<div class="space-y-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Vault needs to be upgraded.</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
AliasVault has been updated. Your personal vault needs to be upgraded in order to be compatible with the new data model.
This upgrade should only take a few seconds.
</p>
<div class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg shadow-sm">
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-3">Version Information</h3>
<div class="space-y-2">
<p class="flex justify-between items-center">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Your vault:</span>
<span class="text-base font-bold text-blue-600 dark:text-blue-400">@CurrentVersion</span>
</p>
<p class="flex justify-between items-center">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">AliasVault latest version:</span>
<span class="text-base font-bold text-green-600 dark:text-green-400">@LatestVersion</span>
</p>
</div>
</div>
<div>
@if (ErrorMessage.Length > 0)
{
<AlertMessageError Message="@ErrorMessage" />
}
@if (IsPendingMigrations)
{
<svg class="mx-auto animate-spin h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
}
else
{
<button @onclick="MigrateDatabase" 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">
Start upgrade process
</button>
}
</div>
</div>
</div>
</div>
@code {
private bool IsPendingMigrations { get; set; } = false;
private string ErrorMessage { get; set; } = string.Empty;
private string CurrentVersion { get; set; } = string.Empty;
private string LatestVersion { get; set; } = string.Empty;
/// <inheritdoc />
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
// Get current and latest available database version
CurrentVersion = await DbService.GetCurrentDatabaseVersionAsync();
LatestVersion = await DbService.GetLatestDatabaseVersionAsync();
}
private async Task MigrateDatabase()
{
// Show loading indicator
IsPendingMigrations = true;
ErrorMessage = String.Empty;
StateHasChanged();
// Simulate a delay.
await Task.Delay(1000);
// Migrate the database
if (await DbService.MigrateDatabaseAsync())
{
// Migration successful
GlobalNotificationService.AddSuccessMessage("Vault upgrade successful.", true);
}
else
{
// Migration failed
ErrorMessage = "Database upgrade failed. Please try again or contact support.";
}
// Reset local state
IsPendingMigrations = false;
StateHasChanged();
}
}

View File

@@ -0,0 +1,10 @@
<div class="relative p-8 bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md mx-auto">
<div class="text-center">
<svg class="mx-auto animate-spin h-12 w-12 text-primary-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<h2 class="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Vault decryption in progress</h2>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">Please wait while your vault is initialized. This may take a moment.</p>
</div>
</div>

View File

@@ -0,0 +1,117 @@
@page "/sync"
@layout EmptyLayout
@implements IDisposable
@using AliasVault.WebApp.Main.Pages.Sync.StatusMessages
@inject ILocalStorageService LocalStorage
@inject DbService DbService
@inject NavigationManager NavigationManager
<LayoutPageTitle>Sync</LayoutPageTitle>
<div class="fixed inset-0 overflow-y-auto h-full w-full flex flex-col items-center justify-center">
@if (CurrentDbState.Status == DbServiceState.DatabaseStatus.DecryptionFailed)
{
<ErrorVaultDecrypt />
}
else if (CurrentDbState.Status == DbServiceState.DatabaseStatus.PendingMigrations)
{
<PendingMigrations />
}
else if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Creating)
{
<Creating />
}
else
{
<VaultDecryptionProgress />
}
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 mt-6">
Switch accounts? <a href="/user/logout" class="text-primary-700 hover:underline dark:text-primary-500">Logout</a>
</div>
</div>
@code {
private DbServiceState.DatabaseState CurrentDbState { get; set; } = new();
private const int MinimumLoadingTimeMs = 800;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
DbService.GetState().StateChanged += OnDatabaseStateChanged;
CurrentDbState = DbService.GetState().CurrentState;
await CheckAndInitializeDatabase();
}
private async Task CheckAndInitializeDatabase()
{
CurrentDbState = DbService.GetState().CurrentState;
if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Uninitialized)
{
await InitializeDatabaseWithProgress();
}
else if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Ready)
{
await RedirectBackToReturnUrl();
}
StateHasChanged();
}
private async void OnDatabaseStateChanged(object? sender, DbServiceState.DatabaseState newState)
{
CurrentDbState = DbService.GetState().CurrentState;
if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Uninitialized)
{
await InitializeDatabaseWithProgress();
}
else if (CurrentDbState.Status == DbServiceState.DatabaseStatus.Ready)
{
await RedirectBackToReturnUrl();
}
StateHasChanged();
}
private async Task InitializeDatabaseWithProgress()
{
StateHasChanged();
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
await DbService.InitializeDatabaseAsync();
stopwatch.Stop();
var elapsedMs = (int)stopwatch.ElapsedMilliseconds;
if (elapsedMs < MinimumLoadingTimeMs)
{
await Task.Delay(MinimumLoadingTimeMs - elapsedMs);
}
await CheckAndInitializeDatabase();
StateHasChanged();
}
private async Task RedirectBackToReturnUrl()
{
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>("returnUrl");
if (!string.IsNullOrEmpty(localStorageReturnUrl) && localStorageReturnUrl != "/sync")
{
await LocalStorage.RemoveItemAsync("returnUrl");
NavigationManager.NavigateTo(localStorageReturnUrl);
}
else
{
NavigationManager.NavigateTo("/");
}
}
public void Dispose()
{
DbService.GetState().StateChanged -= OnDatabaseStateChanged;
}
}

View File

@@ -73,32 +73,14 @@ public class DbService : IDisposable
return;
}
_state.UpdateState(DbServiceState.DatabaseStatus.LoadingFromServer);
// Ensure the in-memory database representation is created and has the necessary tables.
await _dbContext.Database.MigrateAsync();
// Attempt to fill the local database with a previously saved database stored on the server.
var loaded = await LoadDatabaseFromServerAsync();
if (loaded)
{
Console.WriteLine("Database successfully loaded from server.");
// Check if database is up to date with migrations.
var pendingMigrations = await _dbContext.Database.GetPendingMigrationsAsync();
if (pendingMigrations.Any())
{
_state.UpdateState(DbServiceState.DatabaseStatus.PendingMigrations);
}
else
{
_isSuccessfullyInitialized = true;
_state.UpdateState(DbServiceState.DatabaseStatus.Ready);
}
_retryCount = 0;
}
else
{
_state.UpdateState(DbServiceState.DatabaseStatus.DecryptionFailed);
Console.WriteLine("Failed to load database from server.");
}
}
@@ -147,7 +129,7 @@ public class DbService : IDisposable
var success = await SaveToServerAsync(encryptedBase64String);
if (success)
{
Console.WriteLine("Database succesfully saved to server.");
Console.WriteLine("Database successfully saved to server.");
_state.UpdateState(DbServiceState.DatabaseStatus.Ready);
}
else
@@ -205,13 +187,65 @@ public class DbService : IDisposable
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>
/// 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 (_isSuccessfullyInitialized && _sqlConnection.State == ConnectionState.Open)
if (_sqlConnection is not null && _sqlConnection.State == ConnectionState.Open)
{
_sqlConnection.Close();
}
@@ -221,6 +255,8 @@ public class DbService : IDisposable
_dbContext = new AliasClientDbContext(_sqlConnection, log => Console.WriteLine(log));
// Reset the database state.
_state.UpdateState(DbServiceState.DatabaseStatus.Uninitialized);
_isSuccessfullyInitialized = false;
return (_sqlConnection, _dbContext);
@@ -264,61 +300,8 @@ public class DbService : IDisposable
var tempFileName = Path.GetRandomFileName();
await File.WriteAllBytesAsync(tempFileName, bytes);
/*using (var command = _sqlConnection.CreateCommand())
{
// Empty all tables in the original database
command.CommandText = @"
SELECT 'DELETE FROM ' || name || ';'
FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%';";
var emptyTableCommands = new List<string>();
using (var reader = await command.ExecuteReaderAsync())
{
while (await reader.ReadAsync())
{
emptyTableCommands.Add(reader.GetString(0));
}
}
foreach (var emptyTableCommand in emptyTableCommands)
{
command.CommandText = emptyTableCommand;
await command.ExecuteNonQueryAsync();
}
// Attach the imported database and copy tables
command.CommandText = "ATTACH DATABASE @fileName AS importDb";
command.Parameters.Add(new SqliteParameter("@fileName", tempFileName));
await command.ExecuteNonQueryAsync();
command.CommandText = @"
SELECT 'INSERT INTO main.' || name || ' SELECT * FROM importDb.' || name || ';'
FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%';";
var tableInsertCommands = new List<string>();
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();
}
command.CommandText = "DETACH DATABASE importDb";
await command.ExecuteNonQueryAsync();
}
*/
using (var command = _sqlConnection.CreateCommand())
{
Console.WriteLine("Dropping main tables..");
// Disable foreign key constraints
command.CommandText = "PRAGMA foreign_keys = OFF;";
await command.ExecuteNonQueryAsync();
@@ -339,8 +322,6 @@ public class DbService : IDisposable
foreach (var dropTableCommand in dropTableCommands)
{
Console.WriteLine("Dropping table..");
Console.WriteLine("Drop command: " + dropTableCommand);
command.CommandText = dropTableCommand;
await command.ExecuteNonQueryAsync();
}
@@ -350,8 +331,6 @@ public class DbService : IDisposable
command.Parameters.Add(new SqliteParameter("@fileName", tempFileName));
await command.ExecuteNonQueryAsync();
Console.WriteLine("Make create table statements from import db..");
// Get CREATE TABLE statements from the imported database
command.CommandText = @"
SELECT sql
@@ -367,8 +346,6 @@ public class DbService : IDisposable
}
// Create tables in the main database
Console.WriteLine("Create tables in main db..");
foreach (var createTableCommand in createTableCommands)
{
command.CommandText = createTableCommand;
@@ -376,8 +353,6 @@ public class DbService : IDisposable
}
// Copy data from imported database to main database
Console.WriteLine("Copy from import to main db.");
command.CommandText = @"
SELECT 'INSERT INTO main.' || name || ' SELECT * FROM importDb.' || name || ';'
FROM importDb.sqlite_master
@@ -415,6 +390,8 @@ public class DbService : IDisposable
/// <returns>Task.</returns>
private async Task<bool> LoadDatabaseFromServerAsync()
{
_state.UpdateState(DbServiceState.DatabaseStatus.Loading);
// Load from webapi.
try
{
@@ -425,28 +402,32 @@ public class DbService : IDisposable
// on client is sufficient.
if (string.IsNullOrEmpty(vault.Blob))
{
return true;
}
try
{
// Attempt to decrypt the database blob.
string decryptedBase64String = await _jsRuntime.InvokeAsync<string>("cryptoInterop.decrypt", vault.Blob, _authService.GetEncryptionKeyAsBase64Async());
await ImportDbContextFromBase64Async(decryptedBase64String);
}
catch (Exception ex)
{
// If decryption fails it can indicate that the master password hash is not correct anymore,
// so we logout the user just in case.
Console.WriteLine(ex.Message);
// 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 _jsRuntime.InvokeAsync<string>("cryptoInterop.decrypt", vault.Blob, _authService.GetEncryptionKeyAsBase64Async());
await ImportDbContextFromBase64Async(decryptedBase64String);
// 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;
}
_isSuccessfullyInitialized = true;
_state.UpdateState(DbServiceState.DatabaseStatus.Ready);
return true;
}
}
catch
catch (Exception ex)
{
Console.WriteLine(ex.Message);
_state.UpdateState(DbServiceState.DatabaseStatus.DecryptionFailed);
return false;
}
@@ -460,7 +441,8 @@ public class DbService : IDisposable
/// <returns>True if save action succeeded.</returns>
private async Task<bool> SaveToServerAsync(string encryptedDatabase)
{
var vaultObject = new Vault(encryptedDatabase, DateTime.Now, DateTime.Now);
var databaseVersion = await GetCurrentDatabaseVersionAsync();
var vaultObject = new Vault(encryptedDatabase, databaseVersion, DateTime.Now, DateTime.Now);
try
{

View File

@@ -29,6 +29,16 @@ public class DbServiceState
/// </summary>
Uninitialized,
/// <summary>
/// Database is loading from server.
/// </summary>
Loading,
/// <summary>
/// Database is being created.
/// </summary>
Creating,
/// <summary>
/// Database failed to decrypt. No data is accessible.
/// </summary>
@@ -44,11 +54,6 @@ public class DbServiceState
/// </summary>
Ready,
/// <summary>
/// Database is loading from server.
/// </summary>
LoadingFromServer,
/// <summary>
/// Database is saving to server.
/// </summary>
@@ -133,5 +138,22 @@ public class DbServiceState
/// 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 a initialized state.
/// </summary>
/// <returns>Bool.</returns>
public bool IsInitialized()
{
if (Status == DatabaseStatus.Uninitialized ||
Status == DatabaseStatus.PendingMigrations ||
Status == DatabaseStatus.Loading ||
Status == DatabaseStatus.DecryptionFailed)
{
return false;
}
return true;
}
}
}

View File

@@ -1147,6 +1147,11 @@ video {
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
}
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
}
.bg-opacity-50 {
--tw-bg-opacity: 0.5;
}
@@ -1403,6 +1408,16 @@ video {
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity));
}
.text-green-600 {
--tw-text-opacity: 1;
color: rgb(22 163 74 / var(--tw-text-opacity));
}
.opacity-0 {
opacity: 0;
}

View File

@@ -0,0 +1,370 @@
// <auto-generated />
using System;
using AliasServerDb;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace AliasServerDb.Migrations
{
[DbContext(typeof(AliasServerDbContext))]
[Migration("20240708113743_AddVaultVersionColumn")]
partial class AddVaultVersionColumn
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.6")
.HasAnnotation("Proxies:ChangeTracking", false)
.HasAnnotation("Proxies:CheckEquality", false)
.HasAnnotation("Proxies:LazyLoading", true);
modelBuilder.Entity("AliasServerDb.AliasVaultUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<string>("Salt")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("Verifier")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("AliasServerDb.AspNetUserRefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("DeviceIdentifier")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("ExpireDate")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserRefreshTokens");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("VaultBlob")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Vaults");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<string>("UserId")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("RoleId")
.HasColumnType("TEXT");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.Property<string>("UserId")
.HasColumnType("TEXT");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("AliasServerDb.AspNetUserRefreshToken", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("AliasServerDb.Vault", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AliasServerDb.AliasVaultUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
{
b.HasOne("AliasServerDb.AliasVaultUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasServerDb.Migrations
{
/// <inheritdoc />
public partial class AddVaultVersionColumn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Version",
table: "Vaults",
type: "TEXT",
maxLength: 255,
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Version",
table: "Vaults");
}
}
}

View File

@@ -149,6 +149,11 @@ namespace AliasServerDb.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");

View File

@@ -37,6 +37,12 @@ public class Vault
/// </summary>
public string VaultBlob { get; set; } = null!;
/// <summary>
/// Gets or sets the vault data model version.
/// </summary>
[StringLength(255)]
public string Version { get; set; } = null!;
/// <summary>
/// Gets or sets created timestamp.
/// </summary>

View File

@@ -30,15 +30,15 @@ public class VaultRetentionManagerTests
now = new DateTime(2023, 6, 1, 12, 0, 0); // Set a fixed "now" date for testing: June 1, 2023, 12:00 PM
testVaults =
[
new Vault { UpdatedAt = new DateTime(2023, 5, 31, 12, 0, 0) },
new Vault { UpdatedAt = new DateTime(2023, 5, 31, 4, 0, 0) },
new Vault { UpdatedAt = new DateTime(2023, 5, 30, 12, 0, 0) }, // 2 days ago
new Vault { UpdatedAt = new DateTime(2023, 5, 29, 12, 0, 0) }, // 3 days ago
new Vault { UpdatedAt = new DateTime(2023, 5, 28, 12, 0, 0) }, // 4 days ago
new Vault { UpdatedAt = new DateTime(2023, 5, 18, 12, 0, 0) }, // 2 weeks ago
new Vault { UpdatedAt = new DateTime(2023, 5, 11, 12, 0, 0) }, // 3 weeks ago
new Vault { UpdatedAt = new DateTime(2023, 5, 1, 12, 0, 0) }, // 1 month ago
new Vault { UpdatedAt = new DateTime(2023, 4, 1, 12, 0, 0) }, // 2 months ago
new Vault { Version = "1.1.0", UpdatedAt = new DateTime(2023, 5, 31, 12, 0, 0) },
new Vault { Version = "1.1.0", UpdatedAt = new DateTime(2023, 5, 31, 4, 0, 0) },
new Vault { Version = "1.1.0", UpdatedAt = new DateTime(2023, 5, 30, 12, 0, 0) }, // 2 days ago
new Vault { Version = "1.1.0", UpdatedAt = new DateTime(2023, 5, 29, 12, 0, 0) }, // 3 days ago
new Vault { Version = "1.0.3", UpdatedAt = new DateTime(2023, 5, 28, 12, 0, 0) }, // 4 days ago
new Vault { Version = "1.0.3", UpdatedAt = new DateTime(2023, 5, 18, 12, 0, 0) }, // 2 weeks ago
new Vault { Version = "1.0.3", UpdatedAt = new DateTime(2023, 5, 11, 12, 0, 0) }, // 3 weeks ago
new Vault { Version = "1.0.2", UpdatedAt = new DateTime(2023, 5, 1, 12, 0, 0) }, // 1 month ago
new Vault { Version = "1.0.1", UpdatedAt = new DateTime(2023, 4, 1, 12, 0, 0) }, // 2 months ago
];
}
@@ -101,6 +101,23 @@ public class VaultRetentionManagerTests
});
}
/// <summary>
/// Test the VersionRetentionRule.
/// </summary>
[Test]
public void VersionRetentionRuleTest()
{
var rule = new VersionRetentionRule { VersionsToKeep = 2 };
var result = rule.ApplyRule(testVaults, now).ToList();
Assert.Multiple(() =>
{
Assert.That(result, Has.Count.EqualTo(2));
Assert.That(result[0].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 31, 12, 0, 0)));
Assert.That(result[1].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 28, 12, 0, 0)));
});
}
/// <summary>
/// Test the RetentionPolicy object.
/// </summary>
@@ -114,6 +131,7 @@ public class VaultRetentionManagerTests
new DailyRetentionRule { DaysToKeep = 2 },
new WeeklyRetentionRule { WeeksToKeep = 2 },
new MonthlyRetentionRule { MonthsToKeep = 1 },
new VersionRetentionRule { VersionsToKeep = 3 },
},
};
@@ -130,11 +148,12 @@ public class VaultRetentionManagerTests
Assert.Multiple(() =>
{
Assert.That(vaultsToKeep, Has.Count.EqualTo(3));
Assert.That(vaultsToDelete, Has.Count.EqualTo(6));
Assert.That(vaultsToKeep, Has.Count.EqualTo(4));
Assert.That(vaultsToDelete, Has.Count.EqualTo(5));
Assert.That(vaultsToKeep[0].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 31, 12, 0, 0)));
Assert.That(vaultsToKeep[1].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 30, 12, 0, 0)));
Assert.That(vaultsToKeep[2].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 28, 12, 0, 0)));
Assert.That(vaultsToKeep[3].UpdatedAt, Is.EqualTo(new DateTime(2023, 5, 1, 12, 0, 0)));
});
}