mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 15:08:47 -07:00
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:
@@ -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)
|
||||
|
||||
@@ -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<FGCPNAACHIK>): 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<TResult></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<TResult>(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<FGCPNAACHIK></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");
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user