refactor: extract DI, helpers from Program.cs into extension classes
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Helpers;
|
||||
using Nexus.Api.Middleware;
|
||||
using Nexus.Api.Services;
|
||||
|
||||
namespace Nexus.Api.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring the Nexus application pipeline and startup.
|
||||
/// </summary>
|
||||
public static class ApplicationBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies pending EF Core migrations and seeds the initial owner account if none exist.
|
||||
/// </summary>
|
||||
public static async Task EnsureDatabaseAsync(this WebApplication app)
|
||||
{
|
||||
var configuration = app.Configuration;
|
||||
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
var ownerEmail = configuration["Owner:Email"]?.Trim().ToLowerInvariant();
|
||||
var ownerPassword = configuration["Owner:Password"];
|
||||
var ownerDisplayName = configuration["Owner:DisplayName"]?.Trim();
|
||||
var hasUsers = await db.Users.AnyAsync();
|
||||
|
||||
if (!hasUsers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ownerEmail))
|
||||
throw new InvalidOperationException("Owner:Email is required for initial setup.");
|
||||
|
||||
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
|
||||
? PasswordHelper.BuildOwnerDisplayName(ownerEmail)
|
||||
: ownerDisplayName;
|
||||
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
|
||||
? PasswordHelper.GenerateTemporaryPassword()
|
||||
: ownerPassword;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
|
||||
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
|
||||
|
||||
db.Users.Add(new NexusUser
|
||||
{
|
||||
Email = ownerEmail,
|
||||
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
|
||||
DisplayName = initialDisplayName,
|
||||
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
||||
Role = "owner"
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ownerPassword))
|
||||
{
|
||||
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the HTTP middleware pipeline: forwarded headers, rate limiting, auth, security headers, and Swagger in development.
|
||||
/// </summary>
|
||||
public static IApplicationBuilder UseNexusPipeline(this IApplicationBuilder app, IWebHostEnvironment env)
|
||||
{
|
||||
app.UseForwardedHeaders();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseSecurityHeaders();
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Routing;
|
||||
using Nexus.Api.Services;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.RateLimiting;
|
||||
|
||||
namespace Nexus.Api.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering Nexus application services in the DI container.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures JWT authentication, authorization, and antiforgery.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusAuth(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var jwtKey = configuration["Jwt:Key"];
|
||||
var jwtIssuer = configuration["Jwt:Issuer"] ?? "nexus";
|
||||
var jwtAudience = configuration["Jwt:Audience"] ?? "nexus-web";
|
||||
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
|
||||
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.MapInboundClaims = false;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtIssuer,
|
||||
ValidAudience = jwtAudience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||
NameClaimType = JwtRegisteredClaimNames.Sub,
|
||||
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
});
|
||||
|
||||
services.AddAuthorization();
|
||||
services.AddAntiforgery(options =>
|
||||
{
|
||||
options.HeaderName = "X-CSRF-TOKEN";
|
||||
options.Cookie.Name = "nexus-csrf";
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.Cookie.HttpOnly = false;
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures rate limiting policies (auth and agents).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusRateLimiting(this IServiceCollection services)
|
||||
{
|
||||
services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 5,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
|
||||
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 30,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures forwarded headers for reverse proxy scenarios.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusForwardedHeaders(this IServiceCollection services)
|
||||
{
|
||||
services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures Swagger and JSON serialization options.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusSwagger(this IServiceCollection services)
|
||||
{
|
||||
services.AddEndpointsApiExplorer();
|
||||
services.AddSwaggerGen();
|
||||
services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the Entity Framework Core DbContext with Npgsql.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusDatabase(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDbContext<NexusDbContext>(options =>
|
||||
options.UseNpgsql(configuration.GetConnectionString("Nexus"))
|
||||
.ConfigureWarnings(w => w.Ignore(
|
||||
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers typed and named HTTP clients for OpenClaw integration.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusHttpClients(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
||||
{
|
||||
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
services.AddHttpClient("gateway", client =>
|
||||
{
|
||||
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new(configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers application domain services (transient, scoped, singleton).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusApplicationServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddTransient<ModelRoutingService>();
|
||||
services.AddScoped<IAuthService, AuthService>();
|
||||
services.AddScoped<IAgentService, AgentService>();
|
||||
services.AddScoped<IDashboardService, DashboardService>();
|
||||
services.AddScoped<IProjectService, ProjectService>();
|
||||
services.AddScoped<ITaskService, TaskService>();
|
||||
services.AddScoped<IOperationsService, OperationsService>();
|
||||
services.AddScoped<ITeamService, TeamService>();
|
||||
services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
||||
services.AddSingleton<IMemoryService, MemoryService>();
|
||||
services.AddSingleton<IIncidentService, IncidentService>();
|
||||
services.AddSingleton<IDocService, DocService>();
|
||||
services.AddScoped<ICalendarService, CalendarService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers data repositories.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusRepositories(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IUserRepository, UserRepository>();
|
||||
services.AddScoped<IProjectRepository, ProjectRepository>();
|
||||
services.AddScoped<ITaskRepository, TaskRepository>();
|
||||
services.AddScoped<IActivityRepository, ActivityRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures health checks (PostgreSQL connectivity and runtime status).
|
||||
/// </summary>
|
||||
public static IServiceCollection AddNexusHealthChecks(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddHealthChecks()
|
||||
.AddNpgSql(configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
|
||||
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Nexus.Api.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Helper methods for password generation and name construction.
|
||||
/// </summary>
|
||||
public static class PasswordHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a cryptographically random temporary password (30 chars, URL-safe base64).
|
||||
/// </summary>
|
||||
public static string GenerateTemporaryPassword()
|
||||
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
/// <summary>
|
||||
/// Builds a human-readable display name from an email address.
|
||||
/// </summary>
|
||||
public static string BuildOwnerDisplayName(string email)
|
||||
{
|
||||
var localPart = email.Split('@', 2)[0].Trim();
|
||||
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
|
||||
|
||||
var words = localPart
|
||||
.Replace('.', ' ')
|
||||
.Replace('_', ' ')
|
||||
.Replace('-', ' ')
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
|
||||
|
||||
var displayName = string.Join(' ', words);
|
||||
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
|
||||
}
|
||||
}
|
||||
+14
-222
@@ -1,234 +1,26 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Nexus.Api.Data;
|
||||
using Nexus.Api.Integrations;
|
||||
using Nexus.Api.Middleware;
|
||||
using Nexus.Api.Repositories;
|
||||
using Nexus.Api.Routing;
|
||||
using Nexus.Api.Services;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.RateLimiting;
|
||||
using Nexus.Api.Extensions;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// --- JWT Configuration ---
|
||||
var jwtKey = builder.Configuration["Jwt:Key"];
|
||||
var jwtIssuer = builder.Configuration["Jwt:Issuer"] ?? "nexus";
|
||||
var jwtAudience = builder.Configuration["Jwt:Audience"] ?? "nexus-web";
|
||||
if (string.IsNullOrWhiteSpace(jwtKey) || Encoding.UTF8.GetByteCount(jwtKey) < 32)
|
||||
throw new InvalidOperationException("Jwt:Key must be configured with at least 32 bytes.");
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.MapInboundClaims = false;
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtIssuer,
|
||||
ValidAudience = jwtAudience,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)),
|
||||
NameClaimType = JwtRegisteredClaimNames.Sub,
|
||||
RoleClaimType = System.Security.Claims.ClaimTypes.Role,
|
||||
ClockSkew = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
builder.Services.AddAntiforgery(options =>
|
||||
{
|
||||
options.HeaderName = "X-CSRF-TOKEN";
|
||||
options.Cookie.Name = "nexus-csrf";
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
|
||||
options.Cookie.HttpOnly = false;
|
||||
});
|
||||
|
||||
// --- Rate Limiting ---
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
options.AddPolicy("auth", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 5,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
|
||||
options.AddPolicy("agents", context => RateLimitPartition.GetFixedWindowLimiter(
|
||||
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
|
||||
_ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 30,
|
||||
Window = TimeSpan.FromMinutes(1),
|
||||
QueueLimit = 0,
|
||||
AutoReplenishment = true
|
||||
}));
|
||||
});
|
||||
|
||||
// --- Forwarded Headers ---
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
|
||||
options.KnownIPNetworks.Clear();
|
||||
options.KnownProxies.Clear();
|
||||
});
|
||||
|
||||
// --- Swagger & JSON ---
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
|
||||
// --- Database ---
|
||||
builder.Services.AddDbContext<NexusDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("Nexus"))
|
||||
.ConfigureWarnings(w => w.Ignore(Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning)));
|
||||
|
||||
// --- HTTP Clients ---
|
||||
builder.Services.AddHttpClient<IAgentRuntime, OpenClawRuntime>(client =>
|
||||
{
|
||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient("gateway", client =>
|
||||
{
|
||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient<IOpenClawGatewayClient, OpenClawGatewayClient>(client =>
|
||||
{
|
||||
client.BaseAddress = new(builder.Configuration["Integrations:OpenClaw:BaseUrl"]
|
||||
?? "http://127.0.0.1:18789");
|
||||
client.Timeout = TimeSpan.FromSeconds(120);
|
||||
});
|
||||
|
||||
// --- Application Services ---
|
||||
builder.Services.AddTransient<ModelRoutingService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IAgentService, AgentService>();
|
||||
builder.Services.AddScoped<IDashboardService, DashboardService>();
|
||||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
||||
builder.Services.AddScoped<ITaskService, TaskService>();
|
||||
builder.Services.AddScoped<IOperationsService, OperationsService>();
|
||||
builder.Services.AddScoped<ITeamService, TeamService>();
|
||||
builder.Services.AddSingleton<IAgentConfigService, AgentConfigService>();
|
||||
builder.Services.AddSingleton<IMemoryService, MemoryService>();
|
||||
builder.Services.AddSingleton<IIncidentService, IncidentService>();
|
||||
builder.Services.AddSingleton<IDocService, DocService>();
|
||||
builder.Services.AddScoped<ICalendarService, CalendarService>();
|
||||
|
||||
// --- Repositories ---
|
||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
|
||||
builder.Services.AddScoped<ITaskRepository, TaskRepository>();
|
||||
builder.Services.AddScoped<IActivityRepository, ActivityRepository>();
|
||||
|
||||
// --- Health Checks ---
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddNpgSql(builder.Configuration.GetConnectionString("Nexus")!, name: "postgresql", tags: ["database"])
|
||||
.AddCheck("runtime", () => HealthCheckResult.Healthy("Runtime configured"), tags: ["runtime"]);
|
||||
|
||||
// --- Controllers ---
|
||||
// --- Service Registration ---
|
||||
builder.Services.AddNexusAuth(builder.Configuration);
|
||||
builder.Services.AddNexusRateLimiting();
|
||||
builder.Services.AddNexusForwardedHeaders();
|
||||
builder.Services.AddNexusSwagger();
|
||||
builder.Services.AddNexusDatabase(builder.Configuration);
|
||||
builder.Services.AddNexusHttpClients(builder.Configuration);
|
||||
builder.Services.AddNexusApplicationServices();
|
||||
builder.Services.AddNexusRepositories();
|
||||
builder.Services.AddNexusHealthChecks(builder.Configuration);
|
||||
builder.Services.AddControllers();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// --- Database Migration & Owner Seeding ---
|
||||
await using (var scope = app.Services.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<NexusDbContext>();
|
||||
await db.Database.MigrateAsync();
|
||||
|
||||
var ownerEmail = builder.Configuration["Owner:Email"]?.Trim().ToLowerInvariant();
|
||||
var ownerPassword = builder.Configuration["Owner:Password"];
|
||||
var ownerDisplayName = builder.Configuration["Owner:DisplayName"]?.Trim();
|
||||
var hasUsers = await db.Users.AnyAsync();
|
||||
|
||||
if (!hasUsers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ownerEmail))
|
||||
throw new InvalidOperationException("Owner:Email is required for initial setup.");
|
||||
|
||||
var initialDisplayName = string.IsNullOrWhiteSpace(ownerDisplayName)
|
||||
? BuildOwnerDisplayName(ownerEmail)
|
||||
: ownerDisplayName;
|
||||
var initialPassword = string.IsNullOrWhiteSpace(ownerPassword)
|
||||
? GenerateTemporaryPassword()
|
||||
: ownerPassword;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ownerPassword) && ownerPassword.Length < 10)
|
||||
throw new InvalidOperationException("Owner:Password must be at least 10 characters when provided explicitly.");
|
||||
|
||||
db.Users.Add(new NexusUser
|
||||
{
|
||||
Email = ownerEmail,
|
||||
NormalizedEmail = AuthService.NormalizeEmail(ownerEmail),
|
||||
DisplayName = initialDisplayName,
|
||||
PasswordHash = PasswordSecurity.Hash(initialPassword),
|
||||
Role = "owner"
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ownerPassword))
|
||||
{
|
||||
Console.Error.WriteLine($"[nexus] Initial owner credentials generated: displayName={initialDisplayName}, password={initialPassword}");
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Database Migration & Seeding ---
|
||||
await app.EnsureDatabaseAsync();
|
||||
|
||||
// --- Middleware Pipeline ---
|
||||
app.UseForwardedHeaders();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseSecurityHeaders();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
app.UseNexusPipeline(app.Environment);
|
||||
|
||||
app.MapControllers();
|
||||
app.Run();
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
static string GenerateTemporaryPassword()
|
||||
=> Convert.ToBase64String(RandomNumberGenerator.GetBytes(18))
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
static string BuildOwnerDisplayName(string email)
|
||||
{
|
||||
var localPart = email.Split('@', 2)[0].Trim();
|
||||
if (string.IsNullOrWhiteSpace(localPart)) return "Owner";
|
||||
|
||||
var words = localPart
|
||||
.Replace('.', ' ')
|
||||
.Replace('_', ' ')
|
||||
.Replace('-', ' ')
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(word => char.ToUpperInvariant(word[0]) + word[1..].ToLowerInvariant());
|
||||
|
||||
var displayName = string.Join(' ', words);
|
||||
return string.IsNullOrWhiteSpace(displayName) ? "Owner" : displayName;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user