feat(export): add readable account archive
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
using System.Text;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace JobTrackerApi.Controllers
|
||||
{
|
||||
@@ -12,10 +15,43 @@ namespace JobTrackerApi.Controllers
|
||||
public class ExportController : ControllerBase
|
||||
{
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly AccountDataExportService? _accountExport;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
public ExportController(JobTrackerContext db)
|
||||
public ExportController(JobTrackerContext db, AccountDataExportService? accountExport = null, TimeProvider? timeProvider = null)
|
||||
{
|
||||
_db = db;
|
||||
_accountExport = accountExport;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
[HttpPost("account")]
|
||||
[EnableRateLimiting("account-data")]
|
||||
public async Task<IActionResult> ExportAccount(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = LocalAuthIdentity.GetRequiredUserId(User);
|
||||
if (ownerUserId is null) return Unauthorized();
|
||||
if (_accountExport is null) throw new InvalidOperationException("Account export is not configured.");
|
||||
|
||||
var sessionId = User.FindFirstValue("sid");
|
||||
var recentCutoff = _timeProvider.GetUtcNow().AddMinutes(-15);
|
||||
var currentSession = string.IsNullOrWhiteSpace(sessionId)
|
||||
? null
|
||||
: await _db.UserSessions.IgnoreQueryFilters().AsNoTracking()
|
||||
.FirstOrDefaultAsync(item => item.Id == sessionId && item.UserId == ownerUserId, cancellationToken);
|
||||
var recentlyAuthenticated = currentSession is { RevokedAtUtc: null } && currentSession.CreatedAtUtc >= recentCutoff;
|
||||
if (!recentlyAuthenticated)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetails
|
||||
{
|
||||
Title = "Recent sign-in required",
|
||||
Detail = "Sign in again before downloading a complete account export.",
|
||||
});
|
||||
}
|
||||
|
||||
var artifact = await _accountExport.CreateAsync(ownerUserId, cancellationToken);
|
||||
var stream = new FileStream(artifact.StoragePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.DeleteOnClose);
|
||||
return File(stream, "application/zip", artifact.DownloadFileName);
|
||||
}
|
||||
|
||||
[HttpGet("jobs")]
|
||||
|
||||
@@ -46,6 +46,7 @@ builder.Services.AddSingleton<BackgroundTenantRunner>();
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddScoped<UserOperationStore>();
|
||||
builder.Services.AddScoped<EmailSendAttemptStore>();
|
||||
builder.Services.AddScoped<AccountDataExportService>();
|
||||
builder.Services.AddScoped<AiOperationAdmission>();
|
||||
builder.Services.AddScoped<StrategySnapshotService>();
|
||||
builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHandler>();
|
||||
@@ -452,6 +453,17 @@ builder.Services.AddRateLimiter(options =>
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
|
||||
options.AddPolicy("account-data", context =>
|
||||
RateLimitPartition.GetFixedWindowLimiter(
|
||||
partitionKey: $"account-data:{context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? context.User.FindFirst("sub")?.Value ?? context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 2,
|
||||
Window = TimeSpan.FromHours(1),
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 0,
|
||||
}));
|
||||
|
||||
// Brute-forcing a 6-digit TOTP code (1e6 space) is far more feasible than a password, so
|
||||
// this gets a tighter window than auth-login.
|
||||
options.AddPolicy("auth-2fa-challenge", context =>
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record AccountDataExportArtifact(string StoragePath, string DownloadFileName);
|
||||
|
||||
public sealed class AccountDataExportService(
|
||||
JobTrackerContext db,
|
||||
AppPaths paths,
|
||||
IAttachmentStorage attachmentStorage,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
private const string SchemaVersion = "jobtracker.user-export.v1";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
ReferenceHandler = ReferenceHandler.IgnoreCycles,
|
||||
};
|
||||
|
||||
public async Task<AccountDataExportArtifact> CreateAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId);
|
||||
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);
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
var outputPath = Path.Combine(outputRoot, $"{Guid.NewGuid():N}.zip");
|
||||
var warnings = new List<string>();
|
||||
var entries = new List<ManifestEntry>();
|
||||
|
||||
try
|
||||
{
|
||||
await using (var output = new FileStream(outputPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan))
|
||||
using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: false, Encoding.UTF8))
|
||||
{
|
||||
async Task AddJsonAsync(string entryName, object value, int itemCount)
|
||||
{
|
||||
var bytes = JsonSerializer.SerializeToUtf8Bytes(value, value.GetType(), JsonOptions);
|
||||
await AddBytesAsync(archive, entries, entryName, bytes, "data", itemCount, cancellationToken);
|
||||
}
|
||||
|
||||
var roles = await (from userRole in db.UserRoles.AsNoTracking()
|
||||
join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||
where userRole.UserId == ownerUserId
|
||||
orderby role.Name
|
||||
select role.Name!).ToListAsync(cancellationToken);
|
||||
var claims = await db.UserClaims.AsNoTracking()
|
||||
.Where(item => item.UserId == ownerUserId)
|
||||
.OrderBy(item => item.ClaimType)
|
||||
.Select(item => new { item.ClaimType, item.ClaimValue })
|
||||
.ToListAsync(cancellationToken);
|
||||
var logins = await db.UserLogins.AsNoTracking()
|
||||
.Where(item => item.UserId == ownerUserId)
|
||||
.OrderBy(item => item.LoginProvider)
|
||||
.Select(item => new { item.LoginProvider, item.ProviderDisplayName })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
await AddJsonAsync("data/account.json", new
|
||||
{
|
||||
user.Id,
|
||||
user.UserName,
|
||||
user.Email,
|
||||
user.EmailConfirmed,
|
||||
user.PhoneNumber,
|
||||
user.PhoneNumberConfirmed,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.DisplayName,
|
||||
user.PendingEmail,
|
||||
user.PendingEmailRequestedAtUtc,
|
||||
user.ProfileCvText,
|
||||
user.ProfileCvStructureJson,
|
||||
user.CurrentCvUploadArtifactId,
|
||||
user.CurrentCvExtractionRunId,
|
||||
user.CurrentCvProfileVersion,
|
||||
user.GoogleSubject,
|
||||
user.GoogleEmail,
|
||||
user.GoogleLinkedAt,
|
||||
user.MicrosoftSubject,
|
||||
user.MicrosoftTenantId,
|
||||
user.MicrosoftObjectId,
|
||||
user.MicrosoftEmail,
|
||||
user.MicrosoftLinkedAt,
|
||||
user.TotpEnabledAtUtc,
|
||||
user.StripeCustomerId,
|
||||
user.StripeSubscriptionId,
|
||||
user.StripeSubscriptionStatus,
|
||||
user.StripeLastEventCreatedUtc,
|
||||
user.AiEnabled,
|
||||
user.ExternalAiProcessingAllowed,
|
||||
Roles = roles,
|
||||
Claims = claims,
|
||||
ExternalLogins = logins,
|
||||
}, 1);
|
||||
|
||||
var companies = await db.Companies.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var opportunities = await db.Jobs.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var applications = await db.JobApplications.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var applicationIds = applications.Select(item => item.Id).ToList();
|
||||
await AddJsonAsync("data/companies.json", companies, companies.Count);
|
||||
await AddJsonAsync("data/opportunities.json", opportunities, opportunities.Count);
|
||||
await AddJsonAsync("data/applications.json", applications, applications.Count);
|
||||
|
||||
var correspondence = await db.Correspondences.IgnoreQueryFilters().AsNoTracking().Where(item => applicationIds.Contains(item.JobApplicationId)).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var events = await db.JobEvents.IgnoreQueryFilters().AsNoTracking().Where(item => applicationIds.Contains(item.JobApplicationId)).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var attachments = await db.Attachments.IgnoreQueryFilters().AsNoTracking().Where(item => applicationIds.Contains(item.JobApplicationId)).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
await AddJsonAsync("data/correspondence.json", correspondence, correspondence.Count);
|
||||
await AddJsonAsync("data/job-events.json", events, events.Count);
|
||||
await AddJsonAsync("data/attachments.json", attachments.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.JobApplicationId,
|
||||
item.FileName,
|
||||
item.UploadDate,
|
||||
item.FileType,
|
||||
item.FileSize,
|
||||
item.Purpose,
|
||||
item.UseForAi,
|
||||
}).ToList(), attachments.Count);
|
||||
|
||||
var careerProfile = await db.CareerProfiles.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
|
||||
var careerProfileId = careerProfile?.Id;
|
||||
var careerVersions = await db.CareerProfileVersions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Version).ToListAsync(cancellationToken);
|
||||
var careerExperiences = await db.CareerExperiences.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
|
||||
var careerEducation = await db.CareerEducations.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
|
||||
var careerSkills = await db.CareerSkills.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
|
||||
var careerProjects = await db.CareerProjects.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
|
||||
var careerCertifications = await db.CareerCertifications.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
|
||||
var careerLanguages = await db.CareerLanguages.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
|
||||
await AddJsonAsync("data/career.json", new
|
||||
{
|
||||
Profile = careerProfile,
|
||||
Versions = careerVersions,
|
||||
Experiences = careerExperiences,
|
||||
Education = careerEducation,
|
||||
Skills = careerSkills,
|
||||
Projects = careerProjects,
|
||||
Certifications = careerCertifications,
|
||||
Languages = careerLanguages,
|
||||
}, (careerProfileId is null ? 0 : 1) + careerVersions.Count + careerExperiences.Count + careerEducation.Count + careerSkills.Count + careerProjects.Count + careerCertifications.Count + careerLanguages.Count);
|
||||
|
||||
var variants = await db.CvVariants.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var variantVersions = await db.CvVariantVersions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var extractionRuns = await db.CvExtractionRuns.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
await AddJsonAsync("data/cv.json", new
|
||||
{
|
||||
Variants = variants,
|
||||
VariantVersions = variantVersions,
|
||||
UploadArtifacts = artifacts.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.OriginalFileName,
|
||||
item.StoredFileName,
|
||||
item.MimeType,
|
||||
item.ByteSize,
|
||||
item.Sha256,
|
||||
item.UploadedAtUtc,
|
||||
}),
|
||||
ExtractionRuns = extractionRuns,
|
||||
}, variants.Count + variantVersions.Count + artifacts.Count + extractionRuns.Count);
|
||||
|
||||
var tailoredDrafts = await db.TailoredCvDrafts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var interviewNotes = await db.InterviewPrepNotes.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var aiNotes = await db.AiWorkspaceNotes.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var aiInteractions = await db.AiInteractions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var checklist = await db.ApplicationChecklistItems.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var coverLetters = await db.CoverLetterVersions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var interviewItems = await db.InterviewPrepItems.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var emailDrafts = await db.EmailDrafts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc).ToListAsync(cancellationToken);
|
||||
var emailAttempts = await db.EmailSendAttempts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc)
|
||||
.Select(item => new EmailSendAttemptExport(item.Id, item.JobApplicationId, item.Provider, item.ClientRequestId, item.Status, item.ProviderMessageId, item.FailureCategory, item.CreatedAtUtc, item.StartedAtUtc, item.CompletedAtUtc))
|
||||
.ToListAsync(cancellationToken);
|
||||
await AddJsonAsync("data/application-workspace.json", new
|
||||
{
|
||||
TailoredCvDrafts = tailoredDrafts,
|
||||
InterviewPrepNotes = interviewNotes,
|
||||
AiWorkspaceNotes = aiNotes,
|
||||
AiInteractions = aiInteractions,
|
||||
ChecklistItems = checklist,
|
||||
CoverLetterVersions = coverLetters,
|
||||
InterviewPrepItems = interviewItems,
|
||||
EmailDrafts = emailDrafts,
|
||||
EmailSendAttempts = emailAttempts,
|
||||
}, tailoredDrafts.Count + interviewNotes.Count + aiNotes.Count + aiInteractions.Count + checklist.Count + coverLetters.Count + interviewItems.Count + emailDrafts.Count + emailAttempts.Count);
|
||||
|
||||
var operations = await db.UserOperations.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc)
|
||||
.Select(item => new
|
||||
{
|
||||
item.Id,
|
||||
item.TaskType,
|
||||
item.Status,
|
||||
item.Priority,
|
||||
item.EntitlementDecision,
|
||||
item.PrivacyPolicy,
|
||||
item.SubjectType,
|
||||
item.SubjectId,
|
||||
item.Provider,
|
||||
item.Model,
|
||||
item.AttemptCount,
|
||||
item.MaxAttempts,
|
||||
item.CreatedAtUtc,
|
||||
item.AvailableAtUtc,
|
||||
item.StartedAtUtc,
|
||||
item.CompletedAtUtc,
|
||||
item.DeadlineAtUtc,
|
||||
item.CancellationRequestedAtUtc,
|
||||
item.ProgressStage,
|
||||
item.ProgressPercent,
|
||||
item.FailureCategory,
|
||||
item.FailureMessage,
|
||||
item.ResultReference,
|
||||
}).ToListAsync(cancellationToken);
|
||||
var notifications = await db.UserNotifications.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc).ToListAsync(cancellationToken);
|
||||
await AddJsonAsync("data/operations.json", new { Operations = operations, Notifications = notifications }, operations.Count + notifications.Count);
|
||||
|
||||
var gmail = await db.GmailConnections.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId)
|
||||
.Select(item => new { item.GmailAddress, item.Scope, item.ConnectedAt, item.LastSyncedAt, item.LastSyncAttemptedAt, item.LastSyncSucceededAt, item.LastSyncMode, item.LastSyncSource, item.LastSyncStatus, item.LastSyncError }).ToListAsync(cancellationToken);
|
||||
var microsoft = await db.MicrosoftGraphConnections.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId)
|
||||
.Select(item => new { item.MailAddress, item.Scope, item.ConnectedAt, item.LastSyncedAt, item.LastSyncAttemptedAt, item.LastSyncSucceededAt, item.LastSyncMode, item.LastSyncSource, item.LastSyncStatus, item.LastSyncError }).ToListAsync(cancellationToken);
|
||||
var imap = await db.ImapConnections.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId)
|
||||
.Select(item => new { item.Host, item.Port, item.UseSsl, item.Username, item.ConnectedAt, item.LastSyncedAt, item.LastSyncAttemptedAt, item.LastSyncSucceededAt, item.LastSyncMode, item.LastSyncSource, item.LastSyncStatus, item.LastSyncError }).ToListAsync(cancellationToken);
|
||||
var reviewDecisions = await db.GmailReviewDecisions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
|
||||
var userRules = await db.UserRuleSettings.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
|
||||
await AddJsonAsync("data/settings-and-providers.json", new
|
||||
{
|
||||
UserRules = userRules,
|
||||
GmailConnections = gmail,
|
||||
MicrosoftConnections = microsoft,
|
||||
ImapConnections = imap,
|
||||
GmailReviewDecisions = reviewDecisions,
|
||||
}, (userRules is null ? 0 : 1) + gmail.Count + microsoft.Count + imap.Count + reviewDecisions.Count);
|
||||
|
||||
var sessions = await db.UserSessions.IgnoreQueryFilters().AsNoTracking().Where(item => item.UserId == ownerUserId).OrderBy(item => item.Id)
|
||||
.Select(item => new { item.DeviceLabel, item.CreatedAtUtc, item.LastSeenAtUtc, item.ExpiresAtUtc, item.RevokedAtUtc }).ToListAsync(cancellationToken);
|
||||
var trustedDevices = await db.TrustedDevices.IgnoreQueryFilters().AsNoTracking().Where(item => item.UserId == ownerUserId).OrderBy(item => item.Id)
|
||||
.Select(item => new { item.DeviceLabel, item.CreatedAtUtc, item.LastSeenAtUtc, item.ExpiresAtUtc }).ToListAsync(cancellationToken);
|
||||
var recoveryCodeCount = await db.TwoFactorRecoveryCodes.IgnoreQueryFilters().AsNoTracking().CountAsync(item => item.UserId == ownerUserId, cancellationToken);
|
||||
await AddJsonAsync("data/security-metadata.json", new
|
||||
{
|
||||
TwoFactorEnabled = !string.IsNullOrWhiteSpace(user.TotpSecretEncrypted),
|
||||
RecoveryCodeCount = recoveryCodeCount,
|
||||
Sessions = sessions,
|
||||
TrustedDevices = trustedDevices,
|
||||
}, sessions.Count + trustedDevices.Count + recoveryCodeCount);
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
await AddOwnedFileAsync(archive, entries, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath, 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
|
||||
|
||||
This ZIP contains user-readable JSON plus owned files. manifest.json lists every entry,
|
||||
byte size, SHA-256 checksum and any unavailable file warnings.
|
||||
|
||||
Excluded secrets: password/security/concurrency hashes, TOTP secrets, recovery-code and
|
||||
trusted-device token hashes, session IDs, OAuth access/refresh tokens, IMAP passwords,
|
||||
operation lease tokens, email payload hashes, data-protection keys and global settings.
|
||||
|
||||
External providers may retain mailbox, billing or model-service data under their own
|
||||
policies. Application logs and immutable backups are not edited by this export. Their
|
||||
exact retention remains an operator/legal decision documented in BLOCKERS.md.
|
||||
""";
|
||||
await AddBytesAsync(archive, entries, "README.txt", Encoding.UTF8.GetBytes(readme), "documentation", 1, cancellationToken);
|
||||
|
||||
var manifest = new
|
||||
{
|
||||
SchemaVersion,
|
||||
GeneratedAtUtc = generatedAt,
|
||||
AccountId = ownerUserId,
|
||||
EntryCount = entries.Count,
|
||||
Entries = entries,
|
||||
Warnings = warnings,
|
||||
Exclusions = new[]
|
||||
{
|
||||
"authentication secrets and hashes",
|
||||
"provider credentials and access/refresh tokens",
|
||||
"global application settings and data-protection keys",
|
||||
"other users' data",
|
||||
"application logs, immutable backups and external-provider retained data",
|
||||
"unattributable legacy generated files",
|
||||
},
|
||||
};
|
||||
var manifestBytes = JsonSerializer.SerializeToUtf8Bytes(manifest, JsonOptions);
|
||||
var manifestEntry = archive.CreateEntry("manifest.json", CompressionLevel.Optimal);
|
||||
await using var manifestStream = manifestEntry.Open();
|
||||
await manifestStream.WriteAsync(manifestBytes, cancellationToken);
|
||||
}
|
||||
|
||||
return new AccountDataExportArtifact(outputPath, $"jobjakt-account-export-{generatedAt:yyyyMMdd-HHmmss}.zip");
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { File.Delete(outputPath); } catch { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
var checksum = Convert.ToHexString(await SHA256.HashDataAsync(source, cancellationToken)).ToLowerInvariant();
|
||||
source.Position = 0;
|
||||
var entry = archive.CreateEntry(entryName.Replace('\\', '/'), CompressionLevel.Optimal);
|
||||
await using var target = entry.Open();
|
||||
await source.CopyToAsync(target, cancellationToken);
|
||||
entries.Add(new ManifestEntry(entry.FullName, source.Length, checksum, category, 1));
|
||||
}
|
||||
|
||||
private static async Task AddBytesAsync(ZipArchive archive, ICollection<ManifestEntry> entries, string entryName, byte[] bytes, string category, int itemCount, CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
|
||||
await using var target = entry.Open();
|
||||
await target.WriteAsync(bytes, cancellationToken);
|
||||
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