using Application; using Application.Abstractions.Chat; using Application.Abstractions.Hub; using Infrastructure; using SharedKernel; using System.Reflection; using System.Threading.RateLimiting; using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.ResponseCompression; using Microsoft.Extensions.Hosting.WindowsServices; using Serilog; using Web.Api; using Web.Api.Common; using Web.Api.Extensions; using Web.Api.Hubs; using Web.Api.Services; using Infrastructure.Hubs; var builder = WebApplication.CreateBuilder(args); var settings = builder.Configuration.Get()!; if (!WindowsServiceHelpers.IsWindowsService()) { Console.Title = settings.App.Name; } Console.WriteLine($"ENV={builder.Environment.EnvironmentName}"); Console.WriteLine($"현재 시간: {DateTime.Now} / {TimeZoneInfo.Local.Id}"); builder.Host.UseWindowsService(); // Serilog — Production에서만 파일 로그 if (builder.Environment.IsProduction()) { Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(builder.Configuration) .WriteTo.File("logs/webapi-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 30, outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{Level:u3}] {Message:lj}{NewLine}{Exception}") .CreateLogger(); builder.Host.UseSerilog(); } builder.Services.Configure(builder.Configuration); builder.Services.AddApiForwardedHeaders(settings); // Cloudflare → IIS reverse proxy 헤더 신뢰 builder.Services .AddApplication() .AddPresentation(settings) .AddApiInfrastructure(builder.Configuration); // 모든 DateTime 응답에 UTC(Z) 마커 강제 — SQL Server datetime2 + EF Core 가 Kind=Unspecified 로 // 읽어서 JS new Date() 가 로컬 시간으로 오해하는 문제 해결 (KST 환경에서 9시간 차이). builder.Services.ConfigureHttpJsonOptions(opts => { opts.SerializerOptions.Converters.Add(new UtcDateTimeConverter()); opts.SerializerOptions.Converters.Add(new UtcNullableDateTimeConverter()); }); // CORS builder.Services.AddCors(options => { options.AddPolicy(settings.CorsPolicy.Name, policy => { policy .WithOrigins(settings.CorsPolicy.AllowedOrigins.ToArray()) .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials() .SetPreflightMaxAge(TimeSpan.FromSeconds(settings.CorsPolicy.PreflightMaxAgeSeconds)); }); }); // SignalR builder.Services.AddSignalR(); // Response 압축 (Brotli/Gzip) — JSON/HTML 응답 크기 대폭 절감 builder.Services.AddResponseCompression(options => { options.EnableForHttps = true; options.Providers.Add(); options.Providers.Add(); }); // Output cache 정책 등록 (글로벌 적용 X — endpoint 별 .CacheOutput("PublicShort") opt-in) builder.Services.AddOutputCache(options => { options.AddPolicy("PublicShort", b => b.Cache().Expire(TimeSpan.FromSeconds(30)).SetVaryByQuery("*")); }); // Rate limiter 등록 (글로벌 적용 X — endpoint 별 .RequireRateLimiting("api") opt-in) builder.Services.AddRateLimiter(options => { options.AddFixedWindowLimiter("api", w => { w.PermitLimit = 100; w.Window = TimeSpan.FromMinutes(1); w.QueueLimit = 0; }); options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; }); // Chat builder.Services.AddSingleton(); builder.Services.AddHostedService(); // Endpoints builder.Services.AddEndpoints(Assembly.GetExecutingAssembly()); builder.Logging.AddConsole(); /** * ======================================================================================================================================================= */ var app = builder.Build(); /** * ======================================================================================================================================================= */ // NICE 본인인증 시크릿 미주입(placeholder) 감지 — Secret File 치환 누락 시, 사용자에게 혼란스러운 // "권한 오류(1006)" 대신 부팅 로그로 운영자에게 즉시 경고. (IsConfigured=false 라 런타임은 "준비 중" 으로 안전 동작) { var niceOptions = app.Services.GetRequiredService>().Value; if (niceOptions.HasUnsubstitutedPlaceholder) { app.Logger.LogWarning("NICE 본인인증 설정에 미치환 placeholder(__INJECT_)가 남아 있습니다. Jenkins Secret File(PROD/DEV-DPOT-API) 주입 누락 — 본인인증은 '준비 중(NotConfigured)' 으로 동작합니다."); } } // 상태 확인\ app.MapHealthChecks("/health"); // ForwardedHeaders 는 다른 미들웨어보다 먼저 — Exception/Cors/Auth 가 client IP 를 알아야 정확. app.UseForwardedHeaders(); app.UseExceptionHandler(); app.UseResponseCompression(); app.UseCors(settings.CorsPolicy.Name); // Swagger 는 UseCors 다음에 등록해야 swagger.json GET 응답에 CORS 헤더가 적용됨 // (이전 순서에서는 preflight 만 통과하고 실제 GET 응답에 Access-Control-Allow-Origin 누락) // // Public swagger.json 은 모든 환경에 노출 — developers.dpot.live 의 Scalar 가 client-side fetch. // Internal swagger 는 외부 노출 금지 → Production 에서는 /swagger/internal/* 를 404 로 차단. if (!app.Environment.IsDevelopment()) { app.Use(async (ctx, next) => { if (ctx.Request.Path.StartsWithSegments("/swagger/internal")) { ctx.Response.StatusCode = StatusCodes.Status404NotFound; return; } await next(); }); } app.UseSwagger(); if (app.Environment.IsDevelopment()) { // NICE 통합인증 암복호화 invariants 자체 검증 (런타임/패키지 회귀 방지) Infrastructure.Kyc.Nice.NiceCryptoInvariants.EnsureOrThrow(); app.UseSwaggerUI(c => { c.SwaggerEndpoint("/swagger/internal/swagger.json", "DPOT Internal API"); c.SwaggerEndpoint("/swagger/public/swagger.json", "DPOT Public API"); }); } // 외부 storage 경로 (deploy 영향 받지 않음) 의 업로드 파일 정적 서빙. var storageBase = builder.Configuration.GetSection(SharedKernel.Storage.StorageOptions.SectionName).Get()?.BasePath; if (!string.IsNullOrWhiteSpace(storageBase) && Directory.Exists(storageBase)) { var uploadsDir = Path.Combine(storageBase, "uploads"); if (Directory.Exists(uploadsDir)) { app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions { FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(uploadsDir), RequestPath = "/uploads" }); } var editorsDir = Path.Combine(storageBase, "editors"); if (Directory.Exists(editorsDir)) { app.UseStaticFiles(new Microsoft.AspNetCore.Builder.StaticFileOptions { FileProvider = new Microsoft.Extensions.FileProviders.PhysicalFileProvider(editorsDir), RequestPath = "/editors" }); } } app.UseStaticFiles(); app.UseAuthentication(); app.UseAuthorization(); app.UseOutputCache(); app.UseRateLimiter(); app.UseMiddleware(); app.UseMiddleware(); app.MapEndpoints(); app.MapHub("/hubs/chat"); app.MapHub("/hubs/app"); app.MapHub("/hubs/donation"); // 서버 시작 시 stale 연결 정보 초기화 (Web.Api 비정상 종료 후 남은 고아 데이터 제거). // 모든 SignalR client 는 reconnect 시 OnConnected 로 재등록됨. using (var scope = app.Services.CreateScope()) { await scope.ServiceProvider.GetRequiredService().ClearAllAsync(); await scope.ServiceProvider.GetRequiredService().ClearAllAsync(); await scope.ServiceProvider.GetRequiredService().ClearAllAsync(); } app.Run();