using GameList.Data; using GameList.Endpoints; using GameList.Infrastructure; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using System.Net; using System.Text.Json.Serialization; var builder = WebApplication.CreateBuilder(args); var dataDirectory = Path.Combine(builder.Environment.ContentRootPath, "App_Data"); Directory.CreateDirectory(dataDirectory); var dataProtectionDirectory = Path.Combine(dataDirectory, "keys"); Directory.CreateDirectory(dataProtectionDirectory); var configuredConnection = builder.Configuration.GetConnectionString("Default"); var dbPath = Path.Combine(dataDirectory, "gamelist.db"); var connectionBuilder = new SqliteConnectionStringBuilder(string.IsNullOrWhiteSpace(configuredConnection) ? $"Data Source={dbPath}" : configuredConnection); if (connectionBuilder.DataSource.Contains("App_Data", StringComparison.OrdinalIgnoreCase)) { var fileName = Path.GetFileName(connectionBuilder.DataSource); connectionBuilder.DataSource = Path.Combine(dataDirectory, fileName); } else if (!Path.IsPathRooted(connectionBuilder.DataSource)) { connectionBuilder.DataSource = Path.GetFullPath(connectionBuilder.DataSource, dataDirectory); } var connectionString = connectionBuilder.ToString(); builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); builder.Services.AddHttpClient("imageValidation").ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory)); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => { options.Cookie.Name = PlayerIdentityExtensions.PlayerCookieName; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Strict; options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; options.SlidingExpiration = true; options.ExpireTimeSpan = TimeSpan.FromDays(30); options.Events = new CookieAuthenticationEvents { OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; }, OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; } }; }); builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityExtensions.AdminPolicy, policy => policy.RequireClaim(PlayerIdentityExtensions.AdminClaim, "true")); }); var app = builder.Build(); app.UseForwardedHeaders(BuildForwardedHeadersOptions(builder.Configuration)); var basePath = builder.Configuration["BasePath"]; if (!string.IsNullOrWhiteSpace(basePath)) { app.UsePathBase(basePath); UpdateIndexMetaBase(app.Environment, basePath); } app.UseGlobalExceptionLogging(); app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); // Ensure database and migrations are applied on startup using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } app.UseDefaultFiles(); app.UseStaticFiles(); app.MapHealthChecks(); app.MapAuthEndpoints(); app.MapStateEndpoints(); app.MapSuggestEndpoints(); app.MapVoteEndpoints(); app.MapResultsEndpoints(); app.MapAdminEndpoints(); app.Run(); static ForwardedHeadersOptions BuildForwardedHeadersOptions(IConfiguration config) { var options = new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost }; options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); foreach (var proxy in config.GetSection("ForwardedHeaders:KnownProxies").Get() ?? []) { if (IPAddress.TryParse(proxy, out var parsedProxy)) options.KnownProxies.Add(parsedProxy); } foreach (var network in config.GetSection("ForwardedHeaders:KnownNetworks").Get() ?? []) { var parts = network.Split('/', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); if (parts.Length != 2) continue; if (!IPAddress.TryParse(parts[0], out var prefix)) continue; if (!int.TryParse(parts[1], out var prefixLength)) continue; options.KnownIPNetworks.Add(new System.Net.IPNetwork(prefix, prefixLength)); } return options; } static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath) { try { var indexPath = Path.Combine(env.WebRootPath, "index.html"); if (!File.Exists(indexPath)) return; var text = File.ReadAllText(indexPath); var marker = "name=\"app-base\""; var contentKey = "content=\""; var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase); if (markerIndex < 0) return; var contentIndex = text.IndexOf(contentKey, markerIndex, StringComparison.OrdinalIgnoreCase); if (contentIndex < 0) return; var valueStart = contentIndex + contentKey.Length; var valueEnd = text.IndexOf('"', valueStart); if (valueEnd < 0) return; var current = text[valueStart..valueEnd]; var normalized = basePath.EndsWith('/') ? basePath.TrimEnd('/') : basePath; if (current == normalized) return; var updated = text[..valueStart] + normalized + text[valueEnd..]; File.WriteAllText(indexPath, updated); } catch { // If we can't rewrite, continue; frontend can still be set manually. } } public partial class Program;