using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; namespace GmRelay.Web.Services.Portfolio.Covers; public sealed class LocalPortfolioCoverStorage : IPortfolioCoverStorage { public const long MaxBytes = 5 * 1024 * 1024; private static readonly Regex SafeKeyPattern = new( "^[a-f0-9]{32}\\.(jpg|png|webp)$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly byte[] JpegSignature = [0xFF, 0xD8, 0xFF]; private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; private static readonly byte[] RiffMarker = "RIFF"u8.ToArray(); private static readonly byte[] WebpMarker = "WEBP"u8.ToArray(); private readonly string _storagePath; private readonly ILogger _logger; public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options) : this(options, logger: null) { } public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options, ILogger? logger) { ArgumentNullException.ThrowIfNull(options); if (string.IsNullOrWhiteSpace(options.StoragePath)) { throw new InvalidOperationException("PortfolioCovers:StoragePath must be configured."); } _storagePath = options.StoragePath; _logger = logger ?? NullLogger.Instance; } public async Task SaveAsync( Stream content, string contentType, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(content); if (string.IsNullOrWhiteSpace(contentType)) { throw new InvalidOperationException("Content type must be provided."); } var extension = NormalizeExtension(contentType); // Buffer the stream so we can reject oversize uploads before writing to disk // and so we have the bytes we need for signature validation. await using var buffer = new MemoryStream(); await content.CopyToAsync(buffer, cancellationToken); if (buffer.Length > MaxBytes) { throw new InvalidOperationException( $"Cover image exceeds the {MaxBytes}-byte size limit."); } var signature = buffer.GetBuffer(); var signatureLength = (int)buffer.Length; ValidateSignature(extension, signature, signatureLength); Directory.CreateDirectory(_storagePath); var finalName = Guid.NewGuid().ToString("N") + extension; var finalPath = Path.Combine(_storagePath, finalName); var tempPath = finalPath + ".tmp"; try { await using (var tempStream = new FileStream( tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { buffer.Position = 0; await buffer.CopyToAsync(tempStream, cancellationToken); await tempStream.FlushAsync(cancellationToken); } File.Move(tempPath, finalPath, overwrite: false); } catch { TryDelete(tempPath); throw; } return new PortfolioCoverUploadResult(finalName, ResolveContentType(extension)); } public Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(storageKey); EnsureSafeKey(storageKey); var path = Path.Combine(_storagePath, storageKey); TryDelete(path); return Task.CompletedTask; } public string GetPublicPath(string storageKey) { ArgumentException.ThrowIfNullOrWhiteSpace(storageKey); return "/portfolio-covers/" + Uri.EscapeDataString(storageKey); } private static void ValidateSignature(string extension, byte[] data, int length) { var isValid = extension switch { ".jpg" => StartsWith(data, length, JpegSignature), ".png" => StartsWith(data, length, PngSignature), ".webp" => StartsWith(data, length, RiffMarker) && ContainsAt(data, RiffMarker.Length + 4, WebpMarker), _ => false }; if (!isValid) { throw new InvalidOperationException( $"Cover signature does not match the declared content type."); } } private static bool StartsWith(byte[] data, int length, byte[] prefix) { if (length < prefix.Length) { return false; } for (var i = 0; i < prefix.Length; i++) { if (data[i] != prefix[i]) { return false; } } return true; } private static bool ContainsAt(byte[] data, int offset, byte[] needle) { if (offset + needle.Length > data.Length) { return false; } for (var i = 0; i < needle.Length; i++) { if (data[offset + i] != needle[i]) { return false; } } return true; } private static string NormalizeExtension(string contentType) { var normalized = contentType.Trim().ToLowerInvariant(); return normalized switch { "image/jpeg" or "image/jpg" => ".jpg", "image/png" => ".png", "image/webp" => ".webp", _ => throw new InvalidOperationException( $"Unsupported cover content type: '{contentType}'.") }; } private static string ResolveContentType(string extension) => extension switch { ".jpg" => "image/jpeg", ".png" => "image/png", ".webp" => "image/webp", _ => "application/octet-stream" }; private static void EnsureSafeKey(string storageKey) { if (!SafeKeyPattern.IsMatch(storageKey)) { throw new InvalidOperationException("Cover storage key is not in the expected format."); } } private void TryDelete(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete cover file '{Path}'.", path); } } }