EditorImageService.cs 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. using Microsoft.AspNetCore.Hosting;
  2. using Microsoft.Extensions.Logging;
  3. using Microsoft.Extensions.Options;
  4. using SharedKernel.Storage;
  5. using System.Text.RegularExpressions;
  6. namespace Infrastructure.Storage;
  7. public class EditorImageService : IEditorImageService
  8. {
  9. private readonly IFileStorage _storage;
  10. private readonly string _basePath;
  11. private readonly ILogger<EditorImageService> _log;
  12. // src="..."/src='...' 모두 커버 (단순 HTML 정규식)
  13. private static readonly Regex ImgSrcRegex = new Regex(@"<img\b[^>]*?\bsrc\s*=\s*(?:""(?<src>[^""]+)""|'(?<src>[^']+)')[^>]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
  14. // data:image/png;base64,AAAA...
  15. private static readonly Regex DataImageRegex = new Regex(@"^data:image/(?<ext>[a-zA-Z0-9.+-]+);base64,(?<data>[a-zA-Z0-9+/=\s]+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
  16. public EditorImageService(IFileStorage storage, IWebHostEnvironment env, IOptions<StorageOptions> opts, ILogger<EditorImageService> log)
  17. {
  18. _storage = storage;
  19. // StorageOptions.BasePath 가 설정돼있으면 외부 경로 사용 (deploy 영향 X)
  20. _basePath = string.IsNullOrWhiteSpace(opts.Value.BasePath) ? env.WebRootPath : opts.Value.BasePath;
  21. _log = log;
  22. }
  23. public async Task<string?> UploadAsync(string? html, FileStoragePath path, CancellationToken ct = default)
  24. {
  25. html ??= string.Empty;
  26. var basePath = path with { Target = UploadTarget.Editor };
  27. var baseUrlPrefix = "/" + basePath.ToRelativePath(); // "/editors/board/10/3"
  28. var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  29. var rewritten = ImgSrcRegex.Replace(html, match =>
  30. {
  31. var src = match.Groups["src"].Value;
  32. return match.Value;
  33. });
  34. foreach (Match m in ImgSrcRegex.Matches(rewritten))
  35. {
  36. var src = m.Groups["src"].Value;
  37. if (src.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase))
  38. {
  39. try
  40. {
  41. var dm = DataImageRegex.Match(src);
  42. if (!dm.Success)
  43. {
  44. continue;
  45. }
  46. var ext = FileUtils.NormalizeExtension(dm.Groups["ext"].Value);
  47. var bytes = Convert.FromBase64String(dm.Groups["data"].Value);
  48. var result = await _storage.SaveBytesAsync(bytes, ext, basePath, ct);
  49. if (result != null)
  50. {
  51. rewritten = rewritten.Replace(src, result.Url);
  52. used.Add(result.Url);
  53. }
  54. } catch(Exception e) {
  55. _log.LogWarning(e, "Failed to persist data:image");
  56. }
  57. } else if (FileUtils.IsLocalEditorUrl(src, baseUrlPrefix)) {
  58. used.Add(src.Replace("\\", "/"));
  59. }
  60. }
  61. /// 미사용 파일 정리: basePath 폴더 내 파일만 대상으로
  62. CleanupUnusedFiles(basePath, used);
  63. return rewritten;
  64. }
  65. public async Task CleanupByContentAsync(string? html, CancellationToken ct = default)
  66. {
  67. if (string.IsNullOrWhiteSpace(html))
  68. {
  69. return;
  70. }
  71. foreach (Match m in ImgSrcRegex.Matches(html))
  72. {
  73. var src = m.Groups["src"].Value;
  74. if (!FileUtils.IsLocalPublicUrl(src)) {
  75. continue;
  76. }
  77. try {
  78. _storage.DeleteByUrl(src);
  79. } catch (Exception e) {
  80. _log.LogWarning(e, "CleanupByContent delete failed: {0}", src);
  81. }
  82. }
  83. await Task.CompletedTask;
  84. }
  85. public void CleanupFolder(FileStoragePath path)
  86. {
  87. var basePath = path with { Target = UploadTarget.Editor };
  88. try
  89. {
  90. var dir = GetAbsoluteDirectory(basePath);
  91. if (!Directory.Exists(dir))
  92. {
  93. return;
  94. }
  95. Directory.Delete(dir, recursive: true);
  96. }
  97. catch (Exception e)
  98. {
  99. _log.LogWarning(e, "CleanupFolder failed: {0}", basePath);
  100. }
  101. }
  102. private void CleanupUnusedFiles(FileStoragePath basePath, HashSet<string> usedUrls)
  103. {
  104. string dir;
  105. try {
  106. dir = GetAbsoluteDirectory(basePath);
  107. } catch (Exception e) {
  108. _log.LogWarning(e, "삭제 경로가 존재하지 않습니다.");
  109. return;
  110. }
  111. if (!Directory.Exists(dir))
  112. {
  113. return;
  114. }
  115. foreach (var file in Directory.GetFiles(dir))
  116. {
  117. // file => "/editors/....../filename.ext" 형태 URL로 환산
  118. var rel = Path.GetRelativePath(_basePath, file).Replace("\\", "/");
  119. var url = "/" + rel;
  120. if (usedUrls.Contains(url))
  121. {
  122. continue;
  123. }
  124. try {
  125. File.Delete(file);
  126. }
  127. catch (Exception e) {
  128. _log.LogWarning(e, "Failed deleting unused file: {File}", file);
  129. }
  130. }
  131. }
  132. private string GetAbsoluteDirectory(FileStoragePath path)
  133. {
  134. var relative = path.ToRelativePath();
  135. var webRoot = Path.GetFullPath(_basePath);
  136. var fullPath = Path.GetFullPath(Path.Combine(_basePath, relative));
  137. if (!fullPath.StartsWith(webRoot, StringComparison.OrdinalIgnoreCase))
  138. {
  139. throw new UnauthorizedAccessException("경로를 찾을 수 없습니다.");
  140. }
  141. return fullPath;
  142. }
  143. }