using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Identity; using System.Globalization; using RadicadeServer.Auth; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Notifications; using RadicadeServer.Radicade.Discord; using RadicadeServer.Radicade.Mail; using RadicadeServer.API.Storefront; using RadicadeServer.DB; using RadicadeServer.RecNet; using RadicadeServer.RecNet.Enums.Players.Avatar; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Accounts; using RadicadeServer.RecNet.Accounts.Local; using RadicadeServer.RecNet.Auth; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Board; using RadicadeServer.RecNet.Enums.Messages; using RadicadeServer.RecNet.Enums.Moderation; using RadicadeServer.RecNet.Enums; using RadicadeServer.RecNet.Enums.Players; using RadicadeServer.RecNet.Enums.Rooms; using RadicadeServer.RecNet.Config; using RadicadeServer.RecNet.VersionCheck; using RadicadeServer.RecNet.DB; using RadicadeServer.RecNet.Enums.Store; using RadicadeServer.RecNet.Maintenance; using RadicadeServer.RecNet.Messages; using RadicadeServer.RecNet.Moderation; using RadicadeServer.RecNet.Notifications; using RadicadeServer.RecNet.Progression; using RadicadeServer.RecNet.Relationships; using RadicadeServer.RecNet.Rooms; using RadicadeServer.RecNet.Responses; using RadicadeServer.RecNet.Store; using RadicadeServer.RecNet.Storefront; using RadicadeServer.API.Moderation; using RadicadeServer.Clubs; using RadicadeServer.Utils; namespace RadicadeServer.Admin; [Route("admin/panel")] [EnableCors("AdminPanel")] public class AdminPanelAPI(RecDBContext db) : Controller { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private static readonly object StateLock = new(); private static readonly PasswordHasher PasswordHasher = new(); private const long RadicadeTeamAccountId = 1; private static string StatePath => Path.Join(DBConfig.PersistentLogs, "admin-panel-state.json"); private static readonly SemaphoreSlim WeeklySaveGate = new(1); private static List WeeklyRewards() { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; var equipment = JsonSerializer.Deserialize>(DBConfig.DefaultEquipmentItems, options) ?? []; var avatars = JsonSerializer.Deserialize>(DBConfig.DefaultAvatarCatalog, options) ?? []; return equipment.Where(x => !string.IsNullOrWhiteSpace(x.PrefabName) && !string.IsNullOrWhiteSpace(x.ModificationGuid)) .Select(x => new Gift { EquipmentPrefabName = x.PrefabName, EquipmentModificationGuid = x.ModificationGuid, FriendlyName = x.FriendlyName, Tooltip = x.Tooltip, Rarity = x.Rarity, Context = GiftContext.All_Weekly_Challenge_Complete }) .Concat(avatars.Where(x => !string.IsNullOrWhiteSpace(x.AvatarItemDesc)).Select(x => new Gift { AvatarItemDesc = x.AvatarItemDesc, AvatarItemType = (AvatarItemType)x.AvatarItemType, FriendlyName = x.FriendlyName, Tooltip = x.Tooltip, Rarity = (Rarity)x.Rarity, Context = GiftContext.All_Weekly_Challenge_Complete })) .DistinctBy(WeeklyRewardKey).ToList(); } private static string WeeklyRewardKey(Gift gift) => !string.IsNullOrWhiteSpace(gift.AvatarItemDesc) ? "avatar:" + gift.AvatarItemDesc : "equipment:" + gift.EquipmentPrefabName + ":" + gift.EquipmentModificationGuid; [HttpGet("weekly-challenges/rewards")] public async Task GetWeeklyRewards([FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; return Ok(WeeklyRewards().Select(g => new { key = WeeklyRewardKey(g), kind = string.IsNullOrWhiteSpace(g.AvatarItemDesc) ? "equipment" : "avatar", name = string.IsNullOrWhiteSpace(g.FriendlyName) ? WeeklyRewardKey(g) : g.FriendlyName, detail = string.IsNullOrWhiteSpace(g.AvatarItemDesc) ? g.EquipmentPrefabName + " · " + g.EquipmentModificationGuid : g.AvatarItemDesc })); } [HttpGet("weekly-challenges")] public async Task GetWeeklySchedule([FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var weeks = new List(); var now = DateTime.UtcNow; for (var i = 0; i < 4; i++) weeks.Add(await WeeklyChallenges.GetDefinitionAsync(db, now.AddDays(i * 7))); return Ok(new { weeks, current = weeks[0], next = weeks[1], required = WeeklyChallenges.Required }); } [HttpPost("weekly-challenges")] public async Task SaveWeeklySchedule([FromQuery] string key, [FromBody] WeeklyScheduleRequest? request) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var current = WeeklyChallenges.CreateMap(DateTime.UtcNow); if (request == null) return BadRequest("Choose a weekly schedule."); var offset = request.ChallengeMapId - current.ChallengeMapId; if (offset < 0 || offset > 3) return Conflict("The weekly schedule changed. Reload the page."); var map = WeeklyChallenges.CreateMap(current.StartAt.AddDays(offset * 7)); if (request.ChallengeIds == null || request.ChallengeIds.Count is < 3 or > 5 || request.ChallengeIds.Distinct().Count() != request.ChallengeIds.Count) return BadRequest("Choose three to five different challenges."); if (string.IsNullOrWhiteSpace(request.Title) || request.Title.Length > 100 || request.FallbackTokens is < 1 or > 10000) return BadRequest("Enter a title (up to 100 characters) and 1–10,000 fallback tokens."); var reward = WeeklyRewards().FirstOrDefault(x => WeeklyRewardKey(x) == request.RewardKey); if (reward == null) return BadRequest("Choose an equipment skin or avatar item from the catalog."); var library = LoadState().Challenges; foreach (var id in request.ChallengeIds) { var row = library.FirstOrDefault(x => x["id"]?.ToString() == id.ToString(CultureInfo.InvariantCulture)); var config = row?["config"]?.ToString() ?? ""; if (row == null || id <= 0 || id > int.MaxValue || string.IsNullOrWhiteSpace(row["name"]?.ToString()) || !WeeklyChallenges.IsSupportedDefinition(config)) return BadRequest($"Challenge {id} is missing or unsupported. Edit it using a gameplay preset."); map.Challenges.Add(new() { ChallengeId = (int)id, Name = row["name"]!.ToString(), Description = row["description"]?.ToString() ?? "", Tooltip = row["tooltip"]?.ToString() ?? "", Config = config }); } map.Enabled = request.Enabled; map.ChallengeThemeString = request.Title.Trim(); map.Gift = reward.ForClient(); map.FallbackTokens = request.FallbackTokens; map.FallbackGiftName = $"{request.FallbackTokens} Tokens"; map.Revision = Guid.NewGuid().ToString("N"); await WeeklySaveGate.WaitAsync(); try { return await db.Database.CreateExecutionStrategy().ExecuteAsync(async () => { db.ChangeTracker.Clear(); await using var tx = db.Database.IsSqlServer() ? await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable) : null; var scheduleKey = WeeklyChallenges.ScheduleKey(map.ChallengeMapId); var query = db.Database.IsSqlServer() ? db.ServerConfig.FromSqlInterpolated($"SELECT * FROM [ServerConfig] WITH (UPDLOCK, HOLDLOCK) WHERE [Key] = {scheduleKey}") : db.ServerConfig.Where(x => x.Key == scheduleKey); var record = await query.SingleOrDefaultAsync(); var revision = record == null ? "" : JsonSerializer.Deserialize(record.Value)!.Revision; if (revision != request.Revision) return Conflict("This weekly was edited elsewhere. Reload before saving."); if (record == null) db.ServerConfig.Add(new ServerConfigVal(scheduleKey, JsonSerializer.Serialize(map))); else record.Value = JsonSerializer.Serialize(map); await db.SaveChangesAsync(); if (tx != null) await tx.CommitAsync(); return Ok(map); }); } finally { WeeklySaveGate.Release(); } } private static AdminPanelState LoadState() { lock (StateLock) { if (!System.IO.File.Exists(StatePath)) return new AdminPanelState(); try { var json = System.IO.File.ReadAllText(StatePath); return JsonSerializer.Deserialize(json, JsonOptions) ?? new AdminPanelState(); } catch { return new AdminPanelState(); } } } private static void SaveState(AdminPanelState state) { lock (StateLock) { Directory.CreateDirectory(DBConfig.PersistentLogs); System.IO.File.WriteAllText(StatePath, JsonSerializer.Serialize(state, JsonOptions)); } } public static void AddModerationReport(long reporterId, long reportedId, string reportMsg, string details, ReportCategory category, long roomId, string? reportType = null, long? targetId = null, string? targetName = null) { lock (StateLock) { var state = LoadState(); long nextId = 1; foreach (var existing in state.ModerationReports) { var rawId = existing["id"]?.ToString(); if (rawId != null && long.TryParse(rawId, out var parsedId) && parsedId >= nextId) nextId = parsedId + 1; } var report = new JsonObject { ["id"] = nextId, ["report_type"] = reportType ?? "Moderator", ["reporter_id"] = reporterId, ["reported_id"] = reportedId, ["reason"] = reportMsg, ["details"] = details, ["category"] = (int)category, ["room_id"] = roomId, ["status"] = "Pending", ["resolution_notes"] = "", ["created_at"] = DateTime.UtcNow.ToString("O") }; if (targetId.HasValue) report["target_id"] = targetId.Value; if (!string.IsNullOrWhiteSpace(targetName)) report["target_name"] = targetName; state.ModerationReports.Add(report); SaveState(state); } } private Task RejectInvalidKey(string key) { return Task.FromResult(key.IsAdminKey() ? null : Unauthorized("Invalid secret")); } /// The first of these that carries something: an item's own name beats nothing, and a /// catalogue's name beats a raw descriptor used as one. private static string NonEmpty(params string?[] values) { foreach (var value in values) { if (!string.IsNullOrWhiteSpace(value)) return value.Trim(); } return ""; } private static string RarityLabel(int rarity) => ((Rarity)rarity).ToString(); private static string ImageUrl(string? name) { if (string.IsNullOrWhiteSpace(name) || name.Equals("default", StringComparison.OrdinalIgnoreCase) || name.Equals("default.png", StringComparison.OrdinalIgnoreCase) || name.Equals(DBConfig.DefaultProfileImage, StringComparison.OrdinalIgnoreCase) || name.Equals(DBConfig.DefaultRoomImage, StringComparison.OrdinalIgnoreCase)) return $"{DBConfig.ServerUrl}/images/default"; return $"{DBConfig.ServerUrl}/images/img/{name}"; } private async Task> GetAdminUsers(string? eqCol, string? eqVal) { var query = db.AdminUsers.AsNoTracking(); if (eqCol == "user_id" && !string.IsNullOrWhiteSpace(eqVal)) query = query.Where(x => x.Username == eqVal); var rows = await query.ToListAsync(); return rows.Select(u => new { id = u.Id, user_id = u.Username, username = u.Username, created_at = u.CreatedAt, last_login_at = u.LastLoginAt }).Cast().ToList(); } [HttpGet("session")] public async Task Session([FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (Request.Cookies.TryGetValue(AdminSession.CookieName, out var token)) { var (ok, userId) = await AdminSession.ValidateAsync(db, token); if (ok) { var user = await db.AdminUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId); if (user != null) return Ok(new { user = new { id = user.Id, username = user.Username } }); } } return Ok(new { user = (object?)null }); } [HttpGet("appeals")] public async Task ListAppeals([FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var appeals = await db.Moderation .AsNoTracking() .Where(x => x.AppealCode != null && x.AppealText != null) .OrderByDescending(x => x.AppealSubmittedAt) .Select(x => new { id = x.Id, player_id = x.PlayerId, player_name = db.Profiles.Where(p => p.accountId == x.PlayerId).Select(p => p.username).FirstOrDefault() ?? "unknown", code = x.AppealCode, reason = x.Message, category = x.Category.ToString(), permanent = x.ExpireDuration >= DateTime.MaxValue.AddYears(-10), expires_at = x.ExpireDuration, appeal_text = x.AppealText, appeal_contact = x.AppealContact, email = db.Players.Where(pl => pl.AccountId == x.PlayerId).Select(pl => pl.Email).FirstOrDefault() ?? "", submitted_at = x.AppealSubmittedAt, resolution = x.AppealResolution, resolved_at = x.AppealResolvedAt, ban_active = x.IsBan && x.ExpireDuration > DateTime.UtcNow }) .ToListAsync(); return Ok(appeals); } [HttpPost("appeals/{appealId:long}/accept")] public async Task AcceptAppeal(long appealId, [FromQuery] string key, [FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase); var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null); if (ban == null) return NotFound("Appeal not found"); ban.IsBan = false; ban.AppealResolution = "Accepted"; ban.AppealResolvedAt = DateTime.UtcNow; await db.SaveChangesAsync(); await NotifyLoginRefresh(ban.PlayerId); if (wantsEmail) await SendAppealEmailAsync(ban, accepted: true, customText: customEmail); return Ok(new OkResponse { Success = true, Message = "Appeal accepted - ban lifted and player notified." }); } [HttpPost("appeals/{appealId:long}/reject")] public async Task RejectAppeal(long appealId, [FromQuery] string key, [FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase); var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null); if (ban == null) return NotFound("Appeal not found"); ban.AppealResolution = "Rejected"; ban.AppealResolvedAt = DateTime.UtcNow; await db.SaveChangesAsync(); if (wantsEmail) await SendAppealEmailAsync(ban, accepted: false, customText: customEmail); return Ok(new OkResponse { Success = true, Message = "Appeal rejected - ban remains active." }); } [HttpPost("appeals/{appealId:long}/reduce")] public async Task ReduceAppealBanTime(long appealId, [FromQuery] string key, [FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null, [FromQuery] int minutes = 0) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (minutes <= 0) return BadRequest(new OkResponse { Success = false, Message = "Enter how many minutes to reduce the ban by." }); var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null); if (ban == null) return NotFound("Appeal not found"); if (!ban.IsBan) return BadRequest(new OkResponse { Success = false, Message = "This ban is no longer active." }); var now = DateTime.UtcNow; var reduction = TimeSpan.FromMinutes(minutes); if (ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10)) { ban.ExpireDuration = now.Add(reduction); } else { var target = ban.ExpireDuration.Subtract(reduction); ban.ExpireDuration = target < now.AddMinutes(1) ? now.AddMinutes(1) : target; } ban.AppealResolution = "Reduced"; ban.AppealResolvedAt = DateTime.UtcNow; await db.SaveChangesAsync(); await NotifyLoginRefresh(ban.PlayerId); var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase); if (wantsEmail) await SendAppealTimeChangeEmailAsync(ban, reduced: true, customText: customEmail); return Ok(new OkResponse { Success = true, Message = "Ban time reduced - player notified." }); } [HttpPost("appeals/{appealId:long}/extend")] public async Task ExtendAppealBanTime(long appealId, [FromQuery] string key, [FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null, [FromQuery] int minutes = 0) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (minutes <= 0) return BadRequest(new OkResponse { Success = false, Message = "Enter how many minutes to extend the ban by." }); var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null); if (ban == null) return NotFound("Appeal not found"); if (!ban.IsBan) return BadRequest(new OkResponse { Success = false, Message = "This ban is no longer active." }); if (ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10)) return BadRequest(new OkResponse { Success = false, Message = "This ban is permanent and cannot be extended." }); ban.ExpireDuration = ban.ExpireDuration.AddMinutes(minutes); ban.AppealResolution = "Extended"; ban.AppealResolvedAt = DateTime.UtcNow; await db.SaveChangesAsync(); await NotifyLoginRefresh(ban.PlayerId); var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase); if (wantsEmail) await SendAppealTimeChangeEmailAsync(ban, reduced: false, customText: customEmail); return Ok(new OkResponse { Success = true, Message = "Ban time extended - player notified." }); } private async Task NotifyLoginRefresh(long playerId) { var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); if (profile != null) { await Notify.SendNotify(playerId, new Notification { Id = NotificationType.RefreshLogin, Msg = profile }); } } private async Task SendAppealEmailAsync(RecNet.Moderation.ModerationDB ban, bool accepted, string? customText = null) { if (string.IsNullOrWhiteSpace(DBConfig.MailApiKey)) { return; } var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == ban.PlayerId); var email = player?.Email; if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) return; var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId); var name = string.IsNullOrWhiteSpace(profile?.username) ? "there" : profile.username; var reason = string.IsNullOrWhiteSpace(ban.Message) ? "the listed reason" : ban.Message; if (!string.IsNullOrWhiteSpace(customText)) { var subject = accepted ? "Your Radicade ban appeal was approved" : "Your Radicade ban appeal was rejected"; await RadicadeMail.SendAsync(email, subject, BuildCustomEmailHtml(subject, customText)); return; } if (accepted) { var html = $"
" + $"\"Radicade\"" + $"

Your Radicade ban appeal was approved

" + $"

Hi {HtmlEncode(name)},

" + $"

Your appeal against the ban for {HtmlEncode(reason)} has been accepted. The ban has been lifted and you can log back into Radicade.

" + $"

Thanks for reaching out - enjoy the game!

" + $"

- Radicade Moderation

"; await RadicadeMail.SendAsync(email, "Your Radicade ban appeal was approved", html); } else { var html = $"
" + $"\"Radicade\"" + $"

Your Radicade ban appeal was rejected

" + $"

Hi {HtmlEncode(name)},

" + $"

We reviewed your appeal against the ban for {HtmlEncode(reason)} and it was rejected. The ban remains active.

" + $"

If you believe this decision was a mistake, you may submit another appeal once the cooldown period has passed.

" + "

Take this time to review our Code Of Conduct:

" + "

Code Of Conduct

" + $"

- Radicade Moderation

"; await RadicadeMail.SendAsync(email, "Your Radicade ban appeal was rejected", html); } } private async Task SendAppealTimeChangeEmailAsync(RecNet.Moderation.ModerationDB ban, bool reduced, string? customText = null) { if (string.IsNullOrWhiteSpace(DBConfig.MailApiKey)) { return; } var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == ban.PlayerId); var email = player?.Email; if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) return; var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId); var name = string.IsNullOrWhiteSpace(profile?.username) ? "there" : profile.username; var reason = string.IsNullOrWhiteSpace(ban.Message) ? "the listed reason" : ban.Message; var action = reduced ? "reduced" : "extended"; if (!string.IsNullOrWhiteSpace(customText)) { var subject = $"Your Radicade ban time was {action}"; await RadicadeMail.SendAsync(email, subject, BuildCustomEmailHtml(subject, customText)); return; } var permanent = ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10); var when = permanent ? "permanent" : DateTime.SpecifyKind(ban.ExpireDuration, DateTimeKind.Utc).ToString("MMM d, yyyy 'at' h:mm tt 'UTC'"); var cocCta = reduced ? "" : "

Take this time to review our Code Of Conduct:

" + "

Code Of Conduct

"; var html = $"
" + $"\"Radicade\"" + $"

Your Radicade ban time was {action}

" + $"

Hi {HtmlEncode(name)},

" + $"

As part of your appeal, the ban for {HtmlEncode(reason)} has been {action}. It now ends {when}.

" + cocCta + $"

- Radicade Moderation

"; await RadicadeMail.SendAsync(email, $"Your Radicade ban time was {action}", html); } private static string BuildCustomEmailHtml(string title, string customText) { var body = HtmlEncode(customText).Replace("\r", "").Replace("\n", "
"); return $"
" + $"\"Radicade\"" + $"

{HtmlEncode(title)}

" + $"

{body}

" + $"

- Radicade Moderation

"; } private static string HtmlEncode(string value) { return System.Net.WebUtility.HtmlEncode(value); } private static readonly Dictionary RoomRoleNames = new(StringComparer.OrdinalIgnoreCase) { ["none"] = RoleType.None, ["banned"] = RoleType.Banned, ["host"] = RoleType.Host, ["moderator"] = RoleType.Moderator, ["co-owner"] = RoleType.CoOwner, ["coowner"] = RoleType.CoOwner, ["temporarycoowner"] = RoleType.TemporaryCoOwner, ["creator"] = RoleType.Creator }; private static string RoomRoleKey(RoleType role) => role switch { RoleType.Creator => "creator", RoleType.CoOwner => "co-owner", RoleType.TemporaryCoOwner => "co-owner", RoleType.Moderator => "moderator", RoleType.Host => "host", _ => "none" }; private static bool TryParseRoomRole(string? raw, out RoleType role) { role = RoleType.None; if (string.IsNullOrWhiteSpace(raw)) return false; if (RoomRoleNames.TryGetValue(raw.Trim(), out role)) return true; if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric) && Enum.IsDefined(typeof(RoleType), numeric)) { role = (RoleType)numeric; return true; } return Enum.TryParse(raw, true, out role); } private static bool TryParseRoomAccessibility(string? raw, out Accessibility value) { value = Accessibility.Public; if (string.IsNullOrWhiteSpace(raw)) return false; if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric)) { if (!Enum.IsDefined(typeof(Accessibility), numeric)) return false; value = (Accessibility)numeric; return true; } return Enum.TryParse(raw, true, out value) && Enum.IsDefined(typeof(Accessibility), value); } private static Accessibility ParseRoomAccessibility(string? raw) => TryParseRoomAccessibility(raw, out var parsed) ? parsed : Accessibility.Public; [HttpGet("rooms/{roomId:long}")] public async Task RoomPanelDetails(long roomId, [FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); var stats = await db.RoomStats.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId); var roleRows = await db.RoomRoles.AsNoTracking().Where(x => x.RoomId == roomId).ToListAsync(); var roleAccountIds = roleRows.Select(r => r.AccountId).Append(room.CreatorAccountId).Distinct().ToList(); var roleProfiles = await db.Profiles.AsNoTracking().Where(x => roleAccountIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId); object RoleRow(long accountId, RoleType role) { roleProfiles.TryGetValue(accountId, out var member); var handle = member?.username ?? $"account_{accountId}"; return new { account_id = accountId, username = handle, display_name = string.IsNullOrWhiteSpace(member?.displayName) ? handle : member!.displayName, profile_image = ImageUrl(member?.profileImage), role = RoomRoleKey(role), role_key = role.ToString(), role_value = (int)role, is_creator = accountId == room.CreatorAccountId }; } var members = new List(); var seenAccounts = new HashSet(); members.Add(RoleRow(room.CreatorAccountId, RoleType.Creator)); seenAccounts.Add(room.CreatorAccountId); foreach (var row in roleRows.OrderBy(r => (int)r.Role).ThenBy(r => r.AccountId)) { if (!seenAccounts.Add(row.AccountId)) continue; members.Add(RoleRow(row.AccountId, row.Role)); } var subRows = await db.SubRooms.AsNoTracking().Where(x => x.RoomId == roomId).OrderBy(x => x.SubRoomId).ToListAsync(); var subrooms = subRows.Select((sub, index) => (object)new { id = sub.SubRoomId, name = sub.Name, list = index + 1, maxPlayers = sub.MaxPlayers, max_players = sub.MaxPlayers, accessibility = sub.Accessibility.ToString().ToLowerInvariant(), sandbox = sub.IsSandbox, save = sub.DataBlob ?? "", unity_scene_id = sub.UnitySceneId, created_at = DateTime.SpecifyKind(sub.CreatedAt, DateTimeKind.Utc) }).ToList(); var saves = new List(); try { if (Directory.Exists(DBConfig.CDNStorage)) { saves = Directory.GetFiles(DBConfig.CDNStorage, "*.room") .OrderByDescending(f => System.IO.File.GetLastWriteTimeUtc(f)) .Select(f => (object)new { id = Path.GetFileName(f), label = Path.GetFileName(f) }) .ToList(); } } catch { saves = new List(); } return Ok(new { room = new { id = room.RoomId, name = room.Name, description = room.Description, image_name = room.ImageName, custom_warning = room.CustomWarning, creator_account_id = room.CreatorAccountId, publish_state = room.State.ToString(), accessibility = room.Accessibility.ToString(), tags = room.Tags, is_dorm = room.IsDorm, is_rro = room.IsRRO, cloning_allowed = room.CloningAllowed, disable_mic_auto_mute = room.DisableMicAutoMute, disable_room_comments = room.DisableRoomComments, encrypt_voice_chat = room.EncryptVoiceChat, supports_screens = room.SupportsScreens, supports_walk_vr = room.SupportsWalkVR, supports_teleport_vr = room.SupportsTeleportVR, supports_quest2 = room.SupportsQuest2, supports_mobile = room.SupportsMobile, supports_juniors = room.SupportsJuniors, min_level = room.MinLevel, max_players = room.MaxPlayers, max_player_calculation_mode = (int)room.MaxPlayerCalculationMode, created_at = DateTime.SpecifyKind(room.CreatedAt, DateTimeKind.Utc), published_at = room.PublishedAt == default ? (DateTime?)null : DateTime.SpecifyKind(room.PublishedAt, DateTimeKind.Utc), featured = SafeRoomTags(room.Tags).Any(t => string.Equals(t, "featured", StringComparison.OrdinalIgnoreCase)), cheer_count = stats?.CheerCount ?? 0, favorite_count = stats?.FavoriteCount ?? 0, visitor_count = stats?.VisitorCount ?? 0, visit_count = stats?.VisitCount ?? 0, join_gift_enabled = room.JoinGiftEnabled, join_gift_once_per_player = room.JoinGiftOncePerPlayer, join_gift_version = room.JoinGiftVersion, join_gift_title = room.JoinGiftTitle, join_gift_message = room.JoinGiftMessage, join_gift_avatar_item = room.JoinGiftAvatarItem, join_gift_equipment_item = room.JoinGiftEquipmentItem, join_gift_consumable_item = room.JoinGiftConsumableItem, join_gift_currency_amount = room.JoinGiftCurrencyAmount, join_gift_currency_type = room.JoinGiftCurrencyType.ToString(), join_gift_xp = room.JoinGiftXp, join_gift_context = room.JoinGiftContext.ToString(), join_gift_rarity = room.JoinGiftRarity.ToString(), join_gift_avatar_item_type = room.JoinGiftAvatarItemType.ToString(), join_gift_equipment_modification_guid = room.JoinGiftEquipmentModificationGuid, // Digits in a string: an account id is eighteen digits and a JSON number would // arrive in the panel rounded, so a saved id could name the wrong account. join_gift_from_player_id = Math.Max(0, room.JoinGiftFromPlayerId).ToString(CultureInfo.InvariantCulture), join_gifts = JoinGiftRows(room, await RoomJoinGiftUtils.ConfiguredGiftsAsync(db, room.RoomId)) }, roles = members, subrooms, saves }); } [HttpPost("rooms/{roomId:long}/name")] public async Task RenameRoom(long roomId, [FromQuery] string key, [FromQuery] string? name) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var next = (name ?? "").Trim(); if (string.IsNullOrWhiteSpace(next)) return BadRequest("A room has to have a name."); if (next.Length > 64) return BadRequest("Room names are limited to 64 characters."); var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); var previous = room.Name; room.Name = next; await db.SaveChangesAsync(); AdminActionDiscord.LogAction("Rename Room", await GetPanelAdminUsernameAsync(), $"Renamed room {roomId} from \"{previous}\" to \"{next}\"."); return Ok(new { success = true, room_id = roomId, name = room.Name }); } [HttpPost("rooms/{roomId:long}/roles")] public async Task SetRoomRole(long roomId, [FromQuery] string key, [FromQuery] long accountId, [FromQuery] string? role) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); if (!TryParseRoomRole(role, out var nextRole)) return BadRequest("Unknown room role."); if (accountId == room.CreatorAccountId && nextRole != RoleType.Creator) return BadRequest("The room's creator keeps the Creator role."); var existing = await db.RoomRoles.FirstOrDefaultAsync(x => x.RoomId == roomId && x.AccountId == accountId); if (existing == null) { db.RoomRoles.Add(new RoomRole { RoomId = roomId, AccountId = accountId, Role = nextRole, InvitedRole = RoleType.None }); } else { if (existing.Role == nextRole) return Ok(new { success = true, room_id = roomId, account_id = accountId, role = RoomRoleKey(nextRole) }); existing.Role = nextRole; } await db.SaveChangesAsync(); try { var updatedRoom = await RoomUtils.GetRoom(roomId); if (updatedRoom != null) await Notify.SendNotify(accountId, new Notification { Id = "RoomUpdate", Msg = updatedRoom }); } catch (Exception) { } AdminActionDiscord.LogAction("Room Role", await GetPanelAdminUsernameAsync(), $"Set account {accountId} to {nextRole} on room {roomId} ({room.Name})."); return Ok(new { success = true, room_id = roomId, account_id = accountId, role = RoomRoleKey(nextRole) }); } [HttpPost("rooms/{roomId:long}/subrooms/add")] public async Task AddSubroom(long roomId, [FromQuery] string key, [FromQuery] string? name, [FromQuery] int maxPlayers = 0, [FromQuery] string? accessibility = null, [FromQuery] string? sandbox = null, [FromQuery] string? saveId = null) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); var subName = (name ?? "").Trim(); if (string.IsNullOrWhiteSpace(subName)) return BadRequest("A new subroom needs a name."); var sub = new SubRoom { RoomId = roomId, Name = subName, MaxPlayers = maxPlayers > 0 ? maxPlayers : 20, Accessibility = ParseRoomAccessibility(accessibility), IsSandbox = string.Equals(sandbox, "true", StringComparison.OrdinalIgnoreCase) || sandbox == "1", DataBlob = saveId ?? "", SavedByAccountId = room.CreatorAccountId, CreatedAt = DateTime.UtcNow }; db.SubRooms.Add(sub); await db.SaveChangesAsync(); var index = await db.SubRooms.CountAsync(x => x.RoomId == roomId && x.SubRoomId <= sub.SubRoomId); AdminActionDiscord.LogAction("Add Subroom", await GetPanelAdminUsernameAsync(), $"Added subroom \"{sub.Name}\" to room {roomId} ({room.Name})."); return Ok(new { success = true, subroomId = sub.SubRoomId, subroomList = index, maxPlayers = sub.MaxPlayers }); } [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")] public async Task RenameSubroom(long roomId, long subRoomId, [FromQuery] string key, [FromQuery] string? name) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var next = (name ?? "").Trim(); if (string.IsNullOrWhiteSpace(next)) return BadRequest("A subroom has to have a name."); var sub = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId); if (sub == null) return NotFound("Subroom does not exist."); sub.Name = next; await db.SaveChangesAsync(); AdminActionDiscord.LogAction("Rename Subroom", await GetPanelAdminUsernameAsync(), $"Renamed subroom {subRoomId} on room {roomId} to \"{next}\"."); return Ok(new { success = true, subroomId = sub.SubRoomId, name = sub.Name }); } [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/delete")] public async Task DeleteSubroom(long roomId, long subRoomId, [FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var sub = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId); if (sub == null) return NotFound("Subroom does not exist."); var permissions = await db.SubRoomPermissions.Where(x => x.subRoomId == subRoomId).ToListAsync(); if (permissions.Count > 0) db.SubRoomPermissions.RemoveRange(permissions); db.SubRooms.Remove(sub); await db.SaveChangesAsync(); AdminActionDiscord.LogAction("Delete Subroom", await GetPanelAdminUsernameAsync(), $"Deleted subroom \"{sub.Name}\" ({subRoomId}) from room {roomId}."); return Ok(new { success = true, subroomId = subRoomId }); } [HttpPost("rooms/{roomId:long}/saves")] public async Task UploadRoomSave(long roomId, [FromQuery] string key, [FromForm] List file) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId); if (room == null) return NotFound("Room does not exist."); var chosen = file?.FirstOrDefault(f => f.Length > 0 && Path.GetExtension(f.FileName).Equals(".room", StringComparison.OrdinalIgnoreCase)) ?? file?.FirstOrDefault(f => f.Length > 0); if (chosen == null) return BadRequest("No .room save was uploaded."); Directory.CreateDirectory(DBConfig.CDNStorage); var stored = Guid.NewGuid().ToString("N") + Path.GetExtension(chosen.FileName).ToLowerInvariant(); using (var stream = new FileStream(Path.Join(DBConfig.CDNStorage, stored), FileMode.Create)) { await chosen.CopyToAsync(stream); } AdminActionDiscord.LogAction("Upload Room Save", await GetPanelAdminUsernameAsync(), $"Uploaded save \"{chosen.FileName}\" ({stored}) for room {roomId} ({room.Name})."); return Ok(new { success = true, saveId = stored, fileName = chosen.FileName }); } [HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/save")] public async Task SetSubroomSave(long roomId, long subRoomId, [FromQuery] string key, [FromQuery] string? saveId, [FromForm] List? file) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var sub = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId); if (sub == null) return NotFound("Subroom does not exist."); var chosen = file?.FirstOrDefault(f => f.Length > 0 && Path.GetExtension(f.FileName).Equals(".room", StringComparison.OrdinalIgnoreCase)) ?? file?.FirstOrDefault(f => f.Length > 0); string stored; if (chosen != null) { Directory.CreateDirectory(DBConfig.CDNStorage); stored = Guid.NewGuid().ToString("N") + Path.GetExtension(chosen.FileName).ToLowerInvariant(); using var stream = new FileStream(Path.Join(DBConfig.CDNStorage, stored), FileMode.Create); await chosen.CopyToAsync(stream); } else { stored = (saveId ?? "").Trim(); stored = Path.GetFileName(stored); if (!string.IsNullOrEmpty(stored) && !System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, stored))) return BadRequest($"No uploaded save named '{stored}'."); } sub.DataBlob = stored; await db.SaveChangesAsync(); AdminActionDiscord.LogAction("Set Subroom Save", await GetPanelAdminUsernameAsync(), $"Pointed subroom \"{sub.Name}\" ({subRoomId}) on room {roomId} at save \"{(string.IsNullOrEmpty(stored) ? "none" : stored)}\"."); return Ok(new { success = true, subroomId = sub.SubRoomId, saveId = stored }); } [HttpPost("players/{playerId:long}/{kind}")] public Task PlayerPanelAction(long playerId, string kind, [FromQuery] string key) => ApplyPlayerPanelAction(playerId, kind, key); [HttpPost("players/{playerId:long}/{kind}/{sub}")] public Task PlayerPanelSubAction(long playerId, string kind, string sub, [FromQuery] string key) => ApplyPlayerPanelAction(playerId, $"{kind}/{sub}", key); /// Take one saved photo off an account's gallery: the row that indexes it, the cheers /// that point at it, and the file itself. A player can do this to their own gallery in /// game (ImagesAPI's deletesaved), so the panel does it the same way rather than /// leaving the file behind. [HttpDelete("players/{playerId:long}/photos/{imageId:long}")] public async Task DeletePlayerPhoto(long playerId, long imageId, [FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var image = await db.Images.FirstOrDefaultAsync(x => x.Id == imageId && x.OwnerId == playerId); if (image == null) return NotFound("That photo is not on this account's gallery."); var cheers = db.ImageInteractions.Where(x => x.SavedImageId == image.Id); db.ImageInteractions.RemoveRange(cheers); db.Images.Remove(image); await db.SaveChangesAsync(); var fullPath = Path.Join(DBConfig.CDNImages, image.Name); if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath); AdminActionDiscord.LogAction("Delete Photo", await GetPanelAdminUsernameAsync(), $"Deleted photo \"{image.Name}\" ({imageId}) from player {playerId}'s gallery."); return Ok(new { success = true, player_id = playerId, image_id = imageId, name = image.Name }); } /// Take one invention off the account that created it, along with the versions it was /// saved in and every player's personal details about it. Requests and other accounts' /// downloads of it stop resolving on the next read. [HttpDelete("players/{playerId:long}/inventions/{inventionId:long}")] public async Task DeletePlayerInvention(long playerId, long inventionId, [FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var invention = await db.Inventions.FirstOrDefaultAsync(x => x.InventionId == inventionId && x.CreatorPlayerId == playerId); if (invention == null) return NotFound("That invention does not belong to this account."); var versions = await db.InventionVersions.Where(x => x.InventionId == inventionId).ToListAsync(); var details = await db.InventionPersonalDetails.Where(x => x.InventionId == inventionId).ToListAsync(); db.InventionVersions.RemoveRange(versions); db.InventionPersonalDetails.RemoveRange(details); db.Inventions.Remove(invention); await db.SaveChangesAsync(); AdminActionDiscord.LogAction("Delete Invention", await GetPanelAdminUsernameAsync(), $"Deleted invention \"{invention.Name}\" ({inventionId}) created by player {playerId}, with {versions.Count} saved version(s)."); return Ok(new { success = true, player_id = playerId, invention_id = inventionId, name = invention.Name }); } private async Task ApplyPlayerPanelAction(long playerId, string kind, string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == playerId); if (profile == null) return NotFound("Player does not exist."); var display = string.IsNullOrWhiteSpace(profile.displayName) ? profile.username : profile.displayName; string? Q(string name) { var raw = Request.Query[name].ToString(); return string.IsNullOrEmpty(raw) ? null : raw; } bool QBool(string name, bool fallback = false) { var raw = Q(name); if (raw == null) return fallback; return raw.Equals("true", StringComparison.OrdinalIgnoreCase) || raw == "1"; } int QInt(string name, int fallback = 0) => int.TryParse(Q(name), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback; var payload = new Dictionary(); var audit = ""; switch (kind) { case "ban": { var reason = (Q("reason") ?? "").Trim(); var seconds = QInt("durationSeconds", -1); payload["banned"] = true; payload["ban_reason"] = reason; payload["ban_method"] = Q("banMethod") ?? "Admin Panel"; var moderatorNote = (Q("moderatorNote") ?? "").Trim(); if (!string.IsNullOrWhiteSpace(moderatorNote)) payload["moderator_note"] = moderatorNote; payload["ban_until"] = seconds <= 0 ? "permanent" : DateTime.UtcNow.AddSeconds(seconds).ToString("O", CultureInfo.InvariantCulture); audit = seconds <= 0 ? $"Banned player {playerId} ({display}) permanently - {reason}" : $"Banned player {playerId} ({display}) for {seconds} second(s) - {reason}"; break; } case "unban": payload["banned"] = false; audit = $"Lifted player {playerId} ({display})'s ban"; break; case "coach": { var message = (Q("message") ?? "").Trim(); if (string.IsNullOrWhiteSpace(message)) return BadRequest("A coach message cannot be empty."); await Notify.SendMessage(playerId, new Message { Type = MessageType.CoachMessage, Data = message, FromPlayerId = 1 }); AdminActionDiscord.LogAction("Coach Message", await GetPanelAdminUsernameAsync(), $"Sent a coach message to player {playerId} ({display}): {message}"); return Ok(new { success = true, player_id = playerId, message }); } case "roles/add": { var added = (Q("roles") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); if (added.Length == 0) return BadRequest("Pick at least one role to add."); var merged = new List(SplitRoles(profile.role)); foreach (var role in added) { if (!merged.Contains(role, StringComparer.OrdinalIgnoreCase)) merged.Add(role); } payload["roles"] = string.Join(",", merged); audit = $"Added role(s) {string.Join(",", added)} to player {playerId} ({display})"; break; } case "roles/clear": payload["roles"] = ""; audit = $"Cleared player {playerId} ({display})'s in-game roles"; break; case "lockname": payload["name_locked"] = QBool("locked"); audit = $"{(QBool("locked") ? "Locked" : "Unlocked")} player {playerId} ({display})'s name"; break; case "lockbio": payload["bio_locked"] = QBool("locked"); audit = $"{(QBool("locked") ? "Locked" : "Unlocked")} player {playerId} ({display})'s bio"; break; case "junior": payload["is_junior"] = QBool("junior"); audit = $"Marked player {playerId} ({display}) as {(QBool("junior") ? "a Junior account" : "no longer Junior")}"; break; case "resetpassword": { var password = Q("password") ?? ""; if (password.Length < 8) return BadRequest("A reset password needs at least 8 characters."); payload["password"] = password; audit = $"Reset player {playerId} ({display})'s password"; break; } case "username": { var username = (Q("username") ?? "").Trim(); if (string.IsNullOrWhiteSpace(username)) return BadRequest("Enter the new username."); if (username.Length < 3 || username.Length > 32) return BadRequest("Usernames are 3-32 characters."); if (await db.Profiles.AnyAsync(x => x.accountId != playerId && x.username == username)) return BadRequest($"The username '{username}' is already taken."); payload["username"] = username; audit = $"Renamed player {playerId} ({display}) to {username}"; break; } case "reputation": payload["reputation"] = QInt("reputation"); payload["noteriety"] = QInt("notoriety"); audit = $"Saved player {playerId} ({display})'s reputation"; break; case "cheers": payload["cheer_general"] = QInt("general"); payload["cheer_helpful"] = QInt("helpful"); payload["cheer_great_host"] = QInt("greatHost"); payload["cheer_sportsman"] = QInt("sportsman"); payload["cheer_creative"] = QInt("creative"); payload["cheer_credit"] = QInt("credit"); audit = $"Saved player {playerId} ({display})'s cheers"; break; case "progression": payload["level"] = QInt("level", 1); payload["xp"] = QInt("xp"); audit = $"Set player {playerId} ({display}) to level {QInt("level", 1)} / {QInt("xp")} XP"; break; case "notes": { var text = (Q("note") ?? "").Trim(); if (string.IsNullOrWhiteSpace(text)) return BadRequest("A note cannot be empty."); var state = LoadState(); long nextId = 1; foreach (var existing in state.Notes) { if (long.TryParse(existing["id"]?.ToString(), out var parsedId) && parsedId >= nextId) nextId = parsedId + 1; } var note = new JsonObject { ["id"] = nextId, ["player_id"] = playerId, ["text"] = text, ["author"] = await GetPanelAdminUsernameAsync(), ["created_at"] = DateTime.UtcNow.ToString("O") }; state.Notes.Add(note); SaveState(state); return Ok(new { success = true, player_id = playerId, note = note }); } case "settings": { var settingKey = (Q("setting_key") ?? "").Trim(); if (string.IsNullOrWhiteSpace(settingKey)) return BadRequest("A setting needs a key."); return await ApplyPlayerSetting(playerId, new Dictionary { [settingKey] = Q("value") ?? "" }); } case "settings/delete": { var settingKey = (Q("setting_key") ?? "").Trim(); if (string.IsNullOrWhiteSpace(settingKey)) return BadRequest("A setting needs a key."); return await ApplyPlayerSetting(playerId, new Dictionary { [settingKey] = null }); } case "settings/reset": await ApplyPlayerSetting(playerId, null); AdminActionDiscord.LogAction("Reset Settings", await GetPanelAdminUsernameAsync(), $"Reset every in-game setting for player {playerId} ({display})."); return Ok(new { success = true, player_id = playerId }); case "settings/birthday": return await ApplyPlayerSetting(playerId, new Dictionary { ["birthday"] = (Q("birthday") ?? "").Trim() }); case "settings/profilepicture": { var owner = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); if (owner == null) return NotFound("Player does not exist."); owner.profileImage = DBConfig.DefaultProfileImage; await db.SaveChangesAsync(); AdminActionDiscord.LogAction("Reset Profile Picture", await GetPanelAdminUsernameAsync(), $"Reset player {playerId} ({display})'s profile picture."); return Ok(new { success = true, player_id = playerId }); } case "items/revoke": { var itemKind = (Q("itemKind") ?? "").Trim().ToLowerInvariant(); var itemId = (Q("itemId") ?? "").Trim(); var modGuid = (Q("modGuid") ?? "").Trim(); if (itemId.Length == 0) return BadRequest("An item to revoke is required."); var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == playerId); if (unlocks == null) return NotFound("This account has no locker to take an item from."); int removed; string removedName; switch (itemKind) { case "avatar": { var items = unlocks.GetAvatarItems(); removedName = items.FirstOrDefault(i => i != null && string.Equals((i.AvatarItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase))?.FriendlyName ?? itemId; removed = items.RemoveAll(i => i != null && string.Equals((i.AvatarItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase)); unlocks.AvatarItems = items.ToJson(); break; } case "equipment": { var items = unlocks.GetEquipment(); bool Matches(Equipment i) => i != null && string.Equals((i.PrefabName ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase) && (modGuid.Length == 0 || string.Equals((i.ModificationGuid ?? "").Trim(), modGuid, StringComparison.OrdinalIgnoreCase)); removedName = items.FirstOrDefault(Matches)?.FriendlyName ?? itemId; removed = items.RemoveAll(Matches); unlocks.Equipment = items.ToJson(); break; } case "consumable": { var items = unlocks.GetConsumables(); removedName = items.FirstOrDefault(i => i != null && string.Equals((i.ConsumableItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase))?.ConsumableItemDesc ?? itemId; removed = items.RemoveAll(i => i != null && string.Equals((i.ConsumableItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase)); unlocks.SetConsumables(items); break; } default: return BadRequest("The item kind must be avatar, equipment or consumable."); } if (removed == 0) return NotFound("That item is not in this account's locker."); await db.SaveChangesAsync(); AdminActionDiscord.LogAction("Revoke Item", await GetPanelAdminUsernameAsync(), $"Took {removed} {itemKind} item(s) named \"{removedName}\" out of player {playerId} ({display})'s locker."); return Ok(new { success = true, player_id = playerId, item_kind = itemKind, item_id = itemId, removed }); } case "locker/reset": { var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == playerId); if (unlocks == null) return NotFound("This account has no locker to reset."); unlocks.AvatarItems = DBConfig.DefaultAvatarUnlocks; unlocks.Consumables = DBConfig.DefaultConsumableUnlocks; unlocks.Equipment = DBConfig.DefaultEquipmentUnlocks; await db.SaveChangesAsync(); AdminActionDiscord.LogAction("Reset Locker", await GetPanelAdminUsernameAsync(), $"Reset player {playerId} ({display})'s locker to the default inventory."); return Ok(new { success = true, player_id = playerId }); } case "lock": { var caseRef = (Q("case") ?? Q("case_ref") ?? "").Trim(); var result = await AccountLock.ApplyAsync(db, playerId, await GetPanelAdminUsernameAsync(), string.IsNullOrWhiteSpace(caseRef) ? null : caseRef); if (!result.Success) return StatusCode(500, new { success = false, message = result.Error ?? "The account lock could not be applied." }); return Ok(new { success = true, player_id = playerId, locked = true, ban_method = AccountLock.BanMethod, ban_category = ReportCategory.Moderator.ToString(), permanent = true, changed = result.Changed }); } case "unlock": { var caseRef = (Q("case") ?? Q("case_ref") ?? "").Trim(); var result = await AccountLock.RemoveAsync(db, playerId, await GetPanelAdminUsernameAsync(), string.IsNullOrWhiteSpace(caseRef) ? null : caseRef); if (!result.Success) return Conflict(new { success = false, message = result.Error ?? "The account lock could not be removed." }); return Ok(new { success = true, player_id = playerId, locked = false }); } case "quit": return await QuitPlayerGame(playerId, key); case "forcelogout": return await ForceLogoutPlayer(playerId, key); default: return NotFound($"Unknown player action '{kind}'."); } var applied = await UpdatePlayer(playerId, JsonSerializer.SerializeToElement(payload)); if (applied is IActionResult failure) return failure; if (!string.IsNullOrEmpty(audit)) AdminActionDiscord.LogAction(kind, await GetPanelAdminUsernameAsync(), audit); return Ok(new { success = true, player_id = playerId }); } private async Task ApplyPlayerSetting(long playerId, Dictionary? changes) { var row = await db.PlayerSettings.FirstOrDefaultAsync(x => x.AccountId == playerId); var settings = row?.DeserializeSettings() ?? new List(); if (changes != null) { foreach (var change in changes) { settings.RemoveAll(s => string.Equals(s.Key, change.Key, StringComparison.OrdinalIgnoreCase)); if (change.Value != null) settings.Add(new Setting { Key = change.Key, Value = change.Value }); } } else { settings = new List(); } var payload = new Dictionary { ["settings"] = settings.ToDictionary(s => s.Key, s => s.Value) }; var applied = await UpdatePlayer(playerId, JsonSerializer.SerializeToElement(payload)); if (applied is IActionResult failure) return failure; return Ok(new { success = true, player_id = playerId, settings = settings.Count }); } [HttpGet("players/{playerId:long}/details")] public async Task PlayerDetails(long playerId, [FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var rooms = await db.Rooms .Where(r => r.CreatorAccountId == playerId) .Select(r => new { id = r.RoomId, name = r.Name, description = r.Description, image_name = r.ImageName, state = r.State.ToString(), accessibility = r.Accessibility.ToString(), created_at = r.CreatedAt, max_players = r.MaxPlayers, supports_juniors = r.SupportsJuniors, cloning_allowed = r.CloningAllowed }) .ToListAsync(); var inventions = await db.Inventions .Where(i => i.CreatorPlayerId == playerId) .Select(i => new { id = i.InventionId, name = i.Name, description = i.Description, image_name = i.ImageName, is_published = i.IsPublished, num_downloads = i.NumDownloads, cheer_count = i.CheerCount, created_at = i.CreatedAt }) .ToListAsync(); var events = await db.PlayerEvents .Where(e => e.CreatorPlayerId == playerId) .Select(e => new { id = e.PlayerEventId, name = e.Name, description = e.Description, image_name = e.ImageName, room_id = e.RoomId, start_time = e.StartTime, end_time = e.EndTime, attendee_count = e.AttendeeCount, accessibility = e.Accessibility.ToString() }) .ToListAsync(); var currency = await db.Currency.FirstOrDefaultAsync(x => x.AccountId == playerId); var balance = new { rec_tokens = currency?.RecTokens ?? 0, laser_tag_tickets = currency?.LaserTagTickets ?? 0, gold = currency?.Gold ?? 0, dracula_silver = currency?.DraculaSilver ?? 0 }; // What the account actually owns: the unlocks row the game reads when it dresses an // avatar. The panel shows this as the player's locker, so each entry is resolved // against the server's own catalogues here - the saved entry often carries no name // or picture of its own, and the catalogue is the only place that has them. var unlocksRow = await db.Unlocks.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == playerId); var lockerAvatar = unlocksRow?.GetAvatarItems() ?? new List(); var lockerEquipment = unlocksRow?.GetEquipment() ?? new List(); var lockerConsumables = unlocksRow?.GetConsumables() ?? new List(); var avatarCatalog = (DBConfig.DefaultAvatarCatalog.FromJson>() ?? new List()) .Where(x => x != null && !string.IsNullOrWhiteSpace(x.AvatarItemDesc)) .GroupBy(x => x.AvatarItemDesc.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); var equipmentCatalog = (DBConfig.DefaultEquipmentItems.FromJson>(CatalogJsonOptions) ?? new List()) .Where(x => x != null && !string.IsNullOrWhiteSpace(x.PrefabName)) .GroupBy(x => x.PrefabName.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); var consumableCatalog = (DBConfig.DefaultConsumableItems.FromJson>(CatalogJsonOptions) ?? new List()) .Where(x => x != null && !string.IsNullOrWhiteSpace(x.ConsumableItemDesc)) .GroupBy(x => x.ConsumableItemDesc.Trim(), StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); var locker = new { avatar = lockerAvatar .Where(i => i != null && !string.IsNullOrWhiteSpace(i.AvatarItemDesc)) .Select(i => { avatarCatalog.TryGetValue(i.AvatarItemDesc.Trim(), out var meta); return new { desc = i.AvatarItemDesc.Trim(), name = NonEmpty(i.FriendlyName, meta?.FriendlyName, i.AvatarItemDesc), rarity = RarityLabel(meta?.Rarity ?? i.Rarity), thumbnail_image = NonEmpty(i.ThumbnailImage, meta?.ThumbnailImage, "") }; }) .Cast().ToList(), equipment = lockerEquipment .Where(i => i != null && !string.IsNullOrWhiteSpace(i.PrefabName)) .Select(i => { equipmentCatalog.TryGetValue(i.PrefabName.Trim(), out var meta); return new { prefab = i.PrefabName.Trim(), guid = (i.ModificationGuid ?? "").Trim(), name = NonEmpty(i.FriendlyName, meta?.FriendlyName, i.PrefabName), rarity = (meta?.Rarity ?? i.Rarity).ToString() }; }) .Cast().ToList(), consumables = lockerConsumables .Where(i => i != null && !string.IsNullOrWhiteSpace(i.ConsumableItemDesc)) .Select(i => { consumableCatalog.TryGetValue(i.ConsumableItemDesc.Trim(), out var meta); return new { desc = i.ConsumableItemDesc.Trim(), count = i.Count, name = NonEmpty(meta?.FriendlyName, i.ConsumableItemDesc), rarity = meta == null ? "None" : ((Rarity)meta.Rarity).ToString() }; }) .Cast().ToList() }; var photos = await db.Images .Where(i => i.OwnerId == playerId) .OrderByDescending(i => i.CreatedAt) .Select(i => new { id = i.Id, name = i.Name, image_url = $"{DBConfig.ServerUrl}/images/img/{i.Name}", type = i.ImageType.ToString(), accessibility = i.Accessibility.ToString(), room_id = i.RoomId, created_at = DateTime.SpecifyKind(i.CreatedAt, DateTimeKind.Utc) }) .ToListAsync(); var roomIds = rooms.Select(r => r.id).ToList(); var roomReports = LoadState().ModerationReports .Where(r => string.Equals(r["report_type"]?.ToString(), "Room", StringComparison.OrdinalIgnoreCase) && (string.Equals(r["reported_id"]?.ToString(), playerId.ToString(), StringComparison.Ordinal) || (r["target_id"]?.ToString() is string targetRaw && long.TryParse(targetRaw, out var targetId) && roomIds.Contains(targetId)))) .OrderByDescending(r => r["created_at"]?.ToString()) .ToList(); var moderation = (await db.Moderation .Where(m => m.PlayerId == playerId) .ToListAsync()) .Select(m => new { id = m.Id, category = m.Category.ToString(), message = m.Message, moderator_note = m.ModeratorNote, ban_method = m.BanMethod, created_at = DateTime.SpecifyKind(m.CreatedAt, DateTimeKind.Utc), is_ban = m.IsBan, expire_duration = DateTime.SpecifyKind(m.ExpireDuration, DateTimeKind.Utc) }) .ToList(); var voteKicks = await db.VoteKicks .Where(v => v.PlayerId == playerId) .OrderByDescending(v => v.CreatedAt) .Select(v => new { id = v.Id, reason = v.Reason, category = v.Category.ToString(), is_force = v.IsForce, created_at = DateTime.SpecifyKind(v.CreatedAt, DateTimeKind.Utc), expires_at = DateTime.SpecifyKind(v.ExpiresAt, DateTimeKind.Utc) }) .ToListAsync(); var friendRows = await db.Relationships .Where(r => r.AccountId == playerId && r.RelationshipType == RelationshipType.Friend) .ToListAsync(); var friendIds = friendRows.Select(r => r.PlayerId).Distinct().ToList(); var friendProfiles = friendIds.Count > 0 ? await db.Profiles.Where(p => friendIds.Contains(p.accountId)).ToDictionaryAsync(p => p.accountId) : new Dictionary(); var friendProgression = friendIds.Count > 0 ? await db.Progression.Where(p => friendIds.Contains(p.PlayerId)).ToDictionaryAsync(p => p.PlayerId) : new Dictionary(); var friends = friendRows.Select(r => { friendProfiles.TryGetValue(r.PlayerId, out var fp); friendProgression.TryGetValue(r.PlayerId, out var fprog); return new { player_id = r.PlayerId, username = fp?.username ?? "", display_name = fp?.displayName ?? $"Player {r.PlayerId}", profile_image = fp != null ? ImageUrl(fp.profileImage) : ImageUrl(null), level = fprog?.Level ?? 0, relationship_type = r.RelationshipType.ToString() }; }).ToList(); var taggedImages = await db.Images.ToListAsync(); var photosOfMe = taggedImages .Where(i => { try { var ids = i.PlayerIds.FromJson>() ?? new List(); return ids.Contains(playerId); } catch { return false; } }) .OrderByDescending(i => i.CreatedAt) .Select(i => new { id = i.Id, name = i.Name, image_url = $"{DBConfig.ServerUrl}/images/img/{i.Name}", owner_id = i.OwnerId, room_id = i.RoomId, created_at = DateTime.SpecifyKind(i.CreatedAt, DateTimeKind.Utc) }) .ToList(); var notes = LoadState().Notes .Where(n => string.Equals(n["player_id"]?.ToString(), playerId.ToString(), StringComparison.Ordinal)) .OrderByDescending(n => n["created_at"]?.ToString()) .Cast() .ToList(); var clubRecords = await ClubStore.LoadAsync(db); var clubs = clubRecords .Where(c => c.ownerPlayerId == playerId) .OrderByDescending(c => c.createdAt) .Select(c => new { id = c.clubId, name = c.name, description = c.description, primary_tag = c.primaryTag, created_at = DateTime.SpecifyKind(c.createdAt, DateTimeKind.Utc) }) .Cast() .ToList(); var progress = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId); var progressJson = progress?.GetProgressionJson(); var progression = new { level = progress?.Level ?? 0, xp = progress?.XP ?? 0, last_xp_earned_at = progressJson?.LastXpEarnedAt, xp_earned_today = progressJson?.XpEarnedToday ?? 0 }; var accountLocked = moderation.Any(m => m.is_ban && string.Equals(m.ban_method, "Account Lock", StringComparison.OrdinalIgnoreCase) && m.expire_duration > DateTime.UtcNow); var warningRows = await db.PlayerWarnings .AsNoTracking() .Where(w => w.WarnedPlayerId == playerId) .OrderByDescending(w => w.Id) .ToListAsync(); var warningCount = warningRows.Count; var pendingWarningCount = warningRows.Count(w => w.AcknowledgedAt == null); var warnings = warningRows.Take(25).Select(w => new { id = w.Id, category = w.ReportCategory.ToString(), report_category = (int)w.ReportCategory, reason = w.DisplayReason ?? string.Empty, moderator_id = w.ModeratorPlayerId, note = w.ModeratorNote ?? string.Empty, created_at = DateTime.SpecifyKind(w.CreatedAt, DateTimeKind.Utc), acknowledged_at = w.AcknowledgedAt.HasValue ? DateTime.SpecifyKind(w.AcknowledgedAt.Value, DateTimeKind.Utc) : (DateTime?)null }).Cast().ToList(); var voiceRestriction = await VoiceChatRestrictions.ActiveAsync(db, playerId); return Ok(new { rooms, inventions, events, balance, locker, moderation, voice_restricted = voiceRestriction != null, voice_restriction = voiceRestriction == null ? null : new { reason = voiceRestriction.Reason, category = voiceRestriction.Category.ToString(), report_category = (int)voiceRestriction.Category, issued_by = voiceRestriction.IssuedBy, issued_by_username = voiceRestriction.IssuedByUsername, note = voiceRestriction.ModeratorNote, started_at = DateTime.SpecifyKind(voiceRestriction.StartedAt, DateTimeKind.Utc), expires_at = DateTime.SpecifyKind(voiceRestriction.ExpiresAt, DateTimeKind.Utc), minutes_remaining = (int)Math.Max(0, (voiceRestriction.ExpiresAt - DateTime.UtcNow).TotalMinutes) }, vote_kicks = voteKicks, photos, photos_of_me = photosOfMe, friends, clubs, notes, progression, room_reports = roomReports, warn_count = warningCount, pending_warn_count = pendingWarningCount, warnings, account_locked = accountLocked }); } [HttpGet("notify/status")] public async Task NotifyStatus([FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var accounts = Notify.ConnectedAccountIds(); return Ok(new { hub_context_ready = Notify.Context != null, connected_connections = Notify.ConnectedConnectionCount, connected_account_count = accounts.Count, connected_accounts = accounts }); } [HttpGet("{table}")] public async Task List(string table, [FromQuery] string key, [FromQuery(Name = "eq_col")] string? eqCol, [FromQuery(Name = "eq_val")] string? eqVal, [FromQuery] string? order, [FromQuery] bool ascending = true) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; return table switch { "admin_users" => Ok(await GetAdminUsers(eqCol, eqVal)), "players" => Ok(await GetPlayers(order, ascending)), "gift_drops" => Ok(await GetGiftDrops(order, ascending)), "storefront_items" => Ok(await GetStorefrontItems(eqCol, eqVal, order, ascending)), "storefronts" => Ok(GetStorefronts(order, ascending)), "challenges" => Ok(GetChallenges(order, ascending)), "moderation_log" => Ok(FilterByPlayer(LoadState().ModerationLog, eqCol, eqVal, order, ascending)), "gift_log" => Ok(await ProcessGiftLog(eqCol, eqVal, order, ascending)), "notes" => Ok(FilterByPlayer(LoadState().Notes, eqCol, eqVal, order, ascending)), "audit_log" => Ok(LoadState().AuditLog), "rooms" => Ok(await GetRooms(order, ascending)), "room_comments" => Ok(await GetRoomComments(order, ascending)), "community_board" => Ok(GetCommunityBoard()), "moderation_reports" => Ok(GetModerationReports(order, ascending)), "player_events" => Ok(await GetPlayerEvents(order, ascending)), "player_event_responses" => Ok(await GetPlayerEventResponses(eqCol, eqVal, order, ascending)), "event_invitations" => Ok(await GetEventInvitations(eqCol, eqVal, order, ascending)), "avatar_items" => Ok(GetAvatarItems()), "equipment_items" => Ok(GetEquipmentItems()), "consumable_items" => Ok(GetConsumableItems()), "announcements" => Ok(GetAnnouncements()), "ip_bans" => Ok(await GetIpBans(order, ascending)), "hardware_bans" => Ok(await GetHardwareBans(order, ascending)), "account_creation_blocks" => Ok(await GetAccountCreationBlocks(order, ascending)), "leaderboard_scores" => Ok(await GetLeaderboardScores(eqCol, eqVal, order, ascending)), "game_blocks" => Ok(await GetGameBlocks(order, ascending)), "gameconfigs" => Ok(await db.GameConfig.ToListAsync()), "serverconfigs" => Ok(await db.ServerConfig.ToListAsync()), "clubs" => Ok(await GetClubs()), "featured_rooms" => Ok(await GetFeaturedRooms()), "room_recommendations" => Ok(await GetRoomRecommendations()), "item_sets" => Ok(await GetItemSets()), "playlists" => Ok(await GetPlaylists()), "split_tests" => Ok(await GetSplitTests()), "cohort_nux_buttons" => Ok(await GetCohortNuxButtons()), "challenge_maps" => Ok(await GetChallengeMaps()), "tags" => Ok(await GetTags()), _ => GenericTableList(table) }; } private IActionResult GenericTableList(string table) { var prop = db.GetType().GetProperties() .FirstOrDefault(p => p.PropertyType.IsGenericType && p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>) && string.Equals(p.Name, table, StringComparison.OrdinalIgnoreCase)); if (prop == null) return NotFound($"Unknown admin panel table '{table}'."); var set = prop.GetValue(db) as System.Collections.IEnumerable; var rows = set?.Cast().ToList() ?? new List(); return Ok(rows); } private static List SafeRoomTags(string? tags) { if (string.IsNullOrWhiteSpace(tags)) return new List(); try { return StringTools.ReadTags(tags).Select(t => t.Tag).ToList(); } catch { return new List(); } } private async Task> GetClubs() { var config = await db.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "Clubs"); if (config == null || string.IsNullOrWhiteSpace(config.Value)) return new List(); try { var clubs = config.Value.FromJson>() ?? new List(); var clubReports = LoadState().ModerationReports .Where(r => string.Equals(r["report_type"]?.ToString(), "Club", StringComparison.OrdinalIgnoreCase)) .Select(r => r["target_id"]?.ToString()) .Where(v => !string.IsNullOrWhiteSpace(v)) .GroupBy(v => v!, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal); return clubs.Select(c => { var categories = new List(); if (!string.IsNullOrWhiteSpace(c.primaryTag)) categories.Add(c.primaryTag); if (c.customTags != null) categories.AddRange(c.customTags.Where(t => !string.IsNullOrWhiteSpace(t))); return new { club_id = c.clubId, name = c.name, description = c.description, primary_tag = c.primaryTag, categories = categories, image_name = c.imageName, banner_image_name = c.bannerImageName, owner_player_id = c.ownerPlayerId, created_at = c.createdAt, privacy = c.visibility, privacy_label = c.visibility == 0 ? "Private" : "Public", member_access = c.joinability, member_access_label = c.joinability switch { 1 => "Invite Only", 2 => "Ask to Join", _ => "Open" }, member_count = ClubStore.GetMemberCount(c), report_count = clubReports.TryGetValue(c.clubId.ToString(), out var rc) ? rc : 0 }; }).Cast().ToList(); } catch { return new List(); } } private async Task> GetFeaturedRooms() { var rooms = await db.Rooms.AsNoTracking().ToListAsync(); return rooms .Where(r => SafeRoomTags(r.Tags).Any(t => string.Equals(t, "featured", StringComparison.OrdinalIgnoreCase))) .Select(r => new { room_id = r.RoomId, name = r.Name, creator_player_id = r.CreatorAccountId, tags = r.Tags, accessibility = r.Accessibility.ToString(), is_rro = r.IsRRO, is_dorm = r.IsDorm, created_at = r.CreatedAt }).Cast().ToList(); } private async Task> GetRoomRecommendations() { var top = await db.Rooms .AsNoTracking() .Where(r => r.Accessibility == Accessibility.Public && !r.IsDorm) .Select(r => new { r.RoomId, r.Name, r.CreatorAccountId, r.Tags, r.IsRRO, r.CreatedAt, Engagement = db.RoomInteractions .Where(i => i.RoomId == r.RoomId) .Sum(i => (i.Favorited ? 1 : 0) + (i.Cheered ? 1 : 0)) }) .OrderByDescending(x => x.Engagement) .Take(10) .ToListAsync(); var rank = 1; return top.Select(x => new { rank = rank++, room_id = x.RoomId, name = x.Name, creator_player_id = x.CreatorAccountId, tags = x.Tags, is_rro = x.IsRRO, engagement = x.Engagement, created_at = x.CreatedAt }).Cast().ToList(); } private async Task> GetItemSets() { var curated = await db.ItemSets.AsNoTracking().OrderBy(x => x.ItemSetId).ToListAsync(); var derived = await db.GiftItems .AsNoTracking() .Where(g => g.ItemSetId > 0) .GroupBy(g => new { g.ItemSetId, g.ItemSetFriendlyName }) .Select(g => new { item_set_id = g.Key.ItemSetId, item_set_friendly_name = g.Key.ItemSetFriendlyName, item_count = g.Count() }) .OrderBy(x => x.item_set_id) .ToListAsync(); var result = new List(); var curatedIds = curated.Select(c => c.ItemSetId).ToHashSet(); foreach (var set in curated) { var count = derived.Where(d => d.item_set_id == set.ItemSetId).Sum(d => d.item_count); result.Add(new { item_set_id = set.ItemSetId, item_set_friendly_name = set.FriendlyName, item_count = count, source = "curated" }); } foreach (var set in derived.Where(d => !curatedIds.Contains(d.item_set_id))) { result.Add(new { item_set_id = set.item_set_id, item_set_friendly_name = set.item_set_friendly_name, item_count = set.item_count, source = "derived" }); } return result; } private async Task> GetPlaylists() { var playlists = await db.Playlists.AsNoTracking().OrderBy(x => x.PlaylistId).ToListAsync(); return playlists.Select(p => new { playlist_id = p.PlaylistId, keywords = p.Keywords }).Cast().ToList(); } private async Task CreatePlaylist(string keywords) { var playlist = new PlaylistDB { Keywords = keywords }; db.Playlists.Add(playlist); await db.SaveChangesAsync(); return new { playlist_id = playlist.PlaylistId, keywords = playlist.Keywords }; } private async Task CreateItemSet(string friendlyName) { var set = new ItemSetDB { FriendlyName = friendlyName }; db.ItemSets.Add(set); await db.SaveChangesAsync(); return new { item_set_id = set.ItemSetId, item_set_friendly_name = set.FriendlyName, item_count = 0, source = "curated" }; } private async Task CreateTag(string tag, string description) { var row = new TagDB { Tag = tag, Description = description }; db.Tags.Add(row); await db.SaveChangesAsync(); return new { tag_id = (long?)row.TagId, tag = row.Tag, description = row.Description, room_count = 0, source = "curated" }; } private async Task UpdatePlaylist(long id, JsonElement payload) { var playlist = await db.Playlists.FirstOrDefaultAsync(x => x.PlaylistId == id); if (playlist == null) return NotFound("Playlist not found."); var keywords = GetJsonString(payload, "keywords"); if (keywords != null) playlist.Keywords = keywords; await db.SaveChangesAsync(); return Ok(new { playlist_id = playlist.PlaylistId, keywords = playlist.Keywords }); } private async Task UpdateItemSet(long id, JsonElement payload) { var set = await db.ItemSets.FirstOrDefaultAsync(x => x.ItemSetId == id); if (set == null) return NotFound("Item set not found."); var friendlyName = GetJsonString(payload, "item_set_friendly_name") ?? GetJsonString(payload, "friendly_name"); if (!string.IsNullOrWhiteSpace(friendlyName)) set.FriendlyName = friendlyName; await db.SaveChangesAsync(); return Ok(new { item_set_id = set.ItemSetId, item_set_friendly_name = set.FriendlyName, source = "curated" }); } private async Task UpdateTag(long id, JsonElement payload) { var tag = await db.Tags.FirstOrDefaultAsync(x => x.TagId == id); if (tag == null) return NotFound("Tag not found."); var tagValue = GetJsonString(payload, "tag"); if (!string.IsNullOrWhiteSpace(tagValue)) tag.Tag = tagValue; var description = GetJsonString(payload, "description"); if (description != null) tag.Description = description; await db.SaveChangesAsync(); return Ok(new { tag_id = (long?)tag.TagId, tag = tag.Tag, description = tag.Description, source = "curated" }); } private async Task> GetSplitTests() { var configs = await db.GameConfig.AsNoTracking().ToListAsync(); var available = configs.FirstOrDefault(c => c.Key == "splitTestsAvailable")?.Value ?? ""; var hard = configs.FirstOrDefault(c => c.Key == "splitTestHardOverrides")?.Value ?? ""; var probs = configs.FirstOrDefault(c => c.Key == "splitTestSegmentProbabilities")?.Value ?? ""; var hardMap = new Dictionary(); foreach (var part in hard.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var eq = part.IndexOf('='); if (eq <= 0) continue; hardMap[part[..eq].Trim()] = part[(eq + 1)..].Trim(); } JsonNode? probNode = null; try { probNode = JsonNode.Parse(probs); } catch { } var rows = new List(); foreach (var part in available.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var eq = part.IndexOf('='); if (eq <= 0) continue; var name = part[..eq].Trim(); var segments = part[(eq + 1)..].Trim(); string? probabilities = null; if (probNode is JsonObject obj && obj.TryGetPropertyValue(name, out var segNode) && segNode != null) probabilities = segNode.ToJsonString(); rows.Add(new { test_name = name, available_segments = segments, hard_override = hardMap.TryGetValue(name, out var h) ? h : "", segment_probabilities = probabilities ?? "" }); } return rows; } private async Task> GetCohortNuxButtons() { var rows = new List(); var game = await db.GameConfig.AsNoTracking().ToListAsync(); var server = await db.ServerConfig.AsNoTracking().ToListAsync(); void AddFromConfig(string key, string value, string source) { if (MatchesNux(key)) { rows.Add(new { config_key = key, value, source }); return; } foreach (var entry in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (!MatchesNux(entry)) continue; var eq = entry.IndexOf('='); rows.Add(new { config_key = eq > 0 ? entry[..eq].Trim() : entry, value = eq > 0 ? entry[(eq + 1)..].Trim() : "", source }); } } foreach (var c in game) AddFromConfig(c.Key, c.Value, "GameConfig"); foreach (var c in server) AddFromConfig(c.Key, c.Value, "ServerConfig"); return rows; static bool MatchesNux(string s) => s.Contains("NUX", StringComparison.OrdinalIgnoreCase) || s.Contains("Cohort", StringComparison.OrdinalIgnoreCase); } private async Task> GetChallengeMaps() { var rooms = await db.Rooms.AsNoTracking().ToListAsync(); return rooms .Where(r => SafeRoomTags(r.Tags).Any(t => string.Equals(t, "challenge", StringComparison.OrdinalIgnoreCase) || string.Equals(t, "quest", StringComparison.OrdinalIgnoreCase))) .Select(r => new { room_id = r.RoomId, name = r.Name, creator_player_id = r.CreatorAccountId, tags = r.Tags, accessibility = r.Accessibility.ToString(), is_rro = r.IsRRO, created_at = r.CreatedAt }).Cast().ToList(); } private async Task> GetTags() { var curated = await db.Tags.AsNoTracking().OrderBy(x => x.Tag).ToListAsync(); var rooms = await db.Rooms.AsNoTracking().ToListAsync(); var derived = rooms .SelectMany(r => SafeRoomTags(r.Tags).Select(t => new { Tag = t, RoomId = r.RoomId })) .Where(t => !string.IsNullOrWhiteSpace(t.Tag)) .GroupBy(t => t.Tag, StringComparer.OrdinalIgnoreCase) .Select(g => new { tag = g.Key, room_count = g.Select(x => x.RoomId).Distinct().Count() }) .OrderByDescending(x => x.room_count) .ThenBy(x => x.tag) .ToList(); var result = new List(); var curatedTags = curated.Select(c => c.Tag).ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var tag in curated) { var count = derived.FirstOrDefault(d => string.Equals(d.tag, tag.Tag, StringComparison.OrdinalIgnoreCase))?.room_count ?? 0; result.Add(new { tag_id = (long?)tag.TagId, tag = tag.Tag, description = tag.Description, room_count = count, source = "curated" }); } foreach (var tag in derived.Where(d => !curatedTags.Contains(d.tag))) { result.Add(new { tag_id = (long?)null, tag = tag.tag, description = "", room_count = tag.room_count, source = "derived" }); } return result; } private static string? SaveDataUrlFile(string? dataUrl, string directory) { if (string.IsNullOrWhiteSpace(dataUrl) || !dataUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) return null; var comma = dataUrl.IndexOf(','); if (comma < 0) return null; var header = dataUrl.Substring(0, comma); string ext = header.Contains("png", StringComparison.OrdinalIgnoreCase) ? ".png" : header.Contains("jpeg", StringComparison.OrdinalIgnoreCase) ? ".jpg" : header.Contains("jpg", StringComparison.OrdinalIgnoreCase) ? ".jpg" : header.Contains("gif", StringComparison.OrdinalIgnoreCase) ? ".gif" : header.Contains("webp", StringComparison.OrdinalIgnoreCase) ? ".webp" : ".png"; try { var bytes = Convert.FromBase64String(dataUrl.Substring(comma + 1)); Directory.CreateDirectory(directory); var name = Guid.NewGuid().ToString("N") + ext; System.IO.File.WriteAllBytes(Path.Join(directory, name), bytes); return name; } catch { return null; } } private static string FormatBanTime(TimeSpan timeRemaining) { if (timeRemaining.TotalDays > 365 * 10) return "This ban is permanent."; var parts = new List(); if (timeRemaining.Days > 0) parts.Add($"{timeRemaining.Days} Days"); if (timeRemaining.Hours > 0) parts.Add($"{timeRemaining.Hours} Hours"); if (timeRemaining.Minutes > 0) parts.Add($"{timeRemaining.Minutes} Minutes"); if (timeRemaining.Seconds > 0) parts.Add($"{timeRemaining.Seconds} Seconds"); return parts.Count > 0 ? string.Join(", ", parts) : "0 Seconds"; } [HttpPost("upload/video")] [RequestSizeLimit(AdminUtils.MaxUploadBytes)] public async Task UploadVideo([FromQuery] string key, [FromForm] IFormFile file) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (file == null || file.Length == 0) return BadRequest("No file provided."); var allowed = new[] { ".mp4", ".webm", ".mov", ".m4v", ".ogv" }; var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!allowed.Contains(ext)) return BadRequest($"Unsupported video format '{ext}'. Use mp4/webm/mov."); Directory.CreateDirectory(DBConfig.CDNVideos); var name = Guid.NewGuid().ToString("N") + ext; var path = Path.Join(DBConfig.CDNVideos, name); using (var stream = new FileStream(path, FileMode.Create)) { await file.CopyToAsync(stream); } return Ok(new { name, url = $"{DBConfig.ServerUrl}/cdn/video/{name}" }); } [HttpPost("upload/image")] [RequestSizeLimit(AdminUtils.MaxUploadBytes)] public async Task UploadImage([FromQuery] string key, [FromForm] IFormFile file) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (file == null || file.Length == 0) return BadRequest("No file provided."); var allowed = new[] { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp" }; var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!allowed.Contains(ext)) return BadRequest($"Unsupported image format '{ext}'. Use png/jpg/jpeg/gif/webp."); Directory.CreateDirectory(DBConfig.CDNImages); var name = Guid.NewGuid().ToString("N") + ext; var path = Path.Join(DBConfig.CDNImages, name); using (var stream = new FileStream(path, FileMode.Create)) { await file.CopyToAsync(stream); } return Ok(new { name, url = $"{DBConfig.ServerUrl}/images/img/{name}" }); } [HttpPost("upload/room")] [RequestSizeLimit(AdminUtils.MaxUploadBytes)] public async Task UploadRoom([FromQuery] string key, [FromForm] IFormFile file, [FromQuery] string? name) { // An oversized or truncated body fails form binding, so it is reported as the bad // request it is rather than as whatever a half-read form left behind. if (!ModelState.IsValid) return BadRequest($"Invalid upload request. Use a multipart form no larger than {AdminUtils.MaxUploadLabel}."); var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (file == null || file.Length == 0) return BadRequest("No file provided."); // A subroom is imported as three kinds of file, and each keeps the role its // extension names: the .room is the save the subroom loads, the .assetbundle // is the Unity scene its unity_assets metadata points at, and the .htr files // are the audio/data blobs that save references by name. They are imported one // file at a time, so every one of them lands in the CDN under the name it was // uploaded as - the 2023 client asks for exactly that name (GET /data/), // which is why a file this route renamed to a GUID could never be found. // An explicit ?name= wins; otherwise the uploaded file's own name is the one to // store, and it is held to the same bare-name rule either way. var candidate = !string.IsNullOrWhiteSpace(name) ? name : Path.GetFileName(file.FileName); if (!AdminUtils.TryResolveUploadName(candidate, out var finalName, out var nameError)) return BadRequest(nameError); if (string.IsNullOrEmpty(finalName)) return BadRequest("The uploaded file has no name to store it under."); // The stored name decides which of the three a file is, so a renamed upload is // classified by what it is being stored as. var kind = RoomFileKind(finalName); if (kind == null) { var ext = Path.GetExtension(finalName).ToLowerInvariant(); return BadRequest($"Unsupported room file format '{(string.IsNullOrEmpty(ext) ? "no extension" : ext)}'. Only .room, .htr and .assetbundle room files are allowed."); } Directory.CreateDirectory(DBConfig.CDNStorage); var path = Path.Join(DBConfig.CDNStorage, finalName); using (var stream = new FileStream(path, FileMode.Create)) { await file.CopyToAsync(stream); } return Ok(new { name = finalName, kind, url = $"{DBConfig.ServerUrl}/cdn/data/{finalName}" }); } /// The stored name decides which of the three a room file is: the extension is the role. private static string? RoomFileKind(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch { ".room" => "room", ".htr" => "htr", ".assetbundle" => "assetbundle", _ => null }; /// /// One part of a file too big to send in a single request. /// /// A whole room file in one request does not survive the trip to this server: the edge /// proxy in front of it refuses a request body over 100 MB and holds a streamed body in /// memory, so importing a large .assetbundle died between the panel and here and surfaced /// to the admin as a broken connection rather than as a size refusal. The panel therefore /// posts anything large in parts, and this route appends each one to /// "<name>.uploading" and promotes it to "<name>" when the last part lands, so /// a half-imported file is never served, listed or bound. /// /// The offset is checked against what is already stored, so a part retried after its /// answer was lost either continues where it left off or is told exactly where to continue /// (409, with the stored byte count) instead of corrupting the file. /// [HttpPost("upload/part")] [RequestSizeLimit(AdminUtils.MaxUploadBytes)] public async Task UploadPart([FromQuery] string key, [FromQuery] string name, [FromQuery] long offset, [FromQuery] long total) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (!AdminUtils.TryResolveUploadName(name, out var finalName, out var nameError)) return BadRequest(nameError); if (string.IsNullOrEmpty(finalName)) return BadRequest("The uploaded file has no name to store it under."); if (AdminUtils.IsUploadPart(finalName)) return BadRequest($"'{finalName}' is a partial upload name; import the file under its final name."); if (total <= 0 || total > AdminUtils.MaxUploadBytes) return BadRequest($"A file of that size cannot be imported this way; the limit is {AdminUtils.MaxUploadLabel}."); if (offset < 0 || offset > total) return BadRequest("The part's offset is outside the file it belongs to."); Directory.CreateDirectory(DBConfig.CDNStorage); var partPath = Path.Join(DBConfig.CDNStorage, finalName + AdminUtils.PartSuffix); var finalPath = Path.Join(DBConfig.CDNStorage, finalName); // A part starting at zero begins the file over, whether or not an abandoned attempt left // bytes behind; any other offset has to line up with what is stored or it is refused. var stored = System.IO.File.Exists(partPath) ? new FileInfo(partPath).Length : 0; if (offset == 0) { if (System.IO.File.Exists(partPath)) System.IO.File.Delete(partPath); stored = 0; } if (stored != offset) return Conflict(new { error = "part_out_of_order", message = $"This import already holds {stored} of {total} bytes, not {offset}.", stored, total }); long received = 0; using (var stream = new FileStream(partPath, offset == 0 ? FileMode.Create : FileMode.Append, FileAccess.Write, FileShare.None)) { var buffer = new byte[81920]; int read; while ((read = await Request.Body.ReadAsync(buffer, HttpContext.RequestAborted)) > 0) { received += read; if (offset + received > total) { stream.Dispose(); System.IO.File.Delete(partPath); return BadRequest($"This part carried more than the {total} bytes it was sent for; the import was discarded."); } await stream.WriteAsync(buffer.AsMemory(0, read), HttpContext.RequestAborted); } } var written = offset + received; if (written < total) return Ok(new { name = finalName, kind = RoomFileKind(finalName), received = written, total, complete = false }); // The last part: the file becomes the upload it was always meant to be. if (System.IO.File.Exists(finalPath)) System.IO.File.Delete(finalPath); System.IO.File.Move(partPath, finalPath); return Ok(new { name = finalName, kind = RoomFileKind(finalName), url = $"{DBConfig.ServerUrl}/cdn/data/{finalName}", received = written, total, complete = true }); } /// A part left behind by an import that never finished is not an upload and must not /// accumulate: one older than a day was abandoned, so it goes. private static void SweepAbandonedParts() { if (!Directory.Exists(DBConfig.CDNStorage)) return; foreach (var file in Directory.GetFiles(DBConfig.CDNStorage, "*" + AdminUtils.PartSuffix)) { try { if (System.IO.File.GetLastWriteTimeUtc(file) < DateTime.UtcNow.AddHours(-24)) System.IO.File.Delete(file); } catch (IOException) { } } } [HttpPost("upload/datablob")] [RequestSizeLimit(AdminUtils.MaxUploadBytes)] public async Task UploadDatablob([FromQuery] string key, [FromForm] IFormFile file, [FromQuery] string? name) { if (!ModelState.IsValid) return BadRequest($"Invalid upload request. Use a multipart form no larger than {AdminUtils.MaxUploadLabel}."); var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (file == null || file.Length == 0) return BadRequest("No file provided."); // Same rule as a room file: the blob keeps the name it arrived under unless // the caller names it, because the client can only ask for a name it knows. var candidate = !string.IsNullOrWhiteSpace(name) ? name : Path.GetFileName(file.FileName); if (!AdminUtils.TryResolveUploadName(candidate, out var requestedName, out var nameError)) return BadRequest(nameError); if (string.IsNullOrEmpty(requestedName)) return BadRequest("The uploaded file has no name to store it under."); Directory.CreateDirectory(DBConfig.CDNStorage); var finalName = requestedName; var path = Path.Join(DBConfig.CDNStorage, finalName); using (var stream = new FileStream(path, FileMode.Create)) { await file.CopyToAsync(stream); } return Ok(new { name = finalName, url = $"{DBConfig.ServerUrl}/cdn/data/{finalName}" }); } [HttpGet("uploads")] public async Task ListUploads([FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; string[] Scan(string dir) { if (!Directory.Exists(dir)) return Array.Empty(); SweepAbandonedParts(); return Directory.GetFiles(dir) .Select(f => Path.GetFileName(f)!) // A part of a file still being uploaded is not an upload: it is invisible until // its last part lands and it becomes the file it was sent as. .Where(n => !AdminUtils.IsUploadPart(n)) .OrderByDescending(f => System.IO.File.GetLastWriteTimeUtc(Path.Join(dir, f))) .ToArray(); } var images = Scan(DBConfig.CDNImages); var videos = Scan(DBConfig.CDNVideos); var storage = Scan(DBConfig.CDNStorage); var bundles = storage.Where(n => n.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase)).ToArray(); long SizeOf(string dir, string name) { try { return new FileInfo(Path.Join(dir, name)).Length; } catch { return 0; } } return Ok(new { images = images.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/images/img/{n}", size = SizeOf(DBConfig.CDNImages, n) }).Cast().ToList(), videos = videos.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/video/{n}", size = SizeOf(DBConfig.CDNVideos, n) }).Cast().ToList(), rooms = storage.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/room/{n}", size = SizeOf(DBConfig.CDNStorage, n) }).Cast().ToList(), assetbundles = bundles.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/data/{n}", size = SizeOf(DBConfig.CDNStorage, n) }).Cast().ToList(), datablobs = storage.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/data/{n}", size = SizeOf(DBConfig.CDNStorage, n) }).Cast().ToList() }); } [HttpDelete("uploads")] public async Task DeleteUpload([FromQuery] string key, [FromQuery] string type, [FromQuery] string name) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (string.IsNullOrWhiteSpace(name)) return BadRequest("No file name provided."); name = Path.GetFileName(name); string dir = type switch { "image" => DBConfig.CDNImages, "video" => DBConfig.CDNVideos, _ => DBConfig.CDNStorage }; var path = Path.Join(dir, name); if (!System.IO.File.Exists(path)) return NotFound("File not found."); System.IO.File.Delete(path); return Ok(new { deleted = name }); } [HttpPost("{table}")] public async Task Insert(string table, [FromQuery] string key, [FromBody] JsonElement payload) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (table == "gift_drops" || table == "storefront_items") return Ok(await CreateGiftDrop(payload)); if (table == "ip_bans") return Ok(await AddIpBan(payload)); if (table == "hardware_bans") return Ok(await AddHardwareBan(payload)); if (table == "account_creation_blocks") return Ok(await AddAccountCreationBlock(payload)); if (table == "game_blocks") { var addGameBlockResult = await AddGameBlock(payload); return addGameBlockResult is IActionResult actionResult ? actionResult : Ok(addGameBlockResult); } if (table == "gameconfigs" || table == "serverconfigs") return Ok(await UpsertConfig(table, null, payload)); if (table == "playlists") { var keywords = GetJsonString(payload, "keywords") ?? ""; if (string.IsNullOrWhiteSpace(keywords)) return BadRequest("Keywords are required."); return Ok(await CreatePlaylist(keywords)); } if (table == "item_sets") { var friendlyName = GetJsonString(payload, "item_set_friendly_name") ?? GetJsonString(payload, "friendly_name") ?? ""; if (string.IsNullOrWhiteSpace(friendlyName)) return BadRequest("A friendly name is required."); return Ok(await CreateItemSet(friendlyName)); } if (table == "tags") { var tag = GetJsonString(payload, "tag") ?? ""; if (string.IsNullOrWhiteSpace(tag)) return BadRequest("A tag is required."); var description = GetJsonString(payload, "description") ?? ""; return Ok(await CreateTag(tag, description)); } var state = LoadState(); return table switch { "storefronts" => Ok(AddStorefront(state, payload)), "challenges" => Ok(AddChallenge(state, payload)), "moderation_log" => InsertLoggedRows(state, state.ModerationLog, payload, "moderation_log"), "gift_log" => await InsertGiftLogRows(state, payload), "notes" => Ok(RowsResponse(payload, AddRows(state, state.Notes, payload))), "audit_log" => InsertLoggedRows(state, state.AuditLog, payload, "audit_log"), "community_board" => Ok(await UpdateCommunityBoard(state, payload)), "announcements" => await UpdateAnnouncements(payload), _ => NotFound($"Unknown admin panel table '{table}'.") }; } public async Task Update(string table, long id, [FromQuery] string key, [FromBody] JsonElement payload) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (table == "players") { var updatePlayerResult = await UpdatePlayer(id, payload); return updatePlayerResult is IActionResult actionResult ? actionResult : Ok(updatePlayerResult); } if (table == "rooms") { var updateRoomResult = await UpdateRoom(id, payload); return updateRoomResult is IActionResult roomActionResult ? roomActionResult : Ok(updateRoomResult); } if (table == "player_events") return Ok(await UpdatePlayerEvent(id, payload)); if (table == "gift_drops" || table == "storefront_items") return Ok(await UpdateGiftDrop(id, payload)); if (table == "playlists") return await UpdatePlaylist(id, payload); if (table == "item_sets") return await UpdateItemSet(id, payload); if (table == "tags") return await UpdateTag(id, payload); var state = LoadState(); return table switch { "storefronts" => Ok(UpdateRow(state, state.Storefronts, id, payload)), "challenges" => Ok(UpdateRow(state, state.Challenges, id, payload)), "moderation_reports" => Ok(UpdateRow(state, state.ModerationReports, id, payload)), "community_board" => Ok(await UpdateCommunityBoard(state, payload)), _ => NotFound($"Unknown admin panel table '{table}'.") }; } [HttpPatch("{table}")] public async Task UpdateNoId(string table, [FromQuery] string key, [FromBody] JsonElement payload) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var state = LoadState(); return table switch { "community_board" => Ok(await UpdateCommunityBoard(state, payload)), _ => NotFound($"Unknown admin panel table '{table}'.") }; } [HttpPatch("{table}/{id}")] public async Task UpdateByKey(string table, string id, [FromQuery] string key, [FromBody] JsonElement payload) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; if (table == "gameconfigs" || table == "serverconfigs") return Ok(await UpsertConfig(table, id, payload)); if (long.TryParse(id, out var rowId)) return await Update(table, rowId, key, payload); return NotFound($"Unknown admin panel table '{table}'."); } private async Task UpsertConfig(string table, string? fallbackKey, JsonElement payload) { string? key = null, value = null; if (payload.ValueKind == JsonValueKind.Object) { foreach (var prop in payload.EnumerateObject()) { if (prop.Name.Equals("Key", StringComparison.OrdinalIgnoreCase)) key = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() : prop.Value.ToString(); else if (prop.Name.Equals("Value", StringComparison.OrdinalIgnoreCase)) value = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() : prop.Value.ToString(); } } key ??= fallbackKey; if (string.IsNullOrWhiteSpace(key)) return BadRequest("Config 'Key' is required."); value ??= ""; if (table == "gameconfigs") { var row = await db.GameConfig.FirstOrDefaultAsync(x => x.Key == key); if (row == null) db.GameConfig.Add(new ConfigVal(key, value)); else row.Value = value; } else { var row = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == key); if (row == null) db.ServerConfig.Add(new ServerConfigVal(key, value)); else row.Value = value; } await db.SaveChangesAsync(); return Ok(new { Key = key, Value = value }); } [HttpDelete("{table}")] public async Task Delete(string table, [FromQuery] string key, [FromQuery(Name = "in_col")] string? inCol, [FromQuery(Name = "in_vals")] string? inVals, [FromQuery(Name = "eq_col")] string? eqCol, [FromQuery(Name = "eq_val")] string? eqVal, [FromQuery(Name = "room_id")] long? roomId, [FromQuery(Name = "stat_channel")] int? statChannel, [FromQuery(Name = "player_id")] long? playerId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var ids = ParseIds(inVals ?? eqVal); if (table == "gameconfigs" || table == "serverconfigs") { var cfgKeys = (inVals ?? eqVal ?? "") .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .ToList(); if (table == "gameconfigs") { var rows = await db.GameConfig.Where(x => cfgKeys.Contains(x.Key)).ToListAsync(); db.GameConfig.RemoveRange(rows); await db.SaveChangesAsync(); return Ok(rows); } else { var rows = await db.ServerConfig.Where(x => cfgKeys.Contains(x.Key)).ToListAsync(); db.ServerConfig.RemoveRange(rows); await db.SaveChangesAsync(); return Ok(rows); } } if (table == "gift_drops" || table == "storefront_items") { var gifts = await db.GiftItems.Where(x => ids.Contains(x.GiftDropId)).ToListAsync(); db.GiftItems.RemoveRange(gifts); await db.SaveChangesAsync(); return Ok(gifts); } if (table == "playlists") { var playlists = await db.Playlists.Where(x => ids.Contains(x.PlaylistId)).ToListAsync(); db.Playlists.RemoveRange(playlists); await db.SaveChangesAsync(); return Ok(playlists); } if (table == "item_sets") { var sets = await db.ItemSets.Where(x => ids.Contains(x.ItemSetId)).ToListAsync(); db.ItemSets.RemoveRange(sets); await db.SaveChangesAsync(); return Ok(sets); } if (table == "tags") { var tags = await db.Tags.Where(x => ids.Contains(x.TagId)).ToListAsync(); db.Tags.RemoveRange(tags); await db.SaveChangesAsync(); return Ok(tags); } if (table == "player_events") return Ok(await DeletePlayerEvents(ids)); if (table == "ip_bans") { var ipRows = await db.IpBans.Where(x => ids.Contains(x.Id)).ToListAsync(); db.IpBans.RemoveRange(ipRows); await db.SaveChangesAsync(); return Ok(ipRows); } if (table == "hardware_bans") { var hwRows = await db.HardwareBans.Where(x => ids.Contains(x.Id)).ToListAsync(); db.HardwareBans.RemoveRange(hwRows); await db.SaveChangesAsync(); return Ok(hwRows); } if (table == "account_creation_blocks") { var blockRows = await db.AccountCreationBlocks.Where(x => ids.Contains(x.Id)).ToListAsync(); db.AccountCreationBlocks.RemoveRange(blockRows); await db.SaveChangesAsync(); return Ok(blockRows); } if (table == "game_blocks") { var gameBlockRows = await db.GameBlocks.Where(x => ids.Contains(x.Id)).ToListAsync(); db.GameBlocks.RemoveRange(gameBlockRows); await db.SaveChangesAsync(); if (gameBlockRows.Count > 0) GameBlockMonitor.Invalidate(); return Ok(gameBlockRows); } if (table == "room_comments") { var commentRows = await db.RoomComments.Where(x => ids.Contains(x.Id)).ToListAsync(); db.RoomComments.RemoveRange(commentRows); await db.SaveChangesAsync(); return Ok(commentRows); } if (table == "leaderboard_scores") { if (roomId.HasValue && statChannel.HasValue) { var query = db.LeaderboardScores.Where(x => x.StatChannel == statChannel.Value); if (roomId.Value > 0) query = query.Where(x => x.RoomId == roomId.Value); if (playerId.HasValue) query = query.Where(x => x.PlayerId == playerId.Value); var boardRows = await query.ToListAsync(); db.LeaderboardScores.RemoveRange(boardRows); await db.SaveChangesAsync(); return Ok(boardRows); } var scoreRows = await db.LeaderboardScores.Where(x => ids.Contains(x.Id)).ToListAsync(); db.LeaderboardScores.RemoveRange(scoreRows); await db.SaveChangesAsync(); return Ok(scoreRows); } var state = LoadState(); return table switch { "storefronts" => Ok(DeleteRows(state, state.Storefronts, ids)), "challenges" => Ok(DeleteRows(state, state.Challenges, ids)), "notes" => Ok(DeleteRows(state, state.Notes, ids)), "announcements" => Ok(await DeleteAnnouncements(ids)), "clubs" => Ok(await DeleteClubs(ids)), _ => NotFound($"Unknown admin panel table '{table}'.") }; } private async Task> DeleteClubs(List ids) { var deleted = new List(); foreach (var id in ids) { if (id <= 0) continue; if (await ClubStore.DeleteAsync(db, id)) deleted.Add(new { club_id = id }); } return deleted; } private async Task> DeleteAnnouncements(List ids) { var config = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "Announcement"); if (config == null || string.IsNullOrWhiteSpace(config.Value)) return new List(); try { var list = config.Value.FromJson>() ?? new List(); var removed = new List(); var toRemove = ids.Count > 0 ? list.Where(x => ids.Contains(ReadAnnouncementId(x) ?? 0)).ToList() : list; foreach (var item in toRemove) { list.Remove(item); removed.Add(item); } config.Value = JsonSerializer.Serialize(list); await db.SaveChangesAsync(); foreach (var deleted in toRemove) { try { await Notify.SendGlobalNotify(AnnouncementBroadcast.Delete(ToAnnouncement(deleted))); } catch (Exception) { } } var touchedMaintenance = toRemove.Any(x => string.Equals(x["type"]?.ToString(), "Maintenance", StringComparison.OrdinalIgnoreCase)) || list.Any(x => string.Equals(x["type"]?.ToString(), "Maintenance", StringComparison.OrdinalIgnoreCase)); if (touchedMaintenance) await RecomputeMaintenanceFromAnnouncements(list); return removed.Cast().ToList(); } catch { return new List(); } } private async Task ApplyMaintenanceToConfig(int startsInMinutes) { var config = await db.Config.FirstOrDefaultAsync(); if (config == null) return; config.ServerMaintenance = new ServerMaintenance { StartsInMinutes = Math.Clamp(startsInMinutes, 0, 61) }.ToJson(); await db.SaveChangesAsync(); if (MainConfig.ActiveConfig != null) MainConfig.ActiveConfig.ServerMaintenance = config.ServerMaintenance; } private async Task RecomputeMaintenanceFromAnnouncements(List list) { var config = await db.Config.FirstOrDefaultAsync(); if (config == null) return; var now = DateTime.UtcNow; int minutes = 0; foreach (var item in list) { if (!string.Equals(item["type"]?.ToString(), "Maintenance", StringComparison.OrdinalIgnoreCase)) continue; if (item["expires_at"]?.ToString() is string expiresRaw && DateTime.TryParse(expiresRaw, out var expiry) && expiry <= now) continue; var itemMinutes = item["minutes"]?.GetValue(); if (itemMinutes.HasValue && itemMinutes.Value > 0) minutes = Math.Min(61, Math.Max(minutes, itemMinutes.Value)); } config.ServerMaintenance = new ServerMaintenance { StartsInMinutes = minutes }.ToJson(); await db.SaveChangesAsync(); if (MainConfig.ActiveConfig != null) MainConfig.ActiveConfig.ServerMaintenance = config.ServerMaintenance; } private async Task> GetPlayers(string? order, bool ascending) { var profiles = await db.Profiles.ToListAsync(); var playerIds = profiles.Select(x => x.accountId).ToList(); var players = await db.Players.Where(x => playerIds.Contains(x.AccountId)).ToDictionaryAsync(x => x.AccountId); var progression = await db.Progression.Where(x => playerIds.Contains(x.PlayerId)).ToDictionaryAsync(x => x.PlayerId); var reputations = await db.Reputations.Where(x => playerIds.Contains(x.AccountId)).ToDictionaryAsync(x => x.AccountId); var presences = await db.Presences.Where(x => playerIds.Contains(x.playerId)).ToDictionaryAsync(x => x.playerId); var moderation = await db.Moderation.Where(x => playerIds.Contains(x.PlayerId) && x.IsBan).ToListAsync(); var expiredBans = moderation.Where(m => m.ExpireDuration < DateTime.UtcNow).ToList(); foreach (var expired in expiredBans) { db.Moderation.Remove(expired); moderation.Remove(expired); } if (expiredBans.Count > 0) await db.SaveChangesAsync(); var activeBans = moderation.ToDictionary(x => x.PlayerId); var hardwareBans = await db.HardwareBans.ToListAsync(); var ipBans = await db.IpBans.ToListAsync(); var accountCreationBlocks = await db.AccountCreationBlocks.ToListAsync(); var settings = await db.PlayerSettings.Where(x => playerIds.Contains(x.AccountId)).ToDictionaryAsync(x => x.AccountId); var currencies = await db.Currency.Where(x => playerIds.Contains(x.AccountId)).ToDictionaryAsync(x => x.AccountId); var cached = await db.CachedLogins.Where(x => playerIds.Contains(x.accountId)).ToListAsync(); var connectedAccounts = new HashSet(Notify.SnapshotClients().Select(x => x.AccountId)); var rows = profiles.Select(profile => { players.TryGetValue(profile.accountId, out var player); progression.TryGetValue(profile.accountId, out var progress); reputations.TryGetValue(profile.accountId, out var rep); presences.TryGetValue(profile.accountId, out var presence); activeBans.TryGetValue(profile.accountId, out var ban); var progressJson = progress?.GetProgressionJson(); var hardwareBan = player != null && !string.IsNullOrWhiteSpace(player.DeviceId) ? hardwareBans.FirstOrDefault(h => h.DeviceId == player.DeviceId && (!h.ExpiresAt.HasValue || h.ExpiresAt.Value >= DateTime.UtcNow)) : null; var ipBan = player != null && !string.IsNullOrWhiteSpace(player.LastIpAddress) ? ipBans.FirstOrDefault(ip => ip.IpAddress == player.LastIpAddress && (!ip.ExpiresAt.HasValue || ip.ExpiresAt.Value >= DateTime.UtcNow)) : null; var ipBanned = ipBan != null; var accountCreationBlocked = player != null && !string.IsNullOrWhiteSpace(player.LastIpAddress) ? accountCreationBlocks.Any(b => b.IpAddress == player.LastIpAddress && (!b.ExpiresAt.HasValue || b.ExpiresAt.Value >= DateTime.UtcNow)) : false; settings.TryGetValue(profile.accountId, out var playerSettings); currencies.TryGetValue(profile.accountId, out var currency); var login = cached.FirstOrDefault(x => x.accountId == profile.accountId); var birthday = player?.Birthday; var hasRealBirthday = birthday != null && birthday.Value.Year > 1900; var age = hasRealBirthday ? (int?)Math.Floor((DateTime.UtcNow - birthday!.Value).TotalDays / 365.2425) : null; var settingsList = playerSettings?.DeserializeSettings() ?? new List(); var settingsMap = settingsList .Where(x => !string.IsNullOrWhiteSpace(x.Key)) .GroupBy(x => x.Key) .ToDictionary(x => x.Key, x => x.Last().Value); var nameHistoryList = new List(); if (settingsMap.TryGetValue("NameHistory", out var nhRaw)) { try { nameHistoryList = nhRaw.FromJson>() ?? new List(); } catch { nameHistoryList = new List(); } } bool isOnline = presence?.isOnline == true && connectedAccounts.Contains(profile.accountId); return new { id = profile.accountId, name = profile.displayName, original_name = profile.username, username = profile.username, platform = login?.platform.ToString() ?? "Steam", platform_id = login?.platformId ?? "", joined_at = DateTime.SpecifyKind(profile.createdAt, DateTimeKind.Utc), last_online_at = isOnline ? DateTime.UtcNow : DateTime.SpecifyKind(login?.lastLoginTime ?? profile.createdAt, DateTimeKind.Utc), bio = profile.bio, email = player?.Email ?? "", level = progress?.Level ?? 0, xp = progress?.XP ?? 0, last_xp_earned_at = progressJson?.LastXpEarnedAt, xp_earned_today = progressJson?.XpEarnedToday ?? 0, reputation = rep?.ReputationScore ?? Reputation.DefaultReputation, trust = rep?.Trust ?? "Trusted", report_count = rep?.ReportCount ?? 0, noteriety = rep?.Noteriety ?? 0, cheer_general = rep?.CheerGeneral ?? 0, cheer_helpful = rep?.CheerHelpful ?? 0, cheer_great_host = rep?.CheerGreatHost ?? 0, cheer_sportsman = rep?.CheerSportsman ?? 0, cheer_creative = rep?.CheerCreative ?? 0, cheer_credit = rep?.CheerCredit ?? 0, subscriber_count = 0, subscribed_count = 0, name_history = nameHistoryList, settings = settingsMap, avatar_image = ImageUrl(profile.profileImage), avatar_color = (string?)null, is_online = isOnline, online = isOnline, banned = ban != null || hardwareBan != null || ipBan != null, ban_reason = ban?.Message ?? hardwareBan?.Reason ?? ipBan?.Reason ?? "", ban_type = ban != null ? (ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10) || ban.ExpireDuration - DateTime.UtcNow > TimeSpan.FromDays(365 * 10) ? "Permanent" : "Temporary") : (hardwareBan != null ? (hardwareBan.ExpiresAt.HasValue ? "Temporary" : "Permanent") : (ipBan != null ? (ipBan.ExpiresAt.HasValue ? "Temporary" : "Permanent") : "Temporary")), ban_until = ban != null ? DateTime.SpecifyKind(ban.ExpireDuration, DateTimeKind.Utc) : (hardwareBan != null && hardwareBan.ExpiresAt.HasValue ? DateTime.SpecifyKind(hardwareBan.ExpiresAt.Value, DateTimeKind.Utc) : (ipBan != null && ipBan.ExpiresAt.HasValue ? DateTime.SpecifyKind(ipBan.ExpiresAt.Value, DateTimeKind.Utc) : (DateTime?)null)), ban_method = ban != null && !string.IsNullOrWhiteSpace(ban.BanMethod) ? ban.BanMethod : (hardwareBan != null ? "Hardware Ban" : (ipBan != null ? "IP Ban" : "None")), ban_is_moderator = ban?.Message?.StartsWith("Moderator.", StringComparison.OrdinalIgnoreCase) == true, ban_category = ban?.Category ?? (hardwareBan != null ? ReportCategory.Misc : (ipBan != null ? ReportCategory.Misc : ReportCategory.Unknown)), appeal_code = ban?.AppealCode, ban_action_type = ModerationPolicy.IsVoiceModeration(ban?.BanMethod) ? "VoiceModeration" : (ModerationPolicy.IsTimeout(ban?.BanMethod) ? "Timeout" : "Moderation"), account_locked = ban != null && string.Equals(ban.BanMethod, "Account Lock", StringComparison.OrdinalIgnoreCase), ban_display_reason = ban?.Message != null && ModerationPolicy.IsVoiceModeration(ban.BanMethod) ? $"{ModerationPolicy.AutomaticVoiceModerationBanText}\n\n{ban.Message}" : ban?.Message, ban_started_at = ban != null ? DateTime.SpecifyKind(ban.CreatedAt, DateTimeKind.Utc) : (hardwareBan != null ? DateTime.SpecifyKind(hardwareBan.CreatedAt, DateTimeKind.Utc) : (ipBan != null ? DateTime.SpecifyKind(ipBan.CreatedAt, DateTimeKind.Utc) : (DateTime?)null)), ban_related_account_username = hardwareBan?.BannedAccountId.HasValue == true && hardwareBan.BannedAccountId.Value != profile.accountId ? profiles.FirstOrDefault(p => p.accountId == hardwareBan.BannedAccountId.Value)?.username : null, ban_note = ban?.ModeratorNote, ip_banned = ipBanned, hardware_banned = hardwareBan != null, account_creation_blocked = accountCreationBlocked, device_class = player?.DeviceClass switch { DeviceClass.VR or DeviceClass.VRLow or DeviceClass.Quest2 => "VR", DeviceClass.Screen or DeviceClass.Mobile => "Screen", _ => "Screen" }, device_id = player?.DeviceId ?? "", ip = player?.LastIpAddress ?? "", is_junior = profile.isJunior, jr_state = player?.JuniorState.ToString() ?? "N/A", name_locked = profile.NameLocked, bio_locked = profile.BioLocked, parent_account_id = player?.Parent, has_birthday = hasRealBirthday, age = age, is_parental_locked = settingsMap.ContainsKey("IsParentalLocked") && settingsMap["IsParentalLocked"] == "true", is_coach = false, coach_status = "", roles = profile.role?.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(r => r.Trim()).ToList() ?? new List(), rec_tokens = currency?.RecTokens ?? 0, laser_tag_tickets = currency?.LaserTagTickets ?? 0, gold = currency?.Gold ?? 0, dracula_silver = currency?.DraculaSilver ?? 0 }; }).Cast().ToList(); return OrderRows(rows, order, ascending); } private async Task> GetStorefrontItems(string? eqCol, string? eqVal, string? order, bool ascending) { var storefrontId = eqCol == "storefront_id" && long.TryParse(eqVal, out var parsed) ? parsed : (long?)null; if (storefrontId is > 0 && StorefrontCatalog.StorefrontIds.Contains(storefrontId.Value)) return OrderRows(CatalogStorefrontItems(storefrontId.Value), order, ascending); var query = db.GiftItems.AsQueryable(); if (storefrontId != null) query = query.Where(x => x.StorefrontId == storefrontId.Value); else query = query.Where(x => x.StorefrontId > 0); var giftDrops = await query.ToListAsync(); var storefrontNames = GetStorefrontDisplayNames(); var rows = giftDrops.Select(g => new { id = g.GiftDropId, name = g.Name, start_time = g.StartTime, end_time = g.EndTime, storefront_id = g.StorefrontId, storefront_name = storefrontNames.TryGetValue(g.StorefrontId, out var sfName) ? sfName : "", description = g.Description, currency_amount = g.CurrencyAmount, xp_amount = g.XPAmount, avatar_items = g.AvatarItems, equipment_items = g.EquipmentItems, consumable_items = g.ConsumableItems, purchasable_item_id = g.PurchasableItemId, is_featured = g.IsFeatured, price = g.Price, currency_type = g.CurrencyType.ToString(), rarity = g.Rarity.ToString(), context = g.Context.ToString(), avatar_item_type = g.AvatarItemType.ToString(), equipment_modification_guid = g.EquipmentModificationGuid, tooltip = g.Tooltip, is_query = g.IsQuery, unique = g.Unique, subscribers_only = g.SubscribersOnly, item_set_id = g.ItemSetId, item_set_friendly_name = g.ItemSetFriendlyName }).Cast().ToList(); return OrderRows(rows, order, ascending); } private static List CatalogStorefrontItems(long storefrontId) { var storefrontName = StorefrontDisplayName(storefrontId); var rows = new List(); foreach (var item in StorefrontCatalog.GetItems(storefrontId)) { var gift = item.GiftDrop; var price = item.Prices.FirstOrDefault() ?? item.SubscriberPrices.FirstOrDefault(); rows.Add(new { id = item.PurchasableItemId, name = gift?.FriendlyName ?? "", start_time = (DateTime?)null, end_time = (DateTime?)null, storefront_id = storefrontId, storefront_name = storefrontName, description = "", currency_amount = gift?.Currency ?? 0, xp_amount = gift?.Xp ?? 0, avatar_items = gift?.AvatarItemDesc ?? "", equipment_items = gift?.EquipmentPrefabName ?? "", consumable_items = gift?.ConsumableItemDesc ?? "", gift_drop_id = gift?.GiftDropId ?? 0, purchasable_item_id = item.PurchasableItemId, is_featured = item.IsFeatured, price = price?.Price ?? 0, currency_type = (price?.CurrencyType ?? CurrencyType.RecCenterTokens).ToString(), rarity = (gift?.Rarity ?? Rarity.Common).ToString(), context = (gift?.Context ?? GiftContext.Commerce_Purchase).ToString(), avatar_item_type = (gift?.AvatarItemType ?? AvatarItemType.Outfit).ToString(), equipment_modification_guid = gift?.EquipmentModificationGuid ?? "", tooltip = gift?.Tooltip ?? "", is_query = gift?.IsQuery ?? false, unique = gift?.Unique ?? false, subscribers_only = gift?.SubscribersOnly ?? false, item_set_id = gift?.ItemSetId ?? 0, item_set_friendly_name = gift?.ItemSetFriendlyName ?? "", is_shipped = true }); } return rows; } private async Task> GetGiftDrops(string? order, bool ascending) { var giftDrops = await db.GiftItems.ToListAsync(); var storefrontNames = GetStorefrontDisplayNames(); var rows = giftDrops.Select(g => new { id = g.GiftDropId, name = g.Name, start_time = g.StartTime, end_time = g.EndTime, storefront_id = g.StorefrontId, storefront_name = storefrontNames.TryGetValue(g.StorefrontId, out var sfName) ? sfName : "", description = g.Description, currency_amount = g.CurrencyAmount, xp_amount = g.XPAmount, avatar_items = g.AvatarItems, equipment_items = g.EquipmentItems, consumable_items = g.ConsumableItems, purchasable_item_id = g.PurchasableItemId, is_featured = g.IsFeatured, price = g.Price, currency_type = g.CurrencyType.ToString(), rarity = g.Rarity.ToString(), context = g.Context.ToString(), avatar_item_type = g.AvatarItemType.ToString(), equipment_modification_guid = g.EquipmentModificationGuid, tooltip = g.Tooltip, is_query = g.IsQuery, unique = g.Unique, subscribers_only = g.SubscribersOnly, item_set_id = g.ItemSetId, item_set_friendly_name = g.ItemSetFriendlyName }).Cast().ToList(); return OrderRows(rows, order, ascending); } private static readonly JsonSerializerOptions CatalogJsonOptions = new() { PropertyNameCaseInsensitive = true, Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } }; private List GetEquipmentItems() { var defaultItems = DBConfig.DefaultEquipmentItems.FromJson>(CatalogJsonOptions) ?? new List(); var rows = defaultItems .Where(x => x != null && !string.IsNullOrWhiteSpace(x.PrefabName)) .Select(item => new { equipment_prefab_name = item.PrefabName, modification_guid = item.ModificationGuid ?? "", friendly_name = string.IsNullOrWhiteSpace(item.FriendlyName) ? item.PrefabName : item.FriendlyName, tooltip = item.Tooltip ?? "", rarity = item.Rarity.ToString() }).ToList(); if (rows.Count > 0) return rows.Cast().ToList(); var legacyItems = DBConfig.DefaultEquipmentItems.FromJson>(CatalogJsonOptions) ?? new List(); return legacyItems .Where(x => x != null && !string.IsNullOrWhiteSpace(x.EquipmentPrefabName)) .Select(item => new { equipment_prefab_name = item.EquipmentPrefabName, modification_guid = "", friendly_name = string.IsNullOrWhiteSpace(item.FriendlyName) ? item.EquipmentPrefabName : item.FriendlyName, tooltip = item.Tooltip ?? "", rarity = item.Rarity.ToString() }).Cast().ToList(); } private List GetConsumableItems() { var items = DBConfig.DefaultConsumableItems.FromJson>(CatalogJsonOptions) ?? new List(); return items .Where(x => x != null && !string.IsNullOrWhiteSpace(x.ConsumableItemDesc)) .Select(item => new { consumable_item_desc = item.ConsumableItemDesc, friendly_name = string.IsNullOrWhiteSpace(item.FriendlyName) ? item.ConsumableItemDesc : item.FriendlyName, tooltip = item.Tooltip ?? "", rarity = item.Rarity.ToString() }).Cast().ToList(); } private async Task> GetIpBans(string? order, bool ascending) { var now = DateTime.UtcNow; var expired = await db.IpBans.Where(x => x.ExpiresAt != null && x.ExpiresAt < now).ToListAsync(); if (expired.Count > 0) { db.IpBans.RemoveRange(expired); await db.SaveChangesAsync(); } var rows = await db.IpBans.AsNoTracking() .Select(x => new { id = x.Id, ip = x.IpAddress, reason = x.Reason, created_at = x.CreatedAt, expires_at = x.ExpiresAt, is_permanent = x.ExpiresAt == null }) .ToListAsync(); return OrderRows(rows.Cast().ToList(), order, ascending); } private async Task> GetHardwareBans(string? order, bool ascending) { var now = DateTime.UtcNow; var expired = await db.HardwareBans.Where(x => x.ExpiresAt != null && x.ExpiresAt < now).ToListAsync(); if (expired.Count > 0) { db.HardwareBans.RemoveRange(expired); await db.SaveChangesAsync(); } var rows = await db.HardwareBans.AsNoTracking() .Select(x => new { id = x.Id, device_id = x.DeviceId, reason = x.Reason, created_at = x.CreatedAt, expires_at = x.ExpiresAt, is_permanent = x.ExpiresAt == null }) .ToListAsync(); return OrderRows(rows.Cast().ToList(), order, ascending); } private async Task AddIpBan(JsonElement payload) { var ip = GetJsonString(payload, "ip") ?? GetJsonString(payload, "ip_address") ?? ""; var reason = GetJsonString(payload, "reason") ?? "Banned via admin panel"; var expiresAt = TryGetJsonDate(payload, "expires_at"); var ipBan = new IpBan { IpAddress = ip, Reason = reason, CreatedAt = DateTime.UtcNow, ExpiresAt = expiresAt }; await db.IpBans.AddAsync(ipBan); await db.SaveChangesAsync(); return new JsonObject { ["id"] = ipBan.Id, ["ip"] = ipBan.IpAddress, ["reason"] = ipBan.Reason, ["created_at"] = ipBan.CreatedAt, ["expires_at"] = ipBan.ExpiresAt, ["is_permanent"] = ipBan.ExpiresAt == null }; } private async Task AddHardwareBan(JsonElement payload) { var deviceId = GetJsonString(payload, "device_id") ?? ""; var reason = GetJsonString(payload, "reason") ?? "Banned via admin panel"; var expiresAt = TryGetJsonDate(payload, "expires_at"); var hardwareBan = new HardwareBan { DeviceId = deviceId, Reason = reason, CreatedAt = DateTime.UtcNow, ExpiresAt = expiresAt }; await db.HardwareBans.AddAsync(hardwareBan); await db.SaveChangesAsync(); return new JsonObject { ["id"] = hardwareBan.Id, ["device_id"] = hardwareBan.DeviceId, ["reason"] = hardwareBan.Reason, ["created_at"] = hardwareBan.CreatedAt, ["expires_at"] = hardwareBan.ExpiresAt, ["is_permanent"] = hardwareBan.ExpiresAt == null }; } private async Task> GetAccountCreationBlocks(string? order, bool ascending) { var now = DateTime.UtcNow; var expired = await db.AccountCreationBlocks.Where(x => x.ExpiresAt != null && x.ExpiresAt < now).ToListAsync(); if (expired.Count > 0) { db.AccountCreationBlocks.RemoveRange(expired); await db.SaveChangesAsync(); } var rows = await db.AccountCreationBlocks.AsNoTracking() .Select(x => new { id = x.Id, ip = x.IpAddress, reason = x.Reason, created_at = x.CreatedAt, expires_at = x.ExpiresAt, is_permanent = x.ExpiresAt == null }) .ToListAsync(); return OrderRows(rows.Cast().ToList(), order, ascending); } private async Task AddAccountCreationBlock(JsonElement payload) { var ip = GetJsonString(payload, "ip") ?? GetJsonString(payload, "ip_address") ?? ""; var reason = GetJsonString(payload, "reason") ?? "Blocked via admin panel"; var expiresAt = TryGetJsonDate(payload, "expires_at"); var block = new AccountCreationBlock { IpAddress = ip, Reason = reason, CreatedAt = DateTime.UtcNow, ExpiresAt = expiresAt }; await db.AccountCreationBlocks.AddAsync(block); await db.SaveChangesAsync(); return new JsonObject { ["id"] = block.Id, ["ip"] = block.IpAddress, ["reason"] = block.Reason, ["created_at"] = block.CreatedAt, ["expires_at"] = block.ExpiresAt, ["is_permanent"] = block.ExpiresAt == null }; } private async Task> GetGameBlocks(string? order, bool ascending) { var now = DateTime.UtcNow; var expired = await db.GameBlocks.Where(x => x.ExpiresAt != null && x.ExpiresAt < now).ToListAsync(); if (expired.Count > 0) { db.GameBlocks.RemoveRange(expired); await db.SaveChangesAsync(); } var rows = await db.GameBlocks.AsNoTracking() .Select(x => new { id = x.Id, ip = x.IpAddress, reason = x.Reason, created_at = x.CreatedAt, expires_at = x.ExpiresAt, is_permanent = x.ExpiresAt == null }) .ToListAsync(); return OrderRows(rows.Cast().ToList(), order, ascending); } private async Task AddGameBlock(JsonElement payload) { var ip = GameBlockMonitor.NormalizeIp(GetJsonString(payload, "ip") ?? GetJsonString(payload, "ip_address") ?? ""); if (string.IsNullOrWhiteSpace(ip)) return BadRequest("An IP address is required to create a game block."); var reason = GetJsonString(payload, "reason") ?? "Blocked via admin panel"; var expiresAt = TryGetJsonDate(payload, "expires_at"); var block = new GameBlock { IpAddress = ip, Reason = reason, CreatedAt = DateTime.UtcNow, ExpiresAt = expiresAt }; await db.GameBlocks.AddAsync(block); await db.SaveChangesAsync(); GameBlockMonitor.Invalidate(); return new JsonObject { ["id"] = block.Id, ["ip"] = block.IpAddress, ["reason"] = block.Reason, ["created_at"] = block.CreatedAt, ["expires_at"] = block.ExpiresAt, ["is_permanent"] = block.ExpiresAt == null }; } private async Task> GetLeaderboardScores(string? eqCol, string? eqVal, string? order, bool ascending) { var query = db.LeaderboardScores.AsNoTracking(); if (eqCol == "room_id" && long.TryParse(eqVal, out var roomId)) query = query.Where(x => x.RoomId == roomId); var rows = await query.OrderByDescending(x => x.SubmittedAt).Take(5000).ToListAsync(); var playerNames = await LoadPlayerNames(rows.Select(x => x.PlayerId).Distinct().ToList()); var roomIds = rows.Select(x => x.RoomId).Distinct().ToList(); var rooms = await db.Rooms.AsNoTracking() .Where(r => roomIds.Contains(r.RoomId)) .ToDictionaryAsync(r => r.RoomId, r => r.Name); var result = rows.Select(s => (object)new { id = s.Id, room_id = s.RoomId, room_name = rooms.TryGetValue(s.RoomId, out var roomName) ? roomName : $"Room {s.RoomId}", sub_room_id = s.SubRoomId, player_id = s.PlayerId, player_name = playerNames.GetValueOrDefault(s.PlayerId) ?? $"Player {s.PlayerId}", stat_channel = s.StatChannel, score = s.Score, time = s.Time, submitted_at = DateTime.SpecifyKind(s.SubmittedAt, DateTimeKind.Utc) }).ToList(); return OrderRows(result, order, ascending); } private async Task> LoadPlayerNames(List playerIds) { if (playerIds.Count == 0) return new Dictionary(); var profiles = await db.Profiles.AsNoTracking() .Where(x => playerIds.Contains(x.accountId)) .ToDictionaryAsync(x => x.accountId); return profiles.ToDictionary( kv => kv.Key, kv => kv.Value.displayName ?? kv.Value.username ?? $"Player {kv.Key}"); } private List GetStorefronts(string? order, bool ascending) { var state = LoadState(); var rows = new List(); var seen = new HashSet(); foreach (var id in StorefrontCatalog.StorefrontIds) { seen.Add(id); var existing = state.Storefronts.FirstOrDefault(s => (s["id"]?.GetValue() ?? 0) == id); rows.Add(StorefrontRow(id, StorefrontLabel(id), existing, StorefrontCatalog.GetItems(id).Count, shipped: true)); } foreach (var sf in state.Storefronts) { var id = sf["id"]?.GetValue() ?? 0; if (id <= 0 || !seen.Add(id)) continue; rows.Add(StorefrontRow(id, sf["type"]?.ToString(), sf, db.GiftItems.Count(x => x.StorefrontId == id), shipped: false)); } foreach (var id in db.GiftItems.Where(x => x.StorefrontId > 0).Select(x => x.StorefrontId).Distinct().ToList()) { if (!seen.Add(id)) continue; rows.Add(StorefrontRow(id, null, null, db.GiftItems.Count(x => x.StorefrontId == id), shipped: false)); } return OrderRows(rows, order, ascending); } private static object StorefrontRow(long id, string? type, JsonObject? stateRow, int giftDropCount, bool shipped) { var label = string.IsNullOrWhiteSpace(type) ? StorefrontLabel(id) : type; var friendlyName = stateRow?["friendly_name"]?.ToString(); return new { id, type = label, next_update = stateRow?["next_update"]?.ToString() ?? "", new_until = stateRow?["new_until"]?.ToString() ?? "", friendly_name = string.IsNullOrWhiteSpace(friendlyName) ? label : friendlyName, gift_drop_count = giftDropCount, is_auto_detected = shipped }; } private static string? StorefrontTypeName(long id) { if (id <= 0 || id > int.MaxValue) return null; if (id == (long)StorefrontTypes.None) return null; if (!Enum.IsDefined(typeof(StorefrontTypes), (int)id)) return null; return ((StorefrontTypes)id).ToString(); } private static string StorefrontLabel(long id) => StorefrontTypeName(id) ?? $"#{id}"; private static string StorefrontDisplayName(long id) => StorefrontTypeName(id) is { } name ? $"{name} ({id})" : $"#{id}"; private Dictionary GetStorefrontDisplayNames() { var names = new Dictionary(); foreach (var type in Enum.GetValues()) { if (type == StorefrontTypes.None) continue; names[(long)type] = $"{type} ({(long)type})"; } foreach (var id in StorefrontCatalog.StorefrontIds) names[id] = StorefrontDisplayName(id); var state = LoadState(); foreach (var sf in state.Storefronts) { var id = sf["id"]?.GetValue() ?? 0; if (id > 0 && !names.ContainsKey(id)) { names[id] = $"{sf["type"]?.ToString() ?? $"#{id}"} ({id})"; } } foreach (var id in db.GiftItems.Where(x => x.StorefrontId > 0).Select(x => x.StorefrontId).Distinct().ToList()) names.TryAdd(id, StorefrontDisplayName(id)); return names; } private List GetChallenges(string? order, bool ascending) { var state = LoadState(); return OrderRows(state.Challenges.Cast().ToList(), order, ascending); } private List FilterByPlayer(List rows, string? eqCol, string? eqVal, string? order, bool ascending) { if (!string.IsNullOrWhiteSpace(eqCol) && eqCol == "player_id" && eqVal != null) { rows = rows.Where(x => x["player_id"]?.ToString() == eqVal).ToList(); } return OrderRows(rows.Cast().ToList(), order, ascending); } private static List OrderRows(List rows, string? order, bool ascending) { if (string.IsNullOrWhiteSpace(order)) return rows; var sorted = rows.OrderBy(x => 0); if (rows.Count > 0 && rows[0] is JsonObject rowObj) { var first = rows[0] as JsonObject; if (first != null && first.ContainsKey(order)) { sorted = ascending ? rows.OrderBy(x => (x as JsonObject)?[order]?.ToString()) : rows.OrderByDescending(x => (x as JsonObject)?[order]?.ToString()); } } return sorted.ToList(); } private static JsonObject AddStorefront(AdminPanelState state, JsonElement payload) { var row = new JsonObject(); foreach (var prop in payload.EnumerateObject()) { try { row[prop.Name] = JsonNode.Parse(prop.Value.GetRawText()); } catch { if (prop.Value.ValueKind == JsonValueKind.String) row[prop.Name] = prop.Value.GetString(); else if (prop.Value.ValueKind == JsonValueKind.Number) row[prop.Name] = prop.Value.GetRawText(); else if (prop.Value.ValueKind == JsonValueKind.True) row[prop.Name] = true; else if (prop.Value.ValueKind == JsonValueKind.False) row[prop.Name] = false; else if (prop.Value.ValueKind == JsonValueKind.Null) row[prop.Name] = null; else row[prop.Name] = prop.Value.GetRawText(); } } if (!row.ContainsKey("id")) { var rawType = row["type"]?.ToString() ?? ""; long id = 0; foreach (var type in Enum.GetValues()) { if (type == StorefrontTypes.None) continue; if (string.Equals(type.ToString(), rawType, StringComparison.OrdinalIgnoreCase)) { id = (long)type; break; } } if (id == 0) { long maxId = 10000; foreach (var existing in state.Storefronts) { var rawId = existing["id"]?.ToString(); if (rawId != null && long.TryParse(rawId, out var parsedId) && parsedId >= maxId) maxId = parsedId + 1; } id = maxId; } row["id"] = id; } state.Storefronts.Add(row); SaveState(state); return row; } private static JsonObject AddChallenge(AdminPanelState state, JsonElement payload) { var row = new JsonObject(); foreach (var prop in payload.EnumerateObject()) { try { row[prop.Name] = JsonNode.Parse(prop.Value.GetRawText()); } catch { if (prop.Value.ValueKind == JsonValueKind.String) row[prop.Name] = prop.Value.GetString(); else if (prop.Value.ValueKind == JsonValueKind.Number) row[prop.Name] = prop.Value.GetRawText(); else if (prop.Value.ValueKind == JsonValueKind.True) row[prop.Name] = true; else if (prop.Value.ValueKind == JsonValueKind.False) row[prop.Name] = false; else if (prop.Value.ValueKind == JsonValueKind.Null) row[prop.Name] = null; else row[prop.Name] = prop.Value.GetRawText(); } } if (!row.ContainsKey("id")) { row["id"] = state.Challenges.Count > 0 ? state.Challenges.Max(x => x["id"]?.GetValue() ?? 0) + 1 : 1; } state.Challenges.Add(row); SaveState(state); return row; } private static object RowsResponse(JsonElement payload, List rows) { if (payload.ValueKind == JsonValueKind.Array) return rows; return rows.Count > 0 ? rows[0] : new JsonObject(); } private IActionResult InsertLoggedRows(AdminPanelState state, List targetList, JsonElement payload, string table) { var rows = AddRows(state, targetList, payload); NotifyPanelActionDiscord(table, payload); return Ok(RowsResponse(payload, rows)); } private async Task InsertGiftLogRows(AdminPanelState state, JsonElement payload) { var rows = await AddGiftLogRows(state, payload); NotifyPanelActionDiscord("gift_log", payload); return Ok(RowsResponse(payload, rows)); } private void NotifyPanelActionDiscord(string table, JsonElement payload) { var items = new List(); if (payload.ValueKind == JsonValueKind.Array) { foreach (var el in payload.EnumerateArray()) items.Add(el); } else { items.Add(payload); } foreach (var item in items) { if (item.ValueKind != JsonValueKind.Object) continue; var action = GetJsonString(item, "action") ?? table; var admin = GetJsonString(item, "performed_by") ?? GetJsonString(item, "admin_id") ?? "Unknown"; var playerId = GetJsonString(item, "player_id") ?? ""; string? details = null; if (item.TryGetProperty("details", out var d)) { details = d.ValueKind == JsonValueKind.String ? d.GetString() : d.GetRawText(); } else if (table == "gift_log") { var giftParts = new List(); foreach (var name in new[] { "context", "rarity", "avatar", "equip", "cons", "currency", "currency_type", "xp", "message" }) { var val = GetJsonString(item, name); if (!string.IsNullOrWhiteSpace(val) && val != "null") giftParts.Add($"{name}: {val}"); } details = string.Join(", ", giftParts); } var fullDetails = string.IsNullOrWhiteSpace(playerId) ? details : $"Player {playerId} — {details}"; AdminActionDiscord.LogAction(action, admin, fullDetails); } } private async Task GetPanelAdminUsernameAsync() { if (Request.Cookies.TryGetValue(AdminSession.CookieName, out var token)) { var (ok, userId) = await AdminSession.ValidateAsync(db, token); if (ok) { var user = await db.AdminUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId); if (user != null) return user.Username; } } return "Admin"; } private static List AddRows(AdminPanelState state, List targetList, JsonElement payload) { var items = new List(); if (payload.ValueKind == JsonValueKind.Array) { foreach (var el in payload.EnumerateArray()) items.Add(el); } else { items.Add(payload); } var added = new List(); foreach (var item in items) { if (item.ValueKind != JsonValueKind.Object) continue; var row = new JsonObject(); foreach (var prop in item.EnumerateObject()) { try { row[prop.Name] = JsonNode.Parse(prop.Value.GetRawText()); } catch { if (prop.Value.ValueKind == JsonValueKind.String) row[prop.Name] = prop.Value.GetString(); else if (prop.Value.ValueKind == JsonValueKind.Number) row[prop.Name] = prop.Value.GetRawText(); else if (prop.Value.ValueKind == JsonValueKind.True) row[prop.Name] = true; else if (prop.Value.ValueKind == JsonValueKind.False) row[prop.Name] = false; else if (prop.Value.ValueKind == JsonValueKind.Null) row[prop.Name] = null; else row[prop.Name] = prop.Value.GetRawText(); } } if (!row.ContainsKey("id")) { long nextId = 1; if (targetList.Count > 0) { foreach (var existing in targetList) { var rawId = existing["id"]?.ToString(); if (rawId != null && long.TryParse(rawId, out var parsedId) && parsedId >= nextId) nextId = parsedId + 1; } } row["id"] = nextId; } if (!row.ContainsKey("created_at")) row["created_at"] = DateTime.UtcNow.ToString("O"); targetList.Add(row); added.Add(row); } SaveState(state); return added; } private static JsonObject UpdateRow(AdminPanelState state, List targetList, long id, JsonElement payload) { var row = targetList.FirstOrDefault(x => x["id"]?.GetValue() == id); if (row == null) return new JsonObject { ["error"] = "Row not found" }; foreach (var prop in payload.EnumerateObject()) { try { row[prop.Name] = JsonNode.Parse(prop.Value.GetRawText()); } catch { if (prop.Value.ValueKind == JsonValueKind.String) row[prop.Name] = prop.Value.GetString(); else if (prop.Value.ValueKind == JsonValueKind.Number) row[prop.Name] = prop.Value.GetRawText(); else if (prop.Value.ValueKind == JsonValueKind.True) row[prop.Name] = true; else if (prop.Value.ValueKind == JsonValueKind.False) row[prop.Name] = false; else if (prop.Value.ValueKind == JsonValueKind.Null) row[prop.Name] = null; else row[prop.Name] = prop.Value.GetRawText(); } } SaveState(state); return row; } private static List DeleteRows(AdminPanelState state, List targetList, List ids) { var toRemove = targetList.Where(x => ids.Contains(x["id"]?.GetValue() ?? 0)).ToList(); foreach (var item in toRemove) { targetList.Remove(item); } SaveState(state); return toRemove; } private static List ParseIds(string? vals) { if (string.IsNullOrWhiteSpace(vals)) return new List(); return vals.Split(',') .Select(x => long.TryParse(x.Trim(), out var id) ? id : 0) .Where(x => x > 0) .ToList(); } private async Task CreateGiftDrop(JsonElement payload) { var giftDrop = new GiftDropDB { Name = GetJsonString(payload, "name") ?? "Gift Drop", StartTime = TryGetJsonDate(payload, "start_time") ?? DateTime.UtcNow, EndTime = TryGetJsonDate(payload, "end_time") ?? DateTime.UtcNow.AddDays(30), StorefrontId = TryGetJsonLong(payload, "storefront_id") ?? 0, Description = GetJsonString(payload, "description") ?? GetJsonString(payload, "tooltip") ?? "", CurrencyAmount = TryGetJsonInt(payload, "currency_amount") ?? TryGetJsonInt(payload, "currency") ?? 0, XPAmount = TryGetJsonInt(payload, "xp_amount") ?? 0, AvatarItems = GetJsonString(payload, "avatar_items") ?? GetJsonString(payload, "avatar") ?? "", EquipmentItems = GetJsonString(payload, "equipment_items") ?? GetJsonString(payload, "equip") ?? "", ConsumableItems = GetJsonString(payload, "consumable_items") ?? GetJsonString(payload, "consumable") ?? "", PurchasableItemId = TryGetJsonInt(payload, "purchasable_item_id") ?? 0, IsFeatured = TryGetJsonBool(payload, "is_featured") ?? TryGetJsonBool(payload, "featured") ?? false, Price = TryGetJsonInt(payload, "price") ?? 0, CurrencyType = ParseCurrencyType(GetJsonString(payload, "currency_type")) ?? CurrencyType.RecCenterTokens, Rarity = ParseRarity(GetJsonString(payload, "rarity")) ?? Rarity.Common, Context = ParseGiftContext(GetJsonString(payload, "context")) ?? GiftContext.Game_Drop, AvatarItemType = ParseAvatarItemType(GetJsonString(payload, "avatar_item_type")) ?? GetJsonString(payload, "item_type") switch { "HairDye" => AvatarItemType.HairDye, _ => AvatarItemType.Outfit }, EquipmentModificationGuid = GetJsonString(payload, "equipment_modification_guid") ?? GetJsonString(payload, "prefab_modify") ?? "", Tooltip = GetJsonString(payload, "tooltip") ?? "", IsQuery = TryGetJsonBool(payload, "is_query") ?? false, Unique = TryGetJsonBool(payload, "unique") ?? false, SubscribersOnly = TryGetJsonBool(payload, "subscribers_only") ?? TryGetJsonBool(payload, "subonly") ?? false, ItemSetId = TryGetJsonLong(payload, "item_set_id") ?? 0, ItemSetFriendlyName = GetJsonString(payload, "item_set_friendly_name") ?? "" }; db.GiftItems.Add(giftDrop); await db.SaveChangesAsync(); if (giftDrop.PurchasableItemId == 0) { giftDrop.PurchasableItemId = (int)(giftDrop.GiftDropId % int.MaxValue); await db.SaveChangesAsync(); } return GiftDropToJson(giftDrop); } private async Task UpdateGiftDrop(long id, JsonElement payload) { var giftDrop = await db.GiftItems.FirstOrDefaultAsync(x => x.GiftDropId == id); if (giftDrop == null) return new JsonObject { ["error"] = "Gift drop not found" }; ApplyGiftDropPayload(giftDrop, payload); if (giftDrop.PurchasableItemId == 0) giftDrop.PurchasableItemId = (int)(giftDrop.GiftDropId % int.MaxValue); await db.SaveChangesAsync(); return GiftDropToJson(giftDrop); } private static void ApplyGiftDropPayload(GiftDropDB giftDrop, JsonElement payload) { var name = GetJsonString(payload, "name"); if (name != null) giftDrop.Name = name; var startTime = TryGetJsonDate(payload, "start_time"); if (startTime != null) giftDrop.StartTime = startTime.Value; var endTime = TryGetJsonDate(payload, "end_time"); if (endTime != null) giftDrop.EndTime = endTime.Value; var storefrontId = TryGetJsonLong(payload, "storefront_id"); if (storefrontId != null) giftDrop.StorefrontId = storefrontId.Value; var description = GetJsonString(payload, "description") ?? GetJsonString(payload, "tooltip"); if (description != null) giftDrop.Description = description; var currencyAmount = TryGetJsonInt(payload, "currency_amount") ?? TryGetJsonInt(payload, "currency"); if (currencyAmount != null) giftDrop.CurrencyAmount = currencyAmount.Value; var xpAmount = TryGetJsonInt(payload, "xp_amount"); if (xpAmount != null) giftDrop.XPAmount = xpAmount.Value; var avatarItems = GetJsonString(payload, "avatar_items") ?? GetJsonString(payload, "avatar"); if (avatarItems != null) giftDrop.AvatarItems = avatarItems; var equipmentItems = GetJsonString(payload, "equipment_items") ?? GetJsonString(payload, "equip"); if (equipmentItems != null) giftDrop.EquipmentItems = equipmentItems; var consumableItems = GetJsonString(payload, "consumable_items") ?? GetJsonString(payload, "consumable"); if (consumableItems != null) giftDrop.ConsumableItems = consumableItems; var purchasableItemId = TryGetJsonInt(payload, "purchasable_item_id"); if (purchasableItemId != null && purchasableItemId > 0) giftDrop.PurchasableItemId = purchasableItemId.Value; var isFeatured = TryGetJsonBool(payload, "is_featured") ?? TryGetJsonBool(payload, "featured"); if (isFeatured != null) giftDrop.IsFeatured = isFeatured.Value; var price = TryGetJsonInt(payload, "price"); if (price != null) giftDrop.Price = price.Value; var currencyType = ParseCurrencyType(GetJsonString(payload, "currency_type")); if (currencyType != null) giftDrop.CurrencyType = currencyType.Value; var rarity = ParseRarity(GetJsonString(payload, "rarity")); if (rarity != null) giftDrop.Rarity = rarity.Value; var context = ParseGiftContext(GetJsonString(payload, "context")); if (context != null) giftDrop.Context = context.Value; var avatarItemType = ParseAvatarItemType(GetJsonString(payload, "avatar_item_type")); if (avatarItemType != null) giftDrop.AvatarItemType = avatarItemType.Value; var equipMod = GetJsonString(payload, "equipment_modification_guid") ?? GetJsonString(payload, "prefab_modify"); if (equipMod != null) giftDrop.EquipmentModificationGuid = equipMod; var tooltip = GetJsonString(payload, "tooltip"); if (tooltip != null) giftDrop.Tooltip = tooltip; var isQuery = TryGetJsonBool(payload, "is_query"); if (isQuery != null) giftDrop.IsQuery = isQuery.Value; var unique = TryGetJsonBool(payload, "unique"); if (unique != null) giftDrop.Unique = unique.Value; var subscribersOnly = TryGetJsonBool(payload, "subscribers_only") ?? TryGetJsonBool(payload, "subonly"); if (subscribersOnly != null) giftDrop.SubscribersOnly = subscribersOnly.Value; var itemSetId = TryGetJsonLong(payload, "item_set_id"); if (itemSetId != null) giftDrop.ItemSetId = itemSetId.Value; var itemSetName = GetJsonString(payload, "item_set_friendly_name"); if (itemSetName != null) giftDrop.ItemSetFriendlyName = itemSetName; } private static JsonObject GiftDropToJson(GiftDropDB g) { return new JsonObject { ["id"] = g.GiftDropId, ["name"] = g.Name, ["start_time"] = g.StartTime, ["end_time"] = g.EndTime, ["storefront_id"] = g.StorefrontId, ["description"] = g.Description, ["currency_amount"] = g.CurrencyAmount, ["xp_amount"] = g.XPAmount, ["avatar_items"] = g.AvatarItems, ["equipment_items"] = g.EquipmentItems, ["consumable_items"] = g.ConsumableItems, ["purchasable_item_id"] = g.PurchasableItemId, ["is_featured"] = g.IsFeatured, ["price"] = g.Price, ["currency_type"] = g.CurrencyType.ToString(), ["rarity"] = g.Rarity.ToString(), ["context"] = g.Context.ToString(), ["avatar_item_type"] = g.AvatarItemType.ToString(), ["equipment_modification_guid"] = g.EquipmentModificationGuid, ["tooltip"] = g.Tooltip, ["is_query"] = g.IsQuery, ["unique"] = g.Unique, ["subscribers_only"] = g.SubscribersOnly, ["item_set_id"] = g.ItemSetId, ["item_set_friendly_name"] = g.ItemSetFriendlyName }; } private static string? GetJsonString(JsonElement el, string name) { if (el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String) return v.GetString(); return null; } private static int? TryGetJsonInt(JsonElement el, string name) { if (!el.TryGetProperty(name, out var v)) return null; try { if (v.ValueKind == JsonValueKind.Number) return v.GetInt32(); if (v.ValueKind == JsonValueKind.String && int.TryParse(v.GetString(), out var i)) return i; } catch { } return null; } private static long? TryGetJsonLong(JsonElement el, string name) { if (!el.TryGetProperty(name, out var v)) return null; try { if (v.ValueKind == JsonValueKind.Number) return v.GetInt64(); if (v.ValueKind == JsonValueKind.String && long.TryParse(v.GetString(), out var l)) return l; } catch { } return null; } private static DateTime? TryGetJsonDate(JsonElement el, string name) { if (!el.TryGetProperty(name, out var v)) return null; try { if (v.ValueKind == JsonValueKind.String && DateTime.TryParse(v.GetString(), out var d)) return d.ToUniversalTime(); } catch { } return null; } private static bool? TryGetJsonBool(JsonElement el, string name) { if (!el.TryGetProperty(name, out var v)) return null; try { if (v.ValueKind == JsonValueKind.True) return true; if (v.ValueKind == JsonValueKind.False) return false; if (v.ValueKind == JsonValueKind.String && bool.TryParse(v.GetString(), out var b)) return b; } catch { } return null; } private static CurrencyType? ParseCurrencyType(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; if (Enum.TryParse(raw.Trim(), true, out var type) && Enum.IsDefined(type) && type != CurrencyType.Invalid) return type; return null; } private static Rarity? ParseRarity(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; if (Enum.TryParse(raw.Trim(), true, out var rarity) && Enum.IsDefined(rarity)) return rarity; return null; } private static GiftContext? ParseGiftContext(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; if (Enum.TryParse(raw.Trim(), true, out var context) && Enum.IsDefined(context)) return context; return null; } private static AvatarItemType? ParseAvatarItemType(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; if (Enum.TryParse(raw.Trim(), true, out var type) && Enum.IsDefined(type)) return type; return null; } private static int? TryGetJsonInt(JsonObject obj, string key) { if (obj[key] is not JsonValue v) return null; try { if (v.TryGetValue(out var i)) return i; if (v.TryGetValue(out var s) && int.TryParse(s, out var parsed)) return parsed; if (v.TryGetValue(out var d)) return (int)d; } catch { } return null; } private static long NewGiftDropId() { long value = BitConverter.ToInt64(Guid.NewGuid().ToByteArray(), 0); return value switch { > 0 => value, > long.MinValue => -value + 1, _ => 1 }; } private static GiftContext? TryParseGiftContext(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; if (Enum.TryParse(raw.Trim(), true, out var context) && Enum.IsDefined(context)) return context; return null; } private static Rarity? TryParseRarity(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; if (Enum.TryParse(raw.Trim(), true, out var rarity) && Enum.IsDefined(rarity)) return rarity; return null; } private async Task UpdatePlayer(long id, JsonElement payload) { var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == id); if (profile == null) return NotFound(); var player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == id); var progress = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == id); int? priorLevel = progress?.Level; var rep = await db.Reputations.FirstOrDefaultAsync(x => x.AccountId == id); var settings = await db.PlayerSettings.FirstOrDefaultAsync(x => x.AccountId == id); if (TryGetString(payload, "name", out var name)) profile.displayName = name; if (TryGetString(payload, "original_name", out var username) || TryGetString(payload, "username", out username)) profile.username = username; if (TryGetString(payload, "bio", out var bio)) profile.bio = bio; if (payload.TryGetProperty("name_locked", out var nameLockedElem) && nameLockedElem.ValueKind == JsonValueKind.True) profile.NameLocked = true; else if (payload.TryGetProperty("name_locked", out nameLockedElem) && nameLockedElem.ValueKind == JsonValueKind.False) profile.NameLocked = false; if (payload.TryGetProperty("bio_locked", out var bioLockedElem) && bioLockedElem.ValueKind == JsonValueKind.True) profile.BioLocked = true; else if (payload.TryGetProperty("bio_locked", out bioLockedElem) && bioLockedElem.ValueKind == JsonValueKind.False) profile.BioLocked = false; if (TryGetString(payload, "password", out var newPassword) && !string.IsNullOrWhiteSpace(newPassword)) { if (player == null) return BadRequest("Player data not found - cannot reset password."); player.Hashword = PasswordHasher.HashPassword(player, newPassword); var staleLogins = await db.CachedLogins.Where(x => x.accountId == id).ToListAsync(); if (staleLogins.Count > 0) { db.CachedLogins.RemoveRange(staleLogins); } player.TokenVersion++; TokenRevocation.Bump(id, player.TokenVersion); AdminActionDiscord.LogAction("Player Password Reset", await GetPanelAdminUsernameAsync(), $"Player {id} ({(profile.displayName ?? profile.username) ?? id.ToString()})"); await Notify.SendNotify(id, new Notification { Id = NotificationType.ModerationQuitGame, Msg = null }); var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == id); if (presence != null) { presence.isOnline = false; presence.roomInstance = null; presence.LoginLock = null; } } if (payload.TryGetProperty("avatar_image", out var avatarImageElem)) { if (avatarImageElem.ValueKind == JsonValueKind.String) { var avatarImage = avatarImageElem.GetString(); if (!string.IsNullOrWhiteSpace(avatarImage)) { if (avatarImage.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) { var savedName = SaveDataUrlFile(avatarImage, DBConfig.CDNImages); if (savedName != null) profile.profileImage = savedName; } else { var trimmed = avatarImage; var schemeIndex = trimmed.IndexOf("://", StringComparison.Ordinal); if (schemeIndex >= 0) { var lastSlash = trimmed.LastIndexOf('/'); if (lastSlash > schemeIndex) trimmed = trimmed.Substring(lastSlash + 1); } if (!string.IsNullOrWhiteSpace(trimmed)) profile.profileImage = trimmed; } } } else if (avatarImageElem.ValueKind == JsonValueKind.Null && !string.IsNullOrWhiteSpace(profile.profileImage)) { if (profile.profileImage != DBConfig.DefaultProfileImage) { profile.profileImage = DBConfig.DefaultProfileImage; } } } if (payload.TryGetProperty("is_junior", out var isJunior)) { var junior = isJunior.GetBoolean(); var wasJunior = profile.isJunior; profile.isJunior = junior; if (player != null) { player.IsJunior = junior; player.JuniorState = junior ? JuniorState.ModerationOverride : JuniorState.BasedOnBirthday; } if (wasJunior != junior) { if (player != null) player.TokenVersion++; await db.SaveChangesAsync(); if (player != null) TokenRevocation.Bump(id, player.TokenVersion); await NotifyAccountChanged(id, profile, player, refreshSession: true); } } if (payload.TryGetProperty("roles", out var roles)) { var oldRoles = profile.role; profile.role = JoinRoles(roles); if (oldRoles != profile.role) { if (player != null) { player.TokenVersion++; } await db.SaveChangesAsync(); if (player != null) TokenRevocation.Bump(id, player.TokenVersion); await NotifyAccountChanged(id, profile, player, refreshSession: true); } } bool banPenaltyApplied = false; if (payload.TryGetProperty("banned", out var banned)) { var isBanned = banned.GetBoolean(); var banReason = TryGetString(payload, "ban_reason", out var reason) ? reason : "Banned via admin panel"; var banIsModerator = payload.TryGetProperty("ban_is_moderator", out var isMod) && isMod.ValueKind == JsonValueKind.True; if (banIsModerator && !string.IsNullOrWhiteSpace(banReason) && !banReason.StartsWith("Moderator.", StringComparison.OrdinalIgnoreCase)) { banReason = "Moderator. " + banReason; } var banMethod = TryGetString(payload, "ban_method", out var method) && !string.IsNullOrWhiteSpace(method) ? method.Trim() : (isBanned ? "Admin Panel" : "None"); var moderatorNote = TryGetString(payload, "moderator_note", out var note) ? note?.Trim() : null; var banType = TryGetString(payload, "ban_type", out var type) ? type : (isBanned ? "Temporary" : ""); var banCategory = TryGetEnum(payload, "ban_category", out ReportCategory category) ? category : ReportCategory.Misc; var banIsTimeout = isBanned && payload.TryGetProperty("ban_is_timeout", out var isTimeoutFlag) && isTimeoutFlag.ValueKind == JsonValueKind.True; if (banIsTimeout) { banMethod = ModerationPolicy.AutomaticTimeoutBanMethod; banType = "Temporary"; } var isAccountLock = string.Equals(banMethod, "Account Lock", StringComparison.OrdinalIgnoreCase); if (isAccountLock && isBanned) { banType = "Permanent"; banCategory = ReportCategory.Moderator; banReason = AccountLock.Reason; } DateTime? banDuration = null; if (isBanned) { if (string.Equals(banType, "Permanent", StringComparison.OrdinalIgnoreCase)) { banDuration = DateTime.MaxValue; } else if (TryGetString(payload, "ban_until", out var banUntil)) { if (string.Equals(banUntil, "permanent", StringComparison.OrdinalIgnoreCase) || banUntil == "-1") { banDuration = DateTime.MaxValue; } else if (DateTime.TryParse(banUntil, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var parsedUntil)) { banDuration = parsedUntil; } else { banDuration = DateTime.UtcNow.AddDays(30); } } else { banDuration = DateTime.UtcNow.AddDays(30); } if (banIsTimeout) { var requested = banDuration!.Value - DateTime.UtcNow; banDuration = DateTime.UtcNow + ModerationPolicy.ClampTimeout(requested > TimeSpan.Zero ? requested : TimeSpan.Zero); } } if (banIsTimeout) banReason = PlayerReportingAPI.GetTimeoutDisplayReason(banMethod, banReason, banCategory); string playerIp = TryGetString(payload, "ip", out var payloadIp) && !string.IsNullOrWhiteSpace(payloadIp) ? payloadIp : (player?.LastIpAddress ?? ""); string playerDevice = TryGetString(payload, "device_id", out var payloadDevice) && !string.IsNullOrWhiteSpace(payloadDevice) ? payloadDevice : (player?.DeviceId ?? ""); var existingBan = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == id && x.IsBan); bool banChanged = false; if (isBanned) { if (string.Equals(banMethod, "IP Ban", StringComparison.OrdinalIgnoreCase)) { if (string.IsNullOrWhiteSpace(playerIp)) { return BadRequest("No IP address is known for this player. Ask them to log in once, then try the IP ban again."); } if (existingBan != null) { db.Moderation.Remove(existingBan); existingBan = null; } var existingIpBan = await db.IpBans.FirstOrDefaultAsync(x => x.IpAddress == playerIp); if (existingIpBan == null) { await db.IpBans.AddAsync(new IpBan { IpAddress = playerIp, Reason = banReason, CreatedAt = DateTime.UtcNow, ExpiresAt = banDuration == DateTime.MaxValue ? null : banDuration }); banChanged = true; } else { if (existingIpBan.Reason != banReason || existingIpBan.ExpiresAt != (banDuration == DateTime.MaxValue ? null : banDuration)) banChanged = true; existingIpBan.Reason = banReason; existingIpBan.ExpiresAt = banDuration == DateTime.MaxValue ? null : banDuration; } await db.SaveChangesAsync(); await db.VoteKicks.AddAsync(new VoteKickLog { PlayerId = id, Reason = banReason, Category = banCategory, IsForce = true, CreatedAt = DateTime.UtcNow, ExpiresAt = banDuration ?? DateTime.MaxValue }); await db.SaveChangesAsync(); await PlayerUtils.KickOrPromptAsync(db, id, PlayerUtils.BuildBlockDetails(true, banCategory, banReason, banDuration, startedAtUtc: DateTime.UtcNow)); } else { var appealable = ModerationPolicy.IssuesAppealCode(banMethod); var appealCode = appealable ? existingBan?.AppealCode : null; string? appealError = null; if (appealable && string.IsNullOrWhiteSpace(appealCode)) { var (newCode, cooldownError) = await PlayerUtils.GetAppealForNewBanAsync(db, id); appealCode = newCode; appealError = cooldownError; banChanged = true; } var banStartedAt = DateTime.UtcNow; var banWasModified = existingBan == null || existingBan.Message != banReason || existingBan.ExpireDuration != banDuration!.Value || existingBan.BanMethod != banMethod || existingBan.ModeratorNote != moderatorNote || existingBan.Category != banCategory; if (existingBan == null) { await db.Moderation.AddAsync(new ModerationDB { PlayerId = id, Category = banCategory, Message = banReason, ModeratorNote = moderatorNote, CreatedAt = banStartedAt, ExpireDuration = banDuration!.Value, IsBan = true, BanMethod = banMethod, AppealCode = appealCode }); banChanged = true; } else { if (banWasModified) { banChanged = true; existingBan.CreatedAt = banStartedAt; } existingBan.Category = banCategory; existingBan.Message = banReason; existingBan.ModeratorNote = moderatorNote; existingBan.ExpireDuration = banDuration!.Value; existingBan.IsBan = true; existingBan.BanMethod = banMethod; existingBan.AppealCode = appealCode; } if (string.Equals(banMethod, "Hardware Ban", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(playerDevice)) { var existingHardwareBan = await db.HardwareBans.FirstOrDefaultAsync(x => x.DeviceId == playerDevice); if (existingHardwareBan == null) { await db.HardwareBans.AddAsync(new HardwareBan { DeviceId = playerDevice, Reason = banReason, CreatedAt = DateTime.UtcNow, ExpiresAt = banDuration == DateTime.MaxValue ? null : banDuration, BannedAccountId = id }); } else { existingHardwareBan.Reason = banReason; existingHardwareBan.ExpiresAt = banDuration == DateTime.MaxValue ? null : banDuration; existingHardwareBan.BannedAccountId = id; } } await db.SaveChangesAsync(); await db.VoteKicks.AddAsync(new VoteKickLog { PlayerId = id, Reason = banReason, Category = banCategory, IsForce = true, CreatedAt = DateTime.UtcNow, ExpiresAt = banDuration ?? DateTime.MaxValue }); await db.SaveChangesAsync(); var effectiveBan = existingBan ?? await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == id && x.IsBan); var blockDetails = PlayerUtils.BuildBlockDetails(true, banCategory, banReason, banDuration, appealCode: appealCode, appealError: appealError, startedAtUtc: effectiveBan?.CreatedAt ?? DateTime.UtcNow); PlayerUtils.ApplyClientBanKind(blockDetails, banMethod); await PlayerUtils.KickOrPromptAsync(db, id, blockDetails); } if (banChanged) { if (!isAccountLock) { if (rep == null) { rep = new Reputation { AccountId = id }; await db.Reputations.AddAsync(rep); } rep.ApplyBanPenalty( permanent: banDuration == DateTime.MaxValue, cheating: string.Equals(banMethod, "Anti-Cheat", StringComparison.OrdinalIgnoreCase)); } banPenaltyApplied = true; await AdminActionDiscord.LogBanAsync(db, id, await GetPanelAdminUsernameAsync(), causedByReports: false, reason: banReason, category: banCategory, expiry: banDuration, isModerator: banIsModerator, banMethod: banMethod); } } else if (existingBan != null) { db.Moderation.Remove(existingBan); if (string.Equals(existingBan.BanMethod, "IP Ban", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(playerIp)) await db.IpBans.Where(x => x.IpAddress == playerIp).ExecuteDeleteAsync(); if (string.Equals(existingBan.BanMethod, "Hardware Ban", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(playerDevice)) await db.HardwareBans.Where(x => x.DeviceId == playerDevice).ExecuteDeleteAsync(); await db.SaveChangesAsync(); await BestEffort("the refresh-login notification", id, () => Notify.SendNotify(id, new Notification { Id = NotificationType.RefreshLogin, Msg = profile })); await BestEffort("the moderation-clear push", id, () => PlayerUtils.ReleaseModerationHoldAsync(id)); } else { var clearedIpBan = !string.IsNullOrWhiteSpace(playerIp) && await db.IpBans.Where(x => x.IpAddress == playerIp).ExecuteDeleteAsync() > 0; var clearedHwBan = !string.IsNullOrWhiteSpace(playerDevice) && await db.HardwareBans.Where(x => x.DeviceId == playerDevice).ExecuteDeleteAsync() > 0; if (clearedIpBan || clearedHwBan) { await BestEffort("the refresh-login notification", id, () => Notify.SendNotify(id, new Notification { Id = NotificationType.RefreshLogin, Msg = profile })); await BestEffort("the moderation-clear push", id, () => PlayerUtils.ReleaseModerationHoldAsync(id)); } } } if (player != null) { if (TryGetString(payload, "email", out var email)) { player.Email = email; player.EmailVerified = !string.IsNullOrWhiteSpace(email); player.PendingEmail = null; player.EmailVerifyToken = null; } if (TryGetString(payload, "device_id", out var deviceId)) player.DeviceId = deviceId; if (TryGetEnum(payload, "jr_state", out JuniorState juniorState)) player.JuniorState = juniorState; if (payload.TryGetProperty("is_junior", out var playerIsJunior)) player.IsJunior = playerIsJunior.GetBoolean(); if (payload.TryGetProperty("parent_account_id", out var parentProp)) { if (parentProp.ValueKind == JsonValueKind.Null || (parentProp.ValueKind == JsonValueKind.String && string.IsNullOrWhiteSpace(parentProp.GetString()))) { player.Parent = null; } else if (parentProp.ValueKind == JsonValueKind.Number && parentProp.TryGetInt64(out var rawParent)) { player.Parent = rawParent > 0 ? rawParent : null; } else if (parentProp.ValueKind == JsonValueKind.String && long.TryParse(parentProp.GetString(), out var parsedParent)) { player.Parent = parsedParent > 0 ? parsedParent : null; } } } if (payload.TryGetProperty("name_history", out var nameHistory) && nameHistory.ValueKind == JsonValueKind.Array) { var history = nameHistory.EnumerateArray() .Select(x => x.GetString() ?? string.Empty) .Where(x => !string.IsNullOrWhiteSpace(x)) .ToList(); if (settings == null) { settings = new PlayerSettings { AccountId = id }; await db.PlayerSettings.AddAsync(settings); } var existingSettings = settings.DeserializeSettings() ?? new List(); existingSettings.RemoveAll(x => x.Key == "NameHistory"); existingSettings.Add(new Setting { Key = "NameHistory", Value = System.Text.Json.JsonSerializer.Serialize(history) }); settings.Settings = existingSettings.ToJson(); } if (payload.TryGetProperty("settings", out var settingsProp) && settingsProp.ValueKind == JsonValueKind.Object) { var settingList = new List(); foreach (var prop in settingsProp.EnumerateObject()) { string? val = null; if (prop.Value.ValueKind == JsonValueKind.String) val = prop.Value.GetString(); else if (prop.Value.ValueKind == JsonValueKind.True) val = "true"; else if (prop.Value.ValueKind == JsonValueKind.False) val = "false"; else if (prop.Value.ValueKind == JsonValueKind.Number) val = prop.Value.GetRawText(); else val = prop.Value.GetRawText(); if (val != null) settingList.Add(new Setting { Key = prop.Name, Value = val }); } if (settings == null) { settings = new PlayerSettings { AccountId = id }; await db.PlayerSettings.AddAsync(settings); } settings.Settings = settingList.ToJson(); } var currency = await db.Currency.FirstOrDefaultAsync(x => x.AccountId == id); bool hasBalanceField = HasAny(payload, "rec_tokens", "laser_tag_tickets", "gold", "dracula_silver"); if (currency == null && hasBalanceField) { currency = new Currency { AccountId = id }; await db.Currency.AddAsync(currency); } var balanceEdits = new List<(CurrencyType Type, long Balance, long Granted)>(); if (currency != null) { if (TryGetLong(payload, "rec_tokens", out var recTokens)) { balanceEdits.Add((CurrencyType.RecCenterTokens, recTokens, recTokens - currency.RecTokens)); currency.RecTokens = recTokens; } if (TryGetLong(payload, "laser_tag_tickets", out var laserTickets)) { balanceEdits.Add((CurrencyType.LaserTagTickets, laserTickets, laserTickets - currency.LaserTagTickets)); currency.LaserTagTickets = laserTickets; } if (TryGetLong(payload, "gold", out var gold)) { balanceEdits.Add((CurrencyType.LostSkullsGold, gold, gold - currency.Gold)); currency.Gold = gold; } if (TryGetLong(payload, "dracula_silver", out var draculaSilver)) { balanceEdits.Add((CurrencyType.DraculaSilver, draculaSilver, draculaSilver - currency.DraculaSilver)); currency.DraculaSilver = draculaSilver; } } if (progress == null && (payload.TryGetProperty("level", out _) || payload.TryGetProperty("xp", out _))) { progress = new ProgressionData { PlayerId = id }; await db.Progression.AddAsync(progress); } if (progress != null) { if (TryGetInt(payload, "level", out var level)) progress.Level = level; if (TryGetInt(payload, "xp", out var xp)) progress.XP = xp; } if (rep == null && HasAny(payload, "reputation", "noteriety", "cheer_general", "cheer_helpful", "cheer_great_host", "cheer_sportsman", "cheer_creative", "cheer_credit")) { rep = new Reputation { AccountId = id }; await db.Reputations.AddAsync(rep); } if (rep != null) { if (!banPenaltyApplied && TryGetDouble(payload, "reputation", out var reputationScore)) rep.ReputationScore = reputationScore; if (TryGetDouble(payload, "noteriety", out var noteriety)) rep.Noteriety = noteriety; if (TryGetInt(payload, "cheer_general", out var cheerGeneral)) rep.CheerGeneral = cheerGeneral; if (TryGetInt(payload, "cheer_helpful", out var cheerHelpful)) rep.CheerHelpful = cheerHelpful; if (TryGetInt(payload, "cheer_great_host", out var cheerGreatHost)) rep.CheerGreatHost = cheerGreatHost; if (TryGetInt(payload, "cheer_sportsman", out var cheerSportsman)) rep.CheerSportsman = cheerSportsman; if (TryGetInt(payload, "cheer_creative", out var cheerCreative)) rep.CheerCreative = cheerCreative; if (TryGetInt(payload, "cheer_credit", out var cheerCredit)) rep.CheerCredit = cheerCredit; } await db.SaveChangesAsync(); foreach (var edit in balanceEdits) await BestEffort($"the balance update notification ({edit.Type})", id, () => StorefrontShared.SendBalanceUpdateAsync(id, edit.Type, edit.Balance, edit.Granted, StorefrontBalanceAddTypes.Manual_Thanks)); if (progress != null) { await BestEffort("level-up gift delivery", id, () => LevelUpGiftService.DeliverLevelUpGiftsAsync(db, id, recomputeFromXp: true, fromLevel: priorLevel, notify: true)); await BestEffort("progression update push", id, () => RoomProgressionService.PushProgressionUpdateAsync(db, id)); } return Ok(new { success = true }); } private static async Task BestEffort(string what, long playerId, Func action) { try { await action(); } catch (Exception) { } } private static bool TryGetString(JsonElement payload, string prop, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out string? value) { if (payload.TryGetProperty(prop, out var elem) && elem.ValueKind == JsonValueKind.String) { value = elem.GetString()!; return true; } value = null; return false; } [HttpPost("players/{playerId:long}/quitgame")] public async Task QuitPlayerGame(long playerId, [FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); if (profile == null) return NotFound("Player does not exist."); var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username; Notify.SendReliably(playerId, new Notification { Id = NotificationType.ModerationQuitGame, Msg = "" }, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(1), Notify.EmptyObjectPayload(NotificationType.ModerationQuitGame)); var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId); if (presence != null) { presence.isOnline = false; presence.roomInstance = null; await db.SaveChangesAsync(); } AdminActionDiscord.LogAction("Quit Game", await GetPanelAdminUsernameAsync(), $"Forced player {playerId} ({display}) to quit the game."); return Ok(new { success = true, message = $"Sent quit-game to {display}." }); } [HttpPost("players/{playerId:long}/forcelogout")] public async Task ForceLogoutPlayer(long playerId, [FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); if (profile == null) return NotFound("Player does not exist."); var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username; await Notify.SendNotify(playerId, Notify.EmptyObjectPayload(NotificationType.Logout)); var player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == playerId); if (player != null) { player.TokenVersion++; TokenRevocation.Bump(playerId, player.TokenVersion); } var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId); if (presence != null) { presence.isOnline = false; presence.roomInstance = null; } await db.SaveChangesAsync(); _ = Task.Run(async () => { try { await Task.Delay(TimeSpan.FromMilliseconds(300)); Notify.DisconnectAccount(playerId, "force logout"); } catch { } }); AdminActionDiscord.LogAction("Force Logout", await GetPanelAdminUsernameAsync(), $"Forced player {playerId} ({display}) to log out."); return Ok(new { success = true, message = $"Sent force-logout to {display}." }); } [HttpPost("players/{playerId:long}/forcejoin")] public async Task ForceJoinPlayer(long playerId, [FromQuery] string key, [FromQuery(Name = "target_player_id")] long targetPlayerId) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var hostProfile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); if (hostProfile == null) return NotFound("Host player does not exist."); var targetProfile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == targetPlayerId); if (targetProfile == null) return NotFound("Target player does not exist."); var hostDisplay = !string.IsNullOrWhiteSpace(hostProfile.displayName) ? hostProfile.displayName : hostProfile.username; var targetDisplay = !string.IsNullOrWhiteSpace(targetProfile.displayName) ? targetProfile.displayName : targetProfile.username; var hostPresence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId); if (hostPresence == null || !hostPresence.roomInstance.HasValue) return BadRequest(new { success = false, message = $"{hostDisplay} is not currently in a room." }); var roomInstanceId = hostPresence.roomInstance.Value; var roomInstance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == roomInstanceId); if (roomInstance == null) return BadRequest(new { success = false, message = $"The room instance {roomInstanceId} could not be found." }); var matchResult = await MatchmakingUtils.JoinRoom(targetPlayerId, roomInstanceId); if (matchResult.errorCode != JoinStatus.Success) { return BadRequest(new { success = false, message = $"Failed to move {targetDisplay} into the room: {matchResult.errorCode}." }); } db.ChangeTracker.Clear(); var targetPresence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == targetPlayerId); if (targetPresence != null) { try { await Notify.SendNotify(targetPlayerId, new Notification { Id = "PresenceUpdate", Msg = await Presence.CreateAsync(db, targetPresence, roomInstance) }); } catch (Exception) { } } var targetBan = await db.ResolveBanAsync(targetPlayerId); if (targetBan != null && !await db.IsDeveloper(targetPlayerId)) { var banDetails = PlayerUtils.BuildBlockDetails(true, targetBan.Category, targetBan.Reason, targetBan.ExpiryUtc, appealCode: targetBan.AppealCode, startedAtUtc: targetBan.StartedAtUtc, relatedUsername: await PlayerUtils.RelatedAccountUsernameAsync(db, targetBan.BannedAccountId, targetPlayerId)); PlayerUtils.ApplyClientBanKind(banDetails, targetBan.BanMethod); await Notify.SendNotify(targetPlayerId, new Notification { Id = NotificationType.ModerationKick, Msg = banDetails }); } AdminActionDiscord.LogAction("Force Join", await GetPanelAdminUsernameAsync(), $"Force-joined player {targetPlayerId} ({targetDisplay}) into room {roomInstance.name} (instance {roomInstanceId}) where player {playerId} ({hostDisplay}) is."); return Ok(new { success = true, message = $"{targetDisplay} has been moved to {hostDisplay}'s room ({roomInstance.name}).", room_name = roomInstance.name, room_instance_id = roomInstanceId }); } [HttpPost("players/{playerId:long}/warn")] public async Task WarnPlayer(long playerId, [FromQuery] string key, [FromQuery] int reportCategory, [FromQuery] string? reason, [FromQuery] string? note) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); if (profile == null) return NotFound("Player does not exist."); var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username; if (!Enum.IsDefined(typeof(ReportCategory), reportCategory)) return BadRequest(new { success = false, message = $"Unknown report category {reportCategory}." }); var category = (ReportCategory)reportCategory; var displayReason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(); var moderatorNote = string.IsNullOrWhiteSpace(note) ? null : note.Trim(); var warning = await PlayerReportingAPI.IssueWarningAsync(db, playerId, category, displayReason, moderatorNote, 1); if (warning == null) { var connected = Notify.GetClient(playerId) != null; AdminActionDiscord.LogAction("Warn Player (awaiting decision)", await GetPanelAdminUsernameAsync(), $"Warned developer {playerId} ({display}) - {category} - parked until they accept or deny" + (connected ? string.Empty : " (no live notify connection at the time)")); return Ok(new { success = true, message = connected ? $"{display} is a developer: the accept/deny prompt was sent to their client and the warning is not applied unless they accept it." : $"{display} is a developer and has no live connection right now, so the accept/deny prompt could not be shown. The warning is parked (nothing written) and prompts again on the next action aimed at them.", warning_id = (long?)null }); } AdminActionDiscord.LogAction("Warn Player", await GetPanelAdminUsernameAsync(), $"Warned player {playerId} ({display}) - {category}" + (displayReason == null ? string.Empty : $": {displayReason}") + (moderatorNote == null ? string.Empty : $" | note: {moderatorNote}")); return Ok(new { success = true, message = $"{display} was warned: moved to their dorm and shown the warning until they acknowledge it.", warning_id = warning.Id }); } [HttpPost("players/{playerId:long}/voicerestriction")] public async Task ApplyVoiceChatRestriction(long playerId, [FromQuery] string key, [FromQuery] int minutes, [FromQuery] int? reportCategory, [FromQuery] string? reason, [FromQuery] string? note) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); if (profile == null) return NotFound("Player does not exist."); var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username; var category = reportCategory.HasValue && Enum.IsDefined(typeof(ReportCategory), reportCategory.Value) ? (ReportCategory)reportCategory.Value : ReportCategory.Misc; var duration = TimeSpan.FromMinutes(minutes > 0 ? minutes : VoiceChatRestrictions.DefaultDuration.TotalMinutes); var restriction = await VoiceChatRestrictions.ApplyAsync(db, playerId, string.IsNullOrWhiteSpace(reason) ? "Restricted from voice chat for a Code of Conduct violation." : reason.Trim(), category, duration, VoiceChatRestrictions.Method, await GetPanelAdminUsernameAsync(), string.IsNullOrWhiteSpace(note) ? null : note.Trim()); await VoiceChatRestrictions.PushAsync(playerId, restriction); AdminActionDiscord.LogAction("Voice Restriction", await GetPanelAdminUsernameAsync(), $"Restricted {display} ({playerId}) from voice chat for {Math.Round((restriction.ExpiresAt - DateTime.UtcNow).TotalMinutes)} minutes ({category})" + (string.IsNullOrWhiteSpace(reason) ? "" : $": {reason.Trim()}")); return Ok(new { success = true, message = $"{display} is restricted from voice chat until {restriction.ExpiresAt:u}.", expires_at = DateTime.SpecifyKind(restriction.ExpiresAt, DateTimeKind.Utc), minutes = (int)Math.Max(0, (restriction.ExpiresAt - DateTime.UtcNow).TotalMinutes) }); } [HttpDelete("players/{playerId:long}/voicerestriction")] public async Task ClearVoiceChatRestriction(long playerId, [FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); if (profile == null) return NotFound("Player does not exist."); var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username; var cleared = await VoiceChatRestrictions.ClearAsync(db, playerId, await GetPanelAdminUsernameAsync()); if (!cleared) return Ok(new { success = true, message = $"{display} is not restricted from voice chat." }); await VoiceChatRestrictions.PushAsync(playerId, null); AdminActionDiscord.LogAction("Voice Restriction Lifted", await GetPanelAdminUsernameAsync(), $"Lifted the voice chat restriction on {display} ({playerId})."); return Ok(new { success = true, message = $"{display} can use voice chat again." }); } [HttpPost("players/{playerId:long}/togglejunior")] public async Task ToggleJunior(long playerId, [FromQuery] string key) { var rejection = await RejectInvalidKey(key); if (rejection != null) return rejection; var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId); if (profile == null) return NotFound("Player does not exist."); var player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == playerId); var display = !string.IsNullOrWhiteSpace(profile.displayName) ? profile.displayName : profile.username; var makeJunior = !profile.isJunior; profile.isJunior = makeJunior; if (player != null) { player.IsJunior = makeJunior; player.JuniorState = makeJunior ? JuniorState.ModerationOverride : JuniorState.BasedOnBirthday; } await db.SaveChangesAsync(); if (player != null) player.TokenVersion++; await db.SaveChangesAsync(); if (player != null) TokenRevocation.Bump(playerId, player.TokenVersion); await NotifyAccountChanged(playerId, profile, player, refreshSession: true); AdminActionDiscord.LogAction(makeJunior ? "Junior Account" : "Lift Junior", await GetPanelAdminUsernameAsync(), $"Set player {playerId} ({display}) junior status to {makeJunior}."); return Ok(new { success = true, is_junior = makeJunior, jr_state = player?.JuniorState.ToString() ?? profile.isJunior.ToString(), message = makeJunior ? $"{display} is now a Junior account." : $"Junior status removed from {display}." }); } private static bool TryGetInt(JsonElement payload, string prop, out int value) { value = 0; if (!payload.TryGetProperty(prop, out var elem)) return false; switch (elem.ValueKind) { case JsonValueKind.Number: if (elem.TryGetInt64(out var asLong)) { value = (int)Math.Clamp(asLong, int.MinValue, int.MaxValue); return true; } if (elem.TryGetDouble(out var asDouble)) { value = (int)Math.Clamp(asDouble, int.MinValue, int.MaxValue); return true; } return false; case JsonValueKind.String: if (int.TryParse(elem.GetString(), out var parsed)) { value = parsed; return true; } if (double.TryParse(elem.GetString(), out var parsedDouble)) { value = (int)Math.Clamp(parsedDouble, int.MinValue, int.MaxValue); return true; } return false; default: return false; } } private static bool TryGetLong(JsonElement payload, string prop, out long value) { value = 0; if (!payload.TryGetProperty(prop, out var elem)) return false; switch (elem.ValueKind) { case JsonValueKind.Number: if (elem.TryGetInt64(out var asLong)) { value = asLong; return true; } if (elem.TryGetDouble(out var asDouble)) { value = (long)Math.Clamp(asDouble, (double)long.MinValue, (double)long.MaxValue); return true; } return false; case JsonValueKind.String: if (long.TryParse(elem.GetString(), out var parsed)) { value = parsed; return true; } if (double.TryParse(elem.GetString(), out var parsedDouble)) { value = (long)Math.Clamp(parsedDouble, (double)long.MinValue, (double)long.MaxValue); return true; } return false; default: return false; } } /// /// An account id out of a panel payload, where blank means "none". Distinct from /// because a field the panel can clear has to accept "" (and a /// cleared field must not silently keep the old id), and because a value that is neither a /// number nor digits - the panel posts account ids as text to keep eighteen digits exact - /// is reported rather than quietly dropped. /// /// Returns false only when the property is absent, so a caller leaves the stored value alone /// unless the panel actually sent the field. When it returns true, is /// non-null for a value that cannot be an id, and the caller must not write it. /// private static bool TryGetAccountId(JsonElement payload, string prop, out long value, out string? error) { value = 0; error = null; if (!payload.TryGetProperty(prop, out var elem)) return false; var text = elem.ValueKind switch { JsonValueKind.Number => elem.GetRawText(), JsonValueKind.String => elem.GetString()?.Trim(), JsonValueKind.Null => null, _ => "" }; if (string.IsNullOrEmpty(text)) return true; if (long.TryParse(text, out var parsed) && parsed >= 0) { value = parsed; return true; } error = $"'{prop}' must be an account id in digits, or blank for no sender."; return true; } private static bool TryGetDouble(JsonElement payload, string prop, out double value) { value = 0; if (!payload.TryGetProperty(prop, out var elem)) return false; return elem.ValueKind switch { JsonValueKind.Number => elem.TryGetDouble(out value), JsonValueKind.String => double.TryParse(elem.GetString(), out value), _ => false, }; } private static bool TryGetEnum(JsonElement payload, string prop, out T value) where T : struct { if (payload.TryGetProperty(prop, out var elem) && elem.ValueKind == JsonValueKind.String && Enum.TryParse(elem.GetString(), true, out var parsed)) { value = parsed; return true; } value = default; return false; } private static bool HasAny(JsonElement payload, params string[] props) { return props.Any(p => payload.TryGetProperty(p, out _)); } private static string[] SplitRoles(string? roles) => string.IsNullOrWhiteSpace(roles) ? [] : roles.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); private async Task NotifyAccountChanged(long id, Profile profile, PlayerData? player, bool refreshSession = false) { await Notify.SendNotify(id, new Notification { Id = "AccountUpdate", Msg = profile }); await Notify.UpdateSubscriptions(id, Notifs.SubscriptionUpdateProfile, profile); if (player != null) await Notify.SendNotify(id, new Notification { Id = "SelfAccountUpdate", Msg = PlayerUtils.BuildSelfAccount(profile, player) }); if (refreshSession) await Notify.SendNotify(id, new Notification { Id = NotificationType.RefreshLogin, Msg = profile }); } private static string JoinRoles(JsonElement roles) { if (roles.ValueKind != JsonValueKind.Array) return roles.ToString(); return string.Join(",", roles.EnumerateArray().Select(x => x.ToString()).Where(x => !string.IsNullOrWhiteSpace(x))); } private async Task> GetRooms(string? order, bool ascending) { var roomsList = await db.Rooms.ToListAsync(); var roomIds = roomsList.Select(r => r.RoomId).ToList(); var stats = await db.RoomStats.Where(s => roomIds.Contains(s.RoomId)).ToDictionaryAsync(s => s.RoomId); var giftRows = await db.RoomJoinGifts.Where(g => roomIds.Contains(g.RoomId)).ToListAsync(); var giftsByRoom = giftRows.GroupBy(g => g.RoomId).ToDictionary(g => g.Key, g => g.ToList()); object RoomRow(RoomData r, RoomStats? stat, bool banned, string banReason) => new { id = r.RoomId, name = r.Name, description = r.Description, image_name = r.ImageName, custom_warning = r.CustomWarning, creator_account_id = r.CreatorAccountId, publish_state = r.State.ToString(), accessibility = r.Accessibility.ToString(), is_radicade_approved = false, banned = banned, ban_reason = banReason, exclude_from_lists = false, exclude_from_search = false, supports_screens = r.SupportsScreens, supports_walk_vr = r.SupportsWalkVR, supports_teleport_vr = r.SupportsTeleportVR, supports_vr_low = r.SupportsVRLow, supports_quest2 = r.SupportsQuest2, supports_mobile = r.SupportsMobile, supports_juniors = r.SupportsJuniors, min_level = r.MinLevel, age_rating = 0, created_at = r.CreatedAt, published_at = r.PublishedAt, cheer_count = stat?.CheerCount ?? 0, favorite_count = stat?.FavoriteCount ?? 0, visitor_count = stat?.VisitorCount ?? 0, visit_count = stat?.VisitCount ?? 0, ranking_context = "", is_dorm = r.IsDorm, is_place_play = false, max_player_calculation_mode = (int)r.MaxPlayerCalculationMode, max_players = r.MaxPlayers, cloning_allowed = r.CloningAllowed, disable_mic_auto_mute = r.DisableMicAutoMute, disable_room_comments = r.DisableRoomComments, encrypt_voice_chat = r.EncryptVoiceChat, join_gift_enabled = r.JoinGiftEnabled, join_gift_once_per_player = r.JoinGiftOncePerPlayer, join_gift_version = r.JoinGiftVersion, join_gift_title = r.JoinGiftTitle, join_gift_message = r.JoinGiftMessage, join_gift_avatar_item = r.JoinGiftAvatarItem, join_gift_equipment_item = r.JoinGiftEquipmentItem, join_gift_consumable_item = r.JoinGiftConsumableItem, join_gift_currency_amount = r.JoinGiftCurrencyAmount, join_gift_currency_type = r.JoinGiftCurrencyType.ToString(), join_gift_xp = r.JoinGiftXp, join_gift_context = r.JoinGiftContext.ToString(), join_gift_rarity = r.JoinGiftRarity.ToString(), join_gift_avatar_item_type = r.JoinGiftAvatarItemType.ToString(), join_gift_equipment_modification_guid = r.JoinGiftEquipmentModificationGuid, join_gift_from_player_id = Math.Max(0, r.JoinGiftFromPlayerId).ToString(CultureInfo.InvariantCulture), // The room's whole list of boxes. A room saved before the list existed answers with // the one box its row still describes, so the editor always opens on something real. join_gifts = JoinGiftRows(r, giftsByRoom.GetValueOrDefault(r.RoomId)), toxmod_enabled = false, load_screen_locked = false, persistence_version = 0, min_ugc_sub_version = 0, auto_localize_room = false, is_developer_owned = false, tags = r.Tags, featured = SafeRoomTags(r.Tags).Any(t => string.Equals(t, "featured", StringComparison.OrdinalIgnoreCase)) }; var rooms = roomsList.Select(r => { stats.TryGetValue(r.RoomId, out var stat); return RoomRow(r, stat, false, ""); }).ToList(); var liveRoomIds = roomsList.Select(r => r.RoomId).ToHashSet(); foreach (var entry in RoomBanStore.Load()) { if (liveRoomIds.Contains(entry.RoomId)) continue; rooms.Add(RoomRow(entry.Room, entry.Stats, true, entry.Reason)); } return OrderRows(rooms, order, ascending); } private async Task> GetRoomComments(string? order, bool ascending) { var rows = await db.RoomComments .AsNoTracking() .Select(c => new { id = c.Id, comment = c.Comment, comment_type = c.CommentType, room_id = c.RoomId, creator_account_id = c.CreatorAccountId, created_at = c.CreatedAt, position_x = c.PositionX, position_y = c.PositionY, position_z = c.PositionZ }) .ToListAsync(); return OrderRows(rows.Cast().ToList(), order, ascending); } private List GetCommunityBoard() { var state = LoadState(); if (state.CommunityBoard == null) { state.CommunityBoard = new JsonObject { ["id"] = 1, ["featured_player_id"] = null, ["instagram_images"] = new JsonArray(), ["board_message"] = "", ["announcement_url"] = "", ["videos"] = new JsonArray(), ["featured_rooms"] = new JsonArray() }; SaveState(state); } return new List { state.CommunityBoard }; } private async Task UpdateCommunityBoard(AdminPanelState state, JsonElement payload) { if (state.CommunityBoard == null) { state.CommunityBoard = new JsonObject { ["id"] = 1, ["featured_player_id"] = null, ["instagram_images"] = new JsonArray(), ["board_message"] = "", ["announcement_url"] = "", ["videos"] = new JsonArray(), ["featured_rooms"] = new JsonArray() }; } foreach (var prop in payload.EnumerateObject()) { try { state.CommunityBoard[prop.Name] = JsonNode.Parse(prop.Value.GetRawText()); } catch { if (prop.Value.ValueKind == JsonValueKind.String) state.CommunityBoard[prop.Name] = prop.Value.GetString(); else if (prop.Value.ValueKind == JsonValueKind.Number) state.CommunityBoard[prop.Name] = prop.Value.GetRawText(); else if (prop.Value.ValueKind == JsonValueKind.True) state.CommunityBoard[prop.Name] = true; else if (prop.Value.ValueKind == JsonValueKind.False) state.CommunityBoard[prop.Name] = false; else if (prop.Value.ValueKind == JsonValueKind.Null) state.CommunityBoard[prop.Name] = null; else state.CommunityBoard[prop.Name] = prop.Value.GetRawText(); } } var instagramNode = state.CommunityBoard["instagram_images"]; if (instagramNode != null && instagramNode.GetValueKind() == JsonValueKind.Array) { foreach (var img in instagramNode.AsArray()) { var url = img?["image_url"]?.ToString(); var existingName = img?["image_name"]?.ToString(); if (string.IsNullOrWhiteSpace(existingName) && url != null && url.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) { var saved = SaveDataUrlFile(url, DBConfig.CDNImages); if (saved != null) img!["image_name"] = saved; } } } SaveState(state); var boardConfig = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "CommunityBoard"); var instagramImages = new List(); if (instagramNode != null && instagramNode.GetValueKind() == JsonValueKind.Array) { foreach (var img in instagramNode.AsArray()) { if (img == null) continue; instagramImages.Add(new RecNet.Board.InstagramImage { ImageName = img["image_name"]?.ToString() ?? "", ImageUrl = img["image_url"]?.ToString() ?? "" }); } } var videos = new List(); var videosNode = state.CommunityBoard["videos"]; if (videosNode != null && videosNode.GetValueKind() == JsonValueKind.Array) { foreach (var vid in videosNode.AsArray()) { if (vid == null) continue; var blob = Path.GetFileName(vid["blob_name"]?.ToString() ?? ""); if (!string.IsNullOrWhiteSpace(blob) && !System.IO.File.Exists(Path.Join(DBConfig.CDNVideos, blob))) blob = ""; var thumb = vid["thumbnail_blob_name"]?.ToString(); var thumbName = thumb != null && thumb.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ? SaveDataUrlFile(thumb, DBConfig.CDNImages) ?? thumb : thumb ?? ""; videos.Add(new RecNet.Board.BoardVideo { BlobName = blob, Title = vid["title"]?.ToString() ?? "", Description = vid["description"]?.ToString() ?? "", ThumbnailBlobName = thumbName, SourceUrl = vid["source_url"]?.ToString() ?? "" }); } } var communityBoard = new RecNet.Board.CommunityBoard { FeaturedPlayer = new RecNet.Board.FeaturedPlayer { Id = (int)(state.CommunityBoard["featured_player_id"]?.GetValue() ?? 0), TitleOverride = state.CommunityBoard["featured_player_title"]?.ToString() ?? "", UrlOverride = state.CommunityBoard["featured_player_url"]?.ToString() ?? "" }, FeaturedRoomGroup = await BuildFeaturedRoomGroup(state.CommunityBoard), CurrentAnnouncement = new RecNet.Board.BoardAnnouncement { Message = state.CommunityBoard["board_message"]?.ToString() ?? "", MoreInfoUrl = state.CommunityBoard["announcement_url"]?.ToString() ?? "" }, InstagramImages = instagramImages, Videos = videos }; if (boardConfig == null) { boardConfig = new RecNet.Config.ServerConfigVal { Key = "CommunityBoard", Value = JsonSerializer.Serialize(communityBoard) }; db.ServerConfig.Add(boardConfig); } else { boardConfig.Value = JsonSerializer.Serialize(communityBoard); } await db.SaveChangesAsync(); await CommunityBoardBroadcast.PushAsync(communityBoard); return state.CommunityBoard; } private static long TryGetEntryId(JsonNode? entry) { if (entry == null) return 0; foreach (var key in new[] { "id", "room_id", "RoomId" }) { var raw = entry[key]?.ToString(); if (raw != null && long.TryParse(raw, out var parsed) && parsed > 0) return parsed; } return 0; } private async Task BuildFeaturedRoomGroup(JsonObject board) { var rooms = new List(); var featuredRooms = board["featured_rooms"]; if (featuredRooms != null && featuredRooms.GetValueKind() == JsonValueKind.Array) { var roomIds = new List(); foreach (var entry in featuredRooms.AsArray()) { if (entry == null) continue; var id = TryGetEntryId(entry); if (id > 0) roomIds.Add(id); } var dbRooms = roomIds.Count > 0 ? await db.Rooms.Where(x => roomIds.Contains(x.RoomId)).ToDictionaryAsync(x => x.RoomId) : new Dictionary(); foreach (var entry in featuredRooms.AsArray()) { if (entry == null) continue; var id = TryGetEntryId(entry); if (id <= 0) continue; var name = entry["name"]?.ToString() ?? entry["room_name"]?.ToString() ?? entry["RoomName"]?.ToString() ?? ""; dbRooms.TryGetValue(id, out var dbRoom); if (dbRoom != null) { if (string.IsNullOrWhiteSpace(name)) name = dbRoom.Name; } rooms.Add(new RecNet.Board.FeaturedRoom { RoomId = id, RoomName = name, ImageName = dbRoom?.ImageName ?? "" }); } } return new RecNet.Board.FeaturedRoomGroup { FeaturedRoomGroupId = 1, Name = board["featured_rooms_title"]?.ToString() ?? "Top Picks", Rooms = rooms }; } private static Announcement ToAnnouncement(JsonObject row) { string? Text(string key) => row[key]?.ToString(); return new Announcement { AnnouncementId = ReadAnnouncementId(row) ?? 0, AnnouncementStatus = TryGetJsonInt(row, "AnnouncementStatus") ?? 0, AnnouncementType = TryGetJsonInt(row, "AnnouncementType") ?? 0, Title = Text("Title") ?? Text("title") ?? string.Empty, Body = Text("Body") ?? Text("message") ?? string.Empty, ImageName = Text("ImageName") ?? string.Empty, LinkName = Text("LinkName") ?? Text("link_name") ?? string.Empty, LinkType = TryGetJsonInt(row, "LinkType") ?? 0, LinkUri = Text("LinkUri") ?? Text("link_uri") ?? string.Empty, Platform = Platforms.All, CreatedAt = DateTime.UtcNow }; } private static long? ReadAnnouncementId(JsonObject row) { if (row["id"] is not JsonValue value) return null; if (value.TryGetValue(out var asLong)) return asLong; if (value.TryGetValue(out var asDouble) && asDouble <= long.MaxValue && asDouble >= long.MinValue) return (long)asDouble; if (value.TryGetValue(out var asString) && long.TryParse(asString, out var parsed)) return parsed; return null; } private List GetAnnouncements() { var config = db.ServerConfig.FirstOrDefault(x => x.Key == "Announcement"); if (config == null || string.IsNullOrWhiteSpace(config.Value)) { return new List(); } try { var deserialized = config.Value.FromJson>(); return deserialized?.Cast().ToList() ?? new List(); } catch { return new List(); } } private async Task UpdateAnnouncements(JsonElement payload) { try { var items = new List(); if (payload.ValueKind == JsonValueKind.Array) { foreach (var el in payload.EnumerateArray()) items.Add(el); } else { items.Add(payload); } if (items.Count == 0) return Ok(new JsonObject()); var config = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "Announcement"); if (config == null) { config = new RecNet.Config.ServerConfigVal { Key = "Announcement", Value = "[]" }; db.ServerConfig.Add(config); } List list; try { list = (config.Value ?? "[]").FromJson>() ?? new List(); } catch { list = new List(); } long nextId = 1; foreach (var existing in list) { var existingId = ReadAnnouncementId(existing); if (existingId == null) continue; existing["id"] = existingId.Value; if (existingId.Value >= nextId) nextId = existingId.Value + 1; } var pendingNotifs = new List(); int? maintenanceMinutes = null; JsonObject? created = null; foreach (var announcement in items) { var type = announcement.TryGetProperty("type", out var typeProp) ? (typeProp.GetString() ?? "Regular Announcement") : "Regular Announcement"; var message = announcement.TryGetProperty("message", out var msgProp) ? msgProp.GetString() ?? "" : ""; var imageRaw = announcement.TryGetProperty("image", out var imgProp) && imgProp.ValueKind == JsonValueKind.String ? imgProp.GetString() : null; var minutes = TryGetInt(announcement, "minutes", out var minutesValue) ? minutesValue : (int?)null; var expiresAt = announcement.TryGetProperty("expires_at", out var expProp) && expProp.ValueKind == JsonValueKind.String ? expProp.GetString() : null; var linkName = announcement.TryGetProperty("link_name", out var lnProp) ? lnProp.GetString() : null; var linkTypeRaw = TryGetInt(announcement, "link_type", out var linkTypeValue) ? linkTypeValue : (int?)null; var linkUri = announcement.TryGetProperty("link_uri", out var luProp) ? luProp.GetString() : null; var title = announcement.TryGetProperty("title", out var tProp) ? tProp.GetString() : null; var normalized = type.ToLower().Replace(" ", "_"); string? imageName = null; if (!string.IsNullOrWhiteSpace(imageRaw)) { imageName = SaveDataUrlFile(imageRaw, DBConfig.CDNImages); imageName ??= imageRaw; } var panelImage = imageRaw; if (!string.IsNullOrWhiteSpace(imageName) && imageName != imageRaw && !imageName.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) panelImage = $"{DBConfig.ServerUrl}/images/img/{imageName}"; var buttonType = linkTypeRaw ?? 0; var platformType = -1; var buttonName = string.IsNullOrWhiteSpace(linkName) ? (normalized == "maintenance" || normalized == "game_update" ? string.Empty : "Learn More") : linkName; var buttonUri = string.IsNullOrWhiteSpace(linkUri) ? string.Empty : linkUri; var annStatus = normalized == "maintenance" ? 4 : 0; var row = new JsonObject { ["id"] = nextId, ["type"] = type, ["message"] = message, ["image"] = panelImage, ["minutes"] = minutes, ["expires_at"] = expiresAt, ["created_at"] = DateTime.UtcNow, ["title"] = string.IsNullOrWhiteSpace(title) ? type : title, ["link_name"] = buttonName, ["link_type"] = buttonType, ["link_uri"] = buttonUri, ["AnnouncementId"] = nextId, ["AnnouncementStatus"] = annStatus, ["AnnouncementType"] = buttonType, ["Title"] = string.IsNullOrWhiteSpace(title) ? type : title, ["Body"] = message, ["ImageName"] = imageName ?? "", ["IsMostRecent"] = true, ["LinkName"] = buttonName, ["LinkType"] = platformType, ["LinkUri"] = buttonUri, ["Platform"] = -1, ["CreatedAt"] = DateTime.UtcNow }; list.Add(row); created = row; var annObject = new Announcement { AnnouncementId = nextId, AnnouncementStatus = annStatus, AnnouncementType = buttonType, Title = string.IsNullOrWhiteSpace(title) ? type : title, Body = message, ImageName = imageName ?? "", IsMostRecent = true, LinkName = buttonName, LinkType = platformType, LinkUri = buttonUri, Platform = Platforms.All, CreatedAt = DateTime.UtcNow }; nextId++; Notification? notif = null; if (normalized == "maintenance") { var startsInMinutes = Math.Clamp(minutes ?? 30, 0, 61); notif = new Notification { Id = NotificationType.ServerMaintenance, Msg = new ServerMaintenance { StartsInMinutes = startsInMinutes } }; maintenanceMinutes = startsInMinutes; } else if (normalized == "game_update") { notif = Notify.EmptyObjectPayload(GameUpdateVersionCheck.AppVersionUpdateNotificationId); } else { notif = AnnouncementBroadcast.Update(annObject); } if (notif != null) pendingNotifs.Add(notif); } config.Value = JsonSerializer.Serialize(list); await db.SaveChangesAsync(); if (maintenanceMinutes.HasValue) await ApplyMaintenanceToConfig(maintenanceMinutes.Value); foreach (var notif in pendingNotifs) { try { await Notify.SendGlobalNotify(notif); } catch (Exception) { } } return Ok(created ?? new JsonObject()); } catch (Exception ex) { return StatusCode(StatusCodes.Status500InternalServerError, new JsonObject { ["error"] = ex.Message }); } } private List GetModerationReports(string? order, bool ascending) { var state = LoadState(); if (state.ModerationReports.Count == 0) { state.ModerationReports = new List(); SaveState(state); } return OrderRows(state.ModerationReports.Cast().ToList(), order, ascending); } private List GetAvatarItems() { var avatarItems = DBConfig.DefaultAvatarCatalog.FromJson>() ?? new List(); return avatarItems .Where(item => item != null && !string.IsNullOrWhiteSpace(item.AvatarItemDesc)) .Select(item => new { avatar_item_desc = item.AvatarItemDesc, unlocked_level = item.UnlockedLevel, platform_mask = item.PlatformMask, friendly_name = item.FriendlyName ?? "", tooltip = item.Tooltip ?? "", rarity = ((Rarity)item.Rarity).ToString(), // The picture the game shows for this item. The panel builds its own URL from // it (the same /images/img/ the server serves), so an item that has one // is shown as a tile rather than as a line of text. thumbnail_image = item.ThumbnailImage ?? "" }).Cast().ToList(); } private async Task> GetPlayerEvents(string? order, bool ascending) { var events = await db.PlayerEvents.ToListAsync(); var rows = events.Select(ev => new { id = ev.PlayerEventId, creator_player_id = ev.CreatorPlayerId, image_name = ev.ImageName, room_id = ev.RoomId, name = ev.Name, description = ev.Description, sub_room_id = ev.SubRoomId, club_id = (long?)null, start_time = ev.StartTime, end_time = ev.EndTime, attendee_count = ev.AttendeeCount, state = 0, moderation_state = ev.ModerationState, accessibility = (int)ev.Accessibility, is_multi_instance = false, support_multi_instance_room_chat = false, broadcasting_room_instance_id = (long?)null, default_broadcast_permissions = 0, can_request_broadcast_permissions = 0, recurrence_schedule = (string?)null, created_at = ev.CreatedAt }).Cast().ToList(); return OrderRows(rows, order, ascending); } private async Task> GetPlayerEventResponses(string? eqCol, string? eqVal, string? order, bool ascending) { var query = db.PlayerEventResponses.AsQueryable(); if (eqCol == "player_event_id" && long.TryParse(eqVal, out var eventId)) query = query.Where(x => x.PlayerEventId == eventId); var responses = await query.ToListAsync(); var playerIds = responses.Select(r => r.PlayerId).Distinct().ToList(); var profiles = await db.Profiles.Where(p => playerIds.Contains(p.accountId)).ToDictionaryAsync(p => p.accountId); var rows = responses.Select(r => new { id = r.PlayerEventResponseId, player_event_id = r.PlayerEventId, player_id = r.PlayerId, player_name = profiles.TryGetValue(r.PlayerId, out var profile) ? profile.displayName : ("Player " + r.PlayerId), type = (int)r.Type, type_name = r.Type.ToString(), created_at = r.CreatedAt }).Cast().ToList(); return OrderRows(rows, order, ascending); } private async Task> GetEventInvitations(string? eqCol, string? eqVal, string? order, bool ascending) { var query = db.Messages.Where(m => m.Type == MessageType.PlayerEventInvitation); if (eqCol == "player_event_id" && long.TryParse(eqVal, out var eventId)) query = query.Where(m => m.PlayerEventId == eventId); var invitations = await query.ToListAsync(); var playerIds = invitations.SelectMany(m => new[] { m.FromPlayerId, m.ToPlayerId }).Distinct().ToList(); var profiles = await db.Profiles.Where(p => playerIds.Contains(p.accountId)).ToDictionaryAsync(p => p.accountId); var rows = invitations.Select(m => new { id = m.Id, player_event_id = m.PlayerEventId, from_player_id = m.FromPlayerId, from_name = profiles.TryGetValue(m.FromPlayerId, out var from) ? from.displayName : ("Player " + m.FromPlayerId), to_player_id = m.ToPlayerId, to_name = profiles.TryGetValue(m.ToPlayerId, out var to) ? to.displayName : ("Player " + m.ToPlayerId), sent_time = m.SentTime }).Cast().ToList(); return OrderRows(rows, order, ascending); } private async Task UpdatePlayerEvent(long id, JsonElement payload) { var ev = await db.PlayerEvents.FirstOrDefaultAsync(x => x.PlayerEventId == id); if (ev == null) return NotFound(); if (TryGetString(payload, "name", out var name)) ev.Name = name; if (TryGetString(payload, "description", out var description)) ev.Description = description; if (TryGetString(payload, "image_name", out var imageName)) ev.ImageName = imageName; if (TryGetLong(payload, "room_id", out var roomId) && roomId > 0) ev.RoomId = roomId; if (TryGetDate(payload, "start_time", out var startTime)) ev.StartTime = startTime; if (TryGetDate(payload, "end_time", out var endTime)) ev.EndTime = endTime; if (TryGetInt(payload, "attendee_count", out var attendeeCount)) ev.AttendeeCount = attendeeCount; if (TryGetInt(payload, "accessibility", out var accessibility)) ev.Accessibility = (Accessibility)accessibility; if (TryGetLong(payload, "creator_player_id", out var creatorId) && creatorId > 0) ev.CreatorPlayerId = creatorId; if (TryGetInt(payload, "moderation_state", out var moderationState)) ev.ModerationState = moderationState; if (payload.TryGetProperty("sub_room_id", out var subRoomElem) && subRoomElem.ValueKind != JsonValueKind.Undefined) { if (subRoomElem.ValueKind == JsonValueKind.Null) ev.SubRoomId = null; else if (subRoomElem.ValueKind == JsonValueKind.Number && subRoomElem.TryGetInt64(out var subRoomId)) ev.SubRoomId = subRoomId; else if (subRoomElem.ValueKind == JsonValueKind.String && long.TryParse(subRoomElem.GetString(), out var subRoomStr)) ev.SubRoomId = subRoomStr; } await db.SaveChangesAsync(); return Ok(new { success = true }); } private async Task> DeletePlayerEvents(List ids) { var events = await db.PlayerEvents.Where(x => ids.Contains(x.PlayerEventId)).ToListAsync(); if (events.Count == 0) return new List(); var eventIds = events.Select(x => x.PlayerEventId).ToList(); var responses = await db.PlayerEventResponses.Where(x => eventIds.Contains(x.PlayerEventId)).ToListAsync(); db.PlayerEventResponses.RemoveRange(responses); db.PlayerEvents.RemoveRange(events); await db.SaveChangesAsync(); return events.Select(ev => new { id = ev.PlayerEventId, name = ev.Name }).Cast().ToList(); } private static bool TryGetDate(JsonElement payload, string prop, out DateTime value) { if (payload.TryGetProperty(prop, out var elem) && elem.ValueKind == JsonValueKind.String && DateTime.TryParse(elem.GetString(), out var parsed)) { value = parsed.ToUniversalTime(); return true; } value = default; return false; } /// /// A room's join gift boxes as the panel reads them. A room with no list answers with the /// single box its own row describes, so a room configured before the list existed opens on the /// box it actually hands out rather than on an empty editor. /// private static List JoinGiftRows(RoomData room, List? rows) { var source = rows is { Count: > 0 } ? rows : [RoomJoinGiftUtils.Snapshot(room)]; return source .OrderBy(x => x.SortOrder) .ThenBy(x => x.RoomJoinGiftId) .Select(JoinGiftRow) .ToList(); } private static object JoinGiftRow(RoomJoinGift gift) => new { id = gift.RoomJoinGiftId, sort_order = gift.SortOrder, version = gift.Version, enabled = gift.Enabled, once_per_player = gift.OncePerPlayer, title = gift.Title, message = gift.Message, // Digits in a string, for the same reason the room's own sender is: an account id is // eighteen digits and a JSON number would arrive in the panel rounded. from_player_id = Math.Max(0, gift.FromPlayerId).ToString(CultureInfo.InvariantCulture), avatar_item = gift.AvatarItem, avatar_item_type = gift.AvatarItemType.ToString(), equipment_item = gift.EquipmentItem, equipment_modification_guid = gift.EquipmentModificationGuid, consumable_item = gift.ConsumableItem, currency_type = gift.CurrencyType.ToString(), currency_amount = gift.CurrencyAmount, xp = gift.Xp, context = gift.Context.ToString(), rarity = gift.Rarity.ToString() }; /// /// Replaces a room's list of join gift boxes with the list the panel sent. A box that is new is /// inserted; a box whose contents changed keeps its identity but gets a new version, so players /// who already opened the old box are handed the new one; a box the panel left out is deleted. /// Anything that cannot be read refuses the save before a row is touched, so a mistyped id never /// leaves the room half-written. /// private async Task ApplyJoinGiftListAsync(RoomData room, JsonElement list) { var wanted = new List(); var index = 0; foreach (var element in list.EnumerateArray()) { var gift = ReadJoinGift(element, index, out var error); if (gift == null) return BadRequest(error); gift.SortOrder = index; wanted.Add(gift); index++; } if (wanted.Count > MaxJoinGiftsPerRoom) return BadRequest($"A room can hold at most {MaxJoinGiftsPerRoom} join gift boxes."); var existing = await db.RoomJoinGifts.Where(x => x.RoomId == room.RoomId).ToListAsync(); var kept = new HashSet(); var changed = false; foreach (var gift in wanted) { RoomJoinGift? row = null; if (gift.RoomJoinGiftId > 0) { row = existing.FirstOrDefault(x => x.RoomJoinGiftId == gift.RoomJoinGiftId); if (row == null) return BadRequest($"join_gifts[{gift.SortOrder}].id {gift.RoomJoinGiftId} is not one of this room's gift boxes."); } if (row == null) { var added = new RoomJoinGift { RoomId = room.RoomId, SortOrder = gift.SortOrder, Version = 1 }; CopyJoinGiftValues(gift, added); db.RoomJoinGifts.Add(added); changed = true; continue; } kept.Add(row.RoomJoinGiftId); if (!SameJoinGift(row, gift)) { CopyJoinGiftValues(gift, row); row.Version++; changed = true; } // Moving a box up or down the list reissues nothing: the box itself did not change. row.SortOrder = gift.SortOrder; } var removed = existing.Where(x => !kept.Contains(x.RoomJoinGiftId)).ToList(); if (removed.Count > 0) { db.RoomJoinGifts.RemoveRange(removed); changed = true; } // The room's own fields describe its first box, so a reader that only knows those fields - // and the editor of a room saved before the list existed - still sees what it hands out. var first = wanted.OrderBy(x => x.SortOrder).FirstOrDefault(); room.JoinGiftEnabled = wanted.Any(x => x.Enabled); room.JoinGiftOncePerPlayer = first?.OncePerPlayer ?? true; room.JoinGiftTitle = first?.Title ?? "Room Visit Gift"; room.JoinGiftMessage = first?.Message ?? ""; room.JoinGiftAvatarItem = first?.AvatarItem ?? ""; room.JoinGiftAvatarItemType = first?.AvatarItemType ?? AvatarItemType.Outfit; room.JoinGiftEquipmentItem = first?.EquipmentItem ?? ""; room.JoinGiftEquipmentModificationGuid = first?.EquipmentModificationGuid ?? ""; room.JoinGiftConsumableItem = first?.ConsumableItem ?? ""; room.JoinGiftCurrencyType = first?.CurrencyType ?? CurrencyType.RecCenterTokens; room.JoinGiftCurrencyAmount = first?.CurrencyAmount ?? 0; room.JoinGiftXp = first?.Xp ?? 0; room.JoinGiftContext = first?.Context ?? GiftContext.Game_Drop; room.JoinGiftRarity = first?.Rarity ?? Rarity.Common; room.JoinGiftFromPlayerId = first?.FromPlayerId ?? 0; if (changed) room.JoinGiftVersion++; await db.SaveChangesAsync(); return null; } private const int MaxJoinGiftsPerRoom = 20; /// One box as the panel sent it, or null with the reason it could not be read. private static RoomJoinGift? ReadJoinGift(JsonElement element, int index, out string? error) { error = null; if (element.ValueKind != JsonValueKind.Object) { error = $"join_gifts[{index}] must be a gift box."; return null; } var gift = new RoomJoinGift { RoomJoinGiftId = Math.Max(0, TryGetJsonLong(element, "id") ?? 0) }; if (TryGetJsonBool(element, "enabled") is { } enabled) gift.Enabled = enabled; if (TryGetJsonBool(element, "once_per_player") is { } oncePerPlayer) gift.OncePerPlayer = oncePerPlayer; if (GetJsonString(element, "title") is { } title) gift.Title = title; if (GetJsonString(element, "message") is { } message) gift.Message = message; if (GetJsonString(element, "avatar_item") is { } avatarItem) gift.AvatarItem = avatarItem; if (ParseAvatarItemType(GetJsonString(element, "avatar_item_type")) is { } avatarItemType) gift.AvatarItemType = avatarItemType; if (GetJsonString(element, "equipment_item") is { } equipmentItem) gift.EquipmentItem = equipmentItem; if (GetJsonString(element, "equipment_modification_guid") is { } modificationGuid) gift.EquipmentModificationGuid = modificationGuid; if (GetJsonString(element, "consumable_item") is { } consumableItem) gift.ConsumableItem = consumableItem; if (ParseCurrencyType(GetJsonString(element, "currency_type")) is { } currencyType) gift.CurrencyType = currencyType; if (TryGetJsonInt(element, "currency_amount") is { } currencyAmount) gift.CurrencyAmount = Math.Max(0, currencyAmount); if (TryGetJsonInt(element, "xp") is { } xp) gift.Xp = Math.Max(0, xp); if (ParseGiftContext(GetJsonString(element, "context")) is { } context) gift.Context = context; if (ParseRarity(GetJsonString(element, "rarity")) is { } rarity) gift.Rarity = rarity; // Named with the box it belongs to: an eighteen-digit account id has to stay digits, and a // refusal has to say which box it came from. if (TryGetAccountId(element, "from_player_id", out var fromPlayerId, out var fromError)) { if (fromError != null) { error = $"join_gifts[{index}].from_player_id must be an account id in digits, or blank for no sender."; return null; } gift.FromPlayerId = fromPlayerId; } return gift; } private static bool SameJoinGift(RoomJoinGift row, RoomJoinGift sent) { return row.Enabled == sent.Enabled && row.OncePerPlayer == sent.OncePerPlayer && string.Equals(row.Title ?? "", sent.Title ?? "", StringComparison.Ordinal) && string.Equals(row.Message ?? "", sent.Message ?? "", StringComparison.Ordinal) && string.Equals(row.AvatarItem ?? "", sent.AvatarItem ?? "", StringComparison.Ordinal) && row.AvatarItemType == sent.AvatarItemType && string.Equals(row.EquipmentItem ?? "", sent.EquipmentItem ?? "", StringComparison.Ordinal) && string.Equals(row.EquipmentModificationGuid ?? "", sent.EquipmentModificationGuid ?? "", StringComparison.Ordinal) && string.Equals(row.ConsumableItem ?? "", sent.ConsumableItem ?? "", StringComparison.Ordinal) && row.CurrencyType == sent.CurrencyType && row.CurrencyAmount == sent.CurrencyAmount && row.Xp == sent.Xp && row.Context == sent.Context && row.Rarity == sent.Rarity && row.FromPlayerId == sent.FromPlayerId; } /// The fields that make a box a box - never its id, room, order or version. private static void CopyJoinGiftValues(RoomJoinGift from, RoomJoinGift to) { to.Enabled = from.Enabled; to.OncePerPlayer = from.OncePerPlayer; to.Title = from.Title ?? ""; to.Message = from.Message ?? ""; to.AvatarItem = from.AvatarItem ?? ""; to.AvatarItemType = from.AvatarItemType; to.EquipmentItem = from.EquipmentItem ?? ""; to.EquipmentModificationGuid = from.EquipmentModificationGuid ?? ""; to.ConsumableItem = from.ConsumableItem ?? ""; to.CurrencyType = from.CurrencyType; to.CurrencyAmount = from.CurrencyAmount; to.Xp = from.Xp; to.Context = from.Context; to.Rarity = from.Rarity; to.FromPlayerId = from.FromPlayerId; } private async Task UpdateRoom(long id, JsonElement payload) { var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == id); if (room == null) { if (payload.TryGetProperty("banned", out var liftBan) && liftBan.ValueKind == JsonValueKind.False) { var restored = await RoomBanStore.RestoreAsync(db, id); if (restored) return Ok(new { success = true, room_id = id, banned = false, restored = true }); } return NotFound("Room does not exist."); } if (TryGetString(payload, "name", out var name)) room.Name = name; if (TryGetString(payload, "description", out var description)) room.Description = description; if (TryGetString(payload, "image_name", out var imageName)) room.ImageName = imageName; if (TryGetString(payload, "custom_warning", out var customWarning)) room.CustomWarning = customWarning; if (payload.TryGetProperty("supports_screens", out var supportsScreens)) room.SupportsScreens = supportsScreens.GetBoolean(); if (payload.TryGetProperty("supports_walk_vr", out var supportsWalk)) room.SupportsWalkVR = supportsWalk.GetBoolean(); if (payload.TryGetProperty("supports_teleport_vr", out var supportsTeleport)) room.SupportsTeleportVR = supportsTeleport.GetBoolean(); if (payload.TryGetProperty("supports_vr_low", out var supportsVrLow)) room.SupportsVRLow = supportsVrLow.GetBoolean(); if (payload.TryGetProperty("supports_quest2", out var supportsQuest2)) room.SupportsQuest2 = supportsQuest2.GetBoolean(); if (payload.TryGetProperty("supports_mobile", out var supportsMobile)) room.SupportsMobile = supportsMobile.GetBoolean(); if (payload.TryGetProperty("supports_juniors", out var supportsJuniors)) room.SupportsJuniors = supportsJuniors.GetBoolean(); if (payload.TryGetProperty("cloning_allowed", out var cloningAllowed)) room.CloningAllowed = cloningAllowed.GetBoolean(); if (payload.TryGetProperty("disable_mic_auto_mute", out var disableMicAutoMute)) room.DisableMicAutoMute = disableMicAutoMute.GetBoolean(); if (payload.TryGetProperty("disable_room_comments", out var disableRoomComments)) room.DisableRoomComments = disableRoomComments.GetBoolean(); if (payload.TryGetProperty("encrypt_voice_chat", out var encryptVoiceChat)) room.EncryptVoiceChat = encryptVoiceChat.GetBoolean(); if (payload.TryGetProperty("is_dorm", out var isDorm)) room.IsDorm = isDorm.GetBoolean(); if (TryGetInt(payload, "min_level", out var minLevel)) room.MinLevel = minLevel; if (TryGetInt(payload, "max_players", out var maxPlayers)) room.MaxPlayers = maxPlayers; if (TryGetInt(payload, "max_player_calculation_mode", out var maxCalcMode)) room.MaxPlayerCalculationMode = (MaxPlayerMode)maxCalcMode; // A room's boxes arrive as a list. When the panel sends one, it is the whole truth about // that room's boxes and the single-box fields below are not read: the list is written to // RoomJoinGifts and the room's own fields are then filled in from its first box, so every // older reader of those fields still sees the box the room hands out first. if (payload.TryGetProperty("join_gifts", out var joinGiftList) && joinGiftList.ValueKind == JsonValueKind.Array) { var refusal = await ApplyJoinGiftListAsync(room, joinGiftList); if (refusal != null) return refusal; } else { var giftChanged = false; if (payload.TryGetProperty("join_gift_enabled", out var joinGiftEnabled)) room.JoinGiftEnabled = joinGiftEnabled.GetBoolean(); if (payload.TryGetProperty("join_gift_once_per_player", out var joinGiftOnce)) room.JoinGiftOncePerPlayer = joinGiftOnce.GetBoolean(); if (TryGetString(payload, "join_gift_title", out var giftTitle)) { room.JoinGiftTitle = giftTitle ?? ""; giftChanged = true; } if (TryGetString(payload, "join_gift_message", out var giftMessage)) { room.JoinGiftMessage = giftMessage ?? ""; giftChanged = true; } if (TryGetString(payload, "join_gift_avatar_item", out var giftAvatar)) { room.JoinGiftAvatarItem = giftAvatar ?? ""; giftChanged = true; } if (TryGetString(payload, "join_gift_equipment_item", out var giftEquip)) { room.JoinGiftEquipmentItem = giftEquip ?? ""; giftChanged = true; } if (TryGetString(payload, "join_gift_consumable_item", out var giftCons)) { room.JoinGiftConsumableItem = giftCons ?? ""; giftChanged = true; } if (TryGetInt(payload, "join_gift_currency_amount", out var giftCurrencyAmount)) { room.JoinGiftCurrencyAmount = Math.Max(0, giftCurrencyAmount); giftChanged = true; } if (TryGetInt(payload, "join_gift_xp", out var giftXp)) { room.JoinGiftXp = Math.Max(0, giftXp); giftChanged = true; } if (TryGetString(payload, "join_gift_currency_type", out var giftCurrencyTypeRaw) && StorefrontShared.TryParseCurrencyType(giftCurrencyTypeRaw, out var giftCurrencyType)) { room.JoinGiftCurrencyType = giftCurrencyType; giftChanged = true; } if (TryGetString(payload, "join_gift_context", out var giftContextRaw) && ParseGiftContext(giftContextRaw) is { } giftContext) { room.JoinGiftContext = giftContext; giftChanged = true; } if (TryGetString(payload, "join_gift_rarity", out var giftRarityRaw) && ParseRarity(giftRarityRaw) is { } giftRarity) { room.JoinGiftRarity = giftRarity; giftChanged = true; } if (TryGetString(payload, "join_gift_avatar_item_type", out var giftAvatarTypeRaw) && ParseAvatarItemType(giftAvatarTypeRaw) is { } giftAvatarType) { room.JoinGiftAvatarItemType = giftAvatarType; giftChanged = true; } if (TryGetString(payload, "join_gift_equipment_modification_guid", out var giftEquipMod)) { room.JoinGiftEquipmentModificationGuid = giftEquipMod ?? ""; giftChanged = true; } if (TryGetAccountId(payload, "join_gift_from_player_id", out var giftFrom, out var giftFromError)) { if (giftFromError != null) return BadRequest(giftFromError); // Only a sender that actually changed re-issues the box: this field is not carried by // every panel that saves a room, so it must not bump the gift version on its own. if (room.JoinGiftFromPlayerId != giftFrom) { room.JoinGiftFromPlayerId = giftFrom; giftChanged = true; } } if (TryGetInt(payload, "join_gift_version", out var giftVersion) && giftVersion > 0) room.JoinGiftVersion = giftVersion; else if (giftChanged) room.JoinGiftVersion++; } var stats = await db.RoomStats.FirstOrDefaultAsync(x => x.RoomId == room.RoomId); if (stats == null && HasAny(payload, "cheer_count", "favorite_count", "visitor_count", "visit_count")) { stats = new RadicadeServer.RecNet.Rooms.RoomStats { RoomId = room.RoomId }; db.RoomStats.Add(stats); } if (stats != null) { if (TryGetInt(payload, "cheer_count", out var cheerCount)) stats.CheerCount = cheerCount; if (TryGetInt(payload, "favorite_count", out var favoriteCount)) stats.FavoriteCount = favoriteCount; if (TryGetInt(payload, "visitor_count", out var visitorCount)) stats.VisitorCount = visitorCount; if (TryGetInt(payload, "visit_count", out var visitCount)) stats.VisitCount = visitCount; } if (TryGetString(payload, "publish_state", out var publishState) && Enum.TryParse(publishState, true, out var state)) room.State = state; if (payload.TryGetProperty("banned", out var banned)) { if (banned.ValueKind == JsonValueKind.True) { var banReason = TryGetString(payload, "ban_reason", out var reason) ? reason ?? "" : ""; await RoomBanStore.BanAsync(db, room, banReason, await GetPanelAdminUsernameAsync()); return Ok(new { success = true, room_id = id, banned = true, reason = banReason }); } room.State = RoomState.Active; } if (payload.TryGetProperty("accessibility", out var accessibilityProp)) { var accessibilityText = accessibilityProp.ValueKind switch { JsonValueKind.Number => accessibilityProp.GetRawText(), JsonValueKind.String => accessibilityProp.GetString(), _ => null }; if (TryParseRoomAccessibility(accessibilityText, out var parsedAccessibility)) room.Accessibility = parsedAccessibility; } var featuredFlag = TryGetJsonBool(payload, "featured") ?? TryGetJsonBool(payload, "is_featured"); if (featuredFlag != null) { var tags = StringTools.ReadTags(room.Tags); var featuredTag = tags.FirstOrDefault(x => string.Equals(x.Tag, "featured", StringComparison.OrdinalIgnoreCase)); if (featuredFlag.Value) { if (featuredTag == null) tags.Add(new ItemTag { Type = TagType.Auto, Tag = "featured" }); } else if (featuredTag != null) { tags.Remove(featuredTag); } room.Tags = StringTools.CreateTagString(tags); } else if (payload.TryGetProperty("tags", out var tagsElement) || payload.TryGetProperty("Tags", out tagsElement)) { var tagString = ParseRoomTags(tagsElement); if (tagString != null) room.Tags = tagString; } await db.SaveChangesAsync(); return Ok(new { success = true }); } private static string? ParseRoomTags(JsonElement element) { try { var tags = new List(); if (element.ValueKind == JsonValueKind.String) { AddTagsFromString(element.GetString()); } else if (element.ValueKind == JsonValueKind.Array) { foreach (var entry in element.EnumerateArray()) { if (entry.ValueKind == JsonValueKind.String) { AddTagsFromString(entry.GetString()); continue; } if (entry.ValueKind != JsonValueKind.Object) continue; var name = (GetJsonString(entry, "Tag") ?? GetJsonString(entry, "tag"))?.Trim().TrimStart('#'); if (string.IsNullOrWhiteSpace(name)) continue; var type = TagType.General; var rawType = GetJsonString(entry, "Type") ?? GetJsonString(entry, "type"); if (rawType != null) { if (int.TryParse(rawType, out var typeValue) && Enum.IsDefined(typeof(TagType), typeValue)) type = (TagType)typeValue; else if (Enum.TryParse(rawType, true, out var parsedType)) type = parsedType; } tags.Add(new ItemTag { Tag = name, Type = type }); } } else { return null; } return StringTools.CreateTagString(tags); void AddTagsFromString(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return; foreach (var part in raw.Split(new[] { ',', ' ', ';' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { var name = part.TrimStart('#'); if (!string.IsNullOrWhiteSpace(name)) tags.Add(new ItemTag { Tag = name, Type = TagType.General }); } } } catch { return null; } } private async Task> AddGiftLogRows(AdminPanelState state, JsonElement payload) { var added = AddRows(state, state.GiftLog, payload); foreach (var gift in added) { var playerId = long.TryParse(gift["player_id"]?.ToString(), out var pid) ? pid : 0; if (playerId > 0) { await ProcessGiftRowAsync(playerId, gift); } else { gift["error"] = "Invalid or missing player_id."; } } SaveState(state); return added; } private async Task ProcessGiftRowAsync(long playerId, JsonObject gift) { long giftDropId = 0; try { var message = gift["message"]?.ToString() ?? "A gift from the Radicade Team!"; var currencyAmount = TryGetJsonInt(gift, "currency") ?? 0; var xpAmount = TryGetJsonInt(gift, "xp") ?? 0; var avatar = gift["avatar"]?.ToString() ?? ""; var cons = gift["cons"]?.ToString() ?? ""; var equip = gift["equip"]?.ToString() ?? ""; var currencyTypeRaw = gift["currency_type"]?.ToString() ?? gift["currencyType"]?.ToString() ?? ""; var fromPlayerId = long.TryParse(gift["from_player_id"]?.ToString(), out var typedFrom) && typedFrom > 0 ? typedFrom : RadicadeTeamAccountId; var equipModificationGuid = gift["equipment_modification_guid"]?.ToString() ?? gift["prefab_modify"]?.ToString() ?? ""; bool isRoomCurrency = string.Equals(currencyTypeRaw, "RoomCurrency", StringComparison.OrdinalIgnoreCase); var currencyType = StorefrontShared.TryParseCurrencyType(currencyTypeRaw, out var parsed) ? parsed : CurrencyType.Invalid; if (currencyAmount > 0 && currencyType == CurrencyType.Invalid && !isRoomCurrency) throw new InvalidOperationException($"Unknown currency type '{currencyTypeRaw}' - gift not routed to avoid crediting the wrong wallet."); if (isRoomCurrency && currencyAmount > 0) throw new InvalidOperationException("RoomCurrency gifts are not supported yet: no room currency storage exists and no room_id was supplied."); bool hasReward = currencyAmount > 0 || xpAmount > 0 || !string.IsNullOrWhiteSpace(avatar) || !string.IsNullOrWhiteSpace(cons) || !string.IsNullOrWhiteSpace(equip); if (!hasReward) throw new InvalidOperationException("Gift has no rewards (item, currency or XP)."); giftDropId = NewGiftDropId(); var giftBox = new Gift { GiftDropId = giftDropId, ItemSetId = 0, ItemSetFriendlyName = "Radicade Gift", FriendlyName = gift["title"]?.ToString() ?? "A Gift from Radicade!", Tooltip = message, IsQuery = false, Unique = false, SubscribersOnly = false, ConsumableItemDesc = cons, AvatarItemDesc = avatar, AvatarItemType = AvatarItemType.Outfit, EquipmentPrefabName = equip, EquipmentModificationGuid = equipModificationGuid, CurrencyType = currencyType, Currency = currencyAmount, Xp = xpAmount, Context = TryParseGiftContext(gift["context"]?.ToString()) ?? GiftContext.Game_Drop, Rarity = TryParseRarity(gift["rarity"]?.ToString()) ?? Rarity.Common, FromPlayerId = fromPlayerId }; await PersistGiftBoxAsync(playerId, giftBox, gift); gift["sent"] = true; gift["sent_at"] = DateTime.UtcNow; gift["gift_drop_id"] = giftDropId; gift["boxed"] = true; gift["from_player_id"] = fromPlayerId; } catch (Exception ex) { gift["error"] = ex.Message; } return giftDropId; } private async Task PersistGiftBoxAsync(long playerId, Gift giftBox, JsonObject? logRow) { var overwritten = await StorefrontShared.PersistGiftBoxAsync(db, playerId, giftBox); if (overwritten != 0 && logRow != null) logRow["overwrote_gift_drop_id"] = overwritten; } private async Task> ProcessGiftLog(string? eqCol, string? eqVal, string? order, bool ascending) { var state = LoadState(); var filtered = state.GiftLog.AsEnumerable(); if (!string.IsNullOrWhiteSpace(eqCol) && eqVal != null) filtered = filtered.Where(x => x[eqCol]?.ToString() == eqVal); var pendingGifts = filtered.Where(x => !string.Equals(x["sent"]?.ToString(), "true", StringComparison.OrdinalIgnoreCase)).ToList(); foreach (var gift in pendingGifts) { var playerId = long.TryParse(gift["player_id"]?.ToString(), out var pid) ? pid : 0; if (playerId > 0) { await ProcessGiftRowAsync(playerId, gift); } } SaveState(state); return OrderRows(filtered.Cast().ToList(), order, ascending); } } public class WeeklyScheduleRequest { public string Revision { get; set; } = ""; public string Title { get; set; } = "Weekly Challenges"; public bool Enabled { get; set; } = true; public string RewardKey { get; set; } = ""; public int FallbackTokens { get; set; } = 300; public int ChallengeMapId { get; set; } public List ChallengeIds { get; set; } = new(); } public class AdminPanelState { public List Storefronts { get; set; } = new(); public List Challenges { get; set; } = new(); public List ModerationLog { get; set; } = new(); public List GiftLog { get; set; } = new(); public List Notes { get; set; } = new(); public List AuditLog { get; set; } = new(); public List RoomComments { get; set; } = new(); public JsonObject? CommunityBoard { get; set; } public List ModerationReports { get; set; } = new(); public List PlayerEvents { get; set; } = new(); }