refactor(storage): scope exports by owner
CI and Deploy / test (pull_request) Successful in 5m24s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 18:19:59 +02:00
parent 0d487123af
commit cdcc7163fa
17 changed files with 123 additions and 38 deletions
@@ -190,7 +190,7 @@ public sealed class CvVariantController : ControllerBase
if (user is null) return Unauthorized();
var render = await _variants.RenderAsync(user.Id, id, Person(user), ct);
if (render is null) return NotFound();
var artifact = await _pdf.ExportAsync(new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct);
var artifact = await _pdf.ExportAsync(user.Id, new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct);
return File(artifact.Bytes, "application/pdf", artifact.FileName);
}
@@ -62,7 +62,7 @@ namespace JobTrackerApi.Controllers
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
{
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
public Task<CvPdfArtifact> ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
{
throw new InvalidOperationException("CV PDF export is not configured for this controller instance.");
}
@@ -1736,7 +1736,7 @@ Candidate CV/profile:
? null
: AvatarStorage.Resolve(user.AvatarImageDataUrl);
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
var artifact = await _cvPdfExporter.ExportAsync(rendered, cancellationToken);
var artifact = await _cvPdfExporter.ExportAsync(user.Id, rendered, cancellationToken);
return File(artifact.Bytes, "application/pdf", artifact.FileName);
}
@@ -108,7 +108,7 @@ public sealed partial class ProfileCvController : ControllerBase
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
{
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
public Task<CvPdfArtifact> ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
{
throw new InvalidOperationException("CV PDF export is not configured for this controller instance.");
}
@@ -479,7 +479,9 @@ public sealed partial class ProfileCvController : ControllerBase
return StatusCode(StatusCodes.Status500InternalServerError, "The CV preview could not be prepared for PDF export.");
}
var artifact = await _cvPdfExporter.ExportAsync(new TailoredCvRenderResult(preview.TemplateId, preview.SuggestedFileName, preview.Html), cancellationToken);
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var artifact = await _cvPdfExporter.ExportAsync(user.Id, new TailoredCvRenderResult(preview.TemplateId, preview.SuggestedFileName, preview.Html), cancellationToken);
return File(artifact.Bytes, "application/pdf", artifact.FileName);
}
@@ -52,7 +52,7 @@ public sealed class PublicCvController : ControllerBase
if (result is null) return NotFound();
var render = result.Value.render;
var artifact = await _pdf.ExportAsync(new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct);
var artifact = await _pdf.ExportAsync(ownerId, new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct);
return File(artifact.Bytes, "application/pdf", artifact.FileName);
}
+14
View File
@@ -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[]