Files
DorkNet/DorkNet.Server/Controllers/API/CustomAvatarItems/CustomAvatarItemsController.cs
Alexa 6ee1d6f9b3 fix: presence, profile edits, outfit menu and RecNetResult writes for the 2023 client
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.
2026-10-15 14:08:11 +03:00

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&lt;Int32&gt;
/// (RecNet.Runtime/MPBLNLMCEDL.txt:6922 — the same generic token
/// 0x18847B0B8 as the bare-Int32 itemOwnershipLimit call at :2981) and
/// continues with <c>&lt;GetPricingConfig&gt;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&lt;Int32&gt; 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&lt;Int32&gt;. 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&lt;id&gt;.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&lt;Int32&gt; (+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; }
}
}