Merge pull request #91 from lanedirt/74-add-versioning-support-to-local-sqlite-implementation-with-local-upgrade-paths

Add versioning support to local sqlite implementation with local upgrade paths
This commit is contained in:
Leendert de Borst
2024-07-09 12:19:10 -07:00
committed by GitHub
41 changed files with 2294 additions and 288 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>
@@ -70,6 +73,9 @@
<Link>.dockerignore</Link>
</Content>
<AdditionalFiles Include="..\stylecop.json" Link="stylecop.json" />
<Content Update="wwwroot\appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
@@ -86,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

@@ -77,7 +77,10 @@
if (!result.IsSuccessStatusCode)
{
LoginBase.ParseResponse(responseContent);
foreach (var error in LoginBase.ParseResponse(responseContent))
{
_serverValidationErrors.AddError(error);
}
StateHasChanged();
return;
}

View File

@@ -75,7 +75,11 @@
try
{
await ProcessLoginAsync(Email, _unlockModel.Password);
var errors = await ProcessLoginAsync(Email, _unlockModel.Password);
foreach (var error in errors)
{
_serverValidationErrors.AddError(error);
}
StateHasChanged();
}
#if DEBUG

View File

@@ -1,6 +1,6 @@
@inject NavigationManager NavigationManager
<div @onclick="ShowDetails" class="p-4 space-y-2 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700
<div @onclick="ShowDetails" class="credential-card p-4 space-y-2 bg-white border border-gray-200 rounded-lg shadow-sm dark:border-gray-700
dark:bg-gray-800 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors duration-200">
<div class="px-4 py-2 text-gray-400 rounded text-center flex flex-col items-center">
<DisplayFavicon faviconBytes="@Obj.Logo"></DisplayFavicon>

View File

@@ -34,17 +34,12 @@ else
private async void OnDatabaseStateChanged(object? sender, DbServiceState.DatabaseState newState)
{
await InvokeAsync(StateHasChanged);
if (newState.Status == DbServiceState.DatabaseStatus.Saving)
if (newState.Status == DbServiceState.DatabaseStatus.SavingToServer)
{
// Show loading indicator for at least 0.5 seconds even if the save operation is faster.
Message = "Saving...";
await ShowLoadingIndicatorAsync();
}
else if (newState.Status == DbServiceState.DatabaseStatus.Loading)
{
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,10 +1,4 @@
@inherits LayoutComponentBase
@implements IDisposable
@inject DbService DbService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@using Microsoft.AspNetCore.Components.Authorization
@using AliasVault.WebApp.Providers
<CascadingAuthenticationState>
<AuthorizeView>
@@ -12,28 +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
{
<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>
}
<main>
@Body
</main>
<Footer />
</div>
</div>
</Authorized>
@@ -46,94 +22,5 @@
</CascadingAuthenticationState>
@code {
private bool IsDbInitialized { get; set; } = false;
private const int MinimumLoadingTimeMs = 800;
[CascadingParameter] private Task<AuthenticationState>? AuthState { get; set; }
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
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.Initialized)
{
IsDbInitialized = 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.Initialized)
{
IsDbInitialized = 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);
}
IsDbInitialized = true;
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

@@ -21,6 +21,7 @@ using Microsoft.JSInterop;
/// </summary>
public class MainBase : OwningComponentBase
{
private const string ReturnUrlKey = "returnUrl";
private bool _parametersInitialSet;
/// <summary>
@@ -91,6 +92,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(ReturnUrlKey, currentUrl);
NavigationManager.NavigateTo("/sync");
while (true)
{
await Task.Delay(200);
}
}
}
/// <inheritdoc />
@@ -106,6 +120,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(ReturnUrlKey, currentUrl);
NavigationManager.NavigateTo("/sync");
while (true)
{
await Task.Delay(200);
}
}
}
/// <summary>
@@ -152,13 +179,13 @@ public class MainBase : OwningComponentBase
if (!AuthService.IsEncryptionKeySet())
{
// If returnUrl is not set and current URL is not unlock page, set it to the current URL.
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>("returnUrl");
var localStorageReturnUrl = await LocalStorage.GetItemAsync<string>(ReturnUrlKey);
if (string.IsNullOrEmpty(localStorageReturnUrl))
{
var currentUrl = NavigationManager.Uri;
if (!currentUrl.Contains("unlock"))
{
await LocalStorage.SetItemAsync("returnUrl", currentUrl);
await LocalStorage.SetItemAsync(ReturnUrlKey, currentUrl);
}
}

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">
<div class="space-y-4">
<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="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(1500);
// 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,126 @@
@page "/sync"
@layout EmptyLayout
@implements IDisposable
@using AliasVault.WebApp.Main.Pages.Sync.StatusMessages
@inject ILocalStorageService LocalStorage
@inject DbService DbService
@inject AuthService AuthService
@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 const string ReturnUrlKey = "returnUrl";
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;
// Check that encryption key is set. If not, redirect to unlock screen.
if (!AuthService.IsEncryptionKeySet())
{
await LocalStorage.SetItemAsync(ReturnUrlKey, NavigationManager.Uri);
NavigationManager.NavigateTo("/unlock");
}
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>(ReturnUrlKey);
if (!string.IsNullOrEmpty(localStorageReturnUrl) && localStorageReturnUrl != "/sync")
{
await LocalStorage.RemoveItemAsync(ReturnUrlKey);
NavigationManager.NavigateTo(localStorageReturnUrl);
}
else
{
NavigationManager.NavigateTo("/");
}
}
public void Dispose()
{
DbService.GetState().StateChanged -= OnDatabaseStateChanged;
}
}

View File

@@ -163,19 +163,19 @@ public class AuthService(HttpClient httpClient, ILocalStorageService localStorag
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task RemoveTokensAsync()
{
await localStorage.RemoveItemAsync(AccessTokenKey);
await localStorage.RemoveItemAsync(RefreshTokenKey);
// If the remote call fails we catch the exception and ignore it.
// This is because the user is already logged out and we don't want to trigger another refresh token request.
// Revoke the tokens from the server by calling the webapi.
try
{
await RevokeTokenAsync();
}
catch (Exception)
{
// Ignore the exception
// If an exception occurs we ignore it and continue with removing the tokens from local storage.
}
// Remove the tokens from local storage.
await localStorage.RemoveItemAsync(AccessTokenKey);
await localStorage.RemoveItemAsync(RefreshTokenKey);
}
/// <summary>

View File

@@ -13,6 +13,7 @@ using AliasClientDb;
using AliasVault.Shared.Models.WebApi;
using AliasVault.WebApp.Services.Auth;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.JSInterop;
/// <summary>
@@ -72,22 +73,14 @@ public class DbService : IDisposable
return;
}
_state.UpdateState(DbServiceState.DatabaseStatus.Loading);
// Ensure the in-memory database representation is created and has the necessary tables.
await _dbContext.Database.EnsureCreatedAsync();
// Attempt to fill the local database with a previously saved database stored on the server.
var loaded = await LoadDatabaseFromServerAsync();
if (loaded)
{
_isSuccessfullyInitialized = true;
_state.UpdateState(DbServiceState.DatabaseStatus.Initialized);
Console.WriteLine("Database succesfully loaded from server.");
_retryCount = 0;
}
else
{
_state.UpdateState(DbServiceState.DatabaseStatus.Error);
Console.WriteLine("Failed to load database from server.");
}
}
@@ -122,7 +115,7 @@ public class DbService : IDisposable
public async Task SaveDatabaseAsync()
{
// Set the initial state of the database service.
_state.UpdateState(DbServiceState.DatabaseStatus.Saving);
_state.UpdateState(DbServiceState.DatabaseStatus.SavingToServer);
// Save the actual dbContext.
await _dbContext.SaveChangesAsync();
@@ -136,13 +129,13 @@ public class DbService : IDisposable
var success = await SaveToServerAsync(encryptedBase64String);
if (success)
{
Console.WriteLine("Database succesfully saved to server.");
_state.UpdateState(DbServiceState.DatabaseStatus.Initialized);
Console.WriteLine("Database successfully saved to server.");
_state.UpdateState(DbServiceState.DatabaseStatus.Ready);
}
else
{
Console.WriteLine("Failed to save database to server.");
_state.UpdateState(DbServiceState.DatabaseStatus.Error);
_state.UpdateState(DbServiceState.DatabaseStatus.OperationError);
}
}
@@ -173,13 +166,86 @@ public class DbService : IDisposable
return base64String;
}
/// <summary>
/// Migrate the database structure to the latest version.
/// </summary>
/// <returns>Bool which indicates if migration was succesful.</returns>
public async Task<bool> MigrateDatabaseAsync()
{
try
{
await _dbContext.Database.MigrateAsync();
_isSuccessfullyInitialized = true;
_state.UpdateState(DbServiceState.DatabaseStatus.Ready);
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
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>
/// 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();
}
@@ -189,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);
@@ -234,9 +302,13 @@ public class DbService : IDisposable
using (var command = _sqlConnection.CreateCommand())
{
// Disable foreign key constraints
command.CommandText = "PRAGMA foreign_keys = OFF;";
await command.ExecuteNonQueryAsync();
// Drop all tables in the original database
command.CommandText = @"
SELECT 'DELETE FROM ' || name || ';'
SELECT 'DROP TABLE IF EXISTS ' || name || ';'
FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%';";
var dropTableCommands = new List<string>();
@@ -254,14 +326,36 @@ public class DbService : IDisposable
await command.ExecuteNonQueryAsync();
}
// Attach the imported database and copy tables
// 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>();
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 sqlite_master
FROM importDb.sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%';";
var tableInsertCommands = new List<string>();
using (var reader = await command.ExecuteReaderAsync())
@@ -278,8 +372,13 @@ public class DbService : IDisposable
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);
@@ -291,6 +390,8 @@ public class DbService : IDisposable
/// <returns>Task.</returns>
private async Task<bool> LoadDatabaseFromServerAsync()
{
_state.UpdateState(DbServiceState.DatabaseStatus.Loading);
// Load from webapi.
try
{
@@ -301,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;
}
@@ -336,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,25 +29,40 @@ public class DbServiceState
/// </summary>
Uninitialized,
/// <summary>
/// Database is initialized but no task is currently in progress.
/// </summary>
Initialized,
/// <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>
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>
Saving,
SavingToServer,
/// <summary>
/// An error occurred during a database operation.
/// </summary>
Error,
OperationError,
}
/// <summary>
@@ -123,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

@@ -0,0 +1,4 @@
{
"ApiUrl": "http://localhost:5092",
"UseDebugEncryptionKey": "false"
}

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

@@ -32,14 +32,14 @@ public class Alias
/// </summary>
[StringLength(255)]
[Column(TypeName = "VARCHAR")]
public string? FirstName { get; set; } = null!;
public string? FirstName { get; set; }
/// <summary>
/// Gets or sets the last name.
/// </summary>
[StringLength(255)]
[Column(TypeName = "VARCHAR")]
public string? LastName { get; set; } = null!;
public string? LastName { get; set; }
/// <summary>
/// Gets or sets the nickname.

View File

@@ -42,4 +42,8 @@
</AdditionalFiles>
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,278 @@
// <auto-generated />
using System;
using AliasClientDb;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace AliasClientDb.Migrations
{
[DbContext(typeof(AliasClientDbContext))]
[Migration("20240708094944_1.0.0-InitialMigration")]
partial class _100InitialMigration
{
/// <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("AliasClientDb.Alias", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AddressCity")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressCountry")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressState")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressStreet")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressZipCode")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("BankAccountIBAN")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("BirthDate")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EmailPrefix")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("Gender")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("Hobbies")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("NickName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("PhoneMobile")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Aliases");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<string>("Filename")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Attachment");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AliasId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<Guid>("ServiceId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AliasId");
b.HasIndex("ServiceId");
b.ToTable("Credentials");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Passwords");
});
modelBuilder.Entity("AliasClientDb.Service", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<byte[]>("Logo")
.HasColumnType("BLOB");
b.Property<string>("Name")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Url")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Services");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.HasOne("AliasClientDb.Credential", "Credential")
.WithMany("Attachments")
.HasForeignKey("CredentialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Credential");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.HasOne("AliasClientDb.Alias", "Alias")
.WithMany("Credentials")
.HasForeignKey("AliasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AliasClientDb.Service", "Service")
.WithMany("Credentials")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Alias");
b.Navigation("Service");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.HasOne("AliasClientDb.Credential", "Credential")
.WithMany("Passwords")
.HasForeignKey("CredentialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Credential");
});
modelBuilder.Entity("AliasClientDb.Alias", b =>
{
b.Navigation("Credentials");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.Navigation("Attachments");
b.Navigation("Passwords");
});
modelBuilder.Entity("AliasClientDb.Service", b =>
{
b.Navigation("Credentials");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,170 @@
// <auto-generated/>
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasClientDb.Migrations
{
/// <inheritdoc />
public partial class _100InitialMigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Aliases",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Gender = table.Column<string>(type: "VARCHAR", maxLength: 255, nullable: true),
FirstName = table.Column<string>(type: "VARCHAR", maxLength: 255, nullable: true),
LastName = table.Column<string>(type: "VARCHAR", maxLength: 255, nullable: true),
NickName = table.Column<string>(type: "VARCHAR", maxLength: 255, nullable: true),
BirthDate = table.Column<DateTime>(type: "TEXT", nullable: false),
AddressStreet = table.Column<string>(type: "VARCHAR", maxLength: 255, nullable: true),
AddressCity = table.Column<string>(type: "VARCHAR", maxLength: 255, nullable: true),
AddressState = table.Column<string>(type: "VARCHAR", maxLength: 255, nullable: true),
AddressZipCode = table.Column<string>(type: "VARCHAR", maxLength: 255, nullable: true),
AddressCountry = table.Column<string>(type: "VARCHAR", maxLength: 255, nullable: true),
Hobbies = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
EmailPrefix = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
PhoneMobile = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
BankAccountIBAN = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Aliases", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Services",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
Url = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
Logo = table.Column<byte[]>(type: "BLOB", nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Services", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Credentials",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
AliasId = table.Column<Guid>(type: "TEXT", nullable: false),
Notes = table.Column<string>(type: "TEXT", nullable: true),
Username = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
ServiceId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Credentials", x => x.Id);
table.ForeignKey(
name: "FK_Credentials_Aliases_AliasId",
column: x => x.AliasId,
principalTable: "Aliases",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Credentials_Services_ServiceId",
column: x => x.ServiceId,
principalTable: "Services",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Attachment",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Filename = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
Blob = table.Column<byte[]>(type: "BLOB", nullable: false),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
CredentialId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Attachment", x => x.Id);
table.ForeignKey(
name: "FK_Attachment_Credentials_CredentialId",
column: x => x.CredentialId,
principalTable: "Credentials",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Passwords",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Value = table.Column<string>(type: "TEXT", maxLength: 255, nullable: true),
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: false),
CredentialId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Passwords", x => x.Id);
table.ForeignKey(
name: "FK_Passwords_Credentials_CredentialId",
column: x => x.CredentialId,
principalTable: "Credentials",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Attachment_CredentialId",
table: "Attachment",
column: "CredentialId");
migrationBuilder.CreateIndex(
name: "IX_Credentials_AliasId",
table: "Credentials",
column: "AliasId");
migrationBuilder.CreateIndex(
name: "IX_Credentials_ServiceId",
table: "Credentials",
column: "ServiceId");
migrationBuilder.CreateIndex(
name: "IX_Passwords_CredentialId",
table: "Passwords",
column: "CredentialId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Attachment");
migrationBuilder.DropTable(
name: "Passwords");
migrationBuilder.DropTable(
name: "Credentials");
migrationBuilder.DropTable(
name: "Aliases");
migrationBuilder.DropTable(
name: "Services");
}
}
}

View File

@@ -0,0 +1,278 @@
// <auto-generated />
using System;
using AliasClientDb;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace AliasClientDb.Migrations
{
[DbContext(typeof(AliasClientDbContext))]
[Migration("20240708224522_1.0.1-EmptyTestMigration")]
partial class _101EmptyTestMigration
{
/// <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("AliasClientDb.Alias", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AddressCity")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressCountry")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressState")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressStreet")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressZipCode")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("BankAccountIBAN")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("BirthDate")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EmailPrefix")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("Gender")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("Hobbies")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("NickName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("PhoneMobile")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Aliases");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<string>("Filename")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Attachment");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AliasId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<Guid>("ServiceId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AliasId");
b.HasIndex("ServiceId");
b.ToTable("Credentials");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Passwords");
});
modelBuilder.Entity("AliasClientDb.Service", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<byte[]>("Logo")
.HasColumnType("BLOB");
b.Property<string>("Name")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Url")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Services");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.HasOne("AliasClientDb.Credential", "Credential")
.WithMany("Attachments")
.HasForeignKey("CredentialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Credential");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.HasOne("AliasClientDb.Alias", "Alias")
.WithMany("Credentials")
.HasForeignKey("AliasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AliasClientDb.Service", "Service")
.WithMany("Credentials")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Alias");
b.Navigation("Service");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.HasOne("AliasClientDb.Credential", "Credential")
.WithMany("Passwords")
.HasForeignKey("CredentialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Credential");
});
modelBuilder.Entity("AliasClientDb.Alias", b =>
{
b.Navigation("Credentials");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.Navigation("Attachments");
b.Navigation("Passwords");
});
modelBuilder.Entity("AliasClientDb.Service", b =>
{
b.Navigation("Credentials");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,23 @@
// <auto-generated/>
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace AliasClientDb.Migrations
{
/// <inheritdoc />
public partial class _101EmptyTestMigration : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -0,0 +1,275 @@
// <auto-generated />
using System;
using AliasClientDb;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace AliasClientDb.Migrations
{
[DbContext(typeof(AliasClientDbContext))]
partial class AliasClientDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(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("AliasClientDb.Alias", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AddressCity")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressCountry")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressState")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressStreet")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("AddressZipCode")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("BankAccountIBAN")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("BirthDate")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EmailPrefix")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("FirstName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("Gender")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("Hobbies")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("LastName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("NickName")
.HasMaxLength(255)
.HasColumnType("VARCHAR");
b.Property<string>("PhoneMobile")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Aliases");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<byte[]>("Blob")
.IsRequired()
.HasColumnType("BLOB");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<string>("Filename")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Attachment");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("AliasId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<Guid>("ServiceId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AliasId");
b.HasIndex("ServiceId");
b.ToTable("Credentials");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("CredentialId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CredentialId");
b.ToTable("Passwords");
});
modelBuilder.Entity("AliasClientDb.Service", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<byte[]>("Logo")
.HasColumnType("BLOB");
b.Property<string>("Name")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Url")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Services");
});
modelBuilder.Entity("AliasClientDb.Attachment", b =>
{
b.HasOne("AliasClientDb.Credential", "Credential")
.WithMany("Attachments")
.HasForeignKey("CredentialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Credential");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.HasOne("AliasClientDb.Alias", "Alias")
.WithMany("Credentials")
.HasForeignKey("AliasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("AliasClientDb.Service", "Service")
.WithMany("Credentials")
.HasForeignKey("ServiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Alias");
b.Navigation("Service");
});
modelBuilder.Entity("AliasClientDb.Password", b =>
{
b.HasOne("AliasClientDb.Credential", "Credential")
.WithMany("Passwords")
.HasForeignKey("CredentialId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Credential");
});
modelBuilder.Entity("AliasClientDb.Alias", b =>
{
b.Navigation("Credentials");
});
modelBuilder.Entity("AliasClientDb.Credential", b =>
{
b.Navigation("Attachments");
b.Navigation("Passwords");
});
modelBuilder.Entity("AliasClientDb.Service", b =>
{
b.Navigation("Credentials");
});
#pragma warning restore 612, 618
}
}
}

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

@@ -7,6 +7,7 @@
namespace AliasVault.E2ETests.Common;
using AliasServerDb;
using AliasVault.Shared.Providers.Time;
using Microsoft.Playwright;
@@ -42,12 +43,12 @@ public class PlaywrightTest
/// <summary>
/// Gets or sets random unique account email that is used for the test.
/// </summary>
protected string TestUserEmail { get; set; } = string.Empty;
protected virtual string TestUserEmail { get; set; } = string.Empty;
/// <summary>
/// Gets or sets random unique account password that is used for the test.
/// </summary>
protected string TestUserPassword { get; set; } = string.Empty;
protected virtual string TestUserPassword { get; set; } = string.Empty;
/// <summary>
/// Gets the Playwright browser instance.
@@ -69,6 +70,11 @@ public class PlaywrightTest
/// </summary>
protected PlaywrightInputHelper InputHelper { get; private set; } = null!;
/// <summary>
/// Gets the db context for the WebAPI project.
/// </summary>
protected AliasServerDbContext ApiDbContext => _apiFactory.GetDbContext();
/// <summary>
/// One time setup for the Playwright test which runs before all tests in the class.
/// </summary>
@@ -228,9 +234,11 @@ public class PlaywrightTest
/// <returns>Async task.</returns>
private async Task Register()
{
// Generate random email and password
TestUserEmail = $"{Guid.NewGuid()}@test.com";
TestUserPassword = Guid.NewGuid().ToString();
// If email is not set by test explicitly, generate a random email.
TestUserEmail = TestUserEmail.Length > 0 ? TestUserEmail : $"{Guid.NewGuid()}@test.com";
// If password is not set by test explicitly, generate a random password.
TestUserPassword = TestUserPassword.Length > 0 ? TestUserPassword : Guid.NewGuid().ToString();
// Check that we get redirected to /user/login when accessing the root URL and not authenticated.
await Page.GotoAsync(AppBaseUrl);
@@ -241,7 +249,7 @@ public class PlaywrightTest
await registerButton.ClickAsync();
await WaitForURLAsync("**/user/register");
// Try to login with test credentials.
// Try to register an account with the generated test credentials.
var emailField = Page.Locator("input[id='email']");
var passwordField = Page.Locator("input[id='password']");
var password2Field = Page.Locator("input[id='password2']");
@@ -255,11 +263,10 @@ public class PlaywrightTest
// Check if we get redirected when clicking on the register button.
var submitButton = Page.Locator("button[type='submit']");
Console.WriteLine(submitButton);
await submitButton.ClickAsync();
// Check if we get redirected to the root URL after registration which means we are logged in.
await WaitForURLAsync(AppBaseUrl);
await WaitForURLAsync(AppBaseUrl, "Find all of your credentials below");
}
private async Task SetupEnvironment()
@@ -278,11 +285,11 @@ public class PlaywrightTest
// Start WebAPI in-memory.
_apiFactory.HostUrl = "http://localhost:" + apiPort;
var apiClient = _apiFactory.CreateDefaultClient();
_apiFactory.CreateDefaultClient();
// Start Blazor WASM app out-of-process.
_wasmFactory.HostUrl = "http://localhost:" + appPort;
var wasmClient = _wasmFactory.CreateDefaultClient();
_wasmFactory.CreateDefaultClient();
// Set Playwright headless mode true if not in debug mode.
bool isDebugMode = System.Diagnostics.Debugger.IsAttached;

View File

@@ -24,6 +24,16 @@ using Microsoft.Extensions.Hosting;
public class WebApplicationApiFactoryFixture<TEntryPoint> : WebApplicationFactory<TEntryPoint>
where TEntryPoint : class
{
/// <summary>
/// The DbContext instance that is created for the test.
/// </summary>
private AliasServerDbContext? _dbContext;
/// <summary>
/// The DbConnection instance that is created for the test.
/// </summary>
private DbConnection? _dbConnection;
/// <summary>
/// Gets or sets the URL the web application host will listen on.
/// </summary>
@@ -34,6 +44,24 @@ public class WebApplicationApiFactoryFixture<TEntryPoint> : WebApplicationFactor
/// </summary>
public TestTimeProvider TimeProvider { get; private set; } = new();
/// <summary>
/// Returns the DbContext instance for the test. This can be used to seed the database with test data.
/// </summary>
/// <returns>AliasServerDbContext instance.</returns>
public AliasServerDbContext GetDbContext()
{
if (_dbContext == null)
{
var options = new DbContextOptionsBuilder<AliasServerDbContext>()
.UseSqlite(_dbConnection!)
.Options;
_dbContext = new AliasServerDbContext(options);
}
return _dbContext;
}
/// <inheritdoc />
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
@@ -88,10 +116,10 @@ public class WebApplicationApiFactoryFixture<TEntryPoint> : WebApplicationFactor
// Create a new DbConnection and AliasServerDbContext with an in-memory database.
services.AddSingleton<DbConnection>(container =>
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
_dbConnection = new SqliteConnection("DataSource=:memory:");
_dbConnection.Open();
return connection;
return _dbConnection;
});
services.AddDbContext<AliasServerDbContext>((container, options) =>
@@ -114,7 +142,7 @@ public class WebApplicationApiFactoryFixture<TEntryPoint> : WebApplicationFactor
// This delay prevents "ERR_CONNECTION_REFUSED" errors
// which happened like 1 out of 10 times when running tests.
Thread.Sleep(50);
Thread.Sleep(100);
return dummyHost;
}

View File

@@ -41,7 +41,7 @@ public class WebApplicationWasmFactoryFixture<TEntryPoint> : WebApplicationFacto
// This delay prevents "ERR_CONNECTION_REFUSED" errors
// which happened like 1 out of 10 times when running tests.
Thread.Sleep(50);
Thread.Sleep(100);
return dummyHost;
}

View File

@@ -31,6 +31,45 @@ public class AuthTests : PlaywrightTest
await Login();
}
/// <summary>
/// Test if registering an account with the same email address as an existing account shows a warning.
/// </summary>
/// <returns>Async task.</returns>
[Test]
public async Task RegisterFormWarning()
{
// Logout.
await NavigateUsingBlazorRouter("user/logout");
await WaitForURLAsync("**/user/logout", "AliasVault");
// Wait and check if we get redirected to /user/login.
await WaitForURLAsync("**/user/login");
// Try to register a new account.
var registerButton = Page.Locator("a[href='/user/register']");
await registerButton.ClickAsync();
await WaitForURLAsync("**/user/register");
// Register account with same test credentials as used in the initial registration bootstrap method.
var emailField = Page.Locator("input[id='email']");
var passwordField = Page.Locator("input[id='password']");
var password2Field = Page.Locator("input[id='password2']");
await emailField.FillAsync(TestUserEmail);
await passwordField.FillAsync(TestUserPassword);
await password2Field.FillAsync(TestUserPassword);
// Check the terms of service checkbox
var termsCheckbox = Page.Locator("input[id='terms']");
await termsCheckbox.CheckAsync();
// Check if we get a visible warning when trying to register.
var submitButton = Page.Locator("button[type='submit']");
await submitButton.ClickAsync();
var warning = await Page.TextContentAsync("div[role='alert']");
Assert.That(warning, Does.Contain("is already taken."), "No visible warning when registering with existing email address.");
}
/// <summary>
/// Test if logging in works.
/// </summary>
@@ -52,7 +91,7 @@ public class AuthTests : PlaywrightTest
// Check if we get redirected when clicking on the login button.
var loginButton = Page.Locator("button[type='submit']");
await loginButton.ClickAsync();
await WaitForURLAsync(AppBaseUrl);
await WaitForURLAsync(AppBaseUrl, "Find all of your credentials below");
// Check if the login was successful by verifying content.
var pageContent = await Page.TextContentAsync("body");

View File

File diff suppressed because one or more lines are too long

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