using System.Buffers.Binary; using System.Security.Cryptography; using Ludarium.Application; using Ludarium.Domain; namespace Ludarium.Infrastructure; public sealed record StoredGameArtwork(Stream Content, string ContentType, long Length); public sealed class GameArtworkStore { public const long MaximumBytes = 10 * 1024 * 1024; internal string DirectoryPath => root; private readonly string root; public GameArtworkStore(string root) { this.root = Path.GetFullPath(root); Directory.CreateDirectory(this.root); } public async Task OpenAsync(Guid gameId, CancellationToken cancellationToken, Guid? artworkId = null, bool fallbackToCurrent = true) { var candidate = artworkId is null ? null : CandidatePathFor(gameId, artworkId.Value); var path = candidate is not null && File.Exists(candidate) ? candidate : fallbackToCurrent ? PathFor(gameId) : candidate; if (!File.Exists(path)) return null; var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); var signature = new byte[12]; var read = await stream.ReadAsync(signature, cancellationToken); stream.Position = 0; var contentType = Detect(signature.AsSpan(0, read)); if (contentType is null) { await stream.DisposeAsync(); return null; } return new(stream, contentType, stream.Length); } public async Task SaveAsync(Guid gameId, Stream content, long? declaredLength, CancellationToken cancellationToken, string source = "Uploaded") { if (declaredLength is > MaximumBytes) throw new ArtworkValidationException("Artwork exceeds the 10 MiB limit."); var destination = PathFor(gameId); var temporary = destination + ".upload-" + Guid.NewGuid().ToString("N"); try { await using (var output = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan)) { var buffer = new byte[64 * 1024]; long total = 0; while (true) { var read = await content.ReadAsync(buffer, cancellationToken); if (read == 0) break; total += read; if (total > MaximumBytes) throw new ArtworkValidationException("Artwork exceeds the 10 MiB limit."); await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); } if (total == 0) throw new ArtworkValidationException("Artwork is empty."); } var signature = new byte[12]; string? contentType; await using (var input = new FileStream(temporary, FileMode.Open, FileAccess.Read, FileShare.Read)) { var read = await input.ReadAsync(signature, cancellationToken); contentType = Detect(signature.AsSpan(0, read)); if (contentType is null) throw new ArtworkValidationException("Use a valid JPEG, PNG or WebP image."); } File.Move(temporary, destination, true); await File.WriteAllTextAsync(SourcePathFor(gameId), source, cancellationToken); return contentType; } finally { if (File.Exists(temporary)) File.Delete(temporary); } } public bool Delete(Guid gameId) { var path = PathFor(gameId); if (!File.Exists(path)) return false; File.Delete(path); var sourcePath = SourcePathFor(gameId); if (File.Exists(sourcePath)) File.Delete(sourcePath); return true; } public int DeleteAll(Guid gameId) { var deleted = Delete(gameId) ? 1 : 0; var candidates = Path.Combine(root, "candidates", gameId.ToString("N")); if (Directory.Exists(candidates)) { deleted += Directory.EnumerateFiles(candidates).Count(); Directory.Delete(candidates, true); } return deleted; } public string GetSource(Guid gameId) { var path = SourcePathFor(gameId); if (!File.Exists(path)) return "Uploaded"; var source = File.ReadAllText(path).Trim(); return source.Length is > 0 and <= 40 ? source : "Uploaded"; } public async Task DescribeAsync(Guid gameId, string? provider, string matchMethod, Confidence confidence, CancellationToken cancellationToken, string? platform = null, string? externalId = null) { var path = PathFor(gameId); if (!File.Exists(path)) return null; await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); var dimensions = await ReadDimensionsAsync(stream, cancellationToken); stream.Position = 0; var hash = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken)).ToLowerInvariant(); var source = GetSource(gameId); decimal? ratio = dimensions.Width is not null && dimensions.Height is > 0 ? Math.Round((decimal)dimensions.Width.Value / dimensions.Height.Value, 4) : null; var score = ArtworkQuality.Score(dimensions.Width, dimensions.Height, ratio, source, confidence, platform); var uploaded = source.Equals("Uploaded", StringComparison.OrdinalIgnoreCase); var verified = uploaded || score >= ArtworkQuality.VerifiedThreshold; // Candidate identity is content-addressed. Re-running enrichment must not create a // fresh review candidate for the same bytes and source every time. var identity = System.Text.Encoding.UTF8.GetBytes($"{gameId:N}|{platform ?? "global"}|{hash}|{source}|{provider ?? source}"); var artworkId = new Guid(SHA256.HashData(identity)[..16]); var candidatePath = CandidatePathFor(gameId, artworkId); Directory.CreateDirectory(Path.GetDirectoryName(candidatePath)!); File.Copy(path, candidatePath, true); return new(artworkId, gameId, uploaded ? ArtworkRole.UserUpload : verified ? ArtworkRole.VerifiedBoxFront : ArtworkRole.BoxFront, uploaded ? ArtworkVerificationStatus.ManuallyVerified : verified ? ArtworkVerificationStatus.AutomaticallyVerified : ArtworkVerificationStatus.NeedsReview, source, provider ?? source, externalId, matchMethod, confidence, dimensions.Width, dimensions.Height, ratio, stream.Length, hash, score, DateTimeOffset.UtcNow, Platform: platform); } internal static async Task<(int? Width, int? Height)> ReadDimensionsAsync(Stream stream, CancellationToken ct) { var header = new byte[32]; var read = await stream.ReadAsync(header, ct); if (read >= 24 && Detect(header.AsSpan(0, read)) == "image/png") return (BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(16, 4)), BinaryPrimitives.ReadInt32BigEndian(header.AsSpan(20, 4))); if (read >= 30 && Detect(header.AsSpan(0, read)) == "image/webp" && header.AsSpan(12, 4).SequenceEqual("VP8X"u8)) return (1 + header[24] + (header[25] << 8) + (header[26] << 16), 1 + header[27] + (header[28] << 8) + (header[29] << 16)); if (read >= 3 && Detect(header.AsSpan(0, read)) == "image/jpeg") { stream.Position = 2; var marker = new byte[4]; while (await stream.ReadAsync(marker.AsMemory(0, 4), ct) == 4) { if (marker[0] != 0xff) break; var length = BinaryPrimitives.ReadUInt16BigEndian(marker.AsSpan(2, 2)); if (length < 2) break; if (marker[1] is >= 0xc0 and <= 0xc3 or >= 0xc5 and <= 0xc7 or >= 0xc9 and <= 0xcb or >= 0xcd and <= 0xcf) { var size = new byte[5]; if (await stream.ReadAsync(size, ct) != 5) break; return (BinaryPrimitives.ReadUInt16BigEndian(size.AsSpan(3, 2)), BinaryPrimitives.ReadUInt16BigEndian(size.AsSpan(1, 2))); } stream.Seek(length - 2, SeekOrigin.Current); } } return (null, null); } private string PathFor(Guid gameId) => Path.Combine(root, gameId.ToString("N") + ".artwork"); private string CandidatePathFor(Guid gameId, Guid artworkId) => Path.Combine(root, "candidates", gameId.ToString("N"), artworkId.ToString("N") + ".artwork"); private string SourcePathFor(Guid gameId) => Path.Combine(root, gameId.ToString("N") + ".source"); internal static string? Detect(ReadOnlySpan bytes) { if (bytes.Length >= 3 && bytes[0] == 0xff && bytes[1] == 0xd8 && bytes[2] == 0xff) return "image/jpeg"; if (bytes.Length >= 8 && bytes[..8].SequenceEqual(new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a })) return "image/png"; if (bytes.Length >= 12 && bytes[..4].SequenceEqual("RIFF"u8) && bytes[8..12].SequenceEqual("WEBP"u8)) return "image/webp"; return null; } } public sealed class ArtworkValidationException(string message) : Exception(message);