using JobTrackerApi.Data; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; public sealed record AttachmentReconciliationResult(int Promoted, int Restored, int Purged, int Missing, int UnknownOrphans, int UnsafePaths, int Failures); public interface IAttachmentStorage { string CreateFinalPath(int jobId, string storedFileName); string StagePath(string finalPath); string DeletePath(string finalPath); bool IsManagedPath(string path); Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken); void Promote(string stagePath, string finalPath); void Quarantine(string finalPath, string deletePath); void Restore(string deletePath, string finalPath); void Purge(string path); Task ReconcileAsync(JobTrackerContext db, CancellationToken cancellationToken); } public sealed class AttachmentStorage : IAttachmentStorage { private const string UploadSuffix = ".uploading"; private const string DeleteSuffix = ".deleting"; private readonly string _root; private readonly StringComparison _comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; private readonly StringComparer _comparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; public AttachmentStorage(AppPaths paths) { _root = Path.GetFullPath(paths.AttachmentsRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); } public string CreateFinalPath(int jobId, string storedFileName) { var folder = Path.Combine(_root, jobId.ToString(System.Globalization.CultureInfo.InvariantCulture)); Directory.CreateDirectory(folder); EnsureManagedPath(folder, allowMissingLeaf: false); return EnsureManagedPath(Path.Combine(folder, Path.GetFileName(storedFileName)), allowMissingLeaf: true); } public string StagePath(string finalPath) => EnsureManagedPath(finalPath, true) + UploadSuffix; public string DeletePath(string finalPath) => EnsureManagedPath(finalPath, true) + DeleteSuffix; public bool IsManagedPath(string path) { try { EnsureManagedPath(path, allowMissingLeaf: true); return true; } catch { return false; } } public async Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken) { var safeStage = EnsureManagedPath(stagePath, allowMissingLeaf: true); var created = false; try { await using var stream = new FileStream(safeStage, FileMode.CreateNew, FileAccess.Write, FileShare.None); created = true; await file.CopyToAsync(stream, cancellationToken); await stream.FlushAsync(cancellationToken); } catch { if (created) try { File.Delete(safeStage); } catch { } throw; } } public void Promote(string stagePath, string finalPath) => File.Move(EnsureManagedPath(stagePath, false), EnsureManagedPath(finalPath, true), overwrite: false); public void Quarantine(string finalPath, string deletePath) => File.Move(EnsureManagedPath(finalPath, false), EnsureManagedPath(deletePath, true), overwrite: false); public void Restore(string deletePath, string finalPath) => File.Move(EnsureManagedPath(deletePath, false), EnsureManagedPath(finalPath, true), overwrite: false); public void Purge(string path) { var safePath = EnsureManagedPath(path, allowMissingLeaf: true); if (File.Exists(safePath)) File.Delete(safePath); } public async Task ReconcileAsync(JobTrackerContext db, CancellationToken cancellationToken) { var storedPaths = await db.Attachments.IgnoreQueryFilters().AsNoTracking() .Select(x => x.FilePath) .ToListAsync(cancellationToken); var known = new HashSet(storedPaths.Where(IsManagedPath).Select(Path.GetFullPath), _comparer); var unsafePaths = storedPaths.Count(path => !IsManagedPath(path)); var promoted = 0; var restored = 0; var purged = 0; var failures = 0; foreach (var staged in EnumerateStateFiles(UploadSuffix).ToList()) { try { var finalPath = staged[..^UploadSuffix.Length]; if (known.Contains(finalPath) && !File.Exists(finalPath)) { Promote(staged, finalPath); promoted++; } else { Purge(staged); purged++; } } catch { failures++; } } foreach (var deleting in EnumerateStateFiles(DeleteSuffix).ToList()) { try { var finalPath = deleting[..^DeleteSuffix.Length]; if (known.Contains(finalPath) && !File.Exists(finalPath)) { Restore(deleting, finalPath); restored++; } else { Purge(deleting); purged++; } } catch { failures++; } } var missing = known.Count(path => !File.Exists(path)); var unknownOrphans = EnumerateFiles() .Count(path => !path.EndsWith(UploadSuffix, _comparison) && !path.EndsWith(DeleteSuffix, _comparison) && !known.Contains(path)); return new AttachmentReconciliationResult(promoted, restored, purged, missing, unknownOrphans, unsafePaths, failures); } private IEnumerable EnumerateStateFiles(string suffix) => EnumerateFiles().Where(path => path.EndsWith(suffix, _comparison)); private IEnumerable EnumerateFiles() { if (!Directory.Exists(_root)) return Array.Empty(); return Directory.EnumerateFiles(_root, "*", new EnumerationOptions { RecurseSubdirectories = true, IgnoreInaccessible = true, AttributesToSkip = FileAttributes.ReparsePoint, }).Select(Path.GetFullPath); } private string EnsureManagedPath(string path, bool allowMissingLeaf) { if (string.IsNullOrWhiteSpace(path)) throw new InvalidOperationException("Attachment path is empty."); var fullPath = Path.GetFullPath(path); var rootPrefix = _root + Path.DirectorySeparatorChar; if (!fullPath.StartsWith(rootPrefix, _comparison)) throw new InvalidOperationException("Attachment path is outside the configured storage root."); var current = Directory.Exists(fullPath) ? fullPath : Path.GetDirectoryName(fullPath); while (!string.IsNullOrWhiteSpace(current) && !string.Equals(current, _root, _comparison)) { if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) throw new InvalidOperationException("Attachment path crosses a symbolic link or junction."); current = Path.GetDirectoryName(current); } if (!allowMissingLeaf && !File.Exists(fullPath) && !Directory.Exists(fullPath)) throw new FileNotFoundException("Attachment storage path does not exist."); if (File.Exists(fullPath) && (File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) != 0) throw new InvalidOperationException("Attachment file is a symbolic link."); return fullPath; } }