using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Logging; using SharedKernel.Storage; using System.Text.RegularExpressions; namespace Infrastructure.Storage { public class EditorImageService : IEditorImageService { private readonly IFileStorage _storage; private readonly IWebHostEnvironment _env; private readonly ILogger _log; // src="..."/src='...' 모두 커버 (단순 HTML 정규식) private static readonly Regex ImgSrcRegex = new Regex(@"]*?\bsrc\s*=\s*(?:""(?[^""]+)""|'(?[^']+)')[^>]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase); // data:image/png;base64,AAAA... private static readonly Regex DataImageRegex = new Regex(@"^data:image/(?[a-zA-Z0-9.+-]+);base64,(?[a-zA-Z0-9+/=\s]+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); public EditorImageService(IFileStorage storage, IWebHostEnvironment env, ILogger log) { _storage = storage; _env = env; _log = log; } public async Task UploadAsync(string? html, FileStoragePath path, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(html)) { return html; } var basePath = path with { Target = UploadTarget.Editor }; var baseUrlPrefix = "/" + basePath.ToRelativePath(); // "/editors/board/10/3" var used = new HashSet(StringComparer.OrdinalIgnoreCase); var rewritten = ImgSrcRegex.Replace(html, match => { var src = match.Groups["src"].Value; return match.Value; }); foreach (Match m in ImgSrcRegex.Matches(rewritten)) { var src = m.Groups["src"].Value; if (src.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase)) { try { var dm = DataImageRegex.Match(src); if (!dm.Success) { continue; } var ext = FileUtils.NormalizeExtension(dm.Groups["ext"].Value); var bytes = Convert.FromBase64String(dm.Groups["data"].Value); var result = await _storage.SaveBytesAsync(bytes, ext, basePath, ct); if (result != null) { rewritten = rewritten.Replace(src, result.Url); used.Add(result.Url); } } catch(Exception e) { _log.LogWarning(e, "Failed to persist data:image"); } } else if (FileUtils.IsLocalEditorUrl(src, baseUrlPrefix)) { used.Add(src.Replace("\\", "/")); } } /// 미사용 파일 정리: basePath 폴더 내 파일만 대상으로 CleanupUnusedFiles(basePath, used); return rewritten; } public async Task CleanupByContentAsync(string? html, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(html)) { return; } foreach (Match m in ImgSrcRegex.Matches(html)) { var src = m.Groups["src"].Value; if (!FileUtils.IsLocalPublicUrl(src)) { continue; } try { _storage.DeleteByUrl(src); } catch (Exception e) { _log.LogWarning(e, "CleanupByContent delete failed: {0}", src); } } await Task.CompletedTask; } public void CleanupFolder(FileStoragePath path) { var basePath = path with { Target = UploadTarget.Editor }; try { var dir = GetAbsoluteDirectory(basePath); if (!Directory.Exists(dir)) { return; } Directory.Delete(dir, recursive: true); } catch (Exception e) { _log.LogWarning(e, "CleanupFolder failed: {0}", basePath); } } private void CleanupUnusedFiles(FileStoragePath basePath, HashSet usedUrls) { string dir; try { dir = GetAbsoluteDirectory(basePath); } catch (Exception e) { _log.LogWarning(e, "삭제 경로가 존재하지 않습니다."); return; } if (!Directory.Exists(dir)) { return; } foreach (var file in Directory.GetFiles(dir)) { // file => "/editors/....../filename.ext" 형태 URL로 환산 var rel = Path.GetRelativePath(_env.WebRootPath, file).Replace("\\", "/"); var url = "/" + rel; if (usedUrls.Contains(url)) { continue; } try { File.Delete(file); } catch (Exception e) { _log.LogWarning(e, "Failed deleting unused file: {File}", file); } } } private string GetAbsoluteDirectory(FileStoragePath path) { var relative = path.ToRelativePath(); var webRoot = Path.GetFullPath(_env.WebRootPath); var fullPath = Path.GetFullPath(Path.Combine(_env.WebRootPath, relative)); if (!fullPath.StartsWith(webRoot, StringComparison.OrdinalIgnoreCase)) { throw new UnauthorizedAccessException("경로를 찾을 수 없습니다."); } return fullPath; } } }