diff --git a/backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs b/backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs index 078e71d69..d2f1b2216 100644 --- a/backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs +++ b/backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs @@ -9,6 +9,7 @@ using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.ValidateContent; +using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Queries; @@ -17,13 +18,12 @@ namespace Squidex.Extensions.Validation; internal sealed class CompositeUniqueValidator : IValidator { - private readonly string tag; + private readonly string contentTag; private readonly IContentRepository contentRepository; - public CompositeUniqueValidator(string tag, IContentRepository contentRepository) + public CompositeUniqueValidator(string contentTag, IContentRepository contentRepository) { - this.tag = tag; - + this.contentTag = contentTag; this.contentRepository = contentRepository; } @@ -55,7 +55,7 @@ internal sealed class CompositeUniqueValidator : IValidator { var filter = ClrFilter.And(filters); - var found = await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, filter); + var found = await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, filter, SearchScope.All); if (found.Any(x => x.Id != context.Root.ContentId)) { @@ -106,7 +106,7 @@ internal sealed class CompositeUniqueValidator : IValidator { return field.Partitioning == Partitioning.Invariant && - field.RawProperties.Tags?.Contains(tag) == true && + field.RawProperties.Tags?.Contains(contentTag) == true && field.RawProperties is BooleanFieldProperties or DateTimeFieldProperties or NumberFieldProperties or ReferencesFieldProperties or StringFieldProperties; } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs index b6abce29c..535a2af60 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs @@ -31,10 +31,10 @@ public sealed record Role(string Name, PermissionSet? Permissions = null, JsonOb PermissionIds.AppUsage }; - public const string Editor = "Editor"; - public const string Developer = "Developer"; - public const string Owner = "Owner"; - public const string Reader = "Reader"; + public const string Editor = nameof(Editor); + public const string Developer = nameof(Developer); + public const string Owner = nameof(Owner); + public const string Reader = nameof(Reader); public string Name { get; } = Guard.NotNullOrEmpty(Name); diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/GeoJsonValue.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/GeoJsonValue.cs index 026c34cca..5345d3eef 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/GeoJsonValue.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/GeoJsonValue.cs @@ -61,12 +61,11 @@ public static class GeoJsonValue { using (var stream = DefaultPools.MemoryStream.GetStream()) { - serializer.Serialize(obj, stream, true); + serializer.Serialize(obj, stream); stream.Position = 0; - geoJSON = serializer.Deserialize(stream, null, true); - + geoJSON = serializer.Deserialize(stream, null); return true; } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs index 9676895b7..2dc443a37 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/AssetsValidator.cs @@ -15,7 +15,8 @@ using Squidex.Infrastructure.Translations; namespace Squidex.Domain.Apps.Core.ValidateContent.Validators; -public delegate Task> CheckAssets(IEnumerable ids); +public delegate Task> CheckAssets(IEnumerable ids, + CancellationToken ct); public sealed class AssetsValidator : IValidator { @@ -46,16 +47,17 @@ public sealed class AssetsValidator : IValidator public void Validate(object? value, ValidationContext context) { - context.Root.AddTask(ct => ValidateCoreAsync(value, context)); + context.Root.AddTask(ct => ValidateCoreAsync(value, context, ct)); } - private async Task ValidateCoreAsync(object? value, ValidationContext context) + private async Task ValidateCoreAsync(object? value, ValidationContext context, + CancellationToken ct) { var foundIds = new List(); if (value is ICollection { Count: > 0 } assetIds) { - var assets = await checkAssets(assetIds); + var assets = await checkAssets(assetIds, ct); var index = 1; foreach (var assetId in assetIds) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs index 984900a73..50988f5b1 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ReferencesValidator.cs @@ -14,7 +14,8 @@ using Squidex.Infrastructure.Translations; namespace Squidex.Domain.Apps.Core.ValidateContent.Validators; -public delegate Task> CheckContentsByIds(HashSet ids); +public delegate Task> CheckContentsByIds(HashSet ids, + CancellationToken ct); public sealed class ReferencesValidator : IValidator { @@ -45,16 +46,17 @@ public sealed class ReferencesValidator : IValidator public void Validate(object? value, ValidationContext context) { - context.Root.AddTask(ct => ValidateCoreAsync(value, context)); + context.Root.AddTask(ct => ValidateCoreAsync(value, context, ct)); } - private async Task ValidateCoreAsync(object? value, ValidationContext context) + private async Task ValidateCoreAsync(object? value, ValidationContext context, + CancellationToken ct) { var foundIds = new List(); if (value is ICollection { Count: > 0 } contentIds) { - var references = await checkReferences(contentIds.ToHashSet()); + var references = await checkReferences(contentIds.ToHashSet(), ct); var referenceIndex = 1; foreach (var id in contentIds) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs index 2e299b8a8..c660a5906 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/UniqueValidator.cs @@ -13,7 +13,8 @@ using Squidex.Infrastructure.Translations; namespace Squidex.Domain.Apps.Core.ValidateContent.Validators; -public delegate Task> CheckUniqueness(FilterNode filter); +public delegate Task> CheckUniqueness(FilterNode filter, + CancellationToken ct); public sealed class UniqueValidator : IValidator { @@ -43,14 +44,15 @@ public sealed class UniqueValidator : IValidator if (filter != null) { - context.Root.AddTask(ct => ValidateCoreAsync(context, filter)); + context.Root.AddTask(ct => ValidateCoreAsync(context, filter, ct)); } } } - private async Task ValidateCoreAsync(ValidationContext context, FilterNode filter) + private async Task ValidateCoreAsync(ValidationContext context, FilterNode filter, + CancellationToken ct) { - var found = await checkUniqueness(filter); + var found = await checkUniqueness(filter, ct); if (found.Any(x => x.Id != context.Root.ContentId)) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs index d5c191bab..3179ecd14 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository.cs @@ -90,7 +90,7 @@ public sealed partial class MongoAssetFolderRepository : MongoRepositoryBase> { Filter.Eq(x => x.IndexedAppId, appId), - Filter.Eq(x => x.IsDeleted, false) + Filter.Ne(x => x.IsDeleted, true) }; if (parentId != null) diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs index 659f1aa6f..39f2f1ecf 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/Visitors/FindExtensions.cs @@ -49,7 +49,7 @@ public static class FindExtensions if (!query.HasFilterField("IsDeleted")) { - filters.Add(Filter.Eq(x => x.IsDeleted, false)); + filters.Add(Filter.Ne(x => x.IsDeleted, true)); isDefault = true; } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs index f1307b2c6..27241193b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs @@ -100,7 +100,12 @@ public sealed class MongoContentCollection : MongoRepositoryBase x.DocumentId == documentId, Update.Unset(x => x.ScheduleJob).Unset(x => x.ScheduledAt), cancellationToken: ct); + return Collection.UpdateOneAsync( + x => x.DocumentId == documentId, + Update + .Unset(x => x.ScheduleJob) + .Unset(x => x.ScheduledAt), + cancellationToken: ct); } public IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs index 9071f2b03..305148f4f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs @@ -69,98 +69,68 @@ public partial class MongoContentRepository : MongoBase, ICo CanUseTransactions = clusteredAsReplica && clusterVersion >= 4 && options.UseTransactions; } - public IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, + public IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, SearchScope scope, CancellationToken ct = default) { - return collectionComplete.StreamAll(appId, schemaIds, ct); + return GetCollection(scope).StreamAll(appId, schemaIds, ct); } - public IAsyncEnumerable StreamReferencing(DomainId appId, DomainId reference, int take, + public IAsyncEnumerable StreamReferencing(DomainId appId, DomainId reference, int take, SearchScope scope, CancellationToken ct = default) { - return collectionComplete.StreamReferencing(appId, reference, take, ct); + return GetCollection(scope).StreamReferencing(appId, reference, take, ct); } - public IAsyncEnumerable QueryScheduledWithoutDataAsync(Instant now, + public IAsyncEnumerable StreamScheduledWithoutDataAsync(Instant now, SearchScope scope, CancellationToken ct = default) { - return collectionComplete.QueryScheduledWithoutDataAsync(now, ct); + return GetCollection(scope).QueryScheduledWithoutDataAsync(now, ct); } public Task> QueryAsync(IAppEntity app, List schemas, Q q, SearchScope scope, CancellationToken ct = default) { - if (scope == SearchScope.All) - { - return collectionComplete.QueryAsync(app, schemas, q, ct); - } - else - { - return collectionPublished.QueryAsync(app, schemas, q, ct); - } + return GetCollection(scope).QueryAsync(app, schemas, q, ct); } public Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q, SearchScope scope, CancellationToken ct = default) { - if (scope == SearchScope.All) - { - return collectionComplete.QueryAsync(app, schema, q, ct); - } - else - { - return collectionPublished.QueryAsync(app, schema, q, ct); - } + return GetCollection(scope).QueryAsync(app, schema, q, ct); } public Task FindContentAsync(IAppEntity app, ISchemaEntity schema, DomainId id, SearchScope scope, CancellationToken ct = default) { - if (scope == SearchScope.All) - { - return collectionComplete.FindContentAsync(schema, id, ct); - } - else - { - return collectionPublished.FindContentAsync(schema, id, ct); - } + return GetCollection(scope).FindContentAsync(schema, id, ct); } public Task> QueryIdsAsync(DomainId appId, HashSet ids, SearchScope scope, CancellationToken ct = default) { - if (scope == SearchScope.All) - { - return collectionComplete.QueryIdsAsync(appId, ids, ct); - } - else - { - return collectionPublished.QueryIdsAsync(appId, ids, ct); - } + return GetCollection(scope).QueryIdsAsync(appId, ids, ct); } public Task HasReferrersAsync(DomainId appId, DomainId reference, SearchScope scope, CancellationToken ct = default) { - if (scope == SearchScope.All) - { - return collectionComplete.HasReferrersAsync(appId, reference, ct); - } - else - { - return collectionPublished.HasReferrersAsync(appId, reference, ct); - } + return GetCollection(scope).HasReferrersAsync(appId, reference, ct); } - public Task ResetScheduledAsync(DomainId documentId, + public Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, SearchScope scope, CancellationToken ct = default) { - return collectionComplete.ResetScheduledAsync(documentId, ct); + return GetCollection(scope).QueryIdsAsync(appId, schemaId, filterNode, ct); } - public Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, + public Task ResetScheduledAsync(DomainId documentId, SearchScope scope, CancellationToken ct = default) { - return collectionComplete.QueryIdsAsync(appId, schemaId, filterNode, ct); + return GetCollection(SearchScope.All).ResetScheduledAsync(documentId, ct); + } + + private MongoContentCollection GetCollection(SearchScope scope) + { + return scope == SearchScope.All ? collectionComplete : collectionPublished; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs index 2f7ae61cd..4fb741909 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryAsStream.cs @@ -44,6 +44,13 @@ public sealed class QueryAsStream : OperationBase { filters.Add(Filter.In(x => x.IndexedSchemaId, schemaIds)); } + else + { + // If we also add this filter, it is more likely that the index will be used. + filters.Add(Filter.Exists(x => x.IndexedSchemaId)); + } + + filters.Add(Filter.Ne(x => x.IsDeleted, true)); return Filter.And(filters); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs index 47c5eb0e7..89888dcba 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs @@ -221,7 +221,7 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor break; default: - scheduler.Run(callback,ErrorNoAsset); + scheduler.Run(callback, ErrorNoAsset); break; } }); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/EnrichForCaching.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/EnrichForCaching.cs index ccc81c3b5..136986820 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/EnrichForCaching.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/EnrichForCaching.cs @@ -22,6 +22,12 @@ public sealed class EnrichForCaching : IAssetEnricherStep public Task EnrichAsync(Context context, CancellationToken ct) { + // Sometimes we just want to skip this for performance reasons. + if (!ShouldEnrich(context)) + { + return Task.CompletedTask; + } + context.AddCacheHeaders(requestCache); return Task.CompletedTask; @@ -30,6 +36,12 @@ public sealed class EnrichForCaching : IAssetEnricherStep public Task EnrichAsync(Context context, IEnumerable assets, CancellationToken ct) { + // Sometimes we just want to skip this for performance reasons. + if (!ShouldEnrich(context)) + { + return Task.CompletedTask; + } + requestCache.AddDependency(context.App.Id, context.App.Version); foreach (var asset in assets) @@ -39,4 +51,9 @@ public sealed class EnrichForCaching : IAssetEnricherStep return Task.CompletedTask; } + + private static bool ShouldEnrich(Context context) + { + return !context.ShouldSkipCacheKeys(); + } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs index 68c83bc94..b0da1192d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Shared; namespace Squidex.Domain.Apps.Entities.Assets.Queries.Steps; @@ -96,6 +97,11 @@ public sealed class ScriptAsset : IAssetEnricherStep private static bool ShouldEnrich(Context context) { - return !context.IsFrontendClient; + // We need a special permission to disable scripting for security reasons, if the script removes sensible data. + var shouldScript = + !context.ShouldSkipScripting() || + !context.UserPermissions.Allows(PermissionIds.ForApp(PermissionIds.AppNoScripting, context.App.Name)); + + return !context.IsFrontendClient && shouldScript; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs index 8fbd4d8f5..c37deed8a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs @@ -61,7 +61,7 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri trigger.Schemas.Select(x => x.SchemaId).Distinct().ToHashSet() : null; - await foreach (var content in contentRepository.StreamAll(context.AppId.Id, schemaIds, ct)) + await foreach (var content in contentRepository.StreamAll(context.AppId.Id, schemaIds, SearchScope.All, ct)) { var result = new EnrichedContentEvent { @@ -105,7 +105,7 @@ public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscri var take = context.MaxEvents.Value; - await foreach (var content in contentRepository.StreamReferencing(context.AppId.Id, enrichedEvent.Id, take, ct)) + await foreach (var content in contentRepository.StreamReferencing(context.AppId.Id, enrichedEvent.Id, take, SearchScope.All, ct)) { var result = new EnrichedContentEvent { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentSchedulerProcess.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentSchedulerProcess.cs index eddef8adc..b19f680f9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentSchedulerProcess.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentSchedulerProcess.cs @@ -54,7 +54,7 @@ public sealed class ContentSchedulerProcess : IBackgroundProcess { var now = Clock.GetCurrentInstant(); - await foreach (var content in contentRepository.QueryScheduledWithoutDataAsync(now, ct)) + await foreach (var content in contentRepository.StreamScheduledWithoutDataAsync(now, SearchScope.All, ct)) { await TryPublishAsync(content); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs index b5a9cfd3d..86c9d1ef5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs @@ -12,6 +12,9 @@ namespace Squidex.Domain.Apps.Entities.Contents; public interface IContentQueryService { + IAsyncEnumerable StreamAsync(Context context, string schemaIdOrName, int skip, + CancellationToken ct = default); + Task> QueryAsync(Context context, Q q, CancellationToken ct = default); 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 53bed5890..05288a1ff 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Runtime.CompilerServices; using Microsoft.Extensions.Options; using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Schemas; @@ -41,6 +42,29 @@ public sealed class ContentQueryService : IContentQueryService this.queryParser = queryParser; } + public async IAsyncEnumerable StreamAsync(Context context, string schemaIdOrName, int skip, + [EnumeratorCancellation] CancellationToken ct = default) + { + Guard.NotNull(context); + + // We assume that the user has the full read permissions for this schema to optimize the DB query. + var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName, ct); + + // Skip all expensive operations when we call the enricher. + context = context.Clone(b => b + .WithoutScripting() + .WithoutCacheKeys() + .WithoutContentEnrichment()); + + // We run this query without a timeout because it is meant for long running background operations. + var contents = contentRepository.StreamAll(context.App.Id, HashSet.Of(schema.Id), context.Scope(), ct); + + await foreach (var content in contents.WithCancellation(ct)) + { + yield return await contentEnricher.EnrichAsync(content, false, context, ct); + } + } + public async Task FindAsync(Context context, string schemaIdOrName, DomainId id, long version = EtagVersion.Any, CancellationToken ct = default) { @@ -55,6 +79,7 @@ public sealed class ContentQueryService : IContentQueryService IContentEntity? content; + // A special ID to always query the single content of the singleton. if (id.ToString().Equals(SingletonId, StringComparison.Ordinal)) { id = schema.Id; @@ -87,6 +112,7 @@ public sealed class ContentQueryService : IContentQueryService { activity?.SetTag("schemaName", schemaIdOrName); + // Usually the query should not be null, but we never know. if (q == null) { return ResultList.Empty(); @@ -94,6 +120,7 @@ public sealed class ContentQueryService : IContentQueryService var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName, ct); + // The API only checks for read.own permission, so we might need an additional filter here. if (!HasPermission(context, schema, PermissionIds.AppContentsRead)) { q = q with { CreatedBy = context.UserPrincipal.Token() }; @@ -119,6 +146,7 @@ public sealed class ContentQueryService : IContentQueryService using (Telemetry.Activities.StartActivity("ContentQueryService/QueryAsync")) { + // Usually the query should not be null, but we never know. if (q == null) { return ResultList.Empty(); @@ -126,6 +154,7 @@ public sealed class ContentQueryService : IContentQueryService var schemas = await GetSchemasAsync(context, ct); + // If the user does not have a permission to query a single schema the database would return an empty result anyway. if (schemas.Count == 0) { return ResultList.Empty(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs index 63fa30b39..22c084d62 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs @@ -21,6 +21,12 @@ public sealed class EnrichForCaching : IContentEnricherStep public Task EnrichAsync(Context context, CancellationToken ct) { + // Sometimes we just want to skip this for performance reasons. + if (!ShouldEnrich(context)) + { + return Task.CompletedTask; + } + context.AddCacheHeaders(requestCache); return Task.CompletedTask; @@ -29,8 +35,15 @@ public sealed class EnrichForCaching : IContentEnricherStep public async Task EnrichAsync(Context context, IEnumerable contents, ProvideSchema schemas, CancellationToken ct) { + // Sometimes we just want to skip this for performance reasons. + if (!ShouldEnrich(context)) + { + return; + } + var app = context.App; + // Group by schema, so we only fetch the schema once. foreach (var group in contents.GroupBy(x => x.SchemaId.Id)) { ct.ThrowIfCancellationRequested(); @@ -45,4 +58,9 @@ public sealed class EnrichForCaching : IContentEnricherStep } } } + + private static bool ShouldEnrich(Context context) + { + return !context.ShouldSkipCacheKeys(); + } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithSchema.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithSchema.cs index 52114ce5a..6ca5a16c6 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithSchema.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithSchema.cs @@ -14,6 +14,7 @@ public sealed class EnrichWithSchema : IContentEnricherStep public async Task EnrichAsync(Context context, IEnumerable contents, ProvideSchema schemas, CancellationToken ct) { + // Group by schema, so we only fetch the schema once. foreach (var group in contents.GroupBy(x => x.SchemaId.Id)) { ct.ThrowIfCancellationRequested(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs index 293bf422e..6b3842e33 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs @@ -43,6 +43,7 @@ public sealed class ResolveAssets : IContentEnricherStep var ids = new HashSet(); + // Group by schema, so we only fetch the schema once. foreach (var group in contents.GroupBy(x => x.SchemaId.Id)) { var (schema, components) = await schemas(group.Key); @@ -52,6 +53,7 @@ public sealed class ResolveAssets : IContentEnricherStep var assets = await GetAssetsAsync(context, ids, ct); + // Group by schema, so we only fetch the schema once. foreach (var group in contents.GroupBy(x => x.SchemaId.Id)) { var (schema, components) = await schemas(group.Key); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs index 8e1d486e6..3830004d8 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs @@ -30,7 +30,6 @@ public sealed class ResolveReferences : IContentEnricherStep public ResolveReferences(Lazy contentQuery, IRequestCache requestCache) { this.contentQuery = contentQuery; - this.requestCache = requestCache; } @@ -44,6 +43,7 @@ public sealed class ResolveReferences : IContentEnricherStep var ids = new HashSet(); + // Group by schema, so we only fetch the schema once. foreach (var group in contents.GroupBy(x => x.SchemaId.Id)) { var (schema, components) = await schemas(group.Key); @@ -53,6 +53,7 @@ public sealed class ResolveReferences : IContentEnricherStep var references = await GetReferencesAsync(context, ids, ct); + // Group by schema, so we only fetch the schema once. foreach (var group in contents.GroupBy(x => x.SchemaId.Id)) { var (schema, components) = await schemas(group.Key); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs index 0426537c2..b38efbd80 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs @@ -6,6 +6,7 @@ // ========================================================================== using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Shared; namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps; @@ -21,6 +22,7 @@ public sealed class ScriptContent : IContentEnricherStep public async Task EnrichAsync(Context context, IEnumerable contents, ProvideSchema schemas, CancellationToken ct) { + // Sometimes we just want to skip this for performance reasons. if (!ShouldEnrich(context)) { return; @@ -93,6 +95,11 @@ public sealed class ScriptContent : IContentEnricherStep private static bool ShouldEnrich(Context context) { - return !context.IsFrontendClient; + // We need a special permission to disable scripting for security reasons, if the script removes sensible data. + var shouldScript = + !context.ShouldSkipScripting() || + !context.UserPermissions.Allows(PermissionIds.ForApp(PermissionIds.AppNoScripting, context.App.Name)); + + return !context.IsFrontendClient && shouldScript; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs index ca70e543c..bdf2818e5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Repositories/IContentRepository.cs @@ -16,10 +16,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Repositories; public interface IContentRepository { - IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, + IAsyncEnumerable StreamScheduledWithoutDataAsync(Instant now, SearchScope scope, CancellationToken ct = default); - IAsyncEnumerable StreamReferencing(DomainId appId, DomainId references, int take, + IAsyncEnumerable StreamAll(DomainId appId, HashSet? schemaIds, SearchScope scope, + CancellationToken ct = default); + + IAsyncEnumerable StreamReferencing(DomainId appId, DomainId references, int take, SearchScope scope, CancellationToken ct = default); Task> QueryAsync(IAppEntity app, List schemas, Q q, SearchScope scope, @@ -28,7 +31,7 @@ public interface IContentRepository Task> QueryAsync(IAppEntity app, ISchemaEntity schema, Q q, SearchScope scope, CancellationToken ct = default); - Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, + Task> QueryIdsAsync(DomainId appId, DomainId schemaId, FilterNode filterNode, SearchScope scope, CancellationToken ct = default); Task> QueryIdsAsync(DomainId appId, HashSet ids, SearchScope scope, @@ -40,9 +43,6 @@ public interface IContentRepository Task HasReferrersAsync(DomainId appId, DomainId reference, SearchScope scope, CancellationToken ct = default); - Task ResetScheduledAsync(DomainId documentId, - CancellationToken ct = default); - - IAsyncEnumerable QueryScheduledWithoutDataAsync(Instant now, + Task ResetScheduledAsync(DomainId documentId, SearchScope scope, CancellationToken ct = default); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Validation/DependencyValidatorsFactory.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Validation/DependencyValidatorsFactory.cs index c1fffae0e..6e4ebb113 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Validation/DependencyValidatorsFactory.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Validation/DependencyValidatorsFactory.cs @@ -35,9 +35,9 @@ public sealed class DependencyValidatorsFactory : IValidatorsFactory if (field is IField assetsField) { - var checkAssets = new CheckAssets(async ids => + var checkAssets = new CheckAssets(async (ids, ct) => { - return await assetRepository.QueryAsync(context.Root.AppId.Id, null, Q.Empty.WithIds(ids), default); + return await assetRepository.QueryAsync(context.Root.AppId.Id, null, Q.Empty.WithIds(ids), ct); }); yield return new AssetsValidator(isRequired, assetsField.Properties, checkAssets); @@ -45,9 +45,9 @@ public sealed class DependencyValidatorsFactory : IValidatorsFactory if (field is IField referencesField) { - var checkReferences = new CheckContentsByIds(async ids => + var checkReferences = new CheckContentsByIds(async (ids, ct) => { - return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, ids, SearchScope.All, default); + return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, ids, SearchScope.All, ct); }); yield return new ReferencesValidator(isRequired, referencesField.Properties, checkReferences); @@ -55,9 +55,9 @@ public sealed class DependencyValidatorsFactory : IValidatorsFactory if (field is IField numberField && numberField.Properties.IsUnique) { - var checkUniqueness = new CheckUniqueness(async filter => + var checkUniqueness = new CheckUniqueness(async (f, ct) => { - return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, filter, default); + return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, f, SearchScope.All, ct); }); yield return new UniqueValidator(checkUniqueness); @@ -65,9 +65,9 @@ public sealed class DependencyValidatorsFactory : IValidatorsFactory if (field is IField stringField && stringField.Properties.IsUnique) { - var checkUniqueness = new CheckUniqueness(async filter => + var checkUniqueness = new CheckUniqueness(async (f, ct) => { - return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, filter, default); + return await contentRepository.QueryIdsAsync(context.Root.AppId.Id, context.Root.SchemaId.Id, f, SearchScope.All, ct); }); yield return new UniqueValidator(checkUniqueness); diff --git a/backend/src/Squidex.Domain.Apps.Entities/ContextExtensions.cs b/backend/src/Squidex.Domain.Apps.Entities/ContextExtensions.cs index d3371209d..a5e30e5af 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/ContextExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/ContextExtensions.cs @@ -11,6 +11,28 @@ public static class ContextExtensions { private const string HeaderNoTotal = "X-NoTotal"; private const string HeaderNoSlowTotal = "X-NoSlowTotal"; + private const string HeaderNoCacheKeys = "X-NoCacheKeys"; + private const string HeaderNoScripting = "X-NoScripting"; + + public static bool ShouldSkipCacheKeys(this Context context) + { + return context.Headers.ContainsKey(HeaderNoCacheKeys); + } + + public static ICloneBuilder WithoutCacheKeys(this ICloneBuilder builder, bool value = true) + { + return builder.WithBoolean(HeaderNoCacheKeys, value); + } + + public static bool ShouldSkipScripting(this Context context) + { + return context.Headers.ContainsKey(HeaderNoScripting); + } + + public static ICloneBuilder WithoutScripting(this ICloneBuilder builder, bool value = true) + { + return builder.WithBoolean(HeaderNoScripting, value); + } public static bool ShouldSkipTotal(this Context context) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs index 57f04a3de..b59b47a35 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs @@ -49,22 +49,27 @@ public sealed class RuleEnricher : IRuleEnricher results.Add(result); } + // Sometimes we just want to skip this for performance reasons. + var enrichCacheKeys = !context.ShouldSkipCacheKeys(); + foreach (var group in results.GroupBy(x => x.AppId.Id)) { var statistics = await ruleUsageTracker.GetTotalByAppAsync(group.Key, ct); foreach (var rule in group) { - requestCache.AddDependency(rule.UniqueId, rule.Version); - if (statistics.TryGetValue(rule.Id, out var statistic)) { rule.NumFailed = statistic.TotalFailed; rule.NumSucceeded = statistic.TotalSucceeded; } - requestCache.AddDependency(rule.NumFailed); - requestCache.AddDependency(rule.NumSucceeded); + if (enrichCacheKeys) + { + requestCache.AddDependency(rule.UniqueId, rule.Version); + requestCache.AddDependency(rule.NumFailed); + requestCache.AddDependency(rule.NumSucceeded); + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index 6b2824877..d90b46b09 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -29,7 +29,7 @@ - + diff --git a/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs b/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs index b20283e62..2f10f7052 100644 --- a/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs +++ b/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs @@ -76,7 +76,7 @@ public sealed class DefaultDomainObjectCache : IDomainObjectCache { using (var stream = DefaultPools.MemoryStream.GetStream()) { - serializer.Serialize(snapshot, stream, true); + serializer.Serialize(snapshot, stream); await distributedCache.SetAsync(cacheKey, stream.ToArray(), cacheOptions, ct); } diff --git a/backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs b/backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs index 1816c4fb9..619d1c248 100644 --- a/backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs +++ b/backend/src/Squidex.Infrastructure/Json/IJsonSerializer.cs @@ -13,11 +13,17 @@ public interface IJsonSerializer string Serialize(object? value, Type type, bool indented = false); - void Serialize(T value, Stream stream, bool leaveOpen = false); + void Serialize(T value, Stream stream, bool indented = false); - void Serialize(object? value, Type type, Stream stream, bool leaveOpen = false); + void Serialize(object? value, Type type, Stream stream, bool indented = false); + + Task SerializeAsync(T value, Stream stream, bool indented = false, + CancellationToken ct = default); + + Task SerializeAsync(object? value, Type type, Stream stream, bool indented = false, + CancellationToken ct = default); T Deserialize(string value, Type? actualType = null); - T Deserialize(Stream stream, Type? actualType = null, bool leaveOpen = false); + T Deserialize(Stream stream, Type? actualType = null); } diff --git a/backend/src/Squidex.Infrastructure/Json/System/SystemJsonSerializer.cs b/backend/src/Squidex.Infrastructure/Json/System/SystemJsonSerializer.cs index 6ca80f36b..e055f8010 100644 --- a/backend/src/Squidex.Infrastructure/Json/System/SystemJsonSerializer.cs +++ b/backend/src/Squidex.Infrastructure/Json/System/SystemJsonSerializer.cs @@ -41,7 +41,7 @@ public sealed class SystemJsonSerializer : IJsonSerializer } } - public T Deserialize(Stream stream, Type? actualType = null, bool leaveOpen = false) + public T Deserialize(Stream stream, Type? actualType = null) { try { @@ -52,13 +52,6 @@ public sealed class SystemJsonSerializer : IJsonSerializer ThrowHelper.JsonException(ex.Message, ex); return default!; } - finally - { - if (!leaveOpen) - { - stream.Dispose(); - } - } } public string Serialize(T value, bool indented = false) @@ -66,11 +59,11 @@ public sealed class SystemJsonSerializer : IJsonSerializer return Serialize(value, typeof(T), indented); } - public string Serialize(object? value, Type type, bool intented = false) + public string Serialize(object? value, Type type, bool indented = false) { try { - var options = intented ? optionsIndented : optionsNormal; + var options = indented ? optionsIndented : optionsNormal; return JsonSerializer.Serialize(value, type, options); } @@ -81,27 +74,43 @@ public sealed class SystemJsonSerializer : IJsonSerializer } } - public void Serialize(T value, Stream stream, bool leaveOpen = false) + public void Serialize(T value, Stream stream, bool indented = false) { - Serialize(value, typeof(T), stream, leaveOpen); + Serialize(value, typeof(T), stream, indented); } - public void Serialize(object? value, Type type, Stream stream, bool leaveOpen = false) + public void Serialize(object? value, Type type, Stream stream, bool indented = false) { try { + var options = indented ? optionsIndented : optionsNormal; + JsonSerializer.Serialize(stream, value, optionsNormal); } catch (SystemJsonException ex) { ThrowHelper.JsonException(ex.Message, ex); } - finally + } + + public Task SerializeAsync(T value, Stream stream, bool indented = false, + CancellationToken ct = default) + { + return SerializeAsync(value, typeof(T), stream, indented, ct); + } + + public async Task SerializeAsync(object? value, Type type, Stream stream, bool indented = false, + CancellationToken ct = default) + { + try { - if (!leaveOpen) - { - stream.Dispose(); - } + var options = indented ? optionsIndented : optionsNormal; + + await JsonSerializer.SerializeAsync(stream, value, optionsNormal, ct); + } + catch (SystemJsonException ex) + { + ThrowHelper.JsonException(ex.Message, ex); } } } diff --git a/backend/src/Squidex.Shared/PermissionIds.cs b/backend/src/Squidex.Shared/PermissionIds.cs index 08bcdacb0..18298cba0 100644 --- a/backend/src/Squidex.Shared/PermissionIds.cs +++ b/backend/src/Squidex.Shared/PermissionIds.cs @@ -69,6 +69,9 @@ namespace Squidex.Shared // App public const string App = "squidex.apps.{app}"; + // App + public const string AppNoScripting = "squidex.apps.{app}.no-scripting"; + // App General public const string AppAdmin = "squidex.apps.{app}.*"; public const string AppDelete = "squidex.apps.{app}.delete"; diff --git a/backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs b/backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs new file mode 100644 index 000000000..0f56cb799 --- /dev/null +++ b/backend/src/Squidex.Web/Pipeline/JsonStreamResult.cs @@ -0,0 +1,65 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Net.Http.Headers; +using Squidex.Infrastructure.Json; + +namespace Squidex.Web.Pipeline; + +public sealed class JsonStreamResult : ActionResult +{ +#pragma warning disable RECS0108 // Warns about static fields in generic types + public static readonly byte[] Prefix = Encoding.UTF8.GetBytes("data: "); + public static readonly byte[] Separator = Encoding.UTF8.GetBytes("\n\n"); +#pragma warning restore RECS0108 // Warns about static fields in generic types + private readonly IAsyncEnumerable stream; + + public JsonStreamResult(IAsyncEnumerable stream) + { + this.stream = stream; + } + + public override async Task ExecuteResultAsync(ActionContext context) + { + DisableResponseBuffering(context.HttpContext); + + var serializer = context.HttpContext.RequestServices.GetRequiredService(); + + // The official content type for server sent events. + context.HttpContext.Request.Headers[HeaderNames.ContentType] = "text/event-stream"; + context.HttpContext.Request.Headers[HeaderNames.CacheControl] = "no-cache"; + + var ct = context.HttpContext.RequestAborted; + + var body = context.HttpContext.Response.Body; + + await foreach (var item in stream.WithCancellation(context.HttpContext.RequestAborted)) + { + // Every line needs to start with data. + await body.WriteAsync(Prefix, ct); + + await serializer.SerializeAsync(item, body, false, ct); + + // Write the separator after a every json object to simplify deserialization. + await body.WriteAsync(Separator, ct); + } + } + + private static void DisableResponseBuffering(HttpContext context) + { + var bufferingFeature = context.Features.Get(); + if (bufferingFeature != null) + { + bufferingFeature.DisableBuffering(); + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index 3238af0f5..cd8c80a9e 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -6,6 +6,7 @@ // ========================================================================== using Microsoft.AspNetCore.Mvc; +using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Contents.Models; using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Entities; @@ -15,6 +16,7 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Shared; using Squidex.Web; +using Squidex.Web.Pipeline; namespace Squidex.Areas.Api.Controllers.Contents; @@ -33,6 +35,30 @@ public sealed class ContentsController : ApiController this.contentWorkflow = contentWorkflow; } + /// + /// Streams contents. + /// + /// The name of the app. + /// The name of the schema. + /// The number of items to skip. + /// Contents returned.. + /// Schema or app not found.. + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs. + /// + [HttpGet] + [Route("content/{app}/{schema}/stream")] + [ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous(PermissionIds.AppContentsRead)] + [ApiCosts(10)] + [OpenApiIgnore] + public IActionResult StreamContents(string app, string schema, [FromQuery] int skip = 0) + { + var contents = contentQuery.StreamAsync(Context, schema, skip, HttpContext.RequestAborted); + + return new JsonStreamResult(contents); + } + /// /// Queries contents. /// @@ -40,7 +66,7 @@ public sealed class ContentsController : ApiController /// The name of the schema. /// The optional ids of the content to fetch. /// The optional json query. - /// Contents retunred.. + /// Contents returned.. /// Schema or app not found.. /// /// You can read the generated documentation for your app at /api/content/{appName}/docs. diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index f7c6b6104..a466326dc 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -70,7 +70,7 @@ - + diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs index 759829a00..ba7939418 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs @@ -27,7 +27,7 @@ public class AssetsFieldTests : IClassFixture { if (field is IField assets) { - yield return new AssetsValidator(assets.Properties.IsRequired, assets.Properties, ids => + yield return new AssetsValidator(assets.Properties.IsRequired, assets.Properties, (ids, ct) => { var actual = ids.Select(TestAssets.Document).ToList(); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs index 4f0c1ae79..66a46b19a 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ReferencesFieldTests.cs @@ -36,7 +36,7 @@ public class ReferencesFieldTests : IClassFixture { if (field is IField references) { - yield return new ReferencesValidator(references.Properties.IsRequired, references.Properties, ids => + yield return new ReferencesValidator(references.Properties.IsRequired, references.Properties, (ids, ct) => { var actual = ids.Select(x => new ContentIdStatus(schemaId, x, Status.Published)).ToList(); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/AssetsValidatorTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/AssetsValidatorTests.cs index 956da8ad3..2d2b16986 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/AssetsValidatorTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/AssetsValidatorTests.cs @@ -257,7 +257,7 @@ public class AssetsValidatorTests : IClassFixture private static CheckAssets FoundAssets() { - return ids => + return (ids, ct) => { var actual = new List { Document, Image1, Image2, ImageSvg, Video }; diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs index 501fa6f59..cbd1992e6 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/ReferencesValidatorTests.cs @@ -215,7 +215,7 @@ public class ReferencesValidatorTests : IClassFixture private static CheckContentsByIds FoundReferences(DomainId schemaId, params (DomainId Id, Status Status)[] references) { - return x => + return (ids, ct) => { var actual = references.Select(x => new ContentIdStatus(schemaId, x.Id, x.Status)).ToList(); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs index 2ffdc2dd9..4a38bf99b 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/Validators/UniqueValidatorTests.cs @@ -95,7 +95,7 @@ public class UniqueValidatorTests : IClassFixture private static CheckUniqueness FoundDuplicates(DomainId id, Action? filter = null) { - return filterNode => + return (filterNode, ct) => { filter?.Invoke(filterNode.ToString()); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs index acb65e0f1..0c8cdd6be 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/EnrichForCachingTests.cs @@ -59,6 +59,26 @@ public class EnrichForCachingTests : GivenContext .MustHaveHappened(); } + [Fact] + public async Task Should_not_add_cache_headers_if_disabled() + { + await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), CancellationToken); + + A.CallTo(() => requestCache.AddHeader(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_add_cache_headers_for_assets_if_disabled() + { + var asset = CreateAsset(); + + await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), Enumerable.Repeat(asset, 1), CancellationToken); + + A.CallTo(() => requestCache.AddHeader(A._)) + .MustNotHaveHappened(); + } + private AssetEntity CreateAsset() { return new AssetEntity { AppId = AppId, Id = DomainId.NewGuid(), Version = 13 }; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs index ca23df09c..6be7d593a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs @@ -10,6 +10,7 @@ using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities.Assets.Queries.Steps; using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Infrastructure; +using Squidex.Shared; namespace Squidex.Domain.Apps.Entities.Assets.Queries; @@ -26,7 +27,7 @@ public class ScriptAssetTests : GivenContext [Fact] public async Task Should_not_call_script_engine_if_no_script_configured() { - var asset = new AssetEntity(); + var asset = CreateAsset(); await sut.EnrichAsync(ApiContext, new[] { asset }, CancellationToken); @@ -37,10 +38,9 @@ public class ScriptAssetTests : GivenContext [Fact] public async Task Should_not_call_script_engine_for_frontend_user() { - A.CallTo(() => App.AssetScripts) - .Returns(new AssetScripts { Query = "my-query" }); + SetupScript(query: "my-query"); - var asset = new AssetEntity(); + var asset = CreateAsset(); await sut.EnrichAsync(FrontendContext, new[] { asset }, CancellationToken); @@ -48,13 +48,25 @@ public class ScriptAssetTests : GivenContext .MustNotHaveHappened(); } + [Fact] + public async Task Should_not_call_script_engine_if_disabled_and_user_has_permission() + { + SetupScript(query: "my-query"); + + var asset = CreateAsset(); + + await sut.EnrichAsync(ContextWithNoScript(), new[] { asset }, CancellationToken); + + A.CallTo(() => scriptEngine.ExecuteAsync(A._, A._, ScriptOptions(), A._)) + .MustNotHaveHappened(); + } + [Fact] public async Task Should_call_script_engine() { - A.CallTo(() => App.AssetScripts) - .Returns(new AssetScripts { Query = "my-query" }); + SetupScript(query: "my-query"); - var asset = new AssetEntity { Id = DomainId.NewGuid() }; + var asset = CreateAsset(); await sut.EnrichAsync(ApiContext, new[] { asset }, CancellationToken); @@ -65,17 +77,17 @@ public class ScriptAssetTests : GivenContext Equals(x["appName"], AppId.Name) && Equals(x["user"], ApiContext.UserPrincipal)), "my-query", - ScriptOptions(), CancellationToken)) + ScriptOptions(), + CancellationToken)) .MustHaveHappened(); } [Fact] - public async Task Should_make_test_with_pre_query_script() + public async Task Should_call_script_engine_with_pre_query_script() { - A.CallTo(() => App.AssetScripts) - .Returns(new AssetScripts { Query = "my-query", QueryPre = "my-pre-query" }); + SetupScript(query: "my-query", queryPre: "my-pre-query"); - var asset = new AssetEntity { Id = DomainId.NewGuid() }; + var asset = CreateAsset(); await sut.EnrichAsync(ApiContext, new[] { asset }, CancellationToken); @@ -86,7 +98,8 @@ public class ScriptAssetTests : GivenContext Equals(x["appName"], AppId.Name) && Equals(x["user"], ApiContext.UserPrincipal)), "my-pre-query", - ScriptOptions(), CancellationToken)) + ScriptOptions(), + CancellationToken)) .MustHaveHappened(); A.CallTo(() => scriptEngine.ExecuteAsync( @@ -96,12 +109,36 @@ public class ScriptAssetTests : GivenContext Equals(x["appName"], AppId.Name) && Equals(x["user"], ApiContext.UserPrincipal)), "my-query", - ScriptOptions(), CancellationToken)) + ScriptOptions(), + CancellationToken)) .MustHaveHappened(); } + private void SetupScript(string? query = null, string? queryPre = null) + { + A.CallTo(() => App.AssetScripts) + .Returns(new AssetScripts + { + Query = query, + QueryPre = queryPre + }); + } + + private static AssetEntity CreateAsset() + { + return new AssetEntity { Id = DomainId.NewGuid() }; + } + private static ScriptOptions ScriptOptions() { return A.That.Matches(x => x.AsContext); } + + private Context ContextWithNoScript() + { + var contextPermission = PermissionIds.ForApp(PermissionIds.AppNoScripting, App.Name).Id; + var contextInstance = CreateContext(false, contextPermission).Clone(b => b.WithoutScripting()); + + return contextInstance; + } } 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 6ab5686db..56e8e6763 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs @@ -125,7 +125,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext { var ctx = Context(); - A.CallTo(() => contentRepository.StreamAll(AppId.Id, null, CancellationToken)) + A.CallTo(() => contentRepository.StreamAll(AppId.Id, null, SearchScope.All, CancellationToken)) .Returns(new List { new ContentEntity { SchemaId = schemaMatching }, @@ -157,7 +157,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext var ctx = Context(trigger); - A.CallTo(() => contentRepository.StreamAll(AppId.Id, A>.That.Is(schemaMatching.Id), CancellationToken)) + A.CallTo(() => contentRepository.StreamAll(AppId.Id, A>.That.Is(schemaMatching.Id), SearchScope.All, CancellationToken)) .Returns(new List { new ContentEntity { SchemaId = schemaMatching }, @@ -231,7 +231,7 @@ public class ContentChangedTriggerHandlerTests : GivenContext SetupData(@event, 12); - A.CallTo(() => contentRepository.StreamReferencing(AppId.Id, @event.ContentId, 100, CancellationToken)) + A.CallTo(() => contentRepository.StreamReferencing(AppId.Id, @event.ContentId, 100, SearchScope.All, CancellationToken)) .Returns(new List { new ContentEntity { SchemaId = schemaMatching }, diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentSchedulerProcessTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentSchedulerProcessTests.cs index 8bc9c6945..1ba21bf50 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentSchedulerProcessTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentSchedulerProcessTests.cs @@ -53,7 +53,7 @@ public class ContentSchedulerProcessTests : GivenContext A.CallTo(() => clock.GetCurrentInstant()) .Returns(now); - A.CallTo(() => contentRepository.QueryScheduledWithoutDataAsync(now, CancellationToken)) + A.CallTo(() => contentRepository.StreamScheduledWithoutDataAsync(now, SearchScope.All, CancellationToken)) .Returns(new[] { content1, content2 }.ToAsyncEnumerable()); await sut.PublishAsync(CancellationToken); @@ -90,7 +90,7 @@ public class ContentSchedulerProcessTests : GivenContext A.CallTo(() => clock.GetCurrentInstant()) .Returns(now); - A.CallTo(() => contentRepository.QueryScheduledWithoutDataAsync(now, CancellationToken)) + A.CallTo(() => contentRepository.StreamScheduledWithoutDataAsync(now, SearchScope.All, CancellationToken)) .Returns(new[] { content1 }.ToAsyncEnumerable()); await sut.PublishAsync(CancellationToken); @@ -114,7 +114,7 @@ public class ContentSchedulerProcessTests : GivenContext A.CallTo(() => clock.GetCurrentInstant()) .Returns(now); - A.CallTo(() => contentRepository.QueryScheduledWithoutDataAsync(now, CancellationToken)) + A.CallTo(() => contentRepository.StreamScheduledWithoutDataAsync(now, SearchScope.All, CancellationToken)) .Returns(new[] { content1 }.ToAsyncEnumerable()); A.CallTo(() => commandBus.PublishAsync(A._, default)) @@ -122,7 +122,7 @@ public class ContentSchedulerProcessTests : GivenContext await sut.PublishAsync(CancellationToken); - A.CallTo(() => contentRepository.ResetScheduledAsync(content1.UniqueId, default)) + A.CallTo(() => contentRepository.ResetScheduledAsync(content1.UniqueId, SearchScope.All, default)) .MustHaveHappened(); } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs index 0215496a4..b211754a1 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs @@ -104,7 +104,7 @@ public abstract class ContentsQueryFixtureBase : IAsyncLifetime private async Task CreateDataAsync( CancellationToken ct) { - if (await ContentRepository.StreamAll(AppIds[0].Id, null, ct).AnyAsync(ct)) + if (await ContentRepository.StreamAll(AppIds[0].Id, null, SearchScope.All, ct).AnyAsync(ct)) { return; } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTestsBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTestsBase.cs index 939e7ba4b..818e94212 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTestsBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryTestsBase.cs @@ -68,7 +68,7 @@ public abstract class ContentsQueryTestsBase { var filter = F.Eq("data.field1.iv", 12); - var contents = await _.ContentRepository.QueryIdsAsync(_.RandomAppId(), _.RandomSchemaId(), filter); + var contents = await _.ContentRepository.QueryIdsAsync(_.RandomAppId(), _.RandomSchemaId(), filter, SearchScope.All); // We have a concrete query, so we expect an actual. Assert.NotEmpty(contents); @@ -93,7 +93,7 @@ public abstract class ContentsQueryTestsBase { var time = SystemClock.Instance.GetCurrentInstant(); - var contents = await _.ContentRepository.QueryScheduledWithoutDataAsync(time).ToListAsync(); + var contents = await _.ContentRepository.StreamScheduledWithoutDataAsync(time, SearchScope.All).ToListAsync(); // The IDs are random here, as it does not really matter. Assert.NotNull(contents); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs index 6142a07eb..2a190be20 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs @@ -63,6 +63,26 @@ public class EnrichForCachingTests : GivenContext .MustHaveHappened(); } + [Fact] + public async Task Should_not_add_cache_headers_if_disabled() + { + await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), CancellationToken); + + A.CallTo(() => requestCache.AddHeader(A._)) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_not_add_cache_headers_for_contents_if_disabled() + { + var content = CreateContent(); + + await sut.EnrichAsync(ApiContext.Clone(b => b.WithoutCacheKeys()), Enumerable.Repeat(content, 1), SchemaProvider(), CancellationToken); + + A.CallTo(() => requestCache.AddHeader(A._)) + .MustNotHaveHappened(); + } + private ContentEntity CreateContent() { return new ContentEntity { AppId = AppId, Id = DomainId.NewGuid(), SchemaId = SchemaId, Version = 13 }; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs index cfa09cca0..5d6ece2e2 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs @@ -286,8 +286,10 @@ public class ResolveReferencesTests : GivenContext, IClassFixture x.ToString())))), - SchemaId = SchemaId, AppId = AppId, + SchemaId = SchemaId, + Status = Status.Draft, + StatusColor = null!, Version = 0 }; } @@ -305,8 +307,10 @@ public class ResolveReferencesTests : GivenContext, IClassFixture scriptEngine.TransformAsync(A._, A._, ScriptOptions(), A._)) .MustNotHaveHappened(); @@ -44,56 +41,100 @@ public class ScriptContentTests : GivenContext [Fact] public async Task Should_not_call_script_engine_for_frontend_user() { - var (provider, schemaId) = CreateSchema( - query: "my-query"); + SetupScript(query: "my-query"); - var content = new ContentEntity { Data = new ContentData(), SchemaId = schemaId }; + var content = CreateContent(); - await sut.EnrichAsync(FrontendContext, new[] { content }, provider, default); + await sut.EnrichAsync(FrontendContext, new[] { content }, SchemaProvider(), CancellationToken); A.CallTo(() => scriptEngine.TransformAsync(A._, A._, ScriptOptions(), A._)) .MustNotHaveHappened(); } [Fact] - public async Task Should_call_script_engine_with_data() + public async Task Should_not_call_script_engine_if_disabled_and_user_has_permission() { - var oldData = new ContentData(); + SetupScript(query: "my-query"); + + var content = CreateContent(); + + await sut.EnrichAsync(ContextWithNoScript(), new[] { content }, SchemaProvider(), CancellationToken); - var (provider, schemaId) = CreateSchema( - query: "my-query"); + A.CallTo(() => scriptEngine.TransformAsync(A._, A._, ScriptOptions(), A._)) + .MustNotHaveHappened(); + } - var content = new ContentEntity { Data = oldData, SchemaId = schemaId }; + [Fact] + public async Task Should_call_script_engine() + { + SetupScript(query: "my-query"); - A.CallTo(() => scriptEngine.TransformAsync(A._, "my-query", ScriptOptions(), A._)) - .Returns(new ContentData()); + var contentBefore = CreateContent(); + var contentData = contentBefore.Data; - await sut.EnrichAsync(ApiContext, new[] { content }, provider, default); + await sut.EnrichAsync(ApiContext, new[] { contentBefore }, SchemaProvider(), CancellationToken); - Assert.NotSame(oldData, content.Data); + Assert.NotSame(contentBefore.Data, contentData); A.CallTo(() => scriptEngine.TransformAsync( A.That.Matches(x => - Equals(x["contentId"], content.Id) && - Equals(x["data"], oldData) && + Equals(x["contentId"], contentBefore.Id) && + Equals(x["data"], contentData) && Equals(x["appId"], AppId.Id) && Equals(x["appName"], AppId.Name) && Equals(x["user"], ApiContext.UserPrincipal)), "my-query", - ScriptOptions(), A._)) + ScriptOptions(), + CancellationToken)) + .MustHaveHappened(); + } + + [Fact] + public async Task Should_call_script_engine_with_pre_query_script() + { + SetupScript(query: "my-query", queryPre: "my-pre-query"); + + var contentBefore = CreateContent(); + var contentData = contentBefore.Data; + + await sut.EnrichAsync(ApiContext, new[] { contentBefore }, SchemaProvider(), CancellationToken); + + Assert.NotSame(contentBefore.Data, contentData); + + A.CallTo(() => scriptEngine.ExecuteAsync( + A.That.Matches(x => + Equals(x.GetValue("contentId"), null) && + Equals(x["appId"], AppId.Id) && + Equals(x["appName"], AppId.Name) && + Equals(x["user"], ApiContext.UserPrincipal)), + "my-pre-query", + ScriptOptions(), + CancellationToken)) + .MustHaveHappened(); + + A.CallTo(() => scriptEngine.TransformAsync( + A.That.Matches(x => + Equals(x["contentId"], contentBefore.Id) && + Equals(x["data"], contentData) && + Equals(x["appId"], AppId.Id) && + Equals(x["appName"], AppId.Name) && + Equals(x["user"], ApiContext.UserPrincipal)), + "my-query", + ScriptOptions(), + CancellationToken)) .MustHaveHappened(); } [Fact] public async Task Should_make_test_with_pre_query_script() { - var (provider, id) = CreateSchema( + SetupScript( query: @" ctx.data.test = { iv: ctx.custom }; replace()", queryPre: "ctx.custom = 123;"); - var content = new ContentEntity { Data = new ContentData(), SchemaId = id }; + var content = CreateContent(); var realScriptEngine = new JintScriptEngine(new MemoryCache(Options.Create(new MemoryCacheOptions())), @@ -105,31 +146,43 @@ public class ScriptContentTests : GivenContext var sut2 = new ScriptContent(realScriptEngine); - await sut2.EnrichAsync(ApiContext, new[] { content }, provider, default); + await sut2.EnrichAsync(ApiContext, new[] { content }, SchemaProvider(), CancellationToken); Assert.Equal(JsonValue.Create(123), content.Data["test"]!["iv"]); } - private (ProvideSchema, NamedId) CreateSchema(string? query = null, string? queryPre = null) + private void SetupScript(string? query = null, string? queryPre = null) { - var id = NamedId.Of(DomainId.NewGuid(), "my-schema"); - - return (__ => - { - var schemaDef = - new Schema(id.Name) + A.CallTo(() => Schema.SchemaDef) + .Returns( + new Schema(SchemaId.Name) .SetScripts(new SchemaScripts { Query = query, QueryPre = queryPre - }); + })); + } - return Task.FromResult((Mocks.Schema(AppId, id, schemaDef), ResolvedComponents.Empty)); - }, id); + private ContentEntity CreateContent() + { + return new ContentEntity { Data = new ContentData(), SchemaId = SchemaId }; + } + + private ProvideSchema SchemaProvider() + { + return x => Task.FromResult((Schema, ResolvedComponents.Empty)); } private static ScriptOptions ScriptOptions() { return A.That.Matches(x => x.AsContext); } + + private Context ContextWithNoScript() + { + var contextPermission = PermissionIds.ForApp(PermissionIds.AppNoScripting, App.Name).Id; + var contextInstance = CreateContext(false, contextPermission).Clone(b => b.WithoutScripting()); + + return contextInstance; + } } diff --git a/backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs index 195dc9f2c..ec15cfbc6 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Commands/DefaultDomainObjectCacheTests.cs @@ -39,7 +39,7 @@ public class DefaultDomainObjectCacheTests A.CallTo(() => cache.CreateEntry($"{id}_10")) .MustHaveHappened(); - A.CallTo(() => serializer.Serialize(20, A._, true)) + A.CallTo(() => serializer.Serialize(20, A._, false)) .MustHaveHappened(); A.CallTo(() => distributedCache.SetAsync($"{id}_10", A._, A._, ct)) @@ -72,7 +72,7 @@ public class DefaultDomainObjectCacheTests [Fact] public async Task Should_provide_from_distributed_cache_if_not_found_in_cache() { - A.CallTo(() => serializer.Deserialize(A._, null, false)) + A.CallTo(() => serializer.Deserialize(A._, null)) .Returns(20); var actual = await sut.GetAsync(id, 10, ct); diff --git a/frontend/src/app/features/content/pages/content/editor/content-field.component.html b/frontend/src/app/features/content/pages/content/editor/content-field.component.html index b81e85421..7c9e52f2f 100644 --- a/frontend/src/app/features/content/pages/content/editor/content-field.component.html +++ b/frontend/src/app/features/content/pages/content/editor/content-field.component.html @@ -4,7 +4,7 @@
- diff --git a/frontend/src/app/features/content/pages/content/editor/field-languages.component.html b/frontend/src/app/features/content/pages/content/editor/field-languages.component.html index 23b395b6a..7a00d1b2e 100644 --- a/frontend/src/app/features/content/pages/content/editor/field-languages.component.html +++ b/frontend/src/app/features/content/pages/content/editor/field-languages.component.html @@ -1,5 +1,5 @@ -
diff --git a/frontend/src/app/framework/angular/pager.component.html b/frontend/src/app/framework/angular/pager.component.html index 37b92cfb4..4ade8bf26 100644 --- a/frontend/src/app/framework/angular/pager.component.html +++ b/frontend/src/app/framework/angular/pager.component.html @@ -5,6 +5,11 @@ + +   + + +