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 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 _logger; private readonly int _retentionDays; private readonly PdfRenderAdmission _admission; private readonly IHostApplicationLifetime _lifetime; public PlaywrightCvPdfExporter(AppPaths paths, ILogger 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 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 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 } } }