Implement Chromium-based PDF generation service; add fetchAndDownloadPdf utility and update contract generation endpoint

This commit is contained in:
masoodafar-web
2025-11-14 15:20:46 +03:30
parent 230ba41113
commit 680ef3a7e2
9 changed files with 297 additions and 97 deletions

View File

@@ -1,5 +1,4 @@
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using System.Security.Cryptography;
using System.Security.Cryptography;
using System.Text;
namespace FrontOffice.Main.Utilities;
@@ -10,21 +9,28 @@ public class MobileNumberEncryptor
private readonly string _iv;
public MobileNumberEncryptor(IConfiguration configuration)
{
var encryptionSettings = configuration.GetSection("EncryptionSettings").Get<EncryptionSettings>();
var encryptionSettings = configuration
.GetSection("EncryptionSettings")
.Get<EncryptionSettings>()
?? throw new InvalidOperationException("EncryptionSettings section not found in configuration");
_key = encryptionSettings?.Key ?? throw new ArgumentNullException("Encryption Key not found in configuration");
_iv = encryptionSettings?.IV ?? throw new ArgumentNullException("Encryption IV not found in configuration");
_key = !string.IsNullOrWhiteSpace(encryptionSettings.Key)
? encryptionSettings.Key
: throw new InvalidOperationException("Encryption Key not found in configuration");
_iv = !string.IsNullOrWhiteSpace(encryptionSettings.IV)
? encryptionSettings.IV
: throw new InvalidOperationException("Encryption IV not found in configuration");
// اعتبارسنجی سایز
ValidateKeyAndIV();
ValidateKeyAndIv();
}
public MobileNumberEncryptor(string key, string iv)
{
_key = key;
_iv = iv;
ValidateKeyAndIV();
ValidateKeyAndIv();
}
private void ValidateKeyAndIV()
private void ValidateKeyAndIv()
{
try
{
@@ -32,10 +38,10 @@ public class MobileNumberEncryptor
byte[] ivBytes = Convert.FromBase64String(_iv);
if (keyBytes.Length != 32)
throw new ArgumentException("Key must be 32 bytes in Base64 format");
throw new ArgumentException("Key must be 32 bytes in Base64 format", nameof(_key));
if (ivBytes.Length != 16)
throw new ArgumentException("IV must be 16 bytes in Base64 format");
throw new ArgumentException("IV must be 16 bytes in Base64 format", nameof(_iv));
}
catch (FormatException)
{
@@ -46,7 +52,7 @@ public class MobileNumberEncryptor
public string EncryptMobileNumber(string mobileNumber)
{
if (string.IsNullOrEmpty(mobileNumber))
throw new ArgumentException("Mobile number cannot be null or empty");
throw new ArgumentException("Mobile number cannot be null or empty", nameof(mobileNumber));
try
{
@@ -58,7 +64,7 @@ public class MobileNumberEncryptor
{
aesAlg.Key = key;
aesAlg.IV = iv;
aesAlg.Mode = CipherMode.ECB;
aesAlg.Mode = CipherMode.CBC; // ECB ignores IV; CBC is standard with IV
aesAlg.Padding = PaddingMode.PKCS7;
ICryptoTransform encryptor = aesAlg.CreateEncryptor(aesAlg.Key, aesAlg.IV);
@@ -85,7 +91,7 @@ public class MobileNumberEncryptor
public string DecryptMobileNumber(string encryptedMobileNumber)
{
if (string.IsNullOrEmpty(encryptedMobileNumber))
throw new ArgumentException("Encrypted mobile number cannot be null or empty");
throw new ArgumentException("Encrypted mobile number cannot be null or empty", nameof(encryptedMobileNumber));
try
{
@@ -97,7 +103,7 @@ public class MobileNumberEncryptor
{
aesAlg.Key = key;
aesAlg.IV = iv;
aesAlg.Mode = CipherMode.ECB;
aesAlg.Mode = CipherMode.CBC;
aesAlg.Padding = PaddingMode.PKCS7;
ICryptoTransform decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);

View File

@@ -0,0 +1,148 @@
using PuppeteerSharp;
using PuppeteerSharp.Media;
using Microsoft.Extensions.Configuration;
namespace FrontOffice.Main.Utilities.Pdf;
public interface IChromiumPdfService
{
Task<byte[]> GeneratePdfAsync(string html, string? fileName = null, CancellationToken ct = default);
}
public class ChromiumPdfService : IChromiumPdfService
{
private static Task _browserPreparationTask = Task.CompletedTask;
private static bool _browserReady;
private readonly IConfiguration _config;
private static string? _executablePath; // path to system Chrome/Chromium if available
private static IBrowser? _browser; // reused headless browser instance
private static readonly SemaphoreSlim BrowserLock = new(1, 1);
public ChromiumPdfService(IConfiguration config)
{
_config = config;
PrepareBrowserAsync();
}
private void PrepareBrowserAsync()
{
if (_browserReady) return;
_browserPreparationTask = PrepareBrowserCoreAsync();
}
private Task PrepareBrowserCoreAsync()
{
try
{
// Config / env override
_executablePath = _config["Pdf:ChromiumExecutablePath"] ?? Environment.GetEnvironmentVariable("CHROMIUM_EXECUTABLE_PATH");
if (!string.IsNullOrWhiteSpace(_executablePath) && File.Exists(_executablePath))
{
#if DEBUG
Console.WriteLine($"[PDF] Using configured Chromium executable: {_executablePath}");
#endif
_browserReady = true; return Task.CompletedTask;
}
// Common system locations
var candidates = new[]{"/usr/bin/google-chrome","/usr/bin/google-chrome-stable","/usr/bin/chromium","/usr/bin/chromium-browser","/snap/bin/chromium"};
_executablePath = candidates.FirstOrDefault(File.Exists);
if (_executablePath != null)
{
#if DEBUG
Console.WriteLine($"[PDF] Found system Chrome/Chromium: {_executablePath}");
#endif
_browserReady = true; return Task.CompletedTask;
}
// Fall back to PuppeteerSharp auto-download on first launch (no explicit path).
#if DEBUG
Console.WriteLine("[PDF] No local Chromium path resolved. Will rely on PuppeteerSharp auto-download at first launch.");
#endif
_browserReady = true; // allow launch to proceed; it will fetch if needed
}
catch (Exception ex)
{
#if DEBUG
Console.WriteLine($"[PDF] Unexpected preparation error: {ex.Message}");
#endif
}
return Task.CompletedTask;
}
private async Task EnsureBrowserAsync()
{
await _browserPreparationTask;
if (!_browserReady)
{
throw new InvalidOperationException("Chromium not ready for launch.");
}
if (_browser != null && !_browser.IsClosed) return;
await BrowserLock.WaitAsync();
try
{
if (_browser != null && !_browser.IsClosed) return; // double-check after lock
var launchOptions = new LaunchOptions
{
Headless = true,
Args = new[] {"--no-sandbox","--disable-setuid-sandbox"},
ExecutablePath = _executablePath // may be null; PuppeteerSharp will download if needed
};
_browser = await Puppeteer.LaunchAsync(launchOptions);
#if DEBUG
Console.WriteLine("[PDF] Chromium browser instance launched.");
#endif
}
finally
{
BrowserLock.Release();
}
}
public async Task<byte[]> GeneratePdfAsync(string html, string? fileName = null, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(html)) throw new ArgumentException("Html content is empty", nameof(html));
// Ensure browser available (download may happen here if not done yet)
await EnsureBrowserAsync();
ct.ThrowIfCancellationRequested();
// Inject default styles (RTL + font-face) if not present
if (!html.Contains("<html", StringComparison.OrdinalIgnoreCase))
{
html = $"<html><head><meta charset=\"utf-8\"><style>{GetDefaultStyles()}</style></head><body>{html}</body></html>";
}
else if (!html.Contains("<style", StringComparison.OrdinalIgnoreCase))
{
html = html.Replace("<head>", "<head><style>" + GetDefaultStyles() + "</style>");
}
if (_browser == null) throw new InvalidOperationException("Browser not initialized");
await using var page = await _browser.NewPageAsync();
await page.SetContentAsync(html, new NavigationOptions { WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } });
var pdfOptions = new PdfOptions
{
Format = PaperFormat.A4,
PrintBackground = true,
DisplayHeaderFooter = false,
MarginOptions = new MarginOptions { Top = "20mm", Bottom = "20mm", Left = "15mm", Right = "15mm" }
};
ct.ThrowIfCancellationRequested();
var data = await page.PdfDataAsync(pdfOptions);
return data;
}
private string GetDefaultStyles()
{
// Relative paths work when using SetContent; ensure fonts are served or embedded
return "@font-face{font-family:'Vazir';src:url('fonts/Vazir-Regular.ttf') format('truetype');font-weight:400;}" +
"@font-face{font-family:'Vazir';src:url('fonts/Vazir-Medium.ttf') format('truetype');font-weight:500;}" +
"@font-face{font-family:'Vazir';src:url('fonts/Vazir-Bold.ttf') format('truetype');font-weight:700;}" +
"body{font-family:'Vazir','Arial';direction:rtl;text-align:right;font-size:12px;line-height:1.6;margin:0;padding:25px;}";
}
}

View File

@@ -1,36 +1,2 @@
using PdfSharpCore.Pdf;
//using HtmlRendererCore.PdfSharpCore;
// (removed) legacy HtmlToPdfService no longer used
namespace FrontOffice.Main.Utilities.Pdf;
public interface IHtmlToPdfService
{
byte[] GeneratePdf(string html, string? fileName = null);
}
public class HtmlToPdfService : IHtmlToPdfService
{
public byte[] GeneratePdf(string html, string? fileName = null)
{
if (string.IsNullOrWhiteSpace(html))
throw new ArgumentException("Html content is empty", nameof(html));
if (!html.Contains("<html", StringComparison.OrdinalIgnoreCase))
{
html = $"<html><head><meta charset='utf-8'><style>{GetDefaultStyles()}</style></head><body>{html}</body></html>";
}
else if (!html.Contains("<style", StringComparison.OrdinalIgnoreCase))
{
// Inject default styles if none provided
html = html.Replace("<head>", "<head><style>" + GetDefaultStyles() + "</style>");
}
using var document = new PdfDocument();
//PdfGenerator.AddPdfPages(document, html, PdfSharpCore.PageSize.A4);
using var ms = new MemoryStream();
document.Save(ms);
return ms.ToArray();
}
private static string GetDefaultStyles() => @"body{font-family:'Vazir','Arial';direction:rtl;text-align:right;font-size:12px;line-height:1.6;margin:25px;} h1,h2,h3{margin:0 0 12px;} ul{margin:0 0 12px 0;padding:0 18px;} .small{font-size:10px;color:#555;}";
}