food-market/src/food-market.api/Program.cs
nns 284ad095c1
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
fix(s23): adversarial bug-hunt — 4 bugs found, all fixed
Sprint 23 (adversarial): атаковали систему как недоброжелатель.
Найдено 4 бага, все починены.

Bug #001 (Medium): NULL-byte в Product.Name вызывал 500 без тела.
  Postgres TEXT не принимает \x00. Добавил NoControlChars() в
  ProductInputValidator + CounterpartyInputValidator.

Bug #002 (Low): ProductInputValidator MaximumLength(200) конфликтовал
  со StringLength(500) в DTO и schema HasMaxLength(500). Сделал 500
  везде. Counterparty: 200 → 255 (matches HasMaxLength).

Bug #003 (CRITICAL): параллельные posting'и под Serializable выбрасывали
  PostgresException 40001 → middleware → 500 empty body. Добавил
  SerializationConflictMiddleware который мапит 40001 → 409 Conflict
  с {error, retryable: true}. Также SerializableRetry helper для
  явного retry внутри endpoint'ов с exp backoff. Применил retry-wrap
  к RetailSalesController.Post (PostCoreAsync extracted).

Bug #004 (Low): цена 0.0000001 округлялась до 0 уже после прохождения
  required-price check (check был ДО RoundIfNeeded). FindMissing-
  RequiredPriceAsync теперь округляет перед сравнением — required
  цена реально > 0 после rounding.

Bug reports: tests/e2e/reports/bugs/bug-00[1-4]-*.md (github-issue format).

Multi-tenant attacks (cat 3): clean — все cross-org GET/PUT/DELETE
дают 404, bulk-update affected=0, lists не утекают.
Auth-edge (cat 2): clean — JWT tampering 401, garbage 401, CORS evil.com
не получает allow-origin, fake refresh 400 invalid_grant.
DOS (cat 7): clean — 50MB body 413, 200 headers 431, long URL 200.
Hangfire safety (cat 8): clean — regular Admin → /hangfire 403,
  seed-demo использует tenant context, body org-id игнорируется.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 01:35:50 +05:00

697 lines
41 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string[]>()
?? ["http://localhost:5173"];
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials();
}));
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, HttpContextTenantContext>();
// Sprint 20 / TD-3: Mapster config — singleton TypeAdapterConfig.
// Используется в контроллерах через ProjectToType<TDto>(MapsterConfig.Config)
// для SQL-friendly проекций. IMapper в DI не регистрируем, потому что
// ProjectToType<T>() работает напрямую с 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<Microsoft.AspNetCore.Authentication.IClaimsTransformation,
foodmarket.Api.Infrastructure.Tenancy.SuperAdminOverrideClaimsTransformer>();
// Prometheus metrics: singleton-интерсептор EF засекает длительность каждого
// SQL-запроса (см. food_market_db_query_duration_seconds). HTTP-метрики
// включаются ниже через UseHttpMetrics(). /metrics endpoint доступен всем
// (стандартная практика для Prometheus scrape) — на prod закрываем nginx'ом.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Observability.DbMetricsInterceptor>();
// OrgAuditInterceptor — scoped (зависит от ITenantContext). EF тащит его
// через AddInterceptors на каждое создание DbContext (DbContext тоже scoped).
builder.Services.AddScoped<foodmarket.Infrastructure.Persistence.OrgAuditInterceptor>();
// 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<AppDbContext>((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<User, Role>(opts =>
{
opts.Password.RequireDigit = true;
opts.Password.RequiredLength = 8;
opts.Password.RequireNonAlphanumeric = false;
opts.Password.RequireUppercase = false;
opts.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();
builder.Services.Configure<IdentityOptions>(opts =>
{
opts.ClaimsIdentity.UserNameClaimType = Claims.Name;
opts.ClaimsIdentity.UserIdClaimType = Claims.Subject;
opts.ClaimsIdentity.RoleClaimType = Claims.Role;
});
builder.Services.AddOpenIddict()
.AddCore(opts => opts.UseEntityFrameworkCore().UseDbContext<AppDbContext>())
.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<Microsoft.AspNetCore.Authorization.IAuthorizationPolicyProvider,
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationPolicyProvider>();
builder.Services.AddScoped<Microsoft.AspNetCore.Authorization.IAuthorizationHandler,
foodmarket.Api.Infrastructure.Authorization.PermissionAuthorizationHandler>();
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
// Sprint 13: централизованный логгер sensitive-операций (смена пароля,
// 2FA, выдача роли, смена владельца, revoke-all sessions). Пишет в
// org_audit_log + Serilog. См. SensitiveOpsAudit.
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit>();
// Sprint 14: генерация thumb/medium WebP-вариантов при загрузке картинки товара.
builder.Services.AddScoped<foodmarket.Api.Storage.ImageVariantService>();
// 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<foodmarket.Api.Infrastructure.Health.DatabaseReadyHealthCheck>(
"database", tags: ["ready"]);
// Email-отправка через MailKit. Singleton — внутри открывает scope для
// свежего DbContext'а, поэтому конфиг (PlatformSettings) перечитывается
// на каждой отправке без рестарта приложения.
builder.Services.AddSingleton<foodmarket.Application.Common.Email.IEmailSender,
foodmarket.Infrastructure.Email.MailKitEmailSender>();
// ─ Sprint 11: ОФД (фискализация чеков в РК) ────────────────────────
// Все провайдеры регистрируются одновременно — фабрика выбирает по
// FiscalProvider в настройках организации. Default — None: чеки
// проводятся без фискализации, поведение «как до Sprint 11».
//
// Mock = Transient (без HTTP); остальные через AddHttpClient — это
// даёт автоматический pooled HttpMessageHandler + интеграцию с
// IHttpClientFactory (вынос в Polly / переопределение handler'а
// в тестах через .AddHttpMessageHandler).
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider,
foodmarket.Infrastructure.Fiscal.MockFiscalProvider>();
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.WebkassaProvider>();
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.WebkassaProvider>());
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.Kassa24Provider>();
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.Kassa24Provider>());
builder.Services.AddHttpClient<foodmarket.Infrastructure.Fiscal.OfdSoloProvider>();
builder.Services.AddTransient<foodmarket.Application.Common.Fiscal.IFiscalProvider>(sp =>
sp.GetRequiredService<foodmarket.Infrastructure.Fiscal.OfdSoloProvider>());
builder.Services.AddScoped<foodmarket.Application.Common.Fiscal.IFiscalProviderFactory,
foodmarket.Infrastructure.Fiscal.FiscalProviderFactory>();
// EmailTemplates загружает embedded HTML и подставляет {{key}} —
// см. Resources/EmailTemplates/*.html. Singleton с in-memory cache.
builder.Services.AddSingleton<foodmarket.Api.Infrastructure.Email.EmailTemplates>();
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<foodmarket.Application.Purchases.Commands.ISupplyWriter,
foodmarket.Api.Infrastructure.Cqrs.UnusedSupplyWriter>();
builder.Services.AddTransient<foodmarket.Application.Sales.Commands.IRetailSalePoster,
foodmarket.Api.Infrastructure.Cqrs.UnusedRetailSalePoster>();
// FluentValidation: автоматическая регистрация валидаторов из сборки api.
// ValidationFilter гоняет валидаторы на каждом контроллер-action перед
// вызовом — fail возвращает 400 ValidationProblemDetails (RFC 7807).
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddScoped<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
builder.Services.AddControllers(o =>
{
// Глобальный action filter — пишет audit-log при успешных мутациях
// в режиме «SuperAdmin открыть как… + edit-mode» (Phase 3).
o.Filters.AddService<foodmarket.Api.Infrastructure.Tenancy.SuperAdminEditAuditFilter>();
o.Filters.AddService<foodmarket.Api.Infrastructure.Validation.ValidationFilter>();
});
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<string>(),
});
// Стабильные operationId для генерации TS-клиентов:
// <Controller>_<Verb><Action>. 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<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladClient>(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<foodmarket.Infrastructure.Integrations.MoySklad.MoySkladImportService>();
builder.Services.AddSingleton<foodmarket.Infrastructure.Integrations.MoySklad.ImportJobRegistry>();
// Inventory
builder.Services.AddScoped<foodmarket.Application.Inventory.IStockService, foodmarket.Infrastructure.Inventory.StockService>();
// 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<foodmarket.Api.Background.HangfireJobsConfigurator>();
// Sprint 14: timing-фильтр для всех job'ов — пишет длительность каждого
// выполнения в Serilog. Долгие (>30с) логируются как Warning.
builder.Services.AddSingleton<foodmarket.Api.Background.JobTimingFilter>();
builder.Services.AddHostedService<foodmarket.Api.Background.HangfireGlobalFilterRegistrar>();
}
builder.Services.AddScoped<foodmarket.Api.Background.HousekeepingJobs>();
// Sprint 20: DB VACUUM ANALYZE + disk monitoring.
builder.Services.AddScoped<foodmarket.Api.Background.DatabaseMaintenanceJobs>();
builder.Services.AddScoped<foodmarket.Api.Background.DiskMonitoringJob>();
// Sprint 22: GDPR org export + DB schema docs.
builder.Services.AddScoped<foodmarket.Api.Background.OrgExportJob>();
builder.Services.AddScoped<foodmarket.Api.Background.DbSchemaDocsJob>();
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
// Telegram-бот владельца. Token + username берём из конфига; если token
// пустой — клиент тихо отключён, RunAsync джоба возвращается сразу. UI
// переходит в режим «бот не настроен» — кнопка «Привязать Telegram»
// показывает подсказку об env-переменной.
builder.Services.Configure<foodmarket.Api.Integrations.Telegram.TelegramOptions>(
builder.Configuration.GetSection("Telegram"));
builder.Services.AddHttpClient<foodmarket.Api.Integrations.Telegram.ITelegramBotClient,
foodmarket.Api.Integrations.Telegram.TelegramBotClient>(c =>
{
c.Timeout = TimeSpan.FromSeconds(15);
});
builder.Services.AddScoped<foodmarket.Api.Background.OwnerDailySummaryJob>();
// 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<foodmarket.Api.Realtime.INotificationsPublisher,
foodmarket.Api.Realtime.NotificationsPublisher>();
builder.Services.AddHostedService<OpenIddictClientSeeder>();
builder.Services.AddHostedService<SystemReferenceSeeder>();
builder.Services.AddHostedService<DevDataSeeder>();
// Демо-сидер для stage'a (триггерится через POST /api/admin/seed-demo).
builder.Services.AddScoped<foodmarket.Api.Seed.DemoTenantSeeder>();
// Расширенный сидер на год операций (POST /api/admin/seed-demo?years=1).
builder.Services.AddScoped<foodmarket.Api.Seed.YearDemoSeeder>();
builder.Services.AddHostedService<foodmarket.Api.Background.ReferencePriceRefreshJob>();
// 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<DemoCatalogSeeder>();
// 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<foodmarket.Api.Infrastructure.Security.SecurityHeadersMiddleware>();
// Sprint 23 / bug-003: глобальный перехватчик PostgreSQL serialization
// failure'ов (SqlState=40001) — мапит на 409 Conflict + retryable:true.
// Без него параллельные posting'и под Serializable отвечают 500.
app.UseMiddleware<foodmarket.Api.Infrastructure.SerializationConflictMiddleware>();
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<foodmarket.Api.Infrastructure.Observability.LogEnrichmentMiddleware>();
// SuperAdmin «открыть как…» — тот же tenant как у выбранной орги, но
// только GET. Любая мутация → 403, кроме /api/super-admin/* и /connect/*.
app.UseMiddleware<foodmarket.Api.Infrastructure.Tenancy.ReadonlyOverrideMiddleware>();
// Статика товарных изображений: физически /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<bool>("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<foodmarket.Api.Realtime.NotificationsHub>("/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<AppDbContext>();
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&lt;Program&gt;).
public partial class Program;