0954876295
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
268 lines
10 KiB
C#
268 lines
10 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;
|
|
private readonly PdfRenderAdmission _admission;
|
|
private readonly IHostApplicationLifetime _lifetime;
|
|
|
|
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger, IConfiguration configuration,
|
|
PdfRenderAdmission admission, IHostApplicationLifetime lifetime)
|
|
{
|
|
_paths = paths;
|
|
_logger = logger;
|
|
_retentionDays = Math.Clamp(configuration.GetValue("CvExports:RetainDays", 30), 1, 365);
|
|
_admission = admission;
|
|
_lifetime = lifetime;
|
|
}
|
|
|
|
public async Task<CvPdfArtifact> ExportAsync(string ownerUserId, TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
|
{
|
|
using var operation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _lifetime.ApplicationStopping);
|
|
using var admission = _admission.Enter(ownerUserId, operation.Token);
|
|
operation.CancelAfter(_admission.RenderTimeout);
|
|
var elapsed = Stopwatch.StartNew();
|
|
var outcome = "failed";
|
|
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, operation.Token);
|
|
|
|
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;
|
|
operation.Token.ThrowIfCancellationRequested();
|
|
process.Start();
|
|
using var pipeCancellation = CancellationTokenSource.CreateLinkedTokenSource(operation.Token);
|
|
// Drain both pipes while running: a full pipe must not deadlock the child or retain
|
|
// unbounded browser diagnostics (which can contain document contents/URLs).
|
|
var stdout = process.StandardOutput.BaseStream.CopyToAsync(Stream.Null, pipeCancellation.Token);
|
|
var stderr = process.StandardError.BaseStream.CopyToAsync(Stream.Null, pipeCancellation.Token);
|
|
try
|
|
{
|
|
await process.WaitForExitAsync(operation.Token);
|
|
await Task.WhenAll(stdout, stderr);
|
|
}
|
|
finally
|
|
{
|
|
try
|
|
{
|
|
try { if (!process.HasExited) process.Kill(entireProcessTree: true); }
|
|
catch (Exception) when (process.HasExited) { /* Natural exit raced cancellation. */ }
|
|
// Keep admission until the process has actually exited, including cancellation.
|
|
await process.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_admission.Disable();
|
|
_logger.LogCritical("PDF process termination could not be confirmed ({FailureType}); admission disabled until operator recovery", ex.GetType().Name);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
// Observe cancelled pipe copies; disposal also closes inherited pipe handles.
|
|
pipeCancellation.Cancel();
|
|
try { await Task.WhenAll(stdout, stderr); } catch (OperationCanceledException) { }
|
|
}
|
|
}
|
|
|
|
if (process.ExitCode != 0)
|
|
{
|
|
throw new InvalidOperationException($"CV PDF export failed via browser CLI. ExitCode={process.ExitCode}.");
|
|
}
|
|
|
|
if (!File.Exists(storagePath))
|
|
{
|
|
throw new InvalidOperationException($"CV PDF export did not create the expected file at {storagePath}.");
|
|
}
|
|
|
|
var bytes = await File.ReadAllBytesAsync(storagePath, operation.Token);
|
|
outcome = "succeeded";
|
|
return new CvPdfArtifact(fileName, storagePath, bytes);
|
|
}
|
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && !_lifetime.ApplicationStopping.IsCancellationRequested)
|
|
{
|
|
outcome = "timed_out";
|
|
throw new PdfRenderTimeoutException();
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
outcome = "cancelled";
|
|
throw;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning("PDF generation failed ({FailureType})", ex.GetType().Name);
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
if (outcome != "succeeded")
|
|
{
|
|
try { File.Delete(storagePath); }
|
|
catch (Exception ex) { _logger.LogWarning("Could not remove partial PDF ({FailureType})", ex.GetType().Name); }
|
|
}
|
|
TryDeleteDirectory(tempRoot);
|
|
_logger.LogInformation("PDF generation finished; outcome={Outcome}, durationMs={DurationMs}", outcome, elapsed.ElapsedMilliseconds);
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
}
|