From bf0f09ce849b8f414bcd97a3f802ce305757bd0b Mon Sep 17 00:00:00 2001 From: Alexa <53471801+Alexa-RR@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:48:00 +0300 Subject: [PATCH] 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, 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:}. --- DorkNet.Server.Tests/RoomPublish2023Tests.cs | 12 +- DorkNet.Server.Tests/RoomRoles2023Tests.cs | 96 +++++++++++++-- .../SubRoomMaxPlayersTests.cs | 9 +- .../API/Playlists/PlaylistsController.cs | 11 ++ .../API/Rooms/V2/RoomsController.cs | 13 ++ .../API/Rooms/V2/RoomsModerationController.cs | 28 ++++- .../Controllers/RecNetEnvelopeAttribute.cs | 115 ++++++++++++++++++ docs/recroom-2023-client-api-complete.md | 28 ++++- 8 files changed, 294 insertions(+), 18 deletions(-) create mode 100644 DorkNet.Server/Controllers/RecNetEnvelopeAttribute.cs diff --git a/DorkNet.Server.Tests/RoomPublish2023Tests.cs b/DorkNet.Server.Tests/RoomPublish2023Tests.cs index 69c75e9..9efc699 100644 --- a/DorkNet.Server.Tests/RoomPublish2023Tests.cs +++ b/DorkNet.Server.Tests/RoomPublish2023Tests.cs @@ -88,7 +88,11 @@ public sealed class RoomPublish2023Tests : IClassFixture using var modRooms = Client("rooms", moderator); using (var denied = await modRooms.PutAsync($"/roomserver/rooms/{cloneId}/accessibility", new FormUrlEncodedContent(new Dictionary { ["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 new FormUrlEncodedContent(new Dictionary { ["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), 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 GetJsonAsync(HttpClient client, string path) diff --git a/DorkNet.Server.Tests/RoomRoles2023Tests.cs b/DorkNet.Server.Tests/RoomRoles2023Tests.cs index 0ffefe7..046e207 100644 --- a/DorkNet.Server.Tests/RoomRoles2023Tests.cs +++ b/DorkNet.Server.Tests/RoomRoles2023Tests.cs @@ -241,14 +241,14 @@ public sealed class RoomRoles2023Tests : IClassFixture // 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 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 // …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 { 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 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(); @@ -374,7 +375,7 @@ public sealed class RoomRoles2023Tests : IClassFixture 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 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 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); + } + + /// Every room mutation answers the {success, value, error} + /// envelope (NLDBPDCNNCF.BDBHCEGKGDC<FGCPNAACHIK>): assert success and + /// return the room details in value. + 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(); + } + + /// 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". + 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 PutJsonRawAsync(HttpClient client, string path, string json) diff --git a/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs b/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs index 5f3c316..194401e 100644 --- a/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs +++ b/DorkNet.Server.Tests/SubRoomMaxPlayersTests.cs @@ -73,9 +73,12 @@ public sealed class SubRoomMaxPlayersTests : IClassFixture 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()); diff --git a/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs b/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs index 1e5648e..10917d2 100644 --- a/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs +++ b/DorkNet.Server/Controllers/API/Playlists/PlaylistsController.cs @@ -48,6 +48,7 @@ public class PlaylistsController( /// query or JSON body so JSON callers (admin UI) keep working. [HttpPost("/playlists")] [Authorize] + [RecNetEnvelope] public async Task 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 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 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 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 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 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 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 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 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 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 RemoveRoom(long playlistId, long roomId) { try diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs index 006d755..1a2541a 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs @@ -2912,6 +2912,7 @@ public class RoomsController( [HttpPost("roomserver/rooms/{roomId:long}/name")] [HttpPut("roomserver/rooms/{roomId:long}/name")] [Authorize] + [RecNetEnvelope] public async Task 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 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 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 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 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 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 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 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 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 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 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 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 InviteRole(long roomId, long playerId) => ChangeRoomAccountRoleAsync(roomId, playerId, isInvite: true); diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs index 15ff0ae..93688d5 100644 --- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs +++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsModerationController.cs @@ -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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 BareModify(long roomId) { var payload = await ReadPayloadAsync(); @@ -1914,7 +1934,13 @@ public class RoomsModerationController( /// ). 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. + /// mutation reported failure even though it had persisted. + /// + /// That helper is NLDBPDCNNCF.BDBHCEGKGDC<TResult>, the + /// {success, value, error} envelope reader, so the details are the + /// envelope's value: every caller carries + /// . A bare details body + /// fails with "Exception of type 'CEMNLBKJABA' was thrown." private async Task BuildRoomDetailsAsync(RoomEntity room) { var scenes = await db.RoomScenes diff --git a/DorkNet.Server/Controllers/RecNetEnvelopeAttribute.cs b/DorkNet.Server/Controllers/RecNetEnvelopeAttribute.cs new file mode 100644 index 0000000..eaf6577 --- /dev/null +++ b/DorkNet.Server/Controllers/RecNetEnvelopeAttribute.cs @@ -0,0 +1,115 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace DorkNet.Server.Controllers; + +/// +/// Wraps an action's reply in the legacy {success, value, error} +/// envelope the 2023 rooms client reads for every room and playlist mutation. +/// +/// Client contract. All of these calls go through one generic +/// helper, NLDBPDCNNCF.BDBHCEGKGDC<TResult>(string opName, HTTPMethods, +/// string route, …) (RVA 0x35E50A0, recnet-runtime-decomp/NLDBPDCNNCF.cs:2310; +/// called as Call 0x1835E50A0 47 times in +/// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt). It is the same helper, with the +/// same BDBHCEGKGDC<FGCPNAACHIK> instantiation token +/// [0x1884C1948], that "clone room" uses (NLDBPDCNNCF.txt:4815-4823), and +/// clone is proven in game to need {success, value, error} +/// (docs/recroom-2023-room-save.md). A bare room-details body has no +/// success, so the helper rejects it with no message and the player sees +/// Exception of type 'CEMNLBKJABA' was thrown. 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}. +/// +/// A 2xx object result becomes {success:true, value, error:""}. +/// Anything else becomes HTTP 200 {success:false, value:null, error} +/// with a message the client can show, because a non-2xx reply also surfaces +/// only as the message-less CEMNLBKJABA. +/// +[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.", + }; + + /// An error/Error string on the failure body, if any. + 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"); + } +} diff --git a/docs/recroom-2023-client-api-complete.md b/docs/recroom-2023-client-api-complete.md index 0f9a336..06b9bfd 100644 --- a/docs/recroom-2023-client-api-complete.md +++ b/docs/recroom-2023-client-api-complete.md @@ -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(opName, verb, route, …)` (RVA 0x35E50A0, + `Call 0x1835E50A0` at 47 call sites in NLDBPDCNNCF.txt). It is the same + helper and the same `BDBHCEGKGDC` 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`