mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-26 07:29:00 -07:00
7033 lines
243 KiB
C#
7033 lines
243 KiB
C#
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
|
||
/// "<name>.uploading" and promotes it to "<name>" when the last part lands, so
|
||
/// a half-imported file is never served, listed or bound.
|
||
///
|
||
/// The offset is checked against what is already stored, so a part retried after its
|
||
/// answer was lost either continues where it left off or is told exactly where to continue
|
||
/// (409, with the stored byte count) instead of corrupting the file.
|
||
/// </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();
|
||
}
|