using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Http; using Mocha2023.Classes; using Mocha2023.Classes.DBs; using Mocha2023.Classes.DBs.DBClasses; using Mocha2023.Auth; using System.Text.Json; using System.IO; using JsonSerializer = System.Text.Json.JsonSerializer; namespace Mocha2023.Controllers { [ApiController] [Mocha2023.Classes.ApiProtection] [Route("/roomserver")] public class RoomController : ControllerBase { private static object MutationSuccess(object value) => new { Value = value, Success = true, ErrorId = (string?)null, Error = (string?)null, LocalizationContext = (object?)null }; private static object MutationFailure(string errorId, string error) => new { Value = (object?)null, Success = false, ErrorId = errorId, Error = error, LocalizationContext = (object?)null }; private sealed class RecentBlobUpload { public long AccountId { get; init; } public DateTime UploadedAtUtc { get; init; } public string? FileType { get; init; } public string Hash { get; init; } = string.Empty; public long Size { get; init; } } private static readonly System.Collections.Concurrent.ConcurrentDictionary< string, RecentBlobUpload> RecentBlobUploads = new(StringComparer.OrdinalIgnoreCase); private static readonly TimeSpan RecentBlobUploadLifetime = TimeSpan.FromHours(24); private static readonly object BlobCleanupLock = new(); private static DateTime LastBlobCleanupUtc = DateTime.MinValue; private sealed class RoomBlobUsage { public DateTime CreatedAtUtc { get; init; } public long Bytes { get; init; } } private static readonly System.Collections.Concurrent.ConcurrentDictionary< long, Queue> RoomBlobUsageByAccount = new(); private const int MaxRoomBlobUploadsPerHour = 240; private const long MaxRoomBlobBytesPerHour = 2L * 1024L * 1024L * 1024L; private const long MaxRoomBlobBytesPerDay = 10L * 1024L * 1024L * 1024L; private sealed class PendingRoomSaveAttribution { public long RoomId { get; init; } public long SubRoomId { get; init; } public long SubRoomDataSaveId { get; init; } public long RoomInstanceId { get; init; } public long TransportAccountId { get; init; } public long? ClaimedRequesterAccountId { get; init; } public DateTime SavedAtUtc { get; init; } } private static readonly TimeSpan SaveAttributionTelemetryLifetime = TimeSpan.FromSeconds(30); private static readonly System.Collections.Concurrent.ConcurrentDictionary< long, List> PendingSaveAttributions = new(); private static bool CanAcceptRoomBlobUpload( long accountId, long bytes, out int retryAfterSeconds) { retryAfterSeconds = 0; DateTime now = DateTime.UtcNow; Queue usage = RoomBlobUsageByAccount.GetOrAdd( accountId, _ => new Queue()); lock (usage) { DateTime dayCutoff = now.AddHours(-24); while (usage.Count > 0 && usage.Peek().CreatedAtUtc < dayCutoff) usage.Dequeue(); DateTime hourCutoff = now.AddHours(-1); var hourly = usage.Where(item => item.CreatedAtUtc >= hourCutoff).ToList(); long hourlyBytes = hourly.Sum(item => item.Bytes); long dailyBytes = usage.Sum(item => item.Bytes); bool hourlyBlocked = hourly.Count >= MaxRoomBlobUploadsPerHour || hourlyBytes + bytes > MaxRoomBlobBytesPerHour; bool dailyBlocked = dailyBytes + bytes > MaxRoomBlobBytesPerDay; if (hourlyBlocked || dailyBlocked) { DateTime hourlyUnlock = hourlyBlocked ? (hourly.FirstOrDefault()?.CreatedAtUtc ?? now).AddHours(1) : now; DateTime dailyUnlock = dailyBlocked ? (usage.FirstOrDefault()?.CreatedAtUtc ?? now).AddHours(24) : now; DateTime unlockAt = hourlyUnlock > dailyUnlock ? hourlyUnlock : dailyUnlock; retryAfterSeconds = Math.Max( 60, (int)Math.Ceiling((unlockAt - now).TotalSeconds)); return false; } return true; } } private static void RecordRoomBlobUpload(long accountId, long bytes) { Queue usage = RoomBlobUsageByAccount.GetOrAdd( accountId, _ => new Queue()); lock (usage) { usage.Enqueue(new RoomBlobUsage { CreatedAtUtc = DateTime.UtcNow, Bytes = Math.Max(0, bytes) }); } } private static async Task ComputeFileSha256Base64Async( string path, CancellationToken cancellationToken) { using var hash = System.Security.Cryptography.IncrementalHash.CreateHash( System.Security.Cryptography.HashAlgorithmName.SHA256); await using var stream = new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.Read, 81_920, FileOptions.Asynchronous | FileOptions.SequentialScan); byte[] buffer = new byte[81_920]; while (true) { int bytesRead = await stream.ReadAsync( buffer.AsMemory(0, buffer.Length), cancellationToken); if (bytesRead == 0) break; hash.AppendData(buffer, 0, bytesRead); } return Convert.ToBase64String(hash.GetHashAndReset()); } private static void RememberBlobUploader( string blobName, long accountId, string? fileType, string hash, long size) { var receipt = new RecentBlobUpload { AccountId = accountId, UploadedAtUtc = DateTime.UtcNow, FileType = fileType, Hash = hash, Size = size }; RecentBlobUploads[blobName] = receipt; try { string metadataPath = GetBlobReceiptPath(blobName); string temporaryPath = metadataPath + ".tmp"; string json = JsonSerializer.Serialize(receipt); System.IO.File.WriteAllText(temporaryPath, json); System.IO.File.Move(temporaryPath, metadataPath, overwrite: true); } catch (Exception ex) { Console.WriteLine($"[/upload] Could not persist uploader receipt: {ex.Message}"); } if (RecentBlobUploads.Count > 4096) { DateTime cutoff = DateTime.UtcNow - RecentBlobUploadLifetime; foreach (var entry in RecentBlobUploads) { if (entry.Value.UploadedAtUtc < cutoff) RecentBlobUploads.TryRemove(entry.Key, out _); } } TryCleanupOrphanedRoomBlobs(); } private static bool TryGetRecentBlobUpload( string? blobName, out RecentBlobUpload upload) { upload = null!; if (string.IsNullOrWhiteSpace(blobName)) return false; string safeName = Path.GetFileName(blobName); if (!string.Equals(safeName, blobName, StringComparison.Ordinal)) return false; if (RecentBlobUploads.TryGetValue(safeName, out var cached)) { if (DateTime.UtcNow - cached.UploadedAtUtc <= RecentBlobUploadLifetime) { upload = cached; return cached.AccountId > 0; } RecentBlobUploads.TryRemove(safeName, out _); } try { string metadataPath = GetBlobReceiptPath(safeName); if (!System.IO.File.Exists(metadataPath)) return false; var persisted = JsonSerializer.Deserialize( System.IO.File.ReadAllText(metadataPath), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (persisted == null || persisted.AccountId <= 0 || DateTime.UtcNow - persisted.UploadedAtUtc > RecentBlobUploadLifetime) { return false; } RecentBlobUploads[safeName] = persisted; upload = persisted; return true; } catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) { Console.WriteLine($"[ROOM SAVE] Could not read uploader receipt for {safeName}: {ex.Message}"); return false; } } private static string GetBlobReceiptPath(string blobName) { string directory = Path.Combine( Program.dataDir, "DBs", "RoomUploadReceipts"); Directory.CreateDirectory(directory); return Path.Combine(directory, Path.GetFileName(blobName) + ".json"); } private static void TryCleanupOrphanedRoomBlobs() { lock (BlobCleanupLock) { DateTime now = DateTime.UtcNow; if (now - LastBlobCleanupUtc < TimeSpan.FromMinutes(30)) return; LastBlobCleanupUtc = now; try { var referenced = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var room in RoomDB.Rooms.FindAll()) { if (!string.IsNullOrWhiteSpace(room.DataBlob)) referenced.Add(Path.GetFileName(room.DataBlob)); foreach (var subRoom in room.SubRooms ?? new List()) { if (!string.IsNullOrWhiteSpace(subRoom.DataBlob)) referenced.Add(Path.GetFileName(subRoom.DataBlob)); } } foreach (var save in RoomDB.SubRoomDataSaves.FindAll()) { if (!string.IsNullOrWhiteSpace(save.DataBlob)) referenced.Add(Path.GetFileName(save.DataBlob)); if (!string.IsNullOrWhiteSpace(save.RoomDataBlob)) referenced.Add(Path.GetFileName(save.RoomDataBlob)); } string receiptDirectory = Path.Combine( Program.dataDir, "DBs", "RoomUploadReceipts"); string blobDirectory = Path.Combine( Program.dataDir, "CDN", "room"); if (!Directory.Exists(receiptDirectory)) return; DateTime orphanCutoff = now - RecentBlobUploadLifetime; foreach (string receiptPath in Directory.EnumerateFiles( receiptDirectory, "*.json", SearchOption.TopDirectoryOnly)) { string blobName = Path.GetFileNameWithoutExtension(receiptPath); bool isReferenced = referenced.Contains(blobName); DateTime uploadedAt = System.IO.File.GetLastWriteTimeUtc(receiptPath); try { var receipt = JsonSerializer.Deserialize( System.IO.File.ReadAllText(receiptPath)); if (receipt?.UploadedAtUtc != default) uploadedAt = receipt.UploadedAtUtc; } catch { } if (uploadedAt > orphanCutoff) continue; if (!isReferenced) { string blobPath = Path.Combine(blobDirectory, blobName); if (System.IO.File.Exists(blobPath)) System.IO.File.Delete(blobPath); } System.IO.File.Delete(receiptPath); RecentBlobUploads.TryRemove(blobName, out _); } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) { Console.WriteLine($"[ROOM BLOB CLEANUP] {ex.Message}"); } } } private static string? FirstBlobName(params string?[] candidates) { foreach (string? candidate in candidates) { if (string.IsNullOrWhiteSpace(candidate)) continue; string trimmed = candidate.Trim(); string safeName = Path.GetFileName(trimmed); if (string.Equals(trimmed, safeName, StringComparison.Ordinal) && safeName.Length <= 128 && safeName.All(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_')) { return safeName; } } return null; } private static readonly string[] SaveRequesterInputKeys = { "SavedByAccountId", "savedByAccountId", "SavedByPlayerId", "savedByPlayerId", "SaveRequesterAccountId", "saveRequesterAccountId", "SaveRequesterPlayerId", "saveRequesterPlayerId", "SaveRequestedByAccountId", "saveRequestedByAccountId", "SaveRequestedByPlayerId", "saveRequestedByPlayerId", "RequesterAccountId", "requesterAccountId", "RequesterPlayerId", "requesterPlayerId", "RequestingAccountId", "requestingAccountId", "RequestingPlayerId", "requestingPlayerId", "RequestedByAccountId", "requestedByAccountId", "RequestedByPlayerId", "requestedByPlayerId" }; private static readonly string[] SaveRequesterHeaderKeys = { "X-Room-Save-Requester-Account-Id", "X-Room-Save-Requester-Player-Id", "X-Save-Requester-Account-Id", "X-Save-Requester-Player-Id" }; private static long? ReadPositiveFormLong( IFormCollection form, params string[] keys) { foreach (string key in keys) { if (long.TryParse( form[key].FirstOrDefault(), out long value) && value > 0) { return value; } } return null; } private HashSet GetClaimedSaveRequesterIds( SubRoomDataRequest payload) { var claimedIds = new HashSet(); static void AddClaim(HashSet values, long? candidate) { if (candidate is > 0) values.Add(candidate.Value); } AddClaim(claimedIds, payload.SavedByAccountId); AddClaim(claimedIds, payload.SavedByPlayerId); AddClaim(claimedIds, payload.SaveRequesterAccountId); AddClaim(claimedIds, payload.SaveRequesterPlayerId); AddClaim(claimedIds, payload.SaveRequestedByAccountId); AddClaim(claimedIds, payload.SaveRequestedByPlayerId); AddClaim(claimedIds, payload.RequesterAccountId); AddClaim(claimedIds, payload.RequesterPlayerId); AddClaim(claimedIds, payload.RequestingAccountId); AddClaim(claimedIds, payload.RequestingPlayerId); AddClaim(claimedIds, payload.RequestedByAccountId); AddClaim(claimedIds, payload.RequestedByPlayerId); foreach (string key in SaveRequesterInputKeys) { if (long.TryParse( Request.Query[key].FirstOrDefault(), out long value) && value > 0) { claimedIds.Add(value); } } foreach (string key in SaveRequesterInputKeys.Concat( SaveRequesterHeaderKeys)) { if (long.TryParse( Request.Headers[key].FirstOrDefault(), out long value) && value > 0) { claimedIds.Add(value); } } return claimedIds; } private static bool CanAccountSaveRoom( RoomDBClasses.Room room, long accountId, out bool isDeveloper) { var account = PlayerDB.Players.FindById(accountId); isDeveloper = account?.PlayerRoles?.Contains( PlayerDBClasses.PlayerRoles.Developer) == true; return isDeveloper || room.CreatorAccountId == accountId || room.Roles?.Any(role => role.AccountId == accountId && (role.Role == RoomDBClasses.Role.Creator || role.Role == RoomDBClasses.Role.CoOwner || role.Role == RoomDBClasses.Role.TemporaryCoOwner)) == true; } private static bool AreActiveInSameRoomInstance( long roomId, long subRoomId, long transportAccountId, long requesterAccountId) { if (transportAccountId == requesterAccountId) return true; PlayerDBClasses.Heartbeat transportHeartbeat = PlayerDB.GetPlayerHeartbeat(transportAccountId); PlayerDBClasses.Heartbeat requesterHeartbeat = PlayerDB.GetPlayerHeartbeat(requesterAccountId); long transportInstanceId = transportHeartbeat.roomInstance?.roomInstanceId ?? 0; long requesterInstanceId = requesterHeartbeat.roomInstance?.roomInstanceId ?? 0; return transportHeartbeat.isOnline && requesterHeartbeat.isOnline && transportHeartbeat.roomInstance?.roomId == roomId && requesterHeartbeat.roomInstance?.roomId == roomId && transportHeartbeat.roomInstance?.subRoomId == subRoomId && requesterHeartbeat.roomInstance?.subRoomId == subRoomId && transportInstanceId > 0 && transportInstanceId == requesterInstanceId && Sessions.IsConfirmedParticipant( transportAccountId, transportInstanceId) && Sessions.IsConfirmedParticipant( requesterAccountId, requesterInstanceId); } private static void RememberPendingSaveAttribution( long roomId, long subRoomId, long subRoomDataSaveId, long transportAccountId, long? claimedRequesterAccountId) { PlayerDBClasses.Heartbeat transportHeartbeat = PlayerDB.GetPlayerHeartbeat(transportAccountId); long roomInstanceId = transportHeartbeat.roomInstance?.roomInstanceId ?? 0; if (!transportHeartbeat.isOnline || transportHeartbeat.roomInstance?.roomId != roomId || transportHeartbeat.roomInstance?.subRoomId != subRoomId || roomInstanceId <= 0 || !Sessions.IsConfirmedParticipant( transportAccountId, roomInstanceId)) { return; } var pending = PendingSaveAttributions.GetOrAdd( roomInstanceId, _ => new List()); DateTime now = DateTime.UtcNow; lock (pending) { pending.RemoveAll(value => now - value.SavedAtUtc > SaveAttributionTelemetryLifetime); while (pending.Count >= 16) pending.RemoveAt(0); pending.Add(new PendingRoomSaveAttribution { RoomId = roomId, SubRoomId = subRoomId, SubRoomDataSaveId = subRoomDataSaveId, RoomInstanceId = roomInstanceId, TransportAccountId = transportAccountId, ClaimedRequesterAccountId = claimedRequesterAccountId, SavedAtUtc = now }); } } public static bool TryApplySaveRequesterTelemetry( long requesterAccountId) { if (requesterAccountId <= 0) return false; PlayerDBClasses.Heartbeat requesterHeartbeat = PlayerDB.GetPlayerHeartbeat(requesterAccountId); long roomId = requesterHeartbeat.roomInstance?.roomId ?? 0; long subRoomId = requesterHeartbeat.roomInstance?.subRoomId ?? 0; long roomInstanceId = requesterHeartbeat.roomInstance?.roomInstanceId ?? 0; if (!requesterHeartbeat.isOnline || roomId <= 0 || subRoomId <= 0 || roomInstanceId <= 0 || !Sessions.IsConfirmedParticipant( requesterAccountId, roomInstanceId) || !PendingSaveAttributions.TryGetValue( roomInstanceId, out List? pending)) { return false; } var room = RoomDB.GetRoom(roomId); if (room == null || !CanAccountSaveRoom( room, requesterAccountId, out _)) { return false; } PendingRoomSaveAttribution? candidate; DateTime now = DateTime.UtcNow; lock (pending) { pending.RemoveAll(value => now - value.SavedAtUtc > SaveAttributionTelemetryLifetime); candidate = pending.FirstOrDefault(value => value.RoomId == roomId && value.SubRoomId == subRoomId && value.RoomInstanceId == roomInstanceId && value.TransportAccountId != requesterAccountId && (!value.ClaimedRequesterAccountId.HasValue || value.ClaimedRequesterAccountId.Value == requesterAccountId || value.ClaimedRequesterAccountId.Value == value.TransportAccountId)); if (candidate == null) return false; pending.Remove(candidate); } var save = RoomDB.SubRoomDataSaves.FindById( candidate.SubRoomDataSaveId); var subRoom = room.SubRooms?.FirstOrDefault(value => value.SubRoomId == candidate.SubRoomId); if (save == null || subRoom == null) return false; long previousRequesterAccountId = save.SavedByAccountId; save.SavedByAccountId = requesterAccountId; if (!RoomDB.SubRoomDataSaves.Update(save)) return false; if (subRoom.SubRoomDataSaveId == save.SubRoomDataSaveId) { subRoom.SavedByAccountId = requesterAccountId; if (subRoom.SubRoomDataSave?.SubRoomDataSaveId == save.SubRoomDataSaveId) { subRoom.SubRoomDataSave.SavedByAccountId = requesterAccountId; } RoomDB.Rooms.Update(room); } Console.WriteLine( $"[ROOM SAVE REQUESTER CONFIRM] room={roomId} " + $"subroom={subRoomId} instance={roomInstanceId} " + $"transportAuthority={candidate.TransportAccountId} " + $"requester={requesterAccountId} " + $"previousRequester={previousRequesterAccountId} " + $"saveId={candidate.SubRoomDataSaveId} " + "source=authenticated-ugc-room-save-stats"); return true; } [HttpPost("/upload")] [HttpPut("/upload")] [RequestSizeLimit((50 * 1024 * 1024) + (512 * 1024))] [RequestFormLimits(MultipartBodyLengthLimit = (50 * 1024 * 1024) + (512 * 1024))] public async Task UploadBlob() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); if (!Request.HasFormContentType) return BadRequest(new { success = false, error = "Expected multipart form data." }); var form = await Request.ReadFormAsync(HttpContext.RequestAborted); var uploadedFile = form.Files.FirstOrDefault(file => string.Equals(file.Name, "File", StringComparison.OrdinalIgnoreCase)) ?? form.Files.FirstOrDefault(); if (uploadedFile == null || uploadedFile.Length <= 0) return BadRequest(new { success = false, error = "No room blob was uploaded." }); if (uploadedFile.Length > 50L * 1024L * 1024L) return BadRequest(new { success = false, error = "Room blobs must be 50 MB or smaller." }); if (!CanAcceptRoomBlobUpload(id.Value, uploadedFile.Length, out int retryAfterSeconds)) { Response.Headers["Retry-After"] = retryAfterSeconds.ToString(); return StatusCode(StatusCodes.Status429TooManyRequests, new { success = false, error = "room_blob_upload_quota_exceeded", retryAfterSeconds }); } string? fileType = form["FileType"].FirstOrDefault(); bool isAssetBundle = fileType?.Contains("assetbundle", StringComparison.OrdinalIgnoreCase) == true || fileType?.Contains("bakedasset", StringComparison.OrdinalIgnoreCase) == true; string blobsDir = Path.Combine( Program.dataDir, "CDN", isAssetBundle ? "assetbundles" : "room"); Directory.CreateDirectory(blobsDir); string blobName = Guid.NewGuid().ToString("N"); string finalPath = Path.Combine(blobsDir, blobName); string temporaryPath = finalPath + ".uploading"; byte[] hashBytes; long bytesWritten = 0; try { using var hash = System.Security.Cryptography.IncrementalHash.CreateHash( System.Security.Cryptography.HashAlgorithmName.SHA256); await using (var input = uploadedFile.OpenReadStream()) await using (var output = new FileStream( temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan)) { byte[] buffer = new byte[81920]; while (true) { int read = await input.ReadAsync( buffer.AsMemory(0, buffer.Length), HttpContext.RequestAborted); if (read == 0) break; await output.WriteAsync( buffer.AsMemory(0, read), HttpContext.RequestAborted); hash.AppendData(buffer, 0, read); bytesWritten += read; } await output.FlushAsync(HttpContext.RequestAborted); } hashBytes = hash.GetHashAndReset(); if (bytesWritten != uploadedFile.Length) { throw new InvalidDataException( $"Uploaded room blob length mismatch. Expected {uploadedFile.Length}, got {bytesWritten}."); } System.IO.File.Move(temporaryPath, finalPath, overwrite: false); } catch { if (System.IO.File.Exists(temporaryPath)) System.IO.File.Delete(temporaryPath); throw; } string sha256Hex = Convert.ToHexString(hashBytes).ToLowerInvariant(); string sha256Base64 = Convert.ToBase64String(hashBytes); if (fileType?.Contains("invent", StringComparison.OrdinalIgnoreCase) == true) { string inventionDirectory = Path.Combine( Program.dataDir, "CDN", "invention"); Directory.CreateDirectory(inventionDirectory); string inventionPath = Path.Combine(inventionDirectory, blobName); if (!System.IO.File.Exists(inventionPath)) System.IO.File.Copy(finalPath, inventionPath, overwrite: false); } string ownershipProof = Convert.ToBase64String( System.Security.Cryptography.SHA256.HashData( System.Text.Encoding.UTF8.GetBytes( $"{blobName}:{id.Value}:{sha256Base64}"))); RecordRoomBlobUpload(id.Value, bytesWritten); RememberBlobUploader( blobName, id.Value, fileType, sha256Base64, bytesWritten); Console.WriteLine( $"[/upload] player={id.Value} fileType={fileType ?? "unknown"} " + $"blobName={blobName} bytes={bytesWritten} " + $"hashBase64={sha256Base64} hashHex={sha256Hex}"); return Ok(new { success = true, filename = blobName, blobName, Hash = sha256Base64, OwnershipProof = ownershipProof }); } [HttpGet("rooms")] public async Task GetRoomBy([FromQuery] string? name) { if (!string.IsNullOrEmpty(name)) { var room = RoomDB.GetRoomByName(name); long? accountId = AuthStuff.GetPlayerId(Request); return CanViewRoomDirectly(room, accountId) ? Ok(RoomDB.PrepareRoomForClient(room)) : NotFound(); } var rooms = RoomDB.PrepareRoomsForClient( RoomDB.Rooms.FindAll().Where(IsPubliclyDiscoverableRoom)); return Ok(new { Results = rooms, TotalResults = rooms.Count }); } [HttpGet("rooms/search")] [HttpGet("/api/rooms/v1/search")] [HttpGet("/api/rooms/v2/search")] public IActionResult SearchRooms( [FromQuery] string? query = null, [FromQuery(Name = "q")] string? shortQuery = null, [FromQuery(Name = "search")] string? search = null, [FromQuery(Name = "searchTerm")] string? searchTerm = null, [FromQuery(Name = "name")] string? name = null, [FromQuery] int skip = 0, [FromQuery] int take = 100) { skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 100); string rawQuery = new[] { query, shortQuery, search, searchTerm, name, Request.Query["term"].FirstOrDefault(), Request.Query["text"].FirstOrDefault() } .FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?.Trim() ?? string.Empty; static string NormalizeWord(string value) { string normalized = value.Trim(); while (normalized.StartsWith('^')) normalized = normalized[1..]; return normalized.Trim().ToLowerInvariant(); } string[] terms = rawQuery.Split( (char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); string[] tags = terms .Where(term => term.StartsWith('#') && term.Length > 1) .Select(term => NormalizeWord(term[1..])) .Where(term => term.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); string[] words = terms .Where(term => !term.StartsWith('#')) .Select(NormalizeWord) .Where(term => term.Length > 0) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); bool HasTag(RoomDBClasses.Room room, string tag) { if (tag == "base") return RoomDB.IsCanonicalBaseRoom(room); if (tag is "rro" or "recroomoriginal") { return room.IsRRO || room.Tags?.Any(value => string.Equals(value.Tag, "rro", StringComparison.OrdinalIgnoreCase) || string.Equals(value.Tag, "recroomoriginal", StringComparison.OrdinalIgnoreCase)) == true; } return room.Tags?.Any(value => string.Equals(value.Tag, tag, StringComparison.OrdinalIgnoreCase)) == true; } bool ContainsWord(RoomDBClasses.Room room, string word) { string roomName = NormalizeWord(room.Name ?? string.Empty); return roomName.Contains(word, StringComparison.OrdinalIgnoreCase) || (room.Description?.Contains(word, StringComparison.OrdinalIgnoreCase) ?? false) || (room.Tags?.Any(value => value.Tag?.Contains(word, StringComparison.OrdinalIgnoreCase) == true) ?? false); } int SearchScore(RoomDBClasses.Room room) { if (words.Length == 0) return 0; string roomName = NormalizeWord(room.Name ?? string.Empty); string normalizedQuery = NormalizeWord(rawQuery); int score = 0; if (roomName.Equals(normalizedQuery, StringComparison.OrdinalIgnoreCase)) score += 10_000; else if (roomName.StartsWith(normalizedQuery, StringComparison.OrdinalIgnoreCase)) score += 5_000; else if (roomName.Contains(normalizedQuery, StringComparison.OrdinalIgnoreCase)) score += 2_500; foreach (string word in words) { if (roomName.Equals(word, StringComparison.OrdinalIgnoreCase)) score += 1_000; else if (roomName.StartsWith(word, StringComparison.OrdinalIgnoreCase)) score += 500; else if (roomName.Contains(word, StringComparison.OrdinalIgnoreCase)) score += 250; else if (room.Description?.Contains(word, StringComparison.OrdinalIgnoreCase) == true) score += 50; else if (room.Tags?.Any(value => value.Tag?.Contains(word, StringComparison.OrdinalIgnoreCase) == true) == true) score += 25; } return score; } var discoverableRooms = RoomDB.Rooms.FindAll() .Where(IsPubliclyDiscoverableRoom) .Where(room => tags.All(tag => HasTag(room, tag))) .ToList(); var matches = discoverableRooms .Where(room => words.Length == 0 || words.Any(word => ContainsWord(room, word))) .OrderByDescending(SearchScore) .ThenByDescending(room => room.Stats?.VisitCount ?? 0) .ThenByDescending(room => room.Stats?.CheerCount ?? 0) .ThenBy(room => room.Name) .ThenBy(room => room.RoomId) .ToList(); if (tags.Contains("base", StringComparer.OrdinalIgnoreCase)) matches = matches.OrderBy(RoomDB.GetCanonicalBaseRoomOrder).ToList(); var page = matches.Skip(skip).Take(take).ToList(); RoomDB.PrepareRoomsForClient(page); Response.Headers["X-Total-Count"] = matches.Count.ToString(); Console.WriteLine( $"[ROOM SEARCH] query=\"{rawQuery}\" public={discoverableRooms.Count} " + $"matches={matches.Count} skip={skip} take={take}"); return Ok(new { Results = page, TotalResults = matches.Count }); } [HttpGet("rooms/base")] public IActionResult GetBaseRooms( [FromQuery] int skip = 0, [FromQuery] int take = 100) { var (results, total) = RoomDB.GetHotRooms("base", skip, take); return Ok(new { Results = results ?? new List(), TotalResults = total }); } [HttpGet("rooms/{roomId}")] public async Task GetRoomById(string roomId) { if (!long.TryParse(roomId, out long resolvedId)) return NotFound(); var room = RoomDB.GetRoom(resolvedId); long? accountId = AuthStuff.GetPlayerId(Request); return CanViewRoomDirectly(room, accountId) ? Ok(RoomDB.PrepareRoomForClient(room)) : NotFound(); } [HttpDelete("rooms/{roomId}")] public IActionResult DeleteRoom(long roomId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) { return Unauthorized(MutationFailure( "unauthorized", "Authentication is required to delete a room.")); } var room = RoomDB.GetRoom(roomId); if (room == null) return Ok(MutationSuccess(true)); if (room.IsDorm || room.IsRRO || RoomDB.IsCanonicalBaseRoom(room)) { return BadRequest(MutationFailure( "protected_room", "This server-managed room cannot be deleted.")); } if (room.CreatorAccountId != accountId.Value) { return StatusCode( StatusCodes.Status403Forbidden, MutationFailure( "forbidden", "Only the room creator can delete this room.")); } int deletedSaveCount = RoomDB.SubRoomDataSaves.DeleteMany(save => save.RoomId == roomId); int deletedBanCount = RoomDB.RoomBans.DeleteMany(ban => ban.RoomId == roomId); if (!RoomDB.DeleteRoom(roomId)) { return StatusCode( StatusCodes.Status500InternalServerError, MutationFailure( "room_delete_failed", "The room could not be deleted.")); } Console.WriteLine( $"[ROOM DELETE] room={roomId} by={accountId.Value} " + $"deletedSaves={deletedSaveCount} deletedBans={deletedBanCount} " + $"format=api-result success=true"); return Ok(MutationSuccess(true)); } [HttpPut("rooms/{roomid}/description")] public IActionResult UpdateDescription(long roomId, [FromForm] string description) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(); bool canEdit = room.CreatorAccountId == accountId.Value || room.Roles?.Any(role => role.AccountId == accountId.Value && role.Role is RoomDBClasses.Role.Creator or RoomDBClasses.Role.CoOwner) == true; if (!canEdit) return StatusCode(403); description = description?.Trim() ?? string.Empty; if (description.Length > 1_000 || description.Any(ch => ch == '\0')) return BadRequest(new { error = "Room descriptions cannot exceed 1,000 characters." }); room.Description = description; RoomDB.Rooms.Update(room); return Ok(new { success = true }); } [HttpPut("rooms/{roomId}/image")] [RequestSizeLimit(64 * 1024)] public async Task UpdateRoomImage(long roomId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) { return Unauthorized(MutationFailure( "unauthorized", "Authentication is required to change a room image.")); } var room = RoomDB.GetRoom(roomId); if (room == null) { return NotFound(MutationFailure( "room_not_found", "Room was not found.")); } if (!CanEditRoom(room, accountId.Value)) { return StatusCode( StatusCodes.Status403Forbidden, MutationFailure( "forbidden", "You do not have permission to change this room image.")); } string? imageName = Request.Query["imageName"].FirstOrDefault() ?? Request.Query["ImageName"].FirstOrDefault(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(HttpContext.RequestAborted); imageName ??= form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault() ?? form["image"].FirstOrDefault() ?? form["Image"].FirstOrDefault(); } else if (Request.ContentLength.GetValueOrDefault() > 0) { using var reader = new StreamReader( Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1_024, leaveOpen: true); string rawBody = await reader.ReadToEndAsync(HttpContext.RequestAborted); if (!string.IsNullOrWhiteSpace(rawBody)) { try { using var document = JsonDocument.Parse(rawBody); var root = document.RootElement; if (root.ValueKind == JsonValueKind.String) { imageName ??= root.GetString(); } else if (root.ValueKind == JsonValueKind.Object) { foreach (var property in root.EnumerateObject()) { if (property.Name.Equals("imageName", StringComparison.OrdinalIgnoreCase) || property.Name.Equals("image", StringComparison.OrdinalIgnoreCase)) { imageName ??= property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() : property.Value.ToString(); break; } } } } catch (JsonException) { imageName ??= rawBody.Trim().Trim('"'); } } } imageName = imageName?.Trim(); if (string.IsNullOrWhiteSpace(imageName)) { return BadRequest(MutationFailure( "invalid_image_name", "A valid imageName is required.")); } if (imageName.Length > 512 || imageName.IndexOf('\0') >= 0) { return BadRequest(MutationFailure( "invalid_image_name", "The room image name is invalid.")); } room.ImageName = imageName; room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) { return StatusCode( StatusCodes.Status500InternalServerError, MutationFailure( "room_image_update_failed", "The room image could not be saved.")); } var preparedRoom = RoomDB.PrepareRoomForClient(room); Console.WriteLine( $"[ROOM IMAGE] room={roomId} imageName={imageName} " + $"by={accountId.Value} format=api-result-room"); return Ok(MutationSuccess(preparedRoom)); } [HttpPut("rooms/{roomId}/accessibility")] public async Task UpdateRoomAccessibility(long roomId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(new { success = false, error = "Room was not found." }); if (!CanEditRoom(room, accountId.Value)) return StatusCode(403); var accessibility = await ReadRoomAccessibilityAsync(); if (accessibility == null) { return BadRequest(new { success = false, error = "Accessibility must be Private (0), Public (1), or Unlisted (2)." }); } room.Accessibility = accessibility.Value; room.UgcVersion = Math.Max(1, room.UgcVersion + 1); RoomDB.Rooms.Update(room); Console.WriteLine( $"[ROOM ACCESSIBILITY] room={roomId} accessibility={accessibility.Value} by={accountId.Value}"); return Ok(RoomDB.PrepareRoomForClient(room)); } [HttpPut("rooms/{roomId}/subrooms/{subRoomId}/accessibility")] public async Task UpdateSubRoomAccessibility(long roomId, long subRoomId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var room = RoomDB.GetRoom(roomId); var subRoom = room?.SubRooms?.FirstOrDefault(value => value.SubRoomId == subRoomId); if (room == null || subRoom == null) return NotFound(new { success = false, error = "Room or subroom was not found." }); if (!CanEditRoom(room, accountId.Value)) return StatusCode(403); var accessibility = await ReadRoomAccessibilityAsync(); if (accessibility == null) { return BadRequest(new { success = false, error = "Accessibility must be Private (0), Public (1), or Unlisted (2)." }); } subRoom.Accessibility = accessibility.Value; room.UgcVersion = Math.Max(1, room.UgcVersion + 1); RoomDB.Rooms.Update(room); Console.WriteLine( $"[SUBROOM ACCESSIBILITY] room={roomId} subroom={subRoomId} accessibility={accessibility.Value} by={accountId.Value}"); return Ok(MutationSuccess(RoomDB.PrepareRoomForClient(room))); } [HttpPut("rooms/{roomId}/subrooms/{subRoomId}/modify")] [HttpPost("rooms/{roomId}/subrooms/{subRoomId}/modify")] [RequestSizeLimit(64 * 1024)] public async Task ModifySubRoom(long roomId, long subRoomId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var room = RoomDB.GetRoom(roomId); var subRoom = room?.SubRooms?.FirstOrDefault(value => value.SubRoomId == subRoomId); if (room == null || subRoom == null) { return NotFound(new { success = false, error = "Room or subroom was not found." }); } if (!CanEditRoom(room, accountId.Value)) return StatusCode(StatusCodes.Status403Forbidden); string? name = Request.Query["name"].FirstOrDefault(); string? accessibilityValue = Request.Query["accessibility"].FirstOrDefault(); string? maxPlayersValue = Request.Query["maxPlayers"].FirstOrDefault(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(HttpContext.RequestAborted); name ??= form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(); accessibilityValue ??= form["accessibility"].FirstOrDefault() ?? form["Accessibility"].FirstOrDefault(); maxPlayersValue ??= form["maxPlayers"].FirstOrDefault() ?? form["MaxPlayers"].FirstOrDefault(); } else if (Request.ContentLength.GetValueOrDefault() > 0) { using var reader = new StreamReader( Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 4_096, leaveOpen: true); string rawBody = await reader.ReadToEndAsync( HttpContext.RequestAborted); if (!string.IsNullOrWhiteSpace(rawBody)) { try { using var document = JsonDocument.Parse(rawBody); JsonElement root = document.RootElement; if (root.ValueKind == JsonValueKind.Object) { if (name == null && TryGetProperty(root, "name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String) { name = nameElement.GetString(); } if (accessibilityValue == null && TryGetProperty( root, "accessibility", out var accessibilityElement)) { accessibilityValue = accessibilityElement.ToString(); } if (maxPlayersValue == null && TryGetProperty( root, "maxPlayers", out var maxPlayersElement)) { maxPlayersValue = maxPlayersElement.ToString(); } } } catch (JsonException) { foreach (string pair in rawBody.Split( '&', StringSplitOptions.RemoveEmptyEntries)) { string[] parts = pair.Split('=', 2); if (parts.Length != 2) continue; string key = Uri.UnescapeDataString(parts[0]); string value = Uri.UnescapeDataString( parts[1].Replace('+', ' ')); if (key.Equals("name", StringComparison.OrdinalIgnoreCase)) name ??= value; else if (key.Equals( "accessibility", StringComparison.OrdinalIgnoreCase)) accessibilityValue ??= value; else if (key.Equals( "maxPlayers", StringComparison.OrdinalIgnoreCase)) maxPlayersValue ??= value; } } } } bool changed = false; if (name != null) { name = name.Trim(); if (name.Length is < 1 or > 50 || name.Any(character => character == '\0')) { return BadRequest(new { success = false, error = "Subroom names must be 1-50 characters." }); } if (!string.Equals(subRoom.Name, name, StringComparison.Ordinal)) { subRoom.Name = name; changed = true; } } if (!string.IsNullOrWhiteSpace(accessibilityValue)) { RoomDBClasses.RoomAccessibility accessibility; bool validAccessibility; if (int.TryParse( accessibilityValue, out int numericAccessibility) && Enum.IsDefined( typeof(RoomDBClasses.RoomAccessibility), numericAccessibility)) { accessibility = (RoomDBClasses.RoomAccessibility)numericAccessibility; validAccessibility = true; } else { validAccessibility = Enum.TryParse( accessibilityValue, true, out accessibility) && Enum.IsDefined( typeof(RoomDBClasses.RoomAccessibility), accessibility); } if (!validAccessibility) { return BadRequest(new { success = false, error = "Accessibility must be Private (0), Public (1), or Unlisted (2)." }); } if (subRoom.Accessibility != accessibility) { subRoom.Accessibility = accessibility; changed = true; } } if (!string.IsNullOrWhiteSpace(maxPlayersValue)) { if (!int.TryParse(maxPlayersValue, out int maxPlayers) || maxPlayers is < 1 or > 40) { return BadRequest(new { success = false, error = "MaxPlayers must be between 1 and 40." }); } if (subRoom.MaxPlayers != maxPlayers) { subRoom.MaxPlayers = maxPlayers; changed = true; } } if (changed) { room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) { return StatusCode(StatusCodes.Status500InternalServerError, new { success = false, error = "The subroom changes could not be saved." }); } } Console.WriteLine( $"[SUBROOM MODIFY] room={roomId} subroom={subRoomId} " + $"name={subRoom.Name} accessibility={subRoom.Accessibility} " + $"maxPlayers={subRoom.MaxPlayers} changed={changed.ToString().ToLowerInvariant()} " + $"by={accountId.Value}"); return Ok(MutationSuccess(RoomDB.PrepareRoomForClient(room))); } [HttpGet("rooms/ownedby/me")] public IActionResult GetRoomsOwnedByMe( [FromQuery] int skip = 0, [FromQuery] int take = 100) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 200); var all = RoomDB.Rooms.Find(r => r.CreatorAccountId == (long)id).ToList(); var page = RoomDB.PrepareRoomsForClient(all.Skip(skip).Take(take)); return Ok(new { Results = page, TotalResults = all.Count }); } [HttpGet("rooms/ownedby/{ownerId}")] public IActionResult GetRoomsOwnedBy( long ownerId, [FromQuery] int skip = 0, [FromQuery] int take = 100) { long? callerId = AuthStuff.GetPlayerId(Request); skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 200); var all = RoomDB.Rooms.Find(r => r.CreatorAccountId == ownerId) .Where(room => !room.IsDorm && (callerId == ownerId || IsPubliclyDiscoverableRoom(room))) .ToList(); var page = RoomDB.PrepareRoomsForClient(all.Skip(skip).Take(take)); return Ok(new { Results = page, TotalResults = all.Count }); } [HttpPost("rooms/{roomId}/subrooms/{subRoomId}/data")] [HttpPut("rooms/{roomId}/subrooms/{subRoomId}/data")] [RequestSizeLimit(1024 * 1024)] public async Task SetSubRoomData(long roomId, long subRoomId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var room = RoomDB.GetRoom(roomId); var subRoom = room?.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId); if (room == null || subRoom == null) return NotFound(new { success = false, error = "Room or subroom was not found." }); SubRoomDataRequest? payload; if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); payload = new SubRoomDataRequest { Filename = form["filename"].FirstOrDefault(), SubRoomDataFilename = form["subRoomDataFilename"].FirstOrDefault(), ObjectDataFilename = form["objectDataFilename"].FirstOrDefault(), DataBlob = form["dataBlob"].FirstOrDefault(), ObjectDataBlob = form["objectDataBlob"].FirstOrDefault(), SubRoomDataBlob = form["subRoomDataBlob"].FirstOrDefault(), RoomDataFilename = form["roomDataFilename"].FirstOrDefault(), SuperRoomDataFilename = form["superRoomDataFilename"].FirstOrDefault(), RoomDataBlob = form["roomDataBlob"].FirstOrDefault(), SuperRoomDataBlob = form["superRoomDataBlob"].FirstOrDefault(), InventionUsage = form["inventionUsage"].FirstOrDefault(), Description = form["description"].FirstOrDefault(), PersistenceVersion = int.TryParse( form["persistenceVersion"].FirstOrDefault(), out int persistenceVersion) ? persistenceVersion : null, AutoPublish = bool.TryParse( form["autoPublish"].FirstOrDefault(), out bool autoPublish) ? autoPublish : null, SavedByAccountId = ReadPositiveFormLong( form, "SavedByAccountId", "savedByAccountId"), SavedByPlayerId = ReadPositiveFormLong( form, "SavedByPlayerId", "savedByPlayerId"), SaveRequesterAccountId = ReadPositiveFormLong( form, "SaveRequesterAccountId", "saveRequesterAccountId"), SaveRequesterPlayerId = ReadPositiveFormLong( form, "SaveRequesterPlayerId", "saveRequesterPlayerId"), SaveRequestedByAccountId = ReadPositiveFormLong( form, "SaveRequestedByAccountId", "saveRequestedByAccountId"), SaveRequestedByPlayerId = ReadPositiveFormLong( form, "SaveRequestedByPlayerId", "saveRequestedByPlayerId"), RequesterAccountId = ReadPositiveFormLong( form, "RequesterAccountId", "requesterAccountId"), RequesterPlayerId = ReadPositiveFormLong( form, "RequesterPlayerId", "requesterPlayerId"), RequestingAccountId = ReadPositiveFormLong( form, "RequestingAccountId", "requestingAccountId"), RequestingPlayerId = ReadPositiveFormLong( form, "RequestingPlayerId", "requestingPlayerId"), RequestedByAccountId = ReadPositiveFormLong( form, "RequestedByAccountId", "requestedByAccountId"), RequestedByPlayerId = ReadPositiveFormLong( form, "RequestedByPlayerId", "requestedByPlayerId") }; } else { if (Request.ContentLength is 0) return BadRequest(new { success = false, error = "Save payload was empty." }); try { payload = await JsonSerializer.DeserializeAsync( Request.Body, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }, HttpContext.RequestAborted); } catch (JsonException ex) { Console.WriteLine($"[ROOM SAVE] Invalid JSON for room {roomId}: {ex.Message}"); return BadRequest(new { success = false, error = "Invalid save payload." }); } } if (payload == null) return BadRequest(new { success = false, error = "Save payload was invalid." }); long transportAccountId = accountId.Value; string? roomBlob = FirstBlobName( payload.RoomDataFilename, payload.SuperRoomDataFilename, payload.RoomData?.Filename, payload.RoomDataBlob, payload.SuperRoomDataBlob); string? objectBlob = FirstBlobName( payload.Filename, payload.SubRoomDataFilename, payload.ObjectDataFilename, payload.SubRoomData?.Filename, payload.DataBlob, payload.SubRoomDataBlob, payload.ObjectDataBlob); bool hasObjectUploader = TryGetRecentBlobUpload( objectBlob, out var objectUpload); bool hasRoomUploader = TryGetRecentBlobUpload( roomBlob, out var roomUpload); long objectUploaderAccountId = hasObjectUploader ? objectUpload.AccountId : 0; long roomUploaderAccountId = hasRoomUploader ? roomUpload.AccountId : 0; if (hasObjectUploader && objectUploaderAccountId != transportAccountId) { Console.WriteLine( $"[ROOM SAVE SECURITY] rejected subroom blob owner mismatch " + $"room={roomId} subroom={subRoomId} transport={transportAccountId} " + $"uploader={objectUploaderAccountId} blob={objectBlob ?? "null"}"); return StatusCode(403, new { success = false, error = "The subroom data was uploaded by a different player.", roomId, subRoomId }); } if (hasRoomUploader && roomUploaderAccountId != transportAccountId) { Console.WriteLine( $"[ROOM SAVE SECURITY] rejected room blob owner mismatch " + $"room={roomId} subroom={subRoomId} transport={transportAccountId} " + $"uploader={roomUploaderAccountId} blob={roomBlob ?? "null"}"); return StatusCode(403, new { success = false, error = "The room data was uploaded by a different player.", roomId, subRoomId }); } HashSet claimedRequesterIds = GetClaimedSaveRequesterIds(payload); if (claimedRequesterIds.Count > 1) { string conflictingIds = string.Join( ",", claimedRequesterIds.OrderBy(value => value)); Console.WriteLine( $"[ROOM SAVE SECURITY] conflicting requester claims " + $"room={roomId} subroom={subRoomId} " + $"transport={transportAccountId} claims={conflictingIds}"); return BadRequest(new { success = false, error = "Conflicting Save Room requester account IDs were supplied.", errorCode = "conflicting_save_requester_ids", roomId, subRoomId }); } long? claimedRequesterAccountId = claimedRequesterIds.Count == 1 ? claimedRequesterIds.First() : null; long saveActorAccountId = claimedRequesterAccountId ?? transportAccountId; bool delegatedByRoomAuthority = saveActorAccountId != transportAccountId; string actorSource = delegatedByRoomAuthority ? "validated-save-requester-via-room-authority" : claimedRequesterAccountId.HasValue ? "requester-matches-authenticated-transport" : "authenticated-transport-fallback"; bool requesterCanSave = CanAccountSaveRoom( room, saveActorAccountId, out bool requesterIsDeveloper); if (!requesterCanSave) { Console.WriteLine( $"[ROOM SAVE SECURITY] requester lacks permission " + $"room={roomId} subroom={subRoomId} " + $"transport={transportAccountId} " + $"requester={saveActorAccountId} " + $"owner={room.CreatorAccountId} " + $"isDeveloper={requesterIsDeveloper}"); return StatusCode(403, new { success = false, error = "The player who requested Save Room does not have permission.", transportAccountId, saveRequesterAccountId = saveActorAccountId, creatorAccountId = room.CreatorAccountId, roomId, subRoomId }); } if (delegatedByRoomAuthority) { DateTime newestAllowedUpload = DateTime.UtcNow - TimeSpan.FromMinutes(5); bool hasFreshAuthorityUploads = hasObjectUploader && hasRoomUploader && objectUpload.UploadedAtUtc >= newestAllowedUpload && roomUpload.UploadedAtUtc >= newestAllowedUpload; if (!hasFreshAuthorityUploads) { Console.WriteLine( $"[ROOM SAVE SECURITY] delegated requester missing fresh uploads " + $"room={roomId} subroom={subRoomId} " + $"transport={transportAccountId} " + $"requester={saveActorAccountId}"); return StatusCode(403, new { success = false, error = "Room Authority must upload both save blobs immediately before a delegated save.", errorCode = "delegated_save_upload_proof_required", roomId, subRoomId }); } if (!AreActiveInSameRoomInstance( roomId, subRoomId, transportAccountId, saveActorAccountId)) { Console.WriteLine( $"[ROOM SAVE SECURITY] delegated requester instance mismatch " + $"room={roomId} subroom={subRoomId} " + $"transport={transportAccountId} " + $"requester={saveActorAccountId}"); return StatusCode(403, new { success = false, error = "Room Authority and the Save Room requester must be active in the same room instance.", errorCode = "save_requester_instance_mismatch", roomId, subRoomId }); } } Console.WriteLine( $"[ROOM SAVE REQUESTER] room={roomId} subroom={subRoomId} " + $"transportAuthority={transportAccountId} " + $"requester={saveActorAccountId} " + $"claimed={claimedRequesterAccountId?.ToString() ?? "none"} " + $"source={actorSource} objectBlob={objectBlob ?? "null"}"); static bool BlobExists(string? filename) { if (string.IsNullOrWhiteSpace(filename)) return false; string safeName = Path.GetFileName(filename); if (!string.Equals(safeName, filename, StringComparison.Ordinal)) return false; return System.IO.File.Exists( Path.Combine(Program.dataDir, "CDN", "room", safeName)); } if (string.IsNullOrWhiteSpace(roomBlob) || !BlobExists(roomBlob)) { return BadRequest(new { success = false, error = "Room data blob does not exist." }); } if (string.IsNullOrWhiteSpace(objectBlob) || !BlobExists(objectBlob)) { return BadRequest(new { success = false, error = "Subroom data blob does not exist." }); } if (string.Equals(roomBlob, objectBlob, StringComparison.OrdinalIgnoreCase)) { Console.WriteLine( $"[CV2 SNAPSHOT GUARD] rejected aliased save room={roomId} " + $"subroom={subRoomId} blob={roomBlob}"); return BadRequest(new { success = false, error = "Room data and subroom data must be separate uploaded blobs." }); } room.DataBlob = roomBlob; subRoom.DataBlob = objectBlob; if (payload.Description != null) { string description = payload.Description.Trim(); if (description.Length > 1_000 || description.Any(ch => ch == '\0')) return BadRequest(new { success = false, error = "Room description is too long." }); room.Description = description; } room.PersistenceVersion = payload.PersistenceVersion ?? room.PersistenceVersion; string objectBlobPath = Path.Combine( Program.dataDir, "CDN", "room", Path.GetFileName(objectBlob)); long objectBlobLength = new FileInfo(objectBlobPath).Length; string objectBlobHash = hasObjectUploader && objectUpload.Size == objectBlobLength && !string.IsNullOrWhiteSpace(objectUpload.Hash) ? objectUpload.Hash : await ComputeFileSha256Base64Async( objectBlobPath, HttpContext.RequestAborted); subRoom.SavedByAccountId = saveActorAccountId; var dataSave = RoomDB.GetOrCreateSubRoomDataSave( roomId, subRoomId, saveActorAccountId, objectBlob, room.PersistenceVersion, payload.AutoPublish == true, objectBlobHash, payload.Description ?? room.Description, roomBlob, out bool createdSave); if (!string.IsNullOrWhiteSpace(payload.UnityAssetId) && Guid.TryParse(payload.UnityAssetId, out _)) { dataSave.UnityAssetId = payload.UnityAssetId.Trim(); dataSave.RoomDataBlob = string.IsNullOrWhiteSpace(dataSave.RoomDataBlob) ? dataSave.DataBlob : dataSave.RoomDataBlob; RoomDB.SubRoomDataSaves.Update(dataSave); } if (payload.BakedUnityAssets is { Count: > 0 }) { var resolvedBakedAssets = new List(); foreach (var candidate in payload.BakedUnityAssets) { if (string.IsNullOrWhiteSpace(candidate.UnityAssetId) || !Guid.TryParse(candidate.UnityAssetId, out _)) { return BadRequest(new { success = false, error = "Each baked Unity asset needs a valid UnityAssetId.", errorCode = "invalid_baked_asset_id" }); } if (!TryGetRecentBlobUpload(candidate.Filename, out var bundleUpload) || bundleUpload.AccountId != transportAccountId) { Console.WriteLine( $"[ROOM SAVE SECURITY] rejected baked asset owner mismatch " + $"room={roomId} subroom={subRoomId} transport={transportAccountId} " + $"unityAssetId={candidate.UnityAssetId} filename={candidate.Filename ?? "null"}"); return StatusCode(403, new { success = false, error = "A baked asset bundle was not uploaded by this player.", errorCode = "baked_asset_owner_mismatch", unityAssetId = candidate.UnityAssetId }); } string bundlePath = Path.Combine( Program.dataDir, "CDN", "assetbundles", Path.GetFileName(candidate.Filename!)); if (!System.IO.File.Exists(bundlePath)) { return BadRequest(new { success = false, error = "A referenced baked asset bundle does not exist.", unityAssetId = candidate.UnityAssetId }); } resolvedBakedAssets.Add(new RoomDBClasses.BakedUnityAsset { UnityAssetId = candidate.UnityAssetId.Trim(), Target = candidate.Target, Version = candidate.Version > 0 ? candidate.Version : dataSave.SubRoomDataSaveId is > 0 and <= int.MaxValue ? (int)dataSave.SubRoomDataSaveId : 1, Filename = Path.GetFileName(candidate.Filename!), Hash = bundleUpload.Hash, UnityVersion = candidate.UnityVersion, IsAvailable = true }); } dataSave.BakedUnityAssets = resolvedBakedAssets; RoomDB.SubRoomDataSaves.Update(dataSave); Console.WriteLine( $"[ROOM SAVE] room={roomId} subroom={subRoomId} " + $"subRoomDataSaveId={dataSave.SubRoomDataSaveId} " + $"bakedUnityAssets={resolvedBakedAssets.Count} " + $"targets=[{string.Join(",", resolvedBakedAssets.Select(a => a.Target))}]"); } room.UgcVersion = createdSave ? Math.Max(1, room.UgcVersion + 1) : Math.Max(1, room.UgcVersion); subRoom.SubRoomDataSaveId = dataSave.SubRoomDataSaveId; subRoom.SubRoomDataSave = dataSave; if (!RoomDB.Rooms.Update(room)) { return StatusCode(500, new { error = "Failed to save the room database record." }); } RememberPendingSaveAttribution( roomId, subRoomId, dataSave.SubRoomDataSaveId, transportAccountId, claimedRequesterAccountId); Console.WriteLine( $"[ROOM SAVE] room={roomId} subroom={subRoomId} " + $"superRoomBlob={room.DataBlob ?? "null"} " + $"subRoomBlob={subRoom.DataBlob ?? "null"} " + $"subRoomDataSaveId={dataSave.SubRoomDataSaveId} " + $"transport={transportAccountId} actor={saveActorAccountId} " + $"actorSource={actorSource} created={createdSave} " + $"persistenceVersion={room.PersistenceVersion}" ); var saveResult = new { Room = RoomDB.PrepareRoomForClient(room), SubRoomDataSave = dataSave }; return Ok(MutationSuccess(saveResult)); } public class SubRoomDataRequest { public string? Filename { get; set; } public string? SubRoomDataFilename { get; set; } public string? ObjectDataFilename { get; set; } public string? DataBlob { get; set; } public string? ObjectDataBlob { get; set; } public string? SubRoomDataBlob { get; set; } public string? RoomDataFilename { get; set; } public string? SuperRoomDataFilename { get; set; } public string? RoomDataBlob { get; set; } public string? SuperRoomDataBlob { get; set; } public long? SavedByAccountId { get; set; } public long? SavedByPlayerId { get; set; } public long? SaveRequesterAccountId { get; set; } public long? SaveRequesterPlayerId { get; set; } public long? SaveRequestedByAccountId { get; set; } public long? SaveRequestedByPlayerId { get; set; } public long? RequesterAccountId { get; set; } public long? RequesterPlayerId { get; set; } public long? RequestingAccountId { get; set; } public long? RequestingPlayerId { get; set; } public long? RequestedByAccountId { get; set; } public long? RequestedByPlayerId { get; set; } public string? UnityAssetId { get; set; } public BlobRef? RoomData { get; set; } public BlobRef? SubRoomData { get; set; } public string? InventionUsage { get; set; } public int? PersistenceVersion { get; set; } public string? Description { get; set; } public bool? AutoPublish { get; set; } public List? BakedUnityAssets { get; set; } public class BlobRef { public string? Filename { get; set; } public string? Hash { get; set; } public string? OwnershipProof { get; set; } } public class BakedUnityAssetRef { public string? UnityAssetId { get; set; } public int Target { get; set; } public int Version { get; set; } public string? Filename { get; set; } public string? UnityVersion { get; set; } } } private static RoomDBClasses.BakedUnityAsset? SelectBakedUnityAsset( RoomDBClasses.SubRoomDataSave save, int target, int version) { var matches = (save.BakedUnityAssets ?? new List()) .Where(asset => asset.Target == target) .ToList(); if (matches.Count == 0) matches = (save.BakedUnityAssets ?? new List()).ToList(); if (matches.Count == 0) return null; if (version > 0) { var exact = matches.FirstOrDefault(asset => asset.Version == version); if (exact != null) return exact; } return matches .OrderByDescending(asset => asset.Version) .ThenBy(asset => asset.Target == target ? 0 : 1) .First(); } private static object ToBakedUnityAssetPayload(RoomDBClasses.BakedUnityAsset asset) { string baseUrl = ServerConfig.BaseURL.TrimEnd('/'); string safeName = Path.GetFileName(asset.Filename); string url = $"{baseUrl}/cdn/assetbundles/{Uri.EscapeDataString(safeName)}"; bool exists = System.IO.File.Exists(Path.Combine( Program.dataDir, "CDN", "assetbundles", safeName)); return new { asset.UnityAssetId, asset.Target, asset.Version, Filename = safeName, asset.Hash, asset.UnityVersion, IsAvailable = exists, Url = url, AssetBundleUrl = url, CdnPath = $"assetbundles/{safeName}" }; } private static object ToSubRoomSavePayload( RoomDBClasses.SubRoomDataSave save, int unityAssetTarget, int unityAssetVersion) { var selectedAsset = SelectBakedUnityAsset( save, unityAssetTarget, unityAssetVersion); object? selectedPayload = selectedAsset == null ? null : ToBakedUnityAssetPayload(selectedAsset); return new { save.SubRoomDataSaveId, save.RoomId, save.SubRoomId, save.SavedByAccountId, save.DataBlob, SubRoomDataFilename = save.DataBlob, save.DataBlobHash, SubRoomDataHash = save.DataBlobHash, PersistenceVersion = RoomDB.NormalizeLegacyPersistenceVersion( save.PersistenceVersion), save.SavedOnPlatform, save.SavedOnDeviceClass, save.Description, save.UnityAssetId, ReferencedUnityAssetIds = save.ReferencedUnityAssetIds ?? new List(), OMVersion = RoomDB.NormalizeLegacyOMVersion(save.OMVersion), UgcSubVersion = RoomDB.NormalizeLegacyUgcSubVersion( save.UgcSubVersion), save.ModerationState, Tags = save.Tags ?? new List(), save.CreatedAt, DataSavedAt = save.CreatedAt, UnityAsset = selectedPayload, BakedUnityAsset = selectedPayload, BakedUnityAssets = (save.BakedUnityAssets ?? new List()) .Select(ToBakedUnityAssetPayload) .ToList() }; } [HttpGet("bakedunityassets/{unityAssetId}")] [HttpGet("bakedunityassets/{unityAssetId}/{target:int}")] [HttpGet("unityassets/{unityAssetId}")] [HttpGet("unityassets/{unityAssetId}/{target:int}")] [HttpGet("/api/rooms/v1/bakedunityassets/{unityAssetId}")] [HttpGet("/api/rooms/v2/bakedunityassets/{unityAssetId}")] [HttpGet("/api/rooms/v1/unityassets/{unityAssetId}")] [HttpGet("/api/rooms/v2/unityassets/{unityAssetId}")] public IActionResult GetBakedUnityAsset( string unityAssetId, int? target = null, [FromQuery] int? unityAssetTarget = null, [FromQuery] int? version = null, [FromQuery] int? unityAssetVersion = null) { if (!Guid.TryParse(unityAssetId, out _)) return BadRequest(new { error = "UnityAssetId must be a GUID." }); int requestedTarget = target ?? unityAssetTarget ?? 0; int requestedVersion = unityAssetVersion ?? version ?? 0; var saves = RoomDB.SubRoomDataSaves .FindAll() .Where(save => string.Equals( save.UnityAssetId, unityAssetId, StringComparison.OrdinalIgnoreCase) || (save.ReferencedUnityAssetIds ?? new List()) .Any(reference => string.Equals( reference, unityAssetId, StringComparison.OrdinalIgnoreCase))) .OrderByDescending(save => save.CreatedAt) .ToList(); foreach (var save in saves) { var asset = SelectBakedUnityAsset(save, requestedTarget, requestedVersion); if (asset != null && string.Equals( asset.UnityAssetId, unityAssetId, StringComparison.OrdinalIgnoreCase)) { return Ok(ToBakedUnityAssetPayload(asset)); } } return NotFound(new { error = "Baked Unity asset not found.", unityAssetId, target = requestedTarget, version = requestedVersion }); } [HttpGet("rooms/{roomId}/subrooms/{subRoomId}/saves/{subRoomDataSaveId}/bakedunityasset")] [HttpGet("rooms/{roomId}/subrooms/{subRoomId}/saves/{subRoomDataSaveId}/unityasset")] public IActionResult GetSubRoomSaveBakedUnityAsset( long roomId, long subRoomId, long subRoomDataSaveId, [FromQuery] int unityAssetTarget = 0, [FromQuery] int unityAssetVersion = 0) { var save = RoomDB.SubRoomDataSaves.FindById(subRoomDataSaveId); if (save == null || save.RoomId != roomId || save.SubRoomId != subRoomId) return NotFound(); var asset = SelectBakedUnityAsset(save, unityAssetTarget, unityAssetVersion); return asset == null ? NotFound(new { error = "This save has no baked Unity asset for that target." }) : Ok(ToBakedUnityAssetPayload(asset)); } [HttpGet("rooms/{roomId}/subrooms/{subRoomId}/saves")] public IActionResult GetSubRoomSaves( long roomId, long subRoomId, [FromQuery] long? subRoomDataSaveId = null, [FromQuery] int unityAssetTarget = 0, [FromQuery] int unityAssetVersion = 1, [FromQuery] int skip = 0, [FromQuery] int take = 20) { var room = RoomDB.GetRoom(roomId); var subRoom = room?.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId); long? accountId = AuthStuff.GetPlayerId(Request); if (room == null || subRoom == null || !CanViewSubRoomDirectly(room, subRoom, accountId)) return NotFound(); RoomDB.EnsureSubRoomDataSave(room, subRoom); if (subRoomDataSaveId.HasValue) { return GetSubRoomDataSave( roomId, subRoomId, subRoomDataSaveId.Value, unityAssetTarget, unityAssetVersion); } skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 100); var all = RoomDB.SubRoomDataSaves.Find(x => x.RoomId == roomId && x.SubRoomId == subRoomId) .OrderByDescending(x => x.CreatedAt) .ThenByDescending(x => x.SubRoomDataSaveId) .Skip(skip) .Take(take) .Select(save => ToSubRoomSavePayload( save, unityAssetTarget, unityAssetVersion)) .ToList(); return Ok(all); } [HttpGet("rooms/{roomId}/subrooms/{subRoomId}/saves/{subRoomDataSaveId}")] [HttpGet("rooms/{roomId}/subrooms/{subRoomId}/datasaves/{subRoomDataSaveId}")] public IActionResult GetSubRoomDataSave( long roomId, long subRoomId, long subRoomDataSaveId, [FromQuery] int unityAssetTarget = 0, [FromQuery] int unityAssetVersion = 0) { var room = RoomDB.GetRoom(roomId); var subRoom = room?.SubRooms?.FirstOrDefault(s => s.SubRoomId == subRoomId); long? accountId = AuthStuff.GetPlayerId(Request); if (room == null || subRoom == null || !CanViewSubRoomDirectly(room, subRoom, accountId)) return NotFound(); RoomDB.EnsureSubRoomDataSave(room, subRoom); var save = RoomDB.SubRoomDataSaves.FindById(subRoomDataSaveId); bool exists = save != null && save.RoomId == roomId && save.SubRoomId == subRoomId; Console.WriteLine($"[SUBROOM SAVE GET] saveId={subRoomDataSaveId} blob={save?.DataBlob ?? "null"} exists={exists.ToString().ToLowerInvariant()}"); return exists ? Ok(ToSubRoomSavePayload(save!, unityAssetTarget, unityAssetVersion)) : NotFound(); } [HttpPost("subroomdatasaves/{subRoomDataSaveId}/publish")] [HttpPut("subroomdatasaves/{subRoomDataSaveId}/publish")] [HttpPost("rooms/subroomdatasaves/{subRoomDataSaveId}/publish")] [HttpPut("rooms/subroomdatasaves/{subRoomDataSaveId}/publish")] public IActionResult PublishSubRoomDataSave(long subRoomDataSaveId) { return PublishSubRoomDataSaveCore(null, null, subRoomDataSaveId); } [HttpPost("rooms/{roomId}/subrooms/{subRoomId}/publish_save")] [HttpPut("rooms/{roomId}/subrooms/{subRoomId}/publish_save")] [HttpPost("rooms/{roomId}/subrooms/{subRoomId}/saves/{subRoomDataSaveId}/publish")] [HttpPut("rooms/{roomId}/subrooms/{subRoomId}/saves/{subRoomDataSaveId}/publish")] [HttpPost("rooms/{roomId}/subrooms/{subRoomId}/datasaves/{subRoomDataSaveId}/publish")] [HttpPut("rooms/{roomId}/subrooms/{subRoomId}/datasaves/{subRoomDataSaveId}/publish")] public IActionResult PublishSubRoomDataSaveForSubRoom( long roomId, long subRoomId, long subRoomDataSaveId) { return PublishSubRoomDataSaveCore(roomId, subRoomId, subRoomDataSaveId); } [HttpPost("rooms/{roomId}/subrooms/{subRoomId}/publish")] [HttpPut("rooms/{roomId}/subrooms/{subRoomId}/publish")] public IActionResult PublishSubRoomDataSaveFromQuery( long roomId, long subRoomId, [FromQuery] long subRoomDataSaveId) { return PublishSubRoomDataSaveCore(roomId, subRoomId, subRoomDataSaveId); } private IActionResult PublishSubRoomDataSaveCore( long? expectedRoomId, long? expectedSubRoomId, long subRoomDataSaveId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(MutationFailure("unauthorized", "Authentication is required to publish a subroom save.")); var save = RoomDB.SubRoomDataSaves.FindById(subRoomDataSaveId); var room = save == null ? null : RoomDB.GetRoom(save.RoomId); var subRoom = room?.SubRooms?.FirstOrDefault(value => value.SubRoomId == save!.SubRoomId); if (save == null || room == null || subRoom == null || (expectedRoomId.HasValue && save.RoomId != expectedRoomId.Value) || (expectedSubRoomId.HasValue && save.SubRoomId != expectedSubRoomId.Value)) { return NotFound(MutationFailure( "subroom_save_not_found", "The subroom save was not found.")); } if (!CanEditRoom(room, accountId.Value)) { return StatusCode( StatusCodes.Status403Forbidden, MutationFailure("forbidden", "You cannot publish saves for this room.")); } save.IsPublished = true; if (!RoomDB.SubRoomDataSaves.Update(save)) { return StatusCode( StatusCodes.Status500InternalServerError, MutationFailure("subroom_publish_failed", "The subroom save could not be published.")); } subRoom.SubRoomDataSaveId = save.SubRoomDataSaveId; subRoom.SubRoomDataSave = save; subRoom.DataBlob = save.DataBlob; room.DataBlob = save.RoomDataBlob; room.PersistenceVersion = save.PersistenceVersion ?? room.PersistenceVersion; room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) { return StatusCode( StatusCodes.Status500InternalServerError, MutationFailure("room_publish_failed", "The published room pointers could not be saved.")); } var preparedRoom = RoomDB.PrepareRoomForClient(room); Console.WriteLine( $"[SUBROOM PUBLISH] room={room.RoomId} subroom={save.SubRoomId} " + $"save={save.SubRoomDataSaveId} by={accountId.Value} format=api-result-room"); return Ok(MutationSuccess(preparedRoom)); } [HttpPost("rooms/{roomId}/subrooms")] public async Task AddSubRoom(long roomId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(new { success = false, error = "Room was not found." }); if (!CanEditRoom(room, accountId.Value)) return StatusCode(403); string? name = Request.Query["name"].FirstOrDefault(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(HttpContext.RequestAborted); name ??= form["name"].FirstOrDefault(); } else if (Request.ContentLength.GetValueOrDefault() > 0) { using var reader = new StreamReader(Request.Body); string rawBody = await reader.ReadToEndAsync(); try { using var document = JsonDocument.Parse(rawBody); if (TryGetProperty(document.RootElement, "name", out var nameElement) && nameElement.ValueKind == JsonValueKind.String) { name = nameElement.GetString(); } } catch (JsonException) { name ??= rawBody.Trim().Trim('"'); } } name = name?.Trim(); if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { success = false, error = "A subroom name is required." }); if (name.Length > 50 || name.Any(character => character == '\0')) return BadRequest(new { success = false, error = "Subroom names must be 1-50 characters." }); var updatedRoom = RoomDB.AddSubRoom(roomId, accountId.Value, name); if (updatedRoom == null) { return StatusCode( StatusCodes.Status500InternalServerError, MutationFailure("subroom_create_failed", "The subroom could not be created.")); } var preparedRoom = RoomDB.PrepareRoomForClient(updatedRoom); Console.WriteLine( $"[SUBROOM CREATE] room={roomId} name={name} by={accountId.Value} " + $"subrooms={preparedRoom.SubRooms?.Count ?? 0} format=api-result-room"); return Ok(MutationSuccess(preparedRoom)); } [HttpPost("rooms/{roomId}/subrooms/{subRoomId}/clone")] public IActionResult CloneSubRoom(long roomId, long subRoomId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room?.SubRooms == null || room.SubRooms.All(subRoom => subRoom.SubRoomId != subRoomId)) return NotFound(new { success = false, error = "Room or subroom was not found." }); if (!CanEditRoom(room, accountId.Value)) return StatusCode(403); var updatedRoom = RoomDB.CloneSubRoom(roomId, subRoomId, accountId.Value); if (updatedRoom == null) { return StatusCode( StatusCodes.Status500InternalServerError, MutationFailure("subroom_clone_failed", "The subroom could not be cloned.")); } var preparedRoom = RoomDB.PrepareRoomForClient(updatedRoom); Console.WriteLine( $"[SUBROOM CLONE] room={roomId} sourceSubroom={subRoomId} " + $"by={accountId.Value} format=api-result-room"); return Ok(MutationSuccess(preparedRoom)); } [HttpDelete("rooms/{roomId}/subrooms/{subRoomId}")] public IActionResult DeleteSubRoom(long roomId, long subRoomId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(MutationFailure("unauthorized", "Authentication is required to delete a subroom.")); var room = RoomDB.GetRoom(roomId); var subRoom = room?.SubRooms?.FirstOrDefault(value => value.SubRoomId == subRoomId); if (room == null || subRoom == null) { return NotFound(MutationFailure( "subroom_not_found", "Room or subroom was not found.")); } if (!CanEditRoom(room, accountId.Value)) { return StatusCode( StatusCodes.Status403Forbidden, MutationFailure("forbidden", "You cannot delete this subroom.")); } if (room.SubRooms == null || room.SubRooms.Count <= 1) { return BadRequest(MutationFailure( "last_subroom", "A room must contain at least one subroom.")); } room.SubRooms.RemoveAll(value => value.SubRoomId == subRoomId); int deletedSaveCount = RoomDB.SubRoomDataSaves.DeleteMany(save => save.RoomId == roomId && save.SubRoomId == subRoomId); room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) { return StatusCode( StatusCodes.Status500InternalServerError, MutationFailure("subroom_delete_failed", "The subroom could not be deleted.")); } var preparedRoom = RoomDB.PrepareRoomForClient(room); Console.WriteLine( $"[SUBROOM DELETE] room={roomId} subroom={subRoomId} " + $"deletedSaves={deletedSaveCount} by={accountId.Value} format=api-result-room"); return Ok(MutationSuccess(preparedRoom)); } [HttpPut("rooms/{roomId}/subrooms/{subRoomId}/permissions")] [HttpPost("rooms/{roomId}/subrooms/{subRoomId}/permissions")] [HttpPatch("rooms/{roomId}/subrooms/{subRoomId}/permissions")] [HttpPut("rooms/{roomId}/subrooms/{subRoomId}/permissions/modify")] [HttpPost("rooms/{roomId}/subrooms/{subRoomId}/permissions/modify")] [HttpPatch("rooms/{roomId}/subrooms/{subRoomId}/permissions/modify")] public async Task UpdateSubRoomPermissions(long roomId, long subRoomId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var room = RoomDB.GetRoom(roomId); var subRoom = room?.SubRooms?.FirstOrDefault(value => value.SubRoomId == subRoomId); if (room == null || subRoom == null) return NotFound(new { success = false, error = "Room or subroom was not found." }); if (!CanEditRoom(room, accountId.Value)) return StatusCode(403); bool requestHadPayload = Request.QueryString.HasValue || Request.HasFormContentType || (Request.ContentLength ?? 0) > 0 || Request.Headers.TransferEncoding.Count > 0; List permissions = await ReadPermissionPayloadFromRequestAsync(); if (permissions.Count == 0) { if (requestHadPayload) { return BadRequest(MutationFailure( "invalid_permissions", "The permission payload could not be parsed.")); } permissions = subRoom.Permissions?.Count > 0 ? subRoom.Permissions.Select(SanitizePermission).ToList() : CreateDefaultSubRoomPermissions(); } subRoom.Permissions = permissions .Where(permission => !string.IsNullOrWhiteSpace(permission.Permission)) .Select(SanitizePermission) .GroupBy(permission => new { permission.Permission, permission.Role, permission.Type }) .Select(group => group.Last()) .Take(250) .ToList(); room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) return StatusCode(500, new { success = false, error = "Permissions could not be saved." }); Console.WriteLine( $"[SUBROOM PERMISSIONS] room={roomId} subroom={subRoomId} count={subRoom.Permissions.Count} by={accountId.Value}"); return Ok(MutationSuccess(RoomDB.PrepareRoomForClient(room))); } [HttpGet("rooms/visitedby/me")] public IActionResult GetVisitedByMe( [FromQuery] int skip = 0, [FromQuery] int take = 100) { var player = AuthStuff.GetCurrentPlayer(Request); if (player?.Player == null) return Unauthorized(); skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 100); var visits = player.Player.PlayerExtra?.RoomVisits? .OrderByDescending(v => v.VisitedAt) .ToList() ?? new List(); var roomMap = RoomDB.Rooms.Find(r => visits.Select(v => v.RoomId).Contains(r.RoomId)) .ToDictionary(r => r.RoomId); var orderedRooms = visits .Where(v => roomMap.ContainsKey(v.RoomId)) .Select(v => roomMap[v.RoomId]) .Where(room => RoomDB.CanPlayerAccessRoom(room, player.PlayerId)) .ToList(); RoomDB.PrepareRoomsForClient(orderedRooms); return Ok(new { Results = orderedRooms.Skip(skip).Take(take).ToList(), TotalResults = orderedRooms.Count }); } [HttpGet("rooms/favoritedby/me")] public IActionResult GetFavoritedByMe([FromQuery] int skip = 0, [FromQuery] int take = 100) { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); var favIds = player.Player?.FavoritedRooms ?? new List(); var allFavoriteRooms = RoomDB.Rooms.Find(r => favIds.Contains(r.RoomId)) .Where(room => RoomDB.CanPlayerAccessRoom(room, player.PlayerId)) .ToList(); var rooms = allFavoriteRooms .Skip(skip) .Take(take) .ToList(); RoomDB.PrepareRoomsForClient(rooms); return Ok(new { Results = rooms, TotalResults = allFavoriteRooms.Count }); } [HttpPut("rooms/{roomId}/interactionby/me/cheer")] public IActionResult CheerRoomInteraction(long roomId) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); if (!PlayerDB.ToggleCheer((long)id, roomId, out bool nowCheered)) return NotFound(); return Ok(new { cheered = nowCheered }); } [HttpGet("/roomserver/rooms/createdby/me")] public IActionResult GetMyCreatedRooms( [FromQuery] int skip = 0, [FromQuery] int take = 100) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 100); var allRooms = RoomDB.Rooms.Find(r => r.CreatorAccountId == id.Value && !r.IsDorm) .OrderByDescending(r => r.CreatedAt) .ThenByDescending(r => r.RoomId) .ToList(); RoomDB.PrepareRoomsForClient(allRooms); return Ok(allRooms.Skip(skip).Take(take).ToList()); } [HttpGet("/roomserver/rooms/{roomId}/playerdata/me")] public IActionResult GetMyRoomPlayerData(long roomId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (!CanUseRoomPlayerData(room, accountId.Value)) return NotFound(); CreatorFeatureDB.RoomPlayerDataRecord? record = CreatorFeatureDB.GetRoomPlayerData(roomId, accountId.Value); return Ok(BuildRoomPlayerDataResponse( roomId, accountId.Value, record?.DataJson ?? "{}", record?.Version ?? 0, record?.UpdatedAtUtc)); } [HttpPut("/roomserver/rooms/{roomId}/playerdata/me")] [HttpPost("/roomserver/rooms/{roomId}/playerdata/me")] [HttpPatch("/roomserver/rooms/{roomId}/playerdata/me")] [RequestSizeLimit(256 * 1024)] public async Task SaveMyRoomPlayerData(long roomId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (!CanUseRoomPlayerData(room, accountId.Value)) return NotFound(); string rawPlayerData = string.Empty; if (Request.HasFormContentType) { var form = await Request.ReadFormAsync( HttpContext.RequestAborted); rawPlayerData = form["data"].FirstOrDefault() ?? form["Data"].FirstOrDefault() ?? form["playerData"].FirstOrDefault() ?? form["PlayerData"].FirstOrDefault() ?? form["value"].FirstOrDefault() ?? form["Value"].FirstOrDefault() ?? string.Empty; } else { using var reader = new StreamReader(Request.Body); rawPlayerData = await reader.ReadToEndAsync(); } if (string.IsNullOrWhiteSpace(rawPlayerData)) { CreatorFeatureDB.RoomPlayerDataRecord? currentRecord = CreatorFeatureDB.GetRoomPlayerData(roomId, accountId.Value); return Ok(BuildRoomPlayerDataResponse( roomId, accountId.Value, currentRecord?.DataJson ?? "{}", currentRecord?.Version ?? 0, currentRecord?.UpdatedAtUtc)); } JsonElement root; try { using JsonDocument document = JsonDocument.Parse(rawPlayerData); root = document.RootElement.Clone(); } catch (JsonException) { Console.WriteLine( $"[ROOM PLAYER DATA] invalid JSON room={roomId} " + $"account={accountId.Value} contentType={Request.ContentType ?? "unknown"} " + $"bytes={rawPlayerData.Length}"); return BadRequest(MutationFailure( "invalid_player_data", "Room player data must be valid JSON.")); } JsonElement data = UnwrapRoomPlayerData(root); string dataJson = data.GetRawText(); try { CreatorFeatureDB.RoomPlayerDataRecord record = CreatorFeatureDB.SaveRoomPlayerData( roomId, accountId.Value, dataJson, accountId.Value); MirrorPlayerDataToCloudVariables( roomId, accountId.Value, data); Console.WriteLine( $"[ROOM PLAYER DATA SET] room={roomId} account={accountId.Value} " + $"version={record.Version}"); return Ok(BuildRoomPlayerDataResponse( roomId, accountId.Value, record.DataJson, record.Version, record.UpdatedAtUtc)); } catch (ArgumentException exception) { return BadRequest(MutationFailure( "invalid_player_data", exception.Message)); } } [HttpDelete("/roomserver/rooms/{roomId}/playerdata/me")] public IActionResult DeleteMyRoomPlayerData(long roomId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (!CanUseRoomPlayerData(room, accountId.Value)) return NotFound(); CreatorFeatureDB.DeleteRoomPlayerData(roomId, accountId.Value); return Ok(BuildRoomPlayerDataResponse( roomId, accountId.Value, "{}", 0, null)); } [HttpGet("rooms/{roomId}/similar")] public IActionResult GetSimilarRooms(long roomId, [FromQuery] int skip = 0, [FromQuery] int take = 100) { var sourceRoom = RoomDB.GetRoom(roomId); long? callerId = AuthStuff.GetPlayerId(Request); if (!CanViewRoomDirectly(sourceRoom, callerId)) return NotFound(); var sourceTags = sourceRoom.Tags?.Select(t => t.Tag).ToHashSet() ?? new HashSet(); var matches = RoomDB.Rooms.FindAll() .Where(r => r.RoomId != roomId) .Where(IsPubliclyDiscoverableRoom) .Where(r => r.Tags != null && r.Tags.Any(t => sourceTags.Contains(t.Tag))) .ToList(); var page = matches.Skip(skip).Take(take).ToList(); RoomDB.PrepareRoomsForClient(page); return Ok(new { Results = page, TotalResults = matches.Count }); } [HttpDelete("rooms/{roomId}/interactionby/me/favorite")] public IActionResult UnfavoriteRoomInteraction(long roomId) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); if (!PlayerDB.ToggleFavorite((long)id, roomId, out bool nowFavorited)) return NotFound(); return Ok(new { favorited = nowFavorited }); } [HttpDelete("rooms/{roomId}/interactionby/me/cheer")] public IActionResult UncheerRoomInteraction(long roomId) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); if (!PlayerDB.ToggleCheer((long)id, roomId, out bool nowCheered)) return NotFound(); return Ok(new { cheered = nowCheered }); } [HttpPut("rooms/{roomId}/interactionby/me/favorite")] public IActionResult FavoriteRoomInteraction(long roomId) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); if (!PlayerDB.ToggleFavorite((long)id, roomId, out bool nowFavorited)) return NotFound(); return Ok(new { favorited = nowFavorited }); } [HttpPost("rooms/{roomId}/cheer")] public IActionResult CheerRoom(long roomId) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); if (!PlayerDB.ToggleCheer((long)id, roomId, out bool nowCheered)) return NotFound(); return Ok(new { cheered = nowCheered }); } [HttpPost("rooms/{roomId}/favorite")] public IActionResult FavoriteRoom(long roomId) { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); if (!PlayerDB.ToggleFavorite((long)id, roomId, out bool nowFavorited)) return NotFound(); return Ok(new { favorited = nowFavorited }); } [HttpPut("rooms/{roomId}/cloning")] [RequestSizeLimit(16 * 1024)] public async Task UpdateRoomCloning(long roomId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); var room = RoomDB.Rooms.FindById(roomId); if (room == null) return NotFound(new { success = false, error = "room_not_found" }); var requester = PlayerDB.Players.FindById(accountId.Value); bool isDeveloper = requester?.PlayerRoles?.Contains( PlayerDBClasses.PlayerRoles.Developer) == true; if (room.CreatorAccountId != accountId.Value && !isDeveloper) { return StatusCode(403, new { success = false, error = "not_room_owner" }); } bool? cloningAllowed = await ReadBooleanRequestAsync( "CloningAllowed", "cloningAllowed", "Enabled", "enabled", "Value", "value"); room.CloningAllowed = cloningAllowed ?? !room.CloningAllowed; RoomDB.Rooms.Update(room); Console.WriteLine( $"[ROOM CLONING] room={roomId} account={accountId.Value} " + $"allowed={room.CloningAllowed}"); return Ok(new { RoomId = room.RoomId, CloningAllowed = room.CloningAllowed }); } private async Task ReadBooleanRequestAsync(params string[] keys) { foreach (string key in keys) { if (TryParseBoolean(Request.Query[key].FirstOrDefault(), out bool queryValue)) return queryValue; } if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (string key in keys) { if (TryParseBoolean(form[key].FirstOrDefault(), out bool formValue)) return formValue; } return null; } if ((Request.ContentLength ?? 0) <= 0) return null; using var reader = new StreamReader(Request.Body); string body = await reader.ReadToEndAsync(); if (TryParseBoolean(body.Trim().Trim('"'), out bool rawValue)) return rawValue; try { using JsonDocument document = JsonDocument.Parse(body); return FindBoolean(document.RootElement, keys); } catch (JsonException) { return null; } } private static bool? FindBoolean( JsonElement element, IReadOnlyCollection keys) { if (element.ValueKind == JsonValueKind.True) return true; if (element.ValueKind == JsonValueKind.False) return false; if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out int number)) { return number != 0; } if (element.ValueKind == JsonValueKind.String && TryParseBoolean(element.GetString(), out bool stringValue)) { return stringValue; } if (element.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in element.EnumerateObject()) { if (keys.Contains(property.Name, StringComparer.OrdinalIgnoreCase)) { bool? direct = FindBoolean(property.Value, keys); if (direct.HasValue) return direct; } } foreach (JsonProperty property in element.EnumerateObject()) { bool? nested = FindBoolean(property.Value, keys); if (nested.HasValue) return nested; } } if (element.ValueKind == JsonValueKind.Array) { foreach (JsonElement item in element.EnumerateArray()) { bool? nested = FindBoolean(item, keys); if (nested.HasValue) return nested; } } return null; } private static bool TryParseBoolean(string? value, out bool result) { if (bool.TryParse(value, out result)) return true; if (int.TryParse(value, out int number)) { result = number != 0; return true; } string normalized = value?.Trim().ToLowerInvariant() ?? string.Empty; if (normalized is "on" or "yes" or "enabled" or "allow" or "allowed") { result = true; return true; } if (normalized is "off" or "no" or "disabled" or "deny" or "denied") { result = false; return true; } result = false; return false; } [HttpPost("rooms/{roomId}/clone")] public IActionResult CloneRoom(long roomId, [FromForm] string? name) { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) { return Unauthorized(new { Value = (object?)null, Success = false, ErrorId = "unauthorized", Error = "Authentication is required to clone a room.", LocalizationContext = (object?)null }); } var source = RoomDB.GetRoom(roomId); if (source == null) { return NotFound(new { Value = (object?)null, Success = false, ErrorId = "room_not_found", Error = "Source room was not found.", LocalizationContext = (object?)null }); } if (!source.CloningAllowed) { return BadRequest(new { Value = (object?)null, Success = false, ErrorId = "room_cloning_disabled", Error = "This room does not allow cloning.", LocalizationContext = (object?)null }); } var clone = RoomDB.CloneRoom(source, player.PlayerId, name); if (clone == null) { return StatusCode(500, new { Value = (object?)null, Success = false, ErrorId = "room_clone_failed", Error = "The room could not be cloned.", LocalizationContext = (object?)null }); } PlayerDB.RecordRoomVisit(player.PlayerId, clone.RoomId); var preparedClone = RoomDB.PrepareRoomForClient(clone); if (preparedClone == null) { return StatusCode(500, new { Value = (object?)null, Success = false, ErrorId = "room_clone_prepare_failed", Error = "The cloned room could not be prepared for the client.", LocalizationContext = (object?)null }); } Console.WriteLine( $"[ROOM CLONE RESPONSE] source={roomId} clone={preparedClone.RoomId} " + $"format=api-result success=true subrooms={preparedClone.SubRooms?.Count ?? 0}"); return Ok(new { Value = preparedClone, Success = true, ErrorId = (string?)null, Error = (string?)null, LocalizationContext = (object?)null }); } [HttpGet("rooms/bulk")] public async Task GetRoomsByNames([FromQuery] List name) { long? callerId = AuthStuff.GetPlayerId(Request); var rooms = RoomDB.GetRoomsByNames(name) .Where(room => CanViewRoomDirectly(room, callerId)) .ToList(); return Ok(new { Results = rooms, TotalResults = rooms.Count }); } [HttpGet("photon_access_token")] public async Task GetPhotonAccessToken() { var id = AuthStuff.GetPlayerId(Request); if (id == null) return Unauthorized(); var heartbeat = PlayerDB.GetPlayerHeartbeat((long)id); var activeRoom = heartbeat?.roomInstance == null ? null : RoomDB.GetRoom(heartbeat.roomInstance.roomId); var activeSubRoom = activeRoom?.SubRooms?.FirstOrDefault(subRoom => subRoom.SubRoomId == heartbeat?.roomInstance?.subRoomId); var permissions = activeSubRoom?.Permissions?.Count > 0 ? activeSubRoom.Permissions.Select(SanitizePermission).ToList() : CreateDefaultSubRoomPermissions(); if (heartbeat?.roomInstance != null) { var player = PlayerDB.Players.FindById((long)id); DiscordLogger.Log($"🚪 **Joined Room** — `{player?.Player?.Username ?? "unknown"}` (ID: `{id}`) → room `{heartbeat.roomInstance.roomId}` (`{heartbeat.roomInstance.Name}`, instance `{heartbeat.roomInstance.roomInstanceId}`)"); } var playerRecord = PlayerDB.Players.FindById((long)id); string photonTicket = PhotonTicketService.Issue( playerId: (long)id, roomInstanceId: heartbeat?.roomInstance?.roomInstanceId, roomId: heartbeat?.roomInstance?.roomId, displayName: playerRecord?.Player?.DisplayName ?? playerRecord?.Player?.Username); Response.Headers.CacheControl = "no-store"; var response = new { Permissions = permissions.ToArray(), PhotonAccessToken = photonTicket, RoomInstanceId = heartbeat?.roomInstance?.roomInstanceId }; return Ok(response); } [HttpGet("rooms/hot")] public IActionResult HotRooms( [FromQuery] string? tag = null, [FromQuery] int skip = 0, [FromQuery] int take = 100) { skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 100); string selectedTag = string.IsNullOrWhiteSpace(tag) ? "hot" : tag.Trim(); var (results, total) = RoomDB.GetHotRooms(selectedTag, skip, take); if (skip > 0 && total > 0 && (results == null || results.Count == 0)) { Console.WriteLine( $"[ROOM DISCOVERY] stale cursor recovered tag={selectedTag} skip={skip} total={total}"); (results, total) = RoomDB.GetHotRooms(selectedTag, 0, take); } Response.Headers.CacheControl = "no-store"; return Ok(new { Results = results ?? new List(), TotalResults = total }); } [HttpGet("rooms/curated_playlists")] public IActionResult GetCuratedPlaylistIds() { return Ok(new { Results = Array.Empty(), TotalResults = 0 }); } [HttpGet("rooms/carousel/rising")] public IActionResult GetRisingRooms( [FromQuery] int skip = 0, [FromQuery] int take = 100) { skip = Math.Max(0, skip); take = Math.Clamp(take, 1, 100); var allRooms = RoomDB.Rooms.FindAll() .Where(IsPubliclyDiscoverableRoom) .OrderByDescending(room => room.CreatedAt) .ThenByDescending(room => room.RoomId) .ToList(); RoomDB.PrepareRoomsForClient(allRooms); List page = allRooms.Skip(skip).Take(take).ToList(); if (skip > 0 && allRooms.Count > 0 && page.Count == 0) page = allRooms.Take(take).ToList(); Response.Headers.CacheControl = "no-store"; return Ok(new { Results = page, TotalResults = allRooms.Count }); } [HttpGet("featuredrooms/current")] public IActionResult GetCurrentFeaturedRooms() { var allRooms = RoomDB.Rooms.FindAll() .Where(IsPubliclyDiscoverableRoom) .OrderByDescending(room => room.IsDeveloperOwned) .ThenByDescending(room => room.CreatedAt) .ThenBy(room => room.RoomId) .Take(12) .ToList(); var featuredRooms = allRooms.Select(room => new { RoomId = room.RoomId, Name = room.Name, RoomName = room.Name, ImageName = (string?)null }).ToList(); return Ok(new { Id = 1L, FeaturedRoomGroupId = 1L, Name = "Mocha Featured", Rooms = featuredRooms, FeaturedRooms = featuredRooms }); } [HttpPost("rooms/{roomId}/bans")] public async Task BanFromRoom(long roomId) { var callerId = AuthStuff.GetPlayerId(Request); if (callerId == null) { return Unauthorized(new { success = false, error = "Authentication is required." }); } var room = RoomDB.GetRoom(roomId); if (room == null) { return NotFound(new { success = false, error = "Room was not found." }); } bool canBan = room.CreatorAccountId == callerId.Value || room.Roles?.Any(role => role.AccountId == callerId.Value && (role.Role == RoomDBClasses.Role.Creator || role.Role == RoomDBClasses.Role.CoOwner)) == true; if (!canBan) { return StatusCode(StatusCodes.Status403Forbidden, new { success = false, error = "You do not have permission to ban players from this room." }); } long targetAccountId = 0; int banMask = 0; string? reason = null; string rawBody = string.Empty; string[] accountIdKeys = { "id", "Id", "accountId", "AccountId", "targetAccountId", "TargetAccountId", "playerId", "PlayerId", "targetPlayerId", "TargetPlayerId" }; if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); rawBody = string.Join( "&", form.SelectMany(entry => entry.Value.Select(value => $"{entry.Key}={value}"))); foreach (string key in accountIdKeys) { string? value = form[key].FirstOrDefault(); if (long.TryParse(value, out long parsedId) && parsedId > 0) { targetAccountId = parsedId; break; } } int.TryParse( form["banMask"].FirstOrDefault(), out banMask); reason = form["reason"].FirstOrDefault() ?? form["Reason"].FirstOrDefault(); } else { foreach (string key in accountIdKeys) { string? value = Request.Query[key].FirstOrDefault(); if (long.TryParse(value, out long parsedId) && parsedId > 0) { targetAccountId = parsedId; break; } } int.TryParse( Request.Query["banMask"].FirstOrDefault(), out banMask); reason = Request.Query["reason"].FirstOrDefault() ?? Request.Query["Reason"].FirstOrDefault(); using var reader = new StreamReader(Request.Body); rawBody = await reader.ReadToEndAsync(); if (targetAccountId == 0 && !string.IsNullOrWhiteSpace(rawBody)) { string cleanedBody = rawBody.Trim().Trim('"'); if (long.TryParse( cleanedBody, out long rawAccountId) && rawAccountId > 0) { targetAccountId = rawAccountId; } else { try { using var document = JsonDocument.Parse(rawBody); JsonElement root = document.RootElement; if (root.ValueKind == JsonValueKind.Object) { foreach (JsonProperty property in root.EnumerateObject()) { bool isAccountIdProperty = accountIdKeys.Any(key => property.Name.Equals( key, StringComparison.OrdinalIgnoreCase)); if (isAccountIdProperty) { if (property.Value.ValueKind == JsonValueKind.Number && property.Value.TryGetInt64( out long jsonNumberId)) { targetAccountId = jsonNumberId; break; } if (property.Value.ValueKind == JsonValueKind.String && long.TryParse( property.Value.GetString(), out long jsonStringId)) { targetAccountId = jsonStringId; break; } } } foreach (JsonProperty property in root.EnumerateObject()) { if (property.Name.Equals( "banMask", StringComparison.OrdinalIgnoreCase)) { if (property.Value.ValueKind == JsonValueKind.Number) { property.Value.TryGetInt32(out banMask); } else if (property.Value.ValueKind == JsonValueKind.String) { int.TryParse( property.Value.GetString(), out banMask); } } if (property.Name.Equals( "reason", StringComparison.OrdinalIgnoreCase) && property.Value.ValueKind == JsonValueKind.String) { reason = property.Value.GetString(); } } } } catch (JsonException ex) { Console.WriteLine( $"[ROOM BAN] JSON parse failed: {ex.Message}"); } } } } reason = string.IsNullOrWhiteSpace(reason) ? null : reason.Trim(); Console.WriteLine( $"[ROOM BAN] room={roomId} " + $"caller={callerId.Value} " + $"target={targetAccountId} " + $"banMask={banMask} " + $"contentType={Request.ContentType ?? "null"} " + $"rawBody={rawBody}"); if (targetAccountId <= 0) { return BadRequest(new { success = false, error = "A valid player ID was not provided.", contentType = Request.ContentType, receivedBody = rawBody }); } if (targetAccountId == callerId.Value) { return BadRequest(new { success = false, error = "You cannot ban yourself." }); } var targetPlayer = PlayerDB.Players.FindById(targetAccountId); if (targetPlayer == null) { return NotFound(new { success = false, error = $"Player {targetAccountId} was not found." }); } var existingBan = RoomDB.GetActiveBans(roomId) .FirstOrDefault(existing => existing.AccountId == targetAccountId); if (existingBan != null) { return Ok(new { success = true, alreadyBanned = true, roomId, accountId = targetAccountId, banMask, ban = existingBan }); } var ban = RoomDB.BanPlayerFromRoom( roomId, targetAccountId, callerId.Value, reason); DiscordLogger.Log( $"🔨 **Room Ban** — `{targetAccountId}` banned from room " + $"`{roomId}` (`{room.Name}`) by `{callerId.Value}`" + $" — mask `{banMask}`" + (reason == null ? "" : $" — {reason}")); return Ok(new { success = true, alreadyBanned = false, roomId, accountId = targetAccountId, banMask, ban }); } public class BanRequest { public long AccountId { get; set; } public string? Reason { get; set; } } [HttpGet("rooms/{roomId}/bans")] public IActionResult GetRoomBans(long roomId) { var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(); var bans = RoomDB.GetActiveBans(roomId); return Ok(bans); } [HttpDelete("rooms/{roomId}/bans/{targetAccountId}")] public IActionResult UnbanFromRoom(long roomId, long targetAccountId) { var accountId = AuthStuff.GetPlayerId(Request); if (accountId == null) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(); bool canBan = room.CreatorAccountId == accountId.Value || room.Roles?.Any(r => r.AccountId == accountId.Value && (r.Role == RoomDBClasses.Role.Creator || r.Role == RoomDBClasses.Role.CoOwner)) == true; if (!canBan) return StatusCode(403); if (!RoomDB.UnbanPlayerFromRoom(roomId, targetAccountId)) return NotFound(new { success = false, error = "No active ban found for that player." }); DiscordLogger.Log($"✅ **Room Unban** — `{targetAccountId}` unbanned from room `{roomId}` by `{accountId}`"); return Ok(new { success = true }); } [HttpGet("rooms/recommendations")] public IActionResult GetRoomRecommendations() { return Ok(new { Results = Array.Empty(), TotalResults = 0 }); } [HttpGet("rooms/{roomId}/interactionby/me")] public IActionResult GetInteractionByMe(long roomId) { var player = AuthStuff.GetCurrentPlayer(Request); if (player == null) return Unauthorized(); bool cheered = player.Player?.CheeredRooms?.Contains(roomId) ?? false; bool favorited = player.Player?.FavoritedRooms?.Contains(roomId) ?? false; var visit = player.Player?.PlayerExtra?.RoomVisits?.FirstOrDefault(v => v.RoomId == roomId); return Ok(new { Cheered = cheered, Favorited = favorited, LastVisitedAt = visit?.VisitedAt }); } private static bool CanEditRoom(RoomDBClasses.Room room, long accountId) { return room.CreatorAccountId == accountId || room.Roles?.Any(role => role.AccountId == accountId && role.Role is RoomDBClasses.Role.Creator or RoomDBClasses.Role.CoOwner) == true; } private static bool IsPubliclyDiscoverableRoom( RoomDBClasses.Room room) { return !room.IsDorm && room.State != RoomDBClasses.RoomState.MarkedForDelete && room.State != RoomDBClasses.RoomState.Moderation_Closed && room.Accessibility == RoomDBClasses.RoomAccessibility.Public; } private static bool CanViewRoomDirectly( RoomDBClasses.Room? room, long? accountId) { if (room == null || room.State == RoomDBClasses.RoomState.MarkedForDelete || room.State == RoomDBClasses.RoomState.Moderation_Closed) { return false; } if (!room.IsDorm && room.Accessibility != RoomDBClasses.RoomAccessibility.Private) { return true; } return accountId.HasValue && RoomDB.CanPlayerAccessRoom(room, accountId.Value); } private static bool CanViewSubRoomDirectly( RoomDBClasses.Room room, RoomDBClasses.SubRooms subRoom, long? accountId) { if (!CanViewRoomDirectly(room, accountId)) return false; if (subRoom.Accessibility != RoomDBClasses.RoomAccessibility.Private) { return true; } return accountId.HasValue && RoomDB.CanPlayerAccessSubRoom( room, subRoom, accountId.Value); } private async Task ReadRoomAccessibilityAsync() { string? rawValue = Request.Query["accessibility"].FirstOrDefault(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(HttpContext.RequestAborted); rawValue ??= form["accessibility"].FirstOrDefault() ?? form["Accessibility"].FirstOrDefault(); } else { using var reader = new StreamReader( Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1_024, leaveOpen: true); string rawBody = await reader.ReadToEndAsync(HttpContext.RequestAborted); if (!string.IsNullOrWhiteSpace(rawBody)) { try { using var document = JsonDocument.Parse(rawBody); JsonElement root = document.RootElement; if (root.ValueKind == JsonValueKind.Object && TryGetProperty(root, "accessibility", out var property)) { root = property; } if (root.ValueKind is JsonValueKind.String or JsonValueKind.Number) rawValue ??= root.ToString(); } catch (JsonException) { rawValue ??= rawBody.Trim().Trim('"'); } } } if (string.IsNullOrWhiteSpace(rawValue)) return null; rawValue = rawValue.Trim(); if (int.TryParse(rawValue, out int numericValue) && Enum.IsDefined(typeof(RoomDBClasses.RoomAccessibility), numericValue)) { return (RoomDBClasses.RoomAccessibility)numericValue; } return Enum.TryParse(rawValue, true, out var namedValue) && Enum.IsDefined(typeof(RoomDBClasses.RoomAccessibility), namedValue) ? namedValue : null; } [HttpPut("rooms/{roomId}/name")] [HttpPost("rooms/{roomId}/name")] [HttpPatch("rooms/{roomId}/name")] [RequestSizeLimit(16 * 1024)] public async Task UpdateRoomName(long roomId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(MutationFailure("room_not_found", "Room was not found.")); if (!CanEditRoom(room, accountId.Value)) return StatusCode(StatusCodes.Status403Forbidden); RoomSettingsPayload payload = await ReadRoomSettingsPayloadAsync(); string? requestedName = ReadFlexibleString( payload.Values, "name", "Name", "roomName", "RoomName", "value", "Value"); if (string.IsNullOrWhiteSpace(requestedName) && !string.IsNullOrWhiteSpace(payload.RawBody)) { requestedName = payload.RawBody.Trim().Trim('"'); } string clean = (requestedName ?? string.Empty).Trim().TrimStart('^'); if (clean.Length == 0 || clean.Length > 64 || clean.Any(character => character == '\0' || character == '/' || character == '\\')) { return BadRequest(MutationFailure( "invalid_room_name", "A room name between 1 and 64 characters is required.")); } var duplicate = RoomDB.GetRoomByName(clean); if (duplicate != null && duplicate.RoomId != roomId) { return Conflict(MutationFailure( "room_name_taken", "Another room already uses that name.")); } room.Name = clean; if (RoomDB.IsCanonicalBaseRoom(room)) { room.IsBaseRoom = true; room.IsRRO = true; room.IsDorm = false; room.Accessibility = RoomDBClasses.RoomAccessibility.Public; EnsureRoomTag(room, "base", RoomDBClasses.TagType.Auto); EnsureRoomTag(room, "rro", RoomDBClasses.TagType.AGOnly); } room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) return StatusCode(StatusCodes.Status500InternalServerError); Console.WriteLine($"[ROOM NAME] room={roomId} name={clean} by={accountId.Value}"); return Ok(MutationSuccess(RoomDB.PrepareRoomForClient(room)!)); } [HttpPut("rooms/{roomId}/beta")] [HttpPost("rooms/{roomId}/beta")] [HttpPatch("rooms/{roomId}/beta")] [HttpPut("rooms/{roomId}/beta-content")] [HttpPost("rooms/{roomId}/beta-content")] [HttpPatch("rooms/{roomId}/beta-content")] [HttpPut("rooms/{roomId}/creative-tools-beta")] [HttpPost("rooms/{roomId}/creative-tools-beta")] [HttpPatch("rooms/{roomId}/creative-tools-beta")] [HttpPut("rooms/{roomId}/enablebeta")] [HttpPost("rooms/{roomId}/enablebeta")] [HttpPatch("rooms/{roomId}/enablebeta")] [HttpPut("rooms/{roomId}/supportsbetacontent")] [HttpPost("rooms/{roomId}/supportsbetacontent")] [HttpPatch("rooms/{roomId}/supportsbetacontent")] [HttpPut("rooms/{roomId}/supports-beta-content")] [HttpPost("rooms/{roomId}/supports-beta-content")] [HttpPatch("rooms/{roomId}/supports-beta-content")] [HttpPut("rooms/{roomId}/betacontent")] [HttpPost("rooms/{roomId}/betacontent")] [HttpPatch("rooms/{roomId}/betacontent")] [RequestSizeLimit(64 * 1024)] public async Task UpdateRoomBeta(long roomId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(MutationFailure("room_not_found", "Room was not found.")); if (!CanEditRoom(room, accountId.Value)) return StatusCode(StatusCodes.Status403Forbidden); RoomSettingsPayload payload = await ReadRoomSettingsPayloadAsync(); bool? enabled = ReadFlexibleBool( payload.Values, "enabled", "Enabled", "isEnabled", "IsEnabled", "beta", "Beta", "isBeta", "IsBeta", "supportsBetaContent", "SupportsBetaContent", "creativeToolsBetaEnabled", "CreativeToolsBetaEnabled", "betaContentEnabled", "BetaContentEnabled"); if (!enabled.HasValue && !string.IsNullOrWhiteSpace(payload.RawBody)) { string raw = payload.RawBody.Trim().Trim('"'); if (bool.TryParse(raw, out bool parsedBool)) enabled = parsedBool; else if (int.TryParse(raw, out int parsedInt)) enabled = parsedInt != 0; } if (!enabled.HasValue) { return BadRequest(MutationFailure( "beta_value_required", "A boolean beta/enabled value is required.")); } ApplyRoomBeta(room, enabled.Value); room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) return StatusCode(StatusCodes.Status500InternalServerError); Console.WriteLine( $"[ROOM BETA] room={roomId} enabled={enabled.Value} by={accountId.Value}"); return Ok(MutationSuccess(RoomDB.PrepareRoomForClient(room)!)); } [HttpPut("rooms/{roomId}/tags")] [HttpPost("rooms/{roomId}/tags")] [HttpPatch("rooms/{roomId}/tags")] [HttpPut("rooms/{roomId}/tag")] [HttpPost("rooms/{roomId}/tag")] [HttpPatch("rooms/{roomId}/tag")] [HttpPut("rooms/{roomId}/tags/modify")] [HttpPost("rooms/{roomId}/tags/modify")] [HttpPatch("rooms/{roomId}/tags/modify")] [HttpPut("rooms/{roomId}/settags")] [HttpPost("rooms/{roomId}/settags")] [HttpPatch("rooms/{roomId}/settags")] [RequestSizeLimit(64 * 1024)] public async Task UpdateRoomTags(long roomId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(MutationFailure("room_not_found", "Room was not found.")); if (!CanEditRoom(room, accountId.Value)) return StatusCode(StatusCodes.Status403Forbidden); RoomSettingsPayload payload = await ReadRoomSettingsPayloadAsync(); List requestedTags = ParseRoomTags(payload); bool replace = !string.Equals(Request.Method, "POST", StringComparison.OrdinalIgnoreCase) || ReadFlexibleBool(payload.Values, "replace", "Replace") == true; room.Tags ??= new List(); List protectedTags = room.Tags .Where(tag => tag.Type != RoomDBClasses.TagType.General || string.Equals(tag.Tag, "base", StringComparison.OrdinalIgnoreCase) || string.Equals(tag.Tag, "rro", StringComparison.OrdinalIgnoreCase) || string.Equals(tag.Tag, "beta", StringComparison.OrdinalIgnoreCase)) .Select(tag => new RoomDBClasses.Tags { Tag = NormalizeRoomTag(tag.Tag), Type = tag.Type }) .Where(tag => !string.IsNullOrWhiteSpace(tag.Tag)) .ToList(); IEnumerable userTags = requestedTags.Select(tag => new RoomDBClasses.Tags { Tag = tag, Type = RoomDBClasses.TagType.General }); if (!replace) { userTags = room.Tags .Where(tag => tag.Type == RoomDBClasses.TagType.General) .Concat(userTags); } room.Tags = protectedTags .Concat(userTags) .GroupBy(tag => tag.Tag, StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .Take(30) .ToList(); if (room.CreativeToolsBetaEnabled) ApplyRoomBeta(room, true); if (RoomDB.IsCanonicalBaseRoom(room)) { room.IsBaseRoom = true; EnsureRoomTag(room, "base", RoomDBClasses.TagType.Auto); EnsureRoomTag(room, "rro", RoomDBClasses.TagType.AGOnly); } room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) return StatusCode(StatusCodes.Status500InternalServerError); Console.WriteLine( $"[ROOM TAGS] room={roomId} count={room.Tags.Count} replace={replace} " + $"by={accountId.Value}"); return Ok(MutationSuccess(RoomDB.PrepareRoomForClient(room)!)); } [HttpDelete("rooms/{roomId}/tags/{tag}")] [HttpDelete("rooms/{roomId}/tag/{tag}")] public IActionResult DeleteRoomTag(long roomId, string tag) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(); if (!CanEditRoom(room, accountId.Value)) return StatusCode(StatusCodes.Status403Forbidden); string normalized = NormalizeRoomTag(tag); if ((normalized is "base" or "rro") && RoomDB.IsCanonicalBaseRoom(room)) { return BadRequest(MutationFailure( "protected_tag", "Canonical base-room tags cannot be removed.")); } if (normalized == "beta") room.CreativeToolsBetaEnabled = false; room.Tags ??= new List(); room.Tags.RemoveAll(value => string.Equals( value.Tag, normalized, StringComparison.OrdinalIgnoreCase)); room.UgcVersion = Math.Max(1, room.UgcVersion + 1); RoomDB.Rooms.Update(room); return Ok(MutationSuccess(RoomDB.PrepareRoomForClient(room)!)); } [HttpPut("rooms/{roomId}/modify")] [HttpPost("rooms/{roomId}/modify")] [HttpPatch("rooms/{roomId}/modify")] [HttpPut("rooms/{roomId}")] [HttpPost("rooms/{roomId}")] [HttpPatch("rooms/{roomId}")] [RequestSizeLimit(128 * 1024)] public async Task ModifyRoom(long roomId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(MutationFailure("room_not_found", "Room was not found.")); if (!CanEditRoom(room, accountId.Value)) return StatusCode(StatusCodes.Status403Forbidden); RoomSettingsPayload payload = await ReadRoomSettingsPayloadAsync(); bool changed = false; string? name = ReadFlexibleString(payload.Values, "name", "Name"); if (!string.IsNullOrWhiteSpace(name)) { string clean = name.Trim().TrimStart('^'); if (clean.Length > 64 || clean.Any(character => character == '\0')) return BadRequest(MutationFailure("invalid_room_name", "The room name is invalid.")); room.Name = clean; changed = true; } string? description = ReadFlexibleString( payload.Values, "description", "Description"); if (description != null) { room.Description = description.Length > 1_000 ? description[..1_000] : description; changed = true; } string? imageName = ReadFlexibleString( payload.Values, "imageName", "ImageName", "image", "Image"); if (!string.IsNullOrWhiteSpace(imageName)) { room.ImageName = imageName.Trim(); changed = true; } string? accessibilityRaw = ReadFlexibleString( payload.Values, "accessibility", "Accessibility"); if (!string.IsNullOrWhiteSpace(accessibilityRaw) && TryParseRoomAccessibility(accessibilityRaw, out var accessibility)) { room.Accessibility = accessibility; changed = true; } bool? cloningAllowed = ReadFlexibleBool( payload.Values, "cloningAllowed", "CloningAllowed", "allowCloning", "AllowCloning"); if (cloningAllowed.HasValue) { room.CloningAllowed = cloningAllowed.Value; changed = true; } bool? beta = ReadFlexibleBool( payload.Values, "beta", "Beta", "isBeta", "IsBeta", "supportsBetaContent", "SupportsBetaContent", "creativeToolsBetaEnabled", "CreativeToolsBetaEnabled", "betaContentEnabled", "BetaContentEnabled"); if (beta.HasValue) { ApplyRoomBeta(room, beta.Value); changed = true; } if (payload.Values.ContainsKey("tags") || payload.Values.ContainsKey("Tags") || payload.Values.ContainsKey("tagNames") || payload.Values.ContainsKey("TagNames")) { List tags = ParseRoomTags(payload); room.Tags ??= new List(); room.Tags.RemoveAll(tag => tag.Type == RoomDBClasses.TagType.General); room.Tags.AddRange(tags.Select(tag => new RoomDBClasses.Tags { Tag = tag, Type = RoomDBClasses.TagType.General })); changed = true; } int? maxPlayers = ReadFlexibleInt(payload.Values, "maxPlayers", "MaxPlayers"); if (maxPlayers.HasValue) { room.MaxPlayers = Math.Clamp(maxPlayers.Value, 1, 100); changed = true; } if (RoomDB.IsCanonicalBaseRoom(room)) { room.IsBaseRoom = true; room.IsRRO = true; room.IsDorm = false; room.Accessibility = RoomDBClasses.RoomAccessibility.Public; EnsureRoomTag(room, "base", RoomDBClasses.TagType.Auto); EnsureRoomTag(room, "rro", RoomDBClasses.TagType.AGOnly); } if (!changed) return BadRequest(MutationFailure("no_changes", "No supported room settings were provided.")); room.Tags = room.Tags .Where(tag => !string.IsNullOrWhiteSpace(tag.Tag)) .GroupBy(tag => tag.Tag, StringComparer.OrdinalIgnoreCase) .Select(group => group.First()) .Take(30) .ToList(); room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) return StatusCode(StatusCodes.Status500InternalServerError); Console.WriteLine($"[ROOM MODIFY] room={roomId} by={accountId.Value}"); return Ok(MutationSuccess(RoomDB.PrepareRoomForClient(room)!)); } [HttpPut("rooms/{roomId}/permissions")] [HttpPost("rooms/{roomId}/permissions")] [HttpPatch("rooms/{roomId}/permissions")] [HttpPut("rooms/{roomId}/permissions/modify")] [HttpPost("rooms/{roomId}/permissions/modify")] [HttpPatch("rooms/{roomId}/permissions/modify")] [RequestSizeLimit(128 * 1024)] public async Task UpdateRoomPermissions(long roomId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(MutationFailure("room_not_found", "Room was not found.")); if (!CanEditRoom(room, accountId.Value)) return StatusCode(StatusCodes.Status403Forbidden); bool requestHadPayload = Request.QueryString.HasValue || Request.HasFormContentType || (Request.ContentLength ?? 0) > 0 || Request.Headers.TransferEncoding.Count > 0; List permissions = await ReadPermissionPayloadFromRequestAsync(); if (permissions.Count == 0) { if (requestHadPayload) { return BadRequest(MutationFailure( "invalid_permissions", "The permission payload could not be parsed.")); } permissions = room.SubRooms? .FirstOrDefault(subRoom => subRoom.Permissions?.Count > 0)? .Permissions? .Select(SanitizePermission) .ToList() ?? CreateDefaultSubRoomPermissions(); } List sanitized = permissions .Where(permission => !string.IsNullOrWhiteSpace(permission.Permission)) .Select(SanitizePermission) .GroupBy(permission => new { Permission = permission.Permission.ToUpperInvariant(), permission.Role, permission.Type }) .Select(group => group.Last()) .Take(250) .ToList(); room.SubRooms ??= new List(); foreach (var subRoom in room.SubRooms) { subRoom.Permissions = sanitized.Select(permission => new RoomDBClasses.SubRoomPermission { Override = permission.Override, Permission = permission.Permission, Role = permission.Role, Type = permission.Type, Value = permission.Value }).ToList(); } room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) return StatusCode(StatusCodes.Status500InternalServerError); Console.WriteLine( $"[ROOM PERMISSIONS] room={roomId} subrooms={room.SubRooms.Count} " + $"permissions={sanitized.Count} by={accountId.Value}"); return Ok(MutationSuccess(RoomDB.PrepareRoomForClient(room)!)); } private sealed class RoomSettingsPayload { public Dictionary Values { get; } = new(StringComparer.OrdinalIgnoreCase); public string RawBody { get; set; } = string.Empty; } private sealed class RoomRoleInviteTarget { public long AccountId { get; set; } public string? Username { get; set; } } private async Task ReadRoomRoleInviteTargetAsync() { RoomSettingsPayload payload = await ReadRoomSettingsPayloadAsync(); long accountId = 0; foreach (string key in new[] { "accountId", "AccountId", "id", "Id", "playerId", "PlayerId", "targetAccountId", "TargetAccountId" }) { if (long.TryParse(ReadFlexibleString(payload.Values, key)?.Trim('"'), out long parsed) && parsed > 0) { accountId = parsed; break; } } return new RoomRoleInviteTarget { AccountId = accountId, Username = ReadFlexibleString( payload.Values, "username", "Username", "displayName", "DisplayName") }; } private async Task ReadRoomSettingsPayloadAsync() { var payload = new RoomSettingsPayload(); foreach (var item in Request.Query) AddRoomSettingValues(payload.Values, item.Key, item.Value); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(HttpContext.RequestAborted); foreach (var item in form) AddRoomSettingValues(payload.Values, item.Key, item.Value); return payload; } if ((Request.ContentLength ?? 0) <= 0 && Request.Headers.TransferEncoding.Count == 0) return payload; using var reader = new StreamReader(Request.Body); payload.RawBody = await reader.ReadToEndAsync(HttpContext.RequestAborted); if (string.IsNullOrWhiteSpace(payload.RawBody)) return payload; try { using var document = JsonDocument.Parse(payload.RawBody); JsonElement root = document.RootElement; if (root.ValueKind == JsonValueKind.Object) { AddRoomSettingsJsonObject(payload.Values, root); foreach (string wrapperName in new[] { "room", "Room", "settings", "Settings", "roomSettings", "RoomSettings", "update", "Update", "request", "Request", "data", "Data" }) { if (TryGetProperty(root, wrapperName, out JsonElement nested) && nested.ValueKind == JsonValueKind.Object) { AddRoomSettingsJsonObject(payload.Values, nested); } } } else { payload.Values["value"] = root.ValueKind == JsonValueKind.String ? root.GetString() ?? string.Empty : root.GetRawText(); } } catch (JsonException) { payload.Values["value"] = payload.RawBody.Trim(); } return payload; } private static void AddRoomSettingValues( IDictionary destination, string key, Microsoft.Extensions.Primitives.StringValues values) { if (values.Count <= 0) return; bool isList = key.Equals("tags", StringComparison.OrdinalIgnoreCase) || key.Equals("tagNames", StringComparison.OrdinalIgnoreCase) || key.Equals("permissions", StringComparison.OrdinalIgnoreCase) || key.Equals("roomPermissions", StringComparison.OrdinalIgnoreCase); destination[key] = isList && values.Count > 1 ? JsonSerializer.Serialize(values.ToArray()) : values[values.Count - 1] ?? string.Empty; } private static void AddRoomSettingsJsonObject( IDictionary destination, JsonElement element) { foreach (JsonProperty property in element.EnumerateObject()) { destination[property.Name] = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() ?? string.Empty : property.Value.GetRawText(); } } private static bool? ReadFlexibleBool( IReadOnlyDictionary values, params string[] keys) { string? raw = ReadFlexibleString(values, keys)?.Trim('"'); if (bool.TryParse(raw, out bool boolean)) return boolean; if (int.TryParse(raw, out int number)) return number != 0; return null; } private static int? ReadFlexibleInt( IReadOnlyDictionary values, params string[] keys) => int.TryParse(ReadFlexibleString(values, keys)?.Trim('"'), out int value) ? value : null; private static string? ReadFlexibleString( IReadOnlyDictionary values, params string[] keys) { foreach (string key in keys) { if (!values.TryGetValue(key, out string? value)) continue; string clean = value.Trim(); if (clean.Length >= 2 && clean[0] == '"' && clean[^1] == '"') { try { return JsonSerializer.Deserialize(clean); } catch (JsonException) { } } return clean; } return null; } private static bool TryParseRoomAccessibility( string raw, out RoomDBClasses.RoomAccessibility accessibility) { raw = raw.Trim().Trim('"'); if (int.TryParse(raw, out int number) && Enum.IsDefined(typeof(RoomDBClasses.RoomAccessibility), number)) { accessibility = (RoomDBClasses.RoomAccessibility)number; return true; } return Enum.TryParse(raw, true, out accessibility) && Enum.IsDefined(typeof(RoomDBClasses.RoomAccessibility), accessibility); } private static List ParseRoomTags(RoomSettingsPayload payload) { string? raw = ReadFlexibleString( payload.Values, "tags", "Tags", "tagNames", "TagNames", "tag", "Tag", "value"); if (string.IsNullOrWhiteSpace(raw)) return new List(); var output = new List(); try { using var document = JsonDocument.Parse(raw); JsonElement root = document.RootElement; if (root.ValueKind == JsonValueKind.Object && TryGetProperty(root, "tags", out JsonElement nested)) { root = nested; } IEnumerable entries = root.ValueKind == JsonValueKind.Array ? root.EnumerateArray().ToArray() : new[] { root }; foreach (JsonElement entry in entries) { string? tag; if (entry.ValueKind == JsonValueKind.String) { tag = entry.GetString(); } else if (entry.ValueKind == JsonValueKind.Object && (TryGetProperty(entry, "tag", out JsonElement tagElement) || TryGetProperty(entry, "name", out tagElement))) { tag = tagElement.ToString(); } else { tag = entry.ToString(); } string normalized = NormalizeRoomTag(tag); if (!string.IsNullOrWhiteSpace(normalized)) output.Add(normalized); } } catch (JsonException) { foreach (string tag in raw.Split( new[] { ',', ';', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { string normalized = NormalizeRoomTag(tag); if (!string.IsNullOrWhiteSpace(normalized)) output.Add(normalized); } } return output .Distinct(StringComparer.OrdinalIgnoreCase) .Take(30) .ToList(); } private static string NormalizeRoomTag(string? tag) { string value = (tag ?? string.Empty).Trim().TrimStart('#'); value = new string(value .Where(character => char.IsLetterOrDigit(character) || character is '-' or '_' or ' ') .Take(32) .ToArray()); return value.Trim().ToLowerInvariant(); } private static void EnsureRoomTag( RoomDBClasses.Room room, string tag, RoomDBClasses.TagType type) { room.Tags ??= new List(); if (!room.Tags.Any(value => string.Equals( value.Tag, tag, StringComparison.OrdinalIgnoreCase))) { room.Tags.Add(new RoomDBClasses.Tags { Tag = tag, Type = type }); } } private static void ApplyRoomBeta(RoomDBClasses.Room room, bool enabled) { room.CreativeToolsBetaEnabled = enabled; room.Tags ??= new List(); room.Tags.RemoveAll(value => string.Equals( value.Tag, "beta", StringComparison.OrdinalIgnoreCase)); if (enabled) room.Tags.Add(new RoomDBClasses.Tags { Tag = "beta", Type = RoomDBClasses.TagType.Auto }); } private async Task> ReadPermissionPayloadFromRequestAsync() { string rawBody = Request.Query["permissions"].FirstOrDefault() ?? Request.Query["Permissions"].FirstOrDefault() ?? Request.Query["permissionSettings"].FirstOrDefault() ?? Request.Query["roomPermissions"].FirstOrDefault() ?? string.Empty; string? singlePermission = Request.Query["permission"].FirstOrDefault() ?? Request.Query["Permission"].FirstOrDefault() ?? Request.Query["permissionName"].FirstOrDefault() ?? Request.Query["PermissionName"].FirstOrDefault(); string? overrideRaw = Request.Query["override"].FirstOrDefault() ?? Request.Query["Override"].FirstOrDefault(); string? roleRaw = Request.Query["role"].FirstOrDefault() ?? Request.Query["Role"].FirstOrDefault(); string? typeRaw = Request.Query["type"].FirstOrDefault() ?? Request.Query["Type"].FirstOrDefault(); string? valueRaw = Request.Query["value"].FirstOrDefault() ?? Request.Query["Value"].FirstOrDefault(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(HttpContext.RequestAborted); rawBody = form["permissions"].FirstOrDefault() ?? form["Permissions"].FirstOrDefault() ?? form["permissionSettings"].FirstOrDefault() ?? form["PermissionSettings"].FirstOrDefault() ?? form["roomPermissions"].FirstOrDefault() ?? form["RoomPermissions"].FirstOrDefault() ?? form["data"].FirstOrDefault() ?? form["json"].FirstOrDefault() ?? rawBody; singlePermission = form["permission"].FirstOrDefault() ?? form["Permission"].FirstOrDefault() ?? form["permissionName"].FirstOrDefault() ?? form["PermissionName"].FirstOrDefault() ?? singlePermission; overrideRaw = form["override"].FirstOrDefault() ?? form["Override"].FirstOrDefault() ?? overrideRaw; roleRaw = form["role"].FirstOrDefault() ?? form["Role"].FirstOrDefault() ?? roleRaw; typeRaw = form["type"].FirstOrDefault() ?? form["Type"].FirstOrDefault() ?? typeRaw; valueRaw = form["value"].FirstOrDefault() ?? form["Value"].FirstOrDefault() ?? valueRaw; } else if (string.IsNullOrWhiteSpace(rawBody) && ((Request.ContentLength ?? 0) > 0 || Request.Headers.TransferEncoding.Count > 0)) { using var reader = new StreamReader(Request.Body); rawBody = await reader.ReadToEndAsync(HttpContext.RequestAborted); } List parsed = ParsePermissionPayload(rawBody); if (parsed.Count > 0) return parsed; if (string.IsNullOrWhiteSpace(singlePermission)) return parsed; return new List { new() { Permission = singlePermission, Override = !bool.TryParse(overrideRaw, out bool overrideValue) || overrideValue, Role = int.TryParse(roleRaw, out int role) ? role : 0, Type = int.TryParse(typeRaw, out int type) ? type : 0, Value = string.IsNullOrWhiteSpace(valueRaw) ? "True" : valueRaw } }; } [HttpPut("rooms/{roomId}/roles/{roleId}")] [HttpPost("rooms/{roomId}/roles/{roleId}")] [HttpPatch("rooms/{roomId}/roles/{roleId}")] public IActionResult AcceptRoomRole(long roomId, int roleId) { long? accountId = AuthStuff.GetPlayerId(Request); if (!accountId.HasValue) return Unauthorized(); if (!Enum.IsDefined(typeof(RoomDBClasses.Role), roleId)) return BadRequest(MutationFailure("invalid_role", "That room role is invalid.")); var requestedRole = (RoomDBClasses.Role)roleId; if (requestedRole is RoomDBClasses.Role.Banned or RoomDBClasses.Role.Creator) { return BadRequest(MutationFailure( "invalid_role", "That room role cannot be accepted through an invitation.")); } var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(MutationFailure("room_not_found", "Room was not found.")); room.Roles ??= new List(); var assignment = room.Roles.FirstOrDefault(role => role.AccountId == accountId.Value); if (assignment == null || assignment.InvitedRole != requestedRole) { return StatusCode( StatusCodes.Status403Forbidden, MutationFailure( "room_role_invite_required", "A matching room-role invitation is required.")); } assignment.Role = requestedRole; assignment.InvitedRole = RoomDBClasses.Role.None; room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) return StatusCode(StatusCodes.Status500InternalServerError); Console.WriteLine( $"[ROOM ROLE ACCEPTED] room={roomId} role={roleId} player={accountId.Value}"); return Ok(MutationSuccess(RoomDB.PrepareRoomForClient(room)!)); } [HttpPut("rooms/{roomId}/roles/{roleId}/invite")] [HttpPost("rooms/{roomId}/roles/{roleId}/invite")] [HttpPatch("rooms/{roomId}/roles/{roleId}/invite")] public async Task InviteToRoomRole(long roomId, int roleId) { long? callerId = AuthStuff.GetPlayerId(Request); if (!callerId.HasValue) return Unauthorized(); if (!Enum.IsDefined(typeof(RoomDBClasses.Role), roleId)) return BadRequest(MutationFailure("invalid_role", "That room role is invalid.")); var invitedRole = (RoomDBClasses.Role)roleId; if (invitedRole is not (RoomDBClasses.Role.Host or RoomDBClasses.Role.Moderator or RoomDBClasses.Role.CoOwner or RoomDBClasses.Role.TemporaryCoOwner)) { return BadRequest(MutationFailure( "invalid_role", "Only host, moderator, co-owner, and temporary co-owner can be invited.")); } var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(MutationFailure("room_not_found", "Room was not found.")); if (!CanEditRoom(room, callerId.Value)) return StatusCode(StatusCodes.Status403Forbidden); RoomRoleInviteTarget target = await ReadRoomRoleInviteTargetAsync(); long targetAccountId = target.AccountId; if (targetAccountId <= 0 && !string.IsNullOrWhiteSpace(target.Username)) { var matched = PlayerDB.Players.FindAll().FirstOrDefault(player => string.Equals( player.Player?.Username, target.Username, StringComparison.OrdinalIgnoreCase) || string.Equals( player.Player?.DisplayName, target.Username, StringComparison.OrdinalIgnoreCase)); targetAccountId = matched?.PlayerId ?? 0; } if (targetAccountId <= 0) { return BadRequest(MutationFailure( "account_id_required", "A valid accountId or username is required.")); } if (targetAccountId == callerId.Value) { return BadRequest(MutationFailure( "cannot_invite_self", "You cannot invite yourself to a room role.")); } if (PlayerDB.Players.FindById(targetAccountId)?.Player == null) return NotFound(MutationFailure("player_not_found", "Player was not found.")); room.Roles ??= new List(); var existingRole = room.Roles.FirstOrDefault(role => role.AccountId == targetAccountId); if (existingRole == null) { room.Roles.Add(new RoomDBClasses.Roles { AccountId = targetAccountId, Role = RoomDBClasses.Role.None, InvitedRole = invitedRole }); } else { existingRole.InvitedRole = invitedRole; } room.UgcVersion = Math.Max(1, room.UgcVersion + 1); if (!RoomDB.Rooms.Update(room)) return StatusCode(StatusCodes.Status500InternalServerError); Console.WriteLine( $"[ROOM ROLE INVITE] room={roomId} role={roleId} " + $"from={callerId.Value} to={targetAccountId}"); return Ok(new { Success = true, Room = RoomDB.PrepareRoomForClient(room), AccountId = targetAccountId, Role = invitedRole, InvitedRole = invitedRole }); } [HttpDelete("rooms/{roomId}/roles/{targetAccountId:long}")] public IActionResult RemoveRoomRole(long roomId, long targetAccountId) { long? callerId = AuthStuff.GetPlayerId(Request); if (!callerId.HasValue) return Unauthorized(); var room = RoomDB.GetRoom(roomId); if (room == null) return NotFound(); if (!CanEditRoom(room, callerId.Value)) return StatusCode(StatusCodes.Status403Forbidden); if (targetAccountId == room.CreatorAccountId) return BadRequest(MutationFailure("cannot_remove_creator", "The room creator cannot be removed.")); room.Roles ??= new List(); int removed = room.Roles.RemoveAll(role => role.AccountId == targetAccountId); if (removed > 0) { room.UgcVersion = Math.Max(1, room.UgcVersion + 1); RoomDB.Rooms.Update(room); } return Ok(MutationSuccess(RoomDB.PrepareRoomForClient(room)!)); } private static List CreateDefaultSubRoomPermissions() { string[] standardPermissions = { "CAN_USE_ROOM_RESET_BUTTON", "CAN_USE_DELETE_ALL_BUTTON", "CAN_SAVE_INVENTIONS", "CAN_SPAWN_INVENTIONS", "CAN_USE_PLAY_GIZMOS_TOGGLE" }; var permissions = standardPermissions.Select(permission => new RoomDBClasses.SubRoomPermission { Override = true, Permission = permission, Role = 0, Type = 0, Value = "True" }).ToList(); permissions.Add(new RoomDBClasses.SubRoomPermission { Override = false, Permission = "CAN_USE_MAKER_PEN", Role = (int)RoomDBClasses.Role.CoOwner, Type = 0, Value = "True" }); permissions.AddRange(standardPermissions.Select(permission => new RoomDBClasses.SubRoomPermission { Override = true, Permission = permission, Role = (int)RoomDBClasses.Role.CoOwner, Type = 0, Value = "True" })); return permissions; } private static RoomDBClasses.SubRoomPermission SanitizePermission( RoomDBClasses.SubRoomPermission permission) { string permissionName = (permission.Permission ?? string.Empty).Trim(); string permissionValue = (permission.Value ?? "True").Trim(); if (permissionName.Length > 128) permissionName = permissionName[..128]; if (permissionValue.Length > 256) permissionValue = permissionValue[..256]; return new RoomDBClasses.SubRoomPermission { Override = permission.Override, Permission = permissionName, Role = Math.Clamp(permission.Role, 0, byte.MaxValue), Type = Math.Clamp(permission.Type, 0, 32), Value = permissionValue }; } private static List ParsePermissionPayload(string? rawBody) { if (string.IsNullOrWhiteSpace(rawBody)) return new List(); try { using var document = JsonDocument.Parse(rawBody); JsonElement root = document.RootElement; if (root.ValueKind == JsonValueKind.Object && (TryGetProperty(root, "permissions", out var nestedPermissions) || TryGetProperty(root, "permissionSettings", out nestedPermissions) || TryGetProperty(root, "roomPermissions", out nestedPermissions) || TryGetProperty(root, "values", out nestedPermissions))) { root = nestedPermissions; } var permissions = new List(); if (root.ValueKind == JsonValueKind.Object && !TryGetProperty(root, "permission", out _) && !TryGetProperty(root, "permissionName", out _)) { foreach (JsonProperty property in root.EnumerateObject()) { if (property.Value.ValueKind == JsonValueKind.Object) { permissions.Add(ParsePermissionEntry( property.Value, property.Name)); } else { permissions.Add(new RoomDBClasses.SubRoomPermission { Permission = property.Name, Override = true, Role = 0, Type = 0, Value = property.Value.ValueKind == JsonValueKind.String ? property.Value.GetString() ?? "True" : property.Value.ToString() }); } } return permissions; } IEnumerable entries = root.ValueKind == JsonValueKind.Array ? root.EnumerateArray().ToArray() : new[] { root }; foreach (JsonElement entry in entries) { if (entry.ValueKind != JsonValueKind.Object) continue; RoomDBClasses.SubRoomPermission permission = ParsePermissionEntry(entry, null); if (!string.IsNullOrWhiteSpace(permission.Permission)) permissions.Add(permission); } return permissions; } catch (JsonException) { return new List(); } } private static RoomDBClasses.SubRoomPermission ParsePermissionEntry( JsonElement entry, string? fallbackName) { string permissionName = fallbackName ?? string.Empty; foreach (string key in new[] { "permission", "Permission", "permissionName", "PermissionName", "name", "Name", "key", "Key" }) { if (TryGetProperty(entry, key, out JsonElement permissionElement)) { permissionName = permissionElement.ToString(); break; } } bool isOverride = true; JsonElement overrideElement; bool hasOverride = TryGetProperty(entry, "override", out overrideElement); if (hasOverride) { isOverride = overrideElement.ValueKind == JsonValueKind.True || (overrideElement.ValueKind == JsonValueKind.String && bool.TryParse(overrideElement.GetString(), out bool overrideValue) && overrideValue) || (overrideElement.ValueKind == JsonValueKind.Number && overrideElement.TryGetInt32(out int overrideNumber) && overrideNumber != 0); } string permissionValue = "True"; JsonElement valueElement; bool hasValue = TryGetProperty(entry, "value", out valueElement) || TryGetProperty(entry, "enabled", out valueElement); if (hasValue) { permissionValue = valueElement.ValueKind == JsonValueKind.String ? valueElement.GetString() ?? "True" : valueElement.ToString(); } return new RoomDBClasses.SubRoomPermission { Override = isOverride, Permission = permissionName, Role = ReadJsonInt(entry, "role") != 0 ? ReadJsonInt(entry, "role") : ReadJsonInt(entry, "Role"), Type = ReadJsonInt(entry, "type") != 0 ? ReadJsonInt(entry, "type") : ReadJsonInt(entry, "Type"), Value = permissionValue }; } private static int ReadJsonInt(JsonElement element, string name) { if (!TryGetProperty(element, name, out var value)) return 0; if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out int number)) return number; return int.TryParse(value.ToString(), out number) ? number : 0; } private static bool CanUseRoomPlayerData( RoomDBClasses.Room? room, long accountId) { if (RoomDB.CanPlayerAccessRoom(room, accountId)) return true; PlayerDBClasses.Heartbeat heartbeat = PlayerDB.GetPlayerHeartbeat(accountId); return room != null && heartbeat.isOnline && heartbeat.roomInstance?.roomId == room.RoomId; } private static object BuildRoomPlayerDataResponse( long roomId, long accountId, string dataJson, int version, DateTime? updatedAtUtc) { _ = dataJson; _ = version; _ = updatedAtUtc; return new { roomId, accountId, data = (object?)null }; } private static JsonElement UnwrapRoomPlayerData(JsonElement root) { if (root.ValueKind != JsonValueKind.Object) return root; foreach (string name in new[] { "data", "Data", "playerData", "PlayerData", "value", "Value" }) { if (!TryGetProperty(root, name, out JsonElement nested)) continue; if (nested.ValueKind == JsonValueKind.String) { string? raw = nested.GetString(); if (!string.IsNullOrWhiteSpace(raw)) { try { using JsonDocument nestedDocument = JsonDocument.Parse(raw); return nestedDocument.RootElement.Clone(); } catch (JsonException) { } } } if (nested.ValueKind is JsonValueKind.Object or JsonValueKind.Array) return nested; } return root; } private static void MirrorPlayerDataToCloudVariables( long roomId, long accountId, JsonElement data) { IEnumerable<(string Key, JsonElement Value)> entries = EnumeratePlayerDataVariables(data); foreach ((string key, JsonElement value) in entries.Take(512)) { if (string.IsNullOrWhiteSpace(key)) continue; try { CreatorFeatureDB.SetCloudVariable( roomId, accountId, key, value.GetRawText(), accountId); } catch (ArgumentException) { } catch (InvalidOperationException) { break; } } } private static IEnumerable<(string Key, JsonElement Value)> EnumeratePlayerDataVariables(JsonElement data) { if (data.ValueKind == JsonValueKind.Object && (TryGetProperty(data, "variables", out JsonElement variables) || TryGetProperty(data, "Variables", out variables)) && variables.ValueKind == JsonValueKind.Array) { foreach (JsonElement item in variables.EnumerateArray()) { if (TryReadPlayerDataVariable(item, out string key, out JsonElement value)) yield return (key, value); } yield break; } if (data.ValueKind == JsonValueKind.Array) { foreach (JsonElement item in data.EnumerateArray()) { if (TryReadPlayerDataVariable(item, out string key, out JsonElement value)) yield return (key, value); } yield break; } if (data.ValueKind != JsonValueKind.Object) yield break; foreach (JsonProperty property in data.EnumerateObject()) { if (property.Name.Equals("roomId", StringComparison.OrdinalIgnoreCase) || property.Name.Equals("accountId", StringComparison.OrdinalIgnoreCase) || property.Name.Equals("playerId", StringComparison.OrdinalIgnoreCase) || property.Name.Equals("version", StringComparison.OrdinalIgnoreCase) || property.Name.Equals("updatedAt", StringComparison.OrdinalIgnoreCase)) { continue; } if (TryReadPlayerDataVariable( property.Value, out string nestedKey, out JsonElement nestedValue, property.Name)) { yield return (nestedKey, nestedValue); } else { yield return (property.Name, property.Value); } } } private static bool TryReadPlayerDataVariable( JsonElement item, out string key, out JsonElement value, string? fallbackKey = null) { key = fallbackKey ?? string.Empty; value = default; if (item.ValueKind != JsonValueKind.Object) return false; foreach (string keyName in new[] { "key", "Key", "name", "Name", "variableName", "VariableName", "cloudVariableName", "CloudVariableName" }) { if (TryGetProperty(item, keyName, out JsonElement keyElement)) { key = keyElement.ToString(); break; } } foreach (string valueName in new[] { "value", "Value", "data", "Data", "variableValue", "VariableValue", "cloudVariableValue", "CloudVariableValue" }) { if (TryGetProperty(item, valueName, out value)) return !string.IsNullOrWhiteSpace(key); } return false; } private static bool TryGetProperty( JsonElement element, string propertyName, out JsonElement value) { if (element.ValueKind == JsonValueKind.Object) { foreach (var property in element.EnumerateObject()) { if (property.Name.Equals(propertyName, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; } } } value = default; return false; } } }