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 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 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 baked = Array.Empty(); if (File.Exists(path)) { try { var metadata = JsonSerializer.Deserialize(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 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(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 BakedUnityAssets) { public Metadata() : this(Array.Empty()) { } } }