Files
mochaisvibecoded 42f7f57b72 :3
2026-08-24 17:00:26 -04:00

5310 lines
205 KiB
C#

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<RoomBlobUsage>> 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<PendingRoomSaveAttribution>> PendingSaveAttributions = new();
private static bool CanAcceptRoomBlobUpload(
long accountId,
long bytes,
out int retryAfterSeconds)
{
retryAfterSeconds = 0;
DateTime now = DateTime.UtcNow;
Queue<RoomBlobUsage> usage = RoomBlobUsageByAccount.GetOrAdd(
accountId,
_ => new Queue<RoomBlobUsage>());
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<RoomBlobUsage> usage = RoomBlobUsageByAccount.GetOrAdd(
accountId,
_ => new Queue<RoomBlobUsage>());
lock (usage)
{
usage.Enqueue(new RoomBlobUsage
{
CreatedAtUtc = DateTime.UtcNow,
Bytes = Math.Max(0, bytes)
});
}
}
private static async Task<string> 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<RecentBlobUpload>(
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<string>(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<RoomDBClasses.SubRooms>())
{
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<RecentBlobUpload>(
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<long> GetClaimedSaveRequesterIds(
SubRoomDataRequest payload)
{
var claimedIds = new HashSet<long>();
static void AddClaim(HashSet<long> 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<PendingRoomSaveAttribution>());
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<PendingRoomSaveAttribution>? 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<IActionResult> 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<IActionResult> 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<RoomDBClasses.Room>(),
TotalResults = total
});
}
[HttpGet("rooms/{roomId}")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<SubRoomDataRequest>(
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<long> 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<RoomDBClasses.BakedUnityAsset>();
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<BakedUnityAssetRef>? 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<RoomDBClasses.BakedUnityAsset>())
.Where(asset => asset.Target == target)
.ToList();
if (matches.Count == 0)
matches = (save.BakedUnityAssets ?? new List<RoomDBClasses.BakedUnityAsset>()).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<string>(),
OMVersion = RoomDB.NormalizeLegacyOMVersion(save.OMVersion),
UgcSubVersion = RoomDB.NormalizeLegacyUgcSubVersion(
save.UgcSubVersion),
save.ModerationState,
Tags = save.Tags ?? new List<string>(),
save.CreatedAt,
DataSavedAt = save.CreatedAt,
UnityAsset = selectedPayload,
BakedUnityAsset = selectedPayload,
BakedUnityAssets = (save.BakedUnityAssets ?? new List<RoomDBClasses.BakedUnityAsset>())
.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<string>())
.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<IActionResult> 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<IActionResult> 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<RoomDBClasses.SubRoomPermission> 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<PlayerDBClasses.RoomVisit>();
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<long>();
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<IActionResult> 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<string>();
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<IActionResult> 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<bool?> 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<string> 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<IActionResult> GetRoomsByNames([FromQuery] List<string> 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<IActionResult> 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<RoomDBClasses.Room>(),
TotalResults = total
});
}
[HttpGet("rooms/curated_playlists")]
public IActionResult GetCuratedPlaylistIds()
{
return Ok(new
{
Results = Array.Empty<long>(),
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<RoomDBClasses.Room> 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<IActionResult> 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<object>(),
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<RoomDBClasses.RoomAccessibility?> 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<RoomDBClasses.RoomAccessibility>(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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string> requestedTags = ParseRoomTags(payload);
bool replace = !string.Equals(Request.Method, "POST", StringComparison.OrdinalIgnoreCase) ||
ReadFlexibleBool(payload.Values, "replace", "Replace") == true;
room.Tags ??= new List<RoomDBClasses.Tags>();
List<RoomDBClasses.Tags> 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<RoomDBClasses.Tags> 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<RoomDBClasses.Tags>();
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<IActionResult> 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<string> tags = ParseRoomTags(payload);
room.Tags ??= new List<RoomDBClasses.Tags>();
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<IActionResult> 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<RoomDBClasses.SubRoomPermission> 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<RoomDBClasses.SubRoomPermission> 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<RoomDBClasses.SubRooms>();
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<string, string> 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<RoomRoleInviteTarget> 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<RoomSettingsPayload> 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<string, string> 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<string, string> 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<string, string> 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<string, string> values,
params string[] keys) =>
int.TryParse(ReadFlexibleString(values, keys)?.Trim('"'), out int value)
? value
: null;
private static string? ReadFlexibleString(
IReadOnlyDictionary<string, string> 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<string>(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<string> ParseRoomTags(RoomSettingsPayload payload)
{
string? raw = ReadFlexibleString(
payload.Values,
"tags", "Tags", "tagNames", "TagNames", "tag", "Tag", "value");
if (string.IsNullOrWhiteSpace(raw))
return new List<string>();
var output = new List<string>();
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<JsonElement> 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<RoomDBClasses.Tags>();
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<RoomDBClasses.Tags>();
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<List<RoomDBClasses.SubRoomPermission>>
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<RoomDBClasses.SubRoomPermission> parsed =
ParsePermissionPayload(rawBody);
if (parsed.Count > 0)
return parsed;
if (string.IsNullOrWhiteSpace(singlePermission))
return parsed;
return new List<RoomDBClasses.SubRoomPermission>
{
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<RoomDBClasses.Roles>();
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<IActionResult> 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<RoomDBClasses.Roles>();
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<RoomDBClasses.Roles>();
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<RoomDBClasses.SubRoomPermission> 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<RoomDBClasses.SubRoomPermission> ParsePermissionPayload(string? rawBody)
{
if (string.IsNullOrWhiteSpace(rawBody))
return new List<RoomDBClasses.SubRoomPermission>();
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<RoomDBClasses.SubRoomPermission>();
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<JsonElement> 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<RoomDBClasses.SubRoomPermission>();
}
}
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;
}
}
}