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);
755 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false) ??
new TenantConfig { Slug = slug };
756 var policy = await
_policyResolver.ResolveAsync(config, ct).ConfigureAwait(
false);
759 var dir =
_tenants.GetTenantDataPath(slug);
760 Directory.CreateDirectory(dir);
762 var fileName = await
SaveImageFileAsync(slug, file, dir,
"og-thankyou", policy, ct).ConfigureAwait(
false);
763 config.ThankYouOgImageFileName = fileName;
764 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
817 public async Task<string>
UploadVideoAsync(
string slug, IBrowserFile file, CancellationToken ct =
default)
819 var ext = Path.GetExtension(file.Name).ToLowerInvariant();
820 if (ext is not (
".mp4" or
".webm" or
".mov" or
".m4v"))
821 throw new InvalidOperationException($
"허용되지 않는 동영상 형식입니다: {ext}. mp4/webm/mov 권장");
823 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false) ??
new TenantConfig { Slug = slug };
824 var policy = await
_policyResolver.ResolveAsync(config, ct).ConfigureAwait(
false);
826 var effectiveCount = policy.VideoMaxCount;
827 if (effectiveCount > 0 && config.VideoFileNames.Count >= effectiveCount)
828 throw new InvalidOperationException($
"동영상은 최대 {effectiveCount}개까지 업로드할 수 있습니다.");
830 var effectiveMb = policy.VideoMaxFileSizeMb;
831 var maxBytes = policy.VideoMaxFileSizeBytes;
833 if (file.Size > maxBytes)
834 throw new InvalidOperationException($
"동영상 파일은 {effectiveMb}MB 이하여야 합니다.");
836 if (policy.VideoMaxStorageMb > 0)
838 var usage = await
_usageQuery.GetTenantUsageAsync(config, ct).ConfigureAwait(
false);
839 if (usage.VideoBytes + file.Size > policy.VideoMaxStorageBytes)
841 throw new InvalidOperationException($
"동영상 저장 용량은 최대 {policy.VideoMaxStorageMb}MB까지 사용할 수 있습니다.");
845 var dir =
_tenants.GetTenantDataPath(slug);
846 Directory.CreateDirectory(dir);
848 var fileName = $
"video-{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid().ToString("N
")[..8]}{ext}";
849 var destPath = Path.Combine(dir, fileName);
850 await
using (var dest = File.Create(destPath))
851 await file.OpenReadStream(maxBytes, ct).CopyToAsync(dest, ct).ConfigureAwait(
false);
853 config.VideoFileNames.Add(fileName);
854 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
899 public async Task
DeleteVideoAsync(
string slug,
string fileName, CancellationToken ct =
default)
901 var path = Path.Combine(
_tenants.GetTenantDataPath(slug), fileName);
902 if (File.Exists(path)) File.Delete(path);
904 var config = await
_tenants.GetAsync(slug, ct).ConfigureAwait(
false);
905 if (config is
null)
return;
906 config.VideoFileNames.Remove(fileName);
907 await
_tenants.SaveAsync(config, ct).ConfigureAwait(
false);
943 $
"/wedding-data/{slug}/{Uri.EscapeDataString(fileName)}";
978 Path.Combine(
_tenants.GetTenantDataPath(slug),
"gallery", fileName);
1048 throw new InvalidOperationException($
"이미지는 최대 {policy.ImageMaxCount}장까지 업로드할 수 있습니다.");
1053 var usage = await
_usageQuery.GetTenantUsageAsync(config, ct).ConfigureAwait(
false);
1056 throw new InvalidOperationException($
"이미지 저장 용량은 최대 {policy.ImageOptimizedMaxStorageMb}MB까지 사용할 수 있습니다.");
1127 var root =
_tenants.GetTenantDataPath(slug);
1128 var originalDir = Path.Combine(root,
"original");
1129 var originalExt = Path.GetExtension(file.Name).ToLowerInvariant();
1130 var tempPath = Path.Combine(destinationDirectory, $
"{baseName}.upload{originalExt}");
1131 Directory.CreateDirectory(destinationDirectory);
1133 await
using (var dest = File.Create(tempPath))
1135 await file.OpenReadStream(
MaxImageUploadBytes, ct).CopyToAsync(dest, ct).ConfigureAwait(
false);
1140 Directory.CreateDirectory(originalDir);
1141 File.Copy(tempPath, Path.Combine(originalDir, $
"{baseName}{originalExt}"), overwrite:
true);
1145 if (!
string.IsNullOrWhiteSpace(outputFormat))
1147 var outputExt = $
".{outputFormat}";
1148 var optimizedName = $
"{baseName}{outputExt}";
1149 var optimizedPath = Path.Combine(destinationDirectory, optimizedName);
1150 var tempOptimizedPath = Path.Combine(destinationDirectory, $
"{baseName}.optimized.tmp{outputExt}");
1151 var result = await
_imageOptimization.OptimizeAsync(tempPath, tempOptimizedPath, policy, ct).ConfigureAwait(
false);
1152 if (result.Succeeded && File.Exists(tempOptimizedPath))
1154 File.Move(tempOptimizedPath, optimizedPath, overwrite:
true);
1155 File.Delete(tempPath);
1156 return optimizedName;
1159 if (File.Exists(tempOptimizedPath))
1161 File.Delete(tempOptimizedPath);
1165 var fallbackName = $
"{baseName}{originalExt}";
1166 var fallbackPath = Path.Combine(destinationDirectory, fallbackName);
1167 File.Move(tempPath, fallbackPath, overwrite:
true);
1168 return fallbackName;
1240 var ext = Path.GetExtension(file.Name).ToLowerInvariant();
1242 throw new InvalidOperationException($
"허용되지 않는 파일 형식입니다: {ext}");
1244 throw new InvalidOperationException(
"이미지 파일 크기는 200MB 이하여야 합니다.");
1273 var normalized = outputFormat?.Trim().TrimStart(
'.').ToLowerInvariant();
1274 return string.IsNullOrWhiteSpace(normalized) ?
"webp" : normalized;
1303 var ext = Path.GetExtension(originalName).ToLowerInvariant();
1304 return $
"{DateTime.Now:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}{ext}";
int ImageOptimizedMaxStorageMb
long ImageOptimizedMaxStorageBytes
List< string > GalleryFileNames
string ResolveWritableOutputFormat(string? preferredFormat, string? originalExtension)
async Task DeleteAsync(string slug, string fileName, CancellationToken ct=default)
string GetThumbUrl(string slug, string fileName)
string GalleryPath(string slug, string fileName)
LocalPhotoService(ITenantStore tenants, IMediaQuotaPolicyResolver policyResolver, IImageOptimizationService imageOptimization, IMediaUsageQueryService usageQuery)
async Task< string > UploadAsync(string slug, IBrowserFile file, CancellationToken ct=default)
string GetRoadMapUrl(string slug, string fileName)
string GetHeroUrl(string slug, string fileName)
string GetMusicUrl(string slug, string fileName)
static string UniqueFileName(string originalName)
readonly IImageOptimizationService _imageOptimization
string GetVideoUrl(string slug, string fileName)
async Task< string > UploadMusicAsync(string slug, IBrowserFile file, CancellationToken ct=default)
static string NormalizeOutputExtension(string? outputFormat)
async Task< string > UploadOgImageAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task< string > UploadThankYouOgImageAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task< string > UploadRoadMapAsync(string slug, IBrowserFile file, CancellationToken ct=default)
async Task ValidateImageQuotaAsync(TenantConfig config, EffectiveMediaPolicy policy, IBrowserFile file, bool countGalleryImage, CancellationToken ct)
async Task< IReadOnlyList< PhotoInfo > > GetGalleryAsync(string slug, CancellationToken ct=default)
readonly ITenantStore _tenants
readonly IMediaUsageQueryService _usageQuery
string GetPhotoUrl(string slug, string fileName)
readonly IMediaQuotaPolicyResolver _policyResolver
async Task< string > UploadVideoAsync(string slug, IBrowserFile file, CancellationToken ct=default)
static void ValidateImageFile(IBrowserFile file)
static readonly string[] AllowedExts
const long MaxImageUploadBytes
async Task DeleteVideoAsync(string slug, string fileName, CancellationToken ct=default)
async Task< string > SaveImageFileAsync(string slug, IBrowserFile file, string destinationDirectory, string baseName, EffectiveMediaPolicy policy, CancellationToken ct)
async Task< string > UploadHeroAsync(string slug, IBrowserFile file, CancellationToken ct=default)