using System.Text; using System.Text.Json.Serialization; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using Orleans.Configuration; using Orleans.Hosting; using StackExchange.Redis; using Vanguard.Content; using Vanguard.Rules.Cards; using Vanguard.Rules.Decks; using Vanguard.Server.Api; using Vanguard.Server.Auth; using Vanguard.Server.Configuration; using Vanguard.Server.Matches; using Vanguard.Server.Matchmaking; using Vanguard.Server.Meta; using Vanguard.Server.Profiles; using Vanguard.Server.Realtime; using Vanguard.Server.Storage; using Vanguard.Server.Tickets; var builder = WebApplication.CreateBuilder(args); builder.Services.Configure(builder.Configuration.GetSection("Game")); builder.Services.Configure(builder.Configuration.GetSection("Content")); builder.Services.Configure(builder.Configuration.GetSection("Storage")); builder.Services.Configure(builder.Configuration.GetSection("Redis")); builder.Services.Configure(builder.Configuration.GetSection("Meta")); builder.Services.Configure(builder.Configuration.GetSection("Jwt")); builder.Services.Configure(builder.Configuration.GetSection(OrleansOptions.SectionName)); var orleansOptions = builder.Configuration .GetSection(OrleansOptions.SectionName) .Get() ?? new OrleansOptions(); if (orleansOptions.Enabled) { builder.Host.UseOrleans(silo => { // Один silo на инстанс сервера. Для нескольких реплик нужен настоящий // кластер (AdoNet/Redis clustering), но контракт грана уже готов. silo.UseLocalhostClustering(orleansOptions.SiloPort, orleansOptions.GatewayPort); silo.Configure(options => options.CollectionAge = TimeSpan.FromHours(12)); }); } builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); builder.Services.AddSingleton(TimeProvider.System); builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); builder.Services.AddResponseCompression(options => options.EnableForHttps = true); var contentOptions = builder.Configuration.GetSection("Content").Get() ?? new ContentOptions(); var setsDirectory = ContentPaths.Resolve(contentOptions.Path); var contentDirectory = Path.GetDirectoryName(setsDirectory)!; builder.Services.AddSingleton(sp => { var content = sp.GetRequiredService(); return CatalogLoader.LoadDirectory(ContentPaths.Resolve(content.Path)); }); builder.Services.AddSingleton(TrialDeckCatalog.Load(Path.Combine(contentDirectory, "trial-decks.yaml"))); builder.Services.AddSingleton(PackOdds.Load(Path.Combine(contentDirectory, "pack_odds.yaml"))); var storage = builder.Configuration.GetSection("Storage").Get() ?? new StorageOptions(); var storageProvider = storage.Provider?.Trim() ?? "Memory"; if (storageProvider.Equals("Postgres", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrWhiteSpace(storage.ConnectionString)) throw new InvalidOperationException("Storage:ConnectionString обязателен для Provider=Postgres."); builder.Services.AddDbContextFactory(options => options.UseNpgsql(storage.ConnectionString)); builder.Services.AddSingleton(); } else if (storageProvider.Equals("Memory", StringComparison.OrdinalIgnoreCase)) { builder.Services.AddDbContextFactory(options => options.UseInMemoryDatabase("vanguard")); builder.Services.AddSingleton(); } else if (storageProvider.Equals("None", StringComparison.OrdinalIgnoreCase)) { // мета-API всё равно работает в памяти, а матчи — без сохранения builder.Services.AddDbContextFactory(options => options.UseInMemoryDatabase("vanguard-dev")); builder.Services.AddSingleton(); } else { throw new InvalidOperationException( $"Неизвестный Storage:Provider «{storageProvider}». Ожидается None, Memory или Postgres."); } var redis = builder.Configuration.GetSection("Redis").Get() ?? new RedisOptions(); if (!string.IsNullOrWhiteSpace(redis.Configuration)) { builder.Services.AddSingleton(_ => { var configuration = ConfigurationOptions.Parse(redis.Configuration); configuration.AbortOnConnectFail = false; return ConnectionMultiplexer.Connect(configuration); }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); } else { builder.Services.AddSingleton(); builder.Services.AddSingleton(); } var jwt = builder.Configuration.GetSection("Jwt").Get() ?? new JwtOptions(); if (jwt.SigningKey.Length < 32) throw new InvalidOperationException("Jwt:SigningKey должен быть не короче 32 символов."); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = jwt.Issuer, ValidateAudience = true, ValidAudience = jwt.Audience, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.SigningKey)), ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30), NameClaimType = System.Security.Claims.ClaimTypes.Name, }; }); builder.Services.AddAuthorization(); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddFixedWindowLimiter("auth", limiter => { limiter.PermitLimit = 60; limiter.Window = TimeSpan.FromMinutes(1); limiter.QueueLimit = 0; }); options.AddFixedWindowLimiter("mutating", limiter => { limiter.PermitLimit = 300; limiter.Window = TimeSpan.FromMinutes(1); limiter.QueueLimit = 0; }); options.GlobalLimiter = PartitionedRateLimiter.Create(context => { if (HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) return RateLimitPartition.GetNoLimiter("read"); var key = context.Connection.RemoteIpAddress?.ToString() ?? "local"; return RateLimitPartition.GetFixedWindowLimiter(key, _ => new FixedWindowRateLimiterOptions { PermitLimit = 300, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, }); }); }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); var app = builder.Build(); var catalog = app.Services.GetRequiredService(); foreach (var deck in app.Services.GetRequiredService().Decks) { var violations = DeckValidator.Validate(catalog, deck.ToDeck()); if (violations.Count > 0) { throw new InvalidOperationException( $"Триал-дека {deck.Id}: " + string.Join("; ", violations.Select(item => item.Message))); } } var dbFactory = app.Services.GetRequiredService>(); await using (var db = await dbFactory.CreateDbContextAsync()) { if (db.Database.IsRelational()) await db.Database.MigrateAsync(); else await db.Database.EnsureCreatedAsync(); } // незавершённые матчи поднимаем до того, как сервер начнёт принимать подключения await app.Services.GetRequiredService().RecoverAsync(); await app.Services.GetRequiredService().LoadQueuedAsync(); app.UseResponseCompression(); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30), }); app.MapAuthEndpoints(); app.MapMetaEndpoints(); app.MapGameApi(); app.Map("/game", GameWebSocketEndpoint.HandleAsync); app.Run(); /// Точка входа доступна интеграционным тестам через WebApplicationFactory. public partial class Program { }