fix: answer 2023 room and playlist mutations with the success/value/error envelope

Every rooms-client mutation (roles, invite, accessibility/publish, room
fields, sub-room ops, permissions, playlists) goes through
NLDBPDCNNCF.BDBHCEGKGDC<TResult>, the same helper and instantiation as
"clone room", which reads {success, value, error}. A bare room-details body
has no success, so the client rejected it with the message-less
"Exception of type 'CEMNLBKJABA' was thrown" (seen on co-owner invite)
although the server had applied the change.

RecNetEnvelopeAttribute wraps those handlers: 2xx becomes
{success:true, value, error:""}, refusals become 200
{success:false, value:null, error:<message>}.
This commit is contained in:
Alexa
2026-10-24 23:48:00 +03:00
parent fd2153d82b
commit bf0f09ce84
8 changed files with 294 additions and 18 deletions
+10 -2
View File
@@ -88,7 +88,11 @@ public sealed class RoomPublish2023Tests : IClassFixture<DorkNetServerFactory>
using var modRooms = Client("rooms", moderator);
using (var denied = await modRooms.PutAsync($"/roomserver/rooms/{cloneId}/accessibility",
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = "0" })))
Assert.False(denied.IsSuccessStatusCode);
{
// Refusals are 200 {success:false, error} so the client can show them.
using var doc = JsonDocument.Parse(await denied.Content.ReadAsStringAsync());
Assert.False(doc.RootElement.GetProperty("success").GetBoolean());
}
using var coRooms = Client("rooms", coOwner);
var unpublished = await PutAccessibilityAsync(coRooms, cloneId, "0");
Assert.Equal(0, unpublished.GetProperty("Accessibility").GetInt32());
@@ -100,7 +104,11 @@ public sealed class RoomPublish2023Tests : IClassFixture<DorkNetServerFactory>
new FormUrlEncodedContent(new Dictionary<string, string> { ["accessibility"] = value }));
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
// "change room accessibility" goes through the {success, value, error}
// helper (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>), same as clone.
using var doc = JsonDocument.Parse(body);
Assert.True(doc.RootElement.GetProperty("success").GetBoolean(), body);
return doc.RootElement.GetProperty("value").Clone();
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path)
+85 -11
View File
@@ -241,14 +241,14 @@ public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
// With the invite gone, the same accept is refused.
using var again = await PutFormRawAsync(inviteeRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}", ("role", "30"));
Assert.Equal(HttpStatusCode.Forbidden, again.StatusCode);
await AssertRefusedAsync(again);
// A stranger can neither promote themselves nor invite anyone.
using var strangerRooms = Client("rooms", stranger);
using var self = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{stranger.PlayerId}", ("role", "20"));
Assert.Equal(HttpStatusCode.Forbidden, self.StatusCode);
await AssertRefusedAsync(self);
using var other = await PutFormRawAsync(strangerRooms, $"/roomserver/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
Assert.Equal(HttpStatusCode.Forbidden, other.StatusCode);
await AssertRefusedAsync(other);
}
[Fact]
@@ -262,7 +262,7 @@ public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
Assert.Equal(20, RoleOf(granted, target.PlayerId).GetProperty("Role").GetInt32());
using var inviteCo = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}/invite", ("role", "30"));
Assert.Equal(HttpStatusCode.Forbidden, inviteCo.StatusCode);
await AssertRefusedAsync(inviteCo);
// The creator can directly add and remove a co-owner.
using var ownerRooms = Client("rooms", owner);
@@ -271,7 +271,7 @@ public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
// …and now the co-owner can't touch their peer.
using var demote = await PutFormRawAsync(coRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
Assert.Equal(HttpStatusCode.Forbidden, demote.StatusCode);
await AssertRefusedAsync(demote);
var removed = await PutFormAsync(ownerRooms, $"/roomserver/rooms/{roomId}/roles/{target.PlayerId}", ("role", "0"));
Assert.DoesNotContain(removed.GetProperty("Roles").EnumerateArray(),
@@ -297,8 +297,9 @@ public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
{
var text = await put.Content.ReadAsStringAsync();
Assert.True(put.IsSuccessStatusCode, $"{(int)put.StatusCode}: {text}");
using var json = JsonDocument.Parse(text);
Assert.Equal(roomId, json.RootElement.GetProperty("RoomId").GetInt64()); // full room details
// {success, value: full room details, error}
var value = Envelope(text);
Assert.Equal(roomId, value.GetProperty("RoomId").GetInt64());
}
// The owner enters the room, then the matrix comes back with the token.
@@ -318,10 +319,10 @@ public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
using var coRooms = Client("rooms", coOwner);
using (var denied = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"13","Role":30,"Override":true,"Type":0,"Value":"true"}]"""))
Assert.Equal(HttpStatusCode.Forbidden, denied.StatusCode);
await AssertRefusedAsync(denied);
using (var allowed = await PutJsonRawAsync(coRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"13","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
Assert.True(allowed.IsSuccessStatusCode, await allowed.Content.ReadAsStringAsync());
Envelope(await allowed.Content.ReadAsStringAsync());
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<DorkNetDbContext>();
@@ -374,7 +375,7 @@ public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
Assert.True(join.IsSuccessStatusCode, await join.Content.ReadAsStringAsync());
using (var put = await PutJsonRawAsync(ownerRooms, $"/roomserver/rooms/{roomId}/subrooms/1/permissions",
"""[{"Permission":"21","Role":20,"Override":true,"Type":0,"Value":"true"}]"""))
Assert.True(put.IsSuccessStatusCode, await put.Content.ReadAsStringAsync());
Envelope(await put.Content.ReadAsStringAsync());
var token = Assert.Single(hub.To(CapturingHubContext.PlayerGroup(owner.PlayerId), "PhotonAccessToken"));
Assert.True(token.Msg.GetProperty("RoomInstanceId").GetInt64() > 0);
@@ -382,6 +383,54 @@ public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
c => c.GetProperty("Role").GetInt32() == 20 && c.GetProperty("Permission").GetString() == "21");
}
[Fact]
public void Envelope_filter_wraps_success_and_turns_refusals_into_messages()
{
static JsonElement Body(Microsoft.AspNetCore.Mvc.IActionResult r) =>
JsonDocument.Parse(JsonSerializer.Serialize(((Microsoft.AspNetCore.Mvc.ObjectResult)r).Value)).RootElement.Clone();
var ok = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.OkObjectResult(new { RoomId = 5 })));
Assert.True(ok.GetProperty("success").GetBoolean());
Assert.Equal(5, ok.GetProperty("value").GetProperty("RoomId").GetInt32());
Assert.Equal("", ok.GetProperty("error").GetString());
var forbid = Body(Controllers.RecNetEnvelopeAttribute.Wrap(new Microsoft.AspNetCore.Mvc.ForbidResult()));
Assert.False(forbid.GetProperty("success").GetBoolean());
Assert.False(string.IsNullOrWhiteSpace(forbid.GetProperty("error").GetString()));
var bad = Body(Controllers.RecNetEnvelopeAttribute.Wrap(
new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(new { error = "cannot_change_creator_role" })));
Assert.Equal("cannot_change_creator_role", bad.GetProperty("error").GetString());
// An envelope the handler built itself is left alone.
var already = new Microsoft.AspNetCore.Mvc.OkObjectResult(new { success = false, value = (object?)null, error = "x" });
Assert.Same(already, Controllers.RecNetEnvelopeAttribute.Wrap(already));
}
[Fact]
public async Task Room_mutations_answer_the_success_value_error_envelope_on_bare_and_roomserver_paths()
{
var (owner, invitee, _) = await PlayersAsync();
var roomId = await SeedRoomAsync(owner.PlayerId);
using var rooms = Client("rooms", owner);
// The exact request the client sent in production (bare path, form role=30).
var invited = await PutFormAsync(rooms, $"/rooms/{roomId}/roles/{invitee.PlayerId}/invite", ("role", "30"));
Assert.Equal(30, RoleOf(invited, invitee.PlayerId).GetProperty("InvitedRole").GetInt32());
foreach (var (path, key, value) in new[]
{
($"/rooms/{roomId}/description", "description", "hello"),
($"/roomserver/rooms/{roomId}/accessibility", "accessibility", "0"),
($"/roomserver/rooms/{roomId}/subrooms/1/name", "name", "Lobby"),
($"/roomserver/rooms/{roomId}/subrooms/1/maxplayers", "maxPlayers", "10"),
})
{
var details = await PutFormAsync(rooms, path, (key, value));
Assert.Equal(roomId, details.GetProperty("RoomId").GetInt64());
}
}
// ── helpers ─────────────────────────────────────────────────────────
private async Task<(GameClientSession A, GameClientSession B, GameClientSession C)> PlayersAsync()
@@ -426,7 +475,32 @@ public sealed class RoomRoles2023Tests : IClassFixture<DorkNetServerFactory>
using var response = await PutFormRawAsync(client, path, fields);
var body = await response.Content.ReadAsStringAsync();
Assert.True(response.IsSuccessStatusCode, $"PUT {path} -> {(int)response.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.Clone();
return Envelope(body);
}
/// <summary>Every room mutation answers the {success, value, error}
/// envelope (NLDBPDCNNCF.BDBHCEGKGDC&lt;FGCPNAACHIK&gt;): assert success and
/// return the room details in <c>value</c>.</summary>
private static JsonElement Envelope(string body)
{
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
Assert.True(root.GetProperty("success").GetBoolean(), body);
Assert.Equal(string.Empty, root.GetProperty("error").GetString());
return root.GetProperty("value").Clone();
}
/// <summary>A refusal is HTTP 200 with success=false and a message: the
/// client shows the message, while a 403/404 surfaces only as the
/// message-less "Exception of type 'CEMNLBKJABA' was thrown".</summary>
private static async Task AssertRefusedAsync(HttpResponseMessage response)
{
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
using var doc = JsonDocument.Parse(body);
Assert.False(doc.RootElement.GetProperty("success").GetBoolean(), body);
Assert.False(string.IsNullOrWhiteSpace(doc.RootElement.GetProperty("error").GetString()), body);
Assert.Equal(JsonValueKind.Null, doc.RootElement.GetProperty("value").ValueKind);
}
private static Task<HttpResponseMessage> PutJsonRawAsync(HttpClient client, string path, string json)
@@ -73,9 +73,12 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture<DorkNetServerFactory>
Assert.True(slider.IsSuccessStatusCode,
$"owner PUT maxplayers -> {(int)slider.StatusCode}: {sliderBody}");
// The client deserialises the reply as FGCPNAACHIK — the room-details
// shape — so the slider's own response has to carry the new value.
var echoed = JsonDocument.Parse(sliderBody).RootElement
// The client reads the reply through the {success, value, error}
// helper with value = FGCPNAACHIK, so the slider's own response has to
// carry the new value inside the envelope.
var envelope = JsonDocument.Parse(sliderBody).RootElement;
Assert.True(envelope.GetProperty("success").GetBoolean(), sliderBody);
var echoed = envelope.GetProperty("value")
.GetProperty("SubRooms").EnumerateArray()
.First(e => e.GetProperty("SubRoomId").GetInt64() == 2);
Assert.Equal(24, echoed.GetProperty("MaxPlayers").GetInt32());
@@ -48,6 +48,7 @@ public class PlaylistsController(
/// query or JSON body so JSON callers (admin UI) keep working.</summary>
[HttpPost("/playlists")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> Create()
{
var name = await ReadFieldAsync("name", "Name");
@@ -87,6 +88,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistName(long playlistId)
{
var value = await ReadFieldAsync("name", "Name", "value", "Value");
@@ -96,6 +98,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistDescription(long playlistId)
{
var value = await ReadFieldAsync("description", "Description", "value", "Value");
@@ -105,6 +108,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistImage(long playlistId)
{
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
@@ -116,6 +120,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistTags(long playlistId)
{
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
@@ -131,6 +136,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPut("/playlists/{playlistId:long}/visibility")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistAccessibility(long playlistId)
{
var value = await ReadFieldAsync(
@@ -144,6 +150,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistLevelVoting(long playlistId)
{
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
@@ -156,6 +163,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPut("/playlists/{playlistId:long}/restrictions")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistRestrictions(long playlistId)
{
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
@@ -174,6 +182,7 @@ public class PlaylistsController(
[HttpPost("/playlists/{playlistId:long}/warning")]
[HttpPut("/playlists/{playlistId:long}/warning")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PlaylistWarning(long playlistId)
{
var mask = await ReadFieldAsync("warningMask", "WarningMask");
@@ -295,6 +304,7 @@ public class PlaylistsController(
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> AddRoom(long playlistId, long roomId)
{
try
@@ -307,6 +317,7 @@ public class PlaylistsController(
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RemoveRoom(long playlistId, long roomId)
{
try
@@ -2912,6 +2912,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RenameRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] RenameBody body)
@@ -2967,6 +2968,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> RenameSubroom(
long roomId,
long subRoomId,
@@ -3616,6 +3618,7 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/subrooms")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> AddSubRoom(
long roomId,
[ModelBinder(typeof(DorkNet.Server.Binding.FormOrJsonModelBinder))] CreateSubRoomRequest req)
@@ -3658,6 +3661,7 @@ public class RoomsController(
[HttpDelete("rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[HttpDelete("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> DeleteSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
@@ -3742,6 +3746,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/maxplayers")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> SubRoomMaxPlayers(
long roomId,
long subRoomId,
@@ -3759,6 +3764,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/accessibility")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> SubRoomAccessibility(
long roomId,
long subRoomId,
@@ -3795,6 +3801,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/permissions")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> SubRoomPermissions(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
@@ -3894,6 +3901,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/publish_save")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> PublishSubRoomSave(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
@@ -3974,6 +3982,7 @@ public class RoomsController(
[HttpPost("rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/clone")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> CloneSubRoom(long roomId, long subRoomId)
{
subRoomId = RoomSceneIds.ToOrderIndex(subRoomId); // wire id → OrderIndex
@@ -4027,6 +4036,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/modify")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> ModifySubRoom(
long roomId,
long subRoomId,
@@ -4066,6 +4076,7 @@ public class RoomsController(
[HttpPost("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[HttpPut("roomserver/rooms/{roomId:long}/subrooms/{subRoomId:long}/move")]
[Authorize]
[RecNetEnvelope]
public async Task<IActionResult> MoveSubRoom(
long roomId,
long subRoomId,
@@ -4434,6 +4445,7 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> GrantRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: false);
@@ -4456,6 +4468,7 @@ public class RoomsController(
[HttpPut("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[HttpPost("/api/rooms/v2/rooms/{roomId:long}/roles/{playerId:long}/invite")]
[Authorize]
[RecNetEnvelope]
public Task<IActionResult> InviteRole(long roomId, long playerId)
=> ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true);
@@ -501,6 +501,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/description")]
[HttpPut("roomserver/rooms/{roomId:long}/description")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareDescription(long roomId)
{
// RawValue, not the whitespace-skipping reader: clearing the
@@ -520,6 +521,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/image")]
[HttpPut("roomserver/rooms/{roomId:long}/image")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareImage(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -541,6 +543,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareTags(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -567,6 +570,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/accessibility")]
[HttpPut("roomserver/rooms/{roomId:long}/accessibility")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAccessibility(long roomId)
{
var value = await ReadBareIntAsync("accessibility", "Accessibility", "value", "Value");
@@ -899,6 +903,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareCloning(long roomId)
{
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
@@ -918,6 +923,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareAutomute(long roomId)
{
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
@@ -939,6 +945,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareRestrictions(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -965,6 +972,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
[HttpPut("roomserver/rooms/{roomId:long}/warning")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareWarning(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -983,6 +991,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/creator")]
[HttpPut("roomserver/rooms/{roomId:long}/creator")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareCreator(long roomId)
{
var creatorId = await ReadBareLongAsync(
@@ -1001,6 +1010,7 @@ public class RoomsModerationController(
[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")]
[RecNetEnvelope]
public async Task<IActionResult> BareAllowNewUsers(long roomId)
{
var value = await ReadBareBoolAsync("allowNewUsers", "AllowNewUsers", "value", "Value");
@@ -1015,6 +1025,7 @@ public class RoomsModerationController(
[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")]
[RecNetEnvelope]
public async Task<IActionResult> BareMinLevel(long roomId)
{
var value = await ReadBareIntAsync("minLevel", "MinLevel", "level", "Level", "value", "Value");
@@ -1029,6 +1040,7 @@ public class RoomsModerationController(
[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")]
[RecNetEnvelope]
public async Task<IActionResult> BareMaxPlayerCalculationMode(long roomId)
{
var value = await ReadBareIntAsync(
@@ -1054,6 +1066,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
[HttpPut("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareLoadScreen(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1085,6 +1098,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/loadscreen")]
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareLoadScreenRemove(long roomId)
{
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
@@ -1121,6 +1135,7 @@ public class RoomsModerationController(
[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")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoImagesAdd(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1137,6 +1152,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_images")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_images")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoImagesRemove(long roomId)
{
var image = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value");
@@ -1167,6 +1183,7 @@ public class RoomsModerationController(
[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")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoExternalAdd(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1197,6 +1214,7 @@ public class RoomsModerationController(
[HttpDelete("rooms/{roomId:long}/promo_external")]
[HttpDelete("roomserver/rooms/{roomId:long}/promo_external")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BarePromoExternalRemove(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1275,6 +1293,7 @@ public class RoomsModerationController(
[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")]
[RecNetEnvelope]
public async Task<IActionResult> BareAck(long roomId) =>
await ApplyAndReturn(roomId, _ => { /* value not persistable: no column */ });
@@ -1305,6 +1324,7 @@ public class RoomsModerationController(
[HttpPost("roomserver/rooms/{roomId:long}/modify")]
[HttpPut("roomserver/rooms/{roomId:long}/modify")]
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
[RecNetEnvelope]
public async Task<IActionResult> BareModify(long roomId)
{
var payload = await ReadPayloadAsync();
@@ -1914,7 +1934,13 @@ public class RoomsModerationController(
/// <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>
/// mutation reported failure even though it had persisted.
///
/// <para>That helper is <c>NLDBPDCNNCF.BDBHCEGKGDC&lt;TResult&gt;</c>, the
/// <c>{success, value, error}</c> envelope reader, so the details are the
/// envelope's <c>value</c>: every caller carries
/// <see cref="Controllers.RecNetEnvelopeAttribute"/>. A bare details body
/// fails with "Exception of type 'CEMNLBKJABA' was thrown."</para></summary>
private async Task<object> BuildRoomDetailsAsync(RoomEntity room)
{
var scenes = await db.RoomScenes
@@ -0,0 +1,115 @@
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
namespace DorkNet.Server.Controllers;
/// <summary>
/// Wraps an action's reply in the legacy <c>{success, value, error}</c>
/// envelope the 2023 rooms client reads for every room and playlist mutation.
///
/// <para><b>Client contract.</b> All of these calls go through one generic
/// helper, <c>NLDBPDCNNCF.BDBHCEGKGDC&lt;TResult&gt;(string opName, HTTPMethods,
/// string route, …)</c> (RVA 0x35E50A0, recnet-runtime-decomp/NLDBPDCNNCF.cs:2310;
/// called as <c>Call 0x1835E50A0</c> 47 times in
/// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt). It is the same helper, with the
/// same <c>BDBHCEGKGDC&lt;FGCPNAACHIK&gt;</c> instantiation token
/// <c>[0x1884C1948]</c>, that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
/// clone is proven in game to need <c>{success, value, error}</c>
/// (docs/recroom-2023-room-save.md). A bare room-details body has no
/// <c>success</c>, so the helper rejects it with no message and the player sees
/// <c>Exception of type 'CEMNLBKJABA' was thrown.</c> even though the server
/// applied the change. The routes are: rooms/{0}/{accessibility,
/// allow_new_users, automute, cloning, comments, creator, description, image,
/// loadscreen, max_player_calculation_mode, min_level, modify, name,
/// promo_external, promo_images, restrictions, tags, voice_chat_encryption,
/// warning}, rooms/{0}/roles/{1}[/invite], rooms/{0}/subrooms[/{1}[/accessibility,
/// clone, maxplayers, modify, move, name, permissions, publish_save]],
/// playlists and playlists/{0}/{accessibility, description, image, levelvoting,
/// name, restrictions, rooms/{1}, tags, warning}.</para>
///
/// <para>A 2xx object result becomes <c>{success:true, value, error:""}</c>.
/// Anything else becomes HTTP 200 <c>{success:false, value:null, error}</c>
/// with a message the client can show, because a non-2xx reply also surfaces
/// only as the message-less CEMNLBKJABA.</para>
/// </summary>
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
public sealed class RecNetEnvelopeAttribute : ResultFilterAttribute
{
public override void OnResultExecuting(ResultExecutingContext context)
{
context.Result = Wrap(context.Result);
base.OnResultExecuting(context);
}
public static IActionResult Wrap(IActionResult result)
{
switch (result)
{
case ObjectResult obj when obj.StatusCode is null or (>= 200 and < 300):
return IsEnvelope(obj.Value)
? result
: Success(obj.Value);
case ObjectResult obj:
return Failure(ErrorText(obj.Value) ?? DefaultError(obj.StatusCode ?? 500));
case ForbidResult or ChallengeResult or UnauthorizedResult:
return Failure(DefaultError(403));
case StatusCodeResult code when code.StatusCode is >= 200 and < 300:
return Success(null);
case StatusCodeResult code:
return Failure(DefaultError(code.StatusCode));
case EmptyResult:
return Success(null);
default:
return result;
}
}
public static OkObjectResult Success(object? value) =>
new(new { success = true, value, error = string.Empty });
public static OkObjectResult Failure(string error) =>
new(new { success = false, value = (object?)null, error });
private static string DefaultError(int status) => status switch
{
400 => "That request was not valid.",
401 or 403 => "You don't have permission to do that.",
404 => "That room doesn't exist.",
409 => "That name is already taken.",
_ => "Something went wrong.",
};
/// <summary>An <c>error</c>/<c>Error</c> string on the failure body, if any.</summary>
private static string? ErrorText(object? value)
{
if (value is null) return null;
if (value is string s) return string.IsNullOrWhiteSpace(s) ? null : s;
try
{
using var doc = JsonDocument.Parse(JsonSerializer.Serialize(value));
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
foreach (var prop in doc.RootElement.EnumerateObject())
{
if (!prop.Name.Equals("error", StringComparison.OrdinalIgnoreCase)) continue;
if (prop.Value.ValueKind == JsonValueKind.String)
{
var text = prop.Value.GetString();
return string.IsNullOrWhiteSpace(text) ? null : text;
}
}
}
catch (NotSupportedException)
{
}
return null;
}
private static bool IsEnvelope(object? value)
{
if (value is null) return false;
var props = value.GetType().GetProperties();
bool Has(string name) => props.Any(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
return Has("success") && Has("value") && Has("error");
}
}
+27 -1
View File
@@ -624,7 +624,33 @@ Diffed all 45 real HTTP routes of the 2023-03-21 client's rooms subsystem agains
#### Sub-room ids, IsRRO and the instance browser
These three cut across the whole rooms surface.
These cut across the whole rooms surface.
- **Every room and playlist mutation answers `{success, value, error}`.**
They all go through one generic helper,
`NLDBPDCNNCF.BDBHCEGKGDC<TResult>(opName, verb, route, …)` (RVA 0x35E50A0,
`Call 0x1835E50A0` at 47 call sites in NLDBPDCNNCF.txt). It is the same
helper and the same `BDBHCEGKGDC<FGCPNAACHIK>` instantiation token
(`[0x1884C1948]`) that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and
clone is proven in game to need the envelope. A bare room-details body has
no `success`; the helper rejects it with no message, and the player sees
"Exception of type 'CEMNLBKJABA' was thrown." (CEMNLBKJABA is the RecNet
request exception, base BCPHBJHOIEM : Exception, built with a null
message) although the server applied the change. This is what broke the
co-owner invite in game after the role fixes. Routes: rooms/{0}/{accessibility,
allow_new_users, automute, cloning, comments, creator, description, image,
loadscreen, max_player_calculation_mode, min_level, modify, name,
promo_external, promo_images, restrictions, tags, voice_chat_encryption,
warning}, rooms/{0}/roles/{1} and /invite, rooms/{0}/subrooms (POST),
rooms/{0}/subrooms/{1} (DELETE) and its accessibility, clone, maxplayers,
modify, move, name, permissions, publish_save, plus playlists (POST) and
playlists/{0}/{accessibility, description, image, levelvoting, name,
restrictions, rooms/{1}, tags, warning}. `value` is the full FGCPNAACHIK
(playlist DTO for playlists). Refusals are HTTP 200 with `success:false`
and a message in `error`, since a 403/404 also shows only the message-less
exception. Server: `Controllers/RecNetEnvelopeAttribute.cs` on each handler.
Wherever a row below says "FGCPNAACHIK full room details" for one of these
routes, read it as the envelope's `value`.
- **Sub-room ids are OrderIndex + 1 on the wire.** The client refuses to load
a sub-room whose id is not positive: `SubroomModel.DOKCPLJOKIE`