mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
327dcb789c
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.
426 lines
16 KiB
C#
426 lines
16 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using DorkNet.Server.Auth;
|
|
using DorkNet.Server.Data;
|
|
using DorkNet.Server.Data.Entities;
|
|
using DorkNet.Server.Services;
|
|
|
|
namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
|
|
|
|
[ApiController]
|
|
[Route("api/customAvatarItems")]
|
|
[Route("api/customAvatarItems/v1")]
|
|
[Route("api/customAvatarItems/v2")]
|
|
public class CustomAvatarItemsController(
|
|
DorkNetDbContext db,
|
|
LevelService level,
|
|
DomainConfig domain) : ControllerBase
|
|
{
|
|
[HttpGet]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> List(
|
|
[FromQuery] string? q = null,
|
|
[FromQuery] string? query = null,
|
|
[FromQuery] int skip = 0,
|
|
[FromQuery] int take = 50)
|
|
{
|
|
take = Math.Clamp(take, 1, 100);
|
|
skip = Math.Max(0, skip);
|
|
var needle = (q ?? query ?? string.Empty).Trim().ToLowerInvariant();
|
|
IQueryable<CustomAvatarItemEntity> rows = db.CustomAvatarItems
|
|
.Where(i => i.IsPublic);
|
|
if (needle.Length > 0)
|
|
{
|
|
rows = rows.Where(i =>
|
|
i.Name.ToLower().Contains(needle) ||
|
|
i.Description.ToLower().Contains(needle));
|
|
}
|
|
|
|
var result = await rows
|
|
.OrderByDescending(i => i.UpdatedAt)
|
|
.Skip(skip)
|
|
.Take(take)
|
|
.ToListAsync();
|
|
return Ok(result.Select(ToWire));
|
|
}
|
|
|
|
[HttpGet("{id:guid}")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Get(Guid id)
|
|
{
|
|
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
|
if (item is null) return NotFound();
|
|
if (!item.IsPublic && item.CreatorPlayerId != this.CurrentPlayerId()) return Forbid();
|
|
return Ok(ToWire(item));
|
|
}
|
|
|
|
[HttpGet("bulk")]
|
|
[HttpPost("bulk")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Bulk([FromBody] BulkRequest? body)
|
|
{
|
|
var ids = await ReadIdsAsync(body);
|
|
if (ids.Count == 0) return Ok(Array.Empty<object>());
|
|
var rows = await db.CustomAvatarItems
|
|
.Where(i => ids.Contains(i.PublicId) && (i.IsPublic || i.CreatorPlayerId == this.CurrentPlayerId()))
|
|
.ToListAsync();
|
|
return Ok(rows.Select(ToWire));
|
|
}
|
|
|
|
[HttpGet("featured")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Featured([FromQuery] int take = 50)
|
|
{
|
|
take = Math.Clamp(take, 1, 100);
|
|
var rows = await db.CustomAvatarItems
|
|
.Where(i => i.IsPublic && i.IsFeatured)
|
|
.OrderByDescending(i => i.UpdatedAt)
|
|
.Take(take)
|
|
.ToListAsync();
|
|
return Ok(rows.Select(ToWire));
|
|
}
|
|
|
|
[HttpGet("hot")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Hot([FromQuery] int take = 50)
|
|
{
|
|
take = Math.Clamp(take, 1, 100);
|
|
var rows = await db.CustomAvatarItems
|
|
.Where(i => i.IsPublic)
|
|
.OrderByDescending(i => i.CheerCount)
|
|
.ThenByDescending(i => i.PurchaseCount)
|
|
.ThenByDescending(i => i.UpdatedAt)
|
|
.Take(take)
|
|
.ToListAsync();
|
|
return Ok(rows.Select(ToWire));
|
|
}
|
|
|
|
[HttpGet("new")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> New([FromQuery] int take = 50)
|
|
{
|
|
take = Math.Clamp(take, 1, 100);
|
|
var rows = await db.CustomAvatarItems
|
|
.Where(i => i.IsPublic)
|
|
.OrderByDescending(i => i.CreatedAt)
|
|
.Take(take)
|
|
.ToListAsync();
|
|
return Ok(rows.Select(ToWire));
|
|
}
|
|
|
|
[HttpGet("fromCreator/{creatorId:long}")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> FromCreator(
|
|
long creatorId,
|
|
[FromQuery] int skip = 0,
|
|
[FromQuery] int take = 100)
|
|
{
|
|
skip = Math.Max(0, skip);
|
|
take = Math.Clamp(take, 1, 100);
|
|
var currentPlayerId = this.CurrentPlayerId();
|
|
var query = db.CustomAvatarItems
|
|
.Where(i => i.CreatorPlayerId == creatorId
|
|
&& (i.IsPublic || i.CreatorPlayerId == currentPlayerId));
|
|
var total = await query.CountAsync();
|
|
var rows = await query
|
|
.OrderByDescending(i => i.UpdatedAt)
|
|
.Skip(skip)
|
|
.Take(take)
|
|
.ToListAsync();
|
|
// Paged container, not a bare array — the client deserializes this into
|
|
// CKFLFJCNEPH {Results, TotalResults}, same as Owned() below.
|
|
return Ok(new { Results = rows.Select(ToWire), TotalResults = total });
|
|
}
|
|
|
|
[HttpGet("me")]
|
|
[Authorize]
|
|
public async Task<IActionResult> Me([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
|
{
|
|
var pid = this.RequireCurrentPlayerId();
|
|
skip = Math.Max(0, skip);
|
|
take = Math.Clamp(take, 1, 100);
|
|
|
|
var createdQuery = db.CustomAvatarItems
|
|
.Where(i => i.CreatorPlayerId == pid)
|
|
.OrderByDescending(i => i.UpdatedAt);
|
|
var total = await createdQuery.CountAsync();
|
|
var created = await createdQuery
|
|
.Skip(skip)
|
|
.Take(take)
|
|
.ToListAsync();
|
|
var ownedIds = await db.CustomAvatarItemOwnership
|
|
.Where(o => o.PlayerId == pid)
|
|
.Select(o => o.CustomAvatarItemId)
|
|
.ToListAsync();
|
|
var owned = ownedIds.Count == 0
|
|
? new List<CustomAvatarItemEntity>()
|
|
: await db.CustomAvatarItems
|
|
.Where(i => ownedIds.Contains(i.Id))
|
|
.OrderByDescending(i => i.UpdatedAt)
|
|
.ToListAsync();
|
|
|
|
return Ok(new
|
|
{
|
|
Results = created.Select(ToWire),
|
|
TotalResults = total,
|
|
Created = created.Select(ToWire),
|
|
Owned = owned.Select(ToWire),
|
|
});
|
|
}
|
|
|
|
[HttpGet("owned")]
|
|
[Authorize]
|
|
public async Task<IActionResult> Owned([FromQuery] int skip = 0, [FromQuery] int take = 100)
|
|
{
|
|
var pid = this.RequireCurrentPlayerId();
|
|
skip = Math.Max(0, skip);
|
|
take = Math.Clamp(take, 1, 100);
|
|
|
|
var query = db.CustomAvatarItemOwnership
|
|
.Where(o => o.PlayerId == pid)
|
|
.Join(db.CustomAvatarItems, o => o.CustomAvatarItemId, i => i.Id, (o, i) => i)
|
|
.OrderByDescending(i => i.UpdatedAt);
|
|
var total = await query.CountAsync();
|
|
var rows = await query
|
|
.Skip(skip)
|
|
.Take(take)
|
|
.ToListAsync();
|
|
return Ok(new
|
|
{
|
|
Results = rows.Select(ToWire),
|
|
TotalResults = total,
|
|
});
|
|
}
|
|
|
|
[HttpGet("isCreationAllowedForAccount")]
|
|
[Authorize]
|
|
public async Task<IActionResult> IsCreationAllowedForAccount()
|
|
{
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var player = await db.Players
|
|
.Where(p => p.Id == pid)
|
|
.Select(p => new { p.IsJunior, p.BannedUntil })
|
|
.FirstOrDefaultAsync();
|
|
var allowed = player is not null
|
|
&& !player.IsJunior
|
|
&& (player.BannedUntil is null || player.BannedUntil <= DateTime.UtcNow);
|
|
// Bare JSON boolean, like the two sibling gates below: the client's
|
|
// issuing method is FGLDKEJLAKB<System.Boolean> GGMDLMLJDDK()
|
|
// (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route literal on :6205), so an
|
|
// object body threw and the can-create gate blocked shirt creation.
|
|
return Content(allowed ? "true" : "false", "application/json");
|
|
}
|
|
|
|
[HttpGet("isCreationEnabled")]
|
|
[AllowAnonymous]
|
|
public IActionResult IsCreationEnabled() => Content("true", "application/json");
|
|
|
|
[HttpGet("isRenderingEnabled")]
|
|
[AllowAnonymous]
|
|
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
|
|
|
[HttpGet("minPriceForPublicItem")]
|
|
[AllowAnonymous]
|
|
public IActionResult MinPriceForPublicItem() => Ok(new { MinPrice = 100, Price = 100 });
|
|
|
|
[HttpPost("design")]
|
|
[Authorize]
|
|
[Consumes("application/json", "application/x-www-form-urlencoded", "multipart/form-data")]
|
|
public async Task<IActionResult> Design()
|
|
{
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var req = await ReadDesignRequestAsync();
|
|
var name = (req.Name ?? string.Empty).Trim();
|
|
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
|
|
if (name.Length > 128) name = name[..128];
|
|
|
|
var description = (req.Description ?? string.Empty).Trim();
|
|
if (description.Length > 1024) description = description[..1024];
|
|
|
|
CustomAvatarItemEntity item;
|
|
if (req.Id is Guid id)
|
|
{
|
|
item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id)
|
|
?? new CustomAvatarItemEntity { PublicId = id, CreatorPlayerId = pid };
|
|
if (item.Id != 0 && item.CreatorPlayerId != pid) return Forbid();
|
|
if (item.Id == 0) db.CustomAvatarItems.Add(item);
|
|
}
|
|
else
|
|
{
|
|
item = new CustomAvatarItemEntity { CreatorPlayerId = pid };
|
|
db.CustomAvatarItems.Add(item);
|
|
}
|
|
|
|
item.Name = name;
|
|
item.Description = description;
|
|
item.Price = Math.Max(100, req.Price ?? item.Price);
|
|
item.ItemType = req.ItemType ?? item.ItemType;
|
|
item.BaseAvatarItemId = req.BaseAvatarItemId ?? item.BaseAvatarItemId;
|
|
item.Color = Trim(req.Color, 64);
|
|
item.ImageName = Trim(req.ImageName, 256);
|
|
item.AssetName = Trim(req.AssetName, 256);
|
|
item.IsPublic = req.IsPublic ?? item.IsPublic;
|
|
item.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
|
|
await EnsureOwnershipAsync(pid, item.Id);
|
|
return Ok(ToWire(item));
|
|
}
|
|
|
|
/// <summary>The 2023-03-21 client buys custom items through the
|
|
/// <c>econ/</c> host prefix — <c>econ/customAvatarItems/v1/{id}/purchase</c>
|
|
/// — which was never registered, so every custom-shirt purchase 404'd. The
|
|
/// rooted route below is an alias onto the same handler. <c>requestedPrice</c>
|
|
/// arrives as a query param and is validated against the item's real price
|
|
/// so a tampered client can't buy at its own number.</summary>
|
|
[HttpPost("/econ/customAvatarItems/v1/{id:guid}/purchase")]
|
|
[HttpPost("{id:guid}/purchase")]
|
|
[Authorize]
|
|
public async Task<IActionResult> Purchase(Guid id)
|
|
{
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id && i.IsPublic);
|
|
if (item is null) return NotFound();
|
|
if (await db.CustomAvatarItemOwnership.AnyAsync(o => o.PlayerId == pid && o.CustomAvatarItemId == item.Id))
|
|
return Ok(new { Success = true, AlreadyOwned = true, Item = ToWire(item) });
|
|
|
|
var balance = await level.GetBalanceAsync(pid, 2);
|
|
if (balance < item.Price)
|
|
return Ok(new { Success = false, Error = "insufficient_funds", Balance = balance });
|
|
|
|
var newBalance = await level.GrantCurrencyAsync(pid, 2, -item.Price, $"customAvatarItem:{item.PublicId}");
|
|
await EnsureOwnershipAsync(pid, item.Id);
|
|
item.PurchaseCount += 1;
|
|
item.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return Ok(new { Success = true, Balance = newBalance, Item = ToWire(item) });
|
|
}
|
|
|
|
private async Task EnsureOwnershipAsync(long playerId, long itemId)
|
|
{
|
|
if (await db.CustomAvatarItemOwnership.AnyAsync(o =>
|
|
o.PlayerId == playerId && o.CustomAvatarItemId == itemId))
|
|
{
|
|
return;
|
|
}
|
|
|
|
db.CustomAvatarItemOwnership.Add(new CustomAvatarItemOwnershipEntity
|
|
{
|
|
PlayerId = playerId,
|
|
CustomAvatarItemId = itemId,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
private async Task<List<Guid>> ReadIdsAsync(BulkRequest? body)
|
|
{
|
|
var result = new List<Guid>();
|
|
if (body?.Ids is { Count: > 0 })
|
|
result.AddRange(body.Ids);
|
|
if (body?.ItemIds is { Count: > 0 })
|
|
result.AddRange(body.ItemIds);
|
|
|
|
foreach (var pair in Request.Query)
|
|
foreach (var value in pair.Value)
|
|
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
if (Guid.TryParse(part, out var id)) result.Add(id);
|
|
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
foreach (var key in new[] { "ids", "Ids", "itemIds", "ItemIds" })
|
|
foreach (var value in form[key])
|
|
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
|
if (Guid.TryParse(part, out var id)) result.Add(id);
|
|
}
|
|
|
|
return result.Distinct().Take(200).ToList();
|
|
}
|
|
|
|
private async Task<DesignRequest> ReadDesignRequestAsync()
|
|
{
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
return new DesignRequest
|
|
{
|
|
Id = Guid.TryParse(form["id"].FirstOrDefault() ?? form["Id"].FirstOrDefault(), out var id) ? id : null,
|
|
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
|
|
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
|
|
Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null,
|
|
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var itemType) ? itemType : null,
|
|
BaseAvatarItemId = int.TryParse(form["baseAvatarItemId"].FirstOrDefault() ?? form["BaseAvatarItemId"].FirstOrDefault(), out var baseId) ? baseId : null,
|
|
Color = form["color"].FirstOrDefault() ?? form["Color"].FirstOrDefault(),
|
|
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
|
|
AssetName = form["assetName"].FirstOrDefault() ?? form["AssetName"].FirstOrDefault(),
|
|
IsPublic = bool.TryParse(form["isPublic"].FirstOrDefault() ?? form["IsPublic"].FirstOrDefault(), out var pub) ? pub : null,
|
|
};
|
|
}
|
|
|
|
try
|
|
{
|
|
return await JsonSerializer.DeserializeAsync<DesignRequest>(
|
|
Request.Body,
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
|
?? new DesignRequest();
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return new DesignRequest();
|
|
}
|
|
}
|
|
|
|
private object ToWire(CustomAvatarItemEntity item) => new
|
|
{
|
|
Id = item.PublicId,
|
|
CustomAvatarItemId = item.PublicId,
|
|
CreatorId = (int)item.CreatorPlayerId,
|
|
CreatorPlayerId = (int)item.CreatorPlayerId,
|
|
item.Name,
|
|
item.Description,
|
|
item.Price,
|
|
item.ItemType,
|
|
item.BaseAvatarItemId,
|
|
item.Color,
|
|
item.ImageName,
|
|
ImageUrl = string.IsNullOrWhiteSpace(item.ImageName) ? string.Empty : $"https://{domain.Sub("cdn")}/{item.ImageName}",
|
|
item.AssetName,
|
|
item.IsPublic,
|
|
item.IsFeatured,
|
|
item.CheerCount,
|
|
item.PurchaseCount,
|
|
item.CreatedAt,
|
|
item.UpdatedAt,
|
|
Status = item.IsPublic ? 1 : 0,
|
|
};
|
|
|
|
private static string Trim(string? value, int max)
|
|
{
|
|
var s = (value ?? string.Empty).Trim();
|
|
return s.Length <= max ? s : s[..max];
|
|
}
|
|
|
|
public sealed class BulkRequest
|
|
{
|
|
public List<Guid>? Ids { get; set; }
|
|
public List<Guid>? ItemIds { get; set; }
|
|
}
|
|
|
|
public sealed class DesignRequest
|
|
{
|
|
public Guid? Id { get; set; }
|
|
public string? Name { get; set; }
|
|
public string? Description { get; set; }
|
|
public int? Price { get; set; }
|
|
public int? ItemType { get; set; }
|
|
public int? BaseAvatarItemId { get; set; }
|
|
public string? Color { get; set; }
|
|
public string? ImageName { get; set; }
|
|
public string? AssetName { get; set; }
|
|
public bool? IsPublic { get; set; }
|
|
}
|
|
}
|