diff --git a/README.md b/README.md index 9ee1c15..eb9d992 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,7 @@ To prevent unauthorized access to Ultimate PDF capabilities, you should **NEVER* The following constraints apply when using this component. Review them before integrating Ultimate PDF into your application. +* Ultimate PDF is **not compatible** with ODC Self-Hosted deployments. * Ultimate PDF doesn’t support the IP Filtering feature. Enabling this feature in a stage will result in the reports returning a 403 error page. According to OutSystems documentation, the external libraries run on “an external service” outside the realm of the tenant applications. Because of this, Ultimate PDF fails to call the report page to render the PDF. * The screens to print cannot be protected by authentication. Use token-based protection for print screens. Refer to the usage of `GeneratePDFToken` on this documentation for examples. * The input and output payload of the external logic cannot be greater than 5.5MB. Workaround use the REST API Store functionality. diff --git a/generate_upload_package.ps1 b/generate_upload_package.ps1 index 490dae1..828e328 100644 --- a/generate_upload_package.ps1 +++ b/generate_upload_package.ps1 @@ -1,6 +1,27 @@ -if (Test-Path -Path .\UltimatePDF_ExternalLogic.zip -PathType Leaf) { - Remove-Item -Path .\UltimatePDF_ExternalLogic.zip -Force +$zipPath = ".\UltimatePDF_ExternalLogic.zip" +$projectPath = "src\UltimatePDF_ExternalLogic\UltimatePDF_ExternalLogic.csproj" +$publishDir = ".\src\UltimatePDF_ExternalLogic\bin\Release\net10.0\linux-x64\publish\" + +if (Test-Path -Path $zipPath -PathType Leaf) { + Write-Host "Removing existing package: $zipPath" + Remove-Item -Path $zipPath -Force } + +Write-Host "Setting execution policy for current user (Unrestricted)..." Set-ExecutionPolicy -Scope CurrentUser Unrestricted -dotnet publish src\UltimatePDF_ExternalLogic.sln -c Release -r linux-x64 --self-contained false -Compress-Archive -Path .\src\UltimatePDF_ExternalLogic\bin\Release\net10.0\linux-x64\publish\* -Update -DestinationPath UltimatePDF_ExternalLogic.zip \ No newline at end of file + +# Only the main project is needed to produce the upload package (test projects aren't part of it). +Write-Host "Publishing $projectPath for linux-x64 (Release, framework-dependent)..." +dotnet publish $projectPath -c Release -r linux-x64 --self-contained false + +# dotnet publish doesn't throw in PowerShell on failure, so without this check a failed build +# would silently get packaged from stale (or missing) publish output. +if ($LASTEXITCODE -ne 0) { + Write-Host "dotnet publish failed with exit code $LASTEXITCODE. Aborting package creation." -ForegroundColor Red + exit $LASTEXITCODE +} + +Write-Host "Compressing publish output into $zipPath..." +Compress-Archive -Path (Join-Path $publishDir '*') -Update -DestinationPath $zipPath + +Write-Host "Package created: $zipPath" -ForegroundColor Green \ No newline at end of file diff --git a/src/UltimatePDF_ExternalLogic/BrowserExecution/BrowserInstancePool.cs b/src/UltimatePDF_ExternalLogic/BrowserExecution/BrowserInstancePool.cs index bdc6274..3875c43 100644 --- a/src/UltimatePDF_ExternalLogic/BrowserExecution/BrowserInstancePool.cs +++ b/src/UltimatePDF_ExternalLogic/BrowserExecution/BrowserInstancePool.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -7,41 +8,42 @@ using PuppeteerSharp; using UltimatePDF_ExternalLogic.Utils; -namespace OutSystems.UltimatePDF_ExternalLogic.BrowserExecution { - public class BrowserInstancePool { +namespace OutSystems.UltimatePDF_ExternalLogic.BrowserExecution; +public class BrowserInstancePool { - private readonly SemaphoreSlim mutex = new(1, 1); + private readonly SemaphoreSlim mutex = new(1, 1); - private static readonly List pool = new(); + private static readonly List pool = new(); - public BrowserInstancePool() { - } - - private async Task NewBrowserInstance(Logger logger) { - await mutex.WaitAsync(); - try { - var instance = pool.FirstOrDefault(i => i.IsHealthy); + public BrowserInstancePool() { + } - if (instance == null) { - logger.Log("Create new Browser Instance"); + private async Task NewBrowserInstance(Logger logger) { + using var activity = Activity.Current?.Source.StartActivity("BrowserInstancePool.NewBrowserInstance"); + await mutex.WaitAsync(); + try { + var instance = pool.FirstOrDefault(i => i.IsHealthy); - var browserLauncher = new HeadlessChromiumPuppeteerLauncher(logger.GetLoggerFactory("browser.txt")); - var browser = await browserLauncher.LaunchAsync(); - instance = new PooledBrowserInstance(browser); - pool.Add(instance); - } + if (instance == null) { + logger.Log("Create new Browser Instance"); - return instance; - } finally { - mutex.Release(); + var browserLauncher = new HeadlessChromiumPuppeteerLauncher(logger.GetLoggerFactory("browser.txt")); + var browser = await browserLauncher.LaunchAsync(); + instance = new PooledBrowserInstance(browser); + pool.Add(instance); } - } - public async Task NewPooledPage(Logger logger) { - var instance = await NewBrowserInstance(logger); - var page = await instance.Browser.NewPageAsync(); - var pooledPage = new PooledPage(page, logger); - return pooledPage; + return instance; + } finally { + mutex.Release(); } } + + public async Task NewPooledPage(Logger logger) { + using var activity = Activity.Current?.Source.StartActivity("BrowserInstancePool.NewPooledPage"); + var instance = await NewBrowserInstance(logger); + var page = await instance.Browser.NewPageAsync(); + var pooledPage = new PooledPage(page, logger); + return pooledPage; + } } diff --git a/src/UltimatePDF_ExternalLogic/BrowserExecution/ODCUltimatePDFExecutionContext.cs b/src/UltimatePDF_ExternalLogic/BrowserExecution/ODCUltimatePDFExecutionContext.cs index 506e3d3..617442e 100644 --- a/src/UltimatePDF_ExternalLogic/BrowserExecution/ODCUltimatePDFExecutionContext.cs +++ b/src/UltimatePDF_ExternalLogic/BrowserExecution/ODCUltimatePDFExecutionContext.cs @@ -10,202 +10,210 @@ using PuppeteerSharp; using PuppeteerSharp.Media; -namespace OutSystems.UltimatePDF_ExternalLogic.BrowserExecution { - internal class UltimatePDFExecutionContext { +namespace OutSystems.UltimatePDF_ExternalLogic.BrowserExecution; +internal class UltimatePDFExecutionContext { - private const string USER_AGENT_SUFFIX = "UltimatePDF/1.0"; + private const string USER_AGENT_SUFFIX = "UltimatePDF/1.0"; - private static readonly BrowserInstancePool pool = new(); + private static readonly BrowserInstancePool pool = new(); - public async static Task PrintPDF( - Uri uri, string baseUrl, string locale, string timezone, IEnumerable cookies, - ViewPortOptions viewport, PdfOptions options, int timeoutSeconds, Logger logger) { + public async static Task PrintPDF( + Uri uri, string baseUrl, string locale, string timezone, IEnumerable cookies, + ViewPortOptions viewport, PdfOptions options, int timeoutSeconds, Logger logger) { - logger.Log("Page open... " + uri); + using var activity = Activity.Current?.Source.StartActivity("UltimatePDFExecutionContext.PrintPDF"); - Stopwatch sw = new(); - sw.Start(); + logger.Log("Page open... " + uri); - using var pooled = await pool.NewPooledPage(logger); - await SetupPage(pooled.Page, uri, viewport, locale, timezone, cookies, - timeoutSeconds, logger, baseUrl); + Stopwatch sw = new(); + sw.Start(); - logger.Log("Page opened in " + sw.ElapsedMilliseconds + "ms"); - await pooled.Page.WaitForSelectorAsync(":root:not(.ultimate-pdf-is-not-ready)"); - logger.Log("Page opened and ready in " + sw.ElapsedMilliseconds + "ms"); + using var pooled = await pool.NewPooledPage(logger); + await SetupPage(pooled.Page, uri, viewport, locale, timezone, cookies, + timeoutSeconds, logger, baseUrl); - if (!string.IsNullOrEmpty(baseUrl)) { - await pooled.Page.EvaluateExpressionAsync("window?.UltimatePDF?.setBaseUrl?.('" + HttpUtility.JavaScriptStringEncode(baseUrl) + "')"); - } - - if (logger.IsEnabled) { - string html = await pooled.Page.GetContentAsync(); - logger.Attach("input.html", Encoding.UTF8.GetBytes(html)); - } + logger.Log("Page opened in " + sw.ElapsedMilliseconds + "ms"); + await pooled.Page.WaitForSelectorAsync(":root:not(.ultimate-pdf-is-not-ready)"); + logger.Log("Page opened and ready in " + sw.ElapsedMilliseconds + "ms"); - Pipeline pipeline = new Pipeline(); - await pipeline.Initialize(pooled.Page); - byte[] pdf; + if (!string.IsNullOrEmpty(baseUrl)) { + await pooled.Page.EvaluateExpressionAsync("window?.UltimatePDF?.setBaseUrl?.('" + HttpUtility.JavaScriptStringEncode(baseUrl) + "')"); + } - if (pipeline.HasLayouts) { - logger.Log("Using UltimatePDF layout pipeline"); + if (logger.IsEnabled) { + string html = await pooled.Page.GetContentAsync(); + logger.Attach("input.html", Encoding.UTF8.GetBytes(html)); + } - pdf = await pipeline.Render(pooled.Page, logger); - logger.Attach("output.pdf", pdf); - } else { - await InjectCustomStylesAsync(pooled.Page, ref options); - pdf = await pooled.Page.PdfDataAsync(options); - logger.Attach("output.pdf", pdf); - } + Pipeline pipeline = new Pipeline(); + await pipeline.Initialize(pooled.Page); + byte[] pdf; - await pooled.Page.CloseAsync(); - await pooled.Page.Browser.CloseAsync(); + if (pipeline.HasLayouts) { + logger.Log("Using UltimatePDF layout pipeline"); - return pdf; + pdf = await pipeline.Render(pooled.Page, logger); + logger.Attach("output.pdf", pdf); + } else { + await InjectCustomStylesAsync(pooled.Page, ref options); + pdf = await pooled.Page.PdfDataAsync(options); + logger.Attach("output.pdf", pdf); } - public async static Task ScreenshotPNG( - Uri uri, string baseUrl, string locale, string timezone, IEnumerable cookies, - ViewPortOptions viewport, ScreenshotOptions options, int timeoutSeconds, Logger logger) { + await pooled.Page.CloseAsync(); + await pooled.Page.Browser.CloseAsync(); - logger.Log("Page open..."); + return pdf; + } - var sw = new Stopwatch(); - sw.Start(); + public async static Task ScreenshotPNG( + Uri uri, string baseUrl, string locale, string timezone, IEnumerable cookies, + ViewPortOptions viewport, ScreenshotOptions options, int timeoutSeconds, Logger logger) { - using var pooled = await pool.NewPooledPage(logger); - await SetupPage(pooled.Page, uri, viewport, locale, timezone, cookies, timeoutSeconds, logger, baseUrl); + using var activity = Activity.Current?.Source.StartActivity("UltimatePDFExecutionContext.ScreenshotPNG"); - if (logger.IsEnabled) { - string html = await pooled.Page.GetContentAsync(); - logger.Attach("input.html", Encoding.UTF8.GetBytes(html)); - } + logger.Log("Page open..."); + + var sw = new Stopwatch(); + sw.Start(); - byte[] png = await pooled.Page.ScreenshotDataAsync(options); - logger.Attach("output.png", png); + using var pooled = await pool.NewPooledPage(logger); + await SetupPage(pooled.Page, uri, viewport, locale, timezone, cookies, timeoutSeconds, logger, baseUrl); - return png; + if (logger.IsEnabled) { + string html = await pooled.Page.GetContentAsync(); + logger.Attach("input.html", Encoding.UTF8.GetBytes(html)); } - private static async Task SetupPage( - IPage page, Uri uri, ViewPortOptions viewport, string locale, string timezone, - IEnumerable cookies, int timeout, Logger logger, string baseUrl) { + byte[] png = await pooled.Page.ScreenshotDataAsync(options); + logger.Attach("output.png", png); - await page.SetViewportAsync(viewport); + return png; + } - string originalUserAgent = await page.Browser.GetUserAgentAsync(); - await page.SetUserAgentAsync($"{originalUserAgent} {USER_AGENT_SUFFIX}"); + private static async Task SetupPage( + IPage page, Uri uri, ViewPortOptions viewport, string locale, string timezone, + IEnumerable cookies, int timeout, Logger logger, string baseUrl) { - if (!string.IsNullOrEmpty(locale)) { - await page.SetExtraHttpHeadersAsync(GetLocaleHeaders(locale)); - await page.EvaluateExpressionOnNewDocumentAsync(GetLocaleExpressionToEvaluate(locale)); - } + using var activity = Activity.Current?.Source.StartActivity("UltimatePDFExecutionContext.SetupPage"); - if (!string.IsNullOrEmpty(timezone)) { - await page.EmulateTimezoneAsync(timezone); - } + await page.SetViewportAsync(viewport); - if (cookies.Any()) { - await page.SetCookieAsync(cookies.ToArray()); - } + string originalUserAgent = await page.Browser.GetUserAgentAsync(); + await page.SetUserAgentAsync($"{originalUserAgent} {USER_AGENT_SUFFIX}"); - var navigationOptions = new NavigationOptions() { - WaitUntil = new WaitUntilNavigation[] { - WaitUntilNavigation.DOMContentLoaded, - WaitUntilNavigation.Load, - WaitUntilNavigation.Networkidle0 - } - }; + if (!string.IsNullOrEmpty(locale)) { + await page.SetExtraHttpHeadersAsync(GetLocaleHeaders(locale)); + await page.EvaluateExpressionOnNewDocumentAsync(GetLocaleExpressionToEvaluate(locale)); + } - if (timeout > 0) { - navigationOptions.Timeout = timeout * 1000; - } + if (!string.IsNullOrEmpty(timezone)) { + await page.EmulateTimezoneAsync(timezone); + } - lock (page.Browser) { - logger.Log($"Go to... {uri}"); - page.GoToAsync(uri.ToString(), navigationOptions).Wait(); - } + if (cookies.Any()) { + await page.SetCookieAsync(cookies.ToArray()); + } - if (!string.IsNullOrEmpty(locale)) { - logger.Log($"Set locale to {locale}"); - await page.EvaluateExpressionAsync("window?.UltimatePDF?.runtime?.setLocale?.('" + HttpUtility.JavaScriptStringEncode(locale) + "')"); + var navigationOptions = new NavigationOptions() { + WaitUntil = new WaitUntilNavigation[] { + WaitUntilNavigation.DOMContentLoaded, + WaitUntilNavigation.Load, + WaitUntilNavigation.Networkidle0 } + }; - // used for the Async Screen client actions - await page.WaitForSelectorAsync(":root:not(.ultimate-pdf-is-not-ready)"); + if (timeout > 0) { + navigationOptions.Timeout = timeout * 1000; + } - if (!string.IsNullOrEmpty(baseUrl)) { - logger.Log($"Set base url to {baseUrl}"); - await page.EvaluateExpressionAsync("window?.UltimatePDF?.setBaseUrl?.('" + HttpUtility.JavaScriptStringEncode(baseUrl) + "')"); - } + lock (page.Browser) { + logger.Log($"Go to... {uri}"); + page.GoToAsync(uri.ToString(), navigationOptions).Wait(); } - private static Dictionary GetLocaleHeaders(string locale) { - var headers = new Dictionary(); + if (!string.IsNullOrEmpty(locale)) { + logger.Log($"Set locale to {locale}"); + await page.EvaluateExpressionAsync("window?.UltimatePDF?.runtime?.setLocale?.('" + HttpUtility.JavaScriptStringEncode(locale) + "')"); + } - int separatorIndex = locale.IndexOf("-"); - if (separatorIndex > 0) { - headers.Add("Accept-Language", string.Concat(locale, ",", locale[0..separatorIndex])); - } else { - headers.Add("Accept-Language", locale); - } + // used for the Async Screen client actions + await page.WaitForSelectorAsync(":root:not(.ultimate-pdf-is-not-ready)"); - return headers; - } - - private static string GetLocaleExpressionToEvaluate(string locale) { - return @" - Object.defineProperty(navigator, 'language', { - get: function() { return '" + HttpUtility.JavaScriptStringEncode(locale) + @"'; } - }); - Object.defineProperty(navigator, 'languages', { - get: function() { - var separatorIndex = this.language.indexOf('-'); - if (separatorIndex > 0) { - return [ this.language, this.language.substr(0, separatorIndex) ]; - } else { - return [ this.language ]; - } + if (!string.IsNullOrEmpty(baseUrl)) { + logger.Log($"Set base url to {baseUrl}"); + await page.EvaluateExpressionAsync("window?.UltimatePDF?.setBaseUrl?.('" + HttpUtility.JavaScriptStringEncode(baseUrl) + "')"); + } + } + + private static Dictionary GetLocaleHeaders(string locale) { + using var activity = Activity.Current?.Source.StartActivity("UltimatePDFExecutionContext.GetLocaleHeaders"); + var headers = new Dictionary(); + + int separatorIndex = locale.IndexOf("-"); + if (separatorIndex > 0) { + headers.Add("Accept-Language", string.Concat(locale, ",", locale[0..separatorIndex])); + } else { + headers.Add("Accept-Language", locale); + } + + return headers; + } + + private static string GetLocaleExpressionToEvaluate(string locale) { + using var activity = Activity.Current?.Source.StartActivity("UltimatePDFExecutionContext.GetLocaleExpressionToEvaluate"); + return @" + Object.defineProperty(navigator, 'language', { + get: function() { return '" + HttpUtility.JavaScriptStringEncode(locale) + @"'; } + }); + Object.defineProperty(navigator, 'languages', { + get: function() { + var separatorIndex = this.language.indexOf('-'); + if (separatorIndex > 0) { + return [ this.language, this.language.substr(0, separatorIndex) ]; + } else { + return [ this.language ]; } - }); - "; - } - - private static Task InjectCustomStylesAsync(IPage page, ref PdfOptions options) { - /* - * It seems that Puppeteer is not overriding the page styles from the print stylesheet. - * As a workaround, we inject a