Files
Radicade/Server/Studio/StudioUnityAssetRegistry.cs
T
2026-10-16 23:33:13 -07:00

110 lines
4.4 KiB
C#

using System.Security.Cryptography;
using System.Text.Json;
using RadicadeServer.Storage;
namespace RadicadeServer.API.Studio;
public sealed record StudioBakedUnityAsset(string UnityAssetId, byte Target, int Version, string Filename, string? RejectedDependenciesFilename = null);
public sealed record StudioUnityAsset(
string UnityAssetId,
int? CreatedByAccountId,
string Filename,
string Hash,
IReadOnlyList<StudioBakedUnityAsset> BakedUnityAssets);
public static class StudioUnityAssetRegistry
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private static readonly SemaphoreSlim Gate = new(1, 1);
public static bool IsValidAssetId(string? value) =>
value is { Length: 40 } && value.All(char.IsAsciiLetterOrDigit);
public static async Task<StudioUnityAsset?> ReadAsync(string assetId, CancellationToken cancellationToken = default)
{
if (!IsValidAssetId(assetId)) return null;
var manifestPath = Path.Combine(DBConfig.CDNStorage, assetId);
if (!File.Exists(manifestPath)) return null;
byte[] hash;
await using (var manifest = File.OpenRead(manifestPath))
hash = await SHA256.HashDataAsync(manifest, cancellationToken);
var path = MetadataPath(assetId);
IReadOnlyList<StudioBakedUnityAsset> baked = Array.Empty<StudioBakedUnityAsset>();
if (File.Exists(path))
{
try
{
var metadata = JsonSerializer.Deserialize<Metadata>(await File.ReadAllTextAsync(path, cancellationToken), JsonOptions);
if (metadata?.BakedUnityAssets != null) baked = metadata.BakedUnityAssets;
}
catch (JsonException)
{
}
}
return new StudioUnityAsset(assetId, null, assetId, Convert.ToBase64String(hash), baked);
}
public static async Task<StudioBakedUnityAsset?> ResolveBundleAsync(string assetId, int target, int version, CancellationToken cancellationToken = default)
{
var asset = await ReadAsync(assetId, cancellationToken);
return asset?.BakedUnityAssets.FirstOrDefault(x => x.Target == target && x.Version == version);
}
public static async Task AddBakedAssetAsync(string assetId, StudioBakedUnityAsset bakedAsset, CancellationToken cancellationToken = default)
{
if (!IsValidAssetId(assetId) || !IsValidAssetId(bakedAsset.UnityAssetId) || !IsValidAssetId(bakedAsset.Filename) ||
bakedAsset.Version < 0 || !File.Exists(Path.Combine(DBConfig.CDNStorage, bakedAsset.Filename)) ||
(bakedAsset.RejectedDependenciesFilename != null &&
(!IsValidAssetId(bakedAsset.RejectedDependenciesFilename) ||
!File.Exists(Path.Combine(DBConfig.CDNStorage, bakedAsset.RejectedDependenciesFilename)))))
throw new InvalidDataException("Invalid Studio Unity asset metadata.");
await Gate.WaitAsync(cancellationToken);
try
{
Directory.CreateDirectory(DBConfig.CDNStorage);
var path = MetadataPath(assetId);
Metadata current = new();
if (File.Exists(path))
{
try
{
current = JsonSerializer.Deserialize<Metadata>(await File.ReadAllTextAsync(path, cancellationToken), JsonOptions) ?? new Metadata();
}
catch (JsonException) { }
}
var assets = current.BakedUnityAssets
.Where(x => x.Target != bakedAsset.Target || x.Version != bakedAsset.Version)
.Append(bakedAsset)
.ToArray();
var temporary = path + ".tmp-" + Guid.NewGuid().ToString("N");
try
{
await File.WriteAllTextAsync(temporary, JsonSerializer.Serialize(new Metadata(assets), JsonOptions), cancellationToken);
File.Move(temporary, path, overwrite: true);
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
finally
{
Gate.Release();
}
}
private static string MetadataPath(string assetId) =>
Path.Combine(DBConfig.CDNStorage, $".studio-unityasset-{assetId}.json");
private sealed record Metadata(IReadOnlyList<StudioBakedUnityAsset> BakedUnityAssets)
{
public Metadata() : this(Array.Empty<StudioBakedUnityAsset>()) { }
}
}