Browse Source

Performance improvements (#1330)

* Security fixes.

* Fix build.

* Fix build.

* UI fixes.

* Fix asset tags.

* Info the save the query.

* Fix first batches.

* More improvements

* More fixes

* Improve cache keys

* More fixes

* More fixes

* Fix

* Fixes

* Fixes

* More fixes

* Fix issues

* More progress

* Temp

* Update packages.

* Fix formatting.

* Fix db context configuration.
master
Sebastian Stehle 2 weeks ago
committed by GitHub
parent
commit
bdff5054bb
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 55
      CLAUDE.md
  2. 8
      backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs
  3. 12
      backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs
  4. 14
      backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj
  5. 2
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Assets/MongoAssetRepository.cs
  6. 34
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/CollectionProvider.cs
  7. 2
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/MongoContentRepository_SnapshotStore.cs
  8. 2
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Operations/QueryByIds.cs
  9. 17
      backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Operations/QueryByQuery.cs
  10. 7
      backend/src/Squidex.Data.MongoDb/Infrastructure/Queries/LimitExtensions.cs
  11. 12
      backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj
  12. 29
      backend/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs
  13. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  14. 39
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs
  15. 9
      backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/UpdateValues.cs
  16. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs
  17. 23
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs
  18. 34
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs
  19. 119
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs
  20. 59
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringAsyncJintExtension.cs
  21. 33
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IAsyncScript.cs
  22. 6
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs
  23. 30
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScript.cs
  24. 57
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs
  25. 171
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/AsyncScriptExecutionContext.cs
  26. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/CacheParser.cs
  27. 34
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/EnumToStringConverter.cs
  28. 67
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs
  29. 366
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScript.cs
  30. 192
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs
  31. 161
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs
  32. 6
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  33. 39
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/PatternValidator.cs
  34. 40
      backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs
  35. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs
  36. 232
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs
  37. 63
      backend/src/Squidex.Domain.Apps.Entities/Assets/DefaultAssetFileStore.cs
  38. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetsBulkUpdateCommandMiddleware.cs
  39. 26
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/CalculateTokens.cs
  40. 35
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs
  41. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/Transformations.cs
  42. 13
      backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs
  43. 64
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsJintExtension.cs
  44. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsOptions.cs
  45. 74
      backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs
  46. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentsBulkUpdateCommandMiddleware.cs
  47. 28
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/WorkflowExtensions.cs
  48. 143
      backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  49. 44
      backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflows.cs
  50. 12
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs
  51. 45
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  52. 19
      backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  53. 17
      backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflows.cs
  54. 10
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs
  55. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  56. 13
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculatePreviewText.cs
  57. 31
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculateTokens.cs
  58. 8
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs
  59. 58
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs
  60. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs
  61. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs
  62. 35
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs
  63. 99
      backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs
  64. 38
      backend/src/Squidex.Domain.Apps.Entities/Contents/WorkflowDefinition.cs
  65. 50
      backend/src/Squidex.Domain.Apps.Entities/Context.cs
  66. 11
      backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs
  67. 3
      backend/src/Squidex.Domain.Apps.Entities/IContextProvider.cs
  68. 31
      backend/src/Squidex.Domain.Apps.Entities/Jobs/JobWorker.cs
  69. 27
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  70. 24
      backend/src/Squidex.Infrastructure/Log/BackgroundRequestLogStore.cs
  71. 4
      backend/src/Squidex.Infrastructure/Log/RequestLogStoreOptions.cs
  72. 3
      backend/src/Squidex.Infrastructure/LogMessages.cs
  73. 10
      backend/src/Squidex.Infrastructure/Security/Extensions.cs
  74. 14
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  75. 11
      backend/src/Squidex.Infrastructure/Translations/ResourcesLocalizer.cs
  76. 5
      backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs
  77. 11
      backend/src/Squidex.Web/ContextProvider.cs
  78. 34
      backend/src/Squidex.Web/Pipeline/CachingManager.cs
  79. 10
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  80. 6
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs
  81. 18
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  82. 14
      backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/QueryModelDto.cs
  83. 4
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  84. 12
      backend/src/Squidex/Areas/IdentityServer/Config/Dynamic/DynamicSchemeProvider.cs
  85. 4
      backend/src/Squidex/Config/Domain/ContentsServices.cs
  86. 22
      backend/src/Squidex/Squidex.csproj
  87. 11
      backend/src/Squidex/appsettings.json
  88. 2
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs
  89. 2
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs
  90. 2
      backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs
  91. 113
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/ContentConversionRemovalTests.cs
  92. 24
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs
  93. 739
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
  94. 19
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/MockupHttpHandler.cs
  95. 3
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppCommandMiddlewareTests.cs
  96. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs
  97. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DefaultAssetFileStoreTests.cs
  98. 5
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetsBulkUpdateCommandMiddlewareTests.cs
  99. 21
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/CalculateTokensTests.cs
  100. 24
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs

55
CLAUDE.md

@ -0,0 +1,55 @@
# Squidex
Headless CMS. Angular frontend in `frontend/`, ASP.NET Core backend in `backend/`.
## Frontend
- Angular app in `frontend/`, source under `src/app`:
- `framework/` — generic, reusable UI components and utilities (no domain knowledge).
- `shared/` — Squidex-specific services, state stores and components.
- `features/` — the actual screens (apps, assets, content, rules, schemas, settings, teams, ...).
- `shell/` — app frame, navigation, layout.
- State is handled with the state store pattern from `framework/state.ts` (immutable value objects + `State<T>` subclasses), not with a third-party store library.
- Commands:
```bash
npm start
```
```bash
npm test
```
```bash
npm run lint
```
### Best Practices
- i18n texts live in `backend/i18n`, translations are generated into the frontend — do not edit generated translation files by hand.
- Do not write JsDoc comments.
## Backend
- .NET solution `backend/Squidex.slnx`. Projects under `backend/src`, tests under `backend/tests`, optional integrations under `backend/extensions`.
- Layering: `Squidex.Infrastructure` (generic building blocks) → `Squidex.Domain.Apps.*` (core model, operations, events, entities) → `Squidex.Web` / `Squidex` (API host).
- Event-sourced domain: aggregates emit events from `Squidex.Domain.Apps.Events`, state is projected into MongoDB or EF Core (`Squidex.Data.MongoDb`, `Squidex.Data.EntityFramework`).
- Run tests with the filter below — some tests need external setup (real databases, Docker/Testcontainers) and will fail without it:
### Tests
Some tests need test setup or test containers which are slow. Run the tests like this to skip these tests.
```bash
dotnet test --filter "Category!=Dependencies & Category!=TestContainer"
```
### Best Practices
- Code style is enforced by StyleCop (`backend/stylecop.json`) and `.editorconfig` — follow the surrounding file's conventions.
- Do not write XML comments.
## Shared best practices
- Do write precise short comments and only when needed.
- Do not comment a class or a method, only put comments inside functions or above variables.

8
backend/src/Squidex.Data.EntityFramework/Infrastructure/Extensions.cs

@ -54,9 +54,15 @@ public static class Extensions
return options.GetExtension<PrefixExtension>().Prefix; return options.GetExtension<PrefixExtension>().Prefix;
} }
public static DbContextOptionsBuilder SetDefaultWarnings(this DbContextOptionsBuilder builder) public static DbContextOptionsBuilder SetDefaults(this DbContextOptionsBuilder builder)
{ {
builder.ConfigureWarnings(w => w.Ignore(CoreEventId.CollectionWithoutComparer)); builder.ConfigureWarnings(w => w.Ignore(CoreEventId.CollectionWithoutComparer));
// Almost everything is read only or written by inserting new entities, so tracking would
// only cost a snapshot of every entity that is read. The few stores that update an entity
// they have queried ask for it with AsTracking. This cannot be done in OnConfiguring,
// because the contexts are pooled and pooling forbids to modify the options there.
builder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
return builder; return builder;
} }

12
backend/src/Squidex.Data.EntityFramework/ServiceExtensions.cs

@ -82,7 +82,7 @@ public static class ServiceExtensions
services.AddPooledDbContextFactory<MySqlAppDbContext>(builder => services.AddPooledDbContextFactory<MySqlAppDbContext>(builder =>
{ {
builder.SetDefaultWarnings(); builder.SetDefaults();
builder.UseMySql(connectionString, version, options => builder.UseMySql(connectionString, version, options =>
{ {
options.UseNetTopologySuite(); options.UseNetTopologySuite();
@ -93,7 +93,7 @@ public static class ServiceExtensions
services.AddNamedDbContext<MySqlContentDbContext>((builder, name) => services.AddNamedDbContext<MySqlContentDbContext>((builder, name) =>
{ {
builder.SetDefaultWarnings(); builder.SetDefaults();
builder.UseBulkInsertMySql(); builder.UseBulkInsertMySql();
builder.UseMySql(connectionString, version, options => builder.UseMySql(connectionString, version, options =>
{ {
@ -118,7 +118,7 @@ public static class ServiceExtensions
{ {
services.AddPooledDbContextFactory<PostgresAppDbContext>(builder => services.AddPooledDbContextFactory<PostgresAppDbContext>(builder =>
{ {
builder.SetDefaultWarnings(); builder.SetDefaults();
builder.UseBulkInsertPostgreSql(); builder.UseBulkInsertPostgreSql();
builder.UseNpgsql(connectionString, options => builder.UseNpgsql(connectionString, options =>
{ {
@ -128,7 +128,7 @@ public static class ServiceExtensions
services.AddNamedDbContext<PostgresContentDbContext>((builder, name) => services.AddNamedDbContext<PostgresContentDbContext>((builder, name) =>
{ {
builder.SetDefaultWarnings(); builder.SetDefaults();
builder.UseBulkInsertPostgreSql(); builder.UseBulkInsertPostgreSql();
builder.UseNpgsql(connectionString, options => builder.UseNpgsql(connectionString, options =>
{ {
@ -152,7 +152,7 @@ public static class ServiceExtensions
{ {
services.AddPooledDbContextFactory<SqlServerAppDbContext>(builder => services.AddPooledDbContextFactory<SqlServerAppDbContext>(builder =>
{ {
builder.SetDefaultWarnings(); builder.SetDefaults();
builder.UseSqlServer(connectionString, options => builder.UseSqlServer(connectionString, options =>
{ {
options.UseNetTopologySuite(); options.UseNetTopologySuite();
@ -162,7 +162,7 @@ public static class ServiceExtensions
services.AddNamedDbContext<SqlServerContentDbContext>((builder, name) => services.AddNamedDbContext<SqlServerContentDbContext>((builder, name) =>
{ {
builder.SetDefaultWarnings(); builder.SetDefaults();
builder.UseBulkInsertSqlServer(); builder.UseBulkInsertSqlServer();
builder.UseSqlServer(connectionString, options => builder.UseSqlServer(connectionString, options =>
{ {

14
backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj

@ -43,13 +43,13 @@
<PackageReference Include="Microting.EntityFrameworkCore.MySql.Json.Microsoft" Version="10.0.6" /> <PackageReference Include="Microting.EntityFrameworkCore.MySql.Json.Microsoft" Version="10.0.6" />
<PackageReference Include="Microting.EntityFrameworkCore.MySql.NetTopologySuite" Version="10.0.6" /> <PackageReference Include="Microting.EntityFrameworkCore.MySql.NetTopologySuite" Version="10.0.6" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.EntityFramework" Version="8.0.3" /> <PackageReference Include="Squidex.AI.EntityFramework" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.EntityFramework" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.EntityFramework" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.4" />
<PackageReference Include="Squidex.Events.EntityFramework" Version="8.0.3" /> <PackageReference Include="Squidex.Events.EntityFramework" Version="8.0.4" />
<PackageReference Include="Squidex.Flows.EntityFramework" Version="8.0.3" /> <PackageReference Include="Squidex.Flows.EntityFramework" Version="8.0.4" />
<PackageReference Include="Squidex.Hosting" Version="8.0.3" /> <PackageReference Include="Squidex.Hosting" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging.EntityFramework" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.EntityFramework" Version="8.0.4" />
<PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="7.2.1" /> <PackageReference Include="Squidex.OpenIdDict.EntityFramework" Version="7.2.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.6.2" /> <PackageReference Include="System.ValueTuple" Version="4.6.2" />

2
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Assets/MongoAssetRepository.cs

@ -109,7 +109,7 @@ public sealed partial class MongoAssetRepository : MongoRepositoryBase<MongoAsse
{ {
assetTotal = -1; assetTotal = -1;
} }
else else if (query.NeedsTotalById(q.Ids.Count))
{ {
assetTotal = await Collection.Find(filter).CountDocumentsAsync(ct); assetTotal = await Collection.Find(filter).CountDocumentsAsync(ct);
} }

34
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/CollectionProvider.cs

@ -13,15 +13,39 @@ namespace Squidex.Domain.Apps.Entities.Contents;
internal class CollectionProvider(IMongoClient mongoClient, string prefixDatabase, string prefixCollection) internal class CollectionProvider(IMongoClient mongoClient, string prefixDatabase, string prefixCollection)
{ {
private readonly ConcurrentDictionary<(DomainId, DomainId), Task<IMongoCollection<MongoContentEntity>>> collections = private readonly ConcurrentDictionary<(DomainId AppId, DomainId SchemaId), Lazy<Task<IMongoCollection<MongoContentEntity>>>> collections =
new ConcurrentDictionary<(DomainId, DomainId), Task<IMongoCollection<MongoContentEntity>>>(); new ConcurrentDictionary<(DomainId AppId, DomainId SchemaId), Lazy<Task<IMongoCollection<MongoContentEntity>>>>();
public Task<IMongoCollection<MongoContentEntity>> GetCollectionAsync(DomainId appId, DomainId schemaId) public async Task<IMongoCollection<MongoContentEntity>> GetCollectionAsync(DomainId appId, DomainId schemaId)
{ {
return collections.GetOrAdd((appId, schemaId), CreateCollectionAsync); var key = (appId, schemaId);
// The lazy ensures that the indexes are only created once, even when the same collection is
// requested concurrently. GetOrAdd alone can run the factory several times for one key.
var collection = collections.GetOrAdd(key, CreateLazyCollection);
try
{
return await collection.Value;
}
catch
{
// A failed attempt must not stay in the cache. Creating the indexes can fail for a
// transient reason and the collection would be unusable until the process is restarted.
// Only remove our own entry, so that a newer successful one is not thrown away.
collections.TryRemove(new KeyValuePair<(DomainId AppId, DomainId SchemaId), Lazy<Task<IMongoCollection<MongoContentEntity>>>>(key, collection));
throw;
}
}
private Lazy<Task<IMongoCollection<MongoContentEntity>>> CreateLazyCollection((DomainId AppId, DomainId SchemaId) key)
{
return new Lazy<Task<IMongoCollection<MongoContentEntity>>>(
() => CreateCollectionAsync(key),
LazyThreadSafetyMode.ExecutionAndPublication);
} }
private async Task<IMongoCollection<MongoContentEntity>> CreateCollectionAsync((DomainId, DomainId) key) private async Task<IMongoCollection<MongoContentEntity>> CreateCollectionAsync((DomainId AppId, DomainId SchemaId) key)
{ {
var (appId, schemaId) = key; var (appId, schemaId) = key;

2
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/MongoContentRepository_SnapshotStore.cs

@ -135,7 +135,7 @@ public partial class MongoContentRepository : ISnapshotStore<WriteContent>, IDel
collectionUpdates.GetOrAddNew(collection).Add(entity); collectionUpdates.GetOrAddNew(collection).Add(entity);
}); });
foreach (var job in jobs) foreach (var job in validJobs)
{ {
if (job.Value.ShouldWritePublished()) if (job.Value.ShouldWritePublished())
{ {

2
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Operations/QueryByIds.cs

@ -56,7 +56,7 @@ internal sealed class QueryByIds : OperationBase
{ {
contentTotal = -1; contentTotal = -1;
} }
else else if (query.NeedsTotalById(q.Ids.Count))
{ {
contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct); contentTotal = await Collection.Find(filter).CountDocumentsAsync(ct);
} }

17
backend/src/Squidex.Data.MongoDb/Domain/Apps/Entities/Contents/Operations/QueryByQuery.cs

@ -64,6 +64,13 @@ internal sealed class QueryByQuery(MongoCountCollection countCollection) : Opera
{ {
contentTotal = -1; contentTotal = -1;
} }
else if (isDefault)
{
// Cache total count by app and schemas because no other filters are applied (aka default).
var totalKey = CreateTotalKey(app, schemas);
contentTotal = await countCollection.GetOrAddAsync(totalKey, ct => Collection.Find(filter).CountDocumentsAsync(ct), ct);
}
else if (query.IsSatisfiedByIndex()) else if (query.IsSatisfiedByIndex())
{ {
// It is faster to filter with sorting when there is an index, because it forces the index to be used. // It is faster to filter with sorting when there is an index, because it forces the index to be used.
@ -78,6 +85,16 @@ internal sealed class QueryByQuery(MongoCountCollection countCollection) : Opera
return ResultList.Create<Content>(contentTotal, contentEntities); return ResultList.Create<Content>(contentTotal, contentEntities);
} }
private static string CreateTotalKey(App app, List<Schema> schemas)
{
// The schemas depend on the permissions of the user and are not in a stable order, so the ids
// are sorted. They are also hashed, because the key is the ID of the count document and there
// can be enough schemas to exceed the maximum key size of MongoDB.
var schemaIds = schemas.Select(x => x.Id.ToString()).Order(StringComparer.Ordinal);
return $"{app.Id}_Schemas_{string.Join('_', schemaIds).ToSha256Base64()}";
}
public async Task<IResultList<Content>> QueryAsync(Schema schema, Q q, public async Task<IResultList<Content>> QueryAsync(Schema schema, Q q,
CancellationToken ct) CancellationToken ct)
{ {

7
backend/src/Squidex.Data.MongoDb/Infrastructure/Queries/LimitExtensions.cs

@ -11,6 +11,13 @@ namespace Squidex.Infrastructure.Queries;
public static class LimitExtensions public static class LimitExtensions
{ {
public static bool NeedsTotalById(this ClrQuery query, int idCount)
{
// A query by ID can never match more documents than the number of requested IDs, so the result
// already contains all of them unless skip, take or the random selection could have cut it off.
return query.Skip > 0 || query.Take < idCount || query.Random > 0;
}
public static IAggregateFluent<T> QueryLimit<T>(this IAggregateFluent<T> cursor, ClrQuery query) public static IAggregateFluent<T> QueryLimit<T>(this IAggregateFluent<T> cursor, ClrQuery query)
{ {
if (query.Take < long.MaxValue) if (query.Take < long.MaxValue)

12
backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj

@ -25,12 +25,12 @@
<PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="3.0.0" /> <PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="3.0.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI.Mongo" Version="8.0.3" /> <PackageReference Include="Squidex.AI.Mongo" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.Mongo" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.Mongo" Version="8.0.4" />
<PackageReference Include="Squidex.Events.Mongo" Version="8.0.3" /> <PackageReference Include="Squidex.Events.Mongo" Version="8.0.4" />
<PackageReference Include="Squidex.Flows.Mongo" Version="8.0.3" /> <PackageReference Include="Squidex.Flows.Mongo" Version="8.0.4" />
<PackageReference Include="Squidex.Hosting" Version="8.0.3" /> <PackageReference Include="Squidex.Hosting" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging.Mongo" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.Mongo" Version="8.0.4" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="7.2.1" /> <PackageReference Include="Squidex.OpenIddict.MongoDb" Version="7.2.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.6.2" /> <PackageReference Include="System.ValueTuple" Version="4.6.2" />

29
backend/src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Diagnostics.Contracts; using System.Diagnostics.Contracts;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -17,6 +18,8 @@ namespace Squidex.Domain.Apps.Core.Apps;
public sealed class Roles public sealed class Roles
{ {
private const int MaxResolved = 1000;
private readonly ConcurrentDictionary<(string App, string Name, bool IsFrontend), Role?> resolved = new ConcurrentDictionary<(string, string, bool), Role?>();
private readonly ReadonlyDictionary<string, Role> inner; private readonly ReadonlyDictionary<string, Role> inner;
public static readonly IReadOnlyDictionary<string, Role> Defaults = new Dictionary<string, Role> public static readonly IReadOnlyDictionary<string, Role> Defaults = new Dictionary<string, Role>
@ -159,18 +162,34 @@ public sealed class Roles
{ {
Guard.NotNull(app); Guard.NotNull(app);
value = null!; // Resolving a role builds a permission for every permission of the role, but the result only
// depends on the key and the roles are immutable, so it is only done once. This is called for
// every request.
value = resolved.GetOrAdd((app, name, isFrontend), static (key, self) => self.Resolve(key.App, key.Name, key.IsFrontend), this)!;
return value != null;
}
private Role? Resolve(string app, string name, bool isFrontend)
{
// Apps without custom roles share the same empty instance, so this cache is not bound to a
// single app and could grow with the number of apps. Start over when it gets too large.
if (resolved.Count >= MaxResolved)
{
resolved.Clear();
}
if (Defaults.TryGetValue(name, out var role)) if (Defaults.TryGetValue(name, out var role))
{ {
value = role.ForApp(app, isFrontend && name != Role.Owner); return role.ForApp(app, isFrontend && name != Role.Owner);
} }
else if (inner.TryGetValue(name, out role))
if (inner.TryGetValue(name, out role))
{ {
value = role.ForApp(app, isFrontend); return role.ForApp(app, isFrontend);
} }
return value != null; return null;
} }
private static string WithoutPrefix(string permission) private static string WithoutPrefix(string permission)

2
backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -20,7 +20,7 @@
<PackageReference Include="NetTopologySuite" Version="2.6.0" /> <PackageReference Include="NetTopologySuite" Version="2.6.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Flows" Version="8.0.3" /> <PackageReference Include="Squidex.Flows" Version="8.0.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" /> <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
</ItemGroup> </ItemGroup>

39
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/ContentConverter.cs

@ -149,22 +149,27 @@ public sealed class ContentConverter(ResolvedComponents components, Schema schem
return (true, default); return (true, default);
} }
for (int i = 0; i < array.Count; i++) // Compact in place instead of removing items, because every remove moves all the items
// after it and dropping many items from a large array would be quadratic.
var target = 0;
for (var i = 0; i < array.Count; i++)
{ {
var oldValue = array[i]; var oldValue = array[i];
var (removed, newValue) = ConvertArrayItem(field, oldValue); var (removed, newValue) = ConvertArrayItem(field, oldValue);
if (removed) if (removed)
{ {
array.RemoveAt(i); continue;
i--;
}
else if (!ReferenceEquals(newValue.Value, oldValue.Value))
{
array[i] = newValue;
} }
// Faster to check for reference equality than for deep equals.
array[target] = ReferenceEquals(newValue.Value, oldValue.Value) ? oldValue : newValue;
target++;
} }
array.RemoveRange(target, array.Count - target);
return (false, array); return (false, array);
} }
@ -175,23 +180,27 @@ public sealed class ContentConverter(ResolvedComponents components, Schema schem
return (true, default); return (true, default);
} }
for (int i = 0; i < array.Count; i++) // Compact in place instead of removing items, because every remove moves all the items
// after it and dropping many items from a large array would be quadratic.
var target = 0;
for (var i = 0; i < array.Count; i++)
{ {
var oldValue = array[i]; var oldValue = array[i];
var (removed, newValue) = ConvertComponent(oldValue, parent); var (removed, newValue) = ConvertComponent(oldValue, parent);
if (removed) if (removed)
{ {
array.RemoveAt(i); continue;
i--;
}
else if (!ReferenceEquals(newValue.Value, oldValue.Value))
{
// Faster to check for reference equality than for deep equals.
array[i] = newValue;
} }
// Faster to check for reference equality than for deep equals.
array[target] = ReferenceEquals(newValue.Value, oldValue.Value) ? oldValue : newValue;
target++;
} }
array.RemoveRange(target, array.Count - target);
return (false, array); return (false, array);
} }

9
backend/src/Squidex.Domain.Apps.Core.Operations/ConvertContent/UpdateValues.cs

@ -14,6 +14,7 @@ namespace Squidex.Domain.Apps.Core.ConvertContent;
public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptEngine, bool canUnset) : IContentValueConverter, IContentDataConverter public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptEngine, bool canUnset) : IContentValueConverter, IContentDataConverter
{ {
private static readonly ScriptOptions Options = new ScriptOptions { Readonly = true };
private ScriptVars? vars; private ScriptVars? vars;
public void ConvertDataBefore(Schema schema, ContentData source) public void ConvertDataBefore(Schema schema, ContentData source)
@ -32,8 +33,6 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE
if (Updates.IsUpdate(value, out var expression)) if (Updates.IsUpdate(value, out var expression))
{ {
var options = new ScriptOptions { Readonly = true };
// Reuse the vars to save allocations. // Reuse the vars to save allocations.
vars ??= new ScriptVars vars ??= new ScriptVars
{ {
@ -44,7 +43,7 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE
vars["$self"] = value; vars["$self"] = value;
// Put the expression in brackets to return an object directly. // Put the expression in brackets to return an object directly.
var result = scriptEngine.Execute(vars, $"({expression})", options); var result = scriptEngine.Execute(vars, $"({expression})", Options);
if (result.Value is JsonObject obj) if (result.Value is JsonObject obj)
{ {
@ -93,8 +92,6 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE
return (false, source); return (false, source);
} }
var options = new ScriptOptions { Readonly = true };
// Reuse the vars to save allocations. // Reuse the vars to save allocations.
vars ??= new ScriptVars vars ??= new ScriptVars
{ {
@ -105,7 +102,7 @@ public sealed class UpdateValues(ContentData existingData, IScriptEngine scriptE
vars["$self"] = obj; vars["$self"] = obj;
// Put the expression in brackets to return an object directly. // Put the expression in brackets to return an object directly.
var result = scriptEngine.Execute(vars, $"({expression})", options); var result = scriptEngine.Execute(vars, $"({expression})", Options);
return (false, result); return (false, result);
} }

2
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs

@ -43,7 +43,7 @@ public sealed class EventEnricher(IMemoryCache userCache, IUserResolver userReso
private Task<IUser?> FindUserAsync(RefToken actor) private Task<IUser?> FindUserAsync(RefToken actor)
{ {
var cacheKey = $"{typeof(EventEnricher)}_Users_{actor.Identifier}"; var cacheKey = (typeof(EventEnricher), actor.Identifier);
return userCache.GetOrCreateAsync(cacheKey, async x => return userCache.GetOrCreateAsync(cacheKey, async x =>
{ {

23
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventJintExtension.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Jint;
using Jint.Native; using Jint.Native;
using Squidex.Domain.Apps.Core.Properties; using Squidex.Domain.Apps.Core.Properties;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
@ -65,13 +66,13 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens
} }
} }
public void Extend(ScriptExecutionContext context) public void Extend(Engine engine)
{ {
context.Engine.SetValue("console", FlowConsoleWrapper.Instance); engine.SetValue("console", FlowConsoleWrapper.Instance);
context.Engine.SetValue("contentAction", new EventDelegate(() => engine.SetValue("contentAction", new EventDelegate(() =>
{ {
if (context.TryGetValue("event", out var temp) && temp is EnrichedContentEvent contentEvent) if (engine.TryGetVar<EnrichedContentEvent>("event", out var contentEvent))
{ {
return contentEvent.Status.ToString(); return contentEvent.Status.ToString();
} }
@ -79,9 +80,9 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens
return JsValue.Null; return JsValue.Null;
})); }));
context.Engine.SetValue("contentUrl", new EventDelegate(() => engine.SetValue("contentUrl", new EventDelegate(() =>
{ {
if (context.TryGetValue("event", out var temp) && temp is EnrichedContentEvent contentEvent) if (engine.TryGetVar<EnrichedContentEvent>("event", out var contentEvent))
{ {
return urlGenerator.ContentUI(contentEvent.AppId, contentEvent.SchemaId, contentEvent.Id); return urlGenerator.ContentUI(contentEvent.AppId, contentEvent.SchemaId, contentEvent.Id);
} }
@ -89,9 +90,9 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens
return JsValue.Null; return JsValue.Null;
})); }));
context.Engine.SetValue("assetContentSlugUrl", new EventDelegate(() => engine.SetValue("assetContentSlugUrl", new EventDelegate(() =>
{ {
if (context.TryGetValue("event", out var temp) && temp is EnrichedAssetEvent assetEvent) if (engine.TryGetVar<EnrichedAssetEvent>("event", out var assetEvent))
{ {
return urlGenerator.AssetContent(assetEvent.AppId, assetEvent.FileName.Slugify()); return urlGenerator.AssetContent(assetEvent.AppId, assetEvent.FileName.Slugify());
} }
@ -101,7 +102,7 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens
var assetUrl = new EventDelegate(() => var assetUrl = new EventDelegate(() =>
{ {
if (context.TryGetValue("event", out var temp) && temp is EnrichedAssetEvent assetEvent) if (engine.TryGetVar<EnrichedAssetEvent>("event", out var assetEvent))
{ {
return urlGenerator.AssetContent(assetEvent.AppId, assetEvent.Id.ToString()); return urlGenerator.AssetContent(assetEvent.AppId, assetEvent.Id.ToString());
} }
@ -109,8 +110,8 @@ public sealed class EventJintExtension(IUrlGenerator urlGenerator) : IJintExtens
return JsValue.Null; return JsValue.Null;
}); });
context.Engine.SetValue("assetContentUrl", assetUrl); engine.SetValue("assetContentUrl", assetUrl);
context.Engine.SetValue("assetContentAppUrl", assetUrl); engine.SetValue("assetContentAppUrl", assetUrl);
} }
public void Describe(AddDescription describe, ScriptScope scope) public void Describe(AddDescription describe, ScriptScope scope)

34
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EngineExtensions.cs

@ -0,0 +1,34 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Diagnostics.CodeAnalysis;
using Jint;
namespace Squidex.Domain.Apps.Core.Scripting;
public static class EngineExtensions
{
public static ScriptExecutionContext GetContext(this Engine engine)
{
return ScriptExecutionContext.GetContext(engine);
}
public static bool TryGetVar<T>(this Engine engine, string key, [MaybeNullWhen(false)] out T value)
{
return ScriptExecutionContext.GetContext(engine).TryGetValueIfExists(key, out value);
}
public static void Schedule(this Engine engine, Func<CancellationToken, Task> action)
{
ScriptExecutionContext.GetContext(engine).Schedule(action);
}
public static void Schedule<TResult>(this Engine engine, Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback)
{
ScriptExecutionContext.GetContext(engine).Schedule(action, callback);
}
}

119
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/HttpJintExtension.cs

@ -21,48 +21,48 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ
private delegate void HttpJsonWithBodyDelegate(string url, JsValue body, Action<JsValue> callback, JsValue? headers = null, bool ignoreError = false); private delegate void HttpJsonWithBodyDelegate(string url, JsValue body, Action<JsValue> callback, JsValue? headers = null, bool ignoreError = false);
private delegate void HttpRequestDelegate(JsValue requestInit, Action<JsValue> callback); private delegate void HttpRequestDelegate(JsValue requestInit, Action<JsValue> callback);
public void ExtendAsync(ScriptExecutionContext context) public void ExtendAsync(Engine engine)
{ {
AddBodyMethod(context, HttpMethod.Patch, "patchJSON"); AddBodyMethod(engine, HttpMethod.Patch, "patchJSON");
AddBodyMethod(context, HttpMethod.Post, "postJSON"); AddBodyMethod(engine, HttpMethod.Post, "postJSON");
AddBodyMethod(context, HttpMethod.Put, "putJSON"); AddBodyMethod(engine, HttpMethod.Put, "putJSON");
AddMethod(context, HttpMethod.Delete, "deleteJSON"); AddMethod(engine, HttpMethod.Delete, "deleteJSON");
AddMethod(context, HttpMethod.Get, "getJSON"); AddMethod(engine, HttpMethod.Get, "getJSON");
AddMethod(context, "request"); AddMethod(engine, "request");
} }
private void AddMethod(ScriptExecutionContext context, string name) private void AddMethod(Engine engine, string name)
{ {
var action = new HttpRequestDelegate((requestInit, callback) => var action = new HttpRequestDelegate((requestInit, callback) =>
{ {
var httpRequest = ParseRequestInit(requestInit); var (url, method, headers, body) = ParseRequestInit(requestInit);
Request(context, httpRequest.Method, httpRequest.Url, httpRequest.Body, callback, httpRequest.Headers, true, true); Request(engine, method, url, body, callback, headers, true, true);
}); });
context.Engine.SetValue(name, action); engine.SetValue(name, action);
} }
private void AddMethod(ScriptExecutionContext context, HttpMethod method, string name) private void AddMethod(Engine engine, HttpMethod method, string name)
{ {
var action = new HttpJsonDelegate((url, callback, headers, ignoreError) => var action = new HttpJsonDelegate((url, callback, headers, ignoreError) =>
{ {
Request(context, method, url, null, callback, headers, ignoreError); Request(engine, method, url, null, callback, headers, ignoreError);
}); });
context.Engine.SetValue(name, action); engine.SetValue(name, action);
} }
private void AddBodyMethod(ScriptExecutionContext context, HttpMethod method, string name) private void AddBodyMethod(Engine engine, HttpMethod method, string name)
{ {
var action = new HttpJsonWithBodyDelegate((url, body, callback, headers, ignoreError) => var action = new HttpJsonWithBodyDelegate((url, body, callback, headers, ignoreError) =>
{ {
Request(context, method, url, body, callback, headers, ignoreError); Request(engine, method, url, body, callback, headers, ignoreError);
}); });
context.Engine.SetValue(name, action); engine.SetValue(name, action);
} }
private void Request(ScriptExecutionContext context, HttpMethod method, string url, JsValue? body, Action<JsValue> callback, JsValue? headers, bool ignoreError, bool forceRawResponse = false) private void Request(Engine engine, HttpMethod method, string url, JsValue? body, Action<JsValue> callback, JsValue? headers, bool ignoreError, bool forceRawResponse = false)
{ {
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
{ {
@ -74,53 +74,67 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ
throw new JavaScriptException("Callback is not defined."); throw new JavaScriptException("Callback is not defined.");
} }
context.Schedule(async (scheduler, ct) => // The request reads javascript values and is therefore created while we are still inside the engine.
var request = CreateRequest(engine, method, uri, body, headers);
engine.Schedule(async ct =>
{ {
try try
{ {
var httpClient = httpClientFactory.CreateClient("Jint"); using (request)
var request = CreateRequest(context, method, uri, body, headers);
var response = await httpClient.SendAsync(request, ct);
if (!ignoreError)
{ {
response.EnsureSuccessStatusCode(); var httpClient = httpClientFactory.CreateClient("Jint");
}
JsValue responseObject; using var response = await httpClient.SendAsync(request, ct);
var responseString = await response.Content.ReadAsStringAsync(ct); if (!ignoreError)
if (ignoreError && (forceRawResponse || !response.IsSuccessStatusCode || string.IsNullOrEmpty(responseString)))
{
responseObject = JsValue.FromObject(context.Engine, new Dictionary<string, object?>
{ {
["statusCode"] = (int)response.StatusCode, response.EnsureSuccessStatusCode();
["headers"] = }
var responseString = await response.Content.ReadAsStringAsync(ct);
return (
StatusCode: (int)response.StatusCode,
Headers:
response.Content.Headers response.Content.Headers
.Concat(response.Headers) .Concat(response.Headers)
.Concat(response.TrailingHeaders) .Concat(response.TrailingHeaders)
.GroupBy(x => x.Key) .GroupBy(x => x.Key)
.ToDictionary(x => x.Key, x => x.Last().Value.First()), .ToDictionary(x => x.Key, x => x.Last().Value.First()),
["body"] = responseString, Body: responseString,
}); IsRaw: ignoreError && (forceRawResponse || !response.IsSuccessStatusCode || string.IsNullOrEmpty(responseString))
} );
else
{
responseObject = ParseResponse(context, responseString, ct);
} }
scheduler.Run(callback, responseObject);
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
throw new JavaScriptException(ex.Message); throw new JavaScriptException(ex.Message);
} }
},
response =>
{
JsValue responseObject;
if (response.IsRaw)
{
responseObject = JsValue.FromObject(engine, new Dictionary<string, object?>
{
["statusCode"] = response.StatusCode,
["headers"] = response.Headers,
["body"] = response.Body,
});
}
else
{
responseObject = new JsonParser(engine).Parse(response.Body);
}
callback(responseObject);
}); });
} }
private static HttpRequestMessage CreateRequest(ScriptExecutionContext context, private static HttpRequestMessage CreateRequest(Engine engine,
HttpMethod method, HttpMethod method,
Uri uri, Uri uri,
JsValue? body, JsValue? body,
@ -166,7 +180,7 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ
} }
else else
{ {
var jsonWriter = new JsonSerializer(context.Engine); var jsonWriter = new JsonSerializer(engine);
var jsonContent = jsonWriter.Serialize(body, JsValue.Undefined, JsValue.Undefined)?.ToString(); var jsonContent = jsonWriter.Serialize(body, JsValue.Undefined, JsValue.Undefined)?.ToString();
if (jsonContent != null) if (jsonContent != null)
@ -179,19 +193,6 @@ public sealed class HttpJintExtension(IHttpClientFactory httpClientFactory) : IJ
return request; return request;
} }
private static JsValue ParseResponse(ScriptExecutionContext context, string responseString,
CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
var jsonParser = new JsonParser(context.Engine);
var jsonValue = jsonParser.Parse(responseString);
ct.ThrowIfCancellationRequested();
return jsonValue;
}
public void Describe(AddDescription describe, ScriptScope scope) public void Describe(AddDescription describe, ScriptScope scope)
{ {
if (!scope.HasFlag(ScriptScope.Async)) if (!scope.HasFlag(ScriptScope.Async))

59
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringAsyncJintExtension.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Jint;
using Jint.Native; using Jint.Native;
using Jint.Runtime; using Jint.Runtime;
using Squidex.AI; using Squidex.AI;
@ -20,39 +21,40 @@ public sealed class StringAsyncJintExtension(ITranslator translator, IChatAgent
private delegate void TextGenerateDelegate(string prompt, Action<JsValue> callback); private delegate void TextGenerateDelegate(string prompt, Action<JsValue> callback);
private delegate void TextTranslateDelegate(string text, string language, Action<JsValue> callback, string sourceLanguage); private delegate void TextTranslateDelegate(string text, string language, Action<JsValue> callback, string sourceLanguage);
public void ExtendAsync(ScriptExecutionContext context) public void ExtendAsync(Engine engine)
{ {
var generate = new TextGenerateDelegate((prompt, callback) => var generate = new TextGenerateDelegate((prompt, callback) =>
{ {
Generate(context, prompt, callback); Generate(engine, prompt, callback);
}); });
var translate = new TextTranslateDelegate((text, language, callback, sourceLanguage) => var translate = new TextTranslateDelegate((text, language, callback, sourceLanguage) =>
{ {
Translate(context, text, language, callback, sourceLanguage); Translate(engine, text, language, callback, sourceLanguage);
}); });
context.Engine.SetValue("generate", generate); engine.SetValue("generate", generate);
context.Engine.SetValue("translate", translate); engine.SetValue("translate", translate);
} }
private void Generate(ScriptExecutionContext context, string prompt, Action<JsValue> callback) private void Generate(Engine engine, string prompt, Action<JsValue> callback)
{ {
if (callback == null) if (callback == null)
{ {
throw new JavaScriptException("Callback is not defined."); throw new JavaScriptException("Callback is not defined.");
} }
context.Schedule(async (scheduler, ct) => // We are still inside the engine here, therefore the callback can be invoked directly.
if (string.IsNullOrWhiteSpace(prompt))
{
callback(JsValue.Null);
return;
}
engine.Schedule(async ct =>
{ {
try try
{ {
if (string.IsNullOrWhiteSpace(prompt))
{
scheduler.Run(callback, JsValue.Null);
return;
}
var request = new ChatRequest var request = new ChatRequest
{ {
Prompt = prompt, Prompt = prompt,
@ -60,41 +62,44 @@ public sealed class StringAsyncJintExtension(ITranslator translator, IChatAgent
var result = await chatAgent.PromptAsync(request, ct: ct); var result = await chatAgent.PromptAsync(request, ct: ct);
scheduler.Run(callback, JsValue.FromObject(context.Engine, result.Content)); return result.Content;
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
throw new JavaScriptException(ex.Message); throw new JavaScriptException(ex.Message);
} }
}); },
content => callback(JsValue.FromObject(engine, content)));
} }
private void Translate(ScriptExecutionContext context, string text, string language, Action<JsValue> callback, string sourceLanguage) private void Translate(Engine engine, string text, string language, Action<JsValue> callback, string sourceLanguage)
{ {
if (callback == null) if (callback == null)
{ {
throw new JavaScriptException("Callback is not defined."); throw new JavaScriptException("Callback is not defined.");
} }
context.Schedule(async (scheduler, ct) => // We are still inside the engine here, therefore the callback can be invoked directly.
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(language))
{
callback(JsValue.Null);
return;
}
engine.Schedule(async ct =>
{ {
try try
{ {
if (string.IsNullOrWhiteSpace(text) || string.IsNullOrWhiteSpace(language))
{
scheduler.Run(callback, JsValue.Null);
return;
}
var translation = await translator.TranslateAsync(text, language, sourceLanguage, ct); var translation = await translator.TranslateAsync(text, language, sourceLanguage, ct);
scheduler.Run(callback, JsValue.FromObject(context.Engine, translation.Text)); return translation.Text;
} }
catch (Exception ex) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
throw new JavaScriptException(ex.Message); throw new JavaScriptException(ex.Message);
} }
}); },
translated => callback(JsValue.FromObject(engine, translated)));
} }
public void Describe(AddDescription describe, ScriptScope scope) public void Describe(AddDescription describe, ScriptScope scope)

33
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IAsyncScript.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Scripting;
public interface IAsyncScript : IDisposable
{
ValueTask<ContentData> TransformAsync(DataScriptVars vars,
CancellationToken ct = default);
ValueTask<JsonValue> ExecuteAsync(ScriptVars vars,
CancellationToken ct = default);
async ValueTask<bool> EvaluateAsync(ScriptVars vars,
CancellationToken ct = default)
{
try
{
return (await ExecuteAsync(vars, ct)).Equals(true);
}
catch
{
return false;
}
}
}

6
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IJintExtension.cs

@ -15,11 +15,7 @@ public interface IJintExtension
{ {
} }
void Extend(ScriptExecutionContext context) void ExtendAsync(Engine engine)
{
}
void ExtendAsync(ScriptExecutionContext context)
{ {
} }
} }

30
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScript.cs

@ -0,0 +1,30 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Scripting;
public interface IScript : IDisposable
{
ContentData Transform(DataScriptVars vars);
JsonValue Execute(ScriptVars vars);
bool Evaluate(ScriptVars vars)
{
try
{
return Execute(vars).Equals(true);
}
catch
{
return false;
}
}
}

57
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/IScriptEngine.cs

@ -1,4 +1,4 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt) // Copyright (c) Squidex UG (haftungsbeschraenkt)
@ -12,19 +12,62 @@ namespace Squidex.Domain.Apps.Core.Scripting;
public interface IScriptEngine public interface IScriptEngine
{ {
Task<JsonValue> ExecuteAsync(ScriptVars vars, string script, ScriptOptions options = default, IScript CreateScript(string script, ScriptOptions options = default);
CancellationToken ct = default);
IAsyncScript CreateAsyncScript(string script, ScriptOptions options = default);
ContentData Transform(DataScriptVars vars, string script, ScriptOptions options = default)
{
using var compiled = CreateScript(script, options);
Task<ContentData> TransformAsync(DataScriptVars vars, string script, ScriptOptions options = default, return compiled.Transform(vars);
CancellationToken ct = default); }
JsonValue Execute(ScriptVars vars, string script, ScriptOptions options = default)
{
using var compiled = CreateScript(script, options);
JsonValue Execute(ScriptVars vars, string script, ScriptOptions options = default); return compiled.Execute(vars);
}
bool Evaluate(ScriptVars vars, string script, ScriptOptions options = default) bool Evaluate(ScriptVars vars, string script, ScriptOptions options = default)
{ {
try try
{ {
return Execute(vars, script, options).Equals(true); using var compiled = CreateScript(script, options);
return compiled.Evaluate(vars);
}
catch
{
return false;
}
}
async ValueTask<ContentData> TransformAsync(DataScriptVars vars, string script, ScriptOptions options = default,
CancellationToken ct = default)
{
using var compiled = CreateAsyncScript(script, options);
return await compiled.TransformAsync(vars, ct);
}
async ValueTask<JsonValue> ExecuteAsync(ScriptVars vars, string script, ScriptOptions options = default,
CancellationToken ct = default)
{
using var compiled = CreateAsyncScript(script, options);
return await compiled.ExecuteAsync(vars, ct);
}
async ValueTask<bool> EvaluateAsync(ScriptVars vars, string script, ScriptOptions options = default,
CancellationToken ct = default)
{
try
{
using var compiled = CreateAsyncScript(script, options);
return await compiled.EvaluateAsync(vars, ct);
} }
catch catch
{ {

171
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/AsyncScriptExecutionContext.cs

@ -0,0 +1,171 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Jint;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Core.Scripting.Internal;
internal sealed class AsyncScriptExecutionContext<T> : ScriptExecutionContext
{
private readonly TaskCompletionSource<CompletedValue?> tcs = new TaskCompletionSource<CompletedValue?>(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly CancellationTokenRegistration cancellationRegistration;
private readonly CancellationToken cancellationToken;
private readonly JintScript script;
private readonly Engine engine;
private int pendingTasks = 1;
private sealed class CompletedValue
{
public T Value { get; init; }
}
public bool IsCompleted
{
get => tcs.Task.IsCompleted;
}
internal AsyncScriptExecutionContext(Engine engine, JintScript script, CancellationToken ct)
: base(engine)
{
this.engine = engine;
// The lock belongs to the script, because the engine is shared between all executions.
this.script = script;
cancellationToken = ct;
// Settle the source on cancellation, so that pending callbacks do not enter the engine anymore.
cancellationRegistration = cancellationToken.Register(static state =>
{
var self = (AsyncScriptExecutionContext<T>)state!;
self.tcs.TrySetCanceled(self.cancellationToken);
},
this);
}
public async Task<T> WaitForCompletionAsync(Func<T> fallback)
{
TryComplete();
try
{
var result = await tcs.Task;
if (result != null)
{
return result.Value;
}
return await script.RunLockedAsync(() => fallback(), cancellationToken);
}
finally
{
await cancellationRegistration.DisposeAsync();
}
}
public void Complete(T value)
{
tcs.TrySetResult(new CompletedValue { Value = value });
}
public override void Fail(Exception exception)
{
TryFail(exception);
}
public override void Schedule(Func<CancellationToken, Task> action)
{
ScheduleCoreAsync(async ct =>
{
await action(ct);
return true;
},
null);
}
public override void Schedule<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback)
{
ScheduleCoreAsync(async ct =>
{
var result = await action(ct);
return result;
},
callback);
}
private void ScheduleCoreAsync<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback)
{
if (IsCompleted)
{
return;
}
async Task ScheduleAsync()
{
TryStart();
try
{
// The action must not touch the engine, so that parallel tasks do not block each other.
var result = await action(cancellationToken);
// The callback converts javascript values and is therefore the only part that needs the lock.
await RunLockedAsync(() => callback?.Invoke(result));
TryComplete();
}
catch (Exception ex)
{
TryFail(ex);
}
}
ScheduleAsync().Forget();
}
private Task<bool> RunLockedAsync(Action action)
{
// The lock is owned by the script, because the engine is shared between all executions.
return script.RunLockedAsync(() =>
{
// Late callbacks must not touch the engine anymore, the next execution might have started.
if (IsCompleted)
{
return true;
}
// The task can take a while, therefore the action gets a fresh timeout.
engine.Constraints.Reset();
action();
// The evaluation does not wait for the promises anymore, therefore the continuations that
// the callback has unblocked have to be executed here, while the lock is still held.
engine.Advanced.ProcessTasks();
return true;
},
cancellationToken);
}
private void TryFail(Exception exception)
{
tcs.TrySetException(exception);
}
private void TryStart()
{
Interlocked.Increment(ref pendingTasks);
}
private void TryComplete(CompletedValue? result = null)
{
if (Interlocked.Decrement(ref pendingTasks) <= 0)
{
tcs.TrySetResult(result);
}
}
}

4
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/CacheParser.cs

@ -17,7 +17,9 @@ internal sealed class CacheParser(IMemoryCache cache)
public Prepared<Script> Parse(string script) public Prepared<Script> Parse(string script)
{ {
var cacheKey = $"{typeof(CacheParser)}_Script_{script}"; // A tuple keeps a reference to the source instead of copying it into a bigger string on
// every execution, which also stops the cache from holding a second copy of every script.
var cacheKey = (typeof(CacheParser), script);
return cache.GetOrCreate(cacheKey, entry => return cache.GetOrCreate(cacheKey, entry =>
{ {

34
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/EnumToStringConverter.cs

@ -0,0 +1,34 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Diagnostics.CodeAnalysis;
using Jint;
using Jint.Native;
using Jint.Runtime.Interop;
namespace Squidex.Domain.Apps.Core.Scripting.Internal;
internal sealed class EnumToStringConverter : IObjectConverter
{
public static readonly EnumToStringConverter Instance = new EnumToStringConverter();
private EnumToStringConverter()
{
}
public bool TryConvert(Engine engine, object value, [MaybeNullWhen(false)] out JsValue result)
{
if (value is Enum)
{
result = value.ToString();
return true;
}
result = JsValue.Null;
return false;
}
}

67
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs

@ -7,7 +7,6 @@
using Jint; using Jint;
using Jint.Native; using Jint.Native;
using Jint.Runtime.Interop;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Scripting.Internal; namespace Squidex.Domain.Apps.Core.Scripting.Internal;
@ -32,70 +31,4 @@ public static class JintExtensions
return ids; return ids;
} }
internal static ScriptExecutionContext<T> ExtendWithAsyncFunctions<T>(this ScriptExecutionContext<T> context,
IEnumerable<IJintExtension> extensions)
{
foreach (var extension in extensions)
{
extension.ExtendAsync(context);
}
return context;
}
internal static ScriptExecutionContext<T> ExtendWithFunctions<T>(this ScriptExecutionContext<T> context,
IEnumerable<IJintExtension> extensions)
{
foreach (var extension in extensions)
{
extension.Extend(context);
}
return context;
}
internal static ScriptExecutionContext<T> ExtendWithVariables<T>(this ScriptExecutionContext<T> context,
ScriptVars vars,
ScriptOptions options)
{
var engine = context.Engine;
context.CopyFrom(vars);
if (options.AsContext)
{
var contextInstance = new WritableContext(engine, vars);
engine.SetValue("ctx", contextInstance);
engine.SetValue("context", contextInstance);
}
else
{
foreach (var (key, item) in vars)
{
// Sets the value, but runs the conversion only when the script reads it for the first time.
// The name is added right away, so enumeration and "in" checks work as before.
engine.Advanced.AddLazyGlobal(key, e => MapVariable(e, item));
}
}
engine.SetValue("async", true);
return context;
}
/// <summary>
/// Converts a value exactly like <see cref="Engine.SetValue(string, object)"/> does, including its
/// special case for types, so that a deferred variable cannot look different from an eager one.
/// </summary>
private static JsValue MapVariable(Engine engine, object? item)
{
if (item is Type type)
{
return TypeReference.CreateTypeReference(engine, type);
}
return JsValue.FromObject(engine, item);
}
} }

366
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScript.cs

@ -0,0 +1,366 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Diagnostics;
using Acornima;
using Acornima.Ast;
using Jint;
using Jint.Constraints;
using Jint.Native;
using Jint.Native.Promise;
using Jint.Runtime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Scripting.ContentWrapper;
using Squidex.Domain.Apps.Core.Scripting.Internal;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Core.Scripting;
internal sealed class JintScript : IScript, IAsyncScript, IDisposable
{
// The engine is shared between all executions, therefore only one of them can run at a time.
private readonly SemaphoreSlim executionGate = new SemaphoreSlim(1);
// The engine is also shared with the callbacks of the scheduled tasks, which run on other threads.
private readonly SemaphoreSlim engineLock = new SemaphoreSlim(1);
// Jint only creates the constraint for a token that can actually be cancelled, therefore the engine is
// built with a placeholder that is never cancelled and repointed for each execution.
private readonly CancellationTokenSource placeholder = new CancellationTokenSource();
private readonly CancellationConstraint? cancellation;
private readonly Engine engine;
private readonly Prepared<Script> parsed;
private readonly ScriptOptions scriptOptions;
private readonly JintScriptOptions engineOptions;
private readonly GlobalSnapshot snapshot;
public JintScript(
Prepared<Script> parsed,
ScriptOptions scriptOptions,
JintScriptOptions engineOptions,
IJintExtension[] extensions,
bool allowAsync)
{
this.parsed = parsed;
this.scriptOptions = scriptOptions;
this.engineOptions = engineOptions;
engine = new Engine(options =>
{
options.AddObjectConverter(JintObjectConverter.Instance, JintObjectConverter.HandledTypes);
options.AddObjectConverter(EnumToStringConverter.Instance);
options.AllowClrWrite(!scriptOptions.Readonly);
options.SetTypeConverter(engine => new CustomClrConverter(engine));
options.SetReferencesResolver(NullPropagation.Instance);
options.Strict();
if (!Debugger.IsAttached)
{
options.Constraints.PromiseTimeout = engineOptions.TimeoutPromise;
options.TimeoutInterval(engineOptions.TimeoutScript);
options.CancellationToken(placeholder.Token);
}
});
cancellation = engine.Constraints.Find<CancellationConstraint>();
// The extensions resolve the variables from the context of the current execution, therefore they
// are registered once, even though the variables are different for each execution.
foreach (var extension in extensions)
{
extension.Extend(engine);
}
if (allowAsync)
{
foreach (var extension in extensions)
{
extension.ExtendAsync(engine);
}
}
if (scriptOptions.CanDisallow)
{
engine.AddDisallow();
}
if (scriptOptions.CanReject)
{
engine.AddReject();
}
// The evaluation does not wait for the promises anymore, therefore an unhandled rejection would be
// swallowed. The tracker only fires when nothing handles the rejection.
engine.Advanced.PromiseRejectionTracker += (_, args) =>
{
if (args.Operation == PromiseRejectionOperation.Reject)
{
ScriptExecutionContext.GetContext(engine).Fail(
new JavaScriptException($"Promise was rejected with value {args.Value}."));
}
};
snapshot = engine.Advanced.CaptureGlobalSnapshot();
}
public JsonValue Execute(ScriptVars vars)
{
Guard.NotNull(vars);
return Run(vars, (vars, _) =>
{
JsonValue? completed = null;
engine.SetValue("complete", new Action<JsValue?>(value =>
{
completed ??= JsonMapper.Map(value);
}));
var result = engine.Evaluate(parsed);
return completed ?? JsonMapper.Map(result);
});
}
public ValueTask<JsonValue> ExecuteAsync(ScriptVars vars,
CancellationToken ct = default)
{
Guard.NotNull(vars);
return RunAsync<JsonValue, ScriptVars>(vars, async (vars, context, ct) =>
{
engine.SetValue("complete", new Action<JsValue?>(value =>
{
context.Complete(JsonMapper.Map(value));
}));
// The evaluation holds the lock, but returns without waiting for the pending promises.
var result = await RunLockedAsync(() => engine.Evaluate(parsed), ct);
return await context.WaitForCompletionAsync(() => JsonMapper.Map(result));
}, ct);
}
public ContentData Transform(DataScriptVars vars)
{
Guard.NotNull(vars);
return Run(vars, (vars, _) =>
{
ContentData? result = null;
engine.SetValue("complete", new Action<JsValue?>(_ =>
{
result ??= vars.Data;
}));
engine.SetValue("replace", new Action(() =>
{
if (result == null && TransformData(out var modified))
{
result = modified;
}
}));
engine.Evaluate(parsed);
return result ?? vars.Data!;
});
}
public ValueTask<ContentData> TransformAsync(DataScriptVars vars,
CancellationToken ct = default)
{
Guard.NotNull(vars);
return RunAsync<ContentData, DataScriptVars>(vars, async (vars, context, ct) =>
{
var data = vars.Data!;
engine.SetValue("complete", new Action<JsValue?>(_ =>
{
if (!context.IsCompleted)
{
context.Complete(data);
}
}));
engine.SetValue("replace", new Action(() =>
{
if (!context.IsCompleted && TransformData(out var modified))
{
context.Complete(modified);
}
}));
// The evaluation holds the lock, but returns without waiting for the pending promises.
await RunLockedAsync(() => engine.Evaluate(parsed), ct);
return await context.WaitForCompletionAsync(() => data);
}, ct);
}
private T Run<T, TVars>(TVars vars, Func<TVars, ScriptExecutionContext, T> action) where TVars : ScriptVars
{
executionGate.Wait();
try
{
// The extensions resolve the variables over the context, therefore it is also needed here.
var context = new ScriptExecutionContext(engine);
context.CopyFrom(vars);
PrepareEngine(vars, default);
return action(vars, context);
}
catch (Exception ex)
{
throw MapException(ex);
}
finally
{
executionGate.Release();
}
}
private async ValueTask<T> RunAsync<T, TVars>(TVars vars, Func<TVars, AsyncScriptExecutionContext<T>, CancellationToken, ValueTask<T>> action,
CancellationToken ct) where TVars : ScriptVars
{
using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct);
// Enforce a timeout after a configured time span.
combined.CancelAfter(engineOptions.TimeoutExecution);
await executionGate.WaitAsync(ct);
try
{
PrepareEngine(vars, combined.Token);
// The extensions resolve the variables over the context, therefore it is also needed here.
var context = new AsyncScriptExecutionContext<T>(engine, this, combined.Token);
context.CopyFrom(vars);
return await action(vars, context, combined.Token);
}
catch (Exception ex)
{
throw MapException(ex);
}
finally
{
// Stop pending tasks before the token source is disposed, they must not touch the engine anymore.
await combined.CancelAsync();
// The next execution reuses the engine, therefore we wait until no callback is inside anymore.
// Callbacks that are still waiting for the lock are rejected, because the token is cancelled by now.
await engineLock.WaitAsync(default(CancellationToken));
engineLock.Release();
executionGate.Release();
}
}
public void Dispose()
{
engine.Dispose();
engineLock.Dispose();
executionGate.Dispose();
placeholder.Dispose();
}
internal async Task<T> RunLockedAsync<T>(Func<T> action, CancellationToken ct)
{
// Only one thread is allowed inside the engine, no matter whether it is an execution or a callback.
// A callback that is reached synchronously from the engine thread asks for the lock while the
// evaluation still holds it. That is not a deadlock, because the wait yields instead of blocking and
// the callback continues on another thread once the evaluation is done. It must not be made
// reentrant, that would run the promise jobs in the middle of a statement.
await engineLock.WaitAsync(ct);
try
{
return action();
}
finally
{
engineLock.Release();
}
}
private bool TransformData(out ContentData result)
{
var dataInstance =
scriptOptions.AsContext ?
engine.GetValue("ctx").AsObject().Get("data") :
engine.GetValue("data");
if (dataInstance != null &&
dataInstance.IsObject() &&
dataInstance.AsObject() is ContentDataObject dataObject &&
dataObject.TryUpdate(out var modified))
{
result = modified;
return true;
}
result = null!;
return false;
}
private void PrepareEngine(ScriptVars vars, CancellationToken ct)
{
// Removes everything the previous execution has declared, including global const and let.
engine.Advanced.RestoreGlobalSnapshot(snapshot);
engine.Constraints.Reset();
// The engine is created once, therefore the token of the current execution is assigned here.
// The generic reset above does not clear it, so the callbacks keep the token as well.
cancellation?.Reset(ct);
if (scriptOptions.AsContext)
{
var contextInstance = new WritableContext(engine, vars);
engine.SetValue("ctx", contextInstance);
engine.SetValue("context", contextInstance);
}
else
{
foreach (var (key, item) in vars)
{
engine.SetValue(key, item);
}
}
}
internal static Exception MapException(Exception inner)
{
static Exception BuildException(string errorKey, string message, Exception? inner = null)
{
return new ValidationException(T.Get(errorKey, new { message }), inner);
}
switch (inner)
{
case ArgumentException:
return BuildException("common.jsParseError", inner.Message);
case ParseErrorException:
return BuildException("common.jsError", inner.Message);
case ScriptPreparationException:
return BuildException("common.jsError", inner.Message);
case JavaScriptException:
return BuildException("common.jsError", inner.Message);
case JintException:
return BuildException("common.jsError", inner.Message);
case DomainException:
return inner;
default:
return BuildException("common.jsError", inner.GetType().Name, inner);
}
}
}

192
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs

@ -5,21 +5,11 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Diagnostics;
using Acornima;
using Jint;
using Jint.Native;
using Jint.Runtime;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Properties; using Squidex.Domain.Apps.Core.Properties;
using Squidex.Domain.Apps.Core.Scripting.ContentWrapper;
using Squidex.Domain.Apps.Core.Scripting.Internal; using Squidex.Domain.Apps.Core.Scripting.Internal;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation;
namespace Squidex.Domain.Apps.Core.Scripting; namespace Squidex.Domain.Apps.Core.Scripting;
@ -27,195 +17,29 @@ public sealed class JintScriptEngine(IMemoryCache cache, IOptions<JintScriptOpti
{ {
private readonly IJintExtension[] extensions = extensions?.ToArray() ?? []; private readonly IJintExtension[] extensions = extensions?.ToArray() ?? [];
private readonly CacheParser parser = new CacheParser(cache); private readonly CacheParser parser = new CacheParser(cache);
private readonly TimeSpan timeoutScript = options.Value.TimeoutScript;
private readonly TimeSpan timeoutExecution = options.Value.TimeoutExecution;
private readonly TimeSpan timeoutPromise = options.Value.TimeoutPromise;
public async Task<JsonValue> ExecuteAsync(ScriptVars vars, string script, ScriptOptions options = default, public IScript CreateScript(string script, ScriptOptions scriptOptions = default)
CancellationToken ct = default)
{ {
Guard.NotNull(vars); return CreateScriptCore(script, scriptOptions, false);
Guard.NotNullOrEmpty(script);
using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct);
try
{
// Enforce a timeout after a configured time span.
combined.CancelAfter(timeoutExecution);
var context =
CreateEngine<JsonValue>(options, combined.Token)
.ExtendWithVariables(vars, options)
.ExtendWithFunctions(extensions)
.ExtendWithAsyncFunctions(extensions);
context.Engine.SetValue("complete", new Action<JsValue?>(value =>
{
context.Complete(JsonMapper.Map(value));
}));
var result = await ExecuteAsync(context, script);
return await context.WaitForCompletionAsync(() => JsonMapper.Map(result));
}
catch (Exception ex)
{
throw MapException(ex);
}
} }
public async Task<ContentData> TransformAsync(DataScriptVars vars, string script, ScriptOptions options = default, public IAsyncScript CreateAsyncScript(string script, ScriptOptions scriptOptions = default)
CancellationToken ct = default)
{ {
Guard.NotNull(vars); return CreateScriptCore(script, scriptOptions, true);
Guard.NotNullOrEmpty(script);
var data = vars.Data!;
using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct);
try
{
// Enforce a timeout after a configured time span.
combined.CancelAfter(timeoutExecution);
var context =
CreateEngine<ContentData>(options, combined.Token)
.ExtendWithVariables(vars, options)
.ExtendWithFunctions(extensions)
.ExtendWithAsyncFunctions(extensions);
context.Engine.SetValue("complete", new Action<JsValue?>(_ =>
{
context.Complete(data!);
}));
context.Engine.SetValue("replace", new Action(() =>
{
var dataInstance = context.Engine.GetValue("ctx").AsObject().Get("data");
if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data)
{
if (!context.IsCompleted && data.TryUpdate(out var modified))
{
context.Complete(modified);
}
}
}));
await ExecuteAsync(context, script);
return await context.WaitForCompletionAsync(() => data);
}
catch (Exception ex)
{
throw MapException(ex);
}
} }
public JsonValue Execute(ScriptVars vars, string script, ScriptOptions options = default) private JintScript CreateScriptCore(string script, ScriptOptions scriptOptions, bool allowAsync)
{ {
Guard.NotNull(vars);
Guard.NotNullOrEmpty(script); Guard.NotNullOrEmpty(script);
try try
{ {
var context = // The parser caches the prepared script, therefore the same source is only parsed once.
CreateEngine<object>(options, default) return new JintScript(parser.Parse(script), scriptOptions, options.Value, extensions, allowAsync);
.ExtendWithVariables(vars, options)
.ExtendWithFunctions(extensions);
var result = Execute(context, script);
return JsonMapper.Map(result);
} }
catch (Exception ex) catch (Exception ex)
{ {
throw MapException(ex); throw JintScript.MapException(ex);
}
}
private ScriptExecutionContext<T> CreateEngine<T>(ScriptOptions options, CancellationToken ct)
{
if (Debugger.IsAttached)
{
ct = default;
}
var engine = new Engine(engineOptions =>
{
engineOptions.AddObjectConverter(JintObjectConverter.Instance, JintObjectConverter.HandledTypes);
engineOptions.AllowClrWrite(!options.Readonly);
// Converts enums to their name, e.g. "Published". This was done by JintObjectConverter before.
engineOptions.Interop.EnumConversion = EnumConversionMode.String;
engineOptions.SetTypeConverter(engine => new CustomClrConverter(engine));
engineOptions.SetReferencesResolver(NullPropagation.Instance, NullPropagation.Interests);
engineOptions.Strict();
if (!Debugger.IsAttached)
{
engineOptions.Constraints.PromiseTimeout = timeoutPromise;
engineOptions.TimeoutInterval(timeoutScript);
engineOptions.CancellationToken(ct);
}
});
if (options.CanDisallow)
{
engine.AddDisallow();
}
if (options.CanReject)
{
engine.AddReject();
}
foreach (var extension in extensions)
{
extension.Extend(engine);
}
return new ScriptExecutionContext<T>(engine, ct);
}
private JsValue Execute(ScriptExecutionContext context, string script)
{
var parsed = parser.Parse(script);
return context.Evaluate(parsed);
}
private Task<JsValue> ExecuteAsync(ScriptExecutionContext context, string script)
{
var parsed = parser.Parse(script);
return context.EvaluateAsync(parsed);
}
private static Exception MapException(Exception inner)
{
static Exception BuildException(string errorKey, string message, Exception? inner = null)
{
return new ValidationException(T.Get(errorKey, new { message }), inner);
}
switch (inner)
{
case ArgumentException:
return BuildException("common.jsParseError", inner.Message);
case ParseErrorException:
return BuildException("common.jsError", inner.Message);
case ScriptPreparationException:
return BuildException("common.jsError", inner.Message);
case JavaScriptException:
return BuildException("common.jsError", inner.Message);
case JintException:
return BuildException("common.jsError", inner.Message);
case DomainException:
return inner;
default:
return BuildException("common.jsError", inner.GetType().Name, inner);
} }
} }

161
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs

@ -5,170 +5,45 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Acornima.Ast; using System.Runtime.CompilerServices;
using Jint; using Jint;
using Jint.Native; using Squidex.Infrastructure;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Core.Scripting; namespace Squidex.Domain.Apps.Core.Scripting;
public abstract class ScriptExecutionContext(Engine engine) : ScriptVars public class ScriptExecutionContext : ScriptVars
{ {
public Engine Engine { get; } = engine; private static readonly ConditionalWeakTable<Engine, ScriptExecutionContext> Contexts = [];
public abstract JsValue Evaluate(Prepared<Script> script); internal ScriptExecutionContext(Engine engine)
public abstract Task<JsValue> EvaluateAsync(Prepared<Script> script);
public abstract void Schedule(Func<IScheduler, CancellationToken, Task> action);
}
public sealed class ScriptExecutionContext<T> : ScriptExecutionContext, IScheduler
{
private readonly TaskCompletionSource<CompletedValue?> tcs = new TaskCompletionSource<CompletedValue?>();
private readonly CancellationToken cancellationToken;
private int pendingTasks = 1;
private sealed class CompletedValue
{ {
public T Value { get; init; } // The extensions only get the engine and resolve the context from there.
Contexts.AddOrUpdate(engine, this);
} }
public bool IsCompleted public static ScriptExecutionContext GetContext(Engine engine)
{ {
get => tcs.Task.Status is TaskStatus.RanToCompletion or TaskStatus.Faulted; if (!Contexts.TryGetValue(engine, out var context))
}
internal ScriptExecutionContext(Engine engine, CancellationToken cancellationToken)
: base(engine)
{
this.cancellationToken = cancellationToken;
}
public async Task<T> WaitForCompletionAsync(Func<T> fallback)
{
TryComplete();
var result = await tcs.Task.WithCancellation(cancellationToken);
if (result == null)
{ {
return fallback(); ThrowHelper.InvalidOperationException("Engine is not attached to a script context.");
return default!;
} }
return result.Value; return context;
}
public void Complete(T value)
{
tcs.TrySetResult(new CompletedValue { Value = value });
} }
public override JsValue Evaluate(Prepared<Script> script) public virtual void Fail(Exception exception)
{ {
return Engine.Evaluate(script); // The synchronous path reports the error over the exception of the evaluation itself.
} }
public override Task<JsValue> EvaluateAsync(Prepared<Script> script) public virtual void Schedule(Func<CancellationToken, Task> action)
{ {
return Engine.EvaluateAsync(script, cancellationToken); ThrowHelper.NotSupportedException("Async operations are not allowed for this script.");
} }
public override void Schedule(Func<IScheduler, CancellationToken, Task> action) public virtual void Schedule<TResult>(Func<CancellationToken, Task<TResult>> action, Action<TResult>? callback)
{ {
if (IsCompleted) ThrowHelper.NotSupportedException("Async operations are not allowed for this script.");
{
return;
}
async Task ScheduleAsync()
{
TryStart();
try
{
await action(this, cancellationToken);
TryComplete();
}
catch (Exception ex)
{
TryFail(ex);
}
}
ScheduleAsync().Forget();
} }
void IScheduler.Run(Action? action)
{
if (IsCompleted || action == null)
{
return;
}
TryStart();
try
{
lock (Engine)
{
Engine.Constraints.Reset();
action();
}
TryComplete();
}
catch (Exception ex)
{
TryFail(ex);
}
}
void IScheduler.Run<TArg>(Action<TArg>? action, TArg argument)
{
if (IsCompleted || action == null)
{
return;
}
TryStart();
try
{
lock (Engine)
{
Engine.Constraints.Reset();
action(argument);
}
TryComplete(default!);
}
catch (Exception ex)
{
TryFail(ex);
}
}
private void TryFail(Exception exception)
{
tcs.TrySetException(exception);
}
private void TryStart()
{
Interlocked.Increment(ref pendingTasks);
}
private void TryComplete(CompletedValue? result = null)
{
if (Interlocked.Decrement(ref pendingTasks) <= 0)
{
tcs.TrySetResult(result);
}
}
}
#pragma warning disable MA0048 // File name must match type name
public interface IScheduler
#pragma warning restore MA0048 // File name must match type name
{
void Run(Action? action);
void Run<T>(Action<T>? action, T argument);
} }

6
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -20,7 +20,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Fluid.Core" Version="2.31.0" /> <PackageReference Include="Fluid.Core" Version="2.31.0" />
<PackageReference Include="GeoJSON.Net" Version="1.4.1" /> <PackageReference Include="GeoJSON.Net" Version="1.4.1" />
<PackageReference Include="Jint" Version="4.15.3" /> <PackageReference Include="Jint" Version="4.16.1" />
<PackageReference Include="Meziantou.Analyzer" Version="3.0.50"> <PackageReference Include="Meziantou.Analyzer" Version="3.0.50">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -29,8 +29,8 @@
<PackageReference Include="NJsonSchema" Version="11.6.1" /> <PackageReference Include="NJsonSchema" Version="11.6.1" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI" Version="8.0.3" /> <PackageReference Include="Squidex.AI" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Linq.Async" Version="7.0.1" /> <PackageReference Include="System.Linq.Async" Version="7.0.1" />
<PackageReference Include="ValueTaskSupplement" Version="1.1.0" /> <PackageReference Include="ValueTaskSupplement" Version="1.1.0" />

39
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/PatternValidator.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Squidex.Caching;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Translations;
@ -14,6 +15,16 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators;
public class PatternValidator : IValidator public class PatternValidator : IValidator
{ {
private static readonly TimeSpan Timeout = TimeSpan.FromMilliseconds(20); private static readonly TimeSpan Timeout = TimeSpan.FromMilliseconds(20);
// The validator tree is rebuilt for every content write, but parsing the pattern is by far the
// most expensive part of it and only depends on the key, so it is shared over all validators.
// Regex is thread safe for matching, therefore a single instance can be used concurrently.
private static readonly LRUCache<(string Pattern, RegexOptions Options), Regex> Regexes = new LRUCache<(string, RegexOptions), Regex>(1000);
// LRUCache is not thread safe and even TryGetValue mutates the recency list, so there is no
// lock free read path and every access to the cache has to be guarded.
private static readonly Lock RegexesLock = new Lock();
private readonly Regex regex; private readonly Regex regex;
private readonly string? errorMessage; private readonly string? errorMessage;
@ -24,13 +35,37 @@ public class PatternValidator : IValidator
this.errorMessage = errorMessage; this.errorMessage = errorMessage;
var options = RegexOptions.None; var options = RegexOptions.None;
if (!capture) if (!capture)
{ {
options |= RegexOptions.ExplicitCapture; options |= RegexOptions.ExplicitCapture;
} }
regex = new Regex($"^{pattern}$", options, Timeout); regex = GetRegex(pattern, options);
}
private static Regex GetRegex(string pattern, RegexOptions options)
{
var cacheKey = (pattern, options);
lock (RegexesLock)
{
if (Regexes.TryGetValue(cacheKey, out var cached))
{
return cached;
}
}
// Parsing is the expensive part and must not be serialized over all threads, so it happens
// outside the lock. Two threads can build the same pattern at the same time, which only
// wastes a little work, and an invalid pattern still throws from here as it did before.
var createdRegex = new Regex($"^{pattern}$", options, Timeout);
lock (RegexesLock)
{
Regexes.Set(cacheKey, createdRegex);
}
return createdRegex;
} }
public void Validate(object? value, ValidationContext context) public void Validate(object? value, ValidationContext context)

40
backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -86,7 +86,7 @@ public sealed class AppProvider(
public async Task<Team?> GetTeamAsync(DomainId teamId, public async Task<Team?> GetTeamAsync(DomainId teamId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var cacheKey = TeamCacheKey(teamId); var cacheKey = (nameof(AppProvider), "TEAMS_ID", teamId);
var team = await GetOrCreate(cacheKey, () => var team = await GetOrCreate(cacheKey, () =>
{ {
@ -99,7 +99,7 @@ public sealed class AppProvider(
public async Task<Team?> GetTeamByAuthDomainAsync(string authDomain, public async Task<Team?> GetTeamByAuthDomainAsync(string authDomain,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var cacheKey = TeamCacheKey(authDomain); var cacheKey = (nameof(AppProvider), "TEAMS_DOMAIN", authDomain);
var team = await GetOrCreate(cacheKey, () => var team = await GetOrCreate(cacheKey, () =>
{ {
@ -148,7 +148,7 @@ public sealed class AppProvider(
public async Task<List<App>> GetUserAppsAsync(string userId, PermissionSet permissions, public async Task<List<App>> GetUserAppsAsync(string userId, PermissionSet permissions,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var apps = await GetOrCreate($"GetUserApps({userId})", () => var apps = await GetOrCreate((nameof(AppProvider), "GET_USER_APPS", userId), () =>
{ {
return indexForApps.GetAppsForUserAsync(userId, permissions, ct)!; return indexForApps.GetAppsForUserAsync(userId, permissions, ct)!;
}); });
@ -159,7 +159,7 @@ public sealed class AppProvider(
public async Task<List<App>> GetTeamAppsAsync(DomainId teamId, public async Task<List<App>> GetTeamAppsAsync(DomainId teamId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var apps = await GetOrCreate($"GetTeamApps({teamId})", () => var apps = await GetOrCreate((nameof(AppProvider), "GET_TEAM_APPS", teamId), () =>
{ {
return indexForApps.GetAppsForTeamAsync(teamId, ct)!; return indexForApps.GetAppsForTeamAsync(teamId, ct)!;
}); });
@ -169,7 +169,7 @@ public sealed class AppProvider(
public async Task<List<Team>> GetUserTeamsAsync(string userId, CancellationToken ct = default) public async Task<List<Team>> GetUserTeamsAsync(string userId, CancellationToken ct = default)
{ {
var teams = await GetOrCreate($"GetUserTeams({userId})", () => var teams = await GetOrCreate((nameof(AppProvider), "GET_USER_TEAMS", userId), () =>
{ {
return indexForTeams.GetTeamsAsync(userId, ct)!; return indexForTeams.GetTeamsAsync(userId, ct)!;
}); });
@ -180,7 +180,7 @@ public sealed class AppProvider(
public async Task<List<Schema>> GetSchemasAsync(DomainId appId, public async Task<List<Schema>> GetSchemasAsync(DomainId appId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var schemas = await GetOrCreate($"GetSchemasAsync({appId})", () => var schemas = await GetOrCreate((nameof(AppProvider), "GET_SCHEMAS", appId), () =>
{ {
return indexForSchemas.GetSchemasAsync(appId, ct)!; return indexForSchemas.GetSchemasAsync(appId, ct)!;
}); });
@ -200,7 +200,7 @@ public sealed class AppProvider(
public async Task<List<Rule>> GetRulesAsync(DomainId appId, public async Task<List<Rule>> GetRulesAsync(DomainId appId,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var rules = await GetOrCreate($"GetRulesAsync({appId})", () => var rules = await GetOrCreate((nameof(AppProvider), "GET_RULES", appId), () =>
{ {
return indexForRules.GetRulesAsync(appId, ct)!; return indexForRules.GetRulesAsync(appId, ct)!;
}); });
@ -243,33 +243,23 @@ public sealed class AppProvider(
return await result; return await result;
} }
private static string AppCacheKey(DomainId appId) private static object AppCacheKey(DomainId appId)
{ {
return $"APPS_ID_{appId}"; return (nameof(AppProvider), "APPS_ID", appId);
} }
private static string AppCacheKey(string appName) private static object AppCacheKey(string appName)
{ {
return $"APPS_NAME_{appName}"; return (nameof(AppProvider), "APPS_NAME", appName);
} }
private static string TeamCacheKey(DomainId teamId) private static object SchemaCacheKey(DomainId appId, DomainId id)
{ {
return $"TEAMS_ID_{teamId}"; return (nameof(AppProvider), "SCHEMAS_ID", appId, id);
} }
private static string TeamCacheKey(string authDomain) private static object SchemaCacheKey(DomainId appId, string name)
{ {
return $"TEAMS_DOMAIN_{authDomain}"; return (nameof(AppProvider), "SCHEMAS_NAME", appId, name);
}
private static string SchemaCacheKey(DomainId appId, DomainId id)
{
return $"SCHEMAS_ID_{appId}_{id}";
}
private static string SchemaCacheKey(DomainId appId, string name)
{
return $"SCHEMAS_NAME_{appId}_{name}";
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs

@ -37,7 +37,7 @@ public sealed class AppCommandMiddleware(
{ {
if (result.Payload is App app) if (result.Payload is App app)
{ {
contextProvider.Context.App = app; contextProvider.Context = contextProvider.Context.WithApp(app);
} }
return base.EnrichResultAsync(context, result, ct); return base.EnrichResultAsync(context, result, ct);

232
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs

@ -32,46 +32,47 @@ public sealed class AssetsJintExtension(IServiceProvider serviceProvider) : IJin
private delegate void GetAssetTextDelegate(JsValue asset, Action<JsValue> callback, JsValue? encoding); private delegate void GetAssetTextDelegate(JsValue asset, Action<JsValue> callback, JsValue? encoding);
private delegate void GetBlurHashDelegate(JsValue asset, Action<JsValue> callback, JsValue? componentX, JsValue? componentY); private delegate void GetBlurHashDelegate(JsValue asset, Action<JsValue> callback, JsValue? componentX, JsValue? componentY);
public void ExtendAsync(ScriptExecutionContext context) public void ExtendAsync(Engine engine)
{ {
AddGetAssetText(context); AddGetAssetText(engine);
AddGetAssetBlurHash(context); AddGetAssetBlurHash(engine);
AddGetAssetObject(context); AddGetAssetObject(engine);
AddUpdateAsset(context); AddUpdateAsset(engine);
} }
private void AddUpdateAsset(ScriptExecutionContext context) private void AddUpdateAsset(Engine engine)
{ {
if (!context.TryGetValueIfExists<ClaimsPrincipal>("user", out var user))
{
return;
}
var updateAsset = new UpdateAssetDelegate((asset, metadata) => var updateAsset = new UpdateAssetDelegate((asset, metadata) =>
{ {
UpdateAsset(context, user, asset, metadata); if (!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'updateAsset' is not available in this script.");
}
UpdateAsset(engine, user, asset, metadata);
}); });
context.Engine.SetValue("updateAsset", updateAsset); engine.SetValue("updateAsset", updateAsset);
} }
private void UpdateAsset(ScriptExecutionContext context, ClaimsPrincipal user, JsValue input, JsValue metadata) private void UpdateAsset(Engine engine, ClaimsPrincipal user, JsValue input, JsValue metadata)
{ {
context.Schedule(async (scheduler, ct) => // The javascript values are read while we are still inside the engine.
if (!TryGetAssetRef(engine, input, out var asset) || metadata is not ObjectInstance metadataObj)
{ {
if (!TryGetAssetRef(context, input, out var asset) || metadata is not ObjectInstance metadataObj) return;
{ }
return;
}
var commandBus = serviceProvider.GetRequiredService<ICommandBus>(); var assetMetadata = new AssetMetadata();
var assetMetadata = new AssetMetadata(); foreach (var (key, value) in metadataObj.GetOwnProperties())
{
assetMetadata[key.AsString()] = JsonMapper.Map(value.Value);
}
foreach (var (key, value) in metadataObj.GetOwnProperties()) engine.Schedule(async ct =>
{ {
assetMetadata[key.AsString()] = JsonMapper.Map(value.Value); var commandBus = serviceProvider.GetRequiredService<ICommandBus>();
}
var command = new AnnotateAsset var command = new AnnotateAsset
{ {
@ -87,54 +88,56 @@ public sealed class AssetsJintExtension(IServiceProvider serviceProvider) : IJin
}); });
} }
private void AddGetAssetObject(ScriptExecutionContext context) private void AddGetAssetObject(Engine engine)
{ {
if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{
return;
}
if (!context.TryGetValueIfExists<ClaimsPrincipal>("user", out var user))
{
return;
}
var getAssets = new GetAssetsDelegate((references, callback) => var getAssets = new GetAssetsDelegate((references, callback) =>
{ {
GetAssets(context, appId, user, references, callback); if (!engine.TryGetVar<DomainId>("appId", out var appId) ||
!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'getAssets' is not available in this script.");
}
GetAssets(engine, appId, user, references, callback);
}); });
var getAsset = new GetAssetsDelegate((references, callback) => var getAsset = new GetAssetsDelegate((references, callback) =>
{ {
GetAsset(context, appId, user, references, callback); if (!engine.TryGetVar<DomainId>("appId", out var appId) ||
!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'getAssetV2' is not available in this script.");
}
GetAsset(engine, appId, user, references, callback);
}); });
context.Engine.SetValue("getAsset", getAssets); engine.SetValue("getAsset", getAssets);
context.Engine.SetValue("getAssetV2", getAsset); engine.SetValue("getAssetV2", getAsset);
context.Engine.SetValue("getAssets", getAssets); engine.SetValue("getAssets", getAssets);
} }
private void AddGetAssetText(ScriptExecutionContext context) private void AddGetAssetText(Engine engine)
{ {
var action = new GetAssetTextDelegate((references, callback, encoding) => var action = new GetAssetTextDelegate((references, callback, encoding) =>
{ {
GetText(context, references, callback, encoding); GetText(engine, references, callback, encoding);
}); });
context.Engine.SetValue("getAssetText", action); engine.SetValue("getAssetText", action);
} }
private void AddGetAssetBlurHash(ScriptExecutionContext context) private void AddGetAssetBlurHash(Engine engine)
{ {
var getBlurHash = new GetBlurHashDelegate((input, callback, componentX, componentY) => var getBlurHash = new GetBlurHashDelegate((input, callback, componentX, componentY) =>
{ {
GetBlurHash(context, input, callback, componentX, componentY); GetBlurHash(engine, input, callback, componentX, componentY);
}); });
context.Engine.SetValue("getAssetBlurHash", getBlurHash); engine.SetValue("getAssetBlurHash", getBlurHash);
} }
private void GetText(ScriptExecutionContext context, private void GetText(Engine engine,
JsValue input, Action<JsValue> callback, JsValue? encoding) JsValue input, Action<JsValue> callback, JsValue? encoding)
{ {
if (callback == null) if (callback == null)
@ -142,23 +145,26 @@ public sealed class AssetsJintExtension(IServiceProvider serviceProvider) : IJin
throw new JavaScriptException("Callback is not defined."); throw new JavaScriptException("Callback is not defined.");
} }
context.Schedule(async (scheduler, ct) => // The javascript values are read while we are still inside the engine.
TryGetAssetRef(engine, input, out var asset);
var encodingName = encoding?.ToString();
engine.Schedule(async ct =>
{ {
TryGetAssetRef(context, input, out var asset);
try try
{ {
var text = await asset.GetTextAsync(encoding?.ToString(), serviceProvider, ct); return await asset.GetTextAsync(encodingName, serviceProvider, ct);
scheduler.Run(callback, text);
} }
catch catch (Exception ex) when (ex is not OperationCanceledException)
{ {
scheduler.Run(callback, JsValue.Null); return null;
} }
}); },
text => callback(JsValue.FromObject(engine, text)));
} }
private void GetBlurHash(ScriptExecutionContext context, private void GetBlurHash(Engine engine,
JsValue input, Action<JsValue> callback, JsValue? componentX, JsValue? componentY) JsValue input, Action<JsValue> callback, JsValue? componentX, JsValue? componentY)
{ {
if (callback == null) if (callback == null)
@ -166,36 +172,36 @@ public sealed class AssetsJintExtension(IServiceProvider serviceProvider) : IJin
throw new JavaScriptException("Callback is not defined."); throw new JavaScriptException("Callback is not defined.");
} }
context.Schedule(async (scheduler, ct) => // The javascript values are read while we are still inside the engine.
{ TryGetAssetRef(engine, input, out var asset);
TryGetAssetRef(context, input, out var asset);
var options = new BlurOptions(); var options = new BlurOptions();
if (componentX?.IsNumber() == true) if (componentX?.IsNumber() == true)
{ {
options.ComponentX = (int)componentX.AsNumber(); options.ComponentX = (int)componentX.AsNumber();
} }
if (componentY?.IsNumber() == true) if (componentY?.IsNumber() == true)
{ {
options.ComponentX = (int)componentY.AsNumber(); options.ComponentX = (int)componentY.AsNumber();
} }
engine.Schedule(async ct =>
{
try try
{ {
var hash = await asset.GetBlurHashAsync(options, serviceProvider, ct); return await asset.GetBlurHashAsync(options, serviceProvider, ct);
scheduler.Run(callback, hash);
} }
catch catch (Exception ex) when (ex is not OperationCanceledException)
{ {
scheduler.Run(callback, JsValue.Null); return null;
} }
}); },
hash => callback(JsValue.FromObject(engine, hash)));
} }
private void GetAssets(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, private void GetAssets(Engine engine, DomainId appId, ClaimsPrincipal user,
JsValue references, Action<JsValue> callback) JsValue references, Action<JsValue> callback)
{ {
if (callback == null) if (callback == null)
@ -203,74 +209,60 @@ public sealed class AssetsJintExtension(IServiceProvider serviceProvider) : IJin
throw new JavaScriptException("Callback is not defined."); throw new JavaScriptException("Callback is not defined.");
} }
context.Schedule(async (scheduler, ct) => // The javascript values are read while we are still inside the engine.
{ var ids = references.ToIds();
var ids = references.ToIds();
if (ids.Count == 0) if (ids.Count == 0)
{ {
scheduler.Run(callback, new JsArray(context.Engine)); callback(new JsArray(engine));
return; return;
} }
engine.Schedule(async ct =>
{
var app = await GetAppAsync(appId, ct); var app = await GetAppAsync(appId, ct);
if (app == null)
{
scheduler.Run(callback, new JsArray(context.Engine));
return;
}
var assetQuery = serviceProvider.GetRequiredService<IAssetQueryService>(); var assetQuery = serviceProvider.GetRequiredService<IAssetQueryService>();
var requestContext = var requestContext =
new Context(user, app).Clone(b => b new Context(user, app).Clone(b => b
.WithNoTotal()); .WithNoTotal());
var assets = await assetQuery.QueryAsync(requestContext, null, Q.Empty.WithIds(ids), ct); return await assetQuery.QueryAsync(requestContext, null, Q.Empty.WithIds(ids), ct);
},
scheduler.Run(callback, JsValue.FromObject(context.Engine, assets.ToArray())); assets => callback(JsValue.FromObject(engine, assets.ToArray())));
return;
});
} }
private void GetAsset(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, private void GetAsset(Engine engine, DomainId appId, ClaimsPrincipal user,
JsValue references, Action<JsValue> callback) JsValue references, Action<JsValue> callback)
{ {
Guard.NotNull(callback); Guard.NotNull(callback);
context.Schedule(async (scheduler, ct) => // The javascript values are read while we are still inside the engine.
{ var ids = references.ToIds();
var ids = references.ToIds();
if (ids.Count == 0) if (ids.Count == 0)
{ {
scheduler.Run(callback, JsValue.Null); callback(JsValue.Null);
return; return;
} }
engine.Schedule(async ct =>
{
var app = await GetAppAsync(appId, ct); var app = await GetAppAsync(appId, ct);
if (app == null)
{
scheduler.Run(callback, JsValue.Null);
return;
}
var assetQuery = serviceProvider.GetRequiredService<IAssetQueryService>(); var assetQuery = serviceProvider.GetRequiredService<IAssetQueryService>();
var requestContext = var requestContext =
new Context(user, app).Clone(b => b new Context(user, app).Clone(b => b
.WithNoTotal()); .WithNoTotal());
var assets = await assetQuery.QueryAsync(requestContext, null, Q.Empty.WithIds(ids), ct); return await assetQuery.QueryAsync(requestContext, null, Q.Empty.WithIds(ids), ct);
},
scheduler.Run(callback, JsValue.FromObject(context.Engine, assets.FirstOrDefault())); assets => callback(JsValue.FromObject(engine, assets.FirstOrDefault())));
return;
});
} }
private static bool TryGetAssetRef(ScriptExecutionContext context, JsValue input, out AssetRef assetRef) private static bool TryGetAssetRef(Engine engine, JsValue input, out AssetRef assetRef)
{ {
assetRef = default; assetRef = default;
@ -290,14 +282,14 @@ public sealed class AssetsJintExtension(IServiceProvider serviceProvider) : IJin
return true; return true;
case AssetEntityScriptVars vars: case AssetEntityScriptVars vars:
if (!context.TryGetValueIfExists<string>(nameof(AssetScriptVars.AppName), out var appName) || if (!engine.TryGetVar<string>(nameof(AssetScriptVars.AppName), out var appName) ||
!context.TryGetValueIfExists<DomainId>(nameof(AssetScriptVars.AppId), out var appId) || !engine.TryGetVar<DomainId>(nameof(AssetScriptVars.AppId), out var appId) ||
!context.TryGetValueIfExists<DomainId>(nameof(AssetScriptVars.AssetId), out var assetId)) !engine.TryGetVar<DomainId>(nameof(AssetScriptVars.AssetId), out var assetId))
{ {
return false; return false;
} }
context.TryGetValueIfExists<string?>(nameof(AssetScriptVars.FileId), out var fileId); engine.TryGetVar<string?>(nameof(AssetScriptVars.FileId), out var fileId);
assetRef = new AssetRef( assetRef = new AssetRef(
NamedId.Of(appId, appName), NamedId.Of(appId, appName),

63
backend/src/Squidex.Domain.Apps.Entities/Assets/DefaultAssetFileStore.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Text; using System.Text;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Assets; using Squidex.Assets;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
@ -17,9 +18,11 @@ namespace Squidex.Domain.Apps.Entities.Assets;
public sealed class DefaultAssetFileStore( public sealed class DefaultAssetFileStore(
IAssetStore assetStore, IAssetStore assetStore,
IAssetRepository assetRepository, IAssetRepository assetRepository,
IMemoryCache cache,
IOptions<AssetOptions> options) IOptions<AssetOptions> options)
: IAssetFileStore, IDeleter : IAssetFileStore, IDeleter
{ {
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(1);
private readonly AssetOptions options = options.Value; private readonly AssetOptions options = options.Value;
async Task IDeleter.DeleteAppAsync(App app, async Task IDeleter.DeleteAppAsync(App app,
@ -48,37 +51,73 @@ public sealed class DefaultAssetFileStore(
public async Task<long> GetFileSizeAsync(DomainId appId, DomainId id, long fileVersion, string? suffix, public async Task<long> GetFileSizeAsync(DomainId appId, DomainId id, long fileVersion, string? suffix,
CancellationToken ct = default) CancellationToken ct = default)
{ {
try if (options.FolderPerApp)
{ {
var fileNameNew = GetFileName(appId, id, fileVersion, suffix); return await assetStore.GetSizeAsync(GetFileName(appId, id, fileVersion, suffix), ct);
}
return await assetStore.GetSizeAsync(fileNameNew, ct); var (first, second, isOldFirst) = FileNames(appId, id, fileVersion, suffix);
try
{
return await assetStore.GetSizeAsync(first, ct);
} }
catch (AssetNotFoundException) when (!options.FolderPerApp) catch (AssetNotFoundException)
{ {
var fileNameOld = GetFileName(id, fileVersion, suffix); RememberFileName(appId, id, !isOldFirst);
return await assetStore.GetSizeAsync(fileNameOld, ct); return await assetStore.GetSizeAsync(second, ct);
} }
} }
public async Task DownloadAsync(DomainId appId, DomainId id, long fileVersion, string? suffix, Stream stream, BytesRange range = default, public async Task DownloadAsync(DomainId appId, DomainId id, long fileVersion, string? suffix, Stream stream, BytesRange range = default,
CancellationToken ct = default) CancellationToken ct = default)
{ {
try if (options.FolderPerApp)
{ {
var fileNameNew = GetFileName(appId, id, fileVersion, suffix); await assetStore.DownloadAsync(GetFileName(appId, id, fileVersion, suffix), stream, range, ct);
return;
}
await assetStore.DownloadAsync(fileNameNew, stream, range, ct); var (first, second, isOldFirst) = FileNames(appId, id, fileVersion, suffix);
try
{
await assetStore.DownloadAsync(first, stream, range, ct);
} }
catch (AssetNotFoundException) when (!options.FolderPerApp) catch (AssetNotFoundException)
{ {
var fileNameOld = GetFileName(id, fileVersion, suffix); RememberFileName(appId, id, !isOldFirst);
await assetStore.DownloadAsync(fileNameOld, stream, range, ct); await assetStore.DownloadAsync(second, stream, range, ct);
} }
} }
// Assets from older versions are stored under a file name without the app ID. Which name an
// asset uses can only be found out by trying, and a failed try is a full roundtrip to the asset
// store. Therefore the outcome is remembered as a hint. Both names are still tried, so that a
// stale hint only costs the roundtrip it was there to save.
private (string First, string Second, bool IsOldFirst) FileNames(DomainId appId, DomainId id, long fileVersion, string? suffix)
{
var fileNameNew = GetFileName(appId, id, fileVersion, suffix);
var fileNameOld = GetFileName(id, fileVersion, suffix);
if (cache.TryGetValue<bool>(CacheKey(appId, id), out var useOld) && useOld)
{
return (fileNameOld, fileNameNew, true);
}
return (fileNameNew, fileNameOld, false);
}
private void RememberFileName(DomainId appId, DomainId id, bool useOld)
{
cache.Set(CacheKey(appId, id), useOld, CacheDuration);
}
private static object CacheKey(DomainId appId, DomainId id)
{
return (typeof(DefaultAssetFileStore), appId, id);
}
public Task DownloadAsync(string tempFile, Stream stream, public Task DownloadAsync(string tempFile, Stream stream,
CancellationToken ct = default) CancellationToken ct = default)
{ {

2
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetsBulkUpdateCommandMiddleware.cs

@ -56,7 +56,7 @@ public sealed class AssetsBulkUpdateCommandMiddleware(IContextProvider contextPr
return; return;
} }
contextProvider.Context.Change(b => b contextProvider.Context = contextProvider.Context.Clone(b => b
.WithNoAssetEnrichment() .WithNoAssetEnrichment()
.WithNoCleanup() .WithNoCleanup()
.WithUnpublished(true) .WithUnpublished(true)

26
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/CalculateTokens.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Text.Json.Serialization;
using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
@ -12,6 +13,19 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries.Steps;
public sealed class CalculateTokens(IUrlGenerator urlGenerator, IJsonSerializer serializer) : IAssetEnricherStep public sealed class CalculateTokens(IUrlGenerator urlGenerator, IJsonSerializer serializer) : IAssetEnricherStep
{ {
// We have to use these short names here because they are later read like this.
private sealed class Token
{
[JsonPropertyName("a")]
public string App { get; set; }
[JsonPropertyName("i")]
public string Id { get; set; }
[JsonPropertyName("u")]
public string Url { get; set; }
}
public Task EnrichAsync(Context context, IEnumerable<EnrichedAsset> assets, public Task EnrichAsync(Context context, IEnumerable<EnrichedAsset> assets,
CancellationToken ct) CancellationToken ct)
{ {
@ -20,17 +34,13 @@ public sealed class CalculateTokens(IUrlGenerator urlGenerator, IJsonSerializer
return Task.CompletedTask; return Task.CompletedTask;
} }
var url = urlGenerator.Root(); // Only the ID is different for each asset, so the token is reused for all of them.
var token = new Token { Url = urlGenerator.Root() };
foreach (var asset in assets) foreach (var asset in assets)
{ {
// We have to use these short names here because they are later read like this. token.App = asset.AppId.Name;
var token = new token.Id = asset.Id.ToString();
{
a = asset.AppId.Name,
i = asset.Id.ToString(),
u = url,
};
var json = serializer.SerializeToBytes(token); var json = serializer.SerializeToBytes(token);

35
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs

@ -13,6 +13,18 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries.Steps;
public sealed class ScriptAsset(IScriptEngine scriptEngine) : IAssetEnricherStep public sealed class ScriptAsset(IScriptEngine scriptEngine) : IAssetEnricherStep
{ {
private static readonly ScriptOptions PreOptions = new ScriptOptions
{
AsContext = true,
};
private static readonly ScriptOptions Options = new ScriptOptions
{
AsContext = true,
CanDisallow = true,
CanReject = true,
};
public async Task EnrichAsync(Context context, IEnumerable<EnrichedAsset> assets, public async Task EnrichAsync(Context context, IEnumerable<EnrichedAsset> assets,
CancellationToken ct) CancellationToken ct)
{ {
@ -40,21 +52,19 @@ public sealed class ScriptAsset(IScriptEngine scriptEngine) : IAssetEnricherStep
if (!string.IsNullOrWhiteSpace(preScript)) if (!string.IsNullOrWhiteSpace(preScript))
{ {
var options = new ScriptOptions await scriptEngine.ExecuteAsync(vars, preScript, PreOptions, ct);
{
AsContext = true,
};
await scriptEngine.ExecuteAsync(vars, preScript, options, ct);
} }
// The script is compiled once and then reused for all assets.
using var compiled = scriptEngine.CreateAsyncScript(script, Options);
foreach (var asset in assets) foreach (var asset in assets)
{ {
await ScriptAsync(vars, script, asset, ct); await ScriptAsync(compiled, vars, asset, ct);
} }
} }
private async Task ScriptAsync(AssetScriptVars sharedVars, string script, EnrichedAsset asset, private static async Task ScriptAsync(IAsyncScript script, AssetScriptVars sharedVars, EnrichedAsset asset,
CancellationToken ct) CancellationToken ct)
{ {
// Script vars are just wrappers over dictionaries for better performance. // Script vars are just wrappers over dictionaries for better performance.
@ -80,14 +90,7 @@ public sealed class ScriptAsset(IScriptEngine scriptEngine) : IAssetEnricherStep
vars.CopyFrom(sharedVars); vars.CopyFrom(sharedVars);
var options = new ScriptOptions await script.ExecuteAsync(vars, ct);
{
AsContext = true,
CanDisallow = true,
CanReject = true,
};
await scriptEngine.ExecuteAsync(vars, script, options, ct);
} }
private static bool ShouldEnrich(Context context) private static bool ShouldEnrich(Context context)

4
backend/src/Squidex.Domain.Apps.Entities/Assets/Transformations.cs

@ -76,7 +76,9 @@ public static class Transformations
{ {
await DownloadAsync(asset, assetFileStore, stream, ct); await DownloadAsync(asset, assetFileStore, stream, ct);
var bytes = stream.ToArray(); // Use the buffer of the pooled stream. ToArray would allocate another copy of the whole
// file, which for a file of up to the maximum size would go to the large object heap.
var bytes = new ReadOnlySpan<byte>(stream.GetBuffer(), 0, (int)stream.Length);
switch (encoding?.ToLowerInvariant()) switch (encoding?.ToLowerInvariant())
{ {

13
backend/src/Squidex.Domain.Apps.Entities/Billing/UsageGate.cs

@ -89,12 +89,17 @@ public sealed partial class UsageGate(
private bool HasNotifiedBefore(DomainId appId) private bool HasNotifiedBefore(DomainId appId)
{ {
return memoryCache.Get<bool>(appId); return memoryCache.Get<bool>(NotifiedKey(appId));
} }
private bool TrackNotified(DomainId appId) private bool TrackNotified(DomainId appId)
{ {
return memoryCache.Set(appId, true, TimeSpan.FromHours(1)); return memoryCache.Set(NotifiedKey(appId), true, TimeSpan.FromHours(1));
}
private static object NotifiedKey(DomainId appId)
{
return (typeof(UsageGate), nameof(TrackNotified), appId);
} }
private static string[] GetUsers(App app) private static string[] GetUsers(App app)
@ -192,8 +197,8 @@ public sealed partial class UsageGate(
return Task.FromResult((plan, planId)); return Task.FromResult((plan, planId));
} }
private static string CacheKey(DomainId appId) private static object CacheKey(DomainId appId)
{ {
return $"{appId}_Plan"; return (typeof(UsageGate), nameof(GetPlanForAppAsync), appId);
} }
} }

64
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsJintExtension.cs

@ -22,27 +22,23 @@ public sealed class ContentsJintExtension(IServiceProvider serviceProvider) : IJ
{ {
private delegate void GetContentsDelegate(string schema, JsValue query, Action<JsValue> callback); private delegate void GetContentsDelegate(string schema, JsValue query, Action<JsValue> callback);
public void ExtendAsync(ScriptExecutionContext context) public void ExtendAsync(Engine engine)
{ {
if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{
return;
}
if (!context.TryGetValueIfExists<ClaimsPrincipal>("user", out var user))
{
return;
}
var getContents = new GetContentsDelegate((schemas, query, callback) => var getContents = new GetContentsDelegate((schemas, query, callback) =>
{ {
GetContents(context, appId, user, schemas, query, callback); if (!engine.TryGetVar<DomainId>("appId", out var appId) ||
!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'getContents' is not available in this script.");
}
GetContents(engine, appId, user, schemas, query, callback);
}); });
context.Engine.SetValue("getContents", getContents); engine.SetValue("getContents", getContents);
} }
private void GetContents(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, private void GetContents(Engine engine, DomainId appId, ClaimsPrincipal user,
string schema, JsValue query, Action<JsValue> callback) string schema, JsValue query, Action<JsValue> callback)
{ {
if (callback == null) if (callback == null)
@ -50,15 +46,23 @@ public sealed class ContentsJintExtension(IServiceProvider serviceProvider) : IJ
throw new JavaScriptException("Callback is not defined."); throw new JavaScriptException("Callback is not defined.");
} }
context.Schedule(async (scheduler, ct) => // The query is read while we are still inside the engine.
var q = Q.Empty;
if (query is ObjectInstance obj)
{ {
var app = await GetAppAsync(appId); if (obj.TryGetValue("query", out var t) && t is JsString oDataQuery)
if (app == null)
{ {
scheduler.Run(callback, new JsArray(context.Engine)); q = q.WithODataQuery(oDataQuery.AsString());
return;
} }
}
else if (query is JsString oDataQueryValue)
{
q = q.WithODataQuery(oDataQueryValue.AsString());
}
engine.Schedule(async ct =>
{
var app = await GetAppAsync(appId);
var contentQuery = serviceProvider.GetRequiredService<IContentQueryService>(); var contentQuery = serviceProvider.GetRequiredService<IContentQueryService>();
@ -69,23 +73,9 @@ public sealed class ContentsJintExtension(IServiceProvider serviceProvider) : IJ
.WithUnpublished() .WithUnpublished()
.WithNoTotal()); .WithNoTotal());
var q = Q.Empty; return await contentQuery.QueryAsync(requestContext, schema, q, ct);
if (query is ObjectInstance obj) },
{ contents => callback(JsValue.FromObject(engine, contents.ToArray())));
if (obj.TryGetValue("query", out var t) && t is JsString oDataQuery)
{
q = q.WithODataQuery(oDataQuery.AsString());
}
}
else if (query is JsString oDataQuery)
{
q = q.WithODataQuery(oDataQuery.AsString());
}
var contents = await contentQuery.QueryAsync(requestContext, schema, q, ct);
scheduler.Run(callback, JsValue.FromObject(context.Engine, contents.ToArray()));
});
} }
private async Task<App> GetAppAsync(DomainId appId) private async Task<App> GetAppAsync(DomainId appId)

5
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentsOptions.cs

@ -19,6 +19,11 @@ public sealed class ContentsOptions
public int MaxResults { get; set; } = 200; public int MaxResults { get; set; } = 200;
// The number of IDs to fetch from the full text index. The index and the content store can be
// different databases, so the IDs have to be loaded to filter and sort them in the store. A
// query with more matches than this limit silently loses the rest.
public int MaxFullTextResults { get; set; } = 1000;
public string? CDN { get; set; } public string? CDN { get; set; }
public TimeSpan TimeoutFind { get; set; } = TimeSpan.FromSeconds(1); public TimeSpan TimeoutFind { get; set; } = TimeSpan.FromSeconds(1);

74
backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs

@ -5,7 +5,9 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Jint;
using Jint.Native; using Jint.Native;
using Jint.Runtime;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Properties; using Squidex.Domain.Apps.Entities.Properties;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -18,48 +20,58 @@ public sealed class CounterJintExtension(ICounterService counterService) : IJint
private delegate long CounterResetDelegate(string name, long value = 0); private delegate long CounterResetDelegate(string name, long value = 0);
private delegate void CounterResetV2Delegate(string name, Action<JsValue>? callback = null, long value = 0); private delegate void CounterResetV2Delegate(string name, Action<JsValue>? callback = null, long value = 0);
public void Extend(ScriptExecutionContext context) public void Extend(Engine engine)
{ {
if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{
return;
}
var increment = new Func<string, long>(name => var increment = new Func<string, long>(name =>
{ {
if (!engine.TryGetVar<DomainId>("appId", out var appId))
{
return 0;
}
return Increment(appId, name); return Increment(appId, name);
}); });
context.Engine.SetValue("incrementCounter", increment); engine.SetValue("incrementCounter", increment);
var reset = new CounterResetDelegate((name, value) => var reset = new CounterResetDelegate((name, value) =>
{ {
if (!engine.TryGetVar<DomainId>("appId", out var appId))
{
return 0;
}
return Reset(appId, name, value); return Reset(appId, name, value);
}); });
context.Engine.SetValue("resetCounter", reset); engine.SetValue("resetCounter", reset);
} }
public void ExtendAsync(ScriptExecutionContext context) public void ExtendAsync(Engine engine)
{ {
if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{
return;
}
var increment = new Action<string, Action<JsValue>>((name, callback) => var increment = new Action<string, Action<JsValue>>((name, callback) =>
{ {
IncrementV2(context, appId, name, callback); if (!engine.TryGetVar<DomainId>("appId", out var appId))
{
throw new JavaScriptException("'incrementCounterV2' is not available in this script.");
}
IncrementV2(engine, appId, name, callback);
}); });
context.Engine.SetValue("incrementCounterV2", increment); engine.SetValue("incrementCounterV2", increment);
var reset = new CounterResetV2Delegate((name, callback, value) => var reset = new CounterResetV2Delegate((name, callback, value) =>
{ {
ResetV2(context, appId, name, callback, value); if (!engine.TryGetVar<DomainId>("appId", out var appId))
{
throw new JavaScriptException("'resetCounterV2' is not available in this script.");
}
ResetV2(engine, appId, name, callback, value);
}); });
context.Engine.SetValue("resetCounterV2", reset); engine.SetValue("resetCounterV2", reset);
} }
private long Increment(DomainId appId, string name) private long Increment(DomainId appId, string name)
@ -67,17 +79,10 @@ public sealed class CounterJintExtension(ICounterService counterService) : IJint
return AsyncHelper.Sync(() => counterService.IncrementAsync(appId, name)); return AsyncHelper.Sync(() => counterService.IncrementAsync(appId, name));
} }
private void IncrementV2(ScriptExecutionContext context, DomainId appId, string name, Action<JsValue> callback) private void IncrementV2(Engine engine, DomainId appId, string name, Action<JsValue> callback)
{ {
context.Schedule(async (scheduler, ct) => engine.Schedule(ct => counterService.IncrementAsync(appId, name, ct),
{ result => callback?.Invoke(JsValue.FromObject(engine, result)));
var result = await counterService.IncrementAsync(appId, name, ct);
if (callback != null)
{
scheduler.Run(callback, JsValue.FromObject(context.Engine, result));
}
});
} }
private long Reset(DomainId appId, string name, long value) private long Reset(DomainId appId, string name, long value)
@ -85,17 +90,10 @@ public sealed class CounterJintExtension(ICounterService counterService) : IJint
return AsyncHelper.Sync(() => counterService.ResetAsync(appId, name, value)); return AsyncHelper.Sync(() => counterService.ResetAsync(appId, name, value));
} }
private void ResetV2(ScriptExecutionContext context, DomainId appId, string name, Action<JsValue>? callback, long value) private void ResetV2(Engine engine, DomainId appId, string name, Action<JsValue>? callback, long value)
{ {
context.Schedule(async (scheduler, ct) => engine.Schedule(ct => counterService.ResetAsync(appId, name, value, ct),
{ result => callback?.Invoke(JsValue.FromObject(engine, result)));
var result = await counterService.ResetAsync(appId, name, value, ct);
if (callback != null)
{
scheduler.Run(callback, JsValue.FromObject(context.Engine, result));
}
});
} }
public void Describe(AddDescription describe, ScriptScope scope) public void Describe(AddDescription describe, ScriptScope scope)

2
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentsBulkUpdateCommandMiddleware.cs

@ -61,7 +61,7 @@ public sealed class ContentsBulkUpdateCommandMiddleware(
return; return;
} }
contextProvider.Context.Change(b => b contextProvider.Context = contextProvider.Context.Clone(b => b
.WithNoEnrichment() .WithNoEnrichment()
.WithNoCleanup() .WithNoCleanup()
.WithUnpublished(true) .WithUnpublished(true)

28
backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/Guards/WorkflowExtensions.cs

@ -14,27 +14,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject.Guards;
public static class WorkflowExtensions public static class WorkflowExtensions
{ {
public static ValueTask<Status> GetInitialStatusAsync(this ContentOperation operation) public static async ValueTask<Status> GetInitialStatusAsync(this ContentOperation operation)
{ {
var workflow = GetWorkflow(operation); using var workflow = await GetWorkflowAsync(operation);
return workflow.GetInitialStatusAsync(operation.Schema); return workflow.GetInitialStatus();
} }
public static ValueTask<bool> ShouldValidateAsync(this ContentOperation operation, Status status) public static async ValueTask<bool> ShouldValidateAsync(this ContentOperation operation, Status status)
{ {
var workflow = GetWorkflow(operation); using var workflow = await GetWorkflowAsync(operation);
return workflow.ShouldValidateAsync(operation.Schema, status); return workflow.ShouldValidate(status);
} }
public static async Task CheckTransitionAsync(this ContentOperation operation, Status status) public static async Task CheckTransitionAsync(this ContentOperation operation, Status status)
{ {
if (operation.Schema.Type != SchemaType.Singleton) if (operation.Schema.Type != SchemaType.Singleton)
{ {
var workflow = GetWorkflow(operation); using var workflow = await GetWorkflowAsync(operation);
if (!await workflow.CanMoveToAsync(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, status, operation.User)) if (!workflow.CanMoveTo(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, status, operation.User))
{ {
var values = new { oldStatus = operation.Snapshot.EditingStatus, newStatus = status }; var values = new { oldStatus = operation.Snapshot.EditingStatus, newStatus = status };
@ -48,9 +48,9 @@ public static class WorkflowExtensions
{ {
if (operation.Schema.Type != SchemaType.Singleton) if (operation.Schema.Type != SchemaType.Singleton)
{ {
var workflow = GetWorkflow(operation); using var workflow = await GetWorkflowAsync(operation);
var statusInfo = await workflow.GetInfoAsync(operation.Snapshot.ToContent(), status); var statusInfo = workflow.GetInfo(status);
if (statusInfo == null) if (statusInfo == null)
{ {
@ -64,17 +64,17 @@ public static class WorkflowExtensions
{ {
if (operation.User != null) if (operation.User != null)
{ {
var workflow = GetWorkflow(operation); using var workflow = await GetWorkflowAsync(operation);
if (!await workflow.CanUpdateAsync(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, operation.User)) if (!workflow.CanUpdate(operation.Snapshot.ToContent(), operation.Snapshot.EditingStatus, operation.User))
{ {
throw new DomainException(T.Get("contents.workflowErrorUpdate", new { status = operation.Snapshot.EditingStatus })); throw new DomainException(T.Get("contents.workflowErrorUpdate", new { status = operation.Snapshot.EditingStatus }));
} }
} }
} }
private static IContentWorkflow GetWorkflow(ContentOperation operation) private static ValueTask<IContentWorkflow> GetWorkflowAsync(ContentOperation operation)
{ {
return operation.Resolve<IContentWorkflow>(); return operation.Resolve<IContentWorkflows>().GetWorkflowAsync(operation.App, operation.Schema);
} }
} }

143
backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs

@ -7,95 +7,104 @@
using System.Security.Claims; using System.Security.Claims;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents; namespace Squidex.Domain.Apps.Entities.Contents;
public sealed class DynamicContentWorkflow(IScriptEngine scriptEngine, IAppProvider appProvider) : IContentWorkflow public sealed class DynamicContentWorkflow(WorkflowDefinition definition, IScriptEngine scriptEngine) : IContentWorkflow
{ {
public async ValueTask<StatusInfo[]> GetAllAsync(Schema schema) // The same expression is evaluated for every content of a batch, therefore the compiled script is
{ // kept around. A script that cannot be compiled is stored as null to not retry it over and over.
var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); private readonly Dictionary<string, IScript?> scripts = [];
return workflow.Steps.Select(x => new StatusInfo(x.Key, GetColor(x.Value))).ToArray(); // The next statuses of a step without any condition are the same for every content and user.
} private readonly Dictionary<Status, StatusInfo[]> nextStatuses = [];
public async ValueTask<bool> CanPublishInitialAsync(Schema schema, ClaimsPrincipal? user) public StatusInfo[] GetAll()
{ {
var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); return definition.AllStatuses;
return workflow.TryGetTransition(workflow.Initial, Status.Published, out var transition) && IsTrue(transition, null, user);
} }
public async ValueTask<bool> CanMoveToAsync(Content content, Status status, Status next, ClaimsPrincipal? user) public Status GetInitialStatus()
{ {
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); return definition.Workflow.Initial;
return workflow.TryGetTransition(status, next, out var transition) && IsTrue(transition, content.Data, user);
} }
public async ValueTask<bool> CanUpdateAsync(Content content, Status status, ClaimsPrincipal? user) public StatusInfo? GetInfo(Status status)
{ {
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); return definition.StatusInfos.GetValueOrDefault(status);
if (workflow.TryGetStep(status, out var step))
{
return step.NoUpdate == null || !IsTrue(step.NoUpdate, content.Data, user);
}
return true;
} }
public async ValueTask<bool> ShouldValidateAsync(Schema schema, Status status) public bool ShouldValidate(Status status)
{ {
var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); if (definition.Workflow.TryGetStep(status, out var step) && step.Validate)
if (workflow.TryGetStep(status, out var step) && step.Validate)
{ {
return true; return true;
} }
return status == Status.Published && schema.Properties.ValidateOnPublish; return status == Status.Published && definition.ValidateOnPublish;
} }
public async ValueTask<StatusInfo?> GetInfoAsync(Content content, Status status) public bool CanPublishInitial(ClaimsPrincipal? user)
{ {
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); var workflow = definition.Workflow;
if (workflow.TryGetStep(status, out var step)) return workflow.TryGetTransition(workflow.Initial, Status.Published, out var transition) && IsTrue(transition, null, user);
{
return new StatusInfo(status, GetColor(step));
}
return null;
} }
public async ValueTask<Status> GetInitialStatusAsync(Schema schema) public bool CanMoveTo(Content content, Status status, Status next, ClaimsPrincipal? user)
{ {
var workflow = await GetWorkflowAsync(schema.AppId.Id, schema.Id); return definition.Workflow.TryGetTransition(status, next, out var transition) && IsTrue(transition, content.Data, user);
}
var (status, _) = workflow.GetInitialStepId(); public bool CanUpdate(Content content, Status status, ClaimsPrincipal? user)
{
if (definition.Workflow.TryGetStep(status, out var step))
{
return step.NoUpdate == null || !IsTrue(step.NoUpdate, content.Data, user);
}
return status; return true;
} }
public async ValueTask<StatusInfo[]> GetNextAsync(Content content, Status status, ClaimsPrincipal? user) public StatusInfo[] GetNext(Content content, Status status, ClaimsPrincipal? user)
{ {
var result = new List<StatusInfo>(); if (nextStatuses.TryGetValue(status, out var cached))
{
return cached;
}
var workflow = await GetWorkflowAsync(content.AppId.Id, content.SchemaId.Id); List<StatusInfo>? result = null;
foreach (var (to, step, transition) in workflow.GetTransitions(status)) var isStatic = true;
foreach (var (to, _, transition) in definition.Workflow.GetTransitions(status))
{ {
isStatic = isStatic && IsUnconditional(transition);
if (IsTrue(transition, content.Data, user)) if (IsTrue(transition, content.Data, user))
{ {
result.Add(new StatusInfo(to, GetColor(step))); result ??= [];
result.Add(definition.StatusInfos[to]);
} }
} }
return result.ToArray(); var statuses = result?.ToArray() ?? [];
if (isStatic)
{
nextStatuses[status] = statuses;
}
return statuses;
}
public void Dispose()
{
foreach (var script in scripts.Values)
{
script?.Dispose();
}
scripts.Clear();
} }
private bool IsTrue(WorkflowCondition condition, ContentData? data, ClaimsPrincipal? user) private bool IsTrue(WorkflowCondition condition, ContentData? data, ClaimsPrincipal? user)
@ -110,43 +119,45 @@ public sealed class DynamicContentWorkflow(IScriptEngine scriptEngine, IAppProvi
if (!string.IsNullOrWhiteSpace(condition?.Expression) && data != null) if (!string.IsNullOrWhiteSpace(condition?.Expression) && data != null)
{ {
var script = GetScript(condition.Expression);
if (script == null)
{
return false;
}
var vars = new DataScriptVars var vars = new DataScriptVars
{ {
Data = data, Data = data,
}; };
return scriptEngine.Evaluate(vars, condition.Expression); return script.Evaluate(vars);
} }
return true; return true;
} }
private async ValueTask<Workflow> GetWorkflowAsync(DomainId appId, DomainId schemaId) private IScript? GetScript(string expression)
{ {
Workflow? result = null; if (scripts.TryGetValue(expression, out var script))
var app = await appProvider.GetAppAsync(appId, false);
if (app != null)
{ {
result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Contains(schemaId)); return script;
if (result == null)
{
result = app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Count == 0);
}
} }
if (result == null) try
{
script = scriptEngine.CreateScript(expression);
}
catch
{ {
result = Workflow.Default; script = null;
} }
return result; scripts[expression] = script;
return script;
} }
private static string GetColor(WorkflowStep step) private static bool IsUnconditional(WorkflowCondition condition)
{ {
return step.Color ?? StatusColors.Draft; return condition.Roles == null && string.IsNullOrWhiteSpace(condition.Expression);
} }
} }

44
backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflows.cs

@ -0,0 +1,44 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Caching;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Core.Scripting;
namespace Squidex.Domain.Apps.Entities.Contents;
public sealed class DynamicContentWorkflows(IScriptEngine scriptEngine, ILocalCache localCache) : IContentWorkflows
{
public ValueTask<IContentWorkflow> GetWorkflowAsync(App app, Schema schema,
CancellationToken ct = default)
{
// The definition is only cached for the current request, because the workflow must never be
// stale. It is resolved several times per request, especially once per command guard.
var cacheKey = (nameof(DynamicContentWorkflows), app.Id, app.Version, schema.Id, schema.Version);
if (!localCache.TryGetValue(cacheKey, out var cached) || cached is not WorkflowDefinition definition)
{
definition = CreateDefinition(app, schema);
localCache.Add(cacheKey, definition);
}
return new ValueTask<IContentWorkflow>(new DynamicContentWorkflow(definition, scriptEngine));
}
private static WorkflowDefinition CreateDefinition(App app, Schema schema)
{
var workflow =
app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Contains(schema.Id)) ??
app.Workflows.Values.FirstOrDefault(x => x.SchemaIds.Count == 0) ??
Workflow.Default;
return new WorkflowDefinition(workflow, schema.Properties.ValidateOnPublish);
}
}

12
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Globalization;
using GraphQL; using GraphQL;
using GraphQL.DI; using GraphQL.DI;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -64,7 +63,11 @@ public sealed class CachingGraphQLResolver(
return CreateModelAsync(app); return CreateModelAsync(app);
} }
var cacheKey = CreateCacheKey(app.Id, app.Version.ToString(CultureInfo.InvariantCulture)); // The version is not part of the key. Building the schema is expensive and the version
// changes for every app event, most of which do not affect the schema at all. The validator
// below detects the changes that do, because SchemasHashKey contains the app version as
// well as the version of every schema.
var cacheKey = (typeof(CachingGraphQLResolver), app.Id);
return cache.GetOrCreateAsync(cacheKey, options.CacheDuration, async entry => return cache.GetOrCreateAsync(cacheKey, options.CacheDuration, async entry =>
{ {
@ -85,9 +88,4 @@ public sealed class CachingGraphQLResolver(
return new CacheEntry(new Builder(app, options).BuildSchema(schemasList), schemasKey); return new CacheEntry(new Builder(app, options).BuildSchema(schemasList), schemasKey);
} }
private static object CreateCacheKey(DomainId appId, string etag)
{
return $"GraphQLModel_{appId}_{etag}";
}
} }

45
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs

@ -23,10 +23,33 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
private const int MinBatchSize = 1; private const int MinBatchSize = 1;
private static readonly EmptyDataLoaderResult<EnrichedAsset> EmptyAssets = new EmptyDataLoaderResult<EnrichedAsset>(); private static readonly EmptyDataLoaderResult<EnrichedAsset> EmptyAssets = new EmptyDataLoaderResult<EnrichedAsset>();
private static readonly EmptyDataLoaderResult<EnrichedContent> EmptyContents = new EmptyDataLoaderResult<EnrichedContent>(); private static readonly EmptyDataLoaderResult<EnrichedContent> EmptyContents = new EmptyDataLoaderResult<EnrichedContent>();
// Field names are resolved to a new set for every field, therefore the sets have to be compared
// by their content. Otherwise the results are never matched to the keys they were loaded for.
private static readonly IEqualityComparer<HashSet<string>> FieldsComparer = HashSet<string>.CreateSetComparer();
private readonly IDataLoaderContextAccessor dataLoaders; private readonly IDataLoaderContextAccessor dataLoaders;
private readonly GraphQLOptions options;
private readonly int batchSize; private readonly int batchSize;
private sealed class ContentWithFieldsComparer : IEqualityComparer<(DomainId Id, HashSet<string> Fields)>
{
public static readonly ContentWithFieldsComparer Instance = new ContentWithFieldsComparer();
private ContentWithFieldsComparer()
{
}
public bool Equals((DomainId Id, HashSet<string> Fields) x, (DomainId Id, HashSet<string> Fields) y)
{
return x.Id.Equals(y.Id) && FieldsComparer.Equals(x.Fields, y.Fields);
}
public int GetHashCode((DomainId Id, HashSet<string> Fields) obj)
{
return HashCode.Combine(obj.Id, FieldsComparer.GetHashCode(obj.Fields));
}
}
public override Context Context { get; } public override Context Context { get; }
public bool CanExposePII { get; } public bool CanExposePII { get; }
@ -49,8 +72,6 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
.WithNoCleanup() .WithNoCleanup()
.WithNoEnrichment()); .WithNoEnrichment());
this.options = options.Value;
batchSize = Context.BatchSize(); batchSize = Context.BatchSize();
if (batchSize == 0) if (batchSize == 0)
@ -162,11 +183,21 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
return dataLoaders.Context!.GetOrAddNonCachingBatchLoader<(DomainId Id, HashSet<string> Fields), EnrichedContent>(nameof(GetContentsLoaderWithFields), return dataLoaders.Context!.GetOrAddNonCachingBatchLoader<(DomainId Id, HashSet<string> Fields), EnrichedContent>(nameof(GetContentsLoaderWithFields),
async (batch, ct) => async (batch, ct) =>
{ {
var fields = batch.SelectMany(x => x.Fields).ToHashSet(); var result = new Dictionary<(DomainId Id, HashSet<string> Fields), EnrichedContent>(ContentWithFieldsComparer.Instance);
var result = await QueryContentsByIdsAsync(batch.Select(x => x.Id), fields, ct); // A batch can contain several field selections. They cannot be merged into a single
// query, because every content must only contain the fields it was requested with.
foreach (var byFields in batch.GroupBy(x => x.Fields, FieldsComparer))
{
var contents = await QueryContentsByIdsAsync(byFields.Select(x => x.Id), byFields.Key, ct);
return result.ToDictionary(x => (x.Id, fields)); foreach (var content in contents)
{
result[(content.Id, byFields.Key)] = content;
}
}
return result;
}, maxBatchSize: batchSize); }, maxBatchSize: batchSize);
} }
@ -188,7 +219,7 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
for (var i = 0; i < ids.Count; i++) for (var i = 0; i < ids.Count; i++)
{ {
keys[i] = (ids[0], fields); keys[i] = (ids[i], fields);
} }
return keys; return keys;

19
backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs

@ -7,25 +7,24 @@
using System.Security.Claims; using System.Security.Claims;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Entities.Contents; namespace Squidex.Domain.Apps.Entities.Contents;
public interface IContentWorkflow public interface IContentWorkflow : IDisposable
{ {
ValueTask<Status> GetInitialStatusAsync(Schema schema); Status GetInitialStatus();
ValueTask<bool> CanMoveToAsync(Content content, Status status, Status next, ClaimsPrincipal? user); bool CanMoveTo(Content content, Status status, Status next, ClaimsPrincipal? user);
ValueTask<bool> CanUpdateAsync(Content content, Status status, ClaimsPrincipal? user); bool CanUpdate(Content content, Status status, ClaimsPrincipal? user);
ValueTask<bool> CanPublishInitialAsync(Schema schema, ClaimsPrincipal? user); bool CanPublishInitial(ClaimsPrincipal? user);
ValueTask<bool> ShouldValidateAsync(Schema schema, Status status); bool ShouldValidate(Status status);
ValueTask<StatusInfo?> GetInfoAsync(Content content, Status status); StatusInfo? GetInfo(Status status);
ValueTask<StatusInfo[]> GetNextAsync(Content content, Status status, ClaimsPrincipal? user); StatusInfo[] GetNext(Content content, Status status, ClaimsPrincipal? user);
ValueTask<StatusInfo[]> GetAllAsync(Schema schema); StatusInfo[] GetAll();
} }

17
backend/src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflows.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Entities.Contents;
public interface IContentWorkflows
{
ValueTask<IContentWorkflow> GetWorkflowAsync(App app, Schema schema,
CancellationToken ct = default);
}

10
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryParser.cs

@ -90,7 +90,7 @@ public class ContentQueryParser(
var searchFilters = new List<CompareFilter<ClrValue>>(); var searchFilters = new List<CompareFilter<ClrValue>>();
var textQuery = new TextQuery(query.FullText, 1000) var textQuery = new TextQuery(query.FullText, options.MaxFullTextResults)
{ {
PreferredSchemaId = schema.Id, PreferredSchemaId = schema.Id,
}; };
@ -277,19 +277,19 @@ public class ContentQueryParser(
{ {
if (schema == null) if (schema == null)
{ {
return $"EDM/__generic"; return $"EDM/{app.Id}/{app.Version}/{withHidden}";
} }
return $"EDM/{app.Version}/{schema.Id}_{schema.Version}/{withHidden}"; return $"EDM/{app.Id}/{app.Version}/{schema.Id}_{schema.Version}/{withHidden}";
} }
private static string BuildJsonCacheKey(App app, Schema? schema, bool withHidden) private static string BuildJsonCacheKey(App app, Schema? schema, bool withHidden)
{ {
if (schema == null) if (schema == null)
{ {
return $"JSON/__generic"; return $"JSON/{app.Id}/{app.Version}/{withHidden}";
} }
return $"JSON/{app.Version}/{schema.Id}_{schema.Version}/{withHidden}"; return $"JSON/{app.Id}/{app.Version}/{schema.Id}_{schema.Version}/{withHidden}";
} }
} }

8
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs

@ -46,9 +46,13 @@ public sealed class ContentQueryService(
// We run this query without a timeout because it is meant for long running background operations. // 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); var contents = contentRepository.StreamAll(context.App.Id, HashSet.Of(schema.Id), context.Scope(), ct);
await foreach (var content in contents.WithCancellation(ct)) await foreach (var batch in contents.Batch(50, ct).WithCancellation(ct))
{ {
yield return await contentEnricher.EnrichAsync(content, false, context, ct); var enriched = await contentEnricher.EnrichAsync(batch, context, ct);
foreach (var content in enriched)
{
yield return content;
}
} }
} }

13
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculatePreviewText.cs

@ -30,18 +30,25 @@ public sealed class CalculatePreviewText : IContentEnricherStep
private static void AddTexts(Schema schema, RichTextNode node, IEnumerable<EnrichedContent> contents) private static void AddTexts(Schema schema, RichTextNode node, IEnumerable<EnrichedContent> contents)
{ {
// The fields are the same for all contents of the schema, so they are only filtered once.
var richTextFields = schema.Fields.Where(x => x.RawProperties is RichTextFieldProperties).ToList();
if (richTextFields.Count == 0)
{
return;
}
foreach (var content in contents) foreach (var content in contents)
{ {
foreach (var field in schema.Fields.Where(x => x.RawProperties is RichTextFieldProperties)) foreach (var richTextField in richTextFields)
{ {
if (!content.Data.TryGetValue(field.Name, out var fieldData) || fieldData is not { Count: > 0 }) if (!content.Data.TryGetValue(richTextField.Name, out var fieldData) || fieldData is not { Count: > 0 })
{ {
continue; continue;
} }
content.ReferenceData ??= []; content.ReferenceData ??= [];
var fieldReference = content.ReferenceData.GetOrAdd(field.Name, _ => [])!; var fieldReference = content.ReferenceData.GetOrAdd(richTextField.Name, _ => [])!;
foreach (var (partitionKey, partitionValue) in fieldData) foreach (var (partitionKey, partitionValue) in fieldData)
{ {

31
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/CalculateTokens.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Text.Json.Serialization;
using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
@ -12,21 +13,33 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
public sealed class CalculateTokens(IUrlGenerator urlGenerator, IJsonSerializer serializer) : IContentEnricherStep public sealed class CalculateTokens(IUrlGenerator urlGenerator, IJsonSerializer serializer) : IContentEnricherStep
{ {
// We have to use these short names here because they are later read like this.
private sealed class Token
{
[JsonPropertyName("a")]
public string App { get; set; }
[JsonPropertyName("s")]
public string Schema { get; set; }
[JsonPropertyName("i")]
public string Id { get; set; }
[JsonPropertyName("u")]
public string Url { get; set; }
}
public Task EnrichAsync(Context context, IEnumerable<EnrichedContent> contents, ProvideSchema schemas, public Task EnrichAsync(Context context, IEnumerable<EnrichedContent> contents, ProvideSchema schemas,
CancellationToken ct) CancellationToken ct)
{ {
var url = urlGenerator.Root(); // Only the schema and the ID are different for each content, so the token is reused.
var token = new Token { Url = urlGenerator.Root() };
foreach (var content in contents) foreach (var content in contents)
{ {
// We have to use these short names here because they are later read like this. token.Id = content.Id.ToString();
var token = new token.App = content.AppId.Name;
{ token.Schema = content.SchemaId.Name;
a = content.AppId.Name,
s = content.SchemaId.Name,
i = content.Id.ToString(),
u = url,
};
var json = serializer.SerializeToBytes(token); var json = serializer.SerializeToBytes(token);

8
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs

@ -43,11 +43,15 @@ public sealed class EnrichForCaching(IRequestCache requestCache) : IContentEnric
var (schema, _) = await schemas(group.Key); var (schema, _) = await schemas(group.Key);
// The app and the schema are the same for all contents of the group, so they are added
// once per group and not once per content. They are added inside the loop, so that a
// result without contents also has no dependencies and therefore no etag, as before.
requestCache.AddDependency(app.UniqueId, app.Version);
requestCache.AddDependency(schema.UniqueId, schema.Version);
foreach (var content in group) foreach (var content in group)
{ {
requestCache.AddDependency(content.UniqueId, content.Version); requestCache.AddDependency(content.UniqueId, content.Version);
requestCache.AddDependency(schema.UniqueId, schema.Version);
requestCache.AddDependency(app.UniqueId, app.Version);
} }
} }
} }

58
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichWithWorkflows.cs

@ -6,34 +6,44 @@
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps; namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
public sealed class EnrichWithWorkflows(IContentWorkflow contentWorkflow) : IContentEnricherStep public sealed class EnrichWithWorkflows(IContentWorkflows contentWorkflows) : IContentEnricherStep
{ {
private const string DefaultColor = StatusColors.Draft; private const string DefaultColor = StatusColors.Draft;
public async Task EnrichAsync(Context context, IEnumerable<EnrichedContent> contents, ProvideSchema schemas, public async Task EnrichAsync(Context context, IEnumerable<EnrichedContent> contents, ProvideSchema schemas,
CancellationToken ct) CancellationToken ct)
{ {
var cache = new Dictionary<(DomainId, Status), StatusInfo>(); var withStatuses = ShouldEnrichWithStatuses(context);
foreach (var content in contents) foreach (var group in contents.GroupBy(x => x.SchemaId.Id))
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
await EnrichColorAsync(content, cache); var (schema, _) = await schemas(group.Key);
if (ShouldEnrichWithStatuses(context)) // The workflow is resolved once per schema and caches the compiled conditions and the
// status colors for all contents of the group.
using var workflow = await contentWorkflows.GetWorkflowAsync(context.App, schema, ct);
foreach (var content in group)
{ {
await EnrichNextsAsync(content, context); ct.ThrowIfCancellationRequested();
await EnrichCanUpdateAsync(content, context);
EnrichColor(content, workflow);
if (withStatuses)
{
EnrichNexts(content, workflow, context);
EnrichCanUpdate(content, workflow, context);
}
} }
} }
} }
private async Task EnrichNextsAsync(EnrichedContent content, Context context) private static void EnrichNexts(EnrichedContent content, IContentWorkflow workflow, Context context)
{ {
var editingStatus = content.NewStatus ?? content.Status; var editingStatus = content.NewStatus ?? content.Status;
@ -53,47 +63,35 @@ public sealed class EnrichWithWorkflows(IContentWorkflow contentWorkflow) : ICon
} }
else else
{ {
content.NextStatuses = await contentWorkflow.GetNextAsync(content, editingStatus, context.UserPrincipal); content.NextStatuses = workflow.GetNext(content, editingStatus, context.UserPrincipal);
} }
} }
private async Task EnrichCanUpdateAsync(EnrichedContent content, Context context) private static void EnrichCanUpdate(EnrichedContent content, IContentWorkflow workflow, Context context)
{ {
var editingStatus = content.NewStatus ?? content.Status; var editingStatus = content.NewStatus ?? content.Status;
content.CanUpdate = await contentWorkflow.CanUpdateAsync(content, editingStatus, context.UserPrincipal); content.CanUpdate = workflow.CanUpdate(content, editingStatus, context.UserPrincipal);
} }
private async Task EnrichColorAsync(EnrichedContent content, Dictionary<(DomainId, Status), StatusInfo> cache) private static void EnrichColor(EnrichedContent content, IContentWorkflow workflow)
{ {
content.StatusColor = await GetColorAsync(content, content.Status, cache); content.StatusColor = GetColor(workflow, content.Status);
if (content.NewStatus != null) if (content.NewStatus != null)
{ {
content.NewStatusColor = await GetColorAsync(content, content.NewStatus.Value, cache); content.NewStatusColor = GetColor(workflow, content.NewStatus.Value);
} }
if (content.ScheduleJob != null) if (content.ScheduleJob != null)
{ {
content.ScheduledStatusColor = await GetColorAsync(content, content.ScheduleJob.Status, cache); content.ScheduledStatusColor = GetColor(workflow, content.ScheduleJob.Status);
} }
} }
private async Task<string> GetColorAsync(Content content, Status status, Dictionary<(DomainId, Status), StatusInfo> cache) private static string GetColor(IContentWorkflow workflow, Status status)
{ {
if (!cache.TryGetValue((content.SchemaId.Id, status), out var info)) return workflow.GetInfo(status)?.Color ?? DefaultColor;
{
info = await contentWorkflow.GetInfoAsync(content, status);
if (info == null)
{
info = new StatusInfo(status, DefaultColor);
}
cache[(content.SchemaId.Id, status)] = info;
}
return info.Color;
} }
private static bool ShouldEnrichWithStatuses(Context context) private static bool ShouldEnrichWithStatuses(Context context)

6
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs

@ -128,9 +128,13 @@ public sealed class ResolveAssets(IUrlGenerator urlGenerator, IAssetQueryService
private static void AddAssetIds(HashSet<DomainId> ids, Schema schema, ResolvedComponents components, IEnumerable<EnrichedContent> contents) private static void AddAssetIds(HashSet<DomainId> ids, Schema schema, ResolvedComponents components, IEnumerable<EnrichedContent> contents)
{ {
// ResolvingAssets is a lazy query over all fields of the schema, therefore it is only
// evaluated once here and not again for every content.
var fields = schema.ResolvingAssets().ToList();
foreach (var content in contents) foreach (var content in contents)
{ {
content.Data.AddReferencedIds(schema.ResolvingAssets(), ids, components, 1); content.Data.AddReferencedIds(fields, ids, components, 1);
} }
} }

6
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs

@ -136,9 +136,13 @@ public sealed class ResolveReferences(Lazy<IContentQueryService> contentQuery, I
private static void AddReferenceIds(HashSet<DomainId> ids, Schema schema, ResolvedComponents components, IEnumerable<EnrichedContent> contents) private static void AddReferenceIds(HashSet<DomainId> ids, Schema schema, ResolvedComponents components, IEnumerable<EnrichedContent> contents)
{ {
// ResolvingReferences is a lazy query over all fields of the schema, therefore it is only
// evaluated once here and not again for every content.
var fields = schema.ResolvingReferences().ToList();
foreach (var content in contents) foreach (var content in contents)
{ {
content.Data.AddReferencedIds(schema.ResolvingReferences(), ids, components); content.Data.AddReferencedIds(fields, ids, components);
} }
} }

35
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ScriptContent.cs

@ -12,6 +12,18 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
public sealed class ScriptContent(IScriptEngine scriptEngine) : IContentEnricherStep public sealed class ScriptContent(IScriptEngine scriptEngine) : IContentEnricherStep
{ {
private static readonly ScriptOptions PreOptions = new ScriptOptions
{
AsContext = true,
};
private static readonly ScriptOptions Options = new ScriptOptions
{
AsContext = true,
CanDisallow = true,
CanReject = true,
};
public async Task EnrichAsync(Context context, IEnumerable<EnrichedContent> contents, ProvideSchema schemas, public async Task EnrichAsync(Context context, IEnumerable<EnrichedContent> contents, ProvideSchema schemas,
CancellationToken ct) CancellationToken ct)
{ {
@ -46,22 +58,20 @@ public sealed class ScriptContent(IScriptEngine scriptEngine) : IContentEnricher
if (!string.IsNullOrWhiteSpace(preScript)) if (!string.IsNullOrWhiteSpace(preScript))
{ {
var options = new ScriptOptions await scriptEngine.ExecuteAsync(vars, preScript, PreOptions, ct);
{
AsContext = true,
};
await scriptEngine.ExecuteAsync(vars, preScript, options, ct);
} }
// The script is compiled once and then reused for all contents of the schema.
using var compiled = scriptEngine.CreateAsyncScript(script, Options);
foreach (var content in group) foreach (var content in group)
{ {
await TransformAsync(vars, script, content, ct); await TransformAsync(compiled, vars, content, ct);
} }
} }
} }
private async Task TransformAsync(ContentScriptVars sharedVars, string script, EnrichedContent content, private static async Task TransformAsync(IAsyncScript script, ContentScriptVars sharedVars, EnrichedContent content,
CancellationToken ct) CancellationToken ct)
{ {
// Script vars are just wrappers over dictionaries for better performance. // Script vars are just wrappers over dictionaries for better performance.
@ -80,14 +90,7 @@ public sealed class ScriptContent(IScriptEngine scriptEngine) : IContentEnricher
vars.CopyFrom(sharedVars); vars.CopyFrom(sharedVars);
var options = new ScriptOptions content.Data = await script.TransformAsync(vars, ct);
{
AsContext = true,
CanDisallow = true,
CanReject = true,
};
content.Data = await scriptEngine.TransformAsync(vars, script, options, ct);
} }
private static bool ShouldEnrich(Context context) private static bool ShouldEnrich(Context context)

99
backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Security.Claims; using System.Security.Claims;
using Jint;
using Jint.Native; using Jint.Native;
using Jint.Runtime; using Jint.Runtime;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -21,34 +22,36 @@ public sealed class ReferencesJintExtension(IServiceProvider serviceProvider) :
{ {
private delegate void GetReferencesDelegate(JsValue references, Action<JsValue> callback); private delegate void GetReferencesDelegate(JsValue references, Action<JsValue> callback);
public void ExtendAsync(ScriptExecutionContext context) public void ExtendAsync(Engine engine)
{ {
if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{
return;
}
if (!context.TryGetValueIfExists<ClaimsPrincipal>("user", out var user))
{
return;
}
var getReference = new GetReferencesDelegate((references, callback) => var getReference = new GetReferencesDelegate((references, callback) =>
{ {
GetReference(context, appId, user, references, callback); if (!engine.TryGetVar<DomainId>("appId", out var appId) ||
!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'getReference' is not available in this script.");
}
GetReference(engine, appId, user, references, callback);
}); });
var getReferences = new GetReferencesDelegate((references, callback) => var getReferences = new GetReferencesDelegate((references, callback) =>
{ {
GetReferences(context, appId, user, references, callback); if (!engine.TryGetVar<DomainId>("appId", out var appId) ||
!engine.TryGetVar<ClaimsPrincipal>("user", out var user))
{
throw new JavaScriptException("'getReferences' is not available in this script.");
}
GetReferences(engine, appId, user, references, callback);
}); });
context.Engine.SetValue("getReference", getReferences); engine.SetValue("getReference", getReferences);
context.Engine.SetValue("getReferenceV2", getReference); engine.SetValue("getReferenceV2", getReference);
context.Engine.SetValue("getReferences", getReferences); engine.SetValue("getReferences", getReferences);
} }
private void GetReferences(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, private void GetReferences(Engine engine, DomainId appId, ClaimsPrincipal user,
JsValue references, Action<JsValue> callback) JsValue references, Action<JsValue> callback)
{ {
if (callback == null) if (callback == null)
@ -56,24 +59,19 @@ public sealed class ReferencesJintExtension(IServiceProvider serviceProvider) :
throw new JavaScriptException("Callback is not defined."); throw new JavaScriptException("Callback is not defined.");
} }
context.Schedule(async (scheduler, ct) => // The javascript values are read while we are still inside the engine.
{ var ids = references.ToIds();
var ids = references.ToIds();
if (ids.Count == 0) if (ids.Count == 0)
{ {
scheduler.Run(callback, new JsArray(context.Engine)); callback(new JsArray(engine));
return; return;
} }
engine.Schedule(async ct =>
{
var app = await GetAppAsync(appId); var app = await GetAppAsync(appId);
if (app == null)
{
scheduler.Run(callback, new JsArray(context.Engine));
return;
}
var contentQuery = serviceProvider.GetRequiredService<IContentQueryService>(); var contentQuery = serviceProvider.GetRequiredService<IContentQueryService>();
var requestContext = var requestContext =
@ -83,13 +81,12 @@ public sealed class ReferencesJintExtension(IServiceProvider serviceProvider) :
.WithUnpublished() .WithUnpublished()
.WithNoTotal()); .WithNoTotal());
var contents = await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(ids), ct); return await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(ids), ct);
},
scheduler.Run(callback, JsValue.FromObject(context.Engine, contents.ToArray())); contents => callback(JsValue.FromObject(engine, contents.ToArray())));
});
} }
private void GetReference(ScriptExecutionContext context, DomainId appId, ClaimsPrincipal user, private void GetReference(Engine engine, DomainId appId, ClaimsPrincipal user,
JsValue references, Action<JsValue> callback) JsValue references, Action<JsValue> callback)
{ {
if (callback == null) if (callback == null)
@ -97,24 +94,19 @@ public sealed class ReferencesJintExtension(IServiceProvider serviceProvider) :
throw new JavaScriptException("Callback is not defined."); throw new JavaScriptException("Callback is not defined.");
} }
context.Schedule(async (scheduler, ct) => // The javascript values are read while we are still inside the engine.
{ var ids = references.ToIds();
var ids = references.ToIds();
if (ids.Count == 0) if (ids.Count == 0)
{ {
scheduler.Run(callback, JsValue.Null); callback(JsValue.Null);
return; return;
} }
engine.Schedule(async ct =>
{
var app = await GetAppAsync(appId); var app = await GetAppAsync(appId);
if (app == null)
{
scheduler.Run(callback, JsValue.Null);
return;
}
var contentQuery = serviceProvider.GetRequiredService<IContentQueryService>(); var contentQuery = serviceProvider.GetRequiredService<IContentQueryService>();
var requestContext = var requestContext =
@ -124,10 +116,9 @@ public sealed class ReferencesJintExtension(IServiceProvider serviceProvider) :
.WithUnpublished() .WithUnpublished()
.WithNoTotal()); .WithNoTotal());
var contents = await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(ids), ct); return await contentQuery.QueryAsync(requestContext, Q.Empty.WithIds(ids), ct);
},
scheduler.Run(callback, JsValue.FromObject(context.Engine, contents.FirstOrDefault())); contents => callback(JsValue.FromObject(engine, contents.FirstOrDefault())));
});
} }
private async Task<App> GetAppAsync(DomainId appId) private async Task<App> GetAppAsync(DomainId appId)

38
backend/src/Squidex.Domain.Apps.Entities/Contents/WorkflowDefinition.cs

@ -0,0 +1,38 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.Contents;
public sealed class WorkflowDefinition
{
public Workflow Workflow { get; }
public IReadOnlyDictionary<Status, StatusInfo> StatusInfos { get; }
public StatusInfo[] AllStatuses { get; }
public bool ValidateOnPublish { get; }
public WorkflowDefinition(Workflow workflow, bool validateOnPublish)
{
// The status infos never change for a workflow, therefore they are created once and shared
// by all contents instead of allocating them for every single status lookup.
var statusInfos = new Dictionary<Status, StatusInfo>(workflow.Steps.Count);
foreach (var (status, step) in workflow.Steps)
{
statusInfos[status] = new StatusInfo(status, step.Color ?? StatusColors.Draft);
}
Workflow = workflow;
StatusInfos = statusInfos;
AllStatuses = [.. statusInfos.Values];
ValidateOnPublish = validateOnPublish;
}
}

50
backend/src/Squidex.Domain.Apps.Entities/Context.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Concurrent;
using System.Security.Claims; using System.Security.Claims;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -20,16 +21,23 @@ namespace Squidex.Domain.Apps.Entities;
public sealed class Context public sealed class Context
{ {
private static readonly IReadOnlyDictionary<string, string> EmptyHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); private static readonly IReadOnlyDictionary<string, string> EmptyHeaders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
private static readonly char[] Separators = [',', ';'];
public IReadOnlyDictionary<string, string> Headers { get; private set; } // Splitting a header is not free and the same headers are read several times per request, for
// example once per schema of a query. A concurrent dictionary is used because a context is
// shared between the parallel resolvers of a GraphQL query. The context is immutable, so the
// parsed values never have to be invalidated.
private readonly ConcurrentDictionary<string, string[]> headerValues = new ConcurrentDictionary<string, string[]>(StringComparer.OrdinalIgnoreCase);
public IReadOnlyDictionary<string, string> Headers { get; }
public ClaimsPermissions UserPermissions { get; } public ClaimsPermissions UserPermissions { get; }
public ClaimsPrincipal UserPrincipal { get; } public ClaimsPrincipal UserPrincipal { get; }
public App App { get; set; } public App App { get; }
public bool IsFrontendClient => UserPrincipal.IsInClient(DefaultClients.Frontend); public bool IsFrontendClient { get; }
public Context(ClaimsPrincipal user, App app) public Context(ClaimsPrincipal user, App app)
: this(app, user, user.Claims.Permissions(), EmptyHeaders) : this(app, user, user.Claims.Permissions(), EmptyHeaders)
@ -48,6 +56,8 @@ public sealed class Context
UserPrincipal = userPrincipal; UserPrincipal = userPrincipal;
UserPermissions = userPermissions; UserPermissions = userPermissions;
IsFrontendClient = userPrincipal.IsInClient(DefaultClients.Frontend);
Headers = headers; Headers = headers;
} }
@ -69,6 +79,25 @@ public sealed class Context
return new Context(claimsPrincipal, app); return new Context(claimsPrincipal, app);
} }
internal string[] HeaderValues(string key)
{
if (headerValues.TryGetValue(key, out var result))
{
return result;
}
if (!Headers.TryGetValue(key, out var value))
{
return [];
}
result = value.Split(Separators, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Distinct().ToArray();
headerValues[key] = result;
return result;
}
public bool Allows(string permissionId, string schema = Permission.Any) public bool Allows(string permissionId, string schema = Permission.Any)
{ {
return UserPermissions.Allows(permissionId, App.Name, schema); return UserPermissions.Allows(permissionId, App.Name, schema);
@ -88,13 +117,6 @@ public sealed class Context
return context; return context;
} }
public Context Update()
{
context.Headers = headers ?? context.Headers;
return context;
}
public void Remove(string key) public void Remove(string key)
{ {
headers ??= new Dictionary<string, string>(context.Headers, StringComparer.OrdinalIgnoreCase); headers ??= new Dictionary<string, string>(context.Headers, StringComparer.OrdinalIgnoreCase);
@ -108,13 +130,9 @@ public sealed class Context
} }
} }
public Context Change(Action<ICloneBuilder> action) public Context WithApp(App app)
{ {
var builder = new HeaderBuilder(this); return new Context(app, UserPrincipal, UserPermissions, Headers);
action(builder);
return builder.Update();
} }
public Context Clone(Action<ICloneBuilder> action) public Context Clone(Action<ICloneBuilder> action)

11
backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs

@ -11,8 +11,6 @@ namespace Squidex.Domain.Apps.Entities;
public static class ContextHeaders public static class ContextHeaders
{ {
private static readonly char[] Separators = [',', ';'];
public const string KeyBatchSize = "X-BatchSize"; public const string KeyBatchSize = "X-BatchSize";
public const string KeyNoCacheKeys = "X-NoCacheKeys"; public const string KeyNoCacheKeys = "X-NoCacheKeys";
public const string KeyNoScripting = "X-NoScripting"; public const string KeyNoScripting = "X-NoScripting";
@ -128,11 +126,8 @@ public static class ContextHeaders
public static IEnumerable<string> AsStrings(this Context context, string key) public static IEnumerable<string> AsStrings(this Context context, string key)
{ {
if (context.Headers.TryGetValue(key, out var value)) // The context parses the header once and keeps the result, because the same headers are
{ // read several times while a query is enriched.
return value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).Distinct(); return context.HeaderValues(key);
}
return [];
} }
} }

3
backend/src/Squidex.Domain.Apps.Entities/IContextProvider.cs

@ -9,5 +9,6 @@ namespace Squidex.Domain.Apps.Entities;
public interface IContextProvider public interface IContextProvider
{ {
Context Context { get; } // The context is immutable, so it has to be replaced to change it for the current request.
Context Context { get; set; }
} }

31
backend/src/Squidex.Domain.Apps.Entities/Jobs/JobWorker.cs

@ -68,18 +68,39 @@ public sealed class JobWorker :
return GetJobProcessorAsync(message.OwnerId); return GetJobProcessorAsync(message.OwnerId);
} }
private Task<JobProcessor> GetJobProcessorAsync(DomainId appId) private async Task<JobProcessor> GetJobProcessorAsync(DomainId appId)
{ {
Task<JobProcessor> processor;
lock (processors) lock (processors)
{ {
return processors.GetOrAdd(appId, async key => processor = processors.GetOrAdd(appId, async key =>
{ {
var processor = processorFactory(key); var loaded = processorFactory(key);
await processor.LoadAsync(default); await loaded.LoadAsync(default);
return processor; return loaded;
}); });
} }
try
{
return await processor;
}
catch
{
// A failed attempt must not stay in the cache. Loading can fail for a transient reason
// and the jobs of the app would never run again. Only remove our own entry, so that a
// newer successful one is not thrown away.
lock (processors)
{
if (processors.TryGetValue(appId, out var current) && ReferenceEquals(current, processor))
{
processors.Remove(appId);
}
}
throw;
}
} }
} }

27
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs

@ -79,19 +79,27 @@ public sealed class RuleEnqueuer(
// Write in batches of 100 items for better performance. Dispose completes the last write. // Write in batches of 100 items for better performance. Dispose completes the last write.
await using var batch = new RuleQueueWriter(flowManager, ruleUsageTracker, log); await using var batch = new RuleQueueWriter(flowManager, ruleUsageTracker, log);
foreach (var @event in events) static NamedId<DomainId>? GetAppId(Envelope<IEvent> @event)
{ {
if (@event.Headers.Restored()) // Returns null for events that are not handled, so that they all end up in one group to skip.
if (@event.Headers.Restored() || @event.Payload is not AppEvent appEvent)
{ {
continue; return null;
} }
if (@event.Payload is not AppEvent appEvent) return appEvent.AppId;
}
// The events of a batch usually belong to the same app, so the rules are only resolved
// and indexed once per app instead of once per event.
foreach (var byApp in events.GroupBy(GetAppId))
{
if (byApp.Key == null)
{ {
continue; continue;
} }
var rules = await GetRulesAsync(appEvent.AppId.Id); var rules = await GetRulesAsync(byApp.Key.Id);
if (rules.Count == 0) if (rules.Count == 0)
{ {
continue; continue;
@ -99,7 +107,7 @@ public sealed class RuleEnqueuer(
var context = new RulesContext var context = new RulesContext
{ {
AppId = appEvent.AppId, AppId = byApp.Key,
AllowExtraEvents = maxExtraEvents > 0, AllowExtraEvents = maxExtraEvents > 0,
IncludeSkipped = false, IncludeSkipped = false,
IncludeStale = false, IncludeStale = false,
@ -107,9 +115,12 @@ public sealed class RuleEnqueuer(
MaxEvents = maxExtraEvents, MaxEvents = maxExtraEvents,
}; };
await foreach (var result in ruleService.CreateJobsAsync(@event, context)) foreach (var @event in byApp)
{ {
await batch.WriteAsync(appEvent.AppId.Id, result); await foreach (var result in ruleService.CreateJobsAsync(@event, context))
{
await batch.WriteAsync(byApp.Key.Id, result);
}
} }
} }
} }

24
backend/src/Squidex.Infrastructure/Log/BackgroundRequestLogStore.cs

@ -20,6 +20,8 @@ public sealed class BackgroundRequestLogStore : DisposableObjectBase, IRequestLo
private readonly CompletionTimer logTimer; private readonly CompletionTimer logTimer;
private readonly RequestLogStoreOptions options; private readonly RequestLogStoreOptions options;
private readonly ConcurrentQueue<Request> jobs = new ConcurrentQueue<Request>(); private readonly ConcurrentQueue<Request> jobs = new ConcurrentQueue<Request>();
private int jobsCount;
private int jobsDropped;
private bool isUpdating; private bool isUpdating;
public bool HasPendingJobs => !jobs.IsEmpty || isUpdating; public bool HasPendingJobs => !jobs.IsEmpty || isUpdating;
@ -68,10 +70,21 @@ public sealed class BackgroundRequestLogStore : DisposableObjectBase, IRequestLo
isUpdating = true; isUpdating = true;
try try
{ {
// Report the entries that have been dropped since the last run, so that the gap in the
// request log is visible instead of silent.
var dropped = Interlocked.Exchange(ref jobsDropped, 0);
if (dropped > 0)
{
LogMessages.LogRequestLogDropped(log, dropped);
}
var batch = new List<Request>(options.BatchSize); var batch = new List<Request>(options.BatchSize);
while (jobs.TryDequeue(out var dequeued)) while (jobs.TryDequeue(out var dequeued))
{ {
Interlocked.Decrement(ref jobsCount);
batch.Add(dequeued); batch.Add(dequeued);
if (batch.Count >= options.BatchSize) if (batch.Count >= options.BatchSize)
@ -123,6 +136,17 @@ public sealed class BackgroundRequestLogStore : DisposableObjectBase, IRequestLo
return Task.CompletedTask; return Task.CompletedTask;
} }
// The queue is only drained by a timer. If the repository is not available for a longer time
// the queue would grow until the process runs out of memory, so new entries are dropped.
// Logging a request is never important enough to take the whole process down.
if (Volatile.Read(ref jobsCount) >= options.MaxPendingItems)
{
Interlocked.Increment(ref jobsDropped);
return Task.CompletedTask;
}
Interlocked.Increment(ref jobsCount);
jobs.Enqueue(request); jobs.Enqueue(request);
return Task.CompletedTask; return Task.CompletedTask;

4
backend/src/Squidex.Infrastructure/Log/RequestLogStoreOptions.cs

@ -16,4 +16,8 @@ public sealed class RequestLogStoreOptions
public int BatchSize { get; set; } = 1000; public int BatchSize { get; set; } = 1000;
public int WriteIntervall { get; set; } = 1000; public int WriteIntervall { get; set; } = 1000;
// Requests are only written every few seconds. When the repository is not available the pending
// entries would grow without a limit, therefore they are dropped when this size is reached.
public int MaxPendingItems { get; set; } = 50_000;
} }

3
backend/src/Squidex.Infrastructure/LogMessages.cs

@ -56,6 +56,9 @@ internal static partial class LogMessages
[LoggerMessage(Level = LogLevel.Error, Message = "Failed to track usage in background.")] [LoggerMessage(Level = LogLevel.Error, Message = "Failed to track usage in background.")]
public static partial void LogTrackUsageFailed(ILogger logger, Exception exception); public static partial void LogTrackUsageFailed(ILogger logger, Exception exception);
[LoggerMessage(Level = LogLevel.Warning, Message = "Dropped {count} request log entries, because the pending queue is full.")]
public static partial void LogRequestLogDropped(ILogger logger, int count);
[LoggerMessage(Level = LogLevel.Error, Message = "Failed to repair snapshot for domain object of type {type} with ID {id}.")] [LoggerMessage(Level = LogLevel.Error, Message = "Failed to repair snapshot for domain object of type {type} with ID {id}.")]
public static partial void LogFailedToRepairDomainObjectSnapshot(ILogger logger, Type type, DomainId id, Exception exception); public static partial void LogFailedToRepairDomainObjectSnapshot(ILogger logger, Type type, DomainId id, Exception exception);

10
backend/src/Squidex.Infrastructure/Security/Extensions.cs

@ -69,6 +69,14 @@ public static class Extensions
public static bool IsInClient(this ClaimsPrincipal principal, string client) public static bool IsInClient(this ClaimsPrincipal principal, string client)
{ {
return principal.Claims.Any(x => x.Type == OpenIdClaims.ClientId && string.Equals(x.Value, client, StringComparison.OrdinalIgnoreCase)); foreach (var claim in principal.Claims)
{
if (claim.Type == OpenIdClaims.ClientId && string.Equals(claim.Value, client, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
} }
} }

14
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -24,13 +24,13 @@
<PackageReference Include="NodaTime" Version="3.3.1" /> <PackageReference Include="NodaTime" Version="3.3.1" />
<PackageReference Include="OpenTelemetry.Api" Version="1.15.3" /> <PackageReference Include="OpenTelemetry.Api" Version="1.15.3" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="8.0.3" /> <PackageReference Include="Squidex.Assets" Version="8.0.4" />
<PackageReference Include="Squidex.Caching" Version="8.0.3" /> <PackageReference Include="Squidex.Caching" Version="8.0.4" />
<PackageReference Include="Squidex.Events" Version="8.0.3" /> <PackageReference Include="Squidex.Events" Version="8.0.4" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="8.0.3" /> <PackageReference Include="Squidex.Hosting.Abstractions" Version="8.0.4" />
<PackageReference Include="Squidex.Log" Version="8.0.3" /> <PackageReference Include="Squidex.Log" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging" Version="8.0.4" />
<PackageReference Include="Squidex.Text" Version="8.0.3" /> <PackageReference Include="Squidex.Text" Version="8.0.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" /> <PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="System.Linq.Async" Version="7.0.1" /> <PackageReference Include="System.Linq.Async" Version="7.0.1" />

11
backend/src/Squidex.Infrastructure/Translations/ResourcesLocalizer.cs

@ -5,7 +5,9 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Concurrent;
using System.Globalization; using System.Globalization;
using System.Reflection;
using System.Resources; using System.Resources;
using System.Text; using System.Text;
@ -13,6 +15,13 @@ namespace Squidex.Infrastructure.Translations;
public sealed class ResourcesLocalizer(ResourceManager resourceManager) : ILocalizer public sealed class ResourcesLocalizer(ResourceManager resourceManager) : ILocalizer
{ {
// Resolving a property by name is the expensive part of the formatting. The arguments are
// compiler generated types and the variable names come from the resources, so the number of
// combinations is defined by the code and the cache cannot grow beyond that. Misses are cached
// as null, otherwise an unknown variable would pay for the lookup every time.
private static readonly ConcurrentDictionary<(Type Type, string Name), PropertyInfo?> Properties =
new ConcurrentDictionary<(Type Type, string Name), PropertyInfo?>();
public (string Result, bool Found) Get(CultureInfo culture, string key, string fallback, object? args = null) public (string Result, bool Found) Get(CultureInfo culture, string key, string fallback, object? args = null)
{ {
Guard.NotNull(culture); Guard.NotNull(culture);
@ -74,7 +83,7 @@ public sealed class ResourcesLocalizer(ResourceManager resourceManager) : ILocal
var variableName = variable.ToString(); var variableName = variable.ToString();
var variableValue = variableName; var variableValue = variableName;
var property = argsType.GetProperty(variableName); var property = Properties.GetOrAdd((argsType, variableName), static key => key.Type.GetProperty(key.Name));
if (property != null) if (property != null)
{ {

5
backend/src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs

@ -52,7 +52,8 @@ public sealed class CachingUsageTracker(IUsageTracker inner, IMemoryCache cache)
{ {
Guard.NotNull(key); Guard.NotNull(key);
var cacheKey = $"{typeof(CachingUsageTracker)}_UsageForMonth_{key}_{date}_{category}"; // A tuple avoids building a string per request and also avoids formatting the date.
var cacheKey = (typeof(CachingUsageTracker), nameof(GetForMonthAsync), key, date, category);
return cache.GetOrCreateAsync(cacheKey, entry => return cache.GetOrCreateAsync(cacheKey, entry =>
{ {
@ -67,7 +68,7 @@ public sealed class CachingUsageTracker(IUsageTracker inner, IMemoryCache cache)
{ {
Guard.NotNull(key); Guard.NotNull(key);
var cacheKey = $"{typeof(CachingUsageTracker)}_Usage_{key}_{fromDate}_{toDate}_{category}"; var cacheKey = (typeof(CachingUsageTracker), nameof(GetAsync), key, fromDate, toDate, category);
return cache.GetOrCreateAsync(cacheKey, entry => return cache.GetOrCreateAsync(cacheKey, entry =>
{ {

11
backend/src/Squidex.Web/ContextProvider.cs

@ -27,5 +27,16 @@ public sealed class ContextProvider(IHttpContextAccessor httpContextAccessor) :
return httpContextAccessor.HttpContext.Context(); return httpContextAccessor.HttpContext.Context();
} }
set
{
if (httpContextAccessor.HttpContext == null)
{
asyncLocal.Value = value;
}
else
{
httpContextAccessor.HttpContext.Features.Set(value);
}
}
} }
} }

34
backend/src/Squidex.Web/Pipeline/CachingManager.cs

@ -34,15 +34,14 @@ public sealed class CachingManager : IRequestCache
private readonly IncrementalHash hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); private readonly IncrementalHash hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
private readonly HashSet<string> keys = []; private readonly HashSet<string> keys = [];
private readonly HashSet<string> headers = []; private readonly HashSet<string> headers = [];
private readonly ReaderWriterLockSlim slimLock = new ReaderWriterLockSlim(); private readonly Lock lockObject = new Lock();
private bool hasDependency; private bool hasDependency;
private bool isFinished; private bool isFinished;
public void Dispose() public void Dispose()
{ {
hasher.Dispose(); hasher.Dispose();
slimLock.Dispose();
} }
public void AddDependency(string key, long version) public void AddDependency(string key, long version)
@ -52,8 +51,7 @@ public sealed class CachingManager : IRequestCache
return; return;
} }
slimLock.EnterWriteLock(); lock (lockObject)
try
{ {
if (!keys.Add(key)) if (!keys.Add(key))
{ {
@ -65,10 +63,6 @@ public sealed class CachingManager : IRequestCache
hasDependency = true; hasDependency = true;
} }
finally
{
slimLock.ExitWriteLock();
}
} }
public void AddDependency<T>(T value) public void AddDependency<T>(T value)
@ -80,17 +74,12 @@ public sealed class CachingManager : IRequestCache
return; return;
} }
slimLock.EnterWriteLock(); lock (lockObject)
try
{ {
hasher.AppendString(formatted); hasher.AppendString(formatted);
hasDependency = true; hasDependency = true;
} }
finally
{
slimLock.ExitWriteLock();
}
} }
public void Finish(HttpResponse response, ObjectPool<StringBuilder> stringBuilderPool) public void Finish(HttpResponse response, ObjectPool<StringBuilder> stringBuilderPool)
@ -104,8 +93,7 @@ public sealed class CachingManager : IRequestCache
// Set to finish before we start to ensure that we do not call it again in case of an error. // Set to finish before we start to ensure that we do not call it again in case of an error.
isFinished = true; isFinished = true;
slimLock.EnterWriteLock(); lock (lockObject)
try
{ {
if (hasDependency && !response.Headers.ContainsKey(HeaderNames.ETag)) if (hasDependency && !response.Headers.ContainsKey(HeaderNames.ETag))
{ {
@ -160,10 +148,6 @@ public sealed class CachingManager : IRequestCache
response.Headers[HeaderNames.Vary] = new StringValues(headers.ToArray()); response.Headers[HeaderNames.Vary] = new StringValues(headers.ToArray());
} }
} }
finally
{
slimLock.ExitWriteLock();
}
} }
public void AddHeader(string header, StringValues values) public void AddHeader(string header, StringValues values)
@ -173,16 +157,10 @@ public sealed class CachingManager : IRequestCache
return; return;
} }
try lock (lockObject)
{ {
slimLock.EnterWriteLock();
headers.Add(header); headers.Add(header);
} }
finally
{
slimLock.ExitWriteLock();
}
foreach (var value in values) foreach (var value in values)
{ {

10
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -26,7 +26,7 @@ namespace Squidex.Areas.Api.Controllers.Contents;
public sealed class ContentsController( public sealed class ContentsController(
ICommandBus commandBus, ICommandBus commandBus,
IContentQueryService contentQuery, IContentQueryService contentQuery,
IContentWorkflow contentWorkflow) IContentWorkflows contentWorkflows)
: ApiController(commandBus) : ApiController(commandBus)
{ {
/// <summary> /// <summary>
@ -83,7 +83,7 @@ public sealed class ContentsController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, Schema, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, Schema, contentWorkflows);
}); });
return Ok(response); return Ok(response);
@ -117,7 +117,7 @@ public sealed class ContentsController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, Schema, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, Schema, contentWorkflows);
}); });
return Ok(response); return Ok(response);
@ -216,7 +216,7 @@ public sealed class ContentsController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflows);
}); });
return Ok(response); return Ok(response);
@ -251,7 +251,7 @@ public sealed class ContentsController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflows);
}); });
return Ok(response); return Ok(response);

6
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs

@ -25,7 +25,7 @@ namespace Squidex.Areas.Api.Controllers.Contents;
public sealed class ContentsSharedController( public sealed class ContentsSharedController(
ICommandBus commandBus, ICommandBus commandBus,
IContentQueryService contentQuery, IContentQueryService contentQuery,
IContentWorkflow contentWorkflow) IContentWorkflows contentWorkflows)
: ApiController(commandBus) : ApiController(commandBus)
{ {
private static readonly GraphQLHttpMiddlewareOptions GraphQLOptions = new GraphQLHttpMiddlewareOptions private static readonly GraphQLHttpMiddlewareOptions GraphQLOptions = new GraphQLHttpMiddlewareOptions
@ -146,7 +146,7 @@ public sealed class ContentsSharedController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflows);
}); });
return Ok(response); return Ok(response);
@ -179,7 +179,7 @@ public sealed class ContentsSharedController(
var response = Deferred.AsyncResponse(() => var response = Deferred.AsyncResponse(() =>
{ {
return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflows);
}); });
return Ok(response); return Ok(response);

18
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs

@ -30,7 +30,7 @@ public sealed class ContentsDto : Resource
public StatusInfoDto[] Statuses { get; set; } public StatusInfoDto[] Statuses { get; set; }
public static async Task<ContentsDto> FromContentsAsync(IResultList<EnrichedContent> contents, Resources resources, public static async Task<ContentsDto> FromContentsAsync(IResultList<EnrichedContent> contents, Resources resources,
Schema? schema, IContentWorkflow workflow) Schema? schema, IContentWorkflows workflows)
{ {
var result = new ContentsDto var result = new ContentsDto
{ {
@ -40,8 +40,10 @@ public sealed class ContentsDto : Resource
if (schema != null) if (schema != null)
{ {
await result.CreateStatusesAsync(workflow, schema); using var workflow = await workflows.GetWorkflowAsync(resources.Context.App, schema);
await result.CreateLinksAsync(resources, workflow, schema);
result.CreateStatuses(workflow);
result.CreateLinks(resources, workflow, schema);
} }
else else
{ {
@ -51,14 +53,12 @@ public sealed class ContentsDto : Resource
return result; return result;
} }
private async Task CreateStatusesAsync(IContentWorkflow workflow, Schema schema) private void CreateStatuses(IContentWorkflow workflow)
{ {
var allStatuses = await workflow.GetAllAsync(schema); Statuses = workflow.GetAll().Select(StatusInfoDto.FromDomain).ToArray();
Statuses = allStatuses.Select(StatusInfoDto.FromDomain).ToArray();
} }
private async Task CreateLinksAsync(Resources resources, IContentWorkflow workflow, Schema schema) private void CreateLinks(Resources resources, IContentWorkflow workflow, Schema schema)
{ {
var values = new { app = resources.App, schema = schema.Name }; var values = new { app = resources.App, schema = schema.Name };
@ -69,7 +69,7 @@ public sealed class ContentsDto : Resource
AddPostLink("create", AddPostLink("create",
resources.Url<ContentsController>(x => nameof(x.PostContent), values)); resources.Url<ContentsController>(x => nameof(x.PostContent), values));
if (resources.CanChangeStatus(values.schema) && await workflow.CanPublishInitialAsync(schema, resources.Context.UserPrincipal)) if (resources.CanChangeStatus(values.schema) && workflow.CanPublishInitial(resources.Context.UserPrincipal))
{ {
var publishValues = new { values.app, values.schema, publish = true }; var publishValues = new { values.app, values.schema, publish = true };

14
backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/QueryModelDto.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Infrastructure.Queries; using Squidex.Infrastructure.Queries;
@ -20,22 +21,17 @@ public sealed class QueryModelDto
public StatusInfoDto[] Statuses { get; set; } public StatusInfoDto[] Statuses { get; set; }
public static async Task<QueryModelDto> FromModelAsync(QueryModel model, Schema? schema, IContentWorkflow workflow) public static async Task<QueryModelDto> FromModelAsync(QueryModel model, App app, Schema? schema, IContentWorkflows workflows)
{ {
var result = SimpleMapper.Map(model, new QueryModelDto()); var result = SimpleMapper.Map(model, new QueryModelDto());
if (schema != null) if (schema != null)
{ {
await result.AssignStatusesAsync(workflow, schema); using var workflow = await workflows.GetWorkflowAsync(app, schema);
result.Statuses = workflow.GetAll().Select(StatusInfoDto.FromDomain).ToArray();
} }
return result; return result;
} }
private async Task AssignStatusesAsync(IContentWorkflow workflow, Schema schema)
{
var allStatuses = await workflow.GetAllAsync(schema);
Statuses = allStatuses.Select(StatusInfoDto.FromDomain).ToArray();
}
} }

4
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -30,7 +30,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas;
[ApiExplorerSettings(GroupName = nameof(Schemas))] [ApiExplorerSettings(GroupName = nameof(Schemas))]
public sealed class SchemasController( public sealed class SchemasController(
ICommandBus commandBus, ICommandBus commandBus,
IContentWorkflow workflow, IContentWorkflows workflows,
IAppProvider appProvider, IAppProvider appProvider,
SchemaAIGenerator schemaAIGenerator, SchemaAIGenerator schemaAIGenerator,
ScriptingCompleter scriptingCompleter) ScriptingCompleter scriptingCompleter)
@ -395,7 +395,7 @@ public sealed class SchemasController(
var components = await appProvider.GetComponentsAsync(Schema, HttpContext.RequestAborted); var components = await appProvider.GetComponentsAsync(Schema, HttpContext.RequestAborted);
var result = ContentQueryModel.Build(Schema, App.PartitionResolver(), components).Flatten(); var result = ContentQueryModel.Build(Schema, App.PartitionResolver(), components).Flatten();
var response = await QueryModelDto.FromModelAsync(result, Schema, workflow); var response = await QueryModelDto.FromModelAsync(result, App, Schema, workflows);
return Ok(response); return Ok(response);
} }

12
backend/src/Squidex/Areas/IdentityServer/Config/Dynamic/DynamicSchemeProvider.cs

@ -39,8 +39,7 @@ public sealed class DynamicSchemeProvider(
public async Task<string> AddTemporarySchemeAsync(AuthScheme scheme, public async Task<string> AddTemporarySchemeAsync(AuthScheme scheme,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var id = Guid.NewGuid().ToString(); var schemaId = Guid.NewGuid().ToString();
var serialized = jsonSerializer.SerializeToBytes(scheme); var serialized = jsonSerializer.SerializeToBytes(scheme);
var cacheOptions = new DistributedCacheEntryOptions var cacheOptions = new DistributedCacheEntryOptions
@ -48,8 +47,8 @@ public sealed class DynamicSchemeProvider(
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10), AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
}; };
await dynamicCache.SetAsync(CacheKey(id), serialized, cacheOptions, ct); await dynamicCache.SetAsync(CacheKey(schemaId), serialized, cacheOptions, ct);
return id; return schemaId;
} }
public async Task<AuthenticationScheme?> GetSchemaByEmailAddressAsync(string email) public async Task<AuthenticationScheme?> GetSchemaByEmailAddressAsync(string email)
@ -60,14 +59,12 @@ public sealed class DynamicSchemeProvider(
} }
var parts = email.Split('@'); var parts = email.Split('@');
if (parts.Length != 2) if (parts.Length != 2)
{ {
return null; return null;
} }
var team = await appProvider.GetTeamByAuthDomainAsync(parts[1], default); var team = await appProvider.GetTeamByAuthDomainAsync(parts[1], default);
if (team?.AuthScheme != null) if (team?.AuthScheme != null)
{ {
return CreateScheme(team.Id.ToString(), team.AuthScheme).Scheme; return CreateScheme(team.Id.ToString(), team.AuthScheme).Scheme;
@ -79,7 +76,6 @@ public sealed class DynamicSchemeProvider(
public override async Task<AuthenticationScheme?> GetSchemeAsync(string name) public override async Task<AuthenticationScheme?> GetSchemeAsync(string name)
{ {
var result = await GetSchemeCoreAsync(name, default); var result = await GetSchemeCoreAsync(name, default);
if (result != null) if (result != null)
{ {
return result.Scheme; return result.Scheme;
@ -98,7 +94,6 @@ public sealed class DynamicSchemeProvider(
} }
var path = httpContextAccessor.HttpContext.Request.Path.Value; var path = httpContextAccessor.HttpContext.Request.Path.Value;
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))
{ {
return result; return result;
@ -131,6 +126,7 @@ public sealed class DynamicSchemeProvider(
return new DynamicOpenIdConnectOptions(); return new DynamicOpenIdConnectOptions();
} }
// This method is not called very often, so the performance issues are acceptable here.
var scheme = GetSchemeCoreAsync(name, default).Result; var scheme = GetSchemeCoreAsync(name, default).Result;
return scheme?.Options ?? new DynamicOpenIdConnectOptions(); return scheme?.Options ?? new DynamicOpenIdConnectOptions();

4
backend/src/Squidex/Config/Domain/ContentsServices.cs

@ -90,8 +90,8 @@ public static class ContentsServices
services.AddSingletonAs<ContentLoader>() services.AddSingletonAs<ContentLoader>()
.As<IContentLoader>(); .As<IContentLoader>();
services.AddSingletonAs<DynamicContentWorkflow>() services.AddSingletonAs<DynamicContentWorkflows>()
.AsOptional<IContentWorkflow>(); .AsOptional<IContentWorkflows>();
services.AddSingletonAs<DefaultWorkflowsValidator>() services.AddSingletonAs<DefaultWorkflowsValidator>()
.AsOptional<IWorkflowsValidator>(); .AsOptional<IWorkflowsValidator>();

22
backend/src/Squidex/Squidex.csproj

@ -57,16 +57,16 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" /> <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.15.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.5.6" PrivateAssets="all" /> <PackageReference Include="ReportGenerator" Version="5.5.6" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.Azure" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.GoogleCloud" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.FTP" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.FTP" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.ImageSharp" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.S3" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.S3" Version="8.0.4" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.TusAdapter" Version="8.0.4" />
<PackageReference Include="Squidex.ClientLibrary" Version="22.2.0" /> <PackageReference Include="Squidex.ClientLibrary" Version="22.2.0" />
<PackageReference Include="Squidex.Hosting" Version="8.0.3" /> <PackageReference Include="Squidex.Hosting" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging.All" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.All" Version="8.0.4" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.Subscriptions" Version="8.0.4" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="YDotNet" Version="0.6.0" /> <PackageReference Include="YDotNet" Version="0.6.0" />
<PackageReference Include="YDotNet.Native" Version="0.6.0" /> <PackageReference Include="YDotNet.Native" Version="0.6.0" />
@ -80,12 +80,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(IncludeMagick)' == 'true'"> <ItemGroup Condition="'$(IncludeMagick)' == 'true'">
<PackageReference Include="Squidex.Assets.ImageMagick" Version="8.0.3" /> <PackageReference Include="Squidex.Assets.ImageMagick" Version="8.0.4" />
<PackageReference Include="Magick.NET-Q8-AnyCPU" Version="14.16.0" /> <PackageReference Include="Magick.NET-Q8-AnyCPU" Version="14.16.0" />
</ItemGroup> </ItemGroup>
<ItemGroup Condition="'$(IncludeKafka)' == 'true'"> <ItemGroup Condition="'$(IncludeKafka)' == 'true'">
<PackageReference Include="Squidex.Messaging.Kafka" Version="8.0.3" /> <PackageReference Include="Squidex.Messaging.Kafka" Version="8.0.4" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>

11
backend/src/Squidex/appsettings.json

@ -331,10 +331,19 @@
"defaultPageSize": 200, "defaultPageSize": 200,
// The maximum number of items to return for each query. // The maximum number of items to return for each query.
// //
// Warning: Use pagination and not large number of items. // Warning: Use pagination and not large number of items.
"maxResults": 200, "maxResults": 200,
// The maximum number of IDs to load from the full text index for a single query.
//
// The full text index can be a different database than the content store, therefore the IDs
// have to be loaded to filter and sort them in the content store. A query that matches more
// items than this limit loses the rest of them.
//
// Warning: Increasing this makes full text queries slower and use more memory.
"maxFullTextResults": 1000,
// The timeout when searching for single items in the database. // The timeout when searching for single items in the database.
"timeoutFind": "00:00:01", "timeoutFind": "00:00:01",

2
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/MySqlFixture.cs

@ -46,6 +46,7 @@ public class MySqlFixture(string? reuseId = null) : IAsyncLifetime, ISqlContentF
new ServiceCollection() new ServiceCollection()
.AddPooledDbContextFactory<TestDbContextMySql>(builder => .AddPooledDbContextFactory<TestDbContextMySql>(builder =>
{ {
builder.SetDefaults();
builder.UseBulkInsertMySql(); builder.UseBulkInsertMySql();
builder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), options => builder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), options =>
{ {
@ -55,6 +56,7 @@ public class MySqlFixture(string? reuseId = null) : IAsyncLifetime, ISqlContentF
}) })
.AddNamedDbContext<MySqlContentDbContext>((builder, name) => .AddNamedDbContext<MySqlContentDbContext>((builder, name) =>
{ {
builder.SetDefaults();
builder.UseBulkInsertMySql(); builder.UseBulkInsertMySql();
builder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), options => builder.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString), options =>
{ {

2
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/PostgresFixture.cs

@ -45,6 +45,7 @@ public class PostgresFixture(string? reuseId) : IAsyncLifetime, ISqlContentFixtu
new ServiceCollection() new ServiceCollection()
.AddPooledDbContextFactory<TestDbContextPostgres>(builder => .AddPooledDbContextFactory<TestDbContextPostgres>(builder =>
{ {
builder.SetDefaults();
builder.UseBulkInsertPostgreSql(); builder.UseBulkInsertPostgreSql();
builder.UseNpgsql(connectionString, options => builder.UseNpgsql(connectionString, options =>
{ {
@ -53,6 +54,7 @@ public class PostgresFixture(string? reuseId) : IAsyncLifetime, ISqlContentFixtu
}) })
.AddNamedDbContext<PostgresContentDbContext>((builder, name) => .AddNamedDbContext<PostgresContentDbContext>((builder, name) =>
{ {
builder.SetDefaults();
builder.UseBulkInsertPostgreSql(); builder.UseBulkInsertPostgreSql();
builder.UseNpgsql(connectionString, options => builder.UseNpgsql(connectionString, options =>
{ {

2
backend/tests/Squidex.Data.Tests/EntityFramework/TestHelpers/SqlServerFixture.cs

@ -47,6 +47,7 @@ public class SqlServerFixture(string? reuseId = null) : IAsyncLifetime, ISqlCont
new ServiceCollection() new ServiceCollection()
.AddPooledDbContextFactory<TestDbContextSqlServer>(builder => .AddPooledDbContextFactory<TestDbContextSqlServer>(builder =>
{ {
builder.SetDefaults();
builder.UseBulkInsertSqlServer(); builder.UseBulkInsertSqlServer();
builder.UseSqlServer(connectionString, options => builder.UseSqlServer(connectionString, options =>
{ {
@ -55,6 +56,7 @@ public class SqlServerFixture(string? reuseId = null) : IAsyncLifetime, ISqlCont
}) })
.AddNamedDbContext<SqlServerContentDbContext>((builder, name) => .AddNamedDbContext<SqlServerContentDbContext>((builder, name) =>
{ {
builder.SetDefaults();
builder.UseBulkInsertSqlServer(); builder.UseBulkInsertSqlServer();
builder.UseSqlServer(connectionString, options => builder.UseSqlServer(connectionString, options =>
{ {

113
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/ContentConversionRemovalTests.cs

@ -0,0 +1,113 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.ConvertContent;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Operations.ConvertContent;
public class ContentConversionRemovalTests
{
private static readonly DomainId ComponentId = DomainId.NewGuid();
private readonly ResolvedComponents components;
private readonly Schema schema;
public ContentConversionRemovalTests()
{
schema =
new Schema { Name = "my-schema" }
.AddComponents(1, "components", Partitioning.Invariant)
.AddArray(2, "array", Partitioning.Invariant, a => a
.AddString(21, "value"));
components = new ResolvedComponents(new Dictionary<DomainId, Schema>
{
[ComponentId] =
new Schema { Name = "my-component" }
.AddString(1, "value", Partitioning.Invariant),
});
}
[Theory]
[InlineData("abc", "abc")]
[InlineData("-bc", "bc")]
[InlineData("a-c", "ac")]
[InlineData("ab-", "ab")]
[InlineData("--c", "c")]
[InlineData("-b-", "b")]
[InlineData("a--", "a")]
[InlineData("---", "")]
public void Should_remove_array_items_that_are_not_objects(string source, string expected)
{
var items = source.Select(x => x == '-' ? JsonValue.Create(0) : Item(x));
Assert.Equal(expected, Convert("array", items));
}
[Theory]
[InlineData("abc", "abc")]
[InlineData("-bc", "bc")]
[InlineData("a-c", "ac")]
[InlineData("ab-", "ab")]
[InlineData("--c", "c")]
[InlineData("-b-", "b")]
[InlineData("a--", "a")]
[InlineData("---", "")]
public void Should_remove_components_of_unknown_schema(string source, string expected)
{
var items = source.Select(x => x == '-' ? ComponentOf(x, DomainId.NewGuid()) : ComponentOf(x, ComponentId));
Assert.Equal(expected, Convert("components", items));
}
[Theory]
[InlineData("abc", "abc")]
[InlineData("-bc", "bc")]
[InlineData("a-c", "ac")]
[InlineData("ab-", "ab")]
[InlineData("--c", "c")]
[InlineData("-b-", "b")]
[InlineData("a--", "a")]
[InlineData("---", "")]
public void Should_remove_components_without_discriminator(string source, string expected)
{
var items = source.Select(x => x == '-' ? Item(x) : ComponentOf(x, ComponentId));
Assert.Equal(expected, Convert("components", items));
}
private string Convert(string field, IEnumerable<JsonValue> items)
{
var source =
new ContentData()
.AddField(field,
new ContentFieldData()
.AddInvariant(JsonValue.Array(items.ToArray())));
var converted = new ContentConverter(components, schema).Convert(source);
if (!converted.TryGetValue(field, out var data) || data?["iv"].Value is not JsonArray array)
{
return string.Empty;
}
return string.Concat(array.Select(x => ((JsonObject)x.Value!)["value"].ToString()));
}
private static JsonValue Item(char value)
{
return JsonValue.Object().Add("value", value.ToString());
}
private static JsonValue ComponentOf(char value, DomainId schemaId)
{
return JsonValue.Object().Add("value", value.ToString()).Add(Component.Discriminator, schemaId);
}
}

24
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs

@ -25,7 +25,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
private readonly IHttpClientFactory httpClientFactory = A.Fake<IHttpClientFactory>(); private readonly IHttpClientFactory httpClientFactory = A.Fake<IHttpClientFactory>();
private readonly ITranslator translator = A.Fake<ITranslator>(); private readonly ITranslator translator = A.Fake<ITranslator>();
private readonly IChatAgent chatAgent = A.Fake<IChatAgent>(); private readonly IChatAgent chatAgent = A.Fake<IChatAgent>();
private readonly JintScriptEngine sut; private readonly IScriptEngine sut;
public JintScriptEngineHelperTests() public JintScriptEngineHelperTests()
{ {
@ -266,7 +266,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
reject() reject()
"; ";
var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options)); var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options).AsTask());
Assert.NotEmpty(ex.Errors); Assert.NotEmpty(ex.Errors);
} }
@ -287,7 +287,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
reject('Error1') reject('Error1')
"; ";
var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options)); var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options).AsTask());
Assert.Equal(new[] { "Error1" }, ex.Errors.Select(x => x.Message).ToArray()); Assert.Equal(new[] { "Error1" }, ex.Errors.Select(x => x.Message).ToArray());
} }
@ -308,7 +308,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
reject(['Error1', 'Error2']) reject(['Error1', 'Error2'])
"; ";
var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options)); var ex = await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, options).AsTask());
Assert.Equal(new[] { "Error1", "Error2" }, ex.Errors.Select(x => x.Message).ToArray()); Assert.Equal(new[] { "Error1", "Error2" }, ex.Errors.Select(x => x.Message).ToArray());
} }
@ -329,7 +329,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
disallow() disallow()
"; ";
var ex = await Assert.ThrowsAsync<DomainForbiddenException>(() => sut.ExecuteAsync(vars, script, options)); var ex = await Assert.ThrowsAsync<DomainForbiddenException>(() => sut.ExecuteAsync(vars, script, options).AsTask());
Assert.Equal("Script has forbidden the operation.", ex.Message); Assert.Equal("Script has forbidden the operation.", ex.Message);
} }
@ -350,7 +350,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
{ {
}; };
var ex = await Assert.ThrowsAsync<DomainForbiddenException>(() => sut.ExecuteAsync(vars, script, options)); var ex = await Assert.ThrowsAsync<DomainForbiddenException>(() => sut.ExecuteAsync(vars, script, options).AsTask());
Assert.Equal("Operation not allowed", ex.Message); Assert.Equal("Operation not allowed", ex.Message);
} }
@ -368,7 +368,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
}); });
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
} }
[Fact] [Fact]
@ -386,7 +386,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
}); });
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
} }
[Fact] [Fact]
@ -402,7 +402,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
getJSON(url, null); getJSON(url, null);
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
} }
[Fact] [Fact]
@ -719,7 +719,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
generate('prompt', null); generate('prompt', null);
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
} }
[Fact] [Fact]
@ -802,7 +802,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
translate('text', 'en', null); translate('text', 'en', null);
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
} }
[Theory] [Theory]
@ -976,7 +976,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
}}); }});
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script).AsTask());
} }
private MockupHttpHandler SetupRequest(HttpStatusCode statusCode = HttpStatusCode.OK, StringContent? responseContent = null) private MockupHttpHandler SetupRequest(HttpStatusCode statusCode = HttpStatusCode.OK, StringContent? responseContent = null)

739
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs

@ -18,6 +18,7 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
using Engine = Jint.Engine;
namespace Squidex.Domain.Apps.Core.Operations.Scripting; namespace Squidex.Domain.Apps.Core.Operations.Scripting;
@ -31,7 +32,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
}; };
private readonly IHttpClientFactory httpClientFactory = A.Fake<IHttpClientFactory>(); private readonly IHttpClientFactory httpClientFactory = A.Fake<IHttpClientFactory>();
private readonly JintScriptEngine sut; private readonly IScriptEngine sut;
public JintScriptEngineTests() public JintScriptEngineTests()
{ {
@ -68,26 +69,20 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
{ {
private delegate void Delay(Action callback, int time); private delegate void Delay(Action callback, int time);
public void ExtendAsync(ScriptExecutionContext context) public void ExtendAsync(Engine engine)
{ {
context.Engine.SetValue("setTimeout", new Delay((callback, time) => engine.SetValue("setTimeout", new Delay((callback, time) =>
{ {
if (time == 0) engine.Schedule(async ct =>
{ {
context.Schedule((scheduler, ct) => if (time > 0)
{
scheduler.Run(callback);
return Task.CompletedTask;
});
}
else
{
context.Schedule(async (scheduler, ct) =>
{ {
await Task.Delay(time, ct); await Task.Delay(time, ct);
scheduler.Run(callback); }
});
} return true;
},
_ => callback());
})); }));
} }
} }
@ -99,7 +94,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
invalid(() invalid(()
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script).AsTask());
} }
[Fact] [Fact]
@ -109,7 +104,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
throw 'Error'; throw 'Error';
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script).AsTask());
} }
[Fact] [Fact]
@ -179,7 +174,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
throw 'Error'; throw 'Error';
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.TransformAsync([], script)); await Assert.ThrowsAsync<ValidationException>(() => sut.TransformAsync([], script).AsTask());
} }
[Fact] [Fact]
@ -194,7 +189,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
invalid((); invalid(();
"; ";
await Assert.ThrowsAsync<ValidationException>(() => sut.TransformAsync(vars, script, contentOptions)); await Assert.ThrowsAsync<ValidationException>(() => sut.TransformAsync(vars, script, contentOptions).AsTask());
} }
[Fact] [Fact]
@ -432,6 +427,217 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
Assert.Equal(expected, actual); Assert.Equal(expected, actual);
} }
[Fact]
public async Task Should_not_deadlock_if_callback_completes_synchronously()
{
// The callback runs on the thread of the evaluation, which holds the engine lock at that moment.
const string script = @"
function delay() {
return new Promise((resolve) => {
setTimeout(function () {
resolve(1);
}, 0);
});
}
(async () => {
let total = 0;
for (let i = 0; i < 10; i++) {
total += await delay();
}
complete(total);
})()
";
var actual = await sut.ExecuteAsync([], script);
Assert.Equal(JsonValue.Create(10), actual);
}
[Fact]
public async Task Should_throw_if_promise_is_rejected()
{
const string script = @"
(async () => {
await new Promise((resolve, reject) => {
getJSON('http://mockup.squidex.io', function () {
reject('rejected');
});
});
complete(42);
})()
";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync([], script).AsTask());
}
[Fact]
public async Task Should_not_throw_if_rejected_promise_is_handled()
{
const string script = @"
(async () => {
try {
await new Promise((resolve, reject) => {
getJSON('http://mockup.squidex.io', function () {
reject('rejected');
});
});
} catch (e) {
complete(42);
}
})()
";
var actual = await sut.ExecuteAsync([], script);
Assert.Equal(JsonValue.Create(42), actual);
}
[Fact]
public void Should_not_leak_globals_between_executions()
{
var script = sut.CreateScript("var actual = typeof leaked; var leaked = 1; actual");
for (var i = 1; i <= 3; i++)
{
Assert.Equal(JsonValue.Create("undefined"), script.Execute([]));
}
}
[Fact]
public void Should_leak_prototype_changes_between_executions_of_same_script()
{
// The snapshot restores the globals, but it does not undo changes to the prototypes. That is
// acceptable because a script is never shared between apps, but it must not go unnoticed.
var script = sut.CreateScript("var actual = ({}).polluted; Object.prototype.polluted = 'yes'; typeof actual");
Assert.Equal(JsonValue.Create("undefined"), script.Execute([]));
Assert.Equal(JsonValue.Create("string"), script.Execute([]));
}
[Fact]
public void Should_not_leak_prototype_changes_to_other_scripts()
{
sut.CreateScript("Object.prototype.polluted = 'yes'; 1").Execute([]);
var script = sut.CreateScript("typeof ({}).polluted");
Assert.Equal(JsonValue.Create("undefined"), script.Execute([]));
}
[Fact]
public void Should_complete_sync_script()
{
var script = sut.CreateScript("complete(42); 1");
Assert.Equal(JsonValue.Create(42), script.Execute([]));
}
[Fact]
public void Should_transform_with_sync_script()
{
var vars = new DataScriptVars
{
["data"] = new ContentData(),
};
var script = sut.CreateScript("ctx.data.number = { iv: 42 }; replace()", contentOptions);
var actual = script.Transform(vars);
Assert.Equal(JsonValue.Create(42), actual["number"]!["iv"]);
}
[Fact]
public async Task Should_cancel_async_script()
{
using var cts = new CancellationTokenSource();
var script = sut.CreateAsyncScript("while (true) { }");
await cts.CancelAsync();
await Assert.ThrowsAnyAsync<Exception>(() => script.ExecuteAsync([], cts.Token).AsTask());
}
[Fact]
public async Task Should_run_same_script_in_parallel()
{
var script = sut.CreateAsyncScript("const factor = 2; value.i * factor");
var tasks = Enumerable.Range(1, 20).Select(async i =>
{
var vars = new ScriptVars
{
["value"] = new { i },
};
return (i, actual: await script.ExecuteAsync(vars));
});
foreach (var (i, actual) in await Task.WhenAll(tasks))
{
Assert.Equal(JsonValue.Create(i * 2), actual);
}
}
[Fact]
public async Task Should_reuse_script_with_callbacks()
{
var script = sut.CreateAsyncScript(@"
const factor = value.i;
getJSON('http://mockup.squidex.io', function(actual) {
complete(actual.key * factor);
});
");
for (var i = 1; i <= 3; i++)
{
var vars = new ScriptVars
{
["value"] = new { i },
};
Assert.Equal(JsonValue.Create(42 * i), await script.ExecuteAsync(vars));
}
}
[Fact]
public void Should_reuse_script_with_global_declarations()
{
var script = sut.CreateScript("const factor = 2; value.i * factor");
for (var i = 1; i <= 3; i++)
{
var vars = new ScriptVars
{
["value"] = new { i },
};
Assert.Equal(JsonValue.Create(i * 2), script.Execute(vars));
}
}
[Fact]
public async Task Should_reuse_async_script_with_global_declarations()
{
var script = sut.CreateAsyncScript("const factor = 2; value.i * factor");
for (var i = 1; i <= 3; i++)
{
var vars = new ScriptVars
{
["value"] = new { i },
};
Assert.Equal(JsonValue.Create(i * 2), await script.ExecuteAsync(vars));
}
}
[Fact] [Fact]
public void Evaluate_should_return_true_if_expression_match() public void Evaluate_should_return_true_if_expression_match()
{ {
@ -725,497 +931,4 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
Assert.Equal(42.0, result.Value); Assert.Equal(42.0, result.Value);
} }
[Fact]
public void Should_not_throw_if_reading_undeclared_identifier()
{
// Reading an unknown name does not throw, it returns an internal Jint marker string. That is odd,
// but it is what scripts have always seen here, see NullPropagation.TryUnresolvableReference.
const string script = @"
String(unknownName) + '|' + (typeof unknownName);
";
var actual = sut.Execute(new ScriptVars(), script);
Assert.Equal(JsonValue.Create("[[Unresolvable]]|undefined"), actual);
}
[Fact]
public void Should_null_propagate_over_nullish_property_base()
{
var vars = new ScriptVars
{
["value"] = 13,
};
const string script = @"
ctx.unknown.deeper.evenDeeper === undefined;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.True, actual);
}
[Fact]
public void Should_chain_call_over_nullish_property_base()
{
var vars = new ScriptVars
{
["value"] = 13,
};
const string script = @"
ctx.unknown.deeper.someMethod() === undefined;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.True, actual);
}
[Fact]
public void Should_return_base_if_calling_non_callable_member()
{
var vars = new ScriptVars
{
["value"] = "squidex",
};
const string script = @"
ctx.value.notAFunction();
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("squidex"), actual);
}
[Fact]
public void Should_not_change_normal_member_reads_and_calls()
{
var vars = new ScriptVars
{
["value"] = JsonValue.Create(new JsonObject().Add("name", JsonValue.Create("squidex"))),
};
const string script = @"
ctx.value.name.toUpperCase();
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("SQUIDEX"), actual);
}
[Fact]
public void Should_convert_enum_to_name()
{
var vars = new ScriptVars
{
["value"] = ScriptScope.ContentScript,
};
const string script = @"
value;
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create("ContentScript"), actual);
}
[Fact]
public void Should_convert_flags_enum_to_names()
{
var vars = new ScriptVars
{
["value"] = ScriptScope.ContentScript | ScriptScope.Transform,
};
const string script = @"
value;
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create("ContentScript, Transform"), actual);
}
[Fact]
public void Should_convert_enum_member_of_wrapped_object_to_name()
{
var vars = new ScriptVars
{
["value"] = new { scope = ScriptScope.Transform },
};
const string script = @"
value.scope;
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create("Transform"), actual);
}
[Fact]
public void Should_project_json_object_with_source_key_order()
{
var vars = new ScriptVars
{
["value"] = CreateJson(),
};
const string script = @"
Object.keys(ctx.value).join(',');
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("name,count,nested,items"), actual);
}
[Fact]
public void Should_stringify_projected_json_object()
{
var vars = new ScriptVars
{
["value"] = CreateJson(),
};
const string script = @"
JSON.stringify(ctx.value);
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(
JsonValue.Create("{\"name\":\"squidex\",\"count\":3,\"nested\":{\"flag\":true},\"items\":[1,2]}"),
actual);
}
[Fact]
public void Should_enumerate_projected_json_object()
{
var vars = new ScriptVars
{
["value"] = CreateJson(),
};
const string script = @"
var actual = [];
for (var key in ctx.value) {
actual.push(key + '=' + (typeof ctx.value[key]));
}
actual.join(',');
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(
JsonValue.Create("name=string,count=number,nested=object,items=object"),
actual);
}
[Fact]
public void Should_allow_mutation_of_projected_json_object()
{
var vars = new ScriptVars
{
["value"] = CreateJson(),
};
const string script = @"
ctx.value.name = 'changed';
ctx.value.added = 42;
delete ctx.value.count;
ctx.value;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
var expected =
JsonValue.Create(
new JsonObject()
.Add("name", JsonValue.Create("changed"))
.Add("nested", JsonValue.Create(new JsonObject().Add("flag", JsonValue.True)))
.Add("items", JsonValue.Create(new JsonArray().Add(JsonValue.Create(1)).Add(JsonValue.Create(2))))
.Add("added", JsonValue.Create(42)));
Assert.Equal(expected, actual);
}
[Fact]
public void Should_round_trip_projected_json_object()
{
var json = CreateJson();
var vars = new ScriptVars
{
["value"] = json,
};
const string script = @"
ctx.value;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(json, actual);
}
[Fact]
public void Should_enumerate_context_keys()
{
const string script = @"
Object.keys(ctx).join(',');
";
var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("number,text,json,user"), actual);
}
[Fact]
public void Should_answer_in_operator_for_context_keys()
{
const string script = @"
('json' in ctx) + ',' + ('unknown' in ctx);
";
var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("true,false"), actual);
}
[Fact]
public void Should_report_types_of_context_values()
{
const string script = @"
var actual = [];
for (var key in ctx) {
actual.push(key + '=' + (typeof ctx[key]));
}
actual.join(',');
";
var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("number=number,text=string,json=object,user=object"), actual);
}
[Fact]
public void Should_read_context_values()
{
const string script = @"
ctx.number + '|' + ctx.text + '|' + ctx.json.name + '|' + ctx.user.id;
";
var actual = sut.Execute(CreateVars(), script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("13|hello|squidex|user1"), actual);
}
[Fact]
public void Should_write_context_value_through_to_vars()
{
var vars = CreateVars();
const string script = @"
ctx.number = ctx.number * 2;
";
sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(26.0, vars["number"]);
}
[Fact]
public void Should_delete_context_value()
{
var vars = CreateVars();
const string script = @"
delete ctx.text;
Object.keys(ctx).join(',');
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("number,json,user"), actual);
}
[Fact]
public void Should_not_map_unread_variable()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["number"] = 13,
["user"] = principal,
};
const string script = @"
number + 1;
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create(14), actual);
Assert.Equal(0, principal.Reads);
}
[Fact]
public void Should_see_unread_variable_in_enumeration()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["user"] = principal,
};
const string script = @"
('user' in globalThis) + ',' + (Object.getOwnPropertyNames(globalThis).indexOf('user') >= 0);
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create("true,true"), actual);
Assert.Equal(0, principal.Reads);
}
[Fact]
public void Should_map_variable_on_first_read()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["user"] = principal,
};
const string script = @"
user.id;
";
var actual = sut.Execute(vars, script);
Assert.Equal(JsonValue.Create("user1"), actual);
Assert.True(principal.Reads > 0);
}
[Fact]
public void Should_not_map_unread_context_variable()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["number"] = 13,
["user"] = principal,
};
const string script = @"
ctx.number + 1;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create(14), actual);
Assert.Equal(0, principal.Reads);
}
[Fact]
public void Should_see_unread_context_variable_in_enumeration()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["number"] = 13,
["user"] = principal,
};
const string script = @"
Object.keys(ctx).join(',') + '|' + ('user' in ctx);
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("number,user|true"), actual);
Assert.Equal(0, principal.Reads);
}
[Fact]
public void Should_map_context_variable_on_first_read()
{
var principal = new CountingPrincipal();
var vars = new ScriptVars
{
["user"] = principal,
};
const string script = @"
ctx.user.id;
";
var actual = sut.Execute(vars, script, new ScriptOptions { AsContext = true });
Assert.Equal(JsonValue.Create("user1"), actual);
Assert.True(principal.Reads > 0);
}
private sealed class CountingPrincipal : ClaimsPrincipal
{
public int Reads { get; private set; }
public CountingPrincipal()
: base(new ClaimsIdentity(
[
new Claim(OpenIdClaims.Subject, "user1"),
new Claim(OpenIdClaims.Name, "user"),
], "Squidex"))
{
}
public override IEnumerable<Claim> Claims
{
get
{
Reads++;
return base.Claims;
}
}
}
private static ScriptVars CreateVars()
{
return new ScriptVars
{
["number"] = 13,
["text"] = "hello",
["json"] = CreateJson(),
["user"] = new ClaimsPrincipal(
new ClaimsIdentity(
[
new Claim(OpenIdClaims.Subject, "user1"),
new Claim(OpenIdClaims.Name, "user"),
], "Squidex")),
};
}
private static JsonValue CreateJson()
{
return JsonValue.Create(
new JsonObject()
.Add("name", JsonValue.Create("squidex"))
.Add("count", JsonValue.Create(3))
.Add("nested", JsonValue.Create(new JsonObject().Add("flag", JsonValue.True)))
.Add("items", JsonValue.Create(new JsonArray().Add(JsonValue.Create(1)).Add(JsonValue.Create(2)))));
}
} }

19
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/MockupHttpHandler.cs

@ -47,6 +47,23 @@ internal sealed class MockupHttpHandler(HttpResponseMessage response) : HttpMess
currentContentType = body.Headers.ContentType?.MediaType; currentContentType = body.Headers.ContentType?.MediaType;
} }
return response; // The caller disposes the response, therefore every request gets its own copy of the template.
var result = new HttpResponseMessage(response.StatusCode)
{
Content = new StringContent(await response.Content.ReadAsStringAsync(cancellationToken)),
};
foreach (var (key, values) in response.Content.Headers)
{
result.Content.Headers.Remove(key);
result.Content.Headers.TryAddWithoutValidation(key, values);
}
foreach (var (key, values) in response.Headers)
{
result.Headers.TryAddWithoutValidation(key, values);
}
return result;
} }
} }

3
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppCommandMiddlewareTests.cs

@ -43,7 +43,8 @@ public class AppCommandMiddlewareTests : HandlerTestBase<App>
await HandleAsync(new UpdateApp(), replaced); await HandleAsync(new UpdateApp(), replaced);
Assert.Same(replaced, ApiContext.App); // The context is immutable, so the provider gets a new one instead of an updated one.
Assert.Same(replaced, ApiContextProvider.Context.App);
} }
[Fact] [Fact]

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs

@ -31,7 +31,7 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
private readonly IAssetFileStore assetFileStore = A.Fake<IAssetFileStore>(); private readonly IAssetFileStore assetFileStore = A.Fake<IAssetFileStore>();
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>(); private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly IAssetThumbnailGenerator assetGenerator = A.Fake<IAssetThumbnailGenerator>(); private readonly IAssetThumbnailGenerator assetGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly JintScriptEngine sut; private readonly IScriptEngine sut;
public static readonly TheoryData<string> Encodings = public static readonly TheoryData<string> Encodings =
new TheoryData<string>("ascii", "unicode", "utf8", "base64"); new TheoryData<string>("ascii", "unicode", "utf8", "base64");
@ -83,7 +83,7 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
var script = @"getAsset('id')"; var script = @"getAsset('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken).AsTask());
} }
[Fact] [Fact]
@ -114,7 +114,7 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
var script = @"getAssetV2('id')"; var script = @"getAssetV2('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken).AsTask());
} }
[Fact] [Fact]
@ -145,7 +145,7 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
var script = @"getAssetV2('id')"; var script = @"getAssetV2('id')";
await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken)); await Assert.ThrowsAsync<ValidationException>(() => sut.ExecuteAsync(vars, script, ct: CancellationToken).AsTask());
} }
[Fact] [Fact]

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DefaultAssetFileStoreTests.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Globalization; using System.Globalization;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Assets; using Squidex.Assets;
using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.Assets.Repositories;
@ -21,6 +22,7 @@ public class DefaultAssetFileStoreTests : GivenContext
private readonly DomainId assetId = DomainId.NewGuid(); private readonly DomainId assetId = DomainId.NewGuid();
private readonly long assetFileVersion = 21; private readonly long assetFileVersion = 21;
private readonly AssetOptions options = new AssetOptions(); private readonly AssetOptions options = new AssetOptions();
private readonly IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly DefaultAssetFileStore sut; private readonly DefaultAssetFileStore sut;
public static readonly TheoryData<bool, string, string> PathCases = new TheoryData<bool, string, string> public static readonly TheoryData<bool, string, string> PathCases = new TheoryData<bool, string, string>
@ -39,7 +41,7 @@ public class DefaultAssetFileStoreTests : GivenContext
public DefaultAssetFileStoreTests() public DefaultAssetFileStoreTests()
{ {
sut = new DefaultAssetFileStore(assetStore, assetRepository, Options.Create(options)); sut = new DefaultAssetFileStore(assetStore, assetRepository, cache, Options.Create(options));
} }
[Theory] [Theory]

5
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetsBulkUpdateCommandMiddlewareTests.cs

@ -183,8 +183,9 @@ public class AssetsBulkUpdateCommandMiddlewareTests : GivenContext
{ {
var requestContext = CreateContext(false, PermissionIds.ForApp(id, AppId.Name).Id); var requestContext = CreateContext(false, PermissionIds.ForApp(id, AppId.Name).Id);
A.CallTo(() => contextProvider.Context) // Assign instead of configuring the getter, so that the fake keeps the value like the real
.Returns(requestContext); // provider does. The middleware replaces the context, because it is immutable.
contextProvider.Context = requestContext;
return requestContext; return requestContext;
} }

21
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/CalculateTokensTests.cs

@ -5,7 +5,9 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Text;
using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Assets.Queries.Steps; using Squidex.Domain.Apps.Entities.Assets.Queries.Steps;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure.Json; using Squidex.Infrastructure.Json;
@ -58,4 +60,23 @@ public class CalculateTokensTests : GivenContext
A.CallTo(() => urlGenerator.Root()) A.CallTo(() => urlGenerator.Root())
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact]
public async Task Should_compute_ui_token_with_stable_format()
{
var asset = CreateAsset();
A.CallTo(() => urlGenerator.Root())
.Returns("https://squidex.io");
var target = new CalculateTokens(urlGenerator, TestUtils.DefaultSerializer);
await target.EnrichAsync(ApiContext, [asset], CancellationToken);
var actual = Encoding.UTF8.GetString(Convert.FromBase64String(asset.EditToken!));
var expected = $$"""{"a":"{{asset.AppId.Name}}","i":"{{asset.Id}}","u":"https://squidex.io"}""";
Assert.Equal(expected, actual);
}
} }

24
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/ScriptAssetTests.cs

@ -16,10 +16,14 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries;
public class ScriptAssetTests : GivenContext public class ScriptAssetTests : GivenContext
{ {
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>(); private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly IAsyncScript script = A.Fake<IAsyncScript>();
private readonly ScriptAsset sut; private readonly ScriptAsset sut;
public ScriptAssetTests() public ScriptAssetTests()
{ {
A.CallTo(() => scriptEngine.CreateAsyncScript(A<string>._, A<ScriptOptions>._))
.Returns(script);
sut = new ScriptAsset(scriptEngine); sut = new ScriptAsset(scriptEngine);
} }
@ -30,6 +34,9 @@ public class ScriptAssetTests : GivenContext
await sut.EnrichAsync(ApiContext, [asset], CancellationToken); await sut.EnrichAsync(ApiContext, [asset], CancellationToken);
A.CallTo(() => script.ExecuteAsync(A<AssetScriptVars>._, A<CancellationToken>._))
.MustNotHaveHappened();
A.CallTo(() => scriptEngine.ExecuteAsync(A<AssetScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._)) A.CallTo(() => scriptEngine.ExecuteAsync(A<AssetScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -43,6 +50,9 @@ public class ScriptAssetTests : GivenContext
await sut.EnrichAsync(FrontendContext, [asset], CancellationToken); await sut.EnrichAsync(FrontendContext, [asset], CancellationToken);
A.CallTo(() => script.ExecuteAsync(A<AssetScriptVars>._, A<CancellationToken>._))
.MustNotHaveHappened();
A.CallTo(() => scriptEngine.ExecuteAsync(A<AssetScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._)) A.CallTo(() => scriptEngine.ExecuteAsync(A<AssetScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -56,6 +66,9 @@ public class ScriptAssetTests : GivenContext
await sut.EnrichAsync(ContextWithNoScript(), [asset], CancellationToken); await sut.EnrichAsync(ContextWithNoScript(), [asset], CancellationToken);
A.CallTo(() => script.ExecuteAsync(A<AssetScriptVars>._, A<CancellationToken>._))
.MustNotHaveHappened();
A.CallTo(() => scriptEngine.ExecuteAsync(A<AssetScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._)) A.CallTo(() => scriptEngine.ExecuteAsync(A<AssetScriptVars>._, A<string>._, ScriptOptions(), A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -69,14 +82,15 @@ public class ScriptAssetTests : GivenContext
await sut.EnrichAsync(ApiContext, [asset], CancellationToken); await sut.EnrichAsync(ApiContext, [asset], CancellationToken);
A.CallTo(() => scriptEngine.ExecuteAsync( A.CallTo(() => scriptEngine.CreateAsyncScript("my-query", ScriptOptions()))
.MustHaveHappened();
A.CallTo(() => script.ExecuteAsync(
A<AssetScriptVars>.That.Matches(x => A<AssetScriptVars>.That.Matches(x =>
Equals(x["assetId"], asset.Id) && Equals(x["assetId"], asset.Id) &&
Equals(x["appId"], AppId.Id) && Equals(x["appId"], AppId.Id) &&
Equals(x["appName"], AppId.Name) && Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)), Equals(x["user"], ApiContext.UserPrincipal)),
"my-query",
ScriptOptions(),
CancellationToken)) CancellationToken))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -101,14 +115,12 @@ public class ScriptAssetTests : GivenContext
CancellationToken)) CancellationToken))
.MustHaveHappened(); .MustHaveHappened();
A.CallTo(() => scriptEngine.ExecuteAsync( A.CallTo(() => script.ExecuteAsync(
A<AssetScriptVars>.That.Matches(x => A<AssetScriptVars>.That.Matches(x =>
Equals(x.GetValue<object>("assetId"), asset.Id) && Equals(x.GetValue<object>("assetId"), asset.Id) &&
Equals(x["appId"], AppId.Id) && Equals(x["appId"], AppId.Id) &&
Equals(x["appName"], AppId.Name) && Equals(x["appName"], AppId.Name) &&
Equals(x["user"], ApiContext.UserPrincipal)), Equals(x["user"], ApiContext.UserPrincipal)),
"my-query",
ScriptOptions(),
CancellationToken)) CancellationToken))
.MustHaveHappened(); .MustHaveHappened();
} }

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save