feat/Update_Controllers_to_Allow_for_Premium_Membership
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user