Files
Radicade/Server/Admin/AdminPanelAPI.cs
T
2026-10-16 23:33:13 -07:00

7033 lines
243 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using 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<PlayerData> 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<Gift> WeeklyRewards()
{
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var equipment = JsonSerializer.Deserialize<List<Equipment>>(DBConfig.DefaultEquipmentItems, options) ?? [];
var avatars = JsonSerializer.Deserialize<List<AvatarItem>>(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<IActionResult> 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<IActionResult> GetWeeklySchedule([FromQuery] string key)
{
var rejection = await RejectInvalidKey(key);
if (rejection != null) return rejection;
var weeks = new List<RadicadeServer.RecNet.Progression.Challenges.WeeklyChallengeMap>();
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<IActionResult> 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<IActionResult>(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<RadicadeServer.RecNet.Progression.Challenges.WeeklyChallengeMap>(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<AdminPanelState>(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<IActionResult?> RejectInvalidKey(string key)
{
return Task.FromResult<IActionResult?>(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<List<object>> 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<object>().ToList();
}
[HttpGet("session")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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 =
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">Your Radicade ban appeal was approved</h2>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi {HtmlEncode(name)},</p>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Your appeal against the ban for <b>{HtmlEncode(reason)}</b> has been <b>accepted</b>. The ban has been lifted and you can log back into Radicade.</p>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Thanks for reaching out - enjoy the game!</p>" +
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
await RadicadeMail.SendAsync(email, "Your Radicade ban appeal was approved", html);
}
else
{
var html =
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">Your Radicade ban appeal was rejected</h2>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi {HtmlEncode(name)},</p>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">We reviewed your appeal against the ban for <b>{HtmlEncode(reason)}</b> and it was <b>rejected</b>. The ban remains active.</p>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">If you believe this decision was a mistake, you may submit another appeal once the cooldown period has passed.</p>" +
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Take this time to review our Code Of Conduct:</p>" +
"<p style=\"text-align:center;margin:24px 0;\"><a href=\"https://radicade.app/code-of-conduct\" style=\"background:#4a6cf7;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;display:inline-block;\">Code Of Conduct</a></p>" +
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
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
? ""
: "<p style=\"color:#444;font-size:15px;line-height:1.6;\">Take this time to review our Code Of Conduct:</p>" +
"<p style=\"text-align:center;margin:24px 0;\"><a href=\"https://radicade.app/code-of-conduct\" style=\"background:#4a6cf7;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;display:inline-block;\">Code Of Conduct</a></p>";
var html =
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">Your Radicade ban time was {action}</h2>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi {HtmlEncode(name)},</p>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">As part of your appeal, the ban for <b>{HtmlEncode(reason)}</b> has been <b>{action}</b>. It now ends <b>{when}</b>.</p>" +
cocCta +
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
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", "<br />");
return
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">{HtmlEncode(title)}</h2>" +
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">{body}</p>" +
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
}
private static string HtmlEncode(string value)
{
return System.Net.WebUtility.HtmlEncode(value);
}
private static readonly Dictionary<string, RoleType> 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<IActionResult> 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<object>();
var seenAccounts = new HashSet<long>();
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<object>();
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<object>();
}
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> UploadRoomSave(long roomId, [FromQuery] string key, [FromForm] List<IFormFile> 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<IActionResult> SetSubroomSave(long roomId, long subRoomId, [FromQuery] string key,
[FromQuery] string? saveId, [FromForm] List<IFormFile>? 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<IActionResult> PlayerPanelAction(long playerId, string kind, [FromQuery] string key)
=> ApplyPlayerPanelAction(playerId, kind, key);
[HttpPost("players/{playerId:long}/{kind}/{sub}")]
public Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string, object?>();
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<string>(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<string, string?> { [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<string, string?> { [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<string, string?> { ["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<IActionResult> ApplyPlayerSetting(long playerId, Dictionary<string, string?>? changes)
{
var row = await db.PlayerSettings.FirstOrDefaultAsync(x => x.AccountId == playerId);
var settings = row?.DeserializeSettings() ?? new List<Setting>();
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<Setting>();
}
var payload = new Dictionary<string, object>
{
["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<IActionResult> 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<AvatarItem>();
var lockerEquipment = unlocksRow?.GetEquipment() ?? new List<Equipment>();
var lockerConsumables = unlocksRow?.GetConsumables() ?? new List<Consumable>();
var avatarCatalog = (DBConfig.DefaultAvatarCatalog.FromJson<List<AvatarItem>>() ?? new List<AvatarItem>())
.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<List<Equipment>>(CatalogJsonOptions) ?? new List<Equipment>())
.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<List<ConsumableItem>>(CatalogJsonOptions) ?? new List<ConsumableItem>())
.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<object>().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<object>().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<object>().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<long, Profile>();
var friendProgression = friendIds.Count > 0
? await db.Progression.Where(p => friendIds.Contains(p.PlayerId)).ToDictionaryAsync(p => p.PlayerId)
: new Dictionary<long, ProgressionData>();
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<List<long>>() ?? new List<long>();
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<object>()
.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<object>()
.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<object>().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<IActionResult> 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<IActionResult> 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<object>().ToList() ?? new List<object>();
return Ok(rows);
}
private static List<string> SafeRoomTags(string? tags)
{
if (string.IsNullOrWhiteSpace(tags)) return new List<string>();
try { return StringTools.ReadTags(tags).Select(t => t.Tag).ToList(); }
catch { return new List<string>(); }
}
private async Task<List<object>> GetClubs()
{
var config = await db.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "Clubs");
if (config == null || string.IsNullOrWhiteSpace(config.Value)) return new List<object>();
try
{
var clubs = config.Value.FromJson<List<ClubRecord>>() ?? new List<ClubRecord>();
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<string>();
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<object>().ToList();
}
catch
{
return new List<object>();
}
}
private async Task<List<object>> 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<object>().ToList();
}
private async Task<List<object>> 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<object>().ToList();
}
private async Task<List<object>> 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<object>();
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<List<object>> 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<object>().ToList();
}
private async Task<object> 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<object> 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<object> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<List<object>> 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<string, string>();
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<object>();
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<List<object>> GetCohortNuxButtons()
{
var rows = new List<object>();
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<List<object>> 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<object>().ToList();
}
private async Task<List<object>> 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<object>();
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<string>();
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<IActionResult> 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<IActionResult> 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<IActionResult> 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/<name>),
// 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
};
/// <summary>
/// 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
/// "&lt;name&gt;.uploading" and promotes it to "&lt;name&gt;" 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.
/// </summary>
[HttpPost("upload/part")]
[RequestSizeLimit(AdminUtils.MaxUploadBytes)]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<string>();
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<object>().ToList(),
videos = videos.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/video/{n}", size = SizeOf(DBConfig.CDNVideos, n) }).Cast<object>().ToList(),
rooms = storage.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/room/{n}", size = SizeOf(DBConfig.CDNStorage, n) }).Cast<object>().ToList(),
assetbundles = bundles.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/data/{n}", size = SizeOf(DBConfig.CDNStorage, n) }).Cast<object>().ToList(),
datablobs = storage.Select(n => new { name = n, url = $"{DBConfig.ServerUrl}/cdn/data/{n}", size = SizeOf(DBConfig.CDNStorage, n) }).Cast<object>().ToList()
});
}
[HttpDelete("uploads")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<List<object>> DeleteClubs(List<long> ids)
{
var deleted = new List<object>();
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<List<object>> DeleteAnnouncements(List<long> ids)
{
var config = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "Announcement");
if (config == null || string.IsNullOrWhiteSpace(config.Value))
return new List<object>();
try
{
var list = config.Value.FromJson<List<JsonObject>>() ?? new List<JsonObject>();
var removed = new List<JsonObject>();
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<object>().ToList();
}
catch
{
return new List<object>();
}
}
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<JsonObject> 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<int>();
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<List<object>> 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<long>(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<Setting>();
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<string>();
if (settingsMap.TryGetValue("NameHistory", out var nhRaw))
{
try { nameHistoryList = nhRaw.FromJson<List<string>>() ?? new List<string>(); } catch { nameHistoryList = new List<string>(); }
}
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<string>(),
rec_tokens = currency?.RecTokens ?? 0,
laser_tag_tickets = currency?.LaserTagTickets ?? 0,
gold = currency?.Gold ?? 0,
dracula_silver = currency?.DraculaSilver ?? 0
};
}).Cast<object>().ToList();
return OrderRows(rows, order, ascending);
}
private async Task<List<object>> 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<object>().ToList();
return OrderRows(rows, order, ascending);
}
private static List<object> CatalogStorefrontItems(long storefrontId)
{
var storefrontName = StorefrontDisplayName(storefrontId);
var rows = new List<object>();
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<List<object>> 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<object>().ToList();
return OrderRows(rows, order, ascending);
}
private static readonly JsonSerializerOptions CatalogJsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
};
private List<object> GetEquipmentItems()
{
var defaultItems = DBConfig.DefaultEquipmentItems.FromJson<List<Equipment>>(CatalogJsonOptions) ?? new List<Equipment>();
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<object>().ToList();
var legacyItems = DBConfig.DefaultEquipmentItems.FromJson<List<EquipmentItem>>(CatalogJsonOptions) ?? new List<EquipmentItem>();
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<object>().ToList();
}
private List<object> GetConsumableItems()
{
var items = DBConfig.DefaultConsumableItems.FromJson<List<ConsumableItem>>(CatalogJsonOptions) ?? new List<ConsumableItem>();
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<object>().ToList();
}
private async Task<List<object>> 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<object>().ToList(), order, ascending);
}
private async Task<List<object>> 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<object>().ToList(), order, ascending);
}
private async Task<JsonObject> 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<JsonObject> 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<List<object>> 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<object>().ToList(), order, ascending);
}
private async Task<JsonObject> 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<List<object>> 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<object>().ToList(), order, ascending);
}
private async Task<object> 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<List<object>> 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<Dictionary<long, string>> LoadPlayerNames(List<long> playerIds)
{
if (playerIds.Count == 0) return new Dictionary<long, string>();
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<object> GetStorefronts(string? order, bool ascending)
{
var state = LoadState();
var rows = new List<object>();
var seen = new HashSet<long>();
foreach (var id in StorefrontCatalog.StorefrontIds)
{
seen.Add(id);
var existing = state.Storefronts.FirstOrDefault(s => (s["id"]?.GetValue<long>() ?? 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<long>() ?? 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<long, string> GetStorefrontDisplayNames()
{
var names = new Dictionary<long, string>();
foreach (var type in Enum.GetValues<StorefrontTypes>())
{
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<long>() ?? 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<object> GetChallenges(string? order, bool ascending)
{
var state = LoadState();
return OrderRows(state.Challenges.Cast<object>().ToList(), order, ascending);
}
private List<object> FilterByPlayer(List<JsonObject> 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<object>().ToList(), order, ascending);
}
private static List<object> OrderRows(List<object> 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<StorefrontTypes>())
{
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<long>() ?? 0) + 1 : 1;
}
state.Challenges.Add(row);
SaveState(state);
return row;
}
private static object RowsResponse(JsonElement payload, List<JsonObject> rows)
{
if (payload.ValueKind == JsonValueKind.Array)
return rows;
return rows.Count > 0 ? rows[0] : new JsonObject();
}
private IActionResult InsertLoggedRows(AdminPanelState state, List<JsonObject> targetList, JsonElement payload, string table)
{
var rows = AddRows(state, targetList, payload);
NotifyPanelActionDiscord(table, payload);
return Ok(RowsResponse(payload, rows));
}
private async Task<IActionResult> 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<JsonElement>();
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<string>();
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<string> 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<JsonObject> AddRows(AdminPanelState state, List<JsonObject> targetList, JsonElement payload)
{
var items = new List<JsonElement>();
if (payload.ValueKind == JsonValueKind.Array)
{
foreach (var el in payload.EnumerateArray()) items.Add(el);
}
else
{
items.Add(payload);
}
var added = new List<JsonObject>();
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<JsonObject> targetList, long id, JsonElement payload)
{
var row = targetList.FirstOrDefault(x => x["id"]?.GetValue<long>() == 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<JsonObject> DeleteRows(AdminPanelState state, List<JsonObject> targetList, List<long> ids)
{
var toRemove = targetList.Where(x => ids.Contains(x["id"]?.GetValue<long>() ?? 0)).ToList();
foreach (var item in toRemove)
{
targetList.Remove(item);
}
SaveState(state);
return toRemove;
}
private static List<long> ParseIds(string? vals)
{
if (string.IsNullOrWhiteSpace(vals))
return new List<long>();
return vals.Split(',')
.Select(x => long.TryParse(x.Trim(), out var id) ? id : 0)
.Where(x => x > 0)
.ToList();
}
private async Task<JsonObject> 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<JsonObject> 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<CurrencyType>(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<Rarity>(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<GiftContext>(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<AvatarItemType>(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<int>(out var i)) return i;
if (v.TryGetValue<string>(out var s) && int.TryParse(s, out var parsed)) return parsed;
if (v.TryGetValue<double>(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<GiftContext>(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<Rarity>(raw.Trim(), true, out var rarity) && Enum.IsDefined(rarity))
return rarity;
return null;
}
private async Task<object> 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<Setting>();
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<Setting>();
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<Task> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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;
}
}
/// <summary>
/// An account id out of a panel payload, where blank means "none". Distinct from
/// <see cref="TryGetLong"/> 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, <paramref name="error"/> is
/// non-null for a value that cannot be an id, and the caller must not write it.
/// </summary>
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<T>(JsonElement payload, string prop, out T value) where T : struct
{
if (payload.TryGetProperty(prop, out var elem) && elem.ValueKind == JsonValueKind.String && Enum.TryParse<T>(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<List<object>> 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<List<object>> 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<object>().ToList(), order, ascending);
}
private List<object> 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<object> { state.CommunityBoard };
}
private async Task<JsonObject> 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<RecNet.Board.InstagramImage>();
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<RecNet.Board.BoardVideo>();
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<long>() ?? 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<RecNet.Board.FeaturedRoomGroup> BuildFeaturedRoomGroup(JsonObject board)
{
var rooms = new List<RecNet.Board.FeaturedRoom>();
var featuredRooms = board["featured_rooms"];
if (featuredRooms != null && featuredRooms.GetValueKind() == JsonValueKind.Array)
{
var roomIds = new List<long>();
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<long, RadicadeServer.RecNet.Rooms.RoomData>();
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<long>(out var asLong)) return asLong;
if (value.TryGetValue<double>(out var asDouble) && asDouble <= long.MaxValue && asDouble >= long.MinValue) return (long)asDouble;
if (value.TryGetValue<string>(out var asString) && long.TryParse(asString, out var parsed)) return parsed;
return null;
}
private List<object> GetAnnouncements()
{
var config = db.ServerConfig.FirstOrDefault(x => x.Key == "Announcement");
if (config == null || string.IsNullOrWhiteSpace(config.Value))
{
return new List<object>();
}
try
{
var deserialized = config.Value.FromJson<List<JsonObject>>();
return deserialized?.Cast<object>().ToList() ?? new List<object>();
}
catch
{
return new List<object>();
}
}
private async Task<IActionResult> UpdateAnnouncements(JsonElement payload)
{
try
{
var items = new List<JsonElement>();
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<JsonObject> list;
try
{
list = (config.Value ?? "[]").FromJson<List<JsonObject>>() ?? new List<JsonObject>();
}
catch
{
list = new List<JsonObject>();
}
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<Notification>();
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<object> GetModerationReports(string? order, bool ascending)
{
var state = LoadState();
if (state.ModerationReports.Count == 0)
{
state.ModerationReports = new List<JsonObject>();
SaveState(state);
}
return OrderRows(state.ModerationReports.Cast<object>().ToList(), order, ascending);
}
private List<object> GetAvatarItems()
{
var avatarItems = DBConfig.DefaultAvatarCatalog.FromJson<List<AvatarItem>>() ?? new List<AvatarItem>();
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/<name> 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<object>().ToList();
}
private async Task<List<object>> 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<object>().ToList();
return OrderRows(rows, order, ascending);
}
private async Task<List<object>> 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<object>().ToList();
return OrderRows(rows, order, ascending);
}
private async Task<List<object>> 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<object>().ToList();
return OrderRows(rows, order, ascending);
}
private async Task<object> 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<List<object>> DeletePlayerEvents(List<long> ids)
{
var events = await db.PlayerEvents.Where(x => ids.Contains(x.PlayerEventId)).ToListAsync();
if (events.Count == 0) return new List<object>();
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<object>().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;
}
/// <summary>
/// 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.
/// </summary>
private static List<object> JoinGiftRows(RoomData room, List<RoomJoinGift>? 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()
};
/// <summary>
/// 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.
/// </summary>
private async Task<IActionResult?> ApplyJoinGiftListAsync(RoomData room, JsonElement list)
{
var wanted = new List<RoomJoinGift>();
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<long>();
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;
/// <summary>One box as the panel sent it, or null with the reason it could not be read.</summary>
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;
}
/// <summary>The fields that make a box a box - never its id, room, order or version.</summary>
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<object> 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<RoomState>(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<ItemTag>();
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<TagType>(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<List<JsonObject>> 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<long> 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<List<object>> 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<object>().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<long> ChallengeIds { get; set; } = new();
}
public class AdminPanelState
{
public List<JsonObject> Storefronts { get; set; } = new();
public List<JsonObject> Challenges { get; set; } = new();
public List<JsonObject> ModerationLog { get; set; } = new();
public List<JsonObject> GiftLog { get; set; } = new();
public List<JsonObject> Notes { get; set; } = new();
public List<JsonObject> AuditLog { get; set; } = new();
public List<JsonObject> RoomComments { get; set; } = new();
public JsonObject? CommunityBoard { get; set; }
public List<JsonObject> ModerationReports { get; set; } = new();
public List<JsonObject> PlayerEvents { get; set; } = new();
}