using System.Text; using System.Threading.RateLimiting; using FluentValidation; using FluentValidation.AspNetCore; using MealPlan.Api.Data; using MealPlan.Api.Middleware; using MealPlan.Api.Models; using MealPlan.Api.Services; using MealPlan.Api.Validators; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Serilog; var builder = WebApplication.CreateBuilder(args); // Optional local overrides (connection string, secrets) — not committed; see appsettings.Local.json.example. builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); // --------------------------------------------------------------------------- // Serilog: structured logging to console + rolling daily file. // --------------------------------------------------------------------------- builder.Host.UseSerilog((context, services, configuration) => configuration .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext() .WriteTo.Console() .WriteTo.File( path: "logs/mealplan-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 14, shared: true)); // --------------------------------------------------------------------------- // Configuration / options // --------------------------------------------------------------------------- var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException( "ConnectionStrings__DefaultConnection is not configured."); builder.Services.Configure(builder.Configuration.GetSection(JwtOptions.SectionName)); var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get() ?? throw new InvalidOperationException("Jwt configuration section is missing."); if (string.IsNullOrWhiteSpace(jwtOptions.Secret) || jwtOptions.Secret.Length < 32) { throw new InvalidOperationException( "Jwt__Secret must be configured and at least 32 characters long."); } var allowedOrigin = builder.Configuration["CORS:AllowedOrigin"]; // --------------------------------------------------------------------------- // EF Core (PostgreSQL, database-first — no migrations) // --------------------------------------------------------------------------- builder.Services.AddDbContext(options => options.UseNpgsql(connectionString, npgsql => npgsql.EnableRetryOnFailure())); // --------------------------------------------------------------------------- // Application services // --------------------------------------------------------------------------- builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddSingleton, PasswordHasher>(); 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.Configure( builder.Configuration.GetSection(MealPlan.Api.Services.AI.OpenAiOptions.SectionName)); builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // --------------------------------------------------------------------------- // Authentication / authorization (JWT bearer) // --------------------------------------------------------------------------- builder.Services .AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = jwtOptions.Issuer, ValidAudience = jwtOptions.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.Secret)), ClockSkew = TimeSpan.Zero, }; }); builder.Services.AddAuthorization(); // --------------------------------------------------------------------------- // Rate limiting: max 5 login attempts per minute per client IP. // --------------------------------------------------------------------------- builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("login", httpContext => { var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, }); }); }); // --------------------------------------------------------------------------- // CORS: restricted to the configured frontend origin only. // --------------------------------------------------------------------------- const string corsPolicy = "frontend"; builder.Services.AddCors(options => { options.AddPolicy(corsPolicy, policy => { if (builder.Environment.IsDevelopment()) { // Vite may use 5173, 5174, 5175, etc. when ports are busy. policy.SetIsOriginAllowed(origin => !string.IsNullOrEmpty(origin) && Uri.TryCreate(origin, UriKind.Absolute, out var uri) && (uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || uri.Host.Equals("127.0.0.1", StringComparison.OrdinalIgnoreCase))) .AllowAnyHeader() .AllowAnyMethod(); } else if (!string.IsNullOrWhiteSpace(allowedOrigin)) { policy.WithOrigins(allowedOrigin) .AllowAnyHeader() .AllowAnyMethod(); } }); }); // Forwarded headers so the app honors the original scheme/IP behind the nginx proxy. builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); builder.Services.AddControllers(); builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddFluentValidationClientsideAdapters(); builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // Kestrel listens on 5000 (HTTP) inside the container; TLS is terminated at nginx. builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false); var app = builder.Build(); if (args.Contains("--fix-sequences", StringComparer.OrdinalIgnoreCase)) { await using var scope = app.Services.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); var messages = await SequenceMaintenance.ResyncIdentitySequencesAsync(db); foreach (var message in messages) { Console.WriteLine(message); } return; } // --------------------------------------------------------------------------- // HTTP request pipeline // --------------------------------------------------------------------------- app.UseForwardedHeaders(); app.UseSerilogRequestLogging(); app.UseExceptionMiddleware(); // Security headers on every response. app.Use(async (context, next) => { var headers = context.Response.Headers; headers["X-Frame-Options"] = "DENY"; headers["X-Content-Type-Options"] = "nosniff"; headers["Referrer-Policy"] = "no-referrer"; headers["Content-Security-Policy"] = "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'"; headers["X-Permitted-Cross-Domain-Policies"] = "none"; await next(); }); // Behind nginx, X-Forwarded-Proto makes HTTPS redirection work for the public URL. app.UseHttpsRedirection(); if (app.Environment.IsProduction()) { app.UseHsts(); } else { app.UseSwagger(); app.UseSwaggerUI(); } app.UseCors(corsPolicy); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous(); try { Log.Information("Starting MealPlan API"); if (app.Environment.IsDevelopment()) { var authEndpoints = app.Services.GetRequiredService() .Endpoints .OfType() .Where(e => e.RoutePattern.RawText?.Contains("auth", StringComparison.OrdinalIgnoreCase) == true) .Select(e => e.RoutePattern.RawText) .Distinct() .OrderBy(x => x); Log.Information("Auth endpoints: {Endpoints}", string.Join(", ", authEndpoints)); } app.Run(); } catch (Exception ex) { Log.Fatal(ex, "MealPlan API terminated unexpectedly"); } finally { Log.CloseAndFlush(); }