1d3167710d
Updated the CareerMapping component to utilize VisNetwork for visualizing relationships between events and career fields. Replaced the previous Mermaid diagram implementation with a network graph, enhancing interactivity and visual clarity. Adjusted data generation logic to support the new network structure and updated relevant namespaces in the project files.
296 lines
10 KiB
C#
296 lines
10 KiB
C#
using Data;
|
|
using Microsoft.AspNetCore.DataProtection;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using MudBlazor.Services;
|
|
using Serilog;
|
|
using System.Text.Json;
|
|
using VisNetwork.Blazor;
|
|
using WebApp;
|
|
using WebApp.Authentication;
|
|
using WebApp.Components;
|
|
using WebApp.Logging;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
// Load appsettings from Data directory (works in both development and production)
|
|
// This allows runtime configuration changes without touching the codebase
|
|
var dataAppSettingsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "appsettings.json");
|
|
if (!File.Exists(dataAppSettingsPath))
|
|
{
|
|
Console.WriteLine($"appsettings.json not found at {dataAppSettingsPath}. Creating from template...");
|
|
|
|
try
|
|
{
|
|
// Ensure Data directory exists
|
|
Directory.CreateDirectory(Path.Combine(builder.Environment.ContentRootPath, "Data"));
|
|
|
|
// Copy ChapterSettings and ValidationSettings from the base appsettings.json
|
|
var baseAppSettingsPath = Path.Combine(builder.Environment.ContentRootPath, "appsettings.json");
|
|
if (File.Exists(baseAppSettingsPath))
|
|
{
|
|
var baseConfig = File.ReadAllText(baseAppSettingsPath);
|
|
var baseDoc = JsonDocument.Parse(baseConfig);
|
|
|
|
var templateSettings = new Dictionary<string, object?>();
|
|
|
|
if (baseDoc.RootElement.TryGetProperty("ChapterSettings", out var chapterSettings))
|
|
{
|
|
templateSettings["ChapterSettings"] = JsonSerializer.Deserialize<object>(chapterSettings.GetRawText());
|
|
}
|
|
|
|
if (baseDoc.RootElement.TryGetProperty("ValidationSettings", out var validationSettings))
|
|
{
|
|
templateSettings["ValidationSettings"] = JsonSerializer.Deserialize<object>(validationSettings.GetRawText());
|
|
}
|
|
|
|
if (templateSettings.Any())
|
|
{
|
|
var json = JsonSerializer.Serialize(templateSettings, new JsonSerializerOptions
|
|
{
|
|
WriteIndented = true
|
|
});
|
|
|
|
File.WriteAllText(dataAppSettingsPath, json);
|
|
Console.WriteLine("appsettings.json created in Data directory.");
|
|
}
|
|
else
|
|
{
|
|
Console.WriteLine("WARNING: No settings to copy from base appsettings.json");
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"WARNING: Unable to create appsettings.json: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// Add Data/appsettings.json as additional configuration source (all environments)
|
|
if (File.Exists(dataAppSettingsPath))
|
|
{
|
|
builder.Configuration.AddJsonFile(dataAppSettingsPath, optional: true, reloadOnChange: true);
|
|
Console.WriteLine($"Loaded configuration from {dataAppSettingsPath}");
|
|
}
|
|
|
|
// Configure Serilog with custom handling for antiforgery errors
|
|
builder.Host.UseSerilog((context, configuration) =>
|
|
{
|
|
var consoleOutputTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}";
|
|
var fileOutputTemplate = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}";
|
|
|
|
// Create console logger with antiforgery error handling
|
|
var consoleConfig = new LoggerConfiguration()
|
|
.WriteTo.Console(outputTemplate: consoleOutputTemplate);
|
|
var consoleLogger = consoleConfig.CreateLogger();
|
|
|
|
// Create file logger with antiforgery error handling
|
|
var fileConfig = new LoggerConfiguration()
|
|
.WriteTo.File(
|
|
path: "logs/webapp-.txt",
|
|
rollingInterval: RollingInterval.Day,
|
|
retainedFileCountLimit: 30,
|
|
outputTemplate: fileOutputTemplate);
|
|
var fileLogger = fileConfig.CreateLogger();
|
|
|
|
configuration
|
|
.ReadFrom.Configuration(context.Configuration)
|
|
.Enrich.FromLogContext()
|
|
.WriteTo.Sink(new AntiforgeryLogEventSink(consoleLogger))
|
|
.WriteTo.Sink(new AntiforgeryLogEventSink(fileLogger));
|
|
});
|
|
|
|
// Configure authentication secrets for production (Docker, etc.)
|
|
if (builder.Environment.IsProduction())
|
|
{
|
|
// Option 1: Load from volume-mounted secrets file in Data directory
|
|
var secretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
|
|
if (File.Exists(secretsPath))
|
|
{
|
|
builder.Configuration.AddJsonFile(secretsPath, optional: false, reloadOnChange: true);
|
|
}
|
|
|
|
// Option 2: Environment variables with prefix
|
|
builder.Configuration.AddEnvironmentVariables(prefix: "TSA_");
|
|
}
|
|
|
|
// Add services to the container.
|
|
builder.Services.AddControllersWithViews();
|
|
builder.Services.AddRazorComponents()
|
|
.AddInteractiveServerComponents();
|
|
|
|
builder.Services.AddMudServices();
|
|
builder.Services.AddVisNetwork();
|
|
|
|
// Configure Data Protection to persist keys across container restarts
|
|
var keysPath = Path.Combine(builder.Environment.ContentRootPath, "DataProtectionKeys");
|
|
|
|
// Try to create the directory if it doesn't exist
|
|
try
|
|
{
|
|
if (!Directory.Exists(keysPath))
|
|
{
|
|
Directory.CreateDirectory(keysPath);
|
|
Console.WriteLine($"Created DataProtectionKeys directory at: {keysPath}");
|
|
}
|
|
}
|
|
catch (UnauthorizedAccessException ex)
|
|
{
|
|
Console.WriteLine($"WARNING: Unable to create DataProtectionKeys directory at {keysPath}");
|
|
Console.WriteLine($"Error: {ex.Message}");
|
|
Console.WriteLine("This may cause issues with antiforgery tokens and authentication cookies.");
|
|
Console.WriteLine("Ensure the volume mount has correct permissions for the container user.");
|
|
}
|
|
|
|
builder.Services.AddDataProtection()
|
|
.PersistKeysToFileSystem(new DirectoryInfo(keysPath))
|
|
.SetApplicationName("TSA-Chapter-Organizer");
|
|
|
|
// Ensure Data directory exists
|
|
var dataPath = Path.Combine(builder.Environment.ContentRootPath, "Data");
|
|
try
|
|
{
|
|
if (!Directory.Exists(dataPath))
|
|
{
|
|
Directory.CreateDirectory(dataPath);
|
|
Console.WriteLine($"Created Data directory at: {dataPath}");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($"WARNING: Unable to create Data directory at {dataPath}: {ex.Message}");
|
|
}
|
|
|
|
// Configure SQLite with hardcoded connection string
|
|
var connectionString = "Data Source=Data/app.db";
|
|
builder.Services.AddDbContext<AppDbContext>(options =>
|
|
{
|
|
options.UseSqlite(connectionString,
|
|
sqliteOptions => sqliteOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
|
|
});
|
|
|
|
builder.Services.AddQuickGridEntityFrameworkAdapter();
|
|
|
|
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
|
|
|
|
builder.Services.AddScoped<ClipboardService>();
|
|
builder.Services.AddScoped<WebApp.LocalStorageService>();
|
|
builder.Services.AddScoped<WebApp.Services.IEventOccurrenceService, WebApp.Services.EventOccurrenceService>();
|
|
builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService, Core.Services.EventOccurrenceParserService>();
|
|
builder.Services.AddScoped<WebApp.Services.FormValidationService>();
|
|
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
|
|
|
|
// State container for maintaining state per user connection (Blazor Server)
|
|
builder.Services.AddScoped<StateContainer>();
|
|
|
|
// Load validation configuration from appsettings (or use default if not found)
|
|
var validationConfig = builder.Configuration
|
|
.GetSection("ValidationSettings")
|
|
.Get<Core.Validation.ValidationConfiguration>() ?? Core.Validation.ValidationConfiguration.Default;
|
|
|
|
// Validation service with loaded configuration
|
|
builder.Services.AddScoped<Core.Validation.ValidationService>(sp =>
|
|
new Core.Validation.ValidationService(validationConfig));
|
|
|
|
// Add authentication services
|
|
builder.Services.AddHttpContextAccessor();
|
|
builder.Services.AddScoped<AuthenticationService>();
|
|
builder.Services.AddSingleton<LoginRateLimitService>();
|
|
builder.Services.AddHostedService<LoginRateLimitService>(sp => sp.GetRequiredService<LoginRateLimitService>());
|
|
|
|
// Add authentication options
|
|
builder.Services.AddAuthentication("Auth")
|
|
.AddCookie("Auth", options =>
|
|
{
|
|
options.ExpireTimeSpan = TimeSpan.FromMinutes(20);
|
|
options.SlidingExpiration = true;
|
|
options.LoginPath = "/login";
|
|
|
|
// Enhanced security settings
|
|
options.Cookie.HttpOnly = true;
|
|
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment()
|
|
? CookieSecurePolicy.SameAsRequest
|
|
: CookieSecurePolicy.Always;
|
|
options.Cookie.SameSite = SameSiteMode.Strict;
|
|
options.Cookie.Name = "TSA.Auth";
|
|
});
|
|
builder.Services.AddCascadingAuthenticationState();
|
|
|
|
var app = builder.Build();
|
|
|
|
// Check and apply database migrations
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var services = scope.ServiceProvider;
|
|
var logger = services.GetRequiredService<ILogger<Program>>();
|
|
|
|
try
|
|
{
|
|
var context = services.GetRequiredService<AppDbContext>();
|
|
|
|
// Check for pending migrations
|
|
var pendingMigrations = await context.Database.GetPendingMigrationsAsync();
|
|
|
|
if (pendingMigrations.Any())
|
|
{
|
|
logger.LogInformation(
|
|
"Applying database migrations: {Migrations}",
|
|
string.Join(", ", pendingMigrations));
|
|
await context.Database.MigrateAsync();
|
|
logger.LogInformation("Database migrations applied successfully");
|
|
}
|
|
else
|
|
{
|
|
logger.LogInformation("Database is up to date");
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "An error occurred while checking database migrations");
|
|
throw;
|
|
}
|
|
}
|
|
|
|
// Configure the HTTP request pipeline.
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
|
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
|
|
app.UseHsts();
|
|
app.UseMigrationsEndPoint();
|
|
}
|
|
else
|
|
{
|
|
// Only redirect to HTTPS in development (production should use reverse proxy)
|
|
app.UseHttpsRedirection();
|
|
}
|
|
app.UseRouting();
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.UseStaticFiles();
|
|
app.UseAntiforgery();
|
|
|
|
app.MapRazorComponents<App>()
|
|
.AddInteractiveServerRenderMode();
|
|
|
|
// Used for AuthController
|
|
app.MapControllerRoute("default", "{controller}/{action}");
|
|
|
|
// Development-only password hash generator endpoint
|
|
if (app.Environment.IsDevelopment())
|
|
{
|
|
app.MapGet("/dev/hash-password", (string password) =>
|
|
{
|
|
var hash = PasswordHashGenerator.GenerateHash(password);
|
|
return Results.Ok(new
|
|
{
|
|
password,
|
|
hash,
|
|
message = "Copy the hash value to your User Secrets configuration"
|
|
});
|
|
}).WithName("GeneratePasswordHash");
|
|
}
|
|
|
|
app.Run();
|