Files
jobtrackingapp/JobTrackerApi/Services/PlaywrightCvPdfExporter.cs
T
cesnimda cdcc7163fa
CI and Deploy / test (pull_request) Successful in 5m24s
CI and Deploy / deploy (pull_request) Has been skipped
refactor(storage): scope exports by owner
2026-08-15 18:19:59 +02:00

219 lines
7.5 KiB
C#

using System.Diagnostics;
using System.Globalization;
using System.Text;
namespace JobTrackerApi.Services;
public sealed record CvPdfArtifact(string FileName, string StoragePath, byte[] Bytes);
public interface ICvPdfExporter
{
Task<CvPdfArtifact> ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken);
}
public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
{
private static readonly string[] BrowserCandidates =
{
"chromium",
"chromium-browser",
"google-chrome",
"google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable"
};
private readonly AppPaths _paths;
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
private readonly int _retentionDays;
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger, IConfiguration configuration)
{
_paths = paths;
_logger = logger;
_retentionDays = Math.Clamp(configuration.GetValue("CvExports:RetainDays", 30), 1, 365);
}
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.GetOwnerCvExportsRoot(ownerUserId), now.ToString("yyyyMMdd"));
Directory.CreateDirectory(folder);
var suggestedFileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf"
: 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");
Directory.CreateDirectory(tempRoot);
try
{
await File.WriteAllTextAsync(htmlPath, renderResult.Html ?? string.Empty, Encoding.UTF8, cancellationToken);
var browserPath = ResolveBrowserPath();
if (string.IsNullOrWhiteSpace(browserPath))
{
throw new InvalidOperationException("CV PDF export is unavailable. Install Chromium/Google Chrome or set CV_PDF_BROWSER_PATH.");
}
var startInfo = new ProcessStartInfo();
startInfo.FileName = browserPath;
foreach (var argument in BuildArguments(storagePath, htmlPath))
{
startInfo.ArgumentList.Add(argument);
}
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.UseShellExecute = false;
startInfo.CreateNoWindow = true;
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
try
{
await process.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(60), cancellationToken);
}
finally
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
}
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
if (process.ExitCode != 0)
{
throw new InvalidOperationException($"CV PDF export failed via browser CLI. ExitCode={process.ExitCode}. Stdout={stdout}. Stderr={stderr}");
}
if (!File.Exists(storagePath))
{
throw new InvalidOperationException($"CV PDF export did not create the expected file at {storagePath}.");
}
var bytes = await File.ReadAllBytesAsync(storagePath, cancellationToken);
return new CvPdfArtifact(fileName, storagePath, bytes);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath);
throw;
}
finally
{
TryDeleteDirectory(tempRoot);
}
}
private void PruneExpiredExports(DateOnly cutoff)
{
foreach (var directory in Directory.EnumerateDirectories(_paths.CvExportsRoot))
{
var name = Path.GetFileName(directory);
if (DateOnly.TryParseExact(name, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var legacyDate))
{
TryDeleteExpired(directory, legacyDate, cutoff);
continue;
}
foreach (var datedDirectory in Directory.EnumerateDirectories(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[]
{
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--disable-dev-shm-usage",
"--allow-file-access-from-files",
"--enable-local-file-accesses",
$"--print-to-pdf={storagePath}",
new Uri(htmlPath).AbsoluteUri
};
}
private static string? ResolveBrowserPath()
{
var configured = Environment.GetEnvironmentVariable("CV_PDF_BROWSER_PATH");
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
{
return configured;
}
foreach (var candidate in BrowserCandidates)
{
if (Path.IsPathRooted(candidate))
{
if (File.Exists(candidate)) return candidate;
continue;
}
var resolved = FindOnPath(candidate);
if (!string.IsNullOrWhiteSpace(resolved)) return resolved;
}
return null;
}
private static string? FindOnPath(string fileName)
{
var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
var parts = path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
foreach (var dir in parts)
{
var fullPath = Path.Combine(dir, fileName);
if (File.Exists(fullPath)) return fullPath;
}
return null;
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: true);
}
}
catch
{
// best effort temp cleanup
}
}
}