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 { [HttpGet("api/ugcPurchasables")] [AllowAnonymous] public async Task List([FromQuery] 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 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 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 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 Bulk() { // The 2023 client posts {"RoomId":N,"Ids":[{"itemType":1, // "itemId":""}]} — 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()); 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); } /// Parse the bulk request's item ids. Handles the 2023 client's /// JSON object-array shape (Ids:[{itemType,itemId}]) as well as /// flat guid lists in the body/form/query for older callers. private async Task> ReadBulkIdsAsync() { var list = new List<(Guid, int)>(); var seen = new HashSet(); 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; } /// Minimal placeholder for a requested ugc item we don't have a /// row for. Same wire shape as so the client's /// deserializer resolves the reference; zero price / empty metadata. 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 FindItemAsync(Dictionary 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 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> ReadFieldsAsync() { var fields = new Dictionary(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 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 fields, params string[] names) => long.TryParse(ReadString(fields, names), out var value) ? value : null; private static int? ReadInt(Dictionary fields, params string[] names) => int.TryParse(ReadString(fields, names), out var value) ? value : null; private static bool? ReadBool(Dictionary fields, params string[] names) => bool.TryParse(ReadString(fields, names), out var value) ? value : null; private static Guid? ReadGuid(Dictionary fields, params string[] names) => Guid.TryParse(ReadString(fields, names), out var value) ? value : null; private static List ReadGuidList(Dictionary fields, params string[] names) => names.SelectMany(name => fields.TryGetValue(name, out var value) ? value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) : Array.Empty()) .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, }; }