diff --git a/Directory.Build.props b/Directory.Build.props index 5a892dbe..564ea658 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -7,8 +7,8 @@ Copyright 2011-$(CurrentYear) axuno gGmbH https://github.com/axuno/Volleyball-League true - 6.0.0 - 6.0.0 + 6.1.0 + 6.1.0 6.0.0.0 latest enable diff --git a/League.Demo/Views/Shared/_Layout.cshtml b/League.Demo/Views/Shared/_Layout.cshtml index ae349bad..3e0eb928 100644 --- a/League.Demo/Views/Shared/_Layout.cshtml +++ b/League.Demo/Views/Shared/_Layout.cshtml @@ -10,6 +10,7 @@ + @await RenderSectionAsync("meta", required: false) @ViewData.Title() @if (ViewData.Description() != null) { diff --git a/League.Tests/Caching/ReportSheetCacheTests.cs b/League.Tests/Caching/ReportSheetCacheTests.cs new file mode 100644 index 00000000..c8d3549c --- /dev/null +++ b/League.Tests/Caching/ReportSheetCacheTests.cs @@ -0,0 +1,102 @@ +// +// Copyright Volleyball League Project maintainers and contributors. +// Licensed under the MIT license. +// + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using League.Caching; +using League.Tests.TestComponents; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using TournamentManager.DAL.TypedViewClasses; +using TournamentManager.MultiTenancy; + +namespace League.Tests.Caching; + +[TestFixture] +public class ReportSheetCacheTests +{ + private readonly ReportSheetCache _cache; + private readonly ITenantContext _tenantContext; + private readonly IWebHostEnvironment _webHostEnvironment; + + public ReportSheetCacheTests() + { + _webHostEnvironment = new HostingEnvironment { + WebRootPath = Path.GetTempPath(), ContentRootPath + // Because we use the Chromium installation in the demo web app + = DirectoryLocator.GetTargetProjectPath(typeof(League.WebApp.WebAppStartup)) + }; + + _tenantContext = new TenantContext + { + Identifier = "testorg" + }; + + var chromiumPath = new List> + { new("Chromium:ExecutablePath", "Chromium-Win\\chrome.exe") }; + + IServiceProvider services = UnitTestHelpers.GetReportSheetCacheServiceProvider(_tenantContext, _webHostEnvironment, chromiumPath); + _cache = services.GetRequiredService(); + } + + [TestCase("en")] + [TestCase("de")] + public async Task CreateNewPdfInOutputPath(string c) + { + DeleteOutputFolder(); + var culture = CultureInfo.GetCultureInfo(c); + using var switcher = new CultureSwitcher(culture, culture); + + var data = new MatchReportSheetRow + { Id = 1234, ModifiedOn = new DateTime(2023, 03, 22, 12, 0, 0).ToUniversalTime() }; + + var stream = await _cache.GetOrCreatePdf(data, "Some text", CancellationToken.None); + var fileName = Path.GetFileName(((FileStream) stream).Name); + await stream.DisposeAsync(); + + Assert.That(fileName, + Is.EqualTo(string.Format(ReportSheetCache.ReportSheetFilenameTemplate, _tenantContext.Identifier, data.Id, + culture.TwoLetterISOLanguageName))); + } + + [TestCase("en")] + [TestCase("de")] + public async Task ShouldReturnExistingPdfFromCache(string c) + { + DeleteOutputFolder(); + var culture = CultureInfo.GetCultureInfo(c); + using var switcher = new CultureSwitcher(culture, culture); + + var data = new MatchReportSheetRow + { Id = 1234, ModifiedOn = new DateTime(2023, 03, 22, 12, 0, 0).ToUniversalTime() }; + + // (1) This should create the file in the cache + var stream1 = await _cache.GetOrCreatePdf(data, "Some text", CancellationToken.None); + var fileInfo1 = new FileInfo(Path.GetFileName(((FileStream) stream1).Name)); + await stream1.DisposeAsync(); + + // (1) This should return the file from the cache + var stream2 = await _cache.GetOrCreatePdf(data, "Some text", CancellationToken.None); + var fileInfo2 = new FileInfo(Path.GetFileName(((FileStream) stream1).Name)); + await stream2.DisposeAsync(); + + // Assert the file was not created again + Assert.That(fileInfo1.CreationTimeUtc.Ticks, Is.EqualTo(fileInfo2.CreationTimeUtc.Ticks)); + } + + private void DeleteOutputFolder() + { + var outputFolder = Path.Combine(_webHostEnvironment.WebRootPath, ReportSheetCache.ReportSheetCacheFolder); + + // Delete folder in TempPath + if (!Directory.Exists(outputFolder)) return; + Directory.Delete(outputFolder, true); + } +} diff --git a/League.Tests/Identity/RoleStoreTests.cs b/League.Tests/Identity/RoleStoreTests.cs index dddc7adc..0c5cf61f 100644 --- a/League.Tests/Identity/RoleStoreTests.cs +++ b/League.Tests/Identity/RoleStoreTests.cs @@ -6,14 +6,13 @@ using System.Threading.Tasks; using System.Threading; using League.Identity; -using League.Test.TestComponents; using NUnit.Framework; using SD.LLBLGen.Pro.ORMSupportClasses; using TournamentManager.DAL.EntityClasses; using TournamentManager.DAL.DatabaseSpecific; using TournamentManager.MultiTenancy; -namespace League.Test.Identity; +namespace League.Tests.Identity; /// /// Integration tests diff --git a/League.Tests/Identity/UserAuthenticationTokenStoreTests.cs b/League.Tests/Identity/UserAuthenticationTokenStoreTests.cs index 372ece8d..d2516941 100644 --- a/League.Tests/Identity/UserAuthenticationTokenStoreTests.cs +++ b/League.Tests/Identity/UserAuthenticationTokenStoreTests.cs @@ -10,7 +10,7 @@ using SD.LLBLGen.Pro.ORMSupportClasses; using TournamentManager.MultiTenancy; -namespace League.Test.Identity; +namespace League.Tests.Identity; /// /// Integration tests diff --git a/League.Tests/Identity/UserClaimStoreTests.cs b/League.Tests/Identity/UserClaimStoreTests.cs index ce74247e..3830ef1a 100644 --- a/League.Tests/Identity/UserClaimStoreTests.cs +++ b/League.Tests/Identity/UserClaimStoreTests.cs @@ -6,15 +6,13 @@ using System.Threading.Tasks; using System.Threading; using League.Identity; -using League.Test.TestComponents; using NUnit.Framework; using SD.LLBLGen.Pro.ORMSupportClasses; -using TournamentManager.Data; using TournamentManager.DAL.EntityClasses; using TournamentManager.DAL.DatabaseSpecific; using TournamentManager.MultiTenancy; -namespace League.Test.Identity; +namespace League.Tests.Identity; /// /// Integration tests diff --git a/League.Tests/Identity/UserLoginStoreTests.cs b/League.Tests/Identity/UserLoginStoreTests.cs index 2168ed41..d936a285 100644 --- a/League.Tests/Identity/UserLoginStoreTests.cs +++ b/League.Tests/Identity/UserLoginStoreTests.cs @@ -5,15 +5,13 @@ using System.Threading.Tasks; using System.Threading; using League.Identity; -using League.Test.TestComponents; using NUnit.Framework; using TournamentManager.DAL.DatabaseSpecific; -using TournamentManager.Data; using TournamentManager.DAL.EntityClasses; using SD.LLBLGen.Pro.ORMSupportClasses; using TournamentManager.MultiTenancy; -namespace League.Test.Identity; +namespace League.Tests.Identity; /// /// Integration tests diff --git a/League.Tests/Identity/UserRoleStoreTests.cs b/League.Tests/Identity/UserRoleStoreTests.cs index b98c7335..a2f33a39 100644 --- a/League.Tests/Identity/UserRoleStoreTests.cs +++ b/League.Tests/Identity/UserRoleStoreTests.cs @@ -5,19 +5,14 @@ using System.Threading.Tasks; using System.Threading; using League.Identity; -using League.Test.TestComponents; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Logging; using NUnit.Framework; using SD.LLBLGen.Pro.ORMSupportClasses; using TournamentManager.DAL.DatabaseSpecific; -using TournamentManager.Data; using TournamentManager.DAL.EntityClasses; using TournamentManager.DAL.HelperClasses; using TournamentManager.MultiTenancy; -namespace League.Test.Identity; +namespace League.Tests.Identity; /// /// Integration tests diff --git a/League.Tests/Identity/UserStoreTests.cs b/League.Tests/Identity/UserStoreTests.cs index 20c77d1b..d7027b61 100644 --- a/League.Tests/Identity/UserStoreTests.cs +++ b/League.Tests/Identity/UserStoreTests.cs @@ -10,7 +10,7 @@ using TournamentManager.MultiTenancy; using SD.LLBLGen.Pro.ORMSupportClasses; -namespace League.Test.Identity; +namespace League.Tests.Identity; /// /// Integration tests diff --git a/League.Tests/IntegrationTests/BasicIntegrationTests.cs b/League.Tests/IntegrationTests/BasicIntegrationTests.cs index 008aec12..d31c8eda 100644 --- a/League.Tests/IntegrationTests/BasicIntegrationTests.cs +++ b/League.Tests/IntegrationTests/BasicIntegrationTests.cs @@ -3,7 +3,7 @@ using Microsoft.AspNetCore.TestHost; using NUnit.Framework; -namespace League.Test; +namespace League.Tests; [TestFixture] public class BasicIntegrationTests diff --git a/League.Tests/TestComponents/CultureSwitcher.cs b/League.Tests/TestComponents/CultureSwitcher.cs index dd7ce216..98d841ca 100644 --- a/League.Tests/TestComponents/CultureSwitcher.cs +++ b/League.Tests/TestComponents/CultureSwitcher.cs @@ -1,31 +1,41 @@ using System; -using System.Collections.Generic; using System.Globalization; -using System.Text; -namespace League.Test.TestComponents; +namespace League.Tests.TestComponents; public class CultureSwitcher : IDisposable { private readonly CultureInfo _originalCulture; private readonly CultureInfo _originalUiCulture; + private readonly CultureInfo? _originalDefaultThreadCulture; + private readonly CultureInfo? _originalDefaultThreadUiCulture; public CultureSwitcher(CultureInfo culture, CultureInfo uiCulture) { _originalCulture = CultureInfo.CurrentCulture; _originalUiCulture = CultureInfo.CurrentUICulture; - SetCulture(culture, uiCulture); + _originalDefaultThreadCulture = CultureInfo.DefaultThreadCurrentCulture; + _originalDefaultThreadUiCulture = CultureInfo.DefaultThreadCurrentUICulture; + SetCurrentCulture(culture, uiCulture); + SetThreadDefaultCulture(culture, uiCulture); } - private static void SetCulture(CultureInfo culture, CultureInfo uiCulture) + private static void SetCurrentCulture(CultureInfo culture, CultureInfo uiCulture) { CultureInfo.CurrentCulture = culture; CultureInfo.CurrentUICulture = uiCulture; } + private static void SetThreadDefaultCulture(CultureInfo? culture, CultureInfo? uiCulture) + { + CultureInfo.DefaultThreadCurrentCulture = culture; + CultureInfo.DefaultThreadCurrentUICulture = uiCulture; + } + public void Dispose() { GC.SuppressFinalize(this); - SetCulture(_originalCulture, _originalUiCulture); + SetCurrentCulture(_originalCulture, _originalUiCulture); + SetThreadDefaultCulture(_originalDefaultThreadCulture, _originalDefaultThreadUiCulture); } -} \ No newline at end of file +} diff --git a/League.Tests/TestComponents/DirectoryLocator.cs b/League.Tests/TestComponents/DirectoryLocator.cs index 76aacd20..c0c99990 100644 --- a/League.Tests/TestComponents/DirectoryLocator.cs +++ b/League.Tests/TestComponents/DirectoryLocator.cs @@ -2,7 +2,7 @@ using System.IO; using System.Reflection; #nullable enable -namespace League.Test.TestComponents; +namespace League.Tests.TestComponents; public class DirectoryLocator { diff --git a/League.Tests/TestComponents/HostingEnvironment.cs b/League.Tests/TestComponents/HostingEnvironment.cs new file mode 100644 index 00000000..b0b45294 --- /dev/null +++ b/League.Tests/TestComponents/HostingEnvironment.cs @@ -0,0 +1,23 @@ +// +// Copyright Volleyball League Project maintainers and contributors. +// Licensed under the MIT license. +// + +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.FileProviders; + +namespace League.Tests.TestComponents; +public class HostingEnvironment : IWebHostEnvironment +{ + public string EnvironmentName { get; set; } = Microsoft.Extensions.Hosting.Environments.Development; + + public string ApplicationName { get; set; } = null!; + + public string WebRootPath { get; set; } = null!; + + public IFileProvider WebRootFileProvider { get; set; } = null!; + + public string ContentRootPath { get; set; } = null!; + + public IFileProvider ContentRootFileProvider { get; set; } = null!; +} diff --git a/League.Tests/TestComponents/UnitTestHelpers.cs b/League.Tests/TestComponents/UnitTestHelpers.cs index a0edcfbb..a9870082 100644 --- a/League.Tests/TestComponents/UnitTestHelpers.cs +++ b/League.Tests/TestComponents/UnitTestHelpers.cs @@ -1,12 +1,15 @@ -using System.Globalization; +using System.Collections.Generic; +using System.Globalization; using System.IO; using Axuno.VirtualFileSystem; +using League.Caching; using League.Identity; -using League.Test.TestComponents; +using League.Tests.TestComponents; using League.TextTemplatingModule; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; @@ -18,7 +21,7 @@ using TournamentManager.MultiTenancy; using ILogger = Microsoft.Extensions.Logging.ILogger; -namespace League.Test; +namespace League.Tests; public class UnitTestHelpers { @@ -138,6 +141,31 @@ public static ServiceProvider GetTextTemplatingServiceProvider(ITenantContext te .BuildServiceProvider(); } + public static ServiceProvider GetReportSheetCacheServiceProvider(ITenantContext tenantContext, IWebHostEnvironment webHostEnvironment, IEnumerable> chromiumPath) + { + return new ServiceCollection() + .AddLogging(builder => + { + builder.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace); + builder.AddNLog(new NLogProviderOptions + { + CaptureMessageTemplates = true, + CaptureMessageProperties = true + }); + }) + .AddTransient(sp => + { + var c = new ConfigurationManager(); + c.AddInMemoryCollection(chromiumPath); + return c; + }) + .AddTransient(sp => tenantContext) + .AddTransient(sp => webHostEnvironment) + .AddTransient() + .AddLocalization() + .BuildServiceProvider(); + } + public static TestServer GetLeagueTestServer() { diff --git a/League.Tests/TextTemplating/EmailTemplateTests.cs b/League.Tests/TextTemplating/EmailTemplateTests.cs index 56ca9ddd..f8a617ec 100644 --- a/League.Tests/TextTemplating/EmailTemplateTests.cs +++ b/League.Tests/TextTemplating/EmailTemplateTests.cs @@ -6,7 +6,7 @@ using League.Models.ContactViewModels; using League.Templates.Email; using League.Templates.Email.Localization; -using League.Test.TestComponents; +using League.Tests.TestComponents; using League.TextTemplatingModule; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; @@ -14,7 +14,7 @@ using TournamentManager.DAL.TypedViewClasses; using TournamentManager.MultiTenancy; -namespace League.Test.TextTemplating; +namespace League.Tests.TextTemplating; /// /// Tests for all email templates. diff --git a/League/Caching/ReportSheetCache.cs b/League/Caching/ReportSheetCache.cs new file mode 100644 index 00000000..5dbf3797 --- /dev/null +++ b/League/Caching/ReportSheetCache.cs @@ -0,0 +1,241 @@ +// +// Copyright Volleyball League Project maintainers and contributors. +// Licensed under the MIT license. +// + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using TournamentManager.DAL.TypedViewClasses; +using TournamentManager.MultiTenancy; + +namespace League.Caching; +public class ReportSheetCache +{ + private readonly ITenantContext _tenantContext; + private readonly IWebHostEnvironment _webHostEnvironment; + private readonly string _pathToChromium; + private readonly ILogger _logger; + + /// + /// Folder name for the report sheet cache + /// relative to . + /// + public const string ReportSheetCacheFolder = "report-sheets"; + + /// + /// Template for chart images. {0}: tenant key, {1}: match ID, {2}: language + /// + public const string ReportSheetFilenameTemplate = "Sheet_{0}_{1}_{2}.pdf"; + + public ReportSheetCache(ITenantContext tenantContext, IConfiguration configuration, IWebHostEnvironment webHostEnvironment, ILogger logger) + { + _tenantContext = tenantContext; + _webHostEnvironment = webHostEnvironment; + _pathToChromium = Path.Combine(webHostEnvironment.ContentRootPath, configuration["Chromium:ExecutablePath"] ?? string.Empty); + _logger = logger; + } + + private void EnsureCacheFolder() + { + var cacheFolder = Path.Combine(_webHostEnvironment.WebRootPath, ReportSheetCacheFolder); + if (!Directory.Exists(cacheFolder)) + { + _logger.LogDebug("Cache folder '{CacheFolder}' created", cacheFolder); + Directory.CreateDirectory(cacheFolder); + } + } + + public async Task GetOrCreatePdf(MatchReportSheetRow data, string html, CancellationToken cancellationToken) + { + EnsureCacheFolder(); + var matchId = data.Id; + + var cacheFile = GetPathToCacheFile(matchId); +#pragma warning disable CA3003 // No potential File path injection + if (!File.Exists(cacheFile) || IsOutdated(cacheFile, data.ModifiedOn)) + { + _logger.LogDebug("Create new match report for tenant '{Tenant}', match '{MatchId}'", _tenantContext.Identifier, matchId); + cacheFile = await GetReportSheetChromium(matchId, html, cancellationToken); + // GetReportSheetPuppeteer() still throws on production server + if (cacheFile == null) return Stream.Null; + } + + _logger.LogDebug("Read match report from cache for tenant '{Tenant}', match '{MatchId}'", _tenantContext.Identifier, matchId); + var stream = File.OpenRead(cacheFile); + return stream; +#pragma warning restore CA3003 + } + + private static bool IsOutdated(string cacheFile, DateTime dataModifiedOn) + { +#pragma warning disable CA3003 // No potential File path injection + var fi = new FileInfo(cacheFile); + return !fi.Exists || fi.LastWriteTimeUtc < dataModifiedOn; // Database dates are in UTC +#pragma warning restore CA3003 + } + + private async Task GetReportSheetChromium(long matchId, string html, CancellationToken cancellationToken) + { + // Create folder in TempPath + var tempFolder = CreateTempPathFolder(); + + // Temporary file with HTML content - extension must be ".html"! + var htmlUri = await CreateHtmlFile(html, tempFolder, cancellationToken); + + var pdfFile = await CreateReportSheetPdfChromium(tempFolder, htmlUri); + + var cacheFile = MovePdfToCache(pdfFile, matchId); + + DeleteTempPathFolder(tempFolder); + + return cacheFile; + } + + private string? MovePdfToCache(string pdfFile, long matchId) + { + if (!File.Exists(pdfFile)) return null; + +#pragma warning disable CA3003 // No potential File path injection + var fullPath = GetPathToCacheFile(matchId); + try + { + // may throw UnauthorizedAccessException on production server + File.Move(pdfFile, fullPath, true); + } + catch + { + File.Copy(pdfFile, fullPath, true); + } +#pragma warning restore CA3003 + + return fullPath; + } + + private string GetPathToCacheFile(long matchId) + { + var fileName = string.Format(ReportSheetFilenameTemplate, _tenantContext.Identifier, matchId, + Thread.CurrentThread.CurrentUICulture.TwoLetterISOLanguageName); + + return Path.Combine(_webHostEnvironment.WebRootPath, ReportSheetCacheFolder, fileName); + } + + private async Task GetReportSheetPuppeteer(long matchId, string html, CancellationToken cancellationToken) + { + var options = new PuppeteerSharp.LaunchOptions + { + Headless = true, + Product = PuppeteerSharp.Product.Chrome, + // Alternative: --use-cmd-decoder=validating + Args = new[] + { "--no-sandbox", "--disable-gpu", "--disable-extensions", "--use-cmd-decoder=passthrough" }, + ExecutablePath = _pathToChromium, + Timeout = 5000 + }; + // Use Puppeteer as a wrapper for the browser, which can generate PDF from HTML + // Start command line arguments set by Puppeteer: + // --allow-pre-commit-input --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-sync --enable-automation --enable-blink-features=IdleDetection --enable-features=NetworkServiceInProcess2 --export-tagged-pdf --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --headless + await using var browser = await PuppeteerSharp.Puppeteer.LaunchAsync(options).ConfigureAwait(false); + await using var page = await browser.NewPageAsync().ConfigureAwait(false); + + await page.SetContentAsync(html); // Bootstrap 5 is loaded from CDN + await page.EvaluateExpressionHandleAsync("document.fonts.ready"); // Wait for fonts to be loaded. Omitting this might result in no text rendered in pdf. + + // Todo: This part works on the development machine, but throws on the production web server + /* +2023-03-21 22:23:44.4533||FATAL|League.Controllers.Match|ReportSheet failed for match ID '3188' PuppeteerSharp.MessageException: Protocol error (IO.read): Read failed + at PuppeteerSharp.CDPSession.SendAsync(String method, Object args, Boolean waitForCallback) in C:\projects\puppeteer-sharp\lib\PuppeteerSharp\CDPSession.cs:line 94 + at PuppeteerSharp.CDPSession.SendAsync[T](String method, Object args) in C:\projects\puppeteer-sharp\lib\PuppeteerSharp\CDPSession.cs:line 55 + at PuppeteerSharp.Helpers.ProtocolStreamReader.ReadProtocolStreamByteAsync(CDPSession client, String handle, String path) in C:\projects\puppeteer-sharp\lib\PuppeteerSharp\Helpers\ProtocolStreamReader.cs:line 63 + at PuppeteerSharp.Page.PdfInternalAsync(String file, PdfOptions options) in C:\projects\puppeteer-sharp\lib\PuppeteerSharp\Page.cs:line 1175 + at League.Caching.ReportSheetCache.GetReportSheetPuppeteer(Int64 matchId, String html, CancellationToken cancellationToken) + at League.Caching.ReportSheetCache.GetReportSheetPuppeteer(Int64 matchId, String html, CancellationToken cancellationToken) + at League.Caching.ReportSheetCache.GetReportSheetPuppeteer(Int64 matchId, String html, CancellationToken cancellationToken) + at League.Caching.ReportSheetCache.GetOrCreatePdf(MatchReportSheetRow data, String html, CancellationToken cancellationToken) + at League.Controllers.Match.ReportSheet(Int64 id, CancellationToken cancellationToken) at PuppeteerSharp.CDPSession.SendAsync(String method, Object args, Boolean waitForCallback) in C:\projects\puppeteer-sharp\lib\PuppeteerSharp\CDPSession.cs:line 94 + at PuppeteerSharp.CDPSession.SendAsync[T](String method, Object args) in C:\projects\puppeteer-sharp\lib\PuppeteerSharp\CDPSession.cs:line 55 + at PuppeteerSharp.Helpers.ProtocolStreamReader.ReadProtocolStreamByteAsync(CDPSession client, String handle, String path) in C:\projects\puppeteer-sharp\lib\PuppeteerSharp\Helpers\ProtocolStreamReader.cs:line 63 + at PuppeteerSharp.Page.PdfInternalAsync(String file, PdfOptions options) in C:\projects\puppeteer-sharp\lib\PuppeteerSharp\Page.cs:line 1175 + at League.Caching.ReportSheetCache.GetReportSheetPuppeteer(Int64 matchId, String html, CancellationToken cancellationToken) + at League.Caching.ReportSheetCache.GetReportSheetPuppeteer(Int64 matchId, String html, CancellationToken cancellationToken) + at League.Caching.ReportSheetCache.GetReportSheetPuppeteer(Int64 matchId, String html, CancellationToken cancellationToken) + at League.Caching.ReportSheetCache.GetOrCreatePdf(MatchReportSheetRow data, String html, CancellationToken cancellationToken) + at League.Controllers.Match.ReportSheet(Int64 id, CancellationToken cancellationToken) + |url: https://volleyball-liga.de/augsburg/match/reportsheet/3188|action: ReportSheet + */ + var fullPath = GetPathToCacheFile(matchId); + + var bytes = await page.PdfDataAsync(new PuppeteerSharp.PdfOptions + { Scale = 1.0M, Format = PuppeteerSharp.Media.PaperFormat.A4 }).ConfigureAwait(false); + + await File.WriteAllBytesAsync(fullPath, bytes, cancellationToken); + + return fullPath; + } + + private async Task CreateReportSheetPdfChromium(string tempFolder, string htmlUri) + { + // Temporary file for the PDF stream form Chromium + var pdfFile = Path.Combine(tempFolder, Path.GetRandomFileName() + ".pdf"); + + // Run Chromium + // Command line switches overview: https://kapeli.com/cheat_sheets/Chromium_Command_Line_Switches.docset/Contents/Resources/Documents/index + var startInfo = new System.Diagnostics.ProcessStartInfo(_pathToChromium, + $"--allow-pre-commit-input --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-sync --enable-automation --enable-blink-features=IdleDetection --enable-features=NetworkServiceInProcess2 --export-tagged-pdf --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --headless --hide-scrollbars --mute-audio --no-sandbox --disable-gpu --use-cmd-decoder=passthrough --no-margins --user-data-dir={tempFolder} --print-to-pdf={pdfFile} {htmlUri}") + { CreateNoWindow = true, UseShellExecute = false }; + var proc = System.Diagnostics.Process.Start(startInfo); + + if (proc is null) + { + //_logger.LogCritical("Process '{PathToChromium}' could not be started.", _pathToChromium); + throw new InvalidOperationException($"Process '{_pathToChromium}' could not be started."); + } + + const int timeout = 8000; + var timePassed = 0; + while (!proc.HasExited) + { + timePassed += 100; + await Task.Delay(100, default); + if (timePassed < timeout) continue; + + proc.Kill(true); + throw new OperationCanceledException($"Chromium timed out after {timeout}ms."); + } + + return pdfFile; + } + + private static async Task CreateHtmlFile(string html, string tempFolder, CancellationToken cancellationToken) + { + var htmlFile = Path.Combine(tempFolder, Path.GetRandomFileName() + ".html"); + await File.WriteAllTextAsync(htmlFile, html, cancellationToken); + return new Uri(htmlFile).AbsoluteUri; + } + + private static string CreateTempPathFolder() + { + // Create folder in TempPath + var tempFolder = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + if (!Directory.Exists(tempFolder)) Directory.CreateDirectory(tempFolder); + return tempFolder; + } + + private static void DeleteTempPathFolder(string path) + { + // Delete folder in TempPath + if (!Directory.Exists(path)) return; + try + { + Directory.Delete(path, true); + } + catch + { + // Best effort when trying to remove + } + } +} diff --git a/League/Controllers/Match.cs b/League/Controllers/Match.cs index 6129488c..5a082471 100644 --- a/League/Controllers/Match.cs +++ b/League/Controllers/Match.cs @@ -6,6 +6,7 @@ using System.Threading; using System.Threading.Tasks; using League.BackgroundTasks; +using League.Caching; using League.Emailing.Creators; using League.Helpers; using League.Models.MatchViewModels; @@ -14,14 +15,12 @@ using League.Views; using MailMergeLib.AspNet; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; -using NuGet.Packaging.Signing; -using PuppeteerSharp; -using PuppeteerSharp.Media; using SD.LLBLGen.Pro.ORMSupportClasses; using SD.LLBLGen.Pro.QuerySpec; using TournamentManager.DAL.EntityClasses; @@ -49,9 +48,7 @@ public class Match : AbstractController private readonly SendEmailTask _sendMailTask; private readonly RankingUpdateTask _rankingUpdateTask; private readonly RazorViewToStringRenderer _razorViewToStringRenderer; - private readonly IConfiguration _configuration; - private readonly string _pathToChromium; - + /// /// The controller for handling s. /// @@ -63,13 +60,12 @@ public class Match : AbstractController /// /// /// - /// /// public Match(ITenantContext tenantContext, IStringLocalizer localizer, IAuthorizationService authorizationService, Axuno.Tools.DateAndTime.TimeZoneConverter timeZoneConverter, Axuno.BackgroundTask.IBackgroundQueue queue, SendEmailTask sendMailTask, RankingUpdateTask rankingUpdateTask, - RazorViewToStringRenderer razorViewToStringRenderer, IConfiguration configuration, ILogger logger) + RazorViewToStringRenderer razorViewToStringRenderer, ILogger logger) { _tenantContext = tenantContext; _appDb = tenantContext.DbContext.AppDb; @@ -80,9 +76,7 @@ public Match(ITenantContext tenantContext, IStringLocalizer localizer, _sendMailTask = sendMailTask; _rankingUpdateTask = rankingUpdateTask; _razorViewToStringRenderer = razorViewToStringRenderer; - _configuration = configuration; _logger = logger; - _pathToChromium = Path.Combine(Directory.GetCurrentDirectory(), _configuration["Chromium:ExecutablePath"] ?? string.Empty); } /// @@ -621,135 +615,43 @@ private async Task GetEnterResultViewModel(MatchEntity mat /// if the match has not already been played. /// /// - /// Expected to be greater than DateTime.UtcNow.AddHours(-12).Ticks + /// The cache injected from services. /// /// A match report sheet suitable for a printout, if the match has not already been played. - [HttpGet("[action]/{id:long}/{val:long}")] - public async Task ReportSheet(long id, long val, CancellationToken cancellationToken) + [HttpGet("[action]/{id:long}")] + public async Task ReportSheet(long id, [FromServices] ReportSheetCache cache, CancellationToken cancellationToken) { - // Prevent crawlers to download report sheets (link is valid for 12 hours) - if (!(val >= DateTime.UtcNow.AddHours(-12).Ticks && val <= DateTime.UtcNow.Ticks)) - return Redirect(TenantLink.Action(nameof(Fixtures), nameof(Match))!); - MatchReportSheetRow? model = null; try { model = await _appDb.MatchRepository.GetMatchReportSheetAsync(_tenantContext.TournamentContext.MatchPlanTournamentId, id, cancellationToken); - + if (model == null) return NotFound(); - if (System.IO.File.Exists(_pathToChromium)) - { - var contentDisposition = new Microsoft.Net.Http.Headers.ContentDispositionHeaderValue("attachment"); - contentDisposition.SetHttpFileName($"{_localizer["Report Sheet"].Value}_{model.Id}.pdf"); - Response.Headers[Microsoft.Net.Http.Headers.HeaderNames.ContentDisposition] = - contentDisposition.ToString(); + var contentDisposition = new Microsoft.Net.Http.Headers.ContentDispositionHeaderValue("attachment"); + contentDisposition.SetHttpFileName($"{_localizer["Report Sheet"].Value}_{model.Id}.pdf"); + Response.Headers[Microsoft.Net.Http.Headers.HeaderNames.ContentDisposition] = + contentDisposition.ToString(); - var html = await _razorViewToStringRenderer.RenderViewToStringAsync( - $"~/Views/{nameof(Match)}/{ViewNames.Match.ReportSheet}.cshtml", model); + var html = await _razorViewToStringRenderer.RenderViewToStringAsync( + $"~/Views/{nameof(Match)}/{ViewNames.Match.ReportSheet}.cshtml", model); - //return await GetReportSheetPuppeteer(html); - return await GetReportSheetChromium(html); - } + //var cache = new ReportSheetCache(_tenantContext, _configuration, _webHostEnvironment); + var stream = await cache.GetOrCreatePdf(model, html, cancellationToken); + _logger.LogInformation("PDF file returned for tenant '{Tenant}' and match id '{MatchId}'", _tenantContext.Identifier, id); + return new FileStreamResult(stream, "application/pdf"); } catch (Exception e) { _logger.LogCritical(e, "{method} failed for match ID '{matchId}'", nameof(ReportSheet), id); } - // without Chromium installed or throwing exception: return HTML + // Not able to render report sheet as PDF: return HTML Response.Clear(); return View(ViewNames.Match.ReportSheet, model); } - - private async Task GetReportSheetChromium(string html) - { - // Create folder in TempPath - var tempFolder = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - if (!Directory.Exists(tempFolder)) Directory.CreateDirectory(tempFolder); - - // Temporary file with HTML content - extension must be ".html"! - var htmlFile = Path.Combine(tempFolder, Path.GetRandomFileName() + ".html"); - await System.IO.File.WriteAllTextAsync(htmlFile, html, CancellationToken.None); - var htmlUri = new Uri(htmlFile).AbsoluteUri; - - // Temporary file for the PDF stream form Chromium - var streamFile = Path.Combine(tempFolder, Path.GetRandomFileName() + ".pdf"); - - // Run Chromium - // Command line switches overview: https://kapeli.com/cheat_sheets/Chromium_Command_Line_Switches.docset/Contents/Resources/Documents/index - var startInfo = new System.Diagnostics.ProcessStartInfo(_pathToChromium, - $"--allow-pre-commit-input --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-sync --enable-automation --enable-blink-features=IdleDetection --enable-features=NetworkServiceInProcess2 --export-tagged-pdf --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --headless --hide-scrollbars --mute-audio --no-sandbox --disable-gpu --use-cmd-decoder=passthrough --no-margins --user-data-dir={tempFolder} --print-to-pdf={streamFile} {htmlUri}") - {CreateNoWindow = true, UseShellExecute = false}; - var proc = System.Diagnostics.Process.Start(startInfo); - - if (proc is null) - { - _logger.LogCritical("Process '{PathToChromium}' could not be started.", _pathToChromium); - throw new InvalidOperationException($"Process '{_pathToChromium}' could not be started."); - } - - const int timeout = 8000; - var timePassed = 0; - while (!proc.HasExited) - { - timePassed += 100; - await Task.Delay(100, default); - if (timePassed < timeout) continue; - - proc.Kill(true); - throw new OperationCanceledException($"Chromium timed out after {timeout}ms."); - } - - var streamFileInfo = new FileInfo(streamFile); - _logger.LogInformation("Chromium exit code: {ExitCode}. File info: {Path} - {Size} bytes", proc.ExitCode, streamFileInfo.FullName, streamFileInfo.Length); - var stream = System.IO.File.OpenRead(streamFile); - return new FileStreamResult(stream, "application/pdf"); - } - - private async Task GetReportSheetPuppeteer(string html) - { - var options = new LaunchOptions - { - Headless = true, - Product = Product.Chrome, - // Alternative: --use-cmd-decoder=validating - Args = new[] - { "--no-sandbox", "--disable-gpu", "--disable-extensions", "--use-cmd-decoder=passthrough" }, - ExecutablePath = _pathToChromium, - Timeout = 5000 - }; - // Use Puppeteer as a wrapper for the browser, which can generate PDF from HTML - // Start command line arguments set by Puppeteer: - // --allow-pre-commit-input --disable-background-networking --disable-background-timer-throttling --disable-backgrounding-occluded-windows --disable-breakpad --disable-client-side-phishing-detection --disable-component-extensions-with-background-pages --disable-component-update --disable-default-apps --disable-dev-shm-usage --disable-extensions --disable-features=Translate,BackForwardCache,AcceptCHFrame,MediaRouter,OptimizationHints --disable-hang-monitor --disable-ipc-flooding-protection --disable-popup-blocking --disable-prompt-on-repost --disable-renderer-backgrounding --disable-sync --enable-automation --enable-blink-features=IdleDetection --enable-features=NetworkServiceInProcess2 --export-tagged-pdf --force-color-profile=srgb --metrics-recording-only --no-first-run --password-store=basic --use-mock-keychain --headless - await using var browser = await Puppeteer.LaunchAsync(options).ConfigureAwait(false); - await using var page = await browser.NewPageAsync().ConfigureAwait(false); - - await page.SetContentAsync(html); // Bootstrap 5 is loaded from CDN - await page.EvaluateExpressionHandleAsync("document.fonts.ready"); // Wait for fonts to be loaded. Omitting this might result in no text rendered in pdf. - - _logger.LogInformation("Chromium Start arguments: {commandLineArgs}", browser.Process?.StartInfo.Arguments); - - // browser.Process?.Refresh(); - //_logger.LogInformation("Chromium Process physical memory: {physicalMemory:#,0} bytes.", browser.Process?.WorkingSet64); - - // Test, whether the chromium browser renders at all - /* return new FileStreamResult( - await page.ScreenshotStreamAsync(new PuppeteerSharp.ScreenshotOptions - {FullPage = true, Quality = 100, Type = ScreenshotType.Jpeg}).ConfigureAwait(false), - "image/jpeg"); - */ - - // Todo: This part works on the development machine, but throws on the external web server - var result = new FileStreamResult( - await page.PdfStreamAsync(new PuppeteerSharp.PdfOptions - { Scale = 1.0M, Format = PaperFormat.A4 }).ConfigureAwait(false), - "application/pdf"); - _logger.LogInformation("PDF stream created with length {length}", result.FileStream.Length); - return result; - } - + private void SendFixtureNotification(long matchId) { _sendMailTask.SetMessageCreator(new ChangeFixtureCreator diff --git a/League/LeagueStartup.cs b/League/LeagueStartup.cs index 3d5bd3cd..372edaa4 100644 --- a/League/LeagueStartup.cs +++ b/League/LeagueStartup.cs @@ -39,6 +39,7 @@ using Axuno.BackgroundTask; using Axuno.VirtualFileSystem; using League.BackgroundTasks; +using League.Caching; using League.ConfigurationPoco; using League.MultiTenancy; using Microsoft.AspNetCore.Authorization; @@ -199,10 +200,8 @@ public static void ConfigureServices(WebHostBuilderContext context, IServiceColl return factory.GetUrlHelper(actionContext); }); // TenantLink simplifies tenant-specific path/uri generation - services.AddScoped(sp => - new TenantLink(sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService())); + services.AddScoped(); + services.AddScoped(); services.AddMemoryCache(); // Adds a default in-memory cache implementation // MUST be before AddMvc! diff --git a/League/Views/Match/Fixtures.cshtml b/League/Views/Match/Fixtures.cshtml index 5aae10d1..36b3e842 100644 --- a/League/Views/Match/Fixtures.cshtml +++ b/League/Views/Match/Fixtures.cshtml @@ -1,11 +1,12 @@ @using System.Globalization -@using League.Views @using Microsoft.AspNetCore.Mvc.Localization @using TournamentManager.DAL.EntityClasses @using TournamentManager.DAL.TypedViewClasses @using TournamentManager.MultiTenancy +@using League.MultiTenancy @inject IViewLocalizer Localizer @inject ITenantContext TenantContext +@inject TenantLink TenantLink @model League.Models.MatchViewModels.FixturesViewModel @{ ViewData["Title"] = Localizer["Fixtures"].Value + " - " + Model.Tournament?.Name; @@ -110,7 +111,7 @@