mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
cf3aa29ba2
Security:
- roomcode PUT now requires instance ownership (any player could plant
or relocate codes on foreign private instances)
- /platformid/{accountId} requires auth (anonymous Steam/Oculus id
enumeration oracle)
- request tracing no longer captures successful /thread bodies by
default (chat messages landed unredacted in logs); opt in via
Trace__SuccessBodyPaths
- anonymous action-link renewal clamped to 720h per consume
Data integrity:
- buyPurchaseReminder upserts its objective sentinel (second purchase
threw UNIQUE after the wallet was debited)
- progression reward gift + idempotency sentinel commit in one
transaction (crash between them double-minted the reward)
- ClubPermissionsGalleryAndModeration migration defaults corrected to
the entity contract (CommentsJson '[]', ClubChatEnabled true); new
one-shot ClubChatDefaultRepair migration fixes databases that
applied the wrong defaults; bootstrap repairs '' CommentsJson rows
- admin subroom-maxplayers audit entry commits with the mutation
- account purge covers CustomAvatarItems (ownership deleted, created
items reassigned to the system account)
- duplicate photo reports ignored while one is unresolved
Stability:
- bootstrap only swallows the expected duplicate-column error; locked
databases and schema failures now fail startup instead of the first
endpoint
- clone blob write failure degrades to an empty sub-room instead of a
500 after the clone row committed
- resumed photo-feed cursor stamped DateTimeKind.Utc (Npgsql rejects
Unspecified for timestamptz)
- unreadcounts queries per requested room instead of materialising the
whole comment keyspace
Tests:
- destroys-the-probe-session template guard is case-insensitive
74/74 tests pass.
1903 lines
86 KiB
C#
1903 lines
86 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Text.Json;
|
|
using DorkNet.Server.Auth;
|
|
using DorkNet.Server.Data;
|
|
using DorkNet.Server.Data.Entities;
|
|
using DorkNet.Server.Services;
|
|
|
|
namespace DorkNet.Server.Controllers.API.Rooms.V2;
|
|
|
|
/// <summary>
|
|
/// api.rec.net/api/rooms/v{1,2}/{modify,modifyPermissions,banfromroom,
|
|
/// importroombans,roombans,report,modify/scene,modify/sceneParent,
|
|
/// modify/tags,modifyscene} — owner-side mutation surface for rooms.
|
|
///
|
|
/// Split from <see cref="RoomsController"/> to keep that file
|
|
/// (browse / details / clone) at a manageable size. All endpoints
|
|
/// are owner-gated (the caller must equal
|
|
/// <see cref="RoomEntity.CreatorPlayerId"/>) or admin-bypassed via
|
|
/// <see cref="AdminOnlyAttribute"/> on the admin-only sub-routes.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Authorize]
|
|
public class RoomsModerationController(
|
|
DorkNetDbContext db,
|
|
IObjectStorage storage,
|
|
ILogger<RoomsModerationController> logger) : ControllerBase
|
|
{
|
|
private const int PublicAccessibility = 1;
|
|
private const long CanUseShareCamPermission = 1L << 18;
|
|
|
|
private long Me => this.RequireCurrentPlayerId();
|
|
|
|
private async Task<RoomEntity?> RequireOwnedRoomAsync(long roomId)
|
|
{
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (room is null) return null;
|
|
if (room.CreatorPlayerId == Me) return room;
|
|
|
|
var coOwner = await db.RoomRoles.AnyAsync(r =>
|
|
r.RoomId == roomId && r.PlayerId == Me && r.Accepted && r.Role == 0);
|
|
return coOwner ? room : null;
|
|
}
|
|
|
|
// ── /modify (general fields) ─────────────────────────────────────────
|
|
|
|
public sealed class ModifyRoomRequest
|
|
{
|
|
public long RoomId { get; set; }
|
|
public string? Name { get; set; }
|
|
public string? Description { get; set; }
|
|
public string? ImageName { get; set; }
|
|
public string? Tags { get; set; }
|
|
public int? Accessibility { get; set; }
|
|
public bool? SupportsScreens { get; set; }
|
|
public bool? SupportsMobile { get; set; }
|
|
public bool? SupportsVRLow { get; set; }
|
|
public bool? SupportsWalkVR { get; set; }
|
|
public bool? SupportsTeleportVR { get; set; }
|
|
public bool? DisableMicAutoMute { get; set; }
|
|
}
|
|
|
|
[HttpPost("api/rooms/v2/modify")]
|
|
public async Task<IActionResult> Modify([FromBody] ModifyRoomRequest req)
|
|
{
|
|
var room = await RequireOwnedRoomAsync(req.RoomId);
|
|
if (room is null) return NotFound();
|
|
|
|
if (!string.IsNullOrWhiteSpace(req.Name)) room.Name = req.Name.Trim();
|
|
if (req.Description is not null) room.Description = req.Description;
|
|
if (req.ImageName is not null) room.ImageName = req.ImageName;
|
|
if (req.Tags is not null) room.TagsCsv = req.Tags;
|
|
if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2);
|
|
if (req.SupportsScreens.HasValue) room.SupportsScreens = req.SupportsScreens.Value;
|
|
if (req.SupportsMobile.HasValue) room.SupportsMobile = req.SupportsMobile.Value;
|
|
if (req.SupportsVRLow.HasValue) room.SupportsVRLow = req.SupportsVRLow.Value;
|
|
if (req.SupportsWalkVR.HasValue) room.SupportsWalkVR = req.SupportsWalkVR.Value;
|
|
if (req.SupportsTeleportVR.HasValue) room.SupportsTeleportVR = req.SupportsTeleportVR.Value;
|
|
if (req.DisableMicAutoMute.HasValue) room.DisableMicAutoMute = req.DisableMicAutoMute.Value;
|
|
room.UpdatedAt = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync();
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
// Likely a unique-name collision.
|
|
return Conflict(new { Result = 2, Message = "name taken" });
|
|
}
|
|
return Ok(new { Result = 0, Room = Services.RoomService.ToWireRoom(room) });
|
|
}
|
|
|
|
public sealed class ModifyPermissionsRequest
|
|
{
|
|
public long RoomId { get; set; }
|
|
public int? Accessibility { get; set; }
|
|
public bool? SupportsLevelVoting { get; set; }
|
|
public bool? AllowsJuniors { get; set; }
|
|
public bool? CloningAllowed { get; set; }
|
|
public int? RoomWarningMask { get; set; }
|
|
public string? CustomRoomWarning { get; set; }
|
|
}
|
|
|
|
[HttpPost("api/rooms/v2/modifyPermissions")]
|
|
public async Task<IActionResult> ModifyPermissions([FromBody] ModifyPermissionsRequest req)
|
|
{
|
|
var room = await RequireOwnedRoomAsync(req.RoomId);
|
|
if (room is null) return NotFound();
|
|
|
|
if (req.Accessibility.HasValue) room.Accessibility = Math.Clamp(req.Accessibility.Value, 0, 2);
|
|
if (req.SupportsLevelVoting.HasValue) room.SupportsLevelVoting = req.SupportsLevelVoting.Value;
|
|
if (req.AllowsJuniors.HasValue) room.AllowsJuniors = req.AllowsJuniors.Value;
|
|
if (req.CloningAllowed.HasValue) room.CloningAllowed = req.CloningAllowed.Value;
|
|
if (req.RoomWarningMask.HasValue) room.RoomWarningMask = req.RoomWarningMask.Value;
|
|
if (req.CustomRoomWarning is not null) room.CustomRoomWarning = req.CustomRoomWarning;
|
|
room.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return Ok(Services.RoomService.ToWireRoom(room));
|
|
}
|
|
|
|
// ── Scene mutations (LocationReplicationId) ──────────────────────────
|
|
|
|
public sealed class ModifySceneRequest
|
|
{
|
|
public long RoomId { get; set; }
|
|
public string? LocationReplicationId { get; set; }
|
|
}
|
|
|
|
[HttpPost("api/rooms/v1/modify/scene")]
|
|
[HttpPost("api/rooms/v1/modifyscene")]
|
|
public async Task<IActionResult> ModifyScene([FromBody] ModifySceneRequest req)
|
|
{
|
|
var room = await RequireOwnedRoomAsync(req.RoomId);
|
|
if (room is null) return NotFound();
|
|
if (!string.IsNullOrWhiteSpace(req.LocationReplicationId))
|
|
room.LocationReplicationId = req.LocationReplicationId;
|
|
room.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return Ok(Services.RoomService.ToWireRoom(room));
|
|
}
|
|
|
|
public sealed class ModifySceneParentRequest
|
|
{
|
|
public long RoomId { get; set; }
|
|
public long? ParentRoomId { get; set; }
|
|
}
|
|
|
|
/// <summary>Sub-room / nested-scene parent override. We don't
|
|
/// model sub-rooms as a real graph, so we accept-and-ignore but
|
|
/// still owner-gate to prevent grief.</summary>
|
|
[HttpPost("api/rooms/v1/modify/sceneParent")]
|
|
public async Task<IActionResult> ModifySceneParent([FromBody] ModifySceneParentRequest req)
|
|
{
|
|
var room = await RequireOwnedRoomAsync(req.RoomId);
|
|
if (room is null) return NotFound();
|
|
return Ok(new { Result = 0 });
|
|
}
|
|
|
|
public sealed class ModifyTagsRequest
|
|
{
|
|
public long RoomId { get; set; }
|
|
public string? Tags { get; set; }
|
|
}
|
|
|
|
[HttpPost("api/rooms/v1/modify/tags")]
|
|
public async Task<IActionResult> ModifyTags([FromBody] ModifyTagsRequest req)
|
|
{
|
|
var room = await RequireOwnedRoomAsync(req.RoomId);
|
|
if (room is null) return NotFound();
|
|
room.TagsCsv = req.Tags ?? string.Empty;
|
|
room.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return Ok(Services.RoomService.ToWireRoom(room));
|
|
}
|
|
|
|
// ── Bans ─────────────────────────────────────────────────────────────
|
|
|
|
public sealed class BanFromRoomRequest
|
|
{
|
|
public long RoomId { get; set; }
|
|
public long PlayerId { get; set; }
|
|
public int BanType { get; set; } // 0=Soft (kick), 1=Permanent, 2=Temp
|
|
public DateTime? Until { get; set; }
|
|
public string? Reason { get; set; }
|
|
}
|
|
|
|
[HttpPost("api/rooms/v2/banfromroom")]
|
|
public async Task<IActionResult> BanFromRoom([FromBody] BanFromRoomRequest req)
|
|
{
|
|
var room = await RequireOwnedRoomAsync(req.RoomId);
|
|
if (room is null) return NotFound();
|
|
db.RoomBans.Add(new RoomBanEntity
|
|
{
|
|
RoomId = req.RoomId,
|
|
BannedPlayerId = req.PlayerId,
|
|
BannedByPlayerId = Me,
|
|
BanType = req.BanType,
|
|
Until = req.Until,
|
|
Reason = req.Reason ?? string.Empty,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
return Ok(new { Result = 0 });
|
|
}
|
|
|
|
public sealed class ImportRoomBansRequest
|
|
{
|
|
public long RoomId { get; set; }
|
|
public List<BanFromRoomRequest>? Bans { get; set; }
|
|
}
|
|
|
|
[HttpPost("api/rooms/v1/importroombans")]
|
|
public async Task<IActionResult> ImportRoomBans([FromBody] ImportRoomBansRequest req)
|
|
{
|
|
var room = await RequireOwnedRoomAsync(req.RoomId);
|
|
if (room is null) return NotFound();
|
|
if (req.Bans is null || req.Bans.Count == 0) return Ok(new { Imported = 0 });
|
|
|
|
foreach (var b in req.Bans)
|
|
{
|
|
db.RoomBans.Add(new RoomBanEntity
|
|
{
|
|
RoomId = req.RoomId,
|
|
BannedPlayerId = b.PlayerId,
|
|
BannedByPlayerId = Me,
|
|
BanType = b.BanType,
|
|
Until = b.Until,
|
|
Reason = b.Reason ?? string.Empty,
|
|
});
|
|
}
|
|
await db.SaveChangesAsync();
|
|
return Ok(new { Imported = req.Bans.Count });
|
|
}
|
|
|
|
[HttpGet("api/rooms/v1/roombans/{roomId:long}")]
|
|
public async Task<IActionResult> RoomBansList(long roomId)
|
|
{
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (room is null) return NotFound();
|
|
// Only owner sees the ban list (admins also via existing
|
|
// admin endpoints).
|
|
if (room.CreatorPlayerId != Me) return Forbid();
|
|
var rows = await db.RoomBans
|
|
.Where(b => b.RoomId == roomId && (b.Until == null || b.Until > DateTime.UtcNow))
|
|
.OrderByDescending(b => b.CreatedAt)
|
|
.ToListAsync();
|
|
return Ok(rows.Select(b => new
|
|
{
|
|
// The 2023 banned-players reader (IBHAKOOKEEE) has EXACTLY three
|
|
// members — AccountId (Int32), BannedByAccountId (Int32?) and
|
|
// BanStartTime (DateTime) — registered at
|
|
// IsilDump/RecNet.Runtime/ECEBBLBCFKO.txt:279 / :306 / :330 (the
|
|
// property types come from IBHAKOOKEEE.txt:3/:83/:103). None of
|
|
// them overlapped the legacy Id/RoomId/BannedPlayerId keys, so
|
|
// the in-room "banned players" list rendered all-zero rows.
|
|
// AccountId is Int32 on the wire, so clamp the long id.
|
|
AccountId = ToClientAccountId(b.BannedPlayerId),
|
|
BannedByAccountId = ToClientAccountId(b.BannedByPlayerId),
|
|
BanStartTime = b.CreatedAt,
|
|
// Legacy keys kept for the 2020.12 watch — both readers ignore
|
|
// members they don't know.
|
|
b.Id,
|
|
b.RoomId,
|
|
b.BannedPlayerId,
|
|
b.BannedByPlayerId,
|
|
b.BanType,
|
|
b.Until,
|
|
b.Reason,
|
|
b.CreatedAt,
|
|
}));
|
|
}
|
|
|
|
/// <summary>Account ids are Int64 server-side but Int32 in the 2023
|
|
/// wire DTOs (IBHAKOOKEEE.AccountId is System.Int32). Clamp rather than
|
|
/// wrap so an out-of-range id is obviously wrong instead of aliasing
|
|
/// onto a real account.</summary>
|
|
private static int ToClientAccountId(long accountId)
|
|
=> accountId > int.MaxValue
|
|
? int.MaxValue
|
|
: accountId < int.MinValue
|
|
? int.MinValue
|
|
: (int)accountId;
|
|
|
|
// ── Report ───────────────────────────────────────────────────────────
|
|
|
|
// The 2023 client posts the room report as x-www-form-urlencoded with
|
|
// PascalCase keys RoomId (Int64) / RoomKeyId (Int64?) / Details (string) /
|
|
// ReportCategory (Int32) — IsilDump/RecNet.Runtime/IBEOONPEELF.txt:23471
|
|
// ("api/rooms/v2/report"), verb byte 2 = POST at :23473, field names at
|
|
// :23486 / :23499 / :23506 / :23521. A [FromBody] parameter makes
|
|
// ASP.NET reject the form POST with 415 BEFORE the handler runs (the same
|
|
// mechanism documented for clone at :963-968 below), so reports were never
|
|
// persisted and the client logged "Failed to report room"
|
|
// (IBEOONPEELF.txt:23537). Read form-or-JSON manually and keep the older
|
|
// Category/Message names as aliases for the 2020.12 watch.
|
|
[HttpPost("api/rooms/v2/report")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> Report()
|
|
{
|
|
var payload = await ReadPayloadAsync();
|
|
var roomId = LongValue(payload, "RoomId", "RoomKeyId") ?? 0;
|
|
if (roomId <= 0) return BadRequest(new { error = "missing_room" });
|
|
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (room is null) return NotFound();
|
|
var msg = RawValue(payload, "Details", "Message") ?? string.Empty;
|
|
db.Reports.Add(new ReportEntity
|
|
{
|
|
ReporterPlayerId = Me,
|
|
TargetPlayerId = room.CreatorPlayerId,
|
|
TargetRoomId = roomId,
|
|
RoomId = roomId,
|
|
Category = IntValue(payload, "ReportCategory", "Category") ?? 0,
|
|
Message = msg[..Math.Min(1000, msg.Length)],
|
|
});
|
|
await db.SaveChangesAsync();
|
|
return Ok(new { Reported = true });
|
|
}
|
|
|
|
public sealed class RoomRolePermissionRequest
|
|
{
|
|
public long RoomId { get; set; }
|
|
public long PlayerId { get; set; }
|
|
}
|
|
|
|
/// <summary>POST <c>api/rooms/v1/roomRolePermissions</c> — watch
|
|
/// posts its self-computed role + permission grants for
|
|
/// server-side validation. Role enum:
|
|
/// 0=Visitor, 5=Member, 10=Mod, 20=Host, 30=Owner. Room creator
|
|
/// is always Owner; everyone else defaults to Visitor.</summary>
|
|
[HttpPost("api/rooms/v1/roomRolePermissions")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> RoomRolePermissions(
|
|
[FromBody] RoomRolePermissionRequest? body,
|
|
[FromForm(Name = "RoomId")] long? roomIdForm,
|
|
[FromForm(Name = "PlayerId")] long? playerIdForm)
|
|
{
|
|
var roomId = body?.RoomId ?? roomIdForm ?? 0;
|
|
var playerId = body?.PlayerId ?? playerIdForm ?? (this.CurrentPlayerId() ?? 0);
|
|
if (roomId <= 0 || playerId <= 0)
|
|
return Ok(new { success = true, error = "" });
|
|
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
var acceptedRoles = await db.RoomRoles
|
|
.Where(r => r.RoomId == roomId && r.PlayerId == playerId && r.Accepted)
|
|
.Select(r => r.Role)
|
|
.ToListAsync();
|
|
int role = (room?.CreatorPlayerId == playerId)
|
|
? 30
|
|
: acceptedRoles.Select(RoomRoleRank).DefaultIfEmpty(0).Max();
|
|
long permissions = role switch
|
|
{
|
|
30 => -1L,
|
|
20 => 0x0FFFFFFFL,
|
|
10 => 0x0FFFFFFFL,
|
|
5 => 0x000000FFL,
|
|
_ => 0x0000000FL,
|
|
};
|
|
// RoomRole.BJICCBAKLAF.CAN_USE_SHARE_CAM = 18 in the
|
|
// 2020.12 decompile. Public rooms should allow desktop/share
|
|
// screens without requiring host/mod/co-owner, while private
|
|
// rooms keep their explicit role gate.
|
|
if (room?.Accessibility == PublicAccessibility)
|
|
{
|
|
permissions |= CanUseShareCamPermission;
|
|
}
|
|
return Ok(new
|
|
{
|
|
success = true,
|
|
error = "",
|
|
RoomId = roomId,
|
|
PlayerId = (int)playerId,
|
|
Role = role,
|
|
Permissions = permissions,
|
|
});
|
|
}
|
|
|
|
/// <summary>POST <c>api/rooms/v1/verifyRole</c> — status-only
|
|
/// gate used by 2020.12 before privileged room actions. Body is
|
|
/// form or JSON with roomId, role, and optional context.</summary>
|
|
[HttpPost("api/rooms/v1/verifyRole")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> VerifyRole()
|
|
{
|
|
var req = await ReadVerifyRoleRequestAsync();
|
|
if (req.RoomId <= 0) return BadRequest();
|
|
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
|
|
if (room is null) return NotFound();
|
|
|
|
var me = Me;
|
|
var required = RoleRank(req.Role);
|
|
if (required <= 0) return Ok();
|
|
|
|
var isAdmin = await db.Players
|
|
.Where(p => p.Id == me)
|
|
.Select(p => p.IsAdmin)
|
|
.FirstOrDefaultAsync();
|
|
if (isAdmin || room.CreatorPlayerId == me) return Ok();
|
|
|
|
var acceptedRoles = await db.RoomRoles
|
|
.Where(r => r.RoomId == req.RoomId && r.PlayerId == me && r.Accepted)
|
|
.Select(r => r.Role)
|
|
.ToListAsync();
|
|
var actual = acceptedRoles.Select(RoomRoleRank).DefaultIfEmpty(0).Max();
|
|
return actual >= required ? Ok() : Forbid();
|
|
}
|
|
|
|
private sealed class VerifyRoleRequest
|
|
{
|
|
public long RoomId { get; set; }
|
|
public string Role { get; set; } = string.Empty;
|
|
public string Context { get; set; } = string.Empty;
|
|
}
|
|
|
|
private async Task<VerifyRoleRequest> ReadVerifyRoleRequestAsync()
|
|
{
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
return new VerifyRoleRequest
|
|
{
|
|
RoomId = long.TryParse(form["roomId"].FirstOrDefault()
|
|
?? form["RoomId"].FirstOrDefault(), out var roomId)
|
|
? roomId
|
|
: 0,
|
|
Role = form["role"].FirstOrDefault()
|
|
?? form["Role"].FirstOrDefault()
|
|
?? string.Empty,
|
|
Context = form["context"].FirstOrDefault()
|
|
?? form["Context"].FirstOrDefault()
|
|
?? string.Empty,
|
|
};
|
|
}
|
|
|
|
try
|
|
{
|
|
var req = await JsonSerializer.DeserializeAsync<VerifyRoleRequest>(
|
|
Request.Body,
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
return req ?? new VerifyRoleRequest();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return new VerifyRoleRequest();
|
|
}
|
|
}
|
|
|
|
private static int RoomRoleRank(int role) => role switch
|
|
{
|
|
0 => 30, // CoOwner
|
|
1 => 10, // Moderator
|
|
2 => 20, // Host
|
|
_ => 0,
|
|
};
|
|
|
|
private static int RoleRank(string role)
|
|
{
|
|
if (int.TryParse(role, out var n)) return n;
|
|
return role.Trim().ToLowerInvariant() switch
|
|
{
|
|
"owner" or "creator" or "coowner" or "co-owner" => 30,
|
|
"host" => 20,
|
|
"moderator" or "mod" => 10,
|
|
"member" => 5,
|
|
"visitor" or "" => 0,
|
|
_ => 0,
|
|
};
|
|
}
|
|
|
|
// ── Bare-path per-field mutations (2020.12 watch + 2023 roomserver) ──
|
|
//
|
|
// Both clients emit per-field mutation URLs like
|
|
// PUT rooms/{id}/description rather than POST api/rooms/v2/modify with
|
|
// one field set. Each handler below shares the same RequireOwnedRoomAsync
|
|
// gate as /modify and returns the FULL room-details object via
|
|
// ApplyAndReturn (see the note there).
|
|
//
|
|
// Every handler reads the body itself instead of taking a [FromBody]
|
|
// parameter: both clients send x-www-form-urlencoded, and under
|
|
// [ApiController] a [FromBody] parameter makes ASP.NET reject a form body
|
|
// with 415 BEFORE the handler runs (mechanism documented at the clone
|
|
// handler below).
|
|
//
|
|
// Each per-field route registers BOTH POST and PUT — the 2020.12
|
|
// watch's request-builder wraps the HTTP method inside opaque
|
|
// BPHGKAEDBPE helpers; the existing /name handler at
|
|
// RoomsController.cs:1455-1456 set the precedent of "register both
|
|
// because the ISIL is opaque". Same pattern here so the bind never
|
|
// 405s. (The 2023 client is unambiguous: verb byte 3 = PUT, e.g.
|
|
// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt:5369.)
|
|
//
|
|
// …and both bare and roomserver/-prefixed paths, because the 2023
|
|
// in-room roomserver client (NLDBPDCNNCF) issues all of these under the
|
|
// roomserver host prefix — a bare-only registration 404s there.
|
|
|
|
/// <summary>PUT <c>rooms/{id}/description</c> — form key
|
|
/// <c>description</c> (NLDBPDCNNCF/HBKMCDLDHHO:57).</summary>
|
|
[HttpPost("rooms/{roomId:long}/description")]
|
|
[HttpPut("rooms/{roomId:long}/description")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/description")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/description")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareDescription(long roomId)
|
|
{
|
|
// RawValue, not the whitespace-skipping reader: clearing the
|
|
// description sends the key with an empty value.
|
|
var payload = await ReadPayloadAsync();
|
|
var value = RawValue(payload, "description", "value");
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
if (value is not null) r.Description = value;
|
|
});
|
|
}
|
|
|
|
/// <summary>PUT <c>rooms/{id}/image</c> — form key <c>imageName</c>
|
|
/// (NLDBPDCNNCF/MCDHHIBPJPD:57).</summary>
|
|
[HttpPost("rooms/{roomId:long}/image")]
|
|
[HttpPut("rooms/{roomId:long}/image")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/image")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/image")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareImage(long roomId)
|
|
{
|
|
var payload = await ReadPayloadAsync();
|
|
var value = RawValue(payload, "imageName", "image", "name", "value");
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
if (value is not null) r.ImageName = value;
|
|
});
|
|
}
|
|
|
|
/// <summary>PUT <c>rooms/{id}/tags</c>. The 2023 client sends the tag
|
|
/// lists as REPEATED form keys — <c>autoTag</c> then <c>tag</c>
|
|
/// (NLDBPDCNNCF/FOPHALMJKGA:70 and :77, both fed from
|
|
/// <c>IReadOnlyList<string></c> parameters, NLDBPDCNNCF.txt:5543) —
|
|
/// NOT a single CSV field, so the old <c>Tags</c> binding read nothing
|
|
/// and the edit was a silent no-op.</summary>
|
|
[HttpPost("rooms/{roomId:long}/tags")]
|
|
[HttpPut("rooms/{roomId:long}/tags")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareTags(long roomId)
|
|
{
|
|
var payload = await ReadPayloadAsync();
|
|
var tags = Values(payload, "autoTag", "tag");
|
|
// 2020.12 watch / admin tooling: one comma-separated "Tags" field.
|
|
var csv = Value(payload, "tags");
|
|
if (csv is not null)
|
|
tags.AddRange(csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
|
|
|
// Only rewrite when the request actually carried a tag field —
|
|
// otherwise a stray/emptied request would silently wipe the room's
|
|
// tags. Sending the keys with no values IS how the client clears
|
|
// them, so key presence (not value count) is the signal.
|
|
var carriesTags = payload.ContainsKey("autoTag") || payload.ContainsKey("tag") || payload.ContainsKey("tags");
|
|
var joined = string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase));
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
if (carriesTags) r.TagsCsv = joined;
|
|
});
|
|
}
|
|
|
|
[HttpPost("rooms/{roomId:long}/accessibility")]
|
|
[HttpPut("rooms/{roomId:long}/accessibility")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareAccessibility(long roomId)
|
|
{
|
|
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
if (value is int vv) r.Accessibility = Math.Clamp(vv, 0, 2);
|
|
});
|
|
}
|
|
|
|
private async Task<int?> ReadBareIntAsync(params string[] keys)
|
|
{
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
foreach (var key in keys)
|
|
if (int.TryParse(form[key].FirstOrDefault(), out var value)) return value;
|
|
return null;
|
|
}
|
|
|
|
foreach (var key in keys)
|
|
if (int.TryParse(Request.Query[key].FirstOrDefault(), out var value)) return value;
|
|
|
|
if ((Request.ContentLength ?? 0) <= 0) return null;
|
|
try
|
|
{
|
|
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
|
foreach (var key in keys)
|
|
{
|
|
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
|
|
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n;
|
|
if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out n)) return n;
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async Task<long?> ReadBareLongAsync(params string[] keys)
|
|
{
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
foreach (var key in keys)
|
|
if (long.TryParse(form[key].FirstOrDefault(), out var value)) return value;
|
|
return null;
|
|
}
|
|
|
|
foreach (var key in keys)
|
|
if (long.TryParse(Request.Query[key].FirstOrDefault(), out var value)) return value;
|
|
|
|
if ((Request.ContentLength ?? 0) <= 0) return null;
|
|
try
|
|
{
|
|
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
|
foreach (var key in keys)
|
|
{
|
|
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
|
|
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt64(out var n)) return n;
|
|
if (prop.ValueKind == JsonValueKind.String && long.TryParse(prop.GetString(), out n)) return n;
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async Task<bool?> ReadBareBoolAsync(params string[] keys)
|
|
{
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
foreach (var key in keys)
|
|
if (TryParseBool(form[key].FirstOrDefault(), out var value)) return value;
|
|
return null;
|
|
}
|
|
|
|
foreach (var key in keys)
|
|
if (TryParseBool(Request.Query[key].FirstOrDefault(), out var value)) return value;
|
|
|
|
if ((Request.ContentLength ?? 0) <= 0) return null;
|
|
try
|
|
{
|
|
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
|
return doc.RootElement.ValueKind == JsonValueKind.True
|
|
? true
|
|
: doc.RootElement.ValueKind == JsonValueKind.False
|
|
? false
|
|
: null;
|
|
foreach (var key in keys)
|
|
{
|
|
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
|
|
if (prop.ValueKind == JsonValueKind.True) return true;
|
|
if (prop.ValueKind == JsonValueKind.False) return false;
|
|
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n != 0;
|
|
if (prop.ValueKind == JsonValueKind.String && TryParseBool(prop.GetString(), out var parsed)) return parsed;
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async Task<string?> ReadStringValueAsync(params string[] keys)
|
|
{
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
foreach (var key in keys)
|
|
{
|
|
var value = form[key].FirstOrDefault();
|
|
if (!string.IsNullOrWhiteSpace(value)) return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
foreach (var key in keys)
|
|
{
|
|
var value = Request.Query[key].FirstOrDefault();
|
|
if (!string.IsNullOrWhiteSpace(value)) return value;
|
|
}
|
|
|
|
if ((Request.ContentLength ?? 0) <= 0) return null;
|
|
try
|
|
{
|
|
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
|
if (doc.RootElement.ValueKind == JsonValueKind.String)
|
|
return doc.RootElement.GetString();
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
|
|
foreach (var key in keys)
|
|
{
|
|
if (!doc.RootElement.TryGetProperty(key, out var prop)) continue;
|
|
if (prop.ValueKind == JsonValueKind.String) return prop.GetString();
|
|
if (prop.ValueKind == JsonValueKind.Number || prop.ValueKind == JsonValueKind.True || prop.ValueKind == JsonValueKind.False)
|
|
return prop.ToString();
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// ── Multi-field payload reader ──────────────────────────────────────
|
|
//
|
|
// The single-key Read*Async helpers above each parse Request.Body, and a
|
|
// request body can only be consumed once — calling two of them in the
|
|
// same handler silently loses every field after the first. Handlers that
|
|
// need several fields (report / modify / restrictions / warning / bans /
|
|
// loadscreen / promo_external) read the payload ONCE into the map below.
|
|
//
|
|
// It is a key → values map because the 2023 client encodes list
|
|
// parameters as REPEATED form keys (tags sends tag=…&tag=…,
|
|
// bans sends id=…&id=…). Keys are matched case-insensitively, which is
|
|
// what lets one lookup serve both the 2023 camelCase names and the
|
|
// 2020.12 PascalCase ones.
|
|
|
|
private async Task<Dictionary<string, List<string>>> ReadPayloadAsync()
|
|
{
|
|
var map = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
void Add(string key, string? value)
|
|
{
|
|
if (string.IsNullOrEmpty(key) || value is null) return;
|
|
if (!map.TryGetValue(key, out var list)) map[key] = list = new List<string>();
|
|
list.Add(value);
|
|
}
|
|
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
foreach (var key in form.Keys)
|
|
foreach (var value in form[key])
|
|
Add(key, value);
|
|
}
|
|
else if ((Request.ContentLength ?? 0) > 0)
|
|
{
|
|
try
|
|
{
|
|
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
|
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
|
{
|
|
if (prop.Value.ValueKind == JsonValueKind.Array)
|
|
foreach (var item in prop.Value.EnumerateArray())
|
|
Add(prop.Name, JsonScalar(item));
|
|
else
|
|
Add(prop.Name, JsonScalar(prop.Value));
|
|
}
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Non-JSON / truncated body — fall through to the query string.
|
|
}
|
|
}
|
|
|
|
// Query string is the last-resort source (a few of these fields ride
|
|
// on the URL for the 2020.12 watch). Never let it shadow a body field.
|
|
foreach (var key in Request.Query.Keys)
|
|
{
|
|
if (map.ContainsKey(key)) continue;
|
|
foreach (var value in Request.Query[key])
|
|
Add(key, value);
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
private static string? JsonScalar(JsonElement element) => element.ValueKind switch
|
|
{
|
|
JsonValueKind.String => element.GetString(),
|
|
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => element.ToString(),
|
|
_ => null,
|
|
};
|
|
|
|
/// <summary>First NON-EMPTY value for any of <paramref name="keys"/>.</summary>
|
|
private static string? Value(Dictionary<string, List<string>> map, params string[] keys)
|
|
{
|
|
foreach (var key in keys)
|
|
{
|
|
if (!map.TryGetValue(key, out var list)) continue;
|
|
foreach (var value in list)
|
|
if (!string.IsNullOrWhiteSpace(value)) return value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// <summary>First value for any of <paramref name="keys"/> INCLUDING the
|
|
/// empty string. Text fields (description, customWarning) are cleared by
|
|
/// sending the key with an empty value, so the non-empty filter in
|
|
/// <see cref="Value"/> would turn "clear" into a no-op.</summary>
|
|
private static string? RawValue(Dictionary<string, List<string>> map, params string[] keys)
|
|
{
|
|
foreach (var key in keys)
|
|
if (map.TryGetValue(key, out var list) && list.Count > 0)
|
|
return list[0];
|
|
return null;
|
|
}
|
|
|
|
/// <summary>Every non-empty value across <paramref name="keys"/>, in key
|
|
/// order. Keys that differ only by case are visited once (the map is
|
|
/// case-insensitive, so they'd otherwise duplicate the same list).</summary>
|
|
private static List<string> Values(Dictionary<string, List<string>> map, params string[] keys)
|
|
{
|
|
var result = new List<string>();
|
|
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var key in keys)
|
|
{
|
|
if (!seen.Add(key)) continue;
|
|
if (!map.TryGetValue(key, out var list)) continue;
|
|
result.AddRange(list.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.Trim()));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static int? IntValue(Dictionary<string, List<string>> map, params string[] keys)
|
|
=> int.TryParse(Value(map, keys), out var value) ? value : null;
|
|
|
|
private static long? LongValue(Dictionary<string, List<string>> map, params string[] keys)
|
|
=> long.TryParse(Value(map, keys), out var value) ? value : null;
|
|
|
|
private static bool? BoolValue(Dictionary<string, List<string>> map, params string[] keys)
|
|
=> TryParseBool(Value(map, keys), out var value) ? value : null;
|
|
|
|
private static bool TryParseBool(string? raw, out bool value)
|
|
{
|
|
if (bool.TryParse(raw, out value)) return true;
|
|
if (int.TryParse(raw, out var n))
|
|
{
|
|
value = n != 0;
|
|
return true;
|
|
}
|
|
value = false;
|
|
return false;
|
|
}
|
|
|
|
private static List<JsonElement> ReadJsonElementList(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json)) return new List<JsonElement>();
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<JsonElement[]>(json)?.ToList() ?? new List<JsonElement>();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return new List<JsonElement>();
|
|
}
|
|
}
|
|
|
|
private static List<string> ReadStringList(string? json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json)) return new List<string>();
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<string[]>(json)?.Where(v => !string.IsNullOrWhiteSpace(v)).ToList()
|
|
?? new List<string>();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return new List<string>();
|
|
}
|
|
}
|
|
|
|
private static string SerializeLimited<T>(IEnumerable<T> values, int maxChars = 4096)
|
|
{
|
|
var json = JsonSerializer.Serialize(values);
|
|
return json.Length <= maxChars ? json : "[]";
|
|
}
|
|
|
|
/// <summary>PUT <c>rooms/{id}/cloning</c> — form key
|
|
/// <c>cloningAllowed</c> (NLDBPDCNNCF/ECMHAPMBNMA:67).</summary>
|
|
[HttpPost("rooms/{roomId:long}/cloning")]
|
|
[HttpPut("rooms/{roomId:long}/cloning")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareCloning(long roomId)
|
|
{
|
|
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
if (value is bool v) r.CloningAllowed = v;
|
|
});
|
|
}
|
|
|
|
/// <summary>PUT <c>rooms/{id}/automute</c>. The form key is
|
|
/// <c>disable</c>, NOT DisableMicAutoMute
|
|
/// (NLDBPDCNNCF/MAICLJKDBOB:67, from
|
|
/// <c>ChangeRoomMicAutoMute(bool)</c>) — true means "mic auto-mute is
|
|
/// disabled", so it maps straight onto DisableMicAutoMute.</summary>
|
|
[HttpPost("rooms/{roomId:long}/automute")]
|
|
[HttpPut("rooms/{roomId:long}/automute")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareAutomute(long roomId)
|
|
{
|
|
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
if (value is bool v) r.DisableMicAutoMute = v;
|
|
});
|
|
}
|
|
|
|
/// <summary>PUT <c>rooms/{id}/restrictions</c> — the "who can play here"
|
|
/// panel. The 2023 client sends FOUR booleans in one request:
|
|
/// <c>supportsScreens</c>, <c>supportsWalkVR</c>,
|
|
/// <c>supportsTeleportVR</c>, <c>supportsJuniors</c>
|
|
/// (NLDBPDCNNCF/GNCDFBNCADM:26/:39/:52/:65, matching the four-bool
|
|
/// signature at NLDBPDCNNCF.txt:6025). The old single-bool
|
|
/// AllowsJuniors binding dropped three of the four settings.</summary>
|
|
[HttpPost("rooms/{roomId:long}/restrictions")]
|
|
[HttpPut("rooms/{roomId:long}/restrictions")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareRestrictions(long roomId)
|
|
{
|
|
var payload = await ReadPayloadAsync();
|
|
var screens = BoolValue(payload, "supportsScreens");
|
|
var walkVR = BoolValue(payload, "supportsWalkVR");
|
|
var teleportVR = BoolValue(payload, "supportsTeleportVR");
|
|
// 2020.12 watch sent a bare AllowsJuniors/Value instead.
|
|
var juniors = BoolValue(payload, "supportsJuniors", "allowsJuniors", "value");
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
if (screens is bool s) r.SupportsScreens = s;
|
|
if (walkVR is bool w) r.SupportsWalkVR = w;
|
|
if (teleportVR is bool t) r.SupportsTeleportVR = t;
|
|
if (juniors is bool j) r.AllowsJuniors = j;
|
|
});
|
|
}
|
|
|
|
/// <summary>PUT <c>rooms/{id}/warning</c> — content warnings. Form keys
|
|
/// are <c>warningMask</c> (int) and <c>customWarning</c> (string)
|
|
/// (NLDBPDCNNCF/AIDDFKACPLN:80 and :89), not the RoomWarningMask /
|
|
/// CustomRoomWarning names the old [FromBody] DTO used.</summary>
|
|
[HttpPost("rooms/{roomId:long}/warning")]
|
|
[HttpPut("rooms/{roomId:long}/warning")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/warning")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareWarning(long roomId)
|
|
{
|
|
var payload = await ReadPayloadAsync();
|
|
var mask = IntValue(payload, "warningMask", "RoomWarningMask");
|
|
// RawValue so clearing the custom warning (empty string) sticks.
|
|
var custom = RawValue(payload, "customWarning", "CustomRoomWarning");
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
if (mask is int m) r.RoomWarningMask = m;
|
|
if (custom is not null) r.CustomRoomWarning = custom.Length > 512 ? custom[..512] : custom;
|
|
});
|
|
}
|
|
|
|
[HttpPost("rooms/{roomId:long}/creator")]
|
|
[HttpPut("rooms/{roomId:long}/creator")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareCreator(long roomId)
|
|
{
|
|
var creatorId = await ReadBareLongAsync(
|
|
"creatorAccountId", "CreatorAccountId",
|
|
"creatorPlayerId", "CreatorPlayerId",
|
|
"accountId", "AccountId",
|
|
"value", "Value");
|
|
if (creatorId is null or <= 0) return BadRequest(new { error = "missing_creator" });
|
|
var exists = await db.Players.AnyAsync(p => p.Id == creatorId.Value);
|
|
if (!exists) return NotFound(new { error = "creator_not_found", creatorId });
|
|
return await ApplyAndReturn(roomId, r => r.CreatorPlayerId = creatorId.Value);
|
|
}
|
|
|
|
[HttpPost("rooms/{roomId:long}/allow_new_users")]
|
|
[HttpPut("rooms/{roomId:long}/allow_new_users")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/allow_new_users")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/allow_new_users")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareAllowNewUsers(long roomId)
|
|
{
|
|
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
if (value is bool v) r.AllowNewUsers = v;
|
|
});
|
|
}
|
|
|
|
[HttpPost("rooms/{roomId:long}/min_level")]
|
|
[HttpPut("rooms/{roomId:long}/min_level")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/min_level")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/min_level")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareMinLevel(long roomId)
|
|
{
|
|
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
if (value is int v) r.MinLevel = Math.Max(0, v);
|
|
});
|
|
}
|
|
|
|
[HttpPost("rooms/{roomId:long}/max_player_calculation_mode")]
|
|
[HttpPut("rooms/{roomId:long}/max_player_calculation_mode")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/max_player_calculation_mode")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
|
|
{
|
|
var value = await ReadBareIntAsync(
|
|
"maxPlayerCalculationMode", "MaxPlayerCalculationMode",
|
|
"mode", "Mode",
|
|
"value", "Value");
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
if (value is int v) r.MaxPlayerCalculationMode = Math.Max(0, v);
|
|
});
|
|
}
|
|
|
|
/// <summary>PUT <c>rooms/{id}/loadscreen</c> — form keys
|
|
/// <c>imageName</c> / <c>title</c> / <c>subtitle</c>
|
|
/// (NLDBPDCNNCF/AFBGGBEMOGH:83/:93/:103, verb byte 3 = PUT at
|
|
/// NLDBPDCNNCF.txt:8519). Builds the exact three-member object the
|
|
/// client's load-screen reader wants — ImageName / Title / Subtitle
|
|
/// (EOIMIBBJBCB.txt:255/:282/:298) — and APPENDS it. The old code
|
|
/// serialised the whole form dict as one blob and REPLACED the list, so
|
|
/// a room could never hold more than one load screen.</summary>
|
|
[HttpPost("rooms/{roomId:long}/loadscreen")]
|
|
[HttpPut("rooms/{roomId:long}/loadscreen")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareLoadScreen(long roomId)
|
|
{
|
|
var payload = await ReadPayloadAsync();
|
|
var imageName = Value(payload, "imageName", "name", "value") ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(imageName)) return BadRequest(new { error = "missing_image" });
|
|
var title = RawValue(payload, "title") ?? string.Empty;
|
|
var subtitle = RawValue(payload, "subtitle") ?? string.Empty;
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
// Re-setting an existing image replaces that entry rather than
|
|
// stacking a duplicate.
|
|
var screens = ReadJsonElementList(r.LoadScreensJson)
|
|
.Where(s => !LoadScreenMatches(s, imageName))
|
|
.ToList();
|
|
screens.Add(JsonSerializer.SerializeToElement(new
|
|
{
|
|
ImageName = imageName,
|
|
Title = title,
|
|
Subtitle = subtitle,
|
|
}));
|
|
r.LoadScreensJson = SerializeLimited(screens);
|
|
});
|
|
}
|
|
|
|
/// <summary>DELETE <c>rooms/{id}/loadscreen</c> — "remove room loading
|
|
/// screen" (NLDBPDCNNCF.txt:8677, verb byte 4 = DELETE at :8678), form
|
|
/// key <c>imageName</c> (NLDBPDCNNCF/DMOMLNEJHGE:61). Was not registered
|
|
/// at all, so removing a load screen 405'd.</summary>
|
|
[HttpDelete("rooms/{roomId:long}/loadscreen")]
|
|
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareLoadScreenRemove(long roomId)
|
|
{
|
|
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
|
|
?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(imageName)) return BadRequest(new { error = "missing_image" });
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
var screens = ReadJsonElementList(r.LoadScreensJson)
|
|
.Where(s => !LoadScreenMatches(s, imageName))
|
|
.ToList();
|
|
r.LoadScreensJson = SerializeLimited(screens);
|
|
});
|
|
}
|
|
|
|
/// <summary>A stored load screen matches when its ImageName does — that
|
|
/// is the only identity the client sends on remove.</summary>
|
|
private static bool LoadScreenMatches(JsonElement item, string imageName)
|
|
{
|
|
if (item.ValueKind == JsonValueKind.String)
|
|
return string.Equals(item.GetString(), imageName, StringComparison.OrdinalIgnoreCase);
|
|
if (item.ValueKind != JsonValueKind.Object) return false;
|
|
foreach (var key in new[] { "ImageName", "imageName", "Name", "name" })
|
|
{
|
|
if (item.TryGetProperty(key, out var prop) &&
|
|
prop.ValueKind == JsonValueKind.String &&
|
|
string.Equals(prop.GetString(), imageName, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
[HttpPost("rooms/{roomId:long}/promo_images")]
|
|
[HttpPut("rooms/{roomId:long}/promo_images")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/promo_images")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
|
|
{
|
|
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
|
if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" });
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
var images = ReadStringList(r.PromoImagesJson);
|
|
if (!images.Contains(image, StringComparer.OrdinalIgnoreCase))
|
|
images.Add(image);
|
|
r.PromoImagesJson = SerializeLimited(images);
|
|
});
|
|
}
|
|
|
|
[HttpDelete("rooms/{roomId:long}/promo_images")]
|
|
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
|
|
{
|
|
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
|
|
if (string.IsNullOrWhiteSpace(image)) return BadRequest(new { error = "missing_image" });
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
var images = ReadStringList(r.PromoImagesJson)
|
|
.Where(v => !string.Equals(v, image, StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
r.PromoImagesJson = SerializeLimited(images);
|
|
});
|
|
}
|
|
|
|
/// <summary>PUT <c>rooms/{id}/promo_external</c> — "add room promotional
|
|
/// content" (verb byte 3 = PUT at NLDBPDCNNCF.txt:8181). Both the add and
|
|
/// the remove closures send exactly two form keys: <c>type</c> — a BOXED
|
|
/// System.Int32 (NLDBPDCNNCF/OJAKIAHKALI:017 boxes typeof(System.Int32),
|
|
/// key at :022) — and <c>reference</c>, a string (:031).
|
|
///
|
|
/// The old code stored the raw form dictionary, so Type landed on the
|
|
/// wire as the STRING "1"; the client's promo-external reader
|
|
/// (EPPPACFECMH.txt:191/:210 registers Type + Reference, and :233 reads
|
|
/// Type through the numeric accessor FAHHHNKECAB while :254 reads
|
|
/// Reference through the string accessor BKKEIINKNCL) rejects a string
|
|
/// there. Build the two members explicitly and typed.</summary>
|
|
[HttpPost("rooms/{roomId:long}/promo_external")]
|
|
[HttpPut("rooms/{roomId:long}/promo_external")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/promo_external")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/promo_external")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
|
|
{
|
|
var payload = await ReadPayloadAsync();
|
|
var reference = Value(payload, "reference", "Reference", "url", "Url", "value", "Value");
|
|
if (string.IsNullOrWhiteSpace(reference))
|
|
return BadRequest(new { error = "missing_promo_external" });
|
|
var type = IntValue(payload, "type", "Type") ?? 0;
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
var items = ReadJsonElementList(r.PromoExternalContentJson)
|
|
.Where(i => !PromoExternalMatches(i, type, reference))
|
|
.ToList();
|
|
items.Add(JsonSerializer.SerializeToElement(new
|
|
{
|
|
Type = type,
|
|
Reference = reference,
|
|
}));
|
|
r.PromoExternalContentJson = SerializeLimited(items);
|
|
});
|
|
}
|
|
|
|
/// <summary>DELETE <c>rooms/{id}/promo_external</c> — "remove room
|
|
/// promotional content" (verb byte 4 = DELETE at NLDBPDCNNCF.txt:8343).
|
|
/// Identity is the (<c>type</c>, <c>reference</c>) PAIR
|
|
/// (NLDBPDCNNCF/LLFPMJMCLBP:022 and :031), not an id/url — the old
|
|
/// id/url/value lookup always missed, so every remove 400'd with
|
|
/// missing_promo_external and promo links could not be deleted.</summary>
|
|
[HttpDelete("rooms/{roomId:long}/promo_external")]
|
|
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
|
|
{
|
|
var payload = await ReadPayloadAsync();
|
|
var reference = Value(payload, "reference", "Reference", "id", "Id", "url", "Url", "value", "Value");
|
|
if (string.IsNullOrWhiteSpace(reference))
|
|
return BadRequest(new { error = "missing_promo_external" });
|
|
// type is absent on the 2020.12 watch's remove — treat "no type" as
|
|
// "match on reference alone".
|
|
var type = IntValue(payload, "type", "Type");
|
|
return await ApplyAndReturn(roomId, r =>
|
|
{
|
|
var items = ReadJsonElementList(r.PromoExternalContentJson)
|
|
.Where(i => !PromoExternalMatches(i, type, reference))
|
|
.ToList();
|
|
r.PromoExternalContentJson = SerializeLimited(items);
|
|
});
|
|
}
|
|
|
|
/// <summary>A stored promo-external entry matches when its Reference
|
|
/// does and (when the caller supplied one) its Type agrees. Legacy rows
|
|
/// written before the typed shape landed may be bare strings or carry
|
|
/// Url/Id instead of Reference, so those spellings are still probed.
|
|
/// </summary>
|
|
private static bool PromoExternalMatches(JsonElement item, int? type, string reference)
|
|
{
|
|
if (item.ValueKind == JsonValueKind.String)
|
|
return string.Equals(item.GetString(), reference, StringComparison.OrdinalIgnoreCase);
|
|
if (item.ValueKind != JsonValueKind.Object) return false;
|
|
|
|
var referenceMatches = false;
|
|
foreach (var key in new[] { "Reference", "reference", "Url", "url", "Id", "id", "Value", "value" })
|
|
{
|
|
if (item.TryGetProperty(key, out var prop) &&
|
|
prop.ValueKind == JsonValueKind.String &&
|
|
string.Equals(prop.GetString(), reference, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
referenceMatches = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!referenceMatches) return false;
|
|
if (type is not int wanted) return true;
|
|
|
|
foreach (var key in new[] { "Type", "type" })
|
|
{
|
|
if (!item.TryGetProperty(key, out var prop)) continue;
|
|
if (prop.ValueKind == JsonValueKind.Number && prop.TryGetInt32(out var n)) return n == wanted;
|
|
if (prop.ValueKind == JsonValueKind.String && int.TryParse(prop.GetString(), out n)) return n == wanted;
|
|
}
|
|
// Untyped legacy row — reference alone is enough.
|
|
return true;
|
|
}
|
|
|
|
/// <summary><c>rooms/{id}/comments</c> (form key <c>disable</c>, Boolean —
|
|
/// NLDBPDCNNCF/EKLEDLOPLJB:015/:020) and
|
|
/// <c>rooms/{id}/voice_chat_encryption</c> (form key
|
|
/// <c>encryptVoiceChat</c>, Boolean — NLDBPDCNNCF/NDKDDENPHJP:015/:020).
|
|
/// Both are PUT (verb byte 3 at NLDBPDCNNCF.txt:6879 and :7033) and both
|
|
/// are issued under the roomserver/ prefix by the 2023 in-room settings
|
|
/// panel — the missing twins made them 404 there.
|
|
///
|
|
/// The VALUES still aren't persisted: RoomEntity has no
|
|
/// DisableRoomComments / EncryptVoiceChat column, so RoomService.cs:1122
|
|
/// and RoomsController.cs:2130 hardcode both to false on the wire.
|
|
/// Adding those columns (plus a migration and the two detail builders)
|
|
/// is outside this file. Until then this stays an owner-gated
|
|
/// acknowledgement that answers with the full details object the call
|
|
/// site's reader [0x1884C1948] expects, so the panel doesn't report a
|
|
/// save failure on top of the toggle not sticking.</summary>
|
|
[HttpPost("rooms/{roomId:long}/comments")]
|
|
[HttpPut("rooms/{roomId:long}/comments")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/comments")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/comments")]
|
|
[HttpPost("rooms/{roomId:long}/voice_chat_encryption")]
|
|
[HttpPut("rooms/{roomId:long}/voice_chat_encryption")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/voice_chat_encryption")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareAck(long roomId) =>
|
|
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
|
|
|
|
/// <summary>PUT <c>rooms/{id}/modify</c> — the room-settings panel's bulk
|
|
/// save (verb byte 3 = PUT at NLDBPDCNNCF.txt:10829, "modify room"), and
|
|
/// the 2023 client issues it under the roomserver/ prefix too.
|
|
///
|
|
/// The body is x-www-form-urlencoded with ELEVEN camelCase keys, emitted
|
|
/// in this order by NLDBPDCNNCF/MMMNLOBFFKO: <c>name</c> (string, :039),
|
|
/// <c>description</c> (string, :049), <c>accessibility</c> (Int32, boxed
|
|
/// at :059 / key :063), then eight Booleans — <c>supportsScreens</c>
|
|
/// (:076), <c>supportsWalkVR</c> (:089), <c>supportsTeleportVR</c>
|
|
/// (:102), <c>supportsJuniors</c> (:115), <c>cloningAllowed</c> (:128),
|
|
/// <c>disableMicAutoMute</c> (:141), <c>disableRoomComments</c> (:154)
|
|
/// and <c>encryptVoiceChat</c> (:167).
|
|
///
|
|
/// The old <c>[FromBody] ModifyRoomRequest</c> forwarder 415'd on that
|
|
/// form body before the handler ran, was missing four of the fields, and
|
|
/// answered <c>{Result, Room}</c> — but the call site attaches the full
|
|
/// details reader [0x1884C1948] (:10826), the same one clone uses, so
|
|
/// the response must be BuildRoomServerDetails.
|
|
///
|
|
/// disableRoomComments / encryptVoiceChat are deliberately NOT read here:
|
|
/// RoomEntity has no column for either, so there is nowhere to put them
|
|
/// (see <see cref="BareAck"/> above).</summary>
|
|
[HttpPost("rooms/{roomId:long}/modify")]
|
|
[HttpPut("rooms/{roomId:long}/modify")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
|
|
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareModify(long roomId)
|
|
{
|
|
var payload = await ReadPayloadAsync();
|
|
var name = Value(payload, "name", "Name");
|
|
// RawValue for description: clearing it sends the key with "".
|
|
var description = RawValue(payload, "description", "Description");
|
|
var accessibility = IntValue(payload, "accessibility", "Accessibility");
|
|
var imageName = Value(payload, "imageName", "ImageName");
|
|
var screens = BoolValue(payload, "supportsScreens", "SupportsScreens");
|
|
var walkVR = BoolValue(payload, "supportsWalkVR", "SupportsWalkVR");
|
|
var teleportVR = BoolValue(payload, "supportsTeleportVR", "SupportsTeleportVR");
|
|
var juniors = BoolValue(payload, "supportsJuniors", "AllowsJuniors");
|
|
var cloning = BoolValue(payload, "cloningAllowed", "CloningAllowed");
|
|
var autoMute = BoolValue(payload, "disableMicAutoMute", "DisableMicAutoMute");
|
|
var mobile = BoolValue(payload, "supportsMobile", "SupportsMobile");
|
|
var vrLow = BoolValue(payload, "supportsVRLow", "SupportsVRLow");
|
|
|
|
var room = await RequireOwnedRoomAsync(roomId);
|
|
if (room is null) return NotFound();
|
|
|
|
if (!string.IsNullOrWhiteSpace(name)) room.Name = name.Trim();
|
|
if (description is not null) room.Description = description;
|
|
if (imageName is not null) room.ImageName = imageName;
|
|
if (accessibility is int a) room.Accessibility = Math.Clamp(a, 0, 2);
|
|
if (screens is bool s) room.SupportsScreens = s;
|
|
if (walkVR is bool w) room.SupportsWalkVR = w;
|
|
if (teleportVR is bool t) room.SupportsTeleportVR = t;
|
|
if (juniors is bool j) room.AllowsJuniors = j;
|
|
if (cloning is bool c) room.CloningAllowed = c;
|
|
if (autoMute is bool m) room.DisableMicAutoMute = m;
|
|
if (mobile is bool mo) room.SupportsMobile = mo;
|
|
if (vrLow is bool vl) room.SupportsVRLow = vl;
|
|
room.UpdatedAt = DateTime.UtcNow;
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync();
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
// Likely a unique-name collision.
|
|
return Conflict(new { Result = 2, Message = "name taken" });
|
|
}
|
|
return Ok(await BuildRoomDetailsAsync(room));
|
|
}
|
|
|
|
/// <summary>POST <c>rooms/{id}/clone</c> — bare-path alias of
|
|
/// <c>api/rooms/v1/clone</c>. The actual clone logic lives on
|
|
/// <see cref="DorkNet.Server.Controllers.API.Rooms.V2.RoomsController"/>;
|
|
/// this handler proxies via the service so we don't duplicate the
|
|
/// substantial clone-rooms logic.</summary>
|
|
// The 2023 client posts the new name as an x-www-form-urlencoded field
|
|
// (`name=...`), NOT JSON. A `[FromBody]` parameter demands a JSON
|
|
// content-type and makes ASP.NET reject the form POST with 415 BEFORE the
|
|
// handler runs → the client reports "Failed to clone room" and boots the
|
|
// player to their dorm. Accept all three content types and read the name
|
|
// manually from form / JSON body / query.
|
|
// The 2023 in-room "copy room" flow (RecNet.Runtime NLDBPDCNNCF, the
|
|
// roomserver client that also sends the subrooms/... mutations) POSTs
|
|
// this with the roomserver/ prefix — bare-only routing 404s with an
|
|
// empty body, which the client's promise layer surfaces as a
|
|
// message-less exception ("Failed to copy room: Exception of type
|
|
// '...' was thrown"). Same fix as maxplayers/modify/name.
|
|
[HttpPost("rooms/{roomId:long}/clone")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/clone")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareClone(long roomId)
|
|
{
|
|
var newName = await ReadCloneNameAsync();
|
|
var source = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
|
if (source is null) return NotFound();
|
|
// RRO/AG rooms are first-party templates meant to be cloned — the
|
|
// room-creation "base room" picker starts a new build by cloning one
|
|
// (RecCenter, MakerRoom, …). Their seed rows have CloningAllowed=false,
|
|
// so without the IsAGRoom exception every base-room create 403s.
|
|
if (!source.CloningAllowed && !source.IsAGRoom && source.CreatorPlayerId != Me)
|
|
return Forbid();
|
|
|
|
if (string.IsNullOrWhiteSpace(newName))
|
|
newName = $"{source.Name}Copy";
|
|
else
|
|
newName = newName.Trim();
|
|
|
|
// Uniqueness — append a numeric suffix until free.
|
|
var baseName = newName;
|
|
int suffix = 1;
|
|
while (await db.Rooms.AnyAsync(r => r.Name == newName))
|
|
{
|
|
suffix += 1;
|
|
newName = $"{baseName}{suffix}";
|
|
}
|
|
|
|
// Assign an explicit user-room id (> 1000), the same way
|
|
// RoomService.CloneAsync does. Relying on DB auto-increment is unsafe:
|
|
// seeded rooms were inserted with manual ids (100..1000) without
|
|
// advancing the Postgres identity sequence, so auto-increment collides
|
|
// with a seeded id → PK violation → the clone POST 500s → the client
|
|
// reports "Failed to clone room" and boots the player to their dorm.
|
|
// (SQLite in tests tracks max(id), which is why the probe passed.)
|
|
var nextId = await db.Rooms
|
|
.Where(r => r.Id > 1000)
|
|
.MaxAsync(r => (long?)r.Id) ?? 1000L;
|
|
|
|
var clone = new RoomEntity
|
|
{
|
|
Id = nextId + 1,
|
|
Name = newName,
|
|
Description = source.Description,
|
|
CreatorPlayerId = Me,
|
|
ImageName = source.ImageName,
|
|
State = 0,
|
|
// Clones start PRIVATE regardless of the source's visibility —
|
|
// a fresh copy is the owner's workbench, not something the
|
|
// public should matchmake into until they flip it public
|
|
// themselves (rooms/{id}/accessibility).
|
|
Accessibility = 0,
|
|
SupportsLevelVoting = source.SupportsLevelVoting,
|
|
// Inherit IsAGRoom from the source. This is REQUIRED for cloning a
|
|
// baked RRO room (RecCenter, the games): the client uses IsRRO to
|
|
// decide how to load the scene — RRO rooms load baked geometry from
|
|
// the client's own assets, non-RRO rooms load from a data blob. A
|
|
// non-RRO clone pointing at a baked RRO scene can't load ("Room
|
|
// Load faulted" → "Failed to copy room"). Marking the clone AG lets
|
|
// it load the same baked scene, and the RRO MakerPen overlay makes
|
|
// it editable. Safe for admin: the purge gate and "RR Original"
|
|
// badge both key on CreatorPlayerId == SystemAccountId, so a
|
|
// user-owned clone is still shown as Custom and stays purgeable.
|
|
IsAGRoom = source.IsAGRoom,
|
|
IsDormRoom = false,
|
|
CloningAllowed = source.CloningAllowed,
|
|
SupportsVRLow = source.SupportsVRLow,
|
|
SupportsMobile = source.SupportsMobile,
|
|
SupportsScreens = source.SupportsScreens,
|
|
SupportsWalkVR = source.SupportsWalkVR,
|
|
SupportsTeleportVR = source.SupportsTeleportVR,
|
|
AllowsJuniors = source.AllowsJuniors,
|
|
AllowNewUsers = source.AllowNewUsers,
|
|
MinLevel = source.MinLevel,
|
|
MaxPlayerCalculationMode = source.MaxPlayerCalculationMode,
|
|
LoadScreensJson = source.LoadScreensJson,
|
|
PromoImagesJson = source.PromoImagesJson,
|
|
PromoExternalContentJson = source.PromoExternalContentJson,
|
|
RoomWarningMask = source.RoomWarningMask,
|
|
CustomRoomWarning = source.CustomRoomWarning,
|
|
DisableMicAutoMute = source.DisableMicAutoMute,
|
|
LocationReplicationId = source.LocationReplicationId,
|
|
IsStudioRoom = source.IsStudioRoom,
|
|
IsRoomLinkedToRecRoomStudio = source.IsRoomLinkedToRecRoomStudio,
|
|
StudioSessionId = source.StudioSessionId,
|
|
// A user clone is community content — NOT an RRO. Copying the
|
|
// source's tags verbatim would keep "recroomoriginal,quest,…" on
|
|
// the clone, making the client treat it as a quest room (wrong
|
|
// badge, and quest-instance logic can boot the player). Match
|
|
// RoomService.CloneAsync and tag it "community".
|
|
TagsCsv = "community",
|
|
// Carry the source's blob NAME here; GiveCloneItsOwnBlobsAsync
|
|
// below replaces it with a copy under the clone's own name, so
|
|
// nothing ends up shared.
|
|
//
|
|
// First-party RRO templates (RecCenter and friends) used to be
|
|
// emptied here on the reasoning that their content is baked client
|
|
// geometry and any blob is just a MakerPen overlay. That left the
|
|
// clone with no data at all: the CDN synthesises a ~1.8 KB stub for
|
|
// a name it has nothing stored under, the client fetches it through
|
|
// GetRoomData ("/room/{blob}") as part of the copy, and rejects it.
|
|
// A clone that kept real content joined fine. Since the blob is now
|
|
// copied rather than referenced, the original worry — inheriting an
|
|
// overlay on a SHARED template — no longer applies.
|
|
CurrentDataBlobName = source.CurrentDataBlobName,
|
|
};
|
|
db.Rooms.Add(clone);
|
|
await db.SaveChangesAsync();
|
|
|
|
// Copy the source's scenes (subrooms) so the clone is structurally
|
|
// complete. Without this the clone has zero subrooms and the client's
|
|
// room reader rejects it. Shallow copy — scenes point at the same data
|
|
// blobs (copy-on-write happens when the clone is later saved).
|
|
var sourceScenes = await db.RoomScenes
|
|
.Where(s => s.RoomId == source.Id)
|
|
.OrderBy(s => s.OrderIndex)
|
|
.ToListAsync();
|
|
// Scene ids need to be explicit for the same reason room ids do —
|
|
// RoomSceneEntity.Id is an auto/identity column whose Postgres sequence
|
|
// lags behind the manually-seeded rows, so relying on DB generation
|
|
// collides and the whole clone SaveChanges 500s.
|
|
var nextSceneId = await db.RoomScenes.MaxAsync(s => (long?)s.Id) ?? 0L;
|
|
var clonedScenes = sourceScenes.Select(s => new RoomSceneEntity
|
|
{
|
|
Id = ++nextSceneId,
|
|
RoomId = clone.Id,
|
|
OrderIndex = s.OrderIndex,
|
|
Name = s.Name,
|
|
RoomSceneLocationId = s.RoomSceneLocationId,
|
|
// See CurrentDataBlobName above: copied under the clone's own
|
|
// name by GiveCloneItsOwnBlobsAsync, never shared.
|
|
DataBlobName = s.DataBlobName,
|
|
StudioSubRoomDataSaveId = s.StudioSubRoomDataSaveId,
|
|
StudioUnityAssetId = s.StudioUnityAssetId,
|
|
StudioAssetBundleNamesCsv = s.StudioAssetBundleNamesCsv,
|
|
MaxPlayers = s.MaxPlayers,
|
|
IsSandbox = s.IsSandbox,
|
|
CanMatchmakeInto = s.CanMatchmakeInto,
|
|
DataModifiedAt = DateTime.UtcNow,
|
|
}).ToList();
|
|
if (clonedScenes.Count > 0)
|
|
{
|
|
db.RoomScenes.AddRange(clonedScenes);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
await GiveCloneItsOwnBlobsAsync(clone, sourceScenes, clonedScenes, Me);
|
|
|
|
// The 2023 client's clone (RecNet.Runtime NLDBPDCNNCF.GDHIIAHCBMN)
|
|
// deserializes the response as the FULL room-details object
|
|
// (FGCPNAACHIK — the same type get-by-id / rename return on the
|
|
// roomserver host), NOT the slim list-room shape. This is the
|
|
// roomserver-family path (rooms/{id}/clone), so use
|
|
// BuildRoomServerDetails — the exact shape GET rooms/{id} returns for
|
|
// the 2023 client. Returning ToWireRoom (or the wrong details variant)
|
|
// made the strict reader fail → "Failed to copy room: Failed to clone
|
|
// room".
|
|
return Ok(RoomsController.BuildRoomServerDetails(clone, clonedScenes));
|
|
}
|
|
/// <summary>Give a freshly-cloned room its OWN copy of the source's save
|
|
/// data instead of pointing its scenes at the source's blob.
|
|
///
|
|
/// The shallow copy this replaces relied on copy-on-write: the clone
|
|
/// referenced <c>room_<source>_*.dat</c> until someone saved over it.
|
|
/// That left every clone of a room sharing one object, so a clone read as
|
|
/// "already has content" while owning none of it, and the room a player
|
|
/// copied stayed load-bearing for their copy forever. Cloning RecCenter
|
|
/// produced rooms whose data blob was literally <c>room_100_v1.dat</c>.
|
|
///
|
|
/// Each sub-room's bytes are copied to a name derived from the CLONE's id,
|
|
/// so <see cref="BlobRouter"/> files them under that room's own prefix.
|
|
/// Sub-room 0 takes the canonical default name, which matters because the
|
|
/// details builder falls back to exactly that name when a room has no
|
|
/// recorded blob — writing the copy there means both paths resolve to real
|
|
/// content.
|
|
///
|
|
/// A missing source object is not fatal: the clone keeps an empty blob name
|
|
/// and behaves like a fresh room, which is the same outcome as cloning a
|
|
/// room that never had a save. Failing the whole clone would be worse — the
|
|
/// room and its sub-rooms are already committed by this point.</summary>
|
|
private async Task GiveCloneItsOwnBlobsAsync(
|
|
RoomEntity clone,
|
|
List<RoomSceneEntity> sourceScenes,
|
|
List<RoomSceneEntity> clonedScenes,
|
|
long callerId)
|
|
{
|
|
// With no object store there are no objects to copy, and a blob name is
|
|
// only a reference. Clearing it would throw away the player's edits —
|
|
// strictly worse than the shared reference — so leave the shallow copy
|
|
// alone and let copy-on-write handle it, exactly as before.
|
|
if (!storage.IsS3Configured)
|
|
{
|
|
logger.LogInformation(
|
|
"[room-clone] no object store configured; room {Room} keeps the source's blob references",
|
|
clone.Id);
|
|
return;
|
|
}
|
|
|
|
string? subRoomZeroBlob = null;
|
|
|
|
for (var i = 0; i < clonedScenes.Count; i++)
|
|
{
|
|
var target = clonedScenes[i];
|
|
// The scene was created as a shallow copy, so its DataBlobName is
|
|
// currently the SOURCE's. Either a copy replaces it or it gets
|
|
// cleared — it must never survive as-is.
|
|
var copied = await CopyBlobForCloneAsync(
|
|
sourceScenes[i].DataBlobName, clone.Id, target.OrderIndex, callerId);
|
|
|
|
target.DataBlobName = copied ?? string.Empty;
|
|
target.DataModifiedAt = DateTime.UtcNow;
|
|
if (target.OrderIndex == 0) subRoomZeroBlob = copied;
|
|
}
|
|
|
|
// Same rule for the room row, which carries its own inherited copy of
|
|
// the name. Sub-room 0 is the room's current save, so prefer whatever
|
|
// that produced; with no sub-room 0 at all, copy the room-level blob
|
|
// directly. Anything that can't be copied is cleared rather than left
|
|
// pointing into another room.
|
|
if (clonedScenes.Any(s => s.OrderIndex == 0))
|
|
{
|
|
clone.CurrentDataBlobName = subRoomZeroBlob ?? string.Empty;
|
|
}
|
|
else
|
|
{
|
|
clone.CurrentDataBlobName =
|
|
await CopyBlobForCloneAsync(clone.CurrentDataBlobName, clone.Id, 0, callerId)
|
|
?? string.Empty;
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>Copy one blob into the clone's own namespace. Returns the new
|
|
/// name, or null when there was nothing to copy or the source object could
|
|
/// not be read — in which case the caller clears the reference.
|
|
///
|
|
/// A missing source is not fatal: the clone then behaves like a room that
|
|
/// was never saved, which is also what cloning an unsaved room does. The
|
|
/// room and its sub-rooms are already committed by this point, so failing
|
|
/// the request outright would leave a half-made room behind.</summary>
|
|
private async Task<string?> CopyBlobForCloneAsync(
|
|
string? sourceBlob, long cloneId, int orderIndex, long callerId)
|
|
{
|
|
// A first-party template deliberately starts empty (see
|
|
// CurrentDataBlobName in BareClone), and a sub-room may simply never
|
|
// have been saved.
|
|
if (string.IsNullOrWhiteSpace(sourceBlob)) return null;
|
|
|
|
byte[]? bytes;
|
|
try
|
|
{
|
|
var (srcBucket, srcKey) = BlobRouter.Route(sourceBlob);
|
|
bytes = await storage.GetAsync(srcBucket, srcKey);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogWarning(ex,
|
|
"[room-clone] could not read source blob {Blob}; room {Room} sub-room {Sub} starts empty",
|
|
sourceBlob, cloneId, orderIndex);
|
|
return null;
|
|
}
|
|
|
|
if (bytes is null || bytes.Length == 0)
|
|
{
|
|
logger.LogWarning(
|
|
"[room-clone] source blob {Blob} is missing from storage; room {Room} sub-room {Sub} "
|
|
+ "starts empty", sourceBlob, cloneId, orderIndex);
|
|
return null;
|
|
}
|
|
|
|
var newBlob = CloneBlobName(cloneId, orderIndex);
|
|
var (dstBucket, dstKey) = BlobRouter.Route(newBlob);
|
|
try
|
|
{
|
|
await storage.PutAsync(dstBucket, dstKey, bytes, "application/octet-stream");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Same reasoning as the read side: the clone row is already
|
|
// committed, so a storage outage must degrade to an empty
|
|
// sub-room, not a 500 that leaves a failed-looking clone still
|
|
// pointing at the source's data.
|
|
logger.LogWarning(ex,
|
|
"[room-clone] could not write blob {Blob}; room {Room} sub-room {Sub} starts empty",
|
|
newBlob, cloneId, orderIndex);
|
|
return null;
|
|
}
|
|
|
|
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
|
{
|
|
RoomId = cloneId,
|
|
BlobName = newBlob,
|
|
SubRoomId = orderIndex,
|
|
UploadedByPlayerId = callerId,
|
|
UploadedAt = DateTime.UtcNow,
|
|
});
|
|
|
|
logger.LogInformation(
|
|
"[room-clone] copied {Bytes} bytes {From} → {To} (room {Room} sub-room {Sub})",
|
|
bytes.Length, sourceBlob, newBlob, cloneId, orderIndex);
|
|
return newBlob;
|
|
}
|
|
|
|
/// <summary>Blob name for a cloned sub-room, keyed by the CLONE's room id so
|
|
/// BlobRouter files it under that room rather than the source's.</summary>
|
|
private static string CloneBlobName(long roomId, int orderIndex) =>
|
|
orderIndex == 0
|
|
? RoomService.SyntheticDefaultRoomDataBlobName(roomId)
|
|
: $"room_{roomId}_dorknet_v8_sub{orderIndex}.dat";
|
|
|
|
|
|
private async Task<string> ReadCloneNameAsync()
|
|
{
|
|
// Form: name=... (what the 2023 client sends).
|
|
if (Request.HasFormContentType)
|
|
{
|
|
foreach (var k in new[] { "name", "Name" })
|
|
{
|
|
var v = Request.Form[k].ToString();
|
|
if (!string.IsNullOrWhiteSpace(v)) return v;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// JSON body: { "name": "..." }.
|
|
try
|
|
{
|
|
Request.EnableBuffering();
|
|
Request.Body.Position = 0;
|
|
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
|
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
|
foreach (var k in new[] { "name", "Name" })
|
|
if (doc.RootElement.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String)
|
|
return v.GetString() ?? string.Empty;
|
|
}
|
|
catch { /* non-JSON / empty body */ }
|
|
}
|
|
return Request.Query["name"].ToString();
|
|
}
|
|
|
|
// ── Bare-path bans aliases ─────────────────────────────────────────
|
|
//
|
|
// Every one of these needs the roomserver/ twin: the 2023 in-room
|
|
// banned-players panel issues them under that prefix
|
|
// (NLDBPDCNNCF.txt:12901 GET, :13044 POST "ban players", :13191 POST
|
|
// "rooms/{0}/bans/import", :13353 DELETE "rooms/{0}/bans/{1}"), and a
|
|
// bare-only registration 404s there.
|
|
|
|
[HttpGet("rooms/{roomId:long}/bans")]
|
|
[HttpGet("roomserver/rooms/{roomId:long}/bans")]
|
|
public Task<IActionResult> BareBansList(long roomId) => RoomBansList(roomId);
|
|
|
|
/// <summary>POST <c>rooms/{id}/bans</c> — "ban players" (verb byte 2 =
|
|
/// POST at NLDBPDCNNCF.txt:13060). The client sends
|
|
/// x-www-form-urlencoded with ONE <c>banMask</c> (boxed Int32) plus a
|
|
/// REPEATED <c>id</c> key — one per account being banned
|
|
/// (NLDBPDCNNCF/DMMILEJMMCC:024 and :034; the `id` binder is the
|
|
/// list-valued overload, fed from the
|
|
/// <c>IReadOnlyList<Int32></c> parameter of the issuing method at
|
|
/// NLDBPDCNNCF.txt:12920). The old <c>[FromBody] BareBanRequest</c>
|
|
/// rejected the form body with 415 before the handler ran, and named
|
|
/// PlayerId/BanType — keys the client never sends — so in-room bans
|
|
/// never persisted. No response reader is attached at the call site
|
|
/// (plain BDBHCEGKGDC dispatch, :13065), so the ack shape is free.
|
|
/// </summary>
|
|
[HttpPost("rooms/{roomId:long}/bans")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/bans")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareBan(long roomId)
|
|
{
|
|
var room = await RequireOwnedRoomAsync(roomId);
|
|
if (room is null) return NotFound();
|
|
|
|
var payload = await ReadPayloadAsync();
|
|
// banMask IS the BanType enum on the wire. Default 1 (Permanent):
|
|
// the in-room panel's only ban action is a permanent ban.
|
|
var banType = IntValue(payload, "banMask", "banType", "type", "BanType") ?? 1;
|
|
var reason = RawValue(payload, "reason", "Reason") ?? string.Empty;
|
|
DateTime? until = DateTime.TryParse(Value(payload, "until", "Until"), out var parsedUntil)
|
|
? (DateTime?)parsedUntil.ToUniversalTime()
|
|
: null;
|
|
|
|
// "id" is the 2023 key; the others are 2020.12 / admin spellings. The
|
|
// payload map is case-insensitive, so PascalCase variants fall out.
|
|
var ids = Values(payload, "id", "playerId", "accountId")
|
|
.Select(v => long.TryParse(v, out var n) ? n : 0L)
|
|
.Where(n => n > 0)
|
|
.Distinct()
|
|
.ToList();
|
|
if (ids.Count == 0) return BadRequest(new { error = "missing_player" });
|
|
|
|
var existing = await db.RoomBans
|
|
.Where(b => b.RoomId == roomId && ids.Contains(b.BannedPlayerId))
|
|
.ToListAsync();
|
|
foreach (var id in ids)
|
|
{
|
|
// Re-banning an already-banned player updates the row rather than
|
|
// stacking duplicates — the panel re-sends the whole selection.
|
|
var row = existing.FirstOrDefault(b => b.BannedPlayerId == id);
|
|
if (row is null)
|
|
{
|
|
db.RoomBans.Add(new RoomBanEntity
|
|
{
|
|
RoomId = roomId,
|
|
BannedPlayerId = id,
|
|
BannedByPlayerId = Me,
|
|
BanType = banType,
|
|
Until = until,
|
|
Reason = reason.Length > 512 ? reason[..512] : reason,
|
|
});
|
|
}
|
|
else
|
|
{
|
|
row.BanType = banType;
|
|
row.Until = until;
|
|
if (reason.Length > 0) row.Reason = reason.Length > 512 ? reason[..512] : reason;
|
|
}
|
|
}
|
|
await db.SaveChangesAsync();
|
|
return Ok(new { Result = 0, Banned = ids.Count });
|
|
}
|
|
|
|
[HttpDelete("rooms/{roomId:long}/bans/{playerId:long}")]
|
|
[HttpDelete("roomserver/rooms/{roomId:long}/bans/{playerId:long}")]
|
|
public async Task<IActionResult> BareUnban(long roomId, long playerId)
|
|
{
|
|
var room = await RequireOwnedRoomAsync(roomId);
|
|
if (room is null) return NotFound();
|
|
var rows = await db.RoomBans
|
|
.Where(b => b.RoomId == roomId && b.BannedPlayerId == playerId)
|
|
.ToListAsync();
|
|
if (rows.Count == 0) return Ok(new { Removed = 0 });
|
|
db.RoomBans.RemoveRange(rows);
|
|
await db.SaveChangesAsync();
|
|
return Ok(new { Removed = rows.Count });
|
|
}
|
|
|
|
/// <summary>POST <c>rooms/{id}/bans/import</c> — "import ban list"
|
|
/// (verb byte 2 = POST at NLDBPDCNNCF.txt:13207). The client names a
|
|
/// SOURCE ROOM whose bans should be copied in — a single Int64 form key
|
|
/// <c>sourceRoomId</c> (NLDBPDCNNCF/KGDNOLMFENE:015 boxes System.Int64,
|
|
/// :020 is the key) — NOT an explicit ban list. The old
|
|
/// <c>[FromBody] ImportRoomBansRequest</c> therefore 415'd on the form
|
|
/// body AND modelled the wrong contract. The JSON ban-list shape stays
|
|
/// available on the legacy <c>api/rooms/v1/importroombans</c>.</summary>
|
|
[HttpPost("rooms/{roomId:long}/bans/import")]
|
|
[HttpPost("roomserver/rooms/{roomId:long}/bans/import")]
|
|
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
|
public async Task<IActionResult> BareBansImport(long roomId)
|
|
{
|
|
var target = await RequireOwnedRoomAsync(roomId);
|
|
if (target is null) return NotFound();
|
|
|
|
var payload = await ReadPayloadAsync();
|
|
// Deliberately NOT aliased to "roomId" — that name means the TARGET
|
|
// room elsewhere in this surface and would silently self-import.
|
|
var sourceRoomId = LongValue(payload, "sourceRoomId", "SourceRoomId") ?? 0;
|
|
if (sourceRoomId <= 0) return BadRequest(new { error = "missing_source_room" });
|
|
if (sourceRoomId == roomId) return Ok(new { Imported = 0 });
|
|
|
|
// Owner-gate BOTH rooms — importing bulk-reads the source room's ban
|
|
// list, which is owner-only information (see RoomBansList).
|
|
var source = await RequireOwnedRoomAsync(sourceRoomId);
|
|
if (source is null) return Forbid();
|
|
|
|
var sourceBans = await db.RoomBans
|
|
.Where(b => b.RoomId == sourceRoomId)
|
|
.ToListAsync();
|
|
var seen = new HashSet<long>(await db.RoomBans
|
|
.Where(b => b.RoomId == roomId)
|
|
.Select(b => b.BannedPlayerId)
|
|
.ToListAsync());
|
|
|
|
var imported = 0;
|
|
foreach (var b in sourceBans)
|
|
{
|
|
if (!seen.Add(b.BannedPlayerId)) continue;
|
|
db.RoomBans.Add(new RoomBanEntity
|
|
{
|
|
RoomId = roomId,
|
|
BannedPlayerId = b.BannedPlayerId,
|
|
BannedByPlayerId = Me,
|
|
BanType = b.BanType,
|
|
Until = b.Until,
|
|
Reason = b.Reason,
|
|
});
|
|
imported++;
|
|
}
|
|
if (imported > 0) await db.SaveChangesAsync();
|
|
return Ok(new { Imported = imported });
|
|
}
|
|
|
|
private async Task<IActionResult> ApplyAndReturn(long roomId, Action<RoomEntity> mutator)
|
|
{
|
|
var room = await RequireOwnedRoomAsync(roomId);
|
|
if (room is null) return NotFound();
|
|
mutator(room);
|
|
room.UpdatedAt = DateTime.UtcNow;
|
|
try { await db.SaveChangesAsync(); }
|
|
catch (DbUpdateException) { return Conflict(new { Result = 2 }); }
|
|
return Ok(await BuildRoomDetailsAsync(room));
|
|
}
|
|
|
|
/// <summary>The response body EVERY bare-path room mutation must return.
|
|
///
|
|
/// The 2023 roomserver client dispatches all of them through the same
|
|
/// typed helper (<c>0x1835E50A0</c>) with the same response-reader token
|
|
/// <c>[0x1884C1948]</c> — see NLDBPDCNNCF.txt:5366 (description),
|
|
/// :5691 (tags), :6006 (accessibility), :6169 (restrictions),
|
|
/// :6335 (cloning), :6710 (automute), :6876 (comments), :7195 (min_level),
|
|
/// :7360 (allow_new_users), :7859 (promo_images), :8178 (promo_external),
|
|
/// :8516 (loadscreen), :10826 (modify). That is the SAME token the clone
|
|
/// call site uses (:4815), which the repo already established is the full
|
|
/// FGCPNAACHIK details object built by BuildRoomServerDetails (see
|
|
/// <see cref="BareClone"/>). The slim RoomService.ToWireRoom has no
|
|
/// SubRooms/Roles/Tags/LoadScreens/PromoImages/DataBlob, so the strict
|
|
/// reader's dispose-walk NRE'd on the missing nested lists and every
|
|
/// mutation reported failure even though it had persisted.</summary>
|
|
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
|
|
{
|
|
var scenes = await db.RoomScenes
|
|
.Where(s => s.RoomId == room.Id)
|
|
.OrderBy(s => s.OrderIndex)
|
|
.ToListAsync();
|
|
var roles = await db.RoomRoles
|
|
.Where(r => r.RoomId == room.Id)
|
|
.ToListAsync();
|
|
return RoomsController.BuildRoomServerDetails(room, scenes, roles: roles);
|
|
}
|
|
}
|