Browse Source

Cache improvements. (#954)

* Cache improvements.

* Also trim windows path separator.

* Improve scheduler.
pull/955/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
16fc031c31
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/i18n/frontend_en.json
  2. 2
      backend/i18n/frontend_it.json
  3. 2
      backend/i18n/frontend_nl.json
  4. 6
      backend/i18n/frontend_pt.json
  5. 2
      backend/i18n/frontend_zh.json
  6. 2
      backend/i18n/source/frontend_en.json
  7. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs
  9. 47
      backend/src/Squidex.Infrastructure/Tasks/Scheduler.cs
  10. 6
      backend/src/Squidex.Shared/Texts.pt.resx
  11. 94
      backend/src/Squidex.Web/IgnoreHashFileProvider.cs
  12. 7
      backend/src/Squidex/Areas/Frontend/Startup.cs
  13. 8
      backend/src/Squidex/Areas/IdentityServer/Views/Account/Consent.cshtml
  14. 86
      backend/src/Squidex/Areas/IdentityServer/Views/Account/Login.cshtml
  15. 14
      backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml
  16. 56
      backend/src/Squidex/Areas/IdentityServer/Views/Profile/Profile.cshtml
  17. 202
      backend/src/Squidex/Areas/IdentityServer/Views/Setup/Setup.cshtml
  18. 8
      backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml
  19. 39
      backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs
  20. 242
      backend/tests/Squidex.Web.Tests/IgnoreHashFileProviderTests.cs
  21. 33
      frontend/src/app/framework/angular/global-error-handler.ts
  22. 7
      frontend/src/app/framework/declarations.ts
  23. 9
      frontend/src/app/framework/module.ts
  24. 29
      frontend/src/config/webpack.config.js

2
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",

2
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",

2
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",

6
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",

2
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": "已执行",

2
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",

2
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs

@ -36,7 +36,7 @@ public sealed class AssetsFluidExtension : IFluidExtension
AddAssetFilter(options);
AddAssetTextFilter(options);
parser.RegisterParserTag("asset",
parser.RegisterParserTag("asset",
parser.PrimaryParser.AndSkip(ZeroOrOne(parser.CommaParser)).And(parser.PrimaryParser),
ResolveAsset);
}

2
backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs

@ -34,7 +34,7 @@ public sealed class ReferencesFluidExtension : IFluidExtension
AddReferenceFilter(options);
parser.RegisterParserTag("reference",
parser.PrimaryParser.AndSkip(ZeroOrOne(parser.CommaParser)).And(parser.PrimaryParser),
parser.PrimaryParser.AndSkip(ZeroOrOne(parser.CommaParser)).And(parser.PrimaryParser),
ResolveReference);
}

47
backend/src/Squidex.Infrastructure/Tasks/Scheduler.cs

@ -13,6 +13,8 @@ public delegate Task SchedulerTask(CancellationToken ct);
public sealed class Scheduler
{
private const int SpecialStateStartedOrDone = 1;
private const int SpecialStateCompleted = -1;
private readonly TaskCompletionSource<bool> tcs = new TaskCompletionSource<bool>();
private readonly SemaphoreSlim semaphore;
private List<SchedulerTask>? tasks;
@ -30,7 +32,7 @@ public sealed class Scheduler
public void Schedule(SchedulerTask task)
{
if (pendingTasks < 0)
if (pendingTasks <= SpecialStateCompleted)
{
// Already completed.
return;
@ -39,7 +41,7 @@ public sealed class Scheduler
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 sealed class Scheduler
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<SchedulerTask> 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<Task>();
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 sealed class Scheduler
{
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 sealed class Scheduler
{
semaphore.Release();
if (Interlocked.Decrement(ref pendingTasks) <= 1)
if (Interlocked.Decrement(ref pendingTasks) <= SpecialStateStartedOrDone)
{
tcs.TrySetResult(true);
}

6
backend/src/Squidex.Shared/Texts.pt.resx

@ -730,6 +730,9 @@
<data name="history.apps.clientUpdated" xml:space="preserve">
<value>cliente {[Id]} actualizado</value>
</data>
<data name="history.apps.common.updated" xml:space="preserve">
<value>updated general settings</value>
</data>
<data name="history.apps.contributoreAssigned" xml:space="preserve">
<value>associado {user:[Contributor]} a {[Role]}</value>
</data>
@ -778,9 +781,6 @@
<data name="history.apps.transfered" xml:space="preserve">
<value>actualizada app ao cliente</value>
</data>
<data name="history.apps.updated" xml:space="preserve">
<value>actualizadas configurações gerais</value>
</data>
<data name="history.apps.workflowAdded" xml:space="preserve">
<value>adicionado fluxo de trabalho {[Name]}.</value>
</data>

94
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<string, string> map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
private readonly IFileProvider inner;
public IgnoreHashFileProvider(IFileProvider inner)
{
this.inner = inner;
var regex = new Regex("^(?<Name>[^.]+)\\.[0-9a-f]{4,}\\.(?<Extension>.+)$");
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}";
}
}

7
backend/src/Squidex/Areas/Frontend/Startup.cs

@ -10,6 +10,7 @@ using Microsoft.Net.Http.Headers;
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 class Startup
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 =>

8
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" : "";
}
}
<form asp-controller="Account" asp-action="Consent" asp-route-returnurl="@Model!.ReturnUrl" method="post">

86
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;
}
<div class="login-container">
@ -11,55 +11,55 @@ ViewBag.Title = action;
<div class="row text-center">
<div class="btn-group profile-headline">
@if (Model!.IsLogin)
{
<a class="btn btn-toggle btn-primary" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.login")</a>
}
else
{
<a class="btn btn-toggle" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.login")</a>
}
@if (!Model!.IsLogin)
{
<a class="btn btn-toggle btn-primary" asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.signup")</a>
}
else
{
<a class="btn btn-toggle" asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.signup")</a>
}
{
<a class="btn btn-toggle btn-primary" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.login")</a>
}
else
{
<a class="btn btn-toggle" asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.login")</a>
}
@if (!Model!.IsLogin)
{
<a class="btn btn-toggle btn-primary" asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.signup")</a>
}
else
{
<a class="btn btn-toggle" asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">@T.Get("common.signup")</a>
}
</div>
</div>
</div>
<form asp-controller="Account" asp-action="External" asp-route-returnurl="@Model!.ReturnUrl" method="post">
@foreach (var provider in Model!.ExternalProviders)
{
var schema = provider.AuthenticationScheme.ToLowerInvariant();
{
var schema = provider.AuthenticationScheme.ToLowerInvariant();
<div class="form-group">
<div class="form-group">
<button class="btn external-button btn-block btn btn-@schema" type="submit" name="provider" value="@provider.AuthenticationScheme">
<i class="icon-@schema external-icon"></i> @Html.Raw(T.Get("users.login.loginWith", new { action, provider = provider.DisplayName }))
</button>
</div>
}
}
</form>
@if (Model!.HasExternalLogin && Model!.HasPasswordAuth)
{
<div class="profile-separator">
{
<div class="profile-separator">
<div class="profile-separator-text">@T.Get("users.login.separator")</div>
</div>
}
}
@if (Model!.HasPasswordAuth)
{
if (Model!.IsLogin)
{
if (Model!.IsFailed)
{
<div class="form-alert form-alert-error">@T.Get("users.login.error")</div>
}
if (Model!.IsLogin)
{
if (Model!.IsFailed)
{
<div class="form-alert form-alert-error">@T.Get("users.login.error")</div>
}
<form asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl" method="post">
<form asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl" method="post">
<div class="form-group">
<input type="email" class="form-control" name="email" id="email" placeholder="@T.Get("users.login.emailPlaceholder")" />
</div>
@ -70,31 +70,31 @@ if (Model!.IsLogin)
<button type="submit" class="btn btn-block btn-primary">@action</button>
</form>
}
else
{
<div class="profile-password-signup text-center">@T.Get("users.login.askAdmin")</div>
}
}
}
else
{
<div class="profile-password-signup text-center">@T.Get("users.login.askAdmin")</div>
}
}
@if (Model!.IsLogin)
{
<p class="profile-footer">
{
<p class="profile-footer">
@T.Get("users.login.noAccountSignupQuestion")
<a asp-controller="Account" asp-action="Signup" asp-route-returnurl="@Model!.ReturnUrl">
@T.Get("users.login.noAccountSignupAction")
</a>
</p>
}
else
{
<p class="profile-footer">
}
else
{
<p class="profile-footer">
@T.Get("users.login.noAccountLoginQuestion")
<a asp-controller="Account" asp-action="Login" asp-route-returnurl="@Model!.ReturnUrl">
@T.Get("users.login.noAccountLoginAction")
</a>
</p>
}
}
</div>

14
backend/src/Squidex/Areas/IdentityServer/Views/Error/Error.cshtml

@ -10,11 +10,11 @@
<p class="splash-text">
@if (Model!.ErrorMessage != null)
{
@Model!.ErrorMessage
}
else
{
<span>@T.Get("users.error.text")</span>
}
{
@Model!.ErrorMessage
}
else
{
<span>@T.Get("users.error.text")</span>
}
</p>

56
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)
{
<div class="errors-container">
void RenderValidation(string field)
{
@if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid)
{
<div class="errors-container">
<span class="errors">@Html.ValidationMessage(field)</span>
</div>
}
}
}
}
}
<h1>@T.Get("users.profile.headline")</h1>
@ -89,8 +89,8 @@ void RenderValidation(string field)
<col style="width: 100px;" />
</colgroup>
@foreach (var login in Model!.ExternalLogins)
{
<tr>
{
<tr>
<td>
<span>@login.LoginProvider</span>
</td>
@ -99,8 +99,8 @@ void RenderValidation(string field)
</td>
<td class="text-right">
@if (Model!.ExternalLogins.Count > 1 || Model!.HasPassword)
{
<form asp-controller="Profile" asp-action="RemoveLogin" method="post">
{
<form asp-controller="Profile" asp-action="RemoveLogin" method="post">
<input type="hidden" value="@login.LoginProvider" name="LoginProvider" />
<input type="hidden" value="@login.ProviderKey" name="ProviderKey" />
@ -108,21 +108,21 @@ void RenderValidation(string field)
@T.Get("common.remove")
</button>
</form>
}
}
</td>
</tr>
}
}
</table>
<form asp-controller="Profile" asp-action="AddLogin" method="post">
@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();
<button class="btn external-button-small btn-@schema" type="submit" name="provider" value="@provider.AuthenticationScheme">
<button class="btn external-button-small btn-@schema" type="submit" name="provider" value="@provider.AuthenticationScheme">
<i class="icon-@schema external-icon"></i>
</button>
}
}
</form>
</div>
}
@ -134,9 +134,9 @@ void RenderValidation(string field)
<div class="profile-section">
<h2>@T.Get("users.profile.passwordTitle")</h2>
@if (Model!.HasPassword)
{
<form class="profile-form" asp-controller="Profile" asp-action="ChangePassword" method="post">
@if (Model!.HasPassword)
{
<form class="profile-form" asp-controller="Profile" asp-action="ChangePassword" method="post">
<div class="form-group">
<label for="oldPassword">@T.Get("common.oldPassword")</label>
@ -165,10 +165,10 @@ void RenderValidation(string field)
<button type="submit" class="btn btn-primary">@T.Get("users.profile.changePassword")</button>
</div>
</form>
}
else
{
<form class="profile-form" asp-controller="Profile" asp-action="SetPassword" method="post">
}
else
{
<form class="profile-form" asp-controller="Profile" asp-action="SetPassword" method="post">
<div class="form-group">
<label for="password">@T.Get("common.password")</label>
@ -189,7 +189,7 @@ else
<button type="submit" class="btn btn-primary">@T.Get("users.profile.setPassword")</button>
</div>
</form>
}
}
</div>
}
@ -233,8 +233,8 @@ else
<form class="profile-form" asp-controller="Profile" asp-action="UpdateProperties" method="post">
<div class="mb-2" id="properties">
@for (var i = 0; i < Model!.Properties.Count; i++)
{
<div class="row g-2 form-group">
{
<div class="row g-2 form-group">
<div class="col-5 pr-2">
@{ RenderValidation($"Properties[{i}].Name"); }
@ -253,7 +253,7 @@ else
</button>
</div>
</div>
}
}
</div>
<div class="form-group">

202
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)
{
<div class="errors-container">
<span class="errors">@Html.ValidationMessage(field)</span>
</div>
}
}
void RenderRuleAsSuccess(string message)
{
<div class="row mt-4">
<div class="col-auto">
<div class="status-icon status-icon-success mt-2">
<i class="icon-checkmark"></i>
void RenderValidation(string field)
{
@if (ViewContext.ViewData.ModelState[field]?.ValidationState == Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid)
{
<div class="errors-container">
<span class="errors">@Html.ValidationMessage(field)</span>
</div>
}
}
void RenderRuleAsSuccess(string message)
{
<div class="row mt-4">
<div class="col-auto">
<div class="status-icon status-icon-success mt-2">
<i class="icon-checkmark"></i>
</div>
</div>
</div>
<div class="col">
<div>
@Html.Raw(message)
<div class="col">
<div>
@Html.Raw(message)
</div>
</div>
</div>
</div>
}
void RenderRuleAsCritical(string message)
{
<div class="row mt-4">
<div class="col-auto">
<div class="status-icon status-icon-failed mt-2">
<i class="icon-exclamation"></i>
}
void RenderRuleAsCritical(string message)
{
<div class="row mt-4">
<div class="col-auto">
<div class="status-icon status-icon-failed mt-2">
<i class="icon-exclamation"></i>
</div>
</div>
</div>
<div class="col">
<div>
<strong>@T.Get("common.critical")</strong>: @Html.Raw(message)
<div class="col">
<div>
<strong>@T.Get("common.critical")</strong>: @Html.Raw(message)
</div>
</div>
</div>
</div>
}
void RenderRuleAsWarning(string message)
{
<div class="row mt-4">
<div class="col-auto">
<div class="status-icon status-icon-warning mt-2">
<i class="icon-exclamation"></i>
}
void RenderRuleAsWarning(string message)
{
<div class="row mt-4">
<div class="col-auto">
<div class="status-icon status-icon-warning mt-2">
<i class="icon-exclamation"></i>
</div>
</div>
</div>
<div class="col">
<div>
<strong>@T.Get("common.warning")</strong>: @Html.Raw(message)
<div class="col">
<div>
<strong>@T.Get("common.warning")</strong>: @Html.Raw(message)
</div>
</div>
</div>
</div>
}
}
}
<h1>@T.Get("setup.headline")</h1>
@ -77,50 +77,50 @@ void RenderRuleAsWarning(string message)
<h2>@T.Get("setup.rules.headline")</h2>
@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"));
}
</div>
<hr />
@ -142,24 +142,24 @@ RenderRuleAsWarning(T.Get("setup.ruleFolder.warning"));
}
@if (Model!.HasExternalLogin && Model!.HasPasswordAuth)
{
<div class="profile-separator">
<div class="profile-separator-text">@T.Get("setup.createUser.separator")</div>
</div>
}
{
<div class="profile-separator">
<div class="profile-separator-text">@T.Get("setup.createUser.separator")</div>
</div>
}
@if (Model!.HasPasswordAuth)
{
<h3>@T.Get("setup.createUser.headlineCreate")</h3>
{
<h3>@T.Get("setup.createUser.headlineCreate")</h3>
@if (!string.IsNullOrWhiteSpace(Model!.ErrorMessage))
{
<div class="form-alert form-alert-error">
@Model!.ErrorMessage
</div>
}
@if (!string.IsNullOrWhiteSpace(Model!.ErrorMessage))
{
<div class="form-alert form-alert-error">
@Model!.ErrorMessage
</div>
}
<form class="profile-form" asp-controller="Setup" asp-action="Setup" method="post">
<form class="profile-form" asp-controller="Setup" asp-action="Setup" method="post">
<div class="form-group">
<label for="email">@T.Get("common.email")</label>
@ -188,12 +188,12 @@ RenderRuleAsWarning(T.Get("setup.ruleFolder.warning"));
<button type="submit" class="btn btn-success">@T.Get("setup.createUser.button")</button>
</div>
</form>
}
}
@if (!Model!.HasExternalLogin && !Model!.HasPasswordAuth)
{
<div>
{
<div>
@T.Get("setup.createUser.failure")
</div>
}
</div>
}
</div>

8
backend/src/Squidex/Areas/IdentityServer/Views/_Layout.cshtml

@ -8,12 +8,12 @@
<title>@ViewBag.Title - @T.Get("common.product")</title>
<link rel="stylesheet" asp-append-version="true" href="styles.css" />
<link rel="stylesheet" href="styles.css" asp-append-version="true" />
@if (IsSectionDefined("header"))
{
@await RenderSectionAsync("header")
}
{
@await RenderSectionAsync("header")
}
<environment include="Development">
<script type="text/javascript" src="runtime.js"></script>

39
backend/tests/Squidex.Infrastructure.Tests/Tasks/SchedulerTests.cs

@ -12,12 +12,12 @@ namespace Squidex.Infrastructure.Tasks;
public class SchedulerTests
{
private readonly ConcurrentBag<int> actuals = new ConcurrentBag<int>();
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 class SchedulerTests
for (var i = 1; i <= 10; i++)
{
Schedule(i, limited);
ScheduleAsync(i, limited);
}
await limited.CompleteAsync();
@ -42,8 +42,19 @@ public class SchedulerTests
[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 class SchedulerTests
actuals.Add(2);
Schedule(3);
ScheduleAsync(3, sut);
});
});
@ -77,20 +88,18 @@ public class SchedulerTests
[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 @@ public class SchedulerTests
private void Schedule(int value, Scheduler target)
{
target.Schedule(async _ =>
target.Schedule(_ =>
{
await Task.Delay(1);
actuals.Add(value);
return Task.CompletedTask;
});
}
}

242
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<IFileProvider>();
[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<IFileInfo>, IDirectoryContents
{
bool IDirectoryContents.Exists => true;
public DirectoryContents(IEnumerable<IFileInfo> files)
: base(files)
{
}
}
}

33
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);
}
}

7
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';

9
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,

29
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;
};
Loading…
Cancel
Save