Les HttpClient vers l'API BAN et ORS réutilisaient des connexions TLS éventées par le pool .NET, provoquant des blocages jusqu'au timeout front de 10s (SocketException Operation canceled). Configuration d'un SocketsHttpHandler dédié avec PooledConnectionLifetime (2 min), PooledConnectionIdleTimeout (30s) et ConnectTimeout (5s). Resserre aussi le catch de ORSClient : une annulation provenant de l'appelant (CancellationToken du contrôleur) est désormais propagée telle quelle, au lieu d'être maquillée en timeout ORS.
289 lines
13 KiB
C#
289 lines
13 KiB
C#
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<BANOptions>(
|
|
builder.Configuration.GetSection(BANOptions.SectionName));
|
|
builder.Services.Configure<ORSOptions>(
|
|
builder.Configuration.GetSection(ORSOptions.SectionName));
|
|
builder.Services.Configure<JwtOptions>(
|
|
builder.Configuration.GetSection(JwtOptions.SectionName));
|
|
builder.Services.Configure<AuthOptions>(
|
|
builder.Configuration.GetSection(AuthOptions.SectionName));
|
|
builder.Services.Configure<SmtpOptions>(
|
|
builder.Configuration.GetSection(SmtpOptions.SectionName));
|
|
|
|
// --- Client typé BAN (Base Adresses Nationale) ---
|
|
// BaseAddress et Timeout proviennent de BANOptions (appsettings.json).
|
|
// Fabrique le handler HTTP des clients sortants (BAN, ORS). Le recyclage des
|
|
// connexions du pool évite les blocages intermittents dus à une connexion TLS
|
|
// gardée ouverte mais fermée côté serveur/NAT, que .NET tentait de réutiliser :
|
|
// - PooledConnectionLifetime : durée de vie max d'une connexion avant recyclage ;
|
|
// - PooledConnectionIdleTimeout : fermeture des connexions inactives (avant qu'elles
|
|
// ne deviennent « éventées ») ;
|
|
// - ConnectTimeout : échec rapide si l'ouverture de connexion traîne (le fallback
|
|
// Haversine prend alors le relais proprement au lieu de pendre).
|
|
static SocketsHttpHandler CreerHandlerHttpSortant() => new()
|
|
{
|
|
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
|
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(30),
|
|
ConnectTimeout = TimeSpan.FromSeconds(5)
|
|
};
|
|
|
|
var banOptions = builder.Configuration
|
|
.GetSection(BANOptions.SectionName)
|
|
.Get<BANOptions>() ?? new BANOptions();
|
|
|
|
builder.Services.AddHttpClient<IBANClient, BANClient>(client =>
|
|
{
|
|
client.BaseAddress = new Uri(banOptions.BaseUrl);
|
|
client.Timeout = TimeSpan.FromSeconds(banOptions.TimeoutSeconds);
|
|
})
|
|
// Recyclage des connexions du pool pour éviter de réutiliser une connexion TLS
|
|
// « éventée » (fermée côté serveur/NAT), qui provoquait des blocages intermittents
|
|
// jusqu'au timeout. Voir aussi le client ORS ci-dessous.
|
|
.ConfigurePrimaryHttpMessageHandler(() => CreerHandlerHttpSortant());
|
|
|
|
// --- 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<ORSOptions>() ?? new ORSOptions();
|
|
|
|
builder.Services.AddHttpClient<IORSClient, ORSClient>(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);
|
|
}
|
|
})
|
|
// Même recyclage des connexions que pour BAN (évite les blocages intermittents sur
|
|
// une connexion HTTPS réutilisée alors qu'elle a été fermée côté serveur).
|
|
.ConfigurePrimaryHttpMessageHandler(() => CreerHandlerHttpSortant());
|
|
|
|
// --- Calculateur de distance (orchestration BAN + ORS + fallback Haversine) ---
|
|
builder.Services.AddScoped<IDistanceCalculator, DistanceCalculatorService>();
|
|
|
|
// --- Cache des adresses géocodées ---
|
|
builder.Services.AddScoped<IAdresseCacheRepository, AdresseCacheRepository>();
|
|
builder.Services.AddScoped<IAdresseCacheService, AdresseCacheService>();
|
|
builder.Services.AddScoped<ISpecDocumentService, SpecDocumentService>();
|
|
|
|
// --- 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<CreateTrajetDtoValidator>();
|
|
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<AppDbContext>(options =>
|
|
options.UseSqlite(connectionString));
|
|
|
|
// --- Hachage des mots de passe (ASP.NET Core Identity PasswordHasher) ---
|
|
builder.Services.AddSingleton<IPasswordHasher<Compte>, PasswordHasher<Compte>>();
|
|
|
|
// --- Repositories & services métier ---
|
|
builder.Services.AddScoped<IJwtService, JwtService>();
|
|
|
|
// --- Sélection de l'implémentation Email selon la présence de config SMTP ---
|
|
var smtpOptions = builder.Configuration.GetSection(SmtpOptions.SectionName).Get<SmtpOptions>() ?? new SmtpOptions();
|
|
if (string.IsNullOrWhiteSpace(smtpOptions.Host))
|
|
builder.Services.AddScoped<IEmailService, LoggingEmailService>(); // repli journalisation (Exigence 6.5)
|
|
else
|
|
builder.Services.AddScoped<IEmailService, SmtpEmailService>(); // MailKit (Exigence 6.4)
|
|
|
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
|
|
|
// --- Authentification JWT Bearer ---
|
|
var jwtOptions = builder.Configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? 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<AppDbContext>();
|
|
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<SchotzIadKmTracker.Api.Repositories.IVehiculeRepository,
|
|
SchotzIadKmTracker.Api.Repositories.VehiculeRepository>();
|
|
builder.Services.AddScoped<IVehiculeService, VehiculeService>();
|
|
builder.Services.AddScoped<IProfilService, ProfilService>();
|
|
builder.Services.AddScoped<IAdresseNommeeService, AdresseNommeeService>();
|
|
builder.Services.AddScoped<IBaremeService, BaremeService>();
|
|
builder.Services.AddScoped<IPrecisionDistanceService, PrecisionDistanceService>();
|
|
builder.Services.AddScoped<IMontantRegleService, MontantRegleService>();
|
|
builder.Services.AddScoped<IRemboursementService, RemboursementService>();
|
|
builder.Services.AddScoped<ITotauxService, TotauxService>();
|
|
builder.Services.AddScoped<ITrajetRepository, TrajetRepository>();
|
|
builder.Services.AddScoped<ITrajetService, TrajetService>();
|
|
builder.Services.AddScoped<IResumeService, ResumeService>();
|
|
builder.Services.AddScoped<IPdfService, PdfService>();
|
|
|
|
// 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<string[]>() ?? new[] { "http://localhost:4200" };
|
|
|
|
builder.Services.AddCors(options =>
|
|
{
|
|
options.AddPolicy(CorsPolicyName, policy =>
|
|
{
|
|
policy.WithOrigins(allowedOrigins)
|
|
.AllowAnyHeader()
|
|
.AllowAnyMethod()
|
|
// Expose Content-Disposition en lecture côté client (JS) : sans cet appel, les
|
|
// en-têtes de RÉPONSE ne sont pas lisibles en cross-origin même avec
|
|
// AllowAnyHeader() (qui ne couvre que les en-têtes de REQUÊTE). Nécessaire pour
|
|
// que le Frontend récupère le nom de fichier suggéré des Export_PDF.
|
|
.WithExposedHeaders("Content-Disposition");
|
|
});
|
|
});
|
|
|
|
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<AppDbContext>();
|
|
await dbContext.Database.MigrateAsync();
|
|
}
|
|
|
|
app.Run();
|
|
|
|
// Exposé pour les tests d'intégration (WebApplicationFactory<Program>).
|
|
public partial class Program { }
|