Files
MotoresArgentinosV2/Backend/MotoresArgentinosV2.Infrastructure/Services/ImageStorageService.cs

164 lines
6.0 KiB
C#
Raw Normal View History

2026-01-29 13:43:44 -03:00
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Processing;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using System.IO;
namespace MotoresArgentinosV2.Infrastructure.Services;
public interface IImageStorageService
{
Task<string> SaveAdImageAsync(int adId, IFormFile file);
void DeleteAdImage(string relativePath);
}
public class ImageStorageService : IImageStorageService
{
private readonly IWebHostEnvironment _env;
private readonly ILogger<ImageStorageService> _logger;
public ImageStorageService(IWebHostEnvironment env, ILogger<ImageStorageService> logger)
{
_env = env;
_logger = logger;
}
// Firmas de archivos (Magic Numbers) para JPG, PNG, WEBP
private static readonly Dictionary<string, List<byte[]>> _fileSignatures = new Dictionary<string, List<byte[]>>
{
{ ".jpeg", new List<byte[]> { new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE2 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE3 } } },
{ ".jpg", new List<byte[]> { new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE1 }, new byte[] { 0xFF, 0xD8, 0xFF, 0xE8 } } },
{ ".png", new List<byte[]> { new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A } } },
{ ".webp", new List<byte[]> { new byte[] { 0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50 } } }
};
public async Task<string> SaveAdImageAsync(int adId, IFormFile file)
{
// 1. Validación de Extensión Básica
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (string.IsNullOrEmpty(ext) || !_fileSignatures.ContainsKey(ext))
{
throw new Exception("Formato de archivo no permitido. Solo JPG, PNG y WEBP.");
}
// 2. Validación de Tamaño (Max 3MB)
if (file.Length > 3 * 1024 * 1024)
{
throw new Exception("El archivo excede los 3MB permitidos.");
}
// 3. Validación de Magic Numbers (Leer cabecera real)
using (var stream = file.OpenReadStream())
using (var reader = new BinaryReader(stream))
{
// Leemos hasta 12 bytes que cubren nuestras firmas soportadas
var headerBytes = reader.ReadBytes(12);
bool isRealImage = false;
// A. JPG (Flexible: FF D8)
if (headerBytes.Length >= 2 && headerBytes[0] == 0xFF && headerBytes[1] == 0xD8)
{
isRealImage = true;
}
// B. PNG (Sello estricto)
else if (headerBytes.Length >= 8 && headerBytes.Take(8).SequenceEqual(new byte[] { 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A }))
{
isRealImage = true;
}
// C. WEBP (RIFF [4 bytes] WEBP)
else if (headerBytes.Length >= 12 &&
headerBytes.Take(4).SequenceEqual(new byte[] { 0x52, 0x49, 0x46, 0x46 }) &&
headerBytes.Skip(8).Take(4).SequenceEqual(new byte[] { 0x57, 0x45, 0x42, 0x50 }))
{
isRealImage = true;
}
if (!isRealImage)
{
string hex = BitConverter.ToString(headerBytes.Take(8).ToArray());
_logger.LogWarning("Firma de archivo inválida para {Extension}: {HexBytes}", ext, hex);
2026-01-30 15:23:50 -03:00
throw new Exception($"El archivo parece corrupto o tiene una firma inválida ({hex}).");
2026-01-29 13:43:44 -03:00
}
}
2026-01-30 15:23:50 -03:00
2026-01-29 13:43:44 -03:00
try
{
2026-01-30 15:23:50 -03:00
// 4. PREVENCIÓN DoS: Validar dimensiones sin cargar la imagen completa en memoria
// Esto evita que una imagen de 1KB que dice ser de 50000x50000px cuelgue el servidor
using (var stream = file.OpenReadStream())
{
var info = await Image.IdentifyAsync(stream);
if (info == null) throw new Exception("No se pudo identificar el formato de la imagen.");
const int MaxDimension = 5000; // 5000px es más que suficiente para un aviso
if (info.Width > MaxDimension || info.Height > MaxDimension)
{
_logger.LogWarning("Intento de subir imagen con dimensiones excesivas: {W}x{H}", info.Width, info.Height);
throw new Exception($"Las dimensiones de la imagen ({info.Width}x{info.Height}) exceden el límite de seguridad de {MaxDimension}px.");
}
}
2026-01-29 13:43:44 -03:00
// 1. Definir rutas
var uploadFolder = Path.Combine(_env.WebRootPath, "uploads", "ads", adId.ToString());
if (!Directory.Exists(uploadFolder)) Directory.CreateDirectory(uploadFolder);
var uniqueName = Guid.NewGuid().ToString();
var fileName = $"{uniqueName}.jpg";
var thumbName = $"{uniqueName}_thumb.jpg";
var filePath = Path.Combine(uploadFolder, fileName);
var thumbPath = Path.Combine(uploadFolder, thumbName);
2026-01-30 15:23:50 -03:00
// 2. Cargar y Procesar con ImageSharp (Aquí ya es seguro porque validamos dimensiones e identidad)
2026-01-29 13:43:44 -03:00
using (var image = await Image.LoadAsync(file.OpenReadStream()))
{
// A. Guardar imagen principal (Optimized: Max width 1280px)
if (image.Width > 1280)
{
image.Mutate(x => x.Resize(new ResizeOptions
{
2026-01-30 15:23:50 -03:00
Size = new Size(1280, 0),
2026-01-29 13:43:44 -03:00
Mode = ResizeMode.Max
}));
}
await image.SaveAsJpegAsync(filePath);
2026-01-30 15:23:50 -03:00
// B. Generar Thumbnail
2026-01-29 13:43:44 -03:00
image.Mutate(x => x.Resize(new ResizeOptions
{
Size = new Size(400, 300),
2026-01-30 15:23:50 -03:00
Mode = ResizeMode.Crop
2026-01-29 13:43:44 -03:00
}));
await image.SaveAsJpegAsync(thumbPath);
}
return $"/uploads/ads/{adId}/{fileName}";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error procesando imagen para el aviso {AdId}", adId);
throw;
}
}
public void DeleteAdImage(string relativePath)
{
if (string.IsNullOrEmpty(relativePath)) return;
try
{
// Eliminar archivo principal
var fullPath = Path.Combine(_env.WebRootPath, relativePath.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
if (File.Exists(fullPath)) File.Delete(fullPath);
// Eliminar thumbnail asociado
var thumbPath = fullPath.Replace(".jpg", "_thumb.jpg");
if (File.Exists(thumbPath)) File.Delete(thumbPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error eliminando imagen {Path}", relativePath);
}
}
}