feat(account): add deletion lifecycle
CI and Deploy / test (pull_request) Successful in 5m18s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 19:03:54 +02:00
parent 1ec9dd037e
commit 842e793f69
28 changed files with 4418 additions and 129 deletions
@@ -14,7 +14,7 @@ public sealed record AccountDataExportArtifact(string StoragePath, string Downlo
public sealed class AccountDataExportService(
JobTrackerContext db,
AppPaths paths,
IAttachmentStorage attachmentStorage,
AccountOwnedFileInventory fileInventory,
TimeProvider timeProvider)
{
private const string SchemaVersion = "jobtracker.user-export.v1";
@@ -32,8 +32,7 @@ public sealed class AccountDataExportService(
var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken)
?? throw new InvalidOperationException("The account no longer exists.");
var generatedAt = timeProvider.GetUtcNow();
var ownerKey = AppPaths.GetOwnerStorageKey(ownerUserId);
var outputRoot = Path.Combine(paths.DataRoot, "AccountExports", ownerKey);
var outputRoot = paths.GetOwnerAccountExportsRoot(ownerUserId);
Directory.CreateDirectory(outputRoot);
var outputPath = Path.Combine(outputRoot, $"{Guid.NewGuid():N}.zip");
var warnings = new List<string>();
@@ -255,18 +254,15 @@ public sealed class AccountDataExportService(
TrustedDevices = trustedDevices,
}, sessions.Count + trustedDevices.Count + recoveryCodeCount);
foreach (var attachment in attachments)
var ownedFiles = await fileInventory.BuildAsync(ownerUserId, cancellationToken);
warnings.AddRange(ownedFiles.Warnings);
foreach (var ownedFile in ownedFiles.Files)
{
await AddOwnedFileAsync(archive, entries, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath, cancellationToken);
if (ownedFile.InlineBytes is not null)
await AddBytesAsync(archive, entries, ownedFile.ExportPath, ownedFile.InlineBytes, ownedFile.Category, 1, cancellationToken);
else
await AddFileAsync(archive, entries, ownedFile.SourcePath!, ownedFile.ExportPath, ownedFile.Category, cancellationToken);
}
foreach (var artifact in artifacts)
{
await AddOwnedFileAsync(archive, entries, warnings, artifact.StoragePath, $"files/cv-artifacts/{artifact.Id}/{SafeSegment(artifact.OriginalFileName)}", "cv-artifact", path => IsManagedPath(paths.CvArtifactsRoot, path), cancellationToken);
}
await AddAvatarAsync(archive, entries, warnings, paths, ownerUserId, user.AvatarImageDataUrl, cancellationToken);
await AddDirectoryAsync(archive, entries, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv", cancellationToken);
await AddDirectoryAsync(archive, entries, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export", cancellationToken);
const string readme = """
Jobjakt readable account export
@@ -317,77 +313,6 @@ public sealed class AccountDataExportService(
}
}
private static async Task AddOwnedFileAsync(
ZipArchive archive,
ICollection<ManifestEntry> entries,
ICollection<string> warnings,
string sourcePath,
string entryName,
string category,
Func<string, bool> isManaged,
CancellationToken cancellationToken)
{
if (!isManaged(sourcePath))
{
warnings.Add($"Excluded unsafe {category} path for {entryName}.");
return;
}
if (!File.Exists(sourcePath))
{
warnings.Add($"Owned {category} file was unavailable: {entryName}.");
return;
}
await AddFileAsync(archive, entries, sourcePath, entryName, category, cancellationToken);
}
private static async Task AddDirectoryAsync(ZipArchive archive, ICollection<ManifestEntry> entries, ICollection<string> warnings, string root, string entryRoot, string category, CancellationToken cancellationToken)
{
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))
{
warnings.Add($"Excluded unsafe {category} path.");
continue;
}
var entryName = $"{entryRoot}/{string.Join('/', relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Select(SafeSegment))}";
await AddFileAsync(archive, entries, path, entryName, category, cancellationToken);
}
}
private static async Task AddAvatarAsync(ZipArchive archive, ICollection<ManifestEntry> entries, ICollection<string> warnings, AppPaths paths, string ownerUserId, string? storedAvatar, CancellationToken cancellationToken)
{
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));
await AddOwnedFileAsync(archive, entries, warnings, path, $"files/avatar/{SafeSegment(Path.GetFileName(path))}", "avatar", candidate => IsManagedPath(root, candidate), cancellationToken);
return;
}
if (storedAvatar.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
var comma = storedAvatar.IndexOf(',');
if (comma > 0 && storedAvatar[..comma].Contains(";base64", StringComparison.OrdinalIgnoreCase))
{
try
{
var bytes = Convert.FromBase64String(storedAvatar[(comma + 1)..]);
await AddBytesAsync(archive, entries, "files/avatar/avatar", bytes, "avatar", 1, cancellationToken);
return;
}
catch (FormatException) { }
}
}
warnings.Add("The profile avatar was stored in an unsupported format and could not be included.");
}
private static async Task AddFileAsync(ZipArchive archive, ICollection<ManifestEntry> entries, string sourcePath, string entryName, string category, CancellationToken cancellationToken)
{
await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
@@ -407,36 +332,5 @@ public sealed class AccountDataExportService(
entries.Add(new ManifestEntry(entry.FullName, bytes.LongLength, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), category, itemCount));
}
private 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;
}
private sealed record ManifestEntry(string Path, long Bytes, string Sha256, string Category, int ItemCount);
}