diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs index c08a08a61..1536ec15a 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs @@ -25,7 +25,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Extensions this.urlGenerator = urlGenerator; } - public void Extend(ExecutionContext context, bool async) + public void Extend(ExecutionContext context) { context.Engine.SetValue("contentAction", new EventDelegate(() => { diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ExecutionContext.cs index 226a13958..acd08d133 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ExecutionContext.cs @@ -15,26 +15,19 @@ using Squidex.Text; namespace Squidex.Domain.Apps.Core.Scripting { - public delegate bool ExceptionHandler(Exception exception); - - public sealed class ExecutionContext : Dictionary + public sealed class ExecutionContext : ScriptContext { - private readonly ExceptionHandler? exceptionHandler; + private Func? completion; public Engine Engine { get; } - public CancellationToken CancellationToken { get; } + public CancellationToken CancellationToken { get; private set; } public bool IsAsync { get; private set; } - internal ExecutionContext(Engine engine, CancellationToken cancellationToken, ExceptionHandler? exceptionHandler = null) - : base(StringComparer.OrdinalIgnoreCase) + internal ExecutionContext(Engine engine) { Engine = engine; - - CancellationToken = cancellationToken; - - this.exceptionHandler = exceptionHandler; } public void MarkAsync() @@ -44,10 +37,34 @@ namespace Squidex.Domain.Apps.Core.Scripting public void Fail(Exception exception) { - exceptionHandler?.Invoke(exception); + completion?.Invoke(exception); + } + + public ExecutionContext ExtendAsync(IEnumerable extensions, Func completion, CancellationToken ct) + { + CancellationToken = ct; + + this.completion = completion; + + foreach (var extension in extensions) + { + extension.ExtendAsync(this); + } + + return this; + } + + public ExecutionContext Extend(IEnumerable extensions) + { + foreach (var extension in extensions) + { + extension.Extend(this); + } + + return this; } - public void AddVariables(ScriptVars vars, ScriptOptions options) + public ExecutionContext Extend(ScriptVars vars, ScriptOptions options) { var engine = Engine; @@ -86,6 +103,8 @@ namespace Squidex.Domain.Apps.Core.Scripting } engine.SetValue("async", true); + + return this; } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs index 1e805ae3b..8f25001ad 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs @@ -29,14 +29,11 @@ namespace Squidex.Domain.Apps.Core.Scripting.Extensions this.httpClientFactory = httpClientFactory; } - public void Extend(ExecutionContext context, bool async) + public void ExtendAsync(ExecutionContext context) { - if (async) - { - var engine = context.Engine; + var action = new GetJsonDelegate((url, callback, headers) => GetJson(context, url, callback, headers)); - engine.SetValue("getJSON", new GetJsonDelegate((url, callback, headers) => GetJson(context, url, callback, headers))); - } + context.Engine.SetValue("getJSON", action); } private void GetJson(ExecutionContext context, string url, Action callback, JsValue? headers) @@ -46,6 +43,18 @@ namespace Squidex.Domain.Apps.Core.Scripting.Extensions private async Task GetJsonAsync(ExecutionContext context, string url, Action callback, JsValue? headers) { + if (callback == null) + { + context.Fail(new JavaScriptException("Callback cannot be null.")); + return; + } + + if (!Uri.IsWellFormedUriString(url, UriKind.Absolute)) + { + context.Fail(new JavaScriptException("URL is not valid.")); + return; + } + context.MarkAsync(); try diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs index 8b1a75102..4737551b3 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs @@ -15,7 +15,11 @@ namespace Squidex.Domain.Apps.Core.Scripting { } - void Extend(ExecutionContext context, bool async) + void Extend(ExecutionContext context) + { + } + + void ExtendAsync(ExecutionContext context) { } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs index b8b2912a2..69d05f9d2 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs @@ -52,7 +52,11 @@ namespace Squidex.Domain.Apps.Core.Scripting using (cts.Token.Register(() => tcs.TrySetCanceled())) { - var context = CreateEngine(vars, options, tcs.TrySetException, true, cts.Token); + var context = + CreateEngine(options) + .Extend(vars, options) + .Extend(extensions) + .ExtendAsync(extensions, tcs.TrySetException, cts.Token); context.Engine.SetValue("complete", new Action(value => { @@ -82,7 +86,11 @@ namespace Squidex.Domain.Apps.Core.Scripting using (cts.Token.Register(() => tcs.TrySetCanceled())) { - var context = CreateEngine(vars, options, tcs.TrySetException, true, cts.Token); + var context = + CreateEngine(options) + .Extend(vars, options) + .Extend(extensions) + .ExtendAsync(extensions, tcs.TrySetException, cts.Token); context.Engine.SetValue("complete", new Action(_ => { @@ -126,14 +134,17 @@ namespace Squidex.Domain.Apps.Core.Scripting Guard.NotNull(vars, nameof(vars)); Guard.NotNullOrEmpty(script, nameof(script)); - var context = CreateEngine(vars, options); + var context = + CreateEngine(options) + .Extend(vars, options) + .Extend(extensions); Execute(context.Engine, script); return JsonMapper.Map(context.Engine.GetCompletionValue()); } - private ExecutionContext CreateEngine(ScriptVars vars, ScriptOptions options, ExceptionHandler? exceptionHandler = null, bool async = false, CancellationToken ct = default) + private ExecutionContext CreateEngine(ScriptOptions options) { var engine = new Engine(engineOptions => { @@ -158,14 +169,7 @@ namespace Squidex.Domain.Apps.Core.Scripting extension.Extend(engine); } - var context = new ExecutionContext(engine, ct, exceptionHandler); - - context.AddVariables(vars, options); - - foreach (var extension in extensions) - { - extension.Extend(context, async); - } + var context = new ExecutionContext(engine); return context; } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs new file mode 100644 index 000000000..cde181bac --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs @@ -0,0 +1,37 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Scripting +{ + public class ScriptContext : Dictionary + { + public ScriptContext() + : base(StringComparer.OrdinalIgnoreCase) + { + } + + public bool TryGetValue(string key, [MaybeNullWhen(false)] out T value) + { + Guard.NotNull(key, nameof(key)); + + value = default!; + + if (TryGetValue(key, out var temp) && temp is T typed) + { + value = typed; + return true; + } + + return false; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptVars.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptVars.cs index ac10430e3..fa8adda24 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptVars.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptVars.cs @@ -6,21 +6,17 @@ // ========================================================================== using System; -using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Security.Claims; using Squidex.Domain.Apps.Core.Contents; using Squidex.Infrastructure; +#pragma warning disable CS0618 // Type or member is obsolete + namespace Squidex.Domain.Apps.Core.Scripting { - public sealed class ScriptVars : Dictionary + public sealed class ScriptVars : ScriptContext { - public ScriptVars() - : base(StringComparer.OrdinalIgnoreCase) - { - } - public ClaimsPrincipal? User { get => GetValue(); @@ -68,7 +64,7 @@ namespace Squidex.Domain.Apps.Core.Scripting get => GetValue(); set { - SetValue(value, "oldData"); + SetValue(value, nameof(OldData)); SetValue(value); } } @@ -78,11 +74,23 @@ namespace Squidex.Domain.Apps.Core.Scripting get => GetValue(); set { - SetValue(value, "oldStatus"); + SetValue(value, nameof(OldStatus)); SetValue(value); } } + [Obsolete("Use dataOld")] + public ContentData? OldData + { + get => GetValue(); + } + + [Obsolete("Use statusOld")] + public Status? OldStatus + { + get => GetValue(); + } + public void SetValue(object? value, [CallerMemberName] string? key = null) { if (key != null) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 7926ab6be..baea87f99 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -169,7 +169,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets } } - public async Task FindAssetAsync(DomainId appId, string hash, string fileName, long fileSize) + public async Task FindAssetByHashAsync(DomainId appId, string hash, string fileName, long fileSize) { using (Profiler.TraceMethod()) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs new file mode 100644 index 000000000..6ecd04860 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs @@ -0,0 +1,100 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using System.Text.Encodings.Web; +using System.Threading.Tasks; +using Fluid; +using Fluid.Ast; +using Fluid.Tags; +using GraphQL.Utilities; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Templates; +using Squidex.Domain.Apps.Core.ValidateContent; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class AssetsFluidExtension : IFluidExtension + { + private readonly IServiceProvider serviceProvider; + + private sealed class AssetTag : ArgumentsTag + { + private readonly IServiceProvider serviceProvider; + + public AssetTag(IServiceProvider serviceProvider) + { + this.serviceProvider = serviceProvider; + } + + public override async ValueTask WriteToAsync(TextWriter writer, TextEncoder encoder, TemplateContext context, FilterArgument[] arguments) + { + if (arguments.Length == 2 && context.GetValue("event")?.ToObjectValue() is EnrichedEvent enrichedEvent) + { + var app = await GetAppAsync(enrichedEvent); + + if (app == null) + { + return Completion.Normal; + } + + var requestContext = + Context.Admin(app).Clone(b => b + .WithoutTotal()); + + var id = (await arguments[1].Expression.EvaluateAsync(context)).ToStringValue(); + + var assetQuery = serviceProvider.GetRequiredService(); + + var asset = await assetQuery.FindAsync(requestContext, DomainId.Create(id)); + + if (asset != null) + { + var name = (await arguments[0].Expression.EvaluateAsync(context)).ToStringValue(); + + context.SetValue(name, asset); + } + } + + return Completion.Normal; + } + + private Task GetAppAsync(EnrichedEvent enrichedEvent) + { + var appProvider = serviceProvider.GetRequiredService(); + + return appProvider.GetAppAsync(enrichedEvent.AppId.Id, false); + } + } + + public AssetsFluidExtension(IServiceProvider serviceProvider) + { + Guard.NotNull(serviceProvider, nameof(serviceProvider)); + + this.serviceProvider = serviceProvider; + } + + public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy) + { + memberAccessStrategy.Register(); + memberAccessStrategy.Register(); + memberAccessStrategy.Register(); + memberAccessStrategy.Register(); + memberAccessStrategy.Register(); + memberAccessStrategy.Register(); + memberAccessStrategy.Register(); + } + + public void RegisterLanguageExtensions(FluidParserFactory factory) + { + factory.RegisterTag("asset", new AssetTag(serviceProvider)); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs new file mode 100644 index 000000000..4df5836ef --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs @@ -0,0 +1,123 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using GraphQL.Utilities; +using Jint.Native; +using Jint.Runtime; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public sealed class AssetsJintExtension : IJintExtension + { + private delegate void GetAssetsDelegate(JsValue references, Action callback); + private readonly IServiceProvider serviceProvider; + + public AssetsJintExtension(IServiceProvider serviceProvider) + { + Guard.NotNull(serviceProvider, nameof(serviceProvider)); + + this.serviceProvider = serviceProvider; + } + + public void ExtendAsync(ExecutionContext context) + { + if (!context.TryGetValue(nameof(ScriptVars.AppId), out var appId)) + { + return; + } + + if (!context.TryGetValue(nameof(ScriptVars.User), out var user)) + { + return; + } + + var action = new GetAssetsDelegate((references, callback) => GetReferences(context, appId, user, references, callback)); + + context.Engine.SetValue("getAsset", action); + context.Engine.SetValue("getAssets", action); + } + + private void GetReferences(ExecutionContext context, DomainId appId, ClaimsPrincipal user, JsValue references, Action callback) + { + GetReferencesAsync(context, appId, user, references, callback).Forget(); + } + + private async Task GetReferencesAsync(ExecutionContext context, DomainId appId, ClaimsPrincipal user, JsValue references, Action callback) + { + Guard.NotNull(callback, nameof(callback)); + + var ids = new List(); + + if (references.IsString()) + { + ids.Add(DomainId.Create(references.ToString())); + } + else if (references.IsArray()) + { + foreach (var value in references.AsArray()) + { + if (value.IsString()) + { + ids.Add(DomainId.Create(value.ToString())); + } + } + } + + if (ids.Count == 0) + { + var emptyAssets = Array.Empty(); + + callback(JsValue.FromObject(context.Engine, emptyAssets)); + return; + } + + context.MarkAsync(); + + try + { + var app = await GetAppAsync(appId); + + var requestContext = + new Context(user, app).Clone(b => b + .WithoutTotal()); + + var assetQuery = serviceProvider.GetRequiredService(); + + var assets = await assetQuery.QueryAsync(requestContext, null, Q.Empty.WithIds(ids)); + + callback(JsValue.FromObject(context.Engine, assets.ToArray())); + } + catch (Exception ex) + { + context.Fail(ex); + } + } + + private async Task GetAppAsync(DomainId appId) + { + var appProvider = serviceProvider.GetRequiredService(); + + var app = await appProvider.GetAppAsync(appId); + + if (app == null) + { + throw new JavaScriptException("App does not exist."); + } + + return app; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs index 2d4945aa9..d3db4381b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs @@ -21,6 +21,10 @@ namespace Squidex.Domain.Apps.Entities.Assets Task FindByHashAsync(Context context, string hash, string fileName, long fileSize); - Task FindAsync(Context context, DomainId id); + Task FindAsync(Context context, DomainId id, long version = EtagVersion.Any); + + Task FindBySlugAsync(Context context, string slug); + + Task FindGlobalAsync(Context context, DomainId id); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs index d14933d30..ea0c71c44 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs @@ -10,107 +10,195 @@ using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Infrastructure; +using Squidex.Log; namespace Squidex.Domain.Apps.Entities.Assets.Queries { public sealed class AssetQueryService : IAssetQueryService { + private static readonly IResultList EmptyAssets = ResultList.CreateFrom(0); private readonly IAssetEnricher assetEnricher; private readonly IAssetRepository assetRepository; + private readonly IAssetLoader assetLoader; private readonly IAssetFolderRepository assetFolderRepository; private readonly AssetQueryParser queryParser; public AssetQueryService( IAssetEnricher assetEnricher, IAssetRepository assetRepository, + IAssetLoader assetLoader, IAssetFolderRepository assetFolderRepository, AssetQueryParser queryParser) { Guard.NotNull(assetEnricher, nameof(assetEnricher)); Guard.NotNull(assetRepository, nameof(assetRepository)); + Guard.NotNull(assetLoader, nameof(assetLoader)); Guard.NotNull(assetFolderRepository, nameof(assetFolderRepository)); Guard.NotNull(queryParser, nameof(queryParser)); this.assetEnricher = assetEnricher; this.assetRepository = assetRepository; + this.assetLoader = assetLoader; this.assetFolderRepository = assetFolderRepository; this.queryParser = queryParser; } - public async Task FindByHashAsync(Context context, string hash, string fileName, long fileSize) + public async Task> FindAssetFolderAsync(DomainId appId, DomainId id) { - Guard.NotNull(context, nameof(context)); + using (Profiler.TraceMethod()) + { + var result = new List(); - var asset = await assetRepository.FindAssetAsync(context.App.Id, hash, fileName, fileSize); + while (id != DomainId.Empty) + { + var folder = await assetFolderRepository.FindAssetFolderAsync(appId, id); - if (asset != null) - { - return await assetEnricher.EnrichAsync(asset, context); + if (folder == null || result.Any(x => x.Id == folder.Id)) + { + result.Clear(); + break; + } + + result.Insert(0, folder); + + id = folder.ParentId; + } + + return result; } + } + + public async Task> QueryAssetFoldersAsync(Context context, DomainId parentId) + { + using (Profiler.TraceMethod()) + { + var assetFolders = await assetFolderRepository.QueryAsync(context.App.Id, parentId); - return null; + return assetFolders; + } } - public async Task FindAsync(Context context, DomainId id) + public async Task FindByHashAsync(Context context, string hash, string fileName, long fileSize) { Guard.NotNull(context, nameof(context)); - var asset = await assetRepository.FindAssetAsync(context.App.Id, id); - - if (asset != null) + using (Profiler.TraceMethod()) { - return await assetEnricher.EnrichAsync(asset, context); - } + var asset = await assetRepository.FindAssetByHashAsync(context.App.Id, hash, fileName, fileSize); + + if (asset == null) + { + return null; + } - return null; + return await TransformAsync(context, asset); + } } - public async Task> FindAssetFolderAsync(DomainId appId, DomainId id) + public async Task FindBySlugAsync(Context context, string slug) { - var result = new List(); + Guard.NotNull(context, nameof(context)); - while (id != DomainId.Empty) + using (Profiler.TraceMethod()) { - var folder = await assetFolderRepository.FindAssetFolderAsync(appId, id); + var asset = await assetRepository.FindAssetBySlugAsync(context.App.Id, slug); - if (folder == null || result.Any(x => x.Id == folder.Id)) + if (asset == null) { - result.Clear(); - break; + return null; } - result.Insert(0, folder); - - id = folder.ParentId; + return await TransformAsync(context, asset); } + } + + public async Task FindGlobalAsync(Context context, DomainId id) + { + Guard.NotNull(context, nameof(context)); + + using (Profiler.TraceMethod()) + { + var asset = await assetRepository.FindAssetAsync(id); + + if (asset == null) + { + return null; + } - return result; + return await TransformAsync(context, asset); + } } - public async Task> QueryAssetFoldersAsync(Context context, DomainId parentId) + public async Task FindAsync(Context context, DomainId id, long version = EtagVersion.Any) { - var assetFolders = await assetFolderRepository.QueryAsync(context.App.Id, parentId); + Guard.NotNull(context, nameof(context)); - return assetFolders; + using (Profiler.TraceMethod()) + { + IAssetEntity? asset; + + if (version > EtagVersion.Empty) + { + asset = await assetLoader.GetAsync(context.App.Id, id, version); + } + else + { + asset = await assetRepository.FindAssetAsync(context.App.Id, id); + } + + if (asset == null) + { + return null; + } + + return await TransformAsync(context, asset); + } } public async Task> QueryAsync(Context context, DomainId? parentId, Q q) { Guard.NotNull(context, nameof(context)); - Guard.NotNull(q, nameof(q)); - q = await queryParser.ParseAsync(context, q); - - var assets = await assetRepository.QueryAsync(context.App.Id, parentId, q); + if (q == null) + { + return EmptyAssets; + } - if (q.Ids != null && q.Ids.Count > 0) + using (Profiler.TraceMethod()) { - assets = assets.SortSet(x => x.Id, q.Ids); + q = await queryParser.ParseAsync(context, q); + + var assets = await assetRepository.QueryAsync(context.App.Id, parentId, q); + + if (q.Ids != null && q.Ids.Count > 0) + { + assets = assets.SortSet(x => x.Id, q.Ids); + } + + return await TransformAsync(context, assets); } + } - var enriched = await assetEnricher.EnrichAsync(assets, context); + private async Task> TransformAsync(Context context, IResultList assets) + { + var transformed = await TransformCoreAsync(context, assets); - return ResultList.Create(assets.Total, enriched); + return ResultList.Create(assets.Total, transformed); + } + + private async Task TransformAsync(Context context, IAssetEntity asset) + { + var transformed = await TransformCoreAsync(context, Enumerable.Repeat(asset, 1)); + + return transformed[0]; + } + + private async Task> TransformCoreAsync(Context context, IEnumerable assets) + { + using (Profiler.TraceMethod()) + { + return await assetEnricher.EnrichAsync(assets, context); + } } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index d50fe4bc7..ed20b6fcb 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -21,12 +21,12 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories Task> QueryChildIdsAsync(DomainId appId, DomainId parentId); - Task FindAssetAsync(DomainId appId, string hash, string fileName, long fileSize); + Task FindAssetByHashAsync(DomainId appId, string hash, string fileName, long fileSize); + + Task FindAssetBySlugAsync(DomainId appId, string slug); Task FindAssetAsync(DomainId appId); Task FindAssetAsync(DomainId appId, DomainId id); - - Task FindAssetBySlugAsync(DomainId appId, string slug); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs index e3d81eabc..322ea9d8b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs @@ -24,9 +24,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Counter this.grainFactory = grainFactory; } - public void Extend(ExecutionContext context, bool async) + public void Extend(ExecutionContext context) { - if (context.TryGetValue("appId", out var temp) && temp is DomainId appId) + if (context.TryGetValue(nameof(ScriptVars.AppId), out var appId)) { var engine = context.Engine; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs index 6f3b00b38..6c28b6f18 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs @@ -25,52 +25,47 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries private readonly IAppProvider appProvider; private readonly IContentEnricher contentEnricher; private readonly IContentRepository contentRepository; - private readonly IContentLoader contentVersionLoader; + private readonly IContentLoader contentLoader; private readonly ContentQueryParser queryParser; public ContentQueryService( IAppProvider appProvider, IContentEnricher contentEnricher, IContentRepository contentRepository, - IContentLoader contentVersionLoader, + IContentLoader assetLoader, ContentQueryParser queryParser) { Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(contentEnricher, nameof(contentEnricher)); Guard.NotNull(contentRepository, nameof(contentRepository)); - Guard.NotNull(contentVersionLoader, nameof(contentVersionLoader)); + Guard.NotNull(assetLoader, nameof(assetLoader)); Guard.NotNull(queryParser, nameof(queryParser)); this.appProvider = appProvider; this.contentEnricher = contentEnricher; this.contentRepository = contentRepository; - this.contentVersionLoader = contentVersionLoader; + this.contentLoader = assetLoader; this.queryParser = queryParser; this.queryParser = queryParser; } - public async Task FindAsync(Context context, string schemaIdOrName, DomainId id, long version = -1) + public async Task FindAsync(Context context, string schemaIdOrName, DomainId id, long version = EtagVersion.Any) { Guard.NotNull(context, nameof(context)); - if (id == default) - { - throw new DomainObjectNotFoundException(id.ToString()); - } - - var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName); - using (Profiler.TraceMethod()) { + var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName); + IContentEntity? content; if (version > EtagVersion.Empty) { - content = await FindByVersionAsync(context, id, version); + content = await contentLoader.GetAsync(context.App.Id, id, version); } else { - content = await FindCoreAsync(context, id, schema); + content = await contentRepository.FindContentAsync(context.App, schema, id, context.Scope()); } if (content == null || content.SchemaId.Id != schema.Id) @@ -86,20 +81,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { Guard.NotNull(context, nameof(context)); - if (q == null) + using (Profiler.TraceMethod()) { - return EmptyContents; - } + if (q == null) + { + return EmptyContents; + } - var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName); + var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName); - if (!HasPermission(context, schema, Permissions.AppContentsRead)) - { - q = q with { CreatedBy = context.User.Token() }; - } + if (!HasPermission(context, schema, Permissions.AppContentsRead)) + { + q = q with { CreatedBy = context.User.Token() }; + } - using (Profiler.TraceMethod()) - { q = await queryParser.ParseAsync(context, q, schema); var contents = await contentRepository.QueryAsync(context.App, schema, q, context.Scope()); @@ -117,20 +112,20 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { Guard.NotNull(context, nameof(context)); - if (q == null) + using (Profiler.TraceMethod()) { - return EmptyContents; - } + if (q == null) + { + return EmptyContents; + } - var schemas = await GetSchemasAsync(context); + var schemas = await GetSchemasAsync(context); - if (schemas.Count == 0) - { - return EmptyContents; - } + if (schemas.Count == 0) + { + return EmptyContents; + } - using (Profiler.TraceMethod()) - { q = await queryParser.ParseAsync(context, q); var contents = await contentRepository.QueryAsync(context.App, schemas, q, context.Scope()); @@ -218,15 +213,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { return context.UserPermissions.Allows(permissionId, context.App.Name, schema.SchemaDef.Name); } - - private Task FindCoreAsync(Context context, DomainId id, ISchemaEntity schema) - { - return contentRepository.FindContentAsync(context.App, schema, id, context.Scope()); - } - - private Task FindByVersionAsync(Context context, DomainId id, long version) - { - return contentVersionLoader.GetAsync(context.App.Id, id, version); - } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs index 6c77233c5..adbb034ab 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -13,8 +14,10 @@ using System.Threading.Tasks; using Fluid; using Fluid.Ast; using Fluid.Tags; +using GraphQL.Utilities; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Templates; +using Squidex.Domain.Apps.Entities.Apps; using Squidex.Infrastructure; #pragma warning disable CA1826 // Do not use Enumerable methods on indexable collections @@ -23,75 +26,83 @@ namespace Squidex.Domain.Apps.Entities.Contents { public sealed class ReferencesFluidExtension : IFluidExtension { - private readonly IContentQueryService contentQueryService; - private readonly IAppProvider appProvider; + private readonly IServiceProvider serviceProvider; private sealed class ReferenceTag : ArgumentsTag { - private readonly IContentQueryService contentQueryService; - private readonly IAppProvider appProvider; + private readonly IServiceProvider serviceProvider; - public ReferenceTag(IContentQueryService contentQueryService, IAppProvider appProvider) + public ReferenceTag(IServiceProvider serviceProvider) { - this.contentQueryService = contentQueryService; - - this.appProvider = appProvider; + this.serviceProvider = serviceProvider; } public override async ValueTask WriteToAsync(TextWriter writer, TextEncoder encoder, TemplateContext context, FilterArgument[] arguments) { if (arguments.Length == 2 && context.GetValue("event")?.ToObjectValue() is EnrichedEvent enrichedEvent) { - var app = await appProvider.GetAppAsync(enrichedEvent.AppId.Id, false); + var app = await GetAppAsync(enrichedEvent); if (app == null) { return Completion.Normal; } - var appContext = Context.Admin(app).Clone(b => b - .WithoutContentEnrichment() - .WithoutCleanup() - .WithUnpublished()); + var requestContext = + Context.Admin(app).Clone(b => b + .WithoutContentEnrichment() + .WithUnpublished() + .WithoutTotal()); var id = (await arguments[1].Expression.EvaluateAsync(context)).ToStringValue(); var domainId = DomainId.Create(id); var domainIds = new List { domainId }; - var references = await contentQueryService.QueryAsync(appContext, Q.Empty.WithIds(domainIds)); - var reference = references.FirstOrDefault(); + var contentQuery = serviceProvider.GetRequiredService(); + + var contents = await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(domainIds)); + var content = contents.FirstOrDefault(); - if (reference != null) + if (content != null) { var name = (await arguments[0].Expression.EvaluateAsync(context)).ToStringValue(); - context.SetValue(name, reference); + context.SetValue(name, content); } } return Completion.Normal; } + + private Task GetAppAsync(EnrichedEvent enrichedEvent) + { + var appProvider = serviceProvider.GetRequiredService(); + + return appProvider.GetAppAsync(enrichedEvent.AppId.Id, false); + } } - public ReferencesFluidExtension(IContentQueryService contentQueryService, IAppProvider appProvider) + public ReferencesFluidExtension(IServiceProvider serviceProvider) { - Guard.NotNull(contentQueryService, nameof(contentQueryService)); - Guard.NotNull(appProvider, nameof(appProvider)); - - this.contentQueryService = contentQueryService; + Guard.NotNull(serviceProvider, nameof(serviceProvider)); - this.appProvider = appProvider; + this.serviceProvider = serviceProvider; } public void RegisterGlobalTypes(IMemberAccessStrategy memberAccessStrategy) { memberAccessStrategy.Register(); + memberAccessStrategy.Register(); + memberAccessStrategy.Register(); + memberAccessStrategy.Register(); + memberAccessStrategy.Register(); + memberAccessStrategy.Register(); } public void RegisterLanguageExtensions(FluidParserFactory factory) { - factory.RegisterTag("reference", new ReferenceTag(contentQueryService, appProvider)); + factory.RegisterTag("reference", new ReferenceTag(serviceProvider)); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs new file mode 100644 index 000000000..7eb69f12a --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs @@ -0,0 +1,125 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; +using Jint.Native; +using Jint.Runtime; +using Microsoft.Extensions.DependencyInjection; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Entities.Apps; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Tasks; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public sealed class ReferencesJintExtension : IJintExtension + { + private delegate void GetReferencesDelegate(JsValue references, Action callback); + private readonly IServiceProvider serviceProvider; + + public ReferencesJintExtension(IServiceProvider serviceProvider) + { + Guard.NotNull(serviceProvider, nameof(serviceProvider)); + + this.serviceProvider = serviceProvider; + } + + public void ExtendAsync(ExecutionContext context) + { + if (!context.TryGetValue(nameof(ScriptVars.AppId), out var appId)) + { + return; + } + + if (!context.TryGetValue(nameof(ScriptVars.User), out var user)) + { + return; + } + + var action = new GetReferencesDelegate((references, callback) => GetReferences(context, appId, user, references, callback)); + + context.Engine.SetValue("getReference", action); + context.Engine.SetValue("getReferences", action); + } + + private void GetReferences(ExecutionContext context, DomainId appId, ClaimsPrincipal user, JsValue references, Action callback) + { + GetReferencesAsync(context, appId, user, references, callback).Forget(); + } + + private async Task GetReferencesAsync(ExecutionContext context, DomainId appId, ClaimsPrincipal user, JsValue references, Action callback) + { + Guard.NotNull(callback, nameof(callback)); + + var ids = new List(); + + if (references.IsString()) + { + ids.Add(DomainId.Create(references.ToString())); + } + else if (references.IsArray()) + { + foreach (var value in references.AsArray()) + { + if (value.IsString()) + { + ids.Add(DomainId.Create(value.ToString())); + } + } + } + + if (ids.Count == 0) + { + var emptyContents = Array.Empty(); + + callback(JsValue.FromObject(context.Engine, emptyContents)); + return; + } + + context.MarkAsync(); + + try + { + var app = await GetAppAsync(appId); + + var requestContext = + new Context(user, app).Clone(b => b + .WithoutContentEnrichment() + .WithUnpublished() + .WithoutTotal()); + + var contentQuery = serviceProvider.GetRequiredService(); + + var contents = await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(ids)); + + callback(JsValue.FromObject(context.Engine, contents.ToArray())); + } + catch (Exception ex) + { + context.Fail(ex); + } + } + + private async Task GetAppAsync(DomainId appId) + { + var appProvider = serviceProvider.GetRequiredService(); + + var app = await appProvider.GetAppAsync(appId); + + if (app == null) + { + throw new JavaScriptException("App does not exist."); + } + + return app; + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index 947049964..fb2f3128a 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -16,8 +16,8 @@ using Microsoft.Net.Http.Headers; using Squidex.Areas.Api.Controllers.Assets.Models; using Squidex.Assets; using Squidex.Domain.Apps.Core.Assets; +using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Assets; -using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Log; @@ -34,7 +34,7 @@ namespace Squidex.Areas.Api.Controllers.Assets public sealed class AssetContentController : ApiController { private readonly IAssetFileStore assetFileStore; - private readonly IAssetRepository assetRepository; + private readonly IAssetQueryService assetQuery; private readonly IAssetLoader assetLoader; private readonly IAssetStore assetStore; private readonly IAssetThumbnailGenerator assetThumbnailGenerator; @@ -42,14 +42,14 @@ namespace Squidex.Areas.Api.Controllers.Assets public AssetContentController( ICommandBus commandBus, IAssetFileStore assetFileStore, - IAssetRepository assetRepository, + IAssetQueryService assetQuery, IAssetLoader assetLoader, IAssetStore assetStore, IAssetThumbnailGenerator assetThumbnailGenerator) : base(commandBus) { this.assetFileStore = assetFileStore; - this.assetRepository = assetRepository; + this.assetQuery = assetQuery; this.assetLoader = assetLoader; this.assetStore = assetStore; this.assetThumbnailGenerator = assetThumbnailGenerator; @@ -74,14 +74,16 @@ namespace Squidex.Areas.Api.Controllers.Assets [AllowAnonymous] public async Task GetAssetContentBySlug(string app, string idOrSlug, [FromQuery] AssetContentQueryDto queries, string? more = null) { - var asset = await assetRepository.FindAssetAsync(AppId, DomainId.Create(idOrSlug)); + var requestContext = Context.Clone(b => b.WithoutAssetEnrichment()); + + var asset = await assetQuery.FindAsync(requestContext, DomainId.Create(idOrSlug)); if (asset == null) { - asset = await assetRepository.FindAssetBySlugAsync(AppId, idOrSlug); + asset = await assetQuery.FindBySlugAsync(requestContext, idOrSlug); } - return await DeliverAssetAsync(asset, queries); + return await DeliverAssetAsync(requestContext, asset, queries); } /// @@ -102,12 +104,14 @@ namespace Squidex.Areas.Api.Controllers.Assets [Obsolete("Use overload with app name")] public async Task GetAssetContent(DomainId id, [FromQuery] AssetContentQueryDto queries) { - var asset = await assetRepository.FindAssetAsync(id); + var requestContext = Context.Clone(b => b.WithoutAssetEnrichment()); + + var asset = await assetQuery.FindGlobalAsync(requestContext, id); - return await DeliverAssetAsync(asset, queries); + return await DeliverAssetAsync(requestContext, asset, queries); } - private async Task DeliverAssetAsync(IAssetEntity? asset, AssetContentQueryDto queries) + private async Task DeliverAssetAsync(Context context, IAssetEntity? asset, AssetContentQueryDto queries) { queries ??= new AssetContentQueryDto(); @@ -125,7 +129,15 @@ namespace Squidex.Areas.Api.Controllers.Assets if (asset != null && queries.Version > EtagVersion.Any && asset.Version != queries.Version) { - asset = await assetLoader.GetAsync(asset.AppId.Id, asset.Id, queries.Version); + if (context.App != null) + { + asset = await assetQuery.FindAsync(context, asset.Id, queries.Version); + } + else + { + // Fallback for old endpoint. Does not set the surrogate key. + asset = await assetLoader.GetAsync(asset.AppId.Id, asset.Id, queries.Version); + } } if (asset == null) diff --git a/backend/src/Squidex/Config/Domain/RuleServices.cs b/backend/src/Squidex/Config/Domain/RuleServices.cs index 5b92340ff..9b0571d05 100644 --- a/backend/src/Squidex/Config/Domain/RuleServices.cs +++ b/backend/src/Squidex/Config/Domain/RuleServices.cs @@ -47,9 +47,18 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs index 9c5bc06ff..2bfd06608 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs @@ -11,6 +11,7 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; using FakeItEasy; +using Jint.Runtime; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Core.Scripting; @@ -195,7 +196,9 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting CanReject = true }; - var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script, options)); + var vars = new ScriptVars(); + + var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(vars, script, options)); Assert.NotEmpty(ex.Errors); } @@ -212,7 +215,9 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting CanReject = true }; - var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script, options)); + var vars = new ScriptVars(); + + var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(vars, script, options)); Assert.Equal("Not valid", ex.Errors.Single().Message); } @@ -229,7 +234,9 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting CanDisallow = true }; - var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script, options)); + var vars = new ScriptVars(); + + var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(vars, script, options)); Assert.Equal("Script has forbidden the operation.", ex.Message); } @@ -246,25 +253,57 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting CanDisallow = true }; - var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(new ScriptVars(), script, options)); + var vars = new ScriptVars(); + + var ex = await Assert.ThrowsAsync(() => sut.ExecuteAsync(vars, script, options)); Assert.Equal("Operation not allowed", ex.Message); } + [Fact] + public async Task Should_throw_exception_when_getJson_url_is_null() + { + const string script = @" + getJSON(null, function(result) { + complete(result); + }); + "; + + var vars = new ScriptVars(); + + await Assert.ThrowsAsync(() => sut.ExecuteAsync(vars, script)); + } + + [Fact] + public async Task Should_throw_exception_when_getJson_callback_is_null() + { + const string script = @" + var url = 'http://squidex.io'; + + getJSON(url, null); + "; + + var vars = new ScriptVars(); + + await Assert.ThrowsAsync(() => sut.ExecuteAsync(vars, script)); + } + [Fact] public async Task Should_make_json_request() { var httpHandler = SetupRequest(); const string script = @" - async = true; + var url = 'http://squidex.io'; - getJSON('http://squidex.io', function(result) { + getJSON(url, function(result) { complete(result); }); "; - var result = await sut.ExecuteAsync(new ScriptVars(), script); + var vars = new ScriptVars(); + + var result = await sut.ExecuteAsync(vars, script); httpHandler.ShouldBeMethod(HttpMethod.Get); httpHandler.ShouldBeUrl("http://squidex.io/"); @@ -280,19 +319,21 @@ namespace Squidex.Domain.Apps.Core.Operations.Scripting var httpHandler = SetupRequest(); const string script = @" - async = true; - var headers = { 'X-Header1': 1, 'X-Header2': '2' }; - getJSON('http://squidex.io', function(result) { + var url = 'http://squidex.io'; + + getJSON(url, function(result) { complete(result); }, headers); "; - var result = await sut.ExecuteAsync(new ScriptVars(), script); + var vars = new ScriptVars(); + + var result = await sut.ExecuteAsync(vars, script); httpHandler.ShouldBeMethod(HttpMethod.Get); httpHandler.ShouldBeUrl("http://squidex.io/"); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsFluidExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsFluidExtensionTests.cs new file mode 100644 index 000000000..7ff9b7291 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsFluidExtensionTests.cs @@ -0,0 +1,106 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Threading.Tasks; +using FakeItEasy; +using Microsoft.Extensions.DependencyInjection; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Templates; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public class AssetsFluidExtensionTests + { + private readonly IAssetQueryService assetQuery = A.Fake(); + private readonly IAppProvider appProvider = A.Fake(); + private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); + private readonly FluidTemplateEngine sut; + + public AssetsFluidExtensionTests() + { + var services = + new ServiceCollection() + .AddSingleton(appProvider) + .AddSingleton(assetQuery) + .BuildServiceProvider(); + + var extensions = new IFluidExtension[] + { + new AssetsFluidExtension(services) + }; + + A.CallTo(() => appProvider.GetAppAsync(appId.Id, false)) + .Returns(Mocks.App(appId)); + + sut = new FluidTemplateEngine(extensions); + } + + [Fact] + public async Task Should_resolve_assets_in_loop() + { + var assetId1 = DomainId.NewGuid(); + var asset1 = CreateAsset(assetId1, 1); + var assetId2 = DomainId.NewGuid(); + var asset2 = CreateAsset(assetId2, 2); + + var @event = new EnrichedContentEvent + { + Data = + new ContentData() + .AddField("assets", + new ContentFieldData() + .AddJsonValue(JsonValue.Array(assetId1, assetId2))), + AppId = appId + }; + + A.CallTo(() => assetQuery.FindAsync(A._, assetId1, EtagVersion.Any)) + .Returns(asset1); + + A.CallTo(() => assetQuery.FindAsync(A._, assetId2, EtagVersion.Any)) + .Returns(asset2); + + var vars = new TemplateVars + { + ["event"] = @event + }; + + var template = @" + {% for id in event.data.assets.iv %} + {% asset 'ref', id %} + Text: {{ ref.fileName }} {{ ref.id }} + {% endfor %} + "; + + var expected = $@" + Text: file1.jpg {assetId1} + Text: file2.jpg {assetId2} + "; + + var result = await sut.RenderAsync(template, vars); + + Assert.Equal(Cleanup(expected), Cleanup(result)); + } + + private static IEnrichedAssetEntity CreateAsset(DomainId assetId, int index) + { + return new AssetEntity { FileName = $"file{index}.jpg", Id = assetId }; + } + + private static string Cleanup(string text) + { + return text + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Replace(" ", string.Empty); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs new file mode 100644 index 000000000..20ede6483 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs @@ -0,0 +1,139 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Security.Claims; +using System.Threading.Tasks; +using FakeItEasy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public class AssetsJintExtensionTests : IClassFixture + { + private readonly IAssetQueryService assetQuery = A.Fake(); + private readonly IAppProvider appProvider = A.Fake(); + private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); + private readonly JintScriptEngine sut; + + public AssetsJintExtensionTests() + { + var services = + new ServiceCollection() + .AddSingleton(appProvider) + .AddSingleton(assetQuery) + .BuildServiceProvider(); + + var extensions = new IJintExtension[] + { + new AssetsJintExtension(services) + }; + + A.CallTo(() => appProvider.GetAppAsync(appId.Id, false)) + .Returns(Mocks.App(appId)); + + sut = new JintScriptEngine(new MemoryCache(Options.Create(new MemoryCacheOptions())), extensions); + } + + [Fact] + public async Task Should_resolve_asset() + { + var assetId1 = DomainId.NewGuid(); + var asset1 = CreateAsset(assetId1, 1); + + var user = new ClaimsPrincipal(); + + var data = + new ContentData() + .AddField("assets", + new ContentFieldData() + .AddJsonValue(JsonValue.Array(assetId1))); + + A.CallTo(() => assetQuery.QueryAsync( + A.That.Matches(x => x.App.Id == appId.Id && x.User == user), null, A.That.HasIds(assetId1))) + .Returns(ResultList.CreateFrom(1, asset1)); + + var vars = new ScriptVars { Data = data, AppId = appId.Id, User = user }; + + var script = @" + getAsset(data.assets.iv[0], function (assets) { + var result1 = `Text: ${assets[0].fileName}`; + + complete(`${result1}`); + })"; + + var expected = @" + Text: file1.jpg + "; + + var result = (await sut.ExecuteAsync(vars, script)).ToString(); + + Assert.Equal(Cleanup(expected), Cleanup(result)); + } + + [Fact] + public async Task Should_resolve_assets() + { + var assetId1 = DomainId.NewGuid(); + var asset1 = CreateAsset(assetId1, 1); + var assetId2 = DomainId.NewGuid(); + var asset2 = CreateAsset(assetId1, 2); + + var user = new ClaimsPrincipal(); + + var data = + new ContentData() + .AddField("assets", + new ContentFieldData() + .AddJsonValue(JsonValue.Array(assetId1, assetId2))); + + A.CallTo(() => assetQuery.QueryAsync( + A.That.Matches(x => x.App.Id == appId.Id && x.User == user), null, A.That.HasIds(assetId1, assetId2))) + .Returns(ResultList.CreateFrom(2, asset1, asset2)); + + var vars = new ScriptVars { Data = data, AppId = appId.Id, User = user }; + + var script = @" + getAssets(data.assets.iv, function (assets) { + var result1 = `Text: ${assets[0].fileName}`; + var result2 = `Text: ${assets[1].fileName}`; + + complete(`${result1}\n${result2}`); + })"; + + var expected = @" + Text: file1.jpg + Text: file2.jpg + "; + + var result = (await sut.ExecuteAsync(vars, script)).ToString(); + + Assert.Equal(Cleanup(expected), Cleanup(result)); + } + + private static IEnrichedAssetEntity CreateAsset(DomainId assetId, int index) + { + return new AssetEntity { FileName = $"file{index}.jpg", Id = assetId }; + } + + private static string Cleanup(string text) + { + return text + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Replace(" ", string.Empty); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryTests.cs index aeec3db70..81c0af1c8 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/MongoDb/AssetsQueryTests.cs @@ -42,7 +42,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.MongoDb { var random = _.RandomValue(); - var assets = await _.AssetRepository.FindAssetAsync(_.RandomAppId(), random, random, 1024); + var assets = await _.AssetRepository.FindAssetByHashAsync(_.RandomAppId(), random, random, 1024); Assert.NotNull(assets); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs index 47c665ced..62b0e148a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetQueryServiceTests.cs @@ -12,6 +12,7 @@ using FakeItEasy; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; using Xunit; namespace Squidex.Domain.Apps.Entities.Assets.Queries @@ -20,6 +21,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries { private readonly IAssetEnricher assetEnricher = A.Fake(); private readonly IAssetRepository assetRepository = A.Fake(); + private readonly IAssetLoader assetLoader = A.Fake(); private readonly IAssetFolderRepository assetFolderRepository = A.Fake(); private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly Context requestContext; @@ -30,76 +32,167 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries { requestContext = new Context(Mocks.FrontendUser(), Mocks.App(appId)); + SetupEnricher(); + A.CallTo(() => queryParser.ParseAsync(requestContext, A._)) .ReturnsLazily(c => Task.FromResult(c.GetArgument(1)!)); - sut = new AssetQueryService(assetEnricher, assetRepository, assetFolderRepository, queryParser); + sut = new AssetQueryService(assetEnricher, assetRepository, assetLoader, assetFolderRepository, queryParser); + } + + [Fact] + public async Task Should_find_asset_by_slug_and_enrich_it() + { + var asset = CreateAsset(DomainId.NewGuid()); + + A.CallTo(() => assetRepository.FindAssetBySlugAsync(appId.Id, "slug")) + .Returns(asset); + + var result = await sut.FindBySlugAsync(requestContext, "slug"); + + AssertAsset(asset, result); + } + + [Fact] + public async Task Should_return_null_if_asset_by_slug_cannot_be_found() + { + var asset = CreateAsset(DomainId.NewGuid()); + + A.CallTo(() => assetRepository.FindAssetBySlugAsync(appId.Id, "slug")) + .Returns(Task.FromResult(null)); + + var result = await sut.FindBySlugAsync(requestContext, "slug"); + + Assert.Null(result); } [Fact] public async Task Should_find_asset_by_id_and_enrich_it() { - var found = new AssetEntity { Id = DomainId.NewGuid() }; + var asset = CreateAsset(DomainId.NewGuid()); - var enriched = new AssetEntity(); + A.CallTo(() => assetRepository.FindAssetAsync(appId.Id, asset.Id)) + .Returns(asset); - A.CallTo(() => assetRepository.FindAssetAsync(appId.Id, found.Id)) - .Returns(found); + var result = await sut.FindAsync(requestContext, asset.Id); - A.CallTo(() => assetEnricher.EnrichAsync(found, requestContext)) - .Returns(enriched); + AssertAsset(asset, result); + } - var result = await sut.FindAsync(requestContext, found.Id); + [Fact] + public async Task Should_return_null_if_asset_by_id_cannot_be_found() + { + var asset = CreateAsset(DomainId.NewGuid()); + + A.CallTo(() => assetRepository.FindAssetAsync(appId.Id, asset.Id)) + .Returns(Task.FromResult(null)); - Assert.Same(enriched, result); + var result = await sut.FindAsync(requestContext, asset.Id); + + Assert.Null(result); } [Fact] - public async Task Should_find_assets_by_hash_and_and_enrich_it() + public async Task Should_find_asset_by_id_and_version_and_enrich_it() + { + var asset = CreateAsset(DomainId.NewGuid()); + + A.CallTo(() => assetLoader.GetAsync(appId.Id, asset.Id, 2)) + .Returns(asset); + + var result = await sut.FindAsync(requestContext, asset.Id, 2); + + AssertAsset(asset, result); + } + + [Fact] + public async Task Should_return_null_if_asset_by_id_and_version_cannot_be_found() + { + var asset = CreateAsset(DomainId.NewGuid()); + + A.CallTo(() => assetLoader.GetAsync(appId.Id, asset.Id, 2)) + .Returns(Task.FromResult(null)); + + var result = await sut.FindAsync(requestContext, asset.Id, 2); + + Assert.Null(result); + } + + [Fact] + public async Task Should_find_global_asset_by_id_and_enrich_it() { - var found = new AssetEntity { Id = DomainId.NewGuid() }; + var asset = CreateAsset(DomainId.NewGuid()); - var enriched = new AssetEntity(); + A.CallTo(() => assetRepository.FindAssetAsync(asset.Id)) + .Returns(asset); - A.CallTo(() => assetRepository.FindAssetAsync(appId.Id, "hash", "name", 123)) - .Returns(found); + var result = await sut.FindGlobalAsync(requestContext, asset.Id); - A.CallTo(() => assetEnricher.EnrichAsync(found, requestContext)) - .Returns(enriched); + AssertAsset(asset, result); + } + + [Fact] + public async Task Should_return_null_if_global_asset_by_id_cannot_be_found() + { + var asset = CreateAsset(DomainId.NewGuid()); + + A.CallTo(() => assetRepository.FindAssetAsync(asset.Id)) + .Returns(Task.FromResult(null)); + + var result = await sut.FindGlobalAsync(requestContext, asset.Id); + + Assert.Null(result); + } + + [Fact] + public async Task Should_find_assets_by_hash_and_and_enrich_it() + { + var asset = CreateAsset(DomainId.NewGuid()); + + A.CallTo(() => assetRepository.FindAssetByHashAsync(appId.Id, "hash", "name", 123)) + .Returns(asset); var result = await sut.FindByHashAsync(requestContext, "hash", "name", 123); - Assert.Same(enriched, result); + AssertAsset(asset, result); } [Fact] - public async Task Should_load_assets_with_query_and_resolve_tags() + public async Task Should_return_null_if_asset_by_hash_cannot_be_found() { - var found1 = new AssetEntity { Id = DomainId.NewGuid() }; - var found2 = new AssetEntity { Id = DomainId.NewGuid() }; + var asset = CreateAsset(DomainId.NewGuid()); - var enriched1 = new AssetEntity(); - var enriched2 = new AssetEntity(); + A.CallTo(() => assetRepository.FindAssetByHashAsync(appId.Id, "hash", "name", 123)) + .Returns(Task.FromResult(null)); + + var result = await sut.FindByHashAsync(requestContext, "hash", "name", 123); + + Assert.Null(result); + } + + [Fact] + public async Task Should_query_assets_and_enrich_it() + { + var asset1 = CreateAsset(DomainId.NewGuid()); + var asset2 = CreateAsset(DomainId.NewGuid()); var parentId = DomainId.NewGuid(); var q = Q.Empty.WithODataQuery("fileName eq 'Name'"); A.CallTo(() => assetRepository.QueryAsync(appId.Id, parentId, q)) - .Returns(ResultList.CreateFrom(8, found1, found2)); - - A.CallTo(() => assetEnricher.EnrichAsync(A>.That.IsSameSequenceAs(found1, found2), requestContext)) - .Returns(new List { enriched1, enriched2 }); + .Returns(ResultList.CreateFrom(8, asset1, asset2)); var result = await sut.QueryAsync(requestContext, parentId, q); Assert.Equal(8, result.Total); - Assert.Equal(new[] { enriched1, enriched2 }, result.ToArray()); + AssertAsset(asset1, result[0]); + AssertAsset(asset2, result[1]); } [Fact] - public async Task Should_load_assets_folders_from_repository() + public async Task Should_query_asset_folders() { var parentId = DomainId.NewGuid(); @@ -114,7 +207,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries } [Fact] - public async Task Should_resolve_folder_path_from_root() + public async Task Should_find_asset_folder_with_path() { var folderId1 = DomainId.NewGuid(); var folder1 = CreateFolder(folderId1); @@ -205,6 +298,13 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries Assert.Empty(result); } + private static void AssertAsset(IAssetEntity source, IEnrichedAssetEntity? result) + { + Assert.NotNull(result); + Assert.NotSame(source, result); + Assert.Equal(source.AssetId, result?.AssetId); + } + private static IAssetFolderEntity CreateFolder(DomainId id, DomainId parentId = default) { var assetFolder = A.Fake(); @@ -214,5 +314,21 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries return assetFolder; } + + private static AssetEntity CreateAsset(DomainId id) + { + return new AssetEntity { Id = id }; + } + + private void SetupEnricher() + { + A.CallTo(() => assetEnricher.EnrichAsync(A>._, A._)) + .ReturnsLazily(x => + { + var input = x.GetArgument>(0)!; + + return Task.FromResult>(input.Select(c => SimpleMapper.Map(c, new AssetEntity())).ToList()); + }); + } } } \ No newline at end of file diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs index c772187cc..1ddddda43 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs @@ -11,7 +11,6 @@ using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; using FakeItEasy; -using Squidex.Caching; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs index 3167a0ff5..2bca84edd 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs @@ -31,11 +31,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries private readonly IContentRepository contentRepository = A.Fake(); private readonly IContentLoader contentVersionLoader = A.Fake(); private readonly ISchemaEntity schema; - private readonly DomainId contentId = DomainId.NewGuid(); private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly NamedId schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"); private readonly ContentData contentData = new ContentData(); - private readonly ContentData contentTransformed = new ContentData(); private readonly ContentQueryParser queryParser = A.Fake(); private readonly ContentQueryService sut; @@ -67,66 +65,70 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries } [Fact] - public async Task GetSchemaOrThrowAsync_should_return_schema_from_guid_string() + public async Task Should_get_schema_from_guid_string() { var input = schemaId.Id.ToString(); - var ctx = CreateContext(isFrontend: false, allowSchema: true); + var requestContext = CreateContext(); A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, true)) .Returns(schema); - var result = await sut.GetSchemaOrThrowAsync(ctx, input); + var result = await sut.GetSchemaOrThrowAsync(requestContext, input); Assert.Equal(schema, result); } [Fact] - public async Task GetSchemaOrThrowAsync_should_return_schema_from_name() + public async Task Should_get_schema_from_name() { var input = schemaId.Name; - var ctx = CreateContext(isFrontend: false, allowSchema: true); + var requestContext = CreateContext(); A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name, true)) .Returns(schema); - var result = await sut.GetSchemaOrThrowAsync(ctx, input); + var result = await sut.GetSchemaOrThrowAsync(requestContext, input); Assert.Equal(schema, result); } [Fact] - public async Task GetSchemaOrThrowAsync_should_throw_404_if_not_found() + public async Task Should_throw_notfound_exception_if_schema_to_get_not_found() { - var ctx = CreateContext(isFrontend: false, allowSchema: true); + var requestContext = CreateContext(); A.CallTo(() => appProvider.GetSchemaAsync(A._, A._, true)) .Returns((ISchemaEntity?)null); - await Assert.ThrowsAsync(() => sut.GetSchemaOrThrowAsync(ctx, schemaId.Name)); + await Assert.ThrowsAsync(() => sut.GetSchemaOrThrowAsync(requestContext, schemaId.Name)); } [Fact] - public async Task FindContentAsync_should_throw_exception_if_user_has_no_permission() + public async Task Should_throw_permission_exception_if_content_to_find_is_restricted() { - var ctx = CreateContext(isFrontend: false, allowSchema: false); + var requestContext = CreateContext(allowSchema: false); - A.CallTo(() => contentRepository.FindContentAsync(ctx.App, schema, contentId, A._)) - .Returns(CreateContent(contentId)); + var content = CreateContent(DomainId.NewGuid()); - await Assert.ThrowsAsync(() => sut.FindAsync(ctx, schemaId.Name, contentId)); + A.CallTo(() => contentRepository.FindContentAsync(requestContext.App, schema, content.Id, A._)) + .Returns(CreateContent(DomainId.NewGuid())); + + await Assert.ThrowsAsync(() => sut.FindAsync(requestContext, schemaId.Name, content.Id)); } [Fact] - public async Task FindContentAsync_should_return_null_if_not_found() + public async Task Should_return_null_if_content_by_id_dannot_be_found() { - var ctx = CreateContext(isFrontend: false, allowSchema: true); + var requestContext = CreateContext(); + + var content = CreateContent(DomainId.NewGuid()); - A.CallTo(() => contentRepository.FindContentAsync(ctx.App, schema, contentId, A._)) + A.CallTo(() => contentRepository.FindContentAsync(requestContext.App, schema, content.Id, A._)) .Returns(null); - Assert.Null(await sut.FindAsync(ctx, schemaId.Name, contentId)); + Assert.Null(await sut.FindAsync(requestContext, schemaId.Name, content.Id)); } [Theory] @@ -134,45 +136,41 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries [InlineData(1, 1, SearchScope.All)] [InlineData(0, 1, SearchScope.All)] [InlineData(0, 0, SearchScope.Published)] - public async Task FindContentAsync_should_return_content(int isFrontend, int unpublished, SearchScope scope) + public async Task Should_return_content_by_id(int isFrontend, int unpublished, SearchScope scope) { - var ctx = - CreateContext(isFrontend: isFrontend == 1, allowSchema: true).Clone(b => b - .WithUnpublished(unpublished == 1)); + var requestContext = CreateContext(isFrontend, isUnpublished: unpublished); - var content = CreateContent(contentId); + var content = CreateContent(DomainId.NewGuid()); - A.CallTo(() => contentRepository.FindContentAsync(ctx.App, schema, contentId, scope)) + A.CallTo(() => contentRepository.FindContentAsync(requestContext.App, schema, content.Id, scope)) .Returns(content); - var result = await sut.FindAsync(ctx, schemaId.Name, contentId); + var result = await sut.FindAsync(requestContext, schemaId.Name, content.Id); - Assert.Equal(contentTransformed, result!.Data); - Assert.Equal(content.Id, result.Id); + AssertContent(content, result); } [Fact] - public async Task FindContentAsync_should_return_content_by_version() + public async Task Should_return_content_by_id_and_version() { - var ctx = CreateContext(isFrontend: false, allowSchema: true); + var requestContext = CreateContext(); - var content = CreateContent(contentId); + var content = CreateContent(DomainId.NewGuid()); - A.CallTo(() => contentVersionLoader.GetAsync(appId.Id, contentId, 13)) + A.CallTo(() => contentVersionLoader.GetAsync(appId.Id, content.Id, 13)) .Returns(content); - var result = await sut.FindAsync(ctx, schemaId.Name, contentId, 13); + var result = await sut.FindAsync(requestContext, schemaId.Name, content.Id, 13); - Assert.Equal(contentTransformed, result!.Data); - Assert.Equal(content.Id, result.Id); + AssertContent(content, result); } [Fact] - public async Task QueryAsync_should_throw_if_user_has_no_permission() + public async Task Should_throw_exception_if_user_has_no_permission_to_query_content() { - var ctx = CreateContext(isFrontend: false, allowSchema: false); + var requestContext = CreateContext(allowSchema: false); - await Assert.ThrowsAsync(() => sut.QueryAsync(ctx, schemaId.Name, Q.Empty)); + await Assert.ThrowsAsync(() => sut.QueryAsync(requestContext, schemaId.Name, Q.Empty)); } [Theory] @@ -180,89 +178,95 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries [InlineData(1, 1, SearchScope.All)] [InlineData(0, 1, SearchScope.All)] [InlineData(0, 0, SearchScope.Published)] - public async Task QueryAsync_should_return_contents(int isFrontend, int unpublished, SearchScope scope) + public async Task Should_query_contents(int isFrontend, int unpublished, SearchScope scope) { - var ctx = - CreateContext(isFrontend: isFrontend == 1, allowSchema: true).Clone(b => b - .WithUnpublished(unpublished == 1)); + var requestContext = CreateContext(isFrontend, isUnpublished: unpublished); - var content = CreateContent(contentId); + var content1 = CreateContent(DomainId.NewGuid()); + var content2 = CreateContent(DomainId.NewGuid()); var q = Q.Empty.WithReference(DomainId.NewGuid()); - A.CallTo(() => contentRepository.QueryAsync(ctx.App, schema, q, scope)) - .Returns(ResultList.CreateFrom(5, content)); - - var result = await sut.QueryAsync(ctx, schemaId.Name, q); + A.CallTo(() => contentRepository.QueryAsync(requestContext.App, schema, q, scope)) + .Returns(ResultList.CreateFrom(5, content1, content2)); - Assert.Equal(contentData, result[0].Data); - Assert.Equal(contentId, result[0].Id); + var result = await sut.QueryAsync(requestContext, schemaId.Name, q); Assert.Equal(5, result.Total); + + AssertContent(content1, result[0]); + AssertContent(content2, result[1]); } - [Fact] - public async Task QueryAll_should_not_return_contents_if_user_has_no_permission() + [Theory] + [InlineData(1, 0, SearchScope.All)] + [InlineData(1, 1, SearchScope.All)] + [InlineData(0, 1, SearchScope.All)] + [InlineData(0, 0, SearchScope.Published)] + public async Task Should_query_contents_by_ids(int isFrontend, int unpublished, SearchScope scope) { - var ctx = CreateContext(isFrontend: false, allowSchema: false); + var requestContext = CreateContext(isFrontend, isUnpublished: unpublished); var ids = Enumerable.Range(0, 5).Select(x => DomainId.NewGuid()).ToList(); + var contents = ids.Select(CreateContent).ToList(); + var q = Q.Empty.WithIds(ids); - A.CallTo(() => contentRepository.QueryAsync(ctx.App, A>.That.Matches(x => x.Count == 0), q, SearchScope.All)) - .Returns(ResultList.Create(0, ids.Select(CreateContent))); + A.CallTo(() => contentRepository.QueryAsync(requestContext.App, + A>.That.Matches(x => x.Count == 1), q, scope)) + .Returns(ResultList.Create(5, contents)); - var result = await sut.QueryAsync(ctx, q); + var result = await sut.QueryAsync(requestContext, q); - Assert.Empty(result); + Assert.Equal(5, result.Total); + + for (var i = 0; i < contents.Count; i++) + { + AssertContent(contents[i], result[i]); + } } [Fact] - public async Task QueryAll_should_only_query_only_users_contents_if_no_permission() + public async Task Should_query_contents_with_matching_permissions() { - var ctx = - CreateContext(true, true, Permissions.AppContentsReadOwn); + var requestContext = CreateContext(allowSchema: false); - await sut.QueryAsync(ctx, schemaId.Name, Q.Empty); + var ids = Enumerable.Range(0, 5).Select(x => DomainId.NewGuid()).ToList(); - A.CallTo(() => contentRepository.QueryAsync(ctx.App, schema, A.That.Matches(x => x.CreatedBy!.Equals(ctx.User.Token())), SearchScope.All)) - .MustHaveHappened(); + var q = Q.Empty.WithIds(ids); + + A.CallTo(() => contentRepository.QueryAsync(requestContext.App, + A>.That.Matches(x => x.Count == 0), q, SearchScope.All)) + .Returns(ResultList.Create(0, ids.Select(CreateContent))); + + var result = await sut.QueryAsync(requestContext, q); + + Assert.Empty(result); } [Fact] - public async Task QueryAll_should_query_all_contents_if_user_has_permission() + public async Task Should_query_contents_from_user_if_user_has_only_own_permission() { - var ctx = - CreateContext(true, true, Permissions.AppContentsRead); + var requestContext = CreateContext(permissionId: Permissions.AppContentsReadOwn); - await sut.QueryAsync(ctx, schemaId.Name, Q.Empty); + await sut.QueryAsync(requestContext, schemaId.Name, Q.Empty); - A.CallTo(() => contentRepository.QueryAsync(ctx.App, schema, A.That.Matches(x => x.CreatedBy == null), SearchScope.All)) + A.CallTo(() => contentRepository.QueryAsync(requestContext.App, schema, + A.That.Matches(x => Equals(x.CreatedBy, requestContext.User.Token())), SearchScope.Published)) .MustHaveHappened(); } - [Theory] - [InlineData(1, 0, SearchScope.All)] - [InlineData(1, 1, SearchScope.All)] - [InlineData(0, 1, SearchScope.All)] - [InlineData(0, 0, SearchScope.Published)] - public async Task QueryAll_should_return_contents(int isFrontend, int unpublished, SearchScope scope) + [Fact] + public async Task Should_query_all_contents_if_user_has_read_permission() { - var ctx = - CreateContext(isFrontend: isFrontend == 1, allowSchema: true).Clone(b => b - .WithUnpublished(unpublished == 1)); + var requestContext = CreateContext(permissionId: Permissions.AppContentsRead); - var ids = Enumerable.Range(0, 5).Select(x => DomainId.NewGuid()).ToList(); - - var q = Q.Empty.WithIds(ids); - - A.CallTo(() => contentRepository.QueryAsync(ctx.App, A>.That.Matches(x => x.Count == 1), q, scope)) - .Returns(ResultList.Create(5, ids.Select(CreateContent))); - - var result = await sut.QueryAsync(ctx, q); + await sut.QueryAsync(requestContext, schemaId.Name, Q.Empty); - Assert.Equal(ids, result.Select(x => x.Id).ToList()); + A.CallTo(() => contentRepository.QueryAsync(requestContext.App, schema, + A.That.Matches(x => x.CreatedBy == null), SearchScope.Published)) + .MustHaveHappened(); } private void SetupEnricher() @@ -276,12 +280,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries }); } - private Context CreateContext(bool isFrontend, bool allowSchema, string permissionId = Permissions.AppContentsRead) + private Context CreateContext( + int isFrontend = 0, + int isUnpublished = 0, + bool allowSchema = true, + string permissionId = Permissions.AppContentsRead) { var claimsIdentity = new ClaimsIdentity(); var claimsPrincipal = new ClaimsPrincipal(claimsIdentity); - if (isFrontend) + claimsIdentity.AddClaim(new Claim(OpenIdClaims.Subject, "user1")); + + if (isFrontend == 1) { claimsIdentity.AddClaim(new Claim(OpenIdClaims.ClientId, DefaultClients.Frontend)); } @@ -293,7 +303,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries claimsIdentity.AddClaim(new Claim(SquidexClaimTypes.Permissions, concretePermission)); } - return new Context(claimsPrincipal, Mocks.App(appId)); + return new Context(claimsPrincipal, Mocks.App(appId)).Clone(b => b.WithUnpublished(isUnpublished == 1)); + } + + private static void AssertContent(IContentEntity source, IEnrichedContentEntity? result) + { + Assert.NotNull(result); + Assert.NotSame(source, result); + Assert.Same(source.Data, result?.Data); + Assert.Equal(source.Id, result?.Id); } private IContentEntity CreateContent(DomainId id) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesFluidExtensionTests.cs similarity index 73% rename from backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs rename to backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesFluidExtensionTests.cs index 01cd6b3a5..37875c882 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesFluidExtensionTests.cs @@ -7,6 +7,7 @@ using System.Threading.Tasks; using FakeItEasy; +using Microsoft.Extensions.DependencyInjection; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Templates; @@ -17,18 +18,24 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Contents { - public class ReferenceFluidExtensionTests + public class ReferencesFluidExtensionTests { private readonly IContentQueryService contentQuery = A.Fake(); private readonly IAppProvider appProvider = A.Fake(); private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly FluidTemplateEngine sut; - public ReferenceFluidExtensionTests() + public ReferencesFluidExtensionTests() { + var services = + new ServiceCollection() + .AddSingleton(appProvider) + .AddSingleton(contentQuery) + .BuildServiceProvider(); + var extensions = new IFluidExtension[] { - new ReferencesFluidExtension(contentQuery, appProvider) + new ReferencesFluidExtension(services) }; A.CallTo(() => appProvider.GetAppAsync(appId.Id, false)) @@ -43,7 +50,7 @@ namespace Squidex.Domain.Apps.Entities.Contents var referenceId1 = DomainId.NewGuid(); var reference1 = CreateReference(referenceId1, 1); var referenceId2 = DomainId.NewGuid(); - var reference2 = CreateReference(referenceId1, 2); + var reference2 = CreateReference(referenceId2, 2); var @event = new EnrichedContentEvent { @@ -67,20 +74,20 @@ namespace Squidex.Domain.Apps.Entities.Contents }; var template = @" -{% for id in event.data.references.iv %} - {% reference 'ref', id %} - Text: {{ ref.data.field1.iv }} {{ ref.data.field2.iv }} -{% endfor %} -"; + {% for id in event.data.references.iv %} + {% reference 'ref', id %} + Text: {{ ref.data.field1.iv }} {{ ref.data.field2.iv }} {{ ref.id }} + {% endfor %} + "; - var expected = @" - Text: Hello 1 World 1 - Text: Hello 2 World 2 -"; + var expected = $@" + Text: Hello 1 World 1 {referenceId1} + Text: Hello 2 World 2 {referenceId2} + "; var result = await sut.RenderAsync(template, vars); - Assert.Equal(expected, result); + Assert.Equal(Cleanup(expected), Cleanup(result)); } private static IEnrichedContentEntity CreateReference(DomainId referenceId, int index) @@ -98,5 +105,13 @@ namespace Squidex.Domain.Apps.Entities.Contents Id = referenceId }; } + + private static string Cleanup(string text) + { + return text + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Replace(" ", string.Empty); + } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesJintExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesJintExtensionTests.cs new file mode 100644 index 000000000..5fc9e81da --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferencesJintExtensionTests.cs @@ -0,0 +1,150 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Security.Claims; +using System.Threading.Tasks; +using FakeItEasy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.TestHelpers; +using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Json.Objects; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents +{ + public class ReferencesJintExtensionTests : IClassFixture + { + private readonly IContentQueryService contentQuery = A.Fake(); + private readonly IAppProvider appProvider = A.Fake(); + private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); + private readonly JintScriptEngine sut; + + public ReferencesJintExtensionTests() + { + var services = + new ServiceCollection() + .AddSingleton(appProvider) + .AddSingleton(contentQuery) + .BuildServiceProvider(); + + var extensions = new IJintExtension[] + { + new ReferencesJintExtension(services) + }; + + A.CallTo(() => appProvider.GetAppAsync(appId.Id, false)) + .Returns(Mocks.App(appId)); + + sut = new JintScriptEngine(new MemoryCache(Options.Create(new MemoryCacheOptions())), extensions); + } + + [Fact] + public async Task Should_resolve_reference() + { + var referenceId1 = DomainId.NewGuid(); + var reference1 = CreateReference(referenceId1, 1); + + var user = new ClaimsPrincipal(); + + var data = + new ContentData() + .AddField("references", + new ContentFieldData() + .AddJsonValue(JsonValue.Array(referenceId1))); + + A.CallTo(() => contentQuery.QueryAsync( + A.That.Matches(x => x.App.Id == appId.Id && x.User == user), A.That.HasIds(referenceId1))) + .Returns(ResultList.CreateFrom(1, reference1)); + + var vars = new ScriptVars { Data = data, AppId = appId.Id, User = user }; + + var script = @" + getReference(data.references.iv[0], function (references) { + var result1 = `Text: ${references[0].data.field1.iv} ${references[0].data.field2.iv}`; + + complete(`${result1}`); + })"; + + var expected = @" + Text: Hello 1 World 1 + "; + + var result = (await sut.ExecuteAsync(vars, script)).ToString(); + + Assert.Equal(Cleanup(expected), Cleanup(result)); + } + + [Fact] + public async Task Should_resolve_references() + { + var referenceId1 = DomainId.NewGuid(); + var reference1 = CreateReference(referenceId1, 1); + var referenceId2 = DomainId.NewGuid(); + var reference2 = CreateReference(referenceId1, 2); + + var user = new ClaimsPrincipal(); + + var data = + new ContentData() + .AddField("references", + new ContentFieldData() + .AddJsonValue(JsonValue.Array(referenceId1, referenceId2))); + + A.CallTo(() => contentQuery.QueryAsync( + A.That.Matches(x => x.App.Id == appId.Id && x.User == user), A.That.HasIds(referenceId1, referenceId2))) + .Returns(ResultList.CreateFrom(2, reference1, reference2)); + + var vars = new ScriptVars { Data = data, AppId = appId.Id, User = user }; + + var script = @" + getReferences(data.references.iv, function (references) { + var result1 = `Text: ${references[0].data.field1.iv} ${references[0].data.field2.iv}`; + var result2 = `Text: ${references[1].data.field1.iv} ${references[1].data.field2.iv}`; + + complete(`${result1}\n${result2}`); + })"; + + var expected = @" + Text: Hello 1 World 1 + Text: Hello 2 World 2 + "; + + var result = (await sut.ExecuteAsync(vars, script)).ToString(); + + Assert.Equal(Cleanup(expected), Cleanup(result)); + } + + private static IEnrichedContentEntity CreateReference(DomainId referenceId, int index) + { + return new ContentEntity + { + Data = + new ContentData() + .AddField("field1", + new ContentFieldData() + .AddJsonValue(JsonValue.Create($"Hello {index}"))) + .AddField("field2", + new ContentFieldData() + .AddJsonValue(JsonValue.Create($"World {index}"))), + Id = referenceId + }; + } + + private static string Cleanup(string text) + { + return text + .Replace("\r", string.Empty) + .Replace("\n", string.Empty) + .Replace(" ", string.Empty); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs index 7853dd3d0..995699c2c 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeUrlGenerator.cs @@ -36,11 +36,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData return $"contents/{schemaId.Name}/{contentId}"; } - public string AppSettingsUI(NamedId appId) - { - throw new NotSupportedException(); - } - public string AssetsUI(NamedId appId, string? query = null) { throw new NotSupportedException(); diff --git a/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithAppIdCommandMiddlewareTests.cs b/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithAppIdCommandMiddlewareTests.cs index d5eecf1df..9557d774a 100644 --- a/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithAppIdCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithAppIdCommandMiddlewareTests.cs @@ -9,7 +9,6 @@ using System; using System.Threading.Tasks; using FakeItEasy; using Squidex.Domain.Apps.Entities; -using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure;