//----------------------------------------------------------------------- // // Copyright (c) lanedirt. All rights reserved. // Licensed under the MIT license. See LICENSE.md file in the project root for full license information. // //----------------------------------------------------------------------- using AliasServerDb; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata; // Add return type for top-level statements return await Run(args); /// /// Handles the migration of data between SQLite and PostgreSQL databases and password hashing utilities. /// public static partial class Program { /// /// Runs the program with the given arguments. /// /// The command-line arguments. /// The exit code of the program. public static async Task Run(string[] args) { if (args.Length == 0) { Console.WriteLine("Usage:"); Console.WriteLine(" hash-password "); Console.WriteLine(" migrate-sqlite "); return 1; } switch (args[0].ToLower()) { case "hash-password": if (args.Length != 2) { Console.WriteLine("Usage: hash-password "); return 1; } return HashPassword(args[1]); case "migrate-sqlite": if (args.Length != 3) { Console.WriteLine("Usage: migrate-sqlite "); return 1; } return await MigrateSqliteToPostgres(args[1], args[2]); default: Console.WriteLine("Unknown command. Available commands:"); Console.WriteLine(" hash-password "); Console.WriteLine(" migrate-sqlite "); return 1; } } /// /// Hashes a password using ASP.NET Core Identity's password hasher. /// /// The plain text password to hash. /// /// Returns 0 if the password was successfully hashed and printed to console. /// private static int HashPassword(string password) { var hasher = new PasswordHasher(); var user = new AdminUser(); var hashedPassword = hasher.HashPassword(user, password); Console.WriteLine(hashedPassword); return 0; } /// /// Migrates data from a SQLite database to a PostgreSQL database. /// /// The file path to the source SQLite database. /// The connection string to the PostgreSQL database. /// /// Returns 0 if migration was successful, 1 if an error occurred. /// /// Thrown when a migration error occurs. private static async Task MigrateSqliteToPostgres(string sqliteDbPath, string pgConnString) { try { if (!File.Exists(sqliteDbPath)) { Console.WriteLine($"Error: SQLite database not found at {sqliteDbPath}"); return 1; } Console.WriteLine($"Migrating SQLite database to PostgreSQL - start"); // Create connections to both databases var sqliteConnString = $"Data Source={sqliteDbPath}"; // Create contexts var optionsBuilderSqlite = new DbContextOptionsBuilder() .UseSqlite(sqliteConnString); var optionsBuilderPg = new DbContextOptionsBuilder() .UseNpgsql(pgConnString); // Make sure sqlite is on latest version migration Console.WriteLine("Update sqlite database to latest version..."); await using var sqliteContext = new AliasServerDbContextSqlite(optionsBuilderSqlite.Options); await sqliteContext.Database.MigrateAsync(); Console.WriteLine("Updating finished."); // Make sure postgres is on latest version migration Console.WriteLine("Update postgres database to latest version..."); await using var pgContext = new AliasServerDbContextPostgresql(optionsBuilderPg.Options); await pgContext.Database.MigrateAsync(); Console.WriteLine("Updating finished."); Console.WriteLine("Truncating existing tables in reverse dependency order..."); // Truncate tables in reverse order of dependencies await TruncateTable(pgContext.EmailAttachments, "EmailAttachments"); await TruncateTable(pgContext.Emails, "Emails"); await TruncateTable(pgContext.UserTokens, "UserTokens"); await TruncateTable(pgContext.UserRoles, "UserRoles"); await TruncateTable(pgContext.UserLogin, "UserLogins"); await TruncateTable(pgContext.UserEmailClaims, "UserEmailClaims"); await TruncateTable(pgContext.Vaults, "Vaults"); await TruncateTable(pgContext.UserEncryptionKeys, "UserEncryptionKeys"); await TruncateTable(pgContext.AliasVaultUserRefreshTokens, "AliasVaultUserRefreshTokens"); await TruncateTable(pgContext.Logs, "Logs"); await TruncateTable(pgContext.AuthLogs, "AuthLogs"); await TruncateTable(pgContext.DataProtectionKeys, "DataProtectionKeys"); await TruncateTable(pgContext.ServerSettings, "ServerSettings"); await TruncateTable(pgContext.TaskRunnerJobs, "TaskRunnerJobs"); await TruncateTable(pgContext.AliasVaultUsers, "AliasVaultUsers"); await TruncateTable(pgContext.AliasVaultRoles, "AliasVaultRoles"); await TruncateTable(pgContext.AdminUsers, "AdminUsers"); Console.WriteLine("Starting content migration..."); // First, migrate tables without foreign key dependencies await MigrateTable(sqliteContext.AliasVaultRoles, pgContext.AliasVaultRoles, pgContext, "AliasVaultRoles"); await MigrateTable(sqliteContext.AliasVaultUsers, pgContext.AliasVaultUsers, pgContext, "AliasVaultUsers"); await MigrateTable(sqliteContext.ServerSettings, pgContext.ServerSettings, pgContext, "ServerSettings"); await MigrateTable(sqliteContext.TaskRunnerJobs, pgContext.TaskRunnerJobs, pgContext, "TaskRunnerJobs", true); await MigrateTable(sqliteContext.DataProtectionKeys, pgContext.DataProtectionKeys, pgContext, "DataProtectionKeys", true); await MigrateTable(sqliteContext.Logs, pgContext.Logs, pgContext, "Logs", true); await MigrateTable(sqliteContext.AuthLogs, pgContext.AuthLogs, pgContext, "AuthLogs", true); await MigrateTable(sqliteContext.AdminUsers, pgContext.AdminUsers, pgContext, "AdminUsers"); // Then migrate tables with foreign key dependencies await MigrateTable(sqliteContext.AliasVaultUserRefreshTokens, pgContext.AliasVaultUserRefreshTokens, pgContext, "AliasVaultUserRefreshTokens"); await MigrateTable(sqliteContext.UserEncryptionKeys, pgContext.UserEncryptionKeys, pgContext, "UserEncryptionKeys"); await MigrateTable(sqliteContext.UserEmailClaims, pgContext.UserEmailClaims, pgContext, "UserEmailClaims"); await MigrateTable(sqliteContext.Vaults, pgContext.Vaults, pgContext, "Vaults"); // Identity framework related tables await MigrateTable(sqliteContext.UserRoles, pgContext.UserRoles, pgContext, "UserRoles"); await MigrateTable(sqliteContext.UserLogin, pgContext.UserLogin, pgContext, "UserLogins"); await MigrateTable(sqliteContext.UserTokens, pgContext.UserTokens, pgContext, "UserTokens"); // Email related tables (last due to dependencies) await MigrateTable(sqliteContext.Emails, pgContext.Emails, pgContext, "Emails", true); await MigrateTable(sqliteContext.EmailAttachments, pgContext.EmailAttachments, pgContext, "EmailAttachments", true); Console.WriteLine("Migration completed successfully!"); return 0; } catch (Exception ex) { Console.WriteLine($"Error during migration: {ex.Message}"); Console.WriteLine(ex.InnerException); return 1; } } /// /// Truncates a table in the PostgreSQL database. /// /// The entity type of the table being truncated. /// The database table to truncate. /// The name of the table being truncated (for logging purposes). /// A task representing the asynchronous truncation operation. private static async Task TruncateTable(DbSet table, string tableName) where T : class { Console.WriteLine($"Truncating table {tableName}..."); var count = await table.CountAsync(); if (count > 0) { await table.ExecuteDeleteAsync(); Console.WriteLine($"Removed {count} records from {tableName}"); } } /// /// Migrates data from one database table to another, handling the transfer in batches. /// /// The entity type of the table being migrated. /// The source database table. /// The destination database table. /// The destination database context. /// The name of the table being migrated (for logging purposes). /// Whether to reset the sequence for the table after migration. /// A task representing the asynchronous migration operation. /// /// Thrown when the number of records in source and destination tables don't match after migration. /// /// /// Thrown when a concurrency conflict occurs during the migration. /// private static async Task MigrateTable( DbSet source, DbSet destination, DbContext destinationContext, string tableName, bool resetSequence = false) where T : class { Console.WriteLine($"Migrating {tableName}..."); var items = await source.ToListAsync(); Console.WriteLine($"Found {items.Count} records to migrate"); if (items.Count > 0) { // Get entity type from the model to check annotations var entityType = destinationContext.Model.FindEntityType(typeof(T)); foreach (var item in items) { HandleMaxLengthConstraints(item, entityType!, tableName); } const int batchSize = 50; foreach (var batch in items.Chunk(batchSize)) { try { await destination.AddRangeAsync(batch); await destinationContext.SaveChangesAsync(); Console.WriteLine($"Migrated {batch.Length} records from {tableName}"); } catch (DbUpdateConcurrencyException ex) { Console.WriteLine($"Concurrency conflict occurred during migration of {tableName}..."); await HandleConcurrencyConflict(ex, destinationContext); Console.WriteLine($"Concurrency conflict resolved, {batch.Length} records inserted"); } } // Handle sequence reset logic... if (resetSequence && destinationContext.Database.ProviderName == "Npgsql.EntityFrameworkCore.PostgreSQL") { await ResetSequence(destinationContext, tableName); } } // Ensure that the amount of records in the source and destination tables match if (await source.CountAsync() > await destination.CountAsync()) { throw new ArgumentException($"The amount of records in the source is greater than the destination. Check if the migration is working correctly."); } } /// /// Handles max length constraints for string properties in an entity. /// /// The entity type containing the properties to be processed. /// The entity instance containing the properties to be processed. /// The entity type to be processed. /// The name of the table being processed (for logging purposes). private static void HandleMaxLengthConstraints(T item, IEntityType entityType, string tableName) where T : class { foreach (var property in entityType.GetProperties()) { // Only process string properties if (property.ClrType == typeof(string)) { var maxLength = property.GetMaxLength(); if (maxLength.HasValue) { var propertyInfo = typeof(T).GetProperty(property.Name); var value = propertyInfo?.GetValue(item) as string; if (value?.Length > maxLength.Value) { propertyInfo!.SetValue(item, value.Substring(0, maxLength.Value)); Console.WriteLine($"Truncated {property.Name} in {tableName} from {value.Length} to {maxLength.Value} characters"); } } } } } /// /// Resets the sequence for a table in the PostgreSQL database. /// /// The entity type of the table being reset. /// The destination database context. /// The name of the table being reset (for logging purposes). /// A task representing the asynchronous operation. private static async Task ResetSequence(DbContext destinationContext, string tableName) where T : class { var tablePgName = destinationContext.Model.FindEntityType(typeof(T))?.GetTableName(); if (!string.IsNullOrEmpty(tablePgName)) { var schema = destinationContext.Model.FindEntityType(typeof(T))?.GetSchema() ?? "public"; var sql = $""" SELECT setval(pg_get_serial_sequence('{schema}."{tablePgName}"', 'Id'), (SELECT COALESCE(MAX("Id"::integer), 0) + 1 FROM {schema}."{tablePgName}"), false); """; await destinationContext.Database.ExecuteSqlRawAsync(sql); Console.WriteLine($"Reset sequence for {tableName}"); } } /// /// Handles a concurrency conflict by updating the original values with the database values. /// /// The DbUpdateConcurrencyException that occurred. /// The destination database context. /// A task representing the asynchronous operation. private static async Task HandleConcurrencyConflict( DbUpdateConcurrencyException ex, DbContext destinationContext) { foreach (var entry in ex.Entries) { var databaseValues = await entry.GetDatabaseValuesAsync(); if (databaseValues == null) { entry.State = EntityState.Detached; } else { entry.OriginalValues.SetValues(databaseValues); await destinationContext.SaveChangesAsync(); } } } }