Files
meezi/src/Meezi.API/Services/MediaStorageService.cs
T
soroush.asadi 97a9481627 feat(media): content-hash dedup for uploads + media-library endpoint
Uploads previously wrote every file to disk with a fresh GUID name, so the
same image uploaded twice produced two identical files. Now:

- New MediaAsset table records each stored upload (SHA-256 hash, size, type,
  url, kind, scope) + migration. Indexed on (CafeId, ContentHash).
- MediaStorageService computes the content hash on upload; if an identical file
  already exists for that café it returns the existing URL instead of writing a
  duplicate (covers images, videos, 3D models). Dedup lookup/record run via a
  scoped DbContext (the service is a singleton) and never block an upload on
  failure.
- GET /api/cafes/{cafeId}/media lists the café's library (newest first, optional
  ?kind=) so the UI can let users pick an existing file instead of re-uploading.

86 API tests pass.
2026-06-02 22:16:11 +03:30

236 lines
10 KiB
C#

using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using Meezi.Core.Entities;
using Meezi.Infrastructure.Data;
namespace Meezi.API.Services;
public interface IMediaStorageService
{
Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
Task<string?> SaveMenuModel3dFromBytesAsync(string cafeId, byte[] glbBytes, CancellationToken cancellationToken = default);
Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default);
}
public class MediaStorageService : IMediaStorageService
{
private static readonly HashSet<string> ImageMime = new(StringComparer.OrdinalIgnoreCase)
{
"image/jpeg", "image/png", "image/webp"
};
private static readonly HashSet<string> 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<string> Model3dMime = new(StringComparer.OrdinalIgnoreCase)
{
"model/gltf-binary", "application/octet-stream"
};
private readonly IWebHostEnvironment _env;
private readonly ILogger<MediaStorageService> _logger;
private readonly IServiceScopeFactory _scopeFactory;
public MediaStorageService(
IWebHostEnvironment env,
ILogger<MediaStorageService> logger,
IServiceScopeFactory scopeFactory)
{
_env = env;
_logger = logger;
_scopeFactory = scopeFactory;
}
public Task<string?> SaveMenuImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
=> SaveAsync(cafeId, file, "menu_img", ImageMime, MaxImageBytes, cancellationToken);
public Task<string?> SaveMenuVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
=> SaveAsync(cafeId, file, "menu_vid", VideoMime, MaxVideoBytes, cancellationToken);
public Task<string?> SaveTableImageAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
=> SaveAsync(cafeId, file, "table_img", ImageMime, MaxImageBytes, cancellationToken);
public Task<string?> SaveTableVideoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
=> SaveAsync(cafeId, file, "table_vid", VideoMime, MaxVideoBytes, cancellationToken);
public Task<string?> SaveCafeLogoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
=> SaveAsync(cafeId, file, "logo", ImageMime, MaxImageBytes, cancellationToken);
public Task<string?> SaveCafeCoverAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
=> SaveAsync(cafeId, file, "cover", ImageMime, MaxImageBytes, cancellationToken);
public Task<string?> SaveReviewPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
=> SaveAsync(cafeId, file, "review", ImageMime, MaxImageBytes, cancellationToken);
public Task<string?> SaveCafeGalleryPhotoAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
=> SaveAsync(cafeId, file, "gallery", ImageMime, MaxImageBytes, cancellationToken);
public Task<string?> SaveMenuModel3dAsync(string cafeId, IFormFile file, CancellationToken cancellationToken = default)
=> SaveModel3dAsync(cafeId, file, cancellationToken);
public async Task<string?> 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<string?> 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<string?> SaveAsync(
string cafeId,
IFormFile file,
string prefix,
HashSet<string> 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<string?> FindExistingByHashAsync(string? cafeId, string hash, CancellationToken ct)
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
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<AppDbContext>();
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);
}
}
}