2using Microsoft.AspNetCore.Components.Forms;
70 private static readonly
string[]
AllowedExts = [
".jpg",
".jpeg",
".png",
".webp",
".gif"];
156 public async Task<IReadOnlyList<PhotoInfo>>
GetGalleryAsync(
string slug, CancellationToken ct =
default)
158 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
159 if (config is
null)
return [];
161 var list =
new List<PhotoInfo>();
162 foreach (var fn
in config.GalleryFileNames)
165 if (!File.Exists(full))
continue;
166 var fi =
new FileInfo(full);
172 SizeBytes = fi.Length,
173 LastModified = fi.LastWriteTime
219 public async Task<string>
UploadAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
222 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false) ??
new TenantConfig { Slug = slug };
223 var policy = await
_policyResolver.ResolveAsync(config, ct).ConfigureAwait(
false);
226 var baseName = Path.GetFileNameWithoutExtension(
UniqueFileName(file.Name));
227 var galleryDir = Path.Combine(
_tenants.GetTenantDataPath(slug),
"gallery");
228 var thumbDir = Path.Combine(
_tenants.GetTenantDataPath(slug),
"thumb");
229 Directory.CreateDirectory(galleryDir);
230 Directory.CreateDirectory(thumbDir);
232 var fileName = await
SaveImageFileAsync(slug, file, galleryDir, baseName, policy, ct).ConfigureAwait(
false);
233 File.Copy(Path.Combine(galleryDir, fileName), Path.Combine(thumbDir, fileName), overwrite:
true);
235 if (!config.GalleryFileNames.Contains(fileName))
236 config.GalleryFileNames.Add(fileName);
237 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
282 public async Task<string>
UploadHeroAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
285 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false) ??
new TenantConfig { Slug = slug };
286 var policy = await
_policyResolver.ResolveAsync(config, ct).ConfigureAwait(
false);
289 var dir =
_tenants.GetTenantDataPath(slug);
290 Directory.CreateDirectory(dir);
292 var fileName = await
SaveImageFileAsync(slug, file, dir,
"hero", policy, ct).ConfigureAwait(
false);
293 config.HeroImageFileName = fileName;
294 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
339 public async Task<string>
UploadRoadMapAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
342 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false) ??
new TenantConfig { Slug = slug };
343 var policy = await
_policyResolver.ResolveAsync(config, ct).ConfigureAwait(
false);
346 var dir =
_tenants.GetTenantDataPath(slug);
347 Directory.CreateDirectory(dir);
349 var fileName = await
SaveImageFileAsync(slug, file, dir,
"roadmap", policy, ct).ConfigureAwait(
false);
350 config.RoadMapFileName = fileName;
351 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
396 public async Task
DeleteAsync(
string slug,
string fileName, CancellationToken ct =
default)
399 var thumbFile = Path.Combine(
_tenants.GetTenantDataPath(slug),
"thumb", fileName);
400 if (File.Exists(galleryFile)) File.Delete(galleryFile);
401 if (File.Exists(thumbFile)) File.Delete(thumbFile);
403 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
404 if (config is
null)
return;
405 config.GalleryFileNames.Remove(fileName);
406 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
442 $
"/wedding-data/{slug}/gallery/{Uri.EscapeDataString(fileName)}";
477 $
"/wedding-data/{slug}/thumb/{Uri.EscapeDataString(fileName)}";
512 $
"/wedding-data/{slug}/{Uri.EscapeDataString(fileName)}";
547 $
"/wedding-data/{slug}/{Uri.EscapeDataString(fileName)}";
597 public async Task<string>
UploadMusicAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
599 var ext = Path.GetExtension(file.Name).ToLowerInvariant();
600 if (ext is not (
".mp3" or
".ogg" or
".wav" or
".aac" or
".m4a"))
601 throw new InvalidOperationException($
"허용되지 않는 오디오 형식입니다: {ext}");
602 if (file.Size > 30 * 1024 * 1024)
603 throw new InvalidOperationException(
"음악 파일은 30MB 이하여야 합니다.");
605 var dir =
_tenants.GetTenantDataPath(slug);
606 Directory.CreateDirectory(dir);
608 var fileName = $
"bgmusic{ext}";
609 var destPath = Path.Combine(dir, fileName);
610 await
using (var dest = File.Create(destPath))
611 await file.OpenReadStream(30 * 1024 * 1024, ct).CopyToAsync(dest, ct).ConfigureAwait(
false);
613 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false) ??
new TenantConfig { Slug = slug };
614 config.MusicFileName = fileName;
615 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
653 $
"/wedding-data/{slug}/{Uri.EscapeDataString(fileName)}";
695 public async Task<string>
UploadOgImageAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
698 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false) ??
new TenantConfig { Slug = slug };
699 var policy = await
_policyResolver.ResolveAsync(config, ct).ConfigureAwait(
false);
702 var dir =
_tenants.GetTenantDataPath(slug);
703 Directory.CreateDirectory(dir);
705 var fileName = await
SaveImageFileAsync(slug, file, dir,
"og", policy, ct).ConfigureAwait(
false);
706 config.OgImageFileName = fileName;
707 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
760 public async Task<string>
UploadVideoAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
762 var ext = Path.GetExtension(file.Name).ToLowerInvariant();
763 if (ext is not (
".mp4" or
".webm" or
".mov" or
".m4v"))
764 throw new InvalidOperationException($
"허용되지 않는 동영상 형식입니다: {ext}. mp4/webm/mov 권장");
766 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false) ??
new TenantConfig { Slug = slug };
767 var policy = await
_policyResolver.ResolveAsync(config, ct).ConfigureAwait(
false);
769 var effectiveCount = policy.VideoMaxCount;
770 if (effectiveCount > 0 && config.VideoFileNames.Count >= effectiveCount)
771 throw new InvalidOperationException($
"동영상은 최대 {effectiveCount}개까지 업로드할 수 있습니다.");
773 var effectiveMb = policy.VideoMaxFileSizeMb;
774 var maxBytes = policy.VideoMaxFileSizeBytes;
776 if (file.Size > maxBytes)
777 throw new InvalidOperationException($
"동영상 파일은 {effectiveMb}MB 이하여야 합니다.");
779 if (policy.VideoMaxStorageMb > 0)
781 var usage = await
_usageQuery.GetTenantUsageAsync(config, ct).ConfigureAwait(
false);
782 if (usage.VideoBytes + file.Size > policy.VideoMaxStorageBytes)
784 throw new InvalidOperationException($
"동영상 저장 용량은 최대 {policy.VideoMaxStorageMb}MB까지 사용할 수 있습니다.");
788 var dir =
_tenants.GetTenantDataPath(slug);
789 Directory.CreateDirectory(dir);
791 var fileName = $
"video-{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid().ToString("N
")[..8]}{ext}";
792 var destPath = Path.Combine(dir, fileName);
793 await
using (var dest = File.Create(destPath))
794 await file.OpenReadStream(maxBytes, ct).CopyToAsync(dest, ct).ConfigureAwait(
false);
796 config.VideoFileNames.Add(fileName);
797 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
842 public async Task
DeleteVideoAsync(
string slug,
string fileName, CancellationToken ct =
default)
844 var path = Path.Combine(
_tenants.GetTenantDataPath(slug), fileName);
845 if (File.Exists(path)) File.Delete(path);
847 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
848 if (config is
null)
return;
849 config.VideoFileNames.Remove(fileName);
850 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
886 $
"/wedding-data/{slug}/{Uri.EscapeDataString(fileName)}";
921 Path.Combine(
_tenants.GetTenantDataPath(slug),
"gallery", fileName);
991 throw new InvalidOperationException($
"이미지는 최대 {policy.ImageMaxCount}장까지 업로드할 수 있습니다.");
996 var usage = await
_usageQuery.GetTenantUsageAsync(config, ct).ConfigureAwait(
false);
999 throw new InvalidOperationException($
"이미지 저장 용량은 최대 {policy.ImageOptimizedMaxStorageMb}MB까지 사용할 수 있습니다.");
1070 var root =
_tenants.GetTenantDataPath(slug);
1071 var originalDir = Path.Combine(root,
"original");
1072 var originalExt = Path.GetExtension(file.Name).ToLowerInvariant();
1073 var tempPath = Path.Combine(destinationDirectory, $
"{baseName}.upload{originalExt}");
1074 Directory.CreateDirectory(destinationDirectory);
1076 await
using (var dest = File.Create(tempPath))
1078 await file.OpenReadStream(
MaxImageUploadBytes, ct).CopyToAsync(dest, ct).ConfigureAwait(
false);
1083 Directory.CreateDirectory(originalDir);
1084 File.Copy(tempPath, Path.Combine(originalDir, $
"{baseName}{originalExt}"), overwrite:
true);
1088 if (!
string.IsNullOrWhiteSpace(outputFormat))
1090 var outputExt = $
".{outputFormat}";
1091 var optimizedName = $
"{baseName}{outputExt}";
1092 var optimizedPath = Path.Combine(destinationDirectory, optimizedName);
1093 var tempOptimizedPath = Path.Combine(destinationDirectory, $
"{baseName}.optimized.tmp{outputExt}");
1094 var result = await
_imageOptimization.OptimizeAsync(tempPath, tempOptimizedPath, policy, ct).ConfigureAwait(
false);
1095 if (result.Succeeded && File.Exists(tempOptimizedPath))
1097 File.Move(tempOptimizedPath, optimizedPath, overwrite:
true);
1098 File.Delete(tempPath);
1099 return optimizedName;
1102 if (File.Exists(tempOptimizedPath))
1104 File.Delete(tempOptimizedPath);
1108 var fallbackName = $
"{baseName}{originalExt}";
1109 var fallbackPath = Path.Combine(destinationDirectory, fallbackName);
1110 File.Move(tempPath, fallbackPath, overwrite:
true);
1111 return fallbackName;
1183 var ext = Path.GetExtension(file.Name).ToLowerInvariant();
1185 throw new InvalidOperationException($
"허용되지 않는 파일 형식입니다: {ext}");
1187 throw new InvalidOperationException(
"이미지 파일 크기는 200MB 이하여야 합니다.");
1216 var normalized = outputFormat?.Trim().TrimStart(
'.').ToLowerInvariant();
1217 return string.IsNullOrWhiteSpace(normalized) ?
"webp" : normalized;
1246 var ext = Path.GetExtension(originalName).ToLowerInvariant();
1247 return $
"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}{ext}";
long ImageOptimizedMaxStorageBytes
int ImageOptimizedMaxStorageMb
List< string > GalleryFileNames
async Task DeleteAsync(string slug, string fileName, CancellationToken ct=default)
readonly IImageOptimizationService _imageOptimization
string GetVideoUrl(string slug, string fileName)
string GetThumbUrl(string slug, string fileName)
static string UniqueFileName(string originalName)
string GalleryPath(string slug, string fileName)
async Task< string > UploadMusicAsync(string slug, IBrowserFile file, CancellationToken ct=default)
string GetMusicUrl(string slug, string fileName)
async Task< string > UploadVideoAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task< IReadOnlyList< PhotoInfo > > GetGalleryAsync(string slug, CancellationToken ct=default)
async Task DeleteVideoAsync(string slug, string fileName, CancellationToken ct=default)
const long MaxImageUploadBytes
async Task< string > SaveImageFileAsync(string slug, IBrowserFile file, string destinationDirectory, string baseName, EffectiveMediaPolicy policy, CancellationToken ct)
string GetRoadMapUrl(string slug, string fileName)
static void ValidateImageFile(IBrowserFile file)
async Task ValidateImageQuotaAsync(TenantConfig config, EffectiveMediaPolicy policy, IBrowserFile file, bool countGalleryImage, CancellationToken ct)
string ResolveWritableOutputFormat(string? preferredFormat, string? originalExtension)
readonly IMediaUsageQueryService _usageQuery
readonly IMediaQuotaPolicyResolver _policyResolver
static readonly string[] AllowedExts
string GetPhotoUrl(string slug, string fileName)
async Task< string > UploadOgImageAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task< string > UploadRoadMapAsync(string slug, IBrowserFile file, CancellationToken ct=default)
readonly ITenantStore _tenants
async Task< string > UploadAsync(string slug, IBrowserFile file, CancellationToken ct=default)
string GetHeroUrl(string slug, string fileName)
static string NormalizeOutputExtension(string? outputFormat)
LocalPhotoService(ITenantStore tenants, IMediaQuotaPolicyResolver policyResolver, IImageOptimizationService imageOptimization, IMediaUsageQueryService usageQuery)
async Task< string > UploadHeroAsync(string slug, IBrowserFile file, CancellationToken ct=default)