Browse Source

GraphQL Data loader fix. (#1006)

* GraphQL Data loader fix.

* Fix tests

* Fix event store.

* Simplify tests
pull/1009/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
6b7c6fc8a6
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 22
      backend/extensions/Squidex.Extensions/Actions/Script/ScriptActionHandler.cs
  2. 2
      backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
  3. 2
      backend/i18n/translator/Squidex.Translator/Squidex.Translator.csproj
  4. 2
      backend/src/Migrations/Migrations.csproj
  5. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedAssetEvent.cs
  6. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  7. 12
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs
  8. 22
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetCommandScriptVars.cs
  9. 33
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetEntityScriptVars.cs
  10. 19
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetScriptVars.cs
  11. 30
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentScriptVars.cs
  12. 7
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs
  13. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/DataScriptVars.cs
  14. 8
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EventScriptVars.cs
  15. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/AssetMetadataWrapper.cs
  16. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/JintExtensions.cs
  17. 13
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs
  18. 93
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs
  19. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptExecutionContext.cs
  20. 109
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptVars.cs
  21. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs
  22. 4
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  23. 28
      backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs
  24. 5
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository_SnapshotStore.cs
  25. 5
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs
  26. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj
  27. 8
      backend/src/Squidex.Domain.Apps.Entities/Apps/DomainObject/AppCommandMiddleware.cs
  28. 109
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs
  29. 207
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsJintExtension.cs
  30. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs
  31. 6
      backend/src/Squidex.Domain.Apps.Entities/Assets/DefaultAssetFileStore.cs
  32. 14
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs
  33. 124
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptMetadataWrapper.cs
  34. 41
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptingExtensions.cs
  35. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFileStore.cs
  36. 12
      backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs
  37. 3
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/Steps/ScriptAsset.cs
  38. 69
      backend/src/Squidex.Domain.Apps.Entities/Assets/Transformations.cs
  39. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Counter/CounterJintExtension.cs
  40. 70
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Cache/CachingBatchLoader.cs
  41. 36
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Cache/CachingDataLoaderExtensions.cs
  42. 25
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Cache/EmptyDataLoaderResult.cs
  43. 34
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Cache/NonCachingBatchLoader.cs
  44. 126
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  45. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLOptions.cs
  46. 7
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs
  47. 17
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
  48. 14
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs
  49. 14
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs
  50. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  51. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs
  52. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesJintExtension.cs
  53. 9
      backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.Designer.cs
  54. 3
      backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.resx
  55. 2
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  56. 2
      backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj
  57. 2
      backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj
  58. 2
      backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj
  59. 2
      backend/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj
  60. 2
      backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj
  61. 5
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs
  62. 4
      backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs
  63. 4
      backend/src/Squidex.Infrastructure.MongoDb/Log/MongoRequestLogRepository.cs
  64. 2
      backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs
  65. 2
      backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj
  66. 8
      backend/src/Squidex.Infrastructure/Caching/IQueryCache.cs
  67. 79
      backend/src/Squidex.Infrastructure/Caching/QueryCache.cs
  68. 125
      backend/src/Squidex.Infrastructure/Collections/ListDictionary.KeyCollection.cs
  69. 125
      backend/src/Squidex.Infrastructure/Collections/ListDictionary.ValueCollection.cs
  70. 273
      backend/src/Squidex.Infrastructure/Collections/ListDictionary.cs
  71. 4
      backend/src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs
  72. 2
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  73. 2
      backend/src/Squidex.Web/Pipeline/SchemaResolver.cs
  74. 2
      backend/src/Squidex.Web/Squidex.Web.csproj
  75. 8
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppImageController.cs
  76. 12
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs
  77. 8
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs
  78. 2
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  79. 8
      backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs
  80. 2
      backend/src/Squidex/Config/Domain/AssetServices.cs
  81. 2
      backend/src/Squidex/Config/Domain/ContentsServices.cs
  82. 11
      backend/src/Squidex/Config/Web/WebServices.cs
  83. 2
      backend/src/Squidex/Squidex.csproj
  84. 3
      backend/src/Squidex/appsettings.json
  85. 11
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/AssetMetadataWrapperTests.cs
  86. 5
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineTests.cs
  87. 1
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ScriptingCompleterTests.cs
  88. 13
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ComponentFieldTests.cs
  89. 2
      backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj
  90. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppCommandMiddlewareTests.cs
  91. 14
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsFluidExtensionTests.cs
  92. 62
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsJintExtensionTests.cs
  93. 13
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DefaultAssetFileStoreTests.cs
  94. 24
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs
  95. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj
  96. 2
      backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj
  97. 164
      backend/tests/Squidex.Infrastructure.Tests/Caching/QueryCacheTests.cs
  98. 478
      backend/tests/Squidex.Infrastructure.Tests/Collections/ListDictionaryTests.cs
  99. 2
      backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj
  100. 2
      backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj

22
backend/extensions/Squidex.Extensions/Actions/Script/ScriptActionHandler.cs

@ -5,9 +5,12 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Security.Claims;
using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Shared;
using Squidex.Shared.Identity;
#pragma warning disable MA0048 // File name must match type name #pragma warning disable MA0048 // File name must match type name
@ -36,13 +39,30 @@ public sealed class ScriptActionHandler : RuleActionHandler<ScriptAction, Script
// Script vars are just wrappers over dictionaries for better performance. // Script vars are just wrappers over dictionaries for better performance.
var vars = new EventScriptVars var vars = new EventScriptVars
{ {
Event = job.Event Event = job.Event,
AppId = job.Event.AppId.Id,
AppName = job.Event.AppId.Name,
}; };
if (job.Event is EnrichedUserEventBase userEvent)
{
vars.User = AllPrinicpal();
}
var result = await scriptEngine.ExecuteAsync(vars, job.Script, ct: ct); var result = await scriptEngine.ExecuteAsync(vars, job.Script, ct: ct);
return Result.Success(result.ToString()); return Result.Success(result.ToString());
} }
private static ClaimsPrincipal AllPrinicpal()
{
var claimsIdentity = new ClaimsIdentity();
var claimsPrincipal = new ClaimsPrincipal(claimsIdentity);
claimsIdentity.AddClaim(new Claim(SquidexClaimTypes.Permissions, PermissionIds.All));
return claimsPrincipal;
}
} }
public sealed class ScriptJob public sealed class ScriptJob

2
backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

2
backend/i18n/translator/Squidex.Translator/Squidex.Translator.csproj

@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<NoWarn>NU1608</NoWarn> <NoWarn>NU1608</NoWarn>
</PropertyGroup> </PropertyGroup>

2
backend/src/Migrations/Migrations.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

2
backend/src/Squidex.Domain.Apps.Core.Model/Rules/EnrichedEvents/EnrichedAssetEvent.cs

@ -65,7 +65,7 @@ public sealed class EnrichedAssetEvent : EnrichedUserEventBase, IEnrichedEntityE
public AssetType AssetType { get; set; } public AssetType AssetType { get; set; }
[FieldDescription(nameof(FieldDescriptions.AssetMetadata))] [FieldDescription(nameof(FieldDescriptions.AssetMetadata))]
public AssetMetadata Metadata { get; } public AssetMetadata Metadata { get; set; }
[FieldDescription(nameof(FieldDescriptions.AssetIsImage))] [FieldDescription(nameof(FieldDescriptions.AssetIsImage))]
public bool IsImage public bool IsImage

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

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<RootNamespace>Squidex.Domain.Apps.Core</RootNamespace> <RootNamespace>Squidex.Domain.Apps.Core</RootNamespace>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<NeutralLanguage>en</NeutralLanguage> <NeutralLanguage>en</NeutralLanguage>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>

12
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs

@ -23,11 +23,11 @@ using ValueTaskSupplement;
namespace Squidex.Domain.Apps.Core.HandleRules; namespace Squidex.Domain.Apps.Core.HandleRules;
public class RuleEventFormatter public partial class RuleEventFormatter
{ {
private const string GlobalFallback = "null"; private const string GlobalFallback = "null";
private static readonly Regex RegexPatternOld = new Regex(@"^(?<FullPath>(?<Type>[^_]*)_(?<Path>[^\s]*))", RegexOptions.Compiled | RegexOptions.ExplicitCapture); private static readonly Regex RegexPatternOld = RegexPatternOldFactory();
private static readonly Regex RegexPatternNew = new Regex(@"^\{(?<FullPath>(?<Type>[\w]+)_(?<Path>[\w\.\-]+))[\s]*(\|[\s]*(?<Transform>[^\?}]+))?(\?[\s]*(?<Fallback>[^\}\s]+))?[\s]*\}", RegexOptions.Compiled | RegexOptions.ExplicitCapture); private static readonly Regex RegexPatternNew = RegexPatternNewFactory();
private readonly IJsonSerializer serializer; private readonly IJsonSerializer serializer;
private readonly IEnumerable<IRuleEventFormatter> formatters; private readonly IEnumerable<IRuleEventFormatter> formatters;
private readonly ITemplateEngine templateEngine; private readonly ITemplateEngine templateEngine;
@ -392,4 +392,10 @@ public class RuleEventFormatter
return false; return false;
} }
[GeneratedRegex(@"^(?<FullPath>(?<Type>[^_]*)_(?<Path>[^\s]*))", RegexOptions.Compiled | RegexOptions.ExplicitCapture)]
private static partial Regex RegexPatternOldFactory();
[GeneratedRegex(@"^\{(?<FullPath>(?<Type>[\w]+)_(?<Path>[\w\.\-]+))[\s]*(\|[\s]*(?<Transform>[^\?}]+))?(\?[\s]*(?<Fallback>[^\}\s]+))?[\s]*\}", RegexOptions.Compiled | RegexOptions.ExplicitCapture)]
private static partial Regex RegexPatternNewFactory();
} }

22
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetCommandScriptVars.cs

@ -16,66 +16,66 @@ public sealed class AssetCommandScriptVars : ScriptVars
[FieldDescription(nameof(FieldDescriptions.AssetParentId))] [FieldDescription(nameof(FieldDescriptions.AssetParentId))]
public DomainId ParentId public DomainId ParentId
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetFileHash))] [FieldDescription(nameof(FieldDescriptions.AssetFileHash))]
public string? FileHash public string? FileHash
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetFileName))] [FieldDescription(nameof(FieldDescriptions.AssetFileName))]
public string? FileName public string? FileName
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetSlug))] [FieldDescription(nameof(FieldDescriptions.AssetSlug))]
public string? FileSlug public string? FileSlug
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetMimeType))] [FieldDescription(nameof(FieldDescriptions.AssetMimeType))]
public string? MimeType public string? MimeType
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetParentPath))] [FieldDescription(nameof(FieldDescriptions.AssetParentPath))]
public Array? ParentPath public Array? ParentPath
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetMetadata))] [FieldDescription(nameof(FieldDescriptions.AssetMetadata))]
public AssetMetadata? Metadata public AssetMetadata? Metadata
{ {
set => SetValue(value != null ? new AssetMetadataWrapper(value) : null); set => SetInitial(value != null ? new AssetMetadataWrapper(value) : null);
} }
[FieldDescription(nameof(FieldDescriptions.AssetTags))] [FieldDescription(nameof(FieldDescriptions.AssetTags))]
public HashSet<string>? Tags public HashSet<string>? Tags
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetFileSize))] [FieldDescription(nameof(FieldDescriptions.AssetFileSize))]
public long FileSize public long FileSize
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetIsProtected))] [FieldDescription(nameof(FieldDescriptions.AssetIsProtected))]
public bool? IsProtected public bool? IsProtected
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.EntityRequestDeletePermanent))] [FieldDescription(nameof(FieldDescriptions.EntityRequestDeletePermanent))]
public bool? Permanent public bool? Permanent
{ {
set => SetValue(value); set => SetInitial(value);
} }
} }

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

@ -17,66 +17,77 @@ public sealed class AssetEntityScriptVars : ScriptVars
[FieldDescription(nameof(FieldDescriptions.AssetParentId))] [FieldDescription(nameof(FieldDescriptions.AssetParentId))]
public DomainId ParentId public DomainId ParentId
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetFileHash))] [FieldDescription(nameof(FieldDescriptions.AssetFileHash))]
public string? FileHash public string? FileHash
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetFileName))] [FieldDescription(nameof(FieldDescriptions.AssetFileName))]
public string? FileName public string? FileName
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetSlug))] [FieldDescription(nameof(FieldDescriptions.AssetSlug))]
public string? FileSlug public string? FileSlug
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetMimeType))] [FieldDescription(nameof(FieldDescriptions.AssetMimeType))]
public string? MimeType public string? MimeType
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetParentPath))] [FieldDescription(nameof(FieldDescriptions.AssetParentPath))]
public Array? ParentPath public Array? ParentPath
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetMetadata))] [FieldDescription(nameof(FieldDescriptions.AssetMetadata))]
public AssetMetadata? Metadata public AssetMetadata? Metadata
{ {
set => SetValue(value != null ? new ReadOnlyDictionary<string, JsonValue>(value) : null); set => SetInitial(value != null ? new ReadOnlyDictionary<string, JsonValue>(value) : null);
} }
[FieldDescription(nameof(FieldDescriptions.AssetTags))] [FieldDescription(nameof(FieldDescriptions.AssetTags))]
public HashSet<string>? Tags public HashSet<string>? Tags
{ {
set => SetValue(value != null ? new ReadOnlyCollection<string>(value.ToList()) : null); set => SetInitial(value != null ? new ReadOnlyCollection<string>(value.ToList()) : null);
} }
[FieldDescription(nameof(FieldDescriptions.AssetFileSize))] [FieldDescription(nameof(FieldDescriptions.AssetFileSize))]
public long FileSize public long FileSize
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetFileVersion))] [FieldDescription(nameof(FieldDescriptions.AssetFileVersion))]
public long FileVersion public long FileVersion
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AssetIsProtected))] [FieldDescription(nameof(FieldDescriptions.AssetIsProtected))]
public bool? IsProtected public bool? IsProtected
{ {
set => SetValue(value); set => SetInitial(value);
}
[FieldDescription(nameof(FieldDescriptions.AssetType))]
public AssetType Type
{
set => SetInitial(value);
}
public string? FileId
{
set => SetInitial(value);
} }
} }

19
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/AssetScriptVars.cs

@ -15,42 +15,47 @@ public sealed class AssetScriptVars : ScriptVars
[FieldDescription(nameof(FieldDescriptions.AppId))] [FieldDescription(nameof(FieldDescriptions.AppId))]
public DomainId AppId public DomainId AppId
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.EntityId))] [FieldDescription(nameof(FieldDescriptions.EntityId))]
public DomainId AssetId public DomainId AssetId
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AppName))] [FieldDescription(nameof(FieldDescriptions.AppName))]
public string AppName public string AppName
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.Operation))] [FieldDescription(nameof(FieldDescriptions.Operation))]
public string Operation public string Operation
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.Command))] [FieldDescription(nameof(FieldDescriptions.Command))]
public AssetCommandScriptVars Command public AssetCommandScriptVars Command
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.Asset))] [FieldDescription(nameof(FieldDescriptions.Asset))]
public AssetEntityScriptVars Asset public AssetEntityScriptVars Asset
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.User))] [FieldDescription(nameof(FieldDescriptions.User))]
public ClaimsPrincipal? User public ClaimsPrincipal? User
{ {
set => SetValue(value); set => SetInitial(value);
}
public string? FileId
{
set => SetInitial(value);
} }
} }

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

@ -16,91 +16,91 @@ public sealed class ContentScriptVars : DataScriptVars
[FieldDescription(nameof(FieldDescriptions.ContentValidate))] [FieldDescription(nameof(FieldDescriptions.ContentValidate))]
public Action Validate public Action Validate
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AppId))] [FieldDescription(nameof(FieldDescriptions.AppId))]
public DomainId AppId public DomainId AppId
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.SchemaId))] [FieldDescription(nameof(FieldDescriptions.SchemaId))]
public DomainId SchemaId public DomainId SchemaId
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.EntityId))] [FieldDescription(nameof(FieldDescriptions.EntityId))]
public DomainId ContentId public DomainId ContentId
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.AppName))] [FieldDescription(nameof(FieldDescriptions.AppName))]
public string AppName public string AppName
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.ContentSchemaName))] [FieldDescription(nameof(FieldDescriptions.ContentSchemaName))]
public string SchemaName public string SchemaName
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.Operation))] [FieldDescription(nameof(FieldDescriptions.Operation))]
public string Operation public string Operation
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.EntityRequestDeletePermanent))] [FieldDescription(nameof(FieldDescriptions.EntityRequestDeletePermanent))]
public bool Permanent public bool Permanent
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.User))] [FieldDescription(nameof(FieldDescriptions.User))]
public ClaimsPrincipal? User public ClaimsPrincipal? User
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.ContentStatus))] [FieldDescription(nameof(FieldDescriptions.ContentStatus))]
public Status Status public Status Status
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.ContentStatusOld))] [FieldDescription(nameof(FieldDescriptions.ContentStatusOld))]
public Status StatusOld public Status StatusOld
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.ContentStatusOld))] [FieldDescription(nameof(FieldDescriptions.ContentStatusOld))]
public Status OldStatus public Status OldStatus
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.ContentData))] [FieldDescription(nameof(FieldDescriptions.ContentData))]
public ContentData? DataOld public ContentData? DataOld
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.ContentDataOld))] [FieldDescription(nameof(FieldDescriptions.ContentDataOld))]
public ContentData? OldData public ContentData? OldData
{ {
set => SetValue(value); set => SetInitial(value);
} }
[FieldDescription(nameof(FieldDescriptions.ContentData))] [FieldDescription(nameof(FieldDescriptions.ContentData))]
public override ContentData? Data public override ContentData? Data
{ {
get => GetValue<ContentData?>(); get => GetValue<ContentData?>();
set => SetValue(value); set => SetInitial(value);
} }
} }

7
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ContentWrapper/JsonMapper.cs

@ -5,10 +5,12 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections;
using System.Globalization; using System.Globalization;
using Jint; using Jint;
using Jint.Native; using Jint.Native;
using Jint.Native.Object; using Jint.Native.Object;
using Jint.Runtime.Interop;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
@ -125,6 +127,11 @@ public static class JsonMapper
return result; return result;
} }
if (value is ObjectWrapper wrapper && wrapper.Target is not IDictionary)
{
return JsonValue.Create(wrapper.Target);
}
if (value.IsObject()) if (value.IsObject())
{ {
var obj = value.AsObject(); var obj = value.AsObject();

2
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/DataScriptVars.cs

@ -14,6 +14,6 @@ public class DataScriptVars : ScriptVars
public virtual ContentData? Data public virtual ContentData? Data
{ {
get => GetValue<ContentData?>(); get => GetValue<ContentData?>();
set => SetValue(value); set => SetInitial(value);
} }
} }

8
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/EventScriptVars.cs

@ -15,21 +15,21 @@ public sealed class EventScriptVars : ScriptVars
{ {
public DomainId AppId public DomainId AppId
{ {
set => SetValue(value); set => SetInitial(value);
} }
public string AppName public string AppName
{ {
set => SetValue(value); set => SetInitial(value);
} }
public ClaimsPrincipal User public ClaimsPrincipal User
{ {
set => SetValue(value); set => SetInitial(value);
} }
public EnrichedEvent Event public EnrichedEvent Event
{ {
set => SetValue(value); set => SetInitial(value);
} }
} }

2
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Internal/AssetMetadataWrapper.cs

@ -12,7 +12,7 @@ using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Core.Scripting.Internal; namespace Squidex.Domain.Apps.Core.Scripting.Internal;
internal sealed class AssetMetadataWrapper : IDictionary<string, object?> public sealed class AssetMetadataWrapper : IDictionary<string, object?>
{ {
private readonly AssetMetadata metadata; private readonly AssetMetadata metadata;

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

@ -76,7 +76,7 @@ public static class JintExtensions
{ {
foreach (var (key, item) in vars) foreach (var (key, item) in vars)
{ {
engine.SetValue(key, item.Value!); engine.SetValue(key, item);
} }
} }

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

@ -190,18 +190,23 @@ public sealed class JintScriptEngine : IScriptEngine, IScriptDescriptor
private static Exception MapException(Exception inner) 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) switch (inner)
{ {
case ArgumentException: case ArgumentException:
return new ValidationException(T.Get("common.jsParseError", new { error = inner.Message })); return BuildException("common.jsParseError", inner.Message);
case JavaScriptException: case JavaScriptException:
return new ValidationException(T.Get("common.jsError", new { message = inner.Message })); return BuildException("common.jsError", inner.Message);
case ParserException: case ParserException:
return new ValidationException(T.Get("common.jsError", new { message = inner.Message })); return BuildException("common.jsError", inner.Message);
case DomainException: case DomainException:
return inner; return inner;
default: default:
return new ValidationException(T.Get("common.jsError", new { message = inner.GetType().Name }), inner); return BuildException("common.jsError", inner.GetType().Name, inner);
} }
} }

93
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptContext.cs

@ -1,93 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections;
using System.Diagnostics.CodeAnalysis;
using Squidex.Infrastructure;
using Squidex.Text;
namespace Squidex.Domain.Apps.Core.Scripting;
public class ScriptContext : IEnumerable<KeyValuePair<string, (object? Value, bool IsReadonly)>>
{
private readonly Dictionary<string, (object? Value, bool IsReadonly)> values = new Dictionary<string, (object? Value, bool IsReadonly)>(StringComparer.OrdinalIgnoreCase);
public void CopyFrom(ScriptVars vars)
{
Guard.NotNull(vars);
foreach (var (key, item) in vars)
{
if (!values.ContainsKey(key))
{
SetItem(key, item);
}
}
}
public void SetItem(string? key, (object? Value, bool IsReadonly) item)
{
Set(key, item.Value, item.IsReadonly);
}
public void Set(string? key, object? value, bool isReadonly = false)
{
if (string.IsNullOrWhiteSpace(key))
{
return;
}
var finalKey = key.ToCamelCase();
if (values.TryGetValue(finalKey, out var existing) && existing.IsReadonly)
{
return;
}
values[finalKey] = (value, isReadonly);
}
public bool TryGetValue(string key, [MaybeNullWhen(false)] out object? value)
{
Guard.NotNull(key);
value = default!;
if (values.TryGetValue(key, out var item))
{
value = item.Value;
return true;
}
return false;
}
public bool TryGetValue<T>(string key, [MaybeNullWhen(false)] out T value)
{
Guard.NotNull(key);
value = default!;
if (values.TryGetValue(key, out var item) && item.Value is T typed)
{
value = typed;
return true;
}
return false;
}
public IEnumerator<KeyValuePair<string, (object? Value, bool IsReadonly)>> GetEnumerator()
{
return values.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return values.GetEnumerator();
}
}

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

@ -12,7 +12,7 @@ using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Core.Scripting; namespace Squidex.Domain.Apps.Core.Scripting;
public abstract class ScriptExecutionContext : ScriptContext public abstract class ScriptExecutionContext : ScriptVars
{ {
public Engine Engine { get; } public Engine Engine { get; }

109
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/ScriptVars.cs

@ -5,12 +5,34 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using Squidex.Infrastructure;
using Squidex.Text;
namespace Squidex.Domain.Apps.Core.Scripting; namespace Squidex.Domain.Apps.Core.Scripting;
public class ScriptVars : ScriptContext public class ScriptVars : IReadOnlyDictionary<string, object?>
{ {
private readonly Dictionary<string, object?> values = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> lockedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
public IEnumerable<string> Keys
{
get => values.Keys;
}
public IEnumerable<object?> Values
{
get => values.Values;
}
public int Count
{
get => values.Count;
}
public object? this[string key] public object? this[string key]
{ {
get get
@ -18,12 +40,78 @@ public class ScriptVars : ScriptContext
TryGetValue(key, out var result); TryGetValue(key, out var result);
return result; return result;
} }
set => Set(key, value, true); set
{
Set(key, value);
}
}
public void CopyFrom(ScriptVars vars)
{
Guard.NotNull(vars);
foreach (var (key, item) in vars.values)
{
if (!values.ContainsKey(key))
{
Set(key, item, vars.lockedKeys.Contains(key));
}
}
}
public void Set(string? key, object? value, bool isReadonly = false)
{
if (string.IsNullOrWhiteSpace(key))
{
return;
}
var finalKey = key.ToCamelCase();
if (lockedKeys.Contains(finalKey))
{
return;
}
values[finalKey] = value;
if (isReadonly)
{
lockedKeys.Add(finalKey);
}
else
{
lockedKeys.Remove(finalKey);
}
}
public bool TryGetValue(string key, [MaybeNullWhen(false)] out object? value)
{
Guard.NotNull(key);
values.TryGetValue(key, out value);
return true;
} }
public void SetValue(object? value, [CallerMemberName] string? key = null) public bool TryGetValueIfExists<T>(string key, [MaybeNullWhen(false)] out T value)
{
Guard.NotNull(key);
value = default!;
if (values.TryGetValue(key, out var item) && item is T typed)
{
value = typed;
return true;
}
return false;
}
public ScriptVars SetInitial(object? value, [CallerMemberName] string? key = null)
{ {
Set(key, value, true); Set(key, value, true);
return this;
} }
public T GetValue<T>([CallerMemberName] string? key = null) public T GetValue<T>([CallerMemberName] string? key = null)
@ -35,4 +123,19 @@ public class ScriptVars : ScriptContext
return default!; return default!;
} }
public bool ContainsKey(string key)
{
return values.ContainsKey(key);
}
IEnumerator<KeyValuePair<string, object?>> IEnumerable<KeyValuePair<string, object?>>.GetEnumerator()
{
return values.GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return values.GetEnumerator();
}
} }

2
backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/WritableContext.cs

@ -22,7 +22,7 @@ internal sealed class WritableContext : ObjectInstance
foreach (var (key, item) in vars) foreach (var (key, item) in vars)
{ {
base.Set(key, FromObject(engine, item.Value), this); base.Set(key, FromObject(engine, item), this);
} }
} }

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

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<RootNamespace>Squidex.Domain.Apps.Core</RootNamespace> <RootNamespace>Squidex.Domain.Apps.Core</RootNamespace>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<NeutralLanguage>en</NeutralLanguage> <NeutralLanguage>en</NeutralLanguage>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@ -20,7 +20,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Fluid.Core" Version="2.4.0" /> <PackageReference Include="Fluid.Core" Version="2.4.0" />
<PackageReference Include="GeoJSON.Net" Version="1.2.19" /> <PackageReference Include="GeoJSON.Net" Version="1.2.19" />
<PackageReference Include="Jint" Version="3.0.0-beta-2046" /> <PackageReference Include="Jint" Version="3.0.0-beta-2049" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.62"> <PackageReference Include="Meziantou.Analyzer" Version="2.0.62">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

28
backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/JsonValueConverter.cs

@ -256,22 +256,33 @@ public sealed class JsonValueConverter : IFieldVisitor<(object? Result, JsonErro
var id = DomainId.Empty; var id = DomainId.Empty;
if (o.TryGetValue("schemaName", out var found) && found.Value is string schemaName) if (o.TryGetValue(Component.Descriptor, out var found) && found.Value is string schemaName)
{ {
id = components.FirstOrDefault(x => x.Value.Name == schemaName).Key; id = components.FirstOrDefault(x => x.Value.Name == schemaName).Key;
o.Remove("schemaName");
o[Component.Discriminator] = id;
} }
else if (o.TryGetValue(Component.Discriminator, out found) && found.Value is string discriminator) else if (o.TryGetValue(Component.Discriminator, out found) && found.Value is string discriminator)
{ {
id = DomainId.Create(discriminator); if (Guid.TryParseExact(discriminator, "D", out _))
{
id = DomainId.Create(discriminator);
}
else
{
var componentEntry = components.FirstOrDefault(x => x.Value.Name == discriminator);
if (componentEntry.Value != null)
{
id = componentEntry.Key;
}
else
{
id = DomainId.Create(discriminator);
}
}
} }
else if (allowedIds?.Count == 1) else if (allowedIds?.Count == 1)
{ {
id = allowedIds[0]; id = allowedIds[0];
o[Component.Discriminator] = id;
} }
if (id == default) if (id == default)
@ -286,6 +297,9 @@ public sealed class JsonValueConverter : IFieldVisitor<(object? Result, JsonErro
var data = new JsonObject(o); var data = new JsonObject(o);
o[Component.Discriminator] = id;
data.Remove(Component.Descriptor);
data.Remove(Component.Discriminator); data.Remove(Component.Discriminator);
return (new Component(id.ToString(), data, schema), null); return (new Component(id.ToString(), data, schema), null);

5
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetFolderRepository_SnapshotStore.cs

@ -27,8 +27,9 @@ public sealed partial class MongoAssetFolderRepository : ISnapshotStore<AssetFol
IAsyncEnumerable<SnapshotResult<AssetFolderDomainObject.State>> ISnapshotStore<AssetFolderDomainObject.State>.ReadAllAsync( IAsyncEnumerable<SnapshotResult<AssetFolderDomainObject.State>> ISnapshotStore<AssetFolderDomainObject.State>.ReadAllAsync(
CancellationToken ct) CancellationToken ct)
{ {
return Collection.Find(FindAll, Batching.Options).ToAsyncEnumerable(ct) var documents = Collection.Find(FindAll, Batching.Options).ToAsyncEnumerable(ct);
.Select(x => new SnapshotResult<AssetFolderDomainObject.State>(x.DocumentId, x.ToState(), x.Version, true));
return documents.Select(x => new SnapshotResult<AssetFolderDomainObject.State>(x.DocumentId, x.ToState(), x.Version, true));
} }
async Task<SnapshotResult<AssetFolderDomainObject.State>> ISnapshotStore<AssetFolderDomainObject.State>.ReadAsync(DomainId key, async Task<SnapshotResult<AssetFolderDomainObject.State>> ISnapshotStore<AssetFolderDomainObject.State>.ReadAsync(DomainId key,

5
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs

@ -27,8 +27,9 @@ public sealed partial class MongoAssetRepository : ISnapshotStore<AssetDomainObj
IAsyncEnumerable<SnapshotResult<AssetDomainObject.State>> ISnapshotStore<AssetDomainObject.State>.ReadAllAsync( IAsyncEnumerable<SnapshotResult<AssetDomainObject.State>> ISnapshotStore<AssetDomainObject.State>.ReadAllAsync(
CancellationToken ct) CancellationToken ct)
{ {
return Collection.Find(FindAll, Batching.Options).ToAsyncEnumerable(ct) var documents = Collection.Find(FindAll, Batching.Options).ToAsyncEnumerable(ct);
.Select(x => new SnapshotResult<AssetDomainObject.State>(x.DocumentId, x.ToState(), x.Version));
return documents.Select(x => new SnapshotResult<AssetDomainObject.State>(x.DocumentId, x.ToState(), x.Version));
} }
async Task<SnapshotResult<AssetDomainObject.State>> ISnapshotStore<AssetDomainObject.State>.ReadAsync(DomainId key, async Task<SnapshotResult<AssetDomainObject.State>> ISnapshotStore<AssetDomainObject.State>.ReadAsync(DomainId key,

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

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

@ -16,15 +16,15 @@ namespace Squidex.Domain.Apps.Entities.Apps.DomainObject;
public sealed class AppCommandMiddleware : AggregateCommandMiddleware<AppCommandBase, AppDomainObject> public sealed class AppCommandMiddleware : AggregateCommandMiddleware<AppCommandBase, AppDomainObject>
{ {
private readonly IAppImageStore appImageStore; private readonly IAppImageStore appImageStore;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAssetThumbnailGenerator assetGenerator;
private readonly IContextProvider contextProvider; private readonly IContextProvider contextProvider;
public AppCommandMiddleware(IDomainObjectFactory domainObjectFactory, public AppCommandMiddleware(IDomainObjectFactory domainObjectFactory,
IAppImageStore appImageStore, IAssetThumbnailGenerator assetThumbnailGenerator, IContextProvider contextProvider) IAppImageStore appImageStore, IAssetThumbnailGenerator assetGenerator, IContextProvider contextProvider)
: base(domainObjectFactory) : base(domainObjectFactory)
{ {
this.appImageStore = appImageStore; this.appImageStore = appImageStore;
this.assetThumbnailGenerator = assetThumbnailGenerator; this.assetGenerator = assetGenerator;
this.contextProvider = contextProvider; this.contextProvider = contextProvider;
} }
@ -57,7 +57,7 @@ public sealed class AppCommandMiddleware : AggregateCommandMiddleware<AppCommand
await using (var uploadStream = file.OpenRead()) await using (var uploadStream = file.OpenRead())
{ {
var image = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream, file.MimeType, ct); var image = await assetGenerator.GetImageInfoAsync(uploadStream, file.MimeType, ct);
if (image == null) if (image == null)
{ {

109
backend/src/Squidex.Domain.Apps.Entities/Assets/AssetsFluidExtension.cs

@ -11,7 +11,6 @@ using Fluid.Ast;
using Fluid.Values; using Fluid.Values;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Squidex.Assets; using Squidex.Assets;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Templates; using Squidex.Domain.Apps.Core.Templates;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -21,9 +20,6 @@ namespace Squidex.Domain.Apps.Entities.Assets;
public sealed class AssetsFluidExtension : IFluidExtension public sealed class AssetsFluidExtension : IFluidExtension
{ {
private static readonly FluidValue ErrorNoAsset = new StringValue("NoAsset");
private static readonly FluidValue ErrorNoImage = new StringValue("NoImage");
private static readonly FluidValue ErrorTooBig = new StringValue("ErrorTooBig");
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
public AssetsFluidExtension(IServiceProvider serviceProvider) public AssetsFluidExtension(IServiceProvider serviceProvider)
@ -85,91 +81,60 @@ public sealed class AssetsFluidExtension : IFluidExtension
{ {
options.Filters.AddFilter("assetText", async (input, arguments, context) => options.Filters.AddFilter("assetText", async (input, arguments, context) =>
{ {
if (input is not ObjectValue objectValue) TryGetAssetRef(input, out var asset);
{
return ErrorNoAsset;
}
async Task<FluidValue> ResolveAssetTextAsync(AssetRef asset)
{
if (asset.FileSize > 256_000)
{
return ErrorTooBig;
}
var assetFileStore = serviceProvider.GetRequiredService<IAssetFileStore>();
var encoding = arguments.At(0).ToStringValue()?.ToUpperInvariant();
var encoded = await asset.GetTextAsync(encoding, assetFileStore, default);
return new StringValue(encoded);
}
switch (objectValue.ToObjectValue()) var encoding = arguments.At(0).ToStringValue()?.ToUpperInvariant();
{ var encoded = await asset.GetTextAsync(encoding, serviceProvider, default);
case IAssetEntity asset:
return await ResolveAssetTextAsync(asset.ToRef());
case EnrichedAssetEvent @event:
return await ResolveAssetTextAsync(@event.ToRef());
}
return ErrorNoAsset; return new StringValue(encoded);
}); });
options.Filters.AddFilter("assetBlurHash", async (input, arguments, context) => options.Filters.AddFilter("assetBlurHash", async (input, arguments, context) =>
{ {
if (input is not ObjectValue objectValue) TryGetAssetRef(input, out var asset);
{
return ErrorNoAsset;
}
async Task<FluidValue> ResolveAssetHashAsync(AssetRef asset) var options = new BlurOptions();
{
if (asset.FileSize > 512_000)
{
return ErrorTooBig;
}
if (asset.Type != AssetType.Image)
{
return ErrorNoImage;
}
var options = new BlurOptions(); var arg0 = arguments.At(0);
var arg1 = arguments.At(1);
var arg0 = arguments.At(0); if (arg0.Type == FluidValues.Number)
var arg1 = arguments.At(1); {
options.ComponentX = (int)arg0.ToNumberValue();
}
if (arg0.Type == FluidValues.Number) if (arg1.Type == FluidValues.Number)
{ {
options.ComponentX = (int)arg0.ToNumberValue(); options.ComponentX = (int)arg1.ToNumberValue();
} }
if (arg1.Type == FluidValues.Number) var blur = await asset.GetBlurHashAsync(options, serviceProvider, default);
{
options.ComponentX = (int)arg1.ToNumberValue();
}
var assetFileStore = serviceProvider.GetRequiredService<IAssetFileStore>(); return new StringValue(blur);
var assetThumbnailGenerator = serviceProvider.GetRequiredService<IAssetThumbnailGenerator>(); });
}
var blur = await asset.GetBlurHashAsync(options, assetFileStore, assetThumbnailGenerator, default); private static bool TryGetAssetRef(FluidValue input, out AssetRef assetRef)
{
assetRef = default;
return new StringValue(blur); if (input is not ObjectValue objectValue)
} {
return false;
}
switch (objectValue.ToObjectValue()) switch (objectValue.ToObjectValue())
{ {
case IAssetEntity asset: case IAssetEntity asset:
return await ResolveAssetHashAsync(asset.ToRef()); assetRef = asset.ToRef();
return true;
case EnrichedAssetEvent @event: case EnrichedAssetEvent @event:
return await ResolveAssetHashAsync(@event.ToRef()); assetRef = @event.ToRef();
} return true;
}
return ErrorNoAsset; return true;
});
} }
private static async Task<IAssetEntity?> ResolveAssetAsync(IServiceProvider serviceProvider, DomainId appId, FluidValue id) private static async Task<IAssetEntity?> ResolveAssetAsync(IServiceProvider serviceProvider, DomainId appId, FluidValue id)

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

@ -8,6 +8,7 @@
using System.Security.Claims; using System.Security.Claims;
using Jint; using Jint;
using Jint.Native; using Jint.Native;
using Jint.Native.Object;
using Jint.Runtime; using Jint.Runtime;
using Jint.Runtime.Interop; using Jint.Runtime.Interop;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@ -15,17 +16,19 @@ using Squidex.Assets;
using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.Scripting.ContentWrapper;
using Squidex.Domain.Apps.Core.Scripting.Internal; using Squidex.Domain.Apps.Core.Scripting.Internal;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Properties; using Squidex.Domain.Apps.Entities.Properties;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets; namespace Squidex.Domain.Apps.Entities.Assets;
public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor
{ {
private static readonly JsString ErrorNoAsset = new JsString(nameof(ErrorNoAsset)); private delegate void UpdateAssetDelegate(JsValue asset, JsValue metadata);
private static readonly JsString ErrorTooBig = new JsString(nameof(ErrorTooBig));
private delegate void GetAssetsDelegate(JsValue references, Action<JsValue> callback); private delegate void GetAssetsDelegate(JsValue references, Action<JsValue> callback);
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);
@ -41,16 +44,64 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor
AddGetAssetText(context); AddGetAssetText(context);
AddGetAssetBlurHash(context); AddGetAssetBlurHash(context);
AddGetAssetObject(context); AddGetAssetObject(context);
AddUpdateAsset(context);
}
private void AddUpdateAsset(ScriptExecutionContext context)
{
if (!context.TryGetValueIfExists<ClaimsPrincipal>("user", out var user))
{
return;
}
var updateAsset = new UpdateAssetDelegate((asset, metadata) =>
{
UpdateAsset(context, user, asset, metadata);
});
context.Engine.SetValue("updateAsset", updateAsset);
}
private void UpdateAsset(ScriptExecutionContext context, ClaimsPrincipal user, JsValue input, JsValue metadata)
{
context.Schedule(async (scheduler, ct) =>
{
if (!TryGetAssetRef(context, input, out var asset) || metadata is not ObjectInstance metadataObj)
{
return;
}
var commandBus = serviceProvider.GetRequiredService<ICommandBus>();
var assetMetadata = new AssetMetadata();
foreach (var (key, value) in metadataObj.GetOwnProperties())
{
assetMetadata[key.AsString()] = JsonMapper.Map(value.Value);
}
var command = new AnnotateAsset
{
FromRule = true,
AppId = asset.AppId,
Actor = RefToken.Client("Script"),
AssetId = asset.Id,
Metadata = assetMetadata,
User = user,
};
await commandBus.PublishAsync(command, default);
});
} }
private void AddGetAssetObject(ScriptExecutionContext context) private void AddGetAssetObject(ScriptExecutionContext context)
{ {
if (!context.TryGetValue<DomainId>("appId", out var appId)) if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{ {
return; return;
} }
if (!context.TryGetValue<ClaimsPrincipal>("user", out var user)) if (!context.TryGetValueIfExists<ClaimsPrincipal>("user", out var user))
{ {
return; return;
} }
@ -99,46 +150,16 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor
context.Schedule(async (scheduler, ct) => context.Schedule(async (scheduler, ct) =>
{ {
if (input is not ObjectWrapper objectWrapper) TryGetAssetRef(context, input, out var asset);
try
{ {
scheduler.Run(callback, ErrorNoAsset); var text = await asset.GetTextAsync(encoding?.ToString(), serviceProvider, ct);
return;
}
async Task ResolveAssetText(AssetRef asset) scheduler.Run(callback, text);
{
if (asset.FileSize > 256_000)
{
scheduler.Run(callback, ErrorTooBig);
return;
}
var assetFileStore = serviceProvider.GetRequiredService<IAssetFileStore>();
try
{
var text = await asset.GetTextAsync(encoding?.ToString(), assetFileStore, ct);
scheduler.Run(callback, text);
}
catch
{
scheduler.Run(callback, JsValue.Null);
}
} }
catch
switch (objectWrapper.Target)
{ {
case IAssetEntity asset: scheduler.Run(callback, JsValue.Null);
await ResolveAssetText(asset.ToRef());
break;
case EnrichedAssetEvent e:
await ResolveAssetText(e.ToRef());
break;
default:
scheduler.Run(callback, ErrorNoAsset);
break;
} }
}); });
} }
@ -152,59 +173,29 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor
context.Schedule(async (scheduler, ct) => context.Schedule(async (scheduler, ct) =>
{ {
if (input is not ObjectWrapper objectWrapper) TryGetAssetRef(context, input, out var asset);
var options = new BlurOptions();
if (componentX?.IsNumber() == true)
{ {
scheduler.Run(callback, ErrorNoAsset); options.ComponentX = (int)componentX.AsNumber();
return;
} }
async Task ResolveHashAsync(AssetRef asset) if (componentY?.IsNumber() == true)
{ {
if (asset.FileSize > 512_000 || asset.Type != AssetType.Image) options.ComponentX = (int)componentY.AsNumber();
{
scheduler.Run(callback, JsValue.Null);
return;
}
var options = new BlurOptions();
if (componentX?.IsNumber() == true)
{
options.ComponentX = (int)componentX.AsNumber();
}
if (componentY?.IsNumber() == true)
{
options.ComponentX = (int)componentY.AsNumber();
}
var assetGenerator = serviceProvider.GetRequiredService<IAssetThumbnailGenerator>();
var assetFileStore = serviceProvider.GetRequiredService<IAssetFileStore>();
try
{
var hash = await asset.GetBlurHashAsync(options, assetFileStore, assetGenerator, ct);
scheduler.Run(callback, hash);
}
catch
{
scheduler.Run(callback, JsValue.Null);
}
} }
switch (objectWrapper.Target) try
{ {
case IAssetEntity asset: var hash = await asset.GetBlurHashAsync(options, serviceProvider, ct);
await ResolveHashAsync(asset.ToRef());
break;
case EnrichedAssetEvent @event:
await ResolveHashAsync(@event.ToRef());
break;
default: scheduler.Run(callback, hash);
scheduler.Run(callback, ErrorNoAsset); }
break; catch
{
scheduler.Run(callback, JsValue.Null);
} }
}); });
} }
@ -282,6 +273,49 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor
}); });
} }
private static bool TryGetAssetRef(ScriptExecutionContext context, JsValue input, out AssetRef assetRef)
{
assetRef = default;
if (input is not ObjectWrapper objectWrapper)
{
return false;
}
switch (objectWrapper.Target)
{
case IAssetEntity asset:
assetRef = asset.ToRef();
return true;
case EnrichedAssetEvent @event:
assetRef = @event.ToRef();
return true;
case AssetEntityScriptVars vars:
if (!context.TryGetValueIfExists<string>(nameof(AssetScriptVars.AppName), out var appName) ||
!context.TryGetValueIfExists<DomainId>(nameof(AssetScriptVars.AppId), out var appId) ||
!context.TryGetValueIfExists<DomainId>(nameof(AssetScriptVars.AssetId), out var assetId))
{
return false;
}
context.TryGetValueIfExists<string?>(nameof(AssetScriptVars.FileId), out var fileId);
assetRef = new AssetRef(
NamedId.Of(appId, appName),
assetId,
vars.GetValue<long>(nameof(AssetEntityScriptVars.FileVersion)),
vars.GetValue<long>(nameof(AssetEntityScriptVars.FileSize)),
vars.GetValue<string>(nameof(AssetEntityScriptVars.MimeType)),
fileId,
vars.GetValue<AssetType>(nameof(AssetEntityScriptVars.Type)));
return true;
}
return true;
}
private async Task<IAppEntity> GetAppAsync(DomainId appId, private async Task<IAppEntity> GetAppAsync(DomainId appId,
CancellationToken ct) CancellationToken ct)
{ {
@ -319,5 +353,8 @@ public sealed class AssetsJintExtension : IJintExtension, IScriptDescriptor
describe(JsonType.Function, "getAssetBlurHash(asset, callback, x?, y?)", describe(JsonType.Function, "getAssetBlurHash(asset, callback, x?, y?)",
Resources.ScriptingGetBlurHash); Resources.ScriptingGetBlurHash);
describe(JsonType.Function, "updateAsset(asset, metadata)",
Resources.ScriptingUpdateAsset);
} }
} }

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs

@ -21,4 +21,6 @@ public abstract class UploadAssetCommand : AssetCommand
public AssetType Type { get; set; } public AssetType Type { get; set; }
public string FileHash { get; set; } public string FileHash { get; set; }
public string FileId { get; set; }
} }

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

@ -88,6 +88,12 @@ public sealed class DefaultAssetFileStore : IAssetFileStore, IDeleter
} }
} }
public Task DownloadAsync(string tempFile, Stream stream,
CancellationToken ct = default)
{
return assetStore.DownloadAsync(tempFile, stream, default, ct);
}
public Task UploadAsync(DomainId appId, DomainId id, long fileVersion, string? suffix, Stream stream, bool overwrite = true, public Task UploadAsync(DomainId appId, DomainId id, long fileVersion, string? suffix, Stream stream, bool overwrite = true,
CancellationToken ct = default) CancellationToken ct = default)
{ {

14
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs

@ -69,11 +69,12 @@ public sealed class AssetCommandMiddleware : CachingDomainObjectMiddleware<Asset
private async Task UploadWithDuplicateCheckAsync(CommandContext context, UploadAssetCommand command, bool duplicate, NextDelegate next, private async Task UploadWithDuplicateCheckAsync(CommandContext context, UploadAssetCommand command, bool duplicate, NextDelegate next,
CancellationToken ct) CancellationToken ct)
{ {
var tempFile = context.ContextId.ToString(); // Add the file Id to the command, so we can access it later in the script.
command.FileId = context.ContextId.ToString();
try try
{ {
await EnrichWithHashAndUploadAsync(command, tempFile, ct); await EnrichWithHashAndUploadAsync(command, command.FileId, ct);
if (!duplicate) if (!duplicate)
{ {
@ -99,25 +100,26 @@ public sealed class AssetCommandMiddleware : CachingDomainObjectMiddleware<Asset
} }
finally finally
{ {
await assetFileStore.DeleteAsync(tempFile, ct); await assetFileStore.DeleteAsync(command.FileId, ct);
} }
} }
private async Task UploadAndHandleAsync(CommandContext context, UploadAssetCommand command, NextDelegate next, private async Task UploadAndHandleAsync(CommandContext context, UploadAssetCommand command, NextDelegate next,
CancellationToken ct) CancellationToken ct)
{ {
var tempFile = context.ContextId.ToString(); // Add the file Id to the command, so we can access it later in the script.
command.FileId = context.ContextId.ToString();
try try
{ {
await EnrichWithHashAndUploadAsync(command, tempFile, ct); await EnrichWithHashAndUploadAsync(command, command.FileId, ct);
await EnrichWithMetadataAsync(command, ct); await EnrichWithMetadataAsync(command, ct);
await base.HandleAsync(context, next, ct); await base.HandleAsync(context, next, ct);
} }
finally finally
{ {
await assetFileStore.DeleteAsync(tempFile, ct); await assetFileStore.DeleteAsync(command.FileId, ct);
} }
} }

124
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptMetadataWrapper.cs

@ -1,124 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections;
using System.Diagnostics.CodeAnalysis;
using Squidex.Domain.Apps.Core.Assets;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject.Guards;
public sealed class ScriptMetadataWrapper : IDictionary<string, object?>
{
private readonly AssetMetadata metadata;
public int Count
{
get => metadata.Count;
}
public ICollection<string> Keys
{
get => metadata.Keys;
}
public ICollection<object?> Values
{
get => metadata.Values.Cast<object?>().ToList();
}
public object? this[string key]
{
get => metadata[key];
set => metadata[key] = JsonValue.Create(value);
}
public bool IsReadOnly
{
get => false;
}
public ScriptMetadataWrapper(AssetMetadata metadata)
{
this.metadata = metadata;
}
public bool TryGetValue(string key, [MaybeNullWhen(false)] out object? value)
{
if (metadata.TryGetValue(key, out var temp))
{
value = temp;
return true;
}
else
{
value = null;
return false;
}
}
public void Add(string key, object? value)
{
metadata.Add(key, JsonValue.Create(value));
}
public void Add(KeyValuePair<string, object?> item)
{
Add(item.Key, item.Value);
}
public bool Remove(string key)
{
return metadata.Remove(key);
}
public bool Remove(KeyValuePair<string, object?> item)
{
return false;
}
public void Clear()
{
metadata.Clear();
}
public bool Contains(KeyValuePair<string, object?> item)
{
return false;
}
public bool ContainsKey(string key)
{
return metadata.ContainsKey(key);
}
public void CopyTo(KeyValuePair<string, object?>[] array, int arrayIndex)
{
var i = arrayIndex;
foreach (var item in metadata)
{
if (i >= array.Length)
{
break;
}
array[i] = new KeyValuePair<string, object?>(item.Key, item.Value);
i++;
}
}
public IEnumerator<KeyValuePair<string, object?>> GetEnumerator()
{
return metadata.Select(x => new KeyValuePair<string, object?>(x.Key, x.Value)).GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return ((IEnumerable)metadata).GetEnumerator();
}
}

41
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/Guards/ScriptingExtensions.cs

@ -36,6 +36,7 @@ public static class ScriptingExtensions
// Script vars are just wrappers over dictionaries for better performance. // Script vars are just wrappers over dictionaries for better performance.
var vars = new AssetScriptVars var vars = new AssetScriptVars
{ {
FileId = create.FileId,
// Tags and metadata are mutable and can be changed from the scripts, but not replaced. // Tags and metadata are mutable and can be changed from the scripts, but not replaced.
Command = new AssetCommandScriptVars Command = new AssetCommandScriptVars
{ {
@ -52,7 +53,23 @@ public static class ScriptingExtensions
Operation = "Create" Operation = "Create"
}; };
await ExecuteScriptAsync(operation, script, vars, ct); var asset = new AssetEntityScriptVars
{
Type = create.Type,
FileHash = create.FileHash,
FileName = create.File.FileName,
FileSlug = create.File.FileName.Slugify(),
FileSize = create.File.FileSize,
FileVersion = 0,
IsProtected = false,
Metadata = create.Metadata,
MimeType = create.File.MimeType,
ParentId = create.ParentId,
ParentPath = await GetPathAsync(operation, create.ParentId, ct),
Tags = create.Tags
};
await ExecuteScriptAsync(operation, script, vars, asset, ct);
} }
public static Task ExecuteUpdateScriptAsync(this AssetOperation operation, UpdateAsset update, public static Task ExecuteUpdateScriptAsync(this AssetOperation operation, UpdateAsset update,
@ -68,6 +85,7 @@ public static class ScriptingExtensions
// Script vars are just wrappers over dictionaries for better performance. // Script vars are just wrappers over dictionaries for better performance.
var vars = new AssetScriptVars var vars = new AssetScriptVars
{ {
FileId = update.FileId,
// Tags and metadata are mutable and can be changed from the scripts, but not replaced. // Tags and metadata are mutable and can be changed from the scripts, but not replaced.
Command = new AssetCommandScriptVars Command = new AssetCommandScriptVars
{ {
@ -81,7 +99,7 @@ public static class ScriptingExtensions
Operation = "Update" Operation = "Update"
}; };
return ExecuteScriptAsync(operation, script, vars, ct); return ExecuteScriptAsync(operation, script, vars, null, ct);
} }
public static Task ExecuteAnnotateScriptAsync(this AssetOperation operation, AnnotateAsset annotate, public static Task ExecuteAnnotateScriptAsync(this AssetOperation operation, AnnotateAsset annotate,
@ -109,7 +127,7 @@ public static class ScriptingExtensions
Operation = "Annotate" Operation = "Annotate"
}; };
return ExecuteScriptAsync(operation, script, vars, ct); return ExecuteScriptAsync(operation, script, vars, null, ct);
} }
public static async Task ExecuteMoveScriptAsync(this AssetOperation operation, MoveAsset move, public static async Task ExecuteMoveScriptAsync(this AssetOperation operation, MoveAsset move,
@ -135,7 +153,7 @@ public static class ScriptingExtensions
Operation = "Move" Operation = "Move"
}; };
await ExecuteScriptAsync(operation, script, vars, ct); await ExecuteScriptAsync(operation, script, vars, null, ct);
} }
public static Task ExecuteDeleteScriptAsync(this AssetOperation operation, DeleteAsset delete, public static Task ExecuteDeleteScriptAsync(this AssetOperation operation, DeleteAsset delete,
@ -158,30 +176,29 @@ public static class ScriptingExtensions
Operation = "Delete" Operation = "Delete"
}; };
return ExecuteScriptAsync(operation, script, vars, ct); return ExecuteScriptAsync(operation, script, vars, null, ct);
} }
private static async Task ExecuteScriptAsync(AssetOperation operation, string script, AssetScriptVars vars, private static async Task ExecuteScriptAsync(AssetOperation operation, string script, AssetScriptVars vars, AssetEntityScriptVars? asset,
CancellationToken ct) CancellationToken ct)
{ {
var snapshot = operation.Snapshot; var snapshot = operation.Snapshot;
var parentPath = await GetPathAsync(operation, snapshot.ParentId, ct);
// Script vars are just wrappers over dictionaries for better performance. // Script vars are just wrappers over dictionaries for better performance.
var asset = new AssetEntityScriptVars asset ??= new AssetEntityScriptVars
{ {
Metadata = snapshot.Metadata, Type = snapshot.Type,
FileHash = snapshot.FileHash, FileHash = snapshot.FileHash,
FileName = snapshot.FileName, FileName = snapshot.FileName,
FileSize = snapshot.FileSize, FileSize = snapshot.FileSize,
FileSlug = snapshot.Slug, FileSlug = snapshot.Slug,
FileVersion = snapshot.FileVersion, FileVersion = snapshot.FileVersion,
IsProtected = snapshot.IsProtected, IsProtected = snapshot.IsProtected,
Metadata = snapshot.Metadata,
MimeType = snapshot.MimeType, MimeType = snapshot.MimeType,
ParentId = snapshot.ParentId, ParentId = snapshot.ParentId,
ParentPath = parentPath, ParentPath = await GetPathAsync(operation, snapshot.ParentId, ct),
Tags = snapshot.Tags Tags = snapshot.Tags,
}; };
vars.AppId = operation.App.Id; vars.AppId = operation.App.Id;

3
backend/src/Squidex.Domain.Apps.Entities/Assets/IAssetFileStore.cs

@ -26,6 +26,9 @@ public interface IAssetFileStore
Task UploadAsync(DomainId appId, DomainId id, long fileVersion, string? suffix, Stream stream, bool overwrite = true, Task UploadAsync(DomainId appId, DomainId id, long fileVersion, string? suffix, Stream stream, bool overwrite = true,
CancellationToken ct = default); CancellationToken ct = default);
Task DownloadAsync(string tempFile, Stream stream,
CancellationToken ct = default);
Task DownloadAsync(DomainId appId, DomainId id, long fileVersion, string? suffix, Stream stream, BytesRange range = default, Task DownloadAsync(DomainId appId, DomainId id, long fileVersion, string? suffix, Stream stream, BytesRange range = default,
CancellationToken ct = default); CancellationToken ct = default);

12
backend/src/Squidex.Domain.Apps.Entities/Assets/ImageAssetMetadataSource.cs

@ -13,11 +13,11 @@ namespace Squidex.Domain.Apps.Entities.Assets;
public sealed class ImageAssetMetadataSource : IAssetMetadataSource public sealed class ImageAssetMetadataSource : IAssetMetadataSource
{ {
private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAssetThumbnailGenerator assetGenerator;
public ImageAssetMetadataSource(IAssetThumbnailGenerator assetThumbnailGenerator) public ImageAssetMetadataSource(IAssetThumbnailGenerator assetGenerator)
{ {
this.assetThumbnailGenerator = assetThumbnailGenerator; this.assetGenerator = assetGenerator;
} }
public async Task EnhanceAsync(UploadAssetCommand command, public async Task EnhanceAsync(UploadAssetCommand command,
@ -31,7 +31,7 @@ public sealed class ImageAssetMetadataSource : IAssetMetadataSource
await using (var uploadStream = command.File.OpenRead()) await using (var uploadStream = command.File.OpenRead())
{ {
imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(uploadStream, mimeType, ct); imageInfo = await assetGenerator.GetImageInfoAsync(uploadStream, mimeType, ct);
} }
if (imageInfo != null) if (imageInfo != null)
@ -48,13 +48,13 @@ public sealed class ImageAssetMetadataSource : IAssetMetadataSource
{ {
await using (var tempStream = tempFile.OpenWrite()) await using (var tempStream = tempFile.OpenWrite())
{ {
await assetThumbnailGenerator.FixAsync(uploadStream, mimeType, tempStream, ct); await assetGenerator.FixAsync(uploadStream, mimeType, tempStream, ct);
} }
} }
await using (var tempStream = tempFile.OpenRead()) await using (var tempStream = tempFile.OpenRead())
{ {
imageInfo = await assetThumbnailGenerator.GetImageInfoAsync(tempStream, mimeType, ct) ?? imageInfo; imageInfo = await assetGenerator.GetImageInfoAsync(tempStream, mimeType, ct) ?? imageInfo;
} }
await command.File.DisposeAsync(); await command.File.DisposeAsync();

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

@ -69,13 +69,14 @@ public sealed class ScriptAsset : IAssetEnricherStep
AssetId = asset.Id, AssetId = asset.Id,
Asset = new AssetEntityScriptVars Asset = new AssetEntityScriptVars
{ {
Metadata = asset.Metadata, Type = asset.Type,
FileHash = asset.FileHash, FileHash = asset.FileHash,
FileName = asset.FileName, FileName = asset.FileName,
FileSize = asset.FileSize, FileSize = asset.FileSize,
FileSlug = asset.Slug, FileSlug = asset.Slug,
FileVersion = asset.FileVersion, FileVersion = asset.FileVersion,
IsProtected = asset.IsProtected, IsProtected = asset.IsProtected,
Metadata = asset.Metadata,
MimeType = asset.MimeType, MimeType = asset.MimeType,
ParentId = asset.ParentId, ParentId = asset.ParentId,
ParentPath = null, ParentPath = null,

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

@ -6,6 +6,8 @@
// ========================================================================== // ==========================================================================
using System.Text; using System.Text;
using Fluid.Values;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Assets; using Squidex.Assets;
using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
@ -18,46 +20,62 @@ using Squidex.Infrastructure.ObjectPool;
namespace Squidex.Domain.Apps.Entities.Assets; namespace Squidex.Domain.Apps.Entities.Assets;
public record struct AssetRef( public record struct AssetRef(
DomainId AppId, NamedId<DomainId> AppId,
DomainId Id, DomainId Id,
long FileVersion, long FileVersion,
long FileSize, long FileSize,
string MimeType, string MimeType,
string? FileId,
AssetType Type); AssetType Type);
public static class Transformations public static class Transformations
{ {
private const int MaxSize = 4 * 1024 * 1024;
private const string ErrorNoAsset = "NoAsset";
private const string ErrorTooBig = "ErrorTooBig";
public static AssetRef ToRef(this EnrichedAssetEvent @event) public static AssetRef ToRef(this EnrichedAssetEvent @event)
{ {
return new AssetRef( return new AssetRef(
@event.AppId.Id, @event.AppId,
@event.Id, @event.Id,
@event.FileVersion, @event.FileVersion,
@event.FileSize, @event.FileSize,
@event.MimeType, @event.MimeType,
null,
@event.AssetType); @event.AssetType);
} }
public static AssetRef ToRef(this IAssetEntity asset) public static AssetRef ToRef(this IAssetEntity asset)
{ {
return new AssetRef( return new AssetRef(
asset.AppId.Id, asset.AppId,
asset.Id, asset.Id,
asset.FileVersion, asset.FileVersion,
asset.FileSize, asset.FileSize,
asset.MimeType, asset.MimeType,
null,
asset.Type); asset.Type);
} }
public static async Task<string> GetTextAsync(this AssetRef asset, string? encoding, public static async Task<string> GetTextAsync(this AssetRef asset, string? encoding, IServiceProvider services,
IAssetFileStore assetFileStore,
CancellationToken ct = default) CancellationToken ct = default)
{ {
using (var stream = DefaultPools.MemoryStream.GetStream()) if (asset == default)
{
return ErrorNoAsset;
}
if (asset.FileSize > MaxSize)
{ {
await assetFileStore.DownloadAsync(asset.AppId, asset.Id, asset.FileVersion, null, stream, default, ct); return ErrorTooBig;
}
stream.Position = 0; var assetFileStore = services.GetRequiredService<IAssetFileStore>();
using (var stream = DefaultPools.MemoryStream.GetStream())
{
await DownloadAsync(asset, assetFileStore, stream, ct);
var bytes = stream.ToArray(); var bytes = stream.ToArray();
@ -75,18 +93,41 @@ public static class Transformations
} }
} }
public static async Task<string?> GetBlurHashAsync(this AssetRef asset, BlurOptions options, public static async Task<string?> GetBlurHashAsync(this AssetRef asset, BlurOptions options, IServiceProvider services,
IAssetFileStore assetFileStore,
IAssetThumbnailGenerator assetThumbnails,
CancellationToken ct = default) CancellationToken ct = default)
{ {
if (asset == default)
{
return ErrorNoAsset;
}
if (asset.FileSize > MaxSize || asset.Type != AssetType.Image)
{
return null;
}
var assetFileStore = services.GetRequiredService<IAssetFileStore>();
var assetGenerator = services.GetRequiredService<IAssetThumbnailGenerator>();
using (var stream = DefaultPools.MemoryStream.GetStream()) using (var stream = DefaultPools.MemoryStream.GetStream())
{ {
await assetFileStore.DownloadAsync(asset.AppId, asset.Id, asset.FileVersion, null, stream, default, ct); await DownloadAsync(asset, assetFileStore, stream, ct);
stream.Position = 0; return await assetGenerator.ComputeBlurHashAsync(stream, asset.MimeType, options, ct);
}
}
return await assetThumbnails.ComputeBlurHashAsync(stream, asset.MimeType, options, ct); private static async Task DownloadAsync(AssetRef asset, IAssetFileStore assetFileStore, MemoryStream stream, CancellationToken ct)
{
if (asset.FileId != null)
{
await assetFileStore.DownloadAsync(asset.FileId, stream, ct);
} }
else
{
await assetFileStore.DownloadAsync(asset.AppId.Id, asset.Id, asset.FileVersion, null, stream, default, ct);
}
stream.Position = 0;
} }
} }

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

@ -26,7 +26,7 @@ public sealed class CounterJintExtension : IJintExtension, IScriptDescriptor
public void Extend(ScriptExecutionContext context) public void Extend(ScriptExecutionContext context)
{ {
if (!context.TryGetValue<DomainId>("appId", out var appId)) if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{ {
return; return;
} }
@ -48,7 +48,7 @@ public sealed class CounterJintExtension : IJintExtension, IScriptDescriptor
public void ExtendAsync(ScriptExecutionContext context) public void ExtendAsync(ScriptExecutionContext context)
{ {
if (!context.TryGetValue<DomainId>("appId", out var appId)) if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{ {
return; return;
} }

70
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Cache/CachingBatchLoader.cs

@ -0,0 +1,70 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using GraphQL.DataLoader;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
#pragma warning disable MA0048 // File name must match type name
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
#pragma warning disable RECS0082 // Parameter has the same name as a member and hides it
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Cache;
record struct CacheableId<T>(T Id, TimeSpan CacheDuration = default);
internal class CachingBatchDataLoader<TKey, T> : DataLoaderBase<CacheableId<TKey>, T> where TKey : notnull where T : class
{
private readonly IQueryCache<TKey, T> queryCache;
private readonly Func<IEnumerable<TKey>, CancellationToken, Task<IDictionary<TKey, T>>> queryDelegate;
public CachingBatchDataLoader(IQueryCache<TKey, T> queryStore,
Func<IEnumerable<TKey>, CancellationToken, Task<IDictionary<TKey, T>>> queryDelegate, bool canCache = true, int maxBatchSize = int.MaxValue)
: base(canCache, maxBatchSize)
{
this.queryCache = queryStore;
this.queryDelegate = queryDelegate;
}
protected override async Task FetchAsync(IEnumerable<DataLoaderPair<CacheableId<TKey>, T>> list,
CancellationToken cancellationToken)
{
var unmatched = new List<DataLoaderPair<CacheableId<TKey>, T>>(list.Count());
foreach (var entry in list)
{
if (entry.Key.CacheDuration != default && queryCache.TryGet(entry.Key.Id, out var cached))
{
entry.SetResult(cached);
}
else
{
unmatched.Add(entry);
}
}
if (unmatched.Count == 0)
{
return;
}
var ids = unmatched.Select(x => x.Key.Id).Distinct();
var entries = await queryDelegate(ids, cancellationToken);
foreach (var entry in unmatched)
{
entries.TryGetValue(entry.Key.Id, out var value);
entry.SetResult(value!);
if (value != null && entry.Key.CacheDuration != default)
{
queryCache.Set(entry.Key.Id, value, entry.Key.CacheDuration);
}
}
}
}

36
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Cache/CachingDataLoaderExtensions.cs

@ -0,0 +1,36 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using GraphQL.DataLoader;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Translations;
using TagLib.IFD.Tags;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Cache;
internal static class CachingDataLoaderExtensions
{
public static IDataLoader<CacheableId<TKey>, T> GetOrAddCachingLoader<TKey, T>(this DataLoaderContext dataLoaderContext, IQueryCache<TKey, T> queryCache, string loaderKey,
Func<IEnumerable<TKey>, CancellationToken, Task<IDictionary<TKey, T>>> queryDelegate, bool canCache = true, int maxBatchSize = int.MaxValue)
where TKey : notnull where T : class
{
return dataLoaderContext.GetOrAdd(loaderKey, () =>
{
return new CachingBatchDataLoader<TKey, T>(queryCache, queryDelegate, canCache, maxBatchSize);
});
}
public static IDataLoader<TKey, T> GetOrAddNonCachingBatchLoader<TKey, T>(this DataLoaderContext dataLoaderContext, string loaderKey,
Func<IEnumerable<TKey>, CancellationToken, Task<IDictionary<TKey, T>>> queryDelegate, int maxBatchSize = int.MaxValue)
where TKey : notnull where T : class
{
return dataLoaderContext.GetOrAdd(loaderKey, () =>
{
return new NonCachingBatchLoader<TKey, T>(queryDelegate, maxBatchSize);
});
}
}

25
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Cache/EmptyDataLoaderResult.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using GraphQL.DataLoader;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Cache;
public sealed class EmptyDataLoaderResult<T> : IDataLoaderResult<T[]>
{
public Task<T[]> GetResultAsync(
CancellationToken cancellationToken = default)
{
return Task.FromResult(Array.Empty<T>());
}
Task<object?> IDataLoaderResult.GetResultAsync(
CancellationToken cancellationToken)
{
return Task.FromResult<object?>(Array.Empty<T>());
}
}

34
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Cache/NonCachingBatchLoader.cs

@ -0,0 +1,34 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using GraphQL.DataLoader;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Cache;
internal class NonCachingBatchLoader<TKey, T> : DataLoaderBase<TKey, T> where TKey : notnull where T : class
{
private readonly Func<IEnumerable<TKey>, CancellationToken, Task<IDictionary<TKey, T>>> queryDelegate;
public NonCachingBatchLoader(Func<IEnumerable<TKey>, CancellationToken, Task<IDictionary<TKey, T>>> queryDelegate, int maxBatchSize = int.MaxValue)
: base(false, maxBatchSize)
{
this.queryDelegate = queryDelegate;
}
protected override async Task FetchAsync(IEnumerable<DataLoaderPair<TKey, T>> list,
CancellationToken cancellationToken)
{
var dictionary = await queryDelegate(list.Select(x => x.Key), cancellationToken).ConfigureAwait(false);
foreach (var item in list)
{
dictionary.TryGetValue(item.Key, out var value);
item.SetResult(value!);
}
}
}

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

@ -6,20 +6,21 @@
// ========================================================================== // ==========================================================================
using GraphQL.DataLoader; using GraphQL.DataLoader;
using Microsoft.Extensions.Options;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Cache;
using Squidex.Domain.Apps.Entities.Contents.Queries; using Squidex.Domain.Apps.Entities.Contents.Queries;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Shared.Users; using Squidex.Shared.Users;
#pragma warning disable CA1826 // Do not use Enumerable methods on indexable collections
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL;
public sealed class GraphQLExecutionContext : QueryExecutionContext public sealed class GraphQLExecutionContext : QueryExecutionContext
{ {
private static readonly List<IEnrichedAssetEntity> EmptyAssets = new List<IEnrichedAssetEntity>(); private static readonly EmptyDataLoaderResult<IEnrichedAssetEntity> EmptyAssets = new EmptyDataLoaderResult<IEnrichedAssetEntity>();
private static readonly List<IEnrichedContentEntity> EmptyContents = new List<IEnrichedContentEntity>(); private static readonly EmptyDataLoaderResult<IEnrichedContentEntity> EmptyContents = new EmptyDataLoaderResult<IEnrichedContentEntity>();
private readonly IDataLoaderContextAccessor dataLoaders; private readonly IDataLoaderContextAccessor dataLoaders;
private readonly GraphQLOptions options;
public override Context Context { get; } public override Context Context { get; }
@ -30,7 +31,8 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
IContentQueryService contentQuery, IContentQueryService contentQuery,
IContentCache contentCache, IContentCache contentCache,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
Context context) Context context,
IOptions<GraphQLOptions> options)
: base(assetQuery, assetCache, contentQuery, contentCache, serviceProvider) : base(assetQuery, assetCache, contentQuery, contentCache, serviceProvider)
{ {
this.dataLoaders = dataLoaders; this.dataLoaders = dataLoaders;
@ -39,6 +41,8 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
.WithoutCleanup() .WithoutCleanup()
.WithoutContentEnrichment() .WithoutContentEnrichment()
.WithoutAssetEnrichment()); .WithoutAssetEnrichment());
this.options = options.Value;
} }
public async ValueTask<IUser?> FindUserAsync(RefToken refToken, public async ValueTask<IUser?> FindUserAsync(RefToken refToken,
@ -56,104 +60,84 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
} }
} }
public async Task<IEnrichedAssetEntity?> GetAssetAsync(DomainId id, TimeSpan cacheDuration, public IDataLoaderResult<IEnrichedContentEntity?> GetContent(DomainId schemaId, DomainId id, long version)
CancellationToken ct)
{ {
var assets = await GetAssetsAsync(new List<DomainId> { id }, cacheDuration, ct); return dataLoaders.Context!.GetOrAddLoader(nameof(GetContent), ct =>
var asset = assets.FirstOrDefault(); {
return FindContentAsync(schemaId.ToString(), id, version, ct);
}).LoadAsync();
}
public IDataLoaderResult<IEnrichedAssetEntity?> GetAsset(DomainId id,
TimeSpan cacheDuration)
{
var assets = GetAssets(new List<DomainId> { id }, cacheDuration);
var asset = assets.Then(x => x.FirstOrDefault());
return asset; return asset;
} }
public async Task<IEnrichedContentEntity?> GetContentAsync(DomainId schemaId, DomainId id, HashSet<string>? fields, TimeSpan cacheDuration, public IDataLoaderResult<IEnrichedContentEntity?> GetContent(DomainId schemaId, DomainId id, HashSet<string>? fields,
CancellationToken ct) TimeSpan cacheDuration)
{ {
var contents = await GetContentsAsync(new List<DomainId> { id }, fields, cacheDuration, ct); var contents = GetContents(new List<DomainId> { id }, fields, cacheDuration);
var content = contents.FirstOrDefault(x => x.SchemaId.Id == schemaId); var content = contents.Then(x => x.FirstOrDefault(x => x.SchemaId.Id == schemaId));
return content; return content;
} }
public async Task<IReadOnlyList<IEnrichedAssetEntity>> GetAssetsAsync(List<DomainId>? ids, TimeSpan cacheDuration, public IDataLoaderResult<IEnrichedAssetEntity[]> GetAssets(List<DomainId>? ids,
CancellationToken ct) TimeSpan cacheDuration)
{ {
if (ids == null || ids.Count == 0) if (ids == null || ids.Count == 0)
{ {
return EmptyAssets; return EmptyAssets;
} }
async Task<IReadOnlyList<IEnrichedAssetEntity>> LoadAsync(IEnumerable<DomainId> ids) return GetAssetsLoader().LoadAsync(BuildKeys(ids, cacheDuration)).Then(x => x.NotNull().ToArray());
{
var result = await GetAssetsLoader().LoadAsync(ids).GetResultAsync(ct);
return result?.NotNull().ToList() ?? EmptyAssets;
}
if (cacheDuration > TimeSpan.Zero)
{
var assets = await AssetCache.CacheOrQueryAsync(ids, async pendingIds =>
{
return await LoadAsync(pendingIds);
}, cacheDuration);
return assets;
}
return await LoadAsync(ids);
} }
public async Task<IReadOnlyList<IEnrichedContentEntity>> GetContentsAsync(List<DomainId>? ids, HashSet<string>? fields, TimeSpan cacheDuration, public IDataLoaderResult<IEnrichedContentEntity[]> GetContents(List<DomainId>? ids, HashSet<string>? fields,
CancellationToken ct) TimeSpan cacheDuration)
{ {
if (ids == null || ids.Count == 0) if (ids == null || ids.Count == 0)
{ {
return EmptyContents; return EmptyContents;
} }
if (cacheDuration > TimeSpan.Zero || fields == null) if (fields == null)
{ {
var contents = await ContentCache.CacheOrQueryAsync(ids, async pendingIds => return GetContentsLoader().LoadAsync(BuildKeys(ids, cacheDuration)).Then(x => x.NotNull().ToArray());
{
var result = await GetContentsLoader().LoadAsync(ids).GetResultAsync(ct);
return result?.NotNull().ToList() ?? EmptyContents;
}, cacheDuration);
return contents.ToList();
} }
else
{
var contents = await GetContentsLoaderWithFields().LoadAsync(ids.Select(x => (x, fields))).GetResultAsync(ct);
return contents?.NotNull().ToList() ?? EmptyContents; return GetContentsLoaderWithFields().LoadAsync(BuildKeys(ids, fields)).Then(x => x.NotNull().ToArray());
}
} }
private IDataLoader<DomainId, IEnrichedAssetEntity> GetAssetsLoader() private IDataLoader<CacheableId<DomainId>, IEnrichedAssetEntity> GetAssetsLoader()
{ {
return dataLoaders.Context!.GetOrAddBatchLoader<DomainId, IEnrichedAssetEntity>(nameof(GetAssetsLoader), return dataLoaders.Context!.GetOrAddCachingLoader(AssetCache, nameof(GetAssetsLoader),
async (batch, ct) => async (batch, ct) =>
{ {
var result = await QueryAssetsByIdsAsync(new List<DomainId>(batch), ct); var result = await QueryAssetsByIdsAsync(batch, ct);
return result.ToDictionary(x => x.Id); return result.ToDictionary(x => x.Id);
}); }, maxBatchSize: options.DataLoaderBatchSize);
} }
private IDataLoader<DomainId, IEnrichedContentEntity> GetContentsLoader() private IDataLoader<CacheableId<DomainId>, IEnrichedContentEntity> GetContentsLoader()
{ {
return dataLoaders.Context!.GetOrAddBatchLoader<DomainId, IEnrichedContentEntity>(nameof(GetContentsLoader), return dataLoaders.Context!.GetOrAddCachingLoader(ContentCache, nameof(GetContentsLoader),
async (batch, ct) => async (batch, ct) =>
{ {
var result = await QueryContentsByIdsAsync(batch, null, ct); var result = await QueryContentsByIdsAsync(batch, null, ct);
return result.ToDictionary(x => x.Id); return result.ToDictionary(x => x.Id);
}); }, maxBatchSize: options.DataLoaderBatchSize);
} }
private IDataLoader<(DomainId Id, HashSet<string> Fields), IEnrichedContentEntity> GetContentsLoaderWithFields() private IDataLoader<(DomainId Id, HashSet<string> Fields), IEnrichedContentEntity> GetContentsLoaderWithFields()
{ {
return dataLoaders.Context!.GetOrAddBatchLoader<(DomainId Id, HashSet<string> Fields), IEnrichedContentEntity>(nameof(GetContentsLoader), return dataLoaders.Context!.GetOrAddNonCachingBatchLoader<(DomainId Id, HashSet<string> Fields), IEnrichedContentEntity>(nameof(GetContentsLoaderWithFields),
async (batch, ct) => async (batch, ct) =>
{ {
var fields = batch.SelectMany(x => x.Fields).ToHashSet(); var fields = batch.SelectMany(x => x.Fields).ToHashSet();
@ -161,7 +145,7 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
var result = await QueryContentsByIdsAsync(batch.Select(x => x.Id), fields, ct); var result = await QueryContentsByIdsAsync(batch.Select(x => x.Id), fields, ct);
return result.ToDictionary(x => (x.Id, fields)); return result.ToDictionary(x => (x.Id, fields));
}); }, maxBatchSize: options.DataLoaderBatchSize);
} }
private IDataLoader<string, IUser> GetUserLoader() private IDataLoader<string, IUser> GetUserLoader()
@ -174,4 +158,30 @@ public sealed class GraphQLExecutionContext : QueryExecutionContext
return result; return result;
}); });
} }
private static (DomainId, HashSet<string>)[] BuildKeys(List<DomainId> ids, HashSet<string> fields)
{
// Use manual loops and arrays to avoid allocations.
var keys = new (DomainId, HashSet<string>)[ids.Count];
for (var i = 0; i < ids.Count; i++)
{
keys[i] = (ids[0], fields);
}
return keys;
}
private static CacheableId<DomainId>[] BuildKeys(List<DomainId> ids, TimeSpan cacheDuration)
{
// Use manual loops and arrays to avoid allocations.
var keys = new CacheableId<DomainId>[ids.Count];
for (var i = 0; i < ids.Count; i++)
{
keys[i] = new CacheableId<DomainId>(ids[i], cacheDuration);
}
return keys;
}
} }

2
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLOptions.cs

@ -11,5 +11,7 @@ public sealed class GraphQLOptions
{ {
public int CacheDuration { get; set; } = 10 * 60; public int CacheDuration { get; set; } = 10 * 60;
public int DataLoaderBatchSize { get; set; } = 1000;
public bool EnableSubscriptions { get; set; } = true; public bool EnableSubscriptions { get; set; } = true;
} }

7
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs

@ -57,13 +57,12 @@ internal static class AssetActions
} }
}; };
public static readonly IFieldResolver Resolver = Resolvers.Async<object, object?>(async (_, fieldContext, context) => public static readonly IFieldResolver Resolver = Resolvers.Sync<object, object?>((_, fieldContext, context) =>
{ {
var assetId = fieldContext.GetArgument<DomainId>("id"); var assetId = fieldContext.GetArgument<DomainId>("id");
return await context.GetAssetAsync(assetId, return context.GetAsset(assetId,
fieldContext.CacheDuration(), fieldContext.CacheDuration());
fieldContext.CancellationToken);
}); });
} }

17
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs

@ -81,7 +81,7 @@ internal static class ContentActions
} }
}; };
public static readonly IFieldResolver Resolver = Resolvers.Async<object, object?>(async (_, fieldContext, context) => public static readonly IFieldResolver Resolver = Resolvers.Sync<object, object?>((_, fieldContext, context) =>
{ {
var contentId = fieldContext.GetArgument<DomainId>("id"); var contentId = fieldContext.GetArgument<DomainId>("id");
@ -90,15 +90,13 @@ internal static class ContentActions
if (contentVersion >= 0) if (contentVersion >= 0)
{ {
return await context.FindContentAsync(contentSchemaId.ToString(), contentId, contentVersion.Value, return context.GetContent(contentSchemaId, contentId, contentVersion.Value);
fieldContext.CancellationToken);
} }
else else
{ {
return await context.GetContentAsync(contentSchemaId, contentId, return context.GetContent(contentSchemaId, contentId,
fieldContext.FieldNames(), fieldContext.FieldNames(),
fieldContext.CacheDuration(), fieldContext.CacheDuration());
fieldContext.CancellationToken);
} }
}); });
} }
@ -115,14 +113,13 @@ internal static class ContentActions
} }
}; };
public static readonly IFieldResolver Resolver = Resolvers.Async<object, object?>(async (_, fieldContext, context) => public static readonly IFieldResolver Resolver = Resolvers.Sync<object, object?>((_, fieldContext, context) =>
{ {
var ids = fieldContext.GetArgument<DomainId[]>("ids").ToList(); var ids = fieldContext.GetArgument<DomainId[]>("ids").ToList();
return await context.GetContentsAsync(ids, return context.GetContents(ids,
fieldContext.FieldNames(), fieldContext.FieldNames(),
fieldContext.CacheDuration(), fieldContext.CacheDuration());
fieldContext.CancellationToken);
}); });
} }

14
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentFields.cs

@ -19,23 +19,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents;
internal static class ContentFields internal static class ContentFields
{ {
public static readonly IFieldResolver ResolveStringFieldAssets = Resolvers.Async<string, object>(async (value, fieldContext, context) => public static readonly IFieldResolver ResolveStringFieldAssets = Resolvers.Sync<string, object>((value, fieldContext, context) =>
{ {
var ids = context.Resolve<StringReferenceExtractor>().GetEmbeddedAssetIds(value).ToList(); var ids = context.Resolve<StringReferenceExtractor>().GetEmbeddedAssetIds(value).ToList();
return await context.GetAssetsAsync(ids, return context.GetAssets(ids,
fieldContext.CacheDuration(), fieldContext.CacheDuration());
fieldContext.CancellationToken);
}); });
public static readonly IFieldResolver ResolveStringFieldContents = Resolvers.Async<string, object>(async (value, fieldContext, context) => public static readonly IFieldResolver ResolveStringFieldContents = Resolvers.Sync<string, object>((value, fieldContext, context) =>
{ {
var ids = context.Resolve<StringReferenceExtractor>().GetEmbeddedContentIds(value).ToList(); var ids = context.Resolve<StringReferenceExtractor>().GetEmbeddedContentIds(value).ToList();
return await context.GetContentsAsync(ids, return context.GetContents(ids,
fieldContext.FieldNames(), fieldContext.FieldNames(),
fieldContext.CacheDuration(), fieldContext.CacheDuration());
fieldContext.CancellationToken);
}); });
public static readonly FieldType Id = new FieldType public static readonly FieldType Id = new FieldType

14
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/FieldVisitor.cs

@ -98,23 +98,21 @@ internal sealed class FieldVisitor : IFieldVisitor<FieldGraphSchema, FieldInfo>
} }
}); });
private static readonly IFieldResolver Assets = CreateAsyncValueResolver((value, fieldContext, context) => private static readonly IFieldResolver Assets = CreateValueResolver((value, fieldContext, context) =>
{ {
var ids = value.AsIds(); var ids = value.AsIds();
return context.GetAssetsAsync(ids, return context.GetAssets(ids,
fieldContext.CacheDuration(), fieldContext.CacheDuration());
fieldContext.CancellationToken);
}); });
private static readonly IFieldResolver References = CreateAsyncValueResolver((value, fieldContext, context) => private static readonly IFieldResolver References = CreateValueResolver((value, fieldContext, context) =>
{ {
var ids = value.AsIds(); var ids = value.AsIds();
return context.GetContentsAsync(ids, return context.GetContents(ids,
fieldContext.FieldNames(), fieldContext.FieldNames(),
fieldContext.CacheDuration(), fieldContext.CacheDuration());
fieldContext.CancellationToken);
}); });
private readonly Builder builder; private readonly Builder builder;

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

@ -221,7 +221,7 @@ public sealed class ContentQueryService : IContentQueryService
var canCache = !context.IsFrontendClient; var canCache = !context.IsFrontendClient;
if (Guid.TryParse(schemaIdOrName, out var guid)) if (Guid.TryParseExact(schemaIdOrName, "D", out var guid))
{ {
var schemaId = DomainId.Create(guid); var schemaId = DomainId.Create(guid);

4
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs

@ -65,8 +65,6 @@ public abstract class QueryExecutionContext : Dictionary<string, object?>
maxRequests.Release(); maxRequests.Release();
} }
AssetCache.SetMany(assets.Select(x => (x.Id, x))!);
return assets; return assets;
} }
@ -85,8 +83,6 @@ public abstract class QueryExecutionContext : Dictionary<string, object?>
maxRequests.Release(); maxRequests.Release();
} }
ContentCache.SetMany(contents.Select(x => (x.Id, x))!);
return contents; return contents;
} }

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

@ -29,12 +29,12 @@ public sealed class ReferencesJintExtension : IJintExtension, IScriptDescriptor
public void ExtendAsync(ScriptExecutionContext context) public void ExtendAsync(ScriptExecutionContext context)
{ {
if (!context.TryGetValue<DomainId>("appId", out var appId)) if (!context.TryGetValueIfExists<DomainId>("appId", out var appId))
{ {
return; return;
} }
if (!context.TryGetValue<ClaimsPrincipal>("user", out var user)) if (!context.TryGetValueIfExists<ClaimsPrincipal>("user", out var user))
{ {
return; return;
} }

9
backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.Designer.cs

@ -185,5 +185,14 @@ namespace Squidex.Domain.Apps.Entities.Properties {
return ResourceManager.GetString("ScriptingResetCounterV2", resourceCulture); return ResourceManager.GetString("ScriptingResetCounterV2", resourceCulture);
} }
} }
/// <summary>
/// Looks up a localized string similar to Update the metadata of the asset..
/// </summary>
internal static string ScriptingUpdateAsset {
get {
return ResourceManager.GetString("ScriptingUpdateAsset", resourceCulture);
}
}
} }
} }

3
backend/src/Squidex.Domain.Apps.Entities/Properties/Resources.resx

@ -159,4 +159,7 @@
<data name="ScriptingResetCounterV2" xml:space="preserve"> <data name="ScriptingResetCounterV2" xml:space="preserve">
<value>Resets the counter with the given name to zero.</value> <value>Resets the counter with the given name to zero.</value>
</data> </data>
<data name="ScriptingUpdateAsset" xml:space="preserve">
<value>Update the metadata of the asset.</value>
</data>
</root> </root>

2
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<NeutralLanguage>en</NeutralLanguage> <NeutralLanguage>en</NeutralLanguage>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>

2
backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

2
backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

2
backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

2
backend/src/Squidex.Infrastructure.Azure/Squidex.Infrastructure.Azure.csproj

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<RootNamespace>Squidex.Infrastructure</RootNamespace> <RootNamespace>Squidex.Infrastructure</RootNamespace>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

2
backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<RootNamespace>Squidex.Infrastructure</RootNamespace> <RootNamespace>Squidex.Infrastructure</RootNamespace>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

5
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStoreSubscription.cs

@ -143,10 +143,9 @@ public sealed class MongoEventStoreSubscription : IEventSubscription
if (byStream != null) if (byStream != null)
{ {
var filterBuilder = Builders<ChangeStreamDocument<MongoEventCommit>>.Filter; var filterBuilder = Builders<ChangeStreamDocument<MongoEventCommit>>.Filter;
var filterExpression = filterBuilder.Or(filterBuilder.Ne(x => x.OperationType, ChangeStreamOperationType.Insert), byStream);
var filter = filterBuilder.Or(filterBuilder.Ne(x => x.OperationType, ChangeStreamOperationType.Insert), byStream); return result.Match(filterExpression);
return result.Match(filter);
} }
return result; return result;

4
backend/src/Squidex.Infrastructure.MongoDb/EventSourcing/MongoEventStore_Reader.cs

@ -166,12 +166,12 @@ public partial class MongoEventStore : MongoRepositoryBase<MongoEventCommit>, IE
var filterDefinition = CreateFilter(streamFilter, lastPosition); var filterDefinition = CreateFilter(streamFilter, lastPosition);
var find = var find =
Collection.Find(filterDefinition) Collection.Find(filterDefinition).SortBy(x => x.Timestamp).ThenByDescending(x => x.EventStream)
.Limit(take); .Limit(take);
var taken = 0; var taken = 0;
await foreach (var current in find.ToAsyncEnumerable(ct).OrderBy(x => x.Timestamp).ThenBy(x => x.EventStream)) await foreach (var current in find.ToAsyncEnumerable(ct))
{ {
foreach (var @event in current.Filtered(lastPosition)) foreach (var @event in current.Filtered(lastPosition))
{ {

4
backend/src/Squidex.Infrastructure.MongoDb/Log/MongoRequestLogRepository.cs

@ -81,6 +81,8 @@ public sealed class MongoRequestLogRepository : MongoRepositoryBase<MongoRequest
var find = Collection.Find(x => x.Key == key && x.Timestamp >= timestampStart && x.Timestamp < timestampEnd); var find = Collection.Find(x => x.Key == key && x.Timestamp >= timestampStart && x.Timestamp < timestampEnd);
return find.ToAsyncEnumerable(ct).Select(x => x.ToRequest()); var documents = find.ToAsyncEnumerable(ct);
return documents.Select(x => x.ToRequest());
} }
} }

2
backend/src/Squidex.Infrastructure.MongoDb/MongoDb/MongoExtensions.cs

@ -57,7 +57,7 @@ public static class MongoExtensions
public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IFindFluent<T, T> find, public static async IAsyncEnumerable<T> ToAsyncEnumerable<T>(this IFindFluent<T, T> find,
[EnumeratorCancellation] CancellationToken ct = default) [EnumeratorCancellation] CancellationToken ct = default)
{ {
var cursor = await find.ToCursorAsync(ct); using var cursor = await find.ToCursorAsync(ct);
while (await cursor.MoveNextAsync(ct)) while (await cursor.MoveNextAsync(ct))
{ {

2
backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj

@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<RootNamespace>Squidex.Infrastructure</RootNamespace> <RootNamespace>Squidex.Infrastructure</RootNamespace>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

8
backend/src/Squidex.Infrastructure/Caching/IQueryCache.cs

@ -7,11 +7,9 @@
namespace Squidex.Infrastructure.Caching; namespace Squidex.Infrastructure.Caching;
public interface IQueryCache<TKey, T> where TKey : notnull where T : class, IWithId<TKey> public interface IQueryCache<TKey, T> where TKey : notnull
{ {
void SetMany(IEnumerable<(TKey, T?)> results, void Set(TKey key, T item, TimeSpan cacheDuration);
TimeSpan? permanentDuration = null);
Task<List<T>> CacheOrQueryAsync(IEnumerable<TKey> keys, Func<IEnumerable<TKey>, Task<IEnumerable<T>>> query, bool TryGet(TKey key, out T result);
TimeSpan? permanentDuration = null);
} }

79
backend/src/Squidex.Infrastructure/Caching/QueryCache.cs

@ -5,89 +5,46 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
namespace Squidex.Infrastructure.Caching; namespace Squidex.Infrastructure.Caching;
public class QueryCache<TKey, T> : IQueryCache<TKey, T> where TKey : notnull where T : class, IWithId<TKey> public class QueryCache<TKey, T> : IQueryCache<TKey, T> where TKey : notnull
{ {
private readonly ConcurrentDictionary<TKey, T?> entries = new ConcurrentDictionary<TKey, T?>(); private readonly IMemoryCache? cacheStore;
private readonly IMemoryCache? memoryCache; private readonly string? cacheKeyPrefix;
public QueryCache(IMemoryCache? memoryCache = null) public QueryCache(IMemoryCache? cacheStore = null, string? cacheKeyPrefix = null)
{ {
this.memoryCache = memoryCache; this.cacheStore = cacheStore;
this.cacheKeyPrefix = cacheKeyPrefix;
} }
public void SetMany(IEnumerable<(TKey, T?)> results, public void Set(TKey key, T item, TimeSpan cacheDuration)
TimeSpan? permanentDuration = null)
{ {
Guard.NotNull(results); if (cacheStore == null)
foreach (var (key, value) in results)
{ {
Set(key, value, permanentDuration); return;
} }
}
private void Set(TKey key, T? value,
TimeSpan? permanentDuration = null)
{
entries[key] = value;
if (memoryCache != null && permanentDuration > TimeSpan.Zero) cacheStore.Set((cacheKeyPrefix, key), item, cacheDuration);
{
memoryCache.Set(key, value, permanentDuration.Value);
}
} }
public async Task<List<T>> CacheOrQueryAsync(IEnumerable<TKey> keys, Func<IEnumerable<TKey>, Task<IEnumerable<T>>> query, public bool TryGet(TKey key, out T result)
TimeSpan? permanentDuration = null)
{ {
Guard.NotNull(keys); result = default!;
Guard.NotNull(query);
var items = GetMany(keys, permanentDuration.HasValue);
var pendingIds = new HashSet<TKey>(keys.Where(key => !items.ContainsKey(key)));
if (pendingIds.Count > 0) if (cacheStore == null)
{ {
var queried = (await query(pendingIds)).ToDictionary(x => x.Id); return false;
foreach (var id in pendingIds)
{
queried.TryGetValue(id, out var item);
items[id] = item;
Set(id, item, permanentDuration);
}
} }
return items.Values.NotNull().ToList(); if (cacheStore.TryGetValue((cacheKeyPrefix, key), out var item) && item is T typed)
}
private Dictionary<TKey, T?> GetMany(IEnumerable<TKey> keys,
bool fromPermanentCache = false)
{
var result = new Dictionary<TKey, T?>();
foreach (var key in keys)
{ {
if (entries.TryGetValue(key, out var value)) result = typed;
{ return true;
result[key] = value;
}
else if (fromPermanentCache && memoryCache != null && memoryCache.TryGetValue(key, out value))
{
result[key] = value;
entries[key] = value;
}
} }
return result; return false;
} }
} }

125
backend/src/Squidex.Infrastructure/Collections/ListDictionary.KeyCollection.cs

@ -1,125 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections;
namespace Squidex.Infrastructure.Collections;
public partial class ListDictionary<TKey, TValue>
{
private sealed class KeyCollection : ICollection<TKey>
{
private readonly ListDictionary<TKey, TValue> dictionary;
public int Count
{
get => dictionary.Count;
}
public bool IsReadOnly
{
get => false;
}
public KeyCollection(ListDictionary<TKey, TValue> dictionary)
{
this.dictionary = dictionary;
}
public void Add(TKey item)
{
throw new NotSupportedException();
}
public void Clear()
{
throw new NotSupportedException();
}
public void CopyTo(TKey[] array, int arrayIndex)
{
var i = 0;
foreach (var (key, _) in dictionary.entries)
{
array[arrayIndex + i] = key;
i++;
}
}
public bool Remove(TKey item)
{
throw new NotSupportedException();
}
public bool Contains(TKey item)
{
foreach (var entry in dictionary.entries)
{
if (dictionary.comparer.Equals(entry.Key, item))
{
return true;
}
}
return false;
}
public IEnumerator<TKey> GetEnumerator()
{
return new Enumerator(dictionary);
}
IEnumerator IEnumerable.GetEnumerator()
{
return new Enumerator(dictionary);
}
private struct Enumerator : IEnumerator<TKey>, IEnumerator
{
private readonly ListDictionary<TKey, TValue> dictionary;
private int index = -1;
private TKey value = default!;
readonly TKey IEnumerator<TKey>.Current
{
get => value!;
}
readonly object IEnumerator.Current
{
get => value!;
}
public Enumerator(ListDictionary<TKey, TValue> dictionary)
{
this.dictionary = dictionary;
}
public readonly void Dispose()
{
}
public bool MoveNext()
{
if (index >= dictionary.entries.Count - 1)
{
return false;
}
index++;
value = dictionary.entries[index].Key;
return true;
}
public void Reset()
{
index = -1;
}
}
}
}

125
backend/src/Squidex.Infrastructure/Collections/ListDictionary.ValueCollection.cs

@ -1,125 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections;
namespace Squidex.Infrastructure.Collections;
public partial class ListDictionary<TKey, TValue>
{
private sealed class ValueCollection : ICollection<TValue>
{
private readonly ListDictionary<TKey, TValue> dictionary;
public int Count
{
get => dictionary.Count;
}
public bool IsReadOnly
{
get => false;
}
public ValueCollection(ListDictionary<TKey, TValue> dictionary)
{
this.dictionary = dictionary;
}
public void Add(TValue item)
{
throw new NotSupportedException();
}
public void Clear()
{
throw new NotSupportedException();
}
public void CopyTo(TValue[] array, int arrayIndex)
{
var i = 0;
foreach (var (_, value) in dictionary.entries)
{
array[arrayIndex + i] = value;
i++;
}
}
public bool Remove(TValue item)
{
throw new NotSupportedException();
}
public bool Contains(TValue item)
{
foreach (var entry in dictionary.entries)
{
if (Equals(entry.Value, item))
{
return true;
}
}
return false;
}
public IEnumerator<TValue> GetEnumerator()
{
return new Enumerator(dictionary);
}
IEnumerator IEnumerable.GetEnumerator()
{
return new Enumerator(dictionary);
}
private struct Enumerator : IEnumerator<TValue>, IEnumerator
{
private readonly ListDictionary<TKey, TValue> dictionary;
private int index = -1;
private TValue value = default!;
readonly TValue IEnumerator<TValue>.Current
{
get => value!;
}
readonly object IEnumerator.Current
{
get => value!;
}
public Enumerator(ListDictionary<TKey, TValue> dictionary)
{
this.dictionary = dictionary;
}
public readonly void Dispose()
{
}
public bool MoveNext()
{
if (index >= dictionary.entries.Count - 1)
{
return false;
}
index++;
value = dictionary.entries[index].Value;
return true;
}
public void Reset()
{
index = -1;
}
}
}
}

273
backend/src/Squidex.Infrastructure/Collections/ListDictionary.cs

@ -1,273 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections;
using System.Diagnostics.CodeAnalysis;
namespace Squidex.Infrastructure.Collections;
public partial class ListDictionary<TKey, TValue> : IDictionary<TKey, TValue>, IReadOnlyDictionary<TKey, TValue> where TKey : notnull
{
private readonly List<KeyValuePair<TKey, TValue>> entries = new List<KeyValuePair<TKey, TValue>>();
private readonly IEqualityComparer<TKey> comparer;
private struct Enumerator : IEnumerator<KeyValuePair<TKey, TValue>>, IEnumerator
{
private readonly ListDictionary<TKey, TValue> dictionary;
private int index = -1;
private KeyValuePair<TKey, TValue> value = default!;
readonly KeyValuePair<TKey, TValue> IEnumerator<KeyValuePair<TKey, TValue>>.Current
{
get => value!;
}
readonly object IEnumerator.Current
{
get => value!;
}
public Enumerator(ListDictionary<TKey, TValue> dictionary)
{
this.dictionary = dictionary;
}
public readonly void Dispose()
{
}
public bool MoveNext()
{
if (index >= dictionary.entries.Count - 1)
{
return false;
}
index++;
value = dictionary.entries[index];
return true;
}
public void Reset()
{
index = -1;
}
}
public TValue this[TKey key]
{
get
{
if (!TryGetValue(key, out var result))
{
ThrowHelper.KeyNotFoundException();
return default!;
}
return result;
}
set
{
var index = -1;
for (var i = 0; i < entries.Count; i++)
{
if (comparer.Equals(entries[i].Key, key))
{
index = i;
break;
}
}
if (index >= 0)
{
entries[index] = new KeyValuePair<TKey, TValue>(key, value);
}
else
{
entries.Add(new KeyValuePair<TKey, TValue>(key, value));
}
}
}
public ICollection<TKey> Keys
{
get => new KeyCollection(this);
}
public ICollection<TValue> Values
{
get => new ValueCollection(this);
}
public int Count
{
get => entries.Count;
}
public int Capacity
{
get => entries.Capacity;
}
public bool IsReadOnly
{
get => false;
}
IEnumerable<TKey> IReadOnlyDictionary<TKey, TValue>.Keys
{
get => new KeyCollection(this);
}
IEnumerable<TValue> IReadOnlyDictionary<TKey, TValue>.Values
{
get => new ValueCollection(this);
}
public ListDictionary()
: this(1, null)
{
}
public ListDictionary(ListDictionary<TKey, TValue> source, IEqualityComparer<TKey>? comparer = null)
{
Guard.NotNull(source);
entries = source.entries.ToList();
this.comparer = comparer ?? EqualityComparer<TKey>.Default;
}
public ListDictionary(int capacity, IEqualityComparer<TKey>? comparer = null)
{
Guard.GreaterEquals(capacity, 0);
entries = new List<KeyValuePair<TKey, TValue>>(capacity);
this.comparer = comparer ?? EqualityComparer<TKey>.Default;
}
public void Add(TKey key, TValue value)
{
if (ContainsKey(key))
{
ThrowHelper.ArgumentException("Key already exists.", nameof(key));
}
AddUnsafe(key, value);
}
public void Add(KeyValuePair<TKey, TValue> item)
{
Add(item.Key, item.Value);
}
public void AddUnsafe(TKey key, TValue value)
{
entries.Add(new KeyValuePair<TKey, TValue>(key, value));
}
public void Clear()
{
entries.Clear();
}
public bool Contains(KeyValuePair<TKey, TValue> item)
{
foreach (var entry in entries)
{
if (comparer.Equals(entry.Key, item.Key) && Equals(entry.Value, item.Value))
{
return true;
}
}
return false;
}
public bool ContainsKey(TKey key)
{
foreach (var entry in entries)
{
if (comparer.Equals(entry.Key, key))
{
return true;
}
}
return false;
}
public bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value)
{
foreach (var entry in entries)
{
if (comparer.Equals(entry.Key, key))
{
value = entry.Value;
return true;
}
}
value = default;
return false;
}
public bool Remove(TKey key)
{
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
if (comparer.Equals(entry.Key, key))
{
entries.RemoveAt(i);
return true;
}
}
return false;
}
public bool Remove(KeyValuePair<TKey, TValue> item)
{
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
if (comparer.Equals(entry.Key, item.Key) && Equals(entry.Value, item.Value))
{
entries.RemoveAt(i);
return true;
}
}
return false;
}
public void TrimExcess()
{
entries.TrimExcess();
}
public void CopyTo(KeyValuePair<TKey, TValue>[] array, int arrayIndex)
{
entries.CopyTo(array, arrayIndex);
}
public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
{
return new Enumerator(this);
}
IEnumerator IEnumerable.GetEnumerator()
{
return new Enumerator(this);
}
}

4
backend/src/Squidex.Infrastructure/Queries/Json/ValueConverter.cs

@ -240,7 +240,7 @@ public static class ValueConverter
if (value.Value is string s) if (value.Value is string s)
{ {
if (Guid.TryParse(s, out result)) if (Guid.TryParseExact(s, "D", out result))
{ {
return true; return true;
} }
@ -301,7 +301,7 @@ public static class ValueConverter
return true; return true;
case string s: case string s:
{ {
if (Guid.TryParse(s, out var guid)) if (Guid.TryParseExact(s, "D", out var guid))
{ {
result = guid; result = guid;

2
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<NeutralLanguage>en</NeutralLanguage> <NeutralLanguage>en</NeutralLanguage>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>

2
backend/src/Squidex.Web/Pipeline/SchemaResolver.cs

@ -68,7 +68,7 @@ public sealed class SchemaResolver : IAsyncActionFilter
{ {
var canCache = !user.IsInClient(DefaultClients.Frontend); var canCache = !user.IsInClient(DefaultClients.Frontend);
if (Guid.TryParse(schemaIdOrName, out var guid)) if (Guid.TryParseExact(schemaIdOrName, "D", out var guid))
{ {
var schemaId = DomainId.Create(guid); var schemaId = DomainId.Create(guid);

2
backend/src/Squidex.Web/Squidex.Web.csproj

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

8
backend/src/Squidex/Areas/Api/Controllers/Apps/AppImageController.cs

@ -24,17 +24,17 @@ public sealed class AppImageController : ApiController
{ {
private readonly IAppImageStore appImageStore; private readonly IAppImageStore appImageStore;
private readonly IAssetStore assetStore; private readonly IAssetStore assetStore;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAssetThumbnailGenerator assetGenerator;
public AppImageController(ICommandBus commandBus, public AppImageController(ICommandBus commandBus,
IAppImageStore appImageStore, IAppImageStore appImageStore,
IAssetStore assetStore, IAssetStore assetStore,
IAssetThumbnailGenerator assetThumbnailGenerator) IAssetThumbnailGenerator assetGenerator)
: base(commandBus) : base(commandBus)
{ {
this.appImageStore = appImageStore; this.appImageStore = appImageStore;
this.assetStore = assetStore; this.assetStore = assetStore;
this.assetThumbnailGenerator = assetThumbnailGenerator; this.assetGenerator = assetGenerator;
} }
/// <summary> /// <summary>
@ -112,7 +112,7 @@ public sealed class AppImageController : ApiController
{ {
await using (var resizeStream = assetResized.OpenWrite()) await using (var resizeStream = assetResized.OpenWrite())
{ {
await assetThumbnailGenerator.CreateThumbnailAsync(originalStream, mimeType, resizeStream, resizeOptions, ct); await assetGenerator.CreateThumbnailAsync(originalStream, mimeType, resizeStream, resizeOptions, ct);
} }
} }
} }

12
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs

@ -29,20 +29,20 @@ public sealed class AssetContentController : ApiController
private readonly IAssetFileStore assetFileStore; private readonly IAssetFileStore assetFileStore;
private readonly IAssetQueryService assetQuery; private readonly IAssetQueryService assetQuery;
private readonly IAssetLoader assetLoader; private readonly IAssetLoader assetLoader;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAssetThumbnailGenerator assetGenerator;
public AssetContentController( public AssetContentController(
ICommandBus commandBus, ICommandBus commandBus,
IAssetFileStore assetFileStore, IAssetFileStore assetFileStore,
IAssetQueryService assetQuery, IAssetQueryService assetQuery,
IAssetLoader assetLoader, IAssetLoader assetLoader,
IAssetThumbnailGenerator assetThumbnailGenerator) IAssetThumbnailGenerator assetGenerator)
: base(commandBus) : base(commandBus)
{ {
this.assetFileStore = assetFileStore; this.assetFileStore = assetFileStore;
this.assetQuery = assetQuery; this.assetQuery = assetQuery;
this.assetLoader = assetLoader; this.assetLoader = assetLoader;
this.assetThumbnailGenerator = assetThumbnailGenerator; this.assetGenerator = assetGenerator;
} }
/// <summary> /// <summary>
@ -138,13 +138,13 @@ public sealed class AssetContentController : ApiController
Response.Headers[HeaderNames.CacheControl] = $"public,max-age={request.CacheDuration}"; Response.Headers[HeaderNames.CacheControl] = $"public,max-age={request.CacheDuration}";
} }
var resizeOptions = request.ToResizeOptions(asset, assetThumbnailGenerator, HttpContext.Request); var resizeOptions = request.ToResizeOptions(asset, assetGenerator, HttpContext.Request);
var contentLength = (long?)null; var contentLength = (long?)null;
var contentCallback = (FileCallback?)null; var contentCallback = (FileCallback?)null;
var contentType = asset.MimeType; var contentType = asset.MimeType;
if (asset.Type == AssetType.Image && assetThumbnailGenerator.IsResizable(asset.MimeType, resizeOptions, out var destinationMimeType)) if (asset.Type == AssetType.Image && assetGenerator.IsResizable(asset.MimeType, resizeOptions, out var destinationMimeType))
{ {
contentType = destinationMimeType!; contentType = destinationMimeType!;
@ -224,7 +224,7 @@ public sealed class AssetContentController : ApiController
{ {
await using (var resizeStream = assetResized.OpenWrite()) await using (var resizeStream = assetResized.OpenWrite())
{ {
await assetThumbnailGenerator.CreateThumbnailAsync(originalStream, asset.MimeType, resizeStream, resizeOptions); await assetGenerator.CreateThumbnailAsync(originalStream, asset.MimeType, resizeStream, resizeOptions);
} }
} }
} }

8
backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetContentQueryDto.cs

@ -99,7 +99,7 @@ public sealed class AssetContentQueryDto
[FromQuery(Name = "format")] [FromQuery(Name = "format")]
public ImageFormat? Format { get; set; } public ImageFormat? Format { get; set; }
public ResizeOptions ToResizeOptions(IAssetEntity asset, IAssetThumbnailGenerator assetThumbnailGenerator, HttpRequest request) public ResizeOptions ToResizeOptions(IAssetEntity asset, IAssetThumbnailGenerator assetGenerator, HttpRequest request)
{ {
Guard.NotNull(asset); Guard.NotNull(asset);
@ -111,12 +111,12 @@ public sealed class AssetContentQueryDto
result.FocusY = y; result.FocusY = y;
result.TargetWidth = Width; result.TargetWidth = Width;
result.TargetHeight = Height; result.TargetHeight = Height;
result.Format = GetFormat(asset, assetThumbnailGenerator, request); result.Format = GetFormat(asset, assetGenerator, request);
return result; return result;
} }
private ImageFormat? GetFormat(IAssetEntity asset, IAssetThumbnailGenerator assetThumbnailGenerator, HttpRequest request) private ImageFormat? GetFormat(IAssetEntity asset, IAssetThumbnailGenerator assetGenerator, HttpRequest request)
{ {
if (Format.HasValue || !Auto) if (Format.HasValue || !Auto)
{ {
@ -132,7 +132,7 @@ public sealed class AssetContentQueryDto
request.Headers.TryGetValue("Accept", out var accept); request.Headers.TryGetValue("Accept", out var accept);
return accept.Any(x => x?.Contains(mimeType, StringComparison.OrdinalIgnoreCase) == true) && assetThumbnailGenerator.CanReadAndWrite(mimeType); return accept.Any(x => x?.Contains(mimeType, StringComparison.OrdinalIgnoreCase) == true) && assetGenerator.CanReadAndWrite(mimeType);
} }
#if ENABLE_AVIF #if ENABLE_AVIF
if (Accepts("image/avif")) if (Accepts("image/avif"))

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

@ -383,7 +383,7 @@ public sealed class SchemasController : ApiController
private Task<ISchemaEntity?> GetSchemaAsync(string schema) private Task<ISchemaEntity?> GetSchemaAsync(string schema)
{ {
if (Guid.TryParse(schema, out var guid)) if (Guid.TryParseExact(schema, "D", out var guid))
{ {
var schemaId = DomainId.Create(guid); var schemaId = DomainId.Create(guid);

8
backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs

@ -30,19 +30,19 @@ public sealed class ProfileController : IdentityServerController
{ {
private readonly IUserPictureStore userPictureStore; private readonly IUserPictureStore userPictureStore;
private readonly IUserService userService; private readonly IUserService userService;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAssetThumbnailGenerator assetGenerator;
private readonly MyIdentityOptions identityOptions; private readonly MyIdentityOptions identityOptions;
public ProfileController( public ProfileController(
IOptions<MyIdentityOptions> identityOptions, IOptions<MyIdentityOptions> identityOptions,
IUserPictureStore userPictureStore, IUserPictureStore userPictureStore,
IUserService userService, IUserService userService,
IAssetThumbnailGenerator assetThumbnailGenerator) IAssetThumbnailGenerator assetGenerator)
{ {
this.identityOptions = identityOptions.Value; this.identityOptions = identityOptions.Value;
this.userPictureStore = userPictureStore; this.userPictureStore = userPictureStore;
this.userService = userService; this.userService = userService;
this.assetThumbnailGenerator = assetThumbnailGenerator; this.assetGenerator = assetGenerator;
} }
[HttpGet] [HttpGet]
@ -183,7 +183,7 @@ public sealed class ProfileController : IdentityServerController
{ {
await using (var resizeStream = assetResized.OpenWrite()) await using (var resizeStream = assetResized.OpenWrite())
{ {
await assetThumbnailGenerator.CreateThumbnailAsync(originalStream, file.ContentType, resizeStream, resizeOptions, ct); await assetGenerator.CreateThumbnailAsync(originalStream, file.ContentType, resizeStream, resizeOptions, ct);
} }
} }
} }

2
backend/src/Squidex/Config/Domain/AssetServices.cs

@ -46,7 +46,7 @@ public static class AssetServices
services.AddTransientAs<AssetTagsDeleter>() services.AddTransientAs<AssetTagsDeleter>()
.As<IDeleter>(); .As<IDeleter>();
services.AddTransientAs<AssetCache>() services.AddSingletonAs<AssetCache>()
.As<IAssetCache>(); .As<IAssetCache>();
services.AddSingletonAs<RebuildFiles>() services.AddSingletonAs<RebuildFiles>()

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

@ -41,7 +41,7 @@ public static class ContentsServices
services.AddTransientAs<CounterService>() services.AddTransientAs<CounterService>()
.As<ICounterService>().As<IDeleter>(); .As<ICounterService>().As<IDeleter>();
services.AddTransientAs<ContentCache>() services.AddSingletonAs<ContentCache>()
.As<IContentCache>(); .As<IContentCache>();
services.AddSingletonAs<DefaultValidatorsFactory>() services.AddSingletonAs<DefaultValidatorsFactory>()

11
backend/src/Squidex/Config/Web/WebServices.cs

@ -107,6 +107,17 @@ public static class WebServices
builder.AddSchema<DummySchema>(); builder.AddSchema<DummySchema>();
builder.AddSystemTextJson(); builder.AddSystemTextJson();
builder.AddDataLoader(); builder.AddDataLoader();
builder.ConfigureExecutionOptions(options =>
{
var logger = options.RequestServices!.GetRequiredService<ILogger<GraphQLHttpMiddleware>>();
options.UnhandledExceptionDelegate = ctx =>
{
logger.LogError(ctx.Exception, "GraphQL error in field {field}", ctx.FieldContext?.FieldAst?.Name);
return Task.CompletedTask;
};
});
}); });
services.AddSingletonAs<DummySchema>() services.AddSingletonAs<DummySchema>()

2
backend/src/Squidex/Squidex.csproj

@ -3,7 +3,7 @@
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion> <TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked> <TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<NeutralLanguage>en</NeutralLanguage> <NeutralLanguage>en</NeutralLanguage>
<NoWarn>NU1608</NoWarn> <NoWarn>NU1608</NoWarn>

3
backend/src/Squidex/appsettings.json

@ -338,6 +338,9 @@
// The number of days request log items will be stored. // The number of days request log items will be stored.
"storeRetentionInDays": 90, "storeRetentionInDays": 90,
// The name that is used for monitoring.
"name": "Squidex",
"stackdriver": { "stackdriver": {
// True, to enable stackdriver integration. // True, to enable stackdriver integration.
"enabled": false, "enabled": false,

11
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/Guards/ScriptMetadataWrapperTests.cs → backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/AssetMetadataWrapperTests.cs

@ -6,18 +6,19 @@
// ========================================================================== // ==========================================================================
using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Scripting.Internal;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Entities.Assets.DomainObject.Guards; namespace Squidex.Domain.Apps.Core.Operations.Scripting;
public class ScriptMetadataWrapperTests public class AssetMetadataWrapperTests
{ {
private readonly AssetMetadata metadata = new AssetMetadata(); private readonly AssetMetadata metadata = new AssetMetadata();
private readonly ScriptMetadataWrapper sut; private readonly AssetMetadataWrapper sut;
public ScriptMetadataWrapperTests() public AssetMetadataWrapperTests()
{ {
sut = new ScriptMetadataWrapper(metadata); sut = new AssetMetadataWrapper(metadata);
} }
[Fact] [Fact]

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

@ -537,10 +537,7 @@ public class JintScriptEngineTests : IClassFixture<TranslationsFixture>
[Fact] [Fact]
public void Should_not_allow_to_overwrite_initial_var() public void Should_not_allow_to_overwrite_initial_var()
{ {
var vars = new ScriptVars var vars = new ScriptVars().SetInitial(13, "value");
{
["value"] = 13
};
const string script = @" const string script = @"
ctx.value = ctx.value * 2; ctx.value = ctx.value * 2;

1
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/ScriptingCompleterTests.cs

@ -101,6 +101,7 @@ public class ScriptingCompleterTests
"ctx.asset.parentId", "ctx.asset.parentId",
"ctx.asset.parentPath", "ctx.asset.parentPath",
"ctx.asset.tags", "ctx.asset.tags",
"ctx.asset.type",
"ctx.assetId", "ctx.assetId",
"ctx.command", "ctx.command",
"ctx.command.fileHash", "ctx.command.fileHash",

13
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/ComponentFieldTests.cs

@ -127,6 +127,19 @@ public class ComponentFieldTests : IClassFixture<TranslationsFixture>
Assert.Equal(value.AsObject[Component.Discriminator].AsString, schemaId1.ToString()); Assert.Equal(value.AsObject[Component.Discriminator].AsString, schemaId1.ToString());
} }
[Fact]
public async Task Should_resolve_schema_id_from_name_id_id()
{
var (_, sut, components) = Field(new ComponentFieldProperties { SchemaId = schemaId1 });
var value = CreateValue("my-component", "componentField", 1);
await sut.ValidateAsync(value, errors, components: components);
Assert.Empty(errors);
Assert.Equal(value.AsObject[Component.Discriminator].AsString, schemaId1.ToString());
}
[Fact] [Fact]
public async Task Should_resolve_schema_from_single_component() public async Task Should_resolve_schema_from_single_component()
{ {

2
backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj

@ -3,7 +3,7 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<RootNamespace>Squidex.Domain.Apps.Core</RootNamespace> <RootNamespace>Squidex.Domain.Apps.Core</RootNamespace>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

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

@ -18,7 +18,7 @@ public class AppCommandMiddlewareTests : HandlerTestBase<AppDomainObject.State>
{ {
private readonly IDomainObjectFactory domainObjectFactory = A.Fake<IDomainObjectFactory>(); private readonly IDomainObjectFactory domainObjectFactory = A.Fake<IDomainObjectFactory>();
private readonly IAppImageStore appImageStore = A.Fake<IAppImageStore>(); private readonly IAppImageStore appImageStore = A.Fake<IAppImageStore>();
private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>(); private readonly IAssetThumbnailGenerator assetGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly AppCommandMiddleware sut; private readonly AppCommandMiddleware sut;
public sealed class MyCommand : SquidexCommand public sealed class MyCommand : SquidexCommand
@ -32,7 +32,7 @@ public class AppCommandMiddlewareTests : HandlerTestBase<AppDomainObject.State>
public AppCommandMiddlewareTests() public AppCommandMiddlewareTests()
{ {
sut = new AppCommandMiddleware(domainObjectFactory, appImageStore, assetThumbnailGenerator, ApiContextProvider); sut = new AppCommandMiddleware(domainObjectFactory, appImageStore, assetGenerator, ApiContextProvider);
} }
[Fact] [Fact]
@ -50,7 +50,7 @@ public class AppCommandMiddlewareTests : HandlerTestBase<AppDomainObject.State>
{ {
var file = new NoopAssetFile(); var file = new NoopAssetFile();
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A<Stream>._, file.MimeType, CancellationToken)) A.CallTo(() => assetGenerator.GetImageInfoAsync(A<Stream>._, file.MimeType, CancellationToken))
.Returns(new ImageInfo(ImageFormat.PNG, 100, 100, ImageOrientation.None, false)); .Returns(new ImageInfo(ImageFormat.PNG, 100, 100, ImageOrientation.None, false));
await HandleAsync(new UploadAppImage { File = file }, None.Value); await HandleAsync(new UploadAppImage { File = file }, None.Value);
@ -66,7 +66,7 @@ public class AppCommandMiddlewareTests : HandlerTestBase<AppDomainObject.State>
var command = new UploadAppImage { File = file }; var command = new UploadAppImage { File = file };
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A<Stream>._, file.MimeType, CancellationToken)) A.CallTo(() => assetGenerator.GetImageInfoAsync(A<Stream>._, file.MimeType, CancellationToken))
.Returns(Task.FromResult<ImageInfo?>(null)); .Returns(Task.FromResult<ImageInfo?>(null));
await Assert.ThrowsAsync<ValidationException>(() => HandleAsync(sut, command, CancellationToken)); await Assert.ThrowsAsync<ValidationException>(() => HandleAsync(sut, command, CancellationToken));

14
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetsFluidExtensionTests.cs

@ -23,7 +23,7 @@ public class AssetsFluidExtensionTests : GivenContext
{ {
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 assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>(); private readonly IAssetThumbnailGenerator assetGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly FluidTemplateEngine sut; private readonly FluidTemplateEngine sut;
public AssetsFluidExtensionTests() public AssetsFluidExtensionTests()
@ -33,7 +33,7 @@ public class AssetsFluidExtensionTests : GivenContext
.AddSingleton(AppProvider) .AddSingleton(AppProvider)
.AddSingleton(assetFileStore) .AddSingleton(assetFileStore)
.AddSingleton(assetQuery) .AddSingleton(assetQuery)
.AddSingleton(assetThumbnailGenerator) .AddSingleton(assetGenerator)
.BuildServiceProvider(); .BuildServiceProvider();
var extensions = new IFluidExtension[] var extensions = new IFluidExtension[]
@ -146,7 +146,7 @@ public class AssetsFluidExtensionTests : GivenContext
[Fact] [Fact]
public async Task Should_not_resolve_text_if_too_big() public async Task Should_not_resolve_text_if_too_big()
{ {
var (vars, _) = SetupAssetVars(1_000_000); var (vars, _) = SetupAssetVars(10_000_000);
var template = @" var template = @"
{% assign ref = event.data.assets.iv[0] | asset %} {% assign ref = event.data.assets.iv[0] | asset %}
@ -221,7 +221,7 @@ public class AssetsFluidExtensionTests : GivenContext
[Fact] [Fact]
public async Task Should_not_resolve_blur_hash_if_too_big() public async Task Should_not_resolve_blur_hash_if_too_big()
{ {
var (vars, _) = SetupAssetVars(1_000_000); var (vars, _) = SetupAssetVars(10_000_000);
var template = @" var template = @"
{% assign ref = event.data.assets.iv[0] | asset %} {% assign ref = event.data.assets.iv[0] | asset %}
@ -229,7 +229,7 @@ public class AssetsFluidExtensionTests : GivenContext
"; ";
var expected = $@" var expected = $@"
Text: ErrorTooBig Text:
"; ";
var actual = await sut.RenderAsync(template, vars); var actual = await sut.RenderAsync(template, vars);
@ -251,7 +251,7 @@ public class AssetsFluidExtensionTests : GivenContext
"; ";
var expected = $@" var expected = $@"
Text: NoImage Text:
"; ";
var actual = await sut.RenderAsync(template, vars); var actual = await sut.RenderAsync(template, vars);
@ -296,7 +296,7 @@ public class AssetsFluidExtensionTests : GivenContext
private void SetupBlurHash(AssetRef asset, string hash) private void SetupBlurHash(AssetRef asset, string hash)
{ {
A.CallTo(() => assetThumbnailGenerator.ComputeBlurHashAsync(A<Stream>._, asset.MimeType, A<BlurOptions>._, A<CancellationToken>._)) A.CallTo(() => assetGenerator.ComputeBlurHashAsync(A<Stream>._, asset.MimeType, A<BlurOptions>._, A<CancellationToken>._))
.Returns(hash); .Returns(hash);
} }

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

@ -16,17 +16,20 @@ using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Entities.Assets; namespace Squidex.Domain.Apps.Entities.Assets;
public class AssetsJintExtensionTests : GivenContext, IClassFixture<TranslationsFixture> public class AssetsJintExtensionTests : GivenContext, IClassFixture<TranslationsFixture>
{ {
private readonly ICommandBus commandBus = A.Fake<ICommandBus>();
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 assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>(); private readonly IAssetThumbnailGenerator assetGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly JintScriptEngine sut; private readonly JintScriptEngine sut;
public AssetsJintExtensionTests() public AssetsJintExtensionTests()
@ -34,9 +37,10 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
var serviceProvider = var serviceProvider =
new ServiceCollection() new ServiceCollection()
.AddSingleton(AppProvider) .AddSingleton(AppProvider)
.AddSingleton(commandBus)
.AddSingleton(assetFileStore) .AddSingleton(assetFileStore)
.AddSingleton(assetQuery) .AddSingleton(assetQuery)
.AddSingleton(assetThumbnailGenerator) .AddSingleton(assetGenerator)
.BuildServiceProvider(); .BuildServiceProvider();
var extensions = new IJintExtension[] var extensions = new IJintExtension[]
@ -170,7 +174,7 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
[Fact] [Fact]
public async Task Should_not_resolve_text_if_too_big() public async Task Should_not_resolve_text_if_too_big()
{ {
var (vars, _) = SetupAssetsVars(1, 1_000_000); var (vars, _) = SetupAssetsVars(1, 10_000_000);
var expected = @" var expected = @"
Text: ErrorTooBig Text: ErrorTooBig
@ -209,7 +213,10 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
var vars = new ScriptVars var vars = new ScriptVars
{ {
["event"] = @event ["event"] = @event,
["appId"] = AppId.Id,
["appName"] = AppId.Name,
["user"] = new ClaimsPrincipal(),
}; };
var expected = @" var expected = @"
@ -256,7 +263,7 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
[Fact] [Fact]
public async Task Should_not_resolve_blur_hash_if_too_big() public async Task Should_not_resolve_blur_hash_if_too_big()
{ {
var (vars, assets) = SetupAssetsVars(1, 1_000_000); var (vars, assets) = SetupAssetsVars(1, 10_000_000);
SetupBlurHash(assets[0].ToRef(), "Hash"); SetupBlurHash(assets[0].ToRef(), "Hash");
@ -319,7 +326,10 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
var vars = new ScriptVars var vars = new ScriptVars
{ {
["event"] = @event ["event"] = @event,
["appId"] = AppId.Id,
["appName"] = AppId.Name,
["user"] = new ClaimsPrincipal(),
}; };
var expected = @" var expected = @"
@ -338,9 +348,47 @@ public class AssetsJintExtensionTests : GivenContext, IClassFixture<Translations
Assert.Equal(Cleanup(expected), Cleanup(actual)); Assert.Equal(Cleanup(expected), Cleanup(actual));
} }
[Fact]
public async Task Should_update_asset_from_event()
{
var @event = new EnrichedAssetEvent
{
Id = DomainId.NewGuid(),
AssetType = AssetType.Image,
FileVersion = 0,
FileSize = 100,
Metadata = new AssetMetadata
{
[AssetMetadata.PixelWidth] = 100,
[AssetMetadata.PixelHeight] = 50,
},
AppId = AppId
};
var vars = new ScriptVars
{
["event"] = @event,
["appId"] = AppId.Id,
["appName"] = AppId.Name,
["user"] = new ClaimsPrincipal(),
};
var script = @"
var metadata = { ...event.metadata, newValue: 42 };
updateAsset(event, metadata);
";
var actual = (await sut.ExecuteAsync(vars, script, ct: CancellationToken)).ToString();
A.CallTo(() => commandBus.PublishAsync(
A<AnnotateAsset>.That.Matches(x => x.AssetId == @event.Id && x.Metadata!.Count == 3), default))
.MustHaveHappened();
}
private void SetupBlurHash(AssetRef asset, string hash) private void SetupBlurHash(AssetRef asset, string hash)
{ {
A.CallTo(() => assetThumbnailGenerator.ComputeBlurHashAsync(A<Stream>._, asset.MimeType, A<BlurOptions>._, A<CancellationToken>._)) A.CallTo(() => assetGenerator.ComputeBlurHashAsync(A<Stream>._, asset.MimeType, A<BlurOptions>._, A<CancellationToken>._))
.Returns(hash); .Returns(hash);
} }

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

@ -98,7 +98,7 @@ public class DefaultAssetFileStoreTests : GivenContext
} }
[Fact] [Fact]
public async Task Should_upload_temporary_filet_to_store() public async Task Should_upload_temporary_file_to_store()
{ {
var stream = new MemoryStream(); var stream = new MemoryStream();
@ -124,6 +124,17 @@ public class DefaultAssetFileStoreTests : GivenContext
.MustHaveHappened(); .MustHaveHappened();
} }
[Fact]
public async Task Should_download_temporary_file_to_store()
{
var stream = new MemoryStream();
await sut.DownloadAsync("Temp", stream, CancellationToken);
A.CallTo(() => assetStore.DownloadAsync("Temp", stream, default, CancellationToken))
.MustHaveHappened();
}
[Theory] [Theory]
[MemberData(nameof(PathCases))] [MemberData(nameof(PathCases))]
public async Task Should_download_file_from_store(bool folderPerApp, string? suffix, string fileName) public async Task Should_download_file_from_store(bool folderPerApp, string? suffix, string fileName)

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

@ -15,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Assets;
public class ImageAssetMetadataSourceTests : GivenContext public class ImageAssetMetadataSourceTests : GivenContext
{ {
private readonly IAssetThumbnailGenerator assetThumbnailGenerator = A.Fake<IAssetThumbnailGenerator>(); private readonly IAssetThumbnailGenerator assetGenerator = A.Fake<IAssetThumbnailGenerator>();
private readonly MemoryStream stream = new MemoryStream(); private readonly MemoryStream stream = new MemoryStream();
private readonly AssetFile file; private readonly AssetFile file;
private readonly ImageAssetMetadataSource sut; private readonly ImageAssetMetadataSource sut;
@ -24,7 +24,7 @@ public class ImageAssetMetadataSourceTests : GivenContext
{ {
file = new DelegateAssetFile("MyImage.png", "image/png", 1024, () => stream); file = new DelegateAssetFile("MyImage.png", "image/png", 1024, () => stream);
sut = new ImageAssetMetadataSource(assetThumbnailGenerator); sut = new ImageAssetMetadataSource(assetGenerator);
} }
[Fact] [Fact]
@ -34,7 +34,7 @@ public class ImageAssetMetadataSourceTests : GivenContext
await sut.EnhanceAsync(command, CancellationToken); await sut.EnhanceAsync(command, CancellationToken);
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A<Stream>._, file.MimeType, CancellationToken)) A.CallTo(() => assetGenerator.GetImageInfoAsync(A<Stream>._, file.MimeType, CancellationToken))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -43,7 +43,7 @@ public class ImageAssetMetadataSourceTests : GivenContext
{ {
var command = new CreateAsset { File = file }; var command = new CreateAsset { File = file };
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream, file.MimeType, CancellationToken)) A.CallTo(() => assetGenerator.GetImageInfoAsync(stream, file.MimeType, CancellationToken))
.Returns(Task.FromResult<ImageInfo?>(null)); .Returns(Task.FromResult<ImageInfo?>(null));
await sut.EnhanceAsync(command, CancellationToken); await sut.EnhanceAsync(command, CancellationToken);
@ -56,7 +56,7 @@ public class ImageAssetMetadataSourceTests : GivenContext
{ {
var command = new CreateAsset { File = file }; var command = new CreateAsset { File = file };
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream, file.MimeType, CancellationToken)) A.CallTo(() => assetGenerator.GetImageInfoAsync(stream, file.MimeType, CancellationToken))
.Returns(new ImageInfo(ImageFormat.PNG, 800, 600, ImageOrientation.None, false)); .Returns(new ImageInfo(ImageFormat.PNG, 800, 600, ImageOrientation.None, false));
await sut.EnhanceAsync(command, CancellationToken); await sut.EnhanceAsync(command, CancellationToken);
@ -65,7 +65,7 @@ public class ImageAssetMetadataSourceTests : GivenContext
Assert.Equal(600, command.Metadata.GetPixelHeight()); Assert.Equal(600, command.Metadata.GetPixelHeight());
Assert.Equal(AssetType.Image, command.Type); Assert.Equal(AssetType.Image, command.Type);
A.CallTo(() => assetThumbnailGenerator.FixAsync(stream, file.MimeType, A<Stream>._, A<CancellationToken>._)) A.CallTo(() => assetGenerator.FixAsync(stream, file.MimeType, A<Stream>._, A<CancellationToken>._))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
@ -74,10 +74,10 @@ public class ImageAssetMetadataSourceTests : GivenContext
{ {
var command = new CreateAsset { File = file }; var command = new CreateAsset { File = file };
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A<Stream>._, file.MimeType, CancellationToken)) A.CallTo(() => assetGenerator.GetImageInfoAsync(A<Stream>._, file.MimeType, CancellationToken))
.Returns(new ImageInfo(ImageFormat.PNG, 800, 600, ImageOrientation.None, false)); .Returns(new ImageInfo(ImageFormat.PNG, 800, 600, ImageOrientation.None, false));
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream, file.MimeType, CancellationToken)) A.CallTo(() => assetGenerator.GetImageInfoAsync(stream, file.MimeType, CancellationToken))
.Returns(new ImageInfo(ImageFormat.PNG, 800, 600, ImageOrientation.BottomRight, false)).Once(); .Returns(new ImageInfo(ImageFormat.PNG, 800, 600, ImageOrientation.BottomRight, false)).Once();
await sut.EnhanceAsync(command, CancellationToken); await sut.EnhanceAsync(command, CancellationToken);
@ -86,7 +86,7 @@ public class ImageAssetMetadataSourceTests : GivenContext
Assert.Equal(600, command.Metadata.GetPixelHeight()); Assert.Equal(600, command.Metadata.GetPixelHeight());
Assert.Equal(AssetType.Image, command.Type); Assert.Equal(AssetType.Image, command.Type);
A.CallTo(() => assetThumbnailGenerator.FixAsync(stream, file.MimeType, A<Stream>._, CancellationToken)) A.CallTo(() => assetGenerator.FixAsync(stream, file.MimeType, A<Stream>._, CancellationToken))
.MustHaveHappened(); .MustHaveHappened();
} }
@ -95,10 +95,10 @@ public class ImageAssetMetadataSourceTests : GivenContext
{ {
var command = new CreateAsset { File = file }; var command = new CreateAsset { File = file };
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(A<Stream>._, file.MimeType, CancellationToken)) A.CallTo(() => assetGenerator.GetImageInfoAsync(A<Stream>._, file.MimeType, CancellationToken))
.Returns(new ImageInfo(ImageFormat.PNG, 800, 600, ImageOrientation.None, false)); .Returns(new ImageInfo(ImageFormat.PNG, 800, 600, ImageOrientation.None, false));
A.CallTo(() => assetThumbnailGenerator.GetImageInfoAsync(stream, file.MimeType, CancellationToken)) A.CallTo(() => assetGenerator.GetImageInfoAsync(stream, file.MimeType, CancellationToken))
.Returns(new ImageInfo(ImageFormat.PNG, 800, 600, ImageOrientation.None, true)).Once(); .Returns(new ImageInfo(ImageFormat.PNG, 800, 600, ImageOrientation.None, true)).Once();
await sut.EnhanceAsync(command, CancellationToken); await sut.EnhanceAsync(command, CancellationToken);
@ -107,7 +107,7 @@ public class ImageAssetMetadataSourceTests : GivenContext
Assert.Equal(600, command.Metadata.GetPixelHeight()); Assert.Equal(600, command.Metadata.GetPixelHeight());
Assert.Equal(AssetType.Image, command.Type); Assert.Equal(AssetType.Image, command.Type);
A.CallTo(() => assetThumbnailGenerator.FixAsync(stream, file.MimeType, A<Stream>._, CancellationToken)) A.CallTo(() => assetGenerator.FixAsync(stream, file.MimeType, A<Stream>._, CancellationToken))
.MustHaveHappened(); .MustHaveHappened();
} }

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj

@ -3,7 +3,7 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<RootNamespace>Squidex.Domain.Apps.Entities</RootNamespace> <RootNamespace>Squidex.Domain.Apps.Entities</RootNamespace>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<NeutralLanguage>en</NeutralLanguage> <NeutralLanguage>en</NeutralLanguage>

2
backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj

@ -3,7 +3,7 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<RootNamespace>Squidex.Domain.Users</RootNamespace> <RootNamespace>Squidex.Domain.Users</RootNamespace>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

164
backend/tests/Squidex.Infrastructure.Tests/Caching/QueryCacheTests.cs

@ -8,171 +8,43 @@
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
#pragma warning disable SA1313 // Parameter names should begin with lower-case letter
namespace Squidex.Infrastructure.Caching; namespace Squidex.Infrastructure.Caching;
public class QueryCacheTests public class QueryCacheTests
{ {
private readonly IMemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions())); private readonly IMemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private record CachedEntry(int Value) : IWithId<int>
{
public int Id => Value;
}
[Fact]
public async Task Should_query_from_cache()
{
var sut = new QueryCache<int, CachedEntry>();
var (queried, actual) = await ConfigureAsync(sut, 1, 2);
Assert.Equal(new[] { 1, 2 }, queried);
Assert.Equal(new[] { 1, 2 }, actual);
}
[Fact]
public async Task Should_query_pending_from_cache()
{
var sut = new QueryCache<int, CachedEntry>();
var (queried1, actual1) = await ConfigureAsync(sut, 1, 2);
var (queried2, actual2) = await ConfigureAsync(sut, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2 }, queried1.ToArray());
Assert.Equal(new[] { 3, 4 }, queried2.ToArray());
Assert.Equal(new[] { 1, 2 }, actual1.ToArray());
Assert.Equal(new[] { 1, 2, 3, 4 }, actual2.ToArray());
}
[Fact]
public async Task Should_query_pending_from_cache_if_manually_added()
{
var sut = new QueryCache<int, CachedEntry>();
sut.SetMany(new[] { (1, null), (2, new CachedEntry(2)) });
var (queried, actual) = await ConfigureAsync(sut, 1, 2, 3, 4);
Assert.Equal(new[] { 3, 4 }, queried);
Assert.Equal(new[] { 2, 3, 4 }, actual);
}
[Fact] [Fact]
public async Task Should_query_pending_from_memory_cache_if_manually_added() public void Should_query_from_cache()
{ {
var sut1 = new QueryCache<int, CachedEntry>(memoryCache); var sut = new QueryCache<int, int>(memoryCache);
var sut2 = new QueryCache<int, CachedEntry>(memoryCache);
var cacheDuration = TimeSpan.FromSeconds(10); sut.Set(1, 1, TimeSpan.FromHours(1));
sut1.SetMany(new[] { (1, null), (2, new CachedEntry(2)) }, cacheDuration); var result1 = sut.TryGet(1, out var found1);
var result2 = sut.TryGet(2, out var found2);
var (queried, actual) = await ConfigureAsync(sut2, x => true, cacheDuration, 1, 2, 3, 4); Assert.True(result1);
Assert.Equal(1, found1);
Assert.Equal(new[] { 3, 4 }, queried); Assert.False(result2);
Assert.Equal(new[] { 2, 3, 4 }, actual); Assert.Equal(0, found2);
} }
[Fact] [Fact]
public async Task Should_query_pending_from_memory_cache_if_manually_added_but_not_added_permanently() public void Should_not_query_from_cache_if_not_configured()
{
var sut1 = new QueryCache<int, CachedEntry>(memoryCache);
var sut2 = new QueryCache<int, CachedEntry>(memoryCache);
sut1.SetMany(new[] { (1, null), (2, new CachedEntry(2)) });
var (queried, actual) = await ConfigureAsync(sut2, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2, 3, 4 }, queried);
Assert.Equal(new[] { 1, 2, 3, 4 }, actual);
}
[Fact]
public async Task Should_query_pending_from_memory_cache_if_manually_added_but_not_queried_permanently()
{
var sut1 = new QueryCache<int, CachedEntry>(memoryCache);
var sut2 = new QueryCache<int, CachedEntry>(memoryCache);
var cacheDuration = TimeSpan.FromSeconds(10);
sut1.SetMany(new[] { (1, null), (2, new CachedEntry(2)) }, cacheDuration);
var (queried, actual) = await ConfigureAsync(sut2, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2, 3, 4 }, queried);
Assert.Equal(new[] { 1, 2, 3, 4 }, actual);
}
[Fact]
public async Task Should_not_query_again_if_failed_before()
{
var sut = new QueryCache<int, CachedEntry>();
var (queried1, actual1) = await ConfigureAsync(sut, x => x > 1, default, 1, 2);
var (queried2, actual2) = await ConfigureAsync(sut, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2 }, queried1.ToArray());
Assert.Equal(new[] { 3, 4 }, queried2.ToArray());
Assert.Equal(new[] { 2 }, actual1.ToArray());
Assert.Equal(new[] { 2, 3, 4 }, actual2.ToArray());
}
[Fact]
public async Task Should_query_from_memory_cache()
{
var sut1 = new QueryCache<int, CachedEntry>(memoryCache);
var sut2 = new QueryCache<int, CachedEntry>(memoryCache);
var cacheDuration = TimeSpan.FromSeconds(10);
var (queried1, actual1) = await ConfigureAsync(sut1, x => true, cacheDuration, 1, 2);
var (queried2, actual2) = await ConfigureAsync(sut2, x => true, cacheDuration, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2 }, queried1.ToArray());
Assert.Equal(new[] { 3, 4 }, queried2.ToArray());
Assert.Equal(new[] { 1, 2 }, actual1.ToArray());
Assert.Equal(new[] { 1, 2, 3, 4 }, actual2.ToArray());
}
[Fact]
public async Task Should_not_query_from_memory_cache_if_not_queried_permanently()
{
var sut1 = new QueryCache<int, CachedEntry>(memoryCache);
var sut2 = new QueryCache<int, CachedEntry>(memoryCache);
var (queried1, actual1) = await ConfigureAsync(sut1, x => true, null, 1, 2);
var (queried2, actual2) = await ConfigureAsync(sut2, x => true, null, 1, 2, 3, 4);
Assert.Equal(new[] { 1, 2 }, queried1.ToArray());
Assert.Equal(new[] { 1, 2, 3, 4 }, queried2.ToArray());
Assert.Equal(new[] { 1, 2 }, actual1.ToArray());
Assert.Equal(new[] { 1, 2, 3, 4 }, actual2.ToArray());
}
private static Task<(int[], int[])> ConfigureAsync(IQueryCache<int, CachedEntry> sut, params int[] ids)
{
return ConfigureAsync(sut, x => true, null, ids);
}
private static async Task<(int[], int[])> ConfigureAsync(IQueryCache<int, CachedEntry> sut, Func<int, bool> predicate, TimeSpan? cacheDuration, params int[] ids)
{ {
var queried = new HashSet<int>(); var sut = new QueryCache<int, int>();
var actual = await sut.CacheOrQueryAsync(ids, async pending => sut.Set(1, 1, TimeSpan.FromHours(1));
{
queried.AddRange(pending);
await Task.Yield(); var result1 = sut.TryGet(1, out var found1);
var result2 = sut.TryGet(2, out var found2);
return pending.Where(predicate).Select(x => new CachedEntry(x)); Assert.False(result1);
}, cacheDuration); Assert.Equal(0, found1);
return (queried.ToArray(), actual.Select(x => x.Value).ToArray()); Assert.False(result2);
Assert.Equal(0, found2);
} }
} }

478
backend/tests/Squidex.Infrastructure.Tests/Collections/ListDictionaryTests.cs

@ -1,478 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections;
using Squidex.Infrastructure.TestHelpers;
#pragma warning disable xUnit2017 // Do not use Contains() to check if a value exists in a collection
#pragma warning disable IDE0028 // Simplify collection initialization
#pragma warning disable CA1841 // Prefer Dictionary.Contains methods
namespace Squidex.Infrastructure.Collections;
public class ListDictionaryTests
{
[Fact]
public void Should_create_empty()
{
var sut = new ListDictionary<int, int>();
Assert.Empty(sut);
Assert.Equal(1, sut.Capacity);
}
[Fact]
public void Should_create_with_capacity()
{
var sut = new ListDictionary<int, int>(20);
Assert.Empty(sut);
Assert.Equal(20, sut.Capacity);
}
[Fact]
public void Should_create_as_copy()
{
var source = new ListDictionary<int, int>();
source.Add(1, 10);
source.Add(2, 20);
var sut = new ListDictionary<int, int>(source);
Assert.Equal(2, sut.Count);
}
[Fact]
public void Should_not_be_readonly()
{
var sut = new ListDictionary<int, int>();
Assert.False(sut.IsReadOnly);
Assert.False(sut.Keys.IsReadOnly);
Assert.False(sut.Values.IsReadOnly);
}
[Fact]
public void Should_add_item()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
Assert.Single(sut);
Assert.Equal(10, sut[1]);
}
[Fact]
public void Should_add_item_unsafe()
{
var sut = new ListDictionary<int, int>();
sut.AddUnsafe(1, 10);
Assert.Single(sut);
Assert.Equal(10, sut[1]);
}
[Fact]
public void Should_add_item_as_pair()
{
var sut = new ListDictionary<int, int>();
sut.Add(new KeyValuePair<int, int>(1, 10));
Assert.Single(sut);
Assert.Equal(10, sut[1]);
}
[Fact]
public void Should_throw_exception_if_adding_existing_key()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
Assert.Throws<ArgumentException>(() => sut.Add(1, 20));
}
[Fact]
public void Should_throw_exception_if_adding_pair_with_existing_key()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
Assert.Throws<ArgumentException>(() => sut.Add(new KeyValuePair<int, int>(1, 20)));
}
[Fact]
public void Should_set_item()
{
var sut = new ListDictionary<int, int>();
sut[1] = 10;
Assert.Single(sut);
Assert.Equal(10, sut[1]);
}
[Fact]
public void Should_override_item()
{
var sut = new ListDictionary<int, int>();
sut[1] = 20;
Assert.Single(sut);
Assert.Equal(20, sut[1]);
}
[Fact]
public void Should_return_true_when_dictionary_contains_value()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
Assert.True(sut.Contains(new KeyValuePair<int, int>(1, 10)));
Assert.True(sut.ContainsKey(1));
Assert.True(sut.Keys.Contains(1));
Assert.True(sut.Values.Contains(10));
}
[Fact]
public void Should_return_false_when_dictionary_does_not_contains_value()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
Assert.False(sut.Contains(new KeyValuePair<int, int>(1, 20)));
Assert.False(sut.ContainsKey(2));
Assert.False(sut.Keys.Contains(2));
Assert.False(sut.Values.Contains(20));
}
[Fact]
public void Should_get_count()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
sut.Add(3, 30);
Assert.Equal(3, sut.Count);
Assert.Equal(3, sut.Keys.Count);
Assert.Equal(3, sut.Values.Count);
}
[Fact]
public void Should_clear()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
sut.Add(3, 30);
sut.Clear();
Assert.Empty(sut);
}
[Fact]
public void Should_remove_key()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
sut.Add(3, 30);
Assert.True(sut.Remove(2));
Assert.False(sut.ContainsKey(2));
}
[Fact]
public void Should_not_remove_key_if_not_found()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
sut.Add(3, 30);
Assert.False(sut.Remove(4));
}
[Fact]
public void Should_remove_item()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
sut.Add(3, 30);
Assert.True(sut.Remove(new KeyValuePair<int, int>(2, 20)));
Assert.False(sut.ContainsKey(2));
}
[Fact]
public void Should_not_remove_item_if_key_not_found()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
sut.Add(3, 30);
Assert.False(sut.Remove(new KeyValuePair<int, int>(4, 40)));
}
[Fact]
public void Should_not_remove_item_if_value_not_equal()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
sut.Add(3, 30);
Assert.False(sut.Remove(new KeyValuePair<int, int>(2, 40)));
}
[Fact]
public void Should_get_value_by_method_if_found()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
Assert.True(sut.TryGetValue(2, out var found));
Assert.Equal(20, found);
}
[Fact]
public void Should_not_get_value_by_method_if_not_found()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(3, 30);
Assert.False(sut.TryGetValue(4, out var found));
Assert.Equal(0, found);
}
[Fact]
public void Should_get_value_by_indexer_if_found()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
Assert.Equal(20, sut[2]);
}
[Fact]
public void Should_not_get_value_by_indexer_if_not_found()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
Assert.Throws<KeyNotFoundException>(() => sut[4]);
}
[Fact]
public void Should_loop_over_entries()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
var actual = new List<KeyValuePair<int, int>>();
foreach (var entry in sut)
{
actual.Add(entry);
}
Assert.Equal(new[]
{
new KeyValuePair<int, int>(1, 10),
new KeyValuePair<int, int>(2, 20)
}, actual.ToArray());
}
[Fact]
public void Should_loop_over_entries_with_old_enumerator()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
var actual = new List<KeyValuePair<int, int>>();
foreach (KeyValuePair<int, int> entry in (IEnumerable)sut)
{
actual.Add(entry);
}
Assert.Equal(new[]
{
new KeyValuePair<int, int>(1, 10),
new KeyValuePair<int, int>(2, 20)
}, actual.ToArray());
}
[Fact]
public void Should_copy_entries_to_array()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
Assert.Equal(new[]
{
new KeyValuePair<int, int>(1, 10),
new KeyValuePair<int, int>(2, 20)
}, sut.ToArray());
}
[Fact]
public void Should_loop_over_keys()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
var actual = new List<int>();
foreach (var entry in sut.Keys)
{
actual.Add(entry);
}
Assert.Equal(new[] { 1, 2 }, actual.ToArray());
}
[Fact]
public void Should_loop_over_keys_with_old_enumerator()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
var actual = new List<int>();
foreach (int entry in (IEnumerable)sut.Keys)
{
actual.Add(entry);
}
Assert.Equal(new[] { 1, 2 }, actual.ToArray());
}
[Fact]
public void Should_copy_keys_to_array()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
Assert.Equal(new[] { 1, 2 }, sut.Keys.ToArray());
}
[Fact]
public void Should_loop_over_values()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
var actual = new List<int>();
foreach (var entry in sut.Values)
{
actual.Add(entry);
}
Assert.Equal(new[] { 10, 20 }, actual.ToArray());
}
[Fact]
public void Should_loop_over_values_with_old_enumerator()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
var actual = new List<int>();
foreach (int entry in (IEnumerable)sut.Values)
{
actual.Add(entry);
}
Assert.Equal(new[] { 10, 20 }, actual.ToArray());
}
[Fact]
public void Should_copy_values_to_array()
{
var sut = new ListDictionary<int, int>();
sut.Add(1, 10);
sut.Add(2, 20);
Assert.Equal(new[] { 10, 20 }, sut.Values.ToArray());
}
[Fact]
public void Should_trim()
{
var sut = new ListDictionary<int, int>(20);
sut.Add(1, 10);
sut.Add(2, 20);
Assert.Equal(20, sut.Capacity);
sut.TrimExcess();
Assert.Equal(2, sut.Capacity);
}
[Fact]
public void Should_serialize_and_deserialize()
{
var sut = new Dictionary<int, int>
{
[11] = 1,
[12] = 2,
[13] = 3
}.ToReadonlyDictionary();
var serialized = sut.SerializeAndDeserialize();
Assert.Equal(sut, serialized);
}
}

2
backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj

@ -3,7 +3,7 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<RootNamespace>Squidex.Infrastructure</RootNamespace> <RootNamespace>Squidex.Infrastructure</RootNamespace>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<NeutralLanguage>en</NeutralLanguage> <NeutralLanguage>en</NeutralLanguage>

2
backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj

@ -3,7 +3,7 @@
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net7.0</TargetFramework>
<RootNamespace>Squidex.Web</RootNamespace> <RootNamespace>Squidex.Web</RootNamespace>
<LangVersion>10.0</LangVersion> <LangVersion>11.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>

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

Loading…
Cancel
Save