using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using QuestionsApi.Services; using QuestionsApi.Services.Flashcards; using MongoDB.Bson; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; using System.Threading.RateLimiting; if (args.FirstOrDefault() == "promote-content") { Environment.ExitCode = await ContentPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out); return; } if (args.FirstOrDefault() == "promote-questions") { Environment.ExitCode = await QuestionPromotionCommand.RunAsync(args.Skip(1).ToArray(), Console.Out); return; } if (args.FirstOrDefault() == "promote-dynamic-questions") { Environment.ExitCode = await DynamicBlueprintCommand.RunAsync(args.Skip(1).ToArray(), Console.Out); return; } if (args.FirstOrDefault() == "export-dynamic-review") { Environment.ExitCode = await DynamicReviewExportCommand.RunAsync(args.Skip(1).ToArray(), Console.Out); return; } if (args.FirstOrDefault() == "seed-flashcards") { var seedConfiguration = new ConfigurationBuilder() .AddJsonFile("appsettings.json", optional: true) .AddEnvironmentVariables() .Build(); Environment.ExitCode = await SeedFlashcardsCommand.RunAsync(seedConfiguration, Console.Out); return; } if (args.FirstOrDefault() == "flag-media-questions") { var flagConfiguration = new ConfigurationBuilder() .AddJsonFile("appsettings.json", optional: true) .AddEnvironmentVariables() .Build(); Environment.ExitCode = await FlagMediaQuestionsCommand.RunAsync(flagConfiguration, Console.Out); return; } var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.ForwardLimit = 2; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); builder.Services.AddSingleton(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHttpClient(); builder.Services.AddRateLimiter(options => { static RateLimitPartition Fixed(HttpContext context, int permits, TimeSpan window) => RateLimitPartition.GetFixedWindowLimiter( context.Connection.RemoteIpAddress?.ToString() ?? "unknown", _ => new FixedWindowRateLimiterOptions { PermitLimit = permits, Window = window, QueueLimit = 0, AutoReplenishment = true }); options.AddPolicy("auth-register", context => Fixed(context, 5, TimeSpan.FromMinutes(15))); options.AddPolicy("auth-login", context => Fixed(context, 20, TimeSpan.FromMinutes(5))); options.AddPolicy("auth-refresh", context => Fixed(context, 60, TimeSpan.FromMinutes(5))); options.AddPolicy("password-reset-request", context => Fixed(context, 5, TimeSpan.FromMinutes(15))); options.AddPolicy("password-reset-confirm", context => Fixed(context, 10, TimeSpan.FromMinutes(15))); options.AddPolicy("steam-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1))); options.AddPolicy("steam-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1))); options.AddPolicy("steam-complete", context => Fixed(context, 20, TimeSpan.FromMinutes(1))); options.AddPolicy("external-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1))); options.AddPolicy("external-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1))); options.AddPolicy("external-complete", context => Fixed(context, 20, TimeSpan.FromMinutes(1))); options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "Questions API", Version = "v1" }); c.AddSecurityDefinition("bearerAuth", new OpenApiSecurityScheme { Type = SecuritySchemeType.Http, Scheme = "bearer", BearerFormat = "JWT", Description = "Paste your access token here (no 'Bearer ' prefix needed)." }); c.AddSecurityRequirement(new OpenApiSecurityRequirement { { new OpenApiSecurityScheme { Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "bearerAuth" } }, Array.Empty() } }); }); var cfg = builder.Configuration; var jwtKey = cfg["Jwt:Key"] ?? throw new Exception("Jwt:Key missing"); var issuer = cfg["Jwt:Issuer"] ?? "promiscuity"; var aud = cfg["Jwt:Audience"] ?? issuer; builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(o => { o.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = issuer, ValidateAudience = true, ValidAudience = aud, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30) }; o.Events = new JwtBearerEvents { OnTokenValidated = async context => { var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier); if (string.IsNullOrWhiteSpace(userId)) { context.Fail("User identity missing"); return; } var services = context.HttpContext.RequestServices; var userTask = services.GetRequiredService().GetByIdAsync(userId); var deletedTask = services.GetRequiredService().IsDeletedAsync(userId); var jti = context.Principal?.FindFirstValue(JwtRegisteredClaimNames.Jti); var revokedTask = string.IsNullOrWhiteSpace(jti) ? Task.FromResult(false) : services.GetRequiredService().ContainsAsync(jti); await Task.WhenAll(userTask, deletedTask, revokedTask); var user = await userTask; var tokenVersion = int.TryParse(context.Principal?.FindFirstValue("auth_ver"), out var parsedVersion) ? parsedVersion : 0; if (user is null || user.AuthVersion != tokenVersion) context.Fail("Session revoked"); else if (await deletedTask) context.Fail("Account deleted"); else if (await revokedTask) context.Fail("Token revoked"); } }; }); builder.Services.AddAuthorization(); var app = builder.Build(); await app.Services.GetRequiredService().InitializeAsync(); app.UseForwardedHeaders(); app.Use(async (context, next) => { var started = System.Diagnostics.Stopwatch.StartNew(); try { await next(); } finally { context.RequestServices.GetRequiredService().HttpRequest(started.Elapsed); } }); app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { var feature = context.Features.Get(); var exception = feature?.Error; var logger = context.RequestServices.GetRequiredService().CreateLogger("GlobalException"); var traceId = context.TraceIdentifier; if (exception is not null) { logger.LogError( exception, "Unhandled exception for {Method} {Path}. TraceId={TraceId}", context.Request.Method, context.Request.Path, traceId ); } context.Response.StatusCode = StatusCodes.Status500InternalServerError; context.Response.ContentType = "application/problem+json"; await context.Response.WriteAsJsonAsync(new { type = "https://httpstatuses.com/500", title = "Internal Server Error", status = 500, detail = exception?.Message ?? "An unexpected server error occurred.", traceId }); }); }); app.MapGet("/healthz", () => Results.Ok("ok")); app.MapGet("/readyz", async (MongoDatabaseProvider mongo) => { await mongo.Database.RunCommandAsync(new BsonDocument("ping", 1)); return Results.Ok("ready"); }); app.MapGet("/api/runtime", (IConfiguration configuration) => Results.Ok(new { environment = configuration["Deployment:Environment"] ?? "development", buildRevision = configuration["Deployment:BuildRevision"] ?? "development" })); app.MapGet("/metrics", (ServiceMetrics metrics) => Results.Text(metrics.RenderPrometheus(), "text/plain; version=0.0.4")); app.UseSwagger(); app.UseSwaggerUI(o => { o.SwaggerEndpoint("/swagger/v1/swagger.json", "Questions API v1"); o.RoutePrefix = "swagger"; }); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.Run();