Endpoint dedie `PUT Section/{id}/visibility` : passer par `Update` aurait
reconstruit le sous-type via SectionFactory, donc efface le contenu specifique
de la section.
Le logo des e-mails est servi par le service lui-meme (wwwroot + UseStaticFiles)
plutot que par le manager deploye en face, dont il ne doit pas dependre.
488 lines
21 KiB
C#
488 lines
21 KiB
C#
using FirebaseAdmin;
|
||
using Google.Apis.Auth.OAuth2;
|
||
using Hangfire;
|
||
using Hangfire.PostgreSql;
|
||
using Microsoft.Extensions.AI;
|
||
using OpenAI;
|
||
using System.ClientModel;
|
||
using Manager.Framework.Models;
|
||
using Manager.Helpers;
|
||
using Manager.Interfaces;
|
||
using Manager.Interfaces.Models;
|
||
using Manager.Services;
|
||
using ManagerService.Data;
|
||
using ManagerService.Extensions;
|
||
using ManagerService.Helpers;
|
||
using ManagerService.Security;
|
||
using ManagerService.Service;
|
||
using ManagerService.Service.Services;
|
||
using Microsoft.AspNetCore.Authentication;
|
||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||
using Microsoft.AspNetCore.Builder;
|
||
using Microsoft.AspNetCore.Diagnostics;
|
||
using Microsoft.AspNetCore.Hosting;
|
||
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;
|
||
using Microsoft.Extensions.FileProviders;
|
||
using Microsoft.Extensions.Hosting;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.IdentityModel.Tokens;
|
||
using Mqtt.Client.AspNetCore.Settings;
|
||
using MyCore.Service.Extensions;
|
||
using Npgsql;
|
||
using NSwag;
|
||
using NSwag.Generation.AspNetCore;
|
||
using NSwag.Generation.Processors.Security;
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Text.Json.Serialization;
|
||
using System.Threading.Tasks;
|
||
using ManagerService.Services;
|
||
using Serilog;
|
||
|
||
namespace ManagerService
|
||
{
|
||
public class Startup
|
||
{
|
||
public Startup(IConfiguration configuration)
|
||
{
|
||
Configuration = configuration;
|
||
|
||
MapConfiguration();
|
||
}
|
||
|
||
public IConfiguration Configuration { get; }
|
||
|
||
private void MapConfiguration()
|
||
{
|
||
MapBrokerHostSettings();
|
||
MapClientSettings();
|
||
}
|
||
|
||
private void MapBrokerHostSettings()
|
||
{
|
||
BrokerHostSettings brokerHostSettings = new BrokerHostSettings();
|
||
Configuration.GetSection(nameof(BrokerHostSettings)).Bind(brokerHostSettings);
|
||
AppSettingsProvider.BrokerHostSettings = brokerHostSettings;
|
||
}
|
||
|
||
private void MapClientSettings()
|
||
{
|
||
ClientSettings clientSettings = new ClientSettings();
|
||
Configuration.GetSection(nameof(ClientSettings)).Bind(clientSettings);
|
||
AppSettingsProvider.ClientSettings = clientSettings;
|
||
}
|
||
|
||
// This method gets called by the runtime. Use this method to add services to the container.
|
||
public void ConfigureServices(IServiceCollection services)
|
||
{
|
||
// Swagger
|
||
services.AddControllers()
|
||
.AddJsonOptions(options =>
|
||
{
|
||
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||
});
|
||
|
||
services.AddOpenApiDocument(config =>
|
||
{
|
||
ConfigureSwagger(config);
|
||
});
|
||
|
||
services.AddCors(o => o.AddPolicy("AllowAll", builder =>
|
||
{
|
||
builder.AllowAnyOrigin()
|
||
.AllowAnyMethod()
|
||
.AllowAnyHeader();
|
||
}));
|
||
|
||
services.Configure<FormOptions>(o => {
|
||
o.ValueLengthLimit = int.MaxValue;
|
||
o.MultipartBodyLengthLimit = int.MaxValue;
|
||
o.MemoryBufferThreshold = int.MaxValue;
|
||
});
|
||
|
||
// Authentication
|
||
|
||
var tokensConfiguration = Configuration.GetSection("Tokens");
|
||
var tokenSettings = tokensConfiguration.Get<TokensSettings>();
|
||
|
||
services.Configure<TokensSettings>(tokensConfiguration);
|
||
services.Configure<StripeSettings>(Configuration.GetSection("Stripe"));
|
||
services.Configure<ResendSettings>(Configuration.GetSection("Resend"));
|
||
|
||
ManagerService.EmailTemplates.EmailLayout.Configure(
|
||
Configuration["AppUrls:Logo"] ?? $"{Configuration["AppUrls:Api"]}/email-logo.png",
|
||
Configuration["AppUrls:Landing"]);
|
||
|
||
foreach (var policy in ManagerService.Service.Security.PoliciesConfiguration)
|
||
services.AddAuthorization(options =>
|
||
{
|
||
options.AddPolicy(policy.Name, policyAdmin =>
|
||
{
|
||
policyAdmin.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, "ApiKey");
|
||
foreach (var claim in policy.Claims)
|
||
policyAdmin.RequireClaim(ManagerService.Service.Security.ClaimTypes.Permission, claim);
|
||
});
|
||
});
|
||
|
||
services.AddAuthorization(options =>
|
||
options.AddPolicy(ManagerService.Service.Security.Policies.AppReadAccess, policy =>
|
||
policy.AddAuthenticationSchemes(JwtBearerDefaults.AuthenticationScheme, "ApiKey")
|
||
.RequireAuthenticatedUser()));
|
||
|
||
services
|
||
.AddAuthentication(x =>
|
||
{
|
||
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||
x.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
|
||
x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||
})
|
||
.AddJwtBearer(x =>
|
||
{
|
||
x.RequireHttpsMetadata = false;
|
||
x.SaveToken = true;
|
||
x.TokenValidationParameters = new TokenValidationParameters
|
||
{
|
||
ValidateIssuerSigningKey = true,
|
||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenSettings.Secret)),
|
||
ValidateIssuer = false,
|
||
ValidateAudience = false,
|
||
RequireExpirationTime = false,
|
||
ValidateLifetime = true
|
||
};
|
||
})
|
||
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>("ApiKey", _ => { });
|
||
|
||
#if RELEASE
|
||
//services.AddMqttClientHostedService();
|
||
#endif
|
||
services.AddHttpContextAccessor();
|
||
services.AddScoped(typeof(ProfileLogic));
|
||
services.AddScoped<TokensService>();
|
||
services.AddScoped<LanguageInit>();
|
||
|
||
// OLD services
|
||
services.AddScoped<UserDatabaseService>();
|
||
services.AddScoped<SectionDatabaseService>();
|
||
services.AddScoped<ConfigurationDatabaseService>();
|
||
services.AddScoped<ResourceDatabaseService>();
|
||
services.AddMemoryCache();
|
||
services.AddScoped<ResourceUsageService>();
|
||
services.AddScoped<DeviceDatabaseService>();
|
||
services.AddScoped<InstanceDatabaseService>();
|
||
services.AddScoped<ApiKeyDatabaseService>();
|
||
services.AddScoped<IEmailService, ResendEmailService>();
|
||
services.AddScoped<StripeService>();
|
||
|
||
// Assistant IA — choisir un provider (package NuGet : Microsoft.Extensions.AI.OpenAI) :
|
||
// OpenAI : new OpenAIClient(new ApiKeyCredential(apiKey)).AsChatClient("gpt-4o-mini")
|
||
// Gemini : endpoint OpenAI-compatible (ci-dessous)
|
||
// Anthropic : package Anthropic.SDK → new AnthropicClient(apiKey).Messages.AsChatClient("claude-haiku-4-5-20251001")
|
||
services.AddSingleton<IChatClient>(_ =>
|
||
new OpenAIClient(
|
||
new ApiKeyCredential(Configuration["AI:ApiKey"]!),
|
||
new OpenAIClientOptions { Endpoint = new Uri("https://generativelanguage.googleapis.com/v1beta/openai/") }
|
||
).AsChatClient("gemini-2.5-flash-lite")
|
||
.AsBuilder()
|
||
.UseFunctionInvocation()
|
||
.Build());
|
||
services.AddScoped<IAssistantService, AssistantService>();
|
||
services.AddScoped<IEmbeddingService, GoogleEmbeddingService>();
|
||
services.AddScoped<IVectorStoreService, VectorStoreService>();
|
||
services.AddScoped<IIngestionService, IngestionService>();
|
||
|
||
// Push Notifications
|
||
var firebaseCredentialsPath = Configuration["Firebase:CredentialsPath"];
|
||
if (!string.IsNullOrEmpty(firebaseCredentialsPath) && FirebaseApp.DefaultInstance == null)
|
||
{
|
||
FirebaseApp.Create(new AppOptions
|
||
{
|
||
Credential = GoogleCredential.FromFile(firebaseCredentialsPath)
|
||
});
|
||
}
|
||
services.AddSingleton<NotificationService>();
|
||
// Réutilise le credential Firebase ci-dessus ; seul Firebase:StorageBucket
|
||
// s'ajoute à la configuration. Non configuré, le service ne supprime rien
|
||
// et le dit — il ne fait jamais semblant d'avoir nettoyé.
|
||
services.AddSingleton<IResourceBlobService, ResourceBlobService>();
|
||
services.AddHttpClient();
|
||
services.AddScoped<AgendaSyncService>();
|
||
services.AddScoped<WeatherSyncService>();
|
||
services.AddScoped<TrialLifecycleService>();
|
||
services.AddScoped<VisitEventPurgeService>();
|
||
services.AddScoped<VisitorQuestionPurgeService>();
|
||
services.AddScoped<QuestionThemingService>();
|
||
services.AddScoped<AuditLogPurgeService>();
|
||
|
||
var connectionString = Configuration.GetConnectionString("PostgresConnection");
|
||
|
||
// Hangfire
|
||
services.AddHangfire(config => config
|
||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||
.UseSimpleAssemblyNameTypeSerializer()
|
||
.UseRecommendedSerializerSettings()
|
||
.UsePostgreSqlStorage(c => c.UseNpgsqlConnection(connectionString)));
|
||
services.AddHangfireServer(options => options.ServerName = "default");
|
||
|
||
// Serveur séparé pour l'ingestion : le défaut lance min(nbCPU × 5, 20) workers, et
|
||
// autant d'extractions simultanées saturent la RAM d'un conteneur qui sert aussi l'API.
|
||
// Un serveur à part plutôt qu'une queue de plus, sinon les jobs d'ingestion occupent
|
||
// les workers de la file générale (sync agenda, météo, e-mails).
|
||
services.AddHangfireServer(options =>
|
||
{
|
||
options.ServerName = IngestionService.QueueName;
|
||
options.Queues = new[] { IngestionService.QueueName };
|
||
options.WorkerCount = 2;
|
||
});
|
||
|
||
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
|
||
dataSourceBuilder.UseNetTopologySuite();
|
||
dataSourceBuilder.UseVector();
|
||
dataSourceBuilder.EnableDynamicJson();
|
||
var dataSource = dataSourceBuilder.Build();
|
||
|
||
// Scoped, pas singleton : l'intercepteur accumule les sections touchées entre
|
||
// SavingChanges et SavedChanges. Partagé entre requêtes, deux clients se
|
||
// déclencheraient mutuellement des ré-indexations.
|
||
services.AddScoped<SectionIndexingInterceptor>();
|
||
|
||
services.AddDbContext<MyInfoMateDbContext>((serviceProvider, options) =>
|
||
{
|
||
options.UseNpgsql(dataSource, o => o.UseNetTopologySuite().UseVector())
|
||
.AddInterceptors(serviceProvider.GetRequiredService<SectionIndexingInterceptor>());
|
||
#if DEBUG
|
||
// Écrit les valeurs des paramètres dans les logs : mots de passe hashés,
|
||
// clés API, données de visiteurs. Jamais en production — le Dockerfile
|
||
// publie en `-c Release`, donc ce bloc n'y est pas compilé.
|
||
options.EnableSensitiveDataLogging()
|
||
.LogTo(Console.WriteLine, LogLevel.Information);
|
||
#endif
|
||
}
|
||
);
|
||
|
||
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.
|
||
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
|
||
{
|
||
/*app.UseCors(
|
||
options => options.WithOrigins("http://localhost:49430").AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader().AllowCredentials()
|
||
);*/
|
||
|
||
if (env.IsDevelopment())
|
||
{
|
||
app.UseDeveloperExceptionPage();
|
||
}
|
||
|
||
app.UseExceptionHandler(HandleError);
|
||
app.UseSerilogRequestLogging();
|
||
|
||
//app.UseHttpsRedirection();
|
||
|
||
// Sert wwwroot/ — aujourd'hui le seul fichier est le logo des e-mails
|
||
// transactionnels, qui doit vivre sur le même service que l'envoi pour
|
||
// ne pas dépendre du build du manager déployé en face.
|
||
app.UseStaticFiles();
|
||
|
||
app.UseRouting();
|
||
app.UseAuthentication();
|
||
app.UseAuthorization();
|
||
//app.UseCors("AllowAll");
|
||
|
||
app.UseCors(
|
||
#if DEBUG
|
||
options => options
|
||
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "http://localhost:9090" || origin == "http://localhost:3000")
|
||
.AllowAnyMethod()
|
||
.AllowAnyHeader()
|
||
.AllowCredentials()
|
||
#else
|
||
options => options
|
||
.SetIsOriginAllowed(origin => string.IsNullOrEmpty(origin) || origin == "https://manager.mymuseum.be" || origin == "https://manager.myinfomate.be" || origin == "https://visitnamur.myinfomate.be" || origin == "https://fortsaintheribert.myinfomate.be" || origin == "https://app.myinfomate.be" || origin == "https://myinfomate.be")
|
||
.AllowAnyMethod()
|
||
.AllowAnyHeader()
|
||
.AllowCredentials()
|
||
#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
|
||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||
{
|
||
Authorization = new[] { new HangfireDashboardAuthorizationFilter() }
|
||
});
|
||
#endif
|
||
|
||
RecurringJob.AddOrUpdate<AgendaSyncService>(
|
||
"agenda-sync-daily",
|
||
s => s.SyncAllAsync(),
|
||
Cron.Daily());
|
||
|
||
RecurringJob.AddOrUpdate<WeatherSyncService>(
|
||
"weather-sync-morning",
|
||
s => s.SyncAllAsync(),
|
||
"0 6 * * *");
|
||
|
||
RecurringJob.AddOrUpdate<WeatherSyncService>(
|
||
"weather-sync-afternoon",
|
||
s => s.SyncAllAsync(),
|
||
"0 13 * * *");
|
||
|
||
RecurringJob.AddOrUpdate<TrialLifecycleService>(
|
||
"trial-lifecycle-daily",
|
||
s => s.RunAsync(),
|
||
Cron.Daily());
|
||
|
||
// Ne supprime rien tant que Stats:RetentionDays n'est pas défini — voir VisitEventPurgeService.
|
||
RecurringJob.AddOrUpdate<VisitEventPurgeService>(
|
||
"visit-events-purge",
|
||
s => s.PurgeAsync(),
|
||
"0 3 * * *");
|
||
|
||
// ⚠️ Le regroupement passe AVANT la purge dans la nuit — 2 h contre 3 h 30. Une
|
||
// question purgée avant d'avoir été classée ne compte dans aucun agrégat, et rien
|
||
// ne peut la rattraper : elle n'existe plus.
|
||
RecurringJob.AddOrUpdate<QuestionThemingService>(
|
||
"visitor-questions-theming",
|
||
s => s.RunAsync(),
|
||
"0 2 * * *");
|
||
|
||
// Actif sans condition : les 90 jours sont un engagement des CGU §8.4, pas un réglage.
|
||
RecurringJob.AddOrUpdate<VisitorQuestionPurgeService>(
|
||
"visitor-questions-purge",
|
||
s => s.PurgeAsync(),
|
||
"30 3 * * *");
|
||
|
||
// Ne supprime rien tant qu'Audit:RetentionDays n'est pas défini — même verrou que
|
||
// les VisitEvent, et pour la même raison : pas de pg_dump, pas de suppression.
|
||
RecurringJob.AddOrUpdate<AuditLogPurgeService>(
|
||
"audit-log-purge",
|
||
s => s.PurgeAsync(),
|
||
"45 3 * * *");
|
||
|
||
app.UseEndpoints(endpoints =>
|
||
{
|
||
endpoints.MapControllers();
|
||
endpoints.MapHealthChecks("/health");
|
||
});
|
||
|
||
app.UseOpenApi();
|
||
app.UseSwaggerUi3(configure =>
|
||
{
|
||
configure.OperationsSorter = "alpha";
|
||
configure.TagsSorter = "alpha";
|
||
});
|
||
}
|
||
|
||
private void ConfigureSwagger(AspNetCoreOpenApiDocumentGeneratorSettings config)
|
||
{
|
||
config.GenerateEnumMappingDescription = true;
|
||
config.AddSecurity("bearer", Enumerable.Empty<string>(), new OpenApiSecurityScheme
|
||
{
|
||
Type = OpenApiSecuritySchemeType.OAuth2,
|
||
Description = "Manager Authentication",
|
||
Flow = OpenApiOAuth2Flow.Password,
|
||
Flows = new OpenApiOAuthFlows()
|
||
{
|
||
|
||
Password = new OpenApiOAuthFlow()
|
||
{
|
||
Scopes = new Dictionary<string, string>
|
||
{
|
||
{ ManagerService.Service.Security.Scope, "Manager WebAPI" }
|
||
},
|
||
TokenUrl = "/api/authentication/Token",
|
||
AuthorizationUrl = "/authentication/Token",
|
||
}
|
||
}
|
||
});
|
||
config.OperationProcessors.Add(new AspNetCoreOperationSecurityScopeProcessor("bearer"));
|
||
|
||
config.AddSecurity("apikey", Enumerable.Empty<string>(), new OpenApiSecurityScheme
|
||
{
|
||
Type = OpenApiSecuritySchemeType.ApiKey,
|
||
Name = "X-Api-Key",
|
||
In = OpenApiSecurityApiKeyLocation.Header,
|
||
Description = "API Key for mobile apps"
|
||
});
|
||
|
||
config.PostProcess = document =>
|
||
{
|
||
document.Info.Title = "Manager Service";
|
||
document.Info.Description = "API Manager Service";
|
||
document.Info.Version = "Version Alpha";
|
||
};
|
||
}
|
||
|
||
|
||
private void HandleError(IApplicationBuilder error)
|
||
{
|
||
error.Run(async context =>
|
||
{
|
||
var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>();
|
||
var exception = exceptionHandlerPathFeature?.Error as RequestException;
|
||
|
||
if (exception != null)
|
||
{
|
||
var json = exception.GetJson();
|
||
context.Response.ContentType = "application/json";
|
||
context.Response.StatusCode = exception.StatusCode;
|
||
await context.Response.WriteAsync(json);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|