From db62eeec22e5787c255d47f51db02e96222dc9d3 Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Mon, 8 Jul 2024 12:55:12 +0200 Subject: [PATCH] Add client DB migration screen (#74) --- .../Main/Layout/MainLayout.razor | 15 ++ .../StatusMessages/PendingMigrations.razor | 65 ++++++++ .../Services/Database/DbService.cs | 139 +++++++++++++++++- .../Services/Database/DbServiceState.cs | 5 + 4 files changed, 216 insertions(+), 8 deletions(-) create mode 100644 src/AliasVault.WebApp/Main/Layout/StatusMessages/PendingMigrations.razor diff --git a/src/AliasVault.WebApp/Main/Layout/MainLayout.razor b/src/AliasVault.WebApp/Main/Layout/MainLayout.razor index 1750d31e7..72b09d6a8 100644 --- a/src/AliasVault.WebApp/Main/Layout/MainLayout.razor +++ b/src/AliasVault.WebApp/Main/Layout/MainLayout.razor @@ -21,6 +21,10 @@ { } + else if(IsPendingMigrations) + { + + } else { @@ -39,6 +43,7 @@ @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? AuthState { get; set; } @@ -50,6 +55,7 @@ // Reset local state IsDbInitialized = false; IsDbDecryptionError = false; + IsPendingMigrations = false; DbService.GetState().StateChanged += OnDatabaseStateChanged; AuthStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged; @@ -96,6 +102,11 @@ IsDbDecryptionError = true; StateHasChanged(); } + else if (currentState.Status == DbServiceState.DatabaseStatus.PendingMigrations) + { + IsPendingMigrations = true; + StateHasChanged(); + } } private async void OnDatabaseStateChanged(object? sender, DbServiceState.DatabaseState newState) @@ -112,6 +123,10 @@ { IsDbDecryptionError = true; } + else if (newState.Status == DbServiceState.DatabaseStatus.PendingMigrations) + { + IsPendingMigrations = true; + } StateHasChanged(); } diff --git a/src/AliasVault.WebApp/Main/Layout/StatusMessages/PendingMigrations.razor b/src/AliasVault.WebApp/Main/Layout/StatusMessages/PendingMigrations.razor new file mode 100644 index 000000000..c49586cd8 --- /dev/null +++ b/src/AliasVault.WebApp/Main/Layout/StatusMessages/PendingMigrations.razor @@ -0,0 +1,65 @@ +@inject DbService DbService +@inject GlobalNotificationService GlobalNotificationService + +
+
+
+

Vault needs to be upgraded.

+

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.

+ +
+ @if (ErrorMessage.Length > 0) + { + + } + + @if (IsPendingMigrations) + { + + + + + } + else + { + + } +
+
+
+
+ +@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(); + } +} diff --git a/src/AliasVault.WebApp/Services/Database/DbService.cs b/src/AliasVault.WebApp/Services/Database/DbService.cs index 8a179298f..9dc118481 100644 --- a/src/AliasVault.WebApp/Services/Database/DbService.cs +++ b/src/AliasVault.WebApp/Services/Database/DbService.cs @@ -82,9 +82,19 @@ public class DbService : IDisposable var loaded = await LoadDatabaseFromServerAsync(); if (loaded) { - _isSuccessfullyInitialized = true; - _state.UpdateState(DbServiceState.DatabaseStatus.Ready); - Console.WriteLine("Database succesfully loaded from server."); + 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); + } } else { @@ -174,6 +184,27 @@ public class DbService : IDisposable return base64String; } + /// + /// Migrate the database structure to the latest version. + /// + /// Bool which indicates if migration was succesful. + public async Task MigrateDatabaseAsync() + { + try + { + await _dbContext.Database.MigrateAsync(); + _isSuccessfullyInitialized = true; + _state.UpdateState(DbServiceState.DatabaseStatus.Ready); + } + catch (Exception ex) + { + Console.WriteLine(ex.Message); + return false; + } + + return true; + } + /// /// Clears the database connection and creates a new one so that the database is empty. /// @@ -233,25 +264,25 @@ public class DbService : IDisposable var tempFileName = Path.GetRandomFileName(); await File.WriteAllBytesAsync(tempFileName, bytes); - using (var command = _sqlConnection.CreateCommand()) + /*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 dropTableCommands = new List(); + var emptyTableCommands = new List(); using (var reader = await command.ExecuteReaderAsync()) { while (await reader.ReadAsync()) { - dropTableCommands.Add(reader.GetString(0)); + emptyTableCommands.Add(reader.GetString(0)); } } - foreach (var dropTableCommand in dropTableCommands) + foreach (var emptyTableCommand in emptyTableCommands) { - command.CommandText = dropTableCommand; + command.CommandText = emptyTableCommand; await command.ExecuteNonQueryAsync(); } @@ -282,6 +313,98 @@ public class DbService : IDisposable 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(); + + // Drop all tables in the original database + command.CommandText = @" + SELECT 'DROP TABLE IF EXISTS ' || name || ';' + FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%';"; + var dropTableCommands = new List(); + using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + dropTableCommands.Add(reader.GetString(0)); + } + } + + foreach (var dropTableCommand in dropTableCommands) + { + Console.WriteLine("Dropping table.."); + Console.WriteLine("Drop command: " + dropTableCommand); + command.CommandText = dropTableCommand; + await command.ExecuteNonQueryAsync(); + } + + // Attach the imported database + command.CommandText = "ATTACH DATABASE @fileName AS importDb"; + command.Parameters.Add(new SqliteParameter("@fileName", tempFileName)); + await command.ExecuteNonQueryAsync(); + + Console.WriteLine("Make create table statements from import db.."); + + // 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(); + using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + createTableCommands.Add(reader.GetString(0)); + } + } + + // Create tables in the main database + Console.WriteLine("Create tables in main db.."); + + foreach (var createTableCommand in createTableCommands) + { + command.CommandText = createTableCommand; + await command.ExecuteNonQueryAsync(); + } + + // 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 + WHERE type = 'table' AND name NOT LIKE 'sqlite_%';"; + var tableInsertCommands = new List(); + using (var reader = await command.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + tableInsertCommands.Add(reader.GetString(0)); + } + } + + foreach (var tableInsertCommand in tableInsertCommands) + { + command.CommandText = tableInsertCommand; + await command.ExecuteNonQueryAsync(); + } + + // Detach the imported database + command.CommandText = "DETACH DATABASE importDb"; + await command.ExecuteNonQueryAsync(); + + // Re-enable foreign key constraints + command.CommandText = "PRAGMA foreign_keys = ON;"; + await command.ExecuteNonQueryAsync(); + } File.Delete(tempFileName); } diff --git a/src/AliasVault.WebApp/Services/Database/DbServiceState.cs b/src/AliasVault.WebApp/Services/Database/DbServiceState.cs index cb610e689..114357c51 100644 --- a/src/AliasVault.WebApp/Services/Database/DbServiceState.cs +++ b/src/AliasVault.WebApp/Services/Database/DbServiceState.cs @@ -34,6 +34,11 @@ public class DbServiceState /// DecryptionFailed, + /// + /// Database has been decrypted but has pending migrations and needs to be updated. + /// + PendingMigrations, + /// /// Database is ready but no task is currently in progress. ///