Fix backend deployment Playwright restore issue
This commit is contained in:
@@ -16,6 +16,11 @@ FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV ASPNETCORE_URLS=http://+:8080
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
ENV CV_PDF_BROWSER_PATH=/usr/bin/chromium
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends chromium \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN mkdir -p /data
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.Playwright;
|
using System.Diagnostics;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
namespace JobTrackerApi.Services;
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
@@ -11,6 +12,18 @@ public interface ICvPdfExporter
|
|||||||
|
|
||||||
public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
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 AppPaths _paths;
|
||||||
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
|
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
|
||||||
|
|
||||||
@@ -25,42 +38,141 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
|
|||||||
var now = DateTimeOffset.UtcNow;
|
var now = DateTimeOffset.UtcNow;
|
||||||
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
|
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
|
||||||
Directory.CreateDirectory(folder);
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
|
var fileName = string.IsNullOrWhiteSpace(renderResult.SuggestedFileName)
|
||||||
? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf"
|
? $"tailored-cv-{now:yyyyMMddHHmmss}.pdf"
|
||||||
: renderResult.SuggestedFileName;
|
: renderResult.SuggestedFileName;
|
||||||
var storagePath = Path.Combine(folder, fileName);
|
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");
|
||||||
|
var userDataDir = Path.Combine(tempRoot, "profile");
|
||||||
|
|
||||||
|
Directory.CreateDirectory(tempRoot);
|
||||||
|
Directory.CreateDirectory(userDataDir);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var playwright = await Playwright.CreateAsync();
|
await File.WriteAllTextAsync(htmlPath, renderResult.Html ?? string.Empty, Encoding.UTF8, cancellationToken);
|
||||||
await using var browser = await playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
|
||||||
|
var browserPath = ResolveBrowserPath();
|
||||||
|
if (string.IsNullOrWhiteSpace(browserPath))
|
||||||
{
|
{
|
||||||
Headless = true,
|
throw new InvalidOperationException("CV PDF export is unavailable. Install Chromium/Google Chrome or set CV_PDF_BROWSER_PATH.");
|
||||||
});
|
}
|
||||||
var page = await browser.NewPageAsync();
|
|
||||||
await page.SetContentAsync(renderResult.Html, new PageSetContentOptions
|
var arguments = BuildArguments(userDataDir, storagePath, htmlPath);
|
||||||
|
var startInfo = new ProcessStartInfo();
|
||||||
|
startInfo.FileName = browserPath;
|
||||||
|
startInfo.Arguments = arguments;
|
||||||
|
startInfo.RedirectStandardOutput = true;
|
||||||
|
startInfo.RedirectStandardError = true;
|
||||||
|
startInfo.UseShellExecute = false;
|
||||||
|
startInfo.CreateNoWindow = true;
|
||||||
|
|
||||||
|
using var process = new Process();
|
||||||
|
process.StartInfo = startInfo;
|
||||||
|
process.Start();
|
||||||
|
await process.WaitForExitAsync(cancellationToken);
|
||||||
|
|
||||||
|
var stdout = await process.StandardOutput.ReadToEndAsync();
|
||||||
|
var stderr = await process.StandardError.ReadToEndAsync();
|
||||||
|
|
||||||
|
if (process.ExitCode != 0)
|
||||||
{
|
{
|
||||||
WaitUntil = WaitUntilState.Load,
|
throw new InvalidOperationException($"CV PDF export failed via browser CLI. ExitCode={process.ExitCode}. Stdout={stdout}. Stderr={stderr}");
|
||||||
});
|
}
|
||||||
var bytes = await page.PdfAsync(new PagePdfOptions
|
|
||||||
|
if (!File.Exists(storagePath))
|
||||||
{
|
{
|
||||||
Format = "A4",
|
throw new InvalidOperationException($"CV PDF export did not create the expected file at {storagePath}.");
|
||||||
PrintBackground = true,
|
}
|
||||||
Margin = new()
|
|
||||||
{
|
var bytes = await File.ReadAllBytesAsync(storagePath, cancellationToken);
|
||||||
Top = "0",
|
|
||||||
Right = "0",
|
|
||||||
Bottom = "0",
|
|
||||||
Left = "0",
|
|
||||||
}
|
|
||||||
});
|
|
||||||
await File.WriteAllBytesAsync(storagePath, bytes, cancellationToken);
|
|
||||||
return new CvPdfArtifact(fileName, storagePath, bytes);
|
return new CvPdfArtifact(fileName, storagePath, bytes);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath);
|
_logger.LogError(ex, "Failed to export CV PDF to {Path}", storagePath);
|
||||||
throw new InvalidOperationException("CV PDF export is unavailable. Ensure Chromium is installed for Playwright on this machine.", ex);
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
TryDeleteDirectory(tempRoot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string BuildArguments(string userDataDir, string storagePath, string htmlPath)
|
||||||
|
{
|
||||||
|
var parts = new List<string>
|
||||||
|
{
|
||||||
|
"--headless=new",
|
||||||
|
"--disable-gpu",
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--allow-file-access-from-files",
|
||||||
|
"--enable-local-file-accesses",
|
||||||
|
"--user-data-dir=" + Quote(userDataDir),
|
||||||
|
"--print-to-pdf=" + Quote(storagePath),
|
||||||
|
Quote(htmlPath)
|
||||||
|
};
|
||||||
|
|
||||||
|
return string.Join(' ', parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Quote(string value)
|
||||||
|
{
|
||||||
|
return '"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,6 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
|
||||||
<PackageReference Include="Microsoft.Playwright" Version="1.55.0" />
|
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
|
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user