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; public MediaStorageService(IWebHostEnvironment env, ILogger logger) { _env = env; _logger = logger; } 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; 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 using var stream = File.Create(path); await file.CopyToAsync(stream, cancellationToken); _logger.LogInformation("Saved 3D model media for cafe {CafeId}", cafeId); return $"/uploads/{cafeId}/{savedName}"; } 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; 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 using var stream = File.Create(path); await file.CopyToAsync(stream, cancellationToken); _logger.LogInformation("Saved {Prefix} media for cafe {CafeId}", prefix, cafeId); return $"/uploads/{cafeId}/{fileName}"; } }