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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record AccountDeletionRequestResult(Guid RequestId, string Status, string Stage);
|
||||
|
||||
public sealed class AccountDeletionService(
|
||||
JobTrackerContext db,
|
||||
AccountOwnedFileInventory fileInventory,
|
||||
AccountDeletionTombstoneStore tombstones,
|
||||
IConfiguration configuration,
|
||||
IMemoryCache memoryCache,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<AccountDeletionService> logger)
|
||||
{
|
||||
private const int MaxAttempts = 20;
|
||||
public bool CanAcceptRequests => configuration.GetValue("AccountLifecycle:DeletionEnabled", false);
|
||||
|
||||
public async Task<AccountDeletionRequestResult?> RequestAsync(string ownerUserId, string requestedByUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!CanAcceptRequests) return null;
|
||||
var request = await RequestCoreAsync(ownerUserId, requestedByUserId, cancellationToken);
|
||||
return new AccountDeletionRequestResult(request.Id, request.Status, request.Stage);
|
||||
}
|
||||
|
||||
public async Task<int> StageRestoredAccountsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerKeys = (await tombstones.ReadAsync(cancellationToken)).Select(item => item.OwnerKey).ToHashSet(StringComparer.Ordinal);
|
||||
if (ownerKeys.Count == 0) return 0;
|
||||
var users = await db.Users.AsNoTracking().Select(item => item.Id).ToListAsync(cancellationToken);
|
||||
var restored = users.Where(item => ownerKeys.Contains(AppPaths.GetOwnerStorageKey(item))).ToList();
|
||||
foreach (var ownerUserId in restored)
|
||||
{
|
||||
await RequestCoreAsync(ownerUserId, "tombstone-replay", cancellationToken);
|
||||
}
|
||||
return restored.Count;
|
||||
}
|
||||
|
||||
public async Task<int> ProcessPendingAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var requestIds = await db.AccountDeletionRequests.AsNoTracking()
|
||||
.Where(item => item.Status != AccountDeletionRequestStatuses.Completed && item.AttemptCount < MaxAttempts)
|
||||
.OrderBy(item => item.Id)
|
||||
.Select(item => item.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
var completed = 0;
|
||||
foreach (var requestId in requestIds)
|
||||
{
|
||||
if (await ProcessAsync(requestId, cancellationToken)) completed++;
|
||||
}
|
||||
return completed;
|
||||
}
|
||||
|
||||
public async Task<bool> ProcessAsync(Guid requestId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Requests are normally processed in a fresh background scope. Clearing here also makes
|
||||
// direct retries safe when the same scoped service accepted the request: ExecuteDelete
|
||||
// must not leave a previously tracked ApplicationUser pending for a later SaveChanges.
|
||||
db.ChangeTracker.Clear();
|
||||
var request = await db.AccountDeletionRequests.Include(item => item.Files).FirstOrDefaultAsync(item => item.Id == requestId, cancellationToken);
|
||||
if (request is null) return false;
|
||||
if (request.Status == AccountDeletionRequestStatuses.Completed) return true;
|
||||
request.Status = AccountDeletionRequestStatuses.Processing;
|
||||
request.AttemptCount++;
|
||||
request.StartedAtUtc ??= timeProvider.GetUtcNow();
|
||||
request.LastErrorCategory = null;
|
||||
request.LastErrorMessage = null;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
if (request.Stage == AccountDeletionStages.Requested)
|
||||
await PrepareFilesAsync(request, cancellationToken);
|
||||
if (request.Stage == AccountDeletionStages.QuarantiningFiles)
|
||||
await QuarantineFilesAsync(request, cancellationToken);
|
||||
if (request.Stage == AccountDeletionStages.DeletingDatabase)
|
||||
await DeleteDatabaseRowsAsync(request, cancellationToken);
|
||||
if (request.Stage == AccountDeletionStages.PurgingFiles)
|
||||
await PurgeFilesAsync(request, cancellationToken);
|
||||
if (request.Stage == AccountDeletionStages.RecordingTombstone)
|
||||
await CompleteAsync(request, cancellationToken);
|
||||
return request.Status == AccountDeletionRequestStatuses.Completed;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
request.Status = AccountDeletionRequestStatuses.RetryRequired;
|
||||
request.LastErrorCategory = Classify(ex);
|
||||
request.LastErrorMessage = Sanitize(ex.Message);
|
||||
try { await db.SaveChangesAsync(cancellationToken); }
|
||||
catch (Exception saveError) { logger.LogError(saveError, "Could not persist account deletion failure for {RequestId}", request.Id); }
|
||||
logger.LogWarning(ex, "Account deletion request {RequestId} stopped at {Stage}; it remains retryable", request.Id, request.Stage);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<AccountDeletionRequest> RequestCoreAsync(string ownerUserId, string requestedByUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
var existing = await db.AccountDeletionRequests
|
||||
.Where(item => item.OwnerUserId == ownerUserId && item.Status != AccountDeletionRequestStatuses.Completed)
|
||||
.OrderBy(item => item.Id)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
if (existing is not null) return existing;
|
||||
|
||||
var user = await db.Users.FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken)
|
||||
?? throw new InvalidOperationException("The account no longer exists.");
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var request = new AccountDeletionRequest
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = ownerUserId,
|
||||
OwnerKey = AppPaths.GetOwnerStorageKey(ownerUserId),
|
||||
RequestedByUserId = requestedByUserId,
|
||||
Status = AccountDeletionRequestStatuses.Pending,
|
||||
Stage = AccountDeletionStages.Requested,
|
||||
RequestedAtUtc = now,
|
||||
};
|
||||
user.DeletionStatus = AccountDeletionStatuses.Pending;
|
||||
user.DeletionRequestedAtUtc = now;
|
||||
user.SecurityStamp = Guid.NewGuid().ToString();
|
||||
foreach (var variant in await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == ownerUserId && item.IsPublic).ToListAsync(cancellationToken))
|
||||
variant.IsPublic = false;
|
||||
foreach (var session in await db.UserSessions.IgnoreQueryFilters().Where(item => item.UserId == ownerUserId && item.RevokedAtUtc == null).ToListAsync(cancellationToken))
|
||||
session.RevokedAtUtc = now;
|
||||
db.TrustedDevices.RemoveRange(await db.TrustedDevices.IgnoreQueryFilters().Where(item => item.UserId == ownerUserId).ToListAsync(cancellationToken));
|
||||
var operations = await db.UserOperations.IgnoreQueryFilters().Where(item => item.OwnerUserId == ownerUserId
|
||||
&& item.Status != OperationStatuses.Succeeded
|
||||
&& item.Status != OperationStatuses.Failed
|
||||
&& item.Status != OperationStatuses.Cancelled).ToListAsync(cancellationToken);
|
||||
foreach (var operation in operations)
|
||||
{
|
||||
if (operation.Status == OperationStatuses.Running) operation.CancellationRequestedAtUtc = now.UtcDateTime;
|
||||
else
|
||||
{
|
||||
operation.Status = OperationStatuses.Cancelled;
|
||||
operation.CompletedAtUtc = now.UtcDateTime;
|
||||
operation.LeaseToken = null;
|
||||
operation.LeaseExpiresAtUtc = null;
|
||||
}
|
||||
}
|
||||
db.AccountDeletionRequests.Add(request);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return request;
|
||||
}
|
||||
|
||||
private async Task PrepareFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var inventory = await fileInventory.BuildAsync(request.OwnerUserId, cancellationToken, includeAccountExports: true);
|
||||
if (inventory.Warnings.Any(item => item.StartsWith("Excluded unsafe", StringComparison.Ordinal)))
|
||||
throw new InvalidOperationException("One or more owned file paths failed the managed-root safety check.");
|
||||
request.WarningJson = JsonSerializer.Serialize(inventory.Warnings);
|
||||
foreach (var ownedFile in inventory.Files.Where(item => item.SourcePath is not null))
|
||||
{
|
||||
var sourcePath = ownedFile.SourcePath!;
|
||||
if (request.Files.Any(item => string.Equals(item.OriginalPath, sourcePath, PathComparison))) continue;
|
||||
await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
var hash = Convert.ToHexString(await SHA256.HashDataAsync(source, cancellationToken)).ToLowerInvariant();
|
||||
request.Files.Add(new AccountDeletionFile
|
||||
{
|
||||
AccountDeletionRequestId = request.Id,
|
||||
Category = ownedFile.Category,
|
||||
OriginalPath = sourcePath,
|
||||
QuarantinePath = sourcePath + $".{request.Id:N}.account-deleting",
|
||||
Status = "planned",
|
||||
ByteSize = source.Length,
|
||||
Sha256 = hash,
|
||||
});
|
||||
}
|
||||
request.FileCount = request.Files.Count;
|
||||
request.Stage = AccountDeletionStages.QuarantiningFiles;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task QuarantineFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var file in request.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (File.Exists(file.QuarantinePath))
|
||||
{
|
||||
file.Status = "quarantined";
|
||||
continue;
|
||||
}
|
||||
if (!File.Exists(file.OriginalPath))
|
||||
{
|
||||
file.Status = "missing";
|
||||
AppendWarning(request, $"Owned {file.Category} file disappeared before quarantine.");
|
||||
continue;
|
||||
}
|
||||
File.Move(file.OriginalPath, file.QuarantinePath, overwrite: false);
|
||||
file.Status = "quarantined";
|
||||
}
|
||||
request.Stage = AccountDeletionStages.DeletingDatabase;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
RestoreQuarantinedFiles(request);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteDatabaseRowsAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var file in request.Files)
|
||||
{
|
||||
if (File.Exists(file.OriginalPath) || (file.Status != "missing" && !File.Exists(file.QuarantinePath)))
|
||||
throw new InvalidOperationException("Owned files are not fully quarantined; database deletion was not started.");
|
||||
}
|
||||
|
||||
var transaction = db.Database.IsRelational() ? await db.Database.BeginTransactionAsync(cancellationToken) : null;
|
||||
try
|
||||
{
|
||||
var owner = request.OwnerUserId;
|
||||
var applicationIds = await db.JobApplications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).Select(item => item.Id).ToListAsync(cancellationToken);
|
||||
var variantIds = await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).Select(item => item.Id).ToListAsync(cancellationToken);
|
||||
var deleted = 0;
|
||||
deleted += await db.EmailDrafts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.EmailSendAttempts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.ApplicationChecklistItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CoverLetterVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.InterviewPrepItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.InterviewPrepNotes.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.AiWorkspaceNotes.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.AiInteractions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.TailoredCvDrafts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Correspondences.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.JobEvents.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Attachments.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.GmailReviewDecisions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CvVariantVersions.IgnoreQueryFilters().Where(item => variantIds.Contains(item.CvVariantId)).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerExperiences.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerEducations.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerSkills.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerProjects.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerCertifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerLanguages.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerProfileVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CareerProfiles.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CvExtractionRuns.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.CvUploadArtifacts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
if (await db.GmailConnections.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner, cancellationToken))
|
||||
AppendWarning(request, "Google consent was not revoked remotely; local Gmail credentials were deleted.");
|
||||
deleted += await db.GmailConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.MicrosoftGraphConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.ImapConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserRuleSettings.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserNotifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserOperations.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.JobApplications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Jobs.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Companies.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.TwoFactorRecoveryCodes.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.TrustedDevices.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserSessions.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserClaims.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserLogins.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserTokens.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.UserRoles.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
deleted += await db.Users.Where(item => item.Id == owner).ExecuteDeleteAsync(cancellationToken);
|
||||
request.DatabaseRowCount = deleted;
|
||||
request.Stage = AccountDeletionStages.PurgingFiles;
|
||||
request.Status = AccountDeletionRequestStatuses.Processing;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (transaction is not null)
|
||||
{
|
||||
try { await transaction.RollbackAsync(CancellationToken.None); }
|
||||
catch (Exception rollbackError) { logger.LogError(rollbackError, "Account deletion transaction rollback outcome is uncertain for {RequestId}", request.Id); }
|
||||
}
|
||||
|
||||
// A commit can succeed at the database and still lose the acknowledgement. Restore
|
||||
// quarantined files only when the owner row proves the database deletion rolled back.
|
||||
// When the outcome cannot be read, leave files quarantined and safely replay deletion.
|
||||
try
|
||||
{
|
||||
if (await db.Users.AsNoTracking().AnyAsync(item => item.Id == request.OwnerUserId, CancellationToken.None))
|
||||
{
|
||||
RestoreQuarantinedFiles(request);
|
||||
request.Stage = AccountDeletionStages.DeletingDatabase;
|
||||
}
|
||||
}
|
||||
catch (Exception verificationError)
|
||||
{
|
||||
logger.LogError(verificationError, "Could not verify database deletion outcome for {RequestId}; files remain quarantined", request.Id);
|
||||
request.Stage = AccountDeletionStages.DeletingDatabase;
|
||||
}
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (transaction is not null) await transaction.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PurgeFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var file in request.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (File.Exists(file.QuarantinePath)) File.Delete(file.QuarantinePath);
|
||||
file.Status = "purged";
|
||||
}
|
||||
if (memoryCache is MemoryCache cache) cache.Compact(1.0);
|
||||
AppendWarning(request, "The local AI sidecar cache is content-keyed and ages out under its configured TTL; production deletion remains disabled until cache purge/restart is rehearsed.");
|
||||
request.Stage = AccountDeletionStages.RecordingTombstone;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task CompleteAsync(AccountDeletionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var completedAt = timeProvider.GetUtcNow();
|
||||
await tombstones.AppendAsync(request.Id, request.OwnerKey, completedAt, cancellationToken);
|
||||
request.Stage = AccountDeletionStages.Completed;
|
||||
request.Status = AccountDeletionRequestStatuses.Completed;
|
||||
request.CompletedAtUtc = completedAt;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static void RestoreQuarantinedFiles(AccountDeletionRequest request)
|
||||
{
|
||||
foreach (var file in request.Files.Where(item => File.Exists(item.QuarantinePath) && !File.Exists(item.OriginalPath)))
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Move(file.QuarantinePath, file.OriginalPath, overwrite: false);
|
||||
file.Status = "planned";
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendWarning(AccountDeletionRequest request, string warning)
|
||||
{
|
||||
var warnings = string.IsNullOrWhiteSpace(request.WarningJson)
|
||||
? new List<string>()
|
||||
: JsonSerializer.Deserialize<List<string>>(request.WarningJson) ?? new List<string>();
|
||||
if (!warnings.Contains(warning, StringComparer.Ordinal)) warnings.Add(warning);
|
||||
request.WarningJson = JsonSerializer.Serialize(warnings);
|
||||
}
|
||||
|
||||
private static string Classify(Exception exception) => exception switch
|
||||
{
|
||||
IOException => "file_io",
|
||||
UnauthorizedAccessException => "file_access",
|
||||
DbUpdateException => "database",
|
||||
OperationCanceledException => "cancelled",
|
||||
_ => "unexpected",
|
||||
};
|
||||
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
var message = value.Replace('\r', ' ').Replace('\n', ' ').Trim();
|
||||
return message.Length > 500 ? message[..500] : message;
|
||||
}
|
||||
|
||||
private static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
|
||||
}
|
||||
|
||||
public sealed class AccountDeletionHostedService(
|
||||
IServiceScopeFactory scopes,
|
||||
IStartupReadiness startupReadiness,
|
||||
ILogger<AccountDeletionHostedService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await startupReadiness.WaitUntilReadyAsync(stoppingToken);
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopes.CreateAsyncScope();
|
||||
await scope.ServiceProvider.GetRequiredService<AccountDeletionService>().ProcessPendingAsync(stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { }
|
||||
catch (Exception ex) { logger.LogError(ex, "Account deletion reconciliation failed; durable requests remain retryable"); }
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record AccountDeletionTombstone(string SchemaVersion, Guid RequestId, string OwnerKey, DateTimeOffset CompletedAtUtc);
|
||||
|
||||
public sealed class AccountDeletionTombstoneStore(AppPaths paths)
|
||||
{
|
||||
private const string SchemaVersion = "jobtracker.account-deletion-tombstone.v1";
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private string LedgerPath => Path.Combine(paths.AccountDeletionTombstonesRoot, "tombstones.jsonl");
|
||||
|
||||
public async Task AppendAsync(Guid requestId, string ownerKey, DateTimeOffset completedAtUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var existing = await ReadUnsafeAsync(cancellationToken);
|
||||
if (existing.Any(item => item.RequestId == requestId)) return;
|
||||
Directory.CreateDirectory(paths.AccountDeletionTombstonesRoot);
|
||||
var line = JsonSerializer.Serialize(new AccountDeletionTombstone(SchemaVersion, requestId, ownerKey, completedAtUtc));
|
||||
await File.AppendAllTextAsync(LedgerPath, line + Environment.NewLine, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AccountDeletionTombstone>> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _gate.WaitAsync(cancellationToken);
|
||||
try { return await ReadUnsafeAsync(cancellationToken); }
|
||||
finally { _gate.Release(); }
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AccountDeletionTombstone>> ReadUnsafeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(LedgerPath)) return Array.Empty<AccountDeletionTombstone>();
|
||||
var result = new List<AccountDeletionTombstone>();
|
||||
foreach (var line in await File.ReadAllLinesAsync(LedgerPath, cancellationToken))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
try
|
||||
{
|
||||
var item = JsonSerializer.Deserialize<AccountDeletionTombstone>(line);
|
||||
if (item is null
|
||||
|| item.SchemaVersion != SchemaVersion
|
||||
|| item.RequestId == Guid.Empty
|
||||
|| item.OwnerKey.Length != 64
|
||||
|| item.OwnerKey.Any(character => !Uri.IsHexDigit(character)))
|
||||
throw new InvalidOperationException("The account-deletion tombstone ledger contains an invalid record.");
|
||||
result.Add(item);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A partial/corrupt line is never ignored by replay callers: expose a sentinel so
|
||||
// startup fails closed instead of declaring the tombstone set complete.
|
||||
throw new InvalidOperationException("The account-deletion tombstone ledger contains an invalid record.");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace JobTrackerApi.Services
|
||||
public string CvArtifactsRoot { get; }
|
||||
public string CvExportsRoot { get; }
|
||||
public string CvBenchmarksRoot { get; }
|
||||
public string AccountDeletionTombstonesRoot { get; }
|
||||
|
||||
public AppPaths(IConfiguration cfg, IHostEnvironment env)
|
||||
{
|
||||
@@ -49,6 +50,12 @@ namespace JobTrackerApi.Services
|
||||
|
||||
Directory.CreateDirectory(cvBenchmarksRoot);
|
||||
CvBenchmarksRoot = cvBenchmarksRoot;
|
||||
|
||||
var tombstonesRoot = (cfg["AccountLifecycle:TombstonesRoot"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(tombstonesRoot)) tombstonesRoot = Path.Combine(DataRoot, "DeletionTombstones");
|
||||
if (!Path.IsPathRooted(tombstonesRoot)) tombstonesRoot = Path.Combine(env.ContentRootPath, tombstonesRoot);
|
||||
Directory.CreateDirectory(tombstonesRoot);
|
||||
AccountDeletionTombstonesRoot = tombstonesRoot;
|
||||
}
|
||||
|
||||
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
|
||||
@@ -71,6 +78,9 @@ namespace JobTrackerApi.Services
|
||||
|
||||
public string GetOwnerDailyExportsRoot(string? configuredFolder, string ownerUserId) =>
|
||||
Path.Combine(GetExportsRoot(configuredFolder), GetOwnerStorageKey(ownerUserId));
|
||||
|
||||
public string GetOwnerAccountExportsRoot(string ownerUserId) =>
|
||||
Path.Combine(DataRoot, "AccountExports", GetOwnerStorageKey(ownerUserId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
@@ -19,7 +20,9 @@ public static class LocalSessionValidator
|
||||
var session = await db.UserSessions.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(x => x.Id == sid && x.UserId == userId, cancellationToken);
|
||||
if (session is null || session.RevokedAtUtc is not null || session.ExpiresAtUtc <= now) return false;
|
||||
if (requireConfirmedEmail && !await db.Users.IgnoreQueryFilters().AnyAsync(x => x.Id == userId && x.EmailConfirmed, cancellationToken)) return false;
|
||||
var user = await db.Users.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
||||
if (user is null || user.DeletionStatus != AccountDeletionStatuses.Active) return false;
|
||||
if (requireConfirmedEmail && !user.EmailConfirmed) return false;
|
||||
|
||||
if (now - session.LastSeenAtUtc > TimeSpan.FromMinutes(5))
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user