mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
feat: Enhance room management and moderation APIs
- Added support for multiple tag filters in HotRoomServer endpoint, allowing clients to query hot rooms with multiple tags. - Introduced a new DeleteRoom endpoint for owners to soft-archive their rooms, ensuring room data integrity. - Updated MagicDoor endpoint to return a structured response including refresh timing. - Improved reporting functionality to handle form submissions correctly, ensuring room reports are persisted. - Refactored per-field mutation endpoints to support both legacy and new client formats, accommodating changes in request structure. - Implemented a multi-field payload reader to handle complex requests more efficiently. - Enhanced logging for room code management to track player actions and conflicts.
This commit is contained in:
@@ -53,14 +53,48 @@ public class RoomsController(
|
||||
[FromQuery] string? tags)
|
||||
=> Ok((await rooms.HotAsync(tags)).Select(RoomService.ToWireRoom).ToList());
|
||||
|
||||
/// <summary>GET <c>rooms/hot</c> — the room-server "hot rooms" list.
|
||||
/// Response is the PAGED wrapper: the 2023 client's issuing method
|
||||
/// <c>NLDBPDCNNCF.FANOAMOPHBE</c> returns <c>Task<FFCPFGBNLHN></c>
|
||||
/// (NLDBPDCNNCF.txt:2865 + literal at :2983) and FFCPFGBNLHN's reader
|
||||
/// requires <c>Results</c> + <c>TotalResults</c>
|
||||
/// (PJPBJKLDMNA.txt ISIL 040/059).</summary>
|
||||
// The roomserver/ twin is mandatory: FANOAMOPHBE lives on the same
|
||||
// NLDBPDCNNCF client whose requests all arrive with the roomserver/
|
||||
// prefix in this deployment (docs/recroom-2023-room-save.md:83-93), so
|
||||
// the bare-only registration 404'd for the 2023 client.
|
||||
[HttpGet("rooms/hot")]
|
||||
[HttpGet("roomserver/rooms/hot")]
|
||||
public async Task<IActionResult> HotRoomServer(
|
||||
[FromQuery] string? tag,
|
||||
[FromQuery] string? tags,
|
||||
[FromQuery] int skip = 0,
|
||||
[FromQuery] int take = 100)
|
||||
{
|
||||
var all = await rooms.HotAsync(tag ?? tags, take: 200);
|
||||
// The tag filter is a REPEATED query field (`tag=a&tag=b`) — the
|
||||
// client's IFLHPHIPFNC closure adds one entry per selected chip —
|
||||
// so a `[FromQuery] string? tag` binding silently dropped every tag
|
||||
// past the first. Read the raw collection and union the per-tag hits.
|
||||
var tagFilters = Request.Query["tag"]
|
||||
.Concat(Request.Query["tags"])
|
||||
.SelectMany(v => (v ?? string.Empty).Split(',',
|
||||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
List<RoomEntity> all;
|
||||
if (tagFilters.Count == 0)
|
||||
{
|
||||
all = await rooms.HotAsync(null, take: 200);
|
||||
}
|
||||
else
|
||||
{
|
||||
var merged = new List<RoomEntity>();
|
||||
var seen = new HashSet<long>();
|
||||
foreach (var filter in tagFilters)
|
||||
foreach (var room in await rooms.HotAsync(filter, take: 200))
|
||||
if (seen.Add(room.Id)) merged.Add(room);
|
||||
all = merged.OrderByDescending(r => r.HotScore).ToList();
|
||||
}
|
||||
|
||||
var page = all
|
||||
.Skip(Math.Max(skip, 0))
|
||||
.Take(Math.Clamp(take, 1, 100))
|
||||
@@ -151,16 +185,37 @@ public class RoomsController(
|
||||
return Ok(await BuildRoomServerListAsync(rows));
|
||||
}
|
||||
|
||||
/// <summary>GET <c>rooms/magic_door</c> — the Magic Door destination.
|
||||
/// The response is a WRAPPER, not the bare room: the client's
|
||||
/// <c>MOOCMPOIIGM</c> returns <c>Task<GAPJAGDNFEO></c>
|
||||
/// (NLDBPDCNNCF.txt:4503) and that reader requires the keys
|
||||
/// <c>RefreshesAt</c>, <c>RefreshIntervalMinutes</c> and <c>Room</c>
|
||||
/// (MIFGPMGEMEK.txt ISIL 046/073/097). Returning the room object
|
||||
/// directly left <c>Room</c> null and MagicDoorManager with no
|
||||
/// destination. The <c>partySize</c> query param is accepted and
|
||||
/// ignored — we have no party-size-aware curation.</summary>
|
||||
[HttpGet("rooms/magic_door")]
|
||||
[HttpGet("roomserver/rooms/magic_door")]
|
||||
public async Task<IActionResult> MagicDoor()
|
||||
public async Task<IActionResult> MagicDoor([FromQuery] int? partySize)
|
||||
{
|
||||
var room = await PublicRoomQuery()
|
||||
.OrderByDescending(r => r.HotScore)
|
||||
.ThenBy(r => r.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
if (room is null) return NotFound();
|
||||
return Ok((await BuildRoomServerListAsync(new[] { room })).First());
|
||||
|
||||
// The door re-rolls its destination on the client every
|
||||
// RefreshIntervalMinutes; RefreshesAt is when the current pick
|
||||
// expires. Keep the two consistent so the client's local timer
|
||||
// and its next fetch line up.
|
||||
const int refreshIntervalMinutes = 10;
|
||||
return Ok(new
|
||||
{
|
||||
Room = (await BuildRoomServerListAsync(new[] { room })).First(),
|
||||
RefreshesAt = DateTime.UtcNow.AddMinutes(refreshIntervalMinutes)
|
||||
.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
RefreshIntervalMinutes = refreshIntervalMinutes,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -263,8 +318,14 @@ public class RoomsController(
|
||||
/// Unlike the <c>api/rooms/v*</c> variants (flat <c>List<Room></c>),
|
||||
/// this one's client contract is a PAGED wrapper object
|
||||
/// <c>{ Results, TotalResults }</c> (IDLBPALJJDJ : PagedResult<Room>);
|
||||
/// returning a bare array crashes the watch's key-based reader.</summary>
|
||||
/// returning a bare array crashes the watch's key-based reader.
|
||||
/// Confirmed for 2023 too: <c>NLDBPDCNNCF.MOBJJDBNBMF</c> returns
|
||||
/// <c>Task<FFCPFGBNLHN></c> (NLDBPDCNNCF.txt:2733) whose reader
|
||||
/// requires Results/TotalResults (PJPBJKLDMNA.txt ISIL 040/059).</summary>
|
||||
// roomserver/ twin: the 2023 caller is on NLDBPDCNNCF, whose requests all
|
||||
// carry the roomserver/ prefix (docs/recroom-2023-room-save.md:83-93).
|
||||
[HttpGet("rooms/search")]
|
||||
[HttpGet("roomserver/rooms/search")]
|
||||
public async Task<IActionResult> SearchRoomServer(
|
||||
[FromQuery(Name = "query")] string? query,
|
||||
[FromQuery(Name = "value")] string? value,
|
||||
@@ -414,6 +475,46 @@ public class RoomsController(
|
||||
unityAssetVersion: unityAssetVersion));
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>rooms/{roomId}</c> — the owner's "delete room"
|
||||
/// button. Client method <c>NLDBPDCNNCF.FPDBNHOHIAO</c> formats
|
||||
/// <c>"rooms/{0}"</c> and moves verb ordinal 4 (= DELETE) into r8
|
||||
/// (NLDBPDCNNCF.txt ISIL 021/030 around :4897); its return type is the
|
||||
/// body-less promise <c>LDGADANDBIO</c> (CAOCJPMJMDB.txt:339), so the
|
||||
/// response payload is never parsed.
|
||||
///
|
||||
/// <para>The only DELETE for <c>rooms/{id}</c> in the codebase lived on
|
||||
/// AdminController, which is rooted at <c>api/admin/v1</c> — the game
|
||||
/// path therefore 405'd on the bare route and 404'd on the roomserver/
|
||||
/// one, so owners could not delete their rooms at all.</para>
|
||||
///
|
||||
/// <para>Soft-archive (<c>State = 1</c>) exactly like
|
||||
/// <c>AdminController.DeleteRoom</c>: a hard delete would orphan the
|
||||
/// room's saves, visits, bookmarks and role rows. Archived rooms drop
|
||||
/// out of every browse/search query (all of which filter
|
||||
/// <c>State == 0</c>).</para></summary>
|
||||
[HttpDelete("rooms/{roomId:long}")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> DeleteRoom(long roomId)
|
||||
{
|
||||
var me = this.RequireCurrentPlayerId();
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
if (room.CreatorPlayerId != me)
|
||||
{
|
||||
// Co-owners (Role 0) can delete too — same gate the rename path uses.
|
||||
var coOwner = await db.RoomRoles.AnyAsync(rr =>
|
||||
rr.RoomId == roomId && rr.PlayerId == me && rr.Accepted && rr.Role == 0);
|
||||
if (!coOwner) return Forbid();
|
||||
}
|
||||
|
||||
room.State = 1; // Archived
|
||||
room.UpdatedAt = DateTime.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
logger.LogInformation("[room-delete] room={Room} by={By}", roomId, me);
|
||||
return Ok(new { Result = 0, room.Id, room.State });
|
||||
}
|
||||
|
||||
// ── RoomDetails (the boot-critical one) ─────────────────────────────
|
||||
//
|
||||
// /v4/details/{roomId} is hit *during* the matchmaking goto flow.
|
||||
@@ -2511,8 +2612,13 @@ public class RoomsController(
|
||||
/// <summary>GET <c>api/rooms/v1/agRoomIds</c> — list of every
|
||||
/// seeded AG / RR-Original room id. Used by the watch to bias the
|
||||
/// room browser toward official rooms first.</summary>
|
||||
// Bare array of Int64 — the 2023 issuing method GBONEKLGFFK returns
|
||||
// Task<List<Int64>> (NLDBPDCNNCF.txt:1886, literal at :1929). The
|
||||
// roomserver/ twin is what that client actually requests (same prefix
|
||||
// rule as rooms/base, docs/recroom-2023-room-save.md:83-93).
|
||||
[HttpGet("api/rooms/v1/agRoomIds")]
|
||||
[HttpGet("rooms/rro_ids")]
|
||||
[HttpGet("roomserver/rooms/rro_ids")]
|
||||
public async Task<IActionResult> AgRoomIds() =>
|
||||
Ok(await rooms.AgRoomIdsAsync());
|
||||
|
||||
|
||||
@@ -244,6 +244,19 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
.ToListAsync();
|
||||
return Ok(rows.Select(b => new
|
||||
{
|
||||
// The 2023 banned-players reader (IBHAKOOKEEE) has EXACTLY three
|
||||
// members — AccountId (Int32), BannedByAccountId (Int32?) and
|
||||
// BanStartTime (DateTime) — registered at
|
||||
// IsilDump/RecNet.Runtime/ECEBBLBCFKO.txt:279 / :306 / :330 (the
|
||||
// property types come from IBHAKOOKEEE.txt:3/:83/:103). None of
|
||||
// them overlapped the legacy Id/RoomId/BannedPlayerId keys, so
|
||||
// the in-room "banned players" list rendered all-zero rows.
|
||||
// AccountId is Int32 on the wire, so clamp the long id.
|
||||
AccountId = ToClientAccountId(b.BannedPlayerId),
|
||||
BannedByAccountId = ToClientAccountId(b.BannedByPlayerId),
|
||||
BanStartTime = b.CreatedAt,
|
||||
// Legacy keys kept for the 2020.12 watch — both readers ignore
|
||||
// members they don't know.
|
||||
b.Id,
|
||||
b.RoomId,
|
||||
b.BannedPlayerId,
|
||||
@@ -255,28 +268,47 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>Account ids are Int64 server-side but Int32 in the 2023
|
||||
/// wire DTOs (IBHAKOOKEEE.AccountId is System.Int32). Clamp rather than
|
||||
/// wrap so an out-of-range id is obviously wrong instead of aliasing
|
||||
/// onto a real account.</summary>
|
||||
private static int ToClientAccountId(long accountId)
|
||||
=> accountId > int.MaxValue
|
||||
? int.MaxValue
|
||||
: accountId < int.MinValue
|
||||
? int.MinValue
|
||||
: (int)accountId;
|
||||
|
||||
// ── Report ───────────────────────────────────────────────────────────
|
||||
|
||||
public sealed class RoomReportRequest
|
||||
{
|
||||
public long RoomId { get; set; }
|
||||
public int Category { get; set; }
|
||||
public string? Message { get; set; }
|
||||
}
|
||||
|
||||
// The 2023 client posts the room report as x-www-form-urlencoded with
|
||||
// PascalCase keys RoomId (Int64) / RoomKeyId (Int64?) / Details (string) /
|
||||
// ReportCategory (Int32) — IsilDump/RecNet.Runtime/IBEOONPEELF.txt:23471
|
||||
// ("api/rooms/v2/report"), verb byte 2 = POST at :23473, field names at
|
||||
// :23486 / :23499 / :23506 / :23521. A [FromBody] parameter makes
|
||||
// ASP.NET reject the form POST with 415 BEFORE the handler runs (the same
|
||||
// mechanism documented for clone at :963-968 below), so reports were never
|
||||
// persisted and the client logged "Failed to report room"
|
||||
// (IBEOONPEELF.txt:23537). Read form-or-JSON manually and keep the older
|
||||
// Category/Message names as aliases for the 2020.12 watch.
|
||||
[HttpPost("api/rooms/v2/report")]
|
||||
public async Task<IActionResult> Report([FromBody] RoomReportRequest req)
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> Report()
|
||||
{
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == req.RoomId);
|
||||
var payload = await ReadPayloadAsync();
|
||||
var roomId = LongValue(payload, "RoomId", "RoomKeyId") ?? 0;
|
||||
if (roomId <= 0) return BadRequest(new { error = "missing_room" });
|
||||
|
||||
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == roomId);
|
||||
if (room is null) return NotFound();
|
||||
var msg = req.Message ?? string.Empty;
|
||||
var msg = RawValue(payload, "Details", "Message") ?? string.Empty;
|
||||
db.Reports.Add(new ReportEntity
|
||||
{
|
||||
ReporterPlayerId = Me,
|
||||
TargetPlayerId = room.CreatorPlayerId,
|
||||
TargetRoomId = req.RoomId,
|
||||
RoomId = req.RoomId,
|
||||
Category = req.Category,
|
||||
TargetRoomId = roomId,
|
||||
RoomId = roomId,
|
||||
Category = IntValue(payload, "ReportCategory", "Category") ?? 0,
|
||||
Message = msg[..Math.Min(1000, msg.Length)],
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
@@ -433,53 +465,98 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
};
|
||||
}
|
||||
|
||||
// ── Bare-path per-field mutations (2020.12 watch URLs) ─────────────
|
||||
// ── Bare-path per-field mutations (2020.12 watch + 2023 roomserver) ──
|
||||
//
|
||||
// Both clients emit per-field mutation URLs like
|
||||
// PUT rooms/{id}/description rather than POST api/rooms/v2/modify with
|
||||
// one field set. Each handler below shares the same RequireOwnedRoomAsync
|
||||
// gate as /modify and returns the FULL room-details object via
|
||||
// ApplyAndReturn (see the note there).
|
||||
//
|
||||
// Every handler reads the body itself instead of taking a [FromBody]
|
||||
// parameter: both clients send x-www-form-urlencoded, and under
|
||||
// [ApiController] a [FromBody] parameter makes ASP.NET reject a form body
|
||||
// with 415 BEFORE the handler runs (mechanism documented at the clone
|
||||
// handler below).
|
||||
//
|
||||
// The 2020.12 client emits per-field mutation URLs like
|
||||
// POST rooms/{id}/description rather than POST api/rooms/v2/modify
|
||||
// with one field set. Each handler below shares the same
|
||||
// RequireOwnedRoomAsync gate as /modify and returns the same
|
||||
// ToWireRoom shape so the watch's CreateModifyRoomResponse
|
||||
// deserializer reads cleanly.
|
||||
|
||||
public sealed class BareDescriptionRequest { public string? Description { get; set; } }
|
||||
public sealed class BareImageRequest { public string? ImageName { get; set; } }
|
||||
public sealed class BareTagsRequest { public string? Tags { get; set; } public List<string>? TagsList { get; set; } }
|
||||
public sealed class BareIntRequest { public int? Value { get; set; } }
|
||||
public sealed class BareBoolRequest { public bool? Value { get; set; } }
|
||||
public sealed class BareWarningRequest { public int? RoomWarningMask { get; set; } public string? CustomRoomWarning { get; set; } }
|
||||
|
||||
// Each per-field route registers BOTH POST and PUT — the 2020.12
|
||||
// watch's request-builder wraps the HTTP method inside opaque
|
||||
// BPHGKAEDBPE helpers; the existing /name handler at
|
||||
// RoomsController.cs:1455-1456 set the precedent of "register both
|
||||
// because the ISIL is opaque". Same pattern here so the bind never
|
||||
// 405s.
|
||||
// 405s. (The 2023 client is unambiguous: verb byte 3 = PUT, e.g.
|
||||
// IsilDump/RecNet.Runtime/NLDBPDCNNCF.txt:5369.)
|
||||
//
|
||||
// …and both bare and roomserver/-prefixed paths, because the 2023
|
||||
// in-room roomserver client (NLDBPDCNNCF) issues all of these under the
|
||||
// roomserver host prefix — a bare-only registration 404s there.
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/description</c> — form key
|
||||
/// <c>description</c> (NLDBPDCNNCF/HBKMCDLDHHO:57).</summary>
|
||||
[HttpPost("rooms/{roomId:long}/description")]
|
||||
[HttpPut("rooms/{roomId:long}/description")]
|
||||
public async Task<IActionResult> BareDescription(long roomId,
|
||||
[FromBody] BareDescriptionRequest? body,
|
||||
[FromForm(Name = "Description")] string? form) =>
|
||||
await ApplyAndReturn(roomId, r => r.Description = body?.Description ?? form ?? r.Description);
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/description")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/description")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareDescription(long roomId)
|
||||
{
|
||||
// RawValue, not the whitespace-skipping reader: clearing the
|
||||
// description sends the key with an empty value.
|
||||
var payload = await ReadPayloadAsync();
|
||||
var value = RawValue(payload, "description", "value");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
if (value is not null) r.Description = value;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/image</c> — form key <c>imageName</c>
|
||||
/// (NLDBPDCNNCF/MCDHHIBPJPD:57).</summary>
|
||||
[HttpPost("rooms/{roomId:long}/image")]
|
||||
[HttpPut("rooms/{roomId:long}/image")]
|
||||
public async Task<IActionResult> BareImage(long roomId,
|
||||
[FromBody] BareImageRequest? body,
|
||||
[FromForm(Name = "ImageName")] string? form) =>
|
||||
await ApplyAndReturn(roomId, r => r.ImageName = body?.ImageName ?? form ?? r.ImageName);
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/image")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/image")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareImage(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
var value = RawValue(payload, "imageName", "image", "name", "value");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
if (value is not null) r.ImageName = value;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/tags</c>. The 2023 client sends the tag
|
||||
/// lists as REPEATED form keys — <c>autoTag</c> then <c>tag</c>
|
||||
/// (NLDBPDCNNCF/FOPHALMJKGA:70 and :77, both fed from
|
||||
/// <c>IReadOnlyList<string></c> parameters, NLDBPDCNNCF.txt:5543) —
|
||||
/// NOT a single CSV field, so the old <c>Tags</c> binding read nothing
|
||||
/// and the edit was a silent no-op.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/tags")]
|
||||
[HttpPut("rooms/{roomId:long}/tags")]
|
||||
public async Task<IActionResult> BareTags(long roomId,
|
||||
[FromBody] BareTagsRequest? body,
|
||||
[FromForm(Name = "Tags")] string? form)
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/tags")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/tags")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareTags(long roomId)
|
||||
{
|
||||
var csv = body?.Tags
|
||||
?? (body?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
|
||||
?? form;
|
||||
return await ApplyAndReturn(roomId, r => r.TagsCsv = csv ?? r.TagsCsv);
|
||||
var payload = await ReadPayloadAsync();
|
||||
var tags = Values(payload, "autoTag", "tag");
|
||||
// 2020.12 watch / admin tooling: one comma-separated "Tags" field.
|
||||
var csv = Value(payload, "tags");
|
||||
if (csv is not null)
|
||||
tags.AddRange(csv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
|
||||
|
||||
// Only rewrite when the request actually carried a tag field —
|
||||
// otherwise a stray/emptied request would silently wipe the room's
|
||||
// tags. Sending the keys with no values IS how the client clears
|
||||
// them, so key presence (not value count) is the signal.
|
||||
var carriesTags = payload.ContainsKey("autoTag") || payload.ContainsKey("tag") || payload.ContainsKey("tags");
|
||||
var joined = string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase));
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
if (carriesTags) r.TagsCsv = joined;
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/accessibility")]
|
||||
@@ -681,6 +758,129 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Multi-field payload reader ──────────────────────────────────────
|
||||
//
|
||||
// The single-key Read*Async helpers above each parse Request.Body, and a
|
||||
// request body can only be consumed once — calling two of them in the
|
||||
// same handler silently loses every field after the first. Handlers that
|
||||
// need several fields (report / modify / restrictions / warning / bans /
|
||||
// loadscreen / promo_external) read the payload ONCE into the map below.
|
||||
//
|
||||
// It is a key → values map because the 2023 client encodes list
|
||||
// parameters as REPEATED form keys (tags sends tag=…&tag=…,
|
||||
// bans sends id=…&id=…). Keys are matched case-insensitively, which is
|
||||
// what lets one lookup serve both the 2023 camelCase names and the
|
||||
// 2020.12 PascalCase ones.
|
||||
|
||||
private async Task<Dictionary<string, List<string>>> ReadPayloadAsync()
|
||||
{
|
||||
var map = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
void Add(string key, string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(key) || value is null) return;
|
||||
if (!map.TryGetValue(key, out var list)) map[key] = list = new List<string>();
|
||||
list.Add(value);
|
||||
}
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in form.Keys)
|
||||
foreach (var value in form[key])
|
||||
Add(key, value);
|
||||
}
|
||||
else if ((Request.ContentLength ?? 0) > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (prop.Value.ValueKind == JsonValueKind.Array)
|
||||
foreach (var item in prop.Value.EnumerateArray())
|
||||
Add(prop.Name, JsonScalar(item));
|
||||
else
|
||||
Add(prop.Name, JsonScalar(prop.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Non-JSON / truncated body — fall through to the query string.
|
||||
}
|
||||
}
|
||||
|
||||
// Query string is the last-resort source (a few of these fields ride
|
||||
// on the URL for the 2020.12 watch). Never let it shadow a body field.
|
||||
foreach (var key in Request.Query.Keys)
|
||||
{
|
||||
if (map.ContainsKey(key)) continue;
|
||||
foreach (var value in Request.Query[key])
|
||||
Add(key, value);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static string? JsonScalar(JsonElement element) => element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False => element.ToString(),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>First NON-EMPTY value for any of <paramref name="keys"/>.</summary>
|
||||
private static string? Value(Dictionary<string, List<string>> map, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!map.TryGetValue(key, out var list)) continue;
|
||||
foreach (var value in list)
|
||||
if (!string.IsNullOrWhiteSpace(value)) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>First value for any of <paramref name="keys"/> INCLUDING the
|
||||
/// empty string. Text fields (description, customWarning) are cleared by
|
||||
/// sending the key with an empty value, so the non-empty filter in
|
||||
/// <see cref="Value"/> would turn "clear" into a no-op.</summary>
|
||||
private static string? RawValue(Dictionary<string, List<string>> map, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
if (map.TryGetValue(key, out var list) && list.Count > 0)
|
||||
return list[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Every non-empty value across <paramref name="keys"/>, in key
|
||||
/// order. Keys that differ only by case are visited once (the map is
|
||||
/// case-insensitive, so they'd otherwise duplicate the same list).</summary>
|
||||
private static List<string> Values(Dictionary<string, List<string>> map, params string[] keys)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!seen.Add(key)) continue;
|
||||
if (!map.TryGetValue(key, out var list)) continue;
|
||||
result.AddRange(list.Where(v => !string.IsNullOrWhiteSpace(v)).Select(v => v.Trim()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int? IntValue(Dictionary<string, List<string>> map, params string[] keys)
|
||||
=> int.TryParse(Value(map, keys), out var value) ? value : null;
|
||||
|
||||
private static long? LongValue(Dictionary<string, List<string>> map, params string[] keys)
|
||||
=> long.TryParse(Value(map, keys), out var value) ? value : null;
|
||||
|
||||
private static bool? BoolValue(Dictionary<string, List<string>> map, params string[] keys)
|
||||
=> TryParseBool(Value(map, keys), out var value) ? value : null;
|
||||
|
||||
private static bool TryParseBool(string? raw, out bool value)
|
||||
{
|
||||
if (bool.TryParse(raw, out value)) return true;
|
||||
@@ -726,47 +926,91 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
return json.Length <= maxChars ? json : "[]";
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/cloning</c> — form key
|
||||
/// <c>cloningAllowed</c> (NLDBPDCNNCF/ECMHAPMBNMA:67).</summary>
|
||||
[HttpPost("rooms/{roomId:long}/cloning")]
|
||||
[HttpPut("rooms/{roomId:long}/cloning")]
|
||||
public async Task<IActionResult> BareCloning(long roomId, [FromBody] BareBoolRequest? body,
|
||||
[FromForm(Name = "CloningAllowed")] bool? form) =>
|
||||
await ApplyAndReturn(roomId, r =>
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/cloning")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/cloning")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareCloning(long roomId)
|
||||
{
|
||||
var value = await ReadBareBoolAsync("cloningAllowed", "CloningAllowed", "value", "Value");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var v = body?.Value ?? form;
|
||||
if (v is bool vv) r.CloningAllowed = vv;
|
||||
if (value is bool v) r.CloningAllowed = v;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/automute</c>. The form key is
|
||||
/// <c>disable</c>, NOT DisableMicAutoMute
|
||||
/// (NLDBPDCNNCF/MAICLJKDBOB:67, from
|
||||
/// <c>ChangeRoomMicAutoMute(bool)</c>) — true means "mic auto-mute is
|
||||
/// disabled", so it maps straight onto DisableMicAutoMute.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/automute")]
|
||||
[HttpPut("rooms/{roomId:long}/automute")]
|
||||
public async Task<IActionResult> BareAutomute(long roomId, [FromBody] BareBoolRequest? body,
|
||||
[FromForm(Name = "DisableMicAutoMute")] bool? form) =>
|
||||
await ApplyAndReturn(roomId, r =>
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/automute")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/automute")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareAutomute(long roomId)
|
||||
{
|
||||
var value = await ReadBareBoolAsync("disable", "Disable", "DisableMicAutoMute", "value", "Value");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var v = body?.Value ?? form;
|
||||
if (v is bool vv) r.DisableMicAutoMute = vv;
|
||||
if (value is bool v) r.DisableMicAutoMute = v;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary><c>rooms/{id}/restrictions</c> — junior / age-related
|
||||
/// restriction toggle. Body bool maps to AllowsJuniors (true = no
|
||||
/// restriction, false = juniors restricted).</summary>
|
||||
/// <summary>PUT <c>rooms/{id}/restrictions</c> — the "who can play here"
|
||||
/// panel. The 2023 client sends FOUR booleans in one request:
|
||||
/// <c>supportsScreens</c>, <c>supportsWalkVR</c>,
|
||||
/// <c>supportsTeleportVR</c>, <c>supportsJuniors</c>
|
||||
/// (NLDBPDCNNCF/GNCDFBNCADM:26/:39/:52/:65, matching the four-bool
|
||||
/// signature at NLDBPDCNNCF.txt:6025). The old single-bool
|
||||
/// AllowsJuniors binding dropped three of the four settings.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/restrictions")]
|
||||
[HttpPut("rooms/{roomId:long}/restrictions")]
|
||||
public async Task<IActionResult> BareRestrictions(long roomId, [FromBody] BareBoolRequest? body,
|
||||
[FromForm(Name = "AllowsJuniors")] bool? form) =>
|
||||
await ApplyAndReturn(roomId, r =>
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/restrictions")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/restrictions")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareRestrictions(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
var screens = BoolValue(payload, "supportsScreens");
|
||||
var walkVR = BoolValue(payload, "supportsWalkVR");
|
||||
var teleportVR = BoolValue(payload, "supportsTeleportVR");
|
||||
// 2020.12 watch sent a bare AllowsJuniors/Value instead.
|
||||
var juniors = BoolValue(payload, "supportsJuniors", "allowsJuniors", "value");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var v = body?.Value ?? form;
|
||||
if (v is bool vv) r.AllowsJuniors = vv;
|
||||
if (screens is bool s) r.SupportsScreens = s;
|
||||
if (walkVR is bool w) r.SupportsWalkVR = w;
|
||||
if (teleportVR is bool t) r.SupportsTeleportVR = t;
|
||||
if (juniors is bool j) r.AllowsJuniors = j;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/warning</c> — content warnings. Form keys
|
||||
/// are <c>warningMask</c> (int) and <c>customWarning</c> (string)
|
||||
/// (NLDBPDCNNCF/AIDDFKACPLN:80 and :89), not the RoomWarningMask /
|
||||
/// CustomRoomWarning names the old [FromBody] DTO used.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/warning")]
|
||||
[HttpPut("rooms/{roomId:long}/warning")]
|
||||
public async Task<IActionResult> BareWarning(long roomId, [FromBody] BareWarningRequest? body) =>
|
||||
await ApplyAndReturn(roomId, r =>
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/warning")]
|
||||
[HttpPut("roomserver/rooms/{roomId:long}/warning")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareWarning(long roomId)
|
||||
{
|
||||
var payload = await ReadPayloadAsync();
|
||||
var mask = IntValue(payload, "warningMask", "RoomWarningMask");
|
||||
// RawValue so clearing the custom warning (empty string) sticks.
|
||||
var custom = RawValue(payload, "customWarning", "CustomRoomWarning");
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
if (body?.RoomWarningMask is int m) r.RoomWarningMask = m;
|
||||
if (body?.CustomRoomWarning is not null) r.CustomRoomWarning = body.CustomRoomWarning;
|
||||
if (mask is int m) r.RoomWarningMask = m;
|
||||
if (custom is not null) r.CustomRoomWarning = custom.Length > 512 ? custom[..512] : custom;
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/creator")]
|
||||
[HttpPut("rooms/{roomId:long}/creator")]
|
||||
@@ -831,6 +1075,14 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>PUT <c>rooms/{id}/loadscreen</c> — form keys
|
||||
/// <c>imageName</c> / <c>title</c> / <c>subtitle</c>
|
||||
/// (NLDBPDCNNCF/AFBGGBEMOGH:83/:93/:103, verb byte 3 = PUT at
|
||||
/// NLDBPDCNNCF.txt:8519). Builds the exact three-member object the
|
||||
/// client's load-screen reader wants — ImageName / Title / Subtitle
|
||||
/// (EOIMIBBJBCB.txt:255/:282/:298) — and APPENDS it. The old code
|
||||
/// serialised the whole form dict as one blob and REPLACED the list, so
|
||||
/// a room could never hold more than one load screen.</summary>
|
||||
[HttpPost("rooms/{roomId:long}/loadscreen")]
|
||||
[HttpPut("rooms/{roomId:long}/loadscreen")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||
@@ -838,16 +1090,66 @@ public class RoomsModerationController(DorkNetDbContext db) : ControllerBase
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareLoadScreen(long roomId)
|
||||
{
|
||||
var payload = await ReadJsonElementAsync("loadScreen", "LoadScreen", "value", "Value");
|
||||
var payload = await ReadPayloadAsync();
|
||||
var imageName = Value(payload, "imageName", "name", "value") ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(imageName)) return BadRequest(new { error = "missing_image" });
|
||||
var title = RawValue(payload, "title") ?? string.Empty;
|
||||
var subtitle = RawValue(payload, "subtitle") ?? string.Empty;
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
if (payload is JsonElement p)
|
||||
r.LoadScreensJson = p.ValueKind == JsonValueKind.Array
|
||||
? p.GetRawText()
|
||||
: SerializeLimited(new[] { p });
|
||||
// Re-setting an existing image replaces that entry rather than
|
||||
// stacking a duplicate.
|
||||
var screens = ReadJsonElementList(r.LoadScreensJson)
|
||||
.Where(s => !LoadScreenMatches(s, imageName))
|
||||
.ToList();
|
||||
screens.Add(JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
ImageName = imageName,
|
||||
Title = title,
|
||||
Subtitle = subtitle,
|
||||
}));
|
||||
r.LoadScreensJson = SerializeLimited(screens);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>DELETE <c>rooms/{id}/loadscreen</c> — "remove room loading
|
||||
/// screen" (NLDBPDCNNCF.txt:8677, verb byte 4 = DELETE at :8678), form
|
||||
/// key <c>imageName</c> (NLDBPDCNNCF/DMOMLNEJHGE:61). Was not registered
|
||||
/// at all, so removing a load screen 405'd.</summary>
|
||||
[HttpDelete("rooms/{roomId:long}/loadscreen")]
|
||||
[HttpDelete("roomserver/rooms/{roomId:long}/loadscreen")]
|
||||
[Consumes("application/x-www-form-urlencoded", "multipart/form-data", "application/json")]
|
||||
public async Task<IActionResult> BareLoadScreenRemove(long roomId)
|
||||
{
|
||||
var imageName = await ReadStringValueAsync("imageName", "ImageName", "name", "Name", "value", "Value")
|
||||
?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(imageName)) return BadRequest(new { error = "missing_image" });
|
||||
return await ApplyAndReturn(roomId, r =>
|
||||
{
|
||||
var screens = ReadJsonElementList(r.LoadScreensJson)
|
||||
.Where(s => !LoadScreenMatches(s, imageName))
|
||||
.ToList();
|
||||
r.LoadScreensJson = SerializeLimited(screens);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>A stored load screen matches when its ImageName does — that
|
||||
/// is the only identity the client sends on remove.</summary>
|
||||
private static bool LoadScreenMatches(JsonElement item, string imageName)
|
||||
{
|
||||
if (item.ValueKind == JsonValueKind.String)
|
||||
return string.Equals(item.GetString(), imageName, StringComparison.OrdinalIgnoreCase);
|
||||
if (item.ValueKind != JsonValueKind.Object) return false;
|
||||
foreach (var key in new[] { "ImageName", "imageName", "Name", "name" })
|
||||
{
|
||||
if (item.TryGetProperty(key, out var prop) &&
|
||||
prop.ValueKind == JsonValueKind.String &&
|
||||
string.Equals(prop.GetString(), imageName, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
[HttpPost("rooms/{roomId:long}/promo_images")]
|
||||
[HttpPut("rooms/{roomId:long}/promo_images")]
|
||||
[HttpPost("roomserver/rooms/{roomId:long}/promo_images")]
|
||||
|
||||
Reference in New Issue
Block a user