using Data; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.StaticFiles; 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(); if (baseDoc.RootElement.TryGetProperty("ChapterSettings", out var chapterSettings)) { templateSettings["ChapterSettings"] = JsonSerializer.Deserialize(chapterSettings.GetRawText()); } if (baseDoc.RootElement.TryGetProperty("ValidationSettings", out var validationSettings)) { templateSettings["ValidationSettings"] = JsonSerializer.Deserialize(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(); // Configure SignalR HubOptions to support large text inputs (e.g., pasted event occurrence files) // Default limit is around 32KB, increase to 1MB to support large pasted text files builder.Services.AddRazorComponents() .AddInteractiveServerComponents() .AddHubOptions(options => { // Increase maximum message size to 1MB to support large pasted text files // The test file is ~430 lines, which is well within this limit options.MaximumReceiveMessageSize = 1024 * 1024; // 1MB }); 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(options => { options.UseSqlite(connectionString, sqliteOptions => sqliteOptions.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)); }); builder.Services.AddQuickGridEntityFrameworkAdapter(); builder.Services.AddDatabaseDeveloperPageExceptionFilter(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // EventOccurrenceParserService with configuration injection builder.Services.AddScoped(sp => { var configuration = sp.GetRequiredService(); return new Core.Services.EventOccurrenceParserService(configuration); }); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // State container for maintaining state per user connection (Blazor Server) builder.Services.AddScoped(); // Load validation configuration from appsettings (or use default if not found) var validationConfig = builder.Configuration .GetSection("ValidationSettings") .Get() ?? Core.Validation.ValidationConfiguration.Default; // Validation service with loaded configuration builder.Services.AddScoped(sp => new Core.Validation.ValidationService(validationConfig)); // Add authentication services builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // 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>(); try { var context = services.GetRequiredService(); // 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(); // Configure static files with proper cache headers for Blazor assets app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = ctx => { // Blazor framework files: Use ETags with short cache and revalidation // This allows caching for performance but ensures fresh files after deployments // Browser will check ETag on each request (304 Not Modified if unchanged) if (ctx.File.Name.Contains("_framework") || ctx.File.Name.Contains("blazor") || ctx.File.Name.EndsWith(".dll") || ctx.File.Name.EndsWith(".wasm")) { // Cache for 12 hours, but must revalidate (check ETag) before using // If file hasn't changed, browser gets 304 Not Modified (no download) // If file changed, browser downloads new version ctx.Context.Response.Headers.Append("Cache-Control", "public, max-age=43200, must-revalidate"); } else { // Other static files (CSS, images, etc.) can be cached long-term ctx.Context.Response.Headers.Append("Cache-Control", "public, max-age=31536000"); } } }); app.UseAntiforgery(); app.MapRazorComponents() .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();