using System.Text; using System.Text.Json; using System.Security.Claims; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.Logging.Abstractions; using ROLAC.API.Data; using ROLAC.API.Data.Interceptors; using ROLAC.API.Data.Logging; using ROLAC.API.Entities; using ROLAC.API.Json; using ROLAC.API.Middleware; using ROLAC.API.Services; using ROLAC.API.Services.Logging; var builder = WebApplication.CreateBuilder(args); var config = builder.Configuration; // --------------------------------------------------------------------------- // Database // --------------------------------------------------------------------------- builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddDbContext((sp, opt) => opt.UseNpgsql(config.GetConnectionString("DefaultConnection")) .AddInterceptors( sp.GetRequiredService(), sp.GetRequiredService())); // Dedicated context for log writes — NO interceptors and a silent logger factory, so persisting // a log row produces no log events the DB sink would pick up (breaks recursion / log-storms). builder.Services.AddDbContext(opt => opt.UseNpgsql(config.GetConnectionString("DefaultConnection")) .UseLoggerFactory(NullLoggerFactory.Instance)); // --------------------------------------------------------------------------- // System + audit logging (custom EF DB sink) // --------------------------------------------------------------------------- builder.Services.Configure(config.GetSection("Logging:Database")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // --------------------------------------------------------------------------- // Identity (API-only — no cookie auth; JWT is the default scheme) // --------------------------------------------------------------------------- builder.Services .AddIdentityCore(opt => { opt.Password.RequiredLength = 8; opt.Password.RequireDigit = true; opt.Password.RequireUppercase = true; opt.Password.RequireLowercase = true; opt.Password.RequireNonAlphanumeric = true; opt.User.RequireUniqueEmail = true; }) .AddRoles() .AddEntityFrameworkStores() .AddDefaultTokenProviders(); // --------------------------------------------------------------------------- // JWT Authentication // --------------------------------------------------------------------------- var jwtKey = config["Jwt:SecretKey"] ?? throw new InvalidOperationException("Jwt:SecretKey is not configured."); builder.Services .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(opt => { // Keep JWT claim names exactly as written ("role", "sub", "email"). // Without this, .NET 8's JsonWebTokenHandler may remap "role" to the // long ClaimTypes.Role URI, which conflicts with RoleClaimType = "role". opt.MapInboundClaims = false; opt.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = config["Jwt:Issuer"], ValidAudience = config["Jwt:Audience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), NameClaimType = "sub", RoleClaimType = "role", ClockSkew = TimeSpan.FromMinutes(1), }; // Diagnostic events — visible in the API console while debugging 401s. opt.Events = new JwtBearerEvents { OnAuthenticationFailed = ctx => { Console.WriteLine( $"[JWT] Auth failed: {ctx.Exception.GetType().Name} — {ctx.Exception.Message}"); return Task.CompletedTask; }, OnChallenge = ctx => { // Fires when a 401 challenge is about to be sent. Console.WriteLine( $"[JWT] Challenge: error={ctx.Error}, description={ctx.ErrorDescription}"); return Task.CompletedTask; }, OnForbidden = ctx => { // Fires when user IS authenticated but lacks the required role (403). Console.WriteLine( $"[JWT] Forbidden: user={ctx.HttpContext.User.Identity?.Name}, " + $"roles=[{string.Join(',', ctx.HttpContext.User.Claims .Where(c => c.Type == "role") .Select(c => c.Value))}]"); return Task.CompletedTask; }, }; }); // --------------------------------------------------------------------------- // CORS // --------------------------------------------------------------------------- var allowedOrigins = config.GetSection("Cors:AllowedOrigins").Get() ?? ["http://localhost:4200"]; builder.Services.AddCors(opt => opt.AddPolicy("Angular", policy => policy.WithOrigins(allowedOrigins) .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials())); // --------------------------------------------------------------------------- // Application services // --------------------------------------------------------------------------- builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // ── Notifications (email via SMTP + Line) ────────────────────────────────── // IOptions binding stays only as the one-time seed/fallback; the runtime source of truth is the // DB-backed NotificationSetting row, read (and hot-reloaded) via INotificationSettingsService. builder.Services.Configure(config.GetSection("Smtp")); builder.Services.Configure(config.GetSection("Line")); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); // --------------------------------------------------------------------------- // Configurable role-based permissions (RBAC matrix) // --------------------------------------------------------------------------- builder.Services.AddMemoryCache(); builder.Services.AddSingleton(); builder.Services.AddAuthorization(); // Dynamic policy provider materializes "PERM::" policies on demand; // must be registered AFTER AddAuthorization so it overrides the default provider. builder.Services.AddSingleton(); builder.Services.AddScoped(); // Real-time hub for the live Sunday attendance counter. builder.Services.AddSignalR(); // --------------------------------------------------------------------------- // Swagger / MVC // --------------------------------------------------------------------------- builder.Services .AddControllers() .AddJsonOptions(opt => { // camelCase in/out + tolerant DateOnly (accepts "yyyy-MM-dd" or full ISO datetime). opt.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; opt.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase; opt.JsonSerializerOptions.PropertyNameCaseInsensitive = true; opt.JsonSerializerOptions.Converters.Add(new TolerantDateOnlyConverter()); }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddHealthChecks(); builder.Services.AddSwaggerGen(opt => { opt.SwaggerDoc("v1", new() { Title = "ROLAC API", Version = "v1" }); // Enable JWT in Swagger UI opt.AddSecurityDefinition("Bearer", new() { Name = "Authorization", Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http, Scheme = "Bearer", BearerFormat = "JWT", In = Microsoft.OpenApi.Models.ParameterLocation.Header, Description = "Enter your JWT access token.", }); opt.AddSecurityRequirement(new() { { new() { Reference = new() { Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, Id = "Bearer" } }, [] } }); }); // --------------------------------------------------------------------------- // Build // --------------------------------------------------------------------------- var app = builder.Build(); // Behind a TLS-terminating reverse proxy (nginx), honour the original scheme/client IP. app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto }); // First in the pipeline: catch every unhandled exception, log it to SystemLogs, and return // a clean problem+json. Placed after UseForwardedHeaders so the logged client IP is correct. app.UseMiddleware(); // Apply migrations + seed on startup using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); await DbSeeder.SeedAsync(scope.ServiceProvider); } if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // TLS is terminated by nginx in production; only redirect in local dev. if (app.Environment.IsDevelopment()) { app.UseHttpsRedirection(); } app.UseCors("Angular"); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapHub("/hubs/attendance"); app.MapHub("/hubs/offering-entry"); app.MapHealthChecks("/health"); app.Run();