Files
MadJax 61298d6384 Refonte export PDF, colonne commentaire et UX résumés
- Export PDF: nouveau nommage AAAAMM/AAAA_Trajets_MATRICULE_PLAQUE.pdf,
  logo carré embarqué en en-tête, km formatés selon Precision_Distance,
  restructuration en-têtes (Domicile déplacé, période/année fusionnées
  au titre)
- Correction bug: le nom de fichier suggéré au téléchargement ne
  remontait pas au frontend (CORS WithExposedHeaders manquant +
  composants résumé ne lisaient pas Content-Disposition)
- Résumé mensuel: colonne Commentaire, sélecteur temporel unifié
  (auto-chargement), km selon précision
- Résumé annuel: bouton Export PDF repositionné, km selon précision
- Panneau totaux: renommage "Remboursement indicatif" + infobulles
  d'aide (icône) au lieu du texte permanent
- Ajout de PdfServiceIntegrationTests (3 tests), 65/65 tests backend OK
- Mise à jour requirements.md et design.md en conséquence
2026-09-28 08:58:23 +02:00

267 lines
12 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).
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);
});
// --- 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);
}
});
// --- 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 { }