Files
DorkNet/DorkNet.Server/Controllers/API/UgcPurchasables/UgcPurchasablesController.cs
T
Alexa 327dcb789c fix: correct 2023-03-21 client wire contracts across the API and notify surfaces
Audits every route the March-2023 client can call against what this server
actually serves, and fixes the defects that were breaking gameplay. Adds
docs/recroom-2023-client-api-complete.md as the reference: ~500 route
literals across 13 subsystems, with verb, request/response shape, and a
per-subsystem diff. 315 defects catalogued; the ones fixed here are listed
in its "Already fixed" table.

Path coverage was never the problem — 465 of 506 routes already had
handlers. The failures were in shape, and they cluster into four causes:

- Wrong verb. The verb is a BestHTTP.HTTPMethods ordinal (GET=0 .. PATCH=5),
  confirmed from that enum's static constructor. Routes registered POST-only
  while the client PUTs returned 405, which made room consumables neither
  buyable nor consumable and blocked every room-key edit.
- Bare scalar wrapped in an object. The client reads `1000`, not
  `{"Limit":1000}`; the issuing method signature says which. Wrapping makes
  the deserializer throw and the feature dies silently.
- [FromBody] vs form-urlencoded. The client sends nearly every write as form
  data, which an [ApiController] action bound [FromBody] rejects with 415
  before the handler runs. This alone broke all 13 club member-management
  endpoints. Adds Binding/FormOrJsonModelBinder so this is fixed once.
- Notify dispatch keys. Both enum overloads of the client's RegisterHandler
  lower the id via Int32.ToString() and register only that numeric key, and
  lookup is a case-sensitive TryGetValue that returns silently on a miss. We
  were sending the enum NAME for most ids, so friend-request updates, forced
  logout, maintenance broadcasts, live balance and consumable updates, and
  invention moderation pushes all evaporated with no error anywhere.

Notable behavioural bugs fixed:

- Editing a club announcement deleted it: PUT was a third binding on the
  delete handler. Split apart, and the missing create route added.
- leaderboard/CheckAndSetStat did not exist. The 2023 client has no SetStat
  literal at all, so every leaderboard score it wrote was discarded.
- Setting a club clubhouse cleared it: the handler read roomId from the query
  while the client sends it in the body, then wrote the resulting null.
- Club join requests were inverted (the enum is Open=0, InviteOnly=1,
  AskToJoin=2), so ask-to-join clubs rejected everyone and invite-only clubs
  quietly admitted them.
- Room bans were pushed as id 24, for which the client registers no handler;
  they now go out as ModerationKick with IsBan, so the ban takes effect
  immediately instead of at next login.
- Room-key ownership queries answered for the wrong player, and "has anyone
  bought this key" asked "did I buy it" — always false for the creator.

Also persists settings that were previously acknowledged and dropped
(playlist accessibility/restrictions/level-voting/warning, per-player status
visibility, VR movement mode and avoid-juniors, custom room codes), which is
what the new columns in the migration are for.

One test asserted the old nested PriceAndCurrency response shape; the
client's consumable formatter has no such key and reads Price and
PurchaseCurrencyId flat, so the test was corrected alongside the handler.
2026-07-27 23:27:23 +03:00

351 lines
15 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using DorkNet.Server.Auth;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
namespace DorkNet.Server.Controllers.API.UgcPurchasables;
[ApiController]
public class UgcPurchasablesController(DorkNetDbContext db) : ControllerBase
{
/// <summary>The 2023-03-21 client asks for a room's in-room store with the
/// path form <c>api/ugcPurchasables/v1/items/room/{roomId}</c>, not the
/// <c>?roomId=</c> query this handler was originally written for. Without
/// the path route it 404'd and the in-room UGC store never populated on
/// room join.</summary>
[HttpGet("api/ugcPurchasables/v1/items/room/{roomId:long}")]
[HttpGet("api/ugcPurchasables")]
[AllowAnonymous]
// No explicit binding source: roomId comes from the route on the path form
// above and from the query string on the bare route.
public async Task<IActionResult> List(long roomId = 0)
{
var rows = await db.UgcPurchasables
.Where(i => !i.IsDeleted && (roomId <= 0 || i.RoomId == roomId))
.OrderBy(i => i.SortOrder)
.ThenBy(i => i.Name)
.Take(200)
.ToListAsync();
return Ok(rows.Select(ToWire));
}
[HttpPost("api/ugcPurchasables")]
[Authorize]
public async Task<IActionResult> Create()
{
var fields = await ReadFieldsAsync();
var roomId = ReadLong(fields, "roomId", "RoomId");
if (roomId is not long rid || rid <= 0) return BadRequest("missing_room");
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == rid);
if (room is null) return NotFound("room_not_found");
var me = this.RequireCurrentPlayerId();
if (!await CanManageRoomAsync(room, me)) return Forbid();
var item = new UgcPurchasableEntity
{
RoomId = room.Id,
CreatorPlayerId = me,
Name = Trim(ReadString(fields, "name", "Name") ?? "Purchasable", 128),
Description = Trim(ReadString(fields, "description", "Description"), 1024),
ImageName = Trim(ReadString(fields, "imageName", "ImageName"), 256),
Price = Math.Max(0, ReadInt(fields, "price", "Price") ?? 0),
CurrencyType = Math.Max(0, ReadInt(fields, "currencyType", "CurrencyType") ?? 2),
ItemType = Math.Max(0, ReadInt(fields, "itemType", "ItemType") ?? 0),
SortOrder = ReadInt(fields, "sortOrder", "SortOrder") ?? 0,
IsFeatured = ReadBool(fields, "isFeatured", "IsFeatured") ?? false,
};
db.UgcPurchasables.Add(item);
await db.SaveChangesAsync();
return Ok(ToWire(item));
}
[HttpPut("api/ugcPurchasables")]
[HttpPost("api/ugcPurchasables/update")]
[Authorize]
public async Task<IActionResult> Update()
{
var fields = await ReadFieldsAsync();
var item = await FindItemAsync(fields);
if (item is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid();
if (ReadString(fields, "name", "Name") is { } name) item.Name = Trim(name, 128);
if (ReadString(fields, "description", "Description") is { } description) item.Description = Trim(description, 1024);
if (ReadString(fields, "imageName", "ImageName") is { } imageName) item.ImageName = Trim(imageName, 256);
if (ReadInt(fields, "price", "Price") is int price) item.Price = Math.Max(0, price);
if (ReadInt(fields, "currencyType", "CurrencyType") is int currencyType) item.CurrencyType = Math.Max(0, currencyType);
if (ReadInt(fields, "itemType", "ItemType") is int itemType) item.ItemType = Math.Max(0, itemType);
if (ReadInt(fields, "sortOrder", "SortOrder") is int sortOrder) item.SortOrder = sortOrder;
if (ReadBool(fields, "isFeatured", "IsFeatured") is bool featured) item.IsFeatured = featured;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(item));
}
[HttpDelete("api/ugcPurchasables")]
[HttpPost("api/ugcPurchasables/delete")]
[Authorize]
public async Task<IActionResult> Delete()
{
var fields = await ReadFieldsAsync();
var item = await FindItemAsync(fields);
if (item is null) return NotFound();
var room = await db.Rooms.FirstOrDefaultAsync(r => r.Id == item.RoomId);
if (room is null || !await CanManageRoomAsync(room, this.RequireCurrentPlayerId())) return Forbid();
item.IsDeleted = true;
item.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return Ok(ToWire(item));
}
[HttpGet("api/ugcPurchasables/v1/items/bulk")]
[HttpPost("api/ugcPurchasables/v1/items/bulk")]
[AllowAnonymous]
public async Task<IActionResult> Bulk()
{
// The 2023 client posts {"RoomId":N,"Ids":[{"itemType":1,
// "itemId":"<guid>"}]} — Ids is an array of OBJECTS, not flat guid
// strings. The generic field-flattener collapses each object to its
// JSON text, so the old ReadGuidList parsed ZERO guids and this
// endpoint always returned [] — the client's UGC resolver then
// re-queried the same ids forever (the growing-list flood in the
// server log), the referenced items never resolved, and the online
// player/avatar spawn null-reffed on an unresolved custom item
// (reportjoinresult FailedToSpawnPlayer → infinite dorm bounce).
var requested = await ReadBulkIdsAsync();
if (requested.Count == 0) return Ok(Array.Empty<object>());
var ids = requested.Select(r => r.Id).ToList();
var rows = await db.UgcPurchasables
.Where(i => !i.IsDeleted && ids.Contains(i.PublicId))
.ToListAsync();
var byId = rows.ToDictionary(r => r.PublicId);
// Return an entry for EVERY requested id, in request order. Items not
// in our DB (custom UGC the player owns from real Rec Room that we
// never ingested) get a synthetic placeholder so the client treats
// the reference as resolved instead of spinning.
var result = requested
.Select(r => byId.TryGetValue(r.Id, out var row) ? ToWire(row) : SyntheticWire(r.Id, r.ItemType))
.ToList();
return Ok(result);
}
/// <summary>Parse the bulk request's item ids. Handles the 2023 client's
/// JSON object-array shape (<c>Ids:[{itemType,itemId}]</c>) as well as
/// flat guid lists in the body/form/query for older callers.</summary>
private async Task<List<(Guid Id, int ItemType)>> ReadBulkIdsAsync()
{
var list = new List<(Guid, int)>();
var seen = new HashSet<Guid>();
void Add(Guid id, int itemType)
{
if (id != Guid.Empty && seen.Add(id)) list.Add((id, itemType));
}
if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& (TryGetPropertyCI(root, "Ids", out var idsEl)
|| TryGetPropertyCI(root, "itemIds", out idsEl)
|| TryGetPropertyCI(root, "ugcPurchasableIds", out idsEl))
&& idsEl.ValueKind == JsonValueKind.Array)
{
foreach (var el in idsEl.EnumerateArray())
{
if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var flat))
{
Add(flat, 0);
}
else if (el.ValueKind == JsonValueKind.Object)
{
var itemType = TryGetPropertyCI(el, "itemType", out var it) && it.TryGetInt32(out var n) ? n : 0;
if ((TryGetPropertyCI(el, "itemId", out var idEl)
|| TryGetPropertyCI(el, "id", out idEl)
|| TryGetPropertyCI(el, "ugcPurchasableId", out idEl))
&& Guid.TryParse(idEl.GetString(), out var guid))
{
Add(guid, itemType);
}
}
}
}
}
catch (JsonException)
{
}
}
// Fallback: flat guid lists in form/query (older callers).
if (list.Count == 0)
{
var fields = await ReadFieldsAsync();
foreach (var id in ReadGuidList(fields, "ids", "Ids", "itemIds", "ItemIds", "ugcPurchasableIds", "UgcPurchasableIds"))
Add(id, 0);
}
return list.Take(200).ToList();
}
private static bool TryGetPropertyCI(JsonElement obj, string name, out JsonElement value)
{
foreach (var prop in obj.EnumerateObject())
{
if (string.Equals(prop.Name, name, StringComparison.OrdinalIgnoreCase))
{
value = prop.Value;
return true;
}
}
value = default;
return false;
}
/// <summary>Minimal placeholder for a requested ugc item we don't have a
/// row for. Same wire shape as <see cref="ToWire"/> so the client's
/// deserializer resolves the reference; zero price / empty metadata.</summary>
private static object SyntheticWire(Guid id, int itemType) => new
{
UgcPurchasableId = id,
PurchasableItemId = id,
Id = id,
InternalId = 0L,
RoomId = 0L,
CreatorPlayerId = 0,
Name = string.Empty,
Description = string.Empty,
ImageName = string.Empty,
Price = 0,
CurrencyType = 2,
ItemType = itemType,
IsFeatured = false,
SortOrder = 0,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
private async Task<UgcPurchasableEntity?> FindItemAsync(Dictionary<string, string> fields)
{
var publicId = ReadGuid(fields, "ugcPurchasableId", "UgcPurchasableId", "itemId", "ItemId", "id", "Id");
if (publicId is Guid guid)
return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.PublicId == guid && !i.IsDeleted);
var localId = ReadLong(fields, "internalId", "ugcPurchasableInternalId");
if (localId is long id)
return await db.UgcPurchasables.FirstOrDefaultAsync(i => i.Id == id && !i.IsDeleted);
return null;
}
private async Task<bool> CanManageRoomAsync(RoomEntity room, long playerId)
{
if (room.CreatorPlayerId == playerId) return true;
if (await db.RoomRoles.AnyAsync(r => r.RoomId == room.Id && r.PlayerId == playerId && r.Role == 0 && r.Accepted))
return true;
return await db.Players.Where(p => p.Id == playerId).Select(p => p.IsAdmin).FirstOrDefaultAsync();
}
private async Task<Dictionary<string, string>> ReadFieldsAsync()
{
var fields = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in Request.Query)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var pair in form)
fields[pair.Key] = pair.Value.FirstOrDefault() ?? string.Empty;
}
else if ((Request.ContentLength ?? 0) > 0
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
{
try
{
using var doc = await JsonDocument.ParseAsync(Request.Body);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
foreach (var prop in doc.RootElement.EnumerateObject())
{
fields[prop.Name] = prop.Value.ValueKind switch
{
JsonValueKind.Array => string.Join(",", prop.Value.EnumerateArray().Select(v => v.ToString())),
JsonValueKind.String => prop.Value.GetString() ?? string.Empty,
_ => prop.Value.GetRawText(),
};
}
}
}
catch (JsonException)
{
}
}
return fields;
}
private static string? ReadString(Dictionary<string, string> fields, params string[] names)
{
foreach (var name in names)
if (fields.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value))
return value;
return null;
}
private static long? ReadLong(Dictionary<string, string> fields, params string[] names) =>
long.TryParse(ReadString(fields, names), out var value) ? value : null;
private static int? ReadInt(Dictionary<string, string> fields, params string[] names) =>
int.TryParse(ReadString(fields, names), out var value) ? value : null;
private static bool? ReadBool(Dictionary<string, string> fields, params string[] names) =>
bool.TryParse(ReadString(fields, names), out var value) ? value : null;
private static Guid? ReadGuid(Dictionary<string, string> fields, params string[] names) =>
Guid.TryParse(ReadString(fields, names), out var value) ? value : null;
private static List<Guid> ReadGuidList(Dictionary<string, string> fields, params string[] names) =>
names.SelectMany(name => fields.TryGetValue(name, out var value)
? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
: Array.Empty<string>())
.Select(v => Guid.TryParse(v, out var id) ? id : Guid.Empty)
.Where(id => id != Guid.Empty)
.Distinct()
.Take(200)
.ToList();
private static string Trim(string? value, int max)
{
var s = (value ?? string.Empty).Trim();
return s.Length <= max ? s : s[..max];
}
private static object ToWire(UgcPurchasableEntity item) => new
{
UgcPurchasableId = item.PublicId,
PurchasableItemId = item.PublicId,
Id = item.PublicId,
InternalId = item.Id,
item.RoomId,
CreatorPlayerId = (int)item.CreatorPlayerId,
item.Name,
item.Description,
item.ImageName,
item.Price,
item.CurrencyType,
item.ItemType,
item.IsFeatured,
item.SortOrder,
item.CreatedAt,
item.UpdatedAt,
};
}