EditorImageService.cs 5.9 KB

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