mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 15:38:45 -07:00
fda64ca3ce
- Updated Bulk method to parse incoming JSON object arrays for UGC item IDs. - Introduced ReadBulkIdsAsync to handle both object arrays and flat GUID lists. - Added synthetic placeholder responses for missing UGC items to prevent client errors. feat: Implement MatchmakeRoom and MatchmakeSubRoom endpoints in GoToController - Added MatchmakeRoom and MatchmakeSubRoom methods to facilitate joining public and sub-rooms by numeric ID. - Enhanced ApplySubRoomMutation to ensure proper state transitions for sub-rooms. - Improved logging for matchmaking actions. fix: Refactor PlayerSettingsController to handle database save exceptions - Introduced SaveSettingsChangesAsync to manage DbUpdateException for busy SQLite scenarios. - Updated methods to call SaveSettingsChangesAsync instead of direct SaveChangesAsync. chore: Update URL coverage documentation for new matchmaking endpoints - Documented new MatchmakeRoom and MatchmakeSubRoom endpoints in url-coverage.md. chore: Add default room data file for room loading - Included default_room.room to serve as a fallback for missing room data. fix: Improve SQLite concurrency handling in DatabaseBootstrap - Configured SQLite to use WAL mode and set a busy timeout for better concurrency. chore: Create SqliteErrors utility for handling SQLite exceptions - Added SqliteErrors class to encapsulate logic for detecting busy SQLite exceptions. refactor: Update RoomDataBlobService to serve default room data - Modified GetDefaultBlob to load and serve a default room blob with proper role data.
343 lines
14 KiB
C#
343 lines
14 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
|
|
{
|
|
[HttpGet("api/ugcPurchasables")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> 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<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,
|
|
};
|
|
}
|