LOT B — une seule migration EF (LotB_FreezeSchema) : - SectionMap.MapResourceId → IconResourceId. L'écart (g) de la bascule tombe avec. Il fallait renommer aussi la propriété de navigation MapResource : la convention EF l'appariait au FK, la laisser aurait fabriqué un FK fantôme. Elle n'était utilisée nulle part ailleurs. - SectionEvent.ParcoursIds supprimé (champ, DTO, SectionFactory, et une initialisation dans un montage de test). - Instance.IsImageWatermark remplace le `instanceId == "633ee379…"` en dur de ResourceController. EF a généré un RenameColumn, pas un drop+add : les icônes déjà configurées survivent. L'avertissement de perte de données ne porte que sur le DropColumn de ParcoursIds, ce qui est l'intention. Non fait, et c'était une erreur de doc : « supprimer SectionEvent.IconResourceId ». Ce champ n'existe pas — la ligne visée appartient à la classe imbriquée MapAnnotation, partagée par SectionEvent, SectionAgenda et SectionMap, lue par cinq contrôleurs et par GetReferencedResourceIds. La supprimer aurait cassé les icônes d'annotation des trois types et la collecte offline. SÉCURITÉ (lot A, même repo) : - AuthenticationController.Authenticate : un bloc #if DEBUG écrasait l'email et le mot de passe reçus par un compte de test, donc toute compilation en Debug authentifiait n'importe quelle saisie. Retiré. - EnableSensitiveDataLogging (qui écrit les valeurs des paramètres dans les logs) passe sous #if DEBUG, l'idiome déjà employé dans Startup.cs pour le CORS et Hangfire. Le Dockerfile publiant en -c Release, c'est un verrou réel. LOT C1 : - Calculateur StoragePath/SizeBytes extrait dans Helpers/ResourceStorage.cs, avec 13 tests fixant l'invariant des types URL. Il ferme le lien L5 : le backfill (C2) et l'écart (e) de la migration appelleront le même code. - L'extraction a révélé la divergence qu'elle devait empêcher : des deux chemins de création de ResourceController, le chemin multipart écrivait SizeBytes mais laissait StoragePath nul. dotnet build Debug et Release verts, dotnet test 143/143 (130 + 13). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
415 lines
17 KiB
C#
415 lines
17 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.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"));
|
||
|
||
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.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>();
|
||
services.AddHttpClient();
|
||
services.AddScoped<AgendaSyncService>();
|
||
services.AddScoped<WeatherSyncService>();
|
||
services.AddScoped<TrialLifecycleService>();
|
||
services.AddScoped<VisitEventPurgeService>();
|
||
services.AddScoped<VisitorQuestionPurgeService>();
|
||
|
||
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);
|
||
}
|
||
|
||
// 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();
|
||
|
||
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
|
||
);
|
||
|
||
#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 * * *");
|
||
|
||
// 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 * * *");
|
||
|
||
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);
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|