diff --git a/api/Dockerfile b/api/Dockerfile index 201a8e4..f879bfe 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,14 +1,20 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release -WORKDIR /src/api +WORKDIR /src -# Copy the project file and restore first to leverage Docker layer caching -COPY api.csproj ./ -RUN dotnet restore api.csproj +COPY api/api.csproj api/ +COPY api-models/api-models.csproj api-models/ +COPY cv-matcher-api-models/cv-matcher-api-models.csproj cv-matcher-api-models/ +COPY startup-helpers/startup-helpers.csproj startup-helpers/startup-helpers/ -# Copy only the api project files to avoid bringing other projects into the build context -COPY . ./ -RUN dotnet publish api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false +RUN dotnet restore api/api.csproj + +COPY api/ api/ +COPY api-models/ api-models/ +COPY cv-matcher-api-models/ cv-matcher-api-models/ +COPY startup-helpers/ startup-helpers/ + +RUN dotnet publish api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app @@ -16,4 +22,5 @@ EXPOSE 8080 ENV ASPNETCORE_URLS=http://0.0.0.0:8080 COPY --from=build /app/publish . + ENTRYPOINT ["dotnet", "api.dll"] \ No newline at end of file diff --git a/api/Program.cs b/api/Program.cs index 171224d..90fbf48 100644 --- a/api/Program.cs +++ b/api/Program.cs @@ -1,75 +1,27 @@ -using Models.Settings; +using System.Reflection; using Api.Services; using Api.Services.Contracts; -using Azure.Identity; -using Microsoft.AspNetCore.HttpOverrides; -using Serilog; -using System.Reflection; -using System.Threading.RateLimiting; +using Models.Settings; using Refit; +using Serilog; +using StartupHelpers; +StartupExtensions.LoadDotEnvFile(); -// Load .env file if it exists (for local development) -DotNetEnv.Env.Load(); +const string ServiceName = "api"; +var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly()); try { - var builder = WebApplication.CreateBuilder(args); - var appVersion = - Assembly.GetExecutingAssembly() - .GetCustomAttribute()? - .InformationalVersion - ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString() - ?? "unknown"; + builder.ConfigureJsonSerilog(ServiceName, appVersion); + Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion); - 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()); - }); + builder.AddAzureKeyVaultIfConfigured(); - 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")); @@ -77,18 +29,19 @@ try builder.Services.Configure(builder.Configuration.GetSection("Captcha")); builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); - // Services builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); - // Refit client for CvMatcher API builder.Services.AddRefitClient() .ConfigureHttpClient((sp, client) => { var config = sp.GetRequiredService(); var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty; - if (!string.IsNullOrWhiteSpace(baseUrl)) client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + if (!string.IsNullOrWhiteSpace(baseUrl)) + { + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + } var key = config["CvMatcherApi:InternalApiKey"]; if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key")) @@ -97,299 +50,35 @@ try } }); - // Swagger - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(options => - { - // Include XML comments (enable in csproj) - var xmlFile = (Assembly.GetExecutingAssembly().GetName().Name ?? "Api") + ".xml"; - var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); - if (File.Exists(xmlPath)) options.IncludeXmlComments(xmlPath); - - // Enable annotations like [SwaggerOperation], [SwaggerResponse] - options.EnableAnnotations(); - }); - - // 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 - ); - }; - }); + builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "API"); + builder.Services.ConfigureCaddyForwardedHeaders(); + builder.Services.AddFrontendCorsFromConfiguration(builder.Configuration); + builder.Services.AddPublicApiRateLimiting(); var app = builder.Build(); - var logger = app.Services.GetRequiredService>(); - logger.LogInformation("API starting up..."); - logger.LogInformation("Environment: {Environment}", app.Environment.EnvironmentName); + app.LogStartupDiagnostics(ServiceName); - // 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.UseDefaultSerilogRequestLogging(includeProxyHeaders: true); + app.UseSwaggerInDevelopment("API", "API"); app.UseHttpsRedirection(); - app.UseAuthorization(); - app.UseRouting(); - app.UseCors("FrontendOnly"); - app.UseRateLimiter(); - app.MapControllers(); - logger.LogInformation("API startup complete. Listening for requests..."); - + Log.Information("{Service} startup complete. Listening for requests...", ServiceName); app.Run(); } catch (Exception ex) { - Log.Fatal(ex, "Application terminated unexpectedly"); + Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName); } finally { - Log.Information("Shutting down API..."); + Log.Information("Shutting down {Service}", ServiceName); Log.CloseAndFlush(); } - -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("==========================================================="); -} - -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); - } - } -} - -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); -} - -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}"; -} \ No newline at end of file diff --git a/api/api.csproj b/api/api.csproj index 44d0301..642cfe6 100644 --- a/api/api.csproj +++ b/api/api.csproj @@ -37,6 +37,7 @@ - + + diff --git a/cv-matcher-api/Dockerfile b/cv-matcher-api/Dockerfile index 13cb6d0..4b7a11a 100644 --- a/cv-matcher-api/Dockerfile +++ b/cv-matcher-api/Dockerfile @@ -1,15 +1,24 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +COPY cv-mapper-api/cv-mapper-api.csproj cv-mapper-api/ +COPY cv-matcher-api-models/cv-matcher-api-models.csproj cv-matcher-api-models/ +COPY startup-helpers/startup-helpers.csproj startup-helpers/startup-helpers/ + +RUN dotnet restore cv-mapper-api/api.csproj + +COPY cv-mapper-api/ cv-mapper-api/ +COPY cv-matcher-api-models/ cv-matcher-api-models/ +COPY startup-helpers/ startup-helpers/ + +RUN dotnet publish cv-mapper-api/cv-mapper-api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app EXPOSE 8080 +ENV ASPNETCORE_URLS=http://0.0.0.0:8080 -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src -COPY ["cv-matcher-api.csproj", "./"] -RUN dotnet restore "cv-matcher-api.csproj" -COPY . . -RUN dotnet publish "cv-matcher-api.csproj" -c Release -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "cv-matcher-api.dll"] + +ENTRYPOINT ["dotnet", "cv-mapper-api.dll"] \ No newline at end of file diff --git a/cv-matcher-api/Program.cs b/cv-matcher-api/Program.cs index d2d1d12..9bc51e0 100644 --- a/cv-matcher-api/Program.cs +++ b/cv-matcher-api/Program.cs @@ -1,74 +1,33 @@ -using Azure.Identity; -using Api.Data; -using Api.Services; -using Api.Services.Contracts; -using Microsoft.AspNetCore.Diagnostics; -using Serilog; using System.Reflection; -using Microsoft.EntityFrameworkCore; -using Refit; -using Api.Data.Repositories; -using Api.Data.Repositories.Contracts; -using Api.Clients.Api; -using Api.Clients.Api.Contracts; using Api.Clients.Ai; using Api.Clients.Ai.Contracts; +using Api.Clients.Api; +using Api.Clients.Api.Contracts; +using Api.Data; +using Api.Data.Repositories; +using Api.Data.Repositories.Contracts; +using Api.Services; +using Api.Services.Contracts; using CvMatcher.Models.Settings; +using Microsoft.EntityFrameworkCore; +using Refit; +using Serilog; using Shared.Models.Settings; +using StartupHelpers; -DotNetEnv.Env.Load(); +StartupExtensions.LoadDotEnvFile(); + +const string ServiceName = "cv-matcher-api"; +var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly()); 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("Service", "cv-matcher-api") - .Enrich.WithProperty("AppVersion", appVersion) - .WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()); - }); + builder.ConfigureJsonSerilog(ServiceName, appVersion); + Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion); - Log.Information("Starting {Service} version {AppVersion}", "cv-matcher-api", 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"); - } + builder.AddAzureKeyVaultIfConfigured(); builder.Services.Configure(builder.Configuration.GetSection("RagApi")); builder.Services.Configure(builder.Configuration.GetSection("InternalApi")); @@ -76,7 +35,6 @@ try builder.Services.Configure(builder.Configuration.GetSection("Matcher")); builder.Services.Configure(builder.Configuration.GetSection("Smtp")); - // Register Refit client for the external RAG API and a thin wrapper that implements IRagApiClient builder.Services.AddRefitClient() .ConfigureHttpClient((sp, c) => { @@ -98,28 +56,11 @@ try builder.Services.AddScoped(); builder.Services.AddControllers(); - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(options => - { - var xmlFile = (Assembly.GetExecutingAssembly().GetName().Name ?? "cv-matcher-api") + ".xml"; - var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); - if (File.Exists(xmlPath)) options.IncludeXmlComments(xmlPath); - options.EnableAnnotations(); - }); + builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName); 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); - } + app.LogStartupDiagnostics(ServiceName); using (var scope = app.Services.CreateScope()) { @@ -127,181 +68,30 @@ try await repository.InitializeAsync(CancellationToken.None); } - 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()); - }; - }); - - app.UseExceptionHandler(errorApp => - { - errorApp.Run(async context => - { - var feature = context.Features.Get(); - var logger = context.RequestServices.GetRequiredService>(); - if (feature?.Error is not null) - { - logger.LogError(feature.Error, "Unhandled exception in {Service}", "cv-matcher-api"); - } - - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error." }); - }); - }); - - app.Use(async (context, next) => - { - var settings = context.RequestServices.GetRequiredService>().Value; - if (settings.RequireApiKey) - { - var header = context.Request.Headers["X-Internal-Api-Key"].ToString(); - if (string.IsNullOrWhiteSpace(settings.ApiKey) || header != settings.ApiKey) - { - var logger = context.RequestServices.GetRequiredService>(); - logger.LogWarning("Rejected unauthorized internal API call. Path={Path}, RemoteIP={RemoteIP}", context.Request.Path, context.Connection.RemoteIpAddress?.ToString()); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - await context.Response.WriteAsJsonAsync(new { error = "Unauthorized internal API call." }); - return; - } - } - - await next(); - }); - - // Swagger (typically only in Development) - if (app.Environment.IsDevelopment()) - { - app.UseSwagger(); - app.UseSwaggerUI(options => - { - options.DocumentTitle = "cv-matcher-api"; - options.SwaggerEndpoint("/swagger/v1/swagger.json", "cv-matcher-api v1"); - options.RoutePrefix = "swagger"; - }); - } + app.UseDefaultSerilogRequestLogging(); + app.UseJsonExceptionHandler(ServiceName); + app.UseInternalApiKeyProtection(); + app.UseSwaggerInDevelopment(ServiceName, ServiceName); app.MapControllers(); - app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "cv-matcher-api", version = appVersion, timeUtc = DateTimeOffset.UtcNow })); + app.MapHealthEndpoint(ServiceName, appVersion); - - Log.Information("Running EfCore DbMigrations if any"); + Log.Information("Running EF Core migrations if any"); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } - Log.Information("{Service} startup complete", "cv-matcher-api"); + Log.Information("{Service} startup complete", ServiceName); app.Run(); } catch (Exception ex) { - Log.Fatal(ex, "cv-matcher-api terminated unexpectedly"); + Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName); } finally { - Log.Information("Shutting down cv-matcher-api"); + Log.Information("Shutting down {Service}", ServiceName); Log.CloseAndFlush(); } - -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("==========================================================="); -} - -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); - } - } -} - -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); -} - -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}"; -} \ No newline at end of file diff --git a/cv-matcher-api/cv-matcher-api.csproj b/cv-matcher-api/cv-matcher-api.csproj index 0df7f95..ded3a6a 100644 --- a/cv-matcher-api/cv-matcher-api.csproj +++ b/cv-matcher-api/cv-matcher-api.csproj @@ -1,4 +1,4 @@ - + net10.0 enable @@ -79,5 +79,6 @@ - + + diff --git a/rag-api/Dockerfile b/rag-api/Dockerfile index f1b69c0..1372a1e 100644 --- a/rag-api/Dockerfile +++ b/rag-api/Dockerfile @@ -1,15 +1,26 @@ -FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +COPY rag-api/rag-api.csproj rag-api/ +COPY rag-api-models/rag-api-models.csproj rag-api-models/ +COPY shared-models/shared-models.csproj shared-models/ +COPY startup-helpers/startup-helpers.csproj startup-helpers/startup-helpers/ + +RUN dotnet restore rag-api/api.csproj + +COPY rag-api/ rag-api/ +COPY rag-api-models/ rag-api-models/ +COPY shared-models/ shared-models/ +COPY startup-helpers/ startup-helpers/ + +RUN dotnet publish rag-api/rag-api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app EXPOSE 8080 +ENV ASPNETCORE_URLS=http://0.0.0.0:8080 -FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build -WORKDIR /src -COPY ["rag-api.csproj", "./"] -RUN dotnet restore "rag-api.csproj" -COPY . . -RUN dotnet publish "rag-api.csproj" -c Release -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "rag-api.dll"] + +ENTRYPOINT ["dotnet", "rag-api.dll"] \ No newline at end of file diff --git a/rag-api/Program.cs b/rag-api/Program.cs index 1eb9370..fd43a3e 100644 --- a/rag-api/Program.cs +++ b/rag-api/Program.cs @@ -1,71 +1,30 @@ -using Azure.Identity; -using Microsoft.AspNetCore.Diagnostics; +using System.Reflection; +using Api.Clients.Ai; +using Api.Clients.Ai.Contracts; using Api.Data; +using Api.Data.Repositories; +using Api.Data.Repositories.Contracts; using Api.Services; using Api.Services.Contracts; -using Serilog; -using System.Reflection; using Microsoft.EntityFrameworkCore; using Rag.Models.Settings; -using Api.Data.Repositories.Contracts; -using Api.Data.Repositories; -using Api.Clients.Ai.Contracts; -using Api.Clients.Ai; +using Serilog; using Shared.Models.Settings; +using StartupHelpers; -DotNetEnv.Env.Load(); +StartupExtensions.LoadDotEnvFile(); + +const string ServiceName = "rag-api"; +var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly()); 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("Service", "rag-api") - .Enrich.WithProperty("AppVersion", appVersion) - .WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()); - }); + builder.ConfigureJsonSerilog(ServiceName, appVersion); + Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion); - Log.Information("Starting {Service} version {AppVersion}", "rag-api", 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"); - } + builder.AddAzureKeyVaultIfConfigured(); builder.Services.Configure(builder.Configuration.GetSection("Rag")); builder.Services.Configure(builder.Configuration.GetSection("Ai")); @@ -84,28 +43,11 @@ try builder.Services.AddScoped(); builder.Services.AddControllers(); - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(options => - { - var xmlFile = (Assembly.GetExecutingAssembly().GetName().Name ?? "rag-api") + ".xml"; - var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); - if (File.Exists(xmlPath)) options.IncludeXmlComments(xmlPath); - options.EnableAnnotations(); - }); + builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName); 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); - } + app.LogStartupDiagnostics(ServiceName); using (var scope = app.Services.CreateScope()) { @@ -113,180 +55,30 @@ try await repository.InitializeAsync(CancellationToken.None); } - 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()); - }; - }); - - app.UseExceptionHandler(errorApp => - { - errorApp.Run(async context => - { - var feature = context.Features.Get(); - var logger = context.RequestServices.GetRequiredService>(); - if (feature?.Error is not null) - { - logger.LogError(feature.Error, "Unhandled exception in {Service}", "rag-api"); - } - - context.Response.StatusCode = StatusCodes.Status500InternalServerError; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error." }); - }); - }); - - app.Use(async (context, next) => - { - var settings = context.RequestServices.GetRequiredService>().Value; - if (settings.RequireApiKey) - { - var header = context.Request.Headers["X-Internal-Api-Key"].ToString(); - if (string.IsNullOrWhiteSpace(settings.ApiKey) || header != settings.ApiKey) - { - var logger = context.RequestServices.GetRequiredService>(); - logger.LogWarning("Rejected unauthorized internal API call. Path={Path}, RemoteIP={RemoteIP}", context.Request.Path, context.Connection.RemoteIpAddress?.ToString()); - context.Response.StatusCode = StatusCodes.Status401Unauthorized; - await context.Response.WriteAsJsonAsync(new { error = "Unauthorized internal API call." }); - return; - } - } - - await next(); - }); - - // Swagger (typically only in Development) - if (app.Environment.IsDevelopment()) - { - app.UseSwagger(); - app.UseSwaggerUI(options => - { - options.DocumentTitle = "rag-api"; - options.SwaggerEndpoint("/swagger/v1/swagger.json", "rag-api v1"); - options.RoutePrefix = "swagger"; - }); - } + app.UseDefaultSerilogRequestLogging(); + app.UseJsonExceptionHandler(ServiceName); + app.UseInternalApiKeyProtection(); + app.UseSwaggerInDevelopment(ServiceName, ServiceName); app.MapControllers(); - app.MapGet("/health", () => Results.Ok(new { status = "ok", service = "rag-api", version = appVersion, timeUtc = DateTimeOffset.UtcNow })); + app.MapHealthEndpoint(ServiceName, appVersion); - Log.Information("Running EfCore DbMigrations if any"); + Log.Information("Running EF Core migrations if any"); using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } - Log.Information("{Service} startup complete", "rag-api"); + Log.Information("{Service} startup complete", ServiceName); app.Run(); } catch (Exception ex) { - Log.Fatal(ex, "rag-api terminated unexpectedly"); + Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName); } finally { - Log.Information("Shutting down rag-api"); + Log.Information("Shutting down {Service}", ServiceName); Log.CloseAndFlush(); } - -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("==========================================================="); -} - -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); - } - } -} - -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); -} - -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}"; -} diff --git a/rag-api/rag-api.csproj b/rag-api/rag-api.csproj index fd54438..8fc583f 100644 --- a/rag-api/rag-api.csproj +++ b/rag-api/rag-api.csproj @@ -1,4 +1,4 @@ - + net10.0 enable @@ -79,5 +79,6 @@ - + + diff --git a/startup-helpers/EnvironmentDiagnostics.cs b/startup-helpers/EnvironmentDiagnostics.cs new file mode 100644 index 0000000..acba34f --- /dev/null +++ b/startup-helpers/EnvironmentDiagnostics.cs @@ -0,0 +1,92 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace StartupHelpers; + +public static class EnvironmentDiagnostics +{ + public static void LogEnvironmentSettings(ILogger logger, IConfiguration configuration, IWebHostEnvironment environment) + { + logger.LogInformation("==================== ENVIRONMENT SETTINGS ===================="); + 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); + + 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; + + if (IsSensitiveKey(key)) + { + value = MaskValueWithLastChars(value); + } + + sortedEnvVars[key] = value; + } + + foreach (var kvp in sortedEnvVars) + { + logger.LogInformation(" {Key} = {Value}", kvp.Key, kvp.Value); + } + + logger.LogInformation("-------------- Configuration Settings --------------"); + LogConfigurationRecursive(logger, configuration.GetChildren(), string.Empty); + logger.LogInformation("==========================================================="); + } + + private static void LogConfigurationRecursive(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; + if (IsSensitiveKey(key)) + { + value = MaskValueWithLastChars(value); + } + + logger.LogInformation(" {Key} = {Value}", key, value); + } + + if (section.GetChildren().Any()) + { + LogConfigurationRecursive(logger, section.GetChildren(), key); + } + } + } + + private 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); + } + + private static string MaskValueWithLastChars(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "***NOT SET***"; + } + + if (value.Length <= 4) + { + return "***MASKED***"; + } + + var lastChars = value[^4..]; + return $"***MASKED***...{lastChars}"; + } +} diff --git a/startup-helpers/RateLimitingExtensions.cs b/startup-helpers/RateLimitingExtensions.cs new file mode 100644 index 0000000..7524298 --- /dev/null +++ b/startup-helpers/RateLimitingExtensions.cs @@ -0,0 +1,74 @@ +using System.Threading.RateLimiting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace StartupHelpers; + +public static class RateLimitingExtensions +{ + public static void AddPublicApiRateLimiting(this IServiceCollection services) + { + services.AddRateLimiter(options => + { + options.GlobalLimiter = PartitionedRateLimiter.Create(httpContext => + { + var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: ip, + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 120, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + }); + }); + + options.AddPolicy("contact", httpContext => + { + var ip = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter( + partitionKey: ip, + factory: _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + AutoReplenishment = true + }); + }); + + 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() + .CreateLogger("RateLimiting"); + + 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); + + context.HttpContext.Response.ContentType = "application/json"; + await context.HttpContext.Response.WriteAsync("""{"error":"Too many requests. Try again later."}""", ct); + }; + }); + } +} diff --git a/startup-helpers/StartupExtensions.cs b/startup-helpers/StartupExtensions.cs new file mode 100644 index 0000000..7bda41c --- /dev/null +++ b/startup-helpers/StartupExtensions.cs @@ -0,0 +1,229 @@ +using System.Reflection; +using Azure.Identity; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog; +using Swashbuckle.AspNetCore.SwaggerGen; +using Swashbuckle.AspNetCore.Annotations; + +namespace StartupHelpers; + +public static class StartupExtensions +{ + public static void LoadDotEnvFile() + { + DotNetEnv.Env.Load(); + } + + public static string GetApplicationVersion(Assembly assembly) + { + return assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + } + + public static void ConfigureJsonSerilog(this WebApplicationBuilder builder, string serviceName, string appVersion) + { + builder.Host.UseSerilog((context, services, configuration) => + { + configuration + .ReadFrom.Configuration(context.Configuration) + .ReadFrom.Services(services) + .Enrich.FromLogContext() + .Enrich.WithMachineName() + .Enrich.WithEnvironmentName() + .Enrich.WithProperty("Service", serviceName) + .Enrich.WithProperty("AppVersion", appVersion) + .WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()); + }); + } + + public static void AddAzureKeyVaultIfConfigured(this WebApplicationBuilder builder) + { + var keyVaultUri = builder.Configuration["KeyVault:VaultUri"]; + var keyVaultEnabled = builder.Configuration.GetValue("KeyVault:Enabled"); + + if (!keyVaultEnabled || string.IsNullOrWhiteSpace(keyVaultUri)) + { + Log.Information("Azure Key Vault is disabled or not configured"); + return; + } + + 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."); + } + } + + public static void AddSwaggerWithXmlComments(this IServiceCollection services, Assembly assembly, string fallbackName, bool enableAnnotations = true) + { + services.AddEndpointsApiExplorer(); + services.AddSwaggerGen(options => + { + var xmlFile = (assembly.GetName().Name ?? fallbackName) + ".xml"; + var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile); + if (File.Exists(xmlPath)) + { + options.IncludeXmlComments(xmlPath); + } + + if (enableAnnotations) + { + options.EnableAnnotations(); + } + }); + } + + public static void ConfigureCaddyForwardedHeaders(this IServiceCollection services) + { + services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.ForwardedForHeaderName = "X-Real-IP"; + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); + options.ForwardLimit = 1; + }); + } + + public static void AddFrontendCorsFromConfiguration(this IServiceCollection services, IConfiguration configuration, string policyName = "FrontendOnly") + { + var allowedOrigins = configuration.GetSection("Cors:AllowedOrigins").Get() ?? Array.Empty(); + + services.AddCors(options => + { + options.AddPolicy(policyName, policy => + { + if (allowedOrigins.Length > 0) + { + policy.WithOrigins(allowedOrigins) + .WithMethods("POST", "OPTIONS") + .WithHeaders("Content-Type") + .SetPreflightMaxAge(TimeSpan.FromHours(1)); + } + }); + }); + } + + public static void LogStartupDiagnostics(this WebApplication app, string serviceName) + { + var logger = app.Services.GetRequiredService().CreateLogger(serviceName); + logger.LogInformation("{Service} starting up...", serviceName); + logger.LogInformation("Environment: {Environment}", app.Environment.EnvironmentName); + + var logEnvironmentOnStartup = app.Configuration.GetValue("LogEnvironmentOnStartup", defaultValue: true); + if (logEnvironmentOnStartup) + { + EnvironmentDiagnostics.LogEnvironmentSettings(logger, app.Configuration, app.Environment); + } + } + + public static void UseDefaultSerilogRequestLogging(this WebApplication app, bool includeProxyHeaders = false) + { + 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()); + + if (includeProxyHeaders) + { + diagnosticContext.Set("XRealIP", httpContext.Request.Headers["X-Real-IP"].ToString()); + diagnosticContext.Set("XForwardedFor", httpContext.Request.Headers["X-Forwarded-For"].ToString()); + } + }; + }); + } + + public static void UseJsonExceptionHandler(this WebApplication app, string serviceName) + { + app.UseExceptionHandler(errorApp => + { + errorApp.Run(async context => + { + var feature = context.Features.Get(); + var logger = context.RequestServices.GetRequiredService().CreateLogger(serviceName); + if (feature?.Error is not null) + { + logger.LogError(feature.Error, "Unhandled exception in {Service}", serviceName); + } + + context.Response.StatusCode = StatusCodes.Status500InternalServerError; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error." }); + }); + }); + } + + public static void UseInternalApiKeyProtection(this WebApplication app, string sectionName = "InternalApi") + { + app.Use(async (context, next) => + { + var requireApiKey = context.RequestServices.GetRequiredService().GetValue($"{sectionName}:RequireApiKey"); + if (requireApiKey) + { + var configuredApiKey = context.RequestServices.GetRequiredService()[$"{sectionName}:ApiKey"]; + var headerApiKey = context.Request.Headers["X-Internal-Api-Key"].ToString(); + + if (string.IsNullOrWhiteSpace(configuredApiKey) || headerApiKey != configuredApiKey) + { + var logger = context.RequestServices.GetRequiredService().CreateLogger("InternalApiKey"); + logger.LogWarning( + "Rejected unauthorized internal API call. Path={Path}, RemoteIP={RemoteIP}", + context.Request.Path, + context.Connection.RemoteIpAddress?.ToString()); + + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new { error = "Unauthorized internal API call." }); + return; + } + } + + await next(); + }); + } + + public static void UseSwaggerInDevelopment(this WebApplication app, string documentTitle, string endpointName) + { + if (!app.Environment.IsDevelopment()) + { + return; + } + + app.UseSwagger(); + app.UseSwaggerUI(options => + { + options.DocumentTitle = documentTitle; + options.SwaggerEndpoint("/swagger/v1/swagger.json", $"{endpointName} v1"); + options.RoutePrefix = "swagger"; + }); + } + + public static void MapHealthEndpoint(this WebApplication app, string serviceName, string appVersion) + { + app.MapGet("/health", () => Results.Ok(new + { + status = "ok", + service = serviceName, + version = appVersion, + timeUtc = DateTimeOffset.UtcNow + })); + } +} diff --git a/startup-helpers/startup-helpers.csproj b/startup-helpers/startup-helpers.csproj index c08327b..0937fa7 100644 --- a/startup-helpers/startup-helpers.csproj +++ b/startup-helpers/startup-helpers.csproj @@ -1,10 +1,24 @@ - + net10.0 - startup_helpers + StartupHelpers enable enable + + + + + + + + + + + + + +