using Adapters.Driven.Persistence; using Adapters.Driven.Persistence.Context; using Adapters.Driving.Api.Extensions; using Application; using Application.Auth; using dotenv.net; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.IdentityModel.Tokens; using Serilog; using System.IO.Compression; using System.Text; using System.Text.Json; using System.Threading.RateLimiting; // Load .env file (searches up to root directory) DotEnv.Load(options: new DotEnvOptions( probeForEnv: true, probeLevelsToSearch: 5 )); // Configure Serilog from appsettings + environment variables Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(new ConfigurationBuilder() .AddJsonFile("appsettings.json") .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true) .AddEnvironmentVariables() .Build()) .CreateLogger(); try { Log.Information("Starting application"); var builder = WebApplication.CreateBuilder(args); // Add environment variables to configuration builder.Configuration.AddEnvironmentVariables(); // Build connection string from individual env vars if not provided directly var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); if (string.IsNullOrEmpty(connectionString)) { var dbHost = Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost"; var dbPort = Environment.GetEnvironmentVariable("DB_PORT") ?? "1433"; var dbName = Environment.GetEnvironmentVariable("DB_NAME") ?? "StudentEnrollment"; var dbUser = Environment.GetEnvironmentVariable("DB_USER") ?? "sa"; var dbPassword = Environment.GetEnvironmentVariable("DB_PASSWORD") ?? ""; connectionString = $"Server={dbHost},{dbPort};Database={dbName};User Id={dbUser};Password={dbPassword};TrustServerCertificate=True"; builder.Configuration["ConnectionStrings:DefaultConnection"] = connectionString; } builder.Host.UseSerilog(); // Add services builder.Services.AddApplication(); builder.Services.AddPersistence(builder.Configuration); builder.Services.AddGraphQLApi(); // Response Compression (Brotli preferred, then Gzip) builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; options.Providers.Add(); options.Providers.Add(); options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat( ["application/json", "application/graphql-response+json"]); }); builder.Services.Configure(options => options.Level = CompressionLevel.Fastest); builder.Services.Configure(options => options.Level = CompressionLevel.Fastest); // CORS (configurable via CORS_ORIGINS env var, comma-separated) var corsOrigins = Environment.GetEnvironmentVariable("CORS_ORIGINS")?.Split(',', StringSplitOptions.RemoveEmptyEntries) ?? ["http://localhost:4200"]; builder.Services.AddCors(options => { options.AddDefaultPolicy(policy => policy.WithOrigins(corsOrigins) .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials()); }); // JWT Authentication var jwtSecretKey = Environment.GetEnvironmentVariable("JWT_SECRET_KEY") ?? "SuperSecretKeyForDevelopmentOnly_ChangeInProduction_AtLeast32Chars!"; builder.Services.Configure(opt => { opt.SecretKey = jwtSecretKey; opt.Issuer = Environment.GetEnvironmentVariable("JWT_ISSUER") ?? "StudentEnrollmentApi"; opt.Audience = Environment.GetEnvironmentVariable("JWT_AUDIENCE") ?? "StudentEnrollmentApp"; opt.ExpirationMinutes = int.TryParse(Environment.GetEnvironmentVariable("JWT_EXPIRATION_MINUTES"), out var exp) ? exp : 60; }); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = Environment.GetEnvironmentVariable("JWT_ISSUER") ?? "StudentEnrollmentApi", ValidAudience = Environment.GetEnvironmentVariable("JWT_AUDIENCE") ?? "StudentEnrollmentApp", IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSecretKey)) }; }); builder.Services.AddAuthorization(options => { options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin")); options.AddPolicy("StudentOrAdmin", policy => policy.RequireRole("Student", "Admin")); }); // Output caching for read-heavy operations builder.Services.AddOutputCache(options => { options.AddBasePolicy(policy => policy.Expire(TimeSpan.FromSeconds(10))); options.AddPolicy("Subjects", policy => policy.Expire(TimeSpan.FromMinutes(5))); options.AddPolicy("Professors", policy => policy.Expire(TimeSpan.FromMinutes(5))); }); // Health checks builder.Services.AddHealthChecks() .AddDbContextCheck("database"); // Rate limiting (DoS prevention) builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddFixedWindowLimiter("graphql", limiter => { limiter.PermitLimit = 100; limiter.Window = TimeSpan.FromMinutes(1); limiter.QueueProcessingOrder = QueueProcessingOrder.OldestFirst; limiter.QueueLimit = 10; }); options.AddFixedWindowLimiter("mutations", limiter => { limiter.PermitLimit = 30; limiter.Window = TimeSpan.FromMinutes(1); limiter.QueueLimit = 5; }); }); var app = builder.Build(); // Verify database connection and apply migrations await VerifyDatabaseConnectionAsync(app); // Create admin user if not exists await CreateAdminUserAsync(app); async Task VerifyDatabaseConnectionAsync(WebApplication app) { var maxRetries = 10; var delay = TimeSpan.FromSeconds(5); for (int attempt = 1; attempt <= maxRetries; attempt++) { try { using var scope = app.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); Log.Information("Attempting database connection (attempt {Attempt}/{MaxRetries})...", attempt, maxRetries); // Try to apply migrations - this will create the DB if it doesn't exist Log.Information("Applying database migrations (this will create DB if needed)..."); await db.Database.MigrateAsync(); Log.Information("Database migrations applied successfully"); // Verify connection works after migrations if (await db.Database.CanConnectAsync()) { Log.Information("Database connection verified successfully"); return; } } catch (Exception ex) { Log.Error(ex, "Database operation failed (attempt {Attempt}/{MaxRetries}): {ErrorMessage}", attempt, maxRetries, ex.Message); if (attempt == maxRetries) { Log.Fatal("Could not establish database connection after {MaxRetries} attempts. " + "Please verify: DB_HOST={DbHost}, DB_PORT={DbPort}, DB_NAME={DbName}, DB_USER={DbUser}", maxRetries, Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost", Environment.GetEnvironmentVariable("DB_PORT") ?? "1433", Environment.GetEnvironmentVariable("DB_NAME") ?? "StudentEnrollment", Environment.GetEnvironmentVariable("DB_USER") ?? "sa"); } } if (attempt < maxRetries) { Log.Information("Waiting {Delay} seconds before next connection attempt...", delay.TotalSeconds); await Task.Delay(delay); } } } async Task CreateAdminUserAsync(WebApplication app) { try { using var scope = app.Services.CreateScope(); var userRepo = scope.ServiceProvider.GetRequiredService(); var passwordService = scope.ServiceProvider.GetRequiredService(); var unitOfWork = scope.ServiceProvider.GetRequiredService(); var adminUsername = Environment.GetEnvironmentVariable("ADMIN_USERNAME") ?? "admin"; var adminPassword = Environment.GetEnvironmentVariable("ADMIN_PASSWORD") ?? "admin123"; if (!await userRepo.ExistsAsync(adminUsername)) { var passwordHash = passwordService.HashPassword(adminPassword); var adminUser = Domain.Entities.User.Create(adminUsername, passwordHash, Domain.Entities.UserRoles.Admin); await userRepo.AddAsync(adminUser); await unitOfWork.SaveChangesAsync(); Log.Information("Admin user '{Username}' created successfully", adminUsername); } else { Log.Information("Admin user '{Username}' already exists", adminUsername); } } catch (Exception ex) { Log.Warning(ex, "Could not create admin user: {Message}", ex.Message); } } // Security headers (OWASP recommended) app.Use(async (context, next) => { var headers = context.Response.Headers; headers.Append("X-Content-Type-Options", "nosniff"); headers.Append("X-Frame-Options", "DENY"); headers.Append("X-XSS-Protection", "0"); headers.Append("Referrer-Policy", "strict-origin-when-cross-origin"); headers.Append("Permissions-Policy", "geolocation=(), microphone=(), camera=()"); // CSP: More restrictive in production, relaxed for dev (GraphQL Playground needs unsafe-inline/eval) var csp = app.Environment.IsDevelopment() ? "default-src 'self'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self'; " + "connect-src 'self' http://localhost:* https://localhost:*; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'" : "default-src 'self'; " + "script-src 'self'; " + "style-src 'self'; " + "img-src 'self' data:; " + "font-src 'self'; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'"; headers.Append("Content-Security-Policy", csp); if (!context.Request.IsHttps && !app.Environment.IsDevelopment()) { headers.Append("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); } await next(); }); // Request logging (structured logs, excludes sensitive data) app.UseSerilogRequestLogging(options => { options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => { diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString()); }; options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; }); // Middleware order matters for performance app.UseResponseCompression(); app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter(); app.UseOutputCache(); // Health check endpoint with JSON response app.MapHealthChecks("/health", new HealthCheckOptions { ResponseWriter = async (context, report) => { context.Response.ContentType = "application/json"; var result = new { status = report.Status.ToString(), timestamp = DateTime.UtcNow, checks = report.Entries.Select(e => new { name = e.Key, status = e.Value.Status.ToString(), description = e.Value.Description, duration = e.Value.Duration.TotalMilliseconds }) }; await context.Response.WriteAsync(JsonSerializer.Serialize(result, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); } }); // GraphQL endpoint with Banana Cake Pop playground and rate limiting app.MapGraphQL() .RequireRateLimiting("graphql") .WithOptions(new HotChocolate.AspNetCore.GraphQLServerOptions { Tool = { Enable = app.Environment.IsDevelopment() } }); await app.RunAsync(); } catch (Exception ex) { Log.Fatal(ex, "Application terminated unexpectedly"); } finally { await Log.CloseAndFlushAsync(); }