Radicade Public Code

This commit is contained in:
JogmansonReal
2026-10-16 23:33:13 -07:00
commit 55175acdf6
832 changed files with 363261 additions and 0 deletions
@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace RadicadeServer.RecNet.Avatar
{
public class CustomAvatarItemDB
{
[Key, DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public long Id { get; set; }
public Guid CustomAvatarItemId { get; set; }
public long CreatorAccountId { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public int Price { get; set; }
public int BaseAvatarItemId { get; set; }
public string? BaseAvatarItemColor { get; set; }
public int Accessibility { get; set; }
public bool IsPublished { get; set; }
public string? ImageName { get; set; }
public string? DesignFileName { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime ModifiedAt { get; set; }
}
}
@@ -0,0 +1,491 @@
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);
/// <summary>
/// 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.
/// </summary>
[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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Bulk()
{
var ids = new List<Guid>();
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<IActionResult> Create()
{
var ownerId = User.GetUserId();
if (ownerId == null)
{
return Unauthorized();
}
string? rawMeta = null;
var files = new List<string>();
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<string, object>() });
}
private async Task<IActionResult> Page(System.Linq.Expressions.Expression<Func<CustomAvatarItemDB, bool>> 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<JsonDocument> 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<string> 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;
}
}
}