193 lines
7.7 KiB
C#
193 lines
7.7 KiB
C#
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<AttachmentReconciliationResult> 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<AttachmentReconciliationResult> ReconcileAsync(JobTrackerContext db, CancellationToken cancellationToken)
|
|
{
|
|
var storedPaths = await db.Attachments.IgnoreQueryFilters().AsNoTracking()
|
|
.Select(x => x.FilePath)
|
|
.ToListAsync(cancellationToken);
|
|
var known = new HashSet<string>(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<string> EnumerateStateFiles(string suffix) =>
|
|
EnumerateFiles().Where(path => path.EndsWith(suffix, _comparison));
|
|
|
|
private IEnumerable<string> EnumerateFiles()
|
|
{
|
|
if (!Directory.Exists(_root)) return Array.Empty<string>();
|
|
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;
|
|
}
|
|
}
|