From 16fc031c3116b33a85c2ab9ef77590ca97933773 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 12 Dec 2022 14:49:10 +0100 Subject: [PATCH] Cache improvements. (#954) * Cache improvements. * Also trim windows path separator. * Improve scheduler. --- backend/i18n/frontend_en.json | 2 + backend/i18n/frontend_it.json | 2 + backend/i18n/frontend_nl.json | 2 + backend/i18n/frontend_pt.json | 6 + backend/i18n/frontend_zh.json | 2 + backend/i18n/source/frontend_en.json | 2 + .../Assets/AssetsFluidExtension.cs | 2 +- .../Contents/ReferencesFluidExtension.cs | 2 +- .../Squidex.Infrastructure/Tasks/Scheduler.cs | 47 ++-- backend/src/Squidex.Shared/Texts.pt.resx | 6 +- .../src/Squidex.Web/IgnoreHashFileProvider.cs | 94 +++++++ backend/src/Squidex/Areas/Frontend/Startup.cs | 7 +- .../Views/Account/Consent.cshtml | 8 +- .../IdentityServer/Views/Account/Login.cshtml | 86 +++---- .../IdentityServer/Views/Error/Error.cshtml | 14 +- .../Views/Profile/Profile.cshtml | 56 ++-- .../IdentityServer/Views/Setup/Setup.cshtml | 202 +++++++-------- .../Areas/IdentityServer/Views/_Layout.cshtml | 8 +- .../Tasks/SchedulerTests.cs | 39 +-- .../IgnoreHashFileProviderTests.cs | 242 ++++++++++++++++++ .../framework/angular/global-error-handler.ts | 33 +++ frontend/src/app/framework/declarations.ts | 7 +- frontend/src/app/framework/module.ts | 9 +- frontend/src/config/webpack.config.js | 29 --- 24 files changed, 633 insertions(+), 274 deletions(-) create mode 100644 backend/src/Squidex.Web/IgnoreHashFileProvider.cs create mode 100644 backend/tests/Squidex.Web.Tests/IgnoreHashFileProviderTests.cs create mode 100644 frontend/src/app/framework/angular/global-error-handler.ts diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index 255a2c651e..668d8fe72c 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -282,6 +282,8 @@ "common.errorBack": "Back to previous page.", "common.errorNoPermission": "You do not have the permissions to do this.", "common.errorNotFound": "Not Found", + "common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?", + "common.errors.chunkLoadingTitle": "Failed to load chunk", "common.event": "Event", "common.events": "Events", "common.executed": "Executed", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index bf5fcd697b..cddab1b9a9 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -282,6 +282,8 @@ "common.errorBack": "Torna alla pagina precedente.", "common.errorNoPermission": "Non hai i permessi per questo.", "common.errorNotFound": "Non trovato", + "common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?", + "common.errors.chunkLoadingTitle": "Failed to load chunk", "common.event": "Evento", "common.events": "Eventi", "common.executed": "Eseguito", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index 6cfcc8cb49..378fae50d4 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -282,6 +282,8 @@ "common.errorBack": "Terug naar de vorige pagina.", "common.errorNoPermission": "Je hebt niet de permissies om dit te doen.", "common.errorNotFound": "Niet gevonden", + "common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?", + "common.errors.chunkLoadingTitle": "Failed to load chunk", "common.event": "Evenement", "common.events": "Evenementen", "common.executed": "Uitgevoerd", diff --git a/backend/i18n/frontend_pt.json b/backend/i18n/frontend_pt.json index 93256083be..50ff4bc6e3 100644 --- a/backend/i18n/frontend_pt.json +++ b/backend/i18n/frontend_pt.json @@ -188,12 +188,16 @@ "clients.connectWizard.manuallyTokenHint": "Tokens normalmente expiram após 30 dias, mas você pode solicitar várias tokens.", "clients.connectWizard.postManDocs": "Comece com o tutorial do Carteiro na [Documentação](https://docs.squidex.io/02-documentation/developer-guides/api-overview/postman).", "clients.connectWizard.sdk": "Conecte-se à sua App com a SDK", + "clients.connectWizard.sdkDocumentation": "Documentations for the .NET SDK is available: ", "clients.connectWizard.sdkHelp": "Precisa de outro SDK?", "clients.connectWizard.sdkHelpLink": "Contacte-nos no Fórum de Apoio", "clients.connectWizard.sdkHint": "Descarregue um SDK e estabeleça uma ligação a esta aplicação.", "clients.connectWizard.sdkStep1": "Instale o .NET SDK", "clients.connectWizard.sdkStep1Download": "O SDK está disponível em [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary/)", "clients.connectWizard.sdkStep2": "Criar um gestor de clientes", + "clients.connectWizard.sdkStep3": "Optionally: Install the Service Extensions for the SDK", + "clients.connectWizard.sdkStep3Download": "The SDK Extension is available on [nuget](https://www.nuget.org/packages/Squidex.ClientLibrary.ServiceExtensions/)", + "clients.connectWizard.sdkStep4": "Optionally: Register the client manager and all clients", "clients.connectWizard.step0Title": "Cliente de configuração", "clients.connectWizard.step1Title": "Escolha o método de ligação", "clients.connectWizard.step2Title": "Ligar", @@ -278,6 +282,8 @@ "common.errorBack": "De volta à página anterior.", "common.errorNoPermission": "Não tem as permissões para fazer isto.", "common.errorNotFound": "Não encontrado", + "common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?", + "common.errors.chunkLoadingTitle": "Failed to load chunk", "common.event": "Evento", "common.events": "Eventos", "common.executed": "Executado", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index fde0b2fa96..374b3f2d8b 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -282,6 +282,8 @@ "common.errorBack": "返回上一页。", "common.errorNoPermission": "您无权执行此操作。", "common.errorNotFound": "未找到", + "common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?", + "common.errors.chunkLoadingTitle": "Failed to load chunk", "common.event": "事件", "common.events": "事件", "common.executed": "已执行", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 255a2c651e..668d8fe72c 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -282,6 +282,8 @@ "common.errorBack": "Back to previous page.", "common.errorNoPermission": "You do not have the permissions to do this.", "common.errorNotFound": "Not Found", + "common.errors.chunkLoadingText": "Failed to load necessary javascript files. Very likely a new very version has been deployed. Do you want to reload?", + "common.errors.chunkLoadingTitle": "Failed to load chunk", "common.event": "Event", "common.events": "Events", "common.executed": "Executed", diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs index 1a4c16056b..bfcf36f429 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs @@ -36,7 +36,7 @@ public void RegisterLanguageExtensions(CustomFluidParser parser, TemplateOptions AddAssetFilter(options); AddAssetTextFilter(options); - parser.RegisterParserTag("asset", + parser.RegisterParserTag("asset", parser.PrimaryParser.AndSkip(ZeroOrOne(parser.CommaParser)).And(parser.PrimaryParser), ResolveAsset); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs index bf4e01cce1..89f1d43a4b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs @@ -34,7 +34,7 @@ public void RegisterLanguageExtensions(CustomFluidParser parser, TemplateOptions AddReferenceFilter(options); parser.RegisterParserTag("reference", - parser.PrimaryParser.AndSkip(ZeroOrOne(parser.CommaParser)).And(parser.PrimaryParser), + parser.PrimaryParser.AndSkip(ZeroOrOne(parser.CommaParser)).And(parser.PrimaryParser), ResolveReference); } diff --git a/backend/src/Squidex.Infrastructure/Tasks/Scheduler.cs b/backend/src/Squidex.Infrastructure/Tasks/Scheduler.cs index 9067d5c2fd..bd0240d8c1 100644 --- a/backend/src/Squidex.Infrastructure/Tasks/Scheduler.cs +++ b/backend/src/Squidex.Infrastructure/Tasks/Scheduler.cs @@ -13,6 +13,8 @@ namespace Squidex.Infrastructure.Tasks; public sealed class Scheduler { + private const int SpecialStateStartedOrDone = 1; + private const int SpecialStateCompleted = -1; private readonly TaskCompletionSource tcs = new TaskCompletionSource(); private readonly SemaphoreSlim semaphore; private List? tasks; @@ -30,7 +32,7 @@ public Scheduler(int maxDegreeOfParallelism = 0) public void Schedule(SchedulerTask task) { - if (pendingTasks < 0) + if (pendingTasks <= SpecialStateCompleted) { // Already completed. return; @@ -39,7 +41,7 @@ public void Schedule(SchedulerTask task) if (pendingTasks >= 1) { // If we already in a tasks we just queue it with the semaphore. - ScheduleTask(task, default).Forget(); + ScheduleTasks(new[] { task }, default); return; } @@ -50,50 +52,35 @@ public void Schedule(SchedulerTask task) public async ValueTask CompleteAsync( CancellationToken ct = default) { - if (tasks == null || tasks.Count == 0) + // Do not allow another completion call. + if (tasks == null || pendingTasks <= SpecialStateCompleted) { return; } // Use the value to indicate that the task have been started. - pendingTasks = 1; + pendingTasks = SpecialStateStartedOrDone; try { - RunTasks(ct).AsTask().Forget(); - + ScheduleTasks(tasks, ct); await tcs.Task; } finally { - pendingTasks = -1; + pendingTasks = SpecialStateCompleted; } } - private async ValueTask RunTasks( + private void ScheduleTasks(IReadOnlyCollection taskToSchedule, CancellationToken ct) { - // If nothing needs to be done, we can just stop here. - if (tasks == null || tasks.Count == 0) - { - tcs.TrySetResult(true); - return; - } + // Increment the pending tasks once, so we avoid issues when the tasks are executed sequentially. + Interlocked.Add(ref pendingTasks, taskToSchedule.Count); - // Quick check to avoid the allocation of the list. - if (tasks.Count == 1) + foreach (var task in taskToSchedule) { - await ScheduleTask(tasks[0], ct); - return; + ScheduleTask(task, ct).Forget(); } - - var runningTasks = new List(); - - foreach (var validationTask in tasks) - { - runningTasks.Add(ScheduleTask(validationTask, ct)); - } - - await Task.WhenAll(runningTasks); } private async Task ScheduleTask(SchedulerTask task, @@ -101,9 +88,7 @@ public void Schedule(SchedulerTask task) { try { - // Use the interlock to reduce degree of parallelization. - Interlocked.Increment(ref pendingTasks); - + // Use the semaphore to reduce degree of parallelization. await semaphore.WaitAsync(ct); await task(ct); } @@ -115,7 +100,7 @@ public void Schedule(SchedulerTask task) { semaphore.Release(); - if (Interlocked.Decrement(ref pendingTasks) <= 1) + if (Interlocked.Decrement(ref pendingTasks) <= SpecialStateStartedOrDone) { tcs.TrySetResult(true); } diff --git a/backend/src/Squidex.Shared/Texts.pt.resx b/backend/src/Squidex.Shared/Texts.pt.resx index be0d55f602..e15f3802ba 100644 --- a/backend/src/Squidex.Shared/Texts.pt.resx +++ b/backend/src/Squidex.Shared/Texts.pt.resx @@ -730,6 +730,9 @@ cliente {[Id]} actualizado + + updated general settings + associado {user:[Contributor]} a {[Role]} @@ -778,9 +781,6 @@ actualizada app ao cliente - - actualizadas configurações gerais - adicionado fluxo de trabalho {[Name]}. diff --git a/backend/src/Squidex.Web/IgnoreHashFileProvider.cs b/backend/src/Squidex.Web/IgnoreHashFileProvider.cs new file mode 100644 index 0000000000..8f08a059b7 --- /dev/null +++ b/backend/src/Squidex.Web/IgnoreHashFileProvider.cs @@ -0,0 +1,94 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Text.RegularExpressions; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace Squidex.Web; + +public sealed class IgnoreHashFileProvider : IFileProvider +{ + private readonly char[] pathSeparators = { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar, '\\' }; + private readonly Dictionary map = new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly IFileProvider inner; + + public IgnoreHashFileProvider(IFileProvider inner) + { + this.inner = inner; + + var regex = new Regex("^(?[^.]+)\\.[0-9a-f]{4,}\\.(?.+)$"); + + void MapDirectory(string path) + { + foreach (var file in inner.GetDirectoryContents(path)) + { + if (file.IsDirectory) + { + MapDirectory(Combine(path, file.Name)); + continue; + } + + var match = regex.Match(file.Name); + + if (match.Success) + { + var nameWithouthHash = $"{match.Groups["Name"].Value}.{match.Groups["Extension"].Value}"; + + var pathHashed = Combine(path, file.Name); + var pathNormal = Combine(path, nameWithouthHash); + + map[pathNormal] = pathHashed; + } + } + } + + MapDirectory(string.Empty); + } + + public IFileInfo GetFileInfo(string subpath) + { + var file = inner.GetFileInfo(subpath); + + if (!file.Exists) + { + subpath = subpath.TrimStart(pathSeparators).Replace('\\', '/'); + + if (map.TryGetValue(subpath, out var withHash)) + { + file = inner.GetFileInfo(withHash); + } + } + + return file; + } + + public IDirectoryContents GetDirectoryContents(string subpath) + { + return inner.GetDirectoryContents(subpath); + } + + public IChangeToken Watch(string filter) + { + return inner.Watch(filter); + } + + private static string Combine(string path1, string path2) + { + if (string.IsNullOrWhiteSpace(path1)) + { + return path2; + } + + if (string.IsNullOrWhiteSpace(path2)) + { + return path1; + } + + return $"{path1}/{path2}"; + } +} diff --git a/backend/src/Squidex/Areas/Frontend/Startup.cs b/backend/src/Squidex/Areas/Frontend/Startup.cs index 864331ddb2..98c313abe4 100644 --- a/backend/src/Squidex/Areas/Frontend/Startup.cs +++ b/backend/src/Squidex/Areas/Frontend/Startup.cs @@ -10,6 +10,7 @@ using Squidex.Areas.Frontend.Middlewares; using Squidex.Hosting.Web; using Squidex.Pipeline.Squid; +using Squidex.Web; using Squidex.Web.Pipeline; namespace Squidex.Areas.Frontend; @@ -26,8 +27,10 @@ public static void UseFrontend(this IApplicationBuilder app) if (!environment.IsDevelopment()) { - fileProvider = new CompositeFileProvider(fileProvider, - new PhysicalFileProvider(Path.Combine(environment.WebRootPath, "build"))); + var buildFolder = new PhysicalFileProvider(Path.Combine(environment.WebRootPath, "build")); + var buildProvider = new IgnoreHashFileProvider(buildFolder); + + fileProvider = new CompositeFileProvider(fileProvider, buildFolder, buildProvider); } app.Map("/squid.svg", builder => diff --git a/backend/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml b/backend/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml index 31b68dd47b..acc02cf783 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml +++ b/backend/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml @@ -5,10 +5,10 @@ } @functions { -public string ErrorClass(string error) -{ -return ViewData.ModelState[error]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid ? "border-danger" : ""; -} + public string ErrorClass(string error) + { + return ViewData.ModelState[error]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid ? "border-danger" : ""; + } }
diff --git a/backend/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml b/backend/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml index 9fe5d6fda0..956ab25864 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml +++ b/backend/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml @@ -3,7 +3,7 @@ @{ var action = Model!.IsLogin ? T.Get("common.login") : T.Get("common.signup"); -ViewBag.Title = action; + ViewBag.Title = action; } @foreach (var provider in Model!.ExternalProviders) -{ - var schema = provider.AuthenticationScheme.ToLowerInvariant(); + { + var schema = provider.AuthenticationScheme.ToLowerInvariant(); -
+
-} + } @if (Model!.HasExternalLogin && Model!.HasPasswordAuth) -{ -
+ { +
@T.Get("users.login.separator")
-} + } @if (Model!.HasPasswordAuth) -{ -if (Model!.IsLogin) -{ - if (Model!.IsFailed) { -
@T.Get("users.login.error")
- } + if (Model!.IsLogin) + { + if (Model!.IsFailed) + { +
@T.Get("users.login.error")
+ } -
+
@@ -70,31 +70,31 @@ if (Model!.IsLogin)
-} -else -{ - -} -} + } + else + { + + } + } @if (Model!.IsLogin) -{ - -} -else -{ - -} + }
\ No newline at end of file diff --git a/backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml b/backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml index 131624e5ba..60b248f2c3 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml +++ b/backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml @@ -10,11 +10,11 @@

@if (Model!.ErrorMessage != null) -{ - @Model!.ErrorMessage -} -else -{ - @T.Get("users.error.text") -} + { + @Model!.ErrorMessage + } + else + { + @T.Get("users.error.text") + }

\ No newline at end of file diff --git a/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml b/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml index 737f930bf0..7037481206 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml +++ b/backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml @@ -3,15 +3,15 @@ @{ ViewBag.Title = T.Get("users.profile.title"); -void RenderValidation(string field) -{ - @if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid) -{ -
+ void RenderValidation(string field) + { + @if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid) + { +
@Html.ValidationMessage(field)
-} -} + } + } }

@T.Get("users.profile.headline")

@@ -89,8 +89,8 @@ void RenderValidation(string field) @foreach (var login in Model!.ExternalLogins) - { - + { + @login.LoginProvider @@ -99,8 +99,8 @@ void RenderValidation(string field) @if (Model!.ExternalLogins.Count > 1 || Model!.HasPassword) - { -
+ { + @@ -108,21 +108,21 @@ void RenderValidation(string field) @T.Get("common.remove")
- } + } - } + }
@foreach (var provider in Model!.ExternalProviders.Where(x => Model!.ExternalLogins.All(y => x.AuthenticationScheme != y.LoginProvider))) - { - var schema = provider.AuthenticationScheme.ToLowerInvariant(); + { + var schema = provider.AuthenticationScheme.ToLowerInvariant(); - - } + }
} @@ -134,9 +134,9 @@ void RenderValidation(string field)

@T.Get("users.profile.passwordTitle")

- @if (Model!.HasPassword) -{ -
+ @if (Model!.HasPassword) + { +
@@ -165,10 +165,10 @@ void RenderValidation(string field)
-} -else -{ -
+ } + else + { +
@@ -189,7 +189,7 @@ else
-} + }
} @@ -233,8 +233,8 @@ else
@for (var i = 0; i < Model!.Properties.Count; i++) - { -
+ { +
@{ RenderValidation($"Properties[{i}].Name"); } @@ -253,7 +253,7 @@ else
- } + }
diff --git a/backend/src/Squidex/Areas/IdentityServer/Views/Setup/Setup.cshtml b/backend/src/Squidex/Areas/IdentityServer/Views/Setup/Setup.cshtml index 9ca2d036c4..eb6b6b95cf 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Views/Setup/Setup.cshtml +++ b/backend/src/Squidex/Areas/IdentityServer/Views/Setup/Setup.cshtml @@ -3,66 +3,66 @@ @{ ViewBag.Title = T.Get("setup.title"); -void RenderValidation(string field) -{ - @if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid) -{ -
- @Html.ValidationMessage(field) -
-} -} - -void RenderRuleAsSuccess(string message) -{ -
-
-
- + void RenderValidation(string field) + { + @if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid) + { +
+ @Html.ValidationMessage(field) +
+ } + } + + void RenderRuleAsSuccess(string message) + { +
+
+
+ +
-
-
-
- @Html.Raw(message) +
+
+ @Html.Raw(message) +
-
-} - -void RenderRuleAsCritical(string message) -{ -
-
-
- + } + + void RenderRuleAsCritical(string message) + { +
+
+
+ +
-
-
-
- @T.Get("common.critical"): @Html.Raw(message) +
+
+ @T.Get("common.critical"): @Html.Raw(message) +
-
-} - -void RenderRuleAsWarning(string message) -{ -
-
-
- + } + + void RenderRuleAsWarning(string message) + { +
+
+
+ +
-
-
-
- @T.Get("common.warning"): @Html.Raw(message) +
+
+ @T.Get("common.warning"): @Html.Raw(message) +
-
-} + } }

@T.Get("setup.headline")

@@ -77,50 +77,50 @@ void RenderRuleAsWarning(string message)

@T.Get("setup.rules.headline")

@if (Model!.IsValidHttps) -{ -RenderRuleAsSuccess(T.Get("setup.ruleHttps.success")); -} -else -{ -RenderRuleAsCritical(T.Get("setup.ruleHttps.failure")); -} + { + RenderRuleAsSuccess(T.Get("setup.ruleHttps.success")); + } + else + { + RenderRuleAsCritical(T.Get("setup.ruleHttps.failure")); + } @if (Model!.BaseUrlConfigured == Model!.BaseUrlCurrent) -{ -RenderRuleAsSuccess(T.Get("setup.ruleUrl.success")); -} -else -{ -RenderRuleAsCritical(T.Get("setup.ruleUrl.failure", new { actual = Model!.BaseUrlCurrent, configured = Model!.BaseUrlConfigured })); -} + { + RenderRuleAsSuccess(T.Get("setup.ruleUrl.success")); + } + else + { + RenderRuleAsCritical(T.Get("setup.ruleUrl.failure", new { actual = Model!.BaseUrlCurrent, configured = Model!.BaseUrlConfigured })); + } @if (Model!.EverybodyCanCreateApps) -{ -RenderRuleAsWarning(T.Get("setup.ruleAppCreation.warningAdmins")); -} -else -{ -RenderRuleAsWarning(T.Get("setup.ruleAppCreation.warningAll")); -} + { + RenderRuleAsWarning(T.Get("setup.ruleAppCreation.warningAdmins")); + } + else + { + RenderRuleAsWarning(T.Get("setup.ruleAppCreation.warningAll")); + } @if (Model!.EverybodyCanCreateTeams) -{ -RenderRuleAsWarning(T.Get("setup.ruleTeamCreation.warningAdmins")); -} -else -{ -RenderRuleAsWarning(T.Get("setup.ruleTeamCreation.warningAll")); -} + { + RenderRuleAsWarning(T.Get("setup.ruleTeamCreation.warningAdmins")); + } + else + { + RenderRuleAsWarning(T.Get("setup.ruleTeamCreation.warningAll")); + } @if (Model!.IsAssetStoreFtp) -{ -RenderRuleAsWarning(T.Get("setup.ruleFtp.warning")); -} + { + RenderRuleAsWarning(T.Get("setup.ruleFtp.warning")); + } @if (Model!.IsAssetStoreFile) -{ -RenderRuleAsWarning(T.Get("setup.ruleFolder.warning")); -} + { + RenderRuleAsWarning(T.Get("setup.ruleFolder.warning")); + }

@@ -142,24 +142,24 @@ RenderRuleAsWarning(T.Get("setup.ruleFolder.warning")); } @if (Model!.HasExternalLogin && Model!.HasPasswordAuth) -{ -
-
@T.Get("setup.createUser.separator")
-
-} + { +
+
@T.Get("setup.createUser.separator")
+
+ } @if (Model!.HasPasswordAuth) -{ -

@T.Get("setup.createUser.headlineCreate")

+ { +

@T.Get("setup.createUser.headlineCreate")

- @if (!string.IsNullOrWhiteSpace(Model!.ErrorMessage)) -{ -
- @Model!.ErrorMessage -
-} + @if (!string.IsNullOrWhiteSpace(Model!.ErrorMessage)) + { +
+ @Model!.ErrorMessage +
+ } - +
@@ -188,12 +188,12 @@ RenderRuleAsWarning(T.Get("setup.ruleFolder.warning"));
-} + } @if (!Model!.HasExternalLogin && !Model!.HasPasswordAuth) -{ -
+ { +
@T.Get("setup.createUser.failure") -
-} +
+ }
\ No newline at end of file diff --git a/backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml b/backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml index 8d4fe3ebf9..6b28897797 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml +++ b/backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml @@ -8,12 +8,12 @@ @ViewBag.Title - @T.Get("common.product") - + @if (IsSectionDefined("header")) -{ - @await RenderSectionAsync("header") -} + { + @await RenderSectionAsync("header") + } diff --git a/backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs index b11f25eb06..4dba900a78 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs @@ -12,12 +12,12 @@ namespace Squidex.Infrastructure.Tasks; public class SchedulerTests { private readonly ConcurrentBag actuals = new ConcurrentBag(); - private readonly Scheduler sut = new Scheduler(); + private Scheduler sut = new Scheduler(); [Fact] public async Task Should_schedule_single_task() { - Schedule(1); + ScheduleAsync(1, sut); await sut.CompleteAsync(); @@ -31,7 +31,7 @@ public async Task Should_schedule_lot_of_tasks_with_limited_concurrency() for (var i = 1; i <= 10; i++) { - Schedule(i, limited); + ScheduleAsync(i, limited); } await limited.CompleteAsync(); @@ -42,8 +42,19 @@ public async Task Should_schedule_lot_of_tasks_with_limited_concurrency() [Fact] public async Task Should_schedule_multiple_tasks() { - Schedule(1); - Schedule(2); + ScheduleAsync(1, sut); + ScheduleAsync(2, sut); + + await sut.CompleteAsync(); + + Assert.Equal(new[] { 1, 2 }, actuals.OrderBy(x => x).ToArray()); + } + + [Fact] + public async Task Should_schedule_multiple_synchronous_tasks() + { + Schedule(1, sut); + Schedule(2, sut); await sut.CompleteAsync(); @@ -65,7 +76,7 @@ public async Task Should_schedule_nested_tasks() actuals.Add(2); - Schedule(3); + ScheduleAsync(3, sut); }); }); @@ -77,20 +88,18 @@ public async Task Should_schedule_nested_tasks() [Fact] public async Task Should_ignore_schedule_after_completion() { - Schedule(1); + ScheduleAsync(1, sut); await sut.CompleteAsync(); - Schedule(3); - - await Task.Delay(50); + ScheduleAsync(3, sut); Assert.Equal(new[] { 1 }, actuals.OrderBy(x => x).ToArray()); } - private void Schedule(int value) + private void ScheduleAsync(int value, Scheduler target) { - sut.Schedule(async _ => + target.Schedule(async _ => { await Task.Delay(1); @@ -100,11 +109,11 @@ private void Schedule(int value) private void Schedule(int value, Scheduler target) { - target.Schedule(async _ => + target.Schedule(_ => { - await Task.Delay(1); - actuals.Add(value); + + return Task.CompletedTask; }); } } diff --git a/backend/tests/Squidex.Web.Tests/IgnoreHashFileProviderTests.cs b/backend/tests/Squidex.Web.Tests/IgnoreHashFileProviderTests.cs new file mode 100644 index 0000000000..8a40c1259b --- /dev/null +++ b/backend/tests/Squidex.Web.Tests/IgnoreHashFileProviderTests.cs @@ -0,0 +1,242 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.FileProviders; + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Web; + +public class IgnoreHashFileProviderTests +{ + private readonly IFileProvider inner = A.Fake(); + + [Fact] + public void Should_get_file_from_inner() + { + var fileNormal = CreateFile("styles.css"); + + var sut = CreateSut(); + + A.CallTo(() => inner.GetFileInfo(fileNormal.Name)) + .Returns(fileNormal); + + var actual = sut.GetFileInfo(fileNormal.Name); + + Assert.Equal(fileNormal, actual); + } + + [Theory] + [InlineData(@"\styles.css")] + [InlineData(@"/styles.css")] + public void Should_get_file_from_hashed_version_if_normal_file_does_not_exist(string path) + { + var fileNormal = CreateFile("styles.css", exists: false); + var fileHashed = CreateFile("styles.42efefef.css"); + + var directories = new[] + { + (string.Empty, + new[] + { + fileHashed + } + ) + }; + + var sut = CreateSut(directories); + + A.CallTo(() => inner.GetFileInfo(path)) + .Returns(fileNormal); + + A.CallTo(() => inner.GetFileInfo(fileHashed.Name)) + .Returns(fileHashed); + + var actual = sut.GetFileInfo(path); + + Assert.Equal(fileHashed, actual); + } + + [Theory] + [InlineData(@"build/styles.css")] + [InlineData(@"build\styles.css")] + [InlineData(@"\build\styles.css")] + [InlineData(@"/build/styles.css")] + public void Should_get_nested_file_from_hashed_version_if_normal_file_does_not_exist(string path) + { + var directory = CreateFile("build", directory: true); + + var fileNormal = CreateFile("styles.css", exists: false); + var fileHashed = CreateFile("styles.42efefef.css"); + + var directories = new[] + { + (string.Empty, + new[] + { + directory + } + ), + (directory.Name, + new[] + { + fileHashed + } + ) + }; + + var sut = CreateSut(directories); + + A.CallTo(() => inner.GetFileInfo(path)) + .Returns(fileNormal); + + A.CallTo(() => inner.GetFileInfo($"build/{fileHashed.Name}")) + .Returns(fileHashed); + + var actual = sut.GetFileInfo(path); + + Assert.Equal(fileHashed, actual); + } + + [Fact] + public void Should_not_get_file_from_hashed_version_if_normal_file_exists() + { + var fileNormal = CreateFile("styles.css"); + var fileHashed = CreateFile("styles.42efefef.css"); + + var directories = new[] + { + (string.Empty, + new[] + { + fileNormal + } + ) + }; + + var sut = CreateSut(directories); + + A.CallTo(() => inner.GetFileInfo(fileNormal.Name)) + .Returns(fileNormal); + + A.CallTo(() => inner.GetFileInfo(fileHashed.Name)) + .Returns(fileHashed); + + var actual = sut.GetFileInfo(fileNormal.Name); + + Assert.Equal(fileNormal, actual); + } + + [Fact] + public void Should_not_get_file_from_hashed_version_if_normal_file_is_directory() + { + var fileNormal = CreateFile("styles.css", directory: true); + var fileHashed = CreateFile("styles.42efefef.css"); + + var directories = new[] + { + (string.Empty, + new[] + { + fileNormal + } + ) + }; + + var sut = CreateSut(directories); + + A.CallTo(() => inner.GetFileInfo(fileNormal.Name)) + .Returns(fileNormal); + + A.CallTo(() => inner.GetFileInfo(fileHashed.Name)) + .Returns(fileHashed); + + var actual = sut.GetFileInfo(fileNormal.Name); + + Assert.Equal(fileNormal, actual); + } + + [Fact] + public void Should_not_get_file_from_hashed_version_if_not_mapped() + { + var fileNormal = CreateFile("styles.css"); + var fileHashed = CreateFile("styles.42efefef.css"); + + var sut = CreateSut(); + + A.CallTo(() => inner.GetFileInfo(fileNormal.Name)) + .Returns(fileNormal); + + A.CallTo(() => inner.GetFileInfo(fileHashed.Name)) + .Returns(fileHashed); + + var actual = sut.GetFileInfo(fileNormal.Name); + + Assert.Equal(fileNormal, actual); + } + + [Fact] + public void Should_forward_watch_call_to_inner() + { + var sut = CreateSut(); + + sut.Watch("/"); + + A.CallTo(() => inner.Watch("/")) + .MustHaveHappened(); + } + + [Fact] + public void Should_forward_directory_call_to_inner() + { + var sut = CreateSut(); + + sut.GetDirectoryContents("/"); + + A.CallTo(() => inner.GetDirectoryContents("/")) + .MustHaveHappened(); + } + + private IgnoreHashFileProvider CreateSut(params (string Path, IFileInfo[] Files)[] directories) + { + foreach (var directory in directories) + { + A.CallTo(() => inner.GetDirectoryContents(directory.Path)) + .Returns(new DirectoryContents(directory.Files)); + } + + return new IgnoreHashFileProvider(inner); + } + + private static IFileInfo CreateFile(string name, bool exists = true, bool directory = false) + { + return new File(name, exists, directory); + } + + public record File(string Name, bool Exists = true, bool IsDirectory = false, long Length = 100) : IFileInfo +#pragma warning restore SA1313 // Parameter names should begin with lower-case letter + { + public DateTimeOffset LastModified => default; + + public string? PhysicalPath => default; + + public Stream CreateReadStream() + { + throw new NotImplementedException(); + } + } + + private sealed class DirectoryContents : List, IDirectoryContents + { + bool IDirectoryContents.Exists => true; + + public DirectoryContents(IEnumerable files) + : base(files) + { + } + } +} diff --git a/frontend/src/app/framework/angular/global-error-handler.ts b/frontend/src/app/framework/angular/global-error-handler.ts new file mode 100644 index 0000000000..4bcf1715e4 --- /dev/null +++ b/frontend/src/app/framework/angular/global-error-handler.ts @@ -0,0 +1,33 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { ErrorHandler, Injectable, NgZone } from '@angular/core'; +import { DialogService } from './../internal'; + +@Injectable() +export class GlobalErrorHandler implements ErrorHandler { + constructor( + private readonly dialogs: DialogService, + private readonly zone: NgZone, + ) { + } + + public handleError(error: any): void { + const chunkFailedMessage = /Loading chunk [\d]+ failed/; + + if (chunkFailedMessage.test(error.message)) { + this.zone.run(() => { + this.dialogs.confirm('i18n:common.errors.chunkLoadingTitle', 'i18n:common.errors.chunkLoadingText') + .subscribe(() => { + location.reload(); + }); + }); + } + + console.error(error); + } +} \ No newline at end of file diff --git a/frontend/src/app/framework/declarations.ts b/frontend/src/app/framework/declarations.ts index 81d932e7dd..50a47179a4 100644 --- a/frontend/src/app/framework/declarations.ts +++ b/frontend/src/app/framework/declarations.ts @@ -11,8 +11,8 @@ export * from './angular/compensate-scrollbar.directive'; export * from './angular/dropdown-menu.component'; export * from './angular/external-link.directive'; export * from './angular/forms/confirm-click.directive'; -export * from './angular/forms/control-errors.component'; export * from './angular/forms/control-errors-messages.component'; +export * from './angular/forms/control-errors.component'; export * from './angular/forms/copy.directive'; export * from './angular/forms/editable-title.component'; export * from './angular/forms/editors/autocomplete.component'; @@ -40,18 +40,19 @@ export * from './angular/forms/progress-bar.component'; export * from './angular/forms/templated-form-array'; export * from './angular/forms/transform-input.directive'; export * from './angular/forms/validators'; +export * from './angular/global-error-handler'; export * from './angular/hover-background.directive'; export * from './angular/http/caching.interceptor'; export * from './angular/http/http-extensions'; export * from './angular/http/loading.interceptor'; +export * from './angular/if-once.directive'; export * from './angular/image-source.directive'; export * from './angular/image-url.directive'; -export * from './angular/if-once.directive'; export * from './angular/language-selector.component'; -export * from './angular/loader.component'; export * from './angular/layout-container.directive'; export * from './angular/layout.component'; export * from './angular/list-view.component'; +export * from './angular/loader.component'; export * from './angular/markdown.directive'; export * from './angular/modals/dialog-renderer.component'; export * from './angular/modals/modal-dialog.component'; diff --git a/frontend/src/app/framework/module.ts b/frontend/src/app/framework/module.ts index 63ee870957..69ee9d1e17 100644 --- a/frontend/src/app/framework/module.ts +++ b/frontend/src/app/framework/module.ts @@ -7,11 +7,11 @@ import { CommonModule } from '@angular/common'; import { HTTP_INTERCEPTORS } from '@angular/common/http'; -import { ModuleWithProviders, NgModule } from '@angular/core'; +import { ErrorHandler, ModuleWithProviders, NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; import { ColorPickerModule } from 'ngx-color-picker'; -import { AutocompleteComponent, AvatarComponent, CachingInterceptor, CanDeactivateGuard, CheckboxGroupComponent, ClipboardService, CodeComponent, CodeEditorComponent, ColorPickerComponent, CompensateScrollbarDirective, ConfirmClickDirective, ControlErrorsComponent, ControlErrorsMessagesComponent, CopyDirective, DarkenPipe, DatePipe, DateTimeEditorComponent, DayOfWeekPipe, DayPipe, DialogRendererComponent, DialogService, DisplayNamePipe, DropdownComponent, DropdownMenuComponent, DurationPipe, EditableTitleComponent, ExternalLinkDirective, FileDropDirective, FileSizePipe, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, FromNowPipe, FullDateTimePipe, HighlightPipe, HoverBackgroundDirective, IfOnceDirective, ImageSourceDirective, ImageUrlDirective, IndeterminateValueDirective, ISODatePipe, JoinPipe, KeysPipe, KNumberPipe, LanguageSelectorComponent, LayoutComponent, LayoutContainerDirective, LightenPipe, ListViewComponent, LoaderComponent, LoadingInterceptor, LoadingService, LocalizedInputComponent, LocalStoreService, MarkdownDirective, MarkdownInlinePipe, MarkdownPipe, MessageBus, ModalDialogComponent, ModalDirective, ModalPlacementDirective, MonthPipe, OnboardingService, OnboardingTooltipComponent, PagerComponent, ParentLinkDirective, ProgressBarComponent, RadioGroupComponent, ResizedDirective, ResizeService, ResourceLoaderService, RootViewComponent, SafeHtmlPipe, SafeResourceUrlPipe, SafeUrlPipe, ScrollActiveDirective, ShortcutComponent, ShortcutDirective, ShortcutService, ShortDatePipe, ShortTimePipe, StarsComponent, StatusIconComponent, StopClickDirective, StopDragDirective, SyncScollingDirective, SyncWidthDirective, TabRouterlinkDirective, TagEditorComponent, TemplateWrapperDirective, TempService, TitleComponent, TitleService, ToggleComponent, ToolbarComponent, TooltipDirective, TransformInputDirective, TranslatePipe, VideoPlayerComponent } from './declarations'; +import { AutocompleteComponent, AvatarComponent, CachingInterceptor, CanDeactivateGuard, CheckboxGroupComponent, ClipboardService, CodeComponent, CodeEditorComponent, ColorPickerComponent, CompensateScrollbarDirective, ConfirmClickDirective, ControlErrorsComponent, ControlErrorsMessagesComponent, CopyDirective, DarkenPipe, DatePipe, DateTimeEditorComponent, DayOfWeekPipe, DayPipe, DialogRendererComponent, DialogService, DisplayNamePipe, DropdownComponent, DropdownMenuComponent, DurationPipe, EditableTitleComponent, ExternalLinkDirective, FileDropDirective, FileSizePipe, FocusOnInitDirective, FormAlertComponent, FormErrorComponent, FormHintComponent, FromNowPipe, FullDateTimePipe, GlobalErrorHandler, HighlightPipe, HoverBackgroundDirective, IfOnceDirective, ImageSourceDirective, ImageUrlDirective, IndeterminateValueDirective, ISODatePipe, JoinPipe, KeysPipe, KNumberPipe, LanguageSelectorComponent, LayoutComponent, LayoutContainerDirective, LightenPipe, ListViewComponent, LoaderComponent, LoadingInterceptor, LoadingService, LocalizedInputComponent, LocalStoreService, MarkdownDirective, MarkdownInlinePipe, MarkdownPipe, MessageBus, ModalDialogComponent, ModalDirective, ModalPlacementDirective, MonthPipe, OnboardingService, OnboardingTooltipComponent, PagerComponent, ParentLinkDirective, ProgressBarComponent, RadioGroupComponent, ResizedDirective, ResizeService, ResourceLoaderService, RootViewComponent, SafeHtmlPipe, SafeResourceUrlPipe, SafeUrlPipe, ScrollActiveDirective, ShortcutComponent, ShortcutDirective, ShortcutService, ShortDatePipe, ShortTimePipe, StarsComponent, StatusIconComponent, StopClickDirective, StopDragDirective, SyncScollingDirective, SyncWidthDirective, TabRouterlinkDirective, TagEditorComponent, TemplateWrapperDirective, TempService, TitleComponent, TitleService, ToggleComponent, ToolbarComponent, TooltipDirective, TransformInputDirective, TranslatePipe, VideoPlayerComponent } from './declarations'; @NgModule({ imports: [ @@ -217,6 +217,11 @@ export class SqxFrameworkModule { ShortcutService, TempService, TitleService, + { + provide: ErrorHandler, + useClass: GlobalErrorHandler, + multi: false, + }, { provide: HTTP_INTERCEPTORS, useClass: LoadingInterceptor, diff --git a/frontend/src/config/webpack.config.js b/frontend/src/config/webpack.config.js index 7ab5f3bdd2..5dbf38eb2b 100644 --- a/frontend/src/config/webpack.config.js +++ b/frontend/src/config/webpack.config.js @@ -69,34 +69,5 @@ module.exports = (config, _, options) => { ); } - const index = config.plugins.findIndex(x => x instanceof plugins.MiniCssExtractPlugin); - - if (index >= 0) { - config.plugins.splice(index, 1); - } - - config.plugins.push(new plugins.MiniCssExtractPlugin({ - filename: '[name].css', - })); - - /* - * Specifies the name of each output file on disk. - * - * See: https://webpack.js.org/configuration/output/#output-filename - */ - config.output.filename = '[name].js'; - - /* - * The filename of non-entry chunks as relative path inside the output.path directory. - * - * See: https://webpack.js.org/configuration/output/#output-chunkfilename - */ - config.output.chunkFilename = '[id].[fullhash].chunk.js'; - - /* - * The filename for assets. - */ - config.output.assetModuleFilename = 'assets/[hash][ext][query]'; - return config; }; \ No newline at end of file