Files
jobtrackingapp/JobTrackerApi/Services/AccountOwnedFileInventory.cs
T
cesnimda 842e793f69
CI and Deploy / test (pull_request) Successful in 5m18s
CI and Deploy / deploy (pull_request) Has been skipped
feat(account): add deletion lifecycle
2026-08-15 19:03:54 +02:00

140 lines
7.1 KiB
C#

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