mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
|
||||
namespace RadicadeServer.RecNet.Accounts
|
||||
{
|
||||
public class AccountCreationData
|
||||
{
|
||||
public Platforms platform { get; set; }
|
||||
public string platformId { get; set; } = null!;
|
||||
public string deviceId { get; set; } = null!;
|
||||
public string? password { get; set; }
|
||||
|
||||
|
||||
public DeviceClass deviceClass { get; set; }
|
||||
|
||||
|
||||
|
||||
public string? username { get; set; }
|
||||
|
||||
public DateTime? birthday { get; set; }
|
||||
|
||||
public DateTime? birthdayUTC { get; set; }
|
||||
|
||||
public string? email { get; set; }
|
||||
|
||||
public string? avatar { get; set; }
|
||||
|
||||
public string? avatarJson { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace RadicadeServer.Utils
|
||||
{
|
||||
public static class RegistrationGuard
|
||||
{
|
||||
public static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private static int maxCreationsPerIp = 20;
|
||||
private static int maxCreationsPerDevice = 10;
|
||||
private static TimeSpan minimumDeviceInterval = TimeSpan.FromSeconds(10);
|
||||
|
||||
public static int MaxCreationsPerIp => maxCreationsPerIp;
|
||||
|
||||
public static int MaxCreationsPerDevice => maxCreationsPerDevice;
|
||||
|
||||
public static TimeSpan MinimumDeviceInterval => minimumDeviceInterval;
|
||||
|
||||
public static void Configure(int maxCreationsPerIp, int maxCreationsPerDevice, TimeSpan minimumDeviceInterval)
|
||||
{
|
||||
RegistrationGuard.maxCreationsPerIp = maxCreationsPerIp;
|
||||
RegistrationGuard.maxCreationsPerDevice = maxCreationsPerDevice;
|
||||
RegistrationGuard.minimumDeviceInterval = minimumDeviceInterval;
|
||||
Reset();
|
||||
}
|
||||
|
||||
private sealed class Counter
|
||||
{
|
||||
public readonly List<DateTime> Events = [];
|
||||
public DateTime LastCreation = DateTime.MinValue;
|
||||
}
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Counter> ByIp = new(StringComparer.Ordinal);
|
||||
private static readonly ConcurrentDictionary<string, Counter> ByDevice = new(StringComparer.Ordinal);
|
||||
private static readonly ConcurrentDictionary<string, SemaphoreSlim> IdentityGates = new(StringComparer.Ordinal);
|
||||
|
||||
public static string? Check(string? ip, string? deviceId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
var device = ByDevice.GetOrAdd(deviceId, _ => new Counter());
|
||||
lock (device)
|
||||
{
|
||||
device.Events.RemoveAll(evt => now - evt > Window);
|
||||
if (now - device.LastCreation < MinimumDeviceInterval)
|
||||
return "An account was just created on this device. Please wait a moment and try again.";
|
||||
if (device.Events.Count >= MaxCreationsPerDevice)
|
||||
return "Too many accounts have been created on this device. Please try again later.";
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ip))
|
||||
{
|
||||
var source = ByIp.GetOrAdd(ip, _ => new Counter());
|
||||
lock (source)
|
||||
{
|
||||
source.Events.RemoveAll(evt => now - evt > Window);
|
||||
if (source.Events.Count >= MaxCreationsPerIp)
|
||||
return "Too many accounts have been created from this connection. Please try again later.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void Record(string? ip, string? deviceId)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
var device = ByDevice.GetOrAdd(deviceId, _ => new Counter());
|
||||
lock (device)
|
||||
{
|
||||
device.Events.RemoveAll(evt => now - evt > Window);
|
||||
device.Events.Add(now);
|
||||
device.LastCreation = now;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ip))
|
||||
{
|
||||
var source = ByIp.GetOrAdd(ip, _ => new Counter());
|
||||
lock (source)
|
||||
{
|
||||
source.Events.RemoveAll(evt => now - evt > Window);
|
||||
source.Events.Add(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string IdentityKey(string platform, string? platformId, string? deviceId) =>
|
||||
platform + "|" + (platformId?.Trim() ?? string.Empty) + "|" + (deviceId?.Trim() ?? string.Empty);
|
||||
|
||||
public static Task<IDisposable> LockNameAsync(string name) =>
|
||||
LockIdentityAsync("name|" + UsernameRules.ComparisonKey(name));
|
||||
|
||||
public static async Task<IDisposable> LockIdentityAsync(string key)
|
||||
{
|
||||
var gate = IdentityGates.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
|
||||
await gate.WaitAsync();
|
||||
return new Releaser(gate);
|
||||
}
|
||||
|
||||
private sealed class Releaser(SemaphoreSlim gate) : IDisposable
|
||||
{
|
||||
private int released;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref released, 1) == 0) gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static void Reset()
|
||||
{
|
||||
ByIp.Clear();
|
||||
ByDevice.Clear();
|
||||
IdentityGates.Clear();
|
||||
}
|
||||
|
||||
internal static string Describe(string? ip, string? deviceId) =>
|
||||
"ip=" + (ip ?? "<none>") + " device=" + (deviceId ?? "<none>");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.API.Activities.Charades.V1
|
||||
{
|
||||
[Route("api/activities/charades/v1")]
|
||||
public class CharadesV1(RecDBContext db) : Controller
|
||||
{
|
||||
[HttpGet("words/{cardType}")]
|
||||
public async Task<ActionResult<List<CardBoxWord>>> GetCardWords(string cardType)
|
||||
{
|
||||
switch (cardType)
|
||||
{
|
||||
case "Charades":
|
||||
var charades = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "Charades");
|
||||
return Ok(charades?.Value.ParseCardBoxWords());
|
||||
case "CharadesAprilFoolsDay":
|
||||
var charadesAprilFools = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "CharadesAprilFools");
|
||||
return Ok(charadesAprilFools?.Value.ParseCardBoxWords());
|
||||
case "Icebreakers":
|
||||
var icebreakers = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "Icebreakers");
|
||||
return Ok(icebreakers?.Value.ParseCardBoxWords());
|
||||
}
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.Auth;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.Admin;
|
||||
|
||||
[Route("admin/auth")]
|
||||
[EnableCors("AdminPanel")]
|
||||
public class AdminAuthAPI(RecDBContext db) : Controller
|
||||
{
|
||||
private CookieOptions SessionCookieOptions => new()
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Strict,
|
||||
MaxAge = AdminSession.Lifetime,
|
||||
Path = "/"
|
||||
};
|
||||
|
||||
public record LoginRequest(string? Username, string? Password);
|
||||
|
||||
[HttpPost("login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequest request)
|
||||
{
|
||||
var username = request.Username?.Trim() ?? "";
|
||||
var ip = Request.GetClientIp();
|
||||
|
||||
if (AdminLoginThrottle.IsLocked(username, ip, out var remaining))
|
||||
return LockoutResponse(remaining);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Unauthorized(new { error = "invalid_credentials", message = "Invalid username or password." });
|
||||
|
||||
var user = await db.AdminUsers.FirstOrDefaultAsync(x => x.Username == username);
|
||||
|
||||
if (user == null || !AdminSecurity.VerifyPassword(user.PasswordHash, request.Password))
|
||||
{
|
||||
await Task.Delay(350);
|
||||
AdminLoginThrottle.RecordFailure(username, ip);
|
||||
if (AdminLoginThrottle.IsLocked(username, ip, out remaining))
|
||||
return LockoutResponse(remaining);
|
||||
return Unauthorized(new { error = "invalid_credentials", message = "Invalid username or password." });
|
||||
}
|
||||
|
||||
if (AdminSecurity.NeedsRehash(user.PasswordHash))
|
||||
{
|
||||
user.PasswordHash = AdminSecurity.HashPassword(request.Password);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
AdminLoginThrottle.Clear(username, ip);
|
||||
await AdminSession.PruneAsync(db);
|
||||
return await CompleteLoginAsync(user);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> CompleteLoginAsync(AdminUser user)
|
||||
{
|
||||
var token = await AdminSession.CreateAsync(db, user.Id);
|
||||
user.LastLoginAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
Response.Cookies.Append(AdminSession.CookieName, token, SessionCookieOptions);
|
||||
return Ok(new { ok = true, user = Identity(user) });
|
||||
}
|
||||
|
||||
[HttpGet("session")]
|
||||
public async Task<IActionResult> Session()
|
||||
{
|
||||
var user = await RequireSessionAsync();
|
||||
if (user == null) return Unauthorized(new { error = "not_authenticated" });
|
||||
|
||||
var (rotated, didRotate) = AdminSession.TryRotate(db, CurrentToken());
|
||||
if (didRotate && !string.IsNullOrEmpty(rotated))
|
||||
Response.Cookies.Append(AdminSession.CookieName, rotated, SessionCookieOptions);
|
||||
|
||||
return Ok(new { user = Identity(user) });
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
public async Task<IActionResult> Me()
|
||||
{
|
||||
var user = await RequireSessionAsync();
|
||||
if (user == null) return Unauthorized(new { error = "not_authenticated" });
|
||||
return Ok(new { user = Identity(user) });
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
public async Task<IActionResult> Logout()
|
||||
{
|
||||
await AdminSession.RevokeAsync(db, CurrentToken());
|
||||
Response.Cookies.Delete(AdminSession.CookieName, new CookieOptions { Path = "/" });
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
private string? CurrentToken() =>
|
||||
Request.Cookies.TryGetValue(AdminSession.CookieName, out var token) ? token : null;
|
||||
|
||||
private async Task<AdminUser?> RequireSessionAsync()
|
||||
{
|
||||
var (ok, userId) = await AdminSession.ValidateAsync(db, CurrentToken());
|
||||
if (!ok) return null;
|
||||
|
||||
return await db.AdminUsers.FirstOrDefaultAsync(x => x.Id == userId);
|
||||
}
|
||||
|
||||
private static object Identity(AdminUser user) => new
|
||||
{
|
||||
id = user.Id,
|
||||
username = user.Username
|
||||
};
|
||||
|
||||
private static string Display(string ip) => string.IsNullOrWhiteSpace(ip) ? "an unknown address" : ip;
|
||||
|
||||
private IActionResult LockoutResponse(TimeSpan remaining)
|
||||
{
|
||||
var seconds = (int)Math.Ceiling(remaining.TotalSeconds);
|
||||
Response.Headers["Retry-After"] = seconds.ToString();
|
||||
|
||||
var message = seconds > 60
|
||||
? $"Too many failed sign-in attempts. Try again in {(int)Math.Ceiling(remaining.TotalMinutes)} minutes."
|
||||
: $"Too many failed sign-in attempts. Try again in {seconds} seconds.";
|
||||
return StatusCode(429, new { error = "locked_out", message, retry_after_seconds = seconds });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace RadicadeServer.Admin
|
||||
{
|
||||
public class AdminBoardAPI
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.Clubs;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.Admin
|
||||
{
|
||||
[Route("admin/clubs")]
|
||||
[EnableCors("AdminPanel")]
|
||||
public class AdminClubAPI(RecDBContext db) : Controller
|
||||
{
|
||||
private Task<IActionResult?> RejectInvalidKey(string key)
|
||||
{
|
||||
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
|
||||
}
|
||||
|
||||
[HttpPost("upload/image")]
|
||||
public async Task<IActionResult> UploadImage([FromQuery] string key, [FromQuery] long clubId, [FromForm] IFormFile file)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var clubs = await ClubStore.LoadAsync(db);
|
||||
var club = clubs.FirstOrDefault(x => x.clubId == clubId);
|
||||
if (club == null) return NotFound("Club does not exist.");
|
||||
|
||||
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 (string.IsNullOrEmpty(ext) || !allowed.Contains(ext))
|
||||
return BadRequest($"Unsupported image format '{(string.IsNullOrEmpty(ext) ? "no extension" : ext)}'. Use png/jpg/jpeg/gif/webp.");
|
||||
|
||||
Directory.CreateDirectory(DBConfig.CDNImages);
|
||||
var name = Guid.NewGuid().ToString() + ext;
|
||||
var path = Path.Join(DBConfig.CDNImages, name);
|
||||
|
||||
using (var stream = new FileStream(path, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
club.imageName = name;
|
||||
|
||||
await ClubStore.SaveAsync(db, clubs);
|
||||
|
||||
return Ok($"Uploaded {name}!\nURL: {DBConfig.ServerUrl}/images/img/{name}");
|
||||
}
|
||||
|
||||
[HttpGet("set/image")]
|
||||
public async Task<IActionResult> SetImage([FromQuery] string key, [FromQuery] long clubId, [FromQuery] string imgName)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var clubs = await ClubStore.LoadAsync(db);
|
||||
var club = clubs.FirstOrDefault(x => x.clubId == clubId);
|
||||
if (club == null) return NotFound("Club does not exist.");
|
||||
|
||||
imgName = Path.GetFileName(imgName ?? "").Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(imgName)) imgName = ClubStore.DefaultImageName;
|
||||
|
||||
club.imageName = imgName;
|
||||
|
||||
await ClubStore.SaveAsync(db, clubs);
|
||||
|
||||
return Ok($"Set to {imgName}!\nURL: {DBConfig.ServerUrl}/images/img/{imgName}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet.Config;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.Admin
|
||||
{
|
||||
[Route("admin/config")]
|
||||
[EnableCors("AdminPanel")]
|
||||
public class AdminConfigAPI(RecDBContext db) : Controller
|
||||
{
|
||||
private Task<IActionResult?> RejectInvalidKey(string key)
|
||||
{
|
||||
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
|
||||
}
|
||||
|
||||
[HttpGet("forcerefresh")]
|
||||
public async Task<IActionResult> ForceRefresh([FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
MainConfig? config = await db.Config.FirstOrDefaultAsync();
|
||||
if (config == null) return StatusCode(500);
|
||||
|
||||
bool didChanges = false;
|
||||
|
||||
if (config.ExpectingReload)
|
||||
{
|
||||
didChanges = true;
|
||||
config.SetAsActive();
|
||||
config.ExpectingReload = false;
|
||||
}
|
||||
|
||||
if (config.RefreshConfig(true))
|
||||
didChanges = true;
|
||||
|
||||
if (didChanges)
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(config?.ToActualConfig());
|
||||
}
|
||||
|
||||
[HttpGet("full")]
|
||||
public async Task<IActionResult> GetFullConfig([FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
MainConfig? config = await db.Config.FirstOrDefaultAsync();
|
||||
return Ok(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RadicadeServer.Admin;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public class AdminUser
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public long Id { get; set; }
|
||||
|
||||
[Required, MaxLength(64)]
|
||||
public string Username { get; set; } = "";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[Required]
|
||||
public string PasswordHash { get; set; } = "";
|
||||
|
||||
|
||||
[MaxLength(64)]
|
||||
public string? TotpSecret { get; set; }
|
||||
|
||||
public bool TotpEnabled { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public class AdminSessionRecord
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public long Id { get; set; }
|
||||
|
||||
public long AdminUserId { get; set; }
|
||||
|
||||
|
||||
[Required, MaxLength(64)]
|
||||
public string TokenHash { get; set; } = "";
|
||||
|
||||
|
||||
|
||||
[MaxLength(64)]
|
||||
public string? PreviousTokenHash { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,7032 @@
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using System.Globalization;
|
||||
using RadicadeServer.Auth;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.API.Notifications;
|
||||
using RadicadeServer.Radicade.Discord;
|
||||
using RadicadeServer.Radicade.Mail;
|
||||
using RadicadeServer.API.Storefront;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet;
|
||||
using RadicadeServer.RecNet.Enums.Players.Avatar;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Accounts.Local;
|
||||
using RadicadeServer.RecNet.Auth;
|
||||
using RadicadeServer.RecNet.Avatar;
|
||||
using RadicadeServer.RecNet.Board;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Enums.Moderation;
|
||||
using RadicadeServer.RecNet.Enums;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
using RadicadeServer.RecNet.Enums.Rooms;
|
||||
using RadicadeServer.RecNet.Config;
|
||||
using RadicadeServer.RecNet.VersionCheck;
|
||||
using RadicadeServer.RecNet.DB;
|
||||
using RadicadeServer.RecNet.Enums.Store;
|
||||
using RadicadeServer.RecNet.Maintenance;
|
||||
using RadicadeServer.RecNet.Messages;
|
||||
using RadicadeServer.RecNet.Moderation;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
using RadicadeServer.RecNet.Progression;
|
||||
using RadicadeServer.RecNet.Relationships;
|
||||
using RadicadeServer.RecNet.Rooms;
|
||||
using RadicadeServer.RecNet.Responses;
|
||||
using RadicadeServer.RecNet.Store;
|
||||
using RadicadeServer.RecNet.Storefront;
|
||||
using RadicadeServer.API.Moderation;
|
||||
using RadicadeServer.Clubs;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.Admin;
|
||||
|
||||
[Route("admin/panel")]
|
||||
[EnableCors("AdminPanel")]
|
||||
public class AdminPanelAPI(RecDBContext db) : Controller
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
private static readonly object StateLock = new();
|
||||
|
||||
|
||||
|
||||
private static readonly PasswordHasher<PlayerData> PasswordHasher = new();
|
||||
|
||||
private const long RadicadeTeamAccountId = 1;
|
||||
private static string StatePath => Path.Join(DBConfig.PersistentLogs, "admin-panel-state.json");
|
||||
|
||||
private static readonly SemaphoreSlim WeeklySaveGate = new(1);
|
||||
|
||||
|
||||
|
||||
private static List<Gift> WeeklyRewards()
|
||||
|
||||
{
|
||||
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
|
||||
var equipment = JsonSerializer.Deserialize<List<Equipment>>(DBConfig.DefaultEquipmentItems, options) ?? [];
|
||||
|
||||
var avatars = JsonSerializer.Deserialize<List<AvatarItem>>(DBConfig.DefaultAvatarCatalog, options) ?? [];
|
||||
|
||||
return equipment.Where(x => !string.IsNullOrWhiteSpace(x.PrefabName) && !string.IsNullOrWhiteSpace(x.ModificationGuid))
|
||||
|
||||
.Select(x => new Gift { EquipmentPrefabName = x.PrefabName, EquipmentModificationGuid = x.ModificationGuid,
|
||||
|
||||
FriendlyName = x.FriendlyName, Tooltip = x.Tooltip, Rarity = x.Rarity, Context = GiftContext.All_Weekly_Challenge_Complete })
|
||||
|
||||
.Concat(avatars.Where(x => !string.IsNullOrWhiteSpace(x.AvatarItemDesc)).Select(x => new Gift {
|
||||
|
||||
AvatarItemDesc = x.AvatarItemDesc, AvatarItemType = (AvatarItemType)x.AvatarItemType,
|
||||
|
||||
FriendlyName = x.FriendlyName, Tooltip = x.Tooltip, Rarity = (Rarity)x.Rarity, Context = GiftContext.All_Weekly_Challenge_Complete }))
|
||||
|
||||
.DistinctBy(WeeklyRewardKey).ToList();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static string WeeklyRewardKey(Gift gift) => !string.IsNullOrWhiteSpace(gift.AvatarItemDesc)
|
||||
|
||||
? "avatar:" + gift.AvatarItemDesc : "equipment:" + gift.EquipmentPrefabName + ":" + gift.EquipmentModificationGuid;
|
||||
|
||||
|
||||
|
||||
[HttpGet("weekly-challenges/rewards")]
|
||||
|
||||
public async Task<IActionResult> GetWeeklyRewards([FromQuery] string key)
|
||||
|
||||
{
|
||||
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
return Ok(WeeklyRewards().Select(g => new { key = WeeklyRewardKey(g),
|
||||
|
||||
kind = string.IsNullOrWhiteSpace(g.AvatarItemDesc) ? "equipment" : "avatar",
|
||||
|
||||
name = string.IsNullOrWhiteSpace(g.FriendlyName) ? WeeklyRewardKey(g) : g.FriendlyName,
|
||||
|
||||
detail = string.IsNullOrWhiteSpace(g.AvatarItemDesc) ? g.EquipmentPrefabName + " · " + g.EquipmentModificationGuid : g.AvatarItemDesc }));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
[HttpGet("weekly-challenges")]
|
||||
|
||||
public async Task<IActionResult> GetWeeklySchedule([FromQuery] string key)
|
||||
|
||||
{
|
||||
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var weeks = new List<RadicadeServer.RecNet.Progression.Challenges.WeeklyChallengeMap>();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (var i = 0; i < 4; i++) weeks.Add(await WeeklyChallenges.GetDefinitionAsync(db, now.AddDays(i * 7)));
|
||||
|
||||
return Ok(new { weeks, current = weeks[0], next = weeks[1], required = WeeklyChallenges.Required });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
[HttpPost("weekly-challenges")]
|
||||
|
||||
public async Task<IActionResult> SaveWeeklySchedule([FromQuery] string key, [FromBody] WeeklyScheduleRequest? request)
|
||||
|
||||
{
|
||||
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var current = WeeklyChallenges.CreateMap(DateTime.UtcNow);
|
||||
|
||||
if (request == null) return BadRequest("Choose a weekly schedule.");
|
||||
|
||||
var offset = request.ChallengeMapId - current.ChallengeMapId;
|
||||
|
||||
if (offset < 0 || offset > 3) return Conflict("The weekly schedule changed. Reload the page.");
|
||||
|
||||
var map = WeeklyChallenges.CreateMap(current.StartAt.AddDays(offset * 7));
|
||||
|
||||
if (request.ChallengeIds == null || request.ChallengeIds.Count is < 3 or > 5
|
||||
|
||||
|| request.ChallengeIds.Distinct().Count() != request.ChallengeIds.Count)
|
||||
|
||||
return BadRequest("Choose three to five different challenges.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title) || request.Title.Length > 100 || request.FallbackTokens is < 1 or > 10000)
|
||||
|
||||
return BadRequest("Enter a title (up to 100 characters) and 1–10,000 fallback tokens.");
|
||||
|
||||
var reward = WeeklyRewards().FirstOrDefault(x => WeeklyRewardKey(x) == request.RewardKey);
|
||||
|
||||
if (reward == null) return BadRequest("Choose an equipment skin or avatar item from the catalog.");
|
||||
|
||||
var library = LoadState().Challenges;
|
||||
|
||||
foreach (var id in request.ChallengeIds)
|
||||
|
||||
{
|
||||
|
||||
var row = library.FirstOrDefault(x => x["id"]?.ToString() == id.ToString(CultureInfo.InvariantCulture));
|
||||
|
||||
var config = row?["config"]?.ToString() ?? "";
|
||||
|
||||
if (row == null || id <= 0 || id > int.MaxValue || string.IsNullOrWhiteSpace(row["name"]?.ToString())
|
||||
|
||||
|| !WeeklyChallenges.IsSupportedDefinition(config))
|
||||
|
||||
return BadRequest($"Challenge {id} is missing or unsupported. Edit it using a gameplay preset.");
|
||||
|
||||
map.Challenges.Add(new() { ChallengeId = (int)id, Name = row["name"]!.ToString(),
|
||||
|
||||
Description = row["description"]?.ToString() ?? "", Tooltip = row["tooltip"]?.ToString() ?? "", Config = config });
|
||||
|
||||
}
|
||||
|
||||
map.Enabled = request.Enabled;
|
||||
|
||||
map.ChallengeThemeString = request.Title.Trim();
|
||||
|
||||
map.Gift = reward.ForClient();
|
||||
|
||||
map.FallbackTokens = request.FallbackTokens;
|
||||
|
||||
map.FallbackGiftName = $"{request.FallbackTokens} Tokens";
|
||||
|
||||
map.Revision = Guid.NewGuid().ToString("N");
|
||||
|
||||
await WeeklySaveGate.WaitAsync();
|
||||
|
||||
try
|
||||
|
||||
{
|
||||
|
||||
return await db.Database.CreateExecutionStrategy().ExecuteAsync<IActionResult>(async () =>
|
||||
|
||||
{
|
||||
|
||||
db.ChangeTracker.Clear();
|
||||
|
||||
await using var tx = db.Database.IsSqlServer()
|
||||
|
||||
? await db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable) : null;
|
||||
|
||||
var scheduleKey = WeeklyChallenges.ScheduleKey(map.ChallengeMapId);
|
||||
|
||||
var query = db.Database.IsSqlServer()
|
||||
|
||||
? db.ServerConfig.FromSqlInterpolated($"SELECT * FROM [ServerConfig] WITH (UPDLOCK, HOLDLOCK) WHERE [Key] = {scheduleKey}")
|
||||
|
||||
: db.ServerConfig.Where(x => x.Key == scheduleKey);
|
||||
|
||||
var record = await query.SingleOrDefaultAsync();
|
||||
|
||||
var revision = record == null ? "" : JsonSerializer.Deserialize<RadicadeServer.RecNet.Progression.Challenges.WeeklyChallengeMap>(record.Value)!.Revision;
|
||||
|
||||
if (revision != request.Revision) return Conflict("This weekly was edited elsewhere. Reload before saving.");
|
||||
|
||||
if (record == null) db.ServerConfig.Add(new ServerConfigVal(scheduleKey, JsonSerializer.Serialize(map)));
|
||||
|
||||
else record.Value = JsonSerializer.Serialize(map);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (tx != null) await tx.CommitAsync();
|
||||
|
||||
return Ok(map);
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
finally { WeeklySaveGate.Release(); }
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static AdminPanelState LoadState()
|
||||
{
|
||||
lock (StateLock)
|
||||
{
|
||||
if (!System.IO.File.Exists(StatePath))
|
||||
return new AdminPanelState();
|
||||
|
||||
try
|
||||
{
|
||||
var json = System.IO.File.ReadAllText(StatePath);
|
||||
return JsonSerializer.Deserialize<AdminPanelState>(json, JsonOptions) ?? new AdminPanelState();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AdminPanelState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveState(AdminPanelState state)
|
||||
{
|
||||
lock (StateLock)
|
||||
{
|
||||
Directory.CreateDirectory(DBConfig.PersistentLogs);
|
||||
System.IO.File.WriteAllText(StatePath, JsonSerializer.Serialize(state, JsonOptions));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static void AddModerationReport(long reporterId, long reportedId, string reportMsg, string details, ReportCategory category, long roomId,
|
||||
string? reportType = null, long? targetId = null, string? targetName = null)
|
||||
{
|
||||
lock (StateLock)
|
||||
{
|
||||
var state = LoadState();
|
||||
|
||||
long nextId = 1;
|
||||
foreach (var existing in state.ModerationReports)
|
||||
{
|
||||
var rawId = existing["id"]?.ToString();
|
||||
if (rawId != null && long.TryParse(rawId, out var parsedId) && parsedId >= nextId)
|
||||
nextId = parsedId + 1;
|
||||
}
|
||||
|
||||
var report = new JsonObject
|
||||
{
|
||||
["id"] = nextId,
|
||||
["report_type"] = reportType ?? "Moderator",
|
||||
["reporter_id"] = reporterId,
|
||||
["reported_id"] = reportedId,
|
||||
["reason"] = reportMsg,
|
||||
["details"] = details,
|
||||
["category"] = (int)category,
|
||||
["room_id"] = roomId,
|
||||
["status"] = "Pending",
|
||||
["resolution_notes"] = "",
|
||||
["created_at"] = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
if (targetId.HasValue)
|
||||
report["target_id"] = targetId.Value;
|
||||
if (!string.IsNullOrWhiteSpace(targetName))
|
||||
report["target_name"] = targetName;
|
||||
|
||||
state.ModerationReports.Add(report);
|
||||
|
||||
SaveState(state);
|
||||
}
|
||||
}
|
||||
|
||||
private Task<IActionResult?> RejectInvalidKey(string key)
|
||||
{
|
||||
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
|
||||
}
|
||||
|
||||
/// The first of these that carries something: an item's own name beats nothing, and a
|
||||
/// catalogue's name beats a raw descriptor used as one.
|
||||
private static string NonEmpty(params string?[] values)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value.Trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string RarityLabel(int rarity) => ((Rarity)rarity).ToString();
|
||||
|
||||
private static string ImageUrl(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name) ||
|
||||
name.Equals("default", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals("default.png", StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals(DBConfig.DefaultProfileImage, StringComparison.OrdinalIgnoreCase) ||
|
||||
name.Equals(DBConfig.DefaultRoomImage, StringComparison.OrdinalIgnoreCase))
|
||||
return $"{DBConfig.ServerUrl}/images/default";
|
||||
return $"{DBConfig.ServerUrl}/images/img/{name}";
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private async Task<List<object>> GetAdminUsers(string? eqCol, string? eqVal)
|
||||
{
|
||||
var query = db.AdminUsers.AsNoTracking();
|
||||
if (eqCol == "user_id" && !string.IsNullOrWhiteSpace(eqVal))
|
||||
query = query.Where(x => x.Username == eqVal);
|
||||
|
||||
var rows = await query.ToListAsync();
|
||||
return rows.Select(u => new
|
||||
{
|
||||
id = u.Id,
|
||||
user_id = u.Username,
|
||||
username = u.Username,
|
||||
created_at = u.CreatedAt,
|
||||
last_login_at = u.LastLoginAt
|
||||
}).Cast<object>().ToList();
|
||||
}
|
||||
|
||||
[HttpGet("session")]
|
||||
public async Task<IActionResult> Session([FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
|
||||
|
||||
if (Request.Cookies.TryGetValue(AdminSession.CookieName, out var token))
|
||||
{
|
||||
var (ok, userId) = await AdminSession.ValidateAsync(db, token);
|
||||
if (ok)
|
||||
{
|
||||
var user = await db.AdminUsers.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId);
|
||||
if (user != null)
|
||||
return Ok(new { user = new { id = user.Id, username = user.Username } });
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new { user = (object?)null });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpGet("appeals")]
|
||||
public async Task<IActionResult> ListAppeals([FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var appeals = await db.Moderation
|
||||
.AsNoTracking()
|
||||
.Where(x => x.AppealCode != null && x.AppealText != null)
|
||||
.OrderByDescending(x => x.AppealSubmittedAt)
|
||||
.Select(x => new
|
||||
{
|
||||
id = x.Id,
|
||||
player_id = x.PlayerId,
|
||||
player_name = db.Profiles.Where(p => p.accountId == x.PlayerId).Select(p => p.username).FirstOrDefault() ?? "unknown",
|
||||
code = x.AppealCode,
|
||||
reason = x.Message,
|
||||
category = x.Category.ToString(),
|
||||
permanent = x.ExpireDuration >= DateTime.MaxValue.AddYears(-10),
|
||||
expires_at = x.ExpireDuration,
|
||||
appeal_text = x.AppealText,
|
||||
appeal_contact = x.AppealContact,
|
||||
|
||||
email = db.Players.Where(pl => pl.AccountId == x.PlayerId).Select(pl => pl.Email).FirstOrDefault() ?? "",
|
||||
submitted_at = x.AppealSubmittedAt,
|
||||
resolution = x.AppealResolution,
|
||||
resolved_at = x.AppealResolvedAt,
|
||||
ban_active = x.IsBan && x.ExpireDuration > DateTime.UtcNow
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(appeals);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("appeals/{appealId:long}/accept")]
|
||||
public async Task<IActionResult> AcceptAppeal(long appealId, [FromQuery] string key, [FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null);
|
||||
if (ban == null) return NotFound("Appeal not found");
|
||||
|
||||
ban.IsBan = false;
|
||||
ban.AppealResolution = "Accepted";
|
||||
ban.AppealResolvedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
|
||||
|
||||
await NotifyLoginRefresh(ban.PlayerId);
|
||||
|
||||
if (wantsEmail)
|
||||
await SendAppealEmailAsync(ban, accepted: true, customText: customEmail);
|
||||
|
||||
return Ok(new OkResponse { Success = true, Message = "Appeal accepted - ban lifted and player notified." });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("appeals/{appealId:long}/reject")]
|
||||
public async Task<IActionResult> RejectAppeal(long appealId, [FromQuery] string key, [FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null);
|
||||
if (ban == null) return NotFound("Appeal not found");
|
||||
|
||||
ban.AppealResolution = "Rejected";
|
||||
ban.AppealResolvedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (wantsEmail)
|
||||
await SendAppealEmailAsync(ban, accepted: false, customText: customEmail);
|
||||
|
||||
return Ok(new OkResponse { Success = true, Message = "Appeal rejected - ban remains active." });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("appeals/{appealId:long}/reduce")]
|
||||
public async Task<IActionResult> ReduceAppealBanTime(long appealId, [FromQuery] string key,
|
||||
[FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null, [FromQuery] int minutes = 0)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
if (minutes <= 0)
|
||||
return BadRequest(new OkResponse { Success = false, Message = "Enter how many minutes to reduce the ban by." });
|
||||
|
||||
var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null);
|
||||
if (ban == null) return NotFound("Appeal not found");
|
||||
if (!ban.IsBan) return BadRequest(new OkResponse { Success = false, Message = "This ban is no longer active." });
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var reduction = TimeSpan.FromMinutes(minutes);
|
||||
if (ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10))
|
||||
{
|
||||
|
||||
ban.ExpireDuration = now.Add(reduction);
|
||||
}
|
||||
else
|
||||
{
|
||||
var target = ban.ExpireDuration.Subtract(reduction);
|
||||
ban.ExpireDuration = target < now.AddMinutes(1) ? now.AddMinutes(1) : target;
|
||||
}
|
||||
|
||||
|
||||
ban.AppealResolution = "Reduced";
|
||||
ban.AppealResolvedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await NotifyLoginRefresh(ban.PlayerId);
|
||||
|
||||
var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (wantsEmail)
|
||||
await SendAppealTimeChangeEmailAsync(ban, reduced: true, customText: customEmail);
|
||||
|
||||
return Ok(new OkResponse { Success = true, Message = "Ban time reduced - player notified." });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("appeals/{appealId:long}/extend")]
|
||||
public async Task<IActionResult> ExtendAppealBanTime(long appealId, [FromQuery] string key,
|
||||
[FromQuery(Name = "send_email")] string? sendEmail = null, [FromQuery(Name = "custom_email")] string? customEmail = null, [FromQuery] int minutes = 0)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
if (minutes <= 0)
|
||||
return BadRequest(new OkResponse { Success = false, Message = "Enter how many minutes to extend the ban by." });
|
||||
|
||||
var ban = await db.Moderation.FirstOrDefaultAsync(x => x.Id == appealId && x.AppealCode != null && x.AppealText != null);
|
||||
if (ban == null) return NotFound("Appeal not found");
|
||||
if (!ban.IsBan) return BadRequest(new OkResponse { Success = false, Message = "This ban is no longer active." });
|
||||
if (ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10))
|
||||
return BadRequest(new OkResponse { Success = false, Message = "This ban is permanent and cannot be extended." });
|
||||
|
||||
ban.ExpireDuration = ban.ExpireDuration.AddMinutes(minutes);
|
||||
|
||||
|
||||
ban.AppealResolution = "Extended";
|
||||
ban.AppealResolvedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await NotifyLoginRefresh(ban.PlayerId);
|
||||
|
||||
var wantsEmail = sendEmail == "1" || string.Equals(sendEmail, "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (wantsEmail)
|
||||
await SendAppealTimeChangeEmailAsync(ban, reduced: false, customText: customEmail);
|
||||
|
||||
return Ok(new OkResponse { Success = true, Message = "Ban time extended - player notified." });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private async Task NotifyLoginRefresh(long playerId)
|
||||
{
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
|
||||
if (profile != null)
|
||||
{
|
||||
await Notify.SendNotify(playerId, new Notification { Id = NotificationType.RefreshLogin, Msg = profile });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private async Task SendAppealEmailAsync(RecNet.Moderation.ModerationDB ban, bool accepted, string? customText = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DBConfig.MailApiKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == ban.PlayerId);
|
||||
var email = player?.Email;
|
||||
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) return;
|
||||
|
||||
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId);
|
||||
var name = string.IsNullOrWhiteSpace(profile?.username) ? "there" : profile.username;
|
||||
var reason = string.IsNullOrWhiteSpace(ban.Message) ? "the listed reason" : ban.Message;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(customText))
|
||||
{
|
||||
var subject = accepted ? "Your Radicade ban appeal was approved" : "Your Radicade ban appeal was rejected";
|
||||
await RadicadeMail.SendAsync(email, subject, BuildCustomEmailHtml(subject, customText));
|
||||
return;
|
||||
}
|
||||
|
||||
if (accepted)
|
||||
{
|
||||
var html =
|
||||
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
|
||||
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
|
||||
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">Your Radicade ban appeal was approved</h2>" +
|
||||
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi {HtmlEncode(name)},</p>" +
|
||||
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Your appeal against the ban for <b>{HtmlEncode(reason)}</b> has been <b>accepted</b>. The ban has been lifted and you can log back into Radicade.</p>" +
|
||||
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Thanks for reaching out - enjoy the game!</p>" +
|
||||
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
|
||||
|
||||
await RadicadeMail.SendAsync(email, "Your Radicade ban appeal was approved", html);
|
||||
}
|
||||
else
|
||||
{
|
||||
var html =
|
||||
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
|
||||
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
|
||||
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">Your Radicade ban appeal was rejected</h2>" +
|
||||
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi {HtmlEncode(name)},</p>" +
|
||||
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">We reviewed your appeal against the ban for <b>{HtmlEncode(reason)}</b> and it was <b>rejected</b>. The ban remains active.</p>" +
|
||||
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">If you believe this decision was a mistake, you may submit another appeal once the cooldown period has passed.</p>" +
|
||||
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Take this time to review our Code Of Conduct:</p>" +
|
||||
"<p style=\"text-align:center;margin:24px 0;\"><a href=\"https://radicade.app/code-of-conduct\" style=\"background:#4a6cf7;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;display:inline-block;\">Code Of Conduct</a></p>" +
|
||||
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
|
||||
|
||||
await RadicadeMail.SendAsync(email, "Your Radicade ban appeal was rejected", html);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private async Task SendAppealTimeChangeEmailAsync(RecNet.Moderation.ModerationDB ban, bool reduced, string? customText = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DBConfig.MailApiKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == ban.PlayerId);
|
||||
var email = player?.Email;
|
||||
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@')) return;
|
||||
|
||||
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId);
|
||||
var name = string.IsNullOrWhiteSpace(profile?.username) ? "there" : profile.username;
|
||||
var reason = string.IsNullOrWhiteSpace(ban.Message) ? "the listed reason" : ban.Message;
|
||||
|
||||
var action = reduced ? "reduced" : "extended";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(customText))
|
||||
{
|
||||
var subject = $"Your Radicade ban time was {action}";
|
||||
await RadicadeMail.SendAsync(email, subject, BuildCustomEmailHtml(subject, customText));
|
||||
return;
|
||||
}
|
||||
var permanent = ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10);
|
||||
var when = permanent
|
||||
? "permanent"
|
||||
: DateTime.SpecifyKind(ban.ExpireDuration, DateTimeKind.Utc).ToString("MMM d, yyyy 'at' h:mm tt 'UTC'");
|
||||
|
||||
|
||||
var cocCta = reduced
|
||||
? ""
|
||||
: "<p style=\"color:#444;font-size:15px;line-height:1.6;\">Take this time to review our Code Of Conduct:</p>" +
|
||||
"<p style=\"text-align:center;margin:24px 0;\"><a href=\"https://radicade.app/code-of-conduct\" style=\"background:#4a6cf7;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;display:inline-block;\">Code Of Conduct</a></p>";
|
||||
|
||||
var html =
|
||||
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
|
||||
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
|
||||
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">Your Radicade ban time was {action}</h2>" +
|
||||
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi {HtmlEncode(name)},</p>" +
|
||||
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">As part of your appeal, the ban for <b>{HtmlEncode(reason)}</b> has been <b>{action}</b>. It now ends <b>{when}</b>.</p>" +
|
||||
cocCta +
|
||||
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
|
||||
|
||||
await RadicadeMail.SendAsync(email, $"Your Radicade ban time was {action}", html);
|
||||
}
|
||||
|
||||
private static string BuildCustomEmailHtml(string title, string customText)
|
||||
{
|
||||
var body = HtmlEncode(customText).Replace("\r", "").Replace("\n", "<br />");
|
||||
return
|
||||
$"<div style=\"font-family:Arial,sans-serif;max-width:520px;margin:0 auto;padding:24px;\">" +
|
||||
$"<img src=\"https://radicade.app/assests/RadicadeHorizontal.png\" alt=\"Radicade\" style=\"display:block;max-width:220px;height:auto;margin:0 auto 22px;\" />" +
|
||||
$"<h2 style=\"color:#1a1a2e;margin-top:0;\">{HtmlEncode(title)}</h2>" +
|
||||
$"<p style=\"color:#444;font-size:15px;line-height:1.6;\">{body}</p>" +
|
||||
$"<p style=\"color:#999;font-size:12px;margin-top:24px;\">- Radicade Moderation</p></div>";
|
||||
}
|
||||
|
||||
private static string HtmlEncode(string value)
|
||||
{
|
||||
return System.Net.WebUtility.HtmlEncode(value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private static readonly Dictionary<string, RoleType> RoomRoleNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["none"] = RoleType.None,
|
||||
["banned"] = RoleType.Banned,
|
||||
["host"] = RoleType.Host,
|
||||
["moderator"] = RoleType.Moderator,
|
||||
["co-owner"] = RoleType.CoOwner,
|
||||
["coowner"] = RoleType.CoOwner,
|
||||
["temporarycoowner"] = RoleType.TemporaryCoOwner,
|
||||
["creator"] = RoleType.Creator
|
||||
};
|
||||
|
||||
private static string RoomRoleKey(RoleType role) => role switch
|
||||
{
|
||||
RoleType.Creator => "creator",
|
||||
RoleType.CoOwner => "co-owner",
|
||||
RoleType.TemporaryCoOwner => "co-owner",
|
||||
RoleType.Moderator => "moderator",
|
||||
RoleType.Host => "host",
|
||||
_ => "none"
|
||||
};
|
||||
|
||||
private static bool TryParseRoomRole(string? raw, out RoleType role)
|
||||
{
|
||||
role = RoleType.None;
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
if (RoomRoleNames.TryGetValue(raw.Trim(), out role)) return true;
|
||||
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric) && Enum.IsDefined(typeof(RoleType), numeric))
|
||||
{
|
||||
role = (RoleType)numeric;
|
||||
return true;
|
||||
}
|
||||
return Enum.TryParse(raw, true, out role);
|
||||
}
|
||||
|
||||
private static bool TryParseRoomAccessibility(string? raw, out Accessibility value)
|
||||
{
|
||||
value = Accessibility.Public;
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
if (int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var numeric))
|
||||
{
|
||||
if (!Enum.IsDefined(typeof(Accessibility), numeric)) return false;
|
||||
value = (Accessibility)numeric;
|
||||
return true;
|
||||
}
|
||||
return Enum.TryParse(raw, true, out value) && Enum.IsDefined(typeof(Accessibility), value);
|
||||
}
|
||||
|
||||
private static Accessibility ParseRoomAccessibility(string? raw)
|
||||
=> TryParseRoomAccessibility(raw, out var parsed) ? parsed : Accessibility.Public;
|
||||
|
||||
[HttpGet("rooms/{roomId:long}")]
|
||||
public async Task<IActionResult> RoomPanelDetails(long roomId, [FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId);
|
||||
if (room == null) return NotFound("Room does not exist.");
|
||||
|
||||
var stats = await db.RoomStats.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId);
|
||||
|
||||
var roleRows = await db.RoomRoles.AsNoTracking().Where(x => x.RoomId == roomId).ToListAsync();
|
||||
var roleAccountIds = roleRows.Select(r => r.AccountId).Append(room.CreatorAccountId).Distinct().ToList();
|
||||
var roleProfiles = await db.Profiles.AsNoTracking().Where(x => roleAccountIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId);
|
||||
|
||||
object RoleRow(long accountId, RoleType role)
|
||||
{
|
||||
roleProfiles.TryGetValue(accountId, out var member);
|
||||
var handle = member?.username ?? $"account_{accountId}";
|
||||
return new
|
||||
{
|
||||
account_id = accountId,
|
||||
username = handle,
|
||||
display_name = string.IsNullOrWhiteSpace(member?.displayName) ? handle : member!.displayName,
|
||||
profile_image = ImageUrl(member?.profileImage),
|
||||
role = RoomRoleKey(role),
|
||||
role_key = role.ToString(),
|
||||
role_value = (int)role,
|
||||
is_creator = accountId == room.CreatorAccountId
|
||||
};
|
||||
}
|
||||
|
||||
var members = new List<object>();
|
||||
var seenAccounts = new HashSet<long>();
|
||||
|
||||
members.Add(RoleRow(room.CreatorAccountId, RoleType.Creator));
|
||||
seenAccounts.Add(room.CreatorAccountId);
|
||||
|
||||
foreach (var row in roleRows.OrderBy(r => (int)r.Role).ThenBy(r => r.AccountId))
|
||||
{
|
||||
if (!seenAccounts.Add(row.AccountId)) continue;
|
||||
members.Add(RoleRow(row.AccountId, row.Role));
|
||||
}
|
||||
|
||||
var subRows = await db.SubRooms.AsNoTracking().Where(x => x.RoomId == roomId).OrderBy(x => x.SubRoomId).ToListAsync();
|
||||
var subrooms = subRows.Select((sub, index) => (object)new
|
||||
{
|
||||
id = sub.SubRoomId,
|
||||
name = sub.Name,
|
||||
list = index + 1,
|
||||
maxPlayers = sub.MaxPlayers,
|
||||
max_players = sub.MaxPlayers,
|
||||
accessibility = sub.Accessibility.ToString().ToLowerInvariant(),
|
||||
sandbox = sub.IsSandbox,
|
||||
save = sub.DataBlob ?? "",
|
||||
unity_scene_id = sub.UnitySceneId,
|
||||
created_at = DateTime.SpecifyKind(sub.CreatedAt, DateTimeKind.Utc)
|
||||
}).ToList();
|
||||
|
||||
var saves = new List<object>();
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(DBConfig.CDNStorage))
|
||||
{
|
||||
saves = Directory.GetFiles(DBConfig.CDNStorage, "*.room")
|
||||
.OrderByDescending(f => System.IO.File.GetLastWriteTimeUtc(f))
|
||||
.Select(f => (object)new { id = Path.GetFileName(f), label = Path.GetFileName(f) })
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
saves = new List<object>();
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
room = new
|
||||
{
|
||||
id = room.RoomId,
|
||||
name = room.Name,
|
||||
description = room.Description,
|
||||
image_name = room.ImageName,
|
||||
custom_warning = room.CustomWarning,
|
||||
creator_account_id = room.CreatorAccountId,
|
||||
publish_state = room.State.ToString(),
|
||||
accessibility = room.Accessibility.ToString(),
|
||||
tags = room.Tags,
|
||||
is_dorm = room.IsDorm,
|
||||
is_rro = room.IsRRO,
|
||||
cloning_allowed = room.CloningAllowed,
|
||||
disable_mic_auto_mute = room.DisableMicAutoMute,
|
||||
disable_room_comments = room.DisableRoomComments,
|
||||
encrypt_voice_chat = room.EncryptVoiceChat,
|
||||
supports_screens = room.SupportsScreens,
|
||||
supports_walk_vr = room.SupportsWalkVR,
|
||||
supports_teleport_vr = room.SupportsTeleportVR,
|
||||
supports_quest2 = room.SupportsQuest2,
|
||||
supports_mobile = room.SupportsMobile,
|
||||
supports_juniors = room.SupportsJuniors,
|
||||
min_level = room.MinLevel,
|
||||
max_players = room.MaxPlayers,
|
||||
max_player_calculation_mode = (int)room.MaxPlayerCalculationMode,
|
||||
created_at = DateTime.SpecifyKind(room.CreatedAt, DateTimeKind.Utc),
|
||||
published_at = room.PublishedAt == default ? (DateTime?)null : DateTime.SpecifyKind(room.PublishedAt, DateTimeKind.Utc),
|
||||
featured = SafeRoomTags(room.Tags).Any(t => string.Equals(t, "featured", StringComparison.OrdinalIgnoreCase)),
|
||||
cheer_count = stats?.CheerCount ?? 0,
|
||||
favorite_count = stats?.FavoriteCount ?? 0,
|
||||
visitor_count = stats?.VisitorCount ?? 0,
|
||||
visit_count = stats?.VisitCount ?? 0,
|
||||
join_gift_enabled = room.JoinGiftEnabled,
|
||||
join_gift_once_per_player = room.JoinGiftOncePerPlayer,
|
||||
join_gift_version = room.JoinGiftVersion,
|
||||
join_gift_title = room.JoinGiftTitle,
|
||||
join_gift_message = room.JoinGiftMessage,
|
||||
join_gift_avatar_item = room.JoinGiftAvatarItem,
|
||||
join_gift_equipment_item = room.JoinGiftEquipmentItem,
|
||||
join_gift_consumable_item = room.JoinGiftConsumableItem,
|
||||
join_gift_currency_amount = room.JoinGiftCurrencyAmount,
|
||||
join_gift_currency_type = room.JoinGiftCurrencyType.ToString(),
|
||||
join_gift_xp = room.JoinGiftXp,
|
||||
join_gift_context = room.JoinGiftContext.ToString(),
|
||||
join_gift_rarity = room.JoinGiftRarity.ToString(),
|
||||
join_gift_avatar_item_type = room.JoinGiftAvatarItemType.ToString(),
|
||||
join_gift_equipment_modification_guid = room.JoinGiftEquipmentModificationGuid,
|
||||
// Digits in a string: an account id is eighteen digits and a JSON number would
|
||||
// arrive in the panel rounded, so a saved id could name the wrong account.
|
||||
join_gift_from_player_id = Math.Max(0, room.JoinGiftFromPlayerId).ToString(CultureInfo.InvariantCulture),
|
||||
join_gifts = JoinGiftRows(room, await RoomJoinGiftUtils.ConfiguredGiftsAsync(db, room.RoomId))
|
||||
},
|
||||
roles = members,
|
||||
subrooms,
|
||||
saves
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/name")]
|
||||
public async Task<IActionResult> RenameRoom(long roomId, [FromQuery] string key, [FromQuery] string? name)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var next = (name ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(next)) return BadRequest("A room has to have a name.");
|
||||
if (next.Length > 64) return BadRequest("Room names are limited to 64 characters.");
|
||||
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
|
||||
if (room == null) return NotFound("Room does not exist.");
|
||||
|
||||
var previous = room.Name;
|
||||
room.Name = next;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
AdminActionDiscord.LogAction("Rename Room", await GetPanelAdminUsernameAsync(), $"Renamed room {roomId} from \"{previous}\" to \"{next}\".");
|
||||
|
||||
return Ok(new { success = true, room_id = roomId, name = room.Name });
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/roles")]
|
||||
public async Task<IActionResult> SetRoomRole(long roomId, [FromQuery] string key, [FromQuery] long accountId, [FromQuery] string? role)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
|
||||
if (room == null) return NotFound("Room does not exist.");
|
||||
|
||||
if (!TryParseRoomRole(role, out var nextRole)) return BadRequest("Unknown room role.");
|
||||
if (accountId == room.CreatorAccountId && nextRole != RoleType.Creator)
|
||||
return BadRequest("The room's creator keeps the Creator role.");
|
||||
|
||||
var existing = await db.RoomRoles.FirstOrDefaultAsync(x => x.RoomId == roomId && x.AccountId == accountId);
|
||||
if (existing == null)
|
||||
{
|
||||
db.RoomRoles.Add(new RoomRole
|
||||
{
|
||||
RoomId = roomId,
|
||||
AccountId = accountId,
|
||||
Role = nextRole,
|
||||
InvitedRole = RoleType.None
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (existing.Role == nextRole) return Ok(new { success = true, room_id = roomId, account_id = accountId, role = RoomRoleKey(nextRole) });
|
||||
existing.Role = nextRole;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var updatedRoom = await RoomUtils.GetRoom(roomId);
|
||||
if (updatedRoom != null)
|
||||
await Notify.SendNotify(accountId, new Notification { Id = "RoomUpdate", Msg = updatedRoom });
|
||||
}
|
||||
catch (Exception) { }
|
||||
|
||||
AdminActionDiscord.LogAction("Room Role", await GetPanelAdminUsernameAsync(),
|
||||
$"Set account {accountId} to {nextRole} on room {roomId} ({room.Name}).");
|
||||
|
||||
return Ok(new { success = true, room_id = roomId, account_id = accountId, role = RoomRoleKey(nextRole) });
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/add")]
|
||||
public async Task<IActionResult> AddSubroom(long roomId, [FromQuery] string key, [FromQuery] string? name,
|
||||
[FromQuery] int maxPlayers = 0, [FromQuery] string? accessibility = null, [FromQuery] string? sandbox = null, [FromQuery] string? saveId = null)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
|
||||
if (room == null) return NotFound("Room does not exist.");
|
||||
|
||||
var subName = (name ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(subName)) return BadRequest("A new subroom needs a name.");
|
||||
|
||||
var sub = new SubRoom
|
||||
{
|
||||
RoomId = roomId,
|
||||
Name = subName,
|
||||
MaxPlayers = maxPlayers > 0 ? maxPlayers : 20,
|
||||
Accessibility = ParseRoomAccessibility(accessibility),
|
||||
IsSandbox = string.Equals(sandbox, "true", StringComparison.OrdinalIgnoreCase) || sandbox == "1",
|
||||
DataBlob = saveId ?? "",
|
||||
SavedByAccountId = room.CreatorAccountId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.SubRooms.Add(sub);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var index = await db.SubRooms.CountAsync(x => x.RoomId == roomId && x.SubRoomId <= sub.SubRoomId);
|
||||
|
||||
AdminActionDiscord.LogAction("Add Subroom", await GetPanelAdminUsernameAsync(),
|
||||
$"Added subroom \"{sub.Name}\" to room {roomId} ({room.Name}).");
|
||||
|
||||
return Ok(new { success = true, subroomId = sub.SubRoomId, subroomList = index, maxPlayers = sub.MaxPlayers });
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
|
||||
public async Task<IActionResult> RenameSubroom(long roomId, long subRoomId, [FromQuery] string key, [FromQuery] string? name)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var next = (name ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(next)) return BadRequest("A subroom has to have a name.");
|
||||
|
||||
var sub = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId);
|
||||
if (sub == null) return NotFound("Subroom does not exist.");
|
||||
|
||||
sub.Name = next;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
AdminActionDiscord.LogAction("Rename Subroom", await GetPanelAdminUsernameAsync(), $"Renamed subroom {subRoomId} on room {roomId} to \"{next}\".");
|
||||
|
||||
return Ok(new { success = true, subroomId = sub.SubRoomId, name = sub.Name });
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/delete")]
|
||||
public async Task<IActionResult> DeleteSubroom(long roomId, long subRoomId, [FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var sub = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId);
|
||||
if (sub == null) return NotFound("Subroom does not exist.");
|
||||
|
||||
var permissions = await db.SubRoomPermissions.Where(x => x.subRoomId == subRoomId).ToListAsync();
|
||||
if (permissions.Count > 0) db.SubRoomPermissions.RemoveRange(permissions);
|
||||
|
||||
db.SubRooms.Remove(sub);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
AdminActionDiscord.LogAction("Delete Subroom", await GetPanelAdminUsernameAsync(), $"Deleted subroom \"{sub.Name}\" ({subRoomId}) from room {roomId}.");
|
||||
|
||||
return Ok(new { success = true, subroomId = subRoomId });
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/saves")]
|
||||
public async Task<IActionResult> UploadRoomSave(long roomId, [FromQuery] string key, [FromForm] List<IFormFile> file)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var room = await db.Rooms.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == roomId);
|
||||
if (room == null) return NotFound("Room does not exist.");
|
||||
|
||||
var chosen = file?.FirstOrDefault(f => f.Length > 0 && Path.GetExtension(f.FileName).Equals(".room", StringComparison.OrdinalIgnoreCase))
|
||||
?? file?.FirstOrDefault(f => f.Length > 0);
|
||||
if (chosen == null) return BadRequest("No .room save was uploaded.");
|
||||
|
||||
Directory.CreateDirectory(DBConfig.CDNStorage);
|
||||
var stored = Guid.NewGuid().ToString("N") + Path.GetExtension(chosen.FileName).ToLowerInvariant();
|
||||
using (var stream = new FileStream(Path.Join(DBConfig.CDNStorage, stored), FileMode.Create))
|
||||
{
|
||||
await chosen.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
AdminActionDiscord.LogAction("Upload Room Save", await GetPanelAdminUsernameAsync(),
|
||||
$"Uploaded save \"{chosen.FileName}\" ({stored}) for room {roomId} ({room.Name}).");
|
||||
|
||||
return Ok(new { success = true, saveId = stored, fileName = chosen.FileName });
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/save")]
|
||||
public async Task<IActionResult> SetSubroomSave(long roomId, long subRoomId, [FromQuery] string key,
|
||||
[FromQuery] string? saveId, [FromForm] List<IFormFile>? file)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var sub = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId);
|
||||
if (sub == null) return NotFound("Subroom does not exist.");
|
||||
|
||||
var chosen = file?.FirstOrDefault(f => f.Length > 0 && Path.GetExtension(f.FileName).Equals(".room", StringComparison.OrdinalIgnoreCase))
|
||||
?? file?.FirstOrDefault(f => f.Length > 0);
|
||||
|
||||
string stored;
|
||||
if (chosen != null)
|
||||
{
|
||||
Directory.CreateDirectory(DBConfig.CDNStorage);
|
||||
stored = Guid.NewGuid().ToString("N") + Path.GetExtension(chosen.FileName).ToLowerInvariant();
|
||||
using var stream = new FileStream(Path.Join(DBConfig.CDNStorage, stored), FileMode.Create);
|
||||
await chosen.CopyToAsync(stream);
|
||||
}
|
||||
else
|
||||
{
|
||||
stored = (saveId ?? "").Trim();
|
||||
stored = Path.GetFileName(stored);
|
||||
if (!string.IsNullOrEmpty(stored) && !System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, stored)))
|
||||
return BadRequest($"No uploaded save named '{stored}'.");
|
||||
}
|
||||
|
||||
sub.DataBlob = stored;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
AdminActionDiscord.LogAction("Set Subroom Save", await GetPanelAdminUsernameAsync(),
|
||||
$"Pointed subroom \"{sub.Name}\" ({subRoomId}) on room {roomId} at save \"{(string.IsNullOrEmpty(stored) ? "none" : stored)}\".");
|
||||
|
||||
return Ok(new { success = true, subroomId = sub.SubRoomId, saveId = stored });
|
||||
}
|
||||
|
||||
[HttpPost("players/{playerId:long}/{kind}")]
|
||||
public Task<IActionResult> PlayerPanelAction(long playerId, string kind, [FromQuery] string key)
|
||||
=> ApplyPlayerPanelAction(playerId, kind, key);
|
||||
|
||||
[HttpPost("players/{playerId:long}/{kind}/{sub}")]
|
||||
public Task<IActionResult> PlayerPanelSubAction(long playerId, string kind, string sub, [FromQuery] string key)
|
||||
=> ApplyPlayerPanelAction(playerId, $"{kind}/{sub}", key);
|
||||
|
||||
/// Take one saved photo off an account's gallery: the row that indexes it, the cheers
|
||||
/// that point at it, and the file itself. A player can do this to their own gallery in
|
||||
/// game (ImagesAPI's deletesaved), so the panel does it the same way rather than
|
||||
/// leaving the file behind.
|
||||
[HttpDelete("players/{playerId:long}/photos/{imageId:long}")]
|
||||
public async Task<IActionResult> DeletePlayerPhoto(long playerId, long imageId, [FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var image = await db.Images.FirstOrDefaultAsync(x => x.Id == imageId && x.OwnerId == playerId);
|
||||
if (image == null) return NotFound("That photo is not on this account's gallery.");
|
||||
|
||||
var cheers = db.ImageInteractions.Where(x => x.SavedImageId == image.Id);
|
||||
db.ImageInteractions.RemoveRange(cheers);
|
||||
db.Images.Remove(image);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var fullPath = Path.Join(DBConfig.CDNImages, image.Name);
|
||||
if (System.IO.File.Exists(fullPath))
|
||||
System.IO.File.Delete(fullPath);
|
||||
|
||||
AdminActionDiscord.LogAction("Delete Photo", await GetPanelAdminUsernameAsync(),
|
||||
$"Deleted photo \"{image.Name}\" ({imageId}) from player {playerId}'s gallery.");
|
||||
return Ok(new { success = true, player_id = playerId, image_id = imageId, name = image.Name });
|
||||
}
|
||||
|
||||
/// Take one invention off the account that created it, along with the versions it was
|
||||
/// saved in and every player's personal details about it. Requests and other accounts'
|
||||
/// downloads of it stop resolving on the next read.
|
||||
[HttpDelete("players/{playerId:long}/inventions/{inventionId:long}")]
|
||||
public async Task<IActionResult> DeletePlayerInvention(long playerId, long inventionId, [FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var invention = await db.Inventions.FirstOrDefaultAsync(x => x.InventionId == inventionId && x.CreatorPlayerId == playerId);
|
||||
if (invention == null) return NotFound("That invention does not belong to this account.");
|
||||
|
||||
var versions = await db.InventionVersions.Where(x => x.InventionId == inventionId).ToListAsync();
|
||||
var details = await db.InventionPersonalDetails.Where(x => x.InventionId == inventionId).ToListAsync();
|
||||
db.InventionVersions.RemoveRange(versions);
|
||||
db.InventionPersonalDetails.RemoveRange(details);
|
||||
db.Inventions.Remove(invention);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
AdminActionDiscord.LogAction("Delete Invention", await GetPanelAdminUsernameAsync(),
|
||||
$"Deleted invention \"{invention.Name}\" ({inventionId}) created by player {playerId}, with {versions.Count} saved version(s).");
|
||||
return Ok(new { success = true, player_id = playerId, invention_id = inventionId, name = invention.Name });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ApplyPlayerPanelAction(long playerId, string kind, string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == playerId);
|
||||
if (profile == null) return NotFound("Player does not exist.");
|
||||
var display = string.IsNullOrWhiteSpace(profile.displayName) ? profile.username : profile.displayName;
|
||||
|
||||
string? Q(string name)
|
||||
{
|
||||
var raw = Request.Query[name].ToString();
|
||||
return string.IsNullOrEmpty(raw) ? null : raw;
|
||||
}
|
||||
bool QBool(string name, bool fallback = false)
|
||||
{
|
||||
var raw = Q(name);
|
||||
if (raw == null) return fallback;
|
||||
return raw.Equals("true", StringComparison.OrdinalIgnoreCase) || raw == "1";
|
||||
}
|
||||
int QInt(string name, int fallback = 0) => int.TryParse(Q(name), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) ? parsed : fallback;
|
||||
|
||||
var payload = new Dictionary<string, object?>();
|
||||
var audit = "";
|
||||
|
||||
switch (kind)
|
||||
{
|
||||
case "ban":
|
||||
{
|
||||
var reason = (Q("reason") ?? "").Trim();
|
||||
var seconds = QInt("durationSeconds", -1);
|
||||
payload["banned"] = true;
|
||||
payload["ban_reason"] = reason;
|
||||
payload["ban_method"] = Q("banMethod") ?? "Admin Panel";
|
||||
var moderatorNote = (Q("moderatorNote") ?? "").Trim();
|
||||
if (!string.IsNullOrWhiteSpace(moderatorNote)) payload["moderator_note"] = moderatorNote;
|
||||
payload["ban_until"] = seconds <= 0
|
||||
? "permanent"
|
||||
: DateTime.UtcNow.AddSeconds(seconds).ToString("O", CultureInfo.InvariantCulture);
|
||||
audit = seconds <= 0
|
||||
? $"Banned player {playerId} ({display}) permanently - {reason}"
|
||||
: $"Banned player {playerId} ({display}) for {seconds} second(s) - {reason}";
|
||||
break;
|
||||
}
|
||||
case "unban":
|
||||
payload["banned"] = false;
|
||||
audit = $"Lifted player {playerId} ({display})'s ban";
|
||||
break;
|
||||
case "coach":
|
||||
{
|
||||
var message = (Q("message") ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(message)) return BadRequest("A coach message cannot be empty.");
|
||||
|
||||
await Notify.SendMessage(playerId, new Message
|
||||
{
|
||||
Type = MessageType.CoachMessage,
|
||||
Data = message,
|
||||
FromPlayerId = 1
|
||||
});
|
||||
|
||||
AdminActionDiscord.LogAction("Coach Message", await GetPanelAdminUsernameAsync(), $"Sent a coach message to player {playerId} ({display}): {message}");
|
||||
return Ok(new { success = true, player_id = playerId, message });
|
||||
}
|
||||
case "roles/add":
|
||||
{
|
||||
var added = (Q("roles") ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (added.Length == 0) return BadRequest("Pick at least one role to add.");
|
||||
|
||||
var merged = new List<string>(SplitRoles(profile.role));
|
||||
foreach (var role in added)
|
||||
{
|
||||
if (!merged.Contains(role, StringComparer.OrdinalIgnoreCase)) merged.Add(role);
|
||||
}
|
||||
payload["roles"] = string.Join(",", merged);
|
||||
audit = $"Added role(s) {string.Join(",", added)} to player {playerId} ({display})";
|
||||
break;
|
||||
}
|
||||
case "roles/clear":
|
||||
payload["roles"] = "";
|
||||
audit = $"Cleared player {playerId} ({display})'s in-game roles";
|
||||
break;
|
||||
case "lockname":
|
||||
payload["name_locked"] = QBool("locked");
|
||||
audit = $"{(QBool("locked") ? "Locked" : "Unlocked")} player {playerId} ({display})'s name";
|
||||
break;
|
||||
case "lockbio":
|
||||
payload["bio_locked"] = QBool("locked");
|
||||
audit = $"{(QBool("locked") ? "Locked" : "Unlocked")} player {playerId} ({display})'s bio";
|
||||
break;
|
||||
case "junior":
|
||||
payload["is_junior"] = QBool("junior");
|
||||
audit = $"Marked player {playerId} ({display}) as {(QBool("junior") ? "a Junior account" : "no longer Junior")}";
|
||||
break;
|
||||
case "resetpassword":
|
||||
{
|
||||
var password = Q("password") ?? "";
|
||||
if (password.Length < 8) return BadRequest("A reset password needs at least 8 characters.");
|
||||
payload["password"] = password;
|
||||
audit = $"Reset player {playerId} ({display})'s password";
|
||||
break;
|
||||
}
|
||||
case "username":
|
||||
{
|
||||
var username = (Q("username") ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(username)) return BadRequest("Enter the new username.");
|
||||
if (username.Length < 3 || username.Length > 32) return BadRequest("Usernames are 3-32 characters.");
|
||||
if (await db.Profiles.AnyAsync(x => x.accountId != playerId && x.username == username))
|
||||
return BadRequest($"The username '{username}' is already taken.");
|
||||
payload["username"] = username;
|
||||
audit = $"Renamed player {playerId} ({display}) to {username}";
|
||||
break;
|
||||
}
|
||||
case "reputation":
|
||||
payload["reputation"] = QInt("reputation");
|
||||
payload["noteriety"] = QInt("notoriety");
|
||||
audit = $"Saved player {playerId} ({display})'s reputation";
|
||||
break;
|
||||
case "cheers":
|
||||
payload["cheer_general"] = QInt("general");
|
||||
payload["cheer_helpful"] = QInt("helpful");
|
||||
payload["cheer_great_host"] = QInt("greatHost");
|
||||
payload["cheer_sportsman"] = QInt("sportsman");
|
||||
payload["cheer_creative"] = QInt("creative");
|
||||
payload["cheer_credit"] = QInt("credit");
|
||||
audit = $"Saved player {playerId} ({display})'s cheers";
|
||||
break;
|
||||
case "progression":
|
||||
payload["level"] = QInt("level", 1);
|
||||
payload["xp"] = QInt("xp");
|
||||
audit = $"Set player {playerId} ({display}) to level {QInt("level", 1)} / {QInt("xp")} XP";
|
||||
break;
|
||||
case "notes":
|
||||
{
|
||||
var text = (Q("note") ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(text)) return BadRequest("A note cannot be empty.");
|
||||
|
||||
var state = LoadState();
|
||||
long nextId = 1;
|
||||
foreach (var existing in state.Notes)
|
||||
{
|
||||
if (long.TryParse(existing["id"]?.ToString(), out var parsedId) && parsedId >= nextId)
|
||||
nextId = parsedId + 1;
|
||||
}
|
||||
|
||||
var note = new JsonObject
|
||||
{
|
||||
["id"] = nextId,
|
||||
["player_id"] = playerId,
|
||||
["text"] = text,
|
||||
["author"] = await GetPanelAdminUsernameAsync(),
|
||||
["created_at"] = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
state.Notes.Add(note);
|
||||
SaveState(state);
|
||||
|
||||
return Ok(new { success = true, player_id = playerId, note = note });
|
||||
}
|
||||
case "settings":
|
||||
{
|
||||
var settingKey = (Q("setting_key") ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(settingKey)) return BadRequest("A setting needs a key.");
|
||||
return await ApplyPlayerSetting(playerId, new Dictionary<string, string?> { [settingKey] = Q("value") ?? "" });
|
||||
}
|
||||
case "settings/delete":
|
||||
{
|
||||
var settingKey = (Q("setting_key") ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(settingKey)) return BadRequest("A setting needs a key.");
|
||||
return await ApplyPlayerSetting(playerId, new Dictionary<string, string?> { [settingKey] = null });
|
||||
}
|
||||
case "settings/reset":
|
||||
await ApplyPlayerSetting(playerId, null);
|
||||
AdminActionDiscord.LogAction("Reset Settings", await GetPanelAdminUsernameAsync(), $"Reset every in-game setting for player {playerId} ({display}).");
|
||||
return Ok(new { success = true, player_id = playerId });
|
||||
case "settings/birthday":
|
||||
return await ApplyPlayerSetting(playerId, new Dictionary<string, string?> { ["birthday"] = (Q("birthday") ?? "").Trim() });
|
||||
case "settings/profilepicture":
|
||||
{
|
||||
var owner = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
|
||||
if (owner == null) return NotFound("Player does not exist.");
|
||||
owner.profileImage = DBConfig.DefaultProfileImage;
|
||||
await db.SaveChangesAsync();
|
||||
AdminActionDiscord.LogAction("Reset Profile Picture", await GetPanelAdminUsernameAsync(), $"Reset player {playerId} ({display})'s profile picture.");
|
||||
return Ok(new { success = true, player_id = playerId });
|
||||
}
|
||||
case "items/revoke":
|
||||
{
|
||||
var itemKind = (Q("itemKind") ?? "").Trim().ToLowerInvariant();
|
||||
var itemId = (Q("itemId") ?? "").Trim();
|
||||
var modGuid = (Q("modGuid") ?? "").Trim();
|
||||
if (itemId.Length == 0) return BadRequest("An item to revoke is required.");
|
||||
|
||||
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == playerId);
|
||||
if (unlocks == null) return NotFound("This account has no locker to take an item from.");
|
||||
|
||||
int removed;
|
||||
string removedName;
|
||||
switch (itemKind)
|
||||
{
|
||||
case "avatar":
|
||||
{
|
||||
var items = unlocks.GetAvatarItems();
|
||||
removedName = items.FirstOrDefault(i => i != null && string.Equals((i.AvatarItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase))?.FriendlyName ?? itemId;
|
||||
removed = items.RemoveAll(i => i != null && string.Equals((i.AvatarItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase));
|
||||
unlocks.AvatarItems = items.ToJson();
|
||||
break;
|
||||
}
|
||||
case "equipment":
|
||||
{
|
||||
var items = unlocks.GetEquipment();
|
||||
bool Matches(Equipment i) => i != null
|
||||
&& string.Equals((i.PrefabName ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase)
|
||||
&& (modGuid.Length == 0 || string.Equals((i.ModificationGuid ?? "").Trim(), modGuid, StringComparison.OrdinalIgnoreCase));
|
||||
removedName = items.FirstOrDefault(Matches)?.FriendlyName ?? itemId;
|
||||
removed = items.RemoveAll(Matches);
|
||||
unlocks.Equipment = items.ToJson();
|
||||
break;
|
||||
}
|
||||
case "consumable":
|
||||
{
|
||||
var items = unlocks.GetConsumables();
|
||||
removedName = items.FirstOrDefault(i => i != null && string.Equals((i.ConsumableItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase))?.ConsumableItemDesc ?? itemId;
|
||||
removed = items.RemoveAll(i => i != null && string.Equals((i.ConsumableItemDesc ?? "").Trim(), itemId, StringComparison.OrdinalIgnoreCase));
|
||||
unlocks.SetConsumables(items);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return BadRequest("The item kind must be avatar, equipment or consumable.");
|
||||
}
|
||||
|
||||
if (removed == 0) return NotFound("That item is not in this account's locker.");
|
||||
await db.SaveChangesAsync();
|
||||
AdminActionDiscord.LogAction("Revoke Item", await GetPanelAdminUsernameAsync(),
|
||||
$"Took {removed} {itemKind} item(s) named \"{removedName}\" out of player {playerId} ({display})'s locker.");
|
||||
return Ok(new { success = true, player_id = playerId, item_kind = itemKind, item_id = itemId, removed });
|
||||
}
|
||||
case "locker/reset":
|
||||
{
|
||||
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == playerId);
|
||||
if (unlocks == null) return NotFound("This account has no locker to reset.");
|
||||
unlocks.AvatarItems = DBConfig.DefaultAvatarUnlocks;
|
||||
unlocks.Consumables = DBConfig.DefaultConsumableUnlocks;
|
||||
unlocks.Equipment = DBConfig.DefaultEquipmentUnlocks;
|
||||
await db.SaveChangesAsync();
|
||||
AdminActionDiscord.LogAction("Reset Locker", await GetPanelAdminUsernameAsync(),
|
||||
$"Reset player {playerId} ({display})'s locker to the default inventory.");
|
||||
return Ok(new { success = true, player_id = playerId });
|
||||
}
|
||||
case "lock":
|
||||
{
|
||||
var caseRef = (Q("case") ?? Q("case_ref") ?? "").Trim();
|
||||
var result = await AccountLock.ApplyAsync(db, playerId, await GetPanelAdminUsernameAsync(),
|
||||
string.IsNullOrWhiteSpace(caseRef) ? null : caseRef);
|
||||
if (!result.Success)
|
||||
return StatusCode(500, new { success = false, message = result.Error ?? "The account lock could not be applied." });
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
player_id = playerId,
|
||||
locked = true,
|
||||
ban_method = AccountLock.BanMethod,
|
||||
ban_category = ReportCategory.Moderator.ToString(),
|
||||
permanent = true,
|
||||
changed = result.Changed
|
||||
});
|
||||
}
|
||||
case "unlock":
|
||||
{
|
||||
var caseRef = (Q("case") ?? Q("case_ref") ?? "").Trim();
|
||||
var result = await AccountLock.RemoveAsync(db, playerId, await GetPanelAdminUsernameAsync(),
|
||||
string.IsNullOrWhiteSpace(caseRef) ? null : caseRef);
|
||||
if (!result.Success)
|
||||
return Conflict(new { success = false, message = result.Error ?? "The account lock could not be removed." });
|
||||
return Ok(new { success = true, player_id = playerId, locked = false });
|
||||
}
|
||||
case "quit":
|
||||
return await QuitPlayerGame(playerId, key);
|
||||
case "forcelogout":
|
||||
return await ForceLogoutPlayer(playerId, key);
|
||||
default:
|
||||
return NotFound($"Unknown player action '{kind}'.");
|
||||
}
|
||||
|
||||
var applied = await UpdatePlayer(playerId, JsonSerializer.SerializeToElement(payload));
|
||||
if (applied is IActionResult failure) return failure;
|
||||
|
||||
if (!string.IsNullOrEmpty(audit))
|
||||
AdminActionDiscord.LogAction(kind, await GetPanelAdminUsernameAsync(), audit);
|
||||
|
||||
return Ok(new { success = true, player_id = playerId });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ApplyPlayerSetting(long playerId, Dictionary<string, string?>? changes)
|
||||
{
|
||||
var row = await db.PlayerSettings.FirstOrDefaultAsync(x => x.AccountId == playerId);
|
||||
var settings = row?.DeserializeSettings() ?? new List<Setting>();
|
||||
|
||||
if (changes != null)
|
||||
{
|
||||
foreach (var change in changes)
|
||||
{
|
||||
settings.RemoveAll(s => string.Equals(s.Key, change.Key, StringComparison.OrdinalIgnoreCase));
|
||||
if (change.Value != null) settings.Add(new Setting { Key = change.Key, Value = change.Value });
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
settings = new List<Setting>();
|
||||
}
|
||||
|
||||
var payload = new Dictionary<string, object>
|
||||
{
|
||||
["settings"] = settings.ToDictionary(s => s.Key, s => s.Value)
|
||||
};
|
||||
|
||||
var applied = await UpdatePlayer(playerId, JsonSerializer.SerializeToElement(payload));
|
||||
if (applied is IActionResult failure) return failure;
|
||||
|
||||
return Ok(new { success = true, player_id = playerId, settings = settings.Count });
|
||||
}
|
||||
|
||||
[HttpGet("players/{playerId:long}/details")]
|
||||
public async Task<IActionResult> PlayerDetails(long playerId, [FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var rooms = await db.Rooms
|
||||
.Where(r => r.CreatorAccountId == playerId)
|
||||
.Select(r => new
|
||||
{
|
||||
id = r.RoomId,
|
||||
name = r.Name,
|
||||
description = r.Description,
|
||||
image_name = r.ImageName,
|
||||
state = r.State.ToString(),
|
||||
accessibility = r.Accessibility.ToString(),
|
||||
created_at = r.CreatedAt,
|
||||
max_players = r.MaxPlayers,
|
||||
supports_juniors = r.SupportsJuniors,
|
||||
cloning_allowed = r.CloningAllowed
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var inventions = await db.Inventions
|
||||
.Where(i => i.CreatorPlayerId == playerId)
|
||||
.Select(i => new
|
||||
{
|
||||
id = i.InventionId,
|
||||
name = i.Name,
|
||||
description = i.Description,
|
||||
image_name = i.ImageName,
|
||||
is_published = i.IsPublished,
|
||||
num_downloads = i.NumDownloads,
|
||||
cheer_count = i.CheerCount,
|
||||
created_at = i.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var events = await db.PlayerEvents
|
||||
.Where(e => e.CreatorPlayerId == playerId)
|
||||
.Select(e => new
|
||||
{
|
||||
id = e.PlayerEventId,
|
||||
name = e.Name,
|
||||
description = e.Description,
|
||||
image_name = e.ImageName,
|
||||
room_id = e.RoomId,
|
||||
start_time = e.StartTime,
|
||||
end_time = e.EndTime,
|
||||
attendee_count = e.AttendeeCount,
|
||||
accessibility = e.Accessibility.ToString()
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var currency = await db.Currency.FirstOrDefaultAsync(x => x.AccountId == playerId);
|
||||
var balance = new
|
||||
{
|
||||
rec_tokens = currency?.RecTokens ?? 0,
|
||||
laser_tag_tickets = currency?.LaserTagTickets ?? 0,
|
||||
gold = currency?.Gold ?? 0,
|
||||
dracula_silver = currency?.DraculaSilver ?? 0
|
||||
};
|
||||
|
||||
// What the account actually owns: the unlocks row the game reads when it dresses an
|
||||
// avatar. The panel shows this as the player's locker, so each entry is resolved
|
||||
// against the server's own catalogues here - the saved entry often carries no name
|
||||
// or picture of its own, and the catalogue is the only place that has them.
|
||||
var unlocksRow = await db.Unlocks.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == playerId);
|
||||
var lockerAvatar = unlocksRow?.GetAvatarItems() ?? new List<AvatarItem>();
|
||||
var lockerEquipment = unlocksRow?.GetEquipment() ?? new List<Equipment>();
|
||||
var lockerConsumables = unlocksRow?.GetConsumables() ?? new List<Consumable>();
|
||||
var avatarCatalog = (DBConfig.DefaultAvatarCatalog.FromJson<List<AvatarItem>>() ?? new List<AvatarItem>())
|
||||
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.AvatarItemDesc))
|
||||
.GroupBy(x => x.AvatarItemDesc.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
var equipmentCatalog = (DBConfig.DefaultEquipmentItems.FromJson<List<Equipment>>(CatalogJsonOptions) ?? new List<Equipment>())
|
||||
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.PrefabName))
|
||||
.GroupBy(x => x.PrefabName.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
var consumableCatalog = (DBConfig.DefaultConsumableItems.FromJson<List<ConsumableItem>>(CatalogJsonOptions) ?? new List<ConsumableItem>())
|
||||
.Where(x => x != null && !string.IsNullOrWhiteSpace(x.ConsumableItemDesc))
|
||||
.GroupBy(x => x.ConsumableItemDesc.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||
var locker = new
|
||||
{
|
||||
avatar = lockerAvatar
|
||||
.Where(i => i != null && !string.IsNullOrWhiteSpace(i.AvatarItemDesc))
|
||||
.Select(i =>
|
||||
{
|
||||
avatarCatalog.TryGetValue(i.AvatarItemDesc.Trim(), out var meta);
|
||||
return new
|
||||
{
|
||||
desc = i.AvatarItemDesc.Trim(),
|
||||
name = NonEmpty(i.FriendlyName, meta?.FriendlyName, i.AvatarItemDesc),
|
||||
rarity = RarityLabel(meta?.Rarity ?? i.Rarity),
|
||||
thumbnail_image = NonEmpty(i.ThumbnailImage, meta?.ThumbnailImage, "")
|
||||
};
|
||||
})
|
||||
.Cast<object>().ToList(),
|
||||
equipment = lockerEquipment
|
||||
.Where(i => i != null && !string.IsNullOrWhiteSpace(i.PrefabName))
|
||||
.Select(i =>
|
||||
{
|
||||
equipmentCatalog.TryGetValue(i.PrefabName.Trim(), out var meta);
|
||||
return new
|
||||
{
|
||||
prefab = i.PrefabName.Trim(),
|
||||
guid = (i.ModificationGuid ?? "").Trim(),
|
||||
name = NonEmpty(i.FriendlyName, meta?.FriendlyName, i.PrefabName),
|
||||
rarity = (meta?.Rarity ?? i.Rarity).ToString()
|
||||
};
|
||||
})
|
||||
.Cast<object>().ToList(),
|
||||
consumables = lockerConsumables
|
||||
.Where(i => i != null && !string.IsNullOrWhiteSpace(i.ConsumableItemDesc))
|
||||
.Select(i =>
|
||||
{
|
||||
consumableCatalog.TryGetValue(i.ConsumableItemDesc.Trim(), out var meta);
|
||||
return new
|
||||
{
|
||||
desc = i.ConsumableItemDesc.Trim(),
|
||||
count = i.Count,
|
||||
name = NonEmpty(meta?.FriendlyName, i.ConsumableItemDesc),
|
||||
rarity = meta == null ? "None" : ((Rarity)meta.Rarity).ToString()
|
||||
};
|
||||
})
|
||||
.Cast<object>().ToList()
|
||||
};
|
||||
|
||||
|
||||
|
||||
var photos = await db.Images
|
||||
.Where(i => i.OwnerId == playerId)
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.Select(i => new
|
||||
{
|
||||
id = i.Id,
|
||||
name = i.Name,
|
||||
image_url = $"{DBConfig.ServerUrl}/images/img/{i.Name}",
|
||||
type = i.ImageType.ToString(),
|
||||
accessibility = i.Accessibility.ToString(),
|
||||
room_id = i.RoomId,
|
||||
created_at = DateTime.SpecifyKind(i.CreatedAt, DateTimeKind.Utc)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
|
||||
|
||||
var roomIds = rooms.Select(r => r.id).ToList();
|
||||
var roomReports = LoadState().ModerationReports
|
||||
.Where(r => string.Equals(r["report_type"]?.ToString(), "Room", StringComparison.OrdinalIgnoreCase)
|
||||
&& (string.Equals(r["reported_id"]?.ToString(), playerId.ToString(), StringComparison.Ordinal)
|
||||
|| (r["target_id"]?.ToString() is string targetRaw
|
||||
&& long.TryParse(targetRaw, out var targetId) && roomIds.Contains(targetId))))
|
||||
.OrderByDescending(r => r["created_at"]?.ToString())
|
||||
.ToList();
|
||||
|
||||
|
||||
|
||||
var moderation = (await db.Moderation
|
||||
.Where(m => m.PlayerId == playerId)
|
||||
.ToListAsync())
|
||||
.Select(m => new
|
||||
{
|
||||
id = m.Id,
|
||||
category = m.Category.ToString(),
|
||||
message = m.Message,
|
||||
moderator_note = m.ModeratorNote,
|
||||
ban_method = m.BanMethod,
|
||||
created_at = DateTime.SpecifyKind(m.CreatedAt, DateTimeKind.Utc),
|
||||
is_ban = m.IsBan,
|
||||
|
||||
|
||||
|
||||
expire_duration = DateTime.SpecifyKind(m.ExpireDuration, DateTimeKind.Utc)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
|
||||
|
||||
var voteKicks = await db.VoteKicks
|
||||
.Where(v => v.PlayerId == playerId)
|
||||
.OrderByDescending(v => v.CreatedAt)
|
||||
.Select(v => new
|
||||
{
|
||||
id = v.Id,
|
||||
reason = v.Reason,
|
||||
category = v.Category.ToString(),
|
||||
is_force = v.IsForce,
|
||||
created_at = DateTime.SpecifyKind(v.CreatedAt, DateTimeKind.Utc),
|
||||
expires_at = DateTime.SpecifyKind(v.ExpiresAt, DateTimeKind.Utc)
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var friendRows = await db.Relationships
|
||||
.Where(r => r.AccountId == playerId && r.RelationshipType == RelationshipType.Friend)
|
||||
.ToListAsync();
|
||||
var friendIds = friendRows.Select(r => r.PlayerId).Distinct().ToList();
|
||||
var friendProfiles = friendIds.Count > 0
|
||||
? await db.Profiles.Where(p => friendIds.Contains(p.accountId)).ToDictionaryAsync(p => p.accountId)
|
||||
: new Dictionary<long, Profile>();
|
||||
var friendProgression = friendIds.Count > 0
|
||||
? await db.Progression.Where(p => friendIds.Contains(p.PlayerId)).ToDictionaryAsync(p => p.PlayerId)
|
||||
: new Dictionary<long, ProgressionData>();
|
||||
var friends = friendRows.Select(r =>
|
||||
{
|
||||
friendProfiles.TryGetValue(r.PlayerId, out var fp);
|
||||
friendProgression.TryGetValue(r.PlayerId, out var fprog);
|
||||
return new
|
||||
{
|
||||
player_id = r.PlayerId,
|
||||
username = fp?.username ?? "",
|
||||
display_name = fp?.displayName ?? $"Player {r.PlayerId}",
|
||||
profile_image = fp != null ? ImageUrl(fp.profileImage) : ImageUrl(null),
|
||||
level = fprog?.Level ?? 0,
|
||||
relationship_type = r.RelationshipType.ToString()
|
||||
};
|
||||
}).ToList();
|
||||
|
||||
var taggedImages = await db.Images.ToListAsync();
|
||||
var photosOfMe = taggedImages
|
||||
.Where(i =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var ids = i.PlayerIds.FromJson<List<long>>() ?? new List<long>();
|
||||
return ids.Contains(playerId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.OrderByDescending(i => i.CreatedAt)
|
||||
.Select(i => new
|
||||
{
|
||||
id = i.Id,
|
||||
name = i.Name,
|
||||
image_url = $"{DBConfig.ServerUrl}/images/img/{i.Name}",
|
||||
owner_id = i.OwnerId,
|
||||
room_id = i.RoomId,
|
||||
created_at = DateTime.SpecifyKind(i.CreatedAt, DateTimeKind.Utc)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var notes = LoadState().Notes
|
||||
.Where(n => string.Equals(n["player_id"]?.ToString(), playerId.ToString(), StringComparison.Ordinal))
|
||||
.OrderByDescending(n => n["created_at"]?.ToString())
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
var clubRecords = await ClubStore.LoadAsync(db);
|
||||
var clubs = clubRecords
|
||||
.Where(c => c.ownerPlayerId == playerId)
|
||||
.OrderByDescending(c => c.createdAt)
|
||||
.Select(c => new
|
||||
{
|
||||
id = c.clubId,
|
||||
name = c.name,
|
||||
description = c.description,
|
||||
primary_tag = c.primaryTag,
|
||||
created_at = DateTime.SpecifyKind(c.createdAt, DateTimeKind.Utc)
|
||||
})
|
||||
.Cast<object>()
|
||||
.ToList();
|
||||
|
||||
var progress = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
|
||||
var progressJson = progress?.GetProgressionJson();
|
||||
var progression = new
|
||||
{
|
||||
level = progress?.Level ?? 0,
|
||||
xp = progress?.XP ?? 0,
|
||||
last_xp_earned_at = progressJson?.LastXpEarnedAt,
|
||||
xp_earned_today = progressJson?.XpEarnedToday ?? 0
|
||||
};
|
||||
|
||||
var accountLocked = moderation.Any(m => m.is_ban
|
||||
&& string.Equals(m.ban_method, "Account Lock", StringComparison.OrdinalIgnoreCase)
|
||||
&& m.expire_duration > DateTime.UtcNow);
|
||||
|
||||
var warningRows = await db.PlayerWarnings
|
||||
.AsNoTracking()
|
||||
.Where(w => w.WarnedPlayerId == playerId)
|
||||
.OrderByDescending(w => w.Id)
|
||||
.ToListAsync();
|
||||
var warningCount = warningRows.Count;
|
||||
var pendingWarningCount = warningRows.Count(w => w.AcknowledgedAt == null);
|
||||
var warnings = warningRows.Take(25).Select(w => new
|
||||
{
|
||||
id = w.Id,
|
||||
category = w.ReportCategory.ToString(),
|
||||
report_category = (int)w.ReportCategory,
|
||||
reason = w.DisplayReason ?? string.Empty,
|
||||
moderator_id = w.ModeratorPlayerId,
|
||||
note = w.ModeratorNote ?? string.Empty,
|
||||
created_at = DateTime.SpecifyKind(w.CreatedAt, DateTimeKind.Utc),
|
||||
acknowledged_at = w.AcknowledgedAt.HasValue
|
||||
? DateTime.SpecifyKind(w.AcknowledgedAt.Value, DateTimeKind.Utc)
|
||||
: (DateTime?)null
|
||||
}).Cast<object>().ToList();
|
||||
|
||||
var voiceRestriction = await VoiceChatRestrictions.ActiveAsync(db, playerId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
rooms,
|
||||
inventions,
|
||||
events,
|
||||
balance,
|
||||
locker,
|
||||
moderation,
|
||||
voice_restricted = voiceRestriction != null,
|
||||
voice_restriction = voiceRestriction == null
|
||||
? null
|
||||
: new
|
||||
{
|
||||
reason = voiceRestriction.Reason,
|
||||
category = voiceRestriction.Category.ToString(),
|
||||
report_category = (int)voiceRestriction.Category,
|
||||
issued_by = voiceRestriction.IssuedBy,
|
||||
issued_by_username = voiceRestriction.IssuedByUsername,
|
||||
note = voiceRestriction.ModeratorNote,
|
||||
started_at = DateTime.SpecifyKind(voiceRestriction.StartedAt, DateTimeKind.Utc),
|
||||
expires_at = DateTime.SpecifyKind(voiceRestriction.ExpiresAt, DateTimeKind.Utc),
|
||||
minutes_remaining = (int)Math.Max(0, (voiceRestriction.ExpiresAt - DateTime.UtcNow).TotalMinutes)
|
||||
},
|
||||
vote_kicks = voteKicks,
|
||||
photos,
|
||||
photos_of_me = photosOfMe,
|
||||
friends,
|
||||
clubs,
|
||||
notes,
|
||||
progression,
|
||||
room_reports = roomReports,
|
||||
warn_count = warningCount,
|
||||
pending_warn_count = pendingWarningCount,
|
||||
warnings,
|
||||
account_locked = accountLocked
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("notify/status")]
|
||||
public async Task<IActionResult> NotifyStatus([FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var accounts = Notify.ConnectedAccountIds();
|
||||
return Ok(new
|
||||
{
|
||||
hub_context_ready = Notify.Context != null,
|
||||
connected_connections = Notify.ConnectedConnectionCount,
|
||||
connected_account_count = accounts.Count,
|
||||
connected_accounts = accounts
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{table}")]
|
||||
public async Task<IActionResult> List(string table, [FromQuery] string key, [FromQuery(Name = "eq_col")] string? eqCol, [FromQuery(Name = "eq_val")] string? eqVal, [FromQuery] string? order, [FromQuery] bool ascending = true)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
return table switch
|
||||
{
|
||||
"admin_users" => Ok(await GetAdminUsers(eqCol, eqVal)),
|
||||
"players" => Ok(await GetPlayers(order, ascending)),
|
||||
"gift_drops" => Ok(await GetGiftDrops(order, ascending)),
|
||||
"storefront_items" => Ok(await GetStorefrontItems(eqCol, eqVal, order, ascending)),
|
||||
"storefronts" => Ok(GetStorefronts(order, ascending)),
|
||||
"challenges" => Ok(GetChallenges(order, ascending)),
|
||||
"moderation_log" => Ok(FilterByPlayer(LoadState().ModerationLog, eqCol, eqVal, order, ascending)),
|
||||
"gift_log" => Ok(await ProcessGiftLog(eqCol, eqVal, order, ascending)),
|
||||
"notes" => Ok(FilterByPlayer(LoadState().Notes, eqCol, eqVal, order, ascending)),
|
||||
"audit_log" => Ok(LoadState().AuditLog),
|
||||
"rooms" => Ok(await GetRooms(order, ascending)),
|
||||
"room_comments" => Ok(await GetRoomComments(order, ascending)),
|
||||
"community_board" => Ok(GetCommunityBoard()),
|
||||
"moderation_reports" => Ok(GetModerationReports(order, ascending)),
|
||||
"player_events" => Ok(await GetPlayerEvents(order, ascending)),
|
||||
"player_event_responses" => Ok(await GetPlayerEventResponses(eqCol, eqVal, order, ascending)),
|
||||
"event_invitations" => Ok(await GetEventInvitations(eqCol, eqVal, order, ascending)),
|
||||
"avatar_items" => Ok(GetAvatarItems()),
|
||||
"equipment_items" => Ok(GetEquipmentItems()),
|
||||
"consumable_items" => Ok(GetConsumableItems()),
|
||||
"announcements" => Ok(GetAnnouncements()),
|
||||
"ip_bans" => Ok(await GetIpBans(order, ascending)),
|
||||
"hardware_bans" => Ok(await GetHardwareBans(order, ascending)),
|
||||
"account_creation_blocks" => Ok(await GetAccountCreationBlocks(order, ascending)),
|
||||
"leaderboard_scores" => Ok(await GetLeaderboardScores(eqCol, eqVal, order, ascending)),
|
||||
"game_blocks" => Ok(await GetGameBlocks(order, ascending)),
|
||||
"gameconfigs" => Ok(await db.GameConfig.ToListAsync()),
|
||||
"serverconfigs" => Ok(await db.ServerConfig.ToListAsync()),
|
||||
"clubs" => Ok(await GetClubs()),
|
||||
"featured_rooms" => Ok(await GetFeaturedRooms()),
|
||||
"room_recommendations" => Ok(await GetRoomRecommendations()),
|
||||
"item_sets" => Ok(await GetItemSets()),
|
||||
"playlists" => Ok(await GetPlaylists()),
|
||||
"split_tests" => Ok(await GetSplitTests()),
|
||||
"cohort_nux_buttons" => Ok(await GetCohortNuxButtons()),
|
||||
"challenge_maps" => Ok(await GetChallengeMaps()),
|
||||
"tags" => Ok(await GetTags()),
|
||||
_ => GenericTableList(table)
|
||||
};
|
||||
}
|
||||
|
||||
private IActionResult GenericTableList(string table)
|
||||
{
|
||||
var prop = db.GetType().GetProperties()
|
||||
.FirstOrDefault(p => p.PropertyType.IsGenericType
|
||||
&& p.PropertyType.GetGenericTypeDefinition() == typeof(DbSet<>)
|
||||
&& string.Equals(p.Name, table, StringComparison.OrdinalIgnoreCase));
|
||||
if (prop == null) return NotFound($"Unknown admin panel table '{table}'.");
|
||||
|
||||
var set = prop.GetValue(db) as System.Collections.IEnumerable;
|
||||
var rows = set?.Cast<object>().ToList() ?? new List<object>();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
private static List<string> SafeRoomTags(string? tags)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tags)) return new List<string>();
|
||||
try { return StringTools.ReadTags(tags).Select(t => t.Tag).ToList(); }
|
||||
catch { return new List<string>(); }
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetClubs()
|
||||
{
|
||||
var config = await db.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == "Clubs");
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.Value)) return new List<object>();
|
||||
|
||||
try
|
||||
{
|
||||
var clubs = config.Value.FromJson<List<ClubRecord>>() ?? new List<ClubRecord>();
|
||||
var clubReports = LoadState().ModerationReports
|
||||
.Where(r => string.Equals(r["report_type"]?.ToString(), "Club", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(r => r["target_id"]?.ToString())
|
||||
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||
.GroupBy(v => v!, StringComparer.Ordinal)
|
||||
.ToDictionary(g => g.Key, g => g.Count(), StringComparer.Ordinal);
|
||||
|
||||
return clubs.Select(c =>
|
||||
{
|
||||
var categories = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(c.primaryTag)) categories.Add(c.primaryTag);
|
||||
if (c.customTags != null) categories.AddRange(c.customTags.Where(t => !string.IsNullOrWhiteSpace(t)));
|
||||
|
||||
return new
|
||||
{
|
||||
club_id = c.clubId,
|
||||
name = c.name,
|
||||
description = c.description,
|
||||
primary_tag = c.primaryTag,
|
||||
categories = categories,
|
||||
image_name = c.imageName,
|
||||
banner_image_name = c.bannerImageName,
|
||||
owner_player_id = c.ownerPlayerId,
|
||||
created_at = c.createdAt,
|
||||
privacy = c.visibility,
|
||||
privacy_label = c.visibility == 0 ? "Private" : "Public",
|
||||
member_access = c.joinability,
|
||||
member_access_label = c.joinability switch { 1 => "Invite Only", 2 => "Ask to Join", _ => "Open" },
|
||||
member_count = ClubStore.GetMemberCount(c),
|
||||
report_count = clubReports.TryGetValue(c.clubId.ToString(), out var rc) ? rc : 0
|
||||
};
|
||||
}).Cast<object>().ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<object>();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetFeaturedRooms()
|
||||
{
|
||||
var rooms = await db.Rooms.AsNoTracking().ToListAsync();
|
||||
return rooms
|
||||
.Where(r => SafeRoomTags(r.Tags).Any(t => string.Equals(t, "featured", StringComparison.OrdinalIgnoreCase)))
|
||||
.Select(r => new
|
||||
{
|
||||
room_id = r.RoomId,
|
||||
name = r.Name,
|
||||
creator_player_id = r.CreatorAccountId,
|
||||
tags = r.Tags,
|
||||
accessibility = r.Accessibility.ToString(),
|
||||
is_rro = r.IsRRO,
|
||||
is_dorm = r.IsDorm,
|
||||
created_at = r.CreatedAt
|
||||
}).Cast<object>().ToList();
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetRoomRecommendations()
|
||||
{
|
||||
var top = await db.Rooms
|
||||
.AsNoTracking()
|
||||
.Where(r => r.Accessibility == Accessibility.Public && !r.IsDorm)
|
||||
.Select(r => new
|
||||
{
|
||||
r.RoomId,
|
||||
r.Name,
|
||||
r.CreatorAccountId,
|
||||
r.Tags,
|
||||
r.IsRRO,
|
||||
r.CreatedAt,
|
||||
Engagement = db.RoomInteractions
|
||||
.Where(i => i.RoomId == r.RoomId)
|
||||
.Sum(i => (i.Favorited ? 1 : 0) + (i.Cheered ? 1 : 0))
|
||||
})
|
||||
.OrderByDescending(x => x.Engagement)
|
||||
.Take(10)
|
||||
.ToListAsync();
|
||||
|
||||
var rank = 1;
|
||||
return top.Select(x => new
|
||||
{
|
||||
rank = rank++,
|
||||
room_id = x.RoomId,
|
||||
name = x.Name,
|
||||
creator_player_id = x.CreatorAccountId,
|
||||
tags = x.Tags,
|
||||
is_rro = x.IsRRO,
|
||||
engagement = x.Engagement,
|
||||
created_at = x.CreatedAt
|
||||
}).Cast<object>().ToList();
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetItemSets()
|
||||
{
|
||||
var curated = await db.ItemSets.AsNoTracking().OrderBy(x => x.ItemSetId).ToListAsync();
|
||||
var derived = await db.GiftItems
|
||||
.AsNoTracking()
|
||||
.Where(g => g.ItemSetId > 0)
|
||||
.GroupBy(g => new { g.ItemSetId, g.ItemSetFriendlyName })
|
||||
.Select(g => new { item_set_id = g.Key.ItemSetId, item_set_friendly_name = g.Key.ItemSetFriendlyName, item_count = g.Count() })
|
||||
.OrderBy(x => x.item_set_id)
|
||||
.ToListAsync();
|
||||
|
||||
var result = new List<object>();
|
||||
var curatedIds = curated.Select(c => c.ItemSetId).ToHashSet();
|
||||
foreach (var set in curated)
|
||||
{
|
||||
var count = derived.Where(d => d.item_set_id == set.ItemSetId).Sum(d => d.item_count);
|
||||
result.Add(new { item_set_id = set.ItemSetId, item_set_friendly_name = set.FriendlyName, item_count = count, source = "curated" });
|
||||
}
|
||||
foreach (var set in derived.Where(d => !curatedIds.Contains(d.item_set_id)))
|
||||
{
|
||||
result.Add(new { item_set_id = set.item_set_id, item_set_friendly_name = set.item_set_friendly_name, item_count = set.item_count, source = "derived" });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetPlaylists()
|
||||
{
|
||||
var playlists = await db.Playlists.AsNoTracking().OrderBy(x => x.PlaylistId).ToListAsync();
|
||||
return playlists.Select(p => new
|
||||
{
|
||||
playlist_id = p.PlaylistId,
|
||||
keywords = p.Keywords
|
||||
}).Cast<object>().ToList();
|
||||
}
|
||||
|
||||
private async Task<object> CreatePlaylist(string keywords)
|
||||
{
|
||||
var playlist = new PlaylistDB { Keywords = keywords };
|
||||
db.Playlists.Add(playlist);
|
||||
await db.SaveChangesAsync();
|
||||
return new { playlist_id = playlist.PlaylistId, keywords = playlist.Keywords };
|
||||
}
|
||||
|
||||
private async Task<object> CreateItemSet(string friendlyName)
|
||||
{
|
||||
var set = new ItemSetDB { FriendlyName = friendlyName };
|
||||
db.ItemSets.Add(set);
|
||||
await db.SaveChangesAsync();
|
||||
return new { item_set_id = set.ItemSetId, item_set_friendly_name = set.FriendlyName, item_count = 0, source = "curated" };
|
||||
}
|
||||
|
||||
private async Task<object> CreateTag(string tag, string description)
|
||||
{
|
||||
var row = new TagDB { Tag = tag, Description = description };
|
||||
db.Tags.Add(row);
|
||||
await db.SaveChangesAsync();
|
||||
return new { tag_id = (long?)row.TagId, tag = row.Tag, description = row.Description, room_count = 0, source = "curated" };
|
||||
}
|
||||
|
||||
private async Task<IActionResult> UpdatePlaylist(long id, JsonElement payload)
|
||||
{
|
||||
var playlist = await db.Playlists.FirstOrDefaultAsync(x => x.PlaylistId == id);
|
||||
if (playlist == null) return NotFound("Playlist not found.");
|
||||
var keywords = GetJsonString(payload, "keywords");
|
||||
if (keywords != null) playlist.Keywords = keywords;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { playlist_id = playlist.PlaylistId, keywords = playlist.Keywords });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> UpdateItemSet(long id, JsonElement payload)
|
||||
{
|
||||
var set = await db.ItemSets.FirstOrDefaultAsync(x => x.ItemSetId == id);
|
||||
if (set == null) return NotFound("Item set not found.");
|
||||
var friendlyName = GetJsonString(payload, "item_set_friendly_name") ?? GetJsonString(payload, "friendly_name");
|
||||
if (!string.IsNullOrWhiteSpace(friendlyName)) set.FriendlyName = friendlyName;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { item_set_id = set.ItemSetId, item_set_friendly_name = set.FriendlyName, source = "curated" });
|
||||
}
|
||||
|
||||
private async Task<IActionResult> UpdateTag(long id, JsonElement payload)
|
||||
{
|
||||
var tag = await db.Tags.FirstOrDefaultAsync(x => x.TagId == id);
|
||||
if (tag == null) return NotFound("Tag not found.");
|
||||
var tagValue = GetJsonString(payload, "tag");
|
||||
if (!string.IsNullOrWhiteSpace(tagValue)) tag.Tag = tagValue;
|
||||
var description = GetJsonString(payload, "description");
|
||||
if (description != null) tag.Description = description;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { tag_id = (long?)tag.TagId, tag = tag.Tag, description = tag.Description, source = "curated" });
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetSplitTests()
|
||||
{
|
||||
var configs = await db.GameConfig.AsNoTracking().ToListAsync();
|
||||
var available = configs.FirstOrDefault(c => c.Key == "splitTestsAvailable")?.Value ?? "";
|
||||
var hard = configs.FirstOrDefault(c => c.Key == "splitTestHardOverrides")?.Value ?? "";
|
||||
var probs = configs.FirstOrDefault(c => c.Key == "splitTestSegmentProbabilities")?.Value ?? "";
|
||||
|
||||
var hardMap = new Dictionary<string, string>();
|
||||
foreach (var part in hard.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var eq = part.IndexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
hardMap[part[..eq].Trim()] = part[(eq + 1)..].Trim();
|
||||
}
|
||||
|
||||
JsonNode? probNode = null;
|
||||
try { probNode = JsonNode.Parse(probs); } catch { }
|
||||
|
||||
var rows = new List<object>();
|
||||
foreach (var part in available.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var eq = part.IndexOf('=');
|
||||
if (eq <= 0) continue;
|
||||
var name = part[..eq].Trim();
|
||||
var segments = part[(eq + 1)..].Trim();
|
||||
string? probabilities = null;
|
||||
if (probNode is JsonObject obj && obj.TryGetPropertyValue(name, out var segNode) && segNode != null)
|
||||
probabilities = segNode.ToJsonString();
|
||||
rows.Add(new
|
||||
{
|
||||
test_name = name,
|
||||
available_segments = segments,
|
||||
hard_override = hardMap.TryGetValue(name, out var h) ? h : "",
|
||||
segment_probabilities = probabilities ?? ""
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetCohortNuxButtons()
|
||||
{
|
||||
var rows = new List<object>();
|
||||
var game = await db.GameConfig.AsNoTracking().ToListAsync();
|
||||
var server = await db.ServerConfig.AsNoTracking().ToListAsync();
|
||||
|
||||
void AddFromConfig(string key, string value, string source)
|
||||
{
|
||||
if (MatchesNux(key))
|
||||
{
|
||||
rows.Add(new { config_key = key, value, source });
|
||||
return;
|
||||
}
|
||||
foreach (var entry in value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!MatchesNux(entry)) continue;
|
||||
var eq = entry.IndexOf('=');
|
||||
rows.Add(new
|
||||
{
|
||||
config_key = eq > 0 ? entry[..eq].Trim() : entry,
|
||||
value = eq > 0 ? entry[(eq + 1)..].Trim() : "",
|
||||
source
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var c in game) AddFromConfig(c.Key, c.Value, "GameConfig");
|
||||
foreach (var c in server) AddFromConfig(c.Key, c.Value, "ServerConfig");
|
||||
return rows;
|
||||
|
||||
static bool MatchesNux(string s) =>
|
||||
s.Contains("NUX", StringComparison.OrdinalIgnoreCase) || s.Contains("Cohort", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetChallengeMaps()
|
||||
{
|
||||
var rooms = await db.Rooms.AsNoTracking().ToListAsync();
|
||||
return rooms
|
||||
.Where(r => SafeRoomTags(r.Tags).Any(t =>
|
||||
string.Equals(t, "challenge", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(t, "quest", StringComparison.OrdinalIgnoreCase)))
|
||||
.Select(r => new
|
||||
{
|
||||
room_id = r.RoomId,
|
||||
name = r.Name,
|
||||
creator_player_id = r.CreatorAccountId,
|
||||
tags = r.Tags,
|
||||
accessibility = r.Accessibility.ToString(),
|
||||
is_rro = r.IsRRO,
|
||||
created_at = r.CreatedAt
|
||||
}).Cast<object>().ToList();
|
||||
}
|
||||
|
||||
private async Task<List<object>> GetTags()
|
||||
{
|
||||
var curated = await db.Tags.AsNoTracking().OrderBy(x => x.Tag).ToListAsync();
|
||||
var rooms = await db.Rooms.AsNoTracking().ToListAsync();
|
||||
var derived = rooms
|
||||
.SelectMany(r => SafeRoomTags(r.Tags).Select(t => new { Tag = t, RoomId = r.RoomId }))
|
||||
.Where(t => !string.IsNullOrWhiteSpace(t.Tag))
|
||||
.GroupBy(t => t.Tag, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(g => new { tag = g.Key, room_count = g.Select(x => x.RoomId).Distinct().Count() })
|
||||
.OrderByDescending(x => x.room_count)
|
||||
.ThenBy(x => x.tag)
|
||||
.ToList();
|
||||
|
||||
var result = new List<object>();
|
||||
var curatedTags = curated.Select(c => c.Tag).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var tag in curated)
|
||||
{
|
||||
var count = derived.FirstOrDefault(d => string.Equals(d.tag, tag.Tag, StringComparison.OrdinalIgnoreCase))?.room_count ?? 0;
|
||||
result.Add(new { tag_id = (long?)tag.TagId, tag = tag.Tag, description = tag.Description, room_count = count, source = "curated" });
|
||||
}
|
||||
foreach (var tag in derived.Where(d => !curatedTags.Contains(d.tag)))
|
||||
{
|
||||
result.Add(new { tag_id = (long?)null, tag = tag.tag, description = "", room_count = tag.room_count, source = "derived" });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private static string? SaveDataUrlFile(string? dataUrl, string directory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dataUrl) || !dataUrl.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
var comma = dataUrl.IndexOf(',');
|
||||
if (comma < 0) return null;
|
||||
|
||||
var header = dataUrl.Substring(0, comma);
|
||||
string ext = header.Contains("png", StringComparison.OrdinalIgnoreCase) ? ".png"
|
||||
: header.Contains("jpeg", StringComparison.OrdinalIgnoreCase) ? ".jpg"
|
||||
: header.Contains("jpg", StringComparison.OrdinalIgnoreCase) ? ".jpg"
|
||||
: header.Contains("gif", StringComparison.OrdinalIgnoreCase) ? ".gif"
|
||||
: header.Contains("webp", StringComparison.OrdinalIgnoreCase) ? ".webp"
|
||||
: ".png";
|
||||
|
||||
try
|
||||
{
|
||||
var bytes = Convert.FromBase64String(dataUrl.Substring(comma + 1));
|
||||
Directory.CreateDirectory(directory);
|
||||
var name = Guid.NewGuid().ToString("N") + ext;
|
||||
System.IO.File.WriteAllBytes(Path.Join(directory, name), bytes);
|
||||
return name;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatBanTime(TimeSpan timeRemaining)
|
||||
{
|
||||
if (timeRemaining.TotalDays > 365 * 10)
|
||||
return "This ban is permanent.";
|
||||
|
||||
var parts = new List<string>();
|
||||
if (timeRemaining.Days > 0)
|
||||
parts.Add($"{timeRemaining.Days} Days");
|
||||
if (timeRemaining.Hours > 0)
|
||||
parts.Add($"{timeRemaining.Hours} Hours");
|
||||
if (timeRemaining.Minutes > 0)
|
||||
parts.Add($"{timeRemaining.Minutes} Minutes");
|
||||
if (timeRemaining.Seconds > 0)
|
||||
parts.Add($"{timeRemaining.Seconds} Seconds");
|
||||
|
||||
return parts.Count > 0 ? string.Join(", ", parts) : "0 Seconds";
|
||||
}
|
||||
|
||||
[HttpPost("upload/video")]
|
||||
[RequestSizeLimit(AdminUtils.MaxUploadBytes)]
|
||||
public async Task<IActionResult> UploadVideo([FromQuery] string key, [FromForm] IFormFile file)
|
||||
{
|
||||
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
if (file == null || file.Length == 0) return BadRequest("No file provided.");
|
||||
|
||||
|
||||
var allowed = new[] { ".mp4", ".webm", ".mov", ".m4v", ".ogv" };
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!allowed.Contains(ext)) return BadRequest($"Unsupported video format '{ext}'. Use mp4/webm/mov.");
|
||||
|
||||
Directory.CreateDirectory(DBConfig.CDNVideos);
|
||||
var name = Guid.NewGuid().ToString("N") + ext;
|
||||
var path = Path.Join(DBConfig.CDNVideos, name);
|
||||
using (var stream = new FileStream(path, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
return Ok(new { name, url = $"{DBConfig.ServerUrl}/cdn/video/{name}" });
|
||||
}
|
||||
|
||||
[HttpPost("upload/image")]
|
||||
[RequestSizeLimit(AdminUtils.MaxUploadBytes)]
|
||||
public async Task<IActionResult> UploadImage([FromQuery] string key, [FromForm] IFormFile file)
|
||||
{
|
||||
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
if (file == null || file.Length == 0) return BadRequest("No file provided.");
|
||||
|
||||
|
||||
|
||||
var allowed = new[] { ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp" };
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!allowed.Contains(ext)) return BadRequest($"Unsupported image format '{ext}'. Use png/jpg/jpeg/gif/webp.");
|
||||
|
||||
Directory.CreateDirectory(DBConfig.CDNImages);
|
||||
var name = Guid.NewGuid().ToString("N") + ext;
|
||||
var path = Path.Join(DBConfig.CDNImages, name);
|
||||
using (var stream = new FileStream(path, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
return Ok(new { name, url = $"{DBConfig.ServerUrl}/images/img/{name}" });
|
||||
}
|
||||
|
||||
[HttpPost("upload/room")]
|
||||
[RequestSizeLimit(AdminUtils.MaxUploadBytes)]
|
||||
public async Task<IActionResult> UploadRoom([FromQuery] string key, [FromForm] IFormFile file, [FromQuery] string? name)
|
||||
{
|
||||
|
||||
// An oversized or truncated body fails form binding, so it is reported as the bad
|
||||
// request it is rather than as whatever a half-read form left behind.
|
||||
if (!ModelState.IsValid) return BadRequest($"Invalid upload request. Use a multipart form no larger than {AdminUtils.MaxUploadLabel}.");
|
||||
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
if (file == null || file.Length == 0) return BadRequest("No file provided.");
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// A subroom is imported as three kinds of file, and each keeps the role its
|
||||
// extension names: the .room is the save the subroom loads, the .assetbundle
|
||||
// is the Unity scene its unity_assets metadata points at, and the .htr files
|
||||
// are the audio/data blobs that save references by name. They are imported one
|
||||
// file at a time, so every one of them lands in the CDN under the name it was
|
||||
// uploaded as - the 2023 client asks for exactly that name (GET /data/<name>),
|
||||
// which is why a file this route renamed to a GUID could never be found.
|
||||
// An explicit ?name= wins; otherwise the uploaded file's own name is the one to
|
||||
// store, and it is held to the same bare-name rule either way.
|
||||
var candidate = !string.IsNullOrWhiteSpace(name) ? name : Path.GetFileName(file.FileName);
|
||||
if (!AdminUtils.TryResolveUploadName(candidate, out var finalName, out var nameError))
|
||||
return BadRequest(nameError);
|
||||
if (string.IsNullOrEmpty(finalName))
|
||||
return BadRequest("The uploaded file has no name to store it under.");
|
||||
|
||||
// The stored name decides which of the three a file is, so a renamed upload is
|
||||
// classified by what it is being stored as.
|
||||
var kind = RoomFileKind(finalName);
|
||||
if (kind == null)
|
||||
{
|
||||
var ext = Path.GetExtension(finalName).ToLowerInvariant();
|
||||
return BadRequest($"Unsupported room file format '{(string.IsNullOrEmpty(ext) ? "no extension" : ext)}'. Only .room, .htr and .assetbundle room files are allowed.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(DBConfig.CDNStorage);
|
||||
var path = Path.Join(DBConfig.CDNStorage, finalName);
|
||||
using (var stream = new FileStream(path, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
return Ok(new { name = finalName, kind, url = $"{DBConfig.ServerUrl}/cdn/data/{finalName}" });
|
||||
}
|
||||
|
||||
/// The stored name decides which of the three a room file is: the extension is the role.
|
||||
private static string? RoomFileKind(string fileName) => Path.GetExtension(fileName).ToLowerInvariant() switch
|
||||
{
|
||||
".room" => "room",
|
||||
".htr" => "htr",
|
||||
".assetbundle" => "assetbundle",
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// One part of a file too big to send in a single request.
|
||||
///
|
||||
/// A whole room file in one request does not survive the trip to this server: the edge
|
||||
/// proxy in front of it refuses a request body over 100 MB and holds a streamed body in
|
||||
/// memory, so importing a large .assetbundle died between the panel and here and surfaced
|
||||
/// to the admin as a broken connection rather than as a size refusal. The panel therefore
|
||||
/// posts anything large in parts, and this route appends each one to
|
||||
/// "<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();
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using RadicadeServer.API.Notifications;
|
||||
using RadicadeServer.Auth;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.Radicade.Discord;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Auth;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Enums.Moderation;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
using RadicadeServer.RecNet.Messages;
|
||||
using RadicadeServer.RecNet.Moderation;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
using RadicadeServer.RecNet.Rooms;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.Admin
|
||||
{
|
||||
[Route("admin/profiles")]
|
||||
[EnableCors("AdminPanel")]
|
||||
public class AdminProfileAPI(RecDBContext db) : Controller
|
||||
{
|
||||
private Task<IActionResult?> RejectInvalidKey(string key)
|
||||
{
|
||||
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
|
||||
}
|
||||
|
||||
private async Task NotifyUser(long playerId)
|
||||
{
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
|
||||
if (profile == null)
|
||||
return;
|
||||
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == playerId);
|
||||
if (plrData == null)
|
||||
return;
|
||||
|
||||
await Notify.SendNotify(playerId, new Notification
|
||||
{
|
||||
Id = "AccountUpdate",
|
||||
Msg = profile
|
||||
});
|
||||
await Notify.UpdateSubscriptions(playerId, Notifs.SubscriptionUpdateProfile, profile);
|
||||
|
||||
await Notify.SendNotify(playerId, new Notification
|
||||
{
|
||||
Id = "SelfAccountUpdate",
|
||||
Msg = new SelfAccount
|
||||
{
|
||||
juniorState = plrData.Birthday != null && plrData.Birthday.Value.Year > 1900
|
||||
? JuniorState.BasedOnBirthday : JuniorState.PendingOverride,
|
||||
accountId = playerId,
|
||||
parentAccountId = plrData.Parent,
|
||||
availableUsernameChanges = 99,
|
||||
bio = profile.bio,
|
||||
displayName = profile.displayName,
|
||||
email = plrData.Email,
|
||||
createdAt = profile.createdAt,
|
||||
isJunior = profile.isJunior,
|
||||
phone = plrData.Phone,
|
||||
platforms = profile.platforms,
|
||||
profileImage = profile.profileImage,
|
||||
username = profile.username
|
||||
}
|
||||
});
|
||||
}
|
||||
[HttpGet("whitelist")]
|
||||
public async Task<IActionResult> WhitelistUser([FromQuery] string key, [FromQuery] long playerId)
|
||||
{
|
||||
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();
|
||||
|
||||
var cachedLogins = await db.CachedLogins.Where(x => x.accountId == playerId).ToListAsync();
|
||||
|
||||
string addedIds = "";
|
||||
List<string> ids = new();
|
||||
foreach (var cached in cachedLogins)
|
||||
{
|
||||
if (cached.platform != Platforms.Steam) continue;
|
||||
if (!SteamWhitelist.Contains(cached.platformId))
|
||||
{
|
||||
SteamWhitelist.Add(cached.platformId);
|
||||
addedIds += cached.platformId + ",";
|
||||
ids.Add(cached.platformId);
|
||||
}
|
||||
}
|
||||
|
||||
addedIds = addedIds.Trim().TrimEnd(',');
|
||||
|
||||
return Ok($"Whitelisted @{profile.username}! Added Steam IDs: {addedIds}");
|
||||
}
|
||||
[HttpGet("whitelist/toggle")]
|
||||
public async Task<IActionResult> ToggleWhitelist([FromQuery] string key, [FromQuery] bool enabled)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
SteamWhitelist.Enabled = enabled;
|
||||
|
||||
return Ok(enabled
|
||||
? "Steam whitelist is now ENABLED. Only approved Steam IDs can sign in.\nConfig: " + SteamWhitelist.FilePath
|
||||
: "Steam whitelist is now DISABLED. Anyone can sign in.");
|
||||
}
|
||||
[HttpGet("whitelist/list")]
|
||||
public async Task<IActionResult> GetWhitelist([FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
return Ok($"Whitelist {(SteamWhitelist.Enabled ? "ENABLED" : "disabled")} ({SteamWhitelist.SteamIds.Count} IDs)\nFilename: {SteamWhitelist.FilePath}\n```json\n{new SteamWhitelist.WhitelistFile { Enabled = SteamWhitelist.Enabled, SteamIds = SteamWhitelist.SteamIds.ToList() }.ToJson()}\n```");
|
||||
}
|
||||
[HttpGet("whitelist/add")]
|
||||
public async Task<IActionResult> AddWhitelistId([FromQuery] string key, [FromQuery] string steamId)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
if (string.IsNullOrWhiteSpace(steamId)) return BadRequest("No steamId given.");
|
||||
|
||||
var added = SteamWhitelist.Add(steamId);
|
||||
return Ok(added
|
||||
? $"Steam ID {steamId} was added to the whitelist."
|
||||
: $"Steam ID {steamId} is already on the whitelist.");
|
||||
}
|
||||
[HttpGet("whitelist/remove")]
|
||||
public async Task<IActionResult> RemoveWhitelistId([FromQuery] string key, [FromQuery] string steamId)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
if (string.IsNullOrWhiteSpace(steamId)) return BadRequest("No steamId given.");
|
||||
|
||||
var removed = SteamWhitelist.Remove(steamId);
|
||||
return Ok(removed
|
||||
? $"Steam ID {steamId} was removed from the whitelist."
|
||||
: $"Steam ID {steamId} is not on the whitelist.");
|
||||
}
|
||||
[HttpGet("set/uname")]
|
||||
public async Task<IActionResult> SetUsername([FromQuery] string key, [FromQuery] long playerId, [FromQuery] string newName)
|
||||
{
|
||||
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();
|
||||
|
||||
profile.username = newName;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await NotifyUser(profile.accountId);
|
||||
|
||||
return Ok("Updated profile!\n```json" + profile.ToJson() + "```");
|
||||
}
|
||||
[HttpGet("set/dname")]
|
||||
public async Task<IActionResult> SetDisplayName([FromQuery] string key, [FromQuery] long playerId, [FromQuery] string newName)
|
||||
{
|
||||
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();
|
||||
|
||||
profile.displayName = newName;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await NotifyUser(profile.accountId);
|
||||
|
||||
return Ok("Updated profile!\n```json" + profile.ToJson() + "```");
|
||||
}
|
||||
[HttpGet("set/image")]
|
||||
public async Task<IActionResult> SetProfileImage([FromQuery] string key, [FromQuery] long playerId, [FromQuery] string imgName)
|
||||
{
|
||||
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();
|
||||
|
||||
profile.profileImage = imgName;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await NotifyUser(profile.accountId);
|
||||
|
||||
return Ok("Updated profile!\n```json" + profile.ToJson() + "```");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpGet("profiles/{profileId}")]
|
||||
public async Task<IActionResult> GetRoom([FromQuery] string key, long profileId)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == profileId);
|
||||
if (profile == null) return NotFound();
|
||||
return Ok(profile);
|
||||
}
|
||||
[HttpGet("ban")]
|
||||
public async Task<IActionResult> BanUser([FromQuery] string key, [FromQuery] long userId, [FromQuery] ReportCategory category, [FromQuery] int duration, [FromQuery] string reason)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
if (profile == null) return NotFound("User does not exist.");
|
||||
|
||||
bool didBan = false;
|
||||
|
||||
|
||||
DateTime banExpiry = DateTime.MaxValue;
|
||||
string appealCode = string.Empty;
|
||||
var existingModeration = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == userId && x.IsBan);
|
||||
if (existingModeration != null)
|
||||
{
|
||||
if (duration > 0)
|
||||
{
|
||||
|
||||
var expireTime = DateTime.UtcNow.AddMinutes(duration);
|
||||
existingModeration.ExpireDuration = expireTime;
|
||||
existingModeration.Message = reason;
|
||||
existingModeration.Category = category;
|
||||
existingModeration.IsBan = true;
|
||||
didBan = true;
|
||||
banExpiry = expireTime;
|
||||
}
|
||||
else if (duration == -1)
|
||||
{
|
||||
|
||||
existingModeration.ExpireDuration = DateTime.MaxValue;
|
||||
existingModeration.Message = reason;
|
||||
existingModeration.Category = category;
|
||||
existingModeration.IsBan = true;
|
||||
didBan = true;
|
||||
banExpiry = DateTime.MaxValue;
|
||||
}
|
||||
else
|
||||
{
|
||||
db.Moderation.Remove(existingModeration);
|
||||
didBan = false;
|
||||
|
||||
|
||||
|
||||
|
||||
await Notify.SendNotify(userId, new Notification { Id = NotificationType.RefreshLogin, Msg = profile });
|
||||
|
||||
await PlayerUtils.ReleaseModerationHoldAsync(userId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (duration > 0)
|
||||
{
|
||||
|
||||
|
||||
|
||||
var expireTime = DateTime.UtcNow.AddMinutes(duration);
|
||||
var (newCode, _) = await PlayerUtils.GetAppealForNewBanAsync(db, userId);
|
||||
var newModeration = new ModerationDB
|
||||
{
|
||||
Category = category,
|
||||
ExpireDuration = expireTime,
|
||||
IsBan = true,
|
||||
Message = reason,
|
||||
PlayerId = userId,
|
||||
AppealCode = newCode
|
||||
};
|
||||
await db.Moderation.AddAsync(newModeration);
|
||||
appealCode = newCode ?? string.Empty;
|
||||
didBan = true;
|
||||
banExpiry = expireTime;
|
||||
}
|
||||
else if (duration == -1)
|
||||
{
|
||||
|
||||
var expireTime = DateTime.MaxValue;
|
||||
var (newCode, _) = await PlayerUtils.GetAppealForNewBanAsync(db, userId);
|
||||
var newModeration = new ModerationDB
|
||||
{
|
||||
Category = category,
|
||||
ExpireDuration = expireTime,
|
||||
IsBan = true,
|
||||
Message = reason,
|
||||
PlayerId = userId,
|
||||
AppealCode = newCode
|
||||
};
|
||||
await db.Moderation.AddAsync(newModeration);
|
||||
appealCode = newCode ?? string.Empty;
|
||||
didBan = true;
|
||||
banExpiry = expireTime;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (didBan)
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var activeBan = existingModeration ?? await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == userId && x.IsBan);
|
||||
if (activeBan != null && string.IsNullOrWhiteSpace(activeBan.AppealCode))
|
||||
{
|
||||
var (newCode, _) = await PlayerUtils.GetAppealForNewBanAsync(db, userId);
|
||||
activeBan.AppealCode = newCode;
|
||||
appealCode = newCode ?? string.Empty;
|
||||
}
|
||||
else if (string.IsNullOrWhiteSpace(appealCode) && activeBan != null)
|
||||
{
|
||||
appealCode = activeBan.AppealCode ?? string.Empty;
|
||||
}
|
||||
|
||||
await db.VoteKicks.AddAsync(new VoteKickLog
|
||||
{
|
||||
PlayerId = userId,
|
||||
Reason = reason,
|
||||
Category = category,
|
||||
IsForce = true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = banExpiry
|
||||
});
|
||||
|
||||
|
||||
|
||||
var rep = await db.Reputations.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (rep != null)
|
||||
rep.ApplyBanPenalty(permanent: duration == -1, cheating: false);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
|
||||
|
||||
if (didBan)
|
||||
{
|
||||
await AdminActionDiscord.LogBanAsync(db, userId,
|
||||
bannedBy: "Admin (Profile API)",
|
||||
causedByReports: false,
|
||||
reason: reason,
|
||||
category: category,
|
||||
expiry: banExpiry,
|
||||
isModerator: false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (didBan)
|
||||
{
|
||||
var banStart = (await db.Moderation.AsNoTracking().FirstOrDefaultAsync(x => x.PlayerId == userId && x.IsBan))?.CreatedAt;
|
||||
await PlayerUtils.KickOrPromptAsync(db, userId, PlayerUtils.BuildBlockDetails(true, category, reason, banExpiry, appealCode: appealCode, startedAtUtc: banStart ?? DateTime.UtcNow));
|
||||
}
|
||||
|
||||
if (didBan)
|
||||
{
|
||||
|
||||
return Ok(duration == -1
|
||||
? $"Successfully banned @{profile.username} permanently!"
|
||||
: $"Successfully banned @{profile.username} for {duration} minutes!");
|
||||
}
|
||||
else
|
||||
return Ok($"Successfully unbanned @{profile.username}!");
|
||||
}
|
||||
|
||||
[HttpGet("coach")]
|
||||
public async Task<IActionResult> SendCoachMessage([FromQuery] string key, [FromQuery] long userId, [FromQuery] string message)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
if (profile == null) return NotFound("User does not exist.");
|
||||
|
||||
|
||||
|
||||
|
||||
await Notify.SendMessage(userId, new Message
|
||||
{
|
||||
Type = MessageType.CoachMessage,
|
||||
Data = message,
|
||||
FromPlayerId = 1
|
||||
});
|
||||
|
||||
return Ok($"Successfully sent coach message to @{profile.username}!");
|
||||
}
|
||||
|
||||
[HttpGet("coach/all")]
|
||||
public async Task<IActionResult> SendCoachMessageToAll([FromQuery] string key, [FromQuery] string message)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var profiles = await db.Profiles.ToListAsync();
|
||||
long playerCount = 0;
|
||||
foreach (var profile in profiles)
|
||||
{
|
||||
|
||||
if (profile.accountId == 1) continue;
|
||||
|
||||
await Notify.SendMessage(profile.accountId, new Message
|
||||
{
|
||||
Type = MessageType.CoachMessage,
|
||||
Data = message,
|
||||
FromPlayerId = 1
|
||||
});
|
||||
playerCount++;
|
||||
}
|
||||
|
||||
return Ok($"Successfully sent coach message to {playerCount} players!");
|
||||
}
|
||||
|
||||
[HttpGet("forcequit")]
|
||||
public async Task<IActionResult> ForceQuitPlayer([FromQuery] string key, [FromQuery] long userId)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == userId);
|
||||
if (profile == null) return NotFound("User does not exist.");
|
||||
|
||||
|
||||
|
||||
Notify.SendReliably(userId, 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 == userId);
|
||||
if (presence != null)
|
||||
{
|
||||
presence.isOnline = false;
|
||||
presence.roomInstance = null;
|
||||
presence.LoginLock = null;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok($"Successfully force quit @{profile.username}!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1042 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Data;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using RadicadeServer.API.Notifications;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Auth;
|
||||
using RadicadeServer.RecNet.Enums;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Enums.Moderation;
|
||||
using RadicadeServer.RecNet.Enums.Rooms;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
using RadicadeServer.RecNet.Progression;
|
||||
using RadicadeServer.RecNet.Responses;
|
||||
using RadicadeServer.RecNet.Rooms;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.Admin
|
||||
{
|
||||
[Route("admin/rooms")]
|
||||
[EnableCors("AdminPanel")]
|
||||
public class AdminRoomAPI(RecDBContext db) : Controller
|
||||
{
|
||||
private Task<IActionResult?> RejectInvalidKey(string key)
|
||||
{
|
||||
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
|
||||
}
|
||||
|
||||
[HttpGet("room/{roomId}")]
|
||||
public async Task<IActionResult> GetRoom([FromQuery] string key, long roomId)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var room = await RoomUtils.GetRoom(roomId);
|
||||
if (room == null) return NotFound();
|
||||
|
||||
return Ok(room);
|
||||
}
|
||||
|
||||
[HttpPost("create/room")]
|
||||
public async Task<IActionResult> CreateRoom([FromQuery] string key, [FromQuery] long newOwner, [FromBody] JsonNode fullRoom)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var roomData = new RoomData
|
||||
{
|
||||
EncryptVoiceChat = fullRoom.Get("EncryptVoiceChat", false),
|
||||
Accessibility = fullRoom.Get("Accessibility", Accessibility.Private),
|
||||
CloningAllowed = fullRoom.Get("CloningAllowed", false),
|
||||
|
||||
CreatedAt = fullRoom["CreatedAt"]?.GetValue<DateTime>() ?? DateTime.UtcNow,
|
||||
|
||||
CreatorAccountId = newOwner,
|
||||
CustomWarning = fullRoom.Get("CustomWarning", ""),
|
||||
Description = fullRoom.Get("Description", ""),
|
||||
|
||||
DisableMicAutoMute = fullRoom.Get("DisableMicAutoMute", false),
|
||||
DisableRoomComments = fullRoom.Get("DisableRoomComments", false),
|
||||
|
||||
ImageName = fullRoom.Get("ImageName", ""),
|
||||
|
||||
IsDorm = fullRoom.Get("IsDorm", false),
|
||||
IsRRO = fullRoom.Get("IsRRO", false),
|
||||
|
||||
IsDeveloperOwned = fullRoom["IsDeveloperOwned"] != null
|
||||
? fullRoom.Get("IsDeveloperOwned", false)
|
||||
: await db.IsDeveloper(newOwner),
|
||||
|
||||
loadScreen = fullRoom["LoadScreens"]?.ToJson() ?? "[]",
|
||||
|
||||
MaxPlayerCalculationMode = fullRoom.Get("MaxPlayerCalculationMode", MaxPlayerMode.AllSubrooms),
|
||||
MaxPlayers = fullRoom.Get("MaxPlayers", 10),
|
||||
MinLevel = fullRoom.Get("MinLevel", 0),
|
||||
|
||||
Name = fullRoom.Get("Name", ""),
|
||||
State = fullRoom.Get("State", RoomState.Active),
|
||||
|
||||
SupportsJuniors = fullRoom.Get("SupportsJuniors", false),
|
||||
SupportsLevelVoting = fullRoom.Get("SupportsLevelVoting", false),
|
||||
SupportsMobile = fullRoom.Get("SupportsMobile", false),
|
||||
SupportsQuest2 = fullRoom.Get("SupportsQuest2", false),
|
||||
SupportsScreens = fullRoom.Get("SupportsScreens", false),
|
||||
SupportsTeleportVR = fullRoom.Get("SupportsTeleportVR", false),
|
||||
SupportsVRLow = fullRoom.Get("SupportsVRLow", false),
|
||||
SupportsWalkVR = fullRoom.Get("SupportsWalkVR", false),
|
||||
|
||||
Tags = StringTools.CreateTagString(fullRoom.Get<List<ItemTag>>("Tags", [])),
|
||||
|
||||
Version = 1,
|
||||
WarningMask = fullRoom.Get("WarningMask", WarningMask.None)
|
||||
};
|
||||
|
||||
await db.Rooms.AddAsync(roomData);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
foreach (var subRoom in fullRoom["SubRooms"]?.AsArray() ?? new JsonArray())
|
||||
{
|
||||
if (subRoom == null) continue;
|
||||
var subRoomSceneId = subRoom["UnitySceneId"]?.ToString() ?? SceneLocations.MakerRoom;
|
||||
if (!SceneLocations.IsValid(subRoomSceneId))
|
||||
subRoomSceneId = SceneLocations.MakerRoom;
|
||||
|
||||
// A new room's subroom can arrive with its Unity bundle already uploaded,
|
||||
// named exactly as it was imported (see UploadRoom).
|
||||
var bundleName = subRoom["UnityAssetId"]?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(bundleName))
|
||||
{
|
||||
if (!AdminUtils.TryResolveBundleName(bundleName, out var resolvedBundle, out var bundleError))
|
||||
return BadRequest(bundleError);
|
||||
bundleName = resolvedBundle;
|
||||
}
|
||||
|
||||
await db.AddAsync(new SubRoom
|
||||
{
|
||||
Accessibility = (Accessibility)(subRoom["Accessibility"]?.GetValue<int>() ?? 0),
|
||||
CreatedAt = subRoom["CreatedAt"]?.GetValue<DateTime>() ?? DateTime.UtcNow,
|
||||
SavedByAccountId = newOwner,
|
||||
IsSandbox = subRoom["IsSandbox"]?.GetValue<bool>() ?? false,
|
||||
MaxPlayers = subRoom["MaxPlayers"]?.GetValue<int>() ?? 0,
|
||||
Name = subRoom["Name"]?.ToString() ?? "",
|
||||
RoomId = roomData.RoomId,
|
||||
UnitySceneId = subRoomSceneId,
|
||||
DataBlob = subRoom["DataBlob"]?.ToString() ?? "",
|
||||
UnityAssetId = bundleName ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
await db.RoomRoles.AddAsync(new RoomRole
|
||||
{
|
||||
AccountId = newOwner,
|
||||
InvitedRole = RoleType.None,
|
||||
Role = RoleType.Creator,
|
||||
RoomId = roomData.RoomId
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Created as {roomData.RoomId}!");
|
||||
}
|
||||
|
||||
[HttpGet("rename/room")]
|
||||
public async Task<IActionResult> RenameRoom([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string newName)
|
||||
{
|
||||
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.");
|
||||
|
||||
room.Name = newName;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Changed to ^{newName}!");
|
||||
}
|
||||
|
||||
[HttpGet("delete/room")]
|
||||
public async Task<IActionResult> DeleteRoom([FromQuery] string key, [FromQuery] long roomId)
|
||||
{
|
||||
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 (room.IsRRO || room.IsDorm || room.CreatorAccountId == 1) return Ok("You cannot delete RROs.");
|
||||
|
||||
db.Rooms.Remove(room);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Deleted.");
|
||||
}
|
||||
|
||||
[HttpGet("set/datablob")]
|
||||
public async Task<IActionResult> SetDatablob([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string subRoomName, [FromQuery] string datablobName)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId && x.Name == subRoomName);
|
||||
if (subRoom == null) return NotFound("Subroom not found.");
|
||||
|
||||
subRoom.DataBlob = datablobName;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Set datablob to {datablobName} for {subRoom.Name}!");
|
||||
}
|
||||
|
||||
// The other half of importing a subroom: its .assetbundle. The file name is
|
||||
// the asset id - RoomServerAPI serves /rooms/unity_assets/<name> out of the CDN
|
||||
// by that name - so binding means pointing the subroom at an uploaded bundle,
|
||||
// and an empty name clears it again. BindAssetBundle below is the narrower,
|
||||
// Studio-side counterpart for 40-character manifest ids; this one takes the
|
||||
// '<name>.assetbundle' files an import actually produces.
|
||||
[HttpGet("set/bundle")]
|
||||
public async Task<IActionResult> SetBundle([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string subRoomName, [FromQuery] string bundleName)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId && x.Name == subRoomName);
|
||||
if (subRoom == null) return NotFound("Subroom not found.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(bundleName))
|
||||
{
|
||||
subRoom.UnityAssetId = "";
|
||||
await db.SaveChangesAsync();
|
||||
return Ok($"Cleared the Unity bundle for {subRoom.Name}!");
|
||||
}
|
||||
|
||||
if (!AdminUtils.TryResolveBundleName(bundleName, out var resolvedBundle, out var bundleError))
|
||||
return BadRequest(bundleError);
|
||||
|
||||
subRoom.UnityAssetId = resolvedBundle ?? "";
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Set bundle to {subRoom.UnityAssetId} for {subRoom.Name}!");
|
||||
}
|
||||
|
||||
[HttpPost("create/subroom")]
|
||||
public async Task<IActionResult> CreateSubRoom([FromQuery] string key, [FromQuery] long roomId, [FromBody] JsonNode subRoom)
|
||||
{
|
||||
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 name = (subRoom.Get("Name", "") ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(name)) return BadRequest("Subroom name is required.");
|
||||
|
||||
if (await db.SubRooms.AnyAsync(x => x.RoomId == roomId && x.Name == name))
|
||||
return BadRequest($"A subroom named \"{name}\" already exists in this room.");
|
||||
|
||||
// The Unity bundle the subroom loads, if one was imported alongside the save.
|
||||
var bundleName = subRoom.Get("UnityAssetId", "") ?? "";
|
||||
if (!string.IsNullOrWhiteSpace(bundleName))
|
||||
{
|
||||
if (!AdminUtils.TryResolveBundleName(bundleName, out var resolvedBundle, out var bundleError))
|
||||
return BadRequest(bundleError);
|
||||
bundleName = resolvedBundle ?? "";
|
||||
}
|
||||
|
||||
var mainSubRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId);
|
||||
|
||||
var maxPlayers = subRoom.Get("MaxPlayers", mainSubRoom?.MaxPlayers ?? 10);
|
||||
if (maxPlayers <= 0) maxPlayers = mainSubRoom?.MaxPlayers ?? 10;
|
||||
|
||||
var unitySceneId = subRoom.Get("UnitySceneId", "");
|
||||
if (string.IsNullOrWhiteSpace(unitySceneId))
|
||||
unitySceneId = mainSubRoom?.UnitySceneId ?? "a75f7547-79eb-47c6-8986-6767abcb4f92";
|
||||
|
||||
var dbSubRoom = new SubRoom
|
||||
{
|
||||
Accessibility = (Accessibility)(subRoom["Accessibility"]?.GetValue<int>() ?? (int)Accessibility.Public),
|
||||
CreatedAt = subRoom.Get("CreatedAt", DateTime.UtcNow),
|
||||
SavedByAccountId = room.CreatorAccountId,
|
||||
IsSandbox = subRoom.Get("IsSandbox", true),
|
||||
MaxPlayers = maxPlayers,
|
||||
Name = name,
|
||||
RoomId = roomId,
|
||||
UnitySceneId = unitySceneId,
|
||||
DataBlob = subRoom.Get("DataBlob", ""),
|
||||
UnityAssetId = bundleName,
|
||||
};
|
||||
|
||||
await db.AddAsync(dbSubRoom);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Created as {dbSubRoom.SubRoomId}!");
|
||||
}
|
||||
|
||||
[HttpGet("rename/subroom")]
|
||||
public async Task<IActionResult> RenameSubRoom([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long subRoomId, [FromQuery] string newName)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId);
|
||||
if (subRoom == null) return NotFound("Subroom not found.");
|
||||
|
||||
newName = (newName ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(newName)) return BadRequest("Subroom name is required.");
|
||||
|
||||
if (await db.SubRooms.AnyAsync(x => x.RoomId == roomId && x.SubRoomId != subRoomId && x.Name == newName))
|
||||
return BadRequest($"A subroom named \"{newName}\" already exists in this room.");
|
||||
|
||||
subRoom.Name = newName;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Renamed to {newName}!");
|
||||
}
|
||||
|
||||
[HttpGet("delete/subroom")]
|
||||
public async Task<IActionResult> DeleteSubRoom([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long subRoomId)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.SubRoomId == subRoomId && x.RoomId == roomId);
|
||||
if (subRoom == null) return NotFound("Subroom not found.");
|
||||
|
||||
|
||||
var subroomCount = await db.SubRooms.CountAsync(x => x.RoomId == roomId);
|
||||
if (subroomCount <= 1) return BadRequest("A room must keep at least one subroom.");
|
||||
|
||||
var history = await db.SubRoomHistory.Where(x => x.RoomId == roomId && x.SubRoomId == subRoomId).ToListAsync();
|
||||
foreach (var historyItem in history)
|
||||
db.SubRoomHistory.Remove(historyItem);
|
||||
|
||||
db.SubRooms.Remove(subRoom);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Deleted {subRoom.Name}!");
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/assetbundle")]
|
||||
public async Task<IActionResult> BindAssetBundle([FromQuery] string key, long roomId, long subRoomId,
|
||||
[FromBody] string assetId)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
if (!ModelState.IsValid || !RadicadeServer.API.Studio.StudioUnityAssetRegistry.IsValidAssetId(assetId))
|
||||
return BadRequest("Expected a 40-character Unity asset ID.");
|
||||
|
||||
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == roomId && x.SubRoomId == subRoomId);
|
||||
if (subRoom == null) return NotFound("Subroom not found in this room.");
|
||||
|
||||
|
||||
|
||||
if (!System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, assetId + ".assetbundle")))
|
||||
return BadRequest("Upload the bundle as <assetId>.assetbundle before linking it.");
|
||||
if (System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, assetId)))
|
||||
return BadRequest("This ID belongs to a Studio manifest; choose an ID for the archived bundle.");
|
||||
|
||||
var previousAssetId = subRoom.UnityAssetId;
|
||||
subRoom.UnityAssetId = assetId;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { RoomId = roomId, SubRoomId = subRoomId, PreviousUnityAssetId = previousAssetId, UnityAssetId = assetId });
|
||||
}
|
||||
|
||||
[HttpPost("upload/datablob")]
|
||||
[RequestSizeLimit(256L * 1024 * 1024)]
|
||||
public async Task<IActionResult> UploadDatablob([FromQuery] string key, [FromQuery] string name, [FromForm] IFormFile file)
|
||||
{
|
||||
|
||||
if (!ModelState.IsValid) return BadRequest("Invalid upload request. Use a multipart form no larger than 256 MiB.");
|
||||
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var path = Path.Join(DBConfig.CDNStorage, name);
|
||||
|
||||
using (var stream = new FileStream(path, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
return Ok($"Uploaded {name}!\nURL: {DBConfig.ServerUrl}/cdn/room/{name}");
|
||||
}
|
||||
|
||||
[HttpPost("upload/image")]
|
||||
public async Task<IActionResult> UploadImage([FromQuery] string key, [FromQuery] long roomId, [FromForm] IFormFile file)
|
||||
{
|
||||
|
||||
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 (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 (string.IsNullOrEmpty(ext) || !allowed.Contains(ext))
|
||||
return BadRequest($"Unsupported image format '{(string.IsNullOrEmpty(ext) ? "no extension" : ext)}'. Use png/jpg/jpeg/gif/webp.");
|
||||
|
||||
string guid = Guid.NewGuid().ToString();
|
||||
var path = Path.Join(DBConfig.CDNImages, guid + ext);
|
||||
|
||||
using (var stream = new FileStream(path, FileMode.Create))
|
||||
{
|
||||
await file.CopyToAsync(stream);
|
||||
}
|
||||
|
||||
room.ImageName = guid + ext;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Uploaded {guid}{ext}!\nURL: {DBConfig.ServerUrl}/images/img/{guid}{ext}");
|
||||
}
|
||||
|
||||
[HttpGet("set/image")]
|
||||
public async Task<IActionResult> SetImage([FromQuery] string key, [FromQuery] long roomId, [FromQuery] string imgName)
|
||||
{
|
||||
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.");
|
||||
|
||||
|
||||
imgName = Path.GetFileName(imgName ?? "").Trim();
|
||||
|
||||
|
||||
room.ImageName = imgName;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Set to {imgName}!\nURL: {DBConfig.ServerUrl}/images/img/{imgName}");
|
||||
}
|
||||
|
||||
[HttpGet("set/role/{roleType}")]
|
||||
public async Task<IActionResult> SetRoleOfUser([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long target, RoleType roleType)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var existingRole = await db.RoomRoles.FirstOrDefaultAsync(x => x.AccountId == target && x.RoomId == roomId);
|
||||
|
||||
if (existingRole == null)
|
||||
{
|
||||
var newRole = new RoomRole
|
||||
{
|
||||
AccountId = target,
|
||||
InvitedRole = RoleType.None,
|
||||
Role = roleType,
|
||||
RoomId = roomId
|
||||
};
|
||||
|
||||
await db.RoomRoles.AddAsync(newRole);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (existingRole.Role == roleType)
|
||||
return Ok($"Player already has {Enum.GetName(roleType)}.");
|
||||
|
||||
existingRole.Role = roleType;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
var updatedRoom = await RoomUtils.GetRoom(roomId);
|
||||
if (updatedRoom != null)
|
||||
await Notify.SendNotify(target, new Notification { Id = "RoomUpdate", Msg = updatedRoom });
|
||||
}
|
||||
catch (Exception) { }
|
||||
|
||||
return Ok($"Set to {Enum.GetName(roleType)}!");
|
||||
}
|
||||
|
||||
[HttpGet("set/rro")]
|
||||
public async Task<IActionResult> SetRRO([FromQuery] string key, [FromQuery] long roomId)
|
||||
{
|
||||
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.");
|
||||
|
||||
room.IsRRO = true;
|
||||
var tags = StringTools.ReadTags(room.Tags);
|
||||
if (tags.FirstOrDefault(x => x.Tag == "rro" && x.Type == TagType.Auto) == null)
|
||||
tags.Add(new ItemTag { Type = TagType.Auto, Tag = "rro" });
|
||||
room.Tags = StringTools.CreateTagString(tags);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Set to RRO {room.Name}!");
|
||||
}
|
||||
|
||||
[HttpGet("remove/rro")]
|
||||
public async Task<IActionResult> RemoveRRO([FromQuery] string key, [FromQuery] long roomId)
|
||||
{
|
||||
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.");
|
||||
|
||||
room.IsRRO = false;
|
||||
var tags = StringTools.ReadTags(room.Tags);
|
||||
var existingRROTag = tags.FirstOrDefault(x => x.Tag == "rro" && x.Type == TagType.Auto);
|
||||
if (existingRROTag != null)
|
||||
tags.Remove(existingRROTag);
|
||||
room.Tags = StringTools.CreateTagString(tags);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Removed RRO from {room.Name}!");
|
||||
}
|
||||
|
||||
[HttpGet("fulldelete/room")]
|
||||
public async Task<IActionResult> FullyDeleteRoom([FromQuery] string key, [FromQuery] long roomId)
|
||||
{
|
||||
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 (room.IsRRO || room.IsDorm || room.CreatorAccountId == 1) return Ok("You cannot delete RROs.");
|
||||
|
||||
var roles = await db.RoomRoles.Where(x => x.RoomId == roomId).ToListAsync();
|
||||
foreach (var role in roles)
|
||||
{
|
||||
db.RoomRoles.Remove(role);
|
||||
}
|
||||
|
||||
var interactions = await db.RoomInteractions.Where(x => x.RoomId == roomId).ToListAsync();
|
||||
foreach (var i in interactions)
|
||||
{
|
||||
db.RoomInteractions.Remove(i);
|
||||
}
|
||||
|
||||
var keys = await db.RoomKeys.Where(x => x.RoomId == roomId).ToListAsync();
|
||||
foreach (var k in keys)
|
||||
{
|
||||
db.RoomKeys.Remove(k);
|
||||
}
|
||||
|
||||
var stats = await db.RoomStats.FirstOrDefaultAsync(x => x.RoomId == roomId);
|
||||
if (stats != null)
|
||||
db.RoomStats.Remove(stats);
|
||||
|
||||
var subrooms = await db.SubRooms.Where(x => x.RoomId == roomId).ToListAsync();
|
||||
foreach (var subroom in subrooms)
|
||||
{
|
||||
db.SubRooms.Remove(subroom);
|
||||
}
|
||||
|
||||
var prevSubrooms = await db.SubRoomHistory.Where(x => x.RoomId == roomId).ToListAsync();
|
||||
foreach (var subroom in prevSubrooms)
|
||||
{
|
||||
db.SubRoomHistory.Remove(subroom);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok("Deleted.");
|
||||
}
|
||||
|
||||
[HttpGet("set/owner")]
|
||||
public async Task<IActionResult> SetRoomOwner([FromQuery] string key, [FromQuery] long roomId, [FromQuery] long target)
|
||||
{
|
||||
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.");
|
||||
|
||||
long oldOwnerId = room.CreatorAccountId;
|
||||
room.CreatorAccountId = target;
|
||||
|
||||
var existingRole = await db.RoomRoles.FirstOrDefaultAsync(x => x.AccountId == target && x.RoomId == roomId);
|
||||
|
||||
if (existingRole == null)
|
||||
{
|
||||
var newRole = new RoomRole
|
||||
{
|
||||
AccountId = target,
|
||||
InvitedRole = RoleType.None,
|
||||
Role = RoleType.Creator,
|
||||
RoomId = roomId
|
||||
};
|
||||
|
||||
await db.RoomRoles.AddAsync(newRole);
|
||||
}
|
||||
else
|
||||
{
|
||||
existingRole.Role = RoleType.Creator;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (oldOwnerId != target)
|
||||
{
|
||||
var oldOwnerRole = await db.RoomRoles.FirstOrDefaultAsync(x => x.AccountId == oldOwnerId && x.RoomId == roomId);
|
||||
if (oldOwnerRole != null && oldOwnerRole.Role == RoleType.Creator)
|
||||
oldOwnerRole.Role = RoleType.CoOwner;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Set to owner!");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpGet("instances/live")]
|
||||
public async Task<IActionResult> GetLiveRoomInstances([FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var instances = await db.RoomInstances.ToListAsync();
|
||||
var roomIds = instances.Select(i => i.roomId).Distinct().ToList();
|
||||
var rooms = roomIds.Count > 0
|
||||
? await db.Rooms.Where(r => roomIds.Contains(r.RoomId)).ToDictionaryAsync(r => r.RoomId)
|
||||
: new Dictionary<long, RoomData>();
|
||||
|
||||
|
||||
var presences = await db.Presences.Where(p => p.isOnline && p.roomInstance != null).ToListAsync();
|
||||
var profileIds = presences.Select(p => p.playerId).Distinct().ToList();
|
||||
var profiles = profileIds.Count > 0
|
||||
? await db.Profiles.Where(x => profileIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId)
|
||||
: new Dictionary<long, RadicadeServer.RecNet.Auth.Profile>();
|
||||
|
||||
var result = instances.Select(instance =>
|
||||
{
|
||||
var players = presences
|
||||
.Where(p => p.roomInstance == instance.roomInstanceId)
|
||||
.Select(p => new
|
||||
{
|
||||
player_id = p.playerId,
|
||||
name = profiles.TryGetValue(p.playerId, out var profile) ? profile.displayName : $"Player {p.playerId}"
|
||||
})
|
||||
.OrderBy(p => p.player_id)
|
||||
.ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
instance_id = instance.roomInstanceId,
|
||||
room_id = instance.roomId,
|
||||
room_name = rooms.TryGetValue(instance.roomId, out var room) ? room.Name : "Unknown Room",
|
||||
sub_room_id = instance.subRoomId,
|
||||
event_id = instance.eventId,
|
||||
instance_type = instance.roomInstanceType.ToString(),
|
||||
location = instance.location ?? "",
|
||||
room_code = instance.roomCode ?? "",
|
||||
photon_room_id = instance.photonRoomId,
|
||||
is_private = instance.isPrivate,
|
||||
is_in_progress = instance.isInProgress,
|
||||
is_full = instance.isFull,
|
||||
max_capacity = instance.maxCapacity,
|
||||
player_count = players.Count,
|
||||
players
|
||||
};
|
||||
})
|
||||
.OrderByDescending(i => i.player_count)
|
||||
.ToList();
|
||||
|
||||
return Ok(new { count = result.Count, instances = result });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpGet("instances/{instanceId:long}")]
|
||||
public async Task<IActionResult> GetLiveInstanceDetail(long instanceId, [FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var instance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == instanceId);
|
||||
if (instance == null) return NotFound("Instance not found.");
|
||||
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(x => x.RoomId == instance.roomId);
|
||||
var subrooms = await db.SubRooms.Where(s => s.RoomId == instance.roomId).ToListAsync();
|
||||
|
||||
var presences = await db.Presences.Where(p => p.isOnline && p.roomInstance == instanceId).ToListAsync();
|
||||
var profileIds = presences.Select(p => p.playerId).Distinct().ToList();
|
||||
var profiles = profileIds.Count > 0
|
||||
? await db.Profiles.Where(x => profileIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId)
|
||||
: new Dictionary<long, Profile>();
|
||||
var playerData = profileIds.Count > 0
|
||||
? await db.Players.Where(x => profileIds.Contains(x.AccountId)).ToDictionaryAsync(x => x.AccountId)
|
||||
: new Dictionary<long, PlayerData>();
|
||||
var progression = profileIds.Count > 0
|
||||
? await db.Progression.Where(x => profileIds.Contains(x.PlayerId)).ToDictionaryAsync(x => x.PlayerId)
|
||||
: new Dictionary<long, ProgressionData>();
|
||||
|
||||
var playerRows = presences.Select(p =>
|
||||
{
|
||||
profiles.TryGetValue(p.playerId, out var profile);
|
||||
playerData.TryGetValue(p.playerId, out var player);
|
||||
progression.TryGetValue(p.playerId, out var progress);
|
||||
return new
|
||||
{
|
||||
player_id = p.playerId,
|
||||
name = profile?.displayName ?? $"Player {p.playerId}",
|
||||
username = profile?.username ?? "",
|
||||
level = progress?.Level ?? 0,
|
||||
device_class = p.deviceClass.ToString(),
|
||||
is_online = p.isOnline
|
||||
};
|
||||
}).OrderBy(p => p.player_id).ToList();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
instance = new
|
||||
{
|
||||
instance_id = instance.roomInstanceId,
|
||||
room_id = instance.roomId,
|
||||
room_name = room?.Name ?? "Unknown Room",
|
||||
sub_room_id = instance.subRoomId,
|
||||
instance_type = instance.roomInstanceType.ToString(),
|
||||
location = instance.location ?? "",
|
||||
room_code = instance.roomCode ?? "",
|
||||
is_private = instance.isPrivate,
|
||||
is_in_progress = instance.isInProgress,
|
||||
is_full = instance.isFull,
|
||||
max_capacity = instance.maxCapacity,
|
||||
player_count = playerRows.Count
|
||||
},
|
||||
players = playerRows,
|
||||
subrooms = subrooms.Select(s => new
|
||||
{
|
||||
sub_room_id = s.SubRoomId,
|
||||
name = s.Name,
|
||||
max_players = s.MaxPlayers
|
||||
}).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("instances/{instanceId:long}/kick")]
|
||||
public async Task<IActionResult> KickFromInstance(long instanceId, [FromQuery] string key, [FromQuery] long playerId, [FromQuery] string? reason = null)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var instance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == instanceId);
|
||||
if (instance == null) return NotFound("Instance not found.");
|
||||
|
||||
var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId);
|
||||
if (presence == null || presence.roomInstance != instanceId) return NotFound("Player is not in this instance.");
|
||||
|
||||
|
||||
var dorm = await RoomUtils.GetOrCreateDorm(playerId);
|
||||
RoomInstance? dormInstance = null;
|
||||
if (dorm != null)
|
||||
dormInstance = await MatchmakingUtils.CreateRoomInstance(dorm.RoomId, true, null, null, null);
|
||||
if (dormInstance == null) return StatusCode(500, "Could not resolve a dorm instance for this player.");
|
||||
|
||||
presence.roomInstance = dormInstance.roomInstanceId;
|
||||
presence.errorCode = JoinStatus.Success;
|
||||
presence.isOnline = true;
|
||||
MatchmakingUtils.MarkInRoom(playerId);
|
||||
|
||||
await RecomputeInstanceCapacity(instance);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
await Notify.SendNotify(playerId, new Notification
|
||||
{
|
||||
Id = NotificationType.ModerationKick,
|
||||
Msg = PlayerUtils.BuildBlockDetails(false, ReportCategory.Misc, reason ?? "Kicked from room", null, isHostKick: true)
|
||||
});
|
||||
}
|
||||
catch (Exception) { }
|
||||
|
||||
try
|
||||
{
|
||||
await Notify.SendNotify(playerId, new Notification
|
||||
{
|
||||
Id = "PresenceUpdate",
|
||||
Msg = await Presence.CreateAsync(db, presence, dormInstance)
|
||||
});
|
||||
}
|
||||
catch (Exception) { }
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
|
||||
return Ok(new
|
||||
{
|
||||
player_id = playerId,
|
||||
name = profile?.displayName ?? $"Player {playerId}",
|
||||
kicked = true,
|
||||
instance_id = instanceId
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("instances/{instanceId:long}/move")]
|
||||
public async Task<IActionResult> MovePlayerToSubRoom(long instanceId, [FromQuery] string key, [FromQuery] long playerId, [FromQuery] long toSubRoomId)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var instance = await db.RoomInstances.FirstOrDefaultAsync(x => x.roomInstanceId == instanceId);
|
||||
if (instance == null) return NotFound("Instance not found.");
|
||||
|
||||
var presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == playerId);
|
||||
if (presence == null || presence.roomInstance != instanceId) return NotFound("Player is not in this instance.");
|
||||
|
||||
|
||||
|
||||
var subRoom = await db.SubRooms.FirstOrDefaultAsync(x => x.RoomId == instance.roomId && x.SubRoomId == toSubRoomId);
|
||||
if (subRoom == null) return BadRequest("Target subroom does not belong to this room.");
|
||||
if (instance.subRoomId == toSubRoomId)
|
||||
return Ok(new { player_id = playerId, already_in_subroom = true, instance_id = instanceId });
|
||||
|
||||
|
||||
|
||||
var targetInstance = await db.RoomInstances
|
||||
.FirstOrDefaultAsync(x => x.roomId == instance.roomId && x.subRoomId == toSubRoomId && !x.isFull);
|
||||
bool created = false;
|
||||
if (targetInstance == null)
|
||||
{
|
||||
targetInstance = await MatchmakingUtils.CreateRoomInstance(instance.roomId, instance.isPrivate, toSubRoomId, instance.clubId, instance.eventId);
|
||||
if (targetInstance == null) return StatusCode(500, "Could not create the target subroom instance.");
|
||||
|
||||
|
||||
db.RoomInstances.Attach(targetInstance);
|
||||
created = true;
|
||||
}
|
||||
|
||||
presence.roomInstance = targetInstance.roomInstanceId;
|
||||
presence.errorCode = JoinStatus.Success;
|
||||
presence.isOnline = true;
|
||||
MatchmakingUtils.MarkInRoom(playerId);
|
||||
|
||||
await RecomputeInstanceCapacity(instance);
|
||||
await RecomputeInstanceCapacity(targetInstance);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await Notify.SendNotify(playerId, new Notification
|
||||
{
|
||||
Id = "PresenceUpdate",
|
||||
Msg = await Presence.CreateAsync(db, presence, targetInstance)
|
||||
});
|
||||
}
|
||||
catch (Exception) { }
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
|
||||
return Ok(new
|
||||
{
|
||||
player_id = playerId,
|
||||
name = profile?.displayName ?? $"Player {playerId}",
|
||||
instance_id = targetInstance.roomInstanceId,
|
||||
sub_room_id = toSubRoomId,
|
||||
created
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private async Task RecomputeInstanceCapacity(RoomInstance instance)
|
||||
{
|
||||
var count = await MatchmakingUtils.GetPlayerCountInInstance(instance.roomInstanceId);
|
||||
instance.isFull = count >= instance.maxCapacity;
|
||||
}
|
||||
|
||||
[HttpGet("roles")]
|
||||
public async Task<IActionResult> GetRoomRoles([FromQuery] string key, [FromQuery] long roomId)
|
||||
{
|
||||
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 roles = await db.RoomRoles.Where(x => x.RoomId == roomId).ToListAsync();
|
||||
var accountIds = roles.Select(r => r.AccountId).Distinct().ToList();
|
||||
var profiles = accountIds.Count > 0
|
||||
? await db.Profiles.Where(x => accountIds.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId)
|
||||
: new Dictionary<long, RadicadeServer.RecNet.Auth.Profile>();
|
||||
|
||||
string? NameOf(long id) => profiles.TryGetValue(id, out var p)
|
||||
? $"{p.displayName} (@{p.username})"
|
||||
: id.ToString();
|
||||
|
||||
var result = roles.Select(r => new
|
||||
{
|
||||
account_id = r.AccountId,
|
||||
name = NameOf(r.AccountId),
|
||||
role = r.Role.ToString(),
|
||||
role_value = (int)r.Role,
|
||||
invited_role = r.InvitedRole.ToString(),
|
||||
is_creator = r.AccountId == room.CreatorAccountId || r.Role == RoleType.Creator
|
||||
}).ToList();
|
||||
|
||||
|
||||
if (!result.Any(x => x.account_id == room.CreatorAccountId))
|
||||
{
|
||||
result.Insert(0, new
|
||||
{
|
||||
account_id = room.CreatorAccountId,
|
||||
name = NameOf(room.CreatorAccountId),
|
||||
role = "Creator",
|
||||
role_value = (int)RoleType.Creator,
|
||||
invited_role = "None",
|
||||
is_creator = true
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { room_id = roomId, creator_account_id = room.CreatorAccountId, roles = result });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpGet("room/{roomId}/bans")]
|
||||
public async Task<IActionResult> GetRoomBans([FromQuery] string key, long roomId)
|
||||
{
|
||||
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 bans = await db.RoomBans
|
||||
.Where(x => x.roomId == roomId && (!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow))
|
||||
.OrderByDescending(x => x.bannedAt)
|
||||
.ToListAsync();
|
||||
|
||||
var ids = bans.Select(b => b.bannedPlayerId).Concat(bans.Select(b => b.actorId)).Distinct().ToList();
|
||||
var profiles = ids.Count > 0
|
||||
? await db.Profiles.AsNoTracking().Where(x => ids.Contains(x.accountId)).ToDictionaryAsync(x => x.accountId)
|
||||
: new Dictionary<long, Profile>();
|
||||
|
||||
string? NameOf(long id) => profiles.TryGetValue(id, out var p) ? p.displayName : null;
|
||||
|
||||
var result = bans.Select(b => new
|
||||
{
|
||||
ban_id = b.BanId,
|
||||
room_id = b.roomId,
|
||||
player_id = b.bannedPlayerId,
|
||||
player_name = NameOf(b.bannedPlayerId),
|
||||
banned_by_id = b.actorId,
|
||||
banned_by_name = string.IsNullOrWhiteSpace(b.bannedByName) ? (NameOf(b.actorId) ?? $"Player {b.actorId}") : b.bannedByName,
|
||||
reason = b.reason,
|
||||
banned_at = b.bannedAt,
|
||||
expires_at = b.expiresAt
|
||||
}).ToList();
|
||||
|
||||
return Ok(new { room_id = roomId, room_name = room.Name, count = result.Count, bans = result });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("room/{roomId}/bans")]
|
||||
public async Task<IActionResult> BanPlayerFromRoom([FromQuery] string key, long roomId, [FromBody] JsonNode body)
|
||||
{
|
||||
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.");
|
||||
|
||||
long playerId = body["player_id"]?.GetValue<long>() ?? 0;
|
||||
if (playerId <= 0) return BadRequest("player_id is required.");
|
||||
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == playerId);
|
||||
if (profile == null) return NotFound("Player does not exist.");
|
||||
|
||||
var existing = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == roomId && x.bannedPlayerId == playerId &&
|
||||
(!x.expiresAt.HasValue || x.expiresAt.Value > DateTime.UtcNow));
|
||||
if (existing != null) return Ok($"Player is already banned from this room.");
|
||||
|
||||
var reason = body["reason"]?.GetValue<string>()?.Trim() ?? "";
|
||||
var ban = new RoomBan
|
||||
{
|
||||
roomId = roomId,
|
||||
actorId = 1,
|
||||
bannedPlayerId = playerId,
|
||||
bannedByName = "Admin",
|
||||
reason = reason,
|
||||
bannedAt = DateTime.UtcNow,
|
||||
expiresAt = null
|
||||
};
|
||||
|
||||
await db.RoomBans.AddAsync(ban);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
await MatchmakingUtils.KickPlayerFromRoom(playerId, roomId,
|
||||
string.IsNullOrWhiteSpace(reason) ? "You were banned from this room." : $"You were banned from this room: {reason}");
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
ban_id = ban.BanId,
|
||||
room_id = roomId,
|
||||
player_id = playerId,
|
||||
player_name = profile.displayName,
|
||||
reason = reason,
|
||||
banned_at = ban.bannedAt
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpDelete("room/{roomId}/bans/{playerId}")]
|
||||
public async Task<IActionResult> UnbanPlayerFromRoom([FromQuery] string key, long roomId, long playerId)
|
||||
{
|
||||
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 ban = await db.RoomBans.FirstOrDefaultAsync(x => x.roomId == roomId && x.bannedPlayerId == playerId);
|
||||
if (ban == null) return NotFound("Player is not banned from this room.");
|
||||
|
||||
db.RoomBans.Remove(ban);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok($"Ban lifted for player {playerId}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace RadicadeServer.Utils
|
||||
{
|
||||
public static class AdminRuntime
|
||||
{
|
||||
public static readonly DateTime ServerStartedAtUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.Admin
|
||||
{
|
||||
[Route("admin/status")]
|
||||
[EnableCors("AdminPanel")]
|
||||
public class AdminStatusAPI(RecDBContext db) : Controller
|
||||
{
|
||||
private Task<IActionResult?> RejectInvalidKey(string key)
|
||||
{
|
||||
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
|
||||
}
|
||||
|
||||
[HttpGet("players/active")]
|
||||
public async Task<IActionResult> GetActivePlayerCount([FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var activePlayerCount = await db.Presences.CountAsync(x => x.isOnline);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
activePlayerCount
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("")]
|
||||
public async Task<IActionResult> GetServerStatus([FromQuery] string key)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var activePlayerCount = await db.Presences.CountAsync(x => x.isOnline);
|
||||
var playersInRoomsCount = await db.Presences.CountAsync(x => x.isOnline && x.roomInstance != null);
|
||||
var activeRoomInstanceCount = await db.RoomInstances.CountAsync();
|
||||
var gameVersion = await db.ServerConfig
|
||||
.Where(x => x.Key == "GameVersion")
|
||||
.Select(x => x.Value)
|
||||
.FirstOrDefaultAsync() ?? DBConfig.GameVersion;
|
||||
var barrelVersion = await db.ServerConfig
|
||||
.Where(x => x.Key == "BarrelVersion")
|
||||
.Select(x => x.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
var barrelKey = await db.ServerConfig
|
||||
.Where(x => x.Key == "BarrelKey")
|
||||
.Select(x => x.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
var barrelCheck = await db.ServerConfig
|
||||
.Where(x => x.Key == "BarrelCheck")
|
||||
.Select(x => x.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
var config = await db.Config.FirstOrDefaultAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
status = "online",
|
||||
serverUrl = DBConfig.ServerUrl,
|
||||
activePlayerCount,
|
||||
playersInRoomsCount,
|
||||
activeRoomInstanceCount,
|
||||
gameVersion,
|
||||
barrelVersion,
|
||||
barrelKey,
|
||||
barrelCheck,
|
||||
maintenance = config?.ServerMaintenance,
|
||||
startedAtUtc = AdminRuntime.ServerStartedAtUtc,
|
||||
uptimeSeconds = (long)(DateTime.UtcNow - AdminRuntime.ServerStartedAtUtc).TotalSeconds
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet.Store;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.Admin;
|
||||
|
||||
[Route("admin/store")]
|
||||
[EnableCors("AdminPanel")]
|
||||
public class AdminStorefrontsAPI(RecDBContext db) : Controller
|
||||
{
|
||||
private Task<IActionResult?> RejectInvalidKey(string key)
|
||||
{
|
||||
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
|
||||
}
|
||||
|
||||
[HttpGet("{key}/itemset/{itemSetId}")]
|
||||
public async Task<IActionResult> GetItemSet(string key, long itemSetId)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
return Ok("Item sets not implemented for GiftDropDB.");
|
||||
}
|
||||
|
||||
[HttpGet("{key}/gift/{giftId}")]
|
||||
public async Task<IActionResult> GetGift(string key, long giftId)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var gift = await db.GiftItems.FirstOrDefaultAsync(x => x.GiftDropId == giftId);
|
||||
if (gift == null) return Ok("Not found.");
|
||||
|
||||
return Ok(gift);
|
||||
}
|
||||
|
||||
[HttpGet("{key}/gift/{giftId}/set")]
|
||||
public async Task<ActionResult> SetGift()
|
||||
{
|
||||
return Ok("todo.");
|
||||
}
|
||||
|
||||
[HttpGet("{key}/gift/new")]
|
||||
public async Task<ActionResult> CreateGift()
|
||||
{
|
||||
return Ok("todo.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using RadicadeServer.DB;
|
||||
|
||||
namespace RadicadeServer.Utils
|
||||
{
|
||||
public static class AdminUtils
|
||||
{
|
||||
/// <summary>
|
||||
/// The ceiling every admin upload is held to, on the request and on the multipart form.
|
||||
///
|
||||
/// Kestrel's own default is 30 MB, and no room import fits under it: an .assetbundle or
|
||||
/// a .room save routinely runs to tens or hundreds of megabytes. A route that does not
|
||||
/// raise the limit has its connection killed part-way through the body, so the panel sees
|
||||
/// a broken connection instead of an answer - which is what made importing a large room
|
||||
/// look like an authentication failure. Named once here so the next upload route cannot
|
||||
/// quietly forget it.
|
||||
/// </summary>
|
||||
public const long MaxUploadBytes = 256L * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// A room file being assembled from parts lives beside its final name under this suffix
|
||||
/// until the last part lands. Nothing serves or lists one, so a half-imported bundle can
|
||||
/// never be fetched by the client, and a name ending in this suffix is refused outright.
|
||||
/// </summary>
|
||||
public const string PartSuffix = ".uploading";
|
||||
|
||||
/// The multipart ceiling this server answers with, in the words the panel shows an admin.
|
||||
public const string MaxUploadLabel = "256 MiB";
|
||||
|
||||
public static bool IsUploadPart(string? name) =>
|
||||
!string.IsNullOrEmpty(name) && name.EndsWith(PartSuffix, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool SecretMatches(string? given, string? stored)
|
||||
{
|
||||
if (string.IsNullOrEmpty(given) || string.IsNullOrEmpty(stored))
|
||||
return false;
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(given),
|
||||
Encoding.UTF8.GetBytes(stored));
|
||||
}
|
||||
|
||||
public static bool IsAdminKey(this string? key)
|
||||
{
|
||||
return SecretMatches(key, DBConfig.AdminApiKey);
|
||||
}
|
||||
|
||||
public static bool IsSupportKey(this string? key)
|
||||
{
|
||||
return SecretMatches(key, DBConfig.AdminApiKey)
|
||||
|| SecretMatches(key, DBConfig.SupportApiKey);
|
||||
}
|
||||
|
||||
public static bool TryResolveUploadName(string? requested, out string? name, out string? error)
|
||||
{
|
||||
name = null;
|
||||
error = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requested)) return true;
|
||||
|
||||
var trimmed = requested.Trim();
|
||||
|
||||
if (trimmed.Length > 128)
|
||||
{
|
||||
error = "Name is too long.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trimmed != Path.GetFileName(trimmed) || trimmed.Contains(".."))
|
||||
{
|
||||
error = "Name must be a bare file name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var c in trimmed)
|
||||
{
|
||||
if (!char.IsLetterOrDigit(c) && c != '-' && c != '_' && c != '.')
|
||||
{
|
||||
error = "Name may only contain letters, digits, '-', '_' and '.'.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
name = trimmed;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// A subroom's Unity bundle is served under the name it is stored as
|
||||
/// (RoomServerAPI's unity_assets route hands the client that file name, and
|
||||
/// the client then fetches it from the CDN by name), so binding accepts any
|
||||
/// uploaded '<name>.assetbundle'. That is deliberately wider than
|
||||
/// StudioUnityAssetRegistry.IsValidAssetId, which only covers the
|
||||
/// 40-character manifest ids the Studio pipeline mints.
|
||||
public static bool TryResolveBundleName(string? requested, out string? name, out string? error)
|
||||
{
|
||||
if (!TryResolveUploadName(requested, out name, out error)) return false;
|
||||
|
||||
if (name == null)
|
||||
{
|
||||
error = "A bundle file name is required.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!name.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
error = $"'{name}' is not a .assetbundle file.";
|
||||
name = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, name)))
|
||||
{
|
||||
error = $"No uploaded bundle named '{name}'.";
|
||||
name = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet.Config;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.Admin
|
||||
{
|
||||
[Route("admin/version")]
|
||||
[EnableCors("AdminPanel")]
|
||||
public class AdminVersionAPI(RecDBContext db) : Controller
|
||||
{
|
||||
private Task<IActionResult?> RejectInvalidKey(string key)
|
||||
{
|
||||
return Task.FromResult<IActionResult?>(key.IsAdminKey() ? null : Unauthorized("Invalid secret"));
|
||||
}
|
||||
|
||||
private async Task<IActionResult> SetServerConfigValue(string keyName, string key, string value)
|
||||
{
|
||||
var rejection = await RejectInvalidKey(key);
|
||||
if (rejection != null) return rejection;
|
||||
|
||||
var existingValue = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == keyName);
|
||||
|
||||
if (existingValue != null)
|
||||
{
|
||||
existingValue.Value = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.ServerConfig.AddAsync(new ServerConfigVal { Key = keyName, Value = value });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok("Set to: " + value);
|
||||
}
|
||||
|
||||
[HttpGet("setgameversion")]
|
||||
public async Task<IActionResult> SetGameVersion([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("GameVersion", key, value);
|
||||
}
|
||||
[HttpGet("setversion")]
|
||||
public async Task<IActionResult> SetVersion([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("BarrelVersion", key, value);
|
||||
}
|
||||
[HttpGet("setkey")]
|
||||
public async Task<IActionResult> SetKey([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("BarrelKey", key, value);
|
||||
}
|
||||
[HttpGet("setcheck")]
|
||||
public async Task<IActionResult> SetCheck([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("BarrelCheck", key, value);
|
||||
}
|
||||
[HttpGet("setmanifestversion")]
|
||||
public async Task<IActionResult> SetManifestVersion([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("ApprovedManifestVersion", key, value);
|
||||
}
|
||||
[HttpGet("setmanifestsha256")]
|
||||
public async Task<IActionResult> SetManifestSha256([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("ApprovedManifestSha256", key, value);
|
||||
}
|
||||
[HttpGet("setgamedownloadurl")]
|
||||
public async Task<IActionResult> SetGameDownloadUrl([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("GameDownloadUrl", key, value);
|
||||
}
|
||||
[HttpGet("setlaunchticket")]
|
||||
public async Task<IActionResult> SetLaunchTicket([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("LaunchTicket", key, value);
|
||||
}
|
||||
[HttpGet("setlaunchticketrequired")]
|
||||
public async Task<IActionResult> SetLaunchTicketRequired([FromQuery] string key, [FromQuery] bool value)
|
||||
{
|
||||
return await SetServerConfigValue("LaunchTicketRequired", key, value.ToString());
|
||||
}
|
||||
[HttpGet("setlaunchticketurl")]
|
||||
public async Task<IActionResult> SetLaunchTicketUrl([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("LaunchTicketUrl", key, value);
|
||||
}
|
||||
[HttpGet("setlaunchticketaudience")]
|
||||
public async Task<IActionResult> SetLaunchTicketAudience([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("LaunchTicketAudience", key, value);
|
||||
}
|
||||
[HttpGet("setlaunchticketnonce")]
|
||||
public async Task<IActionResult> SetLaunchTicketNonce([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("LaunchTicketNonce", key, value);
|
||||
}
|
||||
[HttpGet("setgamepackagesha256")]
|
||||
public async Task<IActionResult> SetGamePackageSha256([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("GamePackageSha256", key, value);
|
||||
}
|
||||
[HttpGet("setgamepackagesize")]
|
||||
public async Task<IActionResult> SetGamePackageSize([FromQuery] string key, [FromQuery] long value)
|
||||
{
|
||||
return await SetServerConfigValue("GamePackageSize", key, value.ToString());
|
||||
}
|
||||
[HttpGet("setgameextractsubdirectory")]
|
||||
public async Task<IActionResult> SetGameExtractSubdirectory([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("GameExtractSubdirectory", key, value);
|
||||
}
|
||||
[HttpGet("setlaunchermanifestnotes")]
|
||||
public async Task<IActionResult> SetLauncherManifestNotes([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("LauncherManifestNotes", key, value);
|
||||
}
|
||||
[HttpGet("setlaunchermanifestdelete")]
|
||||
public async Task<IActionResult> SetLauncherManifestDelete([FromQuery] string key, [FromQuery] string value)
|
||||
{
|
||||
return await SetServerConfigValue("LauncherManifestDelete", key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.API.Notifications;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Messages;
|
||||
using RadicadeServer.RecNet.Rooms;
|
||||
|
||||
namespace RadicadeServer.Admin;
|
||||
|
||||
public static class RoomBanStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
private static readonly object FileLock = new();
|
||||
|
||||
private const long CoachAccountId = 1;
|
||||
|
||||
private static string ClosedMessage(string roomName, string reason) =>
|
||||
$"We have closed the room '{roomName}' for: {reason}. We encourage creators to review our Creator Code of Conduct to help make Radicade a fun and safe place for everyone.";
|
||||
|
||||
private static string RestoredMessage(string roomName) =>
|
||||
$"We have restored the room '{roomName}' after a thorough review of the previous accusations. Thank you for continuing to follow the Creator Code of Conduct, and making Radicade a better place.";
|
||||
|
||||
public static string FilePath => Path.Join(DBConfig.PersistentLogs, "banned-rooms.json");
|
||||
|
||||
public static List<BannedRoom> Load()
|
||||
{
|
||||
lock (FileLock) return LoadLocked();
|
||||
}
|
||||
|
||||
public static BannedRoom? Find(long roomId) => Load().FirstOrDefault(x => x.RoomId == roomId);
|
||||
|
||||
public static bool IsBanned(long roomId) => Find(roomId) != null;
|
||||
|
||||
public static async Task<BannedRoom> BanAsync(RecDBContext db, RoomData room, string reason, string bannedBy)
|
||||
{
|
||||
var entry = new BannedRoom
|
||||
{
|
||||
RoomId = room.RoomId,
|
||||
Name = room.Name,
|
||||
Reason = reason,
|
||||
BannedBy = bannedBy,
|
||||
BannedAt = DateTime.UtcNow,
|
||||
PublishedAt = room.PublishedAt,
|
||||
Room = room,
|
||||
SubRooms = await db.SubRooms.AsNoTracking().Where(x => x.RoomId == room.RoomId).ToListAsync(),
|
||||
Roles = await db.RoomRoles.AsNoTracking().Where(x => x.RoomId == room.RoomId).ToListAsync(),
|
||||
Stats = await db.RoomStats.AsNoTracking().FirstOrDefaultAsync(x => x.RoomId == room.RoomId)
|
||||
};
|
||||
|
||||
Upsert(entry);
|
||||
|
||||
db.Rooms.Remove(room);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var why = string.IsNullOrWhiteSpace(reason) ? "a breach of the Creator Code of Conduct" : reason;
|
||||
await TellOwnerAsync(room.CreatorAccountId, ClosedMessage(room.Name, why));
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
public static async Task<bool> RestoreAsync(RecDBContext db, long roomId)
|
||||
{
|
||||
var entry = Find(roomId);
|
||||
if (entry == null) return false;
|
||||
|
||||
await InsertRoomWithItsIdAsync(db, entry.Room, entry.PublishedAt);
|
||||
|
||||
Remove(roomId);
|
||||
|
||||
await TellOwnerAsync(entry.Room.CreatorAccountId, RestoredMessage(entry.Name));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task InsertRoomWithItsIdAsync(RecDBContext db, RoomData room, DateTime publishedAt)
|
||||
{
|
||||
var entity = db.Model.FindEntityType(typeof(RoomData))
|
||||
?? throw new InvalidOperationException("RoomData is not part of the model.");
|
||||
|
||||
var table = entity.GetTableName() ?? nameof(RoomData);
|
||||
var qualified = entity.GetSchema() is { Length: > 0 } schema ? $"[{schema}].[{table}]" : $"[{table}]";
|
||||
|
||||
var columns = new List<string>();
|
||||
var cells = new List<string>();
|
||||
var parameters = new List<object>();
|
||||
foreach (var property in entity.GetProperties())
|
||||
{
|
||||
if (property.IsShadowProperty() || property.PropertyInfo == null) continue;
|
||||
|
||||
var value = property.Name == nameof(RoomData.PublishedAt)
|
||||
? publishedAt
|
||||
: property.PropertyInfo.GetValue(room);
|
||||
|
||||
columns.Add($"[{property.GetColumnName()}]");
|
||||
|
||||
var sqlValue = ToSqlValue(value);
|
||||
if (sqlValue == null)
|
||||
{
|
||||
cells.Add("NULL");
|
||||
}
|
||||
else
|
||||
{
|
||||
cells.Add("{" + parameters.Count + "}");
|
||||
parameters.Add(sqlValue);
|
||||
}
|
||||
}
|
||||
|
||||
var sql =
|
||||
$"SET IDENTITY_INSERT {qualified} ON;\n" +
|
||||
$"INSERT INTO {qualified} ({string.Join(", ", columns)}) VALUES ({string.Join(", ", cells)});\n" +
|
||||
$"SET IDENTITY_INSERT {qualified} OFF;";
|
||||
|
||||
await db.Database.ExecuteSqlRawAsync(sql, parameters);
|
||||
}
|
||||
|
||||
private static async Task TellOwnerAsync(long ownerAccountId, string message)
|
||||
{
|
||||
if (ownerAccountId <= 0) return;
|
||||
|
||||
try
|
||||
{
|
||||
await Notify.SendMessage(ownerAccountId, new Message
|
||||
{
|
||||
Type = MessageType.CoachMessage,
|
||||
Data = message,
|
||||
FromPlayerId = CoachAccountId
|
||||
});
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static object? ToSqlValue(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
Enum enumValue => Convert.ChangeType(enumValue, Enum.GetUnderlyingType(enumValue.GetType())),
|
||||
_ => value
|
||||
};
|
||||
|
||||
private static void Upsert(BannedRoom entry)
|
||||
{
|
||||
lock (FileLock)
|
||||
{
|
||||
var rooms = LoadLocked();
|
||||
rooms.RemoveAll(x => x.RoomId == entry.RoomId);
|
||||
rooms.Add(entry);
|
||||
SaveLocked(rooms);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Remove(long roomId)
|
||||
{
|
||||
lock (FileLock)
|
||||
{
|
||||
var rooms = LoadLocked();
|
||||
if (rooms.RemoveAll(x => x.RoomId == roomId) == 0) return;
|
||||
SaveLocked(rooms);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<BannedRoom> LoadLocked()
|
||||
{
|
||||
if (!File.Exists(FilePath)) return new List<BannedRoom>();
|
||||
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(FilePath);
|
||||
var rooms = JsonSerializer.Deserialize<List<BannedRoom>>(json, JsonOptions) ?? new List<BannedRoom>();
|
||||
|
||||
foreach (var room in rooms) room.Room.PublishedAt = room.PublishedAt;
|
||||
|
||||
return rooms;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new List<BannedRoom>();
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveLocked(List<BannedRoom> rooms)
|
||||
{
|
||||
Directory.CreateDirectory(DBConfig.PersistentLogs);
|
||||
File.WriteAllText(FilePath, JsonSerializer.Serialize(rooms, JsonOptions));
|
||||
}
|
||||
}
|
||||
|
||||
public class BannedRoom
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Reason { get; set; } = "";
|
||||
public string BannedBy { get; set; } = "";
|
||||
public DateTime BannedAt { get; set; }
|
||||
public DateTime PublishedAt { get; set; }
|
||||
|
||||
public RoomData Room { get; set; } = new();
|
||||
public List<SubRoom> SubRooms { get; set; } = new();
|
||||
public List<RoomRole> Roles { get; set; } = new();
|
||||
public RoomStats? Stats { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace RadicadeServer.API.Analytics
|
||||
{
|
||||
[Route("datacollection")]
|
||||
public class DataCollectionAPI : Controller
|
||||
{
|
||||
[HttpPost("data/event")]
|
||||
[HttpPost("data/heartbeat")]
|
||||
public IActionResult Event()
|
||||
{
|
||||
return Ok(new { });
|
||||
}
|
||||
|
||||
[HttpPost("data/events")]
|
||||
public IActionResult Events()
|
||||
{
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
[HttpGet("sampling")]
|
||||
public IActionResult Sampling()
|
||||
{
|
||||
return Ok(new { });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.DB;
|
||||
|
||||
namespace RadicadeServer.API.Logs
|
||||
{
|
||||
[Route("gamelogs")]
|
||||
public class GameLogsAPI : Controller
|
||||
{
|
||||
private const int HeadChars = 400;
|
||||
|
||||
private const long MaxAttachmentBytes = 20 * 1024 * 1024;
|
||||
|
||||
[HttpPut("reports/{id}")]
|
||||
[HttpPost("reports/{id}")]
|
||||
public async Task<IActionResult> UploadReport(string id)
|
||||
{
|
||||
var body = await ReadAsync();
|
||||
|
||||
return Ok(new { ReportId = id, Success = true });
|
||||
}
|
||||
|
||||
[HttpPut("reports/{id}/attachments/{name}")]
|
||||
[HttpPost("reports/{id}/attachments/{name}")]
|
||||
public async Task<IActionResult> UploadAttachment(string id, string name)
|
||||
{
|
||||
var body = await ReadBytesAsync();
|
||||
var path = await StoreAsync(id, name, body);
|
||||
|
||||
return Ok(new { ReportId = id, AttachmentName = name, Size = body.Length, Success = true });
|
||||
}
|
||||
|
||||
[HttpPost("reports")]
|
||||
public async Task<IActionResult> CreateReport()
|
||||
{
|
||||
var body = await ReadAsync();
|
||||
var id = Guid.NewGuid().ToString();
|
||||
|
||||
return Ok(new { ReportId = id, Success = true });
|
||||
}
|
||||
|
||||
private async Task<string> ReadAsync()
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
return await reader.ReadToEndAsync();
|
||||
}
|
||||
|
||||
private async Task<byte[]> ReadBytesAsync()
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[81920];
|
||||
int read;
|
||||
while ((read = await Request.Body.ReadAsync(chunk)) > 0)
|
||||
{
|
||||
buffer.Write(chunk, 0, read);
|
||||
if (buffer.Length >= MaxAttachmentBytes) break;
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private static async Task<string?> StoreAsync(string reportId, string name, byte[] body)
|
||||
{
|
||||
if (body.Length == 0 || body.Length > MaxAttachmentBytes) return null;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(AttachmentFolder);
|
||||
var path = Path.Combine(AttachmentFolder, $"{Sanitize(reportId)}-{Sanitize(name)}");
|
||||
await System.IO.File.WriteAllBytesAsync(path, body);
|
||||
return path;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string AttachmentFolder => Path.Combine(DBConfig.PrivateData, "BugReports");
|
||||
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
var clean = new string((value ?? string.Empty).Where(c => !Path.GetInvalidFileNameChars().Contains(c)).ToArray());
|
||||
return clean.Length == 0 ? "upload" : clean;
|
||||
}
|
||||
|
||||
private static string Summarize(byte[] body)
|
||||
{
|
||||
foreach (var b in body)
|
||||
{
|
||||
if (b == 0) return "(binary)";
|
||||
}
|
||||
return Head(System.Text.Encoding.UTF8.GetString(body, 0, Math.Min(body.Length, 4096)));
|
||||
}
|
||||
|
||||
private static string Head(string body)
|
||||
{
|
||||
var flat = body.Replace('\r', ' ').Replace('\n', ' ').Trim();
|
||||
if (flat.Length == 0) return string.Empty;
|
||||
return flat.Length <= HeadChars ? flat : flat[..HeadChars] + "...";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.RecNet.GameSight;
|
||||
using RadicadeServer.RecNet.Responses;
|
||||
|
||||
namespace RadicadeServer.API.Analytics
|
||||
{
|
||||
[Route("api/gamesight/")]
|
||||
public class GameSight : Controller
|
||||
{
|
||||
[HttpPost("event")]
|
||||
public Success SendEvent([FromForm] string type, [FromForm] string user_id)
|
||||
{
|
||||
return new Success(true, null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace RadicadeServer.RecNet.GameSight
|
||||
{
|
||||
public class GameSightIdentifiers
|
||||
{
|
||||
public string resolution { get; set; } = null!;
|
||||
public string language { get; set; } = null!;
|
||||
public string timezone { get; set; } = null!;
|
||||
}
|
||||
public class GameSightEvent
|
||||
{
|
||||
public string type { get; set; } = null!;
|
||||
public string user_id { get; set; } = null!;
|
||||
public GameSightIdentifiers identifiers { get; set; } = null!;
|
||||
public string ip { get; set; } = null!;
|
||||
public string gameplay_session_id { get; set; } = null!;
|
||||
public string transaction_id { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace RadicadeServer.Auth;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static class AdminLoginThrottle
|
||||
{
|
||||
|
||||
public const int MaxFailedAttempts = 5;
|
||||
|
||||
|
||||
public static readonly TimeSpan LockoutDuration = TimeSpan.FromMinutes(15);
|
||||
|
||||
private sealed record Entry(int Failures, DateTime FirstFailureAt, DateTime LockedUntil);
|
||||
|
||||
private static readonly ConcurrentDictionary<string, Entry> Attempts = new();
|
||||
|
||||
|
||||
|
||||
public static bool IsLocked(string username, string ip, out TimeSpan remaining)
|
||||
{
|
||||
remaining = TimeSpan.Zero;
|
||||
var byUser = LockedRemaining("u:" + username);
|
||||
var byIp = LockedRemaining("ip:" + ip);
|
||||
|
||||
if (byUser == null && byIp == null) return false;
|
||||
remaining = Max(byUser, byIp)!.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void RecordFailure(string username, string ip)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var key in Keys(username, ip))
|
||||
{
|
||||
Attempts.AddOrUpdate(key,
|
||||
_ => new Entry(1, now, DateTime.MinValue),
|
||||
(_, existing) =>
|
||||
{
|
||||
|
||||
|
||||
if (now - existing.FirstFailureAt > LockoutDuration)
|
||||
return new Entry(1, now, DateTime.MinValue);
|
||||
|
||||
var failures = existing.Failures + 1;
|
||||
|
||||
|
||||
var lockedUntil = failures >= MaxFailedAttempts ? now + LockoutDuration : DateTime.MinValue;
|
||||
return new Entry(failures, existing.FirstFailureAt, lockedUntil);
|
||||
});
|
||||
}
|
||||
Prune(now);
|
||||
}
|
||||
|
||||
public static void Clear(string username, string ip)
|
||||
{
|
||||
foreach (var key in Keys(username, ip))
|
||||
Attempts.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
private static TimeSpan? LockedRemaining(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key)) return null;
|
||||
if (!Attempts.TryGetValue(key, out var entry)) return null;
|
||||
|
||||
|
||||
if (entry.Failures < MaxFailedAttempts) return null;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (now >= entry.LockedUntil)
|
||||
{
|
||||
Attempts.TryRemove(key, out _);
|
||||
return null;
|
||||
}
|
||||
return entry.LockedUntil - now;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> Keys(string username, string ip)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(username)) yield return "u:" + username;
|
||||
if (!string.IsNullOrWhiteSpace(ip)) yield return "ip:" + ip;
|
||||
}
|
||||
|
||||
private static TimeSpan? Max(TimeSpan? a, TimeSpan? b)
|
||||
{
|
||||
if (a == null) return b;
|
||||
if (b == null) return a;
|
||||
return a > b ? a : b;
|
||||
}
|
||||
|
||||
private static void Prune(DateTime now)
|
||||
{
|
||||
|
||||
foreach (var kv in Attempts)
|
||||
{
|
||||
var e = kv.Value;
|
||||
var lockActive = e.Failures >= MaxFailedAttempts && now < e.LockedUntil;
|
||||
if (!lockActive && now - e.FirstFailureAt > LockoutDuration)
|
||||
Attempts.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace RadicadeServer.Auth;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static class AdminSecurity
|
||||
{
|
||||
|
||||
|
||||
private const int Pbkdf2Iterations = 600_000;
|
||||
private const int SaltSize = 16;
|
||||
private const int HashSize = 32;
|
||||
|
||||
public static string HashPassword(string password)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var hash = Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password), salt, Pbkdf2Iterations, HashAlgorithmName.SHA256, HashSize);
|
||||
return $"pbkdf2${Pbkdf2Iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
|
||||
}
|
||||
|
||||
public static bool VerifyPassword(string storedHash, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storedHash)) return false;
|
||||
|
||||
var parts = storedHash.Split('$');
|
||||
if (parts.Length != 4 || parts[0] != "pbkdf2") return false;
|
||||
if (!int.TryParse(parts[1], out var iterations)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
var salt = Convert.FromBase64String(parts[2]);
|
||||
var expected = Convert.FromBase64String(parts[3]);
|
||||
var actual = Rfc2898DeriveBytes.Pbkdf2(
|
||||
Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, expected.Length);
|
||||
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool NeedsRehash(string storedHash)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storedHash)) return false;
|
||||
|
||||
var parts = storedHash.Split('$');
|
||||
if (parts.Length != 4 || parts[0] != "pbkdf2") return true;
|
||||
return !int.TryParse(parts[1], out var iterations) || iterations < Pbkdf2Iterations;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static string? ValidatePasswordPolicy(string? password, string? username = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password) || password.Length < 12)
|
||||
return "Password must be at least 12 characters long.";
|
||||
if (!password.Any(char.IsUpper))
|
||||
return "Password must contain at least one uppercase letter.";
|
||||
if (!password.Any(char.IsLower))
|
||||
return "Password must contain at least one lowercase letter.";
|
||||
if (!password.Any(char.IsDigit))
|
||||
return "Password must contain at least one number.";
|
||||
if (!password.Any(ch => !char.IsLetterOrDigit(ch)))
|
||||
return "Password must contain at least one symbol (e.g. ! @ # $ %).";
|
||||
if (!string.IsNullOrWhiteSpace(username) && username.Length >= 3 &&
|
||||
password.Contains(username, StringComparison.OrdinalIgnoreCase))
|
||||
return "Password must not contain your username.";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.Admin;
|
||||
using RadicadeServer.DB;
|
||||
|
||||
namespace RadicadeServer.Auth;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static class AdminSession
|
||||
{
|
||||
public const string CookieName = "radicade_admin_session";
|
||||
public const string StateCookieName = "radicade_oauth_state";
|
||||
public const string RedirectCookieName = "radicade_oauth_redirect";
|
||||
|
||||
|
||||
public static readonly TimeSpan Lifetime = TimeSpan.FromHours(12);
|
||||
|
||||
public static readonly TimeSpan AbsoluteLifetime = TimeSpan.FromDays(7);
|
||||
|
||||
|
||||
private static readonly TimeSpan RotationGrace = TimeSpan.FromMinutes(2);
|
||||
|
||||
|
||||
public static async Task<string> CreateAsync(RecDBContext db, long adminUserId)
|
||||
{
|
||||
var token = NewToken();
|
||||
db.AdminSessions.Add(new AdminSessionRecord
|
||||
{
|
||||
AdminUserId = adminUserId,
|
||||
TokenHash = Hash(token),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
LastSeenAt = DateTime.UtcNow,
|
||||
ExpiresAt = DateTime.UtcNow.Add(Lifetime)
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static async Task<(bool Ok, long UserId)> ValidateAsync(RecDBContext db, string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return (false, 0);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var hash = Hash(token);
|
||||
|
||||
var record = await db.AdminSessions.FirstOrDefaultAsync(s => s.TokenHash == hash);
|
||||
if (record == null)
|
||||
{
|
||||
|
||||
record = await db.AdminSessions.FirstOrDefaultAsync(s =>
|
||||
s.PreviousTokenHash == hash && s.LastSeenAt > now - RotationGrace);
|
||||
if (record == null) return (false, 0);
|
||||
}
|
||||
|
||||
if (record.RevokedAt.HasValue || record.ExpiresAt <= now)
|
||||
return (false, 0);
|
||||
|
||||
var ceiling = record.CreatedAt.Add(AbsoluteLifetime);
|
||||
if (now >= ceiling) return (false, 0);
|
||||
|
||||
record.LastSeenAt = now;
|
||||
record.ExpiresAt = now.Add(Lifetime) < ceiling ? now.Add(Lifetime) : ceiling;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return (true, record.AdminUserId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private static readonly object RotateLock = new();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static (string Token, bool Rotated) TryRotate(RecDBContext db, string? currentToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(currentToken)) return ("", false);
|
||||
|
||||
lock (RotateLock)
|
||||
{
|
||||
var record = db.AdminSessions.FirstOrDefault(s => s.TokenHash == Hash(currentToken));
|
||||
if (record == null)
|
||||
{
|
||||
|
||||
|
||||
|
||||
record = db.AdminSessions.FirstOrDefault(s => s.PreviousTokenHash == Hash(currentToken));
|
||||
if (record != null && record.LastSeenAt > DateTime.UtcNow - RotationGrace)
|
||||
return (currentToken, false);
|
||||
return (currentToken, false);
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var ceiling = record.CreatedAt.Add(AbsoluteLifetime);
|
||||
if (now >= ceiling) return (currentToken, false);
|
||||
|
||||
var next = NewToken();
|
||||
record.PreviousTokenHash = record.TokenHash;
|
||||
record.TokenHash = Hash(next);
|
||||
record.LastSeenAt = now;
|
||||
record.ExpiresAt = now.Add(Lifetime) < ceiling ? now.Add(Lifetime) : ceiling;
|
||||
db.SaveChanges();
|
||||
return (next, true);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task RevokeAsync(RecDBContext db, string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token)) return;
|
||||
|
||||
var record = await db.AdminSessions.FirstOrDefaultAsync(s => s.TokenHash == Hash(token));
|
||||
if (record == null) return;
|
||||
|
||||
record.RevokedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
public static async Task RevokeAllAsync(RecDBContext db, long adminUserId)
|
||||
{
|
||||
var sessions = await db.AdminSessions
|
||||
.Where(s => s.AdminUserId == adminUserId && !s.RevokedAt.HasValue)
|
||||
.ToListAsync();
|
||||
foreach (var session in sessions)
|
||||
session.RevokedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
|
||||
public static async Task PruneAsync(RecDBContext db)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-2);
|
||||
var stale = await db.AdminSessions
|
||||
.Where(s => s.RevokedAt.HasValue || s.ExpiresAt < cutoff)
|
||||
.ToListAsync();
|
||||
if (stale.Count == 0) return;
|
||||
db.AdminSessions.RemoveRange(stale);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static string NewToken() => Convert.ToHexString(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
private static string Hash(string token) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
}
|
||||
@@ -0,0 +1,1116 @@
|
||||
using Azure;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Collections.Generic;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Accounts.Local;
|
||||
using RadicadeServer.RecNet.Auth;
|
||||
using RadicadeServer.RecNet.Auth.Cached;
|
||||
using RadicadeServer.RecNet.Auth.Connection;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
using RadicadeServer.RecNet.Moderation;
|
||||
using RadicadeServer.RecNet.Progression;
|
||||
using RadicadeServer.RecNet.Responses;
|
||||
using RadicadeServer.Radicade.Mail;
|
||||
using RadicadeServer.Radicade.Responses;
|
||||
using RadicadeServer.Utils;
|
||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
||||
|
||||
namespace RadicadeServer.Auth
|
||||
{
|
||||
[Route("auth")]
|
||||
public class AuthAPI : Controller
|
||||
{
|
||||
|
||||
public class WebLoginRequest
|
||||
{
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
}
|
||||
|
||||
|
||||
|
||||
public const int MinPasswordLength = 6;
|
||||
|
||||
RecDBContext db;
|
||||
PasswordHasher<PlayerData> hasher;
|
||||
|
||||
public AuthAPI(RecDBContext context) {
|
||||
this.db = context;
|
||||
this.hasher = new PasswordHasher<PlayerData>();
|
||||
}
|
||||
|
||||
[HttpGet("account/me/haspassword"), Authorize]
|
||||
public async Task<bool?> GetHasPassword()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var user = await db.Players.FirstAsync(x => x.AccountId == userId);
|
||||
|
||||
return !string.IsNullOrEmpty(user.Hashword);
|
||||
}
|
||||
|
||||
[HttpPost("account/me/changepassword"), Authorize]
|
||||
public async Task<Success> ChangePassword([FromForm] string oldPassword, [FromForm] string newPassword)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newPassword)) return new Success(false, "New password is required.", null);
|
||||
if (newPassword.Length < MinPasswordLength) return new Success(false, $"Password must be at least {MinPasswordLength} characters.", null);
|
||||
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var user = await db.Players.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (user == null) return new Success(false, "User does not exist.", "damn.");
|
||||
|
||||
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(user.Hashword))
|
||||
{
|
||||
if (string.IsNullOrEmpty(oldPassword))
|
||||
return new Success(false, "Current password is required.", null);
|
||||
var check = hasher.VerifyHashedPassword(user, user.Hashword, oldPassword);
|
||||
if (check != PasswordVerificationResult.Success && check != PasswordVerificationResult.SuccessRehashNeeded)
|
||||
return new Success(false, "Current password is incorrect.", null);
|
||||
}
|
||||
|
||||
user.Hashword = hasher.HashPassword(user, newPassword);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
AuthSecurityMonitor.NoteCredentialChange(user.AccountId, "password changed");
|
||||
return new Success(true, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("account/recoverpassword")]
|
||||
public async Task<Success> RecoverPassword()
|
||||
{
|
||||
|
||||
var email = "";
|
||||
if (Request.HasFormContentType)
|
||||
email = Request.Form["email"].FirstOrDefault() ?? "";
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
using var reader = new System.IO.StreamReader(Request.Body);
|
||||
var raw = await reader.ReadToEndAsync();
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(raw);
|
||||
if (doc.RootElement.TryGetProperty("email", out var e) && e.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
email = e.GetString() ?? "";
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
email = email.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
|
||||
return new Success(false, "Enter a valid email address.", null);
|
||||
|
||||
|
||||
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.Email.ToLower() == email.ToLower());
|
||||
if (plrData != null)
|
||||
{
|
||||
plrData.PasswordResetToken = Guid.NewGuid().ToString("N");
|
||||
plrData.PasswordResetExpires = DateTime.UtcNow.AddHours(1);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
SendResetEmail(plrData);
|
||||
AuthSecurityMonitor.NoteCredentialChange(plrData.AccountId, "password reset requested");
|
||||
}
|
||||
|
||||
|
||||
return new Success(true, null, null);
|
||||
}
|
||||
|
||||
private void SendResetEmail(PlayerData plrData)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plrData.PasswordResetToken)) return;
|
||||
|
||||
var link = $"https://radicade.app/reset?code={plrData.PasswordResetToken}";
|
||||
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;\">Reset your Radicade password</h2>" +
|
||||
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">Hi there,</p>" +
|
||||
"<p style=\"color:#444;font-size:15px;line-height:1.6;\">We received a request to reset the password on your Radicade account from the game. If that was you, click the button below to choose a new password:</p>" +
|
||||
"<p style=\"text-align:center;margin:28px 0;\"><a href=\"" + link + "\" style=\"background:#00a0d0;color:#fff;text-decoration:none;padding:12px 28px;border-radius:6px;display:inline-block;\">Reset my password</a></p>" +
|
||||
"<p style=\"color:#999;font-size:12px;margin-top:24px;\">This link expires in one hour. If you didn't request this, you can safely ignore this email.</p></div>";
|
||||
|
||||
_ = RadicadeMail.SendAsync(plrData.Email, "Reset your password - Radicade", html);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("web/login")]
|
||||
public async Task<IActionResult> WebLogin([FromBody] WebLoginRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req?.Username)) return Ok(new { success = false, message = "No username was given." });
|
||||
if (string.IsNullOrWhiteSpace(req?.Password)) return Ok(new { success = false, message = "No password was given." });
|
||||
|
||||
var loginName = req.Username.Trim().TrimStart('@');
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.username == loginName || x.displayName == loginName);
|
||||
if (profile == null)
|
||||
return Ok(new { success = false, message = "Invalid username or password." });
|
||||
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == profile.accountId);
|
||||
if (plrData == null)
|
||||
return Ok(new { success = false, message = "Invalid username or password." });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (string.IsNullOrWhiteSpace(plrData.Hashword))
|
||||
return Ok(new { success = false, message = "Please make a password in-game!" });
|
||||
|
||||
var loginIp = Request.GetClientIp();
|
||||
var result = hasher.VerifyHashedPassword(plrData, plrData.Hashword, req.Password ?? "");
|
||||
if (result != PasswordVerificationResult.Success)
|
||||
{
|
||||
AuthSecurityMonitor.RecordFailedLogin(profile.accountId, loginIp);
|
||||
return Ok(new { success = false, message = "Invalid username or password." });
|
||||
}
|
||||
|
||||
if (await AccountLock.IsLockedAsync(db, profile.accountId))
|
||||
{
|
||||
var lockedUsername = await AccountLock.ResolveUsernameAsync(db, profile.accountId) ?? profile.username;
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] refused web sign-in for locked account {profile.accountId}");
|
||||
return Ok(new { success = false, error = AccountLock.ErrorCode, message = AccountLock.LoginErrorMessage(lockedUsername) });
|
||||
}
|
||||
AuthSecurityMonitor.RecordSuccessfulLogin(profile.accountId, loginIp, false, false);
|
||||
|
||||
|
||||
|
||||
if (!await db.Presences.AnyAsync(x => x.playerId == profile.accountId))
|
||||
{
|
||||
db.Presences.Add(new RealPresence
|
||||
{
|
||||
playerId = profile.accountId,
|
||||
deviceClass = DeviceClass.Screen,
|
||||
statusVisibility = StatusVisibility.Public,
|
||||
vRMovementMode = VRMovementModes.TELEPORT
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var newToken = await GenerateToken(db, profile.accountId, "web", DeviceClass.Screen, Platforms.Steam, "web:" + profile.accountId);
|
||||
if (!newToken.success || newToken.value == null)
|
||||
return Ok(new { success = false, message = newToken.error ?? "Failed to sign in." });
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
token = (string)newToken.value,
|
||||
username = profile.username,
|
||||
displayName = profile.displayName,
|
||||
email = plrData.Email,
|
||||
emailVerified = plrData.EmailVerified
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("connect/token")]
|
||||
public async Task<ActionResult<AuthResponse?>> ConnectWithToken([FromForm] TokenConnectData data)
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
data.platform_id = data.platform_id?.Trim();
|
||||
data.device_id = data.device_id?.Trim();
|
||||
|
||||
var platformAuth = data.platform_auth?.Trim() ?? string.Empty;
|
||||
if (data.platform == Platforms.Standalone)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(data.device_id) ||
|
||||
data.device_id.Length > 256 || data.client_id != "recroom")
|
||||
return Unauthorized(new AuthResponse("Failed to sign in.", "Sign in with your Radicade username and password."));
|
||||
data.platform_id = "radicade-quest:" + data.device_id;
|
||||
if (data.grant_type == "password" && !string.IsNullOrWhiteSpace(data.username) &&
|
||||
!string.IsNullOrWhiteSpace(data.password))
|
||||
data.account_id = 0;
|
||||
else if (data.grant_type is "cached_login" or "refresh_token")
|
||||
{
|
||||
var remembered = await QuestRememberedSession.ValidateAsync(db,
|
||||
Request.Headers[QuestRememberedSession.Header].ToString(), data.device_id, data.account_id);
|
||||
if (!remembered.HasValue)
|
||||
return Unauthorized(new AuthResponse("invalid_grant", "Sign in with your Radicade username and password."));
|
||||
data.account_id = remembered.Value;
|
||||
data.grant_type = "refresh_token";
|
||||
}
|
||||
else return Unauthorized(new AuthResponse("invalid_grant", "Sign in with your Radicade username and password."));
|
||||
}
|
||||
if (data.platform == Platforms.Steam && !string.IsNullOrEmpty(platformAuth) &&
|
||||
!TryValidateSteamPlatformAuth(platformAuth, out var platformAuthReason))
|
||||
{
|
||||
return SteamIdentityRefusal("platform_auth shape", platformAuthReason, data);
|
||||
}
|
||||
|
||||
if (data.platform == Platforms.Steam && (data.grant_type is "cached_login" or "create_account" or "password"))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(platformAuth))
|
||||
{
|
||||
return SteamIdentityRefusal("platform_auth",
|
||||
"platform_auth was absent or blank", data);
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey))
|
||||
{
|
||||
if (!DBConfig.TrustUnverifiedSteamIdentity)
|
||||
{
|
||||
return SteamIdentityRefusal("steam api key",
|
||||
"no SteamApiKey is configured and TrustUnverifiedSteamIdentity is off", data);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!TryReadSteamTicket(platformAuth, out var steamTicket))
|
||||
{
|
||||
return SteamIdentityRefusal("platform_auth.Ticket",
|
||||
"platform_auth carried no readable string 'Ticket'", data);
|
||||
}
|
||||
|
||||
string? verifiedSteamId;
|
||||
try
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(10));
|
||||
verifiedSteamId = await AuthenticateSteamTicketAsync(steamTicket, GetSteamAppId(platformAuth), timeout.Token);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
return SteamIdentityRefusal("steam ticket check",
|
||||
$"Steam could not be reached ({ex.GetType().Name})", data);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(verifiedSteamId))
|
||||
{
|
||||
return SteamIdentityRefusal("steam ticket check",
|
||||
"Steam would not confirm the ticket (stale ticket, or one issued for an app it will not check)", data);
|
||||
}
|
||||
if (!string.Equals(verifiedSteamId, data.platform_id?.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
return SteamIdentityRefusal("identity match",
|
||||
$"the ticket is for {verifiedSteamId}, not for the posted identity {data.platform_id?.Trim()}", data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isWhitelisted = await AuthShared.IsWhitelisted(data.platform_id ?? string.Empty);
|
||||
if (!isWhitelisted)
|
||||
{
|
||||
return new AuthResponse("Error", "You are not whitelisted.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
AuthResponse response = new AuthResponse();
|
||||
|
||||
switch (data.grant_type)
|
||||
{
|
||||
default:
|
||||
return Unauthorized();
|
||||
|
||||
case "cached_login":
|
||||
{
|
||||
if (data.account_id <= 0)
|
||||
return StatusCode(400, new AuthResponse("invalid_request", "missing account_id"));
|
||||
|
||||
if (!await db.Players.AnyAsync(player => player.AccountId == data.account_id) ||
|
||||
!await db.Profiles.AnyAsync(profile => profile.accountId == data.account_id) ||
|
||||
!await db.Presences.AnyAsync(presence => presence.playerId == data.account_id))
|
||||
return StatusCode(400, new AuthResponse("invalid_grant", "unknown or incomplete account_id"));
|
||||
|
||||
if (!await db.CachedLogins.AnyAsync(login => login.accountId == data.account_id &&
|
||||
login.platform == data.platform && login.platformId == data.platform_id))
|
||||
return StatusCode(400, new AuthResponse("invalid_grant", "no linked account for this platform identity"));
|
||||
|
||||
response = await TokenRefresh(data);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
case "create_account":
|
||||
{
|
||||
var carriesSignUpPayload = HasSignUpPayload(data);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
using var identity = await RegistrationGuard.LockIdentityAsync(
|
||||
RegistrationGuard.IdentityKey(data.platform.ToString(), data.platform_id, data.device_id));
|
||||
|
||||
var existingLink = await db.CachedLogins
|
||||
.AsNoTracking()
|
||||
.Where(login => login.platform == data.platform && login.platformId == data.platform_id)
|
||||
.Where(login => data.account_id <= 0 || login.accountId == data.account_id)
|
||||
.OrderByDescending(login => login.lastLoginTime)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (data.account_id > 0 && existingLink == null)
|
||||
return StatusCode(400, new AuthResponse("invalid_grant", "no linked account for this platform identity"));
|
||||
if (existingLink != null && carriesSignUpPayload)
|
||||
{
|
||||
var linkedSettings = await db.PlayerSettings.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == existingLink.accountId);
|
||||
bool finished = linkedSettings?.DeserializeSettings().Any(x =>
|
||||
string.Equals(x.Key, "Recroom.AccountCreation.HasFinished", StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(x.Value, "true", StringComparison.OrdinalIgnoreCase)) == true;
|
||||
if (finished)
|
||||
existingLink = null;
|
||||
}
|
||||
|
||||
if (existingLink != null)
|
||||
{
|
||||
data.account_id = existingLink.accountId;
|
||||
|
||||
if (carriesSignUpPayload)
|
||||
await ApplySignUpPayloadAsync(data, existingLink.accountId);
|
||||
else if (!await PlayerUtils.HasFinishedAccountCreationAsync(db, existingLink.accountId))
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
await PlayerUtils.RefreshAccountCreationMarkersAsync(db, existingLink.accountId);
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Onboarding] account {existingLink.accountId} resumed " +
|
||||
"before its creation flow finished; started marker restored so the client runs the flow");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var adoptable = await PlayerUtils.FindAdoptableAccountForDeviceAsync(db, data.device_id?.Trim());
|
||||
if (adoptable != null)
|
||||
{
|
||||
data.account_id = adoptable.AccountId;
|
||||
await PlayerUtils.AdoptDeviceAccountAsync(db, adoptable.AccountId, new AccountCreationData
|
||||
{
|
||||
platform = data.platform,
|
||||
platformId = data.platform_id ?? string.Empty,
|
||||
deviceId = data.device_id ?? string.Empty
|
||||
}, markStarted: true);
|
||||
if (carriesSignUpPayload)
|
||||
await ApplySignUpPayloadAsync(data, adoptable.AccountId);
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] create_account adopted this device's account " +
|
||||
$"{adoptable.AccountId} for platform {data.platform} identity {data.platform_id}");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var refusal = await TryProvisionOnboardingAccountAsync(data);
|
||||
if (refusal != null)
|
||||
{
|
||||
return TokenRefusal(refusal);
|
||||
}
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] create_account made account {data.account_id} " +
|
||||
$"for platform {data.platform} identity {data.platform_id}");
|
||||
}
|
||||
}
|
||||
|
||||
response = await TokenRefresh(data);
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
case "refresh_token":
|
||||
response = await TokenRefresh(data);
|
||||
return Ok(response);
|
||||
|
||||
case "password":
|
||||
if (data.username == null) return Ok(new AuthResponse { error = "Failed to sign in.", error_description = "No username was given." });
|
||||
if (data.password == null) return Ok(new AuthResponse { error = "Failed to sign in.", error_description = "No password was given." });
|
||||
|
||||
var loginIp = Request.GetClientIp();
|
||||
if (AuthSecurityMonitor.IsSourceThrottled(loginIp))
|
||||
{
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] throttled sign-in source {loginIp} (suspected spraying)");
|
||||
return StatusCode(429, new AuthResponse("too_many_requests", "Too many sign-in attempts from this connection. Please try again later."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var loginName = data.username.Trim().TrimStart('@');
|
||||
var existingProfile = await db.Profiles.FirstOrDefaultAsync(x => x.username == loginName || x.displayName == loginName);
|
||||
if (existingProfile != null)
|
||||
{
|
||||
data.account_id = existingProfile.accountId;
|
||||
}
|
||||
|
||||
var playerData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == data.account_id);
|
||||
if (playerData != null)
|
||||
{
|
||||
var result = hasher.VerifyHashedPassword(playerData, playerData.Hashword, data.password);
|
||||
if (result == PasswordVerificationResult.Success)
|
||||
{
|
||||
var alreadyCached = await db.CachedLogins.FirstOrDefaultAsync(x => x.accountId == data.account_id && x.platform == data.platform && x.platformId == data.platform_id);
|
||||
if (await AccountLock.IsLockedAsync(db, data.account_id) && alreadyCached == null)
|
||||
{
|
||||
var lockedUsername = await AccountLock.ResolveUsernameAsync(db, data.account_id);
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] refused password sign-in for locked account {data.account_id}");
|
||||
return Ok(new AuthResponse(AccountLock.ErrorCode, AccountLock.LoginErrorMessage(lockedUsername)));
|
||||
}
|
||||
if (alreadyCached == null)
|
||||
{
|
||||
await db.AddAsync(new CachedLogin { lastLoginTime = DateTime.UtcNow, accountId = data.account_id, platform = data.platform, platformId = data.platform_id! });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var newDevice = !string.IsNullOrWhiteSpace(playerData.DeviceId)
|
||||
&& !string.Equals(playerData.DeviceId, data.device_id, StringComparison.Ordinal);
|
||||
var newIp = !string.IsNullOrWhiteSpace(playerData.LastIpAddress)
|
||||
&& !string.Equals(playerData.LastIpAddress, loginIp, StringComparison.Ordinal);
|
||||
AuthSecurityMonitor.RecordSuccessfulLogin(data.account_id, loginIp, newDevice, newIp);
|
||||
|
||||
response = await TokenRefresh(data, verifiedByPassword: true);
|
||||
return Ok(response);
|
||||
}
|
||||
}
|
||||
|
||||
var failures = AuthSecurityMonitor.RecordFailedLogin(data.account_id, loginIp);
|
||||
var failureDelay = AuthSecurityMonitor.ProgressiveDelayMs(failures);
|
||||
if (failureDelay > 0) await Task.Delay(failureDelay);
|
||||
return Ok(new AuthResponse { error = "Failed to sign in.", error_description = "Unknown error." });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
private async Task ApplySignUpPayloadAsync(TokenConnectData data, long accountId)
|
||||
{
|
||||
var profile = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == accountId);
|
||||
var plrData = await db.Players.FirstOrDefaultAsync(x => x.AccountId == accountId);
|
||||
if (profile == null || plrData == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var applied = new List<string>();
|
||||
var ignored = new List<string>();
|
||||
bool isOnboarding = await PlayerUtils.IsChoosingTheirAccountNameAsync(db, accountId) || PlayerUtils.IsNewPlayer(plrData);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
bool nameApplied = false;
|
||||
bool nameChosen = false;
|
||||
var chosen = UsernameRules.Parse(data.username, out var nameRejection);
|
||||
if (nameRejection != null)
|
||||
{
|
||||
|
||||
|
||||
|
||||
ignored.Add($"name=refused({nameRejection})");
|
||||
}
|
||||
else if (chosen == null)
|
||||
{
|
||||
ignored.Add("name=absent");
|
||||
}
|
||||
else if (string.Equals(chosen, profile.username, StringComparison.Ordinal))
|
||||
{
|
||||
ignored.Add($"name={chosen}(already its name)");
|
||||
nameChosen = true;
|
||||
}
|
||||
else if (profile.NameLocked)
|
||||
{
|
||||
ignored.Add($"name={chosen}(locked)");
|
||||
}
|
||||
else if (!await PlayerUtils.IsChoosingTheirAccountNameAsync(db, accountId) && !PlayerUtils.IsNewPlayer(plrData))
|
||||
{
|
||||
ignored.Add($"name={chosen}(flow over for account '{profile.username}')");
|
||||
}
|
||||
else if (await PlayerUtils.IsNameTakenAsync(db, chosen))
|
||||
{
|
||||
ignored.Add($"name={chosen}(taken)");
|
||||
}
|
||||
else
|
||||
{
|
||||
var previous = profile.username;
|
||||
profile.username = chosen;
|
||||
profile.displayName = chosen;
|
||||
applied.Add($"name={chosen}(was '{previous}')");
|
||||
nameApplied = true;
|
||||
nameChosen = true;
|
||||
}
|
||||
|
||||
var birthday = PlayerUtils.ReadBirthday(data.birthday ?? data.birthdayUTC);
|
||||
if (birthday == null)
|
||||
{
|
||||
ignored.Add("birthday=absent");
|
||||
}
|
||||
else if (plrData.Birthday != null && plrData.Birthday.Value.Year > 1900)
|
||||
{
|
||||
ignored.Add($"birthday={birthday:yyyy-MM-dd}(account already gave {plrData.Birthday:yyyy-MM-dd})");
|
||||
}
|
||||
else
|
||||
{
|
||||
plrData.Birthday = birthday;
|
||||
plrData.IsJunior = PlayerUtils.IsJuniorByBirthday(birthday);
|
||||
profile.isJunior = plrData.IsJunior;
|
||||
applied.Add($"birthday={birthday:yyyy-MM-dd}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(data.password))
|
||||
{
|
||||
ignored.Add("password=absent");
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(plrData.Hashword))
|
||||
{
|
||||
ignored.Add("password=(account already has one)");
|
||||
}
|
||||
else
|
||||
{
|
||||
plrData.Hashword = new PasswordHasher<PlayerData>().HashPassword(plrData, data.password);
|
||||
applied.Add("password=set");
|
||||
}
|
||||
|
||||
if (applied.Count > 0 || isOnboarding) await db.SaveChangesAsync();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (isOnboarding || nameApplied)
|
||||
await PlayerUtils.RefreshAccountCreationMarkersAsync(db, accountId, nameChosen ? true : null);
|
||||
|
||||
var line =
|
||||
$"[SignUp] account {accountId} resumed its sign-up on an identity that was already linked: " +
|
||||
$"applied [{string.Join(", ", applied)}], ignored [{string.Join(", ", ignored)}]; " +
|
||||
$"name is now '{profile.username}', birthday {(plrData.Birthday == null ? "none" : plrData.Birthday.Value.ToString("yyyy-MM-dd"))}, " +
|
||||
$"password {(string.IsNullOrWhiteSpace(plrData.Hashword) ? "unset" : "set")}.";
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
private async Task<AuthResponse?> TryProvisionOnboardingAccountAsync(TokenConnectData data)
|
||||
{
|
||||
var platformId = data.platform_id?.Trim() ?? string.Empty;
|
||||
var deviceId = data.device_id?.Trim() ?? string.Empty;
|
||||
|
||||
|
||||
|
||||
if (platformId.Length == 0 || deviceId.Length == 0 || platformId.Length > 64 || deviceId.Length > 256)
|
||||
{
|
||||
return new AuthResponse("Account creation failed.", "A valid platform ID and device ID are required.");
|
||||
}
|
||||
|
||||
var clientIp = Request.GetClientIp();
|
||||
if (await db.IsIpBanned(clientIp))
|
||||
return new AuthResponse("Account creation failed.", "This IP address is banned from creating new accounts.");
|
||||
if (await db.IsAccountCreationBlocked(clientIp))
|
||||
return new AuthResponse("Account creation failed.", "This IP address is blocked from creating new accounts.");
|
||||
if (await db.IsHardwareBanned(deviceId))
|
||||
return new AuthResponse("Account creation failed.", "This device is banned.");
|
||||
|
||||
|
||||
|
||||
|
||||
var throttled = RegistrationGuard.Check(clientIp, deviceId);
|
||||
if (throttled != null)
|
||||
{
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Registration] refused a new account for platform " +
|
||||
$"{data.platform} identity {platformId} ({RegistrationGuard.Describe(clientIp, deviceId)}): {throttled}");
|
||||
return new AuthResponse("Account creation failed.", throttled);
|
||||
}
|
||||
|
||||
data.platform_id = platformId;
|
||||
data.device_id = deviceId;
|
||||
|
||||
var created = await PlayerUtils.CreateAccount(new AccountCreationData
|
||||
{
|
||||
platform = data.platform,
|
||||
platformId = platformId,
|
||||
deviceId = deviceId,
|
||||
deviceClass = data.device_class,
|
||||
password = data.password,
|
||||
username = data.username,
|
||||
birthday = data.birthday,
|
||||
birthdayUTC = data.birthdayUTC,
|
||||
email = data.email,
|
||||
avatar = data.avatar,
|
||||
avatarJson = data.avatarJson
|
||||
}, clientIp, accountCreationInProgress: true);
|
||||
|
||||
if (!created.success || created.value is not Profile profile)
|
||||
return new AuthResponse("Account creation failed.", created.error ?? "Could not create the account.");
|
||||
|
||||
data.account_id = profile.accountId;
|
||||
return null;
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
private static bool HasSignUpPayload(TokenConnectData data) =>
|
||||
!string.IsNullOrWhiteSpace(data.username) ||
|
||||
!string.IsNullOrWhiteSpace(data.password) ||
|
||||
data.birthday != null ||
|
||||
data.birthdayUTC != null ||
|
||||
!string.IsNullOrWhiteSpace(data.avatar) ||
|
||||
!string.IsNullOrWhiteSpace(data.avatarJson) ||
|
||||
!string.IsNullOrWhiteSpace(data.email);
|
||||
|
||||
[NonAction]
|
||||
private static ObjectResult TokenRefusal(AuthResponse refusal)
|
||||
{
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] sign-in refused: error={refusal.error} detail=\"{refusal.error_description}\"");
|
||||
return new(refusal) { StatusCode = 400 };
|
||||
}
|
||||
|
||||
private const string SteamAppId = "471710";
|
||||
private static readonly string[] SteamAllowedAppIds = ["480", SteamAppId];
|
||||
|
||||
[NonAction]
|
||||
protected virtual async Task<string?> AuthenticateSteamTicketAsync(string ticket, string appId, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
foreach (var candidate in CandidateSteamAppIds(appId))
|
||||
{
|
||||
var result = await PlatformIdentity.AuthenticateTicketAsync(ticket, candidate, cancellationToken);
|
||||
if (result.OtherApp) continue;
|
||||
return result.Ok ? result.SteamId : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
private static IEnumerable<string> CandidateSteamAppIds(string appId) =>
|
||||
new[] { appId }.Concat(SteamAllowedAppIds)
|
||||
.Where(candidate => !string.IsNullOrWhiteSpace(candidate))
|
||||
.Distinct(StringComparer.Ordinal);
|
||||
|
||||
[NonAction]
|
||||
private static string GetSteamAppId(string platformAuth)
|
||||
{
|
||||
using var document = JsonDocument.Parse(platformAuth);
|
||||
var appId = document.RootElement.GetProperty("AppId");
|
||||
return appId.ValueKind == JsonValueKind.String ? appId.GetString() ?? SteamAppId : appId.ToString();
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
private static bool TryReadSteamTicket(string platformAuth, out string ticket)
|
||||
{
|
||||
ticket = string.Empty;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(platformAuth);
|
||||
if (!document.RootElement.TryGetProperty("Ticket", out var ticketElement) ||
|
||||
ticketElement.ValueKind != JsonValueKind.String)
|
||||
return false;
|
||||
|
||||
ticket = ticketElement.GetString() ?? string.Empty;
|
||||
return ticket.Length > 0;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[NonAction]
|
||||
private ActionResult SteamIdentityRefusal(string stage, string detail, TokenConnectData data)
|
||||
{
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Auth] connect/token refused at {stage} for " +
|
||||
$"platform {data.platform} identity {OrAbsent(data.platform_id)} " +
|
||||
$"grant_type {OrAbsent(data.grant_type)}: {detail}");
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
private static string OrAbsent(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? "<absent>" : value.Trim();
|
||||
|
||||
[NonAction]
|
||||
private static bool TryValidateSteamPlatformAuth(string platformAuth, out string reason)
|
||||
{
|
||||
reason = "unknown";
|
||||
if (platformAuth[0] != '{' || platformAuth[^1] != '}')
|
||||
{
|
||||
reason = $"not a JSON object (len={platformAuth.Length})";
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(platformAuth);
|
||||
var root = doc.RootElement;
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
reason = "platform_auth is valid JSON but not an object";
|
||||
return false;
|
||||
} if (!root.TryGetProperty("Ticket", out var ticketElement) || ticketElement.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
reason = $"no string 'Ticket' property (properties: {DescribeProperties(root)})";
|
||||
return false;
|
||||
}
|
||||
|
||||
var ticket = ticketElement.GetString() ?? string.Empty;
|
||||
if (ticket.Length == 0 || !ticket.StartsWith("140", StringComparison.Ordinal))
|
||||
{
|
||||
reason = $"'Ticket' has an unexpected format (len={ticket.Length})";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("AppId", out var appIdElement))
|
||||
{
|
||||
reason = $"no 'AppId' property (properties: {DescribeProperties(root)})";
|
||||
return false;
|
||||
}
|
||||
|
||||
var appId = appIdElement.ValueKind == JsonValueKind.String ? appIdElement.GetString() : appIdElement.ToString();
|
||||
if (!SteamAllowedAppIds.Contains(appId, StringComparer.Ordinal))
|
||||
{
|
||||
reason = "'AppId' is not supported for Steam ticket verification";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = "ok";
|
||||
return true;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
reason = $"invalid JSON ({ex.GetType().Name})";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[NonAction]
|
||||
private static string DescribeProperties(JsonElement root)
|
||||
{
|
||||
var names = new List<string>();
|
||||
foreach (var property in root.EnumerateObject())
|
||||
names.Add(property.Name);
|
||||
return names.Count == 0 ? "none" : string.Join(",", names);
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
private async Task<AuthResponse> TokenRefresh(TokenConnectData data, bool verifiedByPassword = false)
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (data.account_id <= 0)
|
||||
{
|
||||
long resolvedAccountId = 0;
|
||||
|
||||
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(data.refresh_token) &&
|
||||
Guid.TryParse(data.refresh_token, out var tokenGuid))
|
||||
{
|
||||
var tokenBytes = tokenGuid.ToByteArray();
|
||||
var embedded = BitConverter.ToInt64(tokenBytes, 0);
|
||||
if (embedded > 0 && await db.Players.AnyAsync(x => x.AccountId == embedded))
|
||||
resolvedAccountId = embedded;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (resolvedAccountId <= 0)
|
||||
{
|
||||
var headerAuth = base.Request.Headers.Authorization.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(headerAuth))
|
||||
{
|
||||
var headerUserId = headerAuth.GetUserId();
|
||||
if (headerUserId.HasValue && headerUserId.Value > 0 && await db.Players.AnyAsync(x => x.AccountId == headerUserId.Value))
|
||||
resolvedAccountId = headerUserId.Value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
if (resolvedAccountId <= 0)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(data.device_id))
|
||||
{
|
||||
var devicePlayer = await db.Players
|
||||
.Where(x => x.DeviceId == data.device_id)
|
||||
.OrderByDescending(x => x.AccountId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (devicePlayer != null) resolvedAccountId = devicePlayer.AccountId;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(data.platform_id))
|
||||
{
|
||||
var recentLogin = await db.CachedLogins
|
||||
.Where(x => x.platformId == data.platform_id && x.platform == data.platform)
|
||||
.OrderByDescending(x => x.lastLoginTime)
|
||||
.FirstOrDefaultAsync();
|
||||
if (recentLogin != null) resolvedAccountId = recentLogin.accountId;
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedAccountId > 0) data.account_id = resolvedAccountId; } PlayerData? player = await db.Players.FirstOrDefaultAsync(x => x.AccountId == data.account_id);
|
||||
|
||||
if (player == null)
|
||||
{
|
||||
var hasIdentity = await db.CachedLogins.AnyAsync(l => l.accountId == data.account_id)
|
||||
|| await db.Profiles.AnyAsync(p => p.accountId == data.account_id);
|
||||
if (hasIdentity)
|
||||
{
|
||||
|
||||
player = new PlayerData
|
||||
{
|
||||
AccountId = data.account_id,
|
||||
Phone = "",
|
||||
AppVersion = data.ver ?? DBConfig.GameVersion,
|
||||
DeviceClass = data.device_class,
|
||||
NameChanges = 99,
|
||||
Email = "",
|
||||
Hashword = "",
|
||||
JuniorState = JuniorState.BasedOnBirthday,
|
||||
Visibility = StatusVisibility.Public,
|
||||
DeviceId = data.device_id ?? "",
|
||||
LastIpAddress = Request.GetClientIp() ?? ""
|
||||
};
|
||||
await db.AddRangeAsync(
|
||||
player,
|
||||
new PlayerAvatar { Avatar = DBConfig.DefaultAvatar, Outfits = "[]", AccountId = data.account_id },
|
||||
new Reputation { AccountId = data.account_id },
|
||||
new PlayerSettings { AccountId = data.account_id, Settings = DBConfig.NewAccountSettings2023 },
|
||||
new ProgressionData { PlayerId = data.account_id, XP = 0, Level = 1, Progress = new ProgressionJson().ToJson() },
|
||||
new RealPresence { playerId = data.account_id, deviceClass = data.device_class, statusVisibility = StatusVisibility.Public, vRMovementMode = VRMovementModes.TELEPORT },
|
||||
new Currency { AccountId = data.account_id },
|
||||
new PlayerUnlocks { AccountId = data.account_id, AvatarItems = DBConfig.DefaultAvatarUnlocks }
|
||||
);
|
||||
await db.SaveChangesAsync();
|
||||
await RoomUtils.GetOrCreateDorm(data.account_id);
|
||||
}
|
||||
}
|
||||
|
||||
CachedLogin? cachedLogin = await db.CachedLogins.FirstOrDefaultAsync(
|
||||
l => l.accountId == data.account_id &&
|
||||
l.platformId == data.platform_id &&
|
||||
l.platform == data.platform);
|
||||
|
||||
if (data.account_id == 1)
|
||||
{
|
||||
return new AuthResponse("Failed to login.", "System account cannot log in.");
|
||||
}
|
||||
if (player == null)
|
||||
{
|
||||
return new AuthResponse("Failed to login.", "Missing player data.");
|
||||
}
|
||||
|
||||
if (await AccountLock.IsLockedAsync(db, data.account_id) && cachedLogin == null)
|
||||
{
|
||||
var lockedUsername = await AccountLock.ResolveUsernameAsync(db, data.account_id);
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Security] refused {data.grant_type} sign-in for locked account {data.account_id} (platform={data.platform}, platformId={data.platform_id})");
|
||||
return new AuthResponse(AccountLock.ErrorCode, AccountLock.LoginErrorMessage(lockedUsername));
|
||||
}
|
||||
if (cachedLogin == null)
|
||||
{
|
||||
return new AuthResponse("Failed to login.", "Missing cached login.");
|
||||
}
|
||||
|
||||
AuthResponse response = new AuthResponse();
|
||||
string authorization = data.platform == Platforms.Standalone ? string.Empty : base.Request.Headers.Authorization.ToString();
|
||||
|
||||
bool hasToken = !string.IsNullOrEmpty(authorization);
|
||||
bool isPlatform = false;
|
||||
|
||||
if (cachedLogin != null)
|
||||
{
|
||||
if (cachedLogin.platformId == data.platform_id &&
|
||||
cachedLogin.platform == data.platform &&
|
||||
(player.DeviceId == data.device_id || string.IsNullOrEmpty(player.DeviceId)) &&
|
||||
data.client_id == "recroom")
|
||||
{
|
||||
isPlatform = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasToken && !isPlatform && !verifiedByPassword)
|
||||
{
|
||||
base.Response.StatusCode = 401;
|
||||
return new AuthResponse("Failed to login.", "Invalid platform session.");
|
||||
}
|
||||
else
|
||||
{
|
||||
long userId = data.account_id;
|
||||
if (hasToken)
|
||||
{
|
||||
var headerUserId = authorization.GetUserId();
|
||||
if (headerUserId.HasValue)
|
||||
{
|
||||
userId = headerUserId.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
hasToken = false;
|
||||
}
|
||||
}
|
||||
|
||||
Success newToken = await GenerateToken(db, data.account_id, data.ver!, data.device_class, data.platform, data.platform_id);
|
||||
player.DeviceId = data.device_id!;
|
||||
|
||||
|
||||
if (data.device_class != DeviceClass.Unknown)
|
||||
player.DeviceClass = data.device_class;
|
||||
|
||||
|
||||
var loginIp = Request.GetClientIp();
|
||||
if (!string.IsNullOrWhiteSpace(loginIp))
|
||||
player.LastIpAddress = loginIp;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (!newToken.success || newToken.value == null)
|
||||
{
|
||||
response = new AuthResponse
|
||||
{
|
||||
error = "Error",
|
||||
error_description = newToken.error
|
||||
};
|
||||
return response;
|
||||
}
|
||||
else
|
||||
{
|
||||
response.access_token = (string)newToken.value;
|
||||
if (data.platform == Platforms.Standalone)
|
||||
Response.Headers[QuestRememberedSession.Header] = response.access_token;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var accountBytes = BitConverter.GetBytes(data.account_id);
|
||||
var refreshGuidBytes = new byte[16];
|
||||
Array.Copy(accountBytes, 0, refreshGuidBytes, 0, 8); response.refresh_token = new Guid(refreshGuidBytes).ToString();
|
||||
response.key = "c3Vja215ZGlja25nYQ==";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[NonAction]
|
||||
internal static async Task<Success> GenerateToken(RecDBContext db, long userId, string version, DeviceClass deviceClass, Platforms platform, string platformId)
|
||||
{
|
||||
|
||||
CachedLogin? cachedLogin = await db.CachedLogins.FirstOrDefaultAsync(
|
||||
login => login.accountId == userId &&
|
||||
login.platformId == platformId &&
|
||||
login.platform == platform);
|
||||
|
||||
Profile? profile = await db.Profiles.FirstOrDefaultAsync((plr) => plr.accountId == userId);
|
||||
|
||||
if (profile == null)
|
||||
return AuthResponses.MissingProfile;
|
||||
|
||||
if (await AccountLock.IsLockedAsync(db, userId) && cachedLogin == null)
|
||||
return new Success(false, AccountLock.ErrorCode, null);
|
||||
RealPresence? presence = await db.Presences.FirstOrDefaultAsync(x => x.playerId == userId);
|
||||
if (presence == null) return AuthResponses.MissingPresence;
|
||||
presence.deviceClass = deviceClass;
|
||||
|
||||
PlayerData? player = await db.Players.FirstOrDefaultAsync((plr) => plr.AccountId == userId);
|
||||
if (player == null) return AuthResponses.MissingPlayer;
|
||||
player.AppVersion = version;
|
||||
|
||||
if (cachedLogin != null)
|
||||
{
|
||||
cachedLogin.lastLoginTime = DateTime.UtcNow;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
MatchmakingUtils.ClearInRoom(userId);
|
||||
|
||||
List<Claim> claims = new List<Claim>();
|
||||
|
||||
claims.Add(new Claim("iss", DBConfig.TokenIssuer));
|
||||
claims.Add(new Claim("client_id", "recroom"));
|
||||
claims.Add(new Claim("role", "gameClient"));
|
||||
if (RadicadeServer.RecNet.Keepsakes.KeepsakeFeature.Enabled)
|
||||
{
|
||||
claims.Add(new Claim("role", "keepsake"));
|
||||
claims.Add(new Claim("role", "livekeepsakeeventroomsaveoverride"));
|
||||
}
|
||||
claims.Add(new Claim("rn.plat", ((int)platform).ToString()));
|
||||
claims.Add(new Claim("rn.platid", platformId));
|
||||
claims.Add(new Claim("rn.deviceclass", deviceClass.ToString()));
|
||||
claims.Add(new Claim("rn.ver", version));
|
||||
claims.Add(new Claim("idp", "local"));
|
||||
claims.Add(new Claim("userId", userId.ToString()));
|
||||
claims.Add(new Claim(ClaimTypes.NameIdentifier, userId.ToString()));
|
||||
|
||||
|
||||
|
||||
claims.Add(new Claim("tver", player.TokenVersion.ToString()));
|
||||
if (platform == Platforms.Standalone)
|
||||
claims.Add(new Claim("q.pwd", QuestRememberedSession.PasswordVersion(player.Hashword)));
|
||||
|
||||
|
||||
if (profile.isJunior || PlayerUtils.IsJuniorByBirthday(player.Birthday))
|
||||
claims.Add(new Claim("role", "junior"));
|
||||
if (profile.HasRole("developer")) claims.Add(new Claim("role", "developer"));
|
||||
if (profile.HasRole("betastudio")) claims.Add(new Claim("role", "betastudio"));
|
||||
if (profile.HasRole("moderator")) claims.Add(new Claim("role", "moderator"));
|
||||
if (profile.HasRole("screenshare")) claims.Add(new Claim("role", "screenshare"));
|
||||
if (profile.HasRole("influencer")) claims.Add(new Claim("role", "influencer"));
|
||||
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var key = Encoding.ASCII.GetBytes(DBConfig.JwtKey);
|
||||
var tokenDescriptor = new SecurityTokenDescriptor
|
||||
{
|
||||
Subject = new ClaimsIdentity(claims.ToArray()),
|
||||
Expires = DateTime.Now.AddDays(7),
|
||||
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256Signature)
|
||||
};
|
||||
var token = tokenHandler.CreateToken(tokenDescriptor);
|
||||
var tokenWritten = tokenHandler.WriteToken(token);
|
||||
return new Success(true, null, tokenWritten);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
|
||||
namespace RadicadeServer.Auth;
|
||||
|
||||
public class AuthShared
|
||||
{
|
||||
public static IDbContextFactory<RecDBContext> DbFactory = null!;
|
||||
|
||||
public static Task<bool> IsWhitelisted(string platformId)
|
||||
{
|
||||
|
||||
|
||||
|
||||
if (!SteamWhitelist.Enabled) return Task.FromResult(true);
|
||||
return Task.FromResult(SteamWhitelist.Contains(platformId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
|
||||
namespace RadicadeServer.RecNet.Auth.Cached
|
||||
{
|
||||
public class CachedLogin
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity), JsonIgnore]
|
||||
public long loginId { get; set; }
|
||||
|
||||
public long accountId { get; set; }
|
||||
|
||||
public Platforms platform { get; set; }
|
||||
|
||||
public string platformId { get; set; } = null!;
|
||||
|
||||
public DateTime lastLoginTime { get; set; }
|
||||
|
||||
public bool requirePassword { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet.Auth.Cached;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
|
||||
namespace RadicadeServer.Auth.Cached
|
||||
{
|
||||
[Route("auth/cachedlogin")]
|
||||
public class CachedLoginAPI : Controller
|
||||
{
|
||||
RecDBContext db;
|
||||
public CachedLoginAPI(RecDBContext context)
|
||||
{
|
||||
this.db = context;
|
||||
}
|
||||
|
||||
[HttpGet("forplatformid/{platform}/{platformId}")]
|
||||
public async Task<IActionResult> GetCachedLoginsForPlatformId(Platforms platform, string platformId)
|
||||
{
|
||||
if (platform == Platforms.Standalone) return Ok(new List<CachedLogin>());
|
||||
var cached = await db.CachedLogins.AsNoTracking()
|
||||
.Where(login => login.platformId == platformId && login.platform == platform)
|
||||
.Where(login => db.Players.Any(player => player.AccountId == login.accountId))
|
||||
.Where(login => db.Profiles.Any(profile => profile.accountId == login.accountId))
|
||||
.Where(login => db.Presences.Any(presence => presence.playerId == login.accountId))
|
||||
.OrderByDescending(login => login.lastLoginTime)
|
||||
.ToListAsync();
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
[HttpPost("forplatformids")]
|
||||
public async Task<List<CachedLogin>> GetCachedLoginsForPlatformIds([FromForm] string[] id)
|
||||
{
|
||||
if (id == null || id.Length <= 0) return [];
|
||||
|
||||
var requestedIds = id.Where(platformId => !string.IsNullOrWhiteSpace(platformId))
|
||||
.Distinct(StringComparer.Ordinal).ToArray();
|
||||
var cachedLogins = new List<CachedLogin>();
|
||||
foreach (var platformId in requestedIds)
|
||||
{
|
||||
var cached = await db.CachedLogins.AsNoTracking()
|
||||
.Where(login => login.platformId == platformId && login.platform != Platforms.Standalone)
|
||||
.Where(login => db.Players.Any(player => player.AccountId == login.accountId))
|
||||
.Where(login => db.Profiles.Any(profile => profile.accountId == login.accountId))
|
||||
.Where(login => db.Presences.Any(presence => presence.playerId == login.accountId))
|
||||
.OrderByDescending(login => login.lastLoginTime)
|
||||
.FirstOrDefaultAsync();
|
||||
if (cached != null) cachedLogins.Add(cached);
|
||||
}
|
||||
|
||||
return cachedLogins;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.DB;
|
||||
|
||||
namespace RadicadeServer.Auth
|
||||
{
|
||||
[Route("/")]
|
||||
public class DeviceApprovalAPI : Controller
|
||||
{
|
||||
|
||||
public DeviceApprovalAPI() {
|
||||
}
|
||||
|
||||
[HttpGet("device")]
|
||||
public IActionResult Device([FromQuery(Name = "user_code")] string userCode)
|
||||
{
|
||||
var (found, approved, accountId, expiresUtc) = StudioAuthAPI.DescribeUserCode(userCode);
|
||||
|
||||
return Content(DevicePage(userCode, found, approved, accountId, expiresUtc), "text/html");
|
||||
}
|
||||
|
||||
private static string DevicePage(string userCode, bool found, bool approved, long accountId, DateTime expiresUtc)
|
||||
{
|
||||
var glyph = found && approved ? "✅" : found ? "⏳" : "⚠️";
|
||||
var color = found && approved ? "#16a34a" : found ? "#d97706" : "#dc2626";
|
||||
var title = found && approved ? "Approved" : found ? "Waiting for approval" : "Code not recognised";
|
||||
var body = found && approved
|
||||
? "This server signs Rec Room Studio in automatically, so this code is already approved as account <b>" + accountId + "</b>. Close this tab and go back to Unity."
|
||||
: found
|
||||
? "The code is valid, but it has not been approved yet. Return to Unity and try again."
|
||||
: "This server has no pending sign-in for that code - it may have expired, or it may already have been used (device codes are single use). Start the sign-in again from Unity.";
|
||||
|
||||
var codeBlock = string.IsNullOrWhiteSpace(userCode)
|
||||
? "<p style=\"color:#475569;font-size:15px;\">No <b>user_code</b> was supplied. Unity sends one when it opens this page.</p>"
|
||||
: "<p style=\"color:#475569;font-size:15px;\">Your code</p>" +
|
||||
"<p style=\"font-family:Consolas,Menlo,monospace;font-size:26px;letter-spacing:3px;color:#0f172a;background:#f1f5f9;border-radius:8px;padding:12px 0;margin:0;\">" +
|
||||
System.Net.WebUtility.HtmlEncode(userCode) + "</p>";
|
||||
|
||||
var expiry = found && expiresUtc > DateTime.UtcNow
|
||||
? "<p style=\"color:#94a3b8;font-size:13px;margin-top:18px;\">Code expires " + expiresUtc.ToString("u") + " UTC.</p>"
|
||||
: "";
|
||||
|
||||
return "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>" +
|
||||
"Radicade - Rec Room Studio sign-in</title></head>" +
|
||||
"<body style=\"margin:0;background:#0f172a;font-family:Arial,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;\">" +
|
||||
"<div style=\"background:#fff;border-radius:12px;padding:40px;max-width:440px;text-align:center;box-shadow:0 10px 40px rgba(0,0,0,0.3);\">" +
|
||||
"<div style=\"font-size:44px;\">" + glyph + "</div>" +
|
||||
"<h1 style=\"color:" + color + ";font-size:22px;margin:12px 0;\">" + title + "</h1>" +
|
||||
codeBlock +
|
||||
"<p style=\"color:#475569;font-size:15px;line-height:1.6;margin-top:18px;\">" + body + "</p>" +
|
||||
expiry +
|
||||
"<p style=\"color:#94a3b8;font-size:12px;margin-top:24px;\">" + DBConfig.ServerUrl + " - local Radicade server</p>" +
|
||||
"</div></body></html>";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace RadicadeServer.Auth
|
||||
{
|
||||
[Route("auth/eac")]
|
||||
public class EACApi : Controller
|
||||
{
|
||||
[HttpGet("challenge")]
|
||||
public IActionResult GetFakeEACChallenge()
|
||||
{
|
||||
return new JsonResult("AQAAAOmBNw8TMlSZs9NsuyGxsU7WMoKVcieTW8IyoSMEGcbTIPg7DZ4JGZTCfg4b1h58FOOkM7r0LUhRVwlq8wo528o3LR9g/kHzlrVJA6H0+HaYTQzjKEe+zl5/VupqSXk8PXQ0BlNiRED3x5YMCGiM+F0fYmd0NU4K0d5hY+A=");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.RecNet.Auth.Photon;
|
||||
|
||||
namespace RadicadeServer.Auth.Photon
|
||||
{
|
||||
[ApiController, Route("auth/photon")]
|
||||
public class PhotonAuthAPI : Controller
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet("authenticate")]
|
||||
[HttpPost("authenticate")]
|
||||
public ActionResult<PhotonAuthResult> AuthenticatePhoton()
|
||||
{
|
||||
return Ok(new PhotonAuthResult { ResultCode = 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RadicadeServer.RecNet.Auth.Photon
|
||||
{
|
||||
public class PhotonAuthResult
|
||||
{
|
||||
public int ResultCode { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Message { get; set; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Nickname { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
using System.Text.Json;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
|
||||
namespace RadicadeServer.Auth;
|
||||
|
||||
public static class PlatformIdentity
|
||||
{
|
||||
public static readonly string[] AllowedSteamAppIds = ["480", "471710"];
|
||||
|
||||
public static HttpClient Client = new() { Timeout = TimeSpan.FromSeconds(10) };
|
||||
|
||||
public sealed record Proof(bool Verified, string? SteamId, string Detail)
|
||||
{
|
||||
public static Proof Ok(string? steamId) => new(true, steamId, "proved");
|
||||
public static Proof No(string detail) => new(false, null, detail);
|
||||
}
|
||||
|
||||
public static async Task<Proof> ProveAsync(
|
||||
Platforms platform,
|
||||
string? platformId,
|
||||
string? steamTicketHeader,
|
||||
string? platformAuth,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var id = platformId?.Trim() ?? string.Empty;
|
||||
if (id.Length == 0) return Proof.No("A platform id is required.");
|
||||
|
||||
return platform switch
|
||||
{
|
||||
Platforms.Steam => await ProveSteamAsync(id, steamTicketHeader, platformAuth, cancellationToken),
|
||||
Platforms.Oculus => ProveOculus(id, platformAuth),
|
||||
_ => Proof.No("Account creation is only available from Steam or Oculus on this server.")
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<Proof> ProveSteamAsync(
|
||||
string platformId,
|
||||
string? steamTicketHeader,
|
||||
string? platformAuth,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var ticket = string.IsNullOrWhiteSpace(steamTicketHeader)
|
||||
? ReadString(platformAuth, "Ticket")
|
||||
: steamTicketHeader.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ticket))
|
||||
return Proof.No("No Steam ticket was sent (x-steam-ticket header or platform_auth.Ticket).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey))
|
||||
{
|
||||
if (!DBConfig.TrustUnverifiedSteamIdentity)
|
||||
return Proof.No("Steam identity cannot be proved: no SteamApiKey is configured.");
|
||||
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] accepted Steam identity {platformId} " +
|
||||
"UNPROVED - no SteamApiKey is configured and TrustUnverifiedSteamIdentity is on.");
|
||||
return Proof.Ok(null);
|
||||
}
|
||||
|
||||
var attempted = new List<string>();
|
||||
foreach (var appId in CandidateAppIds(ReadString(platformAuth, "AppId")))
|
||||
{
|
||||
attempted.Add(appId);
|
||||
var result = await AuthenticateTicketAsync(ticket, appId, cancellationToken);
|
||||
|
||||
if (result.OtherApp) continue;
|
||||
|
||||
if (!result.Ok)
|
||||
return Proof.No(result.Detail);
|
||||
|
||||
if (!string.Equals(result.SteamId, platformId, StringComparison.Ordinal))
|
||||
return Proof.No($"The Steam ticket is for {result.SteamId}, not for the identity it was sent with.");
|
||||
|
||||
return Proof.Ok(result.SteamId);
|
||||
}
|
||||
|
||||
return Proof.No("The Steam ticket is not for an app this server accepts (tried " +
|
||||
string.Join(", ", attempted) + ").");
|
||||
}
|
||||
|
||||
private static Proof ProveOculus(string platformId, string? platformAuth)
|
||||
{
|
||||
var nonce = ReadString(platformAuth, "Nonce");
|
||||
if (string.IsNullOrWhiteSpace(nonce))
|
||||
return Proof.No("No Oculus nonce was sent (platform_auth.Nonce).");
|
||||
|
||||
return Proof.No("Oculus identity cannot be proved yet: no Oculus app secret is configured, " +
|
||||
"so the nonce cannot be validated.");
|
||||
}
|
||||
|
||||
public static async Task<string?> CheckSteamStandingAsync(string steamId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(DBConfig.SteamApiKey)) return null;
|
||||
if (string.IsNullOrWhiteSpace(steamId)) return null;
|
||||
|
||||
var key = Uri.EscapeDataString(DBConfig.SteamApiKey);
|
||||
var id = Uri.EscapeDataString(steamId);
|
||||
|
||||
try
|
||||
{
|
||||
using var summaryDoc = await GetJsonAsync(
|
||||
$"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={key}&steamids={id}",
|
||||
cancellationToken);
|
||||
if (summaryDoc == null) return null;
|
||||
|
||||
if (!summaryDoc.RootElement.TryGetProperty("response", out var summary) ||
|
||||
!summary.TryGetProperty("players", out var players) ||
|
||||
players.ValueKind != JsonValueKind.Array ||
|
||||
players.GetArrayLength() == 0)
|
||||
return "Your Steam profile was not found, so an account cannot be created for it.";
|
||||
|
||||
var player = players[0];
|
||||
|
||||
if (player.TryGetProperty("profilestate", out var profileState) &&
|
||||
profileState.ValueKind == JsonValueKind.Number &&
|
||||
profileState.TryGetInt32(out var state) && state != 1)
|
||||
return "Your Steam profile has to be set up before you can create an account.";
|
||||
|
||||
using var bansDoc = await GetJsonAsync(
|
||||
$"https://api.steampowered.com/ISteamUser/GetPlayerBans/v1/?key={key}&steamids={id}",
|
||||
cancellationToken);
|
||||
if (bansDoc == null) return null;
|
||||
|
||||
if (!bansDoc.RootElement.TryGetProperty("players", out var bans) ||
|
||||
bans.ValueKind != JsonValueKind.Array || bans.GetArrayLength() == 0)
|
||||
return "Steam would not report this account's ban status, so an account cannot be created for it.";
|
||||
|
||||
var entry = bans[0];
|
||||
if (entry.TryGetProperty("CommunityBanned", out var banned) && banned.ValueKind == JsonValueKind.True)
|
||||
return "Steam accounts with an active community ban are not allowed to create an account.";
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] Steam standing check could not be " +
|
||||
$"completed for {steamId}: {ex.GetType().Name}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public sealed record TicketResult(bool Ok, bool OtherApp, string? SteamId, string Detail);
|
||||
|
||||
public static async Task<TicketResult> AuthenticateTicketAsync(string ticket, string appId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await GetJsonAsync(
|
||||
"https://api.steampowered.com/ISteamUserAuth/AuthenticateUserTicket/v1/?key=" +
|
||||
$"{Uri.EscapeDataString(DBConfig.SteamApiKey)}&appid={Uri.EscapeDataString(appId)}" +
|
||||
$"&ticket={Uri.EscapeDataString(ticket)}",
|
||||
cancellationToken);
|
||||
|
||||
if (doc == null) return new TicketResult(false, false, null, "Steam did not answer the ticket check.");
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("response", out var response))
|
||||
return new TicketResult(false, false, null, "Steam did not answer the ticket check.");
|
||||
|
||||
if (response.TryGetProperty("error", out var error))
|
||||
{
|
||||
var code = error.TryGetProperty("errorcode", out var errorCode) && errorCode.TryGetInt32(out var value)
|
||||
? value
|
||||
: 0;
|
||||
var description = error.TryGetProperty("errordesc", out var desc) ? desc.GetString() : null;
|
||||
|
||||
if (code == 102)
|
||||
return new TicketResult(false, true, null, $"ticket for app {appId}");
|
||||
|
||||
return new TicketResult(false, false, null,
|
||||
$"Steam refused the ticket ({(description ?? "no description")}).");
|
||||
}
|
||||
|
||||
if (!response.TryGetProperty("params", out var parameters) ||
|
||||
parameters.ValueKind != JsonValueKind.Object ||
|
||||
!parameters.TryGetProperty("result", out var result) ||
|
||||
result.GetString() != "OK" ||
|
||||
!parameters.TryGetProperty("steamid", out var steamId) ||
|
||||
steamId.ValueKind != JsonValueKind.String)
|
||||
return new TicketResult(false, false, null, "Steam did not accept the ticket.");
|
||||
|
||||
return new TicketResult(true, false, steamId.GetString(), "ok");
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
|
||||
{
|
||||
return new TicketResult(false, false, null,
|
||||
$"Steam could not be reached to check the ticket ({ex.GetType().Name}).");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<JsonDocument?> GetJsonAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await Client.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] [Identity] Steam answered {(int)response.StatusCode} for " +
|
||||
$"{new Uri(url).AbsolutePath}.");
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
return await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> CandidateAppIds(string? claimed)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var ordered = new List<string>();
|
||||
void Add(string? candidate)
|
||||
{
|
||||
var value = candidate?.Trim();
|
||||
if (string.IsNullOrEmpty(value) || !seen.Add(value)) return;
|
||||
ordered.Add(value);
|
||||
}
|
||||
|
||||
Add(claimed);
|
||||
foreach (var id in AllowedSteamAppIds) Add(id);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
private static string? ReadString(string? json, string property)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
if (!doc.RootElement.TryGetProperty(property, out var value)) return null;
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => value.GetString(),
|
||||
JsonValueKind.Number => value.ToString(),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace RadicadeServer.RecNet.Auth.Cached
|
||||
{
|
||||
public class PlatformIds
|
||||
{
|
||||
public long Platform { get; set; }
|
||||
public long PlatformId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
|
||||
namespace RadicadeServer.RecNet.Auth
|
||||
{
|
||||
public class PlayerData
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.None)]
|
||||
public long AccountId { get; set; }
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public bool EmailVerified { get; set; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[JsonIgnore]
|
||||
public string? EmailVerifyToken { get; set; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[JsonIgnore]
|
||||
public string? PendingEmail { get; set; }
|
||||
|
||||
public string Phone { get; set; } = "";
|
||||
public string AppVersion { get; set; } = "";
|
||||
public long? Parent { get; set; }
|
||||
public int NameChanges { get; set; } = 3;
|
||||
public JuniorState JuniorState { get; set; }
|
||||
public bool IsJunior { get; set; }
|
||||
[JsonIgnore]
|
||||
public string Hashword { get; set; } = "";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[JsonIgnore]
|
||||
public int TokenVersion { get; set; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[JsonIgnore]
|
||||
public string? PasswordResetToken { get; set; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[JsonIgnore]
|
||||
public DateTime? PasswordResetExpires { get; set; }
|
||||
public DateTime? Birthday { get; set; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public bool CodeOfConductAccepted { get; set; }
|
||||
public StatusVisibility Visibility { get; set; }
|
||||
public DeviceClass DeviceClass { get; set; }
|
||||
public bool Developer { get; set; }
|
||||
[JsonIgnore]
|
||||
public string DeviceId { get; set; } = "";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[JsonIgnore]
|
||||
public string LastIpAddress { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace RadicadeServer.RecNet.Auth.Enums;
|
||||
|
||||
public enum PlayerRoleTypes
|
||||
{
|
||||
none,
|
||||
influencer,
|
||||
screenshare,
|
||||
moderator,
|
||||
developer
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text.Json.Serialization;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Auth.Enums;
|
||||
|
||||
namespace RadicadeServer.RecNet.Auth
|
||||
{
|
||||
public class Profile
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public long accountId { get; set; }
|
||||
|
||||
public string profileImage { get; set; } = null!;
|
||||
|
||||
public string? bannerImage { get; set; }
|
||||
|
||||
public bool isRecentHistoryVisible { get; set; } = true;
|
||||
|
||||
public bool isJunior { get; set; }
|
||||
|
||||
public int platforms { get; set; }
|
||||
|
||||
public string username { get; set; } = null!;
|
||||
public string displayName { get; set; } = null!;
|
||||
|
||||
public string? bio { get; set; }
|
||||
public int personalPronouns { get; set; }
|
||||
public int identityFlags { get; set; }
|
||||
|
||||
public string? displayEmoji { get; set; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public bool NameLocked { get; set; }
|
||||
public bool BioLocked { get; set; }
|
||||
|
||||
public DateTime createdAt { get; set; }
|
||||
|
||||
public string? role { get; set; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public bool developer => HasRole("developer");
|
||||
public bool moderator => HasRole("moderator");
|
||||
public bool screenshare => HasRole("screenshare");
|
||||
public bool influencer => HasRole("influencer");
|
||||
|
||||
public bool HasRole(string expectedRoleName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(role)) return false;
|
||||
|
||||
|
||||
var normalized = expectedRoleName switch
|
||||
{
|
||||
"screenshare" => "sharescreen",
|
||||
"influencer" => "imfluencer",
|
||||
_ => expectedRoleName
|
||||
};
|
||||
return role.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Any(r => r.Equals(expectedRoleName, StringComparison.OrdinalIgnoreCase) || r.Equals(normalized, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using RadicadeServer.DB;
|
||||
|
||||
namespace RadicadeServer.Auth;
|
||||
|
||||
internal static class QuestRememberedSession
|
||||
{
|
||||
internal const string Header = "X-Radicade-Quest-Session";
|
||||
|
||||
internal static async Task<long?> ValidateAsync(RecDBContext db, string token, string deviceId, long requestedAccount)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token) || token.Length > 8192) return null;
|
||||
try
|
||||
{
|
||||
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
||||
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(DBConfig.JwtKey)),
|
||||
ValidAlgorithms = new[] { SecurityAlgorithms.HmacSha256 },
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = DBConfig.TokenIssuer,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
RequireExpirationTime = true,
|
||||
ClockSkew = TimeSpan.Zero
|
||||
}, out _);
|
||||
string? Claim(string name) => principal.FindFirst(name)?.Value;
|
||||
if (Claim("client_id") != "recroom" || Claim("rn.plat") != "7" ||
|
||||
Claim("rn.platid") != "radicade-quest:" + deviceId ||
|
||||
!long.TryParse(Claim("userId"), out var account) || account <= 0 ||
|
||||
(requestedAccount > 0 && requestedAccount != account) ||
|
||||
!int.TryParse(Claim("tver"), out var version)) return null;
|
||||
var player = await db.Players.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == account);
|
||||
return player != null && player.TokenVersion == version &&
|
||||
Claim("q.pwd") == PasswordVersion(player.Hashword) ? account : null;
|
||||
}
|
||||
catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string PasswordVersion(string? hash) =>
|
||||
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hash ?? "")));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using System.Reflection;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Auth;
|
||||
|
||||
namespace RadicadeServer.Utils.Attributes
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
|
||||
namespace RadicadeServer.API
|
||||
{
|
||||
[Route("role")]
|
||||
[Route("auth/role")]
|
||||
public class RolesAPI(RecDBContext db) : Controller
|
||||
{
|
||||
[HttpGet("{roleType}/{id}")]
|
||||
public async Task<bool> GetDeveloperRole(string roleType, long id)
|
||||
{
|
||||
var player = await db.Profiles.FirstOrDefaultAsync(x => x.accountId == id);
|
||||
if (player != null)
|
||||
{
|
||||
return player.HasRole(roleType);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RadicadeServer.Auth;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static class SteamWhitelist
|
||||
{
|
||||
public sealed class WhitelistFile
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public List<string> SteamIds { get; set; } = new();
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions CaseInsensitive = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
};
|
||||
|
||||
private static readonly object Lock = new();
|
||||
private static WhitelistFile? _data;
|
||||
private static string _filePath = "";
|
||||
private static bool _loaded;
|
||||
private static DateTime _lastWriteUtc = DateTime.MinValue;
|
||||
|
||||
|
||||
public static string FilePath => _filePath;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public static bool CreatedEmptyConfig { get; private set; }
|
||||
|
||||
|
||||
public static void Load()
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
if (_loaded) return;
|
||||
|
||||
_filePath = Path.GetFullPath(Path.Join(DBConfig.PrivateData, "steamwhitelist.json"));
|
||||
_loaded = true;
|
||||
CreatedEmptyConfig = !File.Exists(_filePath);
|
||||
|
||||
try
|
||||
{
|
||||
if (CreatedEmptyConfig)
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
_data = new WhitelistFile { Enabled = false, SteamIds = new List<string>() };
|
||||
}
|
||||
else
|
||||
{
|
||||
var raw = File.ReadAllText(_filePath);
|
||||
_data = JsonSerializer.Deserialize<WhitelistFile>(raw, CaseInsensitive) ?? new WhitelistFile();
|
||||
_data.SteamIds ??= new List<string>();
|
||||
_lastWriteUtc = File.GetLastWriteTimeUtc(_filePath);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_data = new WhitelistFile { Enabled = false, SteamIds = new List<string>() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static bool Enabled
|
||||
{
|
||||
get { EnsureLoaded(); lock (Lock) return _data!.Enabled; }
|
||||
set { EnsureLoaded(); lock (Lock) { _data!.Enabled = value; SaveLocked(); } }
|
||||
}
|
||||
|
||||
|
||||
public static IReadOnlyCollection<string> SteamIds
|
||||
{
|
||||
get { EnsureLoaded(); lock (Lock) return _data!.SteamIds.ToList(); }
|
||||
}
|
||||
|
||||
|
||||
public static void SetSteamIds(IEnumerable<string> steamIds)
|
||||
{
|
||||
EnsureLoaded();
|
||||
lock (Lock)
|
||||
{
|
||||
_data!.SteamIds = steamIds
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.Distinct()
|
||||
.ToList();
|
||||
SaveLocked();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static bool Contains(string steamId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(steamId)) return false;
|
||||
EnsureLoaded();
|
||||
lock (Lock) return _data!.SteamIds.Contains(steamId.Trim());
|
||||
}
|
||||
|
||||
|
||||
public static bool Add(string steamId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(steamId)) return false;
|
||||
steamId = steamId.Trim();
|
||||
|
||||
EnsureLoaded();
|
||||
lock (Lock)
|
||||
{
|
||||
if (_data!.SteamIds.Contains(steamId)) return false;
|
||||
_data.SteamIds.Add(steamId);
|
||||
SaveLocked();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static bool Remove(string steamId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(steamId)) return false;
|
||||
steamId = steamId.Trim();
|
||||
|
||||
EnsureLoaded();
|
||||
lock (Lock)
|
||||
{
|
||||
var removed = _data!.SteamIds.Remove(steamId);
|
||||
if (removed) SaveLocked();
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureLoaded()
|
||||
{
|
||||
if (!_loaded) { Load(); return; }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
lock (Lock)
|
||||
{
|
||||
DateTime lastWriteUtc;
|
||||
try { lastWriteUtc = File.GetLastWriteTimeUtc(_filePath); }
|
||||
catch { return; }
|
||||
|
||||
if (lastWriteUtc > _lastWriteUtc)
|
||||
{
|
||||
try
|
||||
{
|
||||
var raw = File.ReadAllText(_filePath);
|
||||
var reloaded = JsonSerializer.Deserialize<WhitelistFile>(raw, CaseInsensitive);
|
||||
if (reloaded != null)
|
||||
{
|
||||
reloaded.SteamIds ??= new List<string>();
|
||||
_data = reloaded;
|
||||
_lastWriteUtc = lastWriteUtc;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void SaveLocked()
|
||||
{
|
||||
try
|
||||
{
|
||||
var dir = Path.GetDirectoryName(_filePath);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||
File.WriteAllText(_filePath, JsonSerializer.Serialize(_data, options));
|
||||
_lastWriteUtc = File.GetLastWriteTimeUtc(_filePath);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.Utils;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
using RadicadeServer.RecNet.Enums.Rooms;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RadicadeServer.Auth
|
||||
{
|
||||
[Route("auth")]
|
||||
public class StudioAuthAPI : Controller
|
||||
{
|
||||
private const int DeviceCodeLifetimeSeconds = 900;
|
||||
private const int PollIntervalSeconds = 2;
|
||||
private const int TokenLifetimeSeconds = 604800;
|
||||
|
||||
private static readonly ConcurrentDictionary<string, PendingDeviceAuthorization> Pending = new(StringComparer.Ordinal);
|
||||
private const string UserCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
private sealed class PendingDeviceAuthorization
|
||||
{
|
||||
public string UserCode = string.Empty;
|
||||
public string Scope = "studio";
|
||||
public DateTime ExpiresUtc = DateTime.UtcNow;
|
||||
public long AccountId;
|
||||
public bool Approved;
|
||||
}
|
||||
|
||||
RecDBContext db;
|
||||
private readonly IConfiguration config;
|
||||
|
||||
public StudioAuthAPI(RecDBContext context,IConfiguration configuration) {
|
||||
this.db = context;
|
||||
this.config = configuration;
|
||||
}
|
||||
|
||||
[HttpGet("connect/deviceauthorization")]
|
||||
[HttpPost("connect/deviceauthorization")]
|
||||
public async Task<IActionResult> DeviceAuthorization()
|
||||
{
|
||||
var fields = await ReadRequestFields();
|
||||
|
||||
if (!IsDeviceFlowAllowed())
|
||||
{
|
||||
return BadRequest(new { error = "access_denied", error_description = "Rec Room Studio sign-in is only available from the machine running the server." });
|
||||
}
|
||||
|
||||
var accountId = await ResolveStudioAccountId(fields);
|
||||
if (accountId <= 0)
|
||||
{
|
||||
return BadRequest(new { error = "access_denied", error_description = "No account is configured for Rec Room Studio on this server." });
|
||||
}
|
||||
|
||||
PruneExpired();
|
||||
|
||||
var deviceCode = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
|
||||
var pending = new PendingDeviceAuthorization
|
||||
{
|
||||
UserCode = GenerateUserCode(),
|
||||
Scope = fields.TryGetValue("scope", out var scope) && !string.IsNullOrWhiteSpace(scope) ? scope : "studio",
|
||||
ExpiresUtc = DateTime.UtcNow.AddSeconds(DeviceCodeLifetimeSeconds),
|
||||
AccountId = accountId,
|
||||
Approved = true
|
||||
};
|
||||
Pending[deviceCode] = pending;
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
device_code = deviceCode,
|
||||
user_code = pending.UserCode,
|
||||
verification_uri = $"{DBConfig.ServerUrl}/device",
|
||||
verification_uri_complete = $"{DBConfig.ServerUrl}/device?user_code={pending.UserCode}",
|
||||
expires_in = DeviceCodeLifetimeSeconds,
|
||||
interval = PollIntervalSeconds,
|
||||
scope = pending.Scope
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("connect/studiotoken")]
|
||||
[HttpPost("connect/studiotoken")]
|
||||
public async Task<IActionResult> StudioToken()
|
||||
{
|
||||
var fields = await ReadRequestFields();
|
||||
var grantType = fields.GetValueOrDefault("grant_type") ?? string.Empty;
|
||||
|
||||
if (IsDeviceCodeGrant(grantType))
|
||||
return await CompleteDeviceAuthorization(fields);
|
||||
|
||||
if (IsRefreshGrant(grantType))
|
||||
return await RefreshStudioToken(fields);
|
||||
|
||||
if (grantType.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_request", error_description = "grant_type is required." });
|
||||
}
|
||||
return BadRequest(new { error = "unsupported_grant_type", error_description = $"grant_type '{grantType}' is not supported." });
|
||||
}
|
||||
|
||||
private static bool IsDeviceCodeGrant(string grantType) =>
|
||||
grantType.Replace("-", "_").Contains("device_code", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool IsRefreshGrant(string grantType) =>
|
||||
grantType.Replace("-", "_").Contains("refresh_token", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
internal static (bool Found, bool Approved, long AccountId, DateTime ExpiresUtc) DescribeUserCode(string userCode)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userCode)) return (false, false, 0, default);
|
||||
|
||||
var wanted = NormalizeUserCode(userCode);
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var entry in Pending)
|
||||
{
|
||||
if (entry.Value.ExpiresUtc <= now) continue;
|
||||
if (NormalizeUserCode(entry.Value.UserCode) != wanted) continue;
|
||||
return (true, entry.Value.Approved, entry.Value.AccountId, entry.Value.ExpiresUtc);
|
||||
}
|
||||
|
||||
return (false, false, 0, default);
|
||||
}
|
||||
|
||||
private static string NormalizeUserCode(string userCode) =>
|
||||
new string(userCode.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant();
|
||||
|
||||
private async Task<IActionResult> CompleteDeviceAuthorization(Dictionary<string, string> fields)
|
||||
{
|
||||
var deviceCode = fields.GetValueOrDefault("device_code") ?? string.Empty;
|
||||
if (!Pending.TryGetValue(deviceCode, out var pending))
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "Unknown device code." });
|
||||
|
||||
if (pending.ExpiresUtc <= DateTime.UtcNow)
|
||||
{
|
||||
Pending.TryRemove(deviceCode, out _);
|
||||
return BadRequest(new { error = "expired_token", error_description = "The device code has expired." });
|
||||
}
|
||||
|
||||
if (!pending.Approved)
|
||||
return BadRequest(new { error = "authorization_pending", error_description = "The user has not approved this sign-in yet." });
|
||||
|
||||
Pending.TryRemove(deviceCode, out _);
|
||||
return await IssueStudioTokens(pending.AccountId);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> RefreshStudioToken(Dictionary<string, string> fields)
|
||||
{
|
||||
var refreshToken = fields.GetValueOrDefault("refresh_token") ?? string.Empty;
|
||||
var accountId = AccountIdFromRefreshToken(refreshToken);
|
||||
|
||||
if (accountId > 0 && !await db.Players.AnyAsync(x => x.AccountId == accountId))
|
||||
accountId = 0;
|
||||
|
||||
if (accountId <= 0)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_grant", error_description = "Unknown refresh token." });
|
||||
}
|
||||
|
||||
return await IssueStudioTokens(accountId);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> IssueStudioTokens(long accountId)
|
||||
{
|
||||
await EnsurePresence(accountId);
|
||||
|
||||
var token = await AuthAPI.GenerateToken(db, accountId, DBConfig.GameVersion, DeviceClass.Screen, Platforms.Steam, $"studio:{accountId}");
|
||||
if (!token.success || token.value is not string accessToken)
|
||||
{
|
||||
return BadRequest(new { error = "invalid_grant", error_description = token.error ?? "Could not create a studio token." });
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
access_token = accessToken,
|
||||
refresh_token = RefreshTokenFor(accountId),
|
||||
token_type = "Bearer",
|
||||
expires_in = TokenLifetimeSeconds,
|
||||
scope = "studio"
|
||||
});
|
||||
}
|
||||
|
||||
private static string RefreshTokenFor(long accountId)
|
||||
{
|
||||
var bytes = new byte[16];
|
||||
Array.Copy(BitConverter.GetBytes(accountId), 0, bytes, 0, 8);
|
||||
return new Guid(bytes).ToString();
|
||||
}
|
||||
|
||||
private static long AccountIdFromRefreshToken(string refreshToken)
|
||||
{
|
||||
if (!Guid.TryParse(refreshToken, out var guid)) return 0;
|
||||
var embedded = BitConverter.ToInt64(guid.ToByteArray(), 0);
|
||||
return embedded > 0 ? embedded : 0;
|
||||
}
|
||||
|
||||
private async Task EnsurePresence(long accountId)
|
||||
{
|
||||
if (await db.Presences.AnyAsync(x => x.playerId == accountId)) return;
|
||||
|
||||
db.Presences.Add(new RealPresence
|
||||
{
|
||||
playerId = accountId,
|
||||
deviceClass = DeviceClass.Screen,
|
||||
statusVisibility = StatusVisibility.Public,
|
||||
vRMovementMode = VRMovementModes.TELEPORT
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<long> ResolveStudioAccountId(Dictionary<string, string> fields)
|
||||
{
|
||||
var configured = config["Radicade:Studio:AutoApproveAccountId"];
|
||||
if (long.TryParse(configured, out var configuredId) && configuredId > 0 && await db.Players.AnyAsync(x => x.AccountId == configuredId))
|
||||
return configuredId;
|
||||
|
||||
if (long.TryParse(fields.GetValueOrDefault("account_id"), out var requestedId) && requestedId > 0 && await db.Players.AnyAsync(x => x.AccountId == requestedId))
|
||||
return requestedId;
|
||||
|
||||
var developer = await db.Profiles.Where(p => p.role != null && p.role.Contains("developer"))
|
||||
.OrderBy(p => p.accountId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (developer != null) return developer.accountId;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private bool IsDeviceFlowAllowed()
|
||||
{
|
||||
if (bool.TryParse(config["Radicade:Studio:AllowRemoteDeviceAuth"], out var allowRemote) && allowRemote) return true;
|
||||
|
||||
var ip = Request.GetClientIp();
|
||||
if (string.IsNullOrWhiteSpace(ip)) return false;
|
||||
if (IPAddress.TryParse(ip, out var parsed)) return IPAddress.IsLoopback(parsed);
|
||||
return ip.Equals("localhost", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string GenerateUserCode()
|
||||
{
|
||||
var buffer = new char[9];
|
||||
var bytes = RandomNumberGenerator.GetBytes(8);
|
||||
for (var i = 0; i < 8; i++)
|
||||
{
|
||||
buffer[i < 4 ? i : i + 1] = UserCodeAlphabet[bytes[i] % UserCodeAlphabet.Length];
|
||||
}
|
||||
buffer[4] = '-';
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
private static void PruneExpired()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var entry in Pending)
|
||||
{
|
||||
if (entry.Value.ExpiresUtc <= now) Pending.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadRequestFields()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var query in Request.Query)
|
||||
fields[query.Key] = query.Value.ToString();
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
foreach (var form in Request.Form)
|
||||
fields[form.Key] = form.Value.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
Request.Body.Position = 0;
|
||||
using var reader = new StreamReader(Request.Body, Encoding.UTF8, leaveOpen: true);
|
||||
var raw = await reader.ReadToEndAsync();
|
||||
Request.Body.Position = 0;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(raw);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in document.RootElement.EnumerateObject())
|
||||
{
|
||||
fields[property.Name] = property.Value.ValueKind == JsonValueKind.String
|
||||
? property.Value.GetString() ?? string.Empty
|
||||
: property.Value.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
fields["raw"] = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static string Describe(Dictionary<string, string> fields)
|
||||
{
|
||||
var parts = fields.Select(field => field.Key.Contains("secret", StringComparison.OrdinalIgnoreCase)
|
||||
|| field.Key.Contains("password", StringComparison.OrdinalIgnoreCase)
|
||||
? $"{field.Key}=<redacted>"
|
||||
: $"{field.Key}={field.Value}");
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
|
||||
namespace RadicadeServer.RecNet.Auth.Connection
|
||||
{
|
||||
public class TokenConnectData
|
||||
{
|
||||
public string grant_type { get; set; } = null!;
|
||||
public long account_id { get; set; }
|
||||
public string ver { get; set; } = null!;
|
||||
public DeviceClass device_class { get; set; }
|
||||
public string device_id { get; set; } = null!;
|
||||
public Platforms platform { get; set; }
|
||||
public string platform_id { get; set; } = null!;
|
||||
public string client_id { get; set; } = null!;
|
||||
|
||||
public string? username { get; set; }
|
||||
public string? password { get; set; }
|
||||
|
||||
public DateTime? birthday { get; set; }
|
||||
|
||||
public DateTime? birthdayUTC { get; set; }
|
||||
|
||||
public string? email { get; set; }
|
||||
|
||||
public string? avatar { get; set; }
|
||||
|
||||
public string? avatarJson { get; set; }
|
||||
|
||||
public string? platform_auth { get; set; }
|
||||
|
||||
|
||||
|
||||
|
||||
public string? refresh_token { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace RadicadeServer.RecNet.Avatar
|
||||
{
|
||||
public class Avatar
|
||||
{
|
||||
public string OutfitSelections { get; set; } = null!;
|
||||
public string HairColor { get; set; } = null!;
|
||||
public string SkinColor { get; set; } = null!;
|
||||
public string FaceFeatures { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace RadicadeServer.RecNet.Avatar
|
||||
{
|
||||
public class AvatarItem
|
||||
{
|
||||
public string AvatarItemDesc { get; set; } = null!;
|
||||
public int UnlockedLevel { get; set; }
|
||||
public int PlatformMask { get; set; } = -1;
|
||||
public string FriendlyName { get; set; } = string.Empty;
|
||||
public string Tooltip { get; set; } = string.Empty;
|
||||
public int Rarity { get; set; }
|
||||
public int AvatarItemType { get; set; }
|
||||
public int AvatarItemId { get; set; }
|
||||
public bool IsBaseAvatarItem { get; set; }
|
||||
public string? TagList { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public string ThumbnailImage { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Text;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar;
|
||||
|
||||
public static class AvatarItemDescription
|
||||
{
|
||||
public static string Normalize(string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
return description?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = description.Trim();
|
||||
var segments = trimmed.Split(',');
|
||||
if (segments[0].Length == 0)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
var hasEmptySlot = false;
|
||||
for (var index = 1; index < segments.Length - 1; index++)
|
||||
{
|
||||
if (segments[index].Length == 0)
|
||||
{
|
||||
hasEmptySlot = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasEmptySlot)
|
||||
{
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(segments[0]).Append(',');
|
||||
for (var index = 1; index < segments.Length; index++)
|
||||
{
|
||||
var material = segments[index].Trim();
|
||||
if (material.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(material).Append(',');
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
public static bool HasEmptyMaterialSlot(string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var segments = description.Trim().Split(',');
|
||||
for (var index = 1; index < segments.Length - 1; index++)
|
||||
{
|
||||
if (segments[index].Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace RadicadeServer.RecNet.Avatar;
|
||||
|
||||
public sealed class AvatarItemV4(AvatarItem item)
|
||||
{
|
||||
public int AvatarItemId { get; } = item.AvatarItemId;
|
||||
public string AvatarItemDesc { get; } = item.AvatarItemDesc;
|
||||
public int AvatarItemType { get; } = item.AvatarItemType;
|
||||
public int PlatformMask { get; } = item.PlatformMask;
|
||||
public string FriendlyName { get; } = item.FriendlyName ?? string.Empty;
|
||||
public string Tooltip { get; } = item.Tooltip ?? string.Empty;
|
||||
public int Rarity { get; } = item.Rarity;
|
||||
public string TagList { get; } = item.TagList ?? string.Empty;
|
||||
public bool IsBaseAvatarItem { get; } = item.IsBaseAvatarItem;
|
||||
public string ThumbnailImage { get; } = item.ThumbnailImage ?? string.Empty;
|
||||
public DateTime CreatedAt { get; } = item.CreatedAt;
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
using RadicadeServer.Extensions;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar;
|
||||
|
||||
public static class AvatarUnlockCatalog
|
||||
{
|
||||
public static string LoadStarterJson()
|
||||
{
|
||||
using var stream = typeof(AvatarUnlockCatalog).Assembly.GetManifestResourceStream("StarterAvatarItems.json")
|
||||
?? throw new InvalidOperationException("The starter avatar inventory resource is missing.");
|
||||
using var reader = new StreamReader(stream);
|
||||
return reader.ReadToEnd();
|
||||
}
|
||||
|
||||
public static List<AvatarItem> GetStarterItems()
|
||||
{
|
||||
var starters = EnrichWithMetadata(ParseCatalog(LoadStarterJson()), DBConfig.DefaultAvatarCatalog);
|
||||
starters.AddRange(ParseCatalog(DBConfig.DefaultAvatarCatalog).Where(item => item.IsBaseAvatarItem));
|
||||
return KeepClientAvailableItems(KeepResolvableItems(starters))
|
||||
.DistinctBy(item => item.AvatarItemDesc, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions CatalogOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
NumberHandling = JsonNumberHandling.AllowReadingFromString
|
||||
};
|
||||
|
||||
public static List<AvatarItem> ParseCatalog(string? json)
|
||||
{
|
||||
var items = new List<AvatarItem>();
|
||||
if (string.IsNullOrWhiteSpace(json)) return items;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json, new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
CommentHandling = JsonCommentHandling.Skip
|
||||
});
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
var index = 0;
|
||||
foreach (var entry in document.RootElement.EnumerateArray())
|
||||
{
|
||||
try
|
||||
{
|
||||
var item = entry.Deserialize<AvatarItem>(CatalogOptions);
|
||||
if (item != null && !string.IsNullOrWhiteSpace(item.AvatarItemDesc))
|
||||
items.Add(item);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
index++;
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
}
|
||||
return KeepSupportedItems(items);
|
||||
}
|
||||
|
||||
public static bool NeedsMetadataEnrichment(IEnumerable<AvatarItem>? items)
|
||||
{
|
||||
return items != null && items.Any(item =>
|
||||
item == null || item.AvatarItemId <= 0 || string.IsNullOrWhiteSpace(item.ThumbnailImage));
|
||||
}
|
||||
|
||||
public static List<AvatarItem> EnrichWithMetadata(IEnumerable<AvatarItem> items, string? metadataJson)
|
||||
{
|
||||
var metadata = ParseCatalog(metadataJson).ToDictionary(x => x.AvatarItemDesc, StringComparer.OrdinalIgnoreCase);
|
||||
return items.Select(item => metadata.TryGetValue(item.AvatarItemDesc, out var full)
|
||||
? new AvatarItem
|
||||
{
|
||||
AvatarItemDesc = item.AvatarItemDesc, UnlockedLevel = item.UnlockedLevel,
|
||||
PlatformMask = item.PlatformMask, FriendlyName = full.FriendlyName,
|
||||
Tooltip = full.Tooltip, Rarity = full.Rarity, AvatarItemType = full.AvatarItemType,
|
||||
AvatarItemId = full.AvatarItemId, IsBaseAvatarItem = full.IsBaseAvatarItem,
|
||||
TagList = full.TagList, CreatedAt = full.CreatedAt, ThumbnailImage = full.ThumbnailImage
|
||||
}
|
||||
: item).ToList();
|
||||
}
|
||||
private static readonly string[] KnownUnsafeDescriptionParts =
|
||||
[
|
||||
"07116e9b-b182-40b4-90c2-99738e7aedd6",
|
||||
"11b3413f-2c18-41cb-8ef2-55fb825ed476",
|
||||
"UjSxxUK7z0GGdjvI_NsJ3Q",
|
||||
"X0nzvhAtgEKqzjQPb9d3qQ",
|
||||
"6taO0KRM_kuKoaTnOIsHRg",
|
||||
"KUAMuM41hk-YLZoqTiKncA,LjQ1UKN2xkqJkok40Dxaew",
|
||||
"5aa7be74-19a9-4dd8-b525-46c5aef34866",
|
||||
];
|
||||
|
||||
private static readonly Lazy<List<AvatarItem>> SafeDefaultItems = new(() =>
|
||||
DBConfig.SafeDefaultAvatarUnlocks2023.FromJson<List<AvatarItem>>()
|
||||
?? new List<AvatarItem>());
|
||||
private static readonly Lazy<HashSet<string>> SafeDefaultDescriptions = new(() =>
|
||||
SafeDefaultItems.Value
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.AvatarItemDesc))
|
||||
.Select(item => item.AvatarItemDesc)
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
public static List<AvatarItem> GetSafeDefaultItems()
|
||||
{
|
||||
var catalogJson = string.Equals(DBConfig.DefaultAvatarCatalog, DBConfig.SafeDefaultAvatarUnlocks2023,
|
||||
StringComparison.Ordinal) ? DBConfig.DefaultAvatarUnlocks : DBConfig.DefaultAvatarCatalog;
|
||||
var items = ParseCatalog(catalogJson);
|
||||
if (items.Count == 0)
|
||||
items = ParseCatalog(DBConfig.DefaultAvatarUnlocks);
|
||||
var supportedItems = KeepResolvableItems(items);
|
||||
var candidates = supportedItems.Count > 0
|
||||
? supportedItems
|
||||
: SafeDefaultItems.Value.Select(Clone).ToList();
|
||||
var available = KeepClientAvailableItems(candidates);
|
||||
return available.Count > 0 ? available : candidates;
|
||||
}
|
||||
|
||||
public static List<AvatarItem> KeepClientAvailableItems(IEnumerable<AvatarItem>? items)
|
||||
{
|
||||
if (items == null)
|
||||
{
|
||||
return new List<AvatarItem>();
|
||||
}
|
||||
|
||||
var cutoff = DBConfig.AvatarItemReleaseCutoffUtc;
|
||||
if (cutoff == default)
|
||||
{
|
||||
return items.Where(item => item != null).ToList();
|
||||
}
|
||||
|
||||
return items.Where(item => item != null && IsAvailableToClient(item, cutoff)).ToList();
|
||||
}
|
||||
|
||||
public static bool IsAvailableToClient(AvatarItem item)
|
||||
{
|
||||
return IsAvailableToClient(item, DBConfig.AvatarItemReleaseCutoffUtc);
|
||||
}
|
||||
|
||||
private static bool IsAvailableToClient(AvatarItem? item, DateTime cutoffUtc)
|
||||
{
|
||||
if (item == null || item.CreatedAt == default)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var createdAt = item.CreatedAt.Kind == DateTimeKind.Unspecified
|
||||
? DateTime.SpecifyKind(item.CreatedAt, DateTimeKind.Utc)
|
||||
: item.CreatedAt.ToUniversalTime();
|
||||
|
||||
return createdAt < cutoffUtc;
|
||||
}
|
||||
|
||||
public static List<AvatarItem> KeepSupportedItems(IEnumerable<AvatarItem>? items)
|
||||
{
|
||||
if (items == null)
|
||||
{
|
||||
return new List<AvatarItem>();
|
||||
}
|
||||
|
||||
return items
|
||||
.Where(item =>
|
||||
item != null &&
|
||||
!string.IsNullOrWhiteSpace(item.AvatarItemDesc) &&
|
||||
!IsKnownUnsafe(item.AvatarItemDesc))
|
||||
.Select(Clone)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static List<AvatarItem> KeepResolvableItems(IEnumerable<AvatarItem>? items)
|
||||
{
|
||||
var supportedItems = KeepSupportedItems(items);
|
||||
var resolvable = DBConfig.ResolvableAvatarItemDescs;
|
||||
if (resolvable == null || resolvable.Count == 0)
|
||||
{
|
||||
return supportedItems;
|
||||
}
|
||||
|
||||
return supportedItems.Where(item => IsClientResolvableDescription(item.AvatarItemDesc)).ToList();
|
||||
}
|
||||
|
||||
public static bool IsClientResolvableDescription(string? description)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(description)) return false;
|
||||
|
||||
var resolvable = DBConfig.ResolvableAvatarItemDescs;
|
||||
return resolvable == null || resolvable.Count == 0 ||
|
||||
resolvable.Contains(AvatarItemDescription.Normalize(description));
|
||||
}
|
||||
|
||||
public static AvatarItem? FindItem(string? avatarItemDesc, string? catalogueJson = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(avatarItemDesc)) return null;
|
||||
|
||||
var wanted = AvatarItemDescription.Normalize(avatarItemDesc);
|
||||
if (string.IsNullOrWhiteSpace(wanted)) return null;
|
||||
|
||||
var json = string.IsNullOrWhiteSpace(catalogueJson) ? DBConfig.DefaultAvatarCatalog : catalogueJson;
|
||||
return CatalogueByDescription(json).TryGetValue(wanted, out var item) ? Clone(item) : null;
|
||||
}
|
||||
|
||||
private static string? _byDescriptionCacheKey;
|
||||
private static Dictionary<string, AvatarItem>? _byDescriptionCache;
|
||||
|
||||
private static Dictionary<string, AvatarItem> CatalogueByDescription(string? json)
|
||||
{
|
||||
if (_byDescriptionCache != null && string.Equals(_byDescriptionCacheKey, json, StringComparison.Ordinal))
|
||||
return _byDescriptionCache;
|
||||
|
||||
var byDescription = new Dictionary<string, AvatarItem>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in ParseCatalog(json))
|
||||
{
|
||||
if (item == null || string.IsNullOrWhiteSpace(item.AvatarItemDesc)) continue;
|
||||
if (!byDescription.ContainsKey(item.AvatarItemDesc)) byDescription[item.AvatarItemDesc] = item;
|
||||
}
|
||||
|
||||
_byDescriptionCache = byDescription;
|
||||
_byDescriptionCacheKey = json;
|
||||
return byDescription;
|
||||
}
|
||||
|
||||
public static bool IsKnownSafe(string avatarItemDesc)
|
||||
{
|
||||
return SafeDefaultDescriptions.Value.Contains(avatarItemDesc);
|
||||
}
|
||||
|
||||
public static bool IsKnownUnsafe(string avatarItemDesc)
|
||||
{
|
||||
return KnownUnsafeDescriptionParts.Any(part =>
|
||||
avatarItemDesc.Contains(part, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static AvatarItem Clone(AvatarItem item)
|
||||
{
|
||||
return new AvatarItem
|
||||
{
|
||||
AvatarItemDesc = AvatarItemDescription.Normalize(item.AvatarItemDesc),
|
||||
UnlockedLevel = item.UnlockedLevel,
|
||||
PlatformMask = item.PlatformMask,
|
||||
FriendlyName = item.FriendlyName,
|
||||
Tooltip = item.Tooltip,
|
||||
Rarity = item.Rarity,
|
||||
AvatarItemType = item.AvatarItemType,
|
||||
AvatarItemId = item.AvatarItemId,
|
||||
IsBaseAvatarItem = item.IsBaseAvatarItem,
|
||||
TagList = item.TagList,
|
||||
CreatedAt = item.CreatedAt,
|
||||
ThumbnailImage = item.ThumbnailImage
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Avatar;
|
||||
using RadicadeServer.Utils.Attributes;
|
||||
|
||||
namespace RadicadeServer.API.Avatars.V1
|
||||
{
|
||||
[Route("api/avatar/v1")]
|
||||
[Route("econ/api/avatar/v1")]
|
||||
public class AvatarV1 : Controller
|
||||
{
|
||||
RecDBContext db;
|
||||
public AvatarV1(RecDBContext context)
|
||||
{
|
||||
this.db = context;
|
||||
}
|
||||
|
||||
[HttpGet("defaultunlocked")]
|
||||
public List<AvatarItem> GetDefaultUnlocks()
|
||||
{
|
||||
return AvatarUnlockCatalog.GetStarterItems();
|
||||
}
|
||||
|
||||
[HttpGet("defaultbaseavataritems")]
|
||||
public IActionResult GetDefaultBaseAvatarItems()
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(AvatarUnlockCatalog.GetSafeDefaultItems()
|
||||
.Where(item => item.IsBaseAvatarItem).ToList());
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return Ok(Array.Empty<AvatarItem>());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json;
|
||||
using RadicadeServer.API.Storefront;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.Utils;
|
||||
using RadicadeServer.RecNet.Avatar;
|
||||
using RadicadeServer.RecNet.Enums.Store;
|
||||
using RadicadeServer.RecNet.Progression;
|
||||
using RadicadeServer.RecNet.Store;
|
||||
|
||||
namespace RadicadeServer.API.Avatars.V2;
|
||||
|
||||
[Route("api/avatar/v2")]
|
||||
[Route("econ/api/avatar/v2")]
|
||||
public class AvatarV2(RecDBContext db) : Controller
|
||||
{
|
||||
[HttpGet(""), Authorize]
|
||||
public async Task<Avatar?> GetLocalAvatar()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return null;
|
||||
|
||||
var avatar = db.Avatars.FirstOrDefault(x => x.AccountId == userId);
|
||||
if (avatar != null)
|
||||
{
|
||||
if (NormalizeSavedAvatar(avatar))
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return avatar.DeserializeAvatar();
|
||||
}
|
||||
|
||||
avatar = new RadicadeServer.RecNet.Accounts.Local.PlayerAvatar
|
||||
{
|
||||
AccountId = userId.Value,
|
||||
Avatar = DBConfig.DefaultAvatar,
|
||||
Outfits = "[]"
|
||||
};
|
||||
await db.Avatars.AddAsync(avatar);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return avatar.DeserializeAvatar();
|
||||
}
|
||||
|
||||
[HttpGet("{accountId:long}")]
|
||||
public async Task<Avatar> GetAvatarForAccount(long accountId)
|
||||
{
|
||||
var saved = await db.Avatars.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == accountId);
|
||||
var avatar = saved?.DeserializeAvatar();
|
||||
if (avatar != null) return avatar;
|
||||
|
||||
return DBConfig.DefaultAvatar.FromJson<Avatar>() ?? new Avatar();
|
||||
}
|
||||
|
||||
[HttpPost("set"), Authorize]
|
||||
public async Task<Avatar?> SetLocalAvatar([FromBody] Avatar newAvatar)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return null;
|
||||
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (avatar == null)
|
||||
{
|
||||
avatar = new RadicadeServer.RecNet.Accounts.Local.PlayerAvatar
|
||||
{
|
||||
AccountId = userId.Value,
|
||||
Avatar = DBConfig.DefaultAvatar,
|
||||
Outfits = "[]"
|
||||
};
|
||||
await db.Avatars.AddAsync(avatar);
|
||||
}
|
||||
|
||||
avatar.Avatar = newAvatar.ToJson();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return newAvatar;
|
||||
} [HttpGet("gifts"), Authorize]
|
||||
public async Task<IActionResult> GetGifts()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
|
||||
|
||||
return Ok(gifts.Select(g => g.ForClient()).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("gifts/claim"), Authorize]
|
||||
public async Task<IActionResult> ClaimGifts()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var requestedIds = await ReadGiftIdsFromBodyAsync();
|
||||
|
||||
var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
|
||||
var toClaim = requestedIds.Count > 0
|
||||
? gifts.Where(g => requestedIds.Contains(g.GiftDropId)).ToList()
|
||||
: gifts;
|
||||
|
||||
if (toClaim.Count == 0)
|
||||
{
|
||||
return GiftClaimResponse(new List<Gift>(), await GetPlayerLevelAsync(userId.Value));
|
||||
}
|
||||
|
||||
var claimed = new List<Gift>();
|
||||
var levelUpGifts = new List<Gift>();
|
||||
var balanceGrants = new List<StorefrontShared.BalanceGrant>();
|
||||
var strategy = db.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
foreach (var gift in toClaim)
|
||||
{
|
||||
var grant = await GrantGift(userId.Value, gift);
|
||||
if (grant != null) balanceGrants.Add(grant);
|
||||
await StorefrontShared.RemovePendingGiftAsync(db, userId.Value, gift.GiftDropId);
|
||||
claimed.Add(gift);
|
||||
}
|
||||
levelUpGifts.AddRange(await LevelUpGiftService.DeliverLevelUpGiftsAsync(db, userId.Value, recomputeFromXp: true, notify: false));
|
||||
await tx.CommitAsync();
|
||||
await AnnounceBalancesAsync(userId.Value, balanceGrants, StorefrontBalanceAddTypes.FromGiftBox);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await tx.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
foreach (var gift in levelUpGifts)
|
||||
await StorefrontShared.SendGiftPackageNotificationAsync(userId.Value, gift);
|
||||
return GiftClaimResponse(claimed, await GetPlayerLevelAsync(userId.Value));
|
||||
}
|
||||
|
||||
[HttpPost("gifts/generate"), Authorize]
|
||||
public async Task<IActionResult> GenerateGift()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var fields = await ReadGiftGenerateFieldsAsync();
|
||||
if (!TryReadGiftContext(fields, "MFPELEJLJLD", "GiftContext", out var giftContext) ||
|
||||
!TryReadOptionalGiftContext(fields, "CIEHFJEIOJJ", "AlternateGiftContext", out var alternateContext))
|
||||
return BadRequest(new { error = "invalid_gift_context" });
|
||||
|
||||
fields.TryGetValue("Message", out var message);
|
||||
if (message == null && fields.TryGetValue("DPAJDIEBNKM", out var opaqueMessage))
|
||||
message = opaqueMessage;
|
||||
var xp = TryFindField(fields, out var rawXp, "Xp", "XP") && int.TryParse(rawXp, out var parsedXp)
|
||||
? parsedXp
|
||||
: 0;
|
||||
|
||||
var context = (GiftContext)giftContext;
|
||||
if (context is GiftContext.All_Weekly_Challenge_Complete or GiftContext.Weekly_Challenge_Complete)
|
||||
{
|
||||
var mapId = WeeklyChallenges.CreateMap(DateTime.UtcNow).ChallengeMapId;
|
||||
var state = await db.WeeklyChallenges.AsNoTracking().SingleOrDefaultAsync(x =>
|
||||
x.PlayerId == userId.Value && x.ChallengeMapId == mapId);
|
||||
var pendingWeekly = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
|
||||
var weeklyGift = pendingWeekly.FirstOrDefault(x => x.GiftDropId == state?.RewardGiftDropId);
|
||||
return weeklyGift == null
|
||||
? Conflict(new { error = "weekly_reward_not_pending" })
|
||||
: Ok(weeklyGift.ForClient());
|
||||
}
|
||||
void Trace(string line) => RewardTrace.Log(userId.Value, line);
|
||||
|
||||
var room = RroRewardPools.RoomForContext(context);
|
||||
var paysReward = room != null && RroRewardPools.IsRewardActivity(room);
|
||||
RewardTrace.Log(userId.Value, $"stage=Generate claimed={RewardTrace.Context(context)} " +
|
||||
$"room={(room ?? "<none>")} isActivityReward={paysReward} xp={xp} message=\"{message}\"");
|
||||
|
||||
var gift = await GameRewardRedemption.TakeOutstandingAsync(db, userId.Value, context, message, Trace);
|
||||
var source = gift == null ? null : "outstanding reward";
|
||||
|
||||
if (gift == null)
|
||||
{
|
||||
gift = await GameRewardRedemption.TakePendingBoxAsync(db, userId.Value, context, Trace);
|
||||
if (gift != null) source = "box the selection route already handed over";
|
||||
}
|
||||
|
||||
if (gift == null)
|
||||
{
|
||||
gift = await GameRewardRedemption.TakeRecentlyHandedOverAsync(
|
||||
db, userId.Value, context, TimeSpan.FromMinutes(10), Trace);
|
||||
if (gift != null) source = "box the server already handed over for this reward";
|
||||
}
|
||||
|
||||
if (gift == null && paysReward)
|
||||
{
|
||||
gift = await GameRewardRedemption.BuildRoomRewardAsync(db, userId.Value, context, message, Trace);
|
||||
if (gift != null)
|
||||
source = "the activity's own reward, selected by the server";
|
||||
else
|
||||
{
|
||||
var sets = string.Join(", ", RroRewardPools.SetsFor(context)
|
||||
.Select(x => string.IsNullOrEmpty(x.Colour) ? x.Family : $"{x.Family} {x.Colour}"));
|
||||
RewardTrace.Error(userId.Value,
|
||||
"Unable to resolve the reward for this context. " +
|
||||
$"Activity={room} Context={RewardTrace.Context(context)} ExpectedPool={sets} " +
|
||||
$"EligibleItems={RroRewardPools.Resolve(context).Count} " +
|
||||
"Reason=the activity's own pool, its lower ranks and the non-exclusive pool all " +
|
||||
"resolved to nothing.");
|
||||
}
|
||||
}
|
||||
|
||||
if (gift == null)
|
||||
{
|
||||
RewardTrace.Log(userId.Value, $"stage=Fallback claimed={RewardTrace.Context(context)} " +
|
||||
"is not an activity this server pays a reward for; generating the ordinary box.");
|
||||
gift = BuildGeneratedGift(userId.Value, context, message, xp);
|
||||
source = "generated box (no activity reward)";
|
||||
}
|
||||
|
||||
await StorefrontShared.PersistGiftBoxAsync(db, userId.Value, gift, notify: false);
|
||||
RewardTrace.Log(userId.Value, $"stage=Grant source={source} box={RewardTrace.Box(gift)}");
|
||||
return Ok(gift.ForClient());
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadGiftGenerateFieldsAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var field in form) fields[field.Key] = field.Value.FirstOrDefault() ?? string.Empty;
|
||||
return fields;
|
||||
}
|
||||
|
||||
Request.EnableBuffering();
|
||||
using var reader = new StreamReader(Request.Body, leaveOpen: true);
|
||||
var raw = (await reader.ReadToEndAsync()).Trim();
|
||||
Request.Body.Position = 0;
|
||||
if (raw.Length == 0) return fields;
|
||||
|
||||
if (raw.StartsWith('{'))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(raw);
|
||||
foreach (var property in document.RootElement.EnumerateObject())
|
||||
fields[property.Name] = property.Value.ValueKind == JsonValueKind.String
|
||||
? property.Value.GetString() ?? string.Empty
|
||||
: property.Value.ToString();
|
||||
}
|
||||
catch (JsonException) { }
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var pair in raw.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var parts = pair.Split('=', 2);
|
||||
fields[Uri.UnescapeDataString(parts[0].Replace('+', ' '))] =
|
||||
parts.Length > 1 ? Uri.UnescapeDataString(parts[1].Replace('+', ' ')) : string.Empty;
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static bool TryReadGiftContext(IReadOnlyDictionary<string, string> fields, string opaqueName, string friendlyName, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (!TryFindField(fields, out var raw, opaqueName, friendlyName)) return false;
|
||||
if (!int.TryParse(raw, out value)) return false;
|
||||
return Enum.IsDefined(typeof(GiftContext), value) && (GiftContext)value != GiftContext.None;
|
||||
}
|
||||
|
||||
private static bool TryReadOptionalGiftContext(IReadOnlyDictionary<string, string> fields, string opaqueName, string friendlyName, out int? value)
|
||||
{
|
||||
value = null;
|
||||
if (!TryFindField(fields, out var raw, opaqueName, friendlyName) || string.IsNullOrWhiteSpace(raw)) return true;
|
||||
if (!int.TryParse(raw, out var parsed) || !Enum.IsDefined(typeof(GiftContext), parsed)) return false;
|
||||
value = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryFindField(IReadOnlyDictionary<string, string> fields, out string raw, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (fields.TryGetValue(name, out var value))
|
||||
{
|
||||
raw = value;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
raw = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static Gift BuildGeneratedGift(long playerId, GiftContext context, string? message, int xp)
|
||||
{
|
||||
int[] tokenAmounts = [10, 25, 50, 100, 250, 500];
|
||||
return new Gift
|
||||
{
|
||||
GiftDropId = StorefrontShared.NewGiftDropId(),
|
||||
FromPlayerId = playerId,
|
||||
Tooltip = message ?? string.Empty,
|
||||
CurrencyType = CurrencyType.RecCenterTokens,
|
||||
Currency = tokenAmounts[Random.Shared.Next(tokenAmounts.Length)],
|
||||
Xp = xp,
|
||||
Context = context,
|
||||
BalanceType = 0,
|
||||
Rarity = Rarity.Rare
|
||||
};
|
||||
}
|
||||
|
||||
[HttpPost("gifts/consume/{giftDropId:long}"), Authorize]
|
||||
public async Task<IActionResult> ConsumeGift(long giftDropId)
|
||||
=> await ConsumePlayerGift(giftDropId, await ReadGiftIdsFromBodyAsync());
|
||||
|
||||
[HttpPost("gifts/consume"), Authorize]
|
||||
public async Task<IActionResult> ConsumeGiftBody()
|
||||
=> await ConsumePlayerGift(null, await ReadGiftIdsFromBodyAsync());
|
||||
|
||||
private async Task<List<long>> ReadGiftIdsFromBodyAsync()
|
||||
{
|
||||
var ids = new List<long>();
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
using var reader = new StreamReader(Request.Body, leaveOpen: true);
|
||||
var raw = (await reader.ReadToEndAsync()).Trim();
|
||||
Request.Body.Position = 0;
|
||||
if (raw.Length == 0) return ids;
|
||||
|
||||
if (raw.StartsWith('{'))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!string.Equals(prop.Name, "Id", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(prop.Name, "giftDropId", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
if (TryReadGiftId(prop.Value, out var v)) ids.Add(v);
|
||||
}
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!string.Equals(prop.Name, "giftDropIds", StringComparison.OrdinalIgnoreCase) ||
|
||||
prop.Value.ValueKind != JsonValueKind.Array)
|
||||
continue;
|
||||
foreach (var el in prop.Value.EnumerateArray())
|
||||
if (TryReadGiftId(el, out var v)) ids.Add(v);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var pair in raw.Split('&'))
|
||||
{
|
||||
var kv = pair.Split('=', 2);
|
||||
var key = Uri.UnescapeDataString(kv[0]).Trim();
|
||||
if (!string.Equals(key, "Id", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(key, "giftDropId", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
var val = kv.Length > 1 ? Uri.UnescapeDataString(kv[1]).Trim() : "";
|
||||
if (long.TryParse(val, out var v)) ids.Add(v);
|
||||
}
|
||||
if (ids.Count == 0 && long.TryParse(raw, out var bare)) ids.Add(bare);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
return ids.Distinct().ToList();
|
||||
}
|
||||
|
||||
private static bool TryReadGiftId(JsonElement el, out long v)
|
||||
{
|
||||
if (el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out v)) return true;
|
||||
if (el.ValueKind == JsonValueKind.String && long.TryParse(el.GetString(), out v)) return true;
|
||||
v = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ConsumePlayerGift(long? giftDropId, List<long> bodyIds)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var requestedIds = new List<long>();
|
||||
if (giftDropId is > 0) requestedIds.Add(giftDropId.Value);
|
||||
if (bodyIds is { Count: > 0 }) requestedIds.AddRange(bodyIds);
|
||||
|
||||
var gifts = await StorefrontShared.GetPendingGiftsAsync(db, userId.Value);
|
||||
var toConsume = requestedIds.Count > 0
|
||||
? gifts.Where(g => requestedIds.Contains(g.GiftDropId)).ToList()
|
||||
: gifts;
|
||||
if (toConsume.Count == 0)
|
||||
{
|
||||
var delivered = requestedIds.Count > 0
|
||||
? await HandedOverBoxIdsAsync(userId.Value, requestedIds)
|
||||
: new List<long>();
|
||||
if (delivered.Count > 0)
|
||||
{
|
||||
return GiftClaimResponse(new List<Gift>(), await GetPlayerLevelAsync(userId.Value));
|
||||
}
|
||||
return NotFound(new { error = "unable_to_consume_missing_or_used_gift", giftDropId });
|
||||
}
|
||||
|
||||
var consumed = new List<Gift>();
|
||||
var levelUpGifts = new List<Gift>();
|
||||
var balanceGrants = new List<StorefrontShared.BalanceGrant>();
|
||||
var strategy = db.Database.CreateExecutionStrategy();
|
||||
await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
try
|
||||
{
|
||||
foreach (var gift in toConsume)
|
||||
{
|
||||
var grant = await GrantGift(userId.Value, gift);
|
||||
if (grant != null) balanceGrants.Add(grant);
|
||||
await StorefrontShared.RemovePendingGiftAsync(db, userId.Value, gift.GiftDropId);
|
||||
consumed.Add(gift);
|
||||
}
|
||||
levelUpGifts.AddRange(await LevelUpGiftService.DeliverLevelUpGiftsAsync(db, userId.Value, recomputeFromXp: true, notify: false));
|
||||
await tx.CommitAsync();
|
||||
await AnnounceBalancesAsync(userId.Value, balanceGrants, StorefrontBalanceAddTypes.FromGiftBox);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await tx.RollbackAsync();
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
foreach (var gift in levelUpGifts)
|
||||
await StorefrontShared.SendGiftPackageNotificationAsync(userId.Value, gift);
|
||||
|
||||
if (consumed.Count > 0)
|
||||
await ProgressionAwardService.AwardXpAsync(db, userId.Value,
|
||||
consumed.Count * ProgressionAwardService.XpPerGiftBoxOpened, "gift box");
|
||||
return GiftClaimResponse(consumed, await GetPlayerLevelAsync(userId.Value));
|
||||
}
|
||||
|
||||
private async Task<int> GetPlayerLevelAsync(long playerId)
|
||||
{
|
||||
var progression = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
|
||||
return progression?.Level ?? 1;
|
||||
}
|
||||
|
||||
private async Task<List<long>> HandedOverBoxIdsAsync(long playerId, List<long> requestedIds)
|
||||
{
|
||||
var data = await db.Progression.FirstOrDefaultAsync(x => x.PlayerId == playerId);
|
||||
var rewards = data?.GetProgressionJson()?.PendingGameRewards;
|
||||
if (rewards == null) return new List<long>();
|
||||
|
||||
return rewards
|
||||
.Where(r => r.ConsumedGiftDropId != 0 && requestedIds.Contains(r.ConsumedGiftDropId))
|
||||
.Select(r => r.ConsumedGiftDropId)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private IActionResult GiftClaimResponse(List<Gift> claimed, int unlockedLevel)
|
||||
{
|
||||
var body = new Dictionary<string, object?>
|
||||
{
|
||||
["consumed"] = claimed.Select(g => g.ForClient()).ToList(),
|
||||
["UnlockedLevel"] = unlockedLevel
|
||||
};
|
||||
var first = claimed.FirstOrDefault();
|
||||
if (first != null)
|
||||
{
|
||||
foreach (var kv in first.ForClient().ToJson().FromJson<Dictionary<string, object?>>() ?? new Dictionary<string, object?>())
|
||||
body[kv.Key] = kv.Value;
|
||||
}
|
||||
return Ok(body);
|
||||
}
|
||||
|
||||
private async Task<StorefrontShared.BalanceGrant?> GrantGift(long playerId, Gift gift)
|
||||
{
|
||||
return await StorefrontShared.GrantGiftAsync(db, playerId, gift);
|
||||
}
|
||||
|
||||
private static async Task AnnounceBalancesAsync(long playerId, List<StorefrontShared.BalanceGrant> grants, StorefrontBalanceAddTypes addType)
|
||||
{
|
||||
foreach (var grant in grants)
|
||||
await StorefrontShared.SendBalanceUpdateAsync(playerId, grant.CurrencyType, grant.Balance, grant.Granted, addType);
|
||||
}
|
||||
|
||||
private static bool NormalizeSavedAvatar(RadicadeServer.RecNet.Accounts.Local.PlayerAvatar avatar)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(avatar.Avatar))
|
||||
{
|
||||
avatar.Avatar = DBConfig.DefaultAvatar;
|
||||
return true;
|
||||
}
|
||||
|
||||
var original = avatar.Avatar;
|
||||
avatar.Avatar = RepairKnownUnsafeOutfitSelections(avatar.Avatar);
|
||||
|
||||
return avatar.Avatar != original;
|
||||
}
|
||||
|
||||
private static string RepairKnownUnsafeOutfitSelections(string avatarJson)
|
||||
{
|
||||
const string body = "d0a9262f-5504-46a7-bb10-7507503db58e";
|
||||
const string accessory = "95e4cc30-cb68-473d-a395-feadf5b51512";
|
||||
const string repairedOutfitSelection = body + "," + accessory
|
||||
+ ",0440f08f-ef1d-49d8-942b-523056e8bb45,,1";
|
||||
|
||||
string[] shortOutfitSelections =
|
||||
[
|
||||
body + ",,,,1",
|
||||
body + "," + accessory + ",,,1"
|
||||
];
|
||||
|
||||
foreach (var shortOutfitSelection in shortOutfitSelections)
|
||||
{
|
||||
avatarJson = avatarJson.Replace(
|
||||
shortOutfitSelection,
|
||||
repairedOutfitSelection,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return avatarJson;
|
||||
}
|
||||
}
|
||||
|
||||
public class ClaimGiftsRequest
|
||||
{
|
||||
public List<long>? GiftDropIds { get; set; }
|
||||
public long? GiftDropId { get; set; }
|
||||
}
|
||||
|
||||
public class GenerateGiftRequest
|
||||
{
|
||||
public long? RecipientPlayerId { get; set; }
|
||||
public long? RecipientId { get; set; }
|
||||
public int? PurchasableItemId { get; set; }
|
||||
public int? ItemId { get; set; }
|
||||
public long? GiftDropId { get; set; }
|
||||
public StorefrontTypes? Storefront { get; set; }
|
||||
public CurrencyType? Currency { get; set; }
|
||||
public long? Price { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.API.Avatars.V2;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Avatar;
|
||||
|
||||
namespace RadicadeServer.API.Avatars.V3;
|
||||
|
||||
[Route("api/avatar/v3")]
|
||||
[Route("econ/api/avatar/v3")]
|
||||
public class AvatarV3(RecDBContext db) : Controller
|
||||
{
|
||||
[HttpGet("saved"), Authorize]
|
||||
public async Task<List<Outfit>> GetSavedOutfits()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return new List<Outfit>();
|
||||
|
||||
return await SavedOutfits.GetAsync(db, userId.Value);
|
||||
}
|
||||
|
||||
[HttpGet("saved/{accountId:long}"), Authorize]
|
||||
public async Task<List<Outfit>> GetSavedOutfitsForAccount(long accountId)
|
||||
{
|
||||
return await SavedOutfits.GetAsync(db, accountId);
|
||||
}
|
||||
|
||||
[HttpPost("saved/set"), Authorize]
|
||||
public async Task<IActionResult> SetSavedOutfit([FromBody] Outfit? outfit)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var saved = await SavedOutfits.SaveAsync(db, userId.Value, outfit);
|
||||
if (saved == null) return BadRequest(new { error = "invalid_slot" });
|
||||
|
||||
return Ok(saved);
|
||||
}
|
||||
|
||||
[HttpPost("gifts/generate"), Authorize]
|
||||
public Task<IActionResult> GenerateGift()
|
||||
{
|
||||
var compatibilityController = new AvatarV2(db)
|
||||
{
|
||||
ControllerContext = ControllerContext
|
||||
};
|
||||
return compatibilityController.GenerateGift();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Avatar;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
|
||||
namespace RadicadeServer.API.Avatars.V4;
|
||||
|
||||
[Route("api/avatar/v4")]
|
||||
[Route("econ/api/avatar/v4")]
|
||||
public class AvatarV4(RecDBContext db) : Controller
|
||||
{
|
||||
[HttpGet("items")]
|
||||
public async Task<ActionResult<List<AvatarItemV4>>> GetLocalAvatarItems()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
List<AvatarItem> defaultItems;
|
||||
|
||||
try
|
||||
{
|
||||
defaultItems = LockerAccess.HasFullInventory(db, userId)
|
||||
? AvatarUnlockCatalog.GetSafeDefaultItems()
|
||||
: AvatarUnlockCatalog.GetStarterItems();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
defaultItems = new List<AvatarItem>();
|
||||
}
|
||||
|
||||
var playerItems = new List<AvatarItem>();
|
||||
|
||||
if (userId.HasValue)
|
||||
{
|
||||
var playerUnlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
var unlockedItems = playerUnlocks?.GetAvatarItems();
|
||||
playerItems = User.FindFirst("rn.plat")?.Value == ((int)Platforms.Standalone).ToString()
|
||||
? AvatarUnlockCatalog.KeepResolvableItems(unlockedItems)
|
||||
: AvatarUnlockCatalog.KeepSupportedItems(unlockedItems);
|
||||
}
|
||||
|
||||
var items = defaultItems
|
||||
.Concat(playerItems)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.AvatarItemDesc))
|
||||
.GroupBy(x => x.AvatarItemDesc, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(x => x.First())
|
||||
.ToList();
|
||||
|
||||
return Ok(items.Select(item => new AvatarItemV4(item)).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("saved/set"), Authorize]
|
||||
public async Task<IActionResult> SetSavedOutfit([FromBody] Outfit? outfit)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var saved = await SavedOutfits.SaveAsync(db, userId.Value, outfit);
|
||||
if (saved == null) return BadRequest(new { error = "invalid_slot" });
|
||||
|
||||
return Ok(new { Success = true, Slot = saved.Slot });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using RadicadeServer.RecNet.Enums.Store;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar
|
||||
{
|
||||
public class Consumable
|
||||
{
|
||||
public long Id { get; set; }
|
||||
|
||||
public string ConsumableItemDesc { get; set; } = null!;
|
||||
|
||||
public int Count { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public int UnlockedLevel { get; set; }
|
||||
|
||||
public int InitialCount { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
public int ActiveDurationMinutes { get; set; }
|
||||
|
||||
public ConsumableCategories Category { get; set; }
|
||||
|
||||
public int PlatformMask => -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using RadicadeServer.Extensions;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar
|
||||
{
|
||||
public static class ConsumableCatalog
|
||||
{
|
||||
private static readonly Dictionary<string, string> FriendlyToToken =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "20% Off Coupon", "50oCPkzd3EerBd7nYNdkCw" },
|
||||
{ "87 Flavor Cake", "xwQWBB_fekmTqRc2LB92cg" },
|
||||
{ "Assorted Donuts", "ZuvkidodzkuOfGLDnTOFyg" },
|
||||
{ "Black & White Camera Film", "frOMH6WxDEG1fBqC4_83vg" },
|
||||
{ "Bubble Tea", "JxAl1wVJ1UGAjoT3m3ilug" },
|
||||
{ "Bubbly", "iiGTvhOCHkOTNJhb16Zbyw" },
|
||||
{ "Bucket Orange Hair Dye", "xr45B5QC4EyKXThtwZto_A" },
|
||||
{ "Butterfly Blue Hair Dye", "JwJeh15cjkOc9WWaGRibDQ" },
|
||||
{ "Cake Pops", "Efd3_dy4ZEqWr7i6j5x0aA" },
|
||||
{ "Candy Apples", "EmPvh3I6L0uK_1i8Wy_ylQ" },
|
||||
{ "Cauldron Crimson Hair Dye", "3R1bzI35fkChoFFRFbponQ" },
|
||||
{ "Celebration Cake", "Sk_1sm88ZU2zpWn01Lv7hw" },
|
||||
{ "Cheese Pizza", "5hIAZ9wg5EyG1cILf4FS2A" },
|
||||
{ "Chocolate Cake", "BbJb1_0g4EGJP_CeNrjpew" },
|
||||
{ "Chocolate Frosted Donuts", "mMCGPgK3tki5S_15q2Z81A" },
|
||||
{ "Chocolate Popcorn", "lWN_g4OcN0S0C4n6cSZq4g" },
|
||||
{ "Chocolate Variety Box", "K9RbXo-4U0q6NbGu8VL1Sw" },
|
||||
{ "Circuit Cerulean Hair Dye", "x9ntSHto50GpNWPwYCLUEg" },
|
||||
{ "Dawn Camera Film", "m0bVLwWGj0GuIBSb6wCk6Q" },
|
||||
{ "Dracula Film", "oG7CdvW7p0-S8sVe9w5vRw" },
|
||||
{ "EDM Emerald Hair Dye", "4L3JoaqkAUa1kz98nciPXw" },
|
||||
{ "Ego Potion", "Tpxqe_lycUelySRHM8B0Vw" },
|
||||
{ "Flaming Popcorn", "uswFSANaZES0EJMLfOgNSw" },
|
||||
{ "Frontier Forest Hair Dye", "YaVzcoefhk6zjbNDELLx8A" },
|
||||
{ "Ghost Beard Film", "1c0Djlp090uBDczEobYNQw" },
|
||||
{ "Gizmo Grape Hair Dye", "iEtWDLmv7ES87J4NE_TGJQ" },
|
||||
{ "Glazed Donuts", "7OZ5AE3uuUyqa0P-2W1ptg" },
|
||||
{ "Goblin Green Hair Dye", "jA3zzS31zEitBxtRCk7oew" },
|
||||
{ "Goblin King Film", "6SyCoJCgo0Wd6qlPlnMOtg" },
|
||||
{ "H2Oasis Blue Hair Dye", "P7HixsdkskmzgFMdFJ-WdA" },
|
||||
{ "Hawaiian Pizza", "_jnjYGBcyEWY5Ub4OezXcA" },
|
||||
{ "High Five O'Gold", "-hy0qD-iUk-v4NHxNzanmg" },
|
||||
{ "High Five O'Gunpowder", "YEfbJTnsR0yT_p7e7tb_kQ" },
|
||||
{ "High Five O'Lasers", "xHOwjwpXd0GDkvBz2VqieA" },
|
||||
{ "High Five O'Spells", "VQSgL2pTLkWx4B3kwYG7UA" },
|
||||
{ "High Five O'Whip", "lag2tZyB90W04lQ7ol4vMw" },
|
||||
{ "Invasion Donuts", "-_VNAj3P10aWCCJZoVTF4Q" },
|
||||
{ "Jumbotron Film", "Il4VmrnjDkqjmjzddqoIEw" },
|
||||
{ "Jungle Jade Hair Dye", "5Ugg8k19n0WH8GPFCDWTCQ" },
|
||||
{ "KO - Bear Claw", "ZtZnYBpKkECJlhHmkj4MiA" },
|
||||
{ "KO - Bees", "g3kxdlJv5kO8PuBXveM48w" },
|
||||
{ "KO - Cola", "wx--2TPTdEuGAqLCjs9Qag" },
|
||||
{ "KO - Fangs", "XOZcxx-Klkyhe-MDbTqiwA" },
|
||||
{ "KO - FryingPan", "uGVFydNSokCXFAmXu3aceQ" },
|
||||
{ "KO - Grenade", "EAhk3ZZdXEmH5wRAXXT24Q" },
|
||||
{ "KO - Ranger", "Av-wvjXvvkmNVSz7ZZnTiA" },
|
||||
{ "KO - Shield", "5AJin8T2iEG7BzOPOgx2HA" },
|
||||
{ "KO - Skull Wings", "U38Qe6rhEk6mFvArHfYjng" },
|
||||
{ "KO - Star Power", "yvqSbK2czkS2sUCRdrGaEw" },
|
||||
{ "KO - Tire Track", "J1WqFNUWo0OBi4LGKPDHWw" },
|
||||
{ "KREQ Red Hair Dye", "P1kCuPlRc0-NAg6JUlEH_g" },
|
||||
{ "Latte", "P15H1ONBhk-5DYYjid1ttg" },
|
||||
{ "Lounge Lavender Hair Dye", "Hh_O_RejGE-_PubyM8xHaw" },
|
||||
{ "Maker Mauve Hair Dye", "s2pxukIEt0uDeGVJmZayOw" },
|
||||
{ "Mousebot Magenta Hair Dye", "vXL0rrAk70SFxJlaJk0uqQ" },
|
||||
{ "PVPeach Hair Dye", "AQTYAh1it0GIGdgGO5sUHQ" },
|
||||
{ "Paula's Periwinkle Hair Dye", "UAOH6ccEbEmehJg8XW_D8w" },
|
||||
{ "Pepperoni Pizza", "mq23W-RSP0G8iGNLdrcpUw" },
|
||||
{ "Pirate Gold Hair Dye", "o0hG5O_eaUO0R8vPyW-Hvg" },
|
||||
{ "Popcorn", "QRx0aSTT9keMFdAJMQHdTg" },
|
||||
{ "Propulsion Purple Hair Dye", "xRtKWR-D40GUW43R0EFpNg" },
|
||||
{ "Ranger Roy-Al Blue Hair Dye", "8vlVslaWaUyFS-iDHhxW9g" },
|
||||
{ "Rebel Rose Hair Dye", "STFKahjHJ0SQJPfoDe4S1g" },
|
||||
{ "Recreational Raspberry Hair Dye", "eJh_BQ5y4UWwiMJzBKcwMQ" },
|
||||
{ "Red Apples", "uMHrUPLYFk2rJOW_uop5Aw" },
|
||||
{ "Red Velvet Cake", "LwaotjVEBUir0-w8126n_g" },
|
||||
{ "Root Beer", "JfnVXFmilU6ysv-VbTAe3A" },
|
||||
{ "Salted Pretzels", "InQ25wQMGkG_bvuD5rf2Ag" },
|
||||
{ "Sepia Camera Film", "A5M-yf9tgUihq1uab3v58g" },
|
||||
{ "Snowman Donuts", "jdrRJ7deWk2P9aRRrPy3zw" },
|
||||
{ "Sparklers", "hRdK6NGfAkGBmYSs_xGzXg" },
|
||||
{ "Standee Sandy Hair Dye", "inIuPzhhOEmz1RI8mZtSLg" },
|
||||
{ "Supreme Pizza", "wUCIKdJSvEmiQHYMyx4X4w" },
|
||||
{ "Sushi", "4My42w1D1Uu3-98pXB-x_Q" },
|
||||
{ "VR Cola", "YpL6eN6sKEquOYIYnbhvew" },
|
||||
{ "Witch Film", "mL3zCEuy2UWZxAV4V-OsMA" },
|
||||
};
|
||||
|
||||
public static string ToClientDesc(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return value ?? string.Empty;
|
||||
|
||||
var parts = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return string.Join(",", parts.Select(p =>
|
||||
FriendlyToToken.TryGetValue(p, out var token) ? token : p));
|
||||
}
|
||||
|
||||
public const int DefaultCount = 5;
|
||||
|
||||
private const long GrantedIdBase = 800_000_000;
|
||||
private const long GrantedIdSpan = 100_000_000;
|
||||
|
||||
private static readonly DateTime GrantedAt = new(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static readonly System.Text.Json.JsonSerializerOptions CatalogReadOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
public static List<ConsumableItem> ParseItems(string? catalogJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(catalogJson)) return new List<ConsumableItem>();
|
||||
|
||||
try
|
||||
{
|
||||
return catalogJson.FromJson<List<ConsumableItem>>(CatalogReadOptions) ?? new List<ConsumableItem>();
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
return new List<ConsumableItem>();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Consumable> DefaultInstances(string? catalogJson)
|
||||
{
|
||||
var catalog = ParseItems(catalogJson);
|
||||
|
||||
if (catalog.Count == 0) return new List<Consumable>();
|
||||
|
||||
var instances = new List<Consumable>();
|
||||
var taken = new HashSet<long>();
|
||||
foreach (var item in catalog)
|
||||
{
|
||||
var desc = ToClientDesc(item.ConsumableItemDesc);
|
||||
if (string.IsNullOrWhiteSpace(desc)) continue;
|
||||
|
||||
var id = StableId(desc);
|
||||
while (!taken.Add(id)) id++;
|
||||
|
||||
instances.Add(new Consumable
|
||||
{
|
||||
Id = id,
|
||||
ConsumableItemDesc = desc,
|
||||
Count = DefaultCount,
|
||||
InitialCount = DefaultCount,
|
||||
CreatedAt = GrantedAt,
|
||||
UnlockedLevel = 0,
|
||||
IsActive = false,
|
||||
ActiveDurationMinutes = 0
|
||||
});
|
||||
}
|
||||
|
||||
return instances;
|
||||
}
|
||||
|
||||
private static long StableId(string token)
|
||||
{
|
||||
uint hash = 2166136261;
|
||||
foreach (var c in token)
|
||||
{
|
||||
hash ^= c;
|
||||
hash *= 16777619;
|
||||
}
|
||||
return GrantedIdBase + (hash % GrantedIdSpan);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using RadicadeServer.RecNet.Enums.Store;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public class ConsumableItem
|
||||
{
|
||||
|
||||
public string ConsumableItemDesc { get; set; } = "";
|
||||
|
||||
public string FriendlyName { get; set; } = "";
|
||||
public string Tooltip { get; set; } = "";
|
||||
public Rarity Rarity { get; set; } = Rarity.Common;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using RadicadeServer.API.Notifications;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Avatar;
|
||||
using RadicadeServer.API.Storefront;
|
||||
using RadicadeServer.RecNet.Enums.Store;
|
||||
using RadicadeServer.RecNet.Responses;
|
||||
|
||||
namespace RadicadeServer.API.Avatars.V2
|
||||
{
|
||||
[Route("api/consumables/v2")]
|
||||
[Route("econ/api/consumables/v2")]
|
||||
[Route("api/consumables/v1")]
|
||||
[Route("econ/api/consumables/v1")]
|
||||
public class ConsumablesV2 : Controller
|
||||
{
|
||||
RecDBContext db;
|
||||
public ConsumablesV2(RecDBContext context)
|
||||
{
|
||||
this.db = context;
|
||||
}
|
||||
|
||||
[HttpGet("getUnlocked"), Authorize]
|
||||
public async Task<List<BulkConsumableResponse>?> GetUnlockedConsumables()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var consumables = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
var owned = consumables?.GetConsumables() ?? new List<Consumable>();
|
||||
|
||||
var merged = new Dictionary<string, BulkConsumableResponse>(StringComparer.OrdinalIgnoreCase);
|
||||
if (LockerAccess.HasFullInventory(db, userId))
|
||||
{
|
||||
foreach (var item in ConsumableCatalog.DefaultInstances(DBConfig.DefaultConsumableItems))
|
||||
Add(merged, item);
|
||||
foreach (var item in owned)
|
||||
merged.Remove(ConsumableCatalog.ToClientDesc(item.ConsumableItemDesc));
|
||||
}
|
||||
|
||||
foreach (var instance in owned)
|
||||
Add(merged, instance);
|
||||
|
||||
return merged.Values.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The free gift button's list: the consumables that may be handed to the named player.
|
||||
/// The client asks for this before it opens the box, so a 404 leaves the button sitting on
|
||||
/// "Processing" with nothing behind it and the screen never resolves. The body is the same
|
||||
/// bulk consumable the unlocked list serves - only IsTransferable differs, because that is
|
||||
/// the member the button filters on - and it answers even for an account that owns nothing,
|
||||
/// which is what keeps the box offered to a player who has never opened one.
|
||||
/// </summary>
|
||||
[HttpGet("getTransferable/{playerId:long}"), Authorize]
|
||||
[HttpGet("getTransferable"), Authorize]
|
||||
public async Task<List<BulkConsumableResponse>?> GetTransferable(long playerId = 0)
|
||||
{
|
||||
var owner = playerId > 0 ? playerId : User.GetUserId() ?? 0;
|
||||
|
||||
var merged = new Dictionary<string, BulkConsumableResponse>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var item in ConsumableCatalog.DefaultInstances(DBConfig.DefaultConsumableItems))
|
||||
Add(merged, item);
|
||||
|
||||
if (owner > 0)
|
||||
{
|
||||
var unlocks = await db.Unlocks.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == owner);
|
||||
var owned = unlocks?.GetConsumables() ?? new List<Consumable>();
|
||||
|
||||
foreach (var item in owned)
|
||||
merged.Remove(ConsumableCatalog.ToClientDesc(item.ConsumableItemDesc));
|
||||
foreach (var item in owned)
|
||||
Add(merged, item);
|
||||
}
|
||||
|
||||
var transferable = merged.Values.ToList();
|
||||
foreach (var entry in transferable)
|
||||
entry.IsTransferable = true;
|
||||
|
||||
return transferable;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hands one of the caller's consumables to another player - the half of the free gift
|
||||
/// button that getTransferable only sets up. The client posts the entry it showed and then
|
||||
/// waits for this answer; with no route the request 404s and the button stays on
|
||||
/// "Processing" with the gift already looking sent. One unit leaves the sender's stack (the
|
||||
/// entry is dropped when that was the last of it) and reaches the recipient as a gift
|
||||
/// package, the same box the friend box arrives in, so it appears on their watch without a
|
||||
/// relog. The answer is the two obfuscated members the client actually reads, not a
|
||||
/// PascalCase body it would treat as a failure.
|
||||
/// </summary>
|
||||
[HttpPost("transfer"), Authorize]
|
||||
public async Task<IActionResult> TransferConsumable()
|
||||
{
|
||||
var senderId = User.GetUserId();
|
||||
if (senderId == null) return Unauthorized();
|
||||
|
||||
var fields = await ReadBodyFieldsAsync();
|
||||
var toPlayerId = PickLong(fields, "toPlayerId", "playerId", "playerIdTo", "recipientId", "targetPlayerId", "accountId");
|
||||
if (toPlayerId is not > 0)
|
||||
return Ok(ClientAction.Failure("No player was named to receive the item."));
|
||||
if (toPlayerId == senderId.Value)
|
||||
return Ok(ClientAction.Failure("You cannot hand a consumable to yourself."));
|
||||
|
||||
if (!await db.Players.AsNoTracking().AnyAsync(player => player.AccountId == toPlayerId.Value))
|
||||
return Ok(ClientAction.Failure($"Player {toPlayerId.Value} does not exist."));
|
||||
|
||||
var sender = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == senderId.Value);
|
||||
var owned = sender?.GetConsumables() ?? new List<Consumable>();
|
||||
var target = MatchConsumable(owned, fields);
|
||||
if (target == null || target.Count <= 0)
|
||||
return Ok(ClientAction.Failure("You do not own that consumable."));
|
||||
|
||||
var message = Pick(fields, "message", "tooltip", "note");
|
||||
var box = new Gift
|
||||
{
|
||||
FriendlyName = target.ConsumableItemDesc,
|
||||
Tooltip = string.IsNullOrWhiteSpace(message) ? "A gift for you <3" : message.Trim(),
|
||||
FromPlayerId = senderId.Value,
|
||||
ConsumableItemDesc = ConsumableCatalog.ToClientDesc(target.ConsumableItemDesc),
|
||||
Context = GiftContext.Consumable
|
||||
};
|
||||
|
||||
if (target.InitialCount > 1)
|
||||
{
|
||||
target.InitialCount -= 1;
|
||||
target.Count = target.InitialCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
owned.Remove(target);
|
||||
}
|
||||
|
||||
sender!.SetConsumables(owned);
|
||||
await StorefrontShared.PersistGiftBoxAsync(db, toPlayerId.Value, box, notify: true);
|
||||
|
||||
await Notify.SendAccountUpdate(senderId.Value);
|
||||
|
||||
return Ok(ClientAction.Ok());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks which of the player's consumables is the active one. The client asks for this as it
|
||||
/// enters a room, once per entry it wants switched on, and a 404 leaves the effect that the
|
||||
/// consumable bought (a hair dye, a rank-up potion) unapplied. Only the named entry is turned
|
||||
/// on, and turning one on is what turns the others off, so a second activation never leaves
|
||||
/// two effects claiming the same slot. The answer has no body, which is what the route's own
|
||||
/// wrapper returns.
|
||||
/// </summary>
|
||||
[HttpPost("updateActive"), Authorize]
|
||||
public async Task<IActionResult> UpdateActive()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var fields = await ReadBodyFieldsAsync();
|
||||
var activates = PickBool(fields, "isActive", "active", "activate", "enabled") ?? true;
|
||||
|
||||
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId.Value);
|
||||
if (unlocks == null) return Ok();
|
||||
|
||||
var consumables = unlocks.GetConsumables();
|
||||
var target = MatchConsumable(consumables, fields);
|
||||
if (target == null) return Ok();
|
||||
|
||||
if (activates)
|
||||
{
|
||||
var targetDesc = ConsumableCatalog.ToClientDesc(target.ConsumableItemDesc);
|
||||
foreach (var other in consumables)
|
||||
{
|
||||
if (!string.Equals(ConsumableCatalog.ToClientDesc(other.ConsumableItemDesc), targetDesc,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
other.IsActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
target.IsActive = activates;
|
||||
unlocks.SetConsumables(consumables);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The entry the request names, by the id the unlocked list handed out and - when the client
|
||||
/// only echoed the description back - by its client token, alone or as one member of a list.
|
||||
/// </summary>
|
||||
private static Consumable? MatchConsumable(List<Consumable> owned, Dictionary<string, string> fields)
|
||||
{
|
||||
var id = PickLong(fields, "id", "consumableItemId", "consumableId", "itemId", "consumableMappingId");
|
||||
if (id is > 0)
|
||||
{
|
||||
var byId = owned.FirstOrDefault(c => c.Id == id.Value);
|
||||
if (byId != null) return byId;
|
||||
}
|
||||
|
||||
var desc = Pick(fields, "consumableItemDesc", "desc", "descriptions", "consumableItem", "itemDesc");
|
||||
if (string.IsNullOrWhiteSpace(desc)) return null;
|
||||
|
||||
var wanted = ConsumableCatalog.ToClientDesc(desc);
|
||||
var candidates = wanted
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(part => !string.IsNullOrWhiteSpace(part))
|
||||
.ToList();
|
||||
if (candidates.Count == 0) candidates.Add(wanted);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var byDesc = owned.FirstOrDefault(c =>
|
||||
string.Equals(ConsumableCatalog.ToClientDesc(c.ConsumableItemDesc), candidate, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(c.ConsumableItemDesc, candidate, StringComparison.OrdinalIgnoreCase));
|
||||
if (byDesc != null) return byDesc;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The request's members as a flat map. The body is a serialized request DTO whose members
|
||||
/// are not published, and the entry the client names sits one level down in a nested
|
||||
/// consumable, so every leaf is folded in by name instead of binding to a guessed shape; a
|
||||
/// form-encoded body is read the same way.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<string, string>> ReadBodyFieldsAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
using var reader = new StreamReader(Request.Body, leaveOpen: true);
|
||||
var raw = (await reader.ReadToEndAsync()).Trim();
|
||||
if (Request.Body.CanSeek) Request.Body.Position = 0;
|
||||
if (raw.Length == 0) return fields;
|
||||
|
||||
if (raw.StartsWith('{') || raw.StartsWith('['))
|
||||
{
|
||||
using var document = JsonDocument.Parse(raw);
|
||||
Flatten(document.RootElement, fields);
|
||||
return fields;
|
||||
}
|
||||
|
||||
foreach (var pair in raw.Split('&'))
|
||||
{
|
||||
var kv = pair.Split(new[] { '=' }, 2);
|
||||
if (kv.Length < 2) continue;
|
||||
fields[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1]);
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
private static void Flatten(JsonElement element, Dictionary<string, string> into)
|
||||
{
|
||||
switch (element.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Object:
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
|
||||
Flatten(property.Value, into);
|
||||
else
|
||||
into[property.Name] = property.Value.ValueKind == JsonValueKind.String
|
||||
? property.Value.GetString() ?? string.Empty
|
||||
: property.Value.ToString();
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in element.EnumerateArray()) Flatten(item, into);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Pick(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
return value.Trim();
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static long? PickLong(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
var raw = Pick(fields, names);
|
||||
return long.TryParse(raw, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private static bool? PickBool(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
var raw = Pick(fields, names);
|
||||
if (raw.Length == 0) return null;
|
||||
return bool.TryParse(raw, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
public class ConsumeRequest
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public int DeltaCount { get; set; } = 1;
|
||||
}
|
||||
|
||||
[HttpPost("consume"), Authorize]
|
||||
public async Task<IActionResult> ConsumeConsumable()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var dto = await ReadConsumeRequestAsync();
|
||||
if (dto == null || dto.Id <= 0) return Ok();
|
||||
if (dto.DeltaCount <= 0) dto.DeltaCount = 1;
|
||||
|
||||
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId.Value);
|
||||
if (unlocks == null)
|
||||
{
|
||||
unlocks = new PlayerUnlocks { AccountId = userId.Value };
|
||||
await db.Unlocks.AddAsync(unlocks);
|
||||
}
|
||||
|
||||
var consumables = unlocks.GetConsumables();
|
||||
var target = consumables.FirstOrDefault(c => c.Id == dto.Id);
|
||||
|
||||
if (target == null)
|
||||
{
|
||||
return Ok();
|
||||
}
|
||||
|
||||
if (target.InitialCount > dto.DeltaCount)
|
||||
{
|
||||
target.InitialCount -= dto.DeltaCount;
|
||||
target.Count = target.InitialCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
consumables.Remove(target);
|
||||
}
|
||||
|
||||
unlocks.SetConsumables(consumables);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task<ConsumeRequest?> ReadConsumeRequestAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
using var reader = new StreamReader(Request.Body, leaveOpen: true);
|
||||
var raw = (await reader.ReadToEndAsync()).Trim();
|
||||
Request.Body.Position = 0;
|
||||
if (raw.Length == 0) return null;
|
||||
|
||||
var dto = new ConsumeRequest();
|
||||
|
||||
if (raw.StartsWith('{'))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(raw);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (prop.Name.Equals("Id", StringComparison.OrdinalIgnoreCase) && long.TryParse(prop.Value.GetRawText(), out var id))
|
||||
dto.Id = id;
|
||||
if (prop.Name.Equals("DeltaCount", StringComparison.OrdinalIgnoreCase) && int.TryParse(prop.Value.GetRawText(), out var delta))
|
||||
dto.DeltaCount = delta;
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
|
||||
foreach (var pair in raw.Split('&'))
|
||||
{
|
||||
var kv = pair.Split(new[] { '=' }, 2);
|
||||
if (kv.Length < 2) continue;
|
||||
var key = Uri.UnescapeDataString(kv[0]);
|
||||
var value = Uri.UnescapeDataString(kv[1]);
|
||||
if (key.Equals("Id", StringComparison.OrdinalIgnoreCase) && long.TryParse(value, out var id))
|
||||
dto.Id = id;
|
||||
if (key.Equals("DeltaCount", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var delta))
|
||||
dto.DeltaCount = delta;
|
||||
}
|
||||
return dto;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Add(Dictionary<string, BulkConsumableResponse> merged, Consumable instance)
|
||||
{
|
||||
var desc = ConsumableCatalog.ToClientDesc(instance.ConsumableItemDesc);
|
||||
if (string.IsNullOrWhiteSpace(desc)) return;
|
||||
|
||||
if (!merged.TryGetValue(desc, out var entry))
|
||||
{
|
||||
entry = new BulkConsumableResponse { ConsumableItemDesc = desc };
|
||||
merged[desc] = entry;
|
||||
}
|
||||
|
||||
entry.Ids.Add(instance.Id);
|
||||
entry.CreatedAts.Add(instance.CreatedAt);
|
||||
entry.Count += instance.Count;
|
||||
entry.InitialCount += instance.InitialCount;
|
||||
entry.IsActive |= instance.IsActive;
|
||||
entry.ActiveDurationMinutes = Math.Max(
|
||||
entry.ActiveDurationMinutes ?? 0, instance.ActiveDurationMinutes);
|
||||
}
|
||||
|
||||
public class BulkConsumableResponse
|
||||
{
|
||||
public List<long> Ids { get; set; } = new List<long>();
|
||||
public List<DateTime> CreatedAts { get; set; } = new List<DateTime>();
|
||||
public string ConsumableItemDesc { get; set; } = "";
|
||||
public int Count { get; set; }
|
||||
public int InitialCount { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int? ActiveDurationMinutes { get; set; }
|
||||
public bool IsTransferable { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using RadicadeServer.RecNet.Enums.Store;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar
|
||||
{
|
||||
public class Equipment
|
||||
{
|
||||
public string PrefabName { get; set; } = null!;
|
||||
public string ModificationGuid { get; set; } = null!;
|
||||
public int UnlockedLevel { get; set; }
|
||||
public bool Favorited { get; set; }
|
||||
public int PlatformMask => -1;
|
||||
public string FriendlyName { get; set; } = null!;
|
||||
public string Tooltip { get; set; } = null!;
|
||||
public Rarity Rarity { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using RadicadeServer.RecNet.Enums.Store;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
public class EquipmentItem
|
||||
{
|
||||
|
||||
public string EquipmentPrefabName { get; set; } = "";
|
||||
|
||||
public string FriendlyName { get; set; } = "";
|
||||
public string Tooltip { get; set; } = "";
|
||||
public Rarity Rarity { get; set; } = Rarity.Common;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Enums.Store;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar
|
||||
{
|
||||
public class EquipmentSkinItem
|
||||
{
|
||||
[JsonPropertyName("prefab_name")]
|
||||
public string PrefabName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("modification_guid")]
|
||||
public string ModificationGuid { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("display_name")]
|
||||
public string DisplayName { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("tooltip")]
|
||||
public string Tooltip { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("rarity")]
|
||||
public Rarity Rarity { get; set; } = Rarity.Common;
|
||||
|
||||
[JsonPropertyName("storefront")]
|
||||
public string Storefront { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("price")]
|
||||
public long Price { get; set; }
|
||||
|
||||
[JsonPropertyName("image_name")]
|
||||
public string ImageName { get; set; } = "";
|
||||
}
|
||||
|
||||
public class EquipmentSkinFile
|
||||
{
|
||||
[JsonPropertyName("items")]
|
||||
public List<EquipmentSkinItem> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public static class EquipmentSkinCatalog
|
||||
{
|
||||
private static readonly JsonSerializerOptions ReadOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true
|
||||
};
|
||||
|
||||
public static List<EquipmentSkinItem> Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return new List<EquipmentSkinItem>();
|
||||
|
||||
try
|
||||
{
|
||||
var file = JsonSerializer.Deserialize<EquipmentSkinFile>(json, ReadOptions);
|
||||
return (file?.Items ?? new List<EquipmentSkinItem>())
|
||||
.Where(item => item != null
|
||||
&& !string.IsNullOrWhiteSpace(item.PrefabName)
|
||||
&& !string.IsNullOrWhiteSpace(item.ModificationGuid))
|
||||
.ToList();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new List<EquipmentSkinItem>();
|
||||
}
|
||||
}
|
||||
|
||||
public static List<Equipment> ToEquipment(IEnumerable<EquipmentSkinItem> skins)
|
||||
{
|
||||
return skins.Select(skin => new Equipment
|
||||
{
|
||||
PrefabName = skin.PrefabName.Trim(),
|
||||
ModificationGuid = skin.ModificationGuid.Trim(),
|
||||
UnlockedLevel = 0,
|
||||
Favorited = false,
|
||||
FriendlyName = string.IsNullOrWhiteSpace(skin.DisplayName)
|
||||
? skin.PrefabName.Trim()
|
||||
: skin.DisplayName.Trim(),
|
||||
Tooltip = skin.Tooltip ?? "",
|
||||
Rarity = skin.Rarity
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
public static string MergeInto(string? equipmentJson, string? skinsJson)
|
||||
{
|
||||
var skins = ToEquipment(Parse(skinsJson));
|
||||
if (skins.Count == 0) return equipmentJson ?? "[]";
|
||||
|
||||
var equipment = ParseEquipment(equipmentJson);
|
||||
if (equipment == null) return equipmentJson ?? "[]";
|
||||
|
||||
var known = new HashSet<string>(
|
||||
equipment.Select(Key), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var added = 0;
|
||||
foreach (var skin in skins)
|
||||
{
|
||||
if (!known.Add(Key(skin))) continue;
|
||||
equipment.Add(skin);
|
||||
added++;
|
||||
}
|
||||
|
||||
return added == 0 ? equipmentJson ?? "[]" : equipment.ToJson();
|
||||
}
|
||||
|
||||
private static List<Equipment>? ParseEquipment(string? equipmentJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(equipmentJson)) return new List<Equipment>();
|
||||
try
|
||||
{
|
||||
return equipmentJson.FromJson<List<Equipment>>(ReadOptions) ?? new List<Equipment>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Key(Equipment item) =>
|
||||
$"{(item.PrefabName ?? "").Trim()}\u001f{(item.ModificationGuid ?? "").Trim()}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Avatar;
|
||||
|
||||
namespace RadicadeServer.API.Avatars.V1;
|
||||
|
||||
[Route("api/equipment/v1")]
|
||||
[Route("econ/api/equipment/v1")]
|
||||
[Authorize]
|
||||
public class EquipmentV1(RecDBContext db) : Controller
|
||||
{
|
||||
[HttpPost("update")]
|
||||
[HttpPut("update")]
|
||||
public async Task<IActionResult> UpdateEquipment([FromBody] JsonElement body)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
if (!TryParse(body, out var entries))
|
||||
return BadRequest();
|
||||
|
||||
var slots = entries.Where(entry => entry.HasSlot).ToList();
|
||||
var favourites = entries.Where(entry => entry.Favorited.HasValue).ToList();
|
||||
if (slots.Count == 0 && favourites.Count == 0)
|
||||
return Ok();
|
||||
|
||||
if (slots.Count > 0)
|
||||
{
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (avatar != null)
|
||||
{
|
||||
avatar.EquipmentSlots = slots.Select(entry => (object)new
|
||||
{
|
||||
slotIndex = entry.SlotIndex,
|
||||
equipmentPrefabName = entry.PrefabName ?? string.Empty,
|
||||
equipmentModificationGuid = entry.ModificationGuid ?? string.Empty
|
||||
}).ToList().ToJson();
|
||||
}
|
||||
}
|
||||
|
||||
if (favourites.Count > 0)
|
||||
await ApplyFavouritesAsync(userId.Value, favourites);
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private async Task ApplyFavouritesAsync(long userId, List<EquipmentUpdate> favourites)
|
||||
{
|
||||
var unlocks = await db.Unlocks.FirstOrDefaultAsync(x => x.AccountId == userId);
|
||||
if (unlocks == null)
|
||||
{
|
||||
unlocks = new PlayerUnlocks { AccountId = userId };
|
||||
db.Unlocks.Add(unlocks);
|
||||
}
|
||||
|
||||
List<Equipment> equipment;
|
||||
try
|
||||
{
|
||||
equipment = unlocks.GetEquipment() ?? new List<Equipment>();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
equipment = new List<Equipment>();
|
||||
}
|
||||
|
||||
foreach (var update in favourites)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(update.ModificationGuid)) continue;
|
||||
|
||||
var row = equipment.FirstOrDefault(item => string.Equals(
|
||||
item.ModificationGuid, update.ModificationGuid, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (row == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
row.Favorited = update.Favorited == true;
|
||||
}
|
||||
|
||||
unlocks.Equipment = equipment.ToJson();
|
||||
}
|
||||
|
||||
private sealed class EquipmentUpdate
|
||||
{
|
||||
public string? PrefabName { get; set; }
|
||||
public string? ModificationGuid { get; set; }
|
||||
public bool? Favorited { get; set; }
|
||||
public int SlotIndex { get; set; }
|
||||
public bool HasSlot { get; set; }
|
||||
}
|
||||
|
||||
private static bool TryParse(JsonElement body, out List<EquipmentUpdate> entries)
|
||||
{
|
||||
entries = new List<EquipmentUpdate>();
|
||||
|
||||
JsonElement array;
|
||||
if (body.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
array = body;
|
||||
}
|
||||
else if (body.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var found = false;
|
||||
array = default;
|
||||
foreach (var prop in body.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, "equipmentSlots", StringComparison.OrdinalIgnoreCase)
|
||||
&& prop.Value.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
array = prop.Value;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var item in array.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.Object) continue;
|
||||
|
||||
var entry = new EquipmentUpdate();
|
||||
foreach (var prop in item.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(prop.Name, "slotIndex", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(prop.Name, "slot", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(prop.Name, "index", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.Number) entry.SlotIndex = prop.Value.GetInt32();
|
||||
entry.HasSlot = true;
|
||||
}
|
||||
else if (string.Equals(prop.Name, "favorited", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(prop.Name, "favourite", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.True) entry.Favorited = true;
|
||||
else if (prop.Value.ValueKind == JsonValueKind.False) entry.Favorited = false;
|
||||
}
|
||||
else if (string.Equals(prop.Name, "modificationGuid", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(prop.Name, "equipmentModificationGuid", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.String) entry.ModificationGuid = prop.Value.GetString();
|
||||
}
|
||||
else if (string.Equals(prop.Name, "prefabName", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(prop.Name, "equipmentPrefabName", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(prop.Name, "equipment", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.String) entry.PrefabName = prop.Value.GetString();
|
||||
}
|
||||
}
|
||||
|
||||
entries.Add(entry);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Accounts;
|
||||
using RadicadeServer.RecNet.Avatar;
|
||||
|
||||
namespace RadicadeServer.API.Avatars.V2
|
||||
{
|
||||
[Route("api/equipment/v2")]
|
||||
[Route("econ/api/equipment/v2")]
|
||||
public class EquipmentV2 : Controller
|
||||
{
|
||||
RecDBContext db;
|
||||
public EquipmentV2(RecDBContext context)
|
||||
{
|
||||
this.db = context;
|
||||
}
|
||||
|
||||
[HttpGet("getUnlocked"), Authorize]
|
||||
public List<Equipment> GetUnlockedEquipment()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
|
||||
var items = db.Unlocks.FirstOrDefault(x => x.AccountId == userId);
|
||||
var defaultEquipment = TryReadEquipment(LockerAccess.HasFullInventory(db, userId)
|
||||
? EquipmentSkinCatalog.MergeInto(DBConfig.DefaultEquipmentItems, DBConfig.DefaultEquipmentSkinItems)
|
||||
: DBConfig.DefaultEquipmentUnlocks);
|
||||
var playerEquipment = GetPlayerEquipment(items);
|
||||
|
||||
return MergeEquipment(defaultEquipment, playerEquipment);
|
||||
}
|
||||
|
||||
private static List<Equipment> TryReadEquipment(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (json.FromJson<List<Equipment>>() ?? new List<Equipment>())
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.PrefabName))
|
||||
.Select(NormalizeEquipment)
|
||||
.ToList();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<Equipment>();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Equipment> GetPlayerEquipment(PlayerUnlocks? items)
|
||||
{
|
||||
try
|
||||
{
|
||||
return items?.GetEquipment() ?? new List<Equipment>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<Equipment>();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<Equipment> MergeEquipment(IEnumerable<Equipment> defaults, IEnumerable<Equipment> playerEquipment)
|
||||
{
|
||||
var merged = new Dictionary<string, Equipment>();
|
||||
|
||||
foreach (var item in defaults.Concat(playerEquipment).Select(NormalizeEquipment))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.PrefabName)) continue;
|
||||
merged[$"{item.PrefabName}\u001f{item.ModificationGuid}"] = item;
|
||||
}
|
||||
|
||||
return merged.Values.ToList();
|
||||
}
|
||||
|
||||
private static Equipment NormalizeEquipment(Equipment item)
|
||||
{
|
||||
item.PrefabName ??= "";
|
||||
item.ModificationGuid ??= "";
|
||||
item.FriendlyName = string.IsNullOrWhiteSpace(item.FriendlyName) ? item.PrefabName : item.FriendlyName;
|
||||
item.Tooltip ??= "";
|
||||
return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Newtonsoft.Json;
|
||||
using RadicadeServer.RecNet.Enums.Players.Avatar;
|
||||
using RadicadeServer.RecNet.Enums.Store;
|
||||
using RadicadeServer.Radicade.Gifts.Enums;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar
|
||||
{
|
||||
public class Gift
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public long GiftDropId { get; set; }
|
||||
|
||||
public long ItemSetId { get; set; } = 0;
|
||||
public string ItemSetFriendlyName { get; set; } = string.Empty;
|
||||
|
||||
public string FriendlyName { get; set; } = string.Empty;
|
||||
public string Tooltip { get; set; } = string.Empty;
|
||||
|
||||
public bool IsQuery { get; set; } = false;
|
||||
public bool Unique { get; set; } = false;
|
||||
public bool SubscribersOnly { get; set; } = false;
|
||||
|
||||
public string ConsumableItemDesc { get; set; } = string.Empty;
|
||||
|
||||
public string AvatarItemDesc { get; set; } = string.Empty;
|
||||
public AvatarItemType AvatarItemType { get; set; }
|
||||
|
||||
public string EquipmentPrefabName { get; set; } = string.Empty;
|
||||
public string EquipmentModificationGuid { get; set; } = string.Empty;
|
||||
|
||||
public CurrencyType CurrencyType { get; set; } = CurrencyType.RecCenterTokens;
|
||||
public int Currency { get; set; } = 0;
|
||||
public int Xp { get; set; } = 0;
|
||||
|
||||
public int Platform { get; set; } = 0;
|
||||
public int PlatformsToSpawnOn { get; set; } = 0;
|
||||
|
||||
public long FromPlayerId { get; set; } = 0;
|
||||
|
||||
public int? BalanceType { get; set; } = null;
|
||||
|
||||
public RadicadeServer.RecNet.Enums.Store.GiftContext Context { get; set; } = RadicadeServer.RecNet.Enums.Store.GiftContext.Commerce_Purchase;
|
||||
public Rarity Rarity { get; set; } = Rarity.Common;
|
||||
|
||||
[JsonIgnore] public bool IsFeatured { get; set; }
|
||||
[JsonIgnore] public ItemAccessibility Accessibility { get; set; } = ItemAccessibility.Purchasable;
|
||||
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("Id")]
|
||||
[Newtonsoft.Json.JsonProperty("Id")]
|
||||
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||
public long Id { get => GiftDropId; set => GiftDropId = value; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("Message")]
|
||||
[Newtonsoft.Json.JsonProperty("Message")]
|
||||
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||
public string Message { get => Tooltip; set => Tooltip = value ?? string.Empty; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("GiftRarity")]
|
||||
[Newtonsoft.Json.JsonProperty("GiftRarity")]
|
||||
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||
public int GiftRarity { get => (int)Rarity; set => Rarity = (Rarity)value; }
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("GiftContext")]
|
||||
[Newtonsoft.Json.JsonProperty("GiftContext")]
|
||||
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||
public int GiftContext
|
||||
{
|
||||
get => (int)Context;
|
||||
set => Context = (RadicadeServer.RecNet.Enums.Store.GiftContext)value;
|
||||
}
|
||||
|
||||
[System.Text.Json.Serialization.JsonPropertyName("GiftPackageId")]
|
||||
[Newtonsoft.Json.JsonProperty("GiftPackageId")]
|
||||
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||
public long GiftPackageId { get => GiftDropId; set => GiftDropId = value; }
|
||||
|
||||
public Gift Clone() => (Gift)MemberwiseClone();
|
||||
|
||||
public Gift ForClient()
|
||||
|
||||
{
|
||||
var copy = (Gift)MemberwiseClone();
|
||||
copy.ConsumableItemDesc = ConsumableCatalog.ToClientDesc(ConsumableItemDesc);
|
||||
|
||||
copy.Platform = -1;
|
||||
copy.PlatformsToSpawnOn = -1;
|
||||
return copy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using RadicadeServer.DB;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar;
|
||||
|
||||
public static class LockerAccess
|
||||
{
|
||||
public static bool HasFullInventory(RecDBContext db, long? accountId) =>
|
||||
accountId.HasValue && db.Profiles.FirstOrDefault(p => p.accountId == accountId.Value)
|
||||
?.HasRole("unlockedlocker") == true;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace RadicadeServer.RecNet.Avatar
|
||||
{
|
||||
public class Outfit : Avatar
|
||||
{
|
||||
public int? Slot { get; set; }
|
||||
public string PreviewImageName { get; set; } = null!;
|
||||
public string OutfitSelectionsV2 { get; set; } = null!;
|
||||
public object[] CustomAvatarItems { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.API.Avatars.V2;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
|
||||
namespace RadicadeServer.API.Avatars;
|
||||
|
||||
[Route("outfits")]
|
||||
[Authorize]
|
||||
public class OutfitsAPI(RecDBContext db) : Controller
|
||||
{
|
||||
[HttpGet("me")]
|
||||
public async Task<IActionResult> GetCurrentOutfit()
|
||||
{
|
||||
if (User.GetUserId() == null) return Unauthorized();
|
||||
|
||||
var avatarApi = new AvatarV2(db) { ControllerContext = ControllerContext };
|
||||
var avatar = await avatarApi.GetLocalAvatar();
|
||||
if (avatar == null) return NotFound();
|
||||
|
||||
return Ok(new CurrentOutfitResponse
|
||||
{
|
||||
LegacyData = new LegacyOutfitData
|
||||
{
|
||||
SelectionsV1 = avatar.OutfitSelections,
|
||||
HairColor = avatar.HairColor,
|
||||
SkinColor = avatar.SkinColor,
|
||||
FaceFeatures = avatar.FaceFeatures
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class CurrentOutfitResponse
|
||||
{
|
||||
public long DataVersion { get; init; }
|
||||
public LegacyOutfitData LegacyData { get; init; } = new();
|
||||
public string? CustomizationSettings { get; init; }
|
||||
public object[] Selections { get; init; } = [];
|
||||
public int Slot { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public int Accessibility { get; init; }
|
||||
public string? ThumbnailFileName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class LegacyOutfitData
|
||||
{
|
||||
public string? SelectionsV1 { get; init; }
|
||||
public string? SelectionsV2 { get; init; }
|
||||
public string? FaceFeatures { get; init; }
|
||||
public string? SkinColor { get; init; }
|
||||
public string? HairColor { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using RadicadeServer.RecNet.Enums.Store;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar
|
||||
{
|
||||
public class PurchasableItem
|
||||
{
|
||||
public enum PurchasableItemType
|
||||
{
|
||||
GiftDrop = 0,
|
||||
SeasonTier = 1,
|
||||
SeasonEliteUpgrade = 2
|
||||
}
|
||||
|
||||
public int PurchasableItemId { get; set; }
|
||||
|
||||
public PurchasableItemType Type { get; set; } = PurchasableItemType.GiftDrop;
|
||||
|
||||
public List<PurchasablePrice> Prices { get; set; } = new();
|
||||
|
||||
public List<PurchasablePrice> SubscriberPrices { get; set; } = new();
|
||||
|
||||
public bool IsFeatured { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
|
||||
public DateTime? NewUntil { get; set; }
|
||||
}
|
||||
|
||||
public class PurchasablePrice
|
||||
{
|
||||
public CurrencyType CurrencyType { get; set; }
|
||||
|
||||
public int Price { get; set; }
|
||||
|
||||
public int? PurchaseCurrencyId { get; set; }
|
||||
|
||||
public DateTime? StartDate { get; set; }
|
||||
|
||||
public DateTime? EndDate { get; set; }
|
||||
}
|
||||
|
||||
public class PurchasableGiftDrop : PurchasableItem
|
||||
{
|
||||
public List<Gift> GiftDrops { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("GiftDrop")]
|
||||
public Gift? GiftDrop
|
||||
{
|
||||
get => GiftDrops.FirstOrDefault();
|
||||
set
|
||||
{
|
||||
GiftDrops = value == null ? new List<Gift>() : new List<Gift> { value };
|
||||
}
|
||||
}
|
||||
|
||||
public PurchasableGiftDrop() {}
|
||||
public PurchasableGiftDrop(List<Gift> giftDrops) {
|
||||
GiftDrops = giftDrops;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Accounts.Local;
|
||||
|
||||
namespace RadicadeServer.RecNet.Avatar;
|
||||
|
||||
public static class SavedOutfits
|
||||
{
|
||||
public static async Task<List<Outfit>> GetAsync(RecDBContext db, long accountId)
|
||||
{
|
||||
var avatar = await db.Avatars.AsNoTracking().FirstOrDefaultAsync(x => x.AccountId == accountId);
|
||||
return Parse(avatar?.Outfits);
|
||||
}
|
||||
|
||||
public static async Task<Outfit?> SaveAsync(RecDBContext db, long accountId, Outfit? outfit)
|
||||
{
|
||||
if (outfit?.Slot == null)
|
||||
return null;
|
||||
|
||||
var avatar = await db.Avatars.FirstOrDefaultAsync(x => x.AccountId == accountId);
|
||||
if (avatar == null)
|
||||
{
|
||||
avatar = new PlayerAvatar
|
||||
{
|
||||
AccountId = accountId,
|
||||
Avatar = DBConfig.DefaultAvatar,
|
||||
Outfits = "[]"
|
||||
};
|
||||
db.Avatars.Add(avatar);
|
||||
}
|
||||
|
||||
var outfits = Parse(avatar.Outfits);
|
||||
|
||||
var matchingSlot = outfits.FindIndex(x => x.Slot == outfit.Slot);
|
||||
if (matchingSlot >= 0)
|
||||
outfits[matchingSlot] = outfit;
|
||||
else
|
||||
outfits.Add(outfit);
|
||||
|
||||
avatar.Outfits = outfits.ToJson();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return outfit;
|
||||
}
|
||||
|
||||
private static List<Outfit> Parse(string? stored)
|
||||
{
|
||||
try
|
||||
{
|
||||
var outfits = (stored ?? string.Empty).FromJson<List<Outfit>>();
|
||||
return outfits?.Where(x => x != null).ToList() ?? new List<Outfit>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<Outfit>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,1226 @@
|
||||
[
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "5d13a7a2-8213-40e6-90a6-efdd76a3fdcb,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Flowing Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "1d27b674-f9e2-4ffc-9d8c-a58a1be06457,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Afro Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "d84c0ff9-8fbe-4ed8-abf3-7996e81888ab,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Large Afro Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "e5b83dfc-b2e1-4dcb-a4ab-9d3a4c8a34ae,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Long Wavy Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "7dd6f7b0-7ba0-429f-a04f-e32d3a79ee61,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Short Wavy Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "eb9611c6-bb50-41a2-93e9-7f959815a846,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Dreads Long Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Ponytail Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "a12f724f-4a73-4ab8-aad4-6bfc662b4dd6,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Undercut Long Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "0753d7a4-8247-4fca-a6fc-359c26086140,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Fonzie Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "77d3c585-4928-4471-a425-89036efe7299,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Spiky Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "92302d9d-c527-418c-ac5d-1fa869727505,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Part Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "f9dd08f8-16d3-4c39-af4f-89f7bb6e80d3,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Undercut Short Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "b148cb1e-df81-442f-aea6-ab1727aad00e,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Chunky Afro Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "e36bcd98-7e85-43fa-89f8-57e4ec33823a,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Bob with Bangs Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "880a3cc0-7407-4b61-b759-f9dd890fe9e5,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Bob Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "21599b51-c50f-43d8-ac5f-62c30cd02ca5,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Lori Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "193a3bf9-abc0-4d78-8d63-92046908b1c5,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Emo Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "79b90274-6eec-4664-acfb-4a123334661e,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Pig Tails Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "da4e7b34-2095-4a9e-801e-4f409039e0dd,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Buzz Cut Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "9d9fadb6-97eb-480e-a224-4e0179082071,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Meatball Buns Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "d8280c0c-d803-4513-be10-a0ba96d8821e,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Flowhawk Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "e286863c-2967-4d00-b837-b49487b9484a,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Fauxhawk Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2cb4f372-3372-4583-8b57-c4e3988e3c28,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Punky Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Cat-Eye Glasses (Red)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Glasses (Teal)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "8d10cc78-6b00-45f3-affb-205e9cc5b03f,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Beard (Close)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "cc96f8a5-bc5b-4f89-83b7-ecd53905ada7,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Beard (Thick)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "c6c08eb5-381a-4193-9722-80da95d62abe,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Business Tie (Black)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "4d507dfa-4a99-4ac0-8537-229e9dc0eb4a,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Rec Room Tank Top (Orange)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Rec Room Shirt (Crew Neck, White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Rec Room T-Shirt (Crew Neck, Orange)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Shirt (Plaid, Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "T-Shirt",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Rec Room Shirt (V-Neck, White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,95e4cc30-cb68-473d-a395-feadf5b51512,05f0ee6e-c824-470e-9178-5ed576c6fe0c,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Rec Room T-Shirt (V-Neck, Orange)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Tennis Skirt (Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,c5deba2a-6e35-4b13-8e94-8ba5457f39df,b75ef67d-00c3-4ac1-9b72-212032460294,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Tennis Skirt (Yellow)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,758752bd-db2f-43d2-b580-55b3e1efffd5,b75ef67d-00c3-4ac1-9b72-212032460294,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Tennis Skirt (Red)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Zipper Dress (Yellow)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wristbands (White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wrist Tape (White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,55901f12-d5b5-4fa8-b4c8-e479689ee39d,f600037d-c9c0-43fa-b45b-02f456f9dd5f,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Shirt (Denim)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,bf82f2f6-9af8-431e-a296-0890dea48ba7,d015cae7-a905-49e4-8823-6dec069689a6,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Shirt (Argyle)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,EfdMcnfHt0mr0PQ_maaYOg,DRJcNhkqvkKFEaZpOguR6w,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Shirt (Flowers, Green)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,6d703981-2734-4c45-8983-cdd5f328902f,a0271cd0-e172-4d3f-aa2f-9806f21a82d2,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Tank Top (Camo)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,9c03f381-7357-4d0f-8cda-8737d4c43d25,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Tank Top (Rainbow)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,d2a692e6-e1a9-4cfe-8154-10b52be7f8c8,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Jersey (Orange)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 10
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,b292eb4b-07e3-4a48-99b5-3c6587a1e02e,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Tank Top (Dots)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,48abd952-214f-48b2-a8f1-1146f6f69aa2,b78008e8-abbd-4ece-be34-9a911f721fcc,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Tank Top (Zebra)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Baseball Cap (Red)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Baseball Cap (Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Cream)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Black)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,018a5c07-e956-457d-a540-a5e2cd68da09,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Orange, White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,018a5c07-e956-457d-a540-a5e2cd68da09,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Pink, White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,018a5c07-e956-457d-a540-a5e2cd68da09,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Blue, White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,6dd95046-acf8-42fe-ab78-80a334096a9d,56a92c8d-af53-413e-929e-4a9a3cfad780,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Red, White, Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wristbands (Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wristbands (Black)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wristbands (Orange)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wristbands (Pink)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wristbands (Red)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "fcfcaf63-deb4-45f7-b711-c051c9ea45cb,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Top Bun Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Blazer (Blue, White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,6f2e74bf-1e95-463d-97db-d5d1a53b2c28,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Blazer (Black, White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,9374bf66-2ee5-493b-8439-efce4b201904,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Blazer (Grey, Black)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,272fe8eb-5061-4729-a7a8-414ff667a82f,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Blazer (Grey, White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,0ffad843-d6c9-425a-8686-7217009c867e,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Blazer (Green, Black)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,e0397982-c2c2-4733-9a40-46e18675b5af,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Button Top (Orange)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Button Top (Pink)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,49f5864f-9d40-497c-88c8-e87f64d41d74,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Button Top (Tan)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,c5deba2a-6e35-4b13-8e94-8ba5457f39df,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Button Top (Yellow)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,f750de46-3758-4f7d-9709-0a84b1027009,2c8924aa-68f8-4912-9759-18992f72f08a,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Dress (Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,d66aa400-aa5a-4539-a25d-5f8ce94dc281,2c8924aa-68f8-4912-9759-18992f72f08a,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Dress (Green)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,6564acf1-4d70-4f92-92ac-08e2b76dbb6b,2c8924aa-68f8-4912-9759-18992f72f08a,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Dress (Purple)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Dress (Red)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "241506f6-bf88-4b46-b5fe-513a225421f4,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Half Up Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,d6edbc00-3c1d-4f49-8412-3ef8c7c5f4c2,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Long Scarf (Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,6d703981-2734-4c45-8983-cdd5f328902f,cfabdefe-0890-436e-b2a3-b5c712e22955,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Zipper Dress (Green)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,830be2fa-60a5-48cc-931f-34b670eae4bd,cfabdefe-0890-436e-b2a3-b5c712e22955,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Zipper Dress (Purple)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a,cfabdefe-0890-436e-b2a3-b5c712e22955,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Zipper Dress (White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wristbands (Light Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wristbands (Green)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wristbands (Gold)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Tank Top (Black)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Tank Top (Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Tank Top (Orange)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Tank Top (Red)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Long Scarf (Purple)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,6dd95046-acf8-42fe-ab78-80a334096a9d,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Long Scarf (White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Green)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Red)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Yellow)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "24a240f4-1574-420b-b898-a7e91f170759,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Back Bun Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "c45ed7b8-99bd-4a4b-a9ff-e16edf5d7a18,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "High Pony Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Baseball Cap (Light Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Baseball Cap (Black)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Baseball Cap (Orange)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Baseball Cap (White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Baseball Cap (Yellow)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Equestrian Coat (Black)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5,55901f12-d5b5-4fa8-b4c8-e479689ee39d,d344b8cc-85a8-4ace-9f92-38c84f396e99,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Equestrian Coat (Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5,4828b50c-95b6-466a-bb25-514891d78202,d344b8cc-85a8-4ace-9f92-38c84f396e99,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Equestrian Coat (Grey)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "896c2491-2f96-4986-9cbd-b3b31ef5d8c5,d6823e01-69f0-4f85-b94a-74894356a2cf,d344b8cc-85a8-4ace-9f92-38c84f396e99,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Equestrian Coat (Maroon)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "09177621-9ecd-4f6a-b6a5-64490139141d,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Flat Top Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "95ab7a7c-c35d-4da5-9955-0921064470b6,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Gekko Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Grey)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Light Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Orange)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Pink)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,018a5c07-e956-457d-a540-a5e2cd68da09,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Headband (Red, White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Slim Blazer (Teal)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,cd5d7285-202d-42d0-b93f-04245875793e,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Slim Blazer (Green)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Slim Blazer (Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "8b9f1413-e786-4a30-946c-9292f207875a,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Pulp Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "5cd08cfb-c729-4c30-96d9-6a99bb934d91,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Rec Room Sash",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "1a71064b-794f-40fa-9109-8ad36602b6e1,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Shagg Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,761a3193-60f0-4190-80c7-285b8192e794,91a451c1-b285-4c48-b14d-59ded8cc006f,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Stoll Dress (Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,a819f49b-6c7a-49d3-9e6a-d9d79ef5019f,91a451c1-b285-4c48-b14d-59ded8cc006f,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Stoll Dress (Green)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,64850553-cdfe-455a-ac00-dafbe63d613e,91a451c1-b285-4c48-b14d-59ded8cc006f,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Stoll Dress (Orange)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Stoll Dress (Pink)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wrist Tape (Black)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wrist Tape (Cream)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wristbands (Grey)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Wristbands (Cream)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Long Scarf (Red)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,0iSsaY-HgkmLaRHCn5vEdw,PioQ0o3yP0a6szPZ4EKs2A,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Shirt (Blue)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,jGj28vhq8EGwP2RuM074aQ,PioQ0o3yP0a6szPZ4EKs2A,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Shirt (Yellow)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,kmj5zOjcwku_WWKroCeiVQ,PioQ0o3yP0a6szPZ4EKs2A,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Shirt (Pink)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,FAviMCQ_EE2Mpt6QPo5OEw,PioQ0o3yP0a6szPZ4EKs2A,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Shirt (Red)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,MFrcSQ1DYUm8imvy4ypgvw,PioQ0o3yP0a6szPZ4EKs2A,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Collared Shirt (White)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,05ac07e1-67f0-486c-abf5-a62866475abb,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Blazer (Black, Cream)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "0088603e-ec3b-4478-8694-e6fb1989b3f2,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Angled Bob Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "ffea7a65-613f-4835-921e-6dd15f357b7e,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Long Bangs Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "45f5e714-8a5f-4385-a97f-675066167011,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Seventies Stache",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "9bf5d259-7774-4cbe-a90f-7f188cc0dce7,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Thick Goatee",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "a6cbfe76-534a-4655-a8a8-3fed13d001c7,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Bald Top Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "CTcrvbo3OEepIV4oW8bx4w,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Receding Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "-twtjyBdQ02EAdOfBGTiEw,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Van Dyke Beard",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "45eaab67-19c2-4601-8f80-3565a4dceba4,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Pompadour Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "c855dcc3-96cb-470d-b159-d37a025a47d1,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Dutch Braid Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "d7730a9e-78a1-4356-bc09-6b066615850b,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Afro Updo Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "8c35c804-e8d5-49d2-8d5a-ea19fb70bfa6,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Pencil Bun Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "5beeb4c4-f276-4eae-87aa-9302e45b05b7,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Cornrows Hair",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,sxUE0iOSZEmezm54T7xI3Q,tlpa7195x0CkmSjpR1RArQ,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Rainbow Pride)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Winged Hat - Pride (Rainbow Pride)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,D_Xmo0rOzkS-kgq1CYXt3g,tnCJp2eDI0SwjVfJMhk3LQ,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Trans Pride)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,knXPidb-Rkayfc3kSHfZeQ,1yMyo6oTjU-VAygoeWaohQ,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Winged Hat - Pride (Trans Pride)",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "88b6ddeb-a455-460d-91d9-a4569ef6903c,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Square Earrings ",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "0abb6b08-20ce-444f-879e-0d1344df096c,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Round Earrings",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
},
|
||||
{
|
||||
"AvatarItemType": 0,
|
||||
"AvatarItemDesc": "9b5bde11-7408-4798-9fcb-c7ec175444df,,,",
|
||||
"PlatformMask": -1,
|
||||
"FriendlyName": "Hoop Earrings",
|
||||
"Tooltip": "",
|
||||
"Rarity": 0
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,342 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.Radicade.Discord;
|
||||
using RadicadeServer.API.Notifications;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Moderation;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
using RadicadeServer.Utils;
|
||||
using RadicadeServer.Discord.Webhooks;
|
||||
|
||||
namespace RadicadeServer.API.Moderation
|
||||
{
|
||||
public class SubmitAppealRequest
|
||||
{
|
||||
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[Route("api/Appeal/")]
|
||||
public class AppealAPI(RecDBContext db) : Controller
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("submit")]
|
||||
public async Task<ActionResult<OkResponse>> Submit([FromBody] SubmitAppealRequest request)
|
||||
{
|
||||
var code = request?.Code?.Trim().ToUpperInvariant() ?? string.Empty;
|
||||
if (code.Length == 0)
|
||||
return BadRequest(new OkResponse { Success = false, Message = "Missing appeal code." });
|
||||
|
||||
var text = request?.Text?.Trim() ?? string.Empty;
|
||||
if (text.Length < 10)
|
||||
return BadRequest(new OkResponse { Success = false, Message = "Please describe your appeal in at least 10 characters." });
|
||||
|
||||
var ban = await db.Moderation.FirstOrDefaultAsync(x => x.AppealCode == code);
|
||||
if (ban == null)
|
||||
return NotFound(new OkResponse { Success = false, Message = "Unknown appeal code." });
|
||||
|
||||
|
||||
|
||||
|
||||
if (!ban.IsBan || ban.ExpireDuration < DateTime.UtcNow)
|
||||
return BadRequest(new OkResponse { Success = false, Message = "Player is not banned!" });
|
||||
|
||||
if (AccountLock.IsLockBan(ban))
|
||||
return BadRequest(new OkResponse { Success = false, Message = AccountLock.NotAppealableMessage });
|
||||
|
||||
|
||||
if (!ModerationPolicy.IssuesAppealCode(ban.BanMethod) || ModerationPolicy.RefusesAppeal(ban.BanMethod))
|
||||
return BadRequest(new OkResponse { Success = false, Message = AccountLock.NotAppealableMessage });
|
||||
|
||||
|
||||
if (ban.AppealText != null && ban.AppealResolution == null)
|
||||
return BadRequest(new OkResponse { Success = false, Message = "Your appeal is already pending review." });
|
||||
|
||||
if (ban.AppealResolution == "Accepted")
|
||||
return BadRequest(new OkResponse { Success = false, Message = "Player is not banned!" });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var cooldownError = await PlayerUtils.GetAppealCooldownErrorAsync(db, ban.PlayerId);
|
||||
if (cooldownError != null)
|
||||
{
|
||||
return BadRequest(new OkResponse { Success = false, Message = cooldownError });
|
||||
}
|
||||
|
||||
ban.AppealText = text;
|
||||
ban.AppealSubmittedAt = DateTime.UtcNow;
|
||||
ban.AppealResolution = null;
|
||||
ban.AppealResolvedAt = null;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (profile != null)
|
||||
{
|
||||
await Notify.SendNotify(ban.PlayerId, new Notification { Id = NotificationType.RefreshLogin, Msg = profile });
|
||||
}
|
||||
|
||||
ReportDiscord.SendAsync(WebhookConfig.BanAppealsUrl, "🎟️ Ban Appeal",
|
||||
new DiscordPayload
|
||||
{
|
||||
Title = "New Ban Appeal",
|
||||
ThumbnailUrl = WebhookImages.BanAppeal,
|
||||
Color = 0x5865F2,
|
||||
Fields = new List<DiscordField>
|
||||
{
|
||||
new() { Name = "Player", Value = profile?.username ?? ban.PlayerId.ToString(), Inline = true },
|
||||
new() { Name = "Player ID", Value = ban.PlayerId.ToString(), Inline = true },
|
||||
new() { Name = "Appeal Code", Value = code, Inline = true },
|
||||
new() { Name = "Ban Reason", Value = ban.Message ?? "—", Inline = false },
|
||||
new() { Name = "Appeal", Value = text, Inline = false }
|
||||
},
|
||||
TimestampUtc = DateTime.UtcNow.ToString("O")
|
||||
});
|
||||
|
||||
return Ok(new OkResponse { Success = true, Message = "Appeal submitted. A moderator will review it shortly." });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<object>> Status([FromQuery] string code)
|
||||
{
|
||||
var normalized = code?.Trim().ToUpperInvariant() ?? string.Empty;
|
||||
if (normalized.Length == 0)
|
||||
return Ok(new { found = false });
|
||||
|
||||
var ban = await db.Moderation.AsNoTracking().FirstOrDefaultAsync(x => x.AppealCode == normalized);
|
||||
if (ban == null)
|
||||
return Ok(new { found = false });
|
||||
|
||||
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId);
|
||||
|
||||
bool banned = ban.IsBan && ban.ExpireDuration > DateTime.UtcNow;
|
||||
string? cooldownText = null;
|
||||
DateTime? cooldownUntilUtc = null;
|
||||
|
||||
|
||||
if (banned
|
||||
&& (ban.AppealResolution == "Rejected"
|
||||
|| ban.AppealResolution == "Reduced"
|
||||
|| ban.AppealResolution == "Extended"
|
||||
|| ban.AppealResolution == "Reviewed")
|
||||
&& ban.AppealResolvedAt.HasValue)
|
||||
{
|
||||
var cooldownEnds = ban.AppealResolvedAt.Value + PlayerUtils.AppealCooldownDuration;
|
||||
if (cooldownEnds > DateTime.UtcNow)
|
||||
{
|
||||
cooldownUntilUtc = cooldownEnds;
|
||||
cooldownText = $"{PlayerUtils.AppealCooldownPrefix}{PlayerUtils.FormatAppealCooldown(cooldownEnds - DateTime.UtcNow)}";
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
found = true,
|
||||
player = profile?.username ?? "unknown",
|
||||
reason = ban.Message ?? string.Empty,
|
||||
category = ban.Category.ToString(),
|
||||
permanent = ban.ExpireDuration >= DateTime.MaxValue.AddYears(-10),
|
||||
expiresAtUtc = ban.ExpireDuration,
|
||||
banned,
|
||||
hasAppeal = ban.AppealText != null,
|
||||
submittedAt = ban.AppealSubmittedAt,
|
||||
resolution = ban.AppealResolution,
|
||||
resolvedAtUtc = ban.AppealResolvedAt,
|
||||
cooldownUntilUtc,
|
||||
cooldownText
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[Route("api/banappeal/")]
|
||||
public class BanAppealAPI(RecDBContext db) : Controller
|
||||
{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
private static object AppealResponse(bool success, string? error, object? value) => new
|
||||
{
|
||||
success,
|
||||
error,
|
||||
value
|
||||
};
|
||||
|
||||
[HttpGet("generateCode"), Authorize]
|
||||
[HttpPost("generateCode"), Authorize]
|
||||
public async Task<ActionResult<object>> GenerateCode()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null)
|
||||
return Unauthorized(new { success = false, error = "Not signed in.", value = (object?)null });
|
||||
|
||||
var ban = await db.Moderation.FirstOrDefaultAsync(x => x.PlayerId == userId.Value && x.IsBan && x.ExpireDuration > DateTime.UtcNow);
|
||||
if (ban == null)
|
||||
return Ok(AppealResponse(false, "Account is not banned.", null));
|
||||
|
||||
if (AccountLock.IsLockBan(ban))
|
||||
return Ok(AppealResponse(false, AccountLock.NotAppealableMessage, null));
|
||||
|
||||
|
||||
if (!ModerationPolicy.IssuesAppealCode(ban.BanMethod) || ModerationPolicy.RefusesAppeal(ban.BanMethod))
|
||||
return Ok(AppealResponse(false, AccountLock.NotAppealableMessage, null));
|
||||
|
||||
var cooldownError = await PlayerUtils.GetAppealCooldownErrorAsync(db, userId.Value);
|
||||
if (cooldownError != null)
|
||||
return Ok(AppealResponse(false, cooldownError, null));
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (ban.AppealText != null && ban.AppealResolution == null)
|
||||
return Ok(AppealResponse(false, "Your appeal is already pending review.", null));
|
||||
|
||||
ban.AppealCode = PlayerUtils.GenerateAppealCode();
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var url = $"{PlayerUtils.AppealUrl()}?code={ban.AppealCode}";
|
||||
return Ok(AppealResponse(true, null, url));
|
||||
}
|
||||
|
||||
private async Task<(ModerationDB? Ban, ActionResult? Stop)> LookUpAppeal(string? code, string? key)
|
||||
{
|
||||
if (!key.IsSupportKey())
|
||||
return (null, Unauthorized(new { success = false, error = "Invalid secret.", value = (object?)null }));
|
||||
|
||||
var normalized = code?.Trim().ToUpperInvariant() ?? string.Empty;
|
||||
if (normalized.Length == 0)
|
||||
return (null, Ok(AppealResponse(false, "Missing appeal code.", null)));
|
||||
|
||||
var ban = await db.Moderation.FirstOrDefaultAsync(x => x.AppealCode == normalized);
|
||||
if (ban == null)
|
||||
return (null, Ok(AppealResponse(false, "Unknown appeal code.", null)));
|
||||
|
||||
|
||||
if (!ModerationPolicy.IssuesAppealCode(ban.BanMethod) || ModerationPolicy.RefusesAppeal(ban.BanMethod))
|
||||
return (null, Ok(AppealResponse(false, AccountLock.NotAppealableMessage, null)));
|
||||
|
||||
return (ban, null);
|
||||
}
|
||||
|
||||
private string SupportKeyFrom(string? queryKey)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(queryKey)
|
||||
? queryKey
|
||||
: Request.Headers["X-Support-Key"].ToString().Trim();
|
||||
}
|
||||
|
||||
[HttpPost("pending")]
|
||||
public async Task<ActionResult<object>> Pending([FromQuery] string code, [FromQuery] string key)
|
||||
{
|
||||
var (ban, stop) = await LookUpAppeal(code, SupportKeyFrom(key));
|
||||
if (stop != null) return stop;
|
||||
if (ban == null) return Ok(AppealResponse(false, "Unknown appeal code.", null));
|
||||
|
||||
if (ban.AppealResolution != null)
|
||||
return Ok(AppealResponse(false, "This appeal has already been reviewed.", ban.AppealResolution));
|
||||
|
||||
if (ban.AppealText == null)
|
||||
{
|
||||
ban.AppealText = PlayerUtils.SupportSiteAppealText;
|
||||
ban.AppealSubmittedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(AppealResponse(true, null, "pending"));
|
||||
}
|
||||
|
||||
[HttpPost("resolve")]
|
||||
public async Task<ActionResult<object>> Resolve([FromQuery] string code, [FromQuery] string key, [FromQuery] string? decision = null)
|
||||
{
|
||||
var (ban, stop) = await LookUpAppeal(code, SupportKeyFrom(key));
|
||||
if (stop != null) return stop;
|
||||
if (ban == null) return Ok(AppealResponse(false, "Unknown appeal code.", null));
|
||||
|
||||
if (ban.AppealResolution != null)
|
||||
return Ok(AppealResponse(true, null, ban.AppealResolution));
|
||||
|
||||
var verdict = (decision ?? string.Empty).Trim().ToLowerInvariant();
|
||||
|
||||
if (verdict == "accept")
|
||||
{
|
||||
ban.IsBan = false;
|
||||
ban.AppealResolution = "Accepted";
|
||||
}
|
||||
else if (verdict == "deny")
|
||||
{
|
||||
ban.AppealResolution = "Rejected";
|
||||
}
|
||||
else if (verdict == "reviewed" || verdict == "review")
|
||||
{
|
||||
ban.AppealResolution = "Reviewed";
|
||||
}
|
||||
else
|
||||
{
|
||||
return Ok(AppealResponse(false, "Unknown decision.", null));
|
||||
}
|
||||
|
||||
ban.AppealResolvedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (!ban.IsBan)
|
||||
{
|
||||
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == ban.PlayerId);
|
||||
if (profile != null)
|
||||
await Notify.SendNotify(ban.PlayerId, new Notification { Id = NotificationType.RefreshLogin, Msg = profile });
|
||||
}
|
||||
|
||||
return Ok(AppealResponse(true, null, ban.AppealResolution));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace RadicadeServer.RecNet.Board
|
||||
{
|
||||
public class BoardAnnouncement
|
||||
{
|
||||
public string Message { get; set; } = null!;
|
||||
public string MoreInfoUrl { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace RadicadeServer.RecNet.Board
|
||||
{
|
||||
public class BoardVideo
|
||||
{
|
||||
public string BlobName { get; set; } = null!;
|
||||
public string Title { get; set; } = null!;
|
||||
public string Description { get; set; } = null!;
|
||||
public string ThumbnailBlobName { get; set; } = null!;
|
||||
public string SourceUrl { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace RadicadeServer.RecNet.Board
|
||||
{
|
||||
public class CommunityBoard
|
||||
{
|
||||
public FeaturedPlayer FeaturedPlayer { get; set; } = null!;
|
||||
public FeaturedRoomGroup FeaturedRoomGroup { get; set; } = null!;
|
||||
public BoardAnnouncement CurrentAnnouncement { get; set; } = null!;
|
||||
public List<InstagramImage> InstagramImages { get; set; } = null!;
|
||||
public List<BoardVideo> Videos { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using RadicadeServer.API.Notifications;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
|
||||
namespace RadicadeServer.RecNet.Board
|
||||
{
|
||||
public static class CommunityBoardBroadcast
|
||||
{
|
||||
public const string UpdateNotificationId = "CommunityBoardUpdate";
|
||||
|
||||
public static IReadOnlyList<Notification> BuildFrames(CommunityBoard? board)
|
||||
{
|
||||
if (board == null)
|
||||
return Array.Empty<Notification>();
|
||||
|
||||
return new List<Notification>
|
||||
{
|
||||
new Notification
|
||||
{
|
||||
Id = UpdateNotificationId,
|
||||
Msg = board
|
||||
},
|
||||
new Notification
|
||||
{
|
||||
Id = NotificationType.CommunityBoardAnnouncementUpdate,
|
||||
Msg = board.CurrentAnnouncement ?? new BoardAnnouncement
|
||||
{
|
||||
Message = string.Empty,
|
||||
MoreInfoUrl = string.Empty
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task PushAsync(CommunityBoard? board)
|
||||
{
|
||||
foreach (var frame in BuildFrames(board))
|
||||
await Notify.SendGlobalNotify(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Board;
|
||||
|
||||
namespace RadicadeServer.API.Board.V2
|
||||
{
|
||||
[Route("api/communityboard/v2")]
|
||||
public class CommunityBoardV2 : Controller
|
||||
{
|
||||
private readonly RecDBContext db;
|
||||
|
||||
public CommunityBoardV2(RecDBContext context)
|
||||
{
|
||||
db = context;
|
||||
}
|
||||
|
||||
[HttpGet("current")]
|
||||
public async Task<ActionResult<CommunityBoard>> GetCurrentCommunityBoard()
|
||||
{
|
||||
var config = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == "CommunityBoard");
|
||||
var board = config?.Value?.FromJson<CommunityBoard>() ?? CreateDefaultBoard();
|
||||
NormalizeBoard(board);
|
||||
return Ok(board);
|
||||
}
|
||||
|
||||
private static CommunityBoard CreateDefaultBoard()
|
||||
{
|
||||
return new CommunityBoard
|
||||
{
|
||||
FeaturedPlayer = new FeaturedPlayer
|
||||
{
|
||||
Id = 0,
|
||||
TitleOverride = string.Empty,
|
||||
UrlOverride = string.Empty
|
||||
},
|
||||
FeaturedRoomGroup = new FeaturedRoomGroup
|
||||
{
|
||||
FeaturedRoomGroupId = 0,
|
||||
Name = string.Empty,
|
||||
Rooms = new List<FeaturedRoom>()
|
||||
},
|
||||
CurrentAnnouncement = new BoardAnnouncement
|
||||
{
|
||||
Message = string.Empty,
|
||||
MoreInfoUrl = string.Empty
|
||||
},
|
||||
InstagramImages = new List<InstagramImage>(),
|
||||
Videos = new List<BoardVideo>()
|
||||
};
|
||||
}
|
||||
|
||||
private static void NormalizeBoard(CommunityBoard board)
|
||||
{
|
||||
board.FeaturedPlayer ??= new FeaturedPlayer
|
||||
{
|
||||
Id = 0,
|
||||
TitleOverride = string.Empty,
|
||||
UrlOverride = string.Empty
|
||||
};
|
||||
board.FeaturedRoomGroup ??= new FeaturedRoomGroup
|
||||
{
|
||||
FeaturedRoomGroupId = 0,
|
||||
Name = string.Empty,
|
||||
Rooms = new List<FeaturedRoom>()
|
||||
};
|
||||
board.FeaturedRoomGroup.Rooms ??= new List<FeaturedRoom>();
|
||||
board.CurrentAnnouncement ??= new BoardAnnouncement
|
||||
{
|
||||
Message = string.Empty,
|
||||
MoreInfoUrl = string.Empty
|
||||
};
|
||||
board.InstagramImages ??= new List<InstagramImage>();
|
||||
board.Videos ??= new List<BoardVideo>();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
foreach (var video in board.Videos)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(video.SourceUrl) && !string.IsNullOrWhiteSpace(video.BlobName))
|
||||
video.SourceUrl = $"{DBConfig.ServerUrl}/cdn/video/{video.BlobName.TrimStart('/')}";
|
||||
}
|
||||
|
||||
var playable = board.Videos
|
||||
.Where(video => !string.IsNullOrWhiteSpace(video.SourceUrl) || !string.IsNullOrWhiteSpace(video.BlobName))
|
||||
.ToList();
|
||||
board.Videos = playable;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace RadicadeServer.RecNet.Enums.Board
|
||||
{
|
||||
public enum ExternalContentType
|
||||
{
|
||||
YouTube
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace RadicadeServer.RecNet.Board
|
||||
{
|
||||
public class FeaturedPlayer
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string TitleOverride { get; set; } = null!;
|
||||
public string UrlOverride { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace RadicadeServer.RecNet.Board
|
||||
{
|
||||
public class FeaturedRoom
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public string RoomName { get; set; } = null!;
|
||||
public string ImageName { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace RadicadeServer.RecNet.Board
|
||||
{
|
||||
public class FeaturedRoomGroup
|
||||
{
|
||||
public long FeaturedRoomGroupId { get; set; }
|
||||
public string Name { get; set; } = null!;
|
||||
public List<FeaturedRoom> Rooms { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace RadicadeServer.RecNet.Board
|
||||
{
|
||||
public class InstagramImage
|
||||
{
|
||||
public string ImageName { get; set; } = null!;
|
||||
public string ImageUrl { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.Radicade.Discord;
|
||||
using RadicadeServer.Discord.Webhooks;
|
||||
|
||||
namespace RadicadeServer.API.BugReporting;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[Route("api/bugreporting/")]
|
||||
[Route("bugreporting/")]
|
||||
public class BugReportingAPI : Controller
|
||||
{
|
||||
|
||||
private const int MaxLogChars = 3000;
|
||||
|
||||
|
||||
private const long MaxAttachmentBytes = 20 * 1024 * 1024;
|
||||
|
||||
|
||||
|
||||
private const long MaxImageBytes = 8 * 1024 * 1024;
|
||||
private const int MaxImageAttachments = 10;
|
||||
|
||||
[HttpPost("v2/reportbug")]
|
||||
[HttpPost("v1/reportbug")]
|
||||
public async Task<IActionResult> ReportBug()
|
||||
{
|
||||
|
||||
var fields = new List<KeyValuePair<string, string>>();
|
||||
var logs = new List<string>();
|
||||
var images = new List<ReportDiscord.DiscordAttachment>();
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var kv in form)
|
||||
AddField(fields, kv.Key, kv.Value.ToString());
|
||||
foreach (var file in form.Files)
|
||||
{
|
||||
var parsed = await ClassifyFileAsync(file);
|
||||
if (parsed == null) continue;
|
||||
if (parsed.IsImage)
|
||||
images.Add(new ReportDiscord.DiscordAttachment(parsed.Filename!, parsed.Data!, parsed.MimeType!));
|
||||
else
|
||||
logs.Add(parsed.Text ?? "");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
if (!string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
var trimmed = body.TrimStart();
|
||||
if (trimmed.StartsWith("{", StringComparison.Ordinal))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
AddField(fields, prop.Name,
|
||||
prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() ?? "" : prop.Value.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (trimmed.Contains('='))
|
||||
{
|
||||
|
||||
foreach (var pair in body.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var eq = pair.IndexOf('=');
|
||||
if (eq < 0)
|
||||
{
|
||||
AddField(fields, Uri.UnescapeDataString(pair), "");
|
||||
continue;
|
||||
}
|
||||
AddField(fields, Uri.UnescapeDataString(pair.Substring(0, eq)),
|
||||
Uri.UnescapeDataString(pair.Substring(eq + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
var description = Get(fields, "Description", "description", "Summary", "summary") ?? "";
|
||||
if (string.IsNullOrWhiteSpace(description) && logs.Count > 0)
|
||||
description = logs[0];
|
||||
|
||||
|
||||
|
||||
var buildTimestamp = Get(fields, "BuildTimestamp", "buildTimestamp");
|
||||
if (long.TryParse(buildTimestamp, out var ticks) && ticks > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
var dt = new DateTime(ticks, DateTimeKind.Utc);
|
||||
if (dt.Year is > 2000 and < 2100)
|
||||
AddField(fields, "Build Time", dt.ToString("yyyy-MM-dd HH:mm:ss 'UTC'"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
var embedFields = new List<DiscordField>();
|
||||
foreach (var kv in fields)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(kv.Value)) continue;
|
||||
embedFields.Add(new DiscordField { Name = kv.Key, Value = kv.Value, Inline = true });
|
||||
}
|
||||
for (int i = 0; i < logs.Count; i++)
|
||||
embedFields.Add(new DiscordField { Name = $"Log {i + 1}", Value = logs[i], Inline = false });
|
||||
|
||||
var payload = new DiscordPayload
|
||||
{
|
||||
Title = "Bug Report",
|
||||
Description = Truncate(description, 3900),
|
||||
ThumbnailUrl = WebhookImages.BugReport,
|
||||
Color = 0xED4245,
|
||||
Fields = embedFields.Take(24).ToList(),
|
||||
TimestampUtc = DateTime.UtcNow.ToString("O")
|
||||
};
|
||||
|
||||
|
||||
|
||||
var imagesToSend = images.Take(MaxImageAttachments).ToList();
|
||||
var embedImageUrl = imagesToSend.Count > 0 ? $"attachment://{imagesToSend[0].Filename}" : null;
|
||||
|
||||
ReportDiscord.SendAsync(WebhookConfig.ReportsUrl, "🐞 New Bug Report", payload, imagesToSend, embedImageUrl);
|
||||
return Ok(new { Success = true });
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
[HttpPost("ado/bug")]
|
||||
public async Task<IActionResult> ReportAdoBug()
|
||||
{
|
||||
string body;
|
||||
using (var reader = new StreamReader(Request.Body))
|
||||
body = await reader.ReadToEndAsync();
|
||||
|
||||
var summary = body.Length > 0 && body.TrimStart().StartsWith("{", StringComparison.Ordinal)
|
||||
? SummarizeJson(body)
|
||||
: Truncate(body.Trim(), 500);
|
||||
|
||||
ReportDiscord.SendAsync(WebhookConfig.ReportsUrl, "\ud83d\udc1e Crash / bug report", new DiscordPayload
|
||||
{
|
||||
Title = "Bug Report (ADO)",
|
||||
Description = Truncate(string.IsNullOrWhiteSpace(summary) ? "(empty report)" : summary, 3900),
|
||||
ThumbnailUrl = WebhookImages.BugReport,
|
||||
Color = 0xED4245,
|
||||
Fields = new List<DiscordField>(),
|
||||
TimestampUtc = DateTime.UtcNow.ToString("O")
|
||||
});
|
||||
|
||||
return Ok(new { Id = 0, Url = string.Empty, Success = true });
|
||||
}
|
||||
|
||||
private static string SummarizeJson(string body)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return Truncate(body, 500);
|
||||
|
||||
var parts = new List<string>();
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.Object || prop.Value.ValueKind == JsonValueKind.Array) continue;
|
||||
var value = prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() ?? "" : prop.Value.ToString();
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
parts.Add($"{prop.Name}={Truncate(value, 200)}");
|
||||
if (parts.Count >= 12) break;
|
||||
}
|
||||
return string.Join(", ", parts);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return Truncate(body, 500);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddField(List<KeyValuePair<string, string>> fields, string key, string value)
|
||||
{
|
||||
if (TryParseJsonObject(value, out var nested))
|
||||
{
|
||||
foreach (var kv in nested)
|
||||
AddField(fields, kv.Key, kv.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fields.Any(kv => string.Equals(kv.Key, key, StringComparison.OrdinalIgnoreCase)))
|
||||
return;
|
||||
fields.Add(new KeyValuePair<string, string>(key, value));
|
||||
}
|
||||
|
||||
private static bool TryParseJsonObject(string value, out List<KeyValuePair<string, string>> obj)
|
||||
{
|
||||
obj = new List<KeyValuePair<string, string>>();
|
||||
if (string.IsNullOrWhiteSpace(value) || !value.TrimStart().StartsWith("{", StringComparison.Ordinal))
|
||||
return false;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(value);
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object) return false;
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
obj.Add(new KeyValuePair<string, string>(prop.Name,
|
||||
prop.Value.ValueKind == JsonValueKind.String ? prop.Value.GetString() ?? "" : prop.Value.ToString()));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? Get(List<KeyValuePair<string, string>> fields, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
var hit = fields.FirstOrDefault(kv => string.Equals(kv.Key, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(hit.Value)) return hit.Value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<FileClass?> ClassifyFileAsync(IFormFile file)
|
||||
{
|
||||
if (file.Length == 0 || file.Length > MaxAttachmentBytes) return null;
|
||||
try
|
||||
{
|
||||
var data = new byte[file.Length];
|
||||
await using var stream = file.OpenReadStream();
|
||||
var read = 0;
|
||||
while (read < data.Length)
|
||||
{
|
||||
var n = await stream.ReadAsync(data, read, data.Length - read);
|
||||
if (n <= 0) break;
|
||||
read += n;
|
||||
}
|
||||
|
||||
var mime = DetectImageMime(data, read);
|
||||
if (mime != null)
|
||||
{
|
||||
|
||||
|
||||
if (file.Length > MaxImageBytes) return null;
|
||||
var ext = MimeToExt(mime);
|
||||
var name = SanitizeFileName(file.FileName);
|
||||
if (string.IsNullOrWhiteSpace(name) || !name.Contains('.'))
|
||||
name = $"screenshot.{ext}";
|
||||
return new FileClass(name, data[..read], mime, null, true);
|
||||
}
|
||||
|
||||
return new FileClass(null, null, null, Truncate(Encoding.UTF8.GetString(data, 0, read), MaxLogChars), false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? DetectImageMime(byte[] data, int length)
|
||||
{
|
||||
if (length < 8) return null;
|
||||
if (data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47) return "image/png";
|
||||
if (data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF) return "image/jpeg";
|
||||
if (data[0] == 0x47 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x38) return "image/gif";
|
||||
if (data[0] == 0x42 && data[1] == 0x4D) return "image/bmp";
|
||||
if (data[0] == 0x52 && data[1] == 0x49 && data[2] == 0x46 && data[3] == 0x46 &&
|
||||
data[8] == 0x57 && data[9] == 0x45 && data[10] == 0x42 && data[11] == 0x50) return "image/webp";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string MimeToExt(string mime) => mime switch
|
||||
{
|
||||
"image/png" => "png",
|
||||
"image/jpeg" => "jpg",
|
||||
"image/gif" => "gif",
|
||||
"image/bmp" => "bmp",
|
||||
"image/webp" => "webp",
|
||||
_ => "png"
|
||||
};
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fileName)) return "";
|
||||
var name = Path.GetFileName(fileName).Trim();
|
||||
return name.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0
|
||||
? new string(name.Where(c => !Path.GetInvalidFileNameChars().Contains(c)).ToArray())
|
||||
: name;
|
||||
}
|
||||
|
||||
private static string Truncate(string value, int max)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return "";
|
||||
if (value.Length <= max) return value;
|
||||
|
||||
|
||||
var cut = max;
|
||||
if (char.IsHighSurrogate(value[cut - 1]) && cut < value.Length && char.IsLowSurrogate(value[cut]))
|
||||
cut--;
|
||||
return value[..cut];
|
||||
}
|
||||
|
||||
[HttpPost("ado/file")]
|
||||
public async Task<IActionResult> UploadAdoFile()
|
||||
{
|
||||
var upload = await ReadUploadAsync();
|
||||
if (upload == null)
|
||||
{
|
||||
return Ok(new { Id = 0, Url = string.Empty, Success = true });
|
||||
}
|
||||
|
||||
var path = await StoreUploadAsync(upload);
|
||||
|
||||
if (upload.Data.Length <= MaxImageBytes)
|
||||
{
|
||||
ReportDiscord.SendAsync(WebhookConfig.ReportsUrl, "Crash report attachment", new DiscordPayload
|
||||
{
|
||||
Title = "Bug Report Attachment",
|
||||
Description = $"{upload.Filename} ({upload.Data.Length} byte(s))",
|
||||
ThumbnailUrl = WebhookImages.BugReport,
|
||||
Color = 0xED4245,
|
||||
Fields = new List<DiscordField>(),
|
||||
TimestampUtc = DateTime.UtcNow.ToString("O")
|
||||
}, new List<ReportDiscord.DiscordAttachment>
|
||||
{
|
||||
new(upload.Filename, upload.Data, upload.MimeType)
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new { Id = 0, Url = string.Empty, Success = true, FileName = upload.Filename, Size = upload.Data.Length });
|
||||
}
|
||||
|
||||
private sealed record UploadedFile(string Filename, byte[] Data, string MimeType);
|
||||
|
||||
private static string AdoFileFolder => Path.Combine(DBConfig.PrivateData, "BugReports");
|
||||
|
||||
private async Task<UploadedFile?> ReadUploadAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var file in form.Files)
|
||||
{
|
||||
if (file.Length == 0 || file.Length > MaxAttachmentBytes) continue;
|
||||
var data = await ReadAllAsync(file.OpenReadStream());
|
||||
if (data.Length == 0) continue;
|
||||
return new UploadedFile(UploadName(file.FileName, file.ContentType), data, UploadMime(file.ContentType, file.FileName));
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
var body = await ReadAllAsync(Request.Body);
|
||||
if (body.Length == 0) return null;
|
||||
|
||||
var name = FirstQuery("fileName", "filename", "name", "file")
|
||||
?? DispositionFileName()
|
||||
?? "ado-upload";
|
||||
return new UploadedFile(UploadName(name, Request.ContentType), body, UploadMime(Request.ContentType, name));
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadAllAsync(Stream stream)
|
||||
{
|
||||
using var buffer = new MemoryStream();
|
||||
var chunk = new byte[81920];
|
||||
int read;
|
||||
while ((read = await stream.ReadAsync(chunk)) > 0)
|
||||
{
|
||||
buffer.Write(chunk, 0, read);
|
||||
if (buffer.Length >= MaxAttachmentBytes) break;
|
||||
}
|
||||
return buffer.ToArray();
|
||||
}
|
||||
|
||||
private string? FirstQuery(params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
var value = Request.Query[name].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value!;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private string? DispositionFileName()
|
||||
{
|
||||
var header = Request.Headers.ContentDisposition.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(header)) return null;
|
||||
const string marker = "filename=";
|
||||
var at = header!.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (at < 0) return null;
|
||||
return header[(at + marker.Length)..].Trim().Trim('"');
|
||||
}
|
||||
|
||||
private static string UploadName(string? name, string? contentType)
|
||||
{
|
||||
var clean = SanitizeFileName(name ?? "");
|
||||
if (string.IsNullOrWhiteSpace(clean)) clean = "ado-upload";
|
||||
if (!clean.Contains('.')) clean += ExtensionFor(contentType);
|
||||
return clean;
|
||||
}
|
||||
|
||||
private static string UploadMime(string? contentType, string? name)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(contentType) && contentType!.Contains('/')) return contentType;
|
||||
var extension = Path.GetExtension(name ?? "").ToLowerInvariant();
|
||||
return extension switch
|
||||
{
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".txt" or ".log" => "text/plain",
|
||||
".json" => "application/json",
|
||||
".zip" => "application/zip",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtensionFor(string? contentType) => (contentType ?? "").Split(';')[0].Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"image/png" => ".png",
|
||||
"image/jpeg" => ".jpg",
|
||||
"image/gif" => ".gif",
|
||||
"image/webp" => ".webp",
|
||||
"text/plain" => ".log",
|
||||
"application/json" => ".json",
|
||||
"application/zip" => ".zip",
|
||||
_ => ".bin"
|
||||
};
|
||||
|
||||
private static async Task<string> StoreUploadAsync(UploadedFile upload)
|
||||
{
|
||||
Directory.CreateDirectory(AdoFileFolder);
|
||||
var stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
|
||||
var path = Path.Combine(AdoFileFolder, $"{stamp}-{upload.Filename}");
|
||||
for (int n = 1; System.IO.File.Exists(path); n++)
|
||||
path = Path.Combine(AdoFileFolder, $"{stamp}-{n}-{upload.Filename}");
|
||||
|
||||
await System.IO.File.WriteAllBytesAsync(path, upload.Data);
|
||||
return path;
|
||||
}
|
||||
|
||||
private sealed record FileClass(string? Filename, byte[]? Data, string? MimeType, string? Text, bool IsImage);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace RadicadeServer.CDN
|
||||
{
|
||||
internal static class CdnCaching
|
||||
{
|
||||
public const string Immutable = "public, max-age=31536000, immutable";
|
||||
|
||||
public const string Replaceable = "public, max-age=86400";
|
||||
|
||||
public static string ForBlob(string name) =>
|
||||
name.EndsWith(".assetbundle", StringComparison.OrdinalIgnoreCase) ? Immutable : Replaceable;
|
||||
|
||||
public static void Apply(Controller controller, string cacheControl)
|
||||
{
|
||||
if (controller.ControllerContext?.HttpContext == null) return;
|
||||
|
||||
controller.Response.Headers["Cache-Control"] = cacheControl;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.CDN
|
||||
{
|
||||
/// <summary>
|
||||
/// What may be read out of the CDN storage directory and handed to a client, and which file
|
||||
/// a requested name actually is.
|
||||
///
|
||||
/// Two rules live here because every route that serves a stored blob needs both, and a route
|
||||
/// that re-implements them gets one of them wrong:
|
||||
///
|
||||
/// - A request is a single bare file name in that one directory. A name carrying a separator
|
||||
/// or <c>..</c> would let a URL read outside it, and a name ending in the admin upload's
|
||||
/// <c>.uploading</c> suffix is a half-imported bundle: it must answer as if it does not
|
||||
/// exist rather than serve a truncated file, which the client would then fail to load with
|
||||
/// no way to tell why.
|
||||
/// - A bundle is asked for under two spellings. The subroom's <c>UnityAssetId</c> is stored
|
||||
/// both bare and with the <c>.assetbundle</c> suffix an import appends, so a name resolves
|
||||
/// to the exact file first and to <c><name>.assetbundle</c> second.
|
||||
/// </summary>
|
||||
internal static class CdnStorageNames
|
||||
{
|
||||
internal static bool IsServable(string? name) =>
|
||||
!string.IsNullOrEmpty(name)
|
||||
&& name != "."
|
||||
&& !AdminUtils.IsUploadPart(name)
|
||||
&& !name.Contains('/')
|
||||
&& !name.Contains('\\')
|
||||
&& !name.Contains("..")
|
||||
&& name.IndexOf('\0') < 0;
|
||||
|
||||
/// <summary>
|
||||
/// The stored file a requested name is, or null when nothing by either spelling exists.
|
||||
/// A name this server must never serve answers null too, so a route that only checks the
|
||||
/// result is still safe.
|
||||
/// </summary>
|
||||
internal static string? Resolve(string? name)
|
||||
{
|
||||
if (!IsServable(name)) return null;
|
||||
|
||||
if (System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, name))) return name;
|
||||
|
||||
var withExtension = name + ".assetbundle";
|
||||
return System.IO.File.Exists(Path.Join(DBConfig.CDNStorage, withExtension)) ? withExtension : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet;
|
||||
using RadicadeServer.Utils;
|
||||
|
||||
namespace RadicadeServer.CDN.Config
|
||||
{
|
||||
[Route("cdn/config")]
|
||||
public class CDNConfigAPI : Controller
|
||||
{
|
||||
RecDBContext db;
|
||||
public CDNConfigAPI(RecDBContext context)
|
||||
{
|
||||
this.db = context;
|
||||
}
|
||||
|
||||
[HttpGet("LoadingScreenTipData")]
|
||||
public List<LoadingScreenTip> GetLoadingScreenTipData()
|
||||
{
|
||||
var official = LoadingScreenImages.Tips;
|
||||
var officialNames = new HashSet<string>(LoadingScreenImages.Names, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var loadingScreens = db.ServerConfig.FirstOrDefault(x => x.Key == "LoadingScreenTipData");
|
||||
var stored = string.IsNullOrWhiteSpace(loadingScreens?.Value) || loadingScreens.Value == "[]"
|
||||
? new List<LoadingScreenTip>()
|
||||
: loadingScreens.Value.FromJson<List<LoadingScreenTip>>() ?? new List<LoadingScreenTip>();
|
||||
|
||||
var byImage = new Dictionary<string, LoadingScreenTip>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var tip in stored)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(tip.ImageName))
|
||||
byImage.TryAdd(tip.ImageName, tip);
|
||||
}
|
||||
|
||||
var adopted = stored.FirstOrDefault(tip =>
|
||||
!officialNames.Contains(tip.ImageName ?? string.Empty)
|
||||
&& !(tip.Title == "radicade" && tip.Message == "yum"));
|
||||
|
||||
var tips = new List<LoadingScreenTip>(official.Count);
|
||||
for (var i = 0; i < official.Count; i++)
|
||||
{
|
||||
var image = official[i].ImageName;
|
||||
var text = byImage.TryGetValue(image, out var storedTip) ? storedTip
|
||||
: i == 0 && adopted != null ? adopted
|
||||
: official[i];
|
||||
|
||||
tips.Add(new LoadingScreenTip
|
||||
{
|
||||
ImageName = image,
|
||||
Title = string.IsNullOrWhiteSpace(text.Title) ? official[i].Title : text.Title,
|
||||
Message = string.IsNullOrWhiteSpace(text.Message) ? official[i].Message : text.Message,
|
||||
RoomNames = text.RoomNames ?? new List<string>(),
|
||||
PlatformMask = text.PlatformMask
|
||||
});
|
||||
}
|
||||
|
||||
return tips;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using SixLabors.ImageSharp;
|
||||
|
||||
namespace RadicadeServer.CDN
|
||||
{
|
||||
[Route("cdn/data/")]
|
||||
[Route("data/")]
|
||||
public class DataCDN : Controller
|
||||
{
|
||||
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Index()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var entries = Directory.EnumerateFileSystemEntries(DBConfig.CDNStorage).GetEnumerator();
|
||||
_ = entries.MoveNext();
|
||||
return Ok(new { service = "data", status = "ok" });
|
||||
}
|
||||
catch
|
||||
{
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable,
|
||||
new { service = "data", status = "DATA_STORAGE_UNAVAILABLE" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{datablob}")]
|
||||
public IActionResult GetDataBlob(string datablob)
|
||||
{
|
||||
if (!CdnStorageNames.IsServable(datablob)) return BadRequest();
|
||||
|
||||
// This is the URL the client's bundle downloader ranges over, so the name has to be
|
||||
// one that may be served at all and it may arrive with or without the .assetbundle
|
||||
// suffix the import appends.
|
||||
var name = CdnStorageNames.Resolve(datablob);
|
||||
if (name == null) return NotFound();
|
||||
|
||||
CdnCaching.Apply(this, CdnCaching.ForBlob(name));
|
||||
|
||||
return PhysicalFile(Path.Join(DBConfig.CDNStorage, name), "application/octet-stream", enableRangeProcessing: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace RadicadeServer.RecNet.CDN
|
||||
{
|
||||
public class ImageDB
|
||||
{
|
||||
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||
public long Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = null!;
|
||||
|
||||
public long OwnerId { get; set; }
|
||||
|
||||
public SavedImageType ImageType { get; set; }
|
||||
public SavedImageAccessibility Accessibility { get; set; }
|
||||
|
||||
public long? RoomId { get; set; }
|
||||
public long? EventId { get; set; }
|
||||
|
||||
public string PlayerIds { get; set; } = "[]";
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace RadicadeServer.RecNet.CDN
|
||||
{
|
||||
public class ImageNameDTO
|
||||
{
|
||||
public string ImageName { get; set; } = null!;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace RadicadeServer.RecNet.CDN
|
||||
{
|
||||
public enum SavedImageAccessibility
|
||||
{
|
||||
Private = 0,
|
||||
Public = 1,
|
||||
FriendsOnly = 2
|
||||
}
|
||||
|
||||
public enum SavedImageType
|
||||
{
|
||||
None,
|
||||
ShareCamera,
|
||||
OutfitThumbnail,
|
||||
RoomThumbnail,
|
||||
ProfileThumbnail,
|
||||
InventionThumbnail,
|
||||
PlayerEventThumbnail,
|
||||
RoomLoadScreen
|
||||
}
|
||||
|
||||
public class SavedImageMetaDTO
|
||||
{
|
||||
public List<long> playerIds { get; set; } = new();
|
||||
|
||||
public SavedImageType savedImageType { get; set; } = SavedImageType.ShareCamera;
|
||||
|
||||
public long? roomId { get; set; } = null;
|
||||
|
||||
public long? playerEventId { get; set; } = null;
|
||||
|
||||
public SavedImageAccessibility accessibility { get; set; } = SavedImageAccessibility.Public;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace RadicadeServer.CDN
|
||||
{
|
||||
[Route("cdn/sigs")]
|
||||
public class SigsCDN : Controller
|
||||
{
|
||||
|
||||
private static readonly byte[] PlaceholderSignature = Encoding.ASCII.GetBytes(
|
||||
"RecRoom.exe:AAAAAAAAAAAAAAAAAAAAAA==:AAAAAAAAAAAAAAAAAAAAAA==\n" +
|
||||
"RecRoom_Data/globalgamemanagers:AAAAAAAAAAAAAAAAAAAAAA==:AAAAAAAAAAAAAAAAAAAAAA==\n" +
|
||||
"GameAssembly.dll:AAAAAAAAAAAAAAAAAAAAAA==:AAAAAAAAAAAAAAAAAAAAAA==\n");
|
||||
|
||||
[HttpGet("{signatureId}")]
|
||||
public IActionResult GetSignature(string signatureId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(signatureId)) return BadRequest();
|
||||
|
||||
var rangeHeader = ControllerContext.HttpContext?.Request.Headers.Range.ToString();
|
||||
if (string.IsNullOrWhiteSpace(rangeHeader) || !rangeHeader.StartsWith("bytes=", StringComparison.OrdinalIgnoreCase))
|
||||
return File(PlaceholderSignature, "text/plain");
|
||||
|
||||
var (start, end) = ParseByteRange(rangeHeader, PlaceholderSignature.Length);
|
||||
var count = end - start + 1;
|
||||
var body = new byte[count];
|
||||
Array.Copy(PlaceholderSignature, start, body, 0, count);
|
||||
|
||||
Response.Headers.AcceptRanges = "bytes";
|
||||
Response.Headers.ContentRange = $"bytes {start}-{end}/{PlaceholderSignature.Length}";
|
||||
Response.StatusCode = StatusCodes.Status206PartialContent;
|
||||
return File(body, "text/plain", enableRangeProcessing: false);
|
||||
}
|
||||
|
||||
private static (int Start, int End) ParseByteRange(string rangeHeader, int length)
|
||||
{
|
||||
var spec = rangeHeader["bytes=".Length..].Trim();
|
||||
if (spec.Contains(',')) return (0, length - 1);
|
||||
|
||||
var parts = spec.Split('-', 2);
|
||||
if (parts.Length != 2) return (0, length - 1);
|
||||
|
||||
if (parts[0].Length == 0)
|
||||
{
|
||||
if (!int.TryParse(parts[1], out var suffixLength) || suffixLength <= 0)
|
||||
return (0, length - 1);
|
||||
|
||||
var start = Math.Max(0, length - suffixLength);
|
||||
return (start, length - 1);
|
||||
}
|
||||
|
||||
if (!int.TryParse(parts[0], out var parsedStart))
|
||||
return (0, length - 1);
|
||||
|
||||
var startOffset = parsedStart >= length ? 0 : Math.Max(0, parsedStart);
|
||||
var endOffset = length - 1;
|
||||
if (parts[1].Length > 0 && int.TryParse(parts[1], out var parsedEnd))
|
||||
endOffset = Math.Clamp(parsedEnd, startOffset, length - 1);
|
||||
|
||||
return (startOffset, endOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.API.Studio;
|
||||
|
||||
namespace RadicadeServer.CDN
|
||||
{
|
||||
/// <summary>
|
||||
/// Step one of the client's two-step bundle download. The client asks
|
||||
/// <c>/rooms/unity_assets/{assetId}/{target}/{version}</c> and is handed a file name, then
|
||||
/// ranges over that file at <c>/data/{Filename}</c> (and, for a subroom's bundle, under the
|
||||
/// CDN's own <c>/unity/{Filename}</c> alias).
|
||||
///
|
||||
/// The id arrives in two shapes and both have to be answered. A Studio manifest id is 40
|
||||
/// alphanumerics and its baked bundles are named in the manifest's own metadata. A legacy
|
||||
/// bundle is stored under its file name (or that name minus <c>.assetbundle</c>), and that
|
||||
/// name is what the subroom's <c>UnityAssetId</c> carries - every imported room, the built-in
|
||||
/// rooms and every room bound from the admin panel. Answering only the 40-character shape
|
||||
/// leaves every legacy bundle unresolvable, and the client reports one 400 as
|
||||
/// "Error downloading room assets" for the whole room.
|
||||
/// </summary>
|
||||
[Route("rooms/")]
|
||||
public class UnityAssetsCDN : Controller
|
||||
{
|
||||
[HttpGet("unity_assets/{assetId}/{target}/{version}")]
|
||||
public async Task<IActionResult> GetUnityAsset(string assetId, int target, int version)
|
||||
{
|
||||
return await ServeMetadataAsync(assetId, target, version);
|
||||
}
|
||||
|
||||
[HttpGet("unity_assets/{assetId}")]
|
||||
public async Task<IActionResult> GetUnityAsset(string assetId)
|
||||
{
|
||||
return await ServeMetadataAsync(assetId, 0, 1);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ServeMetadataAsync(string assetId, int target, int version)
|
||||
{
|
||||
if (!CdnStorageNames.IsServable(assetId) || target < 0 || target > byte.MaxValue || version < 0)
|
||||
return BadRequest();
|
||||
|
||||
string? filename = null;
|
||||
string? rejectedDependencies = null;
|
||||
|
||||
if (StudioUnityAssetRegistry.IsValidAssetId(assetId))
|
||||
{
|
||||
var asset = await StudioUnityAssetRegistry.ReadAsync(assetId, HttpContext.RequestAborted);
|
||||
var baked = asset?.BakedUnityAssets.FirstOrDefault(x => x.Target == target && x.Version == version);
|
||||
|
||||
if (baked != null)
|
||||
{
|
||||
filename = baked.Filename;
|
||||
rejectedDependencies = baked.RejectedDependenciesFilename;
|
||||
}
|
||||
else if (asset != null)
|
||||
{
|
||||
// The manifest exists, so the file on disk under this id is that JSON
|
||||
// manifest and not a bundle. A target/version it does not name is a 404:
|
||||
// handing the manifest over as the bundle would have the client parse
|
||||
// metadata as an asset bundle.
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
// Reached for every legacy id, and for a 40-character id with no manifest behind it:
|
||||
// the bundle is the file stored under the name the subroom points at, with or without
|
||||
// the .assetbundle suffix an import appends.
|
||||
filename ??= CdnStorageNames.Resolve(assetId);
|
||||
if (filename == null) return NotFound();
|
||||
|
||||
var body = JsonSerializer.Serialize(new
|
||||
{
|
||||
UnityAssetId = assetId,
|
||||
Target = target,
|
||||
Version = version,
|
||||
Filename = filename,
|
||||
RejectedDependenciesFilename = rejectedDependencies
|
||||
});
|
||||
return Content(body, "application/json");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace RadicadeServer.RecNet.CDN
|
||||
{
|
||||
public class UploadResponse
|
||||
{
|
||||
public string filename { get; set; }
|
||||
public UploadResponse(string filename)
|
||||
{
|
||||
this.filename = filename;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace RadicadeServer.CDN
|
||||
{
|
||||
[Route("cdn/video/")]
|
||||
public class VideosCDN : Controller
|
||||
{
|
||||
private static readonly StringComparison PathComparison =
|
||||
OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
|
||||
[HttpGet("{*datablob}")]
|
||||
public IActionResult GetDataBlob(string datablob)
|
||||
{
|
||||
|
||||
|
||||
if (string.IsNullOrEmpty(datablob)) return NotFound();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (datablob.Contains("..", StringComparison.Ordinal) ||
|
||||
datablob.Contains('\\') ||
|
||||
datablob.Contains(':') ||
|
||||
datablob.StartsWith('/'))
|
||||
return NotFound();
|
||||
|
||||
var fullPath = Path.GetFullPath(Path.Join(DBConfig.CDNVideos, datablob));
|
||||
var root = DBConfig.CDNVideos.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
|
||||
if (!fullPath.StartsWith(root, PathComparison)) return NotFound();
|
||||
|
||||
|
||||
|
||||
|
||||
var candidates = System.IO.File.Exists(fullPath)
|
||||
? new[] { fullPath }
|
||||
: new[]
|
||||
{
|
||||
fullPath + ".mp4",
|
||||
fullPath + ".m4v",
|
||||
fullPath + ".webm",
|
||||
fullPath + ".mov",
|
||||
fullPath + ".ogv"
|
||||
};
|
||||
var existing = candidates.FirstOrDefault(p => System.IO.File.Exists(p));
|
||||
|
||||
if (existing == null) return NotFound();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var ext = Path.GetExtension(existing).ToLowerInvariant();
|
||||
var contentType = ext switch
|
||||
{
|
||||
".mp4" or ".m4v" => "video/mp4",
|
||||
".webm" => "video/webm",
|
||||
".mov" => "video/quicktime",
|
||||
".ogv" => "video/ogg",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
|
||||
Response.Headers["Accept-Ranges"] = "bytes";
|
||||
|
||||
return PhysicalFile(existing, contentType, enableRangeProcessing: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace RadicadeServer.API
|
||||
{
|
||||
public static class ChatPhotoMessages
|
||||
{
|
||||
public static readonly TimeSpan UploadWindow = TimeSpan.FromMinutes(15);
|
||||
|
||||
private static readonly Regex ImageReference = new(@"<Image:([^>]*)>", RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex PhotoEnvelope = new("\"Type\"\\s*:\\s*2(?![0-9])", RegexOptions.Compiled);
|
||||
|
||||
public static bool IsPhotoEnvelope(string? contents)
|
||||
=> !string.IsNullOrEmpty(contents) && PhotoEnvelope.IsMatch(contents);
|
||||
|
||||
public static bool HasUnresolvedImageReference(string? contents)
|
||||
{
|
||||
if (string.IsNullOrEmpty(contents))
|
||||
return false;
|
||||
|
||||
foreach (Match match in ImageReference.Matches(contents))
|
||||
{
|
||||
if (!ResolvesToAnImage(match.Groups[1].Value))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string ResolveImageReference(string contents, long imageId)
|
||||
{
|
||||
return ImageReference.Replace(contents, match =>
|
||||
ResolvesToAnImage(match.Groups[1].Value) ? match.Value : $"<Image:{imageId}>");
|
||||
}
|
||||
|
||||
private static bool ResolvesToAnImage(string rawReference)
|
||||
=> long.TryParse(rawReference, out var imageId) && imageId > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace RadicadeServer.API
|
||||
{
|
||||
public static class RecentImageReferences
|
||||
{
|
||||
public static readonly TimeSpan Window = TimeSpan.FromMinutes(10);
|
||||
|
||||
private sealed record Lookup(long ImageId, DateTime At);
|
||||
|
||||
private static readonly ConcurrentDictionary<long, Lookup> ByPlayer = new();
|
||||
|
||||
public static void Remember(long accountId, long imageId)
|
||||
=> Remember(accountId, imageId, DateTime.UtcNow);
|
||||
|
||||
public static void Remember(long accountId, long imageId, DateTime at)
|
||||
{
|
||||
if (accountId <= 0 || imageId <= 0) return;
|
||||
|
||||
Prune(at);
|
||||
ByPlayer[accountId] = new Lookup(imageId, at);
|
||||
}
|
||||
|
||||
public static bool TryGet(long accountId, out long imageId)
|
||||
{
|
||||
imageId = 0;
|
||||
if (accountId <= 0) return false;
|
||||
if (!ByPlayer.TryGetValue(accountId, out var lookup)) return false;
|
||||
if (DateTime.UtcNow - lookup.At > Window) return false;
|
||||
|
||||
imageId = lookup.ImageId;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static void Clear() => ByPlayer.Clear();
|
||||
|
||||
private static void Prune(DateTime now)
|
||||
{
|
||||
foreach (var pair in ByPlayer)
|
||||
{
|
||||
if (now - pair.Value.At > Window)
|
||||
ByPlayer.TryRemove(pair.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Messages;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RadicadeServer.API
|
||||
{
|
||||
public enum QueryMode
|
||||
{
|
||||
Latest = 0,
|
||||
NewerThan = 1,
|
||||
OlderThan = 2
|
||||
}
|
||||
|
||||
[Route("chat/")]
|
||||
public class ThreadAPI(RecDBContext _db) : Controller
|
||||
{
|
||||
private const int Success = 0;
|
||||
private const int ThreadNotFound = 2;
|
||||
private const int MembershipNotFound = 3;
|
||||
|
||||
private const long SystemSenderPlayerId = -5;
|
||||
|
||||
private const int DefaultMessageCount = 50;
|
||||
private const int MaxMessageCount = 100;
|
||||
|
||||
private const int ChatThreadTypeDefault = 0;
|
||||
[HttpGet("thread/party"), Authorize]
|
||||
[HttpPost("thread/party"), Authorize]
|
||||
public IActionResult GetPartyThread()
|
||||
{
|
||||
return Ok(Array.Empty<object>());
|
||||
}
|
||||
|
||||
[HttpGet("thread"), Authorize]
|
||||
public async Task<IActionResult> GetThread([FromQuery] int maxCount, [FromQuery] QueryMode mode, [FromQuery] long? messageId = null, [FromQuery] int count = 0)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var threadLimit = count > 0 ? count : maxCount;
|
||||
var responses = new List<(DateTime LastMessageTime, long ThreadId, Dictionary<string, object?> Response)>();
|
||||
|
||||
foreach (var thread in await _db.Chats.ToListAsync())
|
||||
{
|
||||
var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value);
|
||||
if (!playerIds.Contains(userId.Value))
|
||||
continue;
|
||||
|
||||
var latest = (thread.GetMessages() ?? new List<ChatMessage>())
|
||||
.OrderByDescending(m => m.chatMessageId)
|
||||
.FirstOrDefault();
|
||||
|
||||
responses.Add((latest?.timeSent ?? DateTime.MinValue, thread.chatThreadId, ToListResponse(thread, latest, playerIds, userId.Value)));
|
||||
}
|
||||
|
||||
var orderedResponses = responses
|
||||
.OrderByDescending(x => x.LastMessageTime)
|
||||
.ThenByDescending(x => x.ThreadId)
|
||||
.Select(x => x.Response);
|
||||
|
||||
if (threadLimit > 0)
|
||||
orderedResponses = orderedResponses.Take(threadLimit);
|
||||
|
||||
return Ok(orderedResponses.ToList());
|
||||
}
|
||||
|
||||
[HttpPost("thread/withmembers"), Authorize]
|
||||
public async Task<IActionResult> CreateThreadWithMembers()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var request = await ReadChatThreadRequest(userId.Value);
|
||||
var thread = await FindOrCreateThread(request.PlayerIds, request.ThreadName, userId.Value);
|
||||
|
||||
return Ok(ToResponse(thread, userId.Value));
|
||||
}
|
||||
|
||||
[HttpPost("thread"), Authorize]
|
||||
public async Task<IActionResult> CreateThread()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var request = await ReadChatThreadRequest(userId.Value);
|
||||
var thread = await FindOrCreateThread(request.PlayerIds, request.ThreadName, userId.Value);
|
||||
|
||||
var posted = false;
|
||||
if (!string.IsNullOrWhiteSpace(request.MessageContents))
|
||||
{
|
||||
var message = await AddMessageToThread(thread, userId.Value, request.MessageContents);
|
||||
await NotifyChatMessage(thread, message);
|
||||
posted = true;
|
||||
}
|
||||
|
||||
var chatThreadResponse = ToResponse(thread, userId.Value);
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["chatResult"] = posted ? 0 : 1,
|
||||
["chatThread"] = chatThreadResponse
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("thread/{threadId:long}"), Authorize]
|
||||
public async Task<IActionResult> GetThreadById(long threadId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId);
|
||||
if (thread == null) return NotFound();
|
||||
|
||||
var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value);
|
||||
if (!playerIds.Contains(userId.Value))
|
||||
return NotFound();
|
||||
|
||||
var (mode, messageId) = ReadQueryWindow();
|
||||
var messages = PageMessages(thread.GetMessages() ?? new List<ChatMessage>(), mode, messageId, ReadMessageCount(DefaultMessageCount));
|
||||
|
||||
return Ok(ToResponse(thread, userId.Value, messages, playerIds));
|
||||
}
|
||||
|
||||
[HttpGet("thread/{threadId:long}/message"), Authorize]
|
||||
public async Task<IActionResult> GetThreadMessages(long threadId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId);
|
||||
if (thread == null) return NotFound();
|
||||
|
||||
var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value);
|
||||
if (!playerIds.Contains(userId.Value))
|
||||
return NotFound();
|
||||
|
||||
var (mode, messageId) = ReadQueryWindow();
|
||||
var messages = PageMessages(thread.GetMessages() ?? new List<ChatMessage>(), mode, messageId, ReadMessageCount(DefaultMessageCount));
|
||||
|
||||
return Ok(ToMessageResponses(messages));
|
||||
}
|
||||
|
||||
[HttpPost("thread/{threadId:long}"), Authorize]
|
||||
[HttpPost("thread/{threadId:long}/message"), Authorize]
|
||||
public async Task<IActionResult> SendMessageToThread(long threadId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId);
|
||||
if (thread == null) return NotFound();
|
||||
|
||||
var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value);
|
||||
if (!playerIds.Contains(userId.Value))
|
||||
return NotFound();
|
||||
|
||||
var request = await ReadChatThreadRequest(userId.Value);
|
||||
|
||||
var posted = !string.IsNullOrWhiteSpace(request.MessageContents);
|
||||
if (posted)
|
||||
{
|
||||
var message = await AddMessageToThread(thread, userId.Value, request.MessageContents);
|
||||
await NotifyChatMessage(thread, message);
|
||||
}
|
||||
|
||||
var chatThreadResponse = ToResponse(thread, userId.Value);
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["chatResult"] = posted ? 0 : 1,
|
||||
["chatThread"] = chatThreadResponse
|
||||
});
|
||||
}
|
||||
[HttpPost("thread/{threadId:long}/rename"), Authorize]
|
||||
public async Task<IActionResult> RenameThread(long threadId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId);
|
||||
if (thread == null) return NotFound();
|
||||
|
||||
var body = await ReadJsonBody();
|
||||
var newName = GetStringFromBody(body, "chatThreadName", "ChatThreadName", "threadName", "ThreadName", "name", "Name");
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
newName = GetStringFromForm(form, "chatThreadName", "ChatThreadName", "threadName", "ThreadName", "name", "Name") ?? newName;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
return Ok(ChatResult(4));
|
||||
|
||||
thread.chatThreadName = newName.Trim();
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var chatThreadResponse = ToResponse(thread, userId.Value);
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["chatResult"] = 0,
|
||||
["chatThread"] = chatThreadResponse
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("thread/{threadId:long}/member/{playerId:long}"), Authorize]
|
||||
public async Task<IActionResult> AddThreadMember(long threadId, long playerId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId);
|
||||
if (thread == null) return NotFound();
|
||||
if (playerId <= 0) return BadRequest();
|
||||
|
||||
var playerIds = thread.GetPlayerIds() ?? new List<long>();
|
||||
playerIds = playerIds.Where(x => x > 0).Distinct().ToList();
|
||||
if (!playerIds.Contains(playerId))
|
||||
{
|
||||
playerIds.Add(playerId);
|
||||
thread.playerIds = playerIds.OrderBy(x => x).ToJson();
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var chatThreadResponse = ToResponse(thread, userId.Value);
|
||||
return Ok(new Dictionary<string, object>
|
||||
{
|
||||
["chatResult"] = 0,
|
||||
["chatThread"] = chatThreadResponse
|
||||
});
|
||||
}
|
||||
[HttpPut("thread/{threadId:long}/favorite"), Authorize]
|
||||
[HttpPost("thread/{threadId:long}/favorite"), Authorize]
|
||||
[HttpDelete("thread/{threadId:long}/favorite"), Authorize]
|
||||
public async Task<IActionResult> FavoriteThread(long threadId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId);
|
||||
if (thread == null) return Ok(ChatResult(2));
|
||||
|
||||
var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value);
|
||||
if (!playerIds.Contains(userId.Value)) return Ok(ChatResult(3));
|
||||
|
||||
var favorite = Request.Method.Equals("DELETE", StringComparison.OrdinalIgnoreCase)
|
||||
? false
|
||||
: await ReadBoolValue("isFavorited", "IsFavorited", "favorite", "Favorite", "value", "Value");
|
||||
|
||||
var favorited = thread.GetFavoritedBy();
|
||||
if (favorite)
|
||||
{
|
||||
if (!favorited.Contains(userId.Value)) favorited.Add(userId.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
favorited.Remove(userId.Value);
|
||||
}
|
||||
|
||||
thread.SetFavoritedBy(favorited);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["chatResult"] = 0,
|
||||
["isFavorited"] = favorite
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("thread/{threadId:long}/snooze"), Authorize]
|
||||
public async Task<IActionResult> SnoozeThread(long threadId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId);
|
||||
if (thread == null) return Ok(ChatResult(2));
|
||||
|
||||
var snooze = await ReadBoolValue("snooze", "Snooze");
|
||||
var snoozedUntil = snooze ? DateTime.UtcNow.AddYears(1) : (DateTime?)null;
|
||||
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["chatResult"] = 0,
|
||||
["snoozedUntil"] = snoozedUntil
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("thread/{threadId:long}/leave"), Authorize]
|
||||
public async Task<IActionResult> LeaveThread(long threadId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId);
|
||||
if (thread == null) return Ok(ChatResult(2));
|
||||
|
||||
var playerIds = thread.GetPlayerIds() ?? new List<long>();
|
||||
playerIds = playerIds.Where(x => x != userId.Value).Distinct().OrderBy(x => x).ToList();
|
||||
thread.playerIds = playerIds.ToJson();
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
if (playerIds.Count > 0)
|
||||
await AddMessageToThread(thread, SystemSenderPlayerId, MessageEnvelope($"Player <@U{userId.Value}> left"));
|
||||
|
||||
return Ok(ChatResult(0));
|
||||
}
|
||||
|
||||
[HttpPost("thread/{threadId:long}/message/{messageId:long}/read"), Authorize]
|
||||
public async Task<IActionResult> ReadThreadMessage(long threadId, long messageId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId);
|
||||
if (thread == null) return Ok(ChatResult(ThreadNotFound));
|
||||
|
||||
var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId.Value);
|
||||
if (!playerIds.Contains(userId.Value)) return Ok(ChatResult(MembershipNotFound));
|
||||
|
||||
if (messageId > thread.lastReadMessageId)
|
||||
{
|
||||
thread.lastReadMessageId = messageId;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(ChatResult(Success));
|
||||
}
|
||||
|
||||
[HttpPost("thread/{threadId:long}/read"), Authorize]
|
||||
public async Task<IActionResult> ReadThread(long threadId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var thread = await _db.Chats.FirstOrDefaultAsync(x => x.chatThreadId == threadId);
|
||||
if (thread == null) return Ok(ChatResult(2));
|
||||
|
||||
var messageId = await ReadLongValue("messageId", "MessageId", "lastReadMessageId", "LastReadMessageId");
|
||||
if (messageId.HasValue)
|
||||
{
|
||||
thread.lastReadMessageId = messageId.Value;
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return Ok(ChatResult(0));
|
||||
}
|
||||
|
||||
private async Task<ChatThreadRequest> ReadChatThreadRequest(long userId)
|
||||
{
|
||||
var body = await ReadJsonBody();
|
||||
var playerIds = new List<long> { userId };
|
||||
|
||||
AddIdsFromBody(body, playerIds);
|
||||
AddIdsFromQuery(Request.Query, playerIds);
|
||||
|
||||
var threadName = GetStringFromBody(body, "chatThreadName", "ChatThreadName", "threadName", "ThreadName", "name", "Name") ?? "";
|
||||
var messageContents = GetStringFromBody(body, "messageContents", "MessageContents", "contents", "Contents", "message", "Message") ?? "";
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
AddIdsFromForm(form, playerIds);
|
||||
threadName = GetStringFromForm(form, "chatThreadName", "ChatThreadName", "threadName", "ThreadName", "name", "Name") ?? threadName;
|
||||
messageContents = GetStringFromForm(form, "messageContents", "MessageContents", "contents", "Contents", "message", "Message") ?? messageContents;
|
||||
}
|
||||
|
||||
var normalizedPlayerIds = playerIds
|
||||
.Where(x => x > 0)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.ToList();
|
||||
|
||||
return new ChatThreadRequest(normalizedPlayerIds, threadName, messageContents);
|
||||
}
|
||||
|
||||
private async Task<ChatThread> FindOrCreateThread(List<long> playerIds, string threadName, long callerId)
|
||||
{
|
||||
var thread = await FindThread(playerIds);
|
||||
if (thread == null)
|
||||
{
|
||||
thread = new ChatThread
|
||||
{
|
||||
chatThreadName = string.IsNullOrWhiteSpace(threadName) ? "" : threadName,
|
||||
lastReadMessageId = 0,
|
||||
messages = "[]",
|
||||
playerIds = playerIds.ToJson()
|
||||
};
|
||||
|
||||
_db.Chats.Add(thread);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
var other = playerIds.FirstOrDefault(x => x != callerId);
|
||||
await AddMessageToThread(thread, SystemSenderPlayerId, ChatStartedNotice(other != 0 ? other : callerId));
|
||||
}
|
||||
|
||||
return thread;
|
||||
}
|
||||
|
||||
private static string MessageEnvelope(string text) => JsonSerializer.Serialize(new Dictionary<string, object>
|
||||
{
|
||||
["Type"] = 0,
|
||||
["Version"] = 1,
|
||||
["Data"] = text
|
||||
}, EnvelopeJsonOptions);
|
||||
|
||||
private static readonly JsonSerializerOptions EnvelopeJsonOptions = new()
|
||||
{
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
};
|
||||
|
||||
private static string ChatStartedNotice(long playerId) => MessageEnvelope($"Player <@U{playerId}> started a chat");
|
||||
|
||||
private async Task<ChatMessage> AddMessageToThread(ChatThread thread, long userId, string messageContents)
|
||||
{
|
||||
messageContents = await ResolvePhotoPlaceholder(userId, messageContents);
|
||||
|
||||
var messages = thread.GetMessages() ?? new List<ChatMessage>();
|
||||
var nextMessageId = messages.Count == 0 ? 1 : messages.Max(x => x.chatMessageId) + 1;
|
||||
var message = new ChatMessage
|
||||
{
|
||||
chatMessageId = nextMessageId,
|
||||
chatThreadeId = thread.chatThreadId,
|
||||
senderPlayerId = userId,
|
||||
timeSent = DateTime.UtcNow,
|
||||
contents = messageContents
|
||||
};
|
||||
|
||||
messages.Add(message);
|
||||
thread.lastReadMessageId = nextMessageId;
|
||||
thread.messages = messages.ToJson();
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private async Task<string> ResolvePhotoPlaceholder(long userId, string messageContents)
|
||||
{
|
||||
if (userId <= 0) return messageContents;
|
||||
if (!ChatPhotoMessages.IsPhotoEnvelope(messageContents)) return messageContents;
|
||||
if (!ChatPhotoMessages.HasUnresolvedImageReference(messageContents)) return messageContents;
|
||||
|
||||
string source;
|
||||
long? imageId = null;
|
||||
|
||||
if (RecentImageReferences.TryGet(userId, out var referenced))
|
||||
{
|
||||
imageId = referenced;
|
||||
source = "the image metadata it looked up";
|
||||
}
|
||||
else
|
||||
{
|
||||
var cutoff = DateTime.Now - ChatPhotoMessages.UploadWindow;
|
||||
var recentImage = await _db.Images.AsNoTracking()
|
||||
.Where(image => image.OwnerId == userId && image.CreatedAt >= cutoff)
|
||||
.OrderByDescending(image => image.CreatedAt)
|
||||
.ThenByDescending(image => image.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
imageId = recentImage?.Id;
|
||||
source = $"its upload {recentImage?.Name}";
|
||||
}
|
||||
|
||||
if (imageId == null) return messageContents;
|
||||
|
||||
var resolved = ChatPhotoMessages.ResolveImageReference(messageContents, imageId.Value);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private async Task NotifyChatMessage(ChatThread thread, ChatMessage message)
|
||||
{
|
||||
var notificationMessage = ToMessageResponse(message);
|
||||
var playerIds = (thread.GetPlayerIds() ?? new List<long>())
|
||||
.Where(x => x > 0)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
foreach (var playerId in playerIds)
|
||||
{
|
||||
await Notifications.Notify.SendNotify(playerId, new Notification
|
||||
{
|
||||
Id = nameof(NotificationType.ChatMessageReceived),
|
||||
Msg = notificationMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<JsonElement?> ReadJsonBody()
|
||||
{
|
||||
if (Request.ContentLength == 0) return null;
|
||||
if (Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) != true) return null;
|
||||
|
||||
try
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<JsonElement>(Request.Body);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> ReadBoolValue(params string[] keys)
|
||||
{
|
||||
var body = await ReadJsonBody();
|
||||
var bodyValue = GetBoolFromBody(body, keys);
|
||||
if (bodyValue.HasValue) return bodyValue.Value;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (Request.Query.TryGetValue(key, out var queryValue) && bool.TryParse(queryValue.FirstOrDefault(), out var queryBool))
|
||||
return queryBool;
|
||||
}
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (form.TryGetValue(key, out var formValue) && bool.TryParse(formValue.FirstOrDefault(), out var formBool))
|
||||
return formBool;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task<long?> ReadLongValue(params string[] keys)
|
||||
{
|
||||
var body = await ReadJsonBody();
|
||||
var bodyValue = GetLongFromBody(body, keys);
|
||||
if (bodyValue.HasValue) return bodyValue.Value;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (Request.Query.TryGetValue(key, out var queryValue) && long.TryParse(queryValue.FirstOrDefault(), out var queryLong))
|
||||
return queryLong;
|
||||
}
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (form.TryGetValue(key, out var formValue) && long.TryParse(formValue.FirstOrDefault(), out var formLong))
|
||||
return formLong;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<ChatThread?> FindThread(List<long> playerIds)
|
||||
{
|
||||
var threads = await _db.Chats.ToListAsync();
|
||||
return threads.FirstOrDefault(thread =>
|
||||
{
|
||||
var existingPlayerIds = thread.GetPlayerIds() ?? new List<long>();
|
||||
return existingPlayerIds
|
||||
.Where(x => x > 0)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.SequenceEqual(playerIds);
|
||||
});
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ToResponse(ChatThread thread, long userId, int pageSize = DefaultMessageCount)
|
||||
{
|
||||
var playerIds = NormalizePlayerIds(thread.GetPlayerIds(), userId);
|
||||
var messages = PageMessages(thread.GetMessages() ?? new List<ChatMessage>(), QueryMode.Latest, null, pageSize);
|
||||
|
||||
return ToResponse(thread, userId, messages, playerIds);
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ToResponse(ChatThread thread, long userId, List<ChatMessage> messages, List<long> playerIds)
|
||||
{
|
||||
playerIds = NormalizePlayerIds(playerIds, userId);
|
||||
var messageResponses = ToMessageResponses(messages);
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["chatThreadId"] = thread.chatThreadId,
|
||||
["playerIds"] = playerIds,
|
||||
["lastReadMessageId"] = thread.lastReadMessageId,
|
||||
["chatThreadName"] = ThreadName(thread),
|
||||
["chatThreadType"] = ChatThreadTypeDefault,
|
||||
["snoozedUntil"] = null,
|
||||
["isFavorited"] = thread.GetFavoritedBy().Contains(userId),
|
||||
["messages"] = messageResponses
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ToListResponse(ChatThread thread, ChatMessage? latestMessage, List<long> playerIds, long viewerId)
|
||||
{
|
||||
var latest = latestMessage == null ? null : ToMessageResponse(latestMessage);
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["latestMessage"] = latest,
|
||||
["chatThreadId"] = thread.chatThreadId,
|
||||
["playerIds"] = playerIds,
|
||||
["lastReadMessageId"] = thread.lastReadMessageId,
|
||||
["chatThreadName"] = ThreadName(thread),
|
||||
["chatThreadType"] = ChatThreadTypeDefault,
|
||||
["snoozedUntil"] = null,
|
||||
["isFavorited"] = thread.GetFavoritedBy().Contains(viewerId)
|
||||
};
|
||||
}
|
||||
|
||||
private static string MessageContents(string? contents)
|
||||
{
|
||||
var raw = contents ?? "";
|
||||
if (raw.Length == 0) return MessageEnvelope("");
|
||||
|
||||
var trimmed = raw.TrimStart();
|
||||
if (trimmed.StartsWith("{", StringComparison.Ordinal))
|
||||
{
|
||||
try
|
||||
{
|
||||
using var parsed = System.Text.Json.JsonDocument.Parse(raw);
|
||||
if (parsed.RootElement.ValueKind == JsonValueKind.Object) return raw;
|
||||
}
|
||||
catch (System.Text.Json.JsonException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return MessageEnvelope(raw);
|
||||
}
|
||||
|
||||
private static List<long> NormalizePlayerIds(IEnumerable<long>? playerIds, long userId)
|
||||
{
|
||||
var normalized = (playerIds ?? Enumerable.Empty<long>())
|
||||
.Where(x => x > 0)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
normalized.Add(userId);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string ThreadName(ChatThread thread)
|
||||
=> string.IsNullOrWhiteSpace(thread.chatThreadName) ? "" : thread.chatThreadName;
|
||||
|
||||
private static List<Dictionary<string, object>> ToMessageResponses(IEnumerable<ChatMessage> messages)
|
||||
{
|
||||
return messages.Select(ToMessageResponse).ToList();
|
||||
}
|
||||
|
||||
private static Dictionary<string, object> ToMessageResponse(ChatMessage message)
|
||||
{
|
||||
var contents = MessageContents(message.contents);
|
||||
|
||||
return new Dictionary<string, object>
|
||||
{
|
||||
["chatMessageId"] = message.chatMessageId,
|
||||
["chatThreadId"] = message.chatThreadeId,
|
||||
["senderPlayerId"] = message.senderPlayerId,
|
||||
["timeSent"] = message.timeSent,
|
||||
["contents"] = contents,
|
||||
["moderationState"] = 0
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddIdsFromBody(JsonElement? body, List<long> playerIds)
|
||||
{
|
||||
if (!body.HasValue || body.Value.ValueKind != JsonValueKind.Object) return;
|
||||
|
||||
foreach (var key in new[] { "ids", "Ids", "playerIds", "PlayerIds", "recipientPlayerIds", "RecipientPlayerIds", "memberIds", "MemberIds", "memberPlayerIds", "MemberPlayerIds", "playerId", "PlayerId", "toPlayerId", "ToPlayerId" })
|
||||
{
|
||||
if (body.Value.TryGetProperty(key, out var value))
|
||||
AddIdsFromJson(value, playerIds);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIdsFromJson(JsonElement value, List<long> playerIds)
|
||||
{
|
||||
switch (value.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Array:
|
||||
foreach (var item in value.EnumerateArray())
|
||||
AddIdsFromJson(item, playerIds);
|
||||
break;
|
||||
case JsonValueKind.Number:
|
||||
if (value.TryGetInt64(out var id) && id > 0)
|
||||
playerIds.Add(id);
|
||||
break;
|
||||
case JsonValueKind.String:
|
||||
AddIdsFromStrings(new[] { value.GetString() ?? "" }, playerIds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIdsFromQuery(IQueryCollection query, List<long> playerIds)
|
||||
{
|
||||
foreach (var key in new[] { "ids", "Ids", "playerIds", "PlayerIds", "recipientPlayerIds", "RecipientPlayerIds", "memberIds", "MemberIds", "memberPlayerIds", "MemberPlayerIds", "playerId", "PlayerId", "toPlayerId", "ToPlayerId" })
|
||||
{
|
||||
if (query.TryGetValue(key, out var values))
|
||||
AddIdsFromStrings(values, playerIds);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIdsFromForm(IFormCollection form, List<long> playerIds)
|
||||
{
|
||||
foreach (var key in new[] { "ids", "Ids", "playerIds", "PlayerIds", "recipientPlayerIds", "RecipientPlayerIds", "memberIds", "MemberIds", "memberPlayerIds", "MemberPlayerIds", "playerId", "PlayerId", "toPlayerId", "ToPlayerId" })
|
||||
{
|
||||
if (form.TryGetValue(key, out var values))
|
||||
AddIdsFromStrings(values, playerIds);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIdsFromStrings(IEnumerable<string?> values, List<long> playerIds)
|
||||
{
|
||||
foreach (var value in values)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) continue;
|
||||
|
||||
foreach (var part in value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
var clean = part.Trim('[', ']', '"', '\'');
|
||||
if (long.TryParse(clean, out var id) && id > 0)
|
||||
playerIds.Add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? GetStringFromBody(JsonElement? body, params string[] keys)
|
||||
{
|
||||
if (!body.HasValue || body.Value.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (body.Value.TryGetProperty(key, out var value) && value.ValueKind == JsonValueKind.String)
|
||||
return value.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool? GetBoolFromBody(JsonElement? body, params string[] keys)
|
||||
{
|
||||
if (!body.HasValue || body.Value.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!body.Value.TryGetProperty(key, out var value)) continue;
|
||||
if (value.ValueKind == JsonValueKind.True) return true;
|
||||
if (value.ValueKind == JsonValueKind.False) return false;
|
||||
if (value.ValueKind == JsonValueKind.String && bool.TryParse(value.GetString(), out var parsed))
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? GetLongFromBody(JsonElement? body, params string[] keys)
|
||||
{
|
||||
if (!body.HasValue || body.Value.ValueKind != JsonValueKind.Object) return null;
|
||||
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!body.Value.TryGetProperty(key, out var value)) continue;
|
||||
if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var parsedNumber))
|
||||
return parsedNumber;
|
||||
if (value.ValueKind == JsonValueKind.String && long.TryParse(value.GetString(), out var parsedString))
|
||||
return parsedString;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetStringFromForm(IFormCollection form, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (form.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value.FirstOrDefault()))
|
||||
return value.FirstOrDefault();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int ChatResult(int result) => result;
|
||||
|
||||
private int ReadMessageCount(int fallback)
|
||||
{
|
||||
foreach (var key in new[] { "MessageCount", "messageCount", "count", "Count" })
|
||||
{
|
||||
if (Request.Query.TryGetValue(key, out var values) && int.TryParse(values.FirstOrDefault(), out var parsed))
|
||||
return Math.Clamp(parsed, 1, MaxMessageCount);
|
||||
}
|
||||
|
||||
return Math.Clamp(fallback, 1, MaxMessageCount);
|
||||
}
|
||||
|
||||
private (QueryMode Mode, long? MessageId) ReadQueryWindow()
|
||||
{
|
||||
var mode = QueryMode.Latest;
|
||||
foreach (var key in new[] { "Mode", "mode" })
|
||||
{
|
||||
if (Request.Query.TryGetValue(key, out var values) && TryParseQueryMode(values.FirstOrDefault(), out var parsed))
|
||||
{
|
||||
mode = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
long? messageId = null;
|
||||
foreach (var key in new[] { "ReferenceMessageId", "referenceMessageId", "MessageId", "messageId", "LastMessageId", "lastMessageId" })
|
||||
{
|
||||
if (Request.Query.TryGetValue(key, out var values) && long.TryParse(values.FirstOrDefault(), out var parsed))
|
||||
{
|
||||
messageId = parsed;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return (mode, messageId);
|
||||
}
|
||||
|
||||
private static bool TryParseQueryMode(string? raw, out QueryMode mode)
|
||||
{
|
||||
mode = QueryMode.Latest;
|
||||
if (string.IsNullOrWhiteSpace(raw)) return false;
|
||||
|
||||
if (int.TryParse(raw, out var numeric) && Enum.IsDefined(typeof(QueryMode), numeric))
|
||||
{
|
||||
mode = (QueryMode)numeric;
|
||||
return true;
|
||||
}
|
||||
|
||||
return Enum.TryParse(raw, ignoreCase: true, out mode);
|
||||
}
|
||||
|
||||
private static List<ChatMessage> PageMessages(List<ChatMessage> stored, QueryMode mode, long? messageId, int pageSize)
|
||||
{
|
||||
var window = mode switch
|
||||
{
|
||||
QueryMode.NewerThan when messageId.HasValue => stored.Where(m => m.chatMessageId > messageId.Value),
|
||||
QueryMode.OlderThan when messageId.HasValue => stored.Where(m => m.chatMessageId < messageId.Value),
|
||||
_ => stored
|
||||
};
|
||||
|
||||
return window
|
||||
.OrderByDescending(m => m.chatMessageId)
|
||||
.Take(Math.Clamp(pageSize, 1, MaxMessageCount))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private sealed record ChatThreadRequest(List<long> PlayerIds, string ThreadName, string MessageContents);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Enums.Players;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
|
||||
namespace RadicadeServer.API
|
||||
{
|
||||
[Route("api/announcement")]
|
||||
public class AnnouncementAPI : Controller
|
||||
{
|
||||
RecDBContext db;
|
||||
public AnnouncementAPI(RecDBContext context)
|
||||
{
|
||||
this.db = context;
|
||||
}
|
||||
|
||||
[HttpGet("v1/get")]
|
||||
public List<Announcement>? GetAnnouncement()
|
||||
{
|
||||
var config = db.ServerConfig.FirstOrDefault(x => x.Key == "Announcement");
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.Value) || config.Value == "[]")
|
||||
return new List<Announcement>();
|
||||
|
||||
try
|
||||
{
|
||||
var announcements = config.Value.FromJson<List<Announcement>>() ?? new List<Announcement>();
|
||||
|
||||
|
||||
|
||||
var raw = System.Text.Json.JsonDocument.Parse(config.Value);
|
||||
if (raw.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var keep = new List<Announcement>();
|
||||
int i = 0;
|
||||
foreach (var element in raw.RootElement.EnumerateArray())
|
||||
{
|
||||
var ann = i < announcements.Count ? announcements[i] : null;
|
||||
if (ann != null)
|
||||
{
|
||||
if (element.TryGetProperty("type", out var typeProp) && typeProp.ValueKind == System.Text.Json.JsonValueKind.String &&
|
||||
string.Equals(typeProp.GetString(), "Game Update", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
string? expiresAt = null;
|
||||
if (element.TryGetProperty("expires_at", out var exp) && exp.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
expiresAt = exp.GetString();
|
||||
|
||||
if (string.IsNullOrEmpty(expiresAt) || !DateTime.TryParse(expiresAt, out var expiry) || expiry > now)
|
||||
keep.Add(ann);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
announcements = keep;
|
||||
}
|
||||
|
||||
|
||||
announcements = announcements.OrderByDescending(x => x.CreatedAt).ToList();
|
||||
foreach (var ann in announcements)
|
||||
{
|
||||
ann.IsMostRecent = false;
|
||||
|
||||
|
||||
|
||||
ann.Platform = Platforms.All;
|
||||
}
|
||||
if (announcements.Count > 0)
|
||||
announcements[0].IsMostRecent = true;
|
||||
|
||||
return announcements;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<Announcement>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Config;
|
||||
using RadicadeServer.RecNet.Notifications;
|
||||
|
||||
namespace RadicadeServer.Clubs.Announcements
|
||||
{
|
||||
[Route("clubs/announcements/")]
|
||||
[Route("api/clubs/v1/announcements/")]
|
||||
[Route("api/clubs/v2/announcements/")]
|
||||
public class ClubAnnouncementsAPI : Controller
|
||||
{
|
||||
private const string StorageKey = "ClubAnnouncements";
|
||||
private static readonly ConcurrentDictionary<long, List<ClubAnnouncementRecord>> Store = new();
|
||||
|
||||
private sealed class ClubAnnouncementRecord
|
||||
{
|
||||
public long AnnouncementId { get; set; }
|
||||
public long ClubId { get; set; }
|
||||
public long AccountId { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Body { get; set; } = string.Empty;
|
||||
public string ImageName { get; set; } = string.Empty;
|
||||
public string Meta { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
private readonly RecDBContext? db;
|
||||
|
||||
public ClubAnnouncementsAPI(RecDBContext? db = null)
|
||||
{
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
[HttpGet("v2/subscription/mine/unread")]
|
||||
[HttpGet("subscription/mine/unread")]
|
||||
public List<Announcement> GetUnreadSubscribedAnnouncements()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
[HttpGet("v2/mine/unread")]
|
||||
[HttpGet("mine/unread")]
|
||||
public List<Announcement> GetUnreadAnnouncements()
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
[HttpPost("club/{clubId}")]
|
||||
[HttpPost("~/announcements/club/{clubId:long}")]
|
||||
public async Task<IActionResult> PostClubAnnouncements(long clubId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var (managedClubId, error) = await ResolveManagedClubAsync(clubId, userId.Value);
|
||||
if (error != null) return error;
|
||||
clubId = managedClubId;
|
||||
|
||||
var fields = await ReadBodyFieldsAsync();
|
||||
|
||||
var announcement = new ClubAnnouncementRecord
|
||||
{
|
||||
AnnouncementId = NewAnnouncementId(),
|
||||
ClubId = clubId,
|
||||
AccountId = userId.Value,
|
||||
Title = GetField(fields, "title", "Title") ?? string.Empty,
|
||||
Body = GetField(fields, "body", "Body") ?? string.Empty,
|
||||
ImageName = GetField(fields, "imageName", "ImageName", "image_name") ?? string.Empty,
|
||||
Meta = GetField(fields, "meta", "Meta") ?? string.Empty,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
Add(clubId, announcement);
|
||||
await PersistAsync(clubId);
|
||||
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["error"] = string.Empty,
|
||||
["success"] = true,
|
||||
["value"] = announcement.AnnouncementId
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("club/{clubId}")]
|
||||
[HttpGet("~/announcements/club/{clubId:long}")]
|
||||
public IActionResult GetClubAnnouncements(long clubId)
|
||||
{
|
||||
return Ok(BuildAnnouncementFeed(clubId));
|
||||
}
|
||||
|
||||
[HttpDelete("club/{clubId:long}/{announcementId:long}")]
|
||||
[HttpDelete("~/announcements/club/{clubId:long}/{announcementId:long}")]
|
||||
public async Task<IActionResult> DeleteClubAnnouncement(long clubId, long announcementId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var (managedClubId, error) = await ResolveManagedClubAsync(clubId, userId.Value);
|
||||
if (error != null) return error;
|
||||
clubId = managedClubId;
|
||||
|
||||
if (!Store.TryGetValue(clubId, out var announcements)) return NotFound();
|
||||
|
||||
bool removed;
|
||||
lock (announcements)
|
||||
removed = announcements.RemoveAll(item => item.AnnouncementId == announcementId) > 0;
|
||||
if (!removed) return NotFound();
|
||||
|
||||
await PersistAsync(clubId);
|
||||
return Ok(new { error = string.Empty, success = true, value = announcementId });
|
||||
}
|
||||
|
||||
private async Task<(long ClubId, IActionResult? Error)> ResolveManagedClubAsync(long clubId, long userId)
|
||||
{
|
||||
if (clubId == 0 || PlayerSubscriptions.IsPersonalClubId(clubId))
|
||||
{
|
||||
var personalClubId = PlayerSubscriptions.PersonalClubId(userId);
|
||||
if (clubId != 0 && clubId != personalClubId)
|
||||
return (clubId, StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null }));
|
||||
|
||||
return (personalClubId, null);
|
||||
}
|
||||
|
||||
if (db == null) return (clubId, StatusCode(StatusCodes.Status500InternalServerError));
|
||||
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return (clubId, NotFound());
|
||||
if (ClubStore.GetMembershipType(club, userId) < ClubMembershipTypes.Coowner)
|
||||
return (clubId, StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null }));
|
||||
|
||||
return (clubId, null);
|
||||
}
|
||||
|
||||
private void Add(long clubId, ClubAnnouncementRecord announcement)
|
||||
{
|
||||
var list = Store.GetOrAdd(clubId, _ => new List<ClubAnnouncementRecord>());
|
||||
lock (list) list.Insert(0, announcement);
|
||||
}
|
||||
|
||||
private static long NewAnnouncementId()
|
||||
{
|
||||
return Math.Abs((long)HashCode.Combine(Guid.NewGuid(), Environment.TickCount64));
|
||||
}
|
||||
|
||||
[HttpPost("club/{clubId}/announcement/{announcementId}/read")]
|
||||
[HttpPost("~/announcements/club/{clubId:long}/{announcementId:long}/read")]
|
||||
[HttpPost("~/clubs/announcements/club/{clubId:long}/{announcementId:long}/read")]
|
||||
[HttpPost("~/api/clubs/v1/announcements/club/{clubId:long}/{announcementId:long}/read")]
|
||||
[HttpPost("~/api/clubs/v2/announcements/club/{clubId:long}/{announcementId:long}/read")]
|
||||
public IActionResult MarkAnnouncementRead(long clubId, long announcementId)
|
||||
{
|
||||
if (User.GetUserId() == null) return Unauthorized();
|
||||
return Ok(new { error = string.Empty, success = true, value = announcementId });
|
||||
}
|
||||
|
||||
private async Task PersistAsync(long clubId)
|
||||
{
|
||||
if (db == null) return;
|
||||
try
|
||||
{
|
||||
var config = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == StorageKey);
|
||||
if (config == null)
|
||||
{
|
||||
config = new ServerConfigVal(StorageKey, "{}");
|
||||
await db.ServerConfig.AddAsync(config);
|
||||
}
|
||||
var snapshot = Store.ToDictionary(
|
||||
kv => kv.Key.ToString(),
|
||||
kv => SerializeAnnouncements(kv.Value));
|
||||
config.Value = JsonSerializer.Serialize(snapshot);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static List<object> SerializeAnnouncements(IReadOnlyList<ClubAnnouncementRecord> announcements)
|
||||
{
|
||||
return announcements.Select(announcement => (object)new Dictionary<string, object?>
|
||||
{
|
||||
["AnnouncementId"] = announcement.AnnouncementId,
|
||||
["ClubId"] = announcement.ClubId,
|
||||
["AccountId"] = announcement.AccountId,
|
||||
["Title"] = announcement.Title,
|
||||
["Body"] = announcement.Body,
|
||||
["ImageName"] = announcement.ImageName,
|
||||
["Meta"] = announcement.Meta,
|
||||
["CreatedAt"] = announcement.CreatedAt
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static object BuildAnnouncementFeed(long clubId)
|
||||
{
|
||||
var announcements = Store.TryGetValue(clubId, out var list)
|
||||
? SerializeAnnouncements(list)
|
||||
: new List<object>();
|
||||
var lastAnnouncementId = announcements.Count > 0
|
||||
? (long?)((Dictionary<string, object?>)announcements[0])["AnnouncementId"]
|
||||
: null;
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["error"] = string.Empty,
|
||||
["success"] = true,
|
||||
["value"] = new Dictionary<string, object?>
|
||||
{
|
||||
["Announcements"] = announcements,
|
||||
["ClubId"] = clubId,
|
||||
["LastAnnouncementId"] = lastAnnouncementId,
|
||||
["LastReadAnnouncementId"] = 0L
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string? GetField(Dictionary<string, string> fields, params string[] names)
|
||||
{
|
||||
foreach (var name in names)
|
||||
{
|
||||
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ReadBodyFieldsAsync()
|
||||
{
|
||||
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var kv in form)
|
||||
fields[kv.Key] = kv.Value.ToString();
|
||||
return fields;
|
||||
}
|
||||
|
||||
if (Request.ContentLength is not > 0) return fields;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
if (string.IsNullOrWhiteSpace(body)) return fields;
|
||||
|
||||
var trimmed = body.TrimStart();
|
||||
if (trimmed.StartsWith("{"))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
fields[prop.Name] = prop.Value.ValueKind == JsonValueKind.String
|
||||
? prop.Value.GetString() ?? ""
|
||||
: prop.Value.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var pair in trimmed.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var kv = pair.Split('=', 2);
|
||||
if (kv.Length < 2) continue;
|
||||
fields[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1].Replace('+', ' '));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,782 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Config;
|
||||
using RadicadeServer.RecNet.Auth;
|
||||
using RadicadeServer.RecNet.CDN;
|
||||
using RadicadeServer.RecNet.Enums;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace RadicadeServer.Clubs;
|
||||
|
||||
public sealed class ClubRecord
|
||||
{
|
||||
public long clubId { get; set; }
|
||||
public string name { get; set; } = "Radicade Club";
|
||||
public string description { get; set; } = "";
|
||||
public string primaryTag { get; set; } = "Social";
|
||||
public string imageName { get; set; } = "default";
|
||||
public string bannerImageName { get; set; } = "default";
|
||||
public long ownerPlayerId { get; set; }
|
||||
public DateTime createdAt { get; set; } = DateTime.UtcNow;
|
||||
public long? clubhouseRoomId { get; set; }
|
||||
public int visibility { get; set; } = 1;
|
||||
public int joinability { get; set; } = 0;
|
||||
public bool allowJuniors { get; set; } = true;
|
||||
public int minLevel { get; set; }
|
||||
public string[] customTags { get; set; } = Array.Empty<string>();
|
||||
public string[] additionalImages { get; set; } = Array.Empty<string>();
|
||||
public List<ClubMemberRecord> members { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class ClubMemberRecord
|
||||
{
|
||||
public long accountId { get; set; }
|
||||
public int membershipType { get; set; }
|
||||
public int pendingMembershipType { get; set; }
|
||||
public long inviterAccountId { get; set; }
|
||||
public DateTime createdAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static class ClubMembershipTypes
|
||||
{
|
||||
public const int Banned = -1;
|
||||
public const int None = 0;
|
||||
public const int PendingRequested = 1;
|
||||
public const int PendingInvited = 2;
|
||||
public const int PendingDenied = 3;
|
||||
public const int Member = 10;
|
||||
public const int Moderator = 20;
|
||||
public const int Coowner = 30;
|
||||
public const int Creator = 100;
|
||||
}
|
||||
|
||||
public sealed record ClubMemberView(long AccountId, int MembershipType, long InviterAccountId, DateTime CreatedAt);
|
||||
|
||||
public static class ClubStore
|
||||
{
|
||||
private const string ConfigKey = "Clubs";
|
||||
|
||||
public const string DefaultImageName = "DefaultImgPurple";
|
||||
private const int ActiveMemberThreshold = ClubMembershipTypes.Member;
|
||||
|
||||
private static readonly string[] CategoryQueryKeys =
|
||||
{
|
||||
"category", "Category", "categoryTag", "CategoryTag", "categoryName", "CategoryName",
|
||||
"primaryTag", "PrimaryTag", "tag", "Tag", "tags", "Tags"
|
||||
};
|
||||
|
||||
public static string? CategoryFilter(IQueryCollection query)
|
||||
{
|
||||
foreach (var key in CategoryQueryKeys)
|
||||
{
|
||||
var value = query[key].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value.Trim().TrimStart('#');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool MatchesCategory(ClubRecord? club, string? category)
|
||||
{
|
||||
if (club == null) return false;
|
||||
|
||||
var wanted = category?.Trim().TrimStart('#');
|
||||
if (string.IsNullOrWhiteSpace(wanted)) return true;
|
||||
|
||||
return string.Equals(club.primaryTag?.Trim(), wanted, StringComparison.OrdinalIgnoreCase)
|
||||
|| (club.customTags ?? Array.Empty<string>())
|
||||
.Any(tag => string.Equals(tag?.Trim(), wanted, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public static List<ClubRecord> FilterByCategory(List<ClubRecord> clubs, string? category)
|
||||
=> string.IsNullOrWhiteSpace(category)
|
||||
? clubs
|
||||
: clubs.Where(club => MatchesCategory(club, category)).ToList();
|
||||
|
||||
public static async Task<List<ClubRecord>> LoadAsync(RecDBContext db)
|
||||
{
|
||||
var config = await db.ServerConfig.AsNoTracking().FirstOrDefaultAsync(x => x.Key == ConfigKey);
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.Value)) return new List<ClubRecord>();
|
||||
|
||||
try
|
||||
{
|
||||
var clubs = config.Value.FromJson<List<ClubRecord>>() ?? new List<ClubRecord>();
|
||||
foreach (var club in clubs)
|
||||
{
|
||||
club.members ??= new List<ClubMemberRecord>();
|
||||
club.customTags ??= Array.Empty<string>();
|
||||
club.additionalImages ??= Array.Empty<string>();
|
||||
}
|
||||
return clubs;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<ClubRecord>();
|
||||
}
|
||||
}
|
||||
|
||||
private static readonly string[] HomeClubConfigPrefixes = ["ClubHome:", "ClubHome.", "HomeClub:"];
|
||||
|
||||
private static string HomeClubConfigKey(long accountId) => $"ClubHome:{accountId}";
|
||||
|
||||
private static async Task<long?> GetHomeClubIdAsync(RecDBContext db, long accountId)
|
||||
{
|
||||
var keys = HomeClubConfigPrefixes.Select(prefix => prefix + accountId.ToString(System.Globalization.CultureInfo.InvariantCulture)).ToArray();
|
||||
var value = await db.ServerConfig.AsNoTracking()
|
||||
.Where(config => keys.Contains(config.Key))
|
||||
.Select(config => config.Value)
|
||||
.FirstOrDefaultAsync();
|
||||
return long.TryParse(value, out var clubId) && clubId > 0 ? clubId : null;
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord?> GetHomeClubAsync(RecDBContext db, long accountId)
|
||||
{
|
||||
var clubId = await GetHomeClubIdAsync(db, accountId);
|
||||
if (!clubId.HasValue) return null;
|
||||
var club = await GetAsync(db, clubId.Value);
|
||||
return club?.clubhouseRoomId is > 0 ? club : null;
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord?> SetHomeClubAsync(RecDBContext db, long accountId, long? clubId)
|
||||
{
|
||||
var configKey = HomeClubConfigKey(accountId);
|
||||
var config = await db.ServerConfig.FirstOrDefaultAsync(row => row.Key == configKey);
|
||||
if (clubId is > 0)
|
||||
{
|
||||
if (config == null)
|
||||
{
|
||||
config = new ServerConfigVal(configKey, clubId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
await db.ServerConfig.AddAsync(config);
|
||||
}
|
||||
else
|
||||
{
|
||||
config.Value = clubId.Value.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
else if (config != null)
|
||||
{
|
||||
db.ServerConfig.Remove(config);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return clubId is > 0 ? await GetAsync(db, clubId.Value) : null;
|
||||
}
|
||||
|
||||
public static async Task ClearHomeClubsAsync(RecDBContext db, long clubId)
|
||||
{
|
||||
var clubIdValue = clubId.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
var matching = await db.ServerConfig.Where(config => config.Value == clubIdValue).ToListAsync();
|
||||
matching.RemoveAll(config => !HomeClubConfigPrefixes.Any(prefix => config.Key.StartsWith(prefix, StringComparison.Ordinal)));
|
||||
if (matching.Count == 0) return;
|
||||
db.ServerConfig.RemoveRange(matching);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task<bool> DeleteAsync(RecDBContext db, long clubId)
|
||||
{
|
||||
var clubs = await LoadAsync(db);
|
||||
if (clubs.RemoveAll(club => club.clubId == clubId) == 0) return false;
|
||||
await SaveAsync(db, clubs);
|
||||
await ClearHomeClubsAsync(db, clubId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord?> UpdateAsync(RecDBContext db, long clubId, JsonObject body)
|
||||
{
|
||||
var clubs = await LoadAsync(db);
|
||||
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
|
||||
if (club == null) return null;
|
||||
|
||||
if (GetNonEmptyString(body, "name") is { } name) club.name = name.Trim();
|
||||
if (GetNonEmptyString(body, "description") is { } description) club.description = description;
|
||||
if (GetNonEmptyString(body, "category", "primaryTag") is { } category) club.primaryTag = category.Trim();
|
||||
if (GetNonEmptyString(body, "mainImageName") is { } imageName) club.imageName = imageName.Trim();
|
||||
if (GetNonEmptyString(body, "bannerImageName") is { } bannerName) club.bannerImageName = bannerName.Trim();
|
||||
if (GetInt(body, "visibility") is { } visibility) club.visibility = visibility;
|
||||
if (GetInt(body, "joinability") is { } joinability) club.joinability = joinability;
|
||||
if (GetInt(body, "minLevel") is { } minLevel && minLevel >= 0) club.minLevel = minLevel;
|
||||
if (GetBool(body, "allowJuniors") is { } allowJuniors) club.allowJuniors = allowJuniors;
|
||||
if (GetStringArray(body, "customTags") is { } customTags) club.customTags = NormalizeTags(customTags);
|
||||
|
||||
await SaveAsync(db, clubs);
|
||||
return club;
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord?> SetClubhouseAsync(RecDBContext db, long clubId, long? roomId)
|
||||
{
|
||||
var clubs = await LoadAsync(db);
|
||||
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
|
||||
if (club == null) return null;
|
||||
club.clubhouseRoomId = roomId;
|
||||
await SaveAsync(db, clubs);
|
||||
return club;
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord?> SetAdditionalImageAsync(RecDBContext db, long clubId, int index, string imageName)
|
||||
{
|
||||
var clubs = await LoadAsync(db);
|
||||
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
|
||||
if (club == null) return null;
|
||||
|
||||
var images = (club.additionalImages ?? Array.Empty<string>()).ToList();
|
||||
if (string.IsNullOrWhiteSpace(imageName))
|
||||
{
|
||||
if (index < images.Count) images.RemoveAt(index);
|
||||
}
|
||||
else if (index < images.Count)
|
||||
{
|
||||
images[index] = imageName.Trim();
|
||||
}
|
||||
else if (images.Count < 3)
|
||||
{
|
||||
images.Add(imageName.Trim());
|
||||
}
|
||||
club.additionalImages = images.ToArray();
|
||||
await SaveAsync(db, clubs);
|
||||
return club;
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord?> InviteMemberAsync(RecDBContext db, long clubId, long accountId, int membershipType, long inviterAccountId)
|
||||
{
|
||||
var clubs = await LoadAsync(db);
|
||||
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
|
||||
if (club == null || club.ownerPlayerId == accountId) return null;
|
||||
|
||||
club.members ??= new List<ClubMemberRecord>();
|
||||
var membership = club.members.FirstOrDefault(item => item.accountId == accountId);
|
||||
if (membership == null)
|
||||
{
|
||||
membership = new ClubMemberRecord { accountId = accountId, createdAt = DateTime.UtcNow };
|
||||
club.members.Add(membership);
|
||||
}
|
||||
|
||||
membership.membershipType = ClubMembershipTypes.PendingInvited;
|
||||
membership.pendingMembershipType = membershipType;
|
||||
membership.inviterAccountId = inviterAccountId;
|
||||
await SaveAsync(db, clubs);
|
||||
return club;
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord?> AcceptInvitationAsync(RecDBContext db, long clubId, long accountId)
|
||||
{
|
||||
var clubs = await LoadAsync(db);
|
||||
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
|
||||
var membership = club?.members?.FirstOrDefault(item => item.accountId == accountId);
|
||||
if (club == null || membership?.membershipType != ClubMembershipTypes.PendingInvited) return null;
|
||||
|
||||
var grantedType = membership.pendingMembershipType is ClubMembershipTypes.Member or ClubMembershipTypes.Moderator or ClubMembershipTypes.Coowner
|
||||
? membership.pendingMembershipType
|
||||
: ClubMembershipTypes.Member;
|
||||
membership.membershipType = grantedType;
|
||||
membership.pendingMembershipType = 0;
|
||||
membership.createdAt = DateTime.UtcNow;
|
||||
await SaveAsync(db, clubs);
|
||||
return club;
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord?> RequestToJoinAsync(RecDBContext db, long clubId, long accountId)
|
||||
{
|
||||
var clubs = await LoadAsync(db);
|
||||
var club = clubs.FirstOrDefault(item => item.clubId == clubId);
|
||||
if (club == null) return null;
|
||||
var current = GetMembershipType(club, accountId);
|
||||
if (current == ClubMembershipTypes.Banned || current >= ClubMembershipTypes.Member || current == ClubMembershipTypes.PendingRequested)
|
||||
return club;
|
||||
if (club.joinability == 1) return club;
|
||||
return await SetMemberTypeAsync(db, clubId, accountId,
|
||||
club.joinability == 0 ? ClubMembershipTypes.Member : ClubMembershipTypes.PendingRequested,
|
||||
accountId);
|
||||
}
|
||||
|
||||
private static string? GetString(JsonObject body, params string[] keys)
|
||||
{
|
||||
foreach (var pair in body)
|
||||
{
|
||||
if (!keys.Any(key => pair.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) || pair.Value == null) continue;
|
||||
if (pair.Value is JsonValue jsonValue && jsonValue.TryGetValue<string>(out var value)) return value;
|
||||
return pair.Value.ToString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetNonEmptyString(JsonObject body, params string[] keys)
|
||||
{
|
||||
var value = GetString(body, keys);
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static int? GetInt(JsonObject body, params string[] keys)
|
||||
{
|
||||
var raw = GetString(body, keys)?.Trim();
|
||||
if (int.TryParse(raw, out var value)) return value;
|
||||
return raw?.ToLowerInvariant() switch
|
||||
{
|
||||
"private" => 0,
|
||||
"public" => 1,
|
||||
"open" => 0,
|
||||
"inviteonly" or "invite_only" => 1,
|
||||
"asktojoin" or "requesttojoin" or "ask_to_join" => 2,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static bool? GetBool(JsonObject body, params string[] keys)
|
||||
{
|
||||
var raw = GetString(body, keys)?.Trim();
|
||||
if (bool.TryParse(raw, out var value)) return value;
|
||||
return raw?.ToLowerInvariant() switch
|
||||
{
|
||||
"1" or "yes" => true,
|
||||
"0" or "no" => false,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string[]? GetStringArray(JsonObject body, params string[] keys)
|
||||
{
|
||||
foreach (var pair in body)
|
||||
{
|
||||
if (!keys.Any(key => pair.Key.Equals(key, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
if (pair.Value is JsonArray array)
|
||||
return NormalizeTags(array.Select(item => item?.ToString() ?? string.Empty));
|
||||
return NormalizeTags(new[] { pair.Value?.ToString() ?? string.Empty });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string[] NormalizeTags(IEnumerable<string> tags)
|
||||
{
|
||||
var output = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var raw in tags)
|
||||
{
|
||||
var tag = raw.Trim();
|
||||
if (tag.Length > 0 && seen.Add(tag)) output.Add(tag);
|
||||
}
|
||||
return output.ToArray();
|
||||
}
|
||||
|
||||
public static async Task SaveAsync(RecDBContext db, List<ClubRecord> clubs)
|
||||
{
|
||||
var config = await db.ServerConfig.FirstOrDefaultAsync(x => x.Key == ConfigKey);
|
||||
if (config == null)
|
||||
{
|
||||
config = new ServerConfigVal(ConfigKey, "[]");
|
||||
await db.ServerConfig.AddAsync(config);
|
||||
}
|
||||
|
||||
config.Value = clubs.ToJson();
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord?> GetAsync(RecDBContext db, long clubId)
|
||||
{
|
||||
var clubs = await LoadAsync(db);
|
||||
return clubs.FirstOrDefault(x => x.clubId == clubId);
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord> CreateAsync(
|
||||
RecDBContext db,
|
||||
long ownerPlayerId,
|
||||
string? name,
|
||||
string? description,
|
||||
string? primaryTag = null,
|
||||
int visibility = 1,
|
||||
int joinability = 0,
|
||||
bool allowJuniors = true,
|
||||
int minLevel = 0,
|
||||
string? mainImageName = null)
|
||||
{
|
||||
var clubs = await LoadAsync(db);
|
||||
var clubName = string.IsNullOrWhiteSpace(name) ? "Radicade Club" : name.Trim();
|
||||
var tag = string.IsNullOrWhiteSpace(primaryTag) ? "Social" : primaryTag.Trim();
|
||||
var now = DateTime.UtcNow;
|
||||
var clubId = Math.Abs((long)HashCode.Combine(ownerPlayerId, clubName, now.Ticks));
|
||||
|
||||
while (clubId == 0 || clubs.Any(x => x.clubId == clubId))
|
||||
{
|
||||
clubId = Math.Abs((long)HashCode.Combine(ownerPlayerId, clubName, Guid.NewGuid()));
|
||||
}
|
||||
|
||||
var club = new ClubRecord
|
||||
{
|
||||
clubId = clubId,
|
||||
name = clubName,
|
||||
description = description?.Trim() ?? "",
|
||||
primaryTag = tag,
|
||||
imageName = string.IsNullOrWhiteSpace(mainImageName) ? DefaultImageName : mainImageName.Trim(),
|
||||
visibility = visibility,
|
||||
joinability = joinability,
|
||||
allowJuniors = allowJuniors,
|
||||
minLevel = minLevel,
|
||||
ownerPlayerId = ownerPlayerId,
|
||||
createdAt = now
|
||||
};
|
||||
club.members.Add(new ClubMemberRecord
|
||||
{
|
||||
accountId = ownerPlayerId,
|
||||
membershipType = ClubMembershipTypes.Creator,
|
||||
inviterAccountId = ownerPlayerId,
|
||||
createdAt = now
|
||||
});
|
||||
|
||||
clubs.Add(club);
|
||||
await SaveAsync(db, clubs);
|
||||
return club;
|
||||
}
|
||||
|
||||
public static int GetMembershipType(ClubRecord? club, long accountId)
|
||||
{
|
||||
if (club == null || club.clubId <= 0 || accountId <= 0) return ClubMembershipTypes.None;
|
||||
if (club.ownerPlayerId == accountId) return ClubMembershipTypes.Creator;
|
||||
return club.members?.FirstOrDefault(x => x.accountId == accountId)?.membershipType ?? ClubMembershipTypes.None;
|
||||
}
|
||||
|
||||
public static int GetMemberCount(ClubRecord? club)
|
||||
{
|
||||
if (club == null || club.clubId <= 0) return 0;
|
||||
return 1 + (club.members ?? new List<ClubMemberRecord>())
|
||||
.Where(x => x.accountId != club.ownerPlayerId && x.membershipType >= ActiveMemberThreshold)
|
||||
.Select(x => x.accountId)
|
||||
.Distinct()
|
||||
.Count();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ClubMemberView> GetMembers(ClubRecord? club)
|
||||
{
|
||||
if (club == null || club.clubId <= 0) return Array.Empty<ClubMemberView>();
|
||||
|
||||
var members = new List<ClubMemberView>
|
||||
{
|
||||
new(club.ownerPlayerId, ClubMembershipTypes.Creator, club.ownerPlayerId, club.createdAt)
|
||||
};
|
||||
members.AddRange((club.members ?? new List<ClubMemberRecord>())
|
||||
.Where(x => x.accountId != club.ownerPlayerId)
|
||||
.GroupBy(x => x.accountId)
|
||||
.Select(group => group.Last())
|
||||
.Select(x => new ClubMemberView(
|
||||
x.accountId,
|
||||
x.membershipType,
|
||||
x.inviterAccountId,
|
||||
NormalizeMembershipDate(x.createdAt, club.createdAt))));
|
||||
return members;
|
||||
}
|
||||
|
||||
private static DateTime NormalizeMembershipDate(DateTime memberCreatedAt, DateTime clubCreatedAt)
|
||||
{
|
||||
if (memberCreatedAt > DateTime.MinValue) return memberCreatedAt;
|
||||
if (clubCreatedAt > DateTime.MinValue) return clubCreatedAt;
|
||||
return DateTime.UnixEpoch;
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord?> SetMemberTypeAsync(
|
||||
RecDBContext db,
|
||||
long clubId,
|
||||
long accountId,
|
||||
int membershipType,
|
||||
long inviterAccountId)
|
||||
{
|
||||
var clubs = await LoadAsync(db);
|
||||
var club = clubs.FirstOrDefault(x => x.clubId == clubId);
|
||||
if (club == null || club.ownerPlayerId == accountId) return null;
|
||||
|
||||
club.members ??= new List<ClubMemberRecord>();
|
||||
var membership = club.members.FirstOrDefault(x => x.accountId == accountId);
|
||||
if (membership == null)
|
||||
{
|
||||
club.members.Add(new ClubMemberRecord
|
||||
{
|
||||
accountId = accountId,
|
||||
membershipType = membershipType,
|
||||
inviterAccountId = inviterAccountId,
|
||||
createdAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
if (membership.membershipType < ClubMembershipTypes.Member && membershipType >= ClubMembershipTypes.Member)
|
||||
membership.createdAt = DateTime.UtcNow;
|
||||
membership.membershipType = membershipType;
|
||||
membership.inviterAccountId = inviterAccountId;
|
||||
}
|
||||
|
||||
await SaveAsync(db, clubs);
|
||||
return club;
|
||||
}
|
||||
|
||||
public static async Task<ClubRecord?> RemoveMemberAsync(RecDBContext db, long clubId, long accountId)
|
||||
{
|
||||
var clubs = await LoadAsync(db);
|
||||
var club = clubs.FirstOrDefault(x => x.clubId == clubId);
|
||||
if (club == null || club.ownerPlayerId == accountId) return null;
|
||||
|
||||
club.members ??= new List<ClubMemberRecord>();
|
||||
club.members.RemoveAll(member => member.accountId == accountId && member.membershipType != ClubMembershipTypes.Banned);
|
||||
await SaveAsync(db, clubs);
|
||||
return club;
|
||||
}
|
||||
|
||||
private static long ToUnix(DateTime value) => new DateTimeOffset(value).ToUnixTimeSeconds();
|
||||
|
||||
public static object BuildClub(ClubRecord? club, long viewerId = 0)
|
||||
{
|
||||
club ??= new ClubRecord { clubId = 0 };
|
||||
var memberCount = GetMemberCount(club);
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["ClubId"] = club.clubId,
|
||||
["Name"] = club.name,
|
||||
["Description"] = club.description,
|
||||
["MainImageName"] = club.imageName,
|
||||
["State"] = 0,
|
||||
["CreatorAccountId"] = club.ownerPlayerId,
|
||||
["Category"] = club.primaryTag,
|
||||
["Visibility"] = club.visibility,
|
||||
["Joinability"] = club.joinability,
|
||||
["AllowJuniors"] = club.allowJuniors,
|
||||
["MinLevel"] = club.minLevel,
|
||||
["MemberCount"] = memberCount,
|
||||
["IsRRO"] = false,
|
||||
["ClubhouseRoomId"] = club.clubhouseRoomId,
|
||||
["ClubType"] = 0
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<object> BuildClubDetailsAsync(RecDBContext db, ClubRecord? club, long viewerId = 0)
|
||||
{
|
||||
club ??= new ClubRecord { clubId = 0 };
|
||||
var details = (Dictionary<string, object?>)BuildClubDetails(club, viewerId);
|
||||
details["AdditionalImages"] = await GetAdditionalImagesAsync(db, club.additionalImages ?? Array.Empty<string>());
|
||||
return details;
|
||||
}
|
||||
|
||||
public static object BuildClubDetails(ClubRecord? club, long viewerId = 0)
|
||||
{
|
||||
club ??= new ClubRecord { clubId = 0 };
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["Club"] = BuildClub(club, viewerId),
|
||||
["ClubId"] = club.clubId,
|
||||
["CustomTags"] = club.customTags ?? Array.Empty<string>(),
|
||||
["AdditionalImages"] = Array.Empty<object>(),
|
||||
["CoownerPermissions"] = BuildPermissions(club.clubId, 0),
|
||||
["ModeratorPermissions"] = BuildPermissions(club.clubId, 1),
|
||||
["MemberPermissions"] = BuildPermissions(club.clubId, 2),
|
||||
["MyMembershipType"] = GetMembershipType(club, viewerId)
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object[]> GetAdditionalImagesAsync(RecDBContext db, string[] imageNames)
|
||||
{
|
||||
if (imageNames.Length == 0) return Array.Empty<object>();
|
||||
var records = await db.Images.AsNoTracking()
|
||||
.Where(image => imageNames.Contains(image.Name))
|
||||
.ToDictionaryAsync(image => image.Name);
|
||||
var imageIds = records.Values.Select(image => image.Id).ToList();
|
||||
var cheers = await db.ImageInteractions.AsNoTracking()
|
||||
.Where(interaction => interaction.IsCheered && imageIds.Contains(interaction.SavedImageId))
|
||||
.GroupBy(interaction => interaction.SavedImageId)
|
||||
.Select(group => new { ImageId = group.Key, Count = group.Count() })
|
||||
.ToDictionaryAsync(group => group.ImageId, group => group.Count);
|
||||
|
||||
return imageNames.Select(name =>
|
||||
{
|
||||
if (!records.TryGetValue(name, out var image))
|
||||
return (object)new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = 0L, ["Type"] = 1,
|
||||
["Accessibility"] = (int)SavedImageAccessibility.Public,
|
||||
["AccessibilityLocked"] = false, ["ImageName"] = name,
|
||||
["Description"] = null, ["PlayerId"] = 0L,
|
||||
["TaggedPlayerIds"] = Array.Empty<long>(), ["RoomId"] = null,
|
||||
["PlayerEventId"] = null, ["CreatedAt"] = DateTime.UnixEpoch,
|
||||
["CheerCount"] = 0, ["CommentCount"] = 0
|
||||
};
|
||||
return (object)new Dictionary<string, object?>
|
||||
{
|
||||
["Id"] = image.Id, ["Type"] = (int)image.ImageType,
|
||||
["Accessibility"] = (int)image.Accessibility, ["AccessibilityLocked"] = false,
|
||||
["ImageName"] = image.Name, ["Description"] = null, ["PlayerId"] = image.OwnerId,
|
||||
["TaggedPlayerIds"] = ParseImagePlayerIds(image.PlayerIds), ["RoomId"] = image.RoomId,
|
||||
["PlayerEventId"] = image.EventId, ["CreatedAt"] = image.CreatedAt,
|
||||
["CheerCount"] = cheers.GetValueOrDefault(image.Id), ["CommentCount"] = 0
|
||||
};
|
||||
}).ToArray();
|
||||
}
|
||||
|
||||
private static long[] ParseImagePlayerIds(string? playerIds)
|
||||
{
|
||||
try { return JsonSerializer.Deserialize<long[]>(playerIds ?? "[]") ?? Array.Empty<long>(); }
|
||||
catch { return Array.Empty<long>(); }
|
||||
}
|
||||
|
||||
public static object BuildPermissions(long clubId, int type)
|
||||
{
|
||||
var isCoowner = type == 0;
|
||||
var isModerator = type == 1;
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["Type"] = type switch
|
||||
{
|
||||
0 => ClubMembershipTypes.Coowner,
|
||||
1 => ClubMembershipTypes.Moderator,
|
||||
_ => ClubMembershipTypes.Member
|
||||
},
|
||||
["ClubId"] = clubId,
|
||||
["ApproveMember"] = isCoowner || isModerator,
|
||||
["BanUnban"] = isCoowner || isModerator,
|
||||
["CreateEvent"] = isCoowner,
|
||||
["EditDetails"] = isCoowner,
|
||||
["EditPermissionSettings"] = isCoowner,
|
||||
["PostAnnouncement"] = isCoowner
|
||||
};
|
||||
}
|
||||
|
||||
public static object BuildMemberListItem(long playerId, int role, bool isOwner, long clubId = 0, long created = 0)
|
||||
{
|
||||
var type = isOwner ? ClubMembershipTypes.Creator : role;
|
||||
var createdAt = created > 0 ? DateTimeOffset.FromUnixTimeSeconds(created).UtcDateTime.ToString("O") : null;
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["ClubMemberId"] = playerId,
|
||||
["ClubId"] = clubId,
|
||||
["AccountId"] = playerId,
|
||||
["MembershipType"] = type,
|
||||
["CreatedAt"] = createdAt
|
||||
};
|
||||
}
|
||||
|
||||
public static object BuildMemberListItem(ClubMemberView member, long clubId) =>
|
||||
BuildMemberListItem(
|
||||
member.AccountId,
|
||||
member.MembershipType,
|
||||
member.MembershipType == ClubMembershipTypes.Creator,
|
||||
clubId,
|
||||
ToUnix(member.CreatedAt));
|
||||
|
||||
public static object BuildMember(long playerId, int role, bool isOwner, long clubId = 0, long created = 0) => new Dictionary<string, object?>
|
||||
{
|
||||
["AccountId"] = playerId,
|
||||
["ClubId"] = clubId,
|
||||
["CreatedAt"] = created > 0 ? DateTimeOffset.FromUnixTimeSeconds(created).UtcDateTime.ToString("O") : null,
|
||||
["MembershipType"] = isOwner ? ClubMembershipTypes.Creator : role,
|
||||
["membershipRequestId"] = 0L,
|
||||
["inviterAccountId"] = 0L,
|
||||
["subscriptionId"] = clubId,
|
||||
["playerId"] = playerId,
|
||||
["username"] = string.Empty,
|
||||
["displayName"] = string.Empty,
|
||||
["level"] = 0,
|
||||
["role"] = isOwner ? ClubMembershipTypes.Creator : role,
|
||||
["membershipStatus"] = role >= ActiveMemberThreshold || isOwner ? 1 : 0,
|
||||
["isOwner"] = isOwner,
|
||||
["created"] = created,
|
||||
["createdAt"] = created
|
||||
};
|
||||
|
||||
public static object BuildMember(ClubMemberView membership, ClubRecord? club, Profile? profile)
|
||||
{
|
||||
var role = membership.MembershipType;
|
||||
var created = ToUnix(membership.CreatedAt);
|
||||
var isOwner = role == ClubMembershipTypes.Creator;
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["AccountId"] = membership.AccountId,
|
||||
["ClubId"] = club?.clubId ?? 0L,
|
||||
["CreatedAt"] = membership.CreatedAt.ToString("O"),
|
||||
["MembershipType"] = role,
|
||||
["membershipRequestId"] = 0L,
|
||||
["inviterAccountId"] = membership.InviterAccountId,
|
||||
["subscriptionId"] = club?.clubId ?? 0L,
|
||||
["playerId"] = membership.AccountId,
|
||||
["username"] = profile?.username ?? string.Empty,
|
||||
["displayName"] = profile?.displayName ?? string.Empty,
|
||||
["level"] = 0,
|
||||
["role"] = role,
|
||||
["membershipStatus"] = role >= ActiveMemberThreshold || isOwner ? 1 : 0,
|
||||
["isOwner"] = isOwner,
|
||||
["created"] = created,
|
||||
["createdAt"] = created
|
||||
};
|
||||
}
|
||||
|
||||
public static object BuildMembership(ClubRecord? club, long viewerId = 0)
|
||||
{
|
||||
club ??= new ClubRecord { clubId = 0 };
|
||||
var role = GetMembershipType(club, viewerId);
|
||||
var isMember = role >= ActiveMemberThreshold;
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["accountId"] = viewerId,
|
||||
["clubId"] = club.clubId,
|
||||
["creatorAccountId"] = club.ownerPlayerId,
|
||||
["membershipType"] = role,
|
||||
["LastAnnouncementId"] = 0L,
|
||||
["LastReadAnnouncementId"] = 0L,
|
||||
["announcements"] = Array.Empty<object>(),
|
||||
["subscriptionId"] = club.clubId
|
||||
};
|
||||
}
|
||||
|
||||
public static object BuildSubscriptionEnvelope(ClubRecord? club, long viewerId = 0) => new Dictionary<string, object?>
|
||||
{
|
||||
["success"] = club != null,
|
||||
["error"] = string.Empty,
|
||||
["value"] = club == null ? null : BuildMembership(club, viewerId)
|
||||
};
|
||||
|
||||
public static object BuildSearchResponse(object[] clubs, int skip, int take) => new Dictionary<string, object?>
|
||||
{
|
||||
["Clubs"] = clubs,
|
||||
["TotalClubs"] = clubs.Length,
|
||||
["ContinuationToken"] = null
|
||||
};
|
||||
|
||||
public static object BuildHome(ClubRecord? homeClub, long viewerId, IReadOnlyList<ClubRecord> allClubs, IReadOnlyList<ClubRecord> mine)
|
||||
{
|
||||
return BuildClub(homeClub, viewerId);
|
||||
}
|
||||
|
||||
public static object BuildEmptyHome(long viewerId)
|
||||
{
|
||||
return BuildClub(null, viewerId);
|
||||
}
|
||||
|
||||
public static object BuildModerationBlockDetails() => new Dictionary<string, object?>
|
||||
{
|
||||
["Duration"] = 0L,
|
||||
["GameSessionId"] = 0L,
|
||||
["PlayerIdReporter"] = 0L,
|
||||
["IsBan"] = false,
|
||||
["IsHostKick"] = false,
|
||||
["Message"] = string.Empty,
|
||||
["ReportCategory"] = 0,
|
||||
["VoteKickReason"] = string.Empty,
|
||||
["bannedUntil"] = string.Empty,
|
||||
["displayReason"] = string.Empty,
|
||||
["banType"] = string.Empty,
|
||||
["banAppealCode"] = string.Empty,
|
||||
["banAppealText"] = string.Empty
|
||||
};
|
||||
|
||||
public static object BuildEligibility() => new Dictionary<string, object?>
|
||||
{
|
||||
["canCreateClub"] = true,
|
||||
["canCreate"] = true,
|
||||
["creationAllowed"] = true,
|
||||
["isAllowed"] = true,
|
||||
["isCreationRestricted"] = false,
|
||||
["creationRestricted"] = false,
|
||||
["moderationRestricted"] = false,
|
||||
["moderationIncidentCount"] = 0,
|
||||
["myCreatedClubsCount"] = 0,
|
||||
["maxCreatedClubs"] = 100,
|
||||
["cooldownSeconds"] = 0,
|
||||
["restrictionReason"] = string.Empty
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace RadicadeServer.Clubs;
|
||||
|
||||
[Route("clubs/subscription")]
|
||||
[Route("clubs/subscriptions")]
|
||||
[Route("api/clubs/v1/subscription")]
|
||||
[Route("api/clubs/v2/subscription")]
|
||||
[Route("api/clubs/v1/subscriptions")]
|
||||
[Route("api/clubs/v2/subscriptions")]
|
||||
public class ClubSubscriptionAPI(RecDBContext db) : Controller
|
||||
{
|
||||
private static readonly string[] TargetIdKeys =
|
||||
{
|
||||
"playerId", "PlayerId", "accountId", "AccountId", "creatorAccountId", "CreatorAccountId",
|
||||
"subscriptionId", "SubscriptionId", "id", "Id", "targetPlayerId", "TargetPlayerId"
|
||||
};
|
||||
|
||||
[HttpGet("mine/member")]
|
||||
[HttpGet("my/member")]
|
||||
public IActionResult GetMyMemberships() => Ok(Array.Empty<object>());
|
||||
|
||||
[HttpGet("my/subscriptions")]
|
||||
public async Task<IActionResult> GetMySubscriptions()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Ok(Array.Empty<long>());
|
||||
|
||||
return Ok(await PlayerSubscriptions.SubscriptionIdsAsync(db, userId.Value));
|
||||
}
|
||||
|
||||
[HttpGet("my/subscribers")]
|
||||
public async Task<IActionResult> GetMySubscribers()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Ok(Array.Empty<long>());
|
||||
|
||||
return Ok(await PlayerSubscriptions.SubscriberIdsAsync(db, userId.Value));
|
||||
}
|
||||
|
||||
[HttpGet("details/{accountId:long}")]
|
||||
public async Task<IActionResult> GetAccountSubscriptionDetails(long accountId) => Ok(new
|
||||
{
|
||||
clubId = PlayerSubscriptions.PersonalClubId(accountId),
|
||||
accountId,
|
||||
subscriberCount = await PlayerSubscriptions.SubscriberCountAsync(db, accountId)
|
||||
});
|
||||
|
||||
[HttpGet("details/{subscription}")]
|
||||
public IActionResult GetNamedSubscriptionDetails(string subscription) => Ok(new { });
|
||||
|
||||
[HttpGet("subscriberCount/{accountId:long}")]
|
||||
public async Task<IActionResult> GetSubscriberCount(long accountId)
|
||||
=> Ok(await PlayerSubscriptions.SubscriberCountAsync(db, accountId));
|
||||
|
||||
[HttpGet("top/creators")]
|
||||
public async Task<IActionResult> GetTopCreators() => Ok(await PlayerSubscriptions.TopCreatorsAsync(db, 100));
|
||||
|
||||
[HttpGet("top/creators/count")]
|
||||
public async Task<IActionResult> GetTopCreatorCount()
|
||||
=> Ok((await PlayerSubscriptions.TopCreatorsAsync(db, int.MaxValue)).Count);
|
||||
|
||||
[HttpPost("{playerId:long}"), Authorize]
|
||||
public async Task<IActionResult> Subscribe(long playerId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
if (playerId > 0)
|
||||
await PlayerSubscriptions.SubscribeAsync(db, userId.Value, playerId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
value = PlayerSubscriptions.PersonalClubId(playerId),
|
||||
success = true,
|
||||
errorId = (object?)null,
|
||||
error = (object?)null
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost(""), Authorize]
|
||||
public async Task<IActionResult> SubscribeBare()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var playerId = await ReadTargetPlayerIdAsync();
|
||||
if (playerId > 0)
|
||||
await PlayerSubscriptions.SubscribeAsync(db, userId.Value, playerId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
value = PlayerSubscriptions.PersonalClubId(playerId),
|
||||
success = true,
|
||||
errorId = (object?)null,
|
||||
error = (object?)null
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("{playerId:long}"), Authorize]
|
||||
[HttpPost("{playerId:long}/unsubscribe"), Authorize]
|
||||
public async Task<IActionResult> Unsubscribe(long playerId)
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
if (playerId > 0)
|
||||
await PlayerSubscriptions.UnsubscribeAsync(db, userId.Value, playerId);
|
||||
|
||||
return Ok(new { success = true, errorId = (object?)null, error = (object?)null });
|
||||
}
|
||||
|
||||
[HttpDelete(""), Authorize]
|
||||
public async Task<IActionResult> UnsubscribeBare()
|
||||
{
|
||||
var userId = User.GetUserId();
|
||||
if (userId == null) return Unauthorized();
|
||||
|
||||
var playerId = await ReadTargetPlayerIdAsync();
|
||||
if (playerId > 0)
|
||||
await PlayerSubscriptions.UnsubscribeAsync(db, userId.Value, playerId);
|
||||
|
||||
return Ok(new { success = true, errorId = (object?)null, error = (object?)null });
|
||||
}
|
||||
|
||||
private async Task<long> ReadTargetPlayerIdAsync()
|
||||
{
|
||||
foreach (var key in TargetIdKeys)
|
||||
{
|
||||
var value = Request.Query[key].FirstOrDefault();
|
||||
if (long.TryParse(value, out var fromQuery) && fromQuery > 0) return fromQuery;
|
||||
}
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in TargetIdKeys)
|
||||
{
|
||||
var value = form[key].FirstOrDefault();
|
||||
if (long.TryParse(value, out var fromForm) && fromForm > 0) return fromForm;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (Request.ContentLength is not > 0) return 0;
|
||||
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
using var reader = new StreamReader(Request.Body, leaveOpen: true);
|
||||
var body = await reader.ReadToEndAsync();
|
||||
if (Request.Body.CanSeek) Request.Body.Position = 0;
|
||||
if (string.IsNullOrWhiteSpace(body)) return 0;
|
||||
|
||||
var trimmed = body.Trim();
|
||||
if (trimmed.StartsWith("{"))
|
||||
{
|
||||
using var document = JsonDocument.Parse(trimmed);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in document.RootElement.EnumerateObject())
|
||||
{
|
||||
if (!TargetIdKeys.Any(key => key.Equals(property.Name, StringComparison.OrdinalIgnoreCase))) continue;
|
||||
if (long.TryParse(property.Value.ToString(), out var fromBody) && fromBody > 0) return fromBody;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (long.TryParse(trimmed, out var fromRaw) && fromRaw > 0)
|
||||
{
|
||||
return fromRaw;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using RadicadeServer.API.Notifications;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
using RadicadeServer.RecNet.Auth;
|
||||
using RadicadeServer.RecNet.Enums.Messages;
|
||||
using RadicadeServer.RecNet.Messages;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
|
||||
namespace RadicadeServer.Clubs;
|
||||
|
||||
[Route("clubs/club")]
|
||||
[Route("api/clubs/v1/club")]
|
||||
[Route("api/clubs/v2/club")]
|
||||
public class ClubsAPI : Controller
|
||||
{
|
||||
private readonly RecDBContext db;
|
||||
|
||||
public ClubsAPI(RecDBContext db)
|
||||
{
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
private long ViewerId => User.GetUserId() ?? 0;
|
||||
|
||||
[HttpGet("home/me"), Authorize]
|
||||
[HttpGet("~/club/home/me"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/home/me"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/home/me"), Authorize]
|
||||
public async Task<IActionResult> GetMyClubHome()
|
||||
{
|
||||
var homeClub = await ClubStore.GetHomeClubAsync(db, ViewerId);
|
||||
return homeClub == null
|
||||
? NotFound()
|
||||
: Ok(ClubStore.BuildClub(homeClub, ViewerId));
|
||||
}
|
||||
|
||||
[HttpPut("~/clubs/club/home/me"), Authorize]
|
||||
[HttpPut("~/club/home/me"), Authorize]
|
||||
[HttpPut("~/api/clubs/v1/home/me"), Authorize]
|
||||
[HttpPut("~/api/clubs/v2/home/me"), Authorize]
|
||||
public async Task<IActionResult> SetMyClubHome()
|
||||
{
|
||||
var body = await ReadBodyObjectAsync();
|
||||
var clubId = ReadLong(body, "clubId", "ClubId");
|
||||
if (clubId is null or <= 0) return BadRequest(new { error = "Invalid clubId.", success = false, value = (object?)null });
|
||||
|
||||
var club = await ClubStore.GetAsync(db, clubId.Value);
|
||||
if (club == null) return NotFound();
|
||||
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Member)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "You are not a member of that club.", success = false, value = (object?)null });
|
||||
|
||||
var updated = await ClubStore.SetHomeClubAsync(db, ViewerId, clubId.Value);
|
||||
return Ok(new { error = "", success = true, value = ClubStore.BuildClub(updated, ViewerId) });
|
||||
}
|
||||
|
||||
[HttpDelete("~/clubs/club/home/me"), Authorize]
|
||||
[HttpDelete("~/club/home/me"), Authorize]
|
||||
[HttpDelete("~/api/clubs/v1/home/me"), Authorize]
|
||||
[HttpDelete("~/api/clubs/v2/home/me"), Authorize]
|
||||
public async Task<IActionResult> ClearMyClubHome()
|
||||
{
|
||||
await ClubStore.SetHomeClubAsync(db, ViewerId, null);
|
||||
return Ok(new { error = "", success = true, value = (object?)null });
|
||||
}
|
||||
|
||||
[HttpGet("{clubId}"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/{clubId:long}"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/{clubId:long}"), Authorize]
|
||||
public async Task<IActionResult> GetClub(long clubId)
|
||||
{
|
||||
return Ok(ClubStore.BuildClub(await ClubStore.GetAsync(db, clubId), ViewerId));
|
||||
}
|
||||
|
||||
[HttpGet("{clubId}/details"), Authorize]
|
||||
[HttpGet("~/club/{clubId:long}/details"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/{clubId:long}/details"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/{clubId:long}/details"), Authorize]
|
||||
public async Task<IActionResult> GetClubDetails(long clubId)
|
||||
{
|
||||
return Ok(await ClubStore.BuildClubDetailsAsync(db, await ClubStore.GetAsync(db, clubId), ViewerId));
|
||||
}
|
||||
|
||||
[HttpGet("{clubId:long}/mainimage")]
|
||||
[HttpGet("~/club/{clubId:long}/mainimage")]
|
||||
public async Task<IActionResult> GetClubMainImage(long clubId)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
return Ok(new
|
||||
{
|
||||
error = string.Empty,
|
||||
success = true,
|
||||
value = await ClubStore.BuildClubDetailsAsync(db, club, ViewerId)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("{clubId:long}/mainimage"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/mainimage"), Authorize]
|
||||
public async Task<IActionResult> SetClubMainImage(long clubId)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
|
||||
var body = await ReadBodyObjectAsync();
|
||||
var imageName = ReadString(body, "imageName", "ImageName")?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(imageName))
|
||||
return BadRequest(new { error = "imageName is required.", success = false, value = (object?)null });
|
||||
|
||||
var updated = await ClubStore.UpdateAsync(db, clubId, new JsonObject { ["mainImageName"] = imageName });
|
||||
if (updated == null) return NotFound();
|
||||
return Ok(new
|
||||
{
|
||||
error = string.Empty,
|
||||
success = true,
|
||||
value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{clubId}/members")]
|
||||
[HttpGet("~/club/{clubId:long}/members")]
|
||||
[HttpGet("~/api/clubs/v1/{clubId:long}/members")]
|
||||
[HttpGet("~/api/clubs/v2/{clubId:long}/members")]
|
||||
public async Task<IActionResult> GetClubMembers(long clubId, [FromQuery] int? membershipType = null, [FromQuery] int? sortBy = null)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
var members = ClubStore.GetMembers(club);
|
||||
if (membershipType.HasValue)
|
||||
members = members.Where(member => member.MembershipType == membershipType.Value).ToArray();
|
||||
members = sortBy switch
|
||||
{
|
||||
1 => members.OrderBy(member => member.AccountId).ToArray(),
|
||||
2 => members.OrderBy(member => member.CreatedAt).ToArray(),
|
||||
_ => members.OrderByDescending(member => member.MembershipType).ThenBy(member => member.CreatedAt).ToArray()
|
||||
};
|
||||
var values = members
|
||||
.Select(member => ClubStore.BuildMemberListItem(member, clubId))
|
||||
.ToArray();
|
||||
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["error"] = string.Empty,
|
||||
["success"] = true,
|
||||
["value"] = values
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("{clubId}/hasDisabledClubChat"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/{clubId:long}/hasDisabledClubChat"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/{clubId:long}/hasDisabledClubChat"), Authorize]
|
||||
public IActionResult GetHasDisabledClubChat(long clubId)
|
||||
{
|
||||
return Content("false", "application/json");
|
||||
}
|
||||
|
||||
[HttpGet("{clubId:long}/members/{playerId:long}"), Authorize]
|
||||
[HttpGet("~/club/{clubId:long}/members/{playerId:long}"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/{clubId:long}/members/{playerId:long}"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/{clubId:long}/members/{playerId:long}"), Authorize]
|
||||
public async Task<IActionResult> GetClubMembership(long clubId, long playerId)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
var role = ClubStore.GetMembershipType(club, playerId);
|
||||
var membership = ClubStore.GetMembers(club).FirstOrDefault(member => member.AccountId == playerId);
|
||||
var profile = await db.Profiles.AsNoTracking().FirstOrDefaultAsync(x => x.accountId == playerId);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
clubId = clubId,
|
||||
creatorAccountId = club?.ownerPlayerId ?? 0L,
|
||||
membershipType = role,
|
||||
LastAnnouncementId = 0L,
|
||||
LastReadAnnouncementId = 0L,
|
||||
announcements = Array.Empty<object>(),
|
||||
subscriptionId = clubId,
|
||||
playerId = playerId,
|
||||
membershipStatus = role >= ClubMembershipTypes.Member ? 1 : 0,
|
||||
member = membership == null ? null : ClubStore.BuildMember(membership, club, profile),
|
||||
role = role,
|
||||
subscribed = role >= ClubMembershipTypes.Member,
|
||||
created = membership == null ? 0L : new DateTimeOffset(membership.CreatedAt).ToUnixTimeSeconds(),
|
||||
createdAt = membership == null ? 0L : new DateTimeOffset(membership.CreatedAt).ToUnixTimeSeconds()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("mine/created"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/mine/created"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/mine/created"), Authorize]
|
||||
public async Task<IActionResult> GetMyCreatedClubs()
|
||||
{
|
||||
var clubs = await ClubStore.LoadAsync(db);
|
||||
return Ok(clubs.Where(x => x.ownerPlayerId == ViewerId).Select(x => ClubStore.BuildClub(x, ViewerId)).ToArray());
|
||||
}
|
||||
|
||||
[HttpGet("mine/member"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/mine/member"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/mine/member"), Authorize]
|
||||
public async Task<IActionResult> GetMyMemberClubs()
|
||||
{
|
||||
var clubs = await ClubStore.LoadAsync(db);
|
||||
return Ok(clubs
|
||||
.Where(club => ClubStore.GetMembershipType(club, ViewerId) >= ClubMembershipTypes.Member)
|
||||
.Select(club => ClubStore.BuildClub(club, ViewerId))
|
||||
.ToArray());
|
||||
}
|
||||
|
||||
[HttpGet("account/{accountId}/created"), Authorize]
|
||||
[HttpGet("~/clubs/account/{accountId:long}/created"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/account/{accountId:long}/created"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/account/{accountId:long}/created"), Authorize]
|
||||
public async Task<IActionResult> GetClubsCreatedByAccount(long accountId)
|
||||
{
|
||||
var clubs = await ClubStore.LoadAsync(db);
|
||||
return Ok(clubs.Where(x => x.ownerPlayerId == accountId).Select(x => ClubStore.BuildClub(x, ViewerId)).ToArray());
|
||||
}
|
||||
|
||||
[HttpGet("~/clubs/account/{accountId:long}/clubs")]
|
||||
[HttpGet("~/api/clubs/v1/account/{accountId:long}/clubs")]
|
||||
[HttpGet("~/api/clubs/v2/account/{accountId:long}/clubs")]
|
||||
public Task<IActionResult> GetClubsForAccount(long accountId) => GetClubsCreatedByAccount(accountId);
|
||||
|
||||
[HttpGet("mostactivetoday"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/mostactivetoday"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/mostactivetoday"), Authorize]
|
||||
public async Task<IActionResult> GetMostActiveToday()
|
||||
{
|
||||
var clubs = await ClubStore.LoadAsync(db);
|
||||
clubs = ClubStore.FilterByCategory(clubs, ClubStore.CategoryFilter(Request.Query));
|
||||
return Ok(clubs.Select(x => ClubStore.BuildClub(x, ViewerId)).ToArray());
|
||||
}
|
||||
|
||||
[HttpGet("mostactivenow"), Authorize]
|
||||
public async Task<IActionResult> GetMostActiveNow()
|
||||
{
|
||||
var clubs = await ClubStore.LoadAsync(db);
|
||||
clubs = ClubStore.FilterByCategory(clubs, ClubStore.CategoryFilter(Request.Query));
|
||||
return Ok(clubs.Select(x => ClubStore.BuildClub(x, ViewerId)).ToArray());
|
||||
}
|
||||
|
||||
[HttpGet("categoryTags"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/categoryTags"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/categoryTags"), Authorize]
|
||||
public IActionResult GetCategoryTags()
|
||||
{
|
||||
return Ok(new[] { "Social", "Casual", "Competitive", "Creative", "Entertainment", "Educational", "Lifestyle" });
|
||||
}
|
||||
|
||||
[HttpGet("search"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/search"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/search"), Authorize]
|
||||
public async Task<IActionResult> SearchClubs(
|
||||
[FromQuery] string? query = null,
|
||||
[FromQuery] int sort = 0,
|
||||
[FromQuery] string? category = null,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int count = 30,
|
||||
[FromQuery] int take = 0)
|
||||
{
|
||||
var limit = take > 0 ? take : count;
|
||||
var clubs = await ClubStore.LoadAsync(db);
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
clubs = clubs
|
||||
.Where(x => x.name.Contains(query, StringComparison.OrdinalIgnoreCase) ||
|
||||
x.description.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
clubs = ClubStore.FilterByCategory(clubs, ClubStore.CategoryFilter(Request.Query) ?? category);
|
||||
|
||||
var items = clubs
|
||||
.Skip(Math.Max(0, skip))
|
||||
.Take(Math.Max(1, limit))
|
||||
.Select(x => ClubStore.BuildClub(x, ViewerId))
|
||||
.ToArray();
|
||||
|
||||
return Ok(ClubStore.BuildSearchResponse(items, Math.Max(0, skip), Math.Max(1, limit)));
|
||||
}
|
||||
|
||||
[HttpGet("cancreate"), Authorize]
|
||||
[HttpGet("can-create"), Authorize]
|
||||
[HttpGet("create/eligibility"), Authorize]
|
||||
[HttpGet("creation/eligibility"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/cancreate"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/can-create"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/create/eligibility"), Authorize]
|
||||
[HttpGet("~/api/clubs/v1/creation/eligibility"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/cancreate"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/can-create"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/create/eligibility"), Authorize]
|
||||
[HttpGet("~/api/clubs/v2/creation/eligibility"), Authorize]
|
||||
public IActionResult CanCreateClub()
|
||||
{
|
||||
return Ok(ClubStore.BuildEligibility());
|
||||
}
|
||||
|
||||
[HttpPut("{clubId:long}/members/invite"), Authorize]
|
||||
[HttpPut("{clubId:long}/members/invitemembers"), Authorize]
|
||||
[HttpPost("{clubId:long}/members/invitemembers"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/members/invite"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/members/invitemembers"), Authorize]
|
||||
[HttpPost("~/club/{clubId:long}/members/invitemembers"), Authorize]
|
||||
public async Task<IActionResult> InviteMember(long clubId)
|
||||
{
|
||||
var inviterId = ViewerId;
|
||||
if (inviterId <= 0) return Unauthorized();
|
||||
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
|
||||
var inviterRole = ClubStore.GetMembershipType(club, inviterId);
|
||||
if (inviterRole < ClubMembershipTypes.Coowner)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
|
||||
var (rawAccountIds, rawMembershipType) = await ReadInviteMemberFieldsAsync();
|
||||
if (rawAccountIds.Count == 0)
|
||||
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
|
||||
|
||||
var membershipType = string.IsNullOrWhiteSpace(rawMembershipType)
|
||||
? ClubMembershipTypes.Member
|
||||
: ParseInviteMembershipType(rawMembershipType);
|
||||
if (membershipType is not (ClubMembershipTypes.Member or ClubMembershipTypes.Moderator or ClubMembershipTypes.Coowner))
|
||||
return BadRequest(new { error = "Invalid membershipType.", success = false, value = (object?)null });
|
||||
if (membershipType > inviterRole)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
|
||||
var updated = club;
|
||||
foreach (var rawAccountId in rawAccountIds)
|
||||
{
|
||||
if (!long.TryParse(rawAccountId, out var accountId) || accountId <= 0)
|
||||
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
|
||||
if (accountId == club.ownerPlayerId)
|
||||
return BadRequest(new { error = "You can’t change the club’s creator.", success = false, value = (object?)null });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (ClubStore.GetMembershipType(updated, accountId) >= ClubMembershipTypes.Member)
|
||||
{
|
||||
updated = await ClubStore.SetMemberTypeAsync(db, clubId, accountId, membershipType, inviterId) ?? updated;
|
||||
continue;
|
||||
}
|
||||
|
||||
updated = await ClubStore.InviteMemberAsync(db, clubId, accountId, membershipType, inviterId) ?? updated;
|
||||
|
||||
await Notify.SendMessage(accountId, new Message
|
||||
{
|
||||
FromPlayerId = inviterId,
|
||||
Type = membershipType switch
|
||||
{
|
||||
ClubMembershipTypes.Coowner => MessageType.ClubCoownerInvited,
|
||||
ClubMembershipTypes.Moderator => MessageType.ClubModeratorInvited,
|
||||
_ => MessageType.ClubMemberInvited
|
||||
},
|
||||
Data = clubId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
Details = membershipType.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["error"] = string.Empty,
|
||||
["success"] = true,
|
||||
["value"] = await ClubStore.BuildClubDetailsAsync(db, updated, inviterId)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPut("{clubId:long}/members/changetype"), Authorize]
|
||||
[HttpPost("{clubId:long}/members/changetype"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/members/changetype"), Authorize]
|
||||
[HttpPost("~/club/{clubId:long}/members/changetype"), Authorize]
|
||||
public async Task<IActionResult> ChangeMemberType(long clubId)
|
||||
{
|
||||
var actorId = ViewerId;
|
||||
if (actorId <= 0) return Unauthorized();
|
||||
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
var actorRole = ClubStore.GetMembershipType(club, actorId);
|
||||
if (actorRole < ClubMembershipTypes.Coowner)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
|
||||
var body = await ReadBodyObjectAsync();
|
||||
var accountId = ReadLong(body, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId")
|
||||
?? ReadLongFromRequest(Request, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId");
|
||||
if (accountId is null or <= 0 || accountId == club.ownerPlayerId)
|
||||
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
|
||||
if (ClubStore.GetMembershipType(club, accountId.Value) < ClubMembershipTypes.Member)
|
||||
return BadRequest(new { error = "That player is not a member of this club.", success = false, value = (object?)null });
|
||||
|
||||
var requested = ReadLong(body, "membershipType", "MembershipType", "type", "Type", "role", "Role")
|
||||
?? ReadLongFromRequest(Request, "membershipType", "MembershipType", "type", "Type", "role", "Role");
|
||||
if (requested is null or < 0)
|
||||
return BadRequest(new { error = "Invalid membershipType.", success = false, value = (object?)null });
|
||||
var membershipType = (int)requested.Value;
|
||||
if (membershipType is not (ClubMembershipTypes.Member or ClubMembershipTypes.Moderator or ClubMembershipTypes.Coowner))
|
||||
return BadRequest(new { error = "Invalid membershipType.", success = false, value = (object?)null });
|
||||
if (membershipType > actorRole)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
|
||||
var updated = await ClubStore.SetMemberTypeAsync(db, clubId, accountId.Value, membershipType, actorId);
|
||||
if (updated == null) return NotFound();
|
||||
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, actorId) });
|
||||
}
|
||||
|
||||
[HttpPost("{clubId:long}/members/acceptinvite"), Authorize]
|
||||
[HttpPut("{clubId:long}/members/acceptinvite"), Authorize]
|
||||
[HttpPost("{clubId:long}/members/accept"), Authorize]
|
||||
[HttpPut("{clubId:long}/members/accept"), Authorize]
|
||||
[HttpPost("~/club/{clubId:long}/members/acceptinvite"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/members/acceptinvite"), Authorize]
|
||||
[HttpPost("~/club/{clubId:long}/members/accept"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/members/accept"), Authorize]
|
||||
public async Task<IActionResult> AcceptClubInvitation(long clubId)
|
||||
{
|
||||
var inviteeId = ViewerId;
|
||||
if (inviteeId <= 0) return Unauthorized();
|
||||
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
var pending = ClubStore.GetMembers(club).FirstOrDefault(member => member.AccountId == inviteeId);
|
||||
if (pending?.MembershipType != ClubMembershipTypes.PendingInvited)
|
||||
return BadRequest(new { error = "No pending invitation exists.", success = false, value = (object?)null });
|
||||
|
||||
var updated = await ClubStore.AcceptInvitationAsync(db, clubId, inviteeId);
|
||||
if (updated == null) return NotFound();
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["error"] = string.Empty,
|
||||
["success"] = true,
|
||||
["value"] = await ClubStore.BuildClubDetailsAsync(db, updated, inviteeId)
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("{clubId:long}/members/declineinvite"), Authorize]
|
||||
[HttpPut("{clubId:long}/members/declineinvite"), Authorize]
|
||||
[HttpPost("{clubId:long}/members/decline"), Authorize]
|
||||
[HttpDelete("{clubId:long}/members/invite"), Authorize]
|
||||
[HttpPost("~/club/{clubId:long}/members/declineinvite"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/members/declineinvite"), Authorize]
|
||||
[HttpPost("~/club/{clubId:long}/members/decline"), Authorize]
|
||||
[HttpDelete("~/club/{clubId:long}/members/invite"), Authorize]
|
||||
public async Task<IActionResult> DeclineClubInvitation(long clubId)
|
||||
{
|
||||
var inviteeId = ViewerId;
|
||||
if (inviteeId <= 0) return Unauthorized();
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
var pending = ClubStore.GetMembers(club).FirstOrDefault(member => member.AccountId == inviteeId);
|
||||
if (pending?.MembershipType != ClubMembershipTypes.PendingInvited)
|
||||
return BadRequest(new { error = "No pending invitation exists.", success = false, value = (object?)null });
|
||||
var updated = await ClubStore.RemoveMemberAsync(db, clubId, inviteeId);
|
||||
return Ok(new Dictionary<string, object?>
|
||||
{
|
||||
["error"] = string.Empty,
|
||||
["success"] = true,
|
||||
["value"] = await ClubStore.BuildClubDetailsAsync(db, updated, inviteeId)
|
||||
});
|
||||
}
|
||||
|
||||
private static readonly string[] InviteAccountIdKeys =
|
||||
{
|
||||
"accountId", "AccountId", "accountIds", "AccountIds", "accountId[]", "AccountId[]",
|
||||
"playerId", "PlayerId", "playerIds", "PlayerIds", "invitedPlayerId", "InvitedPlayerId",
|
||||
"invitedPlayerIds", "InvitedPlayerIds", "invitedPlayerIds[]", "InvitedPlayerIds[]",
|
||||
"targetPlayerIds", "TargetPlayerIds"
|
||||
};
|
||||
|
||||
private static int ParseInviteMembershipType(string raw)
|
||||
{
|
||||
if (int.TryParse(raw, out var numeric)) return numeric;
|
||||
|
||||
return raw.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"member" or "regular" or "invited" => ClubMembershipTypes.Member,
|
||||
"moderator" or "mod" => ClubMembershipTypes.Moderator,
|
||||
"coowner" or "co-owner" or "co_owner" or "co owner" => ClubMembershipTypes.Coowner,
|
||||
_ => -1
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<(List<string> AccountIds, string? MembershipType)> ReadInviteMemberFieldsAsync()
|
||||
{
|
||||
var accountIds = new List<string>();
|
||||
string? membershipType = null;
|
||||
|
||||
void Collect(string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return;
|
||||
if (key.Equals("membershipType", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
membershipType ??= value;
|
||||
return;
|
||||
}
|
||||
if (!InviteAccountIdKeys.Contains(key, StringComparer.OrdinalIgnoreCase)) return;
|
||||
|
||||
foreach (var part in value.Split(new[] { ',', '|', ' ' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (long.TryParse(part, out _) && !accountIds.Contains(part)) accountIds.Add(part);
|
||||
}
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var field in form)
|
||||
foreach (var value in field.Value)
|
||||
Collect(field.Key, value);
|
||||
}
|
||||
else if (Request.ContentLength is > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
if (await JsonNode.ParseAsync(Request.Body) is JsonObject body)
|
||||
foreach (var pair in body)
|
||||
{
|
||||
if (pair.Value is JsonArray array)
|
||||
foreach (var item in array) Collect(pair.Key, item?.ToString());
|
||||
else
|
||||
Collect(pair.Key, pair.Value?.ToString());
|
||||
}
|
||||
|
||||
Request.Body.Position = 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { Request.Body.Position = 0; } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var key in InviteAccountIdKeys)
|
||||
foreach (var value in Request.Query[key]) Collect(key, value);
|
||||
membershipType ??= Request.Query["membershipType"].FirstOrDefault()
|
||||
?? Request.Query["MembershipType"].FirstOrDefault();
|
||||
|
||||
return (accountIds, membershipType);
|
||||
}
|
||||
|
||||
[HttpPost("create"), Authorize]
|
||||
[HttpPost("~/club/create"), Authorize]
|
||||
[HttpPost("createclub"), Authorize]
|
||||
[HttpPost("~/api/clubs/v1/create"), Authorize]
|
||||
[HttpPost("~/api/clubs/v1/createclub"), Authorize]
|
||||
[HttpPost("~/api/clubs/v2/create"), Authorize]
|
||||
[HttpPost("~/api/clubs/v2/createclub"), Authorize]
|
||||
public async Task<IActionResult> CreateClub([FromForm] string? name = null, [FromForm] string? description = null)
|
||||
{
|
||||
var userId = ViewerId;
|
||||
if (userId <= 0) return Unauthorized("Missing user ID.");
|
||||
|
||||
var fields = await ReadCreateClubFields(name, description);
|
||||
var club = await ClubStore.CreateAsync(
|
||||
db,
|
||||
userId,
|
||||
fields.Name,
|
||||
fields.Description,
|
||||
fields.Category,
|
||||
fields.Visibility ?? 1,
|
||||
fields.Joinability ?? 0,
|
||||
fields.AllowJuniors ?? true,
|
||||
fields.MinLevel ?? 0,
|
||||
fields.MainImageName);
|
||||
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, club, userId) });
|
||||
}
|
||||
|
||||
private async Task<(string? Name, string? Description, string? Category, int? Visibility, int? Joinability, bool? AllowJuniors, int? MinLevel, string? MainImageName)> ReadCreateClubFields(string? name, string? description)
|
||||
{
|
||||
string? category = null;
|
||||
string? visibility = null;
|
||||
string? joinability = null;
|
||||
string? allowJuniors = null;
|
||||
string? minLevel = null;
|
||||
string? mainImageName = null;
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
name ??= FirstFormValue(form, "name", "Name", "clubName", "ClubName");
|
||||
description ??= FirstFormValue(form, "description", "Description", "clubDescription", "ClubDescription");
|
||||
category = FirstFormValue(form, "category", "Category", "primaryTag", "PrimaryTag");
|
||||
visibility = FirstFormValue(form, "visibility", "Visibility");
|
||||
joinability = FirstFormValue(form, "joinability", "Joinability");
|
||||
allowJuniors = FirstFormValue(form, "allowJuniors", "AllowJuniors");
|
||||
minLevel = FirstFormValue(form, "minLevel", "MinLevel");
|
||||
mainImageName = FirstFormValue(form, "mainImageName", "MainImageName");
|
||||
}
|
||||
else if (Request.ContentLength is > 0)
|
||||
{
|
||||
using var reader = new StreamReader(Request.Body);
|
||||
var rawBody = await reader.ReadToEndAsync();
|
||||
if (!string.IsNullOrWhiteSpace(rawBody) && JsonNode.Parse(rawBody) is JsonObject body)
|
||||
{
|
||||
name ??= FirstJsonValue(body, "name", "Name", "clubName", "ClubName");
|
||||
description ??= FirstJsonValue(body, "description", "Description", "clubDescription", "ClubDescription");
|
||||
category = FirstJsonValue(body, "category", "Category", "primaryTag", "PrimaryTag");
|
||||
visibility = FirstJsonValue(body, "visibility", "Visibility");
|
||||
joinability = FirstJsonValue(body, "joinability", "Joinability");
|
||||
allowJuniors = FirstJsonValue(body, "allowJuniors", "AllowJuniors");
|
||||
minLevel = FirstJsonValue(body, "minLevel", "MinLevel");
|
||||
mainImageName = FirstJsonValue(body, "mainImageName", "MainImageName");
|
||||
}
|
||||
}
|
||||
|
||||
name ??= Request.Query["name"].FirstOrDefault() ?? Request.Query["Name"].FirstOrDefault() ??
|
||||
Request.Query["clubName"].FirstOrDefault() ?? Request.Query["ClubName"].FirstOrDefault();
|
||||
description ??= Request.Query["description"].FirstOrDefault() ?? Request.Query["Description"].FirstOrDefault() ??
|
||||
Request.Query["clubDescription"].FirstOrDefault() ?? Request.Query["ClubDescription"].FirstOrDefault();
|
||||
category ??= Request.Query["category"].FirstOrDefault() ?? Request.Query["Category"].FirstOrDefault() ??
|
||||
Request.Query["primaryTag"].FirstOrDefault() ?? Request.Query["PrimaryTag"].FirstOrDefault();
|
||||
|
||||
return (
|
||||
name,
|
||||
description,
|
||||
category,
|
||||
ParseVisibility(visibility),
|
||||
ParseJoinability(joinability),
|
||||
ParseFormBool(allowJuniors),
|
||||
int.TryParse(minLevel, out var parsedLevel) && parsedLevel >= 0 ? parsedLevel : null,
|
||||
mainImageName);
|
||||
}
|
||||
|
||||
private static int? ParseVisibility(string? raw) => raw?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"private" or "0" => 0,
|
||||
"public" or "1" => 1,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static int? ParseJoinability(string? raw) => raw?.Trim().ToLowerInvariant().Replace("_", "") switch
|
||||
{
|
||||
"open" or "0" => 0,
|
||||
"inviteonly" or "1" => 1,
|
||||
"asktojoin" or "requesttojoin" or "2" => 2,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static bool? ParseFormBool(string? raw) => raw?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"true" or "1" or "yes" => true,
|
||||
"false" or "0" or "no" => false,
|
||||
_ => null
|
||||
};
|
||||
|
||||
[HttpPut("{clubId:long}/minlevel"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/minlevel"), Authorize]
|
||||
public async Task<IActionResult> SetMinimumLevel(long clubId)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
|
||||
var rawMinLevel = await FirstFormValueAsync(Request, "minLevel", "MinLevel");
|
||||
if (!int.TryParse(rawMinLevel, out var minLevel) || minLevel < 0)
|
||||
{
|
||||
var body = await ReadBodyObjectAsync();
|
||||
var parsed = ReadLong(body, "minLevel", "MinLevel");
|
||||
if (parsed is null or < 0 or > int.MaxValue)
|
||||
return BadRequest(new { error = "Invalid minLevel.", success = false, value = (object?)null });
|
||||
minLevel = (int)parsed.Value;
|
||||
}
|
||||
|
||||
var updated = await ClubStore.UpdateAsync(db, clubId, new JsonObject { ["minLevel"] = minLevel });
|
||||
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
|
||||
}
|
||||
|
||||
[HttpPut("{clubId:long}/modifydetails"), Authorize]
|
||||
[HttpPut("{clubId:long}/modify"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/modifydetails"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/modify"), Authorize]
|
||||
public async Task<IActionResult> ModifyClub(long clubId)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
|
||||
var body = await ReadBodyObjectAsync();
|
||||
if (body == null) return BadRequest(new { error = "Invalid request.", success = false, value = (object?)null });
|
||||
var updated = await ClubStore.UpdateAsync(db, clubId, body);
|
||||
if (updated == null) return NotFound();
|
||||
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
|
||||
}
|
||||
|
||||
[HttpPut("{clubId:long}/clubhouse"), Authorize]
|
||||
[HttpDelete("{clubId:long}/clubhouse"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/clubhouse"), Authorize]
|
||||
[HttpDelete("~/club/{clubId:long}/clubhouse"), Authorize]
|
||||
public async Task<IActionResult> SetClubhouse(long clubId)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
|
||||
long? roomId = null;
|
||||
if (!HttpMethods.IsDelete(Request.Method))
|
||||
{
|
||||
var body = await ReadBodyObjectAsync();
|
||||
var raw = ReadLong(body, "roomId", "RoomId");
|
||||
if (body?.Count > 0 && raw == null)
|
||||
return BadRequest(new { error = "Invalid roomId.", success = false, value = (object?)null });
|
||||
roomId = raw;
|
||||
}
|
||||
|
||||
var updated = await ClubStore.SetClubhouseAsync(db, clubId, roomId);
|
||||
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
|
||||
}
|
||||
|
||||
[HttpPut("{clubId:long}/additionalimage/{index:int}"), Authorize]
|
||||
[HttpDelete("{clubId:long}/additionalimage/{index:int}"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/additionalimage/{index:int}"), Authorize]
|
||||
[HttpDelete("~/club/{clubId:long}/additionalimage/{index:int}"), Authorize]
|
||||
public async Task<IActionResult> SetAdditionalImage(long clubId, int index)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
if (index < 0 || index >= 3)
|
||||
return BadRequest(new { error = "Invalid additional image slot.", success = false, value = (object?)null });
|
||||
|
||||
var body = HttpMethods.IsDelete(Request.Method) ? null : await ReadBodyObjectAsync();
|
||||
var imageName = body == null ? "" : ReadString(body, "imageName", "ImageName") ?? "";
|
||||
var updated = await ClubStore.SetAdditionalImageAsync(db, clubId, index, imageName);
|
||||
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
|
||||
}
|
||||
|
||||
[HttpDelete("{clubId:long}"), Authorize]
|
||||
[HttpDelete("~/club/{clubId:long}"), Authorize]
|
||||
public async Task<IActionResult> DeleteClub(long clubId)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
if (club.ownerPlayerId != ViewerId)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
await ClubStore.DeleteAsync(db, clubId);
|
||||
return Ok(new { error = "", success = true, value = (object?)null });
|
||||
}
|
||||
|
||||
[HttpPut("{clubId:long}/members/requesttojoin"), Authorize]
|
||||
[HttpPut("~/club/{clubId:long}/members/requesttojoin"), Authorize]
|
||||
public async Task<IActionResult> RequestToJoinClub(long clubId)
|
||||
{
|
||||
var existingClub = await ClubStore.GetAsync(db, clubId);
|
||||
if (existingClub == null) return NotFound();
|
||||
ClubRecord? club;
|
||||
if (ClubStore.GetMembershipType(existingClub, ViewerId) == ClubMembershipTypes.PendingInvited)
|
||||
club = await ClubStore.AcceptInvitationAsync(db, clubId, ViewerId);
|
||||
else
|
||||
club = await ClubStore.RequestToJoinAsync(db, clubId, ViewerId);
|
||||
if (club == null) return NotFound();
|
||||
var role = ClubStore.GetMembershipType(club, ViewerId);
|
||||
if (role == ClubMembershipTypes.Banned)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "You are banned from this club.", success = false, value = (object?)null });
|
||||
if (club.joinability == 1 && role < ClubMembershipTypes.Member)
|
||||
return BadRequest(new { error = "This club is invite only.", success = false, value = (object?)null });
|
||||
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, club, ViewerId) });
|
||||
}
|
||||
|
||||
[HttpPost("{clubId:long}/members/leave"), Authorize]
|
||||
[HttpPost("~/club/{clubId:long}/members/leave"), Authorize]
|
||||
public async Task<IActionResult> LeaveClub(long clubId)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
if (club.ownerPlayerId == ViewerId)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "You created this club — delete it instead of leaving.", success = false, value = (object?)null });
|
||||
|
||||
club = await ClubStore.RemoveMemberAsync(db, clubId, ViewerId) ?? club;
|
||||
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, club, ViewerId) });
|
||||
}
|
||||
|
||||
[HttpPost("{clubId:long}/members/remove"), Authorize]
|
||||
[HttpDelete("{clubId:long}/members/remove"), Authorize]
|
||||
[HttpPost("~/club/{clubId:long}/members/remove"), Authorize]
|
||||
[HttpDelete("~/club/{clubId:long}/members/remove"), Authorize]
|
||||
public async Task<IActionResult> RemoveClubMember(long clubId)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
var actorRole = ClubStore.GetMembershipType(club, ViewerId);
|
||||
if (actorRole < ClubMembershipTypes.Moderator)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
|
||||
var body = await ReadBodyObjectAsync();
|
||||
var accountId = ReadLong(body, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId")
|
||||
?? ReadLongFromRequest(Request, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId");
|
||||
if (accountId is null or <= 0 || accountId == club.ownerPlayerId || accountId == ViewerId)
|
||||
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
|
||||
var targetRole = ClubStore.GetMembershipType(club, accountId.Value);
|
||||
if (targetRole >= ClubMembershipTypes.Moderator && actorRole < ClubMembershipTypes.Coowner)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
|
||||
var updated = await ClubStore.RemoveMemberAsync(db, clubId, accountId.Value);
|
||||
return updated == null
|
||||
? NotFound()
|
||||
: Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
|
||||
}
|
||||
|
||||
[HttpPost("{clubId:long}/members/ban"), Authorize]
|
||||
[HttpPost("~/club/{clubId:long}/members/ban"), Authorize]
|
||||
public async Task<IActionResult> BanMember(long clubId)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Moderator)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
var body = await ReadBodyObjectAsync();
|
||||
var accountId = ReadLong(body, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId");
|
||||
if (accountId is null or <= 0 || accountId == club.ownerPlayerId || accountId == ViewerId)
|
||||
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
|
||||
var targetRole = ClubStore.GetMembershipType(club, accountId.Value);
|
||||
if (targetRole >= ClubMembershipTypes.Moderator && ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Coowner)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
var updated = await ClubStore.SetMemberTypeAsync(db, clubId, accountId.Value, ClubMembershipTypes.Banned, ViewerId);
|
||||
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
|
||||
}
|
||||
|
||||
[HttpPost("{clubId:long}/members/unban"), Authorize]
|
||||
[HttpPost("~/club/{clubId:long}/members/unban"), Authorize]
|
||||
public async Task<IActionResult> UnbanMember(long clubId)
|
||||
{
|
||||
var club = await ClubStore.GetAsync(db, clubId);
|
||||
if (club == null) return NotFound();
|
||||
if (ClubStore.GetMembershipType(club, ViewerId) < ClubMembershipTypes.Moderator)
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new { error = "Insufficient permissions.", success = false, value = (object?)null });
|
||||
var body = await ReadBodyObjectAsync();
|
||||
var accountId = ReadLong(body, "accountId", "AccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId");
|
||||
if (accountId is null or <= 0)
|
||||
return BadRequest(new { error = "Invalid accountId.", success = false, value = (object?)null });
|
||||
var updated = await ClubStore.SetMemberTypeAsync(db, clubId, accountId.Value, ClubMembershipTypes.None, ViewerId);
|
||||
return Ok(new { error = "", success = true, value = await ClubStore.BuildClubDetailsAsync(db, updated, ViewerId) });
|
||||
}
|
||||
|
||||
private async Task<JsonObject?> ReadBodyObjectAsync()
|
||||
{
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
var jsonForm = new JsonObject();
|
||||
foreach (var pair in form)
|
||||
{
|
||||
if (pair.Value.Count > 1)
|
||||
{
|
||||
var values = new JsonArray();
|
||||
foreach (var value in pair.Value) values.Add(value);
|
||||
jsonForm[pair.Key] = values;
|
||||
}
|
||||
else
|
||||
{
|
||||
jsonForm[pair.Key] = pair.Value.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
return jsonForm;
|
||||
}
|
||||
if (Request.ContentLength is not > 0) return new JsonObject();
|
||||
try
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
var body = await JsonNode.ParseAsync(Request.Body);
|
||||
Request.Body.Position = 0;
|
||||
return body as JsonObject;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { Request.Body.Position = 0; } catch { }
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonObject? body, params string[] keys)
|
||||
{
|
||||
if (body == null) return null;
|
||||
foreach (var key in keys)
|
||||
foreach (var pair in body)
|
||||
if (pair.Key.Equals(key, StringComparison.OrdinalIgnoreCase))
|
||||
return pair.Value?.ToString();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static long? ReadLong(JsonObject? body, params string[] keys)
|
||||
{
|
||||
var value = ReadString(body, keys);
|
||||
return long.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private static long? ReadLongFromRequest(HttpRequest request, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
if (long.TryParse(request.Query[key].FirstOrDefault(), out var value)) return value;
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<string?> FirstFormValueAsync(HttpRequest request, params string[] keys)
|
||||
{
|
||||
if (!request.HasFormContentType) return null;
|
||||
var form = await request.ReadFormAsync();
|
||||
foreach (var key in keys)
|
||||
foreach (var pair in form)
|
||||
if (pair.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) return pair.Value.FirstOrDefault();
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FirstFormValue(IFormCollection form, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
var value = form[key].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? FirstJsonValue(JsonObject? json, params string[] keys)
|
||||
{
|
||||
if (json == null) return null;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (json.TryGetPropertyValue(key, out var value))
|
||||
{
|
||||
var text = value?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(text)) return text;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using RadicadeServer.DB;
|
||||
using RadicadeServer.Extensions;
|
||||
|
||||
namespace RadicadeServer.Clubs;
|
||||
|
||||
[Route("clubs")]
|
||||
[Route("api/clubs/v1")]
|
||||
[Route("api/clubs/v2")]
|
||||
public class ClubsSearchAPI : Controller
|
||||
{
|
||||
private readonly RecDBContext db;
|
||||
|
||||
public ClubsSearchAPI(RecDBContext db)
|
||||
{
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
private long ViewerId => User.GetUserId() ?? 0;
|
||||
|
||||
[HttpGet("search/mostactivenow"), Authorize]
|
||||
[HttpGet("mostactivenow"), Authorize]
|
||||
public async Task<IActionResult> GetMostActiveClubs([FromQuery] int skip = 0, [FromQuery] int take = 30)
|
||||
{
|
||||
var clubs = await ClubStore.LoadAsync(db);
|
||||
clubs = ClubStore.FilterByCategory(clubs, ClubStore.CategoryFilter(Request.Query));
|
||||
var items = clubs
|
||||
.Skip(Math.Max(0, skip))
|
||||
.Take(Math.Max(1, take))
|
||||
.Select(x => ClubStore.BuildClub(x, ViewerId))
|
||||
.ToArray();
|
||||
|
||||
return Ok(items);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user