using Api.Models.Settings; using Api.Services; using Api.Services.Contracts; using Azure.Identity; using Microsoft.AspNetCore.HttpOverrides; using Serilog; using System.Reflection; using System.Threading.RateLimiting; // Load .env file if it exists (for local development) DotNetEnv.Env.Load(); try { var builder = WebApplication.CreateBuilder(args); var appVersion = Assembly.GetExecutingAssembly() .GetCustomAttribute()? .InformationalVersion ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown"; builder.Host.UseSerilog((context, services, configuration) => { configuration .ReadFrom.Configuration(context.Configuration) .ReadFrom.Services(services) .Enrich.FromLogContext() .Enrich.WithMachineName() .Enrich.WithEnvironmentName() .Enrich.WithProperty("AppVersion", appVersion) .WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()); }); Log.Information("Starting API version {AppVersion}", appVersion); // -------------------- // Azure Key Vault Configuration // -------------------- var keyVaultUri = builder.Configuration["KeyVault:VaultUri"]; var keyVaultEnabled = builder.Configuration.GetValue("KeyVault:Enabled"); if (keyVaultEnabled && !string.IsNullOrWhiteSpace(keyVaultUri)) { Log.Information("Loading configuration from Azure Key Vault: {VaultUri}", keyVaultUri); try { builder.Configuration.AddAzureKeyVault( new Uri(keyVaultUri), new DefaultAzureCredential()); Log.Information("Azure Key Vault configuration loaded successfully"); } catch (Exception ex) { Log.Warning(ex, "Failed to load Azure Key Vault configuration. Continuing with other configuration sources."); } } else { Log.Information("Azure Key Vault is disabled or not configured"); } // Controllers builder.Services.AddControllers(); // Options builder.Services.Configure(builder.Configuration.GetSection("Google")); builder.Services.Configure(builder.Configuration.GetSection("Contact")); builder.Services.Configure(builder.Configuration.GetSection("Subscribe")); builder.Services.Configure(builder.Configuration.GetSection("Smtp")); builder.Services.Configure(builder.Configuration.GetSection("Captcha")); builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); // Services builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHttpClient("CvMatcherApi"); // Swagger builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); // If you're behind Caddy / reverse proxy builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; // use the normalized header Caddy sends upstream. options.ForwardedForHeaderName = "X-Real-IP"; options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); options.ForwardLimit = 1; }); // -------------------- // CORS (lock it down) // -------------------- // Configure allowed origins via config/env var. // Example env var in Docker: Cors__AllowedOrigins__0=https://app.yourdomain.com var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); builder.Services.AddCors(options => { options.AddPolicy("FrontendOnly", policy => { // If none configured, fail closed: allow nothing. if (allowedOrigins.Length > 0) { policy.WithOrigins(allowedOrigins) .WithMethods("POST", "OPTIONS") // contact form only .WithHeaders("Content-Type") // keep minimal .SetPreflightMaxAge(TimeSpan.FromHours(1)); } }); }); // -------------------- // Rate Limiting // -------------------- // Two layers: // 1) A global limiter (keeps random traffic sane). // 2) A stricter policy for /api/contact. builder.Services.AddRateLimiter(options => { // Global: per IP, moderate options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => { var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; return RateLimitPartition.GetFixedWindowLimiter( partitionKey: ip, factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 120, // 120 req Window = TimeSpan.FromMinutes(1), // per minute QueueLimit = 0, AutoReplenishment = true } ); }); // Policy: contact endpoint, stricter (per IP) options.AddPolicy("contact", httpContext => { var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; return RateLimitPartition.GetFixedWindowLimiter( partitionKey: ip, factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, // 5 submits Window = TimeSpan.FromMinutes(1), // per minute per IP QueueLimit = 0, AutoReplenishment = true } ); }); // Policy: CV matcher, expensive because it calls AI APIs. options.AddPolicy("cv-matcher", httpContext => { var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; return RateLimitPartition.GetFixedWindowLimiter( partitionKey: ip, factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(10), QueueLimit = 0, AutoReplenishment = true } ); }); options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.OnRejected = async (context, ct) => { var logger = context.HttpContext.RequestServices .GetRequiredService>(); var ip = context.HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; var endpoint = context.HttpContext.Request.Path; logger.LogWarning( "Rate limit exceeded for {Endpoint} from IP {IP}", endpoint, ip ); // Small, bot-unfriendly response context.HttpContext.Response.ContentType = "application/json"; await context.HttpContext.Response.WriteAsync( """{"error":"Too many requests. Try again later."}""", ct ); }; }); var app = builder.Build(); var logger = app.Services.GetRequiredService>(); logger.LogInformation("API starting up..."); logger.LogInformation("Environment: {Environment}", app.Environment.EnvironmentName); // Log all environment variables and configuration settings at startup // Can be controlled via appsettings: "LogEnvironmentOnStartup": true var logEnvironmentOnStartup = app.Configuration.GetValue("LogEnvironmentOnStartup", defaultValue: true); if (logEnvironmentOnStartup) { LogEnvironmentSettings(logger, app.Configuration, app.Environment); } // Forwarded headers must be early in the pipeline app.UseForwardedHeaders(); // Add Serilog request logging app.UseSerilogRequestLogging(options => { options.MessageTemplate = "HTTP {RequestMethod} {RequestPath} responded {StatusCode} in {Elapsed:0.0000} ms"; options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => { diagnosticContext.Set("RequestHost", httpContext.Request.Host.Value); diagnosticContext.Set("RequestScheme", httpContext.Request.Scheme); diagnosticContext.Set("RemoteIP", httpContext.Connection.RemoteIpAddress?.ToString()); diagnosticContext.Set("UserAgent", httpContext.Request.Headers.UserAgent.ToString()); diagnosticContext.Set("XRealIP", httpContext.Request.Headers["X-Real-IP"].ToString()); diagnosticContext.Set("XForwardedFor", httpContext.Request.Headers["X-Forwarded-For"].ToString()); }; }); // Swagger (typically only in Development) if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(options => { options.DocumentTitle = "API"; options.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1"); options.RoutePrefix = "swagger"; // /swagger }); } app.UseHttpsRedirection(); app.UseAuthorization(); app.UseRouting(); app.UseCors("FrontendOnly"); app.UseRateLimiter(); app.MapControllers(); logger.LogInformation("API startup complete. Listening for requests..."); app.Run(); } catch (Exception ex) { Log.Fatal(ex, "Application terminated unexpectedly"); } finally { Log.Information("Shutting down API..."); Log.CloseAndFlush(); } /// /// Logs all environment variables and configuration settings at startup for diagnostics. /// static void LogEnvironmentSettings(Microsoft.Extensions.Logging.ILogger logger, IConfiguration configuration, IWebHostEnvironment environment) { logger.LogInformation("==================== ENVIRONMENT SETTINGS ===================="); // Environment Information logger.LogInformation("Application Name: {ApplicationName}", environment.ApplicationName); logger.LogInformation("Environment Name: {EnvironmentName}", environment.EnvironmentName); logger.LogInformation("Content Root Path: {ContentRootPath}", environment.ContentRootPath); logger.LogInformation("Web Root Path: {WebRootPath}", environment.WebRootPath); // Environment Variables logger.LogInformation("-------------- Environment Variables --------------"); var envVars = Environment.GetEnvironmentVariables(); var sortedEnvVars = new SortedDictionary(); foreach (System.Collections.DictionaryEntry entry in envVars) { var key = entry.Key?.ToString() ?? string.Empty; var value = entry.Value?.ToString() ?? string.Empty; // Mask sensitive values (passwords, secrets, tokens, keys) but show last 4 characters if (IsSensitiveKey(key)) { value = MaskValueWithLastChars(value); } sortedEnvVars[key] = value; } foreach (var kvp in sortedEnvVars) { logger.LogInformation(" {Key} = {Value}", kvp.Key, kvp.Value); } // Configuration Settings logger.LogInformation("-------------- Configuration Settings --------------"); LogConfigurationRecursive(logger, configuration.GetChildren(), ""); logger.LogInformation("==========================================================="); } /// /// Recursively logs configuration settings with hierarchy. /// static void LogConfigurationRecursive(Microsoft.Extensions.Logging.ILogger logger, IEnumerable sections, string prefix) { foreach (var section in sections) { var key = string.IsNullOrEmpty(prefix) ? section.Key : $"{prefix}:{section.Key}"; if (section.Value != null) { var value = section.Value; // Mask sensitive configuration values but show last 4 characters if (IsSensitiveKey(key)) { value = MaskValueWithLastChars(value); } logger.LogInformation(" {Key} = {Value}", key, value); } // Recurse into child sections if (section.GetChildren().Any()) { LogConfigurationRecursive(logger, section.GetChildren(), key); } } } /// /// Checks if a configuration key contains sensitive information. /// static bool IsSensitiveKey(string key) { return key.Contains("Password", StringComparison.OrdinalIgnoreCase) || key.Contains("Secret", StringComparison.OrdinalIgnoreCase) || key.Contains("Token", StringComparison.OrdinalIgnoreCase) || key.Contains("Key", StringComparison.OrdinalIgnoreCase) || key.Contains("ConnectionString", StringComparison.OrdinalIgnoreCase); } /// /// Masks a sensitive value but shows the last 4 characters for verification. /// /// The value to mask. /// Masked value showing last 4 characters (e.g., "***MASKED***...abcd") static string MaskValueWithLastChars(string value) { if (string.IsNullOrEmpty(value)) { return "***NOT SET***"; } // If value is too short, just mask it completely if (value.Length <= 4) { return "***MASKED***"; } // Show last 4 characters var lastChars = value.Substring(value.Length - 4); return $"***MASKED***...{lastChars}"; }