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(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; public PlaywrightCvPdfExporter(AppPaths paths, ILogger logger, IConfiguration configuration) { _paths = paths; _logger = logger; _retentionDays = Math.Clamp(configuration.GetValue("CvExports:RetainDays", 30), 1, 365); } public async Task ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken) { var now = DateTimeOffset.UtcNow; PruneExpiredExports(DateOnly.FromDateTime(now.UtcDateTime).AddDays(-_retentionDays)); var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd")); Directory.CreateDirectory(folder); var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName) ? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf" : renderResult.SuggestedFileName; var storagePath = Path.Combine(folder, fileName); 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 date) || date >= cutoff) continue; 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 } } }