473 lines
15 KiB
C#
473 lines
15 KiB
C#
using MemberCenter.Domain.Entities;
|
|
using MemberCenter.Infrastructure.Configuration;
|
|
using MemberCenter.Infrastructure.Identity;
|
|
using MemberCenter.Infrastructure.Persistence;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Migrations;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging;
|
|
using Npgsql;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using System.CommandLine;
|
|
|
|
EnvLoader.LoadDotEnvIfDevelopment();
|
|
|
|
var root = new RootCommand("Member Center installer");
|
|
|
|
var connectionStringOption = new Option<string>(
|
|
name: "--connection-string",
|
|
description: "Database connection string");
|
|
connectionStringOption.AddAlias("-c");
|
|
|
|
var appsettingsOption = new Option<string?>(
|
|
name: "--appsettings",
|
|
description: "Path to appsettings.json");
|
|
|
|
var noPromptOption = new Option<bool>(
|
|
name: "--no-prompt",
|
|
description: "Disable interactive prompts");
|
|
|
|
var verboseOption = new Option<bool>(
|
|
name: "--verbose",
|
|
description: "Enable verbose output");
|
|
|
|
var forceOption = new Option<bool>(
|
|
name: "--force",
|
|
description: "Force execution even if already installed");
|
|
|
|
var adminEmailOption = new Option<string?>(
|
|
name: "--admin-email",
|
|
description: "Admin email");
|
|
|
|
var adminPasswordOption = new Option<string?>(
|
|
name: "--admin-password",
|
|
description: "Admin password");
|
|
|
|
var adminDisplayNameOption = new Option<string?>(
|
|
name: "--admin-display-name",
|
|
description: "Admin display name (optional)");
|
|
|
|
var targetMigrationOption = new Option<string?>(
|
|
name: "--target",
|
|
description: "Target migration");
|
|
|
|
var initCommand = new Command("init", "Initialize database (migrate + seed + admin)");
|
|
initCommand.AddOption(connectionStringOption);
|
|
initCommand.AddOption(appsettingsOption);
|
|
initCommand.AddOption(noPromptOption);
|
|
initCommand.AddOption(verboseOption);
|
|
initCommand.AddOption(forceOption);
|
|
initCommand.AddOption(adminEmailOption);
|
|
initCommand.AddOption(adminPasswordOption);
|
|
initCommand.AddOption(adminDisplayNameOption);
|
|
|
|
initCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, bool verbose, bool force, string? adminEmail, string? adminPassword) =>
|
|
{
|
|
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
|
|
if (string.IsNullOrWhiteSpace(resolvedConnection))
|
|
{
|
|
Console.Error.WriteLine("Connection string is required.");
|
|
return;
|
|
}
|
|
|
|
var services = BuildServices(resolvedConnection);
|
|
await using var scope = services.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<MemberCenterDbContext>();
|
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
|
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
|
|
|
|
if (!force && await IsInstalledAsync(db))
|
|
{
|
|
Console.Error.WriteLine("System already installed. Use --force to override.");
|
|
return;
|
|
}
|
|
|
|
if (!noPrompt)
|
|
{
|
|
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
|
adminPassword ??= PromptSecret("Admin password");
|
|
}
|
|
|
|
adminEmail ??= "admin@example.com";
|
|
if (string.IsNullOrWhiteSpace(adminPassword))
|
|
{
|
|
Console.Error.WriteLine("Admin password is required.");
|
|
return;
|
|
}
|
|
|
|
await db.Database.MigrateAsync();
|
|
|
|
await EnsureRoleAsync(roleManager, "admin");
|
|
await EnsureRoleAsync(roleManager, "support");
|
|
|
|
var admin = await userManager.FindByEmailAsync(adminEmail);
|
|
if (admin is null)
|
|
{
|
|
admin = new ApplicationUser
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserName = adminEmail,
|
|
Email = adminEmail,
|
|
EmailConfirmed = true
|
|
};
|
|
var createResult = await userManager.CreateAsync(admin, adminPassword);
|
|
if (!createResult.Succeeded)
|
|
{
|
|
Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description)));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!await userManager.IsInRoleAsync(admin, "admin"))
|
|
{
|
|
await userManager.AddToRoleAsync(admin, "admin");
|
|
}
|
|
|
|
await SetInstalledFlagAsync(db);
|
|
|
|
if (verbose)
|
|
{
|
|
Console.WriteLine("Init completed.");
|
|
}
|
|
}, connectionStringOption, appsettingsOption, noPromptOption, verboseOption, forceOption, adminEmailOption, adminPasswordOption);
|
|
|
|
var addAdminCommand = new Command("add-admin", "Add admin user");
|
|
addAdminCommand.AddOption(connectionStringOption);
|
|
addAdminCommand.AddOption(appsettingsOption);
|
|
addAdminCommand.AddOption(noPromptOption);
|
|
addAdminCommand.AddOption(adminEmailOption);
|
|
addAdminCommand.AddOption(adminPasswordOption);
|
|
addAdminCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) =>
|
|
{
|
|
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
|
|
if (string.IsNullOrWhiteSpace(resolvedConnection))
|
|
{
|
|
Console.Error.WriteLine("Connection string is required.");
|
|
return;
|
|
}
|
|
|
|
if (!noPrompt)
|
|
{
|
|
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
|
adminPassword ??= PromptSecret("Admin password");
|
|
}
|
|
|
|
adminEmail ??= "admin@example.com";
|
|
if (string.IsNullOrWhiteSpace(adminPassword))
|
|
{
|
|
Console.Error.WriteLine("Admin password is required.");
|
|
return;
|
|
}
|
|
|
|
var services = BuildServices(resolvedConnection);
|
|
await using var scope = services.CreateAsyncScope();
|
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
|
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
|
|
|
|
await EnsureRoleAsync(roleManager, "admin");
|
|
|
|
var admin = await userManager.FindByEmailAsync(adminEmail);
|
|
if (admin is null)
|
|
{
|
|
admin = new ApplicationUser
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
UserName = adminEmail,
|
|
Email = adminEmail,
|
|
EmailConfirmed = true
|
|
};
|
|
var createResult = await userManager.CreateAsync(admin, adminPassword);
|
|
if (!createResult.Succeeded)
|
|
{
|
|
Console.Error.WriteLine(string.Join(Environment.NewLine, createResult.Errors.Select(e => e.Description)));
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!await userManager.IsInRoleAsync(admin, "admin"))
|
|
{
|
|
await userManager.AddToRoleAsync(admin, "admin");
|
|
}
|
|
|
|
Console.WriteLine("Admin user ready.");
|
|
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
|
|
|
|
var resetCommand = new Command("reset-admin-password", "Reset admin password");
|
|
resetCommand.AddOption(connectionStringOption);
|
|
resetCommand.AddOption(appsettingsOption);
|
|
resetCommand.AddOption(noPromptOption);
|
|
resetCommand.AddOption(adminEmailOption);
|
|
resetCommand.AddOption(adminPasswordOption);
|
|
resetCommand.SetHandler(async (string? connectionString, string? appsettings, bool noPrompt, string? adminEmail, string? adminPassword) =>
|
|
{
|
|
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt);
|
|
if (string.IsNullOrWhiteSpace(resolvedConnection))
|
|
{
|
|
Console.Error.WriteLine("Connection string is required.");
|
|
return;
|
|
}
|
|
|
|
if (!noPrompt)
|
|
{
|
|
adminEmail ??= Prompt("Admin email", "admin@example.com");
|
|
adminPassword ??= PromptSecret("New password");
|
|
}
|
|
|
|
adminEmail ??= "admin@example.com";
|
|
if (string.IsNullOrWhiteSpace(adminPassword))
|
|
{
|
|
Console.Error.WriteLine("Admin password is required.");
|
|
return;
|
|
}
|
|
|
|
var services = BuildServices(resolvedConnection);
|
|
await using var scope = services.CreateAsyncScope();
|
|
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
|
|
|
|
var admin = await userManager.FindByEmailAsync(adminEmail);
|
|
if (admin is null)
|
|
{
|
|
Console.Error.WriteLine("Admin not found.");
|
|
return;
|
|
}
|
|
|
|
var resetToken = await userManager.GeneratePasswordResetTokenAsync(admin);
|
|
var result = await userManager.ResetPasswordAsync(admin, resetToken, adminPassword);
|
|
if (!result.Succeeded)
|
|
{
|
|
Console.Error.WriteLine(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
|
|
return;
|
|
}
|
|
|
|
Console.WriteLine("Password reset completed.");
|
|
}, connectionStringOption, appsettingsOption, noPromptOption, adminEmailOption, adminPasswordOption);
|
|
|
|
var migrateCommand = new Command("migrate", "Run migrations only");
|
|
migrateCommand.AddOption(connectionStringOption);
|
|
migrateCommand.AddOption(appsettingsOption);
|
|
migrateCommand.AddOption(targetMigrationOption);
|
|
migrateCommand.SetHandler(async (string? connectionString, string? appsettings, string? target) =>
|
|
{
|
|
var resolvedConnection = ResolveConnectionString(connectionString, appsettings, noPrompt: true);
|
|
if (string.IsNullOrWhiteSpace(resolvedConnection))
|
|
{
|
|
Console.Error.WriteLine("Connection string is required.");
|
|
return;
|
|
}
|
|
|
|
var services = BuildServices(resolvedConnection);
|
|
await using var scope = services.CreateAsyncScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<MemberCenterDbContext>();
|
|
if (string.IsNullOrWhiteSpace(target))
|
|
{
|
|
await db.Database.MigrateAsync();
|
|
}
|
|
else
|
|
{
|
|
var migrator = scope.ServiceProvider.GetRequiredService<IMigrator>();
|
|
await migrator.MigrateAsync(target);
|
|
}
|
|
|
|
Console.WriteLine("Migrations completed.");
|
|
}, connectionStringOption, appsettingsOption, targetMigrationOption);
|
|
|
|
root.AddCommand(initCommand);
|
|
root.AddCommand(addAdminCommand);
|
|
root.AddCommand(resetCommand);
|
|
root.AddCommand(migrateCommand);
|
|
|
|
return await root.InvokeAsync(args);
|
|
|
|
static IServiceProvider BuildServices(string connectionString)
|
|
{
|
|
var services = new ServiceCollection();
|
|
services.AddLogging(builder => builder.AddConsole());
|
|
services.AddDbContext<MemberCenterDbContext>(options =>
|
|
{
|
|
options.UseNpgsql(connectionString);
|
|
options.UseOpenIddict();
|
|
});
|
|
|
|
services.AddIdentity<ApplicationUser, ApplicationRole>(options =>
|
|
{
|
|
options.User.RequireUniqueEmail = true;
|
|
options.Password.RequireDigit = true;
|
|
options.Password.RequireLowercase = true;
|
|
options.Password.RequireUppercase = true;
|
|
options.Password.RequireNonAlphanumeric = false;
|
|
options.Password.RequiredLength = 8;
|
|
})
|
|
.AddEntityFrameworkStores<MemberCenterDbContext>()
|
|
.AddDefaultTokenProviders();
|
|
|
|
return services.BuildServiceProvider();
|
|
}
|
|
|
|
static string? ResolveConnectionString(string? connectionString, string? appsettingsPath, bool noPrompt)
|
|
{
|
|
var targetPath = ResolveAppsettingsPath(appsettingsPath);
|
|
|
|
var envConnection = Environment.GetEnvironmentVariable("ConnectionStrings__Default")
|
|
?? Environment.GetEnvironmentVariable("MEMBERCENTER_CONNECTION");
|
|
if (!string.IsNullOrWhiteSpace(envConnection))
|
|
{
|
|
return envConnection;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(connectionString))
|
|
{
|
|
WriteConnectionString(targetPath, connectionString);
|
|
return connectionString;
|
|
}
|
|
|
|
if (File.Exists(targetPath))
|
|
{
|
|
var config = new ConfigurationBuilder()
|
|
.AddJsonFile(targetPath, optional: true)
|
|
.Build();
|
|
var existing = config.GetConnectionString("Default");
|
|
if (!string.IsNullOrWhiteSpace(existing))
|
|
{
|
|
return existing;
|
|
}
|
|
}
|
|
|
|
if (noPrompt)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var host = Prompt("DB host", "localhost");
|
|
var database = Prompt("DB name", "member_center");
|
|
var username = Prompt("DB user", "postgres");
|
|
var password = PromptSecret("DB password");
|
|
var generated = $"Host={host};Database={database};Username={username};Password={password}";
|
|
WriteConnectionString(targetPath, generated);
|
|
return generated;
|
|
}
|
|
|
|
static string ResolveAppsettingsPath(string? appsettingsPath)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(appsettingsPath))
|
|
{
|
|
return appsettingsPath;
|
|
}
|
|
|
|
var apiDefault = Path.Combine("src", "MemberCenter.Api", "appsettings.json");
|
|
if (File.Exists(apiDefault))
|
|
{
|
|
return apiDefault;
|
|
}
|
|
|
|
return "appsettings.json";
|
|
}
|
|
|
|
static void WriteConnectionString(string path, string connectionString)
|
|
{
|
|
JsonNode root;
|
|
if (File.Exists(path))
|
|
{
|
|
var json = File.ReadAllText(path);
|
|
root = JsonNode.Parse(json) ?? new JsonObject();
|
|
}
|
|
else
|
|
{
|
|
root = new JsonObject();
|
|
}
|
|
|
|
var obj = root as JsonObject ?? new JsonObject();
|
|
var connectionStrings = obj["ConnectionStrings"] as JsonObject ?? new JsonObject();
|
|
connectionStrings["Default"] = connectionString;
|
|
obj["ConnectionStrings"] = connectionStrings;
|
|
|
|
var options = new JsonSerializerOptions { WriteIndented = true };
|
|
File.WriteAllText(path, obj.ToJsonString(options));
|
|
}
|
|
|
|
static async Task<bool> IsInstalledAsync(MemberCenterDbContext db)
|
|
{
|
|
try
|
|
{
|
|
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
|
|
return flag is not null && string.Equals(flag.Value, "true", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
catch (PostgresException ex) when (ex.SqlState == "42P01")
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
static async Task SetInstalledFlagAsync(MemberCenterDbContext db)
|
|
{
|
|
var flag = await db.SystemFlags.FirstOrDefaultAsync(f => f.Key == "installed");
|
|
if (flag is null)
|
|
{
|
|
db.SystemFlags.Add(new SystemFlag
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Key = "installed",
|
|
Value = "true",
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
});
|
|
}
|
|
else
|
|
{
|
|
flag.Value = "true";
|
|
flag.UpdatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
static async Task EnsureRoleAsync(RoleManager<ApplicationRole> roleManager, string roleName)
|
|
{
|
|
if (!await roleManager.RoleExistsAsync(roleName))
|
|
{
|
|
var result = await roleManager.CreateAsync(new ApplicationRole
|
|
{
|
|
Id = Guid.NewGuid(),
|
|
Name = roleName,
|
|
NormalizedName = roleName.ToUpperInvariant()
|
|
});
|
|
|
|
if (!result.Succeeded)
|
|
{
|
|
throw new InvalidOperationException(string.Join(Environment.NewLine, result.Errors.Select(e => e.Description)));
|
|
}
|
|
}
|
|
}
|
|
|
|
static string Prompt(string label, string defaultValue)
|
|
{
|
|
Console.Write($"{label} [{defaultValue}]: ");
|
|
var input = Console.ReadLine();
|
|
return string.IsNullOrWhiteSpace(input) ? defaultValue : input.Trim();
|
|
}
|
|
|
|
static string PromptSecret(string label)
|
|
{
|
|
Console.Write($"{label}: ");
|
|
var password = string.Empty;
|
|
ConsoleKeyInfo key;
|
|
while ((key = Console.ReadKey(true)).Key != ConsoleKey.Enter)
|
|
{
|
|
if (key.Key == ConsoleKey.Backspace && password.Length > 0)
|
|
{
|
|
password = password[..^1];
|
|
Console.Write("\b \b");
|
|
continue;
|
|
}
|
|
|
|
if (!char.IsControl(key.KeyChar))
|
|
{
|
|
password += key.KeyChar;
|
|
Console.Write("*");
|
|
}
|
|
}
|
|
Console.WriteLine();
|
|
return password;
|
|
}
|