using System.Security.Cryptography; using Microsoft.EntityFrameworkCore; using Meezi.Core.Entities; using Meezi.Infrastructure.Data; namespace Meezi.API.Services; public interface IMediaStorageService { Task SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default); Task SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default); Task SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default); Task SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default); Task SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default); Task SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default); Task SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default); Task SaveMenuModel3dFromBytesAsync(string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default); Task SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default); Task SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default); } public class MediaStorageService : IMediaStorageService { private static readonly HashSet ImageMime = new(StringComparer.OrdinalIgnoreCase) { "image/jpeg", "image/png", "image/webp" }; private static readonly HashSet VideoMime = new(StringComparer.OrdinalIgnoreCase) { "video/mp4", "video/webm", "video/quicktime" }; private const long MaxImageBytes = 5 * 1024 * 1024; private const long MaxVideoBytes = 25 * 1024 * 1024; private const long MaxModel3dBytes = 8 * 1024 * 1024; private static readonly HashSet Model3dMime = new(StringComparer.OrdinalIgnoreCase) { "model/gltf-binary", "application/octet-stream" }; private readonly IWebHostEnvironment _env; private readonly ILogger _logger; private readonly IServiceScopeFactory _scopeFactory; public MediaStorageService( IWebHostEnvironment env, ILogger logger, IServiceScopeFactory scopeFactory) { _env = env; _logger = logger; _scopeFactory = scopeFactory; } public Task SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) => SaveAsync(cafeId, file, "menu_img", ImageMime, MaxImageBytes, cancellationToken); public Task SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) => SaveAsync(cafeId, file, "menu_vid", VideoMime, MaxVideoBytes, cancellationToken); public Task SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) => SaveAsync(cafeId, file, "table_img", ImageMime, MaxImageBytes, cancellationToken); public Task SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) => SaveAsync(cafeId, file, "table_vid", VideoMime, MaxVideoBytes, cancellationToken); public Task SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) => SaveAsync(cafeId, file, "logo", ImageMime, MaxImageBytes, cancellationToken); public Task SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) => SaveAsync(cafeId, file, "cover", ImageMime, MaxImageBytes, cancellationToken); public Task SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) => SaveAsync(cafeId, file, "review", ImageMime, MaxImageBytes, cancellationToken); public Task SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) => SaveAsync(cafeId, file, "gallery", ImageMime, MaxImageBytes, cancellationToken); public Task SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default) => SaveModel3dAsync(cafeId, file, cancellationToken); public async Task SaveMenuModel3dFromBytesAsync( string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default) { if (glbBytes.Length == 0 || glbBytes.Length > MaxModel3dBytes) return null; var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId); Directory.CreateDirectory(dir); var savedName = $"menu_3d_ai_{Guid.NewGuid():N}.glb"; var path = Path.Combine(dir, savedName); await File.WriteAllBytesAsync(path, glbBytes, cancellationToken); _logger.LogInformation("Saved AI 3D model for cafe {CafeId}", cafeId); return $"/uploads/{cafeId}/{savedName}"; } private async Task SaveModel3dAsync( string cafeId, IFormFile file, CancellationToken cancellationToken) { if (file.Length == 0 || file.Length > MaxModel3dBytes) return null; var fileName = file.FileName.ToLowerInvariant(); var isGlb = fileName.EndsWith(".glb", StringComparison.OrdinalIgnoreCase) || Model3dMime.Contains(file.ContentType); if (!isGlb) return null; await using var buffer = new MemoryStream(); await file.CopyToAsync(buffer, cancellationToken); var bytes = buffer.ToArray(); var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken); if (existing is not null) { _logger.LogInformation("Dedup hit for 3D model (cafe {CafeId}); reusing existing file", cafeId); return existing; } var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId); Directory.CreateDirectory(dir); var savedName = $"menu_3d_{Guid.NewGuid():N}.glb"; var path = Path.Combine(dir, savedName); await File.WriteAllBytesAsync(path, bytes, cancellationToken); var url = $"/uploads/{cafeId}/{savedName}"; await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, "menu_3d", file.FileName, cancellationToken); _logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId); return url; } private async Task SaveAsync( string cafeId, IFormFile file, string prefix, HashSet allowedMime, long maxBytes, CancellationToken cancellationToken) { if (file.Length == 0 || file.Length > maxBytes) return null; if (!allowedMime.Contains(file.ContentType)) return null; // Buffer once so we can hash the content and (if new) write it. await using var buffer = new MemoryStream(); await file.CopyToAsync(buffer, cancellationToken); var bytes = buffer.ToArray(); var hash = Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(); // Dedup: an identical file already stored for this scope is reused as-is. var existing = await FindExistingByHashAsync(cafeId, hash, cancellationToken); if (existing is not null) { _logger.LogInformation("Dedup hit for {Prefix} (cafe {CafeId}); reusing existing file", prefix, cafeId); return existing; } var ext = file.ContentType.ToLowerInvariant() switch { "image/png" => ".png", "image/webp" => ".webp", "video/webm" => ".webm", "video/quicktime" => ".mov", "video/mp4" => ".mp4", _ => ".jpg" }; var dir = Path.Combine(_env.ContentRootPath, "uploads", cafeId); Directory.CreateDirectory(dir); var fileName = $"{prefix}_{Guid.NewGuid():N}{ext}"; var path = Path.Combine(dir, fileName); await File.WriteAllBytesAsync(path, bytes, cancellationToken); var url = $"/uploads/{cafeId}/{fileName}"; await RecordAsync(cafeId, hash, bytes.LongLength, file.ContentType, url, prefix, file.FileName, cancellationToken); _logger.LogInformation("Saved {Prefix} media for cafe {CafeId}", prefix, cafeId); return url; } // ─── Deduplication helpers ──────────────────────────────────────────────── // MediaStorageService is a singleton; resolve a scoped DbContext per call. private async Task FindExistingByHashAsync(string? cafeId, string hash, CancellationToken ct) { try { await using var scope = _scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); return await db.MediaAssets.AsNoTracking() .Where(m => m.CafeId == cafeId && m.ContentHash == hash) .Select(m => m.Url) .FirstOrDefaultAsync(ct); } catch (Exception ex) { // Never let a dedup-lookup failure block an upload. _logger.LogWarning(ex, "Media dedup lookup failed; proceeding with a fresh upload"); return null; } } private async Task RecordAsync( string? cafeId, string hash, long size, string contentType, string url, string kind, string? originalName, CancellationToken ct) { try { await using var scope = _scopeFactory.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); db.MediaAssets.Add(new MediaAsset { CafeId = cafeId, ContentHash = hash, SizeBytes = size, ContentType = contentType, Url = url, Kind = kind, OriginalFileName = originalName, }); await db.SaveChangesAsync(ct); } catch (Exception ex) { // The file is already written; a missing dedup record only means a // future identical upload won't be de-duplicated. Don't fail the upload. _logger.LogWarning(ex, "Failed to record media asset for cafe {CafeId}", cafeId); } } }