namespace PlotLine.Services; public interface IHardDeleteFileCleanupService { HardDeleteFileCleanupResult DeleteFiles(string operationName, int ownerId, IReadOnlyList storagePaths); } public sealed record HardDeleteFileCleanupResult(int Deleted, int Missing, int Failed); public sealed class HardDeleteFileCleanupService( IWebHostEnvironment environment, ILogger logger) : IHardDeleteFileCleanupService { public HardDeleteFileCleanupResult DeleteFiles(string operationName, int ownerId, IReadOnlyList storagePaths) { var deleted = 0; var missing = 0; var failed = 0; var deletedDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var storagePath in storagePaths) { var absolutePath = ResolveUploadPath(storagePath); if (absolutePath is null) { logger.LogWarning("Hard delete skipped a non-local upload path during {OperationName} for owner {OwnerID}.", operationName, ownerId); continue; } if (!File.Exists(absolutePath)) { missing++; logger.LogInformation("Hard delete file cleanup found a missing file during {OperationName} for owner {OwnerID}.", operationName, ownerId); continue; } try { File.Delete(absolutePath); deleted++; logger.LogInformation("Hard delete removed an uploaded file during {OperationName} for owner {OwnerID}.", operationName, ownerId); var directory = Path.GetDirectoryName(absolutePath); if (!string.IsNullOrWhiteSpace(directory)) { deletedDirectories.Add(directory); } } catch (Exception ex) { failed++; logger.LogWarning(ex, "Hard delete could not remove an uploaded file during {OperationName} for owner {OwnerID}.", operationName, ownerId); } } foreach (var directory in deletedDirectories.OrderByDescending(x => x.Length)) { TryDeleteEmptyUploadDirectory(operationName, ownerId, directory); } return new HardDeleteFileCleanupResult(deleted, missing, failed); } private string? ResolveUploadPath(string storagePath) { if (string.IsNullOrWhiteSpace(storagePath) || storagePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || storagePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return null; } var normalised = storagePath.Replace('\\', '/').TrimStart('/'); if (!normalised.StartsWith("uploads/", StringComparison.OrdinalIgnoreCase)) { return null; } var uploadsRoot = Path.GetFullPath(Path.Combine(environment.WebRootPath, "uploads")); var absolutePath = Path.GetFullPath(Path.Combine(environment.WebRootPath, normalised.Replace('/', Path.DirectorySeparatorChar))); return absolutePath.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase) ? absolutePath : null; } private void TryDeleteEmptyUploadDirectory(string operationName, int ownerId, string directory) { var uploadsRoot = Path.GetFullPath(Path.Combine(environment.WebRootPath, "uploads")); var fullDirectory = Path.GetFullPath(directory); if (!fullDirectory.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase) || string.Equals(fullDirectory, uploadsRoot, StringComparison.OrdinalIgnoreCase)) { return; } try { if (Directory.Exists(fullDirectory) && !Directory.EnumerateFileSystemEntries(fullDirectory).Any()) { Directory.Delete(fullDirectory); logger.LogInformation("Hard delete removed an empty upload directory during {OperationName} for owner {OwnerID}.", operationName, ownerId); } } catch (Exception ex) { logger.LogWarning(ex, "Hard delete could not remove an empty upload directory during {OperationName} for owner {OwnerID}.", operationName, ownerId); } } }