using System.Text; using FluentValidation; using FluentValidation.AspNetCore; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using SchotzIadKmTracker.Api.Data; using SchotzIadKmTracker.Api.Models.Entities; using SchotzIadKmTracker.Api.Validators; using SchotzIadKmTracker.Api.Configuration; using SchotzIadKmTracker.Api.Repositories; using SchotzIadKmTracker.Api.Services; // Licence QuestPDF (usage communautaire, gratuit) — requise avant toute génération de PDF. QuestPDF.Settings.License = QuestPDF.Infrastructure.LicenseType.Community; var builder = WebApplication.CreateBuilder(args); // --- Options de configuration liées depuis appsettings.json --- builder.Services.Configure( builder.Configuration.GetSection(BANOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(ORSOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(JwtOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(AuthOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(SmtpOptions.SectionName)); // --- Client typé BAN (Base Adresses Nationale) --- // BaseAddress et Timeout proviennent de BANOptions (appsettings.json). var banOptions = builder.Configuration .GetSection(BANOptions.SectionName) .Get() ?? new BANOptions(); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri(banOptions.BaseUrl); client.Timeout = TimeSpan.FromSeconds(banOptions.TimeoutSeconds); }); // --- Client typé ORS (OpenRouteService) --- // BaseAddress, Timeout et en-tête de clé d'API proviennent de ORSOptions (appsettings.json). var orsOptions = builder.Configuration .GetSection(ORSOptions.SectionName) .Get() ?? new ORSOptions(); builder.Services.AddHttpClient(client => { if (!string.IsNullOrWhiteSpace(orsOptions.BaseUrl)) { // Le slash final garantit la résolution correcte des chemins relatifs. var baseUrl = orsOptions.BaseUrl.EndsWith('/') ? orsOptions.BaseUrl : orsOptions.BaseUrl + "/"; client.BaseAddress = new Uri(baseUrl); } client.Timeout = TimeSpan.FromSeconds(orsOptions.TimeoutSeconds); if (!string.IsNullOrWhiteSpace(orsOptions.ApiKey)) { // ORS attend la clé brute dans l'en-tête Authorization (sans schéma « Bearer »). // Les clés ORS (JWT) contiennent des caractères refusés par la validation stricte // de DefaultRequestHeaders.Add ; on utilise TryAddWithoutValidation pour l'insérer // telle quelle et éviter une FormatException à la création du client. client.DefaultRequestHeaders.TryAddWithoutValidation("Authorization", orsOptions.ApiKey); } }); // --- Calculateur de distance (orchestration BAN + ORS + fallback Haversine) --- builder.Services.AddScoped(); // --- Cache des adresses géocodées --- builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // --- Services de base --- // Autorisation GLOBALE par défaut : toutes les routes MVC exigent un utilisateur authentifié. // AuthController porte [AllowAnonymous] ; /health (endpoint minimal) n'est pas couvert par les filtres MVC. builder.Services.AddControllers(options => { var policy = new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder() .RequireAuthenticatedUser() .Build(); options.Filters.Add(new Microsoft.AspNetCore.Mvc.Authorization.AuthorizeFilter(policy)); }); // --- Health checks (endpoint GET /health, utilisé par Docker Compose) --- builder.Services.AddHealthChecks(); // --- Validation des requêtes (FluentValidation) --- // Enregistre tous les validateurs de l'assembly et active la validation automatique côté MVC. builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddFluentValidationAutoValidation(); // --- Base de données (EF Core + SQLite) --- // La chaîne de connexion provient de la configuration ("ConnectionStrings:Default"). var connectionString = builder.Configuration.GetConnectionString("Default") ?? "Data Source=trajets.db"; builder.Services.AddDbContext(options => options.UseSqlite(connectionString)); // --- Hachage des mots de passe (ASP.NET Core Identity PasswordHasher) --- builder.Services.AddSingleton, PasswordHasher>(); // --- Repositories & services métier --- builder.Services.AddScoped(); // --- Sélection de l'implémentation Email selon la présence de config SMTP --- var smtpOptions = builder.Configuration.GetSection(SmtpOptions.SectionName).Get() ?? new SmtpOptions(); if (string.IsNullOrWhiteSpace(smtpOptions.Host)) builder.Services.AddScoped(); // repli journalisation (Exigence 6.5) else builder.Services.AddScoped(); // MailKit (Exigence 6.4) builder.Services.AddScoped(); // --- Authentification JWT Bearer --- var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get() ?? new JwtOptions(); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, // rejette les jetons expirés (Exigence 5.2) ValidateIssuerSigningKey = true, // rejette les signatures invalides (Exigence 5.3) ValidIssuer = jwtOptions.Issuer, ValidAudience = jwtOptions.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtOptions.SecretKey)), ClockSkew = TimeSpan.FromSeconds(30) // tolérance d'horloge réduite }; // Un jeton peut être cryptographiquement valide (bonne signature, non expiré) alors // que le compte n'existe plus (ex. base réinitialisée avec la même clé JWT). On vérifie // donc l'EXISTENCE du compte référencé par le claim `sub` à chaque requête : sinon le // jeton est rejeté (401), ce qui force la reconnexion / l'inscription côté frontend. options.Events = new JwtBearerEvents { OnTokenValidated = async context => { // ASP.NET Core remappe par défaut le claim « sub » vers ClaimTypes.NameIdentifier. // On cherche donc l'identifiant sous les deux noms possibles. var principal = context.Principal; var sub = principal?.FindFirst(System.IdentityModel.Tokens.Jwt.JwtRegisteredClaimNames.Sub)?.Value ?? principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; if (!int.TryParse(sub, out var compteId)) { context.Fail("Jeton invalide : identifiant de compte manquant."); return; } var db = context.HttpContext.RequestServices.GetRequiredService(); var compteExiste = await db.Comptes.AsNoTracking() .AnyAsync(c => c.Id == compteId, context.HttpContext.RequestAborted); if (!compteExiste) { context.Fail("Compte introuvable : jeton non valide."); } } }; }); 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(); // Exploration des endpoints + Swagger / OpenAPI builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(options => { options.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo { Title = "SchotzIAD KM Tracker API", Version = "v1", Description = "API de saisie, calcul et consultation des trajets kilométriques." }); }); // ProblemDetails : réponses d'erreur normalisées (RFC 7807) builder.Services.AddProblemDetails(); // CORS pour le frontend Angular. // Les origines autorisées sont configurables via appsettings ("Cors:AllowedOrigins"). const string CorsPolicyName = "AngularFrontend"; var allowedOrigins = builder.Configuration .GetSection("Cors:AllowedOrigins") .Get() ?? new[] { "http://localhost:4200" }; builder.Services.AddCors(options => { options.AddPolicy(CorsPolicyName, policy => { policy.WithOrigins(allowedOrigins) .AllowAnyHeader() .AllowAnyMethod(); }); }); var app = builder.Build(); // --- Pipeline HTTP --- // Gestion centralisée des exceptions -> ProblemDetails app.UseExceptionHandler(); app.UseStatusCodePages(); if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } // Pas de redirection HTTPS côté application : le conteneur écoute en HTTP et la // terminaison TLS est assurée par le reverse-proxy (Caddy/nginx) placé devant. // Activer UseHttpsRedirection ici provoquerait des redirections parasites derrière le proxy. app.UseCors(CorsPolicyName); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); // Endpoint de santé — utilisé par le healthcheck Docker Compose (depends_on: service_healthy). app.MapHealthChecks("/health"); // --- Application automatique des migrations EF Core au démarrage --- // Au premier lancement, la base (ex. /data/trajets.db sur le volume monté) est créée ; // aux lancements suivants, les migrations en attente sont appliquées sans intervention manuelle. using (var scope = app.Services.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); await dbContext.Database.MigrateAsync(); } app.Run(); // Exposé pour les tests d'intégration (WebApplicationFactory). public partial class Program { }