using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PowderCoating.Application.Configuration; using PowderCoating.Application.Interfaces; using SixLabors.ImageSharp; using SixLabors.ImageSharp.Formats.Jpeg; using SixLabors.ImageSharp.Processing; namespace PowderCoating.Application.Services; /// /// Manages catalog item images in Azure Blob Storage. Each upload produces a full-size original and a /// 200×200 JPEG thumbnail. Both blobs are stored under {companyId}/catalog/{itemId}/ so that /// paths are isolated per tenant and per item — existing blobs for the same item are replaced atomically /// (delete-then-upload) to avoid orphaned files accumulating over time. /// public class CatalogImageService : ICatalogImageService { private readonly IAzureBlobStorageService _blobService; private readonly StorageSettings _settings; private readonly ILogger _logger; private static readonly string[] AllowedExtensions = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB private const int ThumbnailSize = 200; public CatalogImageService( IAzureBlobStorageService blobService, IOptions settings, ILogger logger) { _blobService = blobService; _settings = settings.Value; _logger = logger; } /// /// Validates the upload, removes any existing blobs, stores the original, generates a 200×200 JPEG /// thumbnail, stores the thumbnail, and returns both blob paths. The thumbnail is always stored as /// JPEG regardless of the source format for predictable browser rendering and smaller file sizes. /// public async Task<(bool Success, string ImagePath, string ThumbnailPath, string ErrorMessage)> UploadAsync( IFormFile file, int itemId, int companyId, string? existingImagePath, string? existingThumbnailPath) { if (file == null || file.Length == 0) return (false, string.Empty, string.Empty, "No file provided."); if (file.Length > MaxFileSizeBytes) return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit."); var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!AllowedExtensions.Contains(ext)) return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp."); var container = _settings.Containers.CatalogImages; var blobId = Guid.NewGuid().ToString("N"); var imagePath = $"{companyId}/catalog/{itemId}/{blobId}{ext}"; var thumbPath = $"{companyId}/catalog/{itemId}/thumb_{blobId}.jpg"; // Delete existing blobs before uploading replacements. await DeleteAsync(existingImagePath, existingThumbnailPath); // Upload original. using var originalStream = file.OpenReadStream(); var uploadResult = await _blobService.UploadAsync(container, imagePath, originalStream, file.ContentType); if (!uploadResult.Success) return (false, string.Empty, string.Empty, uploadResult.ErrorMessage); // Generate and upload thumbnail. using var thumbStream = await GenerateThumbnailAsync(file); if (thumbStream == null) { // Thumbnail generation failed; clean up the original and bail out. await _blobService.DeleteAsync(container, imagePath); return (false, string.Empty, string.Empty, "Failed to generate thumbnail."); } var thumbResult = await _blobService.UploadAsync(container, thumbPath, thumbStream, "image/jpeg"); if (!thumbResult.Success) { await _blobService.DeleteAsync(container, imagePath); return (false, string.Empty, string.Empty, thumbResult.ErrorMessage); } _logger.LogInformation("Catalog image uploaded for item {ItemId}: {ImagePath}", itemId, imagePath); return (true, imagePath, thumbPath, string.Empty); } /// public async Task<(bool Success, byte[] Content, string ContentType, string ErrorMessage)> DownloadAsync(string blobPath) { return await _blobService.DownloadAsync(_settings.Containers.CatalogImages, blobPath); } /// public async Task DeleteAsync(string? imagePath, string? thumbnailPath) { var container = _settings.Containers.CatalogImages; if (!string.IsNullOrEmpty(imagePath)) await _blobService.DeleteAsync(container, imagePath); if (!string.IsNullOrEmpty(thumbnailPath)) await _blobService.DeleteAsync(container, thumbnailPath); } /// /// Decodes the uploaded image with ImageSharp, resizes it to fit within a 200×200 square while /// preserving aspect ratio, and encodes the result as JPEG. Returns null if decoding fails so the /// caller can surface a clean error without propagating an ImageSharp exception. /// private async Task GenerateThumbnailAsync(IFormFile file) { try { using var inputStream = file.OpenReadStream(); using var image = await Image.LoadAsync(inputStream); image.Mutate(ctx => ctx.Resize(new ResizeOptions { Size = new Size(ThumbnailSize, ThumbnailSize), Mode = ResizeMode.Max })); var ms = new MemoryStream(); await image.SaveAsync(ms, new JpegEncoder { Quality = 85 }); ms.Position = 0; return ms; } catch (Exception ex) { _logger.LogError(ex, "Thumbnail generation failed for file {FileName}", file.FileName); return null; } } }