mirror of https://github.com/Squidex/squidex.git
Browse Source
* Security fixes. * Fix build. * Fix build. * UI fixes. * Fix asset tags. * Info the save the query. * Fix first batches. * More improvements * More fixes * Improve cache keys * More fixes * More fixes * Fix * Fixes * Fixes * More fixes * Fix issues * More progress * Temp * Update packages. * Fix formatting. * Fix db context configuration.master
committed by
GitHub
117 changed files with 3071 additions and 1860 deletions
@ -0,0 +1,55 @@ |
|||||
|
# Squidex |
||||
|
|
||||
|
Headless CMS. Angular frontend in `frontend/`, ASP.NET Core backend in `backend/`. |
||||
|
|
||||
|
## Frontend |
||||
|
|
||||
|
- Angular app in `frontend/`, source under `src/app`: |
||||
|
- `framework/` — generic, reusable UI components and utilities (no domain knowledge). |
||||
|
- `shared/` — Squidex-specific services, state stores and components. |
||||
|
- `features/` — the actual screens (apps, assets, content, rules, schemas, settings, teams, ...). |
||||
|
- `shell/` — app frame, navigation, layout. |
||||
|
- State is handled with the state store pattern from `framework/state.ts` (immutable value objects + `State<T>` subclasses), not with a third-party store library. |
||||
|
- Commands: |
||||
|
|
||||
|
```bash |
||||
|
npm start |
||||
|
``` |
||||
|
|
||||
|
```bash |
||||
|
npm test |
||||
|
``` |
||||
|
|
||||
|
```bash |
||||
|
npm run lint |
||||
|
``` |
||||
|
|
||||
|
### Best Practices |
||||
|
|
||||
|
- i18n texts live in `backend/i18n`, translations are generated into the frontend — do not edit generated translation files by hand. |
||||
|
- Do not write JsDoc comments. |
||||
|
|
||||
|
## Backend |
||||
|
|
||||
|
- .NET solution `backend/Squidex.slnx`. Projects under `backend/src`, tests under `backend/tests`, optional integrations under `backend/extensions`. |
||||
|
- Layering: `Squidex.Infrastructure` (generic building blocks) → `Squidex.Domain.Apps.*` (core model, operations, events, entities) → `Squidex.Web` / `Squidex` (API host). |
||||
|
- Event-sourced domain: aggregates emit events from `Squidex.Domain.Apps.Events`, state is projected into MongoDB or EF Core (`Squidex.Data.MongoDb`, `Squidex.Data.EntityFramework`). |
||||
|
- Run tests with the filter below — some tests need external setup (real databases, Docker/Testcontainers) and will fail without it: |
||||
|
|
||||
|
### Tests |
||||
|
|
||||
|
Some tests need test setup or test containers which are slow. Run the tests like this to skip these tests. |
||||
|
|
||||
|
```bash |
||||
|
dotnet test --filter "Category!=Dependencies & Category!=TestContainer" |
||||
|
``` |
||||
|
|
||||
|
### Best Practices |
||||
|
|
||||
|
- Code style is enforced by StyleCop (`backend/stylecop.json`) and `.editorconfig` — follow the surrounding file's conventions. |
||||
|
- Do not write XML comments. |
||||
|
|
||||
|
## Shared best practices |
||||
|
|
||||
|
- Do write precise short comments and only when needed. |
||||
|
- Do not comment a class or a method, only put comments inside functions or above variables. |
||||
@ -0,0 +1,34 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Diagnostics.CodeAnalysis; |
||||
|
using Jint; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Scripting; |
||||
|
|
||||
|
public static class EngineExtensions |
||||
|
{ |
||||
|
public static ScriptExecutionContext GetContext(this Engine engine) |
||||
|
{ |
||||
|
return ScriptExecutionContext.GetContext(engine); |
||||
|
} |
||||
|
|
||||
|
public static bool TryGetVar<T>(this Engine engine, string key, [MaybeNullWhen(false)] out T value) |
||||
|
{ |
||||
|
return ScriptExecutionContext.GetContext(engine).TryGetValueIfExists(key, out value); |
||||
|
} |
||||
|
|
||||
|
public static void Schedule(this Engine engine, Func<CancellationToken, Task> action) |
||||
|
{ |
||||
|
ScriptExecutionContext.GetContext(engine).Schedule(action); |
||||
|
} |
||||
|
|
||||
|
public static void Schedule<TResult>(this Engine engine, Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback) |
||||
|
{ |
||||
|
ScriptExecutionContext.GetContext(engine).Schedule(action, callback); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,33 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Infrastructure.Json.Objects; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Scripting; |
||||
|
|
||||
|
public interface IAsyncScript : IDisposable |
||||
|
{ |
||||
|
ValueTask<ContentData> TransformAsync(DataScriptVars vars, |
||||
|
CancellationToken ct = default); |
||||
|
|
||||
|
ValueTask<JsonValue> ExecuteAsync(ScriptVars vars, |
||||
|
CancellationToken ct = default); |
||||
|
|
||||
|
async ValueTask<bool> EvaluateAsync(ScriptVars vars, |
||||
|
CancellationToken ct = default) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
return (await ExecuteAsync(vars, ct)).Equals(true); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,30 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Infrastructure.Json.Objects; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Scripting; |
||||
|
|
||||
|
public interface IScript : IDisposable |
||||
|
{ |
||||
|
ContentData Transform(DataScriptVars vars); |
||||
|
|
||||
|
JsonValue Execute(ScriptVars vars); |
||||
|
|
||||
|
bool Evaluate(ScriptVars vars) |
||||
|
{ |
||||
|
try |
||||
|
{ |
||||
|
return Execute(vars).Equals(true); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,171 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Jint; |
||||
|
using Squidex.Infrastructure.Tasks; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Scripting.Internal; |
||||
|
|
||||
|
internal sealed class AsyncScriptExecutionContext<T> : ScriptExecutionContext |
||||
|
{ |
||||
|
private readonly TaskCompletionSource<CompletedValue?> tcs = new TaskCompletionSource<CompletedValue?>(TaskCreationOptions.RunContinuationsAsynchronously); |
||||
|
private readonly CancellationTokenRegistration cancellationRegistration; |
||||
|
private readonly CancellationToken cancellationToken; |
||||
|
private readonly JintScript script; |
||||
|
private readonly Engine engine; |
||||
|
private int pendingTasks = 1; |
||||
|
|
||||
|
private sealed class CompletedValue |
||||
|
{ |
||||
|
public T Value { get; init; } |
||||
|
} |
||||
|
|
||||
|
public bool IsCompleted |
||||
|
{ |
||||
|
get => tcs.Task.IsCompleted; |
||||
|
} |
||||
|
|
||||
|
internal AsyncScriptExecutionContext(Engine engine, JintScript script, CancellationToken ct) |
||||
|
: base(engine) |
||||
|
{ |
||||
|
this.engine = engine; |
||||
|
|
||||
|
// The lock belongs to the script, because the engine is shared between all executions.
|
||||
|
this.script = script; |
||||
|
|
||||
|
cancellationToken = ct; |
||||
|
|
||||
|
// Settle the source on cancellation, so that pending callbacks do not enter the engine anymore.
|
||||
|
cancellationRegistration = cancellationToken.Register(static state => |
||||
|
{ |
||||
|
var self = (AsyncScriptExecutionContext<T>)state!; |
||||
|
|
||||
|
self.tcs.TrySetCanceled(self.cancellationToken); |
||||
|
}, |
||||
|
this); |
||||
|
} |
||||
|
|
||||
|
public async Task<T> WaitForCompletionAsync(Func<T> fallback) |
||||
|
{ |
||||
|
TryComplete(); |
||||
|
try |
||||
|
{ |
||||
|
var result = await tcs.Task; |
||||
|
if (result != null) |
||||
|
{ |
||||
|
return result.Value; |
||||
|
} |
||||
|
|
||||
|
return await script.RunLockedAsync(() => fallback(), cancellationToken); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
await cancellationRegistration.DisposeAsync(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public void Complete(T value) |
||||
|
{ |
||||
|
tcs.TrySetResult(new CompletedValue { Value = value }); |
||||
|
} |
||||
|
|
||||
|
public override void Fail(Exception exception) |
||||
|
{ |
||||
|
TryFail(exception); |
||||
|
} |
||||
|
|
||||
|
public override void Schedule(Func<CancellationToken, Task> action) |
||||
|
{ |
||||
|
ScheduleCoreAsync(async ct => |
||||
|
{ |
||||
|
await action(ct); |
||||
|
return true; |
||||
|
}, |
||||
|
null); |
||||
|
} |
||||
|
|
||||
|
public override void Schedule<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback) |
||||
|
{ |
||||
|
ScheduleCoreAsync(async ct => |
||||
|
{ |
||||
|
var result = await action(ct); |
||||
|
return result; |
||||
|
}, |
||||
|
callback); |
||||
|
} |
||||
|
|
||||
|
private void ScheduleCoreAsync<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback) |
||||
|
{ |
||||
|
if (IsCompleted) |
||||
|
{ |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
async Task ScheduleAsync() |
||||
|
{ |
||||
|
TryStart(); |
||||
|
try |
||||
|
{ |
||||
|
// The action must not touch the engine, so that parallel tasks do not block each other.
|
||||
|
var result = await action(cancellationToken); |
||||
|
|
||||
|
// The callback converts javascript values and is therefore the only part that needs the lock.
|
||||
|
await RunLockedAsync(() => callback?.Invoke(result)); |
||||
|
|
||||
|
TryComplete(); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
TryFail(ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
ScheduleAsync().Forget(); |
||||
|
} |
||||
|
|
||||
|
private Task<bool> RunLockedAsync(Action action) |
||||
|
{ |
||||
|
// The lock is owned by the script, because the engine is shared between all executions.
|
||||
|
return script.RunLockedAsync(() => |
||||
|
{ |
||||
|
// Late callbacks must not touch the engine anymore, the next execution might have started.
|
||||
|
if (IsCompleted) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
// The task can take a while, therefore the action gets a fresh timeout.
|
||||
|
engine.Constraints.Reset(); |
||||
|
|
||||
|
action(); |
||||
|
|
||||
|
// The evaluation does not wait for the promises anymore, therefore the continuations that
|
||||
|
// the callback has unblocked have to be executed here, while the lock is still held.
|
||||
|
engine.Advanced.ProcessTasks(); |
||||
|
return true; |
||||
|
}, |
||||
|
cancellationToken); |
||||
|
} |
||||
|
|
||||
|
private void TryFail(Exception exception) |
||||
|
{ |
||||
|
tcs.TrySetException(exception); |
||||
|
} |
||||
|
|
||||
|
private void TryStart() |
||||
|
{ |
||||
|
Interlocked.Increment(ref pendingTasks); |
||||
|
} |
||||
|
|
||||
|
private void TryComplete(CompletedValue? result = null) |
||||
|
{ |
||||
|
if (Interlocked.Decrement(ref pendingTasks) <= 0) |
||||
|
{ |
||||
|
tcs.TrySetResult(result); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Diagnostics.CodeAnalysis; |
||||
|
using Jint; |
||||
|
using Jint.Native; |
||||
|
using Jint.Runtime.Interop; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Scripting.Internal; |
||||
|
|
||||
|
internal sealed class EnumToStringConverter : IObjectConverter |
||||
|
{ |
||||
|
public static readonly EnumToStringConverter Instance = new EnumToStringConverter(); |
||||
|
|
||||
|
private EnumToStringConverter() |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public bool TryConvert(Engine engine, object value, [MaybeNullWhen(false)] out JsValue result) |
||||
|
{ |
||||
|
if (value is Enum) |
||||
|
{ |
||||
|
result = value.ToString(); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
result = JsValue.Null; |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,366 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using System.Diagnostics; |
||||
|
using Acornima; |
||||
|
using Acornima.Ast; |
||||
|
using Jint; |
||||
|
using Jint.Constraints; |
||||
|
using Jint.Native; |
||||
|
using Jint.Native.Promise; |
||||
|
using Jint.Runtime; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Domain.Apps.Core.Scripting.ContentWrapper; |
||||
|
using Squidex.Domain.Apps.Core.Scripting.Internal; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Json.Objects; |
||||
|
using Squidex.Infrastructure.Translations; |
||||
|
using Squidex.Infrastructure.Validation; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Scripting; |
||||
|
|
||||
|
internal sealed class JintScript : IScript, IAsyncScript, IDisposable |
||||
|
{ |
||||
|
// The engine is shared between all executions, therefore only one of them can run at a time.
|
||||
|
private readonly SemaphoreSlim executionGate = new SemaphoreSlim(1); |
||||
|
|
||||
|
// The engine is also shared with the callbacks of the scheduled tasks, which run on other threads.
|
||||
|
private readonly SemaphoreSlim engineLock = new SemaphoreSlim(1); |
||||
|
|
||||
|
// Jint only creates the constraint for a token that can actually be cancelled, therefore the engine is
|
||||
|
// built with a placeholder that is never cancelled and repointed for each execution.
|
||||
|
private readonly CancellationTokenSource placeholder = new CancellationTokenSource(); |
||||
|
private readonly CancellationConstraint? cancellation; |
||||
|
private readonly Engine engine; |
||||
|
private readonly Prepared<Script> parsed; |
||||
|
private readonly ScriptOptions scriptOptions; |
||||
|
private readonly JintScriptOptions engineOptions; |
||||
|
private readonly GlobalSnapshot snapshot; |
||||
|
|
||||
|
public JintScript( |
||||
|
Prepared<Script> parsed, |
||||
|
ScriptOptions scriptOptions, |
||||
|
JintScriptOptions engineOptions, |
||||
|
IJintExtension[] extensions, |
||||
|
bool allowAsync) |
||||
|
{ |
||||
|
this.parsed = parsed; |
||||
|
this.scriptOptions = scriptOptions; |
||||
|
this.engineOptions = engineOptions; |
||||
|
|
||||
|
engine = new Engine(options => |
||||
|
{ |
||||
|
options.AddObjectConverter(JintObjectConverter.Instance, JintObjectConverter.HandledTypes); |
||||
|
options.AddObjectConverter(EnumToStringConverter.Instance); |
||||
|
options.AllowClrWrite(!scriptOptions.Readonly); |
||||
|
options.SetTypeConverter(engine => new CustomClrConverter(engine)); |
||||
|
options.SetReferencesResolver(NullPropagation.Instance); |
||||
|
options.Strict(); |
||||
|
|
||||
|
if (!Debugger.IsAttached) |
||||
|
{ |
||||
|
options.Constraints.PromiseTimeout = engineOptions.TimeoutPromise; |
||||
|
options.TimeoutInterval(engineOptions.TimeoutScript); |
||||
|
options.CancellationToken(placeholder.Token); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
cancellation = engine.Constraints.Find<CancellationConstraint>(); |
||||
|
|
||||
|
// The extensions resolve the variables from the context of the current execution, therefore they
|
||||
|
// are registered once, even though the variables are different for each execution.
|
||||
|
foreach (var extension in extensions) |
||||
|
{ |
||||
|
extension.Extend(engine); |
||||
|
} |
||||
|
|
||||
|
if (allowAsync) |
||||
|
{ |
||||
|
foreach (var extension in extensions) |
||||
|
{ |
||||
|
extension.ExtendAsync(engine); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (scriptOptions.CanDisallow) |
||||
|
{ |
||||
|
engine.AddDisallow(); |
||||
|
} |
||||
|
|
||||
|
if (scriptOptions.CanReject) |
||||
|
{ |
||||
|
engine.AddReject(); |
||||
|
} |
||||
|
|
||||
|
// The evaluation does not wait for the promises anymore, therefore an unhandled rejection would be
|
||||
|
// swallowed. The tracker only fires when nothing handles the rejection.
|
||||
|
engine.Advanced.PromiseRejectionTracker += (_, args) => |
||||
|
{ |
||||
|
if (args.Operation == PromiseRejectionOperation.Reject) |
||||
|
{ |
||||
|
ScriptExecutionContext.GetContext(engine).Fail( |
||||
|
new JavaScriptException($"Promise was rejected with value {args.Value}.")); |
||||
|
} |
||||
|
}; |
||||
|
|
||||
|
snapshot = engine.Advanced.CaptureGlobalSnapshot(); |
||||
|
} |
||||
|
|
||||
|
public JsonValue Execute(ScriptVars vars) |
||||
|
{ |
||||
|
Guard.NotNull(vars); |
||||
|
|
||||
|
return Run(vars, (vars, _) => |
||||
|
{ |
||||
|
JsonValue? completed = null; |
||||
|
|
||||
|
engine.SetValue("complete", new Action<JsValue?>(value => |
||||
|
{ |
||||
|
completed ??= JsonMapper.Map(value); |
||||
|
})); |
||||
|
|
||||
|
var result = engine.Evaluate(parsed); |
||||
|
|
||||
|
return completed ?? JsonMapper.Map(result); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ValueTask<JsonValue> ExecuteAsync(ScriptVars vars, |
||||
|
CancellationToken ct = default) |
||||
|
{ |
||||
|
Guard.NotNull(vars); |
||||
|
|
||||
|
return RunAsync<JsonValue, ScriptVars>(vars, async (vars, context, ct) => |
||||
|
{ |
||||
|
engine.SetValue("complete", new Action<JsValue?>(value => |
||||
|
{ |
||||
|
context.Complete(JsonMapper.Map(value)); |
||||
|
})); |
||||
|
|
||||
|
// The evaluation holds the lock, but returns without waiting for the pending promises.
|
||||
|
var result = await RunLockedAsync(() => engine.Evaluate(parsed), ct); |
||||
|
|
||||
|
return await context.WaitForCompletionAsync(() => JsonMapper.Map(result)); |
||||
|
}, ct); |
||||
|
} |
||||
|
|
||||
|
public ContentData Transform(DataScriptVars vars) |
||||
|
{ |
||||
|
Guard.NotNull(vars); |
||||
|
|
||||
|
return Run(vars, (vars, _) => |
||||
|
{ |
||||
|
ContentData? result = null; |
||||
|
|
||||
|
engine.SetValue("complete", new Action<JsValue?>(_ => |
||||
|
{ |
||||
|
result ??= vars.Data; |
||||
|
})); |
||||
|
|
||||
|
engine.SetValue("replace", new Action(() => |
||||
|
{ |
||||
|
if (result == null && TransformData(out var modified)) |
||||
|
{ |
||||
|
result = modified; |
||||
|
} |
||||
|
})); |
||||
|
|
||||
|
engine.Evaluate(parsed); |
||||
|
|
||||
|
return result ?? vars.Data!; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ValueTask<ContentData> TransformAsync(DataScriptVars vars, |
||||
|
CancellationToken ct = default) |
||||
|
{ |
||||
|
Guard.NotNull(vars); |
||||
|
|
||||
|
return RunAsync<ContentData, DataScriptVars>(vars, async (vars, context, ct) => |
||||
|
{ |
||||
|
var data = vars.Data!; |
||||
|
|
||||
|
engine.SetValue("complete", new Action<JsValue?>(_ => |
||||
|
{ |
||||
|
if (!context.IsCompleted) |
||||
|
{ |
||||
|
context.Complete(data); |
||||
|
} |
||||
|
})); |
||||
|
|
||||
|
engine.SetValue("replace", new Action(() => |
||||
|
{ |
||||
|
if (!context.IsCompleted && TransformData(out var modified)) |
||||
|
{ |
||||
|
context.Complete(modified); |
||||
|
} |
||||
|
})); |
||||
|
|
||||
|
// The evaluation holds the lock, but returns without waiting for the pending promises.
|
||||
|
await RunLockedAsync(() => engine.Evaluate(parsed), ct); |
||||
|
|
||||
|
return await context.WaitForCompletionAsync(() => data); |
||||
|
}, ct); |
||||
|
} |
||||
|
|
||||
|
private T Run<T, TVars>(TVars vars, Func<TVars, ScriptExecutionContext, T> action) where TVars : ScriptVars |
||||
|
{ |
||||
|
executionGate.Wait(); |
||||
|
try |
||||
|
{ |
||||
|
// The extensions resolve the variables over the context, therefore it is also needed here.
|
||||
|
var context = new ScriptExecutionContext(engine); |
||||
|
context.CopyFrom(vars); |
||||
|
|
||||
|
PrepareEngine(vars, default); |
||||
|
return action(vars, context); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
throw MapException(ex); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
executionGate.Release(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private async ValueTask<T> RunAsync<T, TVars>(TVars vars, Func<TVars, AsyncScriptExecutionContext<T>, CancellationToken, ValueTask<T>> action, |
||||
|
CancellationToken ct) where TVars : ScriptVars |
||||
|
{ |
||||
|
using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct); |
||||
|
|
||||
|
// Enforce a timeout after a configured time span.
|
||||
|
combined.CancelAfter(engineOptions.TimeoutExecution); |
||||
|
|
||||
|
await executionGate.WaitAsync(ct); |
||||
|
try |
||||
|
{ |
||||
|
PrepareEngine(vars, combined.Token); |
||||
|
|
||||
|
// The extensions resolve the variables over the context, therefore it is also needed here.
|
||||
|
var context = new AsyncScriptExecutionContext<T>(engine, this, combined.Token); |
||||
|
context.CopyFrom(vars); |
||||
|
|
||||
|
return await action(vars, context, combined.Token); |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
throw MapException(ex); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
// Stop pending tasks before the token source is disposed, they must not touch the engine anymore.
|
||||
|
await combined.CancelAsync(); |
||||
|
|
||||
|
// The next execution reuses the engine, therefore we wait until no callback is inside anymore.
|
||||
|
// Callbacks that are still waiting for the lock are rejected, because the token is cancelled by now.
|
||||
|
await engineLock.WaitAsync(default(CancellationToken)); |
||||
|
engineLock.Release(); |
||||
|
|
||||
|
executionGate.Release(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
public void Dispose() |
||||
|
{ |
||||
|
engine.Dispose(); |
||||
|
engineLock.Dispose(); |
||||
|
executionGate.Dispose(); |
||||
|
placeholder.Dispose(); |
||||
|
} |
||||
|
|
||||
|
internal async Task<T> RunLockedAsync<T>(Func<T> action, CancellationToken ct) |
||||
|
{ |
||||
|
// Only one thread is allowed inside the engine, no matter whether it is an execution or a callback.
|
||||
|
// A callback that is reached synchronously from the engine thread asks for the lock while the
|
||||
|
// evaluation still holds it. That is not a deadlock, because the wait yields instead of blocking and
|
||||
|
// the callback continues on another thread once the evaluation is done. It must not be made
|
||||
|
// reentrant, that would run the promise jobs in the middle of a statement.
|
||||
|
await engineLock.WaitAsync(ct); |
||||
|
try |
||||
|
{ |
||||
|
return action(); |
||||
|
} |
||||
|
finally |
||||
|
{ |
||||
|
engineLock.Release(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private bool TransformData(out ContentData result) |
||||
|
{ |
||||
|
var dataInstance = |
||||
|
scriptOptions.AsContext ? |
||||
|
engine.GetValue("ctx").AsObject().Get("data") : |
||||
|
engine.GetValue("data"); |
||||
|
|
||||
|
if (dataInstance != null && |
||||
|
dataInstance.IsObject() && |
||||
|
dataInstance.AsObject() is ContentDataObject dataObject && |
||||
|
dataObject.TryUpdate(out var modified)) |
||||
|
{ |
||||
|
result = modified; |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
result = null!; |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
private void PrepareEngine(ScriptVars vars, CancellationToken ct) |
||||
|
{ |
||||
|
// Removes everything the previous execution has declared, including global const and let.
|
||||
|
engine.Advanced.RestoreGlobalSnapshot(snapshot); |
||||
|
engine.Constraints.Reset(); |
||||
|
|
||||
|
// The engine is created once, therefore the token of the current execution is assigned here.
|
||||
|
// The generic reset above does not clear it, so the callbacks keep the token as well.
|
||||
|
cancellation?.Reset(ct); |
||||
|
|
||||
|
if (scriptOptions.AsContext) |
||||
|
{ |
||||
|
var contextInstance = new WritableContext(engine, vars); |
||||
|
|
||||
|
engine.SetValue("ctx", contextInstance); |
||||
|
engine.SetValue("context", contextInstance); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
foreach (var (key, item) in vars) |
||||
|
{ |
||||
|
engine.SetValue(key, item); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
internal static Exception MapException(Exception inner) |
||||
|
{ |
||||
|
static Exception BuildException(string errorKey, string message, Exception? inner = null) |
||||
|
{ |
||||
|
return new ValidationException(T.Get(errorKey, new { message }), inner); |
||||
|
} |
||||
|
|
||||
|
switch (inner) |
||||
|
{ |
||||
|
case ArgumentException: |
||||
|
return BuildException("common.jsParseError", inner.Message); |
||||
|
case ParseErrorException: |
||||
|
return BuildException("common.jsError", inner.Message); |
||||
|
case ScriptPreparationException: |
||||
|
return BuildException("common.jsError", inner.Message); |
||||
|
case JavaScriptException: |
||||
|
return BuildException("common.jsError", inner.Message); |
||||
|
case JintException: |
||||
|
return BuildException("common.jsError", inner.Message); |
||||
|
case DomainException: |
||||
|
return inner; |
||||
|
default: |
||||
|
return BuildException("common.jsError", inner.GetType().Name, inner); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,44 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Caching; |
||||
|
using Squidex.Domain.Apps.Core.Apps; |
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Domain.Apps.Core.Schemas; |
||||
|
using Squidex.Domain.Apps.Core.Scripting; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents; |
||||
|
|
||||
|
public sealed class DynamicContentWorkflows(IScriptEngine scriptEngine, ILocalCache localCache) : IContentWorkflows |
||||
|
{ |
||||
|
public ValueTask<IContentWorkflow> GetWorkflowAsync(App app, Schema schema, |
||||
|
CancellationToken ct = default) |
||||
|
{ |
||||
|
// The definition is only cached for the current request, because the workflow must never be
|
||||
|
// stale. It is resolved several times per request, especially once per command guard.
|
||||
|
var cacheKey = (nameof(DynamicContentWorkflows), app.Id, app.Version, schema.Id, schema.Version); |
||||
|
|
||||
|
if (!localCache.TryGetValue(cacheKey, out var cached) || cached is not WorkflowDefinition definition) |
||||
|
{ |
||||
|
definition = CreateDefinition(app, schema); |
||||
|
|
||||
|
localCache.Add(cacheKey, definition); |
||||
|
} |
||||
|
|
||||
|
return new ValueTask<IContentWorkflow>(new DynamicContentWorkflow(definition, scriptEngine)); |
||||
|
} |
||||
|
|
||||
|
private static WorkflowDefinition CreateDefinition(App app, Schema schema) |
||||
|
{ |
||||
|
var workflow = |
||||
|
app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Contains(schema.Id)) ?? |
||||
|
app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Count == 0) ?? |
||||
|
Workflow.Default; |
||||
|
|
||||
|
return new WorkflowDefinition(workflow, schema.Properties.ValidateOnPublish); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,17 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Domain.Apps.Core.Apps; |
||||
|
using Squidex.Domain.Apps.Core.Schemas; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents; |
||||
|
|
||||
|
public interface IContentWorkflows |
||||
|
{ |
||||
|
ValueTask<IContentWorkflow> GetWorkflowAsync(App app, Schema schema, |
||||
|
CancellationToken ct = default); |
||||
|
} |
||||
@ -0,0 +1,38 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Entities.Contents; |
||||
|
|
||||
|
public sealed class WorkflowDefinition |
||||
|
{ |
||||
|
public Workflow Workflow { get; } |
||||
|
|
||||
|
public IReadOnlyDictionary<Status, StatusInfo> StatusInfos { get; } |
||||
|
|
||||
|
public StatusInfo[] AllStatuses { get; } |
||||
|
|
||||
|
public bool ValidateOnPublish { get; } |
||||
|
|
||||
|
public WorkflowDefinition(Workflow workflow, bool validateOnPublish) |
||||
|
{ |
||||
|
// The status infos never change for a workflow, therefore they are created once and shared
|
||||
|
// by all contents instead of allocating them for every single status lookup.
|
||||
|
var statusInfos = new Dictionary<Status, StatusInfo>(workflow.Steps.Count); |
||||
|
|
||||
|
foreach (var (status, step) in workflow.Steps) |
||||
|
{ |
||||
|
statusInfos[status] = new StatusInfo(status, step.Color ?? StatusColors.Draft); |
||||
|
} |
||||
|
|
||||
|
Workflow = workflow; |
||||
|
StatusInfos = statusInfos; |
||||
|
AllStatuses = [.. statusInfos.Values]; |
||||
|
ValidateOnPublish = validateOnPublish; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,113 @@ |
|||||
|
// ==========================================================================
|
||||
|
// Squidex Headless CMS
|
||||
|
// ==========================================================================
|
||||
|
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
||||
|
// All rights reserved. Licensed under the MIT license.
|
||||
|
// ==========================================================================
|
||||
|
|
||||
|
using Squidex.Domain.Apps.Core.Contents; |
||||
|
using Squidex.Domain.Apps.Core.ConvertContent; |
||||
|
using Squidex.Domain.Apps.Core.Schemas; |
||||
|
using Squidex.Infrastructure; |
||||
|
using Squidex.Infrastructure.Json.Objects; |
||||
|
|
||||
|
namespace Squidex.Domain.Apps.Core.Operations.ConvertContent; |
||||
|
|
||||
|
public class ContentConversionRemovalTests |
||||
|
{ |
||||
|
private static readonly DomainId ComponentId = DomainId.NewGuid(); |
||||
|
private readonly ResolvedComponents components; |
||||
|
private readonly Schema schema; |
||||
|
|
||||
|
public ContentConversionRemovalTests() |
||||
|
{ |
||||
|
schema = |
||||
|
new Schema { Name = "my-schema" } |
||||
|
.AddComponents(1, "components", Partitioning.Invariant) |
||||
|
.AddArray(2, "array", Partitioning.Invariant, a => a |
||||
|
.AddString(21, "value")); |
||||
|
|
||||
|
components = new ResolvedComponents(new Dictionary<DomainId, Schema> |
||||
|
{ |
||||
|
[ComponentId] = |
||||
|
new Schema { Name = "my-component" } |
||||
|
.AddString(1, "value", Partitioning.Invariant), |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData("abc", "abc")] |
||||
|
[InlineData("-bc", "bc")] |
||||
|
[InlineData("a-c", "ac")] |
||||
|
[InlineData("ab-", "ab")] |
||||
|
[InlineData("--c", "c")] |
||||
|
[InlineData("-b-", "b")] |
||||
|
[InlineData("a--", "a")] |
||||
|
[InlineData("---", "")] |
||||
|
public void Should_remove_array_items_that_are_not_objects(string source, string expected) |
||||
|
{ |
||||
|
var items = source.Select(x => x == '-' ? JsonValue.Create(0) : Item(x)); |
||||
|
|
||||
|
Assert.Equal(expected, Convert("array", items)); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData("abc", "abc")] |
||||
|
[InlineData("-bc", "bc")] |
||||
|
[InlineData("a-c", "ac")] |
||||
|
[InlineData("ab-", "ab")] |
||||
|
[InlineData("--c", "c")] |
||||
|
[InlineData("-b-", "b")] |
||||
|
[InlineData("a--", "a")] |
||||
|
[InlineData("---", "")] |
||||
|
public void Should_remove_components_of_unknown_schema(string source, string expected) |
||||
|
{ |
||||
|
var items = source.Select(x => x == '-' ? ComponentOf(x, DomainId.NewGuid()) : ComponentOf(x, ComponentId)); |
||||
|
|
||||
|
Assert.Equal(expected, Convert("components", items)); |
||||
|
} |
||||
|
|
||||
|
[Theory] |
||||
|
[InlineData("abc", "abc")] |
||||
|
[InlineData("-bc", "bc")] |
||||
|
[InlineData("a-c", "ac")] |
||||
|
[InlineData("ab-", "ab")] |
||||
|
[InlineData("--c", "c")] |
||||
|
[InlineData("-b-", "b")] |
||||
|
[InlineData("a--", "a")] |
||||
|
[InlineData("---", "")] |
||||
|
public void Should_remove_components_without_discriminator(string source, string expected) |
||||
|
{ |
||||
|
var items = source.Select(x => x == '-' ? Item(x) : ComponentOf(x, ComponentId)); |
||||
|
|
||||
|
Assert.Equal(expected, Convert("components", items)); |
||||
|
} |
||||
|
|
||||
|
private string Convert(string field, IEnumerable<JsonValue> items) |
||||
|
{ |
||||
|
var source = |
||||
|
new ContentData() |
||||
|
.AddField(field, |
||||
|
new ContentFieldData() |
||||
|
.AddInvariant(JsonValue.Array(items.ToArray()))); |
||||
|
|
||||
|
var converted = new ContentConverter(components, schema).Convert(source); |
||||
|
|
||||
|
if (!converted.TryGetValue(field, out var data) || data?["iv"].Value is not JsonArray array) |
||||
|
{ |
||||
|
return string.Empty; |
||||
|
} |
||||
|
|
||||
|
return string.Concat(array.Select(x => ((JsonObject)x.Value!)["value"].ToString())); |
||||
|
} |
||||
|
|
||||
|
private static JsonValue Item(char value) |
||||
|
{ |
||||
|
return JsonValue.Object().Add("value", value.ToString()); |
||||
|
} |
||||
|
|
||||
|
private static JsonValue ComponentOf(char value, DomainId schemaId) |
||||
|
{ |
||||
|
return JsonValue.Object().Add("value", value.ToString()).Add(Component.Discriminator, schemaId); |
||||
|
} |
||||
|
} |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue