using System.Security.Claims; using foodmarket.Api.Storage; using Hangfire; using Hangfire.PostgreSql; using Prometheus; using FluentValidation; using foodmarket.Api.Infrastructure.RateLimiting; using foodmarket.Api.Infrastructure.Tenancy; using foodmarket.Api.Seed; using foodmarket.Application.Common.Tenancy; using foodmarket.Infrastructure.Identity; using foodmarket.Infrastructure.Persistence; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using OpenIddict.Validation.AspNetCore; using Serilog; using static OpenIddict.Abstractions.OpenIddictConstants; Log.Logger = new LoggerConfiguration() .WriteTo.Console() .CreateBootstrapLogger(); try { var builder = WebApplication.CreateBuilder(args); builder.Host.UseSerilog((ctx, services, cfg) => cfg.ReadFrom.Configuration(ctx.Configuration).ReadFrom.Services(services)); const string CorsPolicy = "food-market-cors"; builder.Services.AddCors(opts => opts.AddPolicy(CorsPolicy, p => { var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? ["http://localhost:5173"]; p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials(); })); builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); // Sprint 20 / TD-3: Mapster config — singleton TypeAdapterConfig. // Используется в контроллерах через ProjectToType(MapsterConfig.Config) // для SQL-friendly проекций. IMapper в DI не регистрируем, потому что // ProjectToType() работает напрямую с config'ом — это эквивалент // ручного `Select(...)` без runtime-reflection. builder.Services.AddSingleton(foodmarket.Application.Mapping.MapsterConfig.Config); // ClaimsTransformation для SuperAdmin override: добавляет роли Admin/ // Storekeeper/Cashier при наличии X-Org-Override, чтобы [Authorize(Roles=...)] // не отшивал edit-mode мутации SuperAdmin'а. См. SuperAdminOverrideClaimsTransformer. builder.Services.AddScoped(); // Prometheus metrics: singleton-интерсептор EF засекает длительность каждого // SQL-запроса (см. food_market_db_query_duration_seconds). HTTP-метрики // включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем // (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом. builder.Services.AddSingleton(); // OrgAuditInterceptor — scoped (зависит от ITenantContext). EF тащит его // через AddInterceptors на каждое создание DbContext (DbContext тоже scoped). builder.Services.AddScoped(); // Sprint 14: явная конфигурация Npgsql connection pool. // Дефолты Npgsql: Max=100, Min=0, IdleLifetime=300 — формально нормально, // но Min=0 в моменты низкого трафика убивает все коннекшены, и первый // запрос после простоя платит handshake + auth (50-100ms). Подняли // Min до 10 — пул всегда греется. Max=100 оставили (PG default // max_connections=100, превышать без тюна сервера нельзя). // Переопределяется через ConnectionStrings:Default (если в строке // уже есть Maximum/Minimum Pool Size — наш код не перетирает). static string ApplyDefaultPoolConfig(string? raw) { if (string.IsNullOrEmpty(raw)) return raw ?? ""; var lower = raw.ToLowerInvariant(); var b = new System.Text.StringBuilder(raw); if (!raw.EndsWith(";")) b.Append(';'); if (!lower.Contains("maximum pool size")) b.Append("Maximum Pool Size=100;"); if (!lower.Contains("minimum pool size")) b.Append("Minimum Pool Size=10;"); if (!lower.Contains("connection idle lifetime")) b.Append("Connection Idle Lifetime=300;"); // Auto-prepare часто используемых запросов — заметно ускоряет EF Core // на стабильном rotational query mix'е. Threshold=5 = после 5 calls // одного query шаблона PG получает PREPARE, дальнейшие round-trip'ы // идут как EXECUTE prepared (без re-parse/re-plan). if (!lower.Contains("max auto prepare")) b.Append("Max Auto Prepare=20;"); if (!lower.Contains("auto prepare min usages")) b.Append("Auto Prepare Min Usages=5;"); return b.ToString(); } var poolTunedConnString = ApplyDefaultPoolConfig(builder.Configuration.GetConnectionString("Default")); builder.Services.AddDbContext((sp, opts) => { opts.UseNpgsql(poolTunedConnString, npg => npg.MigrationsAssembly(typeof(AppDbContext).Assembly.GetName().Name)); opts.UseOpenIddict(); opts.AddInterceptors(sp.GetRequiredService< foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>()); opts.AddInterceptors(sp.GetRequiredService< foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>()); }); builder.Services.AddIdentity(opts => { opts.Password.RequireDigit = true; opts.Password.RequiredLength = 8; opts.Password.RequireNonAlphanumeric = false; opts.Password.RequireUppercase = false; opts.User.RequireUniqueEmail = true; }) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); builder.Services.Configure(opts => { opts.ClaimsIdentity.UserNameClaimType = Claims.Name; opts.ClaimsIdentity.UserIdClaimType = Claims.Subject; opts.ClaimsIdentity.RoleClaimType = Claims.Role; }); builder.Services.AddOpenIddict() .AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext()) .AddServer(opts => { opts.SetTokenEndpointUris("/connect/token"); opts.AllowPasswordFlow().AllowRefreshTokenFlow(); opts.AcceptAnonymousClients(); opts.RegisterScopes(Scopes.OpenId, Scopes.Profile, Scopes.Email, Scopes.Roles, "api"); // Ключи подписи/шифрования: dev — persistent RSA-ключ в App_Data; // prod/stage — X509-сертификаты из конфига (OpenIddict:SigningCertPath / // EncryptionCertPath), persistent self-signed если файла нет. // Подробности и dev-инвариант — в OpenIddictKeyConfigurator. foodmarket.Api.Infrastructure.Security.OpenIddictKeyConfigurator .ConfigureSigningAndEncryption(opts, builder.Configuration, builder.Environment); opts.UseAspNetCore() .EnableTokenEndpointPassthrough() .DisableTransportSecurityRequirement(); opts.SetAccessTokenLifetime(TimeSpan.Parse( builder.Configuration["OpenIddict:AccessTokenLifetime"] ?? "01:00:00")); opts.SetRefreshTokenLifetime(TimeSpan.Parse( builder.Configuration["OpenIddict:RefreshTokenLifetime"] ?? "30.00:00:00")); // Rolling refresh включён по умолчанию: каждый refresh гасит старый // токен (Redeemed) и выдаёт новый. Но по умолчанию OpenIddict даёт // 30-секундный reuse-leeway — окно, в котором погашенный refresh ещё // принимается (для гонок/ретраев). Для розничной админки это дыра: // утёкший refresh остаётся рабочим 30с после ротации. Обнуляем окно — // ротация инвалидирует старый refresh немедленно. opts.SetRefreshTokenReuseLeeway(TimeSpan.Zero); // Явный Issuer = публичный URL админки. Без него OpenIddict // вычисляет issuer из текущего HTTP-запроса, что ломается за // nginx-прокси если Host/X-Forwarded-Proto не доходят. Берём // из конфигурации (OpenIddict:Issuer / docker compose env). if (builder.Configuration["OpenIddict:Issuer"] is { Length: > 0 } issuer) { opts.SetIssuer(new Uri(issuer)); } }) .AddValidation(opts => { opts.UseLocalServer(); opts.UseAspNetCore(); }); // Force OpenIddict validation to handle ALL [Authorize] challenges — otherwise AddIdentity's // cookie scheme takes over and 401 becomes a 302 redirect to /Account/Login for API calls. var authBuilder = builder.Services.AddAuthentication(options => { options.DefaultScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme; }); // Sprint 20: SSO scaffolding. Внешние providers подключаются только когда // в конфиге задан ClientId — иначе их не регистрируем (контроллер вернёт // 503 на /api/auth/external/{provider}). Cookie-схема нужна для временного // хранения OAuth-стейта между challenge и callback'ом. var googleId = builder.Configuration["Authentication:Google:ClientId"]; var googleSecret = builder.Configuration["Authentication:Google:ClientSecret"]; var msId = builder.Configuration["Authentication:Microsoft:ClientId"]; var msSecret = builder.Configuration["Authentication:Microsoft:ClientSecret"]; var anySso = !string.IsNullOrEmpty(googleId) || !string.IsNullOrEmpty(msId); if (anySso) { authBuilder.AddCookie("ExternalSignIn", o => { o.Cookie.Name = "fm.external"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax; o.ExpireTimeSpan = TimeSpan.FromMinutes(10); }); if (!string.IsNullOrEmpty(googleId) && !string.IsNullOrEmpty(googleSecret)) { authBuilder.AddGoogle("Google", o => { o.ClientId = googleId; o.ClientSecret = googleSecret; o.SignInScheme = "ExternalSignIn"; o.CallbackPath = "/signin-google"; o.SaveTokens = false; }); } if (!string.IsNullOrEmpty(msId) && !string.IsNullOrEmpty(msSecret)) { authBuilder.AddMicrosoftAccount("Microsoft", o => { o.ClientId = msId; o.ClientSecret = msSecret; o.SignInScheme = "ExternalSignIn"; o.CallbackPath = "/signin-microsoft"; o.SaveTokens = false; }); } } builder.Services.AddAuthorization(opts => { // Check the "role" claim explicitly — robust against role-claim-type mismatches between // OpenIddict validation identity and the default ClaimTypes.Role uri. opts.AddPolicy("AdminAccess", p => p.RequireAssertion(ctx => ctx.User.HasClaim(c => c.Type == Claims.Role && (c.Value == "Admin" || c.Value == "SuperAdmin")))); }); // Permission-based авторизация: [RequiresPermission("...")] → policy perm:* → // PermissionRequirement → проверка флага RolePermissions роли сотрудника. builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Sprint 13: централизованный логгер sensitive-операций (смена пароля, // 2FA, выдача роли, смена владельца, revoke-all sessions). Пишет в // org_audit_log + Serilog. См. SensitiveOpsAudit. builder.Services.AddScoped(); // Sprint 14: генерация thumb/medium WebP-вариантов при загрузке картинки товара. builder.Services.AddScoped(); // Anti-brute-force на /connect/token и /api/auth/signup (5/мин + 20/час на IP). // Лимиты конфигурируемы через RateLimiting:* (PerMinute/PerHour/Enabled). builder.Services.AddAuthRateLimiting(builder.Configuration); // Health-пробы: liveness (процесс жив) и readiness (БД + миграции применены). // Readiness-чек помечен тегом "ready", чтобы /health/live его не запускал. builder.Services.AddHealthChecks() .AddCheck( "database", tags: ["ready"]); // Email-отправка через MailKit. Singleton — внутри открывает scope для // свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается // на каждой отправке без рестарта приложения. builder.Services.AddSingleton(); // ─ Sprint 11: ОФД (фискализация чеков в РК) ──────────────────────── // Все провайдеры регистрируются одновременно — фабрика выбирает по // FiscalProvider в настройках организации. Default — None: чеки // проводятся без фискализации, поведение «как до Sprint 11». // // Mock = Transient (без HTTP); остальные через AddHttpClient — это // даёт автоматический pooled HttpMessageHandler + интеграцию с // IHttpClientFactory (вынос в Polly / переопределение handler'а // в тестах через .AddHttpMessageHandler). builder.Services.AddTransient(); builder.Services.AddHttpClient(); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddHttpClient(); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddHttpClient(); builder.Services.AddTransient(sp => sp.GetRequiredService()); builder.Services.AddScoped(); // EmailTemplates загружает embedded HTML и подставляет {{key}} — // см. Resources/EmailTemplates/*.html. Singleton с in-memory cache. builder.Services.AddSingleton(); builder.Services.AddDataProtection(); // MediatR (TD-1 partial CQRS): сканирует food-market.application для // IRequest/IRequestHandler. Образцы: CreateSupplyCommand, // PostRetailSaleCommand, GetSalesReportQuery. Контроллеры пока не // переписаны под mediator — это паттерн-показ, не полный рефакторинг. builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly( typeof(foodmarket.Application.Inventory.IStockService).Assembly)); // Заглушки для абстракций CQRS-handler'ов: handler'ы пока не подключены // к контроллерам (поэтапная миграция). Реальная имплементация появится // когда контроллер начнёт отправлять команду через IMediator. Сейчас // нужны только чтобы DI-validation на старте не падала. builder.Services.AddTransient(); builder.Services.AddTransient(); // FluentValidation: автоматическая регистрация валидаторов из сборки api. // ValidationFilter гоняет валидаторы на каждом контроллер-action перед // вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807). builder.Services.AddValidatorsFromAssemblyContaining(); builder.Services.AddScoped(); builder.Services.AddControllers(o => { // Глобальный action filter — пишет audit-log при успешных мутациях // в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3). o.Filters.AddService(); o.Filters.AddService(); }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(opts => { opts.SwaggerDoc("v1", new Microsoft.OpenApi.Models.OpenApiInfo { Title = "food-market API", Version = "v1", Description = "Multi-tenant POS/inventory backend. Все запросы " + "ограничены организацией текущего JWT (claim `org_id`).", }); // Bearer JWT через OpenIddict. Swagger UI «Authorize» подставит в Authorization. var bearer = new Microsoft.OpenApi.Models.OpenApiSecurityScheme { Type = Microsoft.OpenApi.Models.SecuritySchemeType.Http, Scheme = "bearer", BearerFormat = "JWT", In = Microsoft.OpenApi.Models.ParameterLocation.Header, Description = "Access token, полученный через POST /connect/token.", }; opts.AddSecurityDefinition("Bearer", bearer); opts.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement { [new Microsoft.OpenApi.Models.OpenApiSecurityScheme { Reference = new Microsoft.OpenApi.Models.OpenApiReference { Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme, Id = "Bearer", }, }] = Array.Empty(), }); // Стабильные operationId для генерации TS-клиентов: // _. Verb включён чтобы избежать коллизии // когда ASP.NET стрипает Async-суффикс и два метода (WipeAll, WipeAllAsync) // получают одинаковое имя action → одинаковый operationId → duplicate. // Минимальные API (/health, /metrics, /connect/*) не имеют ключей // controller/action в RouteValues — для них фоллбэк на RelativePath. opts.CustomOperationIds(api => { var rv = api.ActionDescriptor.RouteValues; rv.TryGetValue("controller", out var ctrl); rv.TryGetValue("action", out var action); var verb = api.HttpMethod is { Length: > 0 } m ? char.ToUpper(m[0]) + m[1..].ToLowerInvariant() : ""; if (string.IsNullOrEmpty(ctrl) || string.IsNullOrEmpty(action)) return $"{verb}_{api.RelativePath?.Replace('/', '_').Replace('{', '_').Replace('}', '_')}"; return $"{ctrl}_{verb}{action}"; }); // У нас есть одноимённые nested record'ы в разных контроллерах // (например, StockRow в StockController и StockReportController, или // EmployeeInput в Organizations.EmployeesController и SuperAdmin). // Включаем имя контроллера в schemaId через FullName-suffix чтобы не // словить duplicate schemaId — Swashbuckle падает на этом по умолчанию. // FullName может быть null для generic-параметров — fallback на Name. opts.CustomSchemaIds(t => (t.FullName ?? t.Name) .Replace("foodmarket.Api.Controllers.", "") .Replace("foodmarket.Application.", "") .Replace("foodmarket.Domain.", "") .Replace("+", "_") .Replace(".", "_")); }); // MoySklad import integration. Auto-decompress gzip responses from MoySklad's edge. // BaseAddress берётся из конфигурации (MoySklad:BaseUrl) с дефолтом на боевой // api.moysklad.ru — так интеграцию можно навести на mock-сервер в e2e/интеграционных // тестах, не трогая прод. Трейлинг-слэш обязателен (RFC 3986 §5.3, см. MoySkladClient). builder.Services.AddHttpClient(http => { var baseUrl = builder.Configuration["MoySklad:BaseUrl"]; if (!string.IsNullOrWhiteSpace(baseUrl)) http.BaseAddress = new Uri(baseUrl); }) .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate, }); builder.Services.AddScoped(); builder.Services.AddSingleton(); // Inventory builder.Services.AddScoped(); // Hangfire — фоновые джобы и UI-дашборд. Хранилище — наш Postgres // (тот же ConnectionString:Default). Запускаем фактический сервер только // когда приложение действительно работает (не в тестах): чтобы тестовые // прогоны не плодили tables/jobs в одноразовом контейнере. Регистрация // recurring jobs — после Build() в IHostedService HangfireJobsConfigurator. var enableHangfireServer = builder.Configuration.GetValue("Hangfire:Enabled", true); builder.Services.AddHangfire(cfg => cfg .SetDataCompatibilityLevel(Hangfire.CompatibilityLevel.Version_180) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() .UsePostgreSqlStorage(opts => opts.UseNpgsqlConnection( builder.Configuration.GetConnectionString("Default")))); if (enableHangfireServer) { builder.Services.AddHangfireServer(opts => { opts.WorkerCount = 2; opts.Queues = new[] { "default" }; }); builder.Services.AddHostedService(); // Sprint 14: timing-фильтр для всех job'ов — пишет длительность каждого // выполнения в Serilog. Долгие (>30с) логируются как Warning. builder.Services.AddSingleton(); builder.Services.AddHostedService(); } builder.Services.AddScoped(); // Sprint 20: DB VACUUM ANALYZE + disk monitoring. builder.Services.AddScoped(); builder.Services.AddScoped(); // Sprint 22: GDPR org export + DB schema docs. builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Telegram-бот владельца. Token + username берём из конфига; если token // пустой — клиент тихо отключён, RunAsync джоба возвращается сразу. UI // переходит в режим «бот не настроен» — кнопка «Привязать Telegram» // показывает подсказку об env-переменной. builder.Services.Configure( builder.Configuration.GetSection("Telegram")); builder.Services.AddHttpClient(c => { c.Timeout = TimeSpan.FromSeconds(15); }); builder.Services.AddScoped(); // Object storage (картинки товаров, CSV-импорт). Type=Local|Minio, // fallback на Local если MinIO недоступен. См. Storage/StorageBootstrap.cs. builder.Services.AddObjectStorage(builder.Configuration); // SignalR + per-org notification publisher. Hub смонтирован ниже на // /hubs/notifications. JsonPascalCase оставляем — фронт получает PascalCase // имена полей в DTO (см. NotificationsPublisher payload-records). builder.Services.AddSignalR(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); // Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo). builder.Services.AddScoped(); // Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1). builder.Services.AddScoped(); builder.Services.AddHostedService(); // DemoCatalogSeeder disabled: real catalog is imported from MoySklad. // Keep the file as reference for anyone starting without MoySklad access — // just re-register here to turn demo data back on. // builder.Services.AddHostedService(); // Sprint 13: security-заголовки (CSP, X-Frame-Options и т.д.). Опции // переопределяются секцией Security в конфиге; по дефолту — самодостаточный // SPA + API на одном origin, см. SecurityHeadersOptions.DefaultCsp. builder.Services.AddSingleton(sp => { var opts = new foodmarket.Api.Infrastructure.Security.SecurityHeadersOptions(); var section = builder.Configuration.GetSection("Security"); var csp = section["ContentSecurityPolicy"]; if (!string.IsNullOrWhiteSpace(csp)) opts.ContentSecurityPolicy = csp; return opts; }); // HSTS-параметры для продакшна. 365 дней + includeSubDomains — стандарт. // preload включаем: домен admin.food-market.kz можно подать в // hstspreload.org когда созреем, без preload директивы — нельзя. builder.Services.AddHsts(opts => { opts.MaxAge = TimeSpan.FromDays(365); opts.IncludeSubDomains = true; opts.Preload = true; }); var app = builder.Build(); // HSTS — только когда мы за HTTPS (stage/prod через nginx). В Development // обычно работаем по http://localhost:5081, и Strict-Transport-Security // прибил бы локальный браузер на год. По дефолту 365 дней + preload. if (!app.Environment.IsDevelopment()) { app.UseHsts(); } // Security-заголовки на КАЖДЫЙ ответ — раньше всех остальных middleware'ов, // чтобы они применились даже на 429/403 от rate-limiter'а. app.UseMiddleware(); // Sprint 23 / bug-003: глобальный перехватчик PostgreSQL serialization // failure'ов (SqlState=40001) — мапит на 409 Conflict + retryable:true. // Без него параллельные posting'и под Serializable отвечают 500. app.UseMiddleware(); app.UseSerilogRequestLogging(); app.UseCors(CorsPolicy); // Prometheus HTTP-метрики (http_requests_received_total, http_request_duration_seconds). // Включаем сразу после CORS и до аутентификации, чтобы видеть и 401/403 // (это сигнал об атаке/неверной конфигурации). prometheus-net уже зашивает // reserved-лейблы code/method/controller/action из route template'а — // дополнительной кастомизации не нужно, и она запрещена (см. ValidateMappings). app.UseHttpMetrics(); // До аутентификации: лимитируем перебор пароля ещё на входе, не доводя // до проверки credential'ов в БД. Перед лимитером пик form-body /connect/token, // чтобы per-username бакет имел ключ — без этого мы можем считать только по IP, // и любая NAT/CI-машина роняется первой. app.UseAuthFormUsernameKey(); app.UseRateLimiter(); // SignalR-clients не могут слать кастомные хедеры через WebSocket, поэтому // токен приходит как `?access_token=...`. До UseAuthentication перекладываем // его в Authorization-хедер, чтобы OpenIddict валидатор отработал штатно. app.Use(async (ctx, next) => { var path = ctx.Request.Path; if (path.StartsWithSegments("/hubs") && !ctx.Request.Headers.ContainsKey("Authorization") && ctx.Request.Query.TryGetValue("access_token", out var tok) && !string.IsNullOrEmpty(tok)) { ctx.Request.Headers["Authorization"] = $"Bearer {tok}"; } await next(); }); app.UseAuthentication(); app.UseAuthorization(); // После аутентификации, до контроллеров: вытягиваем OrgId/UserId из ClaimsPrincipal // и кладём в Serilog LogContext вместе с CorrelationId — каждая ILogger.Log // в пайплайне автоматически получит эти лейблы. app.UseMiddleware(); // SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но // только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*. app.UseMiddleware(); // Статика товарных изображений: физически /app/uploads (volume в compose), // публичный URL /uploads/... — раздаются public, без auth. var uploadsDir = System.IO.Path.Combine(app.Environment.ContentRootPath, "uploads"); System.IO.Directory.CreateDirectory(uploadsDir); app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions { FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsDir), RequestPath = "/uploads", }); // Swagger/OpenAPI: всегда в Development, в Production — только при IncludeSwagger=true // (используется на stage для дебага без отдельного билда). На prod admin.food-market.kz // флаг не выставляем — sensitive endpoint enumeration. var includeSwagger = app.Environment.IsDevelopment() || builder.Configuration.GetValue("IncludeSwagger"); if (includeSwagger) { app.UseSwagger(); app.UseSwaggerUI(opts => { opts.DocumentTitle = "food-market API"; opts.RoutePrefix = "swagger"; }); } app.MapControllers(); // SignalR hub для live-уведомлений per-org. Path: /hubs/notifications. // JWT доставляется либо через Authorization-заголовок (наш AuthClient // делает это автоматически), либо через query `?access_token=...` // (фронт react useNotificationsHub использует именно его, потому что // браузерные WebSocket не поддерживают custom headers). app.MapHub("/hubs/notifications"); // /metrics — текстовый Prometheus exposition format. Скрейпится prometheus-сервером // (rate=15s типично). Доступ без авторизации — стандартная практика; // на prod ограничиваем nginx-уровнем (allow 10.0.0.0/8, deny all) или basic-auth. app.MapMetrics("/metrics"); // Hangfire Dashboard на /hangfire — только SuperAdmin. Auth-фильтр // проверяет System.Security.Claims.ClaimsPrincipal (стандартный // OpenIddict-токен в Authorization-заголовке). Не вешаем UseHangfireServer — // он уже стартует через AddHangfireServer выше. if (enableHangfireServer) { app.UseHangfireDashboard("/hangfire", new Hangfire.DashboardOptions { Authorization = new[] { new foodmarket.Api.Background.SuperAdminHangfireFilter() }, // Не показываем команды Delete/Requeue по умолчанию из UI чтобы случайные клики // не разрушили scheduled — все джобы декларативные, перерегистрация делает их // идемпотентной. IgnoreAntiforgeryToken = false, }); } // Liveness: процесс отвечает — без обращения к зависимостям (Predicate=false // => ни один чек не запускается). Используется для рестарта зависшего контейнера. app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = _ => false, ResponseWriter = WriteHealthResponse, }); // Readiness: запускает чеки с тегом "ready" (БД + миграции). 503 если не готово. app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = check => check.Tags.Contains("ready"), ResponseWriter = WriteHealthResponse, }); // Backward-compat: /health = liveness (Dockerfile/nginx исторически бьют сюда). app.MapGet("/health", () => Results.Ok(new { status = "ok", time = DateTime.UtcNow })); app.MapGet("/api/_debug/whoami", (HttpContext ctx) => { var identity = ctx.User.Identity as System.Security.Claims.ClaimsIdentity; return Results.Ok(new { isAuthenticated = ctx.User.Identity?.IsAuthenticated, authType = ctx.User.Identity?.AuthenticationType, nameClaimType = identity?.NameClaimType, roleClaimType = identity?.RoleClaimType, isInRoleAdmin = ctx.User.IsInRole("Admin"), hasAdminRoleClaim = ctx.User.HasClaim(c => c.Type == Claims.Role && c.Value == "Admin"), claims = ctx.User.Claims.Select(c => new { c.Type, c.Value }), }); }).RequireAuthorization(); app.MapGet("/api/me", async (HttpContext ctx, AppDbContext db) => { var user = ctx.User; var roles = user.FindAll(Claims.Role).Select(c => c.Value).ToList(); var orgIdRaw = user.FindFirst(HttpContextTenantContext.OrganizationClaim)?.Value; var subRaw = user.FindFirst(Claims.Subject)?.Value; // Дополнительные сигналы, чтобы фронт точно знал в каком состоянии юзер // находится: orphan AppUser без живой org или без активного Employee // получает /no-organization fallback вместо белого экрана на /dashboard. bool hasLiveOrg = false; bool hasActiveEmployee = false; if (Guid.TryParse(orgIdRaw, out var orgId)) { hasLiveOrg = await db.Organizations.IgnoreQueryFilters() .AnyAsync(o => o.Id == orgId && !o.IsArchived); if (Guid.TryParse(subRaw, out var sub)) { hasActiveEmployee = await db.Employees.IgnoreQueryFilters() .AnyAsync(e => e.OrganizationId == orgId && e.UserId == sub && e.IsActive); } } return Results.Ok(new { sub = subRaw, name = user.Identity?.Name, email = user.FindFirst(Claims.Email)?.Value, roles, orgId = orgIdRaw, hasLiveOrg, hasActiveEmployee, }); }).RequireAuthorization(); // Apply migrations on every startup (idempotent). Without this, fresh // stage/prod deploys land on an empty DB and OpenIddict seeders fail. using (var scope = app.Services.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } app.Run(); } catch (Exception ex) { Log.Fatal(ex, "Application terminated unexpectedly"); } finally { Log.CloseAndFlush(); } // JSON-ответ health-проб: общий статус + по каждому чеку. text/plain по умолчанию // неудобен для мониторинга; отдаём структурировано. static Task WriteHealthResponse(HttpContext context, Microsoft.Extensions.Diagnostics.HealthChecks.HealthReport report) { context.Response.ContentType = "application/json"; return context.Response.WriteAsJsonAsync(new { status = report.Status.ToString(), totalDurationMs = report.TotalDuration.TotalMilliseconds, checks = report.Entries.Select(e => new { name = e.Key, status = e.Value.Status.ToString(), description = e.Value.Description, }), }); } // Делает сгенерированный из top-level statements класс Program видимым для // интеграционных тестов (WebApplicationFactory<Program>). public partial class Program;