Files
jobtrackingapp/JobTrackerApi/Services/AttachmentStorage.cs
T

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;
}
}