Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
+7032
View File
@@ -0,0 +1,7032 @@
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();
}