feat(account): add deletion lifecycle
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
using JobTrackerApi.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record AccountOwnedFile(string Category, string ExportPath, string? SourcePath, byte[]? InlineBytes);
|
||||
public sealed record AccountOwnedFileInventoryResult(IReadOnlyList<AccountOwnedFile> Files, IReadOnlyList<string> Warnings);
|
||||
|
||||
public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths paths, IAttachmentStorage attachmentStorage)
|
||||
{
|
||||
public async Task<AccountOwnedFileInventoryResult> BuildAsync(string ownerUserId, CancellationToken cancellationToken, bool includeAccountExports = false)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId);
|
||||
var files = new List<AccountOwnedFile>();
|
||||
var warnings = new List<string>();
|
||||
var applicationIds = await db.JobApplications.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(item => item.OwnerUserId == ownerUserId)
|
||||
.Select(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var attachments = await db.Attachments.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(item => applicationIds.Contains(item.JobApplicationId))
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
AddPath(files, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath);
|
||||
}
|
||||
|
||||
var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(item => item.OwnerUserId == ownerUserId)
|
||||
.OrderBy(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var artifact in artifacts)
|
||||
{
|
||||
AddPath(files, warnings, artifact.StoragePath, $"files/cv-artifacts/{artifact.Id}/{SafeSegment(artifact.OriginalFileName)}", "cv-artifact", candidate => IsManagedPath(paths.CvArtifactsRoot, candidate));
|
||||
}
|
||||
|
||||
var avatar = await db.Users.AsNoTracking().Where(item => item.Id == ownerUserId).Select(item => item.AvatarImageDataUrl).FirstOrDefaultAsync(cancellationToken);
|
||||
AddAvatar(files, warnings, ownerUserId, avatar);
|
||||
AddDirectory(files, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv");
|
||||
AddDirectory(files, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export");
|
||||
if (includeAccountExports)
|
||||
AddDirectory(files, warnings, paths.GetOwnerAccountExportsRoot(ownerUserId), "files/account-exports", "account-export");
|
||||
return new AccountOwnedFileInventoryResult(files, warnings);
|
||||
}
|
||||
|
||||
private void AddAvatar(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string ownerUserId, string? storedAvatar)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storedAvatar)) return;
|
||||
if (storedAvatar.StartsWith("file:", StringComparison.Ordinal))
|
||||
{
|
||||
var path = storedAvatar[5..];
|
||||
var root = Path.Combine(paths.DataRoot, "Avatars", AppPaths.GetOwnerStorageKey(ownerUserId));
|
||||
AddPath(files, warnings, path, $"files/avatar/{SafeSegment(Path.GetFileName(path))}", "avatar", candidate => IsManagedPath(root, candidate));
|
||||
return;
|
||||
}
|
||||
if (storedAvatar.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var comma = storedAvatar.IndexOf(',');
|
||||
if (comma > 0 && storedAvatar[..comma].Contains(";base64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
try
|
||||
{
|
||||
files.Add(new AccountOwnedFile("avatar", "files/avatar/avatar", null, Convert.FromBase64String(storedAvatar[(comma + 1)..])));
|
||||
return;
|
||||
}
|
||||
catch (FormatException) { }
|
||||
}
|
||||
}
|
||||
warnings.Add("The profile avatar was stored in an unsupported format and could not be included.");
|
||||
}
|
||||
|
||||
private static void AddPath(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string sourcePath, string exportPath, string category, Func<string, bool> isManaged)
|
||||
{
|
||||
if (!isManaged(sourcePath))
|
||||
{
|
||||
warnings.Add($"Excluded unsafe {category} path for {exportPath}.");
|
||||
return;
|
||||
}
|
||||
if (!File.Exists(sourcePath))
|
||||
{
|
||||
warnings.Add($"Owned {category} file was unavailable: {exportPath}.");
|
||||
return;
|
||||
}
|
||||
files.Add(new AccountOwnedFile(category, exportPath, Path.GetFullPath(sourcePath), null));
|
||||
}
|
||||
|
||||
private static void AddDirectory(ICollection<AccountOwnedFile> files, ICollection<string> warnings, string root, string exportRoot, string category)
|
||||
{
|
||||
if (!Directory.Exists(root)) return;
|
||||
foreach (var path in Directory.EnumerateFiles(root, "*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
AttributesToSkip = FileAttributes.ReparsePoint,
|
||||
}))
|
||||
{
|
||||
var relative = Path.GetRelativePath(root, path);
|
||||
if (relative.StartsWith("..", StringComparison.Ordinal) || !IsManagedPath(root, path))
|
||||
{
|
||||
warnings.Add($"Excluded unsafe {category} path.");
|
||||
continue;
|
||||
}
|
||||
var exportPath = $"{exportRoot}/{string.Join('/', relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Select(SafeSegment))}";
|
||||
files.Add(new AccountOwnedFile(category, exportPath, Path.GetFullPath(path), null));
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsManagedPath(string root, string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path)) return false;
|
||||
try
|
||||
{
|
||||
var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
if (!fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, comparison)) return false;
|
||||
var current = Path.GetDirectoryName(fullPath);
|
||||
while (!string.IsNullOrWhiteSpace(current) && !string.Equals(current, fullRoot, comparison))
|
||||
{
|
||||
if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false;
|
||||
current = Path.GetDirectoryName(current);
|
||||
}
|
||||
return !File.Exists(fullPath) || (File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) == 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeSegment(string? value)
|
||||
{
|
||||
var candidate = Path.GetFileName(value ?? string.Empty).Trim();
|
||||
foreach (var invalid in Path.GetInvalidFileNameChars()) candidate = candidate.Replace(invalid, '_');
|
||||
if (candidate.Length > 120) candidate = candidate[..120];
|
||||
return string.IsNullOrWhiteSpace(candidate) || candidate is "." or ".." ? "file" : candidate;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user