Files
chapter-organizer/WebApp/Program.cs
T
poprhythm a9036d5d04 Add state schedule handout feature and configuration options
This commit introduces a new StateScheduleHandout component for generating printable schedules for students, including a combined master list of events. It adds configuration options in appsettings.json for state abbreviations and special event filters, enhancing the scheduling functionality. The Program.cs file is updated to register the new StateScheduleHandoutOptions, and the Calendar and Teams components are modified to include links to the new handout feature. Additionally, utility methods for filtering event occurrences are implemented to support the new functionality, improving the overall user experience in managing state schedules.
2026-04-06 23:33:57 -04:00

346 lines
13 KiB
C#

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;
using WebApp.Models;
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));
});
// Optional user list for login (same file as production). Loaded whenever present so local Development can mirror production auth.
var authSecretsPath = Path.Combine(builder.Environment.ContentRootPath, "Data", "auth-secrets.json");
if (File.Exists(authSecretsPath))
{
builder.Configuration.AddJsonFile(authSecretsPath, optional: false, reloadOnChange: true);
Console.WriteLine($"Loaded authentication users from {authSecretsPath}");
}
if (builder.Environment.IsProduction())
{
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<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>();
// EventOccurrenceParserService with configuration injection
builder.Services.AddScoped<Core.Services.IEventOccurrenceParserService>(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
return new Core.Services.EventOccurrenceParserService(configuration);
});
builder.Services.AddScoped<WebApp.Services.FormValidationService>();
builder.Services.AddScoped<WebApp.Services.EventDefinitionService>();
builder.Services.AddScoped<WebApp.Services.INotesService, WebApp.Services.NotesService>();
builder.Services.AddScoped<WebApp.Services.ITeamMeetingHistoryService, WebApp.Services.TeamMeetingHistoryService>();
builder.Services.AddScoped<WebApp.Services.ICalendarService, WebApp.Services.CalendarService>();
builder.Services.AddScoped<Core.Services.INoteNamingService, Core.Services.NoteNamingService>();
builder.Services.AddScoped<WebApp.Services.MarkdownTablePasteService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleStateService, WebApp.Services.MeetingScheduleStateService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleClipboardService, WebApp.Services.MeetingScheduleClipboardService>();
builder.Services.AddScoped<WebApp.Services.IMeetingScheduleDataService, WebApp.Services.MeetingScheduleDataService>();
builder.Services.Configure<StateScheduleHandoutOptions>(
builder.Configuration.GetSection(StateScheduleHandoutOptions.SectionName));
// 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();
// 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<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();