using System.Text; using System.Threading.RateLimiting; using DoliMiddlewareApi.Exceptions; using DoliMiddlewareApi.Services; using DoliMiddlewareApi.Services.Auth; using DoliMiddlewareApi.Services.Clients; using DoliMiddlewareApi.Services.Notifications; using DoliMiddlewareApi.Services.VeriFactu; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics; using Microsoft.Extensions.Caching.Memory; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi; // ========================================= // PROGRAM.CS - CONFIGURACIÓN Y DEPENDENCIAS // ========================================= var builder = WebApplication.CreateBuilder(args); if (string.IsNullOrEmpty(builder.Configuration["Jwt:Secret"])) throw new InvalidOperationException( "JWT Secret is required. Set 'Jwt__Secret' environment variable, 'Jwt:Secret' in appsettings, or use 'dotnet user-secrets set Jwt:Secret '."); // ========================================= // 1. CONFIGURACIÓN DE SERVICIOS ASP.NET CORE // ========================================= // Controllers + JSON (camelCase + enums as strings) builder.Services.AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; }); // JWT Standard (ASP.NET auto-validation) builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "DoliMiddleware", ValidAudience = builder.Configuration["Jwt:Audience"] ?? "DoliClients", IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Secret"]!)) }; }); builder.Services.AddAuthorization(); // CORS: Permitir cualquier origen (demo) builder.Services.AddCors(options => { options.AddPolicy("AllowVueApp", policy => { policy.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader(); }); }); // Swagger/OpenAPI builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new OpenApiInfo { Title = "DoliMiddlewareApi", Version = "1.0", Description = "BFF API for Dolibarr ERP - Middleware between frontend and Dolibarr" }); options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { Scheme = "bearer", BearerFormat = "JWT", Name = "Authorization", In = ParameterLocation.Header, Type = SecuritySchemeType.Http, Description = "Enter your JWT Bearer token (obtained from POST /api/Auth/login)" }); options.AddSecurityRequirement(document => new OpenApiSecurityRequirement { [new OpenApiSecuritySchemeReference("Bearer", document)] = [] }); }); // Health checks (verifica que el BFF responde) builder.Services.AddHealthChecks(); // Rate limiting: proteger login contra brute force builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.GlobalLimiter = PartitionedRateLimiter.Create(context => { var path = context.Request.Path.Value; if (path != null && path.StartsWith("/api/Auth/login", StringComparison.OrdinalIgnoreCase)) { return RateLimitPartition.GetSlidingWindowLimiter("login", _ => new SlidingWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), SegmentsPerWindow = 2, QueueProcessingOrder = QueueProcessingOrder.OldestFirst, QueueLimit = 0 }); } return RateLimitPartition.GetNoLimiter("default"); }); }); // 2. DOLIBARR HTTP CLIENT // ========================================= // HttpClient named para Dolibarr (configurado solo con BaseAddress) // DolibarrApiClient se crea manual porque necesita HttpClient + TokenCacheService // // POR QUÉ NO usar AddHttpClient() directamente? // -------------------------------------------------------- // HttpClient es COMPARTIDO entre todos los usuarios // Si configuramos el token ahí, el Usuario A usaría el token del Usuario B // Solución: TokenCacheService obtiene el token del USUARIO ACTUAL (del JWT) // factory.CreateClient("Dolibarr") devuelve el MISMO HttpClient reusado // No se crea un cliente por request, los headers son por request (thread-safe) // ========================================= builder.Services.AddHttpClient("Dolibarr", client => { var baseUrl = builder.Configuration["Dolibarr:ApiUrl"]!; if (!baseUrl.EndsWith('/')) baseUrl += '/'; client.BaseAddress = new Uri(baseUrl); }); builder.Services.AddHttpClient("VeriFactu", client => { var baseUrl = builder.Configuration["VeriFactu:ApiUrl"] ?? "http://localhost:6789/"; if (!baseUrl.EndsWith('/')) baseUrl += '/'; client.BaseAddress = new Uri(baseUrl); client.Timeout = TimeSpan.FromSeconds(30); }); builder.Services.AddScoped(sp => { var factory = sp.GetRequiredService(); var client = factory.CreateClient("Dolibarr"); var tokenCacheService = sp.GetRequiredService(); var config = sp.GetRequiredService(); return new DolibarrApiClient(client, tokenCacheService, config); }); // ========================================= // 3. REGISTRO DE SERVICIOS (DEPENDENCIAS) // ========================================= // Servicio de negocio (facturas) builder.Services.AddScoped(); // Servicio de negocio (clientes) builder.Services.AddScoped(); // Servicio de negocio (contactos) builder.Services.AddScoped(); // Servicio de negocio (documentos) builder.Services.AddScoped(); // Servicio de negocio (setup/diccionarios) builder.Services.AddScoped(); // Cliente de VeriFactu MidAPI builder.Services.AddScoped(sp => { var factory = sp.GetRequiredService(); return new VeriFactuApiClient(factory.CreateClient("VeriFactu")); }); // Servicio de negocio (banco) builder.Services.AddScoped(); // Servicio de facturas de proveedores builder.Services.AddScoped(); // Servicio de aplicación (orquesta login + cache) builder.Services.AddScoped(); // Servicio de autenticación con Dolibarr builder.Services.AddScoped(); // Servicio de cache de tokens de Dolibarr builder.Services.AddScoped(); // Generador de JWT - Singleton porque es stateless builder.Services.AddSingleton(); // Cache en memoria para tokens (IMemoryCache) builder.Services.AddMemoryCache(); // HttpContext accessor para acceder a User.Claims en servicios builder.Services.AddHttpContextAccessor(); builder.Services.AddHttpClient("Webhook", c => c.Timeout = TimeSpan.FromSeconds(8)); // ========================================= // NOTIFICACIONES — Webhook (Teams / Slack) // Configura Notifications:WebhookUrl en appsettings o variable de entorno // Si no está configurado, las notificaciones se ignoran silenciosamente // WebhookSettings es singleton para permitir actualización en tiempo de ejecución // ========================================= builder.Services.AddSingleton(); builder.Services.AddScoped(); var app = builder.Build(); // ========================================= // 4. GLOBAL EXCEPTION HANDLER // ========================================= app.UseExceptionHandler(errorApp => { errorApp.Run(async context => { var exceptionHandler = context.Features.Get(); var exception = exceptionHandler?.Error; var logger = context.RequestServices.GetRequiredService>(); var env = context.RequestServices.GetRequiredService(); // Clasificar el tipo de error var (statusCode, title, detail, isExpectedError) = exception switch { NotFoundException notFound => (StatusCodes.Status404NotFound, "Not Found", notFound.Message, true), UnauthorizedException => (StatusCodes.Status401Unauthorized, "Unauthorized", "Invalid API credentials", true), ForbiddenException forbidden => (StatusCodes.Status403Forbidden, "Forbidden", forbidden.Message, true), BadRequestException badRequest => (StatusCodes.Status400BadRequest, "Bad Request", badRequest.Message, true), ApiException apiEx => (StatusCodes.Status500InternalServerError, "External Service Error", env.IsDevelopment() ? apiEx.Message : "The external service is temporarily unavailable", false), _ => (StatusCodes.Status500InternalServerError, "Internal Server Error", env.IsDevelopment() ? exception?.Message ?? "An unexpected error occurred" : "An unexpected error occurred. Please try again later.", false) }; // Loguear siempre (crítico para debugging) if (isExpectedError) { logger.LogWarning(exception, "Expected error: {ExceptionType} | Path: {Path} | Message: {Message}", exception?.GetType().Name, context.Request.Path, exception?.Message); } else { logger.LogError(exception, "UNHANDLED EXCEPTION: {ExceptionType} | Path: {Path} | Message: {Message} | StackTrace: {StackTrace}", exception?.GetType().Name, context.Request.Path, exception?.Message, exception?.StackTrace); } // Construir respuesta ProblemDetails (RFC 7807) context.Response.StatusCode = statusCode; context.Response.ContentType = "application/problem+json"; var problemDetails = new Microsoft.AspNetCore.Mvc.ProblemDetails { Status = statusCode, Title = title, Detail = detail, Instance = context.Request.Path }; // En desarrollo añadir info extra para debugging if (env.IsDevelopment() && !isExpectedError) { problemDetails.Extensions["exceptionType"] = exception?.GetType().Name; problemDetails.Extensions["stackTrace"] = exception?.StackTrace; if (exception?.InnerException != null) { problemDetails.Extensions["innerException"] = exception.InnerException.Message; problemDetails.Extensions["innerStackTrace"] = exception.InnerException.StackTrace; } } await context.Response.WriteAsJsonAsync(problemDetails); }); }); // ========================================= // 5. PIPELINE ASP.NET CORE // ========================================= app.UseSwagger(); app.UseSwaggerUI(options => { options.SwaggerEndpoint("/swagger/v1/swagger.json", "DoliMiddlewareApi v1"); }); // HTTPS redirect solo si ForceHttps=true (nunca en Docker) if (bool.TryParse(builder.Configuration["Dolibarr:ForceHttps"], out var forceHttps) && forceHttps) app.UseHttpsRedirection(); app.UseCors("AllowVueApp"); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); app.MapHealthChecks("/health"); app.Run();