using System.Text; using System.Text.Json; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using RadicadeServer.API.Notifications; using RadicadeServer.DB; using RadicadeServer.Extensions; using RadicadeServer.RecNet.Avatar; using RadicadeServer.RecNet.Responses; namespace RadicadeServer.API.Avatars { [Route("api/customAvatarItems")] [Route("econ/customAvatarItems")] [Route("econ/econ/customAvatarItems")] public class CustomAvatarItemsAPI : Controller { readonly RecDBContext db; public CustomAvatarItemsAPI(RecDBContext context) { this.db = context; } private static Task AnnounceCustomItemChange(long ownerId) => Notify.SendAccountUpdate(ownerId); /// /// Whether this account may draw a custom item at all. The client reads the answer off the /// two obfuscated members this route's own response type carries, so a PascalCase "success" /// body left every option on the drawing screen disabled - creating and recolouring both /// greyed out - even though nothing on the account was wrong. /// [HttpGet("v1/isCreationAllowedForAccount")] public IActionResult IsCreationAllowedForAccount() { return Ok(ClientAction.Ok()); } [HttpGet("v1/isCreationEnabled")] [HttpGet("v1/isRenderingEnabled")] public IActionResult IsEnabled() { return Ok(true); } [HttpGet("v1/minPriceForPublicItem")] public IActionResult MinPriceForPublicItem() { return Ok(0); } [HttpGet("v1/itemOwnershipLimit")] public IActionResult ItemOwnershipLimit() { return Ok(100); } private static object ToItem(CustomAvatarItemDB item) => new { CustomAvatarItemId = item.CustomAvatarItemId.ToString(), CreatorAccountId = item.CreatorAccountId, Name = item.Name, Description = item.Description, Price = item.Price, BaseAvatarItemId = item.BaseAvatarItemId, BaseAvatarItemColor = item.BaseAvatarItemColor, Accessibility = item.Accessibility, IsFeatured = false, Permission = 0, ThumbnailImageFilename = item.ImageName, ImageName = item.ImageName, DesignFilename = item.DesignFileName, CreatedAt = item.CreatedAt, ModifiedAt = item.ModifiedAt, }; private static bool Visible(CustomAvatarItemDB item, long? viewer) => item.IsPublished || (viewer != null && item.CreatorAccountId == viewer); [HttpGet("v1/featured")] [HttpGet("v1/hot")] public async Task Featured() { var viewer = User.GetUserId() ?? 0; var rows = await db.CustomAvatarItems .Where(x => x.IsPublished || x.CreatorAccountId == viewer) .OrderByDescending(x => x.CreatedAt) .Take(50) .ToListAsync(); return Ok(rows.Select(ToItem).ToList()); } [HttpGet("v1/owned")] public async Task Owned([FromQuery] int skip = 0, [FromQuery] int take = 100) { return await Page(x => x.CreatorAccountId == User.GetUserId(), skip, take); } [HttpGet("v1")] [HttpGet("v1/me")] public async Task Items([FromQuery] int skip = 0, [FromQuery] int take = 100) { return await Page(x => x.CreatorAccountId == User.GetUserId(), skip, take); } private static object ToDesign(CustomAvatarItemDB item) => new { CustomAvatarItemId = item.CustomAvatarItemId.ToString(), CustomAvatarItemColor = item.BaseAvatarItemColor, DesignFilename = item.DesignFileName, ImageName = item.ImageName, }; [HttpGet("v1/design")] public async Task Design() { var viewer = User.GetUserId() ?? 0; var itemId = Guid.TryParse(Request.Query["customAvatarItemId"], out var parsed) ? parsed : (Guid?)null; var color = Request.Query["customAvatarItemColor"].ToString(); var query = db.CustomAvatarItems.Where(x => x.DesignFileName != null && (x.IsPublished || x.CreatorAccountId == viewer)); if (itemId != null) query = query.Where(x => x.CustomAvatarItemId == itemId); if (!string.IsNullOrEmpty(color)) query = query.Where(x => x.BaseAvatarItemColor == color); var rows = await query.OrderByDescending(x => x.CreatedAt).ToListAsync(); return Ok(rows.Select(ToDesign).ToList()); } [HttpPost("v1/design")] public async Task SaveDesign() { var ownerId = User.GetUserId(); if (ownerId == null) { return Unauthorized(); } IFormFile? uploaded = null; string? itemIdRaw = Request.Query["customAvatarItemId"].ToString(); string? color = Request.Query["customAvatarItemColor"].ToString(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); uploaded = form.Files.FirstOrDefault(f => f.Length > 0); if (string.IsNullOrEmpty(itemIdRaw)) itemIdRaw = form["customAvatarItemId"].ToString(); if (string.IsNullOrEmpty(color)) color = form["customAvatarItemColor"].ToString(); } var item = Guid.TryParse(itemIdRaw, out var itemId) ? await db.CustomAvatarItems.FirstOrDefaultAsync(x => x.CustomAvatarItemId == itemId) : null; item ??= await db.CustomAvatarItems .Where(x => x.CreatorAccountId == ownerId) .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(); if (item == null) { return NotFound(); } if (uploaded != null) item.DesignFileName = await SaveUpload(uploaded, DBConfig.CDNImages, ".png"); if (!string.IsNullOrEmpty(color)) item.BaseAvatarItemColor = color; item.ModifiedAt = DateTime.UtcNow; await db.SaveChangesAsync(); await AnnounceCustomItemChange(ownerId.Value); return Ok(ToDesign(item)); } [HttpGet("v2/fromCreator/{accountId:long}")] public async Task FromCreator(long accountId, [FromQuery] int skip = 0, [FromQuery] int take = 100) { return await Page(x => x.CreatorAccountId == accountId, skip, take); } [HttpGet("v1/{customAvatarItemId:guid}")] public async Task Item(Guid customAvatarItemId) { var item = await db.CustomAvatarItems.FirstOrDefaultAsync(x => x.CustomAvatarItemId == customAvatarItemId); if (item == null || !Visible(item, User.GetUserId())) return NotFound(); return Ok(ToItem(item)); } [HttpPut("v1/{customAvatarItemId:guid}")] public async Task Update(Guid customAvatarItemId) { var ownerId = User.GetUserId(); if (ownerId == null) return Unauthorized(); var item = await db.CustomAvatarItems.FirstOrDefaultAsync(x => x.CustomAvatarItemId == customAvatarItemId); if (item == null) return NotFound(); if (item.CreatorAccountId != ownerId.Value) { return StatusCode(StatusCodes.Status403Forbidden); } var meta = await ReadMetadataAsync(); if (ReadString(meta, "Name") is { } name && !string.IsNullOrWhiteSpace(name)) item.Name = name; if (ReadString(meta, "Description") is { } description) item.Description = description; if (TryReadInt(meta, "Price", out var price)) item.Price = price; if (TryReadInt(meta, "BaseAvatarItemId", out var baseItemId)) item.BaseAvatarItemId = baseItemId; if (ReadString(meta, "BaseAvatarItemColor") is { } color) item.BaseAvatarItemColor = color; if (TryReadInt(meta, "Accessibility", out var accessibility)) { item.Accessibility = accessibility; item.IsPublished = accessibility != 0; } item.ModifiedAt = DateTime.UtcNow; await db.SaveChangesAsync(); await AnnounceCustomItemChange(ownerId.Value); // The route answers with the two obfuscated members, not the item: the client refetches // what it needs from the account update above, and an item body here read as a failed save. return Ok(ClientAction.Ok()); } [HttpDelete("v1/{customAvatarItemId:guid}")] public async Task Delete(Guid customAvatarItemId) { var ownerId = User.GetUserId(); if (ownerId == null) return Unauthorized(); var item = await db.CustomAvatarItems.FirstOrDefaultAsync(x => x.CustomAvatarItemId == customAvatarItemId); if (item == null) return NotFound(); if (item.CreatorAccountId != ownerId.Value) { return StatusCode(StatusCodes.Status403Forbidden); } db.CustomAvatarItems.Remove(item); await db.SaveChangesAsync(); await AnnounceCustomItemChange(ownerId.Value); return Ok(ClientAction.Ok()); } [HttpGet("v1/search")] public async Task Search( [FromQuery] string? query = null, [FromQuery] string? searchTerm = null, [FromQuery] int skip = 0, [FromQuery] int take = 100) { var viewer = User.GetUserId() ?? 0; var term = (query ?? searchTerm ?? string.Empty).Trim(); return await Page(x => (x.IsPublished || x.CreatorAccountId == viewer) && (term.Length == 0 || (x.Name ?? string.Empty).Contains(term) || (x.Description ?? string.Empty).Contains(term)), skip, take); } [HttpPost("v1/bulk")] public async Task Bulk() { var ids = new List(); if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); foreach (var value in form["customAvatarItemIds"]) if (Guid.TryParse(value, out var id)) ids.Add(id); } var viewer = User.GetUserId(); var rows = await db.CustomAvatarItems.Where(x => ids.Contains(x.CustomAvatarItemId)).ToListAsync(); return Ok(rows.Where(x => Visible(x, viewer)).Select(ToItem).ToList()); } [HttpPost("v1")] [HttpPost("v1/me")] public async Task Create() { var ownerId = User.GetUserId(); if (ownerId == null) { return Unauthorized(); } string? rawMeta = null; var files = new List(); IFormFile? thumbnail = null; IFormFile? design = null; if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); rawMeta = form["metadata"].ToString(); foreach (var file in form.Files) { files.Add($"{file.Name}:{file.ContentType}:{file.Length}"); if (file.Length <= 0) continue; var isThumbnail = file.Name.Contains("thumbnail", StringComparison.OrdinalIgnoreCase); var isDesign = file.Name.Contains("design", StringComparison.OrdinalIgnoreCase); if (isThumbnail && thumbnail == null) thumbnail = file; else if (isDesign && design == null) design = file; else if (thumbnail == null) thumbnail = file; else if (design == null) design = file; } } else { Request.EnableBuffering(); Request.Body.Position = 0; var buffer = new byte[1024]; var read = await Request.Body.ReadAsync(buffer, 0, buffer.Length); Request.Body.Position = 0; rawMeta = Encoding.UTF8.GetString(buffer, 0, read); } using var meta = ParseMetadata(rawMeta); var now = DateTime.UtcNow; var item = new CustomAvatarItemDB { CustomAvatarItemId = Guid.NewGuid(), CreatorAccountId = (long)ownerId, Name = ReadString(meta, "Name") ?? "Custom Avatar Item", Description = ReadString(meta, "Description") ?? string.Empty, Price = ReadInt(meta, "Price"), BaseAvatarItemId = ReadInt(meta, "BaseAvatarItemId"), BaseAvatarItemColor = ReadString(meta, "BaseAvatarItemColor"), Accessibility = ReadInt(meta, "Accessibility"), CreatedAt = now, ModifiedAt = now, }; item.IsPublished = item.Accessibility != 0; if (thumbnail != null) item.ImageName = await SaveUpload(thumbnail, DBConfig.CDNImages, ".png"); if (design != null) item.DesignFileName = await SaveUpload(design, DBConfig.CDNImages, ".png"); db.CustomAvatarItems.Add(item); await db.SaveChangesAsync(); await AnnounceCustomItemChange(ownerId.Value); var body = ToItem(item); return Ok(body); } [HttpPost("GetCustomAvatarItemCurrentSavesForLegacyAvatarItems")] public IActionResult CurrentSavesForLegacyAvatarItems() { return Ok(new { customAvatarItemSavesByAvatarItemDesc = new Dictionary() }); } private async Task Page(System.Linq.Expressions.Expression> filter, int skip, int take) { take = take <= 0 ? 100 : Math.Min(take, 100); skip = Math.Max(skip, 0); var query = db.CustomAvatarItems.Where(filter); var total = await query.CountAsync(); var rows = await query .OrderByDescending(x => x.CreatedAt) .Skip(skip) .Take(take) .ToListAsync(); return Ok(new { Results = rows.Select(ToItem).ToList(), TotalResults = total }); } private async Task ReadMetadataAsync() { if (Request.HasFormContentType) { var form = await Request.ReadFormAsync(); return ParseMetadata(form["metadata"].ToString()); } if (Request.ContentLength is not > 0) return ParseMetadata("{}"); Request.EnableBuffering(); Request.Body.Position = 0; string raw; using (var reader = new StreamReader(Request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: true, bufferSize: 1024, leaveOpen: true)) raw = await reader.ReadToEndAsync(); Request.Body.Position = 0; return ParseMetadata(raw); } private static bool TryReadInt(JsonDocument meta, string key, out int value) { value = 0; if (!TryGet(meta, key, out var element)) return false; if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out value)) return true; return int.TryParse(element.ToString(), out value); } private static JsonDocument ParseMetadata(string raw) { if (string.IsNullOrWhiteSpace(raw)) return JsonDocument.Parse("{}"); try { return JsonDocument.Parse(raw); } catch (JsonException) { return JsonDocument.Parse("{}"); } } private static string? ReadString(JsonDocument meta, string key) { if (!TryGet(meta, key, out var value)) return null; return value.ValueKind == JsonValueKind.String ? value.GetString() : value.ToString(); } private static int ReadInt(JsonDocument meta, string key) { if (!TryGet(meta, key, out var value) || value.ValueKind != JsonValueKind.Number) return 0; return value.GetInt32(); } private static bool TryGet(JsonDocument meta, string key, out JsonElement value) { value = default; if (meta == null) return false; if (meta.RootElement.TryGetProperty(key, out value)) return true; foreach (var property in meta.RootElement.EnumerateObject()) { if (string.Equals(property.Name, key, StringComparison.OrdinalIgnoreCase)) { value = property.Value; return true; } } return false; } private static async Task SaveUpload(IFormFile file, string directory, string extension) { Directory.CreateDirectory(directory); var name = Guid.NewGuid().ToString() + extension; var path = Path.Join(directory, name); using (var stream = new FileStream(path, FileMode.Create)) { await file.CopyToAsync(stream); } return name; } } }