using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http.Features; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.IdentityModel.Tokens; using SixLabors.ImageSharp.Web.DependencyInjection; using System.Diagnostics; using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Text; using System.Text.Json; using RadicadeServer; using RadicadeServer.API.Notifications; using RadicadeServer.API.Relationships; using RadicadeServer.Admin; using RadicadeServer.Auth; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Accounts.Local; using RadicadeServer.RecNet.Config; using RadicadeServer.RecNet.Rooms; using RadicadeServer.RecNet.Storefront; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.Radicade.Discord; using RadicadeServer.Discord.Webhooks; using RadicadeServer.Utils; const string SplitTestHardOverrides = ""; const string LocalHttpsCertPassword = "radicade-local-dev-cert"; var builder = WebApplication.CreateBuilder(args); DBConfig.LoadConfiguration(builder.Configuration); WebhookConfig.Load(builder.Configuration, builder.Environment.ContentRootPath); builder.Logging.ClearProviders(); builder.Logging.AddSimpleConsole(options => { options.SingleLine = true; options.TimestampFormat = "yyyy-MM-dd HH:mm:ss "; }); builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Database.Command", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Infrastructure", LogLevel.Warning); builder.Logging.AddFilter("Microsoft.EntityFrameworkCore.Query", LogLevel.Error); var listenUrls = builder.Configuration["Radicade:ListenUrls"]; if (!string.IsNullOrWhiteSpace(listenUrls)) { builder.WebHost.UseUrls(listenUrls.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)); } else if (builder.Environment.IsProduction()) { builder.WebHost.UseUrls("http://0.0.0.0:3030"); } else { builder.WebHost.UseUrls("https://localhost:3030"); } if (builder.Environment.IsProduction()) { var missingSecrets = new List(); if (string.IsNullOrWhiteSpace(DBConfig.AdminApiKey)) missingSecrets.Add("Radicade:AdminApiKey"); if (string.IsNullOrWhiteSpace(DBConfig.JwtKey)) missingSecrets.Add("Radicade:JwtKey"); if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey)) missingSecrets.Add("Radicade:SteamApiKey"); if (missingSecrets.Count > 0) { throw new InvalidOperationException( "Missing required production secrets: " + string.Join(", ", missingSecrets) + ". Set these as environment variables (e.g. Radicade__SteamApiKey) or in a secrets manager " + "— never commit real values into appsettings.Production.json."); } } else { if (string.IsNullOrWhiteSpace(DBConfig.JwtKey)) { DBConfig.JwtKey = "radicade-local-development-jwt-key-change-before-production"; } if (string.IsNullOrWhiteSpace(DBConfig.AdminApiKey)) { DBConfig.AdminApiKey = "radicade-local-development-admin-key"; } if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey) && string.IsNullOrWhiteSpace(builder.Configuration["Radicade:TrustUnverifiedSteamIdentity"])) { DBConfig.TrustUnverifiedSteamIdentity = true; } } if (!Directory.Exists(DBConfig.CDNImages)) Directory.CreateDirectory(DBConfig.CDNImages); if (!Directory.Exists(DBConfig.CDNStorage)) Directory.CreateDirectory(DBConfig.CDNStorage); if (!Directory.Exists(DBConfig.CDNVideos)) Directory.CreateDirectory(DBConfig.CDNVideos); if (!Directory.Exists(DBConfig.CDNCustom)) Directory.CreateDirectory(DBConfig.CDNCustom); if (!Directory.Exists(DBConfig.PersistentLogs)) Directory.CreateDirectory(DBConfig.PersistentLogs); var dataProtectionKeys = Path.Join(DBConfig.PublicData, "Persistent", "DataProtection-Keys"); if (!Directory.Exists(dataProtectionKeys)) Directory.CreateDirectory(dataProtectionKeys); var localHttpsCertPath = Path.Join(DBConfig.PublicData, "Persistent", "localhost-rsa-aes-devcert.pfx"); if (builder.Environment.IsDevelopment()) { EnsureLocalHttpsCertificate(localHttpsCertPath, LocalHttpsCertPassword); var localHttpsCertificate = LoadLocalHttpsCertificateFromStore( InstallLocalHttpsCertificate(localHttpsCertPath, LocalHttpsCertPassword)); builder.WebHost.ConfigureKestrel(options => { options.ConfigureHttpsDefaults(httpsOptions => { httpsOptions.ServerCertificate = localHttpsCertificate; httpsOptions.SslProtocols = System.Security.Authentication.SslProtocols.Tls12; }); }); } DBConfig.DebugKey = Guid.NewGuid().AltGuid() + Guid.NewGuid().AltGuid(); File.WriteAllText(Path.Join(DBConfig.PersistentLogs, "debugkey.txt"), DBConfig.DebugKey); DBConfig.LoadFiles(); SteamWhitelist.Load(); var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); if (string.IsNullOrWhiteSpace(connectionString)) throw new InvalidOperationException("Missing ConnectionStrings:DefaultConnection. Set ConnectionStrings__DefaultConnection on the VPS or add it to local appsettings.Development.json."); builder.Services.AddDbContextFactory( options => options.UseSqlServer(connectionString, sql => { sql.EnableRetryOnFailure(3); }) .ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)) ); var key = Encoding.ASCII.GetBytes(DBConfig.JwtKey); builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(options => { options.RequireHttpsMetadata = false; options.SaveToken = true; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(key), ValidateIssuer = false, ValidateAudience = false, }; options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; var path = context.HttpContext.Request.Path; if (!string.IsNullOrWhiteSpace(accessToken) && RadicadeServer.API.Notifications.NotifyHubRoutes.IsHubPath(path.Value)) { context.Token = accessToken; } return Task.CompletedTask; }, OnTokenValidated = async context => { var principal = context.Principal; if (principal == null) return; var userIdClaim = principal.FindFirst("userId"); var versionClaim = principal.FindFirst("tver"); if (userIdClaim == null || versionClaim == null) return; if (long.TryParse(userIdClaim.Value, out var userId) && int.TryParse(versionClaim.Value, out var tokenVersion) && !await TokenRevocation.IsValidAsync(context.HttpContext.RequestServices, userId, tokenVersion)) { context.Fail("Session revoked. Please sign in again."); } }, OnAuthenticationFailed = context => { return Task.CompletedTask; }, OnChallenge = context => { return Task.CompletedTask; } }; }); builder.Services.AddImageSharp(options => { options.OnParseCommandsAsync = context => { var commands = context.Commands; if (commands.TryGetValue("cropSquare", out var cropValue) && cropValue == "1" && commands.TryGetValue("width", out var widthValue) && int.TryParse(widthValue, out int width)) { commands["height"] = width.ToString(); commands["rmode"] = "crop"; } return Task.CompletedTask; }; }); builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeys)); builder.Services.AddControllers().AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = null; options.JsonSerializerOptions.WriteIndented = true; }); builder.Services.Configure(options => { options.MultipartBodyLengthLimit = 256L * 1024 * 1024; }); builder.Services.AddSignalR(options => Microsoft.AspNetCore.SignalR.HubOptionsExtensions .AddFilter(options)); builder.Services.AddSingleton(); builder.Services.AddHttpClient(); builder.Services.AddHostedService(); var isDevelopment = builder.Environment.IsDevelopment(); builder.Services.AddCors(options => { options.AddPolicy("AllowAllOrigins", builder => builder.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader()); options.AddPolicy("AdminPanel", builder => { var adminOrigins = new List(); if (!string.IsNullOrWhiteSpace(DBConfig.AdminPanelUrl)) adminOrigins.Add(DBConfig.AdminPanelUrl); if (adminOrigins.Count > 0) { builder.WithOrigins(adminOrigins.ToArray()) .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials(); } else { builder.WithOrigins("http://localhost:0") .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials(); } if (isDevelopment) { builder.SetIsOriginAllowed(origin => { if (!Uri.TryCreate(origin, UriKind.Absolute, out var uri)) return false; return uri.Host is "localhost" or "127.0.0.1" or "[::1]"; }); } }); }); var app = builder.Build(); using (var scope = app.Services.CreateScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); dbContext.Database.Migrate(); await EnsureSubRoomUnityAssetColumn(dbContext); await EnsureRoomJoinGiftColumns(dbContext); await EnsureRoomDeveloperOwnedColumn(dbContext); await EnsurePlayerEventColumns(dbContext); await EnsureVoiceChatRestrictionTable(dbContext); await EnsureValidSceneLocations(dbContext); await EnsureCatalogTables(dbContext); StageShowdownRoomContent(); await EnsureProfileIdentityColumns(dbContext); if (DBConfig.TestInfluencerAccountIds.Count > 0) { var testProfiles = await dbContext.Profiles .Where(profile => DBConfig.TestInfluencerAccountIds.Contains(profile.accountId)) .ToListAsync(); foreach (var profile in testProfiles) { if (!profile.HasRole("influencer")) profile.role = string.IsNullOrWhiteSpace(profile.role) ? "influencer" : $"{profile.role},influencer"; } if (testProfiles.Any(profile => profile.HasRole("influencer"))) await dbContext.SaveChangesAsync(); } await RemoveBlanketCoOwners(dbContext); await SyncAdminAccounts(dbContext); if (!dbContext.Profiles.Any()) { await dbContext.AddCoach(); await dbContext.AddGameConfigs(); await dbContext.AddServerConfig(); await dbContext.AddConfig(); await dbContext.AddDefaultRooms(); } if (!await dbContext.Rooms.AnyAsync(room => room.IsDorm)) { await dbContext.AddDormRoom(); } await dbContext.NormalizeDormRoomImagesAsync(); await DefaultRooms.EnsureAsync(dbContext); await FirstTimeUtils.DownloadImages(); LoadingScreenImages.EnsureInstalled(); await EnsureRequiredGameConfigs(dbContext); await RepairAvatarBootData(dbContext); await EnsureReferenceGameConfigs(dbContext); await RepairSplitTestConfigs(dbContext); await DisableCommunityBoardHowToBuildLink(dbContext); await RepairPlayerSettingsBootData(dbContext); await RepairNullProfileImages(dbContext); StorefrontCatalog.Load(); if (SteamWhitelist.CreatedEmptyConfig) { var wlOnly = await dbContext.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "WhitelistOnly"); var wl = await dbContext.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "SteamWhitelist"); if (wl != null && !string.IsNullOrWhiteSpace(wl.Value)) { SteamWhitelist.SetSteamIds(wl.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)); } if (wlOnly != null && wlOnly.Value.Equals("true", StringComparison.OrdinalIgnoreCase)) { SteamWhitelist.Enabled = true; } } await MatchmakingRuntimeState.ResetAsync(dbContext); MainConfig.ActiveConfig = await dbContext.Config.FirstOrDefaultAsync(); } static void StageShowdownRoomContent() { const string saveName = "Showdown.room"; const string bundleName = "Showdown_0_1.assetbundle"; var candidates = new[] { Path.GetFullPath(Path.Combine("..", "RoomImports", "Showdown")), Path.GetFullPath(Path.Combine("..", "room-imports", "Showdown")), Path.GetFullPath(Path.Combine("RoomImports", "Showdown")), Path.GetFullPath(Path.Combine("room-imports", "Showdown")), }; var sourceDir = candidates.FirstOrDefault(Directory.Exists) ?? candidates[0]; var storage = DBConfig.CDNStorage; Directory.CreateDirectory(storage); var saveSource = Path.Combine(sourceDir, saveName); var bundleSource = Path.Combine(sourceDir, bundleName); if (!File.Exists(saveSource) || !File.Exists(bundleSource)) { return; } File.Copy(saveSource, Path.Combine(storage, saveName), overwrite: true); File.Copy(bundleSource, Path.Combine(storage, bundleName), overwrite: true); } static async Task SyncAdminAccounts(RecDBContext dbContext) { var configured = DBConfig.AdminAccounts; var existing = await dbContext.AdminUsers.ToListAsync(); if (configured.Count == 0) { return; } foreach (var account in configured) { var user = existing.FirstOrDefault(x => string.Equals(x.Username, account.Username, StringComparison.OrdinalIgnoreCase)); if (user == null) { dbContext.AdminUsers.Add(new AdminUser { Username = account.Username, PasswordHash = AdminSecurity.HashPassword(account.Password), CreatedAt = DateTime.UtcNow }); await dbContext.SaveChangesAsync(); continue; } if (!AdminSecurity.VerifyPassword(user.PasswordHash, account.Password)) { user.PasswordHash = AdminSecurity.HashPassword(account.Password); await dbContext.SaveChangesAsync(); await AdminSession.RevokeAllAsync(dbContext, user.Id); } else if (AdminSecurity.NeedsRehash(user.PasswordHash)) { user.PasswordHash = AdminSecurity.HashPassword(account.Password); await dbContext.SaveChangesAsync(); } } var removed = existing .Where(user => !configured.Any(account => string.Equals(account.Username, user.Username, StringComparison.OrdinalIgnoreCase))) .ToList(); if (removed.Count == 0) return; foreach (var user in removed) { await AdminSession.RevokeAllAsync(dbContext, user.Id); } dbContext.AdminUsers.RemoveRange(removed); await dbContext.SaveChangesAsync(); } static async Task RemoveBlanketCoOwners(RecDBContext dbContext) { const string marker = "Config.BlanketCoOwnersRemoved"; if (await dbContext.GameConfig.AnyAsync(x => x.Key == marker)) return; var accounts = new List { 2 }; accounts.AddRange(DBConfig.TestCoOwnerAccountIds); accounts = accounts.Distinct().ToList(); var rows = await dbContext.RoomRoles .Where(role => accounts.Contains(role.AccountId) && role.Role == RoleType.CoOwner) .ToListAsync(); if (rows.Count > 0) { dbContext.RoomRoles.RemoveRange(rows); await dbContext.SaveChangesAsync(); } dbContext.GameConfig.Add(new ConfigVal(marker, "1")); await dbContext.SaveChangesAsync(); } static async Task EnsureProfileIdentityColumns(RecDBContext dbContext) { await dbContext.Database.ExecuteSqlRawAsync(@" IF COL_LENGTH('dbo.Profiles', 'personalPronouns') IS NULL ALTER TABLE [Profiles] ADD [personalPronouns] int NOT NULL CONSTRAINT [DF_Profiles_personalPronouns] DEFAULT 0; IF COL_LENGTH('dbo.Profiles', 'identityFlags') IS NULL ALTER TABLE [Profiles] ADD [identityFlags] int NOT NULL CONSTRAINT [DF_Profiles_identityFlags] DEFAULT 0; -- Profile.bannerImage / isRecentHistoryVisible. Added by a migration too, but a -- production database that drifted (see EnsurePlayerEventColumns) would reach the -- model before the column exists, and every Profiles query would then fail with an -- invalid-column-name error. Idempotent, so the migration and this repair can coexist. IF COL_LENGTH('dbo.Profiles', 'bannerImage') IS NULL ALTER TABLE [Profiles] ADD [bannerImage] nvarchar(max) NULL; IF COL_LENGTH('dbo.Profiles', 'isRecentHistoryVisible') IS NULL ALTER TABLE [Profiles] ADD [isRecentHistoryVisible] bit NOT NULL CONSTRAINT [DF_Profiles_isRecentHistoryVisible] DEFAULT 1;"); } static async Task EnsureSubRoomUnityAssetColumn(RecDBContext dbContext) { await dbContext.Database.ExecuteSqlRawAsync(@" IF COL_LENGTH('dbo.SubRooms', 'UnityAssetId') IS NULL ALTER TABLE [SubRooms] ADD [UnityAssetId] nvarchar(max) NOT NULL CONSTRAINT [DF_SubRooms_UnityAssetId] DEFAULT N'';"); } static async Task RepairNullProfileImages(RecDBContext dbContext) { var broken = await dbContext.Profiles .Where(p => p.profileImage == null || p.profileImage == "") .ToListAsync(); if (broken.Count == 0) return; foreach (var profile in broken) profile.profileImage = "DefaultProfileImage"; await dbContext.SaveChangesAsync(); } RoomUtils.DbFactory = app.Services.GetRequiredService>(); MatchmakingUtils.DbFactory = app.Services.GetRequiredService>(); Notify.DbFactory = app.Services.GetRequiredService>(); Notify.Context = app.Services.GetRequiredService>(); RelationshipsShared.DbFactory = app.Services.GetRequiredService>(); PlayerUtils.DbFactory = app.Services.GetRequiredService>(); AuthShared.DbFactory = app.Services.GetRequiredService>(); app.Use(async (context, next) => { var path = context.Request.Path.Value ?? ""; if (!path.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase) && !string.Equals(path, "/admin", StringComparison.OrdinalIgnoreCase)) { var ip = context.Request.GetClientIp(); if (!string.IsNullOrWhiteSpace(ip)) { var dbFactory = context.RequestServices.GetRequiredService>(); if (await GameBlockMonitor.IsBlockedAsync(dbFactory, ip)) { context.Response.StatusCode = StatusCodes.Status403Forbidden; await context.Response.WriteAsJsonAsync(new { error = "game_blocked", message = "This IP address is blocked from connecting to the game servers." }); return; } } } await next(); }); app.Use(async (context, next) => { var path = context.Request.Path.Value ?? ""; if (path.Contains("//")) { var collapsed = System.Text.RegularExpressions.Regex.Replace(path, "/{2,}", "/"); context.Request.Path = collapsed; } await next(); }); app.UseRouting(); app.UseCors("AllowAllOrigins"); app.Use(async (context, next) => { var path = context.Request.Path.Value ?? ""; if (path.StartsWith("/images/", StringComparison.OrdinalIgnoreCase)) { context.Response.Headers.Append("Access-Control-Allow-Origin", "*"); context.Response.Headers.Append("Access-Control-Allow-Methods", "GET, OPTIONS"); context.Response.Headers.Append("Access-Control-Allow-Headers", "*"); } await next(); }); app.Use(async (context, next) => { var path = context.Request.Path.Value ?? ""; if (path.StartsWith("/admin/", StringComparison.OrdinalIgnoreCase) && !path.StartsWith("/admin/auth", StringComparison.OrdinalIgnoreCase)) { var existingKey = context.Request.Query["key"].ToString(); if (string.IsNullOrEmpty(existingKey) || !existingKey.IsAdminKey()) { void Promote() { var query = new Dictionary(); foreach (var kv in context.Request.Query) query[kv.Key] = kv.Value; query["key"] = DBConfig.AdminApiKey; context.Request.Query = new QueryCollection(query); } if (context.Request.Cookies.TryGetValue(RadicadeServer.Auth.AdminSession.CookieName, out var sessionToken)) { var dbFactory = context.RequestServices.GetRequiredService>(); await using var authDb = await dbFactory.CreateDbContextAsync(); var (valid, _) = await RadicadeServer.Auth.AdminSession.ValidateAsync(authDb, sessionToken); if (valid) Promote(); } } } await next(); }); app.Use(async (context, next) => { var started = Stopwatch.GetTimestamp(); var originalBody = context.Response.Body; var countingBody = new CountingStream(originalBody); context.Response.Body = countingBody; try { await next(); } catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] {context.Request.Method} {context.Request.Path}{context.Request.QueryString} -> client disconnected mid-response (len={countingBody.BytesWritten})"); } catch (Exception ex) { Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] unhandled {context.Request.Method} {context.Request.Path}{context.Request.QueryString}: {ex}"); if (!context.Response.HasStarted && !context.RequestAborted.IsCancellationRequested) { context.Response.StatusCode = StatusCodes.Status500InternalServerError; await context.Response.WriteAsJsonAsync(new { error = "internal_server_error" }); } } finally { var elapsed = Stopwatch.GetElapsedTime(started).TotalMilliseconds; var bodyPreview = countingBody.BytesWritten <= 8 ? " body=" + System.Text.Encoding.UTF8.GetString(countingBody.Peek) : string.Empty; Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] {context.Request.Method} {context.Request.Path}{context.Request.QueryString} -> {context.Response.StatusCode} ({elapsed:0.0} ms) len={countingBody.BytesWritten} ct={context.Response.ContentType}{bodyPreview}"); } }); app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); app.Use(async (context, next) => { var diagnosticPaths = new HashSet(StringComparer.OrdinalIgnoreCase) { "/matchmaking/player", "/matchmaking/player/login", "/matchmaking/player/exclusivelogin", "/leaderboard/GetRanks", "/leaderboard/GetPlayerRank", "/leaderboard/GetNearbyScores", "/leaderboard/leaderboard/GetRanks", "/leaderboard/leaderboard/GetPlayerRank", "/leaderboard/leaderboard/GetNearbyScores", "/econ/api/checklist/v1/current", "/econ/api/objectives/v1/myprogress", "/api/players/v2/progression/bulk", "/accounts/account/bulk", "/api/playerReputation/v2/bulk", "/api/relationships/v2/get", "/api/PlayerReporting/v1/moderationBlockDetails", "/chat/thread", "/playersettings", "/playersettings/v2", "/playersettings/playersettings", "/api/settings", "/api/settings/v2", "/api/settings/playersettings", "/api/gameconfigs/v1/all", "/api/versioncheck/v4" }; var path = context.Request.Path.Value ?? string.Empty; if (!diagnosticPaths.Contains(path)) { await next(); return; } var originalBody = context.Response.Body; await using var capturedBody = new MemoryStream(); context.Response.Body = capturedBody; try { await next(); capturedBody.Position = 0; using var reader = new StreamReader( capturedBody, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); var responseBody = await reader.ReadToEndAsync(); var diagnosticLine = $"[PlayerDataDebug] {context.Request.Method} " + $"{context.Request.Path}{context.Request.QueryString} -> " + $"{context.Response.StatusCode} body={responseBody}"; try { var diagnosticLogPath = Path.Join(DBConfig.PersistentLogs, "player-data-debug.log"); await File.AppendAllTextAsync( diagnosticLogPath, $"[{DateTime.UtcNow:O}] {diagnosticLine}{Environment.NewLine}"); } catch { } capturedBody.Position = 0; await capturedBody.CopyToAsync(originalBody); } finally { context.Response.Body = originalBody; } }); app.Use(async (context, next) => { var leaderboardPath = context.Request.Path.Value ?? string.Empty; if (leaderboardPath.Contains("leaderboard", StringComparison.OrdinalIgnoreCase) || leaderboardPath.Contains("scoreboard", StringComparison.OrdinalIgnoreCase)) { context.Request.EnableBuffering(); string requestBody; using (var reader = new StreamReader( context.Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true)) { requestBody = await reader.ReadToEndAsync(); } context.Request.Body.Position = 0; } await next(); }); app.MapControllers(); foreach (var hubPath in RadicadeServer.API.Notifications.NotifyHubRoutes.Paths) app.MapHub(hubPath); app.MapGet("/diag/build", () => Results.Json(new { build = BuildInfo.Tag, notifyIdFormat = BuildInfo.NotifyIdFormat, assemblyBuiltUtc = BuildInfo.AssemblyBuiltUtc, startedUtc = BuildInfo.StartedUtc, })); app.MapGet("/diag/notify", async (long account, string? key, string? form) => { if (key is null || !key.IsAdminKey()) return Results.Unauthorized(); return Results.Text(await Notify.ReplayStoredCoachMessageAsync(account, form), "text/plain"); }); app.MapGet("/diag/rewardpush", async (long account, string? key, string? id, IDbContextFactory dbFactory) => { if (key is null || !key.IsAdminKey()) return Results.Unauthorized(); await using var db = await dbFactory.CreateDbContextAsync(); return Results.Text(await RewardPushDiag.ReplayPendingRewardSelectionAsync(db, account, id), "text/plain"); }); app.MapGet("/diag/rewards", async (long account, string? key, int? lines, IDbContextFactory dbFactory) => { if (key is null || !key.IsAdminKey()) return Results.Unauthorized(); await using var db = await dbFactory.CreateDbContextAsync(); return Results.Text(await RewardDiag.DescribeAsync(db, account, lines ?? 120), "text/plain"); }); app.Lifetime.ApplicationStarted.Register(() => Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Radicade] started. env={app.Environment.EnvironmentName} url={DBConfig.ServerUrl} build={BuildInfo.Tag} assemblyBuilt={BuildInfo.AssemblyBuiltUtc:u}")); app.Lifetime.ApplicationStopping.Register(() => Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Radicade] stopping.")); app.Run(); static void EnsureLocalHttpsCertificate(string certificatePath, string password) { if (File.Exists(certificatePath)) return; var certificateDirectory = Path.GetDirectoryName(certificatePath); if (!string.IsNullOrWhiteSpace(certificateDirectory) && !Directory.Exists(certificateDirectory)) Directory.CreateDirectory(certificateDirectory); using var rsa = CreateLocalHttpsKey(); var request = new CertificateRequest( "CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); request.CertificateExtensions.Add(new X509BasicConstraintsExtension(false, false, 0, false)); request.CertificateExtensions.Add(new X509KeyUsageExtension( X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment, false)); var enhancedKeyUsages = new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }; request.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(enhancedKeyUsages, false)); request.CertificateExtensions.Add(new X509SubjectKeyIdentifierExtension(request.PublicKey, false)); var subjectAltNames = new SubjectAlternativeNameBuilder(); subjectAltNames.AddDnsName("localhost"); subjectAltNames.AddIpAddress(IPAddress.Loopback); subjectAltNames.AddIpAddress(IPAddress.IPv6Loopback); request.CertificateExtensions.Add(subjectAltNames.Build()); using var certificate = request.CreateSelfSigned( DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5)); File.WriteAllBytes(certificatePath, certificate.Export(X509ContentType.Pfx, password)); } static X509Certificate2 LoadLocalHttpsCertificate(string certificatePath, string password) { return X509CertificateLoader.LoadPkcs12FromFile( certificatePath, password, X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet | X509KeyStorageFlags.Exportable); } static string InstallLocalHttpsCertificate(string certificatePath, string password) { using var certificate = LoadLocalHttpsCertificate(certificatePath, password); using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); store.Open(OpenFlags.ReadWrite); var existingCertificates = store.Certificates.Find( X509FindType.FindByThumbprint, certificate.Thumbprint, validOnly: false); if (existingCertificates.Count == 0) { store.Add(certificate); } return certificate.Thumbprint; } static X509Certificate2 LoadLocalHttpsCertificateFromStore(string thumbprint) { using var store = new X509Store(StoreName.My, StoreLocation.CurrentUser); store.Open(OpenFlags.ReadOnly); var certificates = store.Certificates.Find( X509FindType.FindByThumbprint, thumbprint, validOnly: false); if (certificates.Count == 0) throw new InvalidOperationException($"Local HTTPS certificate {thumbprint} was not found in CurrentUser\\My."); return certificates .OfType() .OrderByDescending(certificate => certificate.NotAfter) .First(); } static RSA CreateLocalHttpsKey() { if (!OperatingSystem.IsWindows()) return RSA.Create(2048); return new RSACryptoServiceProvider( 2048, new CspParameters(24, "Microsoft Enhanced RSA and AES Cryptographic Provider") { KeyContainerName = $"RadicadeLocalHttps-{Guid.NewGuid():N}", KeyNumber = (int)KeyNumber.Exchange }) { PersistKeyInCsp = false }; } static async Task EnsureRequiredGameConfigs(RecDBContext dbContext) { await UpsertGameConfigValue(dbContext, "splitTestSoftOverrides", string.Empty); await UpsertGameConfigValue(dbContext, "splitTestsAvailable", SplitTestConfigs.TestsAvailable); await UpsertGameConfigValue(dbContext, "splitTestSegmentProbabilities", SplitTestConfigs.SegmentProbabilities); await UpsertGameConfigValue(dbContext, "splitTestHardOverrides", SplitTestConfigs.HardOverrides); var probabilitiesRow = await dbContext.GameConfig.FirstOrDefaultAsync(x => x.Key == "splitTestSegmentProbabilities"); if (probabilitiesRow != null && probabilitiesRow.Value == "{}") probabilitiesRow.Value = SplitTestConfigs.SegmentProbabilities; await EnsureSplitTestHardOverrides(dbContext); await UpsertGameConfigValue(dbContext, "Backtrace.versionRegex", ".*"); await UpsertGameConfigValue(dbContext, "Backtrace.messageRegex", ".*"); await UpsertGameConfigValue(dbContext, "Backtrace.stopTimeUTC", "2099-01-01 00:00"); await UpsertGameConfigValue(dbContext, "Growth.FriendFestPromosActive", "false"); await UpsertGameConfigValue(dbContext, "Growth.FriendFestImageName", "FriendFest.jpg"); await UpsertGameConfigValue(dbContext, "Growth.EnableProfilePhoneButton", "false"); await RepairStoreLayoutConfigs(dbContext); await RepairDebugLogConfigs(dbContext); await UpsertGameConfigValue(dbContext, "Friends.PostGamePromptUnderFriendCount", "10"); await UpsertGameConfigValue(dbContext, "Friends.SuggestFriendCodeOnFriendsScreenCount", "25"); await UpsertGameConfigValue(dbContext, "MobileNotifications.UseFirebasePushNotifications", "true"); await UpsertGameConfigValue(dbContext, "UGC.CV2.CooldownBeforeSubscriptionCheckSeconds", "240"); await UnblockImportedRooms(dbContext); await EnableBetaCreativeToolsConfigs(dbContext); await dbContext.SaveChangesAsync(); } static async Task EnsureSplitTestHardOverrides(RecDBContext dbContext) { var existing = await GameConfigSeeder.FindAsync(dbContext, "splitTestHardOverrides"); if (existing == null) { await dbContext.GameConfig.AddAsync(new ConfigVal("splitTestHardOverrides", SplitTestHardOverrides)); return; } if (string.IsNullOrWhiteSpace(existing.Value)) { existing.Value = SplitTestHardOverrides; return; } var entries = existing.Value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var known = new HashSet(entries.Select(e => e.Split('=', 2)[0]), StringComparer.OrdinalIgnoreCase); var additions = new List(); foreach (var entry in SplitTestHardOverrides.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var name = entry.Split('=', 2)[0]; if (known.Add(name)) additions.Add(entry); } if (additions.Count > 0) { existing.Value = (entries.Concat(additions).ToList()).Aggregate((a, b) => a + ";" + b); } } static async Task RepairStoreLayoutConfigs(RecDBContext dbContext) { const string oldLayout = "banner=12;storecampaigns=11;tokenpack=10;allskus=9;hotitem=8;hotinvention=7;newitem=6;mysubscriptioninvention=5;rrplusitem=4;fivestaritem=3;skinsitem=2;hairdyeitem=1;"; const string newLayout = "banner=9;hotitem=7;hotinvention=8;newitem=6;mysubscriptioninvention=5;rrplusitem=4;fivestaritem=3;skinsitem=2;hairdyeitem=1;"; var keys = new[] { "OldStoreLayout", "NewStoreLayout" }; var configs = await dbContext.GameConfig.Where(x => keys.Contains(x.Key)).ToListAsync(); foreach (var config in configs) { var correct = config.Key == "OldStoreLayout" ? oldLayout : newLayout; if (string.Equals(config.Value, correct, StringComparison.Ordinal)) continue; var value = config.Value ?? string.Empty; if (string.IsNullOrWhiteSpace(value) || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) || string.Equals(value, "false", StringComparison.OrdinalIgnoreCase) || !value.StartsWith("banner=", StringComparison.OrdinalIgnoreCase)) { config.Value = correct; } } await dbContext.SaveChangesAsync(); } static async Task UnblockImportedRooms(RecDBContext dbContext) { const string key = "RoomLoad.BlockedRoomNamesOnLowMemoryDevices"; const string importedRoom = "Showdown"; var config = await dbContext.GameConfig.FirstOrDefaultAsync(x => x.Key == key); if (config == null || string.IsNullOrWhiteSpace(config.Value) || !config.Value.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) .Contains(importedRoom, StringComparer.OrdinalIgnoreCase)) return; config.Value = string.Join(",", config.Value .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) .Where(name => !name.Equals(importedRoom, StringComparison.OrdinalIgnoreCase))); } static async Task RepairDebugLogConfigs(RecDBContext dbContext) { const string supportedFlags = "Platform|BootSequence"; var flags = await dbContext.GameConfig.FirstOrDefaultAsync(x => x.Key == "Debug.AdditionalTraceFlags"); if (flags != null && (flags.Value?.Contains("DissonanceLogs", StringComparison.OrdinalIgnoreCase) == true || flags.Value?.Contains("Audio", StringComparison.OrdinalIgnoreCase) == true || flags.Value?.Contains("TachyonClient", StringComparison.OrdinalIgnoreCase) == true)) { flags.Value = supportedFlags; } var traceProbability = await dbContext.GameConfig.FirstOrDefaultAsync(x => x.Key == "Debug.TraceProbability"); if (traceProbability != null && traceProbability.Value == "1") traceProbability.Value = "0"; await dbContext.SaveChangesAsync(); } static async Task RepairAvatarBootData(RecDBContext dbContext) { const string repairedOutfitSelection = ""; const string malformedUnlock = "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,,"; const string repairedUnlock = "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,"; string[] malformedOutfitSelections = [ "d0a9262f-5504-46a7-bb10-7507503db58e,,,,1", "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,,,1", "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,,1" ]; var repairedAvatars = 0; foreach (var avatar in await dbContext.Avatars.ToListAsync()) { var changed = false; foreach (var malformedOutfitSelection in malformedOutfitSelections) { if (!string.IsNullOrWhiteSpace(avatar.Avatar) && avatar.Avatar.Contains(malformedOutfitSelection, StringComparison.OrdinalIgnoreCase)) { avatar.Avatar = avatar.Avatar.Replace( malformedOutfitSelection, repairedOutfitSelection, StringComparison.OrdinalIgnoreCase); changed = true; } if (!string.IsNullOrWhiteSpace(avatar.Outfits) && avatar.Outfits.Contains(malformedOutfitSelection, StringComparison.OrdinalIgnoreCase)) { avatar.Outfits = avatar.Outfits.Replace( malformedOutfitSelection, repairedOutfitSelection, StringComparison.OrdinalIgnoreCase); changed = true; } } if (changed) repairedAvatars++; } var repairedUnlockRows = 0; foreach (var unlocks in await dbContext.Unlocks.ToListAsync()) { if (string.IsNullOrWhiteSpace(unlocks.AvatarItems) || !unlocks.AvatarItems.Contains(malformedUnlock, StringComparison.OrdinalIgnoreCase)) { continue; } unlocks.AvatarItems = unlocks.AvatarItems.Replace( malformedUnlock, repairedUnlock, StringComparison.OrdinalIgnoreCase); repairedUnlockRows++; } if (repairedAvatars > 0 || repairedUnlockRows > 0) { await dbContext.SaveChangesAsync(); } } static Task UpsertGameConfigValue(RecDBContext dbContext, string key, string value) => GameConfigSeeder.UpsertAsync(dbContext, key, value); static async Task EnsureReferenceGameConfigs(RecDBContext dbContext) { var existingKeys = await dbContext.GameConfig .Select(x => x.Key) .ToHashSetAsync(StringComparer.OrdinalIgnoreCase); foreach (var tracked in dbContext.ChangeTracker.Entries()) { if (!string.IsNullOrWhiteSpace(tracked.Entity.Key)) existingKeys.Add(tracked.Entity.Key); } var added = 0; using var doc = JsonDocument.Parse(DBConfig.PlaceholderConfig); foreach (var item in doc.RootElement.EnumerateArray()) { if (!item.TryGetProperty("Key", out var keyElement)) continue; var key = keyElement.GetString(); if (string.IsNullOrWhiteSpace(key) || !existingKeys.Add(key)) continue; var value = item.TryGetProperty("Value", out var valueElement) ? valueElement.GetString() ?? string.Empty : string.Empty; await dbContext.GameConfig.AddAsync(new ConfigVal(key, value)); added++; } if (added > 0) { await dbContext.SaveChangesAsync(); } } static async Task RepairSplitTestConfigs(RecDBContext dbContext) { var desiredValues = new Dictionary { ["splitTestHardOverrides"] = ReferenceSplitTestValue("splitTestHardOverrides"), ["splitTestsAvailable"] = ReferenceSplitTestValue("splitTestsAvailable"), ["splitTestSegmentProbabilities"] = ReferenceSplitTestValue("splitTestSegmentProbabilities"), }; var seeded = 0; foreach (var (key, value) in desiredValues) { if (string.IsNullOrWhiteSpace(value)) continue; var config = await GameConfigSeeder.FindAsync(dbContext, key); if (config == null) { await dbContext.GameConfig.AddAsync(new ConfigVal(key, value)); seeded++; continue; } if (!string.IsNullOrWhiteSpace(config.Value)) continue; config.Value = value; seeded++; } if (seeded > 0) { await dbContext.SaveChangesAsync(); } } static async Task DisableCommunityBoardHowToBuildLink(RecDBContext dbContext) { const string key = "CommunityBoard.HowToBuildLink"; var config = await GameConfigSeeder.FindAsync(dbContext, key); if (config == null || string.IsNullOrEmpty(config.Value)) return; if (config.Value.IndexOf("youtube", StringComparison.OrdinalIgnoreCase) < 0) return; config.Value = string.Empty; await dbContext.SaveChangesAsync(); } static async Task EnableBetaCreativeToolsConfigs(RecDBContext dbContext) { var betaFlags = new Dictionary { ["UGC.Room2Enabled"] = "true", ["UGC.AllowBetaInvertedTubeCreation"] = "true", ["UGC.AllowNonBetaInvertedTubeCreation"] = "true", ["UGC.EnableInventionsInBetaRooms"] = "true", ["UGC.Room2PublishingEnabled"] = "true", ["UGC.Room2InventionsEnabled"] = "true", ["UGC.Room2InventionAllowBetaPublishing"] = "true", ["UGC.Room2InventionPublishingEnabled"] = "true", }; var updated = 0; foreach (var (key, value) in betaFlags) { var existing = await GameConfigSeeder.FindAsync(dbContext, key); if (existing == null) { await dbContext.GameConfig.AddAsync(new ConfigVal(key, value)); updated++; } else if (!string.Equals(existing.Value, value, StringComparison.OrdinalIgnoreCase)) { existing.Value = value; updated++; } } if (updated > 0) { await dbContext.SaveChangesAsync(); } } static string? ReferenceSplitTestValue(string key) { try { var entries = JsonSerializer.Deserialize>(DBConfig.PlaceholderConfig); return entries?.FirstOrDefault(x => string.Equals(x.Key, key, StringComparison.Ordinal))?.Value; } catch (Exception) { return null; } } static async Task RepairPlayerSettingsBootData(RecDBContext dbContext) { const string neutralSplitTestAssignments = "1|{}"; var repairedRows = 0; foreach (var settingsRow in await dbContext.PlayerSettings.ToListAsync()) { var settings = settingsRow.DeserializeSettings(); var splitAssignments = settings.FirstOrDefault(x => string.Equals(x.Key, "SplitTestAssignedSegments", StringComparison.OrdinalIgnoreCase)); if (splitAssignments == null) { settings.Add(new Setting { Key = "SplitTestAssignedSegments", Value = neutralSplitTestAssignments }); repairedRows++; } else if (!string.Equals(splitAssignments.Value, neutralSplitTestAssignments, StringComparison.Ordinal)) { splitAssignments.Value = neutralSplitTestAssignments; repairedRows++; } settingsRow.Settings = settings.ToJson(); } if (repairedRows > 0) { await dbContext.SaveChangesAsync(); } } static async Task EnsureRoomDeveloperOwnedColumn(RecDBContext dbContext) { await dbContext.Database.ExecuteSqlRawAsync(@" IF COL_LENGTH('dbo.Rooms', 'IsDeveloperOwned') IS NULL ALTER TABLE [Rooms] ADD [IsDeveloperOwned] bit NOT NULL CONSTRAINT [DF_Rooms_IsDeveloperOwned] DEFAULT 0;"); } static async Task EnsureRoomJoinGiftColumns(RecDBContext dbContext) { await dbContext.Database.ExecuteSqlRawAsync(@" IF COL_LENGTH('dbo.Rooms', 'JoinGiftAvatarItem') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftAvatarItem] nvarchar(max) NOT NULL DEFAULT N''; IF COL_LENGTH('dbo.Rooms', 'JoinGiftAvatarItemType') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftAvatarItemType] int NOT NULL DEFAULT 0; IF COL_LENGTH('dbo.Rooms', 'JoinGiftConsumableItem') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftConsumableItem] nvarchar(max) NOT NULL DEFAULT N''; IF COL_LENGTH('dbo.Rooms', 'JoinGiftContext') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftContext] int NOT NULL DEFAULT 2; IF COL_LENGTH('dbo.Rooms', 'JoinGiftCurrencyAmount') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftCurrencyAmount] int NOT NULL DEFAULT 0; IF COL_LENGTH('dbo.Rooms', 'JoinGiftCurrencyType') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftCurrencyType] int NOT NULL DEFAULT 1; IF COL_LENGTH('dbo.Rooms', 'JoinGiftEnabled') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftEnabled] bit NOT NULL DEFAULT 0; IF COL_LENGTH('dbo.Rooms', 'JoinGiftEquipmentItem') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftEquipmentItem] nvarchar(max) NOT NULL DEFAULT N''; IF COL_LENGTH('dbo.Rooms', 'JoinGiftEquipmentModificationGuid') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftEquipmentModificationGuid] nvarchar(max) NOT NULL DEFAULT N''; IF COL_LENGTH('dbo.Rooms', 'JoinGiftFromPlayerId') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftFromPlayerId] bigint NOT NULL DEFAULT 0; IF COL_LENGTH('dbo.Rooms', 'JoinGiftMessage') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftMessage] nvarchar(max) NOT NULL DEFAULT N'Thanks for visiting!'; IF COL_LENGTH('dbo.Rooms', 'JoinGiftOncePerPlayer') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftOncePerPlayer] bit NOT NULL DEFAULT 1; IF COL_LENGTH('dbo.Rooms', 'JoinGiftRarity') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftRarity] int NOT NULL DEFAULT 0; IF COL_LENGTH('dbo.Rooms', 'JoinGiftTitle') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftTitle] nvarchar(max) NOT NULL DEFAULT N'Room Visit Gift'; IF COL_LENGTH('dbo.Rooms', 'JoinGiftVersion') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftVersion] int NOT NULL DEFAULT 1; IF COL_LENGTH('dbo.Rooms', 'JoinGiftXp') IS NULL ALTER TABLE [Rooms] ADD [JoinGiftXp] int NOT NULL DEFAULT 0; "); // A room's join gift boxes, one row each. The Rooms row above still describes the first box, // so a room saved before this table existed keeps handing out what it always did. await dbContext.Database.ExecuteSqlRawAsync(@" IF OBJECT_ID('dbo.RoomJoinGifts', 'U') IS NULL BEGIN CREATE TABLE [RoomJoinGifts] ( [RoomJoinGiftId] bigint NOT NULL IDENTITY(1,1), [RoomId] bigint NOT NULL, [SortOrder] int NOT NULL DEFAULT 0, [Version] int NOT NULL DEFAULT 1, [Enabled] bit NOT NULL DEFAULT 1, [OncePerPlayer] bit NOT NULL DEFAULT 1, [Title] nvarchar(max) NOT NULL DEFAULT N'Room Visit Gift', [Message] nvarchar(max) NOT NULL DEFAULT N'Thanks for visiting!', [AvatarItem] nvarchar(max) NOT NULL DEFAULT N'', [AvatarItemType] int NOT NULL DEFAULT 0, [EquipmentItem] nvarchar(max) NOT NULL DEFAULT N'', [EquipmentModificationGuid] nvarchar(max) NOT NULL DEFAULT N'', [ConsumableItem] nvarchar(max) NOT NULL DEFAULT N'', [CurrencyType] int NOT NULL DEFAULT 1, [CurrencyAmount] int NOT NULL DEFAULT 0, [Xp] int NOT NULL DEFAULT 0, [Context] int NOT NULL DEFAULT 2, [Rarity] int NOT NULL DEFAULT 0, [FromPlayerId] bigint NOT NULL DEFAULT 0, CONSTRAINT [PK_RoomJoinGifts] PRIMARY KEY ([RoomJoinGiftId]) ); END IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_RoomJoinGifts_RoomId' AND object_id = OBJECT_ID(N'dbo.RoomJoinGifts')) CREATE INDEX [IX_RoomJoinGifts_RoomId] ON [RoomJoinGifts] ([RoomId]); "); } static async Task EnsurePlayerEventColumns(RecDBContext dbContext) { await dbContext.Database.ExecuteSqlRawAsync(@" IF OBJECT_ID('dbo.PlayerEvents', 'U') IS NULL BEGIN CREATE TABLE [PlayerEvents] ( [PlayerEventId] bigint NOT NULL IDENTITY(1,1), [Name] nvarchar(max) NOT NULL, [Description] nvarchar(max) NOT NULL, [ImageName] nvarchar(max) NOT NULL, [StartTime] datetime2 NOT NULL, [EndTime] datetime2 NOT NULL, [CreatorPlayerId] bigint NOT NULL, [AttendeeCount] int NOT NULL, [RoomId] bigint NOT NULL, [Accessibility] int NOT NULL, [CreatedAt] datetime2 NOT NULL, [SubRoomId] bigint NULL, [ModerationState] int NOT NULL DEFAULT 0, [ClubId] bigint NULL, [IsMultiInstance] bit NOT NULL DEFAULT 0, [SupportMultiInstanceRoomChat] bit NOT NULL DEFAULT 0, [DefaultBroadcastPermissions] int NOT NULL DEFAULT 0, [CanRequestBroadcastPermissions] int NOT NULL DEFAULT 0, [BroadcastingRoomInstanceId] bigint NULL, [TagsJson] nvarchar(max) NOT NULL DEFAULT N'[]', CONSTRAINT [PK_PlayerEvents] PRIMARY KEY ([PlayerEventId]) ); END IF OBJECT_ID('dbo.PlayerEventResponses', 'U') IS NULL BEGIN CREATE TABLE [PlayerEventResponses] ( [PlayerEventResponseId] bigint NOT NULL IDENTITY(1,1), [PlayerEventId] bigint NOT NULL, [PlayerId] bigint NOT NULL, [Type] int NOT NULL, [CreatedAt] datetime2 NOT NULL, CONSTRAINT [PK_PlayerEventResponses] PRIMARY KEY ([PlayerEventResponseId]) ); END IF COL_LENGTH('dbo.PlayerEvents', 'SubRoomId') IS NULL ALTER TABLE [PlayerEvents] ADD [SubRoomId] bigint NULL; IF COL_LENGTH('dbo.PlayerEvents', 'ModerationState') IS NULL ALTER TABLE [PlayerEvents] ADD [ModerationState] int NOT NULL DEFAULT 0; IF COL_LENGTH('dbo.PlayerEvents', 'ClubId') IS NULL ALTER TABLE [PlayerEvents] ADD [ClubId] bigint NULL; IF COL_LENGTH('dbo.PlayerEvents', 'IsMultiInstance') IS NULL ALTER TABLE [PlayerEvents] ADD [IsMultiInstance] bit NOT NULL DEFAULT 0; IF COL_LENGTH('dbo.PlayerEvents', 'SupportMultiInstanceRoomChat') IS NULL ALTER TABLE [PlayerEvents] ADD [SupportMultiInstanceRoomChat] bit NOT NULL DEFAULT 0; IF COL_LENGTH('dbo.PlayerEvents', 'DefaultBroadcastPermissions') IS NULL ALTER TABLE [PlayerEvents] ADD [DefaultBroadcastPermissions] int NOT NULL DEFAULT 0; IF COL_LENGTH('dbo.PlayerEvents', 'CanRequestBroadcastPermissions') IS NULL ALTER TABLE [PlayerEvents] ADD [CanRequestBroadcastPermissions] int NOT NULL DEFAULT 0; IF COL_LENGTH('dbo.PlayerEvents', 'BroadcastingRoomInstanceId') IS NULL ALTER TABLE [PlayerEvents] ADD [BroadcastingRoomInstanceId] bigint NULL; IF COL_LENGTH('dbo.PlayerEvents', 'TagsJson') IS NULL ALTER TABLE [PlayerEvents] ADD [TagsJson] nvarchar(max) NOT NULL DEFAULT N'[]'; IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId = '20260805082102_FixModelChanges') INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ('20260805082102_FixModelChanges', '9.0.11'); IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId = '20260806004625_PlayerEventResponses') INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ('20260806004625_PlayerEventResponses', '9.0.11'); IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId = '20260814120000_AddPlayerEventModerationFields') INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ('20260814120000_AddPlayerEventModerationFields', '9.0.11'); IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId = '20260926120000_AddPlayerEventClubMetadata') INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ('20260926120000_AddPlayerEventClubMetadata', '9.0.11'); IF NOT EXISTS (SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId = '20261008120000_AddPlayerEventBroadcastingRoomInstance') INSERT INTO __EFMigrationsHistory (MigrationId, ProductVersion) VALUES ('20261008120000_AddPlayerEventBroadcastingRoomInstance', '9.0.11'); "); } static async Task EnsureVoiceChatRestrictionTable(RecDBContext dbContext) { await dbContext.Database.ExecuteSqlRawAsync(@" IF OBJECT_ID('dbo.VoiceChatRestrictions', 'U') IS NULL BEGIN CREATE TABLE [VoiceChatRestrictions] ( [Id] bigint NOT NULL IDENTITY(1,1), [PlayerId] bigint NOT NULL, [Reason] nvarchar(max) NOT NULL DEFAULT N'', [Category] int NOT NULL DEFAULT 0, [StartedAt] datetime2 NOT NULL, [ExpiresAt] datetime2 NOT NULL, [IssuedBy] nvarchar(max) NOT NULL DEFAULT N'Admin Panel', [IssuedByUsername] nvarchar(max) NULL, [ModeratorNote] nvarchar(max) NULL, [ClearedAt] datetime2 NULL, [ClearedBy] nvarchar(max) NULL, CONSTRAINT [PK_VoiceChatRestrictions] PRIMARY KEY ([Id]) ); CREATE INDEX [IX_VoiceChatRestrictions_PlayerId] ON [VoiceChatRestrictions] ([PlayerId]); END "); } static async Task EnsureValidSceneLocations(RecDBContext dbContext) { var subRooms = await dbContext.SubRooms.ToListAsync(); if (subRooms.Count == 0) return; var roomIds = subRooms.Select(s => s.RoomId).Distinct().ToList(); var rooms = await dbContext.Rooms .Where(r => roomIds.Contains(r.RoomId)) .ToDictionaryAsync(r => r.RoomId); var byRoom = subRooms .GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => g.ToList()); int repaired = 0; foreach (var subRoom in subRooms) { if (SceneLocations.IsValid(subRoom.UnitySceneId)) continue; bool isDorm = rooms.TryGetValue(subRoom.RoomId, out var room) && room.IsDorm; var siblings = byRoom.TryGetValue(subRoom.RoomId, out var list) ? list.Select(s => s.UnitySceneId) : Enumerable.Empty(); subRoom.UnitySceneId = SceneLocations.ResolveFallback(siblings, isDorm); repaired++; } if (repaired > 0) { await dbContext.SaveChangesAsync(); } } static async Task EnsureCatalogTables(RecDBContext dbContext) { await dbContext.Database.ExecuteSqlRawAsync(@" IF OBJECT_ID('dbo.ItemSets', 'U') IS NULL BEGIN CREATE TABLE [ItemSets] ( [ItemSetId] bigint NOT NULL IDENTITY(1,1), [FriendlyName] nvarchar(max) NOT NULL DEFAULT N'', CONSTRAINT [PK_ItemSets] PRIMARY KEY ([ItemSetId]) ); END IF OBJECT_ID('dbo.Tags', 'U') IS NULL BEGIN CREATE TABLE [Tags] ( [TagId] bigint NOT NULL IDENTITY(1,1), [Tag] nvarchar(max) NOT NULL DEFAULT N'', [Description] nvarchar(max) NOT NULL DEFAULT N'', CONSTRAINT [PK_Tags] PRIMARY KEY ([TagId]) ); END "); } internal sealed class CountingStream : Stream { private readonly Stream _inner; private long _bytes; private readonly System.Collections.Generic.List _captured = new(); public long BytesWritten => _bytes; public byte[] Peek => _captured.ToArray(); public CountingStream(Stream inner) => _inner = inner; public override bool CanRead => _inner.CanRead; public override bool CanSeek => _inner.CanSeek; public override bool CanWrite => _inner.CanWrite; public override long Length => _inner.Length; public override long Position { get => _inner.Position; set => _inner.Position = value; } public override void Flush() => _inner.Flush(); public override Task FlushAsync(CancellationToken ct) => _inner.FlushAsync(ct); public override int Read(byte[] buffer, int offset, int count) => _inner.Read(buffer, offset, count); public override long Seek(long offset, SeekOrigin origin) => _inner.Seek(offset, origin); public override void SetLength(long value) => _inner.SetLength(value); public override void Write(byte[] buffer, int offset, int count) { _inner.Write(buffer, offset, count); _bytes += count; if (_captured.Count < 8) { var take = Math.Min(count, 8 - _captured.Count); for (var i = 0; i < take; i++) _captured.Add(buffer[offset + i]); } } public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) { await _inner.WriteAsync(buffer, offset, count, ct); _bytes += count; if (_captured.Count < 8) { var take = Math.Min(count, 8 - _captured.Count); for (var i = 0; i < take; i++) _captured.Add(buffer[offset + i]); } } }