diff --git a/ManagerService/Controllers/AiController.cs b/ManagerService/Controllers/AiController.cs index eaddf10..392f337 100644 --- a/ManagerService/Controllers/AiController.cs +++ b/ManagerService/Controllers/AiController.cs @@ -4,6 +4,7 @@ using ManagerService.DTOs; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Logging; using NSwag.Annotations; using Microsoft.EntityFrameworkCore; @@ -19,6 +20,13 @@ namespace ManagerService.Controllers [OpenApiTag("AI", Description = "Assistant IA")] public class AiController : ControllerBase { + /// + /// Politique de limitation appliquée aux endpoints qui consomment des jetons. + /// Déclarée ici parce que c'est le seul contrôleur concerné ; Startup la lit + /// pour enregistrer le limiteur. + /// + public const string RateLimitPolicy = "ai"; + /// /// Plafond IA cumulé sur toute la durée de l'essai gratuit (14 jours), distinct du /// compteur mensuel : ~30 requêtes * ~10k tokens/req estimés. Empêche qu'un essai à @@ -301,8 +309,10 @@ namespace ManagerService.Controllers /// Traduit un texte HTML vers plusieurs langues via IA /// [HttpPost("translate")] + [EnableRateLimiting(RateLimitPolicy)] [ProducesResponseType(typeof(AiTranslateResponse), 200)] [ProducesResponseType(403)] + [ProducesResponseType(429)] [ProducesResponseType(typeof(string), 500)] public async Task Translate([FromBody] AiTranslateRequest request, [FromQuery] string instanceId) { @@ -336,8 +346,10 @@ namespace ManagerService.Controllers /// Envoie un message à l'assistant IA, scopé à l'instance et optionnellement à une configuration /// [HttpPost("chat")] + [EnableRateLimiting(RateLimitPolicy)] [ProducesResponseType(typeof(AiChatResponse), 200)] [ProducesResponseType(403)] + [ProducesResponseType(429)] [ProducesResponseType(typeof(string), 500)] public async Task Chat([FromBody] AiChatRequest request) { diff --git a/ManagerService/Startup.cs b/ManagerService/Startup.cs index af0ce15..92ef10f 100644 --- a/ManagerService/Startup.cs +++ b/ManagerService/Startup.cs @@ -25,6 +25,9 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Features; using Microsoft.AspNetCore.HttpsPolicy; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using System.Threading.RateLimiting; +using ManagerService.Controllers; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -262,6 +265,38 @@ namespace ManagerService services.AddHealthChecks() .AddNpgSql(connectionString); + + // La clé API publique d'une instance est embarquée dans les apps visiteur et + // lisible en clair dans le navigateur sur visitapp-web : elle est publique par + // construction. Les endpoints IA sont les seuls qui coûtent de l'argent réel + // (jetons Gemini), et rien n'empêchait d'y boucler jusqu'à vider le quota mensuel + // d'un client qui n'a rien fait. + // + // Partition par instance, parce que c'est l'instance qui porte le quota qu'on + // protège : l'abus chez un client ne doit pas ralentir les autres. Le plafond est + // large — un musée à 100 visiteurs simultanés posant une question par minute + // reste très en dessous — il coupe la boucle automatique, pas l'usage réel. + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.OnRejected = (context, _) => + { + context.HttpContext.Response.Headers.RetryAfter = "60"; + return ValueTask.CompletedTask; + }; + + options.AddPolicy(AiController.RateLimitPolicy, httpContext => + RateLimitPartition.GetFixedWindowLimiter( + httpContext.User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value + ?? httpContext.Connection.RemoteIpAddress?.ToString() + ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 120, + Window = TimeSpan.FromMinutes(1) + })); + }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -302,6 +337,12 @@ namespace ManagerService #endif ); + // Après UseCors : un 429 renvoyé avant que les en-têtes CORS soient posés + // s'affiche comme une erreur CORS dans le navigateur, et visitapp-web ne verrait + // jamais le vrai code. Après UseAuthentication aussi, sinon la partition n'a pas + // encore le claim d'instance et tout le monde tomberait dans le même seau. + app.UseRateLimiter(); + #if DEBUG app.UseHangfireDashboard("/hangfire"); #else