// ========================================================================== // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex UG (haftungsbeschraenkt) // All rights reserved. Licensed under the MIT license. // ========================================================================== using System.Diagnostics; using Acornima; using Jint; using Jint.Native; using Jint.Runtime; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Properties; 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; public sealed class JintScriptEngine(IMemoryCache cache, IOptions options, IEnumerable? extensions = null) : IScriptEngine, IScriptDescriptor { private readonly IJintExtension[] extensions = extensions?.ToArray() ?? []; private readonly CacheParser parser = new CacheParser(cache); private readonly TimeSpan timeoutScript = options.Value.TimeoutScript; private readonly TimeSpan timeoutExecution = options.Value.TimeoutExecution; private readonly TimeSpan timeoutPromise = options.Value.TimeoutPromise; public async Task ExecuteAsync(ScriptVars vars, string script, ScriptOptions options = default, CancellationToken ct = default) { Guard.NotNull(vars); Guard.NotNullOrEmpty(script); using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct); try { // Enforce a timeout after a configured time span. combined.CancelAfter(timeoutExecution); var context = CreateEngine(options, combined.Token) .ExtendWithVariables(vars, options) .ExtendWithFunctions(extensions) .ExtendWithAsyncFunctions(extensions); context.Engine.SetValue("complete", new Action(value => { context.Complete(JsonMapper.Map(value)); })); var result = await ExecuteAsync(context, script); return await context.WaitForCompletionAsync(() => JsonMapper.Map(result)); } catch (Exception ex) { throw MapException(ex); } } public async Task TransformAsync(DataScriptVars vars, string script, ScriptOptions options = default, CancellationToken ct = default) { Guard.NotNull(vars); Guard.NotNullOrEmpty(script); var data = vars.Data!; using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct); try { // Enforce a timeout after a configured time span. combined.CancelAfter(timeoutExecution); var context = CreateEngine(options, combined.Token) .ExtendWithVariables(vars, options) .ExtendWithFunctions(extensions) .ExtendWithAsyncFunctions(extensions); context.Engine.SetValue("complete", new Action(_ => { context.Complete(data!); })); context.Engine.SetValue("replace", new Action(() => { var dataInstance = context.Engine.GetValue("ctx").AsObject().Get("data"); if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data) { if (!context.IsCompleted && data.TryUpdate(out var modified)) { context.Complete(modified); } } })); await ExecuteAsync(context, script); return await context.WaitForCompletionAsync(() => data); } catch (Exception ex) { throw MapException(ex); } } public JsonValue Execute(ScriptVars vars, string script, ScriptOptions options = default) { Guard.NotNull(vars); Guard.NotNullOrEmpty(script); try { var context = CreateEngine(options, default) .ExtendWithVariables(vars, options) .ExtendWithFunctions(extensions); var result = Execute(context, script); return JsonMapper.Map(result); } catch (Exception ex) { throw MapException(ex); } } private ScriptExecutionContext CreateEngine(ScriptOptions options, CancellationToken ct) { if (Debugger.IsAttached) { ct = default; } var engine = new Engine(engineOptions => { engineOptions.AddObjectConverter(JintObjectConverter.Instance, JintObjectConverter.HandledTypes); engineOptions.AllowClrWrite(!options.Readonly); // Converts enums to their name, e.g. "Published". This was done by JintObjectConverter before. engineOptions.Interop.EnumConversion = EnumConversionMode.String; engineOptions.SetTypeConverter(engine => new CustomClrConverter(engine)); engineOptions.SetReferencesResolver(NullPropagation.Instance, NullPropagation.Interests); engineOptions.Strict(); if (!Debugger.IsAttached) { engineOptions.Constraints.PromiseTimeout = timeoutPromise; engineOptions.TimeoutInterval(timeoutScript); engineOptions.CancellationToken(ct); } }); if (options.CanDisallow) { engine.AddDisallow(); } if (options.CanReject) { engine.AddReject(); } foreach (var extension in extensions) { extension.Extend(engine); } return new ScriptExecutionContext(engine, ct); } private JsValue Execute(ScriptExecutionContext context, string script) { var parsed = parser.Parse(script); return context.Evaluate(parsed); } private Task ExecuteAsync(ScriptExecutionContext context, string script) { var parsed = parser.Parse(script); return context.EvaluateAsync(parsed); } private 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); } } public void Describe(AddDescription describe, ScriptScope scope) { if (scope.HasFlag(ScriptScope.ContentTrigger) || scope.HasFlag(ScriptScope.AssetTrigger)) { return; } if (scope.HasFlag(ScriptScope.Transform) || scope.HasFlag(ScriptScope.ContentScript)) { describe(JsonType.Function, "replace()", Resources.ScriptingReplace); } describe(JsonType.Function, "disallow(reason)", Resources.ScriptingDisallow); describe(JsonType.Function, "reject(reason)", Resources.ScriptingReject); describe(JsonType.Function, "complete()", Resources.ScriptingComplete); } }