mirror of
https://github.com/DorkSquadRR/DorkNet.git
synced 2026-10-25 23:18:44 -07:00
feat: Enhance room management and moderation APIs
- Added support for multiple tag filters in HotRoomServer endpoint, allowing clients to query hot rooms with multiple tags. - Introduced a new DeleteRoom endpoint for owners to soft-archive their rooms, ensuring room data integrity. - Updated MagicDoor endpoint to return a structured response including refresh timing. - Improved reporting functionality to handle form submissions correctly, ensuring room reports are persisted. - Refactored per-field mutation endpoints to support both legacy and new client formats, accommodating changes in request structure. - Implemented a multi-field payload reader to handle complex requests more efficiently. - Enhanced logging for room code management to track player actions and conflicts.
This commit is contained in:
@@ -17,7 +17,9 @@ namespace DorkNet.Server.Controllers.API.CustomAvatarItems;
|
||||
public class CustomAvatarItemsController(
|
||||
DorkNetDbContext db,
|
||||
LevelService level,
|
||||
DomainConfig domain) : ControllerBase
|
||||
DomainConfig domain,
|
||||
IObjectStorage storage,
|
||||
ILogger<CustomAvatarItemsController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[AllowAnonymous]
|
||||
@@ -47,6 +49,71 @@ public class CustomAvatarItemsController(
|
||||
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)
|
||||
@@ -57,12 +124,101 @@ public class CustomAvatarItemsController(
|
||||
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([FromBody] BulkRequest? body)
|
||||
public async Task<IActionResult> Bulk()
|
||||
{
|
||||
var ids = await ReadIdsAsync(body);
|
||||
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()))
|
||||
@@ -222,9 +378,126 @@ public class CustomAvatarItemsController(
|
||||
[AllowAnonymous]
|
||||
public IActionResult IsRenderingEnabled() => Content("true", "application/json");
|
||||
|
||||
/// <summary>The client DTO (IHLMDLKOLNB) is an object with exactly one
|
||||
/// Int32 property, but its accessor name (GFGCCNBDKAK) appears nowhere
|
||||
/// else in the dump, so — unlike Price/BaseAvatarItemId/Color, which are
|
||||
/// pinned by accessors shared with NOAAFNFJPFB — the JSON key is NOT
|
||||
/// recoverable. All four spellings below are UNPROVEN hedges; Newtonsoft
|
||||
/// ignores the ones that don't match, and a total miss falls back to the
|
||||
/// DTO's static default (IHLMDLKOLNB.txt:75-117) rather than throwing.</summary>
|
||||
[HttpGet("minPriceForPublicItem")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult MinPriceForPublicItem() => Ok(new { MinPrice = 100, Price = 100 });
|
||||
public IActionResult MinPriceForPublicItem() => Ok(new
|
||||
{
|
||||
MinPrice = MinPublicItemPrice,
|
||||
Price = MinPublicItemPrice,
|
||||
MinimumPrice = MinPublicItemPrice,
|
||||
MinPriceForPublicItem = MinPublicItemPrice,
|
||||
});
|
||||
|
||||
/// <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]
|
||||
@@ -287,6 +560,16 @@ public class CustomAvatarItemsController(
|
||||
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 });
|
||||
@@ -315,64 +598,311 @@ public class CustomAvatarItemsController(
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task<List<Guid>> ReadIdsAsync(BulkRequest? body)
|
||||
/// <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>();
|
||||
if (body?.Ids is { Count: > 0 })
|
||||
result.AddRange(body.Ids);
|
||||
if (body?.ItemIds is { Count: > 0 })
|
||||
result.AddRange(body.ItemIds);
|
||||
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)
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (Guid.TryParse(part, out var id)) result.Add(id);
|
||||
AddCsv(value);
|
||||
|
||||
if (Request.HasFormContentType)
|
||||
{
|
||||
var form = await Request.ReadFormAsync();
|
||||
foreach (var key in new[] { "ids", "Ids", "itemIds", "ItemIds" })
|
||||
foreach (var value in form[key])
|
||||
foreach (var part in (value ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (Guid.TryParse(part, out var id)) result.Add(id);
|
||||
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(form["id"].FirstOrDefault() ?? form["Id"].FirstOrDefault(), out var id) ? id : null,
|
||||
Name = form["name"].FirstOrDefault() ?? form["Name"].FirstOrDefault(),
|
||||
Description = form["description"].FirstOrDefault() ?? form["Description"].FirstOrDefault(),
|
||||
Price = int.TryParse(form["price"].FirstOrDefault() ?? form["Price"].FirstOrDefault(), out var price) ? price : null,
|
||||
ItemType = int.TryParse(form["itemType"].FirstOrDefault() ?? form["ItemType"].FirstOrDefault(), out var itemType) ? itemType : null,
|
||||
BaseAvatarItemId = int.TryParse(form["baseAvatarItemId"].FirstOrDefault() ?? form["BaseAvatarItemId"].FirstOrDefault(), out var baseId) ? baseId : null,
|
||||
Color = form["color"].FirstOrDefault() ?? form["Color"].FirstOrDefault(),
|
||||
ImageName = form["imageName"].FirstOrDefault() ?? form["ImageName"].FirstOrDefault(),
|
||||
AssetName = form["assetName"].FirstOrDefault() ?? form["AssetName"].FirstOrDefault(),
|
||||
IsPublic = bool.TryParse(form["isPublic"].FirstOrDefault() ?? form["IsPublic"].FirstOrDefault(), out var pub) ? pub : null,
|
||||
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
|
||||
{
|
||||
return await JsonSerializer.DeserializeAsync<DesignRequest>(
|
||||
Request.Body,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? new DesignRequest();
|
||||
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 new DesignRequest();
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user