EditorImageService.cs 5.9 KB

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