@@ -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 : 520 px ; margin : 0 auto ; padding : 24 px ; \ ">" +
$"<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 : # 1 a1a2e ; margin - top : 0 ; \ ">Your Radicade ban appeal was approved</h2>" +
$"<p style=\" color : # 444 ; font - size : 15 px ; line - height : 1.6 ; \ ">Hi {HtmlEncode(name)},</p>" +
$"<p style=\" color : # 444 ; font - size : 15 px ; 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 : 15 px ; line - height : 1.6 ; \ ">Thanks for reaching out - enjoy the game!</p>" +
$"<p style=\" color : # 999 ; font - size : 12 px ; margin - top : 24 px ; \ ">- 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 : 520 px ; margin : 0 auto ; padding : 24 px ; \ ">" +
$"<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 : # 1 a1a2e ; margin - top : 0 ; \ ">Your Radicade ban appeal was rejected</h2>" +
$"<p style=\" color : # 444 ; font - size : 15 px ; line - height : 1.6 ; \ ">Hi {HtmlEncode(name)},</p>" +
$"<p style=\" color : # 444 ; font - size : 15 px ; 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 : 15 px ; 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 : 12 px ; margin - top : 24 px ; \ ">- 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 : 520 px ; margin : 0 auto ; padding : 24 px ; \ ">" +
$"<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 : # 1 a1a2e ; margin - top : 0 ; \ ">Your Radicade ban time was {action}</h2>" +
$"<p style=\" color : # 444 ; font - size : 15 px ; line - height : 1.6 ; \ ">Hi {HtmlEncode(name)},</p>" +
$"<p style=\" color : # 444 ; font - size : 15 px ; 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 : 12 px ; margin - top : 24 px ; \ ">- 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 : 520 px ; margin : 0 auto ; padding : 24 px ; \ ">" +
$"<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 : # 1 a1a2e ; margin - top : 0 ; \ ">{HtmlEncode(title)}</h2>" +
$"<p style=\" color : # 444 ; font - size : 15 px ; line - height : 1.6 ; \ ">{body}</p>" +
$"<p style=\" color : # 999 ; font - size : 12 px ; margin - top : 24 px ; \ ">- 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 ( ) ;
}