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