refactor(storage): scope exports by owner
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace JobTrackerApi.Services
|
||||
{
|
||||
@@ -57,6 +59,18 @@ namespace JobTrackerApi.Services
|
||||
if (string.IsNullOrWhiteSpace(folder)) return Path.Combine(DataRoot, "exports");
|
||||
return Path.IsPathRooted(folder) ? folder : Path.Combine(DataRoot, folder);
|
||||
}
|
||||
|
||||
public static string GetOwnerStorageKey(string ownerUserId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId);
|
||||
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(ownerUserId))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public string GetOwnerCvExportsRoot(string ownerUserId) =>
|
||||
Path.Combine(CvExportsRoot, GetOwnerStorageKey(ownerUserId));
|
||||
|
||||
public string GetOwnerDailyExportsRoot(string? configuredFolder, string ownerUserId) =>
|
||||
Path.Combine(GetExportsRoot(configuredFolder), GetOwnerStorageKey(ownerUserId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
@@ -96,10 +94,9 @@ public sealed class DailyExportHostedService(
|
||||
Rules = await RulesEngine.GetSettings(db, cancellationToken),
|
||||
};
|
||||
|
||||
var folder = paths.GetExportsRoot(configuration["Exports:DailyFolder"]);
|
||||
var folder = paths.GetOwnerDailyExportsRoot(configuration["Exports:DailyFolder"], owner);
|
||||
Directory.CreateDirectory(folder);
|
||||
var ownerKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(owner))).ToLowerInvariant();
|
||||
var finalPath = Path.Combine(folder, $"daily_export_{ownerKey}_{DateTime.Now:yyyyMMdd}.json");
|
||||
var finalPath = Path.Combine(folder, $"daily_export_{DateTime.Now:yyyyMMdd}.json");
|
||||
var temporaryPath = finalPath + $".{Guid.NewGuid():N}.tmp";
|
||||
try
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ public sealed record CvPdfArtifact(string FileName, string StoragePath, byte[] B
|
||||
|
||||
public interface ICvPdfExporter
|
||||
{
|
||||
Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken);
|
||||
Task<CvPdfArtifact> ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
@@ -36,17 +36,18 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
_retentionDays = Math.Clamp(configuration.GetValue("CvExports:RetainDays", 30), 1, 365);
|
||||
}
|
||||
|
||||
public async Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||
public async Task<CvPdfArtifact> ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
PruneExpiredExports(DateOnly.FromDateTime(now.UtcDateTime).AddDays(-_retentionDays));
|
||||
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
|
||||
var folder = Path.Combine(_paths.GetOwnerCvExportsRoot(ownerUserId), now.ToString("yyyyMMdd"));
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
|
||||
var suggestedFileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
|
||||
? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf"
|
||||
: renderResult.SuggestedFileName;
|
||||
var storagePath = Path.Combine(folder, fileName);
|
||||
: Path.GetFileName(renderResult.SuggestedFileName);
|
||||
var fileName = string.IsNullOrWhiteSpace(suggestedFileName) ? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf" : suggestedFileName;
|
||||
var storagePath = Path.Combine(folder, $"{Guid.NewGuid():N}.pdf");
|
||||
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), "jobtracker-cv-pdf", Guid.NewGuid().ToString("n"));
|
||||
var htmlPath = Path.Combine(tempRoot, "document.html");
|
||||
@@ -118,19 +119,36 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
||||
foreach (var directory in Directory.EnumerateDirectories(_paths.CvExportsRoot))
|
||||
{
|
||||
var name = Path.GetFileName(directory);
|
||||
if (!DateOnly.TryParseExact(name, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) || date >= cutoff) continue;
|
||||
if (DateOnly.TryParseExact(name, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var legacyDate))
|
||||
{
|
||||
TryDeleteExpired(directory, legacyDate, cutoff);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
foreach (var datedDirectory in Directory.EnumerateDirectories(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not prune expired CV export directory {Directory}", directory);
|
||||
var datedName = Path.GetFileName(datedDirectory);
|
||||
if (DateOnly.TryParseExact(datedName, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date))
|
||||
{
|
||||
TryDeleteExpired(datedDirectory, date, cutoff);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteExpired(string directory, DateOnly date, DateOnly cutoff)
|
||||
{
|
||||
if (date >= cutoff) return;
|
||||
try
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not prune expired CV export directory {Directory}", directory);
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildArguments(string storagePath, string htmlPath)
|
||||
{
|
||||
return new[]
|
||||
|
||||
Reference in New Issue
Block a user