diff --git a/src/Squidex.Domain.Apps.Core/Contents/ContentData.cs b/src/Squidex.Domain.Apps.Core/Contents/ContentData.cs index c5c199c23..1c5a6ea99 100644 --- a/src/Squidex.Domain.Apps.Core/Contents/ContentData.cs +++ b/src/Squidex.Domain.Apps.Core/Contents/ContentData.cs @@ -9,9 +9,9 @@ using System; using System.Collections.Generic; using System.Linq; -using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Json; // ReSharper disable InvertIf @@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Core.Contents { var resultValue = new ContentFieldData(); - foreach (var partitionValue in fieldValue.Value.Where(x => IsNotNull(x.Value))) + foreach (var partitionValue in fieldValue.Value.Where(x => !x.Value.IsNull())) { resultValue[partitionValue.Key] = partitionValue.Value; } @@ -120,16 +120,6 @@ namespace Squidex.Domain.Apps.Core.Contents return this.DictionaryHashCode(); } - protected static bool IsNull(JToken value) - { - return value == null || value.Type == JTokenType.Null; - } - - protected static bool IsNotNull(JToken value) - { - return value != null && value.Type != JTokenType.Null; - } - public abstract T GetKey(Field field); } } diff --git a/src/Squidex.Domain.Apps.Core/Contents/ContentFieldData.cs b/src/Squidex.Domain.Apps.Core/Contents/ContentFieldData.cs index 8f45b1ecc..bdec81004 100644 --- a/src/Squidex.Domain.Apps.Core/Contents/ContentFieldData.cs +++ b/src/Squidex.Domain.Apps.Core/Contents/ContentFieldData.cs @@ -15,6 +15,8 @@ namespace Squidex.Domain.Apps.Core.Contents { public sealed class ContentFieldData : Dictionary, IEquatable { + private static readonly JTokenEqualityComparer JTokenEqualityComparer = new JTokenEqualityComparer(); + public ContentFieldData() : base(StringComparer.OrdinalIgnoreCase) { @@ -43,12 +45,12 @@ namespace Squidex.Domain.Apps.Core.Contents public bool Equals(ContentFieldData other) { - return other != null && (ReferenceEquals(this, other) || this.EqualsDictionary(other)); + return other != null && (ReferenceEquals(this, other) || this.EqualsDictionary(other, EqualityComparer.Default, JTokenEqualityComparer)); } public override int GetHashCode() { - return this.DictionaryHashCode(); + return this.DictionaryHashCode(EqualityComparer.Default, JTokenEqualityComparer); } } } \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Contents/IdContentData.cs b/src/Squidex.Domain.Apps.Core/Contents/IdContentData.cs index 1e3cbb89f..6ddfcc4da 100644 --- a/src/Squidex.Domain.Apps.Core/Contents/IdContentData.cs +++ b/src/Squidex.Domain.Apps.Core/Contents/IdContentData.cs @@ -13,6 +13,7 @@ using System.Text; using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Json; // ReSharper disable InvertIf @@ -65,7 +66,7 @@ namespace Squidex.Domain.Apps.Core.Contents continue; } - foreach (var partitionValue in fieldData.Where(x => IsNotNull(x.Value)).ToList()) + foreach (var partitionValue in fieldData.Where(x => !x.Value.IsNull()).ToList()) { var newValue = referenceField.RemoveDeletedReferences(partitionValue.Value, deletedReferencedIds); @@ -96,7 +97,7 @@ namespace Squidex.Domain.Apps.Core.Contents foreach (var partitionValue in fieldValue.Value) { - if (IsNull(partitionValue.Value)) + if (partitionValue.Value.IsNull()) { encodedValue[partitionValue.Key] = null; } diff --git a/src/Squidex.Domain.Apps.Core/Contents/NamedContentData.cs b/src/Squidex.Domain.Apps.Core/Contents/NamedContentData.cs index e0f16d071..2bded44c7 100644 --- a/src/Squidex.Domain.Apps.Core/Contents/NamedContentData.cs +++ b/src/Squidex.Domain.Apps.Core/Contents/NamedContentData.cs @@ -13,6 +13,7 @@ using System.Text; using Newtonsoft.Json.Linq; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Json; // ReSharper disable InvertIf @@ -65,7 +66,7 @@ namespace Squidex.Domain.Apps.Core.Contents foreach (var partitionValue in fieldValue.Value) { - if (IsNull(partitionValue.Value)) + if (partitionValue.Value.IsNull()) { encodedValue[partitionValue.Key] = null; } diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataObject.cs b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataObject.cs new file mode 100644 index 000000000..d7698ec74 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataObject.cs @@ -0,0 +1,132 @@ +// ========================================================================== +// ContentDataObject.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using Jint; +using Jint.Native; +using Jint.Native.Object; +using Jint.Runtime.Descriptors; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; + +// ReSharper disable InvertIf + +namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper +{ + public sealed class ContentDataObject : ObjectInstance + { + private readonly NamedContentData contentData; + private HashSet fieldsToDelete; + private Dictionary fieldProperties; + private bool isChanged; + + public ContentDataObject(Engine engine, NamedContentData contentData) + : base(engine) + { + Extensible = true; + + this.contentData = contentData; + } + + public void MarkChanged() + { + isChanged = true; + } + + public bool TryUpdate(out NamedContentData result) + { + result = contentData; + + if (isChanged) + { + if (fieldsToDelete != null) + { + foreach (var field in fieldsToDelete) + { + contentData.Remove(field); + } + } + + if (fieldProperties != null) + { + foreach (var kvp in fieldProperties) + { + if (kvp.Value.ContentField.TryUpdate(out var fieldData)) + { + contentData[kvp.Key] = fieldData; + } + } + } + } + + return isChanged; + } + + public override void RemoveOwnProperty(string propertyName) + { + if (fieldsToDelete == null) + { + fieldsToDelete = new HashSet(); + } + + fieldsToDelete.Add(propertyName); + fieldProperties?.Remove(propertyName); + + MarkChanged(); + } + + public override bool DefineOwnProperty(string propertyName, PropertyDescriptor desc, bool throwOnError) + { + EnsurePropertiesInitialized(); + + if (!fieldProperties.ContainsKey(propertyName)) + { + fieldProperties[propertyName] = new ContentDataProperty(this) { Value = desc.Value }; + } + + return true; + } + + public override void Put(string propertyName, JsValue value, bool throwOnError) + { + EnsurePropertiesInitialized(); + + fieldProperties.GetOrAdd(propertyName, x => new ContentDataProperty(this)).Value = value; + } + + public override PropertyDescriptor GetOwnProperty(string propertyName) + { + EnsurePropertiesInitialized(); + + return fieldProperties.GetOrDefault(propertyName) ?? new PropertyDescriptor(new ObjectInstance(Engine) { Extensible = true }, true, false, true); + } + + public override IEnumerable> GetOwnProperties() + { + EnsurePropertiesInitialized(); + + foreach (var property in fieldProperties) + { + yield return new KeyValuePair(property.Key, property.Value); + } + } + + private void EnsurePropertiesInitialized() + { + if (fieldProperties == null) + { + fieldProperties = new Dictionary(contentData.Count); + + foreach (var kvp in contentData) + { + fieldProperties.Add(kvp.Key, new ContentDataProperty(this, new ContentFieldObject(this, kvp.Value, false))); + } + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataProperty.cs b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataProperty.cs new file mode 100644 index 000000000..fcc9e4e79 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentDataProperty.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// ContentFieldProperty.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Jint.Native; +using Jint.Runtime; +using Jint.Runtime.Descriptors; +using Squidex.Domain.Apps.Core.Contents; + +// ReSharper disable InvertIf + +namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper +{ + public sealed class ContentDataProperty : PropertyDescriptor + { + private readonly ContentDataObject contentData; + private ContentFieldObject contentField; + private JsValue value; + + public override JsValue Value + { + get { return value; } + set + { + if (!Equals(this.value, value)) + { + if (value == null || !value.IsObject()) + { + throw new JavaScriptException("Can only assign object to content data."); + } + + var obj = value.AsObject(); + + contentField = new ContentFieldObject(contentData, new ContentFieldData(), true); + + foreach (var kvp in obj.GetOwnProperties()) + { + contentField.Put(kvp.Key, kvp.Value.Value, true); + } + + this.value = new JsValue(contentField); + } + } + } + + public ContentFieldObject ContentField + { + get { return contentField; } + } + + public ContentDataProperty(ContentDataObject contentData, ContentFieldObject contentField = null) + : base(null, true, true, true) + { + this.contentData = contentData; + this.contentField = contentField; + + if (contentField != null) + { + value = new JsValue(contentField); + } + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldObject.cs b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldObject.cs new file mode 100644 index 000000000..977e5d362 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldObject.cs @@ -0,0 +1,142 @@ +// ========================================================================== +// ContentFieldObject.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Collections.Generic; +using Jint.Native.Object; +using Jint.Runtime.Descriptors; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Infrastructure; + +// ReSharper disable InvertIf + +namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper +{ + public sealed class ContentFieldObject : ObjectInstance + { + private readonly ContentDataObject contentData; + private readonly ContentFieldData fieldData; + private HashSet valuesToDelete; + private Dictionary valueProperties; + private bool isChanged; + + public bool IsChanged + { + get { return isChanged; } + } + + public ContentFieldData FieldData + { + get { return fieldData; } + } + + public ContentFieldObject(ContentDataObject contentData, ContentFieldData fieldData, bool isNew) + : base(contentData.Engine) + { + Extensible = true; + + this.contentData = contentData; + this.fieldData = fieldData; + + if (isNew) + { + MarkChanged(); + } + } + + public void MarkChanged() + { + isChanged = true; + + contentData.MarkChanged(); + } + + public bool TryUpdate(out ContentFieldData result) + { + result = fieldData; + + if (isChanged) + { + if (valuesToDelete != null) + { + foreach (var field in valuesToDelete) + { + fieldData.Remove(field); + } + } + + if (valueProperties != null) + { + foreach (var kvp in valueProperties) + { + if (kvp.Value.IsChanged) + { + fieldData[kvp.Key] = kvp.Value.ContentValue; + } + } + } + } + + return isChanged; + } + + public override void RemoveOwnProperty(string propertyName) + { + if (valuesToDelete == null) + { + valuesToDelete = new HashSet(); + } + + valuesToDelete.Add(propertyName); + valueProperties?.Remove(propertyName); + + MarkChanged(); + } + + public override bool DefineOwnProperty(string propertyName, PropertyDescriptor desc, bool throwOnError) + { + EnsurePropertiesInitialized(); + + if (!valueProperties.ContainsKey(propertyName)) + { + valueProperties[propertyName] = new ContentFieldProperty(this) { Value = desc.Value }; + } + + return true; + } + + public override PropertyDescriptor GetOwnProperty(string propertyName) + { + EnsurePropertiesInitialized(); + + return valueProperties?.GetOrDefault(propertyName) ?? PropertyDescriptor.Undefined; + } + + public override IEnumerable> GetOwnProperties() + { + EnsurePropertiesInitialized(); + + foreach (var property in valueProperties) + { + yield return new KeyValuePair(property.Key, property.Value); + } + } + + private void EnsurePropertiesInitialized() + { + if (valueProperties == null) + { + valueProperties = new Dictionary(FieldData.Count); + + foreach (var kvp in FieldData) + { + valueProperties.Add(kvp.Key, new ContentFieldProperty(this, kvp.Value)); + } + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldProperty.cs b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldProperty.cs new file mode 100644 index 000000000..267f6caf4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/ContentFieldProperty.cs @@ -0,0 +1,58 @@ +// ========================================================================== +// ContentFieldProperty.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Jint.Native; +using Jint.Runtime.Descriptors; +using Newtonsoft.Json.Linq; + +// ReSharper disable InvertIf + +namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper +{ + public sealed class ContentFieldProperty : PropertyDescriptor + { + private readonly ContentFieldObject contentField; + private JToken contentValue; + private JsValue value; + private bool isChanged; + + public override JsValue Value + { + get { return value ?? (value = JsonMapper.Map(contentValue, contentField.Engine)); } + set + { + if (!Equals(this.value, value)) + { + this.value = value; + + contentValue = null; + contentField.MarkChanged(); + + isChanged = true; + } + } + } + + public JToken ContentValue + { + get { return contentValue ?? (contentValue = JsonMapper.Map(value)); } + } + + public bool IsChanged + { + get { return isChanged; } + } + + public ContentFieldProperty(ContentFieldObject contentField, JToken contentValue = null) + : base(null, true, true, true) + { + this.contentField = contentField; + this.contentValue = contentValue; + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/JsonMapper.cs b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/JsonMapper.cs new file mode 100644 index 000000000..c46d24c54 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core/Scripting/ContentWrapper/JsonMapper.cs @@ -0,0 +1,146 @@ +// ========================================================================== +// JsonMapper.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using Jint; +using Jint.Native; +using Jint.Native.Object; +using Newtonsoft.Json.Linq; + +// ReSharper disable SwitchStatementMissingSomeCases +// ReSharper disable InvertIf + +namespace Squidex.Domain.Apps.Core.Scripting.ContentWrapper +{ + public static class JsonMapper + { + public static JsValue Map(JToken value, Engine engine) + { + if (value == null) + { + return JsValue.Null; + } + + switch (value.Type) + { + case JTokenType.Date: + case JTokenType.Guid: + case JTokenType.String: + case JTokenType.Uri: + case JTokenType.TimeSpan: + return new JsValue((string)value); + case JTokenType.Null: + return JsValue.Null; + case JTokenType.Undefined: + return JsValue.Undefined; + case JTokenType.Integer: + return new JsValue((long)value); + case JTokenType.Float: + return new JsValue((double)value); + case JTokenType.Boolean: + return new JsValue((bool)value); + case JTokenType.Object: + { + var obj = (JObject)value; + + var target = new ObjectInstance(engine); + + foreach (var property in obj) + { + target.FastAddProperty(property.Key, Map(property.Value, engine), false, true, true); + } + + return target; + } + case JTokenType.Array: + { + var arr = (JArray)value; + + var target = new JsValue[arr.Count]; + + for (var i = 0; i < arr.Count; i++) + { + target[i] = Map(arr[i], engine); + } + + return engine.Array.Construct(target); + } + } + + throw new ArgumentException("Invalid json type", nameof(value)); + } + + public static JToken Map(JsValue value) + { + if (value == null || value.IsNull()) + { + return JValue.CreateNull(); + } + + if (value.IsUndefined()) + { + return JValue.CreateUndefined(); + } + + if (value.IsString()) + { + return new JValue(value.AsString()); + } + + if (value.IsBoolean()) + { + return new JValue(value.AsBoolean()); + } + + if (value.IsNumber()) + { + return new JValue(value.AsNumber()); + } + + if (value.IsDate()) + { + return new JValue(value.AsDate().ToDateTime()); + } + + if (value.IsRegExp()) + { + return JValue.CreateString(value.AsRegExp().Value?.ToString()); + } + + if (value.IsArray()) + { + var arr = value.AsArray(); + + var target = new JArray(); + + for (var i = 0; i < arr.GetLength(); i++) + { + target.Add(Map(arr.Get(i.ToString()))); + } + + return target; + } + + if (value.IsObject()) + { + var obj = value.AsObject(); + + var target = new JObject(); + + foreach (var kvp in obj.GetOwnProperties()) + { + target[kvp.Key] = Map(kvp.Value.Value); + } + + return target; + } + + throw new ArgumentException("Invalid json type", nameof(value)); + } + } +} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Scripting/JurassicScriptEngine.cs b/src/Squidex.Domain.Apps.Core/Scripting/JintScriptEngine.cs similarity index 56% rename from src/Squidex.Domain.Apps.Core/Scripting/JurassicScriptEngine.cs rename to src/Squidex.Domain.Apps.Core/Scripting/JintScriptEngine.cs index fffd52b37..615808338 100644 --- a/src/Squidex.Domain.Apps.Core/Scripting/JurassicScriptEngine.cs +++ b/src/Squidex.Domain.Apps.Core/Scripting/JintScriptEngine.cs @@ -1,5 +1,5 @@ // ========================================================================== -// JurassicScriptEngine.cs +// JintScriptEngine.cs // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex Group @@ -7,28 +7,20 @@ // ========================================================================== using System; -using Jurassic; -using Jurassic.Library; -using Newtonsoft.Json; +using Jint; +using Jint.Native.Object; +using Jint.Parser; +using Jint.Runtime; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Scripting.ContentWrapper; using Squidex.Infrastructure; // ReSharper disable InvertIf -// ReSharper disable ConvertToLambdaExpression namespace Squidex.Domain.Apps.Core.Scripting { - public sealed class JurassicScriptEngine : IScriptEngine + public sealed class JintScriptEngine : IScriptEngine { - private readonly JsonSerializerSettings serializerSettings; - - public JurassicScriptEngine(JsonSerializerSettings serializerSettings) - { - Guard.NotNull(serializerSettings, nameof(serializerSettings)); - - this.serializerSettings = serializerSettings; - } - public void Execute(ScriptContext context, string script, string operationName) { Guard.NotNull(context, nameof(context)); @@ -57,15 +49,13 @@ namespace Squidex.Domain.Apps.Core.Scripting EnableDisallow(engine); EnableReject(engine, operationName); - engine.SetGlobalFunction("replace", new Action(data => + engine.SetValue("replace", new Action(() => { - try - { - result = JsonConvert.DeserializeObject(JSONObject.Stringify(engine, data)); - } - catch + var dataInstance = engine.GetValue("ctx").AsObject().Get("data"); + + if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data) { - result = new NamedContentData(); + data.TryUpdate(out result); } })); @@ -87,9 +77,14 @@ namespace Squidex.Domain.Apps.Core.Scripting { var engine = CreateScriptEngine(context); - engine.SetGlobalFunction("replace", new Action(data => + engine.SetValue("replace", new Action(() => { - result = JsonConvert.DeserializeObject(JSONObject.Stringify(engine, data)); + var dataInstance = engine.GetValue("ctx").AsObject().Get("data"); + + if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data) + { + data.TryUpdate(out result); + } })); } catch (Exception) @@ -101,46 +96,67 @@ namespace Squidex.Domain.Apps.Core.Scripting return result; } - private static void Execute(ScriptEngine engine, string script, string operationName) + private static void Execute(Engine engine, string script, string operationName) { try { engine.Execute(script); } + catch (ParserException ex) + { + throw new ValidationException($"Failed to {operationName} with javascript error.", new ValidationError(ex.Message)); + } catch (JavaScriptException ex) { throw new ValidationException($"Failed to {operationName} with javascript error.", new ValidationError(ex.Message)); } } - private ScriptEngine CreateScriptEngine(ScriptContext context) + private static Engine CreateScriptEngine(ScriptContext context) { - var engine = new ScriptEngine { ForceStrictMode = true }; + var engine = new Engine(options => options.TimeoutInterval(TimeSpan.FromMilliseconds(100)).Strict()); + + var contextInstance = new ObjectInstance(engine); + + if (context.Data != null) + { + contextInstance.FastAddProperty("data", new ContentDataObject(engine, context.Data), true, true, true); + } + + if (context.OldData != null) + { + contextInstance.FastAddProperty("oldData", new ContentDataObject(engine, context.OldData), true, true, true); + } + + if (context.User != null) + { + contextInstance.FastAddProperty("user", new JintUser(engine, context.User), false, true, false); + } - engine.SetGlobalValue("ctx", JSONObject.Parse(engine, JsonConvert.SerializeObject(context, serializerSettings))); + engine.SetValue("ctx", contextInstance); return engine; } - private static void EnableReject(ScriptEngine engine, string operationName) + private static void EnableDisallow(Engine engine) { - Guard.NotNullOrEmpty(operationName, nameof(operationName)); - - engine.SetGlobalFunction("reject", new Action(message => + engine.SetValue("disallow", new Action(message => { - var errors = !string.IsNullOrWhiteSpace(message) ? new[] { new ValidationError(message) } : null; + var exMessage = !string.IsNullOrWhiteSpace(message) ? message : "Not allowed"; - throw new ValidationException($"Script rejected to to {operationName}.", errors); + throw new DomainForbiddenException(exMessage); })); } - private static void EnableDisallow(ScriptEngine engine) + private static void EnableReject(Engine engine, string operationName) { - engine.SetGlobalFunction("disallow", new Action(message => + Guard.NotNullOrEmpty(operationName, nameof(operationName)); + + engine.SetValue("reject", new Action(message => { - var exMessage = !string.IsNullOrWhiteSpace(message) ? message : "Not allowed"; + var errors = !string.IsNullOrWhiteSpace(message) ? new[] { new ValidationError(message) } : null; - throw new DomainForbiddenException(exMessage); + throw new ValidationException($"Script rejected to to {operationName}.", errors); })); } } diff --git a/src/Squidex.Domain.Apps.Core/Scripting/JintUser.cs b/src/Squidex.Domain.Apps.Core/Scripting/JintUser.cs new file mode 100644 index 000000000..8974099d1 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core/Scripting/JintUser.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// JintUser.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Linq; +using System.Security.Claims; +using Jint; +using Jint.Native; +using Jint.Native.Object; +using Squidex.Infrastructure.Security; + +namespace Squidex.Domain.Apps.Core.Scripting +{ + public sealed class JintUser : ObjectInstance + { + public JintUser(Engine engine, ClaimsPrincipal principal) + : base(engine) + { + var subjectId = principal.OpenIdSubject(); + + var isClient = string.IsNullOrWhiteSpace(subjectId); + + if (!isClient) + { + FastAddProperty("id", subjectId, false, true, false); + FastAddProperty("isClient", false, false, true, false); + } + else + { + FastAddProperty("id", principal.OpenIdClientId(), false, true, false); + FastAddProperty("isClient", true, false, true, false); + } + + FastAddProperty("email", principal.OpenIdEmail(), false, true, false); + + var claimsInstance = new ObjectInstance(engine); + + foreach (var group in principal.Claims.GroupBy(x => x.Type)) + { + claimsInstance.FastAddProperty(group.Key, engine.Array.Construct(group.Select(x => new JsValue(x.Value)).ToArray()), false, true, false); + } + + FastAddProperty("claims", claimsInstance, false, true, false); + } + } +} diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ScriptContext.cs b/src/Squidex.Domain.Apps.Core/Scripting/ScriptContext.cs index fb21649a4..ae8628a20 100644 --- a/src/Squidex.Domain.Apps.Core/Scripting/ScriptContext.cs +++ b/src/Squidex.Domain.Apps.Core/Scripting/ScriptContext.cs @@ -7,13 +7,14 @@ // ========================================================================== using System; +using System.Security.Claims; using Squidex.Domain.Apps.Core.Contents; namespace Squidex.Domain.Apps.Core.Scripting { public sealed class ScriptContext { - public ScriptUser User { get; set; } + public ClaimsPrincipal User { get; set; } public Guid ContentId { get; set; } diff --git a/src/Squidex.Domain.Apps.Core/Scripting/ScriptUser.cs b/src/Squidex.Domain.Apps.Core/Scripting/ScriptUser.cs deleted file mode 100644 index 8be73a193..000000000 --- a/src/Squidex.Domain.Apps.Core/Scripting/ScriptUser.cs +++ /dev/null @@ -1,50 +0,0 @@ -// ========================================================================== -// ScriptUser.cs -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex Group -// All rights reserved. -// ========================================================================== - -using System.Collections.Generic; -using System.Linq; -using System.Security.Claims; -using Squidex.Infrastructure; -using Squidex.Infrastructure.Security; -// ReSharper disable ConvertIfStatementToConditionalTernaryExpression - -namespace Squidex.Domain.Apps.Core.Scripting -{ - public sealed class ScriptUser - { - public bool IsClient { get; set; } - - public string Id { get; set; } - - public string Email { get; set; } - - public Dictionary Claims { get; set; } - - public static ScriptUser Create(ClaimsPrincipal principal) - { - Guard.NotNull(principal, nameof(principal)); - - var subjectId = principal.OpenIdSubject(); - - var user = new ScriptUser { IsClient = string.IsNullOrWhiteSpace(subjectId), Email = principal.OpenIdEmail() }; - - if (!user.IsClient) - { - user.Id = subjectId; - } - else - { - user.Id = principal.OpenIdClientId(); - } - - user.Claims = principal.Claims.GroupBy(x => x.Type).ToDictionary(x => x.Key, x => x.Select(y => y.Value).ToArray()); - - return user; - } - } -} \ No newline at end of file diff --git a/src/Squidex.Domain.Apps.Core/Squidex.Domain.Apps.Core.csproj b/src/Squidex.Domain.Apps.Core/Squidex.Domain.Apps.Core.csproj index c811f6da4..ff085a7dd 100644 --- a/src/Squidex.Domain.Apps.Core/Squidex.Domain.Apps.Core.csproj +++ b/src/Squidex.Domain.Apps.Core/Squidex.Domain.Apps.Core.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs b/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs index f376b5ffc..effb02b67 100644 --- a/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Write/Contents/ContentCommandMiddleware.cs @@ -196,7 +196,7 @@ namespace Squidex.Domain.Apps.Write.Contents private static ScriptContext CreateScriptContext(ContentDomainObject content, ContentCommand command, NamedContentData data = null) { - return new ScriptContext { ContentId = content.Id, Data = data, OldData = content.Data, User = ScriptUser.Create(command.User) }; + return new ScriptContext { ContentId = content.Id, Data = data, OldData = content.Data, User = command.User }; } private async Task<(ISchemaEntity SchemaEntity, IAppEntity AppEntity)> ResolveSchemaAndAppAsync(SchemaCommand command) diff --git a/src/Squidex.Domain.Users/UserClaimsPrincipalFactoryWithEmail.cs b/src/Squidex.Domain.Users/UserClaimsPrincipalFactoryWithEmail.cs index a326b264e..a18f6c9ee 100644 --- a/src/Squidex.Domain.Users/UserClaimsPrincipalFactoryWithEmail.cs +++ b/src/Squidex.Domain.Users/UserClaimsPrincipalFactoryWithEmail.cs @@ -19,7 +19,7 @@ namespace Squidex.Domain.Users { public sealed class UserClaimsPrincipalFactoryWithEmail : UserClaimsPrincipalFactory { - public UserClaimsPrincipalFactoryWithEmail(UserManager userManager, RoleManager roleManager, IOptions optionsAccessor) + public UserClaimsPrincipalFactoryWithEmail(UserManager userManager, RoleManager roleManager, IOptions optionsAccessor) : base(userManager, roleManager, optionsAccessor) { } diff --git a/src/Squidex.Infrastructure/CollectionExtensions.cs b/src/Squidex.Infrastructure/CollectionExtensions.cs index 7221d7b34..4778b9651 100644 --- a/src/Squidex.Infrastructure/CollectionExtensions.cs +++ b/src/Squidex.Infrastructure/CollectionExtensions.cs @@ -60,31 +60,36 @@ namespace Squidex.Infrastructure public static int DictionaryHashCode(this IDictionary dictionary) { - return DictionaryHashCode(dictionary, EqualityComparer.Default); + return DictionaryHashCode(dictionary, EqualityComparer.Default, EqualityComparer.Default); } - public static int DictionaryHashCode(this IDictionary dictionary, IEqualityComparer comparer) + public static int DictionaryHashCode(this IDictionary dictionary, IEqualityComparer keyComparer, IEqualityComparer valueComparer) { - Guard.NotNull(comparer, nameof(comparer)); - var hashCode = 17; foreach (var kvp in dictionary.OrderBy(x => x.Key)) { - hashCode = hashCode * 23 + kvp.Key.GetHashCode(); + hashCode = hashCode * 23 + keyComparer.GetHashCode(kvp.Key); if (kvp.Value != null) { - hashCode = hashCode * 23 + comparer.GetHashCode(kvp.Value); + hashCode = hashCode * 23 + valueComparer.GetHashCode(kvp.Value); } } return hashCode; } - public static bool EqualsDictionary(this IReadOnlyDictionary dictionary, IDictionary other) + public static bool EqualsDictionary(this IReadOnlyDictionary dictionary, IReadOnlyDictionary other) { - return other != null && dictionary.Count == other.Count && !dictionary.Except(other).Any(); + return EqualsDictionary(dictionary, other, EqualityComparer.Default, EqualityComparer.Default); + } + + public static bool EqualsDictionary(this IReadOnlyDictionary dictionary, IReadOnlyDictionary other, IEqualityComparer keyComparer, IEqualityComparer valueComparer) + { + var comparer = new KeyValuePairComparer(keyComparer, valueComparer); + + return other != null && dictionary.Count == other.Count && !dictionary.Except(other, comparer).Any(); } public static TValue GetOrDefault(this IReadOnlyDictionary dictionary, TKey key) @@ -136,5 +141,27 @@ namespace Squidex.Infrastructure action(item); } } + + public sealed class KeyValuePairComparer : IEqualityComparer> + { + private readonly IEqualityComparer keyComparer; + private readonly IEqualityComparer valueComparer; + + public KeyValuePairComparer(IEqualityComparer keyComparer, IEqualityComparer valueComparer) + { + this.keyComparer = keyComparer; + this.valueComparer = valueComparer; + } + + public bool Equals(KeyValuePair x, KeyValuePair y) + { + return keyComparer.Equals(x.Key, y.Key) && valueComparer.Equals(x.Value, y.Value); + } + + public int GetHashCode(KeyValuePair obj) + { + return keyComparer.GetHashCode(obj.Key) ^ valueComparer.GetHashCode(obj.Value); + } + } } } \ No newline at end of file diff --git a/src/Squidex/Controllers/ContentApi/ContentsController.cs b/src/Squidex/Controllers/ContentApi/ContentsController.cs index 88c6af993..5beb6b5a2 100644 --- a/src/Squidex/Controllers/ContentApi/ContentsController.cs +++ b/src/Squidex/Controllers/ContentApi/ContentsController.cs @@ -105,7 +105,6 @@ namespace Squidex.Controllers.ContentApi await Task.WhenAll(taskForItems, taskForCount); - var scriptUser = ScriptUser.Create(User); var scriptText = schemaEntity.ScriptQuery; var hasScript = !string.IsNullOrWhiteSpace(scriptText); @@ -123,7 +122,7 @@ namespace Squidex.Controllers.ContentApi if (hasScript && !isFrontendClient) { - data = scriptEngine.Transform(new ScriptContext { Data = data, ContentId = item.Id, User = scriptUser }, scriptText); + data = scriptEngine.Transform(new ScriptContext { Data = data, ContentId = item.Id, User = User }, scriptText); } itemModel.Data = data; @@ -166,14 +165,13 @@ namespace Squidex.Controllers.ContentApi if (!isFrontendClient) { - var scriptUser = ScriptUser.Create(User); var scriptText = schemaEntity.ScriptQuery; var hasScript = !string.IsNullOrWhiteSpace(scriptText); if (hasScript) { - data = scriptEngine.Transform(new ScriptContext { Data = data, ContentId = entity.Id, User = scriptUser }, scriptText); + data = scriptEngine.Transform(new ScriptContext { Data = data, ContentId = entity.Id, User = User }, scriptText); } } diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Scripting/ContentDataObjectTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Scripting/ContentDataObjectTests.cs new file mode 100644 index 000000000..0c5c33d03 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Core.Tests/Scripting/ContentDataObjectTests.cs @@ -0,0 +1,216 @@ +// ========================================================================== +// ContentDataObjectTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using Jint; +using Jint.Runtime; +using Newtonsoft.Json.Linq; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Scripting.ContentWrapper; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Scripting +{ + public sealed class ContentDataObjectTests + { + [Fact] + public void Should_update_content_when_setting_field() + { + var original = new NamedContentData(); + + var expected = + new NamedContentData() + .AddField("number", + new ContentFieldData() + .AddValue("iv", 1.0)); + + var result = ExecuteScript(original, @"data.number = { iv: 1 }"); + + Assert.Equal(expected, result); + } + + [Fact] + public void Should_update_content_when_deleting_field() + { + var original = + new NamedContentData() + .AddField("number", + new ContentFieldData() + .AddValue("iv", 1.0)); + + var expected = new NamedContentData(); + + var result = ExecuteScript(original, @"delete data.number"); + + Assert.Equal(expected, result); + } + + [Fact] + public void Should_update_content_when_setting_field_value_with_string() + { + var original = + new NamedContentData() + .AddField("string", + new ContentFieldData() + .AddValue("iv", "1")); + + var expected = + new NamedContentData() + .AddField("string", + new ContentFieldData() + .AddValue("iv", "1new")); + + var result = ExecuteScript(original, @"data.string.iv = data.string.iv + 'new'"); + + Assert.Equal(expected, result); + } + + [Fact] + public void Should_update_content_when_setting_field_value_with_number() + { + var original = + new NamedContentData() + .AddField("number", + new ContentFieldData() + .AddValue("iv", 1.0)); + + var expected = + new NamedContentData() + .AddField("number", + new ContentFieldData() + .AddValue("iv", 3.0)); + + var result = ExecuteScript(original, @"data.number.iv = data.number.iv + 2"); + + Assert.Equal(expected, result); + } + + [Fact] + public void Should_update_content_when_setting_field_value_with_array() + { + var original = + new NamedContentData() + .AddField("number", + new ContentFieldData() + .AddValue("iv", new JArray(1.0, 2.0))); + + var expected = + new NamedContentData() + .AddField("number", + new ContentFieldData() + .AddValue("iv", new JArray(1.0, 4.0, 5.0))); + + var result = ExecuteScript(original, @"data.number.iv = [data.number.iv[0], data.number.iv[1] + 2, 5]"); + + Assert.Equal(expected, result); + } + + [Fact] + public void Should_update_content_when_setting_field_value_with_object() + { + var original = + new NamedContentData() + .AddField("number", + new ContentFieldData() + .AddValue("iv", new JObject(new JProperty("lat", 1.0)))); + + var expected = + new NamedContentData() + .AddField("number", + new ContentFieldData() + .AddValue("iv", new JObject(new JProperty("lat", 1.0), new JProperty("lon", 4.0)))); + + var result = ExecuteScript(original, @"data.number.iv = { lat: data.number.iv.lat, lon: data.number.iv.lat + 3 }"); + + Assert.Equal(expected, result); + } + + [Fact] + public void Should_update_content_when_setting_field_value_with_boolean() + { + var original = + new NamedContentData() + .AddField("boolean", + new ContentFieldData() + .AddValue("iv", false)); + + var expected = + new NamedContentData() + .AddField("boolean", + new ContentFieldData() + .AddValue("iv", true)); + + var result = ExecuteScript(original, @"data.boolean.iv = !data.boolean.iv"); + + Assert.Equal(expected, result); + } + + [Fact] + public void Should_update_content_when_deleting_field_value() + { + var original = + new NamedContentData() + .AddField("string", + new ContentFieldData() + .AddValue("iv", "hello")); + + var expected = + new NamedContentData() + .AddField("string", + new ContentFieldData()); + + var result = ExecuteScript(original, @"delete data.string.iv"); + + Assert.Equal(expected, result); + } + + [Fact] + public void Should_throw_exceptions_when_changing_objects() + { + var original = + new NamedContentData() + .AddField("obj", + new ContentFieldData() + .AddValue("iv", new JObject(new JProperty("readonly", 1)))); + + Assert.Throws(() => ExecuteScript(original, "data.obj.iv.invalid = 1")); + Assert.Throws(() => ExecuteScript(original, "data.obj.iv.readonly = 2")); + } + + [Fact] + public void Should_not_throw_exceptions_when_changing_arrays() + { + var original = + new NamedContentData() + .AddField("obj", + new ContentFieldData() + .AddValue("iv", new JArray())); + + ExecuteScript(original, "data.obj.iv[0] = 1"); + } + + [Fact] + public void Should_null_propagate_unknown_fields() + { + ExecuteScript(new NamedContentData(), @"data.string.iv = 'hello'"); + } + + private static NamedContentData ExecuteScript(NamedContentData original, string script) + { + var engine = new Engine(o => o.Strict()); + + var value = new ContentDataObject(engine, original); + + engine.SetValue("data", value); + engine.Execute(script); + + value.TryUpdate(out var result); + + return result; + } + } +} diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Scripting/JurassicScriptEngineTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Scripting/JintScriptEngineTests.cs similarity index 66% rename from tests/Squidex.Domain.Apps.Core.Tests/Scripting/JurassicScriptEngineTests.cs rename to tests/Squidex.Domain.Apps.Core.Tests/Scripting/JintScriptEngineTests.cs index eb16ef818..168fc4b7b 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Scripting/JurassicScriptEngineTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Scripting/JintScriptEngineTests.cs @@ -1,27 +1,20 @@ // ========================================================================== -// JurassicScriptEngineTests.cs +// JintScriptEngineTests.cs // Squidex Headless CMS // ========================================================================== // Copyright (c) Squidex Group // All rights reserved. // ========================================================================== -using Newtonsoft.Json; -using Newtonsoft.Json.Serialization; using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure; using Xunit; namespace Squidex.Domain.Apps.Core.Scripting { - public class JurassicScriptEngineTests + public class JintScriptEngineTests { - private readonly JurassicScriptEngine scriptEngine = - new JurassicScriptEngine( - new JsonSerializerSettings - { - ContractResolver = new CamelCasePropertyNamesContractResolver() - }); + private readonly JintScriptEngine scriptEngine = new JintScriptEngine(); [Fact] public void Should_throw_validation_exception_when_calling_reject() @@ -86,44 +79,6 @@ namespace Squidex.Domain.Apps.Core.Scripting Assert.Same(content, result); } - [Fact] - public void Should_return_original_content_when_replacing_with_invalid_content_in_transform() - { - var content = - new NamedContentData() - .AddField("number0", - new ContentFieldData() - .AddValue("iv", 1)) - .AddField("number1", - new ContentFieldData() - .AddValue("iv", 1)); - - var context = new ScriptContext { Data = content }; - - var result = scriptEngine.Transform(context, @"replace({ test: 1 });"); - - Assert.Equal(content, result); - } - - [Fact] - public void Should_return_empty_content_when_replacing_with_invalid_content_in_execute_and_transform() - { - var content = - new NamedContentData() - .AddField("number0", - new ContentFieldData() - .AddValue("iv", 1)) - .AddField("number1", - new ContentFieldData() - .AddValue("iv", 1)); - - var context = new ScriptContext { Data = content }; - - var result = scriptEngine.ExecuteAndTransform(context, @"replace({ test: 1 });", "update"); - - Assert.Equal(new NamedContentData(), result); - } - [Fact] public void Should_transform_content_and_return() { @@ -131,18 +86,18 @@ namespace Squidex.Domain.Apps.Core.Scripting new NamedContentData() .AddField("number0", new ContentFieldData() - .AddValue("iv", 1)) + .AddValue("iv", 1.0)) .AddField("number1", new ContentFieldData() - .AddValue("iv", 1)); + .AddValue("iv", 1.0)); var expected = new NamedContentData() .AddField("number1", new ContentFieldData() - .AddValue("iv", 2)) + .AddValue("iv", 2.0)) .AddField("number2", new ContentFieldData() - .AddValue("iv", 10)); + .AddValue("iv", 10.0)); var context = new ScriptContext { Data = content }; diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Scripting/JintUserTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Scripting/JintUserTests.cs new file mode 100644 index 000000000..e5be0d7db --- /dev/null +++ b/tests/Squidex.Domain.Apps.Core.Tests/Scripting/JintUserTests.cs @@ -0,0 +1,73 @@ +// ========================================================================== +// JintUserTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System.Security.Claims; +using Jint; +using Squidex.Infrastructure.Security; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Scripting +{ + public class JintUserTests + { + [Fact] + public void Should_set_user_id_from_client_id() + { + var identity = new ClaimsIdentity(); + + identity.AddClaim(new Claim(OpenIdClaims.ClientId, "1")); + + Assert.Equal("1", GetValue(identity, "user.id")); + Assert.Equal(true, GetValue(identity, "user.isClient")); + } + + [Fact] + public void Should_set_user_id_from_subject_id() + { + var identity = new ClaimsIdentity(); + + identity.AddClaim(new Claim(OpenIdClaims.Subject, "2")); + + Assert.Equal("2", GetValue(identity, "user.id")); + Assert.Equal(false, GetValue(identity, "user.isClient")); + } + + [Fact] + public void Should_set_email_from_claim() + { + var identity = new ClaimsIdentity(); + + identity.AddClaim(new Claim(OpenIdClaims.Email, "hello@squidex.io")); + + Assert.Equal("hello@squidex.io", GetValue(identity, "user.email")); + } + + [Fact] + public void Should_set_claims() + { + var identity = new ClaimsIdentity(); + + identity.AddClaim(new Claim("claim1", "1a")); + identity.AddClaim(new Claim("claim1", "1b")); + identity.AddClaim(new Claim("claim2", "2a")); + identity.AddClaim(new Claim("claim2", "2b")); + + Assert.Equal(new[] { "1a", "1b" }, GetValue(identity, "user.claims.claim1")); + Assert.Equal(new[] { "2a", "2b" }, GetValue(identity, "user.claims.claim2")); + } + + private static object GetValue(ClaimsIdentity identity, string script) + { + var engine = new Engine(); + + engine.SetValue("user", new JintUser(engine, new ClaimsPrincipal(new[] { identity }))); + + return engine.Execute(script).GetCompletionValue().ToObject(); + } + } +} diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Scripting/ScriptUserTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Scripting/ScriptUserTests.cs deleted file mode 100644 index 489d610ca..000000000 --- a/tests/Squidex.Domain.Apps.Core.Tests/Scripting/ScriptUserTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -// ========================================================================== -// ScriptUserTests.cs -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex Group -// All rights reserved. -// ========================================================================== - -using System.Collections.Generic; -using System.Security.Claims; -using FluentAssertions; -using Squidex.Infrastructure.Security; -using Xunit; - -namespace Squidex.Domain.Apps.Core.Scripting -{ - public class ScriptUserTests - { - [Fact] - public void Should_create_script_user_from_user_principal() - { - var identity = new ClaimsIdentity(); - - identity.AddClaim(new Claim(OpenIdClaims.Subject, "1")); - identity.AddClaim(new Claim(OpenIdClaims.Email, "hello@squidex.io")); - identity.AddClaim(new Claim("claim1", "1a")); - identity.AddClaim(new Claim("claim1", "1b")); - identity.AddClaim(new Claim("claim2", "2a")); - identity.AddClaim(new Claim("claim2", "2b")); - - var principal = new ClaimsPrincipal(new[] { identity }); - - var scriptUser = ScriptUser.Create(principal); - - scriptUser.ShouldBeEquivalentTo( - new ScriptUser - { - Email = "hello@squidex.io", - Id = "1", - IsClient = false, - Claims = new Dictionary - { - { "sub", new [] { "1" } }, - { "claim1", new[] { "1a", "1b" } }, - { "claim2", new[] { "2a", "2b" } }, - { "email", new [] { "hello@squidex.io" } } - } - }); - } - - [Fact] - public void Should_create_script_user_from_client_principal() - { - var identity = new ClaimsIdentity(); - - identity.AddClaim(new Claim(OpenIdClaims.ClientId, "1")); - identity.AddClaim(new Claim("claim1", "1a")); - identity.AddClaim(new Claim("claim1", "1b")); - identity.AddClaim(new Claim("claim2", "2a")); - identity.AddClaim(new Claim("claim2", "2b")); - - var principal = new ClaimsPrincipal(new[] { identity }); - - var scriptUser = ScriptUser.Create(principal); - - scriptUser.ShouldBeEquivalentTo( - new ScriptUser - { - Id = "1", - IsClient = true, - Claims = new Dictionary - { - { "client_id", new [] { "1" } } , - { "claim1", new[] { "1a", "1b" } }, - { "claim2", new[] { "2a", "2b" } } - } - }); - } - } -}