feat(account): add deletion lifecycle
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user