mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-26 07:28:45 -07:00
6ee1d6f9b3
Every change below was checked against the 2023-03-21 client disassembly;
file:line evidence is in the code comments and in
docs/recroom-2023-client-api-complete.md.
Presence
- matchmake/none means "online, in no room" (its only caller is
TryGoToOfflineRoom, which ignores the reply). The server used to keep the
old room or invent a dorm, so the next heartbeat looked out of sync and
the client cancelled its load to chase that room.
- The join watchdog no longer clears presence 30 s into a long room load;
it is a 3 minute backstop that drops the room only.
- One fan-out (PresenceBroadcastService) for every transition: deferred
joins, matchmake/none, logout, failed joins, visibility changes, SignalR
connect/disconnect. isOnline is sent on its own; pushes never go to the
subject, because a self push moves the player.
- Online-status visibility (Public / Friends / Favorites / Appear offline)
is stored and honoured by GET /player and by the live pushes.
Profile
- PUT account/me/{emoji,personalpronouns,identityflags,bannerimage} were
404; added with the client's real form keys and persisted (new Players
columns plus migration). Every account read now returns them, and edits
push AccountUpdate to everyone.
Outfit menu
- Saved outfits kept OutfitSelectionsV2 and CustomAvatarItems, so loading a
slot restores what was saved instead of a stale outfit.
- isCreationAllowedForAccount answers the {Success, Error} envelope the
client parses; minPriceForPublicItem is a bare int; gifts/generate accepts
form bodies; lockeditems returns the full owned-item shape; colourways no
longer share their base item's AvatarItemId.
RecNetResult writes
- About 40 writes look status-only but parse the body as {Success, Error}.
An empty 200 showed "Failed to ..." after the server applied the change,
and a body without Success threw "Error message cannot be null". Clubs,
announcements, keepsakes, comments/read, subscriptions, purchase offers
and privacy settings now answer RecNetResult.
- Login token and device-code grants are real; remoteauth approves the
device code; createlogintoken returns a bare string.
- New: incentivizedreferrals/referrals, account/bulk/{email,phone},
GET strings, account/me/confirmphone, PlayMenuCarouselOrdering_1.
Route inventory and coverage test extended to match.
README now describes the 2023 branch.
1236 lines
54 KiB
C#
1236 lines
54 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,
|
|
IObjectStorage storage,
|
|
ILogger<CustomAvatarItemsController> logger) : ControllerBase
|
|
{
|
|
/// <summary>Floor price for a publicly listed custom item. The client
|
|
/// asks for this over the wire (<c>api/customAvatarItems/v1/minPriceForPublicItem</c>,
|
|
/// RecNet.Runtime/MPBLNLMCEDL.txt:6893) and then enforces it locally in the
|
|
/// designer, so the number the create/update paths clamp to and the number
|
|
/// served on that route have to be the same constant or the client's own
|
|
/// validation disagrees with the server's rejection.</summary>
|
|
private const int MinPublicItemPrice = 100;
|
|
|
|
[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));
|
|
}
|
|
|
|
/// <summary>POST api/customAvatarItems/v1 — publish a shirt from the
|
|
/// in-game designer. The collection route was GET-only, so every
|
|
/// create 405'd and the designer's "publish" button could never work.
|
|
///
|
|
/// The 2023-03-21 client sends <c>multipart/form-data</c> (verb ordinal
|
|
/// rdx=2=POST at RecNet.Runtime/MPBLNLMCEDL.txt:1352-1354): a named
|
|
/// param <c>"metadata"</c> carrying JsonConvert.SerializeObject of the
|
|
/// DCKDCBDHDKI request object, then TWO file parts, <c>"thumbnailImage"</c>
|
|
/// and <c>"design"</c>, both with filename <c>"file.bin"</c>
|
|
/// (:1362-1390). DCKDCBDHDKI's field order is pinned by the arg-store
|
|
/// sequence at :1096-1133 — +0x10 Name, +0x18 Description, +0x20 Price,
|
|
/// +0x24 BaseAvatarItemId(int?), +0x30 Color ("#RRGGBB", built via
|
|
/// ColorUtility.ToHtmlStringRGB at :1118-1131), +0x38 the permission
|
|
/// enum we store as ItemType. The metadata JSON KEY names live in
|
|
/// attribute metadata the dump doesn't render, so every field is read
|
|
/// through an alias list rather than a single guessed key.</summary>
|
|
[HttpPost]
|
|
[Authorize]
|
|
[Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")]
|
|
public async Task<IActionResult> Create()
|
|
{
|
|
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];
|
|
|
|
var item = new CustomAvatarItemEntity
|
|
{
|
|
CreatorPlayerId = pid,
|
|
Name = name,
|
|
Description = description,
|
|
Price = Math.Max(MinPublicItemPrice, req.Price ?? MinPublicItemPrice),
|
|
ItemType = req.ItemType ?? 0,
|
|
BaseAvatarItemId = req.BaseAvatarItemId ?? 0,
|
|
Color = Trim(req.Color, 64),
|
|
// No boolean lives in DCKDCBDHDKI, so a freshly created item is
|
|
// private until the permission enum (ItemType) says otherwise.
|
|
IsPublic = req.IsPublic ?? false,
|
|
};
|
|
|
|
// "thumbnailImage" → ImageName (card art), "design" → AssetName (the
|
|
// shirt texture itself). Fall back to positional order for any client
|
|
// that names the parts differently.
|
|
var (thumbnail, asset) = ReadItemFileParts();
|
|
if (thumbnail is not null)
|
|
item.ImageName = await StoreImageBlobAsync(pid, thumbnail, "custom item thumbnail");
|
|
if (asset is not null)
|
|
item.AssetName = await StoreImageBlobAsync(pid, asset, "custom item design");
|
|
if (item.ImageName.Length == 0) item.ImageName = Trim(req.ImageName, 256);
|
|
if (item.AssetName.Length == 0) item.AssetName = Trim(req.AssetName, 256);
|
|
|
|
db.CustomAvatarItems.Add(item);
|
|
await db.SaveChangesAsync();
|
|
await EnsureOwnershipAsync(pid, item.Id);
|
|
|
|
logger.LogInformation(
|
|
"[customAvatarItems] created {PublicId} by {Pid} name='{Name}' image={Image} asset={Asset}",
|
|
item.PublicId, pid, item.Name, item.ImageName, item.AssetName);
|
|
return Ok(ToWire(item));
|
|
}
|
|
|
|
[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));
|
|
}
|
|
|
|
/// <summary>PUT api/customAvatarItems/v1/{id} — edit an existing item's
|
|
/// metadata. Only GET was registered on the id route, so the details
|
|
/// screen's Save button 405'd ("Unable to update custom avatar item").
|
|
///
|
|
/// The client builds the URL with String.Format("{0}/v1/{1}") over
|
|
/// "api/customAvatarItems" and issues verb ordinal 3 = PUT
|
|
/// (RecNet.Runtime/MPBLNLMCEDL.txt:1899-1913). The body is RAW JSON —
|
|
/// JsonConvert.SerializeObject of MOBHOFPIIIJ handed to
|
|
/// BNDIAONDFFF.FJLLPHFOOJJ at :1890-1920 — NOT form-urlencoded, so this
|
|
/// reads the body itself rather than model-binding it. MOBHOFPIIIJ
|
|
/// carries exactly four properties whose accessors are shared with
|
|
/// NOAAFNFJPFB (MOBHOFPIIIJ.txt:3-77): Name, Description, Price(int?)
|
|
/// and the permission enum stored here as ItemType. Every field is
|
|
/// optional — absent members leave the stored value alone.</summary>
|
|
[HttpPut("{id:guid}")]
|
|
[Authorize]
|
|
public async Task<IActionResult> Update(Guid id)
|
|
{
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
|
if (item is null) return NotFound();
|
|
if (item.CreatorPlayerId != pid) return Forbid();
|
|
|
|
var req = await ReadDesignRequestAsync();
|
|
if (req.Name is { } rawName)
|
|
{
|
|
var name = rawName.Trim();
|
|
if (name.Length < 3) return BadRequest(new { error = "name_too_short" });
|
|
item.Name = name.Length > 128 ? name[..128] : name;
|
|
}
|
|
if (req.Description is { } rawDescription)
|
|
{
|
|
var description = rawDescription.Trim();
|
|
item.Description = description.Length > 1024 ? description[..1024] : description;
|
|
}
|
|
if (req.Price is { } price) item.Price = Math.Max(MinPublicItemPrice, price);
|
|
if (req.ItemType is { } itemType) item.ItemType = itemType;
|
|
if (req.BaseAvatarItemId is { } baseItemId) item.BaseAvatarItemId = baseItemId;
|
|
if (req.Color is { } color) item.Color = Trim(color, 64);
|
|
if (req.ImageName is { } imageName) item.ImageName = Trim(imageName, 256);
|
|
if (req.AssetName is { } assetName) item.AssetName = Trim(assetName, 256);
|
|
if (req.IsPublic is { } isPublic) item.IsPublic = isPublic;
|
|
item.UpdatedAt = DateTime.UtcNow;
|
|
await db.SaveChangesAsync();
|
|
return Ok(ToWire(item));
|
|
}
|
|
|
|
/// <summary>DELETE api/customAvatarItems/v1/{id} — verb ordinal 4 on the
|
|
/// same String.Format("{0}/v1/{1}") URL
|
|
/// (RecNet.Runtime/MPBLNLMCEDL.txt:2188-2199); the client is
|
|
/// fire-and-forget, so the missing route made deleted shirts silently
|
|
/// reappear on the next list refresh.
|
|
///
|
|
/// <see cref="CustomAvatarItemEntity"/> has no IsDeleted column (unlike
|
|
/// UgcPurchasableEntity), and adding one is a schema migration, so this
|
|
/// is a real delete: the ownership rows go first so nobody keeps the
|
|
/// item in their wardrobe, then the item row itself.</summary>
|
|
[HttpDelete("{id:guid}")]
|
|
[Authorize]
|
|
public async Task<IActionResult> Delete(Guid id)
|
|
{
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
|
if (item is null) return Ok();
|
|
if (item.CreatorPlayerId != pid) return Forbid();
|
|
|
|
var ownership = await db.CustomAvatarItemOwnership
|
|
.Where(o => o.CustomAvatarItemId == item.Id)
|
|
.ToListAsync();
|
|
if (ownership.Count > 0) db.CustomAvatarItemOwnership.RemoveRange(ownership);
|
|
db.CustomAvatarItems.Remove(item);
|
|
await db.SaveChangesAsync();
|
|
logger.LogInformation(
|
|
"[customAvatarItems] deleted {PublicId} by {Pid} ({Owners} ownership rows removed)",
|
|
id, pid, ownership.Count);
|
|
return Ok();
|
|
}
|
|
|
|
/// <summary>GET/POST api/customAvatarItems/v1/bulk — resolve the custom
|
|
/// items other players are wearing. The guid-list parameter key the
|
|
/// client uses is the literal <c>"customAvatarItemIds"</c>
|
|
/// (RecNet.Runtime/MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:232-244, POST
|
|
/// ordinal rdx=2), which no read path here honoured, and the old
|
|
/// <c>[FromBody] BulkRequest</c> parameter made ASP.NET reject a
|
|
/// form-urlencoded body with 415 before the handler ran. Result: an
|
|
/// empty array, remote players' shirts never rendered, and the client
|
|
/// re-queried the same ids forever — the same failure the ugcPurchasables
|
|
/// bulk endpoint hit. The binding is gone; <see cref="ReadIdsAsync"/>
|
|
/// now reads the key from the JSON body, the form and the query.</summary>
|
|
[HttpGet("bulk")]
|
|
[HttpPost("bulk")]
|
|
[AllowAnonymous]
|
|
public async Task<IActionResult> Bulk()
|
|
{
|
|
var ids = await ReadIdsAsync();
|
|
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);
|
|
// NOT a bare boolean, unlike the two sibling gates below. The issuing
|
|
// method GGMDLMLJDDK (RecNet.Runtime/MPBLNLMCEDL.txt:6048, route on
|
|
// :6205) parses the body as DJMHAFPGLLN — {Boolean @0x10, String} —
|
|
// (generic token 0x188477F70 at :6216; Func<DJMHAFPGLLN,…> at :6223)
|
|
// and its success continuation HONEEPBHNLB reads the bool at +0x10
|
|
// (:9155-9216).
|
|
// DJMHAFPGLLN's converter LMCCNLLHLCJ registers exactly the keys
|
|
// "Success" and "Error" (LMCCNLLHLCJ.txt:191-254). A bare `true`
|
|
// failed that parse, so the client logged "Failed to query if local
|
|
// account can create Custom Avatar Items: An error occurred" and
|
|
// treated the account as unable to create shirts.
|
|
return Ok(new Dictionary<string, object?>
|
|
{
|
|
["Success"] = allowed,
|
|
["Error"] = allowed ? null : "Custom avatar item creation is not available for this account.",
|
|
});
|
|
}
|
|
|
|
[HttpGet("isCreationEnabled")]
|
|
[AllowAnonymous]
|
|
public IActionResult IsCreationEnabled() => Content("true", "application/json");
|
|
|
|
[HttpGet("isRenderingEnabled")]
|
|
[AllowAnonymous]
|
|
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
|
|
|
/// <summary>Bare JSON integer. The client's IHLMDLKOLNB pricing config is
|
|
/// built CLIENT-side: HCBOHHEACPK sends through FDKKOPAPDGF<Int32>
|
|
/// (RecNet.Runtime/MPBLNLMCEDL.txt:6922 — the same generic token
|
|
/// 0x18847B0B8 as the bare-Int32 itemOwnershipLimit call at :2981) and
|
|
/// continues with <c><GetPricingConfig>b__69_0(Int32 minimumPrice)</c>
|
|
/// (MPBLNLMCEDL_NestedType___c.txt:286). An object body failed the Int32
|
|
/// parse, so the designer's public-price floor never loaded.</summary>
|
|
[HttpGet("minPriceForPublicItem")]
|
|
[AllowAnonymous]
|
|
public IActionResult MinPriceForPublicItem()
|
|
=> Content(MinPublicItemPrice.ToString(System.Globalization.CultureInfo.InvariantCulture), "application/json");
|
|
|
|
/// <summary>GET api/customAvatarItems/v1/design — hand back the caller's
|
|
/// saved in-progress shirt design. Verb ordinal 0 = GET
|
|
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4782-4783); only POST was registered,
|
|
/// so the designer logged "Unable to get custom avatar design info" every
|
|
/// time it opened.
|
|
///
|
|
/// The response DTO NCMMAGDIBND is {Int32 @0x10, Int32? @0x14, String
|
|
/// @0x20, String @0x28} (NCMMAGDIBND.txt:3-145). The obfuscator's name
|
|
/// map is per-property-name rather than per-member, so those four
|
|
/// accessors identify the properties: IDELLAGIFAE/MOKDHHGDMJB is the same
|
|
/// pair used for BaseAvatarItemId on GIOBJIGEMNG and DCKDCBDHDKI,
|
|
/// GJEFKPNEFGI is the Color set from ColorUtility.ToHtmlStringRGB, and
|
|
/// DPJMMBLGKGF / BADCCGPNLMN are the leading int and the first string of
|
|
/// NOAAFNFJPFB — the creator id and the image name. Hence
|
|
/// {CreatorPlayerId, BaseAvatarItemId, ImageName, Color}. The extra keys
|
|
/// are inert aliases.
|
|
///
|
|
/// Returns an empty design rather than 404 when nothing is saved so the
|
|
/// designer opens clean instead of showing the error toast.</summary>
|
|
[HttpGet("design")]
|
|
[Authorize]
|
|
public async Task<IActionResult> GetDesign()
|
|
{
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var design = await LoadDesignAsync(pid) ?? new StoredDesign();
|
|
return Ok(new
|
|
{
|
|
CreatorPlayerId = (int)pid,
|
|
CreatorId = (int)pid,
|
|
design.BaseAvatarItemId,
|
|
design.ImageName,
|
|
ImageUrl = string.IsNullOrWhiteSpace(design.ImageName)
|
|
? string.Empty
|
|
: $"https://{domain.Sub("cdn")}/{design.ImageName}",
|
|
design.Color,
|
|
});
|
|
}
|
|
|
|
/// <summary>PUT api/customAvatarItems/v1/design — save the in-progress
|
|
/// shirt texture. The route only accepted POST, so the designer could
|
|
/// never persist a design ("Unable to save custom avatar design").
|
|
///
|
|
/// Verb ordinal 3 = PUT with a multipart body: named param
|
|
/// <c>"metadata"</c> plus a file part <c>"design"</c> with filename
|
|
/// <c>"file.bin"</c>
|
|
/// (RecNet.Runtime/MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:200-227). The
|
|
/// metadata object GIOBJIGEMNG holds just two properties — a
|
|
/// Nullable<Int32> base avatar item id at +0x10 and the "#RRGGBB"
|
|
/// colour string at +0x18 (GIOBJIGEMNG.txt:3-43), written from the
|
|
/// FKCJDHHJAFF(texture, color, baseItemId) args at MPBLNLMCEDL.txt:4652-4654.
|
|
/// The client's return type is status-only (LDGADANDBIO), so a bare 200
|
|
/// is the whole contract.</summary>
|
|
[HttpPut("design")]
|
|
[Authorize]
|
|
[Consumes("multipart/form-data", "application/x-www-form-urlencoded", "application/json")]
|
|
public async Task<IActionResult> SaveDesign()
|
|
{
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var req = await ReadDesignRequestAsync();
|
|
var existing = await LoadDesignAsync(pid);
|
|
|
|
var design = new StoredDesign
|
|
{
|
|
BaseAvatarItemId = req.BaseAvatarItemId ?? existing?.BaseAvatarItemId,
|
|
Color = Trim(req.Color, 64) is { Length: > 0 } color ? color : existing?.Color ?? string.Empty,
|
|
ImageName = existing?.ImageName ?? string.Empty,
|
|
};
|
|
|
|
// The texture arrives as the "design" part; accept any single file
|
|
// part as a fallback for a client that names it differently.
|
|
var file = Request.HasFormContentType
|
|
? Request.Form.Files.GetFile("design") ?? Request.Form.Files.FirstOrDefault()
|
|
: null;
|
|
if (file is not null && file.Length > 0)
|
|
{
|
|
var bytes = await ReadFileBytesAsync(file);
|
|
var blobName = DesignImageBlobName(pid);
|
|
await PutBlobAsync(blobName, bytes, SniffImageContentType(bytes), "custom avatar design");
|
|
design.ImageName = blobName;
|
|
}
|
|
|
|
await SaveDesignAsync(pid, design);
|
|
return Ok();
|
|
}
|
|
|
|
/// <summary>DELETE api/customAvatarItems/v1/design — discard the saved
|
|
/// design. Verb ordinal 4 on the same literal
|
|
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4862-4864); without it the client
|
|
/// logged "Unable to delete custom avatar item design".
|
|
///
|
|
/// <see cref="IObjectStorage"/> has no delete operation, so the record is
|
|
/// tombstoned (a Deleted marker that <see cref="LoadDesignAsync"/> reads
|
|
/// as "no design") and the texture object is truncated to zero bytes so
|
|
/// the pixels really are gone.</summary>
|
|
[HttpDelete("design")]
|
|
[Authorize]
|
|
public async Task<IActionResult> DeleteDesign()
|
|
{
|
|
var pid = this.RequireCurrentPlayerId();
|
|
await SaveDesignAsync(pid, new StoredDesign { Deleted = true });
|
|
await PutBlobAsync(DesignImageBlobName(pid), Array.Empty<byte>(), "image/png", "custom avatar design");
|
|
return Ok();
|
|
}
|
|
|
|
[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(MinPublicItemPrice, 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) });
|
|
|
|
// requestedPrice is baked into the URL the client formats —
|
|
// "{0}/v1/{1}/purchase/?requestedPrice={2}"
|
|
// (RecNet.Runtime/MPBLNLMCEDL.txt:5084). It is the price the player was
|
|
// SHOWN, so a value below the real price means the listing moved under
|
|
// them (or the request was tampered with); reject instead of silently
|
|
// charging more than the confirmation dialog said.
|
|
var requestedPrice = int.TryParse(Request.Query["requestedPrice"].FirstOrDefault(), out var rp) ? rp : (int?)null;
|
|
if (requestedPrice is { } shown && shown < item.Price)
|
|
return Ok(new { Success = false, Error = "price_changed", Price = item.Price, 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) });
|
|
}
|
|
|
|
/// <summary>POST api/customAvatarItems/v1/{id}/report — file a moderation
|
|
/// report against another player's custom shirt. No report route was
|
|
/// registered anywhere on the server, so the report dialog's "Submit"
|
|
/// button 404'd and the client toasted "Unable to report custom avatar
|
|
/// item {0}".
|
|
///
|
|
/// Verb ordinal <c>dl=2</c> = POST, on the URL
|
|
/// String.Format("{0}/v1/{1}/report") over "api/customAvatarItems"
|
|
/// (RecNet.Runtime/MPBLNLMCEDL.txt:4261-4275; the failure literal is
|
|
/// :4301). The body is RAW JSON — JsonConvert.SerializeObject of a
|
|
/// JEHCPEEKMML handed straight to BNDIAONDFFF.FJLLPHFOOJJ (:4252-4256,
|
|
/// :4282), NOT form-urlencoded — so the body is read here instead of
|
|
/// model-bound, exactly like PUT {id} above.
|
|
///
|
|
/// The issuing method IALGIAGBJMA (:4079) returns LDGADANDBIO — status
|
|
/// only. Its continuation chain (0x181F2F300 → 0x1836E6740 →
|
|
/// BFDGEKKNAPB.PEFKBAEAOPD, :4285-4294) is byte-for-byte the one the
|
|
/// DELETE {id} path uses (:2206-2215), and that path is satisfied by a
|
|
/// bare 200, so an empty 200 is the whole response contract here too.
|
|
///
|
|
/// JEHCPEEKMML has exactly three members, pinned by field offset from the
|
|
/// accessor bodies (JEHCPEEKMML.txt:6/86/108) and by the arg-store
|
|
/// sequence that fills it (:4239-4247): +0x10 the reason enum, +0x18 the
|
|
/// free-text detail, +0x20 a Nullable<Int32>. Their JSON KEY names
|
|
/// live in attribute metadata Cpp2IL doesn't render — obfuscated in the
|
|
/// 2023.06 dump too (EFPKFBKGLCD, dump.cs:1256458-1256469) — so each is
|
|
/// matched against an alias list, with a positional fallback in
|
|
/// declaration order for anything unrecognised (Newtonsoft writes members
|
|
/// in declaration order and the DTO has exactly these three).
|
|
///
|
|
/// The reason enum IS readable: LGEPGEHKNAG.KFCMACCLDEJ in the 2023.06
|
|
/// dump (dump.cs:1250039-1250044) — CoC_Discriminatory=0, CoC_Sexual=1,
|
|
/// CoC_Trolling=2, Misleading=3, Other=4, InappropriateClothing=5. That is
|
|
/// a DIFFERENT numbering from <see cref="ReportEntity.Category"/>'s
|
|
/// RoomieReportCategory convention (Harassment=1 … Other=5), so the
|
|
/// ordinal is stored verbatim and the readable name is written into the
|
|
/// message — otherwise a moderator reading the shared queue cannot tell a
|
|
/// CoC_Sexual=1 clothing report from a Harassment=1 player report.
|
|
///
|
|
/// <see cref="ReportEntity"/> has no custom-avatar-item column and adding
|
|
/// one is a migration, so the reported item's guid is carried in the
|
|
/// stored text — the same thing the sibling screen-share report handler
|
|
/// does with its image/instance evidence. That marker prefix is also what
|
|
/// makes the duplicate check below work.</summary>
|
|
[HttpPost("{id:guid}/report")]
|
|
[Authorize]
|
|
public async Task<IActionResult> Report(Guid id)
|
|
{
|
|
var pid = this.RequireCurrentPlayerId();
|
|
var item = await db.CustomAvatarItems.FirstOrDefaultAsync(i => i.PublicId == id);
|
|
if (item is null) return NotFound();
|
|
// The report button only renders on someone else's item, so this is an
|
|
// impossible UI state; reject rather than pollute the queue.
|
|
if (item.CreatorPlayerId == pid) return BadRequest(new { error = "cannot_report_own_item" });
|
|
|
|
var req = await ReadReportRequestAsync();
|
|
// The dialog refuses to submit without a category ("Cannot report
|
|
// custom clothing without a report category." —
|
|
// Assembly-CSharp/RRUI/Data/CustomAvatarItemReportModel.txt:1941), so
|
|
// a missing reason means a non-stock caller; bucket it as Other=4.
|
|
var reason = req.Reason ?? 4;
|
|
|
|
var context = new List<string> { $"reason={ReportReasonName(reason)}({reason})" };
|
|
// The only in-game caller passes null for the Nullable<Int32>
|
|
// (CustomAvatarItemReportModel.txt:2008/2011 push rbx=0 for both it and
|
|
// the trailing bool), so its meaning is unproven — keep whatever a
|
|
// client does send rather than silently dropping evidence.
|
|
if (req.Context is { } ctx) context.Add($"ctx={ctx}");
|
|
|
|
var details = (req.Details ?? string.Empty).Trim();
|
|
var message = $"{ReportMarker(id)}{string.Join(' ', context)}] {details}".Trim();
|
|
if (message.Length > 1000) message = message[..1000];
|
|
|
|
// One open report per (reporter, item): the dialog is re-openable and
|
|
// the client retries on a failed submit, so without this a single
|
|
// annoyed player can flood the moderation queue.
|
|
var marker = ReportMarker(id);
|
|
var duplicate = await db.Reports.AnyAsync(r =>
|
|
r.ReporterPlayerId == pid && r.ResolvedAt == null && r.Message.StartsWith(marker));
|
|
if (duplicate)
|
|
{
|
|
logger.LogInformation(
|
|
"[customAvatarItems] duplicate report of {PublicId} by {Pid} ignored", id, pid);
|
|
return Ok();
|
|
}
|
|
|
|
db.Reports.Add(new ReportEntity
|
|
{
|
|
ReporterPlayerId = pid,
|
|
TargetPlayerId = item.CreatorPlayerId,
|
|
Category = reason,
|
|
Message = message,
|
|
});
|
|
await db.SaveChangesAsync();
|
|
logger.LogInformation(
|
|
"[customAvatarItems] reported {PublicId} (creator {Creator}) by {Pid} reason={Reason}",
|
|
id, item.CreatorPlayerId, pid, ReportReasonName(reason));
|
|
return Ok();
|
|
}
|
|
|
|
/// <summary>Prefix every stored custom-item report with the item guid so
|
|
/// the row is attributable without a dedicated column, and so the
|
|
/// duplicate check can find prior reports with a plain LIKE.</summary>
|
|
private static string ReportMarker(Guid id) => $"[customAvatarItem {id:D} ";
|
|
|
|
/// <summary>LGEPGEHKNAG.KFCMACCLDEJ, verbatim from the 2023.06 dump
|
|
/// (dump.cs:1250039-1250044). Index IS the wire ordinal.</summary>
|
|
private static readonly string[] ReportReasonNames =
|
|
{
|
|
"CoC_Discriminatory", "CoC_Sexual", "CoC_Trolling",
|
|
"Misleading", "Other", "InappropriateClothing",
|
|
};
|
|
|
|
private static string ReportReasonName(int reason) =>
|
|
reason >= 0 && reason < ReportReasonNames.Length ? ReportReasonNames[reason] : "Unknown";
|
|
|
|
/// <summary>Read the report body. The stock client sends raw JSON, but
|
|
/// form encodings are accepted too so an admin/tooling caller can post the
|
|
/// same three fields flat.</summary>
|
|
private async Task<ReportRequest> ReadReportRequestAsync()
|
|
{
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
string? Field(params string[] keys)
|
|
{
|
|
foreach (var key in keys)
|
|
if (form[key].FirstOrDefault() is { } v) return v;
|
|
return null;
|
|
}
|
|
|
|
return new ReportRequest
|
|
{
|
|
Reason = ParseReportReason(Field("reason", "Reason", "reportReason", "ReportReason",
|
|
"category", "Category", "reportCategory", "ReportCategory", "type", "Type")),
|
|
Details = Field("details", "Details", "description", "Description",
|
|
"message", "Message", "comment", "Comment"),
|
|
Context = int.TryParse(
|
|
Field("contextId", "ContextId", "instanceId", "InstanceId",
|
|
"roomInstanceId", "RoomInstanceId", "roomId", "RoomId",
|
|
"accountId", "AccountId", "playerId", "PlayerId"),
|
|
out var ctx)
|
|
? ctx
|
|
: null,
|
|
};
|
|
}
|
|
|
|
using var reader = new StreamReader(Request.Body);
|
|
return ParseReportJson(await reader.ReadToEndAsync());
|
|
}
|
|
|
|
/// <summary>Lenient JSON → <see cref="ReportRequest"/>. Alias-matched
|
|
/// first; anything left over is assigned positionally in JEHCPEEKMML's
|
|
/// declaration order (reason, detail, nullable int) so an unguessed key
|
|
/// spelling still lands in the right slot.</summary>
|
|
private static ReportRequest ParseReportJson(string json)
|
|
{
|
|
var req = new ReportRequest();
|
|
if (string.IsNullOrWhiteSpace(json)) return req;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return req;
|
|
|
|
var unmatched = new List<JsonElement>();
|
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
|
{
|
|
switch (prop.Name.ToLowerInvariant())
|
|
{
|
|
case "reason" or "reportreason" or "category" or "reportcategory" or "type" or "reporttype":
|
|
req.Reason ??= ParseReportReason(prop.Value);
|
|
break;
|
|
case "details" or "detail" or "description" or "reportdescription"
|
|
or "message" or "comment" or "text":
|
|
req.Details ??= AsString(prop.Value);
|
|
break;
|
|
case "contextid" or "instanceid" or "roominstanceid" or "roomid"
|
|
or "accountid" or "playerid" or "reportedplayerid":
|
|
req.Context ??= AsInt(prop.Value);
|
|
break;
|
|
default:
|
|
unmatched.Add(prop.Value);
|
|
break;
|
|
}
|
|
}
|
|
|
|
foreach (var value in unmatched)
|
|
{
|
|
if (req.Reason is null && ParseReportReason(value) is { } reason)
|
|
{
|
|
req.Reason = reason;
|
|
continue;
|
|
}
|
|
|
|
if (req.Details is null && value.ValueKind == JsonValueKind.String)
|
|
{
|
|
req.Details = value.GetString();
|
|
continue;
|
|
}
|
|
|
|
req.Context ??= AsInt(value);
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
}
|
|
|
|
return req;
|
|
}
|
|
|
|
/// <summary>Accept the reason as an ordinal or as one of the enum's own
|
|
/// names — Newtonsoft writes an int by default, but a StringEnumConverter
|
|
/// on the DTO (an attribute the dump doesn't render) would write the name.</summary>
|
|
private static int? ParseReportReason(JsonElement value) => value.ValueKind switch
|
|
{
|
|
JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null,
|
|
JsonValueKind.String => ParseReportReason(value.GetString()),
|
|
_ => null,
|
|
};
|
|
|
|
private static int? ParseReportReason(string? value)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
|
if (int.TryParse(value, out var ordinal)) return ordinal;
|
|
var index = Array.FindIndex(
|
|
ReportReasonNames,
|
|
n => string.Equals(n, value, StringComparison.OrdinalIgnoreCase));
|
|
return index >= 0 ? index : null;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/// <summary>Collect the bulk request's guid list. The 2023 client's key is
|
|
/// <c>"customAvatarItemIds"</c> (MPBLNLMCEDL_NestedType_KKEBBAAGHAM.txt:242);
|
|
/// older callers use ids/itemIds. Which transport the request builder picks
|
|
/// for a POST collection param isn't decidable from ISIL, so all three are
|
|
/// read: raw JSON body, form fields and query pairs. The body is read
|
|
/// manually — a [FromBody] parameter would 415 a form-encoded POST before
|
|
/// the handler ran.</summary>
|
|
private async Task<List<Guid>> ReadIdsAsync()
|
|
{
|
|
var result = new List<Guid>();
|
|
void AddCsv(string? 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.ContentLength ?? 0) > 0
|
|
&& Request.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true)
|
|
{
|
|
try
|
|
{
|
|
using var doc = await JsonDocument.ParseAsync(Request.Body);
|
|
var root = doc.RootElement;
|
|
// Either a bare guid array or an object carrying one under any
|
|
// of the accepted key spellings.
|
|
if (root.ValueKind == JsonValueKind.Array)
|
|
{
|
|
AddJsonGuids(root, result);
|
|
}
|
|
else if (root.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var prop in root.EnumerateObject())
|
|
{
|
|
if (!IdListKeys.Contains(prop.Name)) continue;
|
|
if (prop.Value.ValueKind == JsonValueKind.Array) AddJsonGuids(prop.Value, result);
|
|
else if (prop.Value.ValueKind == JsonValueKind.String) AddCsv(prop.Value.GetString());
|
|
}
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
}
|
|
}
|
|
|
|
foreach (var pair in Request.Query)
|
|
foreach (var value in pair.Value)
|
|
AddCsv(value);
|
|
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
foreach (var field in form)
|
|
{
|
|
if (!IdListKeys.Contains(field.Key)) continue;
|
|
foreach (var value in field.Value) AddCsv(value);
|
|
}
|
|
}
|
|
|
|
return result.Distinct().Take(200).ToList();
|
|
}
|
|
|
|
private static void AddJsonGuids(JsonElement array, List<Guid> into)
|
|
{
|
|
foreach (var el in array.EnumerateArray())
|
|
if (el.ValueKind == JsonValueKind.String && Guid.TryParse(el.GetString(), out var id))
|
|
into.Add(id);
|
|
}
|
|
|
|
/// <summary>Accepted spellings of the bulk guid-list key. Case-insensitive
|
|
/// set so "customAvatarItemIds" / "CustomAvatarItemIds" both land.</summary>
|
|
private static readonly HashSet<string> IdListKeys = new(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
"customAvatarItemIds", "ids", "itemIds",
|
|
};
|
|
|
|
/// <summary>Read the fields of a create / update / design-save request.
|
|
/// Three encodings have to work: the designer's multipart bodies wrap
|
|
/// everything in a single <c>"metadata"</c> form field holding JSON
|
|
/// (MPBLNLMCEDL.txt:1362 and MPBLNLMCEDL_NestedType_OPEGIAOIHMA.txt:210),
|
|
/// PUT {id} sends a raw JSON body, and older/admin callers post flat form
|
|
/// fields. The JSON key names are not in the dump — they live in attribute
|
|
/// metadata Cpp2IL doesn't render — so each field is matched against an
|
|
/// alias list rather than one guessed spelling. Only the field ORDER is
|
|
/// proven (from the arg-store sequence); the names are inferred.</summary>
|
|
private async Task<DesignRequest> ReadDesignRequestAsync()
|
|
{
|
|
if (Request.HasFormContentType)
|
|
{
|
|
var form = await Request.ReadFormAsync();
|
|
var metadata = form["metadata"].FirstOrDefault() ?? form["Metadata"].FirstOrDefault();
|
|
if (!string.IsNullOrWhiteSpace(metadata))
|
|
return ParseDesignJson(metadata);
|
|
|
|
string? Field(params string[] keys)
|
|
{
|
|
foreach (var key in keys)
|
|
if (form[key].FirstOrDefault() is { } v) return v;
|
|
return null;
|
|
}
|
|
|
|
return new DesignRequest
|
|
{
|
|
Id = Guid.TryParse(Field("id", "Id"), out var id) ? id : null,
|
|
Name = Field("name", "Name"),
|
|
Description = Field("description", "Description"),
|
|
Price = int.TryParse(Field("price", "Price"), out var price) ? price : null,
|
|
ItemType = int.TryParse(Field("itemType", "ItemType", "permission", "Permission"), out var itemType) ? itemType : null,
|
|
BaseAvatarItemId = int.TryParse(Field("baseAvatarItemId", "BaseAvatarItemId"), out var baseId) ? baseId : null,
|
|
Color = Field("color", "Color"),
|
|
ImageName = Field("imageName", "ImageName"),
|
|
AssetName = Field("assetName", "AssetName"),
|
|
IsPublic = bool.TryParse(Field("isPublic", "IsPublic"), out var pub) ? pub : null,
|
|
};
|
|
}
|
|
|
|
using var reader = new StreamReader(Request.Body);
|
|
return ParseDesignJson(await reader.ReadToEndAsync());
|
|
}
|
|
|
|
/// <summary>Lenient JSON → <see cref="DesignRequest"/>. Members that are
|
|
/// absent stay null so callers can tell "not sent" from "sent empty" —
|
|
/// PUT {id} relies on that to leave untouched fields alone.</summary>
|
|
private static DesignRequest ParseDesignJson(string json)
|
|
{
|
|
var req = new DesignRequest();
|
|
if (string.IsNullOrWhiteSpace(json)) return req;
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(json);
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object) return req;
|
|
foreach (var prop in doc.RootElement.EnumerateObject())
|
|
{
|
|
var value = prop.Value;
|
|
switch (prop.Name.ToLowerInvariant())
|
|
{
|
|
case "id" or "customavataritemid":
|
|
if (value.ValueKind == JsonValueKind.String && Guid.TryParse(value.GetString(), out var id)) req.Id = id;
|
|
break;
|
|
case "name" or "itemname" or "title":
|
|
req.Name = AsString(value);
|
|
break;
|
|
case "description" or "desc":
|
|
req.Description = AsString(value);
|
|
break;
|
|
case "price":
|
|
req.Price = AsInt(value);
|
|
break;
|
|
// The enum at DCKDCBDHDKI+0x38 / NOAAFNFJPFB+0x3C is the
|
|
// item's permission (the 06.21 dump names its config row
|
|
// CustomAvatarItemPermissionFriendlyNameMapping); this
|
|
// schema calls that column ItemType, hence both aliases.
|
|
case "itemtype" or "type" or "permission" or "avataritemtype":
|
|
req.ItemType = AsInt(value);
|
|
break;
|
|
case "baseavataritemid" or "baseitemid" or "avataritemid":
|
|
req.BaseAvatarItemId = AsInt(value);
|
|
break;
|
|
case "color" or "colorhex" or "tint":
|
|
req.Color = AsString(value);
|
|
break;
|
|
case "imagename" or "thumbnail" or "thumbnailimage":
|
|
req.ImageName = AsString(value);
|
|
break;
|
|
case "assetname" or "design" or "designname":
|
|
req.AssetName = AsString(value);
|
|
break;
|
|
case "ispublic" or "public":
|
|
req.IsPublic = value.ValueKind switch
|
|
{
|
|
JsonValueKind.True => true,
|
|
JsonValueKind.False => false,
|
|
JsonValueKind.String => bool.TryParse(value.GetString(), out var b) ? b : null,
|
|
_ => null,
|
|
};
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
}
|
|
|
|
return req;
|
|
}
|
|
|
|
private static string? AsString(JsonElement value) => value.ValueKind switch
|
|
{
|
|
JsonValueKind.String => value.GetString(),
|
|
JsonValueKind.Number => value.ToString(),
|
|
_ => null,
|
|
};
|
|
|
|
private static int? AsInt(JsonElement value) => value.ValueKind switch
|
|
{
|
|
JsonValueKind.Number => value.TryGetInt32(out var n) ? n : null,
|
|
JsonValueKind.String => int.TryParse(value.GetString(), out var s) ? s : null,
|
|
_ => null,
|
|
};
|
|
|
|
/// <summary>Pick out the create request's two file parts. Named lookup
|
|
/// first ("thumbnailImage" / "design", both sent with filename
|
|
/// "file.bin" — MPBLNLMCEDL.txt:1372-1390), then positional order as a
|
|
/// fallback.</summary>
|
|
private (IFormFile? Thumbnail, IFormFile? Asset) ReadItemFileParts()
|
|
{
|
|
if (!Request.HasFormContentType) return (null, null);
|
|
var files = Request.Form.Files;
|
|
if (files.Count == 0) return (null, null);
|
|
var thumbnail = files.GetFile("thumbnailImage") ?? files.GetFile("thumbnail");
|
|
var asset = files.GetFile("design") ?? files.GetFile("asset");
|
|
if (thumbnail is null && asset is null)
|
|
{
|
|
thumbnail = files[0];
|
|
asset = files.Count > 1 ? files[1] : null;
|
|
}
|
|
|
|
return (thumbnail?.Length > 0 ? thumbnail : null, asset?.Length > 0 ? asset : null);
|
|
}
|
|
|
|
/// <summary>Persist one uploaded image part and return its BlobName. Same
|
|
/// shape as the images controller: S3 (or the disk fallback) holds the
|
|
/// bytes, a RoomDataBlobs row keeps the uploader audit trail, and the
|
|
/// cdn serves it straight off the BlobName.</summary>
|
|
private async Task<string> StoreImageBlobAsync(long playerId, IFormFile file, string what)
|
|
{
|
|
var bytes = await ReadFileBytesAsync(file);
|
|
var contentType = SniffImageContentType(bytes);
|
|
var blobName = $"img_p{playerId}_{Guid.NewGuid():N}.{(contentType == "image/jpeg" ? "jpg" : "png")}";
|
|
await PutBlobAsync(blobName, bytes, contentType, what);
|
|
db.RoomDataBlobs.Add(new RoomDataBlobEntity
|
|
{
|
|
RoomId = 0,
|
|
BlobName = blobName,
|
|
UploadedByPlayerId = playerId,
|
|
UploadedAt = DateTime.UtcNow,
|
|
ReferencedFilenamesCsv = string.Empty,
|
|
});
|
|
return blobName;
|
|
}
|
|
|
|
private static async Task<byte[]> ReadFileBytesAsync(IFormFile file)
|
|
{
|
|
using var ms = new MemoryStream();
|
|
await file.CopyToAsync(ms);
|
|
return ms.ToArray();
|
|
}
|
|
|
|
/// <summary>The parts are sent with filename "file.bin" and no usable
|
|
/// content type, so sniff the magic bytes instead of trusting either.</summary>
|
|
private static string SniffImageContentType(byte[] bytes) =>
|
|
bytes.Length >= 4 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 ? "image/png"
|
|
: bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF ? "image/jpeg"
|
|
: "image/png";
|
|
|
|
private async Task PutBlobAsync(string blobName, byte[] bytes, string contentType, string what)
|
|
{
|
|
try
|
|
{
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
|
var (bucket, key) = BlobRouter.Route(blobName);
|
|
await storage.PutAsync(bucket, key, bytes, contentType, timeout.Token);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError(ex, "[customAvatarItems] storing {What} blob {Blob} failed", what, blobName);
|
|
}
|
|
}
|
|
|
|
// ── In-progress shirt design ────────────────────────────────────────────
|
|
// The designer's draft (base item + colour + texture) has no table in this
|
|
// schema and adding one is a migration, so it lives in object storage
|
|
// under two deterministic per-player blob names: overwrite-in-place on
|
|
// save, tombstoned on delete.
|
|
private static string DesignMetaBlobName(long playerId) => $"caidesign_p{playerId}.json";
|
|
|
|
private static string DesignImageBlobName(long playerId) => $"caidesign_p{playerId}.png";
|
|
|
|
private async Task<StoredDesign?> LoadDesignAsync(long playerId)
|
|
{
|
|
try
|
|
{
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8));
|
|
var (bucket, key) = BlobRouter.Route(DesignMetaBlobName(playerId));
|
|
var bytes = await storage.GetAsync(bucket, key, timeout.Token);
|
|
if (bytes is null || bytes.Length == 0) return null;
|
|
var design = JsonSerializer.Deserialize<StoredDesign>(
|
|
bytes,
|
|
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
|
return design is null || design.Deleted ? null : design;
|
|
}
|
|
catch (Exception ex) when (ex is JsonException or IOException)
|
|
{
|
|
logger.LogWarning(ex, "[customAvatarItems] unreadable saved design for player {Pid}", playerId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private Task SaveDesignAsync(long playerId, StoredDesign design) =>
|
|
PutBlobAsync(
|
|
DesignMetaBlobName(playerId),
|
|
JsonSerializer.SerializeToUtf8Bytes(design),
|
|
"application/json",
|
|
"custom avatar design record");
|
|
|
|
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; }
|
|
}
|
|
|
|
/// <summary>The designer's in-progress draft, as persisted to the
|
|
/// per-player <c>caidesign_p<id>.json</c> object (see
|
|
/// <see cref="LoadDesignAsync"/>). Mirrors the three payload properties of
|
|
/// the client's design DTOs — the metadata object GIOBJIGEMNG carries the
|
|
/// nullable base-item id and the "#RRGGBB" colour, and the texture arrives
|
|
/// as the separate "design" file part whose stored blob name lands in
|
|
/// <see cref="ImageName"/>.
|
|
///
|
|
/// <see cref="Deleted"/> is the tombstone: <see cref="IObjectStorage"/>
|
|
/// exposes no delete operation, so DELETE design overwrites the record with
|
|
/// a deleted marker and LoadDesignAsync reports it as "no saved design".
|
|
/// Public properties with setters because the round-trip goes through
|
|
/// System.Text.Json.</summary>
|
|
public sealed class StoredDesign
|
|
{
|
|
public int? BaseAvatarItemId { get; set; }
|
|
|
|
public string Color { get; set; } = string.Empty;
|
|
|
|
public string ImageName { get; set; } = string.Empty;
|
|
|
|
public bool Deleted { get; set; }
|
|
}
|
|
|
|
/// <summary>The three members of the client's report DTO JEHCPEEKMML,
|
|
/// in its declaration order: the reason enum (+0x10), the free-text
|
|
/// detail (+0x18) and an unidentified Nullable<Int32> (+0x20) that
|
|
/// the stock report dialog always leaves null. All nullable so
|
|
/// "not sent" is distinguishable from "sent empty".</summary>
|
|
public sealed class ReportRequest
|
|
{
|
|
public int? Reason { get; set; }
|
|
public string? Details { get; set; }
|
|
public int? Context { 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; }
|
|
}
|
|
}
|