Browse Source

Merge branch 'next' into feature/ng8

# Conflicts:
#	src/Squidex/app/features/api/pages/graphql/graphql-page.component.scss
#	src/Squidex/app/features/apps/pages/news-dialog.component.scss
#	src/Squidex/app/features/apps/pages/onboarding-dialog.component.scss
#	src/Squidex/app/features/assets/pages/assets-page.component.scss
#	src/Squidex/app/features/content/pages/content/content-history-page.component.scss
#	src/Squidex/app/features/content/pages/contents/contents-page.component.scss
#	src/Squidex/app/features/content/shared/array-item.component.scss
#	src/Squidex/app/features/content/shared/contents-selector.component.scss
#	src/Squidex/app/features/dashboard/pages/dashboard-page.component.scss
#	src/Squidex/app/features/schemas/pages/schema/field-wizard.component.scss
#	src/Squidex/app/features/schemas/pages/schemas/schema-form.component.scss
#	src/Squidex/app/framework/angular/forms/code-editor.component.scss
#	src/Squidex/app/framework/angular/forms/color-picker.component.scss
#	src/Squidex/app/framework/angular/forms/json-editor.component.scss
#	src/Squidex/app/shared/components/asset.component.scss
#	src/Squidex/app/shared/components/assets-selector.component.scss
#	src/Squidex/app/shared/components/history-list.component.scss
#	src/Squidex/app/shared/state/assets.state.spec.ts
#	src/Squidex/package-lock.json
#	src/Squidex/package.json
pull/376/head
Sebastian Stehle 7 years ago
parent
commit
21100b0eae
  1. 6
      .testrunsettings
  2. 28
      CHANGELOG.md
  3. 12
      Dockerfile
  4. 12
      Dockerfile.build
  5. 5
      README.md
  6. 1
      Squidex.ruleset
  7. 8
      src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs
  8. 42
      src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs
  9. 53
      src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs
  10. 5
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs
  11. 32
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs
  12. 9
      src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs
  13. 5
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs
  14. 4
      src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs
  15. 6
      src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs
  16. 15
      src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs
  17. 7
      src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs
  18. 9
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs
  19. 1
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs
  20. 11
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs
  21. 39
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/StatusSerializer.cs
  22. 6
      src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs
  23. 20
      src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs
  24. 62
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  25. 14
      src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexCommandMiddleware.cs
  26. 4
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs
  27. 4
      src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs
  28. 2
      src/Squidex.Domain.Apps.Entities/Apps/RoleExtensions.cs
  29. 3
      src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs
  30. 3
      src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs
  31. 80
      src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs
  32. 19
      src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs
  33. 26
      src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs
  34. 25
      src/Squidex.Domain.Apps.Entities/Assets/AssetResult.cs
  35. 25
      src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs
  36. 9
      src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs
  37. 9
      src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs
  38. 20
      src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs
  39. 35
      src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs
  40. 6
      src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs
  41. 7
      src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  42. 23
      src/Squidex.Domain.Apps.Entities/Contents/ContentDataChangedResult.cs
  43. 21
      src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  44. 64
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  45. 62
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  46. 54
      src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs
  47. 41
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs
  48. 99
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  49. 18
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs
  50. 2
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs
  51. 42
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/LoggingMiddleware.cs
  52. 4
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs
  53. 59
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs
  54. 4
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs
  55. 50
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs
  56. 41
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs
  57. 6
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs
  58. 35
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  59. 5
      src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs
  60. 26
      src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  61. 12
      src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs
  62. 10
      src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs
  63. 5
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  64. 2
      src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs
  65. 21
      src/Squidex.Domain.Apps.Entities/EntityExtensions.cs
  66. 5
      src/Squidex.Domain.Apps.Entities/Q.cs
  67. 19
      src/Squidex.Domain.Apps.Entities/QueryContext.cs
  68. 18
      src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs
  69. 24
      src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardHelper.cs
  70. 86
      src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs
  71. 50
      src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs
  72. 78
      src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs
  73. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs
  74. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs
  75. 16
      src/Squidex.Domain.Users/UserManagerExtensions.cs
  76. 6
      src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs
  77. 158
      src/Squidex.Infrastructure/Assets/FTPAssetStore.cs
  78. 6
      src/Squidex.Infrastructure/Assets/FolderAssetStore.cs
  79. 2
      src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs
  80. 8
      src/Squidex.Infrastructure/CollectionExtensions.cs
  81. 12
      src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs
  82. 29
      src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs
  83. 12
      src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs
  84. 6
      src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs
  85. 10
      src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs
  86. 1
      src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  87. 10
      src/Squidex.Shared/Permissions.cs
  88. 2
      src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs
  89. 2
      src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs
  90. 8
      src/Squidex.Web/Extensions.cs
  91. 76
      src/Squidex.Web/PermissionExtensions.cs
  92. 5
      src/Squidex.Web/Pipeline/AppResolver.cs
  93. 61
      src/Squidex.Web/Resource.cs
  94. 16
      src/Squidex.Web/ResourceLink.cs
  95. 44
      src/Squidex.Web/UrlHelperExtensions.cs
  96. 70
      src/Squidex/Areas/Api/Config/Swagger/ErrorDtoProcessor.cs
  97. 2
      src/Squidex/Areas/Api/Config/Swagger/FixProcessor.cs
  98. 29
      src/Squidex/Areas/Api/Config/Swagger/SecurityProcessor.cs
  99. 7
      src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs
  100. 52
      src/Squidex/Areas/Api/Config/Swagger/XmlResponseTypesProcessor.cs

6
.testrunsettings

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<RunSettings>
<RunConfiguration>
<MaxCpuCount>4</MaxCpuCount>
</RunConfiguration>
</RunSettings>

28
CHANGELOG.md

@ -1,5 +1,33 @@
# Changelog # Changelog
## v2.1.0 - 2019-06-22
## Features
* **Assets**: Parameter to prevent download in Browser.
* **Assets**: FTP asset store.
* **GraphQL**: Logging for field resolvers
* **GraphQL**: Performance optimizations for asset fields and references with DataLoader.
* **MongoDB**: Performance optimizations.
* **MongoDB**: Support for AWS DocumentDB.
* **Schemas**: Separator field.
* **Schemas**: Setting to prevent duplicate references.
* **UI**: Improved styling of DateTime editor.
* **UI**: Custom Editors: Provide all values.
* **UI**: Custom Editors: Provide context with user information and auth token.
* **UI**: Filter by status.
* **UI**: Dropdown field for references.
* **Users**: Email notifications when contributors is added.
## Bugfixes
* **Contents**: Fix for scheduled publishing.
* **GraphQL**: Fix query parameters for assets.
* **GraphQL**: Fix for duplicate field names in GraphQL.
* **GraphQL**: Fix for invalid field names.
* **Plans**: Fix when plans reset and extra events.
* **UI**: Unify slugify in Frontend and Backend.
## v2.0.5 - 2019-04-21 ## v2.0.5 - 2019-04-21
## Features ## Features

12
Dockerfile

@ -5,11 +5,20 @@ FROM squidex/dotnet:2.2-sdk-chromium-phantomjs-node as builder
WORKDIR /src WORKDIR /src
# Copy Node project files.
COPY src/Squidex/package*.json /tmp/ COPY src/Squidex/package*.json /tmp/
# Install Node packages # Install Node packages
RUN cd /tmp && npm install --loglevel=error RUN cd /tmp && npm install --loglevel=error
# Copy nuget project files.
COPY /**/**/*.csproj /tmp/
# Copy nuget.config for package sources.
COPY NuGet.Config /tmp/
# Install nuget packages
RUN bash -c 'pushd /tmp; for p in *.csproj; do dotnet restore $p --verbosity quiet; true; done; popd'
COPY . . COPY . .
# Build Frontend # Build Frontend
@ -19,8 +28,7 @@ RUN cp -a /tmp/node_modules src/Squidex/ \
&& npm run build && npm run build
# Test Backend # Test Backend
RUN dotnet restore \ RUN dotnet test tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj --filter Category!=Dependencies \
&& dotnet test --filter Category!=Dependencies tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj \
&& dotnet test tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj \ && dotnet test tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj \
&& dotnet test tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj \ && dotnet test tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj \
&& dotnet test tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj \ && dotnet test tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj \

12
Dockerfile.build

@ -2,11 +2,20 @@ FROM squidex/dotnet:2.2-sdk-chromium-phantomjs-node as builder
WORKDIR /src WORKDIR /src
# Copy Node project files.
COPY src/Squidex/package*.json /tmp/ COPY src/Squidex/package*.json /tmp/
# Install Node packages # Install Node packages
RUN cd /tmp && npm install --loglevel=error RUN cd /tmp && npm install --loglevel=error
# Copy Dotnet project files.
COPY /**/**/*.csproj /tmp/
# Copy nuget.config for package sources.
COPY NuGet.Config /tmp/
# Install Dotnet packages
RUN bash -c 'pushd /tmp; for p in *.csproj; do dotnet restore $p --verbosity quiet; true; done; popd'
COPY . . COPY . .
# Build Frontend # Build Frontend
@ -16,8 +25,7 @@ RUN cp -a /tmp/node_modules src/Squidex/ \
&& npm run build && npm run build
# Test Backend # Test Backend
RUN dotnet restore \ RUN dotnet test tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj --filter Category!=Dependencies \
&& dotnet test --filter Category!=Dependencies tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj \
&& dotnet test tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj \ && dotnet test tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj \
&& dotnet test tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj \ && dotnet test tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj \
&& dotnet test tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj \ && dotnet test tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj \

5
README.md

@ -34,9 +34,10 @@ Current Version v2.0.4. Roadmap: https://trello.com/b/KakM4F3S/squidex-roadmap
### Contributors ### Contributors
* [pushrbx](https://pushrbx.net/): Azure Store support.
* [cpmstars](https://www.cpmstars.com): Asset support for rich editor.
* [civicplus](https://www.civicplus.com/) ([Avd6977](https://github.com/Avd6977), [dsbegnoce](https://github.com/dsbegnoche)): Google Maps support, custom regex patterns and a lot of small improvements. * [civicplus](https://www.civicplus.com/) ([Avd6977](https://github.com/Avd6977), [dsbegnoce](https://github.com/dsbegnoche)): Google Maps support, custom regex patterns and a lot of small improvements.
* [cpmstars](https://www.cpmstars.com): Asset support for rich editor.
* [guohai](https://github.com/seamys): FTP asset store support, Email rule support, custom editors and bug fixes.
* [pushrbx](https://pushrbx.net/): Azure Store support.
* [razims](https://github.com/razims): GridFS support. * [razims](https://github.com/razims): GridFS support.
## Contributing ## Contributing

1
Squidex.ruleset

@ -63,6 +63,7 @@
<Rule Id="SA1601" Action="None" /> <Rule Id="SA1601" Action="None" />
<Rule Id="SA1413" Action="None" /> <Rule Id="SA1413" Action="None" />
<Rule Id="SA0001" Action="None" /> <Rule Id="SA0001" Action="None" />
<Rule Id="SA1602" Action="None" />
</Rules> </Rules>
<Rules AnalyzerId="RefactoringEssentials" RuleNamespace="RefactoringEssentials"> <Rules AnalyzerId="RefactoringEssentials" RuleNamespace="RefactoringEssentials">
<Rule Id="RECS0061" Action="Error" /> <Rule Id="RECS0061" Action="Error" />

8
src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs

@ -7,6 +7,7 @@
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.Contracts; using System.Diagnostics.Contracts;
using P = Squidex.Shared.Permissions; using P = Squidex.Shared.Permissions;
@ -20,7 +21,7 @@ namespace Squidex.Domain.Apps.Core.Apps
public const string Owner = "Owner"; public const string Owner = "Owner";
public const string Reader = "Reader"; public const string Reader = "Reader";
private static readonly HashSet<string> DefaultRolesSet = new HashSet<string> private static readonly HashSet<string> DefaultRolesSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ {
Editor, Editor,
Developer, Developer,
@ -54,6 +55,11 @@ namespace Squidex.Domain.Apps.Core.Apps
return role != null && DefaultRolesSet.Contains(role); return role != null && DefaultRolesSet.Contains(role);
} }
public static bool IsRole(string name, string expected)
{
return name != null && string.Equals(name, expected, StringComparison.OrdinalIgnoreCase);
}
public static Role CreateOwner(string app) public static Role CreateOwner(string app)
{ {
return new Role(Owner, return new Role(Owner,

42
src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs

@ -0,0 +1,42 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using System;
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Core.Contents.Json
{
public sealed class StatusConverter : JsonConverter, ISupportedTypes
{
public IEnumerable<Type> SupportedTypes
{
get { yield return typeof(Status); }
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
writer.WriteValue(value.ToString());
}
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
if (reader.TokenType != JsonToken.String)
{
throw new JsonException($"Expected String, but got {reader.TokenType}.");
}
return new Status(reader.Value.ToString());
}
public override bool CanConvert(Type objectType)
{
return objectType == typeof(Status);
}
}
}

53
src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs

@ -5,12 +5,57 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure;
using System;
namespace Squidex.Domain.Apps.Core.Contents namespace Squidex.Domain.Apps.Core.Contents
{ {
public enum Status public struct Status : IEquatable<Status>
{ {
Draft, public static readonly Status Archived = new Status("Archived");
Archived, public static readonly Status Draft = new Status("Draft");
Published public static readonly Status Published = new Status("Published");
private readonly string name;
public string Name
{
get { return name ?? "Unknown"; }
}
public Status(string name)
{
this.name = name;
}
public override bool Equals(object obj)
{
return obj is Status status && Equals(status);
}
public bool Equals(Status other)
{
return string.Equals(name, other.name);
}
public override int GetHashCode()
{
return name?.GetHashCode() ?? 0;
}
public override string ToString()
{
return name;
}
public static bool operator ==(Status lhs, Status rhs)
{
return lhs.Equals(rhs);
}
public static bool operator !=(Status lhs, Status rhs)
{
return !lhs.Equals(rhs);
}
} }
} }

5
src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs

@ -1,7 +1,7 @@
// ========================================================================== // ==========================================================================
// Squidex Headless CMS // Squidex Headless CMS
// ========================================================================== // ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt) // Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
@ -9,9 +9,8 @@ namespace Squidex.Domain.Apps.Core.Contents
{ {
public enum StatusChange public enum StatusChange
{ {
Archived, Change,
Published, Published,
Restored,
Unpublished Unpublished
} }
} }

32
src/Squidex.Domain.Apps.Core.Model/Contents/StatusFlow.cs

@ -1,32 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
namespace Squidex.Domain.Apps.Core.Contents
{
public static class StatusFlow
{
private static readonly Dictionary<Status, Status[]> Flow = new Dictionary<Status, Status[]>
{
[Status.Draft] = new[] { Status.Published, Status.Archived },
[Status.Archived] = new[] { Status.Draft },
[Status.Published] = new[] { Status.Draft, Status.Archived }
};
public static bool Exists(Status status)
{
return Flow.ContainsKey(status);
}
public static bool CanChange(Status status, Status toStatus)
{
return Flow.TryGetValue(status, out var state) && state.Contains(toStatus);
}
}
}

9
src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs

@ -122,9 +122,14 @@ namespace Squidex.Domain.Apps.Core.Schemas
{ {
Guard.NotNull(field, nameof(field)); Guard.NotNull(field, nameof(field));
if (ByName.ContainsKey(field.Name) || ById.ContainsKey(field.Id)) if (ByName.ContainsKey(field.Name))
{ {
throw new ArgumentException($"A field with name '{field.Name}' and id {field.Id} already exists.", nameof(field)); throw new ArgumentException($"A field with name '{field.Name}' already exists.", nameof(field));
}
if (ById.ContainsKey(field.Id))
{
throw new ArgumentException($"A field with id {field.Id} already exists.", nameof(field));
} }
return Clone(clone => return Clone(clone =>

5
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs

@ -9,12 +9,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{ {
public enum EnrichedContentEventType public enum EnrichedContentEventType
{ {
Archived,
Created, Created,
Deleted, Deleted,
Published, Published,
Restored, StatusChanged,
Updated,
Unpublished, Unpublished,
Updated
} }
} }

4
src/Squidex.Domain.Apps.Core.Operations/Scripting/JintUser.cs

@ -48,9 +48,9 @@ namespace Squidex.Domain.Apps.Core.Scripting
private static ObjectWrapper CreateUser(Engine engine, string id, bool isClient, string email, string name, IEnumerable<Claim> allClaims) private static ObjectWrapper CreateUser(Engine engine, string id, bool isClient, string email, string name, IEnumerable<Claim> allClaims)
{ {
var claims = var claims =
allClaims.GroupBy(x => x.Type) allClaims.GroupBy(x => x.Type.Split(ClaimSeparators).Last())
.ToDictionary( .ToDictionary(
x => x.Key.Split(ClaimSeparators).Last(), x => x.Key,
x => x.Select(y => y.Value).ToArray()); x => x.Select(y => y.Value).ToArray());
return new ObjectWrapper(engine, new { id, isClient, email, name, claims }); return new ObjectWrapper(engine, new { id, isClient, email, name, claims });

6
src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/FieldValidator.cs

@ -34,11 +34,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
{ {
var typedValue = value; var typedValue = value;
if (value == null) if (value is IJsonValue jsonValue)
{
typedValue = Undefined.Value;
}
else if (value is IJsonValue jsonValue)
{ {
if (jsonValue.Type == JsonValueType.Null) if (jsonValue.Type == JsonValueType.Null)
{ {

15
src/Squidex.Domain.Apps.Core.Operations/ValidateContent/Validators/ObjectValidator.cs

@ -26,7 +26,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
public async Task ValidateAsync(object value, ValidationContext context, AddError addError) public async Task ValidateAsync(object value, ValidationContext context, AddError addError)
{ {
if (value == null) if (value.IsNullOrUndefined())
{ {
value = DefaultValue; value = DefaultValue;
} }
@ -49,17 +49,22 @@ namespace Squidex.Domain.Apps.Core.ValidateContent.Validators
{ {
var name = field.Key; var name = field.Key;
if (!values.TryGetValue(name, out var fieldValue)) var (isOptional, validator) = field.Value;
var fieldValue = Undefined.Value;
if (!values.TryGetValue(name, out var temp))
{ {
if (isPartial) if (isPartial)
{ {
continue; continue;
} }
}
fieldValue = default; else
{
fieldValue = temp;
} }
var (isOptional, validator) = field.Value;
var fieldContext = context.Nested(name).Optional(isOptional); var fieldContext = context.Nested(name).Optional(isOptional);
tasks.Add(validator.ValidateAsync(fieldValue, fieldContext, addError)); tasks.Add(validator.ValidateAsync(fieldValue, fieldContext, addError));

7
src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs

@ -119,12 +119,9 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets
{ {
var find = Collection.Find(x => ids.Contains(x.Id)).SortByDescending(x => x.LastModified); var find = Collection.Find(x => ids.Contains(x.Id)).SortByDescending(x => x.LastModified);
var assetItems = find.ToListAsync(); var assetItems = await find.ToListAsync();
var assetCount = find.CountDocumentsAsync();
await Task.WhenAll(assetItems, assetCount); return ResultList.Create(assetItems.Count, assetItems.OfType<IAssetEntity>());
return ResultList.Create(assetCount.Result, assetItems.Result.OfType<IAssetEntity>());
} }
} }

9
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollection.cs

@ -137,17 +137,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
var find = Collection.Find(FilterFactory.IdsBySchema(schema.Id, ids, status)); var find = Collection.Find(FilterFactory.IdsBySchema(schema.Id, ids, status));
var contentItems = find.WithoutDraft(includeDraft).ToListAsync(); var contentItems = await find.WithoutDraft(includeDraft).ToListAsync();
var contentCount = find.CountDocumentsAsync();
await Task.WhenAll(contentItems, contentCount);
foreach (var entity in contentItems.Result) foreach (var entity in contentItems)
{ {
entity.ParseData(schema.SchemaDef, serializer); entity.ParseData(schema.SchemaDef, serializer);
} }
return ResultList.Create<IContentEntity>(contentCount.Result, contentItems.Result); return ResultList.Create<IContentEntity>(contentItems.Count, contentItems);
} }
public async Task<IContentEntity> FindContentAsync(ISchemaEntity schema, Guid id, Status[] status, bool includeDraft) public async Task<IContentEntity> FindContentAsync(ISchemaEntity schema, Guid id, Status[] status, bool includeDraft)

1
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentEntity.cs

@ -51,7 +51,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
[BsonRequired] [BsonRequired]
[BsonElement("ss")] [BsonElement("ss")]
[BsonRepresentation(BsonType.String)]
public Status Status { get; set; } public Status Status { get; set; }
[BsonIgnoreIfNull] [BsonIgnoreIfNull]

11
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository.cs

@ -36,6 +36,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
private readonly string typeContentDeleted; private readonly string typeContentDeleted;
private readonly MongoContentCollection contents; private readonly MongoContentCollection contents;
static MongoContentRepository()
{
StatusSerializer.Register();
}
public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider, IJsonSerializer serializer, ITextIndexer indexer, TypeNameRegistry typeNameRegistry) public MongoContentRepository(IMongoDatabase database, IAppProvider appProvider, IJsonSerializer serializer, ITextIndexer indexer, TypeNameRegistry typeNameRegistry)
{ {
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(appProvider, nameof(appProvider));
@ -64,7 +69,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
Guard.NotNull(app, nameof(app)); Guard.NotNull(app, nameof(app));
Guard.NotNull(schema, nameof(schema)); Guard.NotNull(schema, nameof(schema));
Guard.NotNull(status, nameof(status));
Guard.NotNull(query, nameof(query)); Guard.NotNull(query, nameof(query));
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery")) using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByQuery"))
@ -83,9 +87,8 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet<Guid> ids, bool includeDraft = true) public async Task<IResultList<IContentEntity>> QueryAsync(IAppEntity app, ISchemaEntity schema, Status[] status, HashSet<Guid> ids, bool includeDraft = true)
{ {
Guard.NotNull(app, nameof(app)); Guard.NotNull(app, nameof(app));
Guard.NotNull(schema, nameof(schema));
Guard.NotNull(status, nameof(status));
Guard.NotNull(ids, nameof(ids)); Guard.NotNull(ids, nameof(ids));
Guard.NotNull(schema, nameof(schema));
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds")) using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds"))
{ {
@ -96,7 +99,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, Status[] status, HashSet<Guid> ids, bool includeDraft = true) public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(IAppEntity app, Status[] status, HashSet<Guid> ids, bool includeDraft = true)
{ {
Guard.NotNull(app, nameof(app)); Guard.NotNull(app, nameof(app));
Guard.NotNull(status, nameof(status));
Guard.NotNull(ids, nameof(ids)); Guard.NotNull(ids, nameof(ids));
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema")) using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema"))
@ -109,7 +111,6 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
Guard.NotNull(app, nameof(app)); Guard.NotNull(app, nameof(app));
Guard.NotNull(schema, nameof(schema)); Guard.NotNull(schema, nameof(schema));
Guard.NotNull(status, nameof(status));
using (Profiler.TraceMethod<MongoContentRepository>()) using (Profiler.TraceMethod<MongoContentRepository>())
{ {

39
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/StatusSerializer.cs

@ -0,0 +1,39 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading;
using MongoDB.Bson.Serialization;
using MongoDB.Bson.Serialization.Serializers;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{
public sealed class StatusSerializer : SerializerBase<Status>
{
private static volatile int isRegistered;
public static void Register()
{
if (Interlocked.Increment(ref isRegistered) == 1)
{
BsonSerializer.RegisterSerializer(new StatusSerializer());
}
}
public override Status Deserialize(BsonDeserializationContext context, BsonDeserializationArgs args)
{
var value = context.Reader.ReadString();
return new Status(value);
}
public override void Serialize(BsonSerializationContext context, BsonSerializationArgs args, Status value)
{
context.Writer.WriteString(value.Name);
}
}
}

6
src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Visitors/FilterFactory.cs

@ -162,7 +162,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Visitors
} }
filters.Add(Filter.Ne(x => x.IsDeleted, true)); filters.Add(Filter.Ne(x => x.IsDeleted, true));
filters.Add(Filter.In(x => x.Status, status));
if (status != null)
{
filters.Add(Filter.In(x => x.Status, status));
}
if (ids != null && ids.Count > 0) if (ids != null && ids.Count > 0)
{ {

20
src/Squidex.Domain.Apps.Entities/Apps/AppExtensions.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps
{
public static class AppExtensions
{
public static NamedId<Guid> NamedId(this IAppEntity app)
{
return new NamedId<Guid>(app.Id, app.Name);
}
}
}

62
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -60,11 +60,13 @@ namespace Squidex.Domain.Apps.Entities.Apps
switch (command) switch (command)
{ {
case CreateApp createApp: case CreateApp createApp:
return CreateAsync(createApp, c => return CreateReturn(createApp, c =>
{ {
GuardApp.CanCreate(c); GuardApp.CanCreate(c);
Create(c); Create(c);
return Snapshot;
}); });
case AssignContributor assignContributor: case AssignContributor assignContributor:
@ -74,111 +76,137 @@ namespace Squidex.Domain.Apps.Entities.Apps
AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId)); AssignContributor(c, !Snapshot.Contributors.ContainsKey(assignContributor.ContributorId));
return EntityCreatedResult.Create(c.ContributorId, Version); return Snapshot;
}); });
case RemoveContributor removeContributor: case RemoveContributor removeContributor:
return UpdateAsync(removeContributor, c => return UpdateReturn(removeContributor, c =>
{ {
GuardAppContributors.CanRemove(Snapshot.Contributors, c); GuardAppContributors.CanRemove(Snapshot.Contributors, c);
RemoveContributor(c); RemoveContributor(c);
return Snapshot;
}); });
case AttachClient attachClient: case AttachClient attachClient:
return UpdateAsync(attachClient, c => return UpdateReturn(attachClient, c =>
{ {
GuardAppClients.CanAttach(Snapshot.Clients, c); GuardAppClients.CanAttach(Snapshot.Clients, c);
AttachClient(c); AttachClient(c);
return Snapshot;
}); });
case UpdateClient updateClient: case UpdateClient updateClient:
return UpdateAsync(updateClient, c => return UpdateReturn(updateClient, c =>
{ {
GuardAppClients.CanUpdate(Snapshot.Clients, c, Snapshot.Roles); GuardAppClients.CanUpdate(Snapshot.Clients, c, Snapshot.Roles);
UpdateClient(c); UpdateClient(c);
return Snapshot;
}); });
case RevokeClient revokeClient: case RevokeClient revokeClient:
return UpdateAsync(revokeClient, c => return UpdateReturn(revokeClient, c =>
{ {
GuardAppClients.CanRevoke(Snapshot.Clients, c); GuardAppClients.CanRevoke(Snapshot.Clients, c);
RevokeClient(c); RevokeClient(c);
return Snapshot;
}); });
case AddLanguage addLanguage: case AddLanguage addLanguage:
return UpdateAsync(addLanguage, c => return UpdateReturn(addLanguage, c =>
{ {
GuardAppLanguages.CanAdd(Snapshot.LanguagesConfig, c); GuardAppLanguages.CanAdd(Snapshot.LanguagesConfig, c);
AddLanguage(c); AddLanguage(c);
return Snapshot;
}); });
case RemoveLanguage removeLanguage: case RemoveLanguage removeLanguage:
return UpdateAsync(removeLanguage, c => return UpdateReturn(removeLanguage, c =>
{ {
GuardAppLanguages.CanRemove(Snapshot.LanguagesConfig, c); GuardAppLanguages.CanRemove(Snapshot.LanguagesConfig, c);
RemoveLanguage(c); RemoveLanguage(c);
return Snapshot;
}); });
case UpdateLanguage updateLanguage: case UpdateLanguage updateLanguage:
return UpdateAsync(updateLanguage, c => return UpdateReturn(updateLanguage, c =>
{ {
GuardAppLanguages.CanUpdate(Snapshot.LanguagesConfig, c); GuardAppLanguages.CanUpdate(Snapshot.LanguagesConfig, c);
UpdateLanguage(c); UpdateLanguage(c);
return Snapshot;
}); });
case AddRole addRole: case AddRole addRole:
return UpdateAsync(addRole, c => return UpdateReturn(addRole, c =>
{ {
GuardAppRoles.CanAdd(Snapshot.Roles, c); GuardAppRoles.CanAdd(Snapshot.Roles, c);
AddRole(c); AddRole(c);
return Snapshot;
}); });
case DeleteRole deleteRole: case DeleteRole deleteRole:
return UpdateAsync(deleteRole, c => return UpdateReturn(deleteRole, c =>
{ {
GuardAppRoles.CanDelete(Snapshot.Roles, c, Snapshot.Contributors, Snapshot.Clients); GuardAppRoles.CanDelete(Snapshot.Roles, c, Snapshot.Contributors, Snapshot.Clients);
DeleteRole(c); DeleteRole(c);
return Snapshot;
}); });
case UpdateRole updateRole: case UpdateRole updateRole:
return UpdateAsync(updateRole, c => return UpdateReturn(updateRole, c =>
{ {
GuardAppRoles.CanUpdate(Snapshot.Roles, c); GuardAppRoles.CanUpdate(Snapshot.Roles, c);
UpdateRole(c); UpdateRole(c);
return Snapshot;
}); });
case AddPattern addPattern: case AddPattern addPattern:
return UpdateAsync(addPattern, c => return UpdateReturn(addPattern, c =>
{ {
GuardAppPatterns.CanAdd(Snapshot.Patterns, c); GuardAppPatterns.CanAdd(Snapshot.Patterns, c);
AddPattern(c); AddPattern(c);
return Snapshot;
}); });
case DeletePattern deletePattern: case DeletePattern deletePattern:
return UpdateAsync(deletePattern, c => return UpdateReturn(deletePattern, c =>
{ {
GuardAppPatterns.CanDelete(Snapshot.Patterns, c); GuardAppPatterns.CanDelete(Snapshot.Patterns, c);
DeletePattern(c); DeletePattern(c);
return Snapshot;
}); });
case UpdatePattern updatePattern: case UpdatePattern updatePattern:
return UpdateAsync(updatePattern, c => return UpdateReturn(updatePattern, c =>
{ {
GuardAppPatterns.CanUpdate(Snapshot.Patterns, c); GuardAppPatterns.CanUpdate(Snapshot.Patterns, c);
UpdatePattern(c); UpdatePattern(c);
return Snapshot;
}); });
case ChangePlan changePlan: case ChangePlan changePlan:
@ -194,7 +222,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
} }
else else
{ {
var result = await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.Id, Snapshot.Name, c.PlanId); var result = await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.NamedId(), c.PlanId);
switch (result) switch (result)
{ {
@ -213,7 +241,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
case ArchiveApp archiveApp: case ArchiveApp archiveApp:
return UpdateAsync(archiveApp, async c => return UpdateAsync(archiveApp, async c =>
{ {
await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.Id, Snapshot.Name, null); await appPlansBillingManager.ChangePlanAsync(c.Actor.Identifier, Snapshot.NamedId(), null);
ArchiveApp(c); ArchiveApp(c);
}); });

14
src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByUserIndexCommandMiddleware.cs

@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
await Index(GetUserId(createApp)).AddAppAsync(createApp.AppId); await Index(GetUserId(createApp)).AddAppAsync(createApp.AppId);
break; break;
case AssignContributor assignContributor: case AssignContributor assignContributor:
await Index(GetUserId(context)).AddAppAsync(assignContributor.AppId); await Index(GetUserId(assignContributor)).AddAppAsync(assignContributor.AppId);
break; break;
case RemoveContributor removeContributor: case RemoveContributor removeContributor:
await Index(GetUserId(removeContributor)).RemoveAppAsync(removeContributor.AppId); await Index(GetUserId(removeContributor)).RemoveAppAsync(removeContributor.AppId);
@ -57,19 +57,19 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
await next(); await next();
} }
private static string GetUserId(RemoveContributor removeContributor) private static string GetUserId(CreateApp createApp)
{ {
return removeContributor.ContributorId; return createApp.Actor.Identifier;
} }
private static string GetUserId(CreateApp createApp) private static string GetUserId(AssignContributor assignContributor)
{ {
return createApp.Actor.Identifier; return assignContributor.ContributorId;
} }
private static string GetUserId(CommandContext context) private static string GetUserId(RemoveContributor removeContributor)
{ {
return context.Result<EntityCreatedResult<string>>().IdOrValue; return removeContributor.ContributorId;
} }
private IAppsByUserIndex Index(string id) private IAppsByUserIndex Index(string id)

4
src/Squidex.Domain.Apps.Entities/Apps/Invitation/InviteUserCommandMiddleware.cs

@ -35,9 +35,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Invitation
await next(); await next();
if (assignContributor.IsCreated && context.PlainResult is EntityCreatedResult<string> id) if (assignContributor.IsCreated && context.PlainResult is IAppEntity app)
{ {
context.Complete(new InvitedResult { Id = id }); context.Complete(new InvitedResult { App = app });
} }
return; return;

4
src/Squidex.Domain.Apps.Entities/Apps/Invitation/InvitedResult.cs

@ -5,12 +5,10 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Apps.Invitation namespace Squidex.Domain.Apps.Entities.Apps.Invitation
{ {
public sealed class InvitedResult public sealed class InvitedResult
{ {
public EntityCreatedResult<string> Id { get; set; } public IAppEntity App { get; set; }
} }
} }

2
src/Squidex.Domain.Apps.Entities/Apps/RoleExtensions.cs

@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
{ {
return id; return id;
} }
})); }).Where(x => x != "common"));
} }
} }
} }

3
src/Squidex.Domain.Apps.Entities/Apps/Services/IAppPlanBillingManager.cs

@ -7,6 +7,7 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Services namespace Squidex.Domain.Apps.Entities.Apps.Services
{ {
@ -14,7 +15,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services
{ {
bool HasPortal { get; } bool HasPortal { get; }
Task<IChangePlanResult> ChangePlanAsync(string userId, Guid appId, string appName, string planId); Task<IChangePlanResult> ChangePlanAsync(string userId, NamedId<Guid> appId, string planId);
Task<string> GetPortalLinkAsync(string userId); Task<string> GetPortalLinkAsync(string userId);
} }

3
src/Squidex.Domain.Apps.Entities/Apps/Services/Implementations/NoopAppPlanBillingManager.cs

@ -7,6 +7,7 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations
{ {
@ -17,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Services.Implementations
get { return false; } get { return false; }
} }
public Task<IChangePlanResult> ChangePlanAsync(string userId, Guid appId, string appName, string planId) public Task<IChangePlanResult> ChangePlanAsync(string userId, NamedId<Guid> appId, string planId)
{ {
return Task.FromResult<IChangePlanResult>(new PlanResetResult()); return Task.FromResult<IChangePlanResult>(new PlanResetResult());
} }

80
src/Squidex.Domain.Apps.Entities/Assets/AssetCommandMiddleware.cs

@ -10,6 +10,7 @@ using System.Collections.Generic;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Threading.Tasks; using System.Threading.Tasks;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Domain.Apps.Entities.Assets.Commands;
using Squidex.Domain.Apps.Entities.Tags; using Squidex.Domain.Apps.Entities.Tags;
using Squidex.Infrastructure; using Squidex.Infrastructure;
@ -24,25 +25,28 @@ namespace Squidex.Domain.Apps.Entities.Assets
private readonly IAssetQueryService assetQuery; private readonly IAssetQueryService assetQuery;
private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAssetThumbnailGenerator assetThumbnailGenerator;
private readonly IEnumerable<ITagGenerator<CreateAsset>> tagGenerators; private readonly IEnumerable<ITagGenerator<CreateAsset>> tagGenerators;
private readonly ITagService tagService;
public AssetCommandMiddleware( public AssetCommandMiddleware(
IGrainFactory grainFactory, IGrainFactory grainFactory,
IAssetQueryService assetQuery, IAssetQueryService assetQuery,
IAssetStore assetStore, IAssetStore assetStore,
IAssetThumbnailGenerator assetThumbnailGenerator, IAssetThumbnailGenerator assetThumbnailGenerator,
IEnumerable<ITagGenerator<CreateAsset>> tagGenerators) IEnumerable<ITagGenerator<CreateAsset>> tagGenerators,
ITagService tagService)
: base(grainFactory) : base(grainFactory)
{ {
Guard.NotNull(assetStore, nameof(assetStore)); Guard.NotNull(assetStore, nameof(assetStore));
Guard.NotNull(assetQuery, nameof(assetQuery)); Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator)); Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator));
Guard.NotNull(tagGenerators, nameof(tagGenerators)); Guard.NotNull(tagGenerators, nameof(tagGenerators));
Guard.NotNull(tagService, nameof(tagService));
this.assetStore = assetStore; this.assetStore = assetStore;
this.assetQuery = assetQuery; this.assetQuery = assetQuery;
this.assetThumbnailGenerator = assetThumbnailGenerator; this.assetThumbnailGenerator = assetThumbnailGenerator;
this.tagGenerators = tagGenerators; this.tagGenerators = tagGenerators;
this.tagService = tagService;
} }
public override async Task HandleAsync(CommandContext context, Func<Task> next) public override async Task HandleAsync(CommandContext context, Func<Task> next)
@ -56,9 +60,8 @@ namespace Squidex.Domain.Apps.Entities.Assets
createAsset.Tags = new HashSet<string>(); createAsset.Tags = new HashSet<string>();
} }
createAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(createAsset.File.OpenRead()); await EnrichWithImageInfosAsync(createAsset);
await EnrichWithHashAndUploadAsync(createAsset, context);
createAsset.FileHash = await UploadAsync(context, createAsset.File);
try try
{ {
@ -70,13 +73,9 @@ namespace Squidex.Domain.Apps.Entities.Assets
{ {
if (IsDuplicate(createAsset, existing)) if (IsDuplicate(createAsset, existing))
{ {
result = new AssetCreatedResult( var denormalizedTags = await tagService.DenormalizeTagsAsync(createAsset.AppId.Id, TagGroups.Assets, existing.Tags);
existing.Id,
existing.Tags, result = new AssetCreatedResult(existing, true, new HashSet<string>(denormalizedTags.Values));
existing.Version,
existing.FileVersion,
existing.FileHash,
true);
} }
break; break;
@ -89,17 +88,11 @@ namespace Squidex.Domain.Apps.Entities.Assets
tagGenerator.GenerateTags(createAsset, createAsset.Tags); tagGenerator.GenerateTags(createAsset, createAsset.Tags);
} }
var commandResult = (AssetSavedResult)await ExecuteCommandAsync(createAsset); var asset = (IAssetEntity)await ExecuteCommandAsync(createAsset);
result = new AssetCreatedResult( result = new AssetCreatedResult(asset, false, createAsset.Tags);
createAsset.AssetId,
createAsset.Tags,
commandResult.Version,
commandResult.FileVersion,
commandResult.FileHash,
false);
await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), result.FileVersion, null); await assetStore.CopyAsync(context.ContextId.ToString(), createAsset.AssetId.ToString(), asset.FileVersion, null);
} }
context.Complete(result); context.Complete(result);
@ -114,16 +107,16 @@ namespace Squidex.Domain.Apps.Entities.Assets
case UpdateAsset updateAsset: case UpdateAsset updateAsset:
{ {
updateAsset.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(updateAsset.File.OpenRead()); await EnrichWithImageInfosAsync(updateAsset);
await EnrichWithHashAndUploadAsync(updateAsset, context);
updateAsset.FileHash = await UploadAsync(context, updateAsset.File);
try try
{ {
var result = (AssetSavedResult)await ExecuteCommandAsync(updateAsset); var result = (AssetResult)await ExecuteAndAdjustTagsAsync(updateAsset);
context.Complete(result); context.Complete(result);
await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.FileVersion, null); await assetStore.CopyAsync(context.ContextId.ToString(), updateAsset.AssetId.ToString(), result.Asset.FileVersion, null);
} }
finally finally
{ {
@ -133,29 +126,54 @@ namespace Squidex.Domain.Apps.Entities.Assets
break; break;
} }
case AssetCommand command:
{
var result = await ExecuteAndAdjustTagsAsync(command);
context.Complete(result);
break;
}
default: default:
await base.HandleAsync(context, next); await base.HandleAsync(context, next);
break; break;
} }
} }
private async Task<object> ExecuteAndAdjustTagsAsync(AssetCommand command)
{
var result = await ExecuteCommandAsync(command);
if (result is IAssetEntity asset)
{
var denormalizedTags = await tagService.DenormalizeTagsAsync(asset.AppId.Id, TagGroups.Assets, asset.Tags);
return new AssetResult(asset, new HashSet<string>(denormalizedTags.Values));
}
return result;
}
private static bool IsDuplicate(CreateAsset createAsset, IAssetEntity asset) private static bool IsDuplicate(CreateAsset createAsset, IAssetEntity asset)
{ {
return asset != null && asset.FileName == createAsset.File.FileName && asset.FileSize == createAsset.File.FileSize; return asset != null && asset.FileName == createAsset.File.FileName && asset.FileSize == createAsset.File.FileSize;
} }
private async Task<string> UploadAsync(CommandContext context, AssetFile file) private async Task EnrichWithImageInfosAsync(UploadAssetCommand command)
{ {
string hash; command.ImageInfo = await assetThumbnailGenerator.GetImageInfoAsync(command.File.OpenRead());
}
using (var hashStream = new HasherStream(file.OpenRead(), HashAlgorithmName.SHA256)) private async Task EnrichWithHashAndUploadAsync(UploadAssetCommand command, CommandContext context)
{
using (var hashStream = new HasherStream(command.File.OpenRead(), HashAlgorithmName.SHA256))
{ {
await assetStore.UploadAsync(context.ContextId.ToString(), hashStream); await assetStore.UploadAsync(context.ContextId.ToString(), hashStream);
hash = $"{hashStream.GetHashStringAndReset()}{file.FileName}{file.FileSize}".Sha256Base64(); command.FileHash = $"{hashStream.GetHashStringAndReset()}{command.File.FileName}{command.File.FileSize}".Sha256Base64();
} }
return hash;
} }
} }
} }

19
src/Squidex.Domain.Apps.Entities/Assets/AssetCreatedResult.cs

@ -5,30 +5,17 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Collections.Generic; using System.Collections.Generic;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
{ {
public sealed class AssetCreatedResult : EntityCreatedResult<Guid> public sealed class AssetCreatedResult : AssetResult
{ {
public HashSet<string> Tags { get; }
public long FileVersion { get; }
public string FileHash { get; }
public bool IsDuplicate { get; } public bool IsDuplicate { get; }
public AssetCreatedResult(Guid id, HashSet<string> tags, long version, long fileVersion, string fileHash, bool isDuplicate) public AssetCreatedResult(IAssetEntity asset, bool isDuplicate, HashSet<string> tags)
: base(id, version) : base(asset, tags)
{ {
Tags = tags;
FileVersion = fileVersion;
FileHash = fileHash;
IsDuplicate = isDuplicate; IsDuplicate = isDuplicate;
} }
} }

26
src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs

@ -51,16 +51,27 @@ namespace Squidex.Domain.Apps.Entities.Assets
Create(c, tagIds); Create(c, tagIds);
return new AssetSavedResult(Version, Snapshot.FileVersion, Snapshot.FileHash); return Snapshot;
}); });
case UpdateAsset updateRule: case UpdateAsset updateRule:
return UpdateAsync(updateRule, c => return UpdateReturn(updateRule, c =>
{ {
GuardAsset.CanUpdate(c); GuardAsset.CanUpdate(c);
Update(c); Update(c);
return new AssetSavedResult(Version, Snapshot.FileVersion, Snapshot.FileHash); return Snapshot;
});
case AnnotateAsset annotateAsset:
return UpdateReturnAsync(annotateAsset, async c =>
{
GuardAsset.CanAnnotate(c, Snapshot.FileName, Snapshot.Slug);
var tagIds = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags);
Annotate(c, tagIds);
return Snapshot;
}); });
case DeleteAsset deleteAsset: case DeleteAsset deleteAsset:
return UpdateAsync(deleteAsset, async c => return UpdateAsync(deleteAsset, async c =>
@ -71,15 +82,6 @@ namespace Squidex.Domain.Apps.Entities.Assets
Delete(c); Delete(c);
}); });
case AnnotateAsset annotateAsset:
return UpdateAsync(annotateAsset, async c =>
{
GuardAsset.CanAnnotate(c, Snapshot.FileName, Snapshot.Slug);
var tagIds = await NormalizeTagsAsync(Snapshot.AppId.Id, c.Tags);
Annotate(c, tagIds);
});
default: default:
throw new NotSupportedException(); throw new NotSupportedException();
} }

25
src/Squidex.Domain.Apps.Entities/Assets/AssetResult.cs

@ -0,0 +1,25 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetResult
{
public IAssetEntity Asset { get; }
public HashSet<string> Tags { get; }
public AssetResult(IAssetEntity asset, HashSet<string> tags)
{
Asset = asset;
Tags = tags;
}
}
}

25
src/Squidex.Domain.Apps.Entities/Assets/AssetSavedResult.cs

@ -1,25 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetSavedResult : EntitySavedResult
{
public long FileVersion { get; }
public string FileHash { get; }
public AssetSavedResult(long version, long fileVersion, string fileHash)
: base(version)
{
FileVersion = fileVersion;
FileHash = fileHash;
}
}
}

9
src/Squidex.Domain.Apps.Entities/Assets/Commands/CreateAsset.cs

@ -8,22 +8,15 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
namespace Squidex.Domain.Apps.Entities.Assets.Commands namespace Squidex.Domain.Apps.Entities.Assets.Commands
{ {
public sealed class CreateAsset : AssetCommand, IAppCommand public sealed class CreateAsset : UploadAssetCommand, IAppCommand
{ {
public NamedId<Guid> AppId { get; set; } public NamedId<Guid> AppId { get; set; }
public AssetFile File { get; set; }
public ImageInfo ImageInfo { get; set; }
public HashSet<string> Tags { get; set; } public HashSet<string> Tags { get; set; }
public string FileHash { get; set; }
public CreateAsset() public CreateAsset()
{ {
AssetId = Guid.NewGuid(); AssetId = Guid.NewGuid();

9
src/Squidex.Domain.Apps.Entities/Assets/Commands/UpdateAsset.cs

@ -5,16 +5,9 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using Squidex.Infrastructure.Assets;
namespace Squidex.Domain.Apps.Entities.Assets.Commands namespace Squidex.Domain.Apps.Entities.Assets.Commands
{ {
public sealed class UpdateAsset : AssetCommand public sealed class UpdateAsset : UploadAssetCommand
{ {
public AssetFile File { get; set; }
public ImageInfo ImageInfo { get; set; }
public string FileHash { get; set; }
} }
} }

20
src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure.Assets;
namespace Squidex.Domain.Apps.Entities.Assets.Commands
{
public abstract class UploadAssetCommand : AssetCommand
{
public AssetFile File { get; set; }
public ImageInfo ImageInfo { get; set; }
public string FileHash { get; set; }
}
}

35
src/Squidex.Domain.Apps.Entities/Assets/Edm/EdmAssetModel.cs

@ -18,20 +18,27 @@ namespace Squidex.Domain.Apps.Entities.Assets.Edm
{ {
var entityType = new EdmEntityType("Squidex", "Asset"); var entityType = new EdmEntityType("Squidex", "Asset");
entityType.AddStructuralProperty(nameof(IAssetEntity.Id).ToCamelCase(), EdmPrimitiveTypeKind.String); void AddProperty(string name, EdmPrimitiveTypeKind type)
entityType.AddStructuralProperty(nameof(IAssetEntity.Created).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); {
entityType.AddStructuralProperty(nameof(IAssetEntity.CreatedBy).ToCamelCase(), EdmPrimitiveTypeKind.String); entityType.AddStructuralProperty(name.ToCamelCase(), type);
entityType.AddStructuralProperty(nameof(IAssetEntity.LastModified).ToCamelCase(), EdmPrimitiveTypeKind.DateTimeOffset); }
entityType.AddStructuralProperty(nameof(IAssetEntity.LastModifiedBy).ToCamelCase(), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.Version).ToCamelCase(), EdmPrimitiveTypeKind.Int64); AddProperty(nameof(IAssetEntity.Id), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.FileName).ToCamelCase(), EdmPrimitiveTypeKind.String); AddProperty(nameof(IAssetEntity.Created), EdmPrimitiveTypeKind.DateTimeOffset);
entityType.AddStructuralProperty(nameof(IAssetEntity.FileSize).ToCamelCase(), EdmPrimitiveTypeKind.Int64); AddProperty(nameof(IAssetEntity.CreatedBy), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.FileVersion).ToCamelCase(), EdmPrimitiveTypeKind.Int64); AddProperty(nameof(IAssetEntity.LastModified), EdmPrimitiveTypeKind.DateTimeOffset);
entityType.AddStructuralProperty(nameof(IAssetEntity.IsImage).ToCamelCase(), EdmPrimitiveTypeKind.Boolean); AddProperty(nameof(IAssetEntity.LastModifiedBy), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.MimeType).ToCamelCase(), EdmPrimitiveTypeKind.String); AddProperty(nameof(IAssetEntity.Version), EdmPrimitiveTypeKind.Int64);
entityType.AddStructuralProperty(nameof(IAssetEntity.PixelHeight).ToCamelCase(), EdmPrimitiveTypeKind.Int32); AddProperty(nameof(IAssetEntity.FileName), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.PixelWidth).ToCamelCase(), EdmPrimitiveTypeKind.Int32); AddProperty(nameof(IAssetEntity.FileHash), EdmPrimitiveTypeKind.String);
entityType.AddStructuralProperty(nameof(IAssetEntity.Tags).ToCamelCase(), EdmPrimitiveTypeKind.String); AddProperty(nameof(IAssetEntity.FileSize), EdmPrimitiveTypeKind.Int64);
AddProperty(nameof(IAssetEntity.FileVersion), EdmPrimitiveTypeKind.Int64);
AddProperty(nameof(IAssetEntity.IsImage), EdmPrimitiveTypeKind.Boolean);
AddProperty(nameof(IAssetEntity.MimeType), EdmPrimitiveTypeKind.String);
AddProperty(nameof(IAssetEntity.PixelHeight), EdmPrimitiveTypeKind.Int32);
AddProperty(nameof(IAssetEntity.PixelWidth), EdmPrimitiveTypeKind.Int32);
AddProperty(nameof(IAssetEntity.Slug), EdmPrimitiveTypeKind.String);
AddProperty(nameof(IAssetEntity.Tags), EdmPrimitiveTypeKind.String);
var container = new EdmEntityContainer("Squidex", "Container"); var container = new EdmEntityContainer("Squidex", "Container");

6
src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs

@ -73,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Comments
switch (command) switch (command)
{ {
case CreateComment createComment: case CreateComment createComment:
return UpsertAsync(createComment, c => return UpsertReturn(createComment, c =>
{ {
GuardComments.CanCreate(c); GuardComments.CanCreate(c);
@ -83,7 +83,7 @@ namespace Squidex.Domain.Apps.Entities.Comments
}); });
case UpdateComment updateComment: case UpdateComment updateComment:
return UpsertAsync(updateComment, c => return Upsert(updateComment, c =>
{ {
GuardComments.CanUpdate(events, c); GuardComments.CanUpdate(events, c);
@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.Comments
}); });
case DeleteComment deleteComment: case DeleteComment deleteComment:
return UpsertAsync(deleteComment, c => return Upsert(deleteComment, c =>
{ {
GuardComments.CanDelete(events, c); GuardComments.CanDelete(events, c);

7
src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs

@ -69,11 +69,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
case StatusChange.Unpublished: case StatusChange.Unpublished:
result.Type = EnrichedContentEventType.Unpublished; result.Type = EnrichedContentEventType.Unpublished;
break; break;
case StatusChange.Archived: default:
result.Type = EnrichedContentEventType.Archived; result.Type = EnrichedContentEventType.StatusChanged;
break;
case StatusChange.Restored:
result.Type = EnrichedContentEventType.Restored;
break; break;
} }

23
src/Squidex.Domain.Apps.Entities/Contents/ContentDataChangedResult.cs

@ -1,23 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentDataChangedResult : EntitySavedResult
{
public NamedContentData Data { get; }
public ContentDataChangedResult(NamedContentData data, long version)
: base(version)
{
Data = data;
}
}
}

21
src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs

@ -8,9 +8,7 @@
using System; using System;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
@ -41,24 +39,5 @@ namespace Squidex.Domain.Apps.Entities.Contents
public Status Status { get; set; } public Status Status { get; set; }
public bool IsPending { get; set; } public bool IsPending { get; set; }
public static ContentEntity Create(CreateContent command, EntityCreatedResult<NamedContentData> result)
{
var now = SystemClock.Instance.GetCurrentInstant();
var response = new ContentEntity
{
Id = command.ContentId,
Data = result.IdOrValue,
Version = result.Version,
Created = now,
CreatedBy = command.Actor,
LastModified = now,
LastModifiedBy = command.Actor,
Status = command.Publish ? Status.Published : Status.Draft
};
return response;
}
} }
} }

64
src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs

@ -32,6 +32,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly IAssetRepository assetRepository; private readonly IAssetRepository assetRepository;
private readonly IContentRepository contentRepository; private readonly IContentRepository contentRepository;
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
private readonly IContentWorkflow contentWorkflow;
public ContentGrain( public ContentGrain(
IStore<Guid> store, IStore<Guid> store,
@ -39,17 +40,20 @@ namespace Squidex.Domain.Apps.Entities.Contents
IAppProvider appProvider, IAppProvider appProvider,
IAssetRepository assetRepository, IAssetRepository assetRepository,
IScriptEngine scriptEngine, IScriptEngine scriptEngine,
IContentWorkflow contentWorkflow,
IContentRepository contentRepository) IContentRepository contentRepository)
: base(store, log) : base(store, log)
{ {
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(appProvider, nameof(appProvider));
Guard.NotNull(scriptEngine, nameof(scriptEngine)); Guard.NotNull(scriptEngine, nameof(scriptEngine));
Guard.NotNull(assetRepository, nameof(assetRepository)); Guard.NotNull(assetRepository, nameof(assetRepository));
Guard.NotNull(contentWorkflow, nameof(contentWorkflow));
Guard.NotNull(contentRepository, nameof(contentRepository)); Guard.NotNull(contentRepository, nameof(contentRepository));
this.appProvider = appProvider; this.appProvider = appProvider;
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
this.assetRepository = assetRepository; this.assetRepository = assetRepository;
this.contentWorkflow = contentWorkflow;
this.contentRepository = contentRepository; this.contentRepository = contentRepository;
} }
@ -79,35 +83,37 @@ namespace Squidex.Domain.Apps.Entities.Contents
await ctx.ExecuteScriptAsync(s => s.Change, "Published", c, c.Data); await ctx.ExecuteScriptAsync(s => s.Change, "Published", c, c.Data);
} }
Create(c); var status = await contentWorkflow.GetInitialStatusAsync(ctx.Schema);
return EntityCreatedResult.Create(c.Data, Version); Create(c, status);
return Snapshot;
}); });
case UpdateContent updateContent: case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, c => return UpdateReturnAsync(updateContent, async c =>
{ {
GuardContent.CanUpdate(c); await GuardContent.CanUpdate(Snapshot, contentWorkflow, c);
return UpdateAsync(c, x => c.Data, false); return await UpdateAsync(c, x => c.Data, false);
}); });
case PatchContent patchContent: case PatchContent patchContent:
return UpdateReturnAsync(patchContent, c => return UpdateReturnAsync(patchContent, async c =>
{ {
GuardContent.CanPatch(c); await GuardContent.CanPatch(Snapshot, contentWorkflow, c);
return UpdateAsync(c, c.Data.MergeInto, true); return await UpdateAsync(c, c.Data.MergeInto, true);
}); });
case ChangeContentStatus changeContentStatus: case ChangeContentStatus changeContentStatus:
return UpdateAsync(changeContentStatus, async c => return UpdateReturnAsync(changeContentStatus, async c =>
{ {
try try
{ {
var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, Snapshot.Id, () => "Failed to change content."); var ctx = await CreateContext(Snapshot.AppId.Id, Snapshot.SchemaId.Id, Snapshot.Id, () => "Failed to change content.");
GuardContent.CanChangeContentStatus(ctx.Schema, Snapshot.IsPending, Snapshot.Status, c); await GuardContent.CanChangeStatus(ctx.Schema, Snapshot, contentWorkflow, c);
if (c.DueTime.HasValue) if (c.DueTime.HasValue)
{ {
@ -127,17 +133,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
reason = StatusChange.Published; reason = StatusChange.Published;
} }
else if (c.Status == Status.Archived)
{
reason = StatusChange.Archived;
}
else if (Snapshot.Status == Status.Published) else if (Snapshot.Status == Status.Published)
{ {
reason = StatusChange.Unpublished; reason = StatusChange.Unpublished;
} }
else else
{ {
reason = StatusChange.Restored; reason = StatusChange.Change;
} }
await ctx.ExecuteScriptAsync(s => s.Change, reason, c, Snapshot.Data); await ctx.ExecuteScriptAsync(s => s.Change, reason, c, Snapshot.Data);
@ -157,6 +159,18 @@ namespace Squidex.Domain.Apps.Entities.Contents
throw; throw;
} }
} }
return Snapshot;
});
case DiscardChanges discardChanges:
return UpdateReturn(discardChanges, c =>
{
GuardContent.CanDiscardChanges(Snapshot.IsPending, c);
DiscardChanges(c);
return Snapshot;
}); });
case DeleteContent deleteContent: case DeleteContent deleteContent:
@ -171,14 +185,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
Delete(c); Delete(c);
}); });
case DiscardChanges discardChanges:
return UpdateAsync(discardChanges, c =>
{
GuardContent.CanDiscardChanges(Snapshot.IsPending, c);
DiscardChanges(c);
});
default: default:
throw new NotSupportedException(); throw new NotSupportedException();
} }
@ -220,16 +226,16 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
return new ContentDataChangedResult(newData, Version); return Snapshot;
} }
public void Create(CreateContent command) public void Create(CreateContent command, Status status)
{ {
RaiseEvent(SimpleMapper.Map(command, new ContentCreated())); RaiseEvent(SimpleMapper.Map(command, new ContentCreated { Status = status }));
if (command.Publish) if (command.Publish)
{ {
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published })); RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published }));
} }
} }
@ -268,9 +274,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value })); RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value }));
} }
public void ChangeStatus(ChangeContentStatus command, StatusChange reason) public void ChangeStatus(ChangeContentStatus command, StatusChange change)
{ {
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = reason })); RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = change }));
} }
private void RaiseEvent(SchemaEvent @event) private void RaiseEvent(SchemaEvent @event)

62
src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs

@ -33,10 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class ContentQueryService : IContentQueryService public sealed class ContentQueryService : IContentQueryService
{ {
private static readonly Status[] StatusAll = { Status.Archived, Status.Draft, Status.Published };
private static readonly Status[] StatusArchived = { Status.Archived };
private static readonly Status[] StatusPublishedOnly = { Status.Published }; private static readonly Status[] StatusPublishedOnly = { Status.Published };
private static readonly Status[] StatusPublishedDraft = { Status.Published, Status.Draft };
private readonly IContentRepository contentRepository; private readonly IContentRepository contentRepository;
private readonly IContentVersionLoader contentVersionLoader; private readonly IContentVersionLoader contentVersionLoader;
private readonly IAppProvider appProvider; private readonly IAppProvider appProvider;
@ -76,16 +73,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.scriptEngine = scriptEngine; this.scriptEngine = scriptEngine;
} }
public Task ThrowIfSchemaNotExistsAsync(QueryContext context, string schemaIdOrName)
{
return GetSchemaAsync(context, schemaIdOrName);
}
public async Task<IContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = -1) public async Task<IContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = -1)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
var schema = await GetSchemaAsync(context, schemaIdOrName); var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName);
CheckPermission(context.User, schema); CheckPermission(context.User, schema);
@ -93,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var isVersioned = version > EtagVersion.Empty; var isVersioned = version > EtagVersion.Empty;
var status = GetFindStatus(context); var status = GetStatus(context);
var content = var content =
isVersioned ? isVersioned ?
@ -113,13 +105,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
var schema = await GetSchemaAsync(context, schemaIdOrName); var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName);
CheckPermission(context.User, schema); CheckPermission(context.User, schema);
using (Profiler.TraceMethod<ContentQueryService>()) using (Profiler.TraceMethod<ContentQueryService>())
{ {
var status = GetQueryStatus(context); var status = GetStatus(context);
IResultList<IContentEntity> contents; IResultList<IContentEntity> contents;
@ -139,13 +131,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
public async Task<IList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids) public async Task<IReadOnlyList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids)
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
using (Profiler.TraceMethod<ContentQueryService>()) using (Profiler.TraceMethod<ContentQueryService>())
{ {
var status = GetQueryStatus(context); var status = GetStatus(context);
List<IContentEntity> result; List<IContentEntity> result;
@ -217,7 +209,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
result.Data = result.Data.ConvertName2Name(schema.SchemaDef, converters); result.Data = result.Data.ConvertName2Name(schema.SchemaDef, converters);
} }
if (result.DataDraft != null && (context.ApiStatus == StatusForApi.PublishedDraft || context.IsFrontendClient)) if (result.DataDraft != null && (context.ApiStatus == StatusForApi.All || context.IsFrontendClient))
{ {
result.DataDraft = result.DataDraft.ConvertName2Name(schema.SchemaDef, converters); result.DataDraft = result.DataDraft.ConvertName2Name(schema.SchemaDef, converters);
} }
@ -298,7 +290,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
public async Task<ISchemaEntity> GetSchemaAsync(QueryContext context, string schemaIdOrName) public async Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName)
{ {
ISchemaEntity schema = null; ISchemaEntity schema = null;
@ -340,15 +332,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return permissions.Allows(permission); return permissions.Allows(permission);
} }
private static Status[] GetFindStatus(QueryContext context) private static Status[] GetStatus(QueryContext context)
{ {
if (context.IsFrontendClient) if (context.IsFrontendClient || context.ApiStatus == StatusForApi.All)
{
return StatusAll;
}
else if (context.ApiStatus == StatusForApi.PublishedDraft)
{ {
return StatusPublishedDraft; return null;
} }
else else
{ {
@ -356,32 +344,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
} }
} }
private static Status[] GetQueryStatus(QueryContext context)
{
if (context.IsFrontendClient)
{
switch (context.FrontendStatus)
{
case StatusForFrontend.Archived:
return StatusArchived;
case StatusForFrontend.PublishedOnly:
return StatusPublishedOnly;
default:
return StatusPublishedDraft;
}
}
else
{
switch (context.ApiStatus)
{
case StatusForApi.PublishedDraft:
return StatusPublishedDraft;
default:
return StatusPublishedOnly;
}
}
}
private Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids, Status[] status) private Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids, Status[] status)
{ {
return contentRepository.QueryAsync(context.App, status, new HashSet<Guid>(ids), ShouldIncludeDraft(context)); return contentRepository.QueryAsync(context.App, status, new HashSet<Guid>(ids), ShouldIncludeDraft(context));
@ -409,7 +371,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
private static bool ShouldIncludeDraft(QueryContext context) private static bool ShouldIncludeDraft(QueryContext context)
{ {
return context.ApiStatus == StatusForApi.PublishedDraft || context.IsFrontendClient; return context.ApiStatus == StatusForApi.All || context.IsFrontendClient;
} }
} }
} }

54
src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs

@ -0,0 +1,54 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class DefaultContentWorkflow : IContentWorkflow
{
private static readonly Status[] All = { Status.Archived, Status.Draft, Status.Published };
private static readonly Dictionary<Status, Status[]> Flow = new Dictionary<Status, Status[]>
{
[Status.Draft] = new[] { Status.Archived, Status.Published },
[Status.Archived] = new[] { Status.Draft },
[Status.Published] = new[] { Status.Draft, Status.Archived }
};
public Task<Status> GetInitialStatusAsync(ISchemaEntity schema)
{
return Task.FromResult(Status.Draft);
}
public Task<bool> CanMoveToAsync(IContentEntity content, Status next)
{
return Task.FromResult(Flow.TryGetValue(content.Status, out var state) && state.Contains(next));
}
public Task<bool> CanUpdateAsync(IContentEntity content)
{
return Task.FromResult(content.Status != Status.Archived);
}
public Task<Status[]> GetNextsAsync(IContentEntity content)
{
return Task.FromResult(Flow.TryGetValue(content.Status, out var result) ? result : Array.Empty<Status>());
}
public Task<Status[]> GetAllAsync(ISchemaEntity schema)
{
return Task.FromResult(All);
}
}
}

41
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs

@ -8,6 +8,7 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using GraphQL;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
@ -18,28 +19,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
public sealed class CachingGraphQLService : CachingProviderBase, IGraphQLService public sealed class CachingGraphQLService : CachingProviderBase, IGraphQLService
{ {
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10); private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
private readonly IContentQueryService contentQuery; private readonly IDependencyResolver resolver;
private readonly IGraphQLUrlGenerator urlGenerator;
private readonly IAssetQueryService assetQuery; public CachingGraphQLService(IMemoryCache cache, IDependencyResolver resolver)
private readonly IAppProvider appProvider;
public CachingGraphQLService(
IMemoryCache cache,
IAppProvider appProvider,
IAssetQueryService assetQuery,
IContentQueryService contentQuery,
IGraphQLUrlGenerator urlGenerator)
: base(cache) : base(cache)
{ {
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(resolver, nameof(resolver));
Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(contentQuery, nameof(contentQuery)); this.resolver = resolver;
Guard.NotNull(urlGenerator, nameof(urlGenerator));
this.appProvider = appProvider;
this.assetQuery = assetQuery;
this.contentQuery = contentQuery;
this.urlGenerator = urlGenerator;
} }
public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries) public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries)
@ -49,7 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var model = await GetModelAsync(context.App); var model = await GetModelAsync(context.App);
var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator); var ctx = new GraphQLExecutionContext(context, resolver);
var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, ctx, q))); var result = await Task.WhenAll(queries.Select(q => QueryInternalAsync(model, ctx, q)));
@ -63,14 +50,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var model = await GetModelAsync(context.App); var model = await GetModelAsync(context.App);
var ctx = new GraphQLExecutionContext(context, assetQuery, contentQuery, urlGenerator); var ctx = new GraphQLExecutionContext(context, resolver);
var result = await QueryInternalAsync(model, ctx, query); var result = await QueryInternalAsync(model, ctx, query);
return result; return result;
} }
private static async Task<(bool HasError, object Response)> QueryInternalAsync(GraphQLModel model, GraphQLExecutionContext ctx, GraphQLQuery query) private async Task<(bool HasError, object Response)> QueryInternalAsync(GraphQLModel model, GraphQLExecutionContext ctx, GraphQLQuery query)
{ {
if (string.IsNullOrWhiteSpace(query.Query)) if (string.IsNullOrWhiteSpace(query.Query))
{ {
@ -97,9 +84,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{ {
entry.AbsoluteExpirationRelativeToNow = CacheDuration; entry.AbsoluteExpirationRelativeToNow = CacheDuration;
var allSchemas = await appProvider.GetSchemasAsync(app.Id); var allSchemas = await resolver.Resolve<IAppProvider>().GetSchemasAsync(app.Id);
return new GraphQLModel(app, allSchemas, contentQuery.DefaultPageSizeGraphQl, assetQuery.DefaultPageSizeGraphQl, urlGenerator); return new GraphQLModel(app,
allSchemas,
resolver.Resolve<IContentQueryService>().DefaultPageSizeGraphQl,
resolver.Resolve<IAssetQueryService>().DefaultPageSizeGraphQl,
resolver.Resolve<IGraphQLUrlGenerator>());
}); });
} }

99
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs

@ -7,37 +7,114 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using GraphQL;
using GraphQL.DataLoader;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types;
using Squidex.Infrastructure.Json.Objects; using Squidex.Infrastructure.Json.Objects;
using Squidex.Infrastructure.Log;
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<IAssetEntity> EmptyAssets = new List<IAssetEntity>();
private static readonly List<IContentEntity> EmptyContents = new List<IContentEntity>();
private readonly IDataLoaderContextAccessor dataLoaderContextAccessor;
private readonly IDependencyResolver resolver;
public IGraphQLUrlGenerator UrlGenerator { get; } public IGraphQLUrlGenerator UrlGenerator { get; }
public GraphQLExecutionContext(QueryContext context, public ISemanticLog Log { get; }
IAssetQueryService assetQuery,
IContentQueryService contentQuery, public GraphQLExecutionContext(QueryContext context, IDependencyResolver resolver)
IGraphQLUrlGenerator urlGenerator) : base(context,
: base(context, assetQuery, contentQuery) resolver.Resolve<IAssetQueryService>(),
resolver.Resolve<IContentQueryService>())
{
UrlGenerator = resolver.Resolve<IGraphQLUrlGenerator>();
dataLoaderContextAccessor = resolver.Resolve<IDataLoaderContextAccessor>();
this.resolver = resolver;
}
public void Setup(ExecutionOptions execution)
{
var loader = resolver.Resolve<DataLoaderDocumentListener>();
var logger = LoggingMiddleware.Create(resolver.Resolve<ISemanticLog>());
execution.Listeners.Add(loader);
execution.FieldMiddleware.Use(logger);
execution.UserContext = this;
}
public override Task<IAssetEntity> FindAssetAsync(Guid id)
{ {
UrlGenerator = urlGenerator; var dataLoader = GetAssetsLoader();
return dataLoader.LoadAsync(id);
}
public override Task<IContentEntity> FindContentAsync(Guid schemaId, Guid id)
{
var dataLoader = GetContentsLoader(schemaId);
return dataLoader.LoadAsync(id);
} }
public Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(IJsonValue value) public async Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(IJsonValue value)
{ {
var ids = ParseIds(value); var ids = ParseIds(value);
return GetReferencedAssetsAsync(ids); if (ids == null)
{
return EmptyAssets;
}
var dataLoader = GetAssetsLoader();
return await dataLoader.LoadManyAsync(ids);
} }
public Task<IReadOnlyList<IContentEntity>> GetReferencedContentsAsync(Guid schemaId, IJsonValue value) public async Task<IReadOnlyList<IContentEntity>> GetReferencedContentsAsync(Guid schemaId, IJsonValue value)
{ {
var ids = ParseIds(value); var ids = ParseIds(value);
return GetReferencedContentsAsync(schemaId, ids); if (ids == null)
{
return EmptyContents;
}
var dataLoader = GetContentsLoader(schemaId);
return await dataLoader.LoadManyAsync(ids);
}
private IDataLoader<Guid, IAssetEntity> GetAssetsLoader()
{
return dataLoaderContextAccessor.Context.GetOrAddBatchLoader<Guid, IAssetEntity>("Assets",
async batch =>
{
var result = await GetReferencedAssetsAsync(new List<Guid>(batch));
return result.ToDictionary(x => x.Id);
});
}
private IDataLoader<Guid, IContentEntity> GetContentsLoader(Guid schemaId)
{
return dataLoaderContextAccessor.Context.GetOrAddBatchLoader<Guid, IContentEntity>($"Schema_{schemaId}",
async batch =>
{
var result = await GetReferencedContentsAsync(schemaId, new List<Guid>(batch));
return result.ToDictionary(x => x.Id);
});
} }
private static ICollection<Guid> ParseIds(IJsonValue value) private static ICollection<Guid> ParseIds(IJsonValue value)
@ -58,7 +135,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
} }
catch catch
{ {
return new List<Guid>(); return null;
} }
} }
} }

18
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLModel.cs

@ -135,9 +135,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return partitionResolver(key); return partitionResolver(key);
} }
public (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field) public (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field, string fieldName)
{ {
return field.Accept(new QueryGraphTypeVisitor(schema, GetContentType, this, assetListType)); return field.Accept(new QueryGraphTypeVisitor(schema, GetContentType, this, assetListType, fieldName));
} }
public IGraphType GetAssetType() public IGraphType GetAssetType()
@ -175,15 +175,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{ {
Guard.NotNull(context, nameof(context)); Guard.NotNull(context, nameof(context));
var inputs = query.Variables?.ToInputs(); var result = await new DocumentExecuter().ExecuteAsync(execution =>
var result = await new DocumentExecuter().ExecuteAsync(options =>
{ {
options.OperationName = query.OperationName; context.Setup(execution);
options.UserContext = context;
options.Schema = graphQLSchema; execution.Schema = graphQLSchema;
options.Inputs = inputs; execution.Inputs = query.Variables?.ToInputs();
options.Query = query.Query; execution.Query = query.Query;
}).ConfigureAwait(false); }).ConfigureAwait(false);
return (result.Data, result.Errors?.Select(x => (object)new { x.Message, x.Locations }).ToArray()); return (result.Data, result.Errors?.Select(x => (object)new { x.Message, x.Locations }).ToArray());

2
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphModel.cs

@ -35,6 +35,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
IGraphType GetContentDataType(Guid schemaId); IGraphType GetContentDataType(Guid schemaId);
(IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field); (IGraphType ResolveType, ValueResolver Resolver) GetGraphType(ISchemaEntity schema, IField field, string fieldName);
} }
} }

42
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/LoggingMiddleware.cs

@ -0,0 +1,42 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using GraphQL.Instrumentation;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{
public static class LoggingMiddleware
{
public static Func<FieldMiddlewareDelegate, FieldMiddlewareDelegate> Create(ISemanticLog log)
{
Guard.NotNull(log, nameof(log));
return new Func<FieldMiddlewareDelegate, FieldMiddlewareDelegate>(next =>
{
return async context =>
{
try
{
return await next(context);
}
catch (Exception ex)
{
log.LogWarning(ex, w => w
.WriteProperty("action", "reolveField")
.WriteProperty("status", "failed")
.WriteProperty("field", context.FieldName));
throw ex;
}
};
});
}
}
}

4
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AllTypes.cs

@ -28,8 +28,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
public static readonly IGraphType Float = new FloatGraphType(); public static readonly IGraphType Float = new FloatGraphType();
public static readonly IGraphType Status = new EnumerationGraphType<Status>();
public static readonly IGraphType String = new StringGraphType(); public static readonly IGraphType String = new StringGraphType();
public static readonly IGraphType Boolean = new BooleanGraphType(); public static readonly IGraphType Boolean = new BooleanGraphType();
@ -46,8 +44,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
public static readonly IGraphType NonNullBoolean = new NonNullGraphType(Boolean); public static readonly IGraphType NonNullBoolean = new NonNullGraphType(Boolean);
public static readonly IGraphType NonNullStatusType = new NonNullGraphType(Status);
public static readonly IGraphType NoopDate = new NoopGraphType(Date); public static readonly IGraphType NoopDate = new NoopGraphType(Date);
public static readonly IGraphType NoopJson = new NoopGraphType(Json); public static readonly IGraphType NoopJson = new NoopGraphType(Json);

59
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentDataGraphType.cs

@ -25,18 +25,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
Name = $"{schemaType}DataDto"; Name = $"{schemaType}DataDto";
foreach (var field in schema.SchemaDef.Fields.ForApi()) foreach (var (field, fieldName, typeName) in schema.SchemaDef.Fields.SafeFields())
{ {
var (resolvedType, valueResolver) = model.GetGraphType(schema, field); var (resolvedType, valueResolver) = model.GetGraphType(schema, field, fieldName);
if (valueResolver != null) if (valueResolver != null)
{ {
var fieldType = field.TypeName(); var displayName = field.DisplayName();
var fieldName = field.DisplayName();
var fieldGraphType = new ObjectGraphType var fieldGraphType = new ObjectGraphType
{ {
Name = $"{schemaType}Data{fieldType}Dto" Name = $"{schemaType}Data{typeName}Dto"
}; };
var partition = model.ResolvePartition(field.Partitioning); var partition = model.ResolvePartition(field.Partitioning);
@ -45,45 +44,51 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{ {
var key = partitionItem.Key; var key = partitionItem.Key;
var partitionResolver = new FuncFieldResolver<object>(c =>
{
if (((ContentFieldData)c.Source).TryGetValue(key, out var value))
{
return valueResolver(value, c);
}
else
{
return null;
}
});
fieldGraphType.AddField(new FieldType fieldGraphType.AddField(new FieldType
{ {
Name = key.EscapePartition(), Name = key.EscapePartition(),
Resolver = partitionResolver, Resolver = PartitionResolver(valueResolver, key),
ResolvedType = resolvedType, ResolvedType = resolvedType,
Description = field.RawProperties.Hints Description = field.RawProperties.Hints
}); });
} }
fieldGraphType.Description = $"The structure of the {fieldName} field of the {schemaName} content type."; fieldGraphType.Description = $"The structure of the {displayName} field of the {schemaName} content type.";
var fieldResolver = new FuncFieldResolver<NamedContentData, IReadOnlyDictionary<string, IJsonValue>>(c =>
{
return c.Source.GetOrDefault(field.Name);
});
AddField(new FieldType AddField(new FieldType
{ {
Name = field.Name.ToCamelCase(), Name = fieldName,
Resolver = fieldResolver, Resolver = FieldResolver(field),
ResolvedType = fieldGraphType, ResolvedType = fieldGraphType,
Description = $"The {fieldName} field." Description = $"The {displayName} field."
}); });
} }
} }
Description = $"The structure of the {schemaName} content type."; Description = $"The structure of the {schemaName} content type.";
} }
private static FuncFieldResolver<object> PartitionResolver(ValueResolver valueResolver, string key)
{
return new FuncFieldResolver<object>(c =>
{
if (((ContentFieldData)c.Source).TryGetValue(key, out var value))
{
return valueResolver(value, c);
}
else
{
return null;
}
});
}
private static FuncFieldResolver<NamedContentData, IReadOnlyDictionary<string, IJsonValue>> FieldResolver(RootField field)
{
return new FuncFieldResolver<NamedContentData, IReadOnlyDictionary<string, IJsonValue>>(c =>
{
return c.Source.GetOrDefault(field.Name);
});
}
} }
} }

4
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ContentGraphType.cs

@ -73,8 +73,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
AddField(new FieldType AddField(new FieldType
{ {
Name = "status", Name = "status",
ResolvedType = AllTypes.NonNullStatusType, ResolvedType = AllTypes.NonNullString,
Resolver = Resolve(x => x.Status), Resolver = Resolve(x => x.Status.Name.ToUpperInvariant()),
Description = $"The the status of the {schemaName} content." Description = $"The the status of the {schemaName} content."
}); });

50
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Extensions.cs

@ -0,0 +1,50 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using GraphQL.DataLoader;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{
public static class Extensions
{
public static IEnumerable<(T Field, string Name, string Type)> SafeFields<T>(this IEnumerable<T> fields) where T : IField
{
var allFields =
fields.ForApi()
.Select(f => (Field: f, Name: f.Name.ToCamelCase(), Type: f.TypeName())).GroupBy(x => x.Name)
.Select(g =>
{
return g.Select((f, i) => (f.Field, f.Name.SafeString(i), f.Type.SafeString(i)));
})
.SelectMany(x => x);
return allFields;
}
private static string SafeString(this string value, int index)
{
if (index > 0)
{
return value + (index + 1);
}
return value;
}
public static async Task<IReadOnlyList<T>> LoadManyAsync<TKey, T>(this IDataLoader<TKey, T> dataLoader, ICollection<TKey> keys) where T : class
{
var contents = await Task.WhenAll(keys.Select(x => dataLoader.LoadAsync(x)));
return contents.Where(x => x != null).ToList();
}
}
}

41
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/NestedGraphType.cs

@ -16,44 +16,49 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
{ {
public sealed class NestedGraphType : ObjectGraphType<JsonObject> public sealed class NestedGraphType : ObjectGraphType<JsonObject>
{ {
public NestedGraphType(IGraphModel model, ISchemaEntity schema, IArrayField field) public NestedGraphType(IGraphModel model, ISchemaEntity schema, IArrayField field, string fieldName)
{ {
var schemaType = schema.TypeName(); var schemaType = schema.TypeName();
var schemaName = schema.DisplayName(); var schemaName = schema.DisplayName();
var fieldName = field.DisplayName(); var fieldDisplayName = field.DisplayName();
Name = $"{schemaType}{fieldName}ChildDto"; Name = $"{schemaType}{fieldName}ChildDto";
foreach (var nestedField in field.Fields.ForApi()) foreach (var (nestedField, nestedName, _) in field.Fields.SafeFields())
{ {
var fieldInfo = model.GetGraphType(schema, nestedField); var fieldInfo = model.GetGraphType(schema, nestedField, nestedName);
if (fieldInfo.ResolveType != null) if (fieldInfo.ResolveType != null)
{ {
var resolver = new FuncFieldResolver<object>(c => var resolver = ValueResolver(nestedField, fieldInfo);
{
if (((JsonObject)c.Source).TryGetValue(nestedField.Name, out var value))
{
return fieldInfo.Resolver(value, c);
}
else
{
return fieldInfo;
}
});
AddField(new FieldType AddField(new FieldType
{ {
Name = nestedField.Name.ToCamelCase(), Name = nestedName,
Resolver = resolver, Resolver = resolver,
ResolvedType = fieldInfo.ResolveType, ResolvedType = fieldInfo.ResolveType,
Description = $"The {fieldName}/{nestedField.DisplayName()} nested field." Description = $"The {fieldDisplayName}/{nestedField.DisplayName()} nested field."
}); });
} }
} }
Description = $"The structure of the {schemaName}.{fieldName} nested schema."; Description = $"The structure of the {schemaName}.{fieldDisplayName} nested schema.";
}
private static FuncFieldResolver<object> ValueResolver(NestedField nestedField, (IGraphType ResolveType, ValueResolver Resolver) fieldInfo)
{
return new FuncFieldResolver<object>(c =>
{
if (((JsonObject)c.Source).TryGetValue(nestedField.Name, out var value))
{
return fieldInfo.Resolver(value, c);
}
else
{
return fieldInfo;
}
});
} }
} }
} }

6
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/QueryGraphTypeVisitor.cs

@ -22,13 +22,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
private readonly Func<Guid, IGraphType> schemaResolver; private readonly Func<Guid, IGraphType> schemaResolver;
private readonly IGraphModel model; private readonly IGraphModel model;
private readonly IGraphType assetListType; private readonly IGraphType assetListType;
private readonly string fieldName;
public QueryGraphTypeVisitor(ISchemaEntity schema, Func<Guid, IGraphType> schemaResolver, IGraphModel model, IGraphType assetListType) public QueryGraphTypeVisitor(ISchemaEntity schema, Func<Guid, IGraphType> schemaResolver, IGraphModel model, IGraphType assetListType, string fieldName)
{ {
this.model = model; this.model = model;
this.assetListType = assetListType; this.assetListType = assetListType;
this.schema = schema; this.schema = schema;
this.schemaResolver = schemaResolver; this.schemaResolver = schemaResolver;
this.fieldName = fieldName;
} }
public (IGraphType ResolveType, ValueResolver Resolver) Visit(IArrayField field) public (IGraphType ResolveType, ValueResolver Resolver) Visit(IArrayField field)
@ -93,7 +95,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
private (IGraphType ResolveType, ValueResolver Resolver) ResolveNested(IArrayField field) private (IGraphType ResolveType, ValueResolver Resolver) ResolveNested(IArrayField field)
{ {
var schemaFieldType = new ListGraphType(new NonNullGraphType(new NestedGraphType(model, schema, field))); var schemaFieldType = new ListGraphType(new NonNullGraphType(new NestedGraphType(model, schema, field, this.fieldName)));
return (schemaFieldType, NoopResolver); return (schemaFieldType, NoopResolver);
} }

35
src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Threading.Tasks;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core.Contents; using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Commands;
@ -30,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
} }
} }
public static void CanUpdate(UpdateContent command) public static async Task CanUpdate(IContentEntity content, IContentWorkflow contentWorkflow, UpdateContent command)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
@ -38,9 +39,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{ {
ValidateData(command, e); ValidateData(command, e);
}); });
await ValidateCanUpdate(content, contentWorkflow);
} }
public static void CanPatch(PatchContent command) public static async Task CanPatch(IContentEntity content, IContentWorkflow contentWorkflow, PatchContent command)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
@ -48,6 +51,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
{ {
ValidateData(command, e); ValidateData(command, e);
}); });
await ValidateCanUpdate(content, contentWorkflow);
} }
public static void CanDiscardChanges(bool isPending, DiscardChanges command) public static void CanDiscardChanges(bool isPending, DiscardChanges command)
@ -60,33 +65,29 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
} }
} }
public static void CanChangeContentStatus(ISchemaEntity schema, bool isPending, Status status, ChangeContentStatus command) public static Task CanChangeStatus(ISchemaEntity schema, IContentEntity content, IContentWorkflow contentWorkflow, ChangeContentStatus command)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
if (schema.SchemaDef.IsSingleton && command.Status != Status.Published) if (schema.SchemaDef.IsSingleton && command.Status != Status.Published)
{ {
throw new DomainException("Singleton content archived or unpublished."); throw new DomainException("Singleton content cannot be changed.");
} }
Validate.It(() => "Cannot change status.", e => return Validate.It(() => "Cannot change status.", async e =>
{ {
if (!StatusFlow.Exists(command.Status)) if (!await contentWorkflow.CanMoveToAsync(content, command.Status))
{ {
e(Not.Valid("Status"), nameof(command.Status)); if (content.Status == command.Status && content.Status == Status.Published)
}
else if (!StatusFlow.CanChange(status, command.Status))
{
if (status == command.Status && status == Status.Published)
{ {
if (!isPending) if (!content.IsPending)
{ {
e("Content has no changes to publish.", nameof(command.Status)); e("Content has no changes to publish.", nameof(command.Status));
} }
} }
else else
{ {
e($"Cannot change status from {status} to {command.Status}.", nameof(command.Status)); e($"Cannot change status from {content.Status} to {command.Status}.", nameof(command.Status));
} }
} }
@ -114,5 +115,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
e(Not.Defined("Data"), nameof(command.Data)); e(Not.Defined("Data"), nameof(command.Data));
} }
} }
private static async Task ValidateCanUpdate(IContentEntity content, IContentWorkflow contentWorkflow)
{
if (!await contentWorkflow.CanUpdateAsync(content))
{
throw new DomainException($"The workflow does not allow updates at status {content.Status}");
}
}
} }
} }

5
src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs

@ -8,6 +8,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
@ -16,12 +17,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
int DefaultPageSizeGraphQl { get; } int DefaultPageSizeGraphQl { get; }
Task<IList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids); Task<IReadOnlyList<IContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids);
Task<IResultList<IContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query); Task<IResultList<IContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query);
Task<IContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any); Task<IContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any);
Task ThrowIfSchemaNotExistsAsync(QueryContext context, string schemaIdOrName); Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName);
} }
} }

26
src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IContentWorkflow
{
Task<Status> GetInitialStatusAsync(ISchemaEntity schema);
Task<bool> CanMoveToAsync(IContentEntity content, Status next);
Task<bool> CanUpdateAsync(IContentEntity content);
Task<Status[]> GetNextsAsync(IContentEntity content);
Task<Status[]> GetAllAsync(ISchemaEntity schema);
}
}

12
src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs

@ -34,7 +34,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.context = context; this.context = context;
} }
public async Task<IAssetEntity> FindAssetAsync(Guid id) public virtual async Task<IAssetEntity> FindAssetAsync(Guid id)
{ {
var asset = cachedAssets.GetOrDefault(id); var asset = cachedAssets.GetOrDefault(id);
@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return asset; return asset;
} }
public async Task<IContentEntity> FindContentAsync(Guid schemaId, Guid id) public virtual async Task<IContentEntity> FindContentAsync(Guid schemaId, Guid id)
{ {
var content = cachedContents.GetOrDefault(id); var content = cachedContents.GetOrDefault(id);
@ -68,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return content; return content;
} }
public async Task<IResultList<IAssetEntity>> QueryAssetsAsync(string query) public virtual async Task<IResultList<IAssetEntity>> QueryAssetsAsync(string query)
{ {
var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(query)); var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(query));
@ -80,7 +80,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return assets; return assets;
} }
public async Task<IResultList<IContentEntity>> QueryContentsAsync(string schemaIdOrName, string query) public virtual async Task<IResultList<IContentEntity>> QueryContentsAsync(string schemaIdOrName, string query)
{ {
var result = await contentQuery.QueryAsync(context, schemaIdOrName, Q.Empty.WithODataQuery(query)); var result = await contentQuery.QueryAsync(context, schemaIdOrName, Q.Empty.WithODataQuery(query));
@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return result; return result;
} }
public async Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(ICollection<Guid> ids) public virtual async Task<IReadOnlyList<IAssetEntity>> GetReferencedAssetsAsync(ICollection<Guid> ids)
{ {
Guard.NotNull(ids, nameof(ids)); Guard.NotNull(ids, nameof(ids));
@ -111,7 +111,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return ids.Select(cachedAssets.GetOrDefault).Where(x => x != null).ToList(); return ids.Select(cachedAssets.GetOrDefault).Where(x => x != null).ToList();
} }
public async Task<IReadOnlyList<IContentEntity>> GetReferencedContentsAsync(Guid schemaId, ICollection<Guid> ids) public virtual async Task<IReadOnlyList<IContentEntity>> GetReferencedContentsAsync(Guid schemaId, ICollection<Guid> ids)
{ {
Guard.NotNull(ids, nameof(ids)); Guard.NotNull(ids, nameof(ids));

10
src/Squidex.Domain.Apps.Entities/Contents/ScheduleJob.cs

@ -22,17 +22,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
public Instant DueTime { get; } public Instant DueTime { get; }
public ScheduleJob(Guid id, Status status, RefToken by, Instant due) public ScheduleJob(Guid id, Status status, RefToken scheduledBy, Instant dueTime)
{ {
Id = id; Id = id;
ScheduledBy = by; ScheduledBy = scheduledBy;
Status = status; Status = status;
DueTime = due; DueTime = dueTime;
} }
public static ScheduleJob Build(Status status, RefToken by, Instant due) public static ScheduleJob Build(Status status, RefToken scheduledBy, Instant dueTime)
{ {
return new ScheduleJob(Guid.NewGuid(), status, by, due); return new ScheduleJob(Guid.NewGuid(), status, scheduledBy, dueTime);
} }
} }
} }

5
src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs

@ -50,6 +50,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
SimpleMapper.Map(@event, this); SimpleMapper.Map(@event, this);
UpdateData(null, @event.Data, false); UpdateData(null, @event.Data, false);
if (Status == default)
{
Status = Status.Draft;
}
} }
protected void On(ContentChangesPublished @event) protected void On(ContentChangesPublished @event)

2
src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs

@ -10,6 +10,6 @@ namespace Squidex.Domain.Apps.Entities.Contents
public enum StatusForApi public enum StatusForApi
{ {
PublishedOnly, PublishedOnly,
PublishedDraft, All,
} }
} }

21
src/Squidex.Domain.Apps.Entities/EntityExtensions.cs

@ -0,0 +1,21 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities
{
public static class EntityExtensions
{
public static NamedId<Guid> NamedId(this IAppEntity entity)
{
return new NamedId<Guid>(entity.Id, entity.Name);
}
}
}

5
src/Squidex.Domain.Apps.Entities/Q.cs

@ -25,6 +25,11 @@ namespace Squidex.Domain.Apps.Entities
return Clone(c => c.ODataQuery = odataQuery); return Clone(c => c.ODataQuery = odataQuery);
} }
public Q WithIds(params Guid[] ids)
{
return Clone(c => c.Ids = ids.ToList());
}
public Q WithIds(IEnumerable<Guid> ids) public Q WithIds(IEnumerable<Guid> ids)
{ {
return Clone(c => c.Ids = ids.ToList()); return Clone(c => c.Ids = ids.ToList());

19
src/Squidex.Domain.Apps.Entities/QueryContext.cs

@ -27,8 +27,6 @@ namespace Squidex.Domain.Apps.Entities
public StatusForApi ApiStatus { get; private set; } public StatusForApi ApiStatus { get; private set; }
public StatusForFrontend FrontendStatus { get; private set; }
public IReadOnlyCollection<string> AssetUrlsToResolve { get; private set; } public IReadOnlyCollection<string> AssetUrlsToResolve { get; private set; }
public IReadOnlyCollection<Language> Languages { get; private set; } public IReadOnlyCollection<Language> Languages { get; private set; }
@ -49,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities
public QueryContext WithUnpublished(bool unpublished) public QueryContext WithUnpublished(bool unpublished)
{ {
return WithApiStatus(unpublished ? StatusForApi.PublishedDraft : StatusForApi.PublishedOnly); return WithApiStatus(unpublished ? StatusForApi.All : StatusForApi.PublishedOnly);
} }
public QueryContext WithApiStatus(StatusForApi status) public QueryContext WithApiStatus(StatusForApi status)
@ -57,21 +55,6 @@ namespace Squidex.Domain.Apps.Entities
return Clone(c => c.ApiStatus = status); return Clone(c => c.ApiStatus = status);
} }
public QueryContext WithFrontendStatus(StatusForFrontend status)
{
return Clone(c => c.FrontendStatus = status);
}
public QueryContext WithFrontendStatus(string status)
{
if (status != null && Enum.TryParse<StatusForFrontend>(status, out var result))
{
return WithFrontendStatus(result);
}
return this;
}
public QueryContext WithAssetUrlsToResolve(IEnumerable<string> fieldNames) public QueryContext WithAssetUrlsToResolve(IEnumerable<string> fieldNames)
{ {
if (fieldNames != null) if (fieldNames != null)

18
src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs

@ -41,35 +41,43 @@ namespace Squidex.Domain.Apps.Entities.Rules
switch (command) switch (command)
{ {
case CreateRule createRule: case CreateRule createRule:
return CreateAsync(createRule, async c => return CreateReturnAsync(createRule, async c =>
{ {
await GuardRule.CanCreate(c, appProvider); await GuardRule.CanCreate(c, appProvider);
Create(c); Create(c);
return Snapshot;
}); });
case UpdateRule updateRule: case UpdateRule updateRule:
return UpdateAsync(updateRule, async c => return UpdateReturnAsync(updateRule, async c =>
{ {
await GuardRule.CanUpdate(c, Snapshot.AppId.Id, appProvider); await GuardRule.CanUpdate(c, Snapshot.AppId.Id, appProvider);
Update(c); Update(c);
return Snapshot;
}); });
case EnableRule enableRule: case EnableRule enableRule:
return UpdateAsync(enableRule, c => return UpdateReturn(enableRule, c =>
{ {
GuardRule.CanEnable(c, Snapshot.RuleDef); GuardRule.CanEnable(c, Snapshot.RuleDef);
Enable(c); Enable(c);
return Snapshot;
}); });
case DisableRule disableRule: case DisableRule disableRule:
return UpdateAsync(disableRule, c => return UpdateReturn(disableRule, c =>
{ {
GuardRule.CanDisable(c, Snapshot.RuleDef); GuardRule.CanDisable(c, Snapshot.RuleDef);
Disable(c); Disable(c);
return Snapshot;
}); });
case DeleteRule deleteRule: case DeleteRule deleteRule:
return UpdateAsync(deleteRule, c => return Update(deleteRule, c =>
{ {
GuardRule.CanDelete(deleteRule); GuardRule.CanDelete(deleteRule);

24
src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardHelper.cs

@ -12,21 +12,26 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{ {
public static class GuardHelper public static class GuardHelper
{ {
public static IArrayField GetArrayFieldOrThrow(Schema schema, long parentId) public static IArrayField GetArrayFieldOrThrow(Schema schema, long parentId, bool allowLocked)
{ {
if (!schema.FieldsById.TryGetValue(parentId, out var rootField) || !(rootField is IArrayField arrayField)) if (!schema.FieldsById.TryGetValue(parentId, out var rootField) || !(rootField is IArrayField arrayField))
{ {
throw new DomainObjectNotFoundException(parentId.ToString(), "Fields", typeof(Schema)); throw new DomainObjectNotFoundException(parentId.ToString(), "Fields", typeof(Schema));
} }
if (!allowLocked)
{
EnsureNotLocked(arrayField);
}
return arrayField; return arrayField;
} }
public static IField GetFieldOrThrow(Schema schema, long fieldId, long? parentId) public static IField GetFieldOrThrow(Schema schema, long fieldId, long? parentId, bool allowLocked)
{ {
if (parentId.HasValue) if (parentId.HasValue)
{ {
var arrayField = GetArrayFieldOrThrow(schema, parentId.Value); var arrayField = GetArrayFieldOrThrow(schema, parentId.Value, allowLocked);
if (!arrayField.FieldsById.TryGetValue(fieldId, out var nestedField)) if (!arrayField.FieldsById.TryGetValue(fieldId, out var nestedField))
{ {
@ -41,7 +46,20 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
throw new DomainObjectNotFoundException(fieldId.ToString(), "Fields", typeof(Schema)); throw new DomainObjectNotFoundException(fieldId.ToString(), "Fields", typeof(Schema));
} }
if (!allowLocked)
{
EnsureNotLocked(field);
}
return field; return field;
} }
private static void EnsureNotLocked(IField field)
{
if (field.IsLocked)
{
throw new DomainException("Schema field is locked.");
}
}
} }
} }

86
src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchema.cs

@ -56,7 +56,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
if (command.ParentFieldId.HasValue) if (command.ParentFieldId.HasValue)
{ {
arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value); arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value, false);
} }
Validate.It(() => "Cannot reorder schema fields.", error => Validate.It(() => "Cannot reorder schema fields.", error =>
@ -142,49 +142,73 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
fieldIndex++; fieldIndex++;
fieldPrefix = $"Fields[{fieldIndex}]"; fieldPrefix = $"Fields[{fieldIndex}]";
if (!field.Partitioning.IsValidPartitioning()) ValidateRootField(field, fieldPrefix, e);
{ }
e(Not.Valid("Partitioning"), $"{fieldPrefix}.{nameof(field.Partitioning)}");
}
ValidateField(field, fieldPrefix, e); if (command.Fields.Select(x => x?.Name).Distinct().Count() != command.Fields.Count)
{
e("Fields cannot have duplicate names.", nameof(command.Fields));
}
}
}
if (field.Nested?.Count > 0) private static void ValidateRootField(UpsertSchemaField field, string prefix, AddValidation e)
{ {
if (field.Properties is ArrayFieldProperties) if (field == null)
{ {
var nestedIndex = 0; e(Not.Defined("Field"), prefix);
var nestedPrefix = string.Empty; }
else
{
if (!field.Partitioning.IsValidPartitioning())
{
e(Not.Valid("Partitioning"), $"{prefix}.{nameof(field.Partitioning)}");
}
foreach (var nestedField in field.Nested) ValidateField(field, prefix, e);
{
nestedIndex++;
nestedPrefix = $"{fieldPrefix}.Nested[{nestedIndex}]";
if (nestedField.Properties is ArrayFieldProperties) if (field.Nested?.Count > 0)
{ {
e("Nested field cannot be array fields.", $"{nestedPrefix}.{nameof(nestedField.Properties)}"); if (field.Properties is ArrayFieldProperties)
} {
var nestedIndex = 0;
var nestedPrefix = string.Empty;
ValidateField(nestedField, nestedPrefix, e); foreach (var nestedField in field.Nested)
}
}
else if (field.Nested.Count > 0)
{ {
e("Only array fields can have nested fields.", $"{fieldPrefix}.{nameof(field.Partitioning)}"); nestedIndex++;
} nestedPrefix = $"{prefix}.Nested[{nestedIndex}]";
if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count) ValidateNestedField(nestedField, nestedPrefix, e);
{
e("Fields cannot have duplicate names.", $"{fieldPrefix}.Nested");
} }
} }
else if (field.Nested.Count > 0)
{
e("Only array fields can have nested fields.", $"{prefix}.{nameof(field.Partitioning)}");
}
if (field.Nested.Select(x => x.Name).Distinct().Count() != field.Nested.Count)
{
e("Fields cannot have duplicate names.", $"{prefix}.Nested");
}
} }
}
}
if (command.Fields.Select(x => x.Name).Distinct().Count() != command.Fields.Count) private static void ValidateNestedField(UpsertSchemaNestedField nestedField, string prefix, AddValidation e)
{
if (nestedField == null)
{
e(Not.Defined("Field"), prefix);
}
else
{
if (nestedField.Properties is ArrayFieldProperties)
{ {
e("Fields cannot have duplicate names.", nameof(command.Fields)); e("Nested field cannot be array fields.", $"{prefix}.{nameof(nestedField.Properties)}");
} }
ValidateField(nestedField, prefix, e);
} }
} }

50
src/Squidex.Domain.Apps.Entities/Schemas/Guards/GuardSchemaField.cs

@ -38,7 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
if (command.ParentFieldId.HasValue) if (command.ParentFieldId.HasValue)
{ {
var arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value); var arrayField = GuardHelper.GetArrayFieldOrThrow(schema, command.ParentFieldId.Value, false);
if (arrayField.FieldsByName.ContainsKey(command.Name)) if (arrayField.FieldsByName.ContainsKey(command.Name))
{ {
@ -64,12 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (field.IsLocked)
{
throw new DomainException("Schema field is already locked.");
}
Validate.It(() => "Cannot update field.", e => Validate.It(() => "Cannot update field.", e =>
{ {
@ -90,12 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (field.IsLocked)
{
throw new DomainException("Schema field is locked.");
}
if (field.IsHidden) if (field.IsHidden)
{ {
@ -108,18 +98,30 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
} }
} }
public static void CanShow(Schema schema, ShowField command)
{
Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (!field.IsHidden)
{
throw new DomainException("Schema field is already visible.");
}
}
public static void CanDisable(Schema schema, DisableField command) public static void CanDisable(Schema schema, DisableField command)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (field.IsDisabled) if (field.IsDisabled)
{ {
throw new DomainException("Schema field is already disabled."); throw new DomainException("Schema field is already disabled.");
} }
if (!field.IsForApi()) if (!field.IsForApi(true))
{ {
throw new DomainException("UI field cannot be disabled."); throw new DomainException("UI field cannot be disabled.");
} }
@ -129,7 +131,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (field.IsLocked) if (field.IsLocked)
{ {
@ -137,23 +139,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
} }
} }
public static void CanShow(Schema schema, ShowField command)
{
Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId);
if (!field.IsHidden)
{
throw new DomainException("Schema field is already visible.");
}
}
public static void CanEnable(Schema schema, EnableField command) public static void CanEnable(Schema schema, EnableField command)
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (!field.IsDisabled) if (!field.IsDisabled)
{ {
@ -165,7 +155,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards
{ {
Guard.NotNull(command, nameof(command)); Guard.NotNull(command, nameof(command));
var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId); var field = GuardHelper.GetFieldOrThrow(schema, command.FieldId, command.ParentFieldId, false);
if (field.IsLocked) if (field.IsLocked)
{ {

78
src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs

@ -48,7 +48,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
switch (command) switch (command)
{ {
case AddField addField: case AddField addField:
return UpdateAsync(addField, c => return UpdateReturn(addField, c =>
{ {
GuardSchemaField.CanAdd(Snapshot.SchemaDef, c); GuardSchemaField.CanAdd(Snapshot.SchemaDef, c);
@ -65,139 +65,171 @@ namespace Squidex.Domain.Apps.Entities.Schemas
id = ((IArrayField)Snapshot.SchemaDef.FieldsById[c.ParentFieldId.Value]).FieldsByName[c.Name].Id; id = ((IArrayField)Snapshot.SchemaDef.FieldsById[c.ParentFieldId.Value]).FieldsByName[c.Name].Id;
} }
return EntityCreatedResult.Create(id, Version); return Snapshot;
}); });
case CreateSchema createSchema: case CreateSchema createSchema:
return CreateAsync(createSchema, async c => return CreateReturnAsync(createSchema, async c =>
{ {
await GuardSchema.CanCreate(c, appProvider); await GuardSchema.CanCreate(c, appProvider);
Create(c); Create(c);
return Snapshot;
}); });
case SynchronizeSchema synchronizeSchema: case SynchronizeSchema synchronizeSchema:
return UpdateAsync(synchronizeSchema, c => return UpdateReturn(synchronizeSchema, c =>
{ {
GuardSchema.CanSynchronize(c); GuardSchema.CanSynchronize(c);
Synchronize(c); Synchronize(c);
return Snapshot;
}); });
case DeleteField deleteField: case DeleteField deleteField:
return UpdateAsync(deleteField, c => return UpdateReturn(deleteField, c =>
{ {
GuardSchemaField.CanDelete(Snapshot.SchemaDef, deleteField); GuardSchemaField.CanDelete(Snapshot.SchemaDef, deleteField);
DeleteField(c); DeleteField(c);
return Snapshot;
}); });
case LockField lockField: case LockField lockField:
return UpdateAsync(lockField, c => return UpdateReturn(lockField, c =>
{ {
GuardSchemaField.CanLock(Snapshot.SchemaDef, lockField); GuardSchemaField.CanLock(Snapshot.SchemaDef, lockField);
LockField(c); LockField(c);
return Snapshot;
}); });
case HideField hideField: case HideField hideField:
return UpdateAsync(hideField, c => return UpdateReturn(hideField, c =>
{ {
GuardSchemaField.CanHide(Snapshot.SchemaDef, c); GuardSchemaField.CanHide(Snapshot.SchemaDef, c);
HideField(c); HideField(c);
return Snapshot;
}); });
case ShowField showField: case ShowField showField:
return UpdateAsync(showField, c => return UpdateReturn(showField, c =>
{ {
GuardSchemaField.CanShow(Snapshot.SchemaDef, c); GuardSchemaField.CanShow(Snapshot.SchemaDef, c);
ShowField(c); ShowField(c);
return Snapshot;
}); });
case DisableField disableField: case DisableField disableField:
return UpdateAsync(disableField, c => return UpdateReturn(disableField, c =>
{ {
GuardSchemaField.CanDisable(Snapshot.SchemaDef, c); GuardSchemaField.CanDisable(Snapshot.SchemaDef, c);
DisableField(c); DisableField(c);
return Snapshot;
}); });
case EnableField enableField: case EnableField enableField:
return UpdateAsync(enableField, c => return UpdateReturn(enableField, c =>
{ {
GuardSchemaField.CanEnable(Snapshot.SchemaDef, c); GuardSchemaField.CanEnable(Snapshot.SchemaDef, c);
EnableField(c); EnableField(c);
return Snapshot;
}); });
case UpdateField updateField: case UpdateField updateField:
return UpdateAsync(updateField, c => return UpdateReturn(updateField, c =>
{ {
GuardSchemaField.CanUpdate(Snapshot.SchemaDef, c); GuardSchemaField.CanUpdate(Snapshot.SchemaDef, c);
UpdateField(c); UpdateField(c);
return Snapshot;
}); });
case ReorderFields reorderFields: case ReorderFields reorderFields:
return UpdateAsync(reorderFields, c => return UpdateReturn(reorderFields, c =>
{ {
GuardSchema.CanReorder(Snapshot.SchemaDef, c); GuardSchema.CanReorder(Snapshot.SchemaDef, c);
Reorder(c); Reorder(c);
return Snapshot;
}); });
case UpdateSchema updateSchema: case UpdateSchema updateSchema:
return UpdateAsync(updateSchema, c => return UpdateReturn(updateSchema, c =>
{ {
GuardSchema.CanUpdate(Snapshot.SchemaDef, c); GuardSchema.CanUpdate(Snapshot.SchemaDef, c);
Update(c); Update(c);
return Snapshot;
}); });
case PublishSchema publishSchema: case PublishSchema publishSchema:
return UpdateAsync(publishSchema, c => return UpdateReturn(publishSchema, c =>
{ {
GuardSchema.CanPublish(Snapshot.SchemaDef, c); GuardSchema.CanPublish(Snapshot.SchemaDef, c);
Publish(c); Publish(c);
return Snapshot;
}); });
case UnpublishSchema unpublishSchema: case UnpublishSchema unpublishSchema:
return UpdateAsync(unpublishSchema, c => return UpdateReturn(unpublishSchema, c =>
{ {
GuardSchema.CanUnpublish(Snapshot.SchemaDef, c); GuardSchema.CanUnpublish(Snapshot.SchemaDef, c);
Unpublish(c); Unpublish(c);
return Snapshot;
}); });
case ConfigureScripts configureScripts: case ConfigureScripts configureScripts:
return UpdateAsync(configureScripts, c => return UpdateReturn(configureScripts, c =>
{ {
GuardSchema.CanConfigureScripts(Snapshot.SchemaDef, c); GuardSchema.CanConfigureScripts(Snapshot.SchemaDef, c);
ConfigureScripts(c); ConfigureScripts(c);
return Snapshot;
}); });
case ChangeCategory changeCategory: case ChangeCategory changeCategory:
return UpdateAsync(changeCategory, c => return UpdateReturn(changeCategory, c =>
{ {
GuardSchema.CanChangeCategory(Snapshot.SchemaDef, c); GuardSchema.CanChangeCategory(Snapshot.SchemaDef, c);
ChangeCategory(c); ChangeCategory(c);
return Snapshot;
}); });
case ConfigurePreviewUrls configurePreviewUrls: case ConfigurePreviewUrls configurePreviewUrls:
return UpdateAsync(configurePreviewUrls, c => return UpdateReturn(configurePreviewUrls, c =>
{ {
GuardSchema.CanConfigurePreviewUrls(c); GuardSchema.CanConfigurePreviewUrls(c);
ConfigurePreviewUrls(c); ConfigurePreviewUrls(c);
return Snapshot;
}); });
case DeleteSchema deleteSchema: case DeleteSchema deleteSchema:
return UpdateAsync(deleteSchema, c => return Update(deleteSchema, c =>
{ {
GuardSchema.CanDelete(Snapshot.SchemaDef, c); GuardSchema.CanDelete(Snapshot.SchemaDef, c);
@ -321,7 +353,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
{ {
if (id.HasValue && Snapshot.SchemaDef.FieldsById.TryGetValue(id.Value, out var field)) if (id.HasValue && Snapshot.SchemaDef.FieldsById.TryGetValue(id.Value, out var field))
{ {
return NamedId.Of(field.Id, field.Name); return field.NamedId();
} }
return null; return null;
@ -333,13 +365,13 @@ namespace Squidex.Domain.Apps.Entities.Schemas
{ {
if (Snapshot.SchemaDef.FieldsById.TryGetValue(pc.ParentFieldId.Value, out var field)) if (Snapshot.SchemaDef.FieldsById.TryGetValue(pc.ParentFieldId.Value, out var field))
{ {
pe.ParentFieldId = NamedId.Of(field.Id, field.Name); pe.ParentFieldId = field.NamedId();
if (command is FieldCommand fc && @event is FieldEvent fe) if (command is FieldCommand fc && @event is FieldEvent fe)
{ {
if (field is IArrayField arrayField && arrayField.FieldsById.TryGetValue(fc.FieldId, out var nestedField)) if (field is IArrayField arrayField && arrayField.FieldsById.TryGetValue(fc.FieldId, out var nestedField))
{ {
fe.FieldId = NamedId.Of(nestedField.Id, nestedField.Name); fe.FieldId = nestedField.NamedId();
} }
} }
} }
@ -357,7 +389,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
{ {
if (@event.SchemaId == null) if (@event.SchemaId == null)
{ {
@event.SchemaId = NamedId.Of(Snapshot.Id, Snapshot.SchemaDef.Name); @event.SchemaId = Snapshot.NamedId();
} }
if (@event.AppId == null) if (@event.AppId == null)

2
src/Squidex.Domain.Apps.Events/Contents/ContentCreated.cs

@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Events.Contents
[EventType(nameof(ContentCreated))] [EventType(nameof(ContentCreated))]
public sealed class ContentCreated : ContentEvent public sealed class ContentCreated : ContentEvent
{ {
public Status Status { get; set; }
public NamedContentData Data { get; set; } public NamedContentData Data { get; set; }
} }
} }

2
src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs

@ -13,7 +13,7 @@ namespace Squidex.Domain.Apps.Events.Contents
[EventType(nameof(ContentStatusChanged))] [EventType(nameof(ContentStatusChanged))]
public sealed class ContentStatusChanged : ContentEvent public sealed class ContentStatusChanged : ContentEvent
{ {
public StatusChange? Change { get; set; } public StatusChange Change { get; set; }
public Status Status { get; set; } public Status Status { get; set; }
} }

16
src/Squidex.Domain.Users/UserManagerExtensions.cs

@ -115,7 +115,7 @@ namespace Squidex.Domain.Users
return result; return result;
} }
public static async Task<IdentityUser> CreateAsync(this UserManager<IdentityUser> userManager, IUserFactory factory, UserValues values) public static async Task<UserWithClaims> CreateAsync(this UserManager<IdentityUser> userManager, IUserFactory factory, UserValues values)
{ {
var user = factory.Create(values.Email); var user = factory.Create(values.Email);
@ -142,10 +142,10 @@ namespace Squidex.Domain.Users
throw; throw;
} }
return user; return await userManager.ResolveUserAsync(user);
} }
public static async Task UpdateAsync(this UserManager<IdentityUser> userManager, string id, UserValues values) public static async Task<UserWithClaims> UpdateAsync(this UserManager<IdentityUser> userManager, string id, UserValues values)
{ {
var user = await userManager.FindByIdAsync(id); var user = await userManager.FindByIdAsync(id);
@ -155,6 +155,8 @@ namespace Squidex.Domain.Users
} }
await UpdateAsync(userManager, user, values); await UpdateAsync(userManager, user, values);
return await userManager.ResolveUserAsync(user);
} }
public static async Task<IdentityResult> UpdateSafeAsync(this UserManager<IdentityUser> userManager, IdentityUser user, UserValues values) public static async Task<IdentityResult> UpdateSafeAsync(this UserManager<IdentityUser> userManager, IdentityUser user, UserValues values)
@ -193,7 +195,7 @@ namespace Squidex.Domain.Users
} }
} }
public static async Task LockAsync(this UserManager<IdentityUser> userManager, string id) public static async Task<UserWithClaims> LockAsync(this UserManager<IdentityUser> userManager, string id)
{ {
var user = await userManager.FindByIdAsync(id); var user = await userManager.FindByIdAsync(id);
@ -203,9 +205,11 @@ namespace Squidex.Domain.Users
} }
await DoChecked(() => userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)), "Cannot lock user."); await DoChecked(() => userManager.SetLockoutEndDateAsync(user, DateTimeOffset.UtcNow.AddYears(100)), "Cannot lock user.");
return await userManager.ResolveUserAsync(user);
} }
public static async Task UnlockAsync(this UserManager<IdentityUser> userManager, string id) public static async Task<UserWithClaims> UnlockAsync(this UserManager<IdentityUser> userManager, string id)
{ {
var user = await userManager.FindByIdAsync(id); var user = await userManager.FindByIdAsync(id);
@ -215,6 +219,8 @@ namespace Squidex.Domain.Users
} }
await DoChecked(() => userManager.SetLockoutEndDateAsync(user, null), "Cannot unlock user."); await DoChecked(() => userManager.SetLockoutEndDateAsync(user, null), "Cannot unlock user.");
return await userManager.ResolveUserAsync(user);
} }
private static async Task DoChecked(Func<Task<IdentityResult>> action, string message) private static async Task DoChecked(Func<Task<IdentityResult>> action, string message)

6
src/Squidex.Infrastructure.MongoDb/Assets/MongoGridFsAssetStore.cs

@ -46,6 +46,8 @@ namespace Squidex.Infrastructure.Assets
public async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default) public async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default)
{ {
Guard.NotNullOrEmpty(targetFileName, nameof(targetFileName));
try try
{ {
var sourceName = GetFileName(sourceFileName, nameof(sourceFileName)); var sourceName = GetFileName(sourceFileName, nameof(sourceFileName));
@ -63,6 +65,8 @@ namespace Squidex.Infrastructure.Assets
public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default) public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default)
{ {
Guard.NotNull(stream, nameof(stream));
try try
{ {
var name = GetFileName(fileName, nameof(fileName)); var name = GetFileName(fileName, nameof(fileName));
@ -80,6 +84,8 @@ namespace Squidex.Infrastructure.Assets
public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default)
{ {
Guard.NotNull(stream, nameof(stream));
try try
{ {
var name = GetFileName(fileName, nameof(fileName)); var name = GetFileName(fileName, nameof(fileName));

158
src/Squidex.Infrastructure/Assets/FTPAssetStore.cs

@ -0,0 +1,158 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using FluentFTP;
using Squidex.Infrastructure.Log;
namespace Squidex.Infrastructure.Assets
{
public sealed class FTPAssetStore : IAssetStore, IInitializable
{
private readonly string path;
private readonly ISemanticLog log;
private readonly Func<IFtpClient> factory;
public FTPAssetStore(Func<IFtpClient> factory, string path, ISemanticLog log)
{
Guard.NotNull(factory, nameof(factory));
Guard.NotNullOrEmpty(path, nameof(path));
Guard.NotNull(log, nameof(log));
this.factory = factory;
this.path = path;
this.log = log;
}
public string GeneratePublicUrl(string fileName)
{
return null;
}
public async Task InitializeAsync(CancellationToken ct = default)
{
using (var client = factory())
{
await client.ConnectAsync(ct);
if (!await client.DirectoryExistsAsync(path, ct))
{
await client.CreateDirectoryAsync(path, ct);
}
}
log.LogInformation(w => w
.WriteProperty("action", "FTPAssetStoreConfigured")
.WriteProperty("path", path));
}
public async Task CopyAsync(string sourceFileName, string targetFileName, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(sourceFileName, nameof(sourceFileName));
Guard.NotNullOrEmpty(targetFileName, nameof(targetFileName));
using (var client = GetFtpClient())
{
var tempPath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName());
using (var stream = new FileStream(tempPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 4096, FileOptions.DeleteOnClose))
{
await DownloadAsync(client, sourceFileName, stream, ct);
await UploadAsync(client, targetFileName, stream, false, ct);
}
}
}
public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
Guard.NotNull(stream, nameof(stream));
using (var client = GetFtpClient())
{
await DownloadAsync(client, fileName, stream, ct);
}
}
public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
Guard.NotNull(stream, nameof(stream));
using (var client = GetFtpClient())
{
await UploadAsync(client, fileName, stream, overwrite, ct);
}
}
private static async Task DownloadAsync(IFtpClient client, string fileName, Stream stream, CancellationToken ct)
{
try
{
await client.DownloadAsync(stream, fileName, token: ct);
}
catch (FtpException ex) when (IsNotFound(ex))
{
throw new AssetNotFoundException(fileName, ex);
}
}
private static async Task UploadAsync(IFtpClient client, string fileName, Stream stream, bool overwrite, CancellationToken ct)
{
if (!overwrite && await client.FileExistsAsync(fileName, ct))
{
throw new AssetAlreadyExistsException(fileName);
}
await client.UploadAsync(stream, fileName, overwrite ? FtpExists.Overwrite : FtpExists.Skip, true, null, ct);
}
public async Task DeleteAsync(string fileName)
{
Guard.NotNullOrEmpty(fileName, nameof(fileName));
using (var client = GetFtpClient())
{
try
{
await client.DeleteFileAsync(fileName);
}
catch (FtpException ex)
{
if (!IsNotFound(ex))
{
throw ex;
}
}
}
}
private IFtpClient GetFtpClient()
{
var client = factory();
client.Connect();
client.SetWorkingDirectory(path);
return client;
}
private static bool IsNotFound(Exception exception)
{
if (exception is FtpCommandException command)
{
return command.CompletionCode == "550";
}
return exception.InnerException != null && IsNotFound(exception.InnerException);
}
}
}

6
src/Squidex.Infrastructure/Assets/FolderAssetStore.cs

@ -82,7 +82,7 @@ namespace Squidex.Infrastructure.Assets
public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default) public async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default)
{ {
Guard.NotNullOrEmpty(fileName, nameof(fileName)); Guard.NotNull(stream, nameof(stream));
var file = GetFile(fileName); var file = GetFile(fileName);
@ -101,7 +101,7 @@ namespace Squidex.Infrastructure.Assets
public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) public async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default)
{ {
Guard.NotNullOrEmpty(fileName, nameof(fileName)); Guard.NotNull(stream, nameof(stream));
var file = GetFile(fileName); var file = GetFile(fileName);
@ -120,8 +120,6 @@ namespace Squidex.Infrastructure.Assets
public Task DeleteAsync(string fileName) public Task DeleteAsync(string fileName)
{ {
Guard.NotNullOrEmpty(fileName, nameof(fileName));
var file = GetFile(fileName); var file = GetFile(fileName);
file.Delete(); file.Delete();

2
src/Squidex.Infrastructure/Assets/MemoryAssetStore.cs

@ -43,6 +43,7 @@ namespace Squidex.Infrastructure.Assets
public virtual async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default) public virtual async Task DownloadAsync(string fileName, Stream stream, CancellationToken ct = default)
{ {
Guard.NotNullOrEmpty(fileName, nameof(fileName)); Guard.NotNullOrEmpty(fileName, nameof(fileName));
Guard.NotNull(stream, nameof(stream));
if (!streams.TryGetValue(fileName, out var sourceStream)) if (!streams.TryGetValue(fileName, out var sourceStream))
{ {
@ -65,6 +66,7 @@ namespace Squidex.Infrastructure.Assets
public virtual async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default) public virtual async Task UploadAsync(string fileName, Stream stream, bool overwrite = false, CancellationToken ct = default)
{ {
Guard.NotNullOrEmpty(fileName, nameof(fileName)); Guard.NotNullOrEmpty(fileName, nameof(fileName));
Guard.NotNull(stream, nameof(stream));
var memoryStream = new MemoryStream(); var memoryStream = new MemoryStream();

8
src/Squidex.Infrastructure/CollectionExtensions.cs

@ -13,6 +13,14 @@ namespace Squidex.Infrastructure
{ {
public static class CollectionExtensions public static class CollectionExtensions
{ {
public static void AddRange<T>(this ICollection<T> target, IEnumerable<T> source)
{
foreach (var value in source)
{
target.Add(value);
}
}
public static IEnumerable<T> Shuffle<T>(this IEnumerable<T> enumerable) public static IEnumerable<T> Shuffle<T>(this IEnumerable<T> enumerable)
{ {
var random = new Random(); var random = new Random();

12
src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs

@ -93,7 +93,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler, Mode.Create); return InvokeAsync(command, handler, Mode.Create);
} }
protected Task<object> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand protected Task<object> CreateReturn<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{ {
return InvokeAsync(command, handler?.ToAsync(), Mode.Create); return InvokeAsync(command, handler?.ToAsync(), Mode.Create);
} }
@ -103,7 +103,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler.ToDefault<TCommand, object>(), Mode.Create); return InvokeAsync(command, handler.ToDefault<TCommand, object>(), Mode.Create);
} }
protected Task<object> CreateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand protected Task<object> Create<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{ {
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Create); return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Create);
} }
@ -113,7 +113,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler, Mode.Update); return InvokeAsync(command, handler, Mode.Update);
} }
protected Task<object> UpdateAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand protected Task<object> UpdateReturn<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{ {
return InvokeAsync(command, handler?.ToAsync(), Mode.Update); return InvokeAsync(command, handler?.ToAsync(), Mode.Update);
} }
@ -123,7 +123,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), Mode.Update); return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), Mode.Update);
} }
protected Task<object> UpdateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand protected Task<object> Update<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{ {
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Update); return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Update);
} }
@ -133,7 +133,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler, Mode.Upsert); return InvokeAsync(command, handler, Mode.Upsert);
} }
protected Task<object> UpsertAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand protected Task<object> UpsertReturn<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{ {
return InvokeAsync(command, handler?.ToAsync(), Mode.Upsert); return InvokeAsync(command, handler?.ToAsync(), Mode.Upsert);
} }
@ -143,7 +143,7 @@ namespace Squidex.Infrastructure.Commands
return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), Mode.Upsert); return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), Mode.Upsert);
} }
protected Task<object> UpsertAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand protected Task<object> Upsert<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{ {
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Upsert); return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), Mode.Upsert);
} }

29
src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs

@ -58,7 +58,12 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
public Task<Immutable<EventConsumerInfo>> GetStateAsync() public Task<Immutable<EventConsumerInfo>> GetStateAsync()
{ {
return Task.FromResult(State.ToInfo(eventConsumer.Name).AsImmutable()); return Task.FromResult(CreateInfo());
}
private Immutable<EventConsumerInfo> CreateInfo()
{
return State.ToInfo(eventConsumer.Name).AsImmutable();
} }
public Task OnEventAsync(Immutable<IEventSubscription> subscription, Immutable<StoredEvent> storedEvent) public Task OnEventAsync(Immutable<IEventSubscription> subscription, Immutable<StoredEvent> storedEvent)
@ -109,39 +114,43 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
return TaskHelper.Done; return TaskHelper.Done;
} }
public Task StartAsync() public async Task<Immutable<EventConsumerInfo>> StartAsync()
{ {
if (!State.IsStopped) if (!State.IsStopped)
{ {
return TaskHelper.Done; return CreateInfo();
} }
return DoAndUpdateStateAsync(() => await DoAndUpdateStateAsync(() =>
{ {
Subscribe(State.Position); Subscribe(State.Position);
State = State.Started(); State = State.Started();
}); });
return CreateInfo();
} }
public Task StopAsync() public async Task<Immutable<EventConsumerInfo>> StopAsync()
{ {
if (State.IsStopped) if (State.IsStopped)
{ {
return TaskHelper.Done; return CreateInfo();
} }
return DoAndUpdateStateAsync(() => await DoAndUpdateStateAsync(() =>
{ {
Unsubscribe(); Unsubscribe();
State = State.Stopped(); State = State.Stopped();
}); });
return CreateInfo();
} }
public Task ResetAsync() public async Task<Immutable<EventConsumerInfo>> ResetAsync()
{ {
return DoAndUpdateStateAsync(async () => await DoAndUpdateStateAsync(async () =>
{ {
Unsubscribe(); Unsubscribe();
@ -151,6 +160,8 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
State = State.Reset(); State = State.Reset();
}); });
return CreateInfo();
} }
private Task DoAndUpdateStateAsync(Action action, [CallerMemberName] string caller = null) private Task DoAndUpdateStateAsync(Action action, [CallerMemberName] string caller = null)

12
src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerManagerGrain.cs

@ -74,33 +74,31 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{ {
return Task.WhenAll( return Task.WhenAll(
eventConsumers eventConsumers
.Select(c => GrainFactory.GetGrain<IEventConsumerGrain>(c.Name)) .Select(c => StartAsync(c.Name)));
.Select(c => c.StartAsync()));
} }
public Task StopAllAsync() public Task StopAllAsync()
{ {
return Task.WhenAll( return Task.WhenAll(
eventConsumers eventConsumers
.Select(c => GrainFactory.GetGrain<IEventConsumerGrain>(c.Name)) .Select(c => StopAsync(c.Name)));
.Select(c => c.StopAsync()));
} }
public Task ResetAsync(string consumerName) public Task<Immutable<EventConsumerInfo>> ResetAsync(string consumerName)
{ {
var eventConsumer = GrainFactory.GetGrain<IEventConsumerGrain>(consumerName); var eventConsumer = GrainFactory.GetGrain<IEventConsumerGrain>(consumerName);
return eventConsumer.ResetAsync(); return eventConsumer.ResetAsync();
} }
public Task StartAsync(string consumerName) public Task<Immutable<EventConsumerInfo>> StartAsync(string consumerName)
{ {
var eventConsumer = GrainFactory.GetGrain<IEventConsumerGrain>(consumerName); var eventConsumer = GrainFactory.GetGrain<IEventConsumerGrain>(consumerName);
return eventConsumer.StartAsync(); return eventConsumer.StartAsync();
} }
public Task StopAsync(string consumerName) public Task<Immutable<EventConsumerInfo>> StopAsync(string consumerName)
{ {
var eventConsumer = GrainFactory.GetGrain<IEventConsumerGrain>(consumerName); var eventConsumer = GrainFactory.GetGrain<IEventConsumerGrain>(consumerName);

6
src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerGrain.cs

@ -16,11 +16,11 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{ {
Task<Immutable<EventConsumerInfo>> GetStateAsync(); Task<Immutable<EventConsumerInfo>> GetStateAsync();
Task StopAsync(); Task<Immutable<EventConsumerInfo>> StopAsync();
Task StartAsync(); Task<Immutable<EventConsumerInfo>> StartAsync();
Task ResetAsync(); Task<Immutable<EventConsumerInfo>> ResetAsync();
Task OnEventAsync(Immutable<IEventSubscription> subscription, Immutable<StoredEvent> storedEvent); Task OnEventAsync(Immutable<IEventSubscription> subscription, Immutable<StoredEvent> storedEvent);

10
src/Squidex.Infrastructure/EventSourcing/Grains/IEventConsumerManagerGrain.cs

@ -16,15 +16,15 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{ {
Task ActivateAsync(string streamName); Task ActivateAsync(string streamName);
Task StopAllAsync(); Task StartAllAsync();
Task StopAsync(string consumerName); Task StopAllAsync();
Task StartAllAsync(); Task<Immutable<EventConsumerInfo>> StopAsync(string consumerName);
Task StartAsync(string consumerName); Task<Immutable<EventConsumerInfo>> StartAsync(string consumerName);
Task ResetAsync(string consumerName); Task<Immutable<EventConsumerInfo>> ResetAsync(string consumerName);
Task<Immutable<List<EventConsumerInfo>>> GetConsumersAsync(); Task<Immutable<List<EventConsumerInfo>>> GetConsumersAsync();
} }

1
src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -8,6 +8,7 @@
<DebugSymbols>True</DebugSymbols> <DebugSymbols>True</DebugSymbols>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="FluentFTP" Version="24.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" /> <PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="2.2.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="2.2.0" /> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="2.2.0" />

10
src/Squidex.Shared/Permissions.cs

@ -37,8 +37,6 @@ namespace Squidex.Shared
public const string AdminAppCreate = "squidex.admin.apps.create"; public const string AdminAppCreate = "squidex.admin.apps.create";
public const string AdminRestore = "squidex.admin.restore"; public const string AdminRestore = "squidex.admin.restore";
public const string AdminRestoreRead = "squidex.admin.restore.read";
public const string AdminRestoreCreate = "squidex.admin.restore.create";
public const string AdminEvents = "squidex.admin.events"; public const string AdminEvents = "squidex.admin.events";
public const string AdminEventsRead = "squidex.admin.events.read"; public const string AdminEventsRead = "squidex.admin.events.read";
@ -79,7 +77,6 @@ namespace Squidex.Shared
public const string AppRolesDelete = "squidex.apps.{app}.roles.delete"; public const string AppRolesDelete = "squidex.apps.{app}.roles.delete";
public const string AppPatterns = "squidex.apps.{app}.patterns"; public const string AppPatterns = "squidex.apps.{app}.patterns";
public const string AppPatternsRead = "squidex.apps.{app}.patterns.read";
public const string AppPatternsCreate = "squidex.apps.{app}.patterns.create"; public const string AppPatternsCreate = "squidex.apps.{app}.patterns.create";
public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update"; public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update";
public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete"; public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete";
@ -108,7 +105,6 @@ namespace Squidex.Shared
public const string AppRulesDelete = "squidex.apps.{app}.rules.delete"; public const string AppRulesDelete = "squidex.apps.{app}.rules.delete";
public const string AppSchemas = "squidex.apps.{app}.schemas.{name}"; public const string AppSchemas = "squidex.apps.{app}.schemas.{name}";
public const string AppSchemasRead = "squidex.apps.{app}.schemas.{name}.read";
public const string AppSchemasCreate = "squidex.apps.{app}.schemas.{name}.create"; public const string AppSchemasCreate = "squidex.apps.{app}.schemas.{name}.create";
public const string AppSchemasUpdate = "squidex.apps.{app}.schemas.{name}.update"; public const string AppSchemasUpdate = "squidex.apps.{app}.schemas.{name}.update";
public const string AppSchemasScripts = "squidex.apps.{app}.schemas.{name}.scripts"; public const string AppSchemasScripts = "squidex.apps.{app}.schemas.{name}.scripts";
@ -117,14 +113,10 @@ namespace Squidex.Shared
public const string AppContents = "squidex.apps.{app}.contents.{name}"; public const string AppContents = "squidex.apps.{app}.contents.{name}";
public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read"; public const string AppContentsRead = "squidex.apps.{app}.contents.{name}.read";
public const string AppContentsGraphQL = "squidex.apps.{app}.contents.{name}.graphql";
public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create"; public const string AppContentsCreate = "squidex.apps.{app}.contents.{name}.create";
public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update"; public const string AppContentsUpdate = "squidex.apps.{app}.contents.{name}.update";
public const string AppContentsStatus = "squidex.apps.{app}.contents.{name}.status.{status}";
public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard"; public const string AppContentsDiscard = "squidex.apps.{app}.contents.{name}.discard";
public const string AppContentsArchive = "squidex.apps.{app}.contents.{name}.archive";
public const string AppContentsRestore = "squidex.apps.{app}.contents.{name}.restore";
public const string AppContentsPublish = "squidex.apps.{app}.contents.{name}.publish";
public const string AppContentsUnpublish = "squidex.apps.{app}.contents.{name}.unpublish";
public const string AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete"; public const string AppContentsDelete = "squidex.apps.{app}.contents.{name}.delete";
public const string AppApi = "squidex.apps.{app}.api"; public const string AppApi = "squidex.apps.{app}.api";

2
src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs

@ -57,7 +57,7 @@ namespace Squidex.Web.CommandMiddlewares
throw new InvalidOperationException("Cannot resolve app."); throw new InvalidOperationException("Cannot resolve app.");
} }
return NamedId.Of(appFeature.App.Id, appFeature.App.Name); return appFeature.App.NamedId();
} }
} }
} }

2
src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs

@ -69,7 +69,7 @@ namespace Squidex.Web.CommandMiddlewares
if (appFeature?.App != null) if (appFeature?.App != null)
{ {
appId = NamedId.Of(appFeature.App.Id, appFeature.App.Name); appId = appFeature.App.NamedId();
} }
} }

8
src/Squidex.Web/Extensions.cs

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System;
using System.Security.Claims; using System.Security.Claims;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
@ -40,5 +41,12 @@ namespace Squidex.Web
return (null, null); return (null, null);
} }
public static bool IsUser(this ApiController controller, string userId)
{
var subject = controller.User.OpenIdSubject();
return string.Equals(subject, userId, StringComparison.OrdinalIgnoreCase);
}
} }
} }

76
src/Squidex.Web/PermissionExtensions.cs

@ -0,0 +1,76 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Http;
using Squidex.Infrastructure.Security;
using Squidex.Shared.Identity;
namespace Squidex.Web
{
public static class PermissionExtensions
{
private sealed class PermissionFeature
{
public PermissionSet Permissions { get; }
public PermissionFeature(PermissionSet permissions)
{
Permissions = permissions;
}
}
public static PermissionSet Permissions(this HttpContext httpContext)
{
var feature = httpContext.Features.Get<PermissionFeature>();
if (feature == null)
{
feature = new PermissionFeature(httpContext.User.Permissions());
httpContext.Features.Set(feature);
}
return feature.Permissions;
}
public static bool HasPermission(this HttpContext httpContext, Permission permission, PermissionSet permissions = null)
{
return httpContext.Permissions().Includes(permission) || permissions?.Includes(permission) == true;
}
public static bool HasPermission(this HttpContext httpContext, string id, string app = "*", string schema = "*", PermissionSet permissions = null)
{
return httpContext.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions);
}
public static bool HasPermission(this ApiController controller, Permission permission, PermissionSet permissions = null)
{
return controller.HttpContext.HasPermission(permission, permissions);
}
public static bool HasPermission(this ApiController controller, string id, string app = "*", string schema = "*", PermissionSet permissions = null)
{
if (app == "*")
{
if (controller.RouteData.Values.TryGetValue("app", out var value) && value is string s)
{
app = s;
}
}
if (schema == "*")
{
if (controller.RouteData.Values.TryGetValue("name", out var value) && value is string s)
{
schema = s;
}
}
return controller.HasPermission(Shared.Permissions.ForApp(id, app, schema), permissions);
}
}
}

5
src/Squidex.Web/Pipeline/AppResolver.cs

@ -65,7 +65,10 @@ namespace Squidex.Web.Pipeline
{ {
var identity = user.Identities.First(); var identity = user.Identities.First();
identity.AddClaim(new Claim(ClaimTypes.Role, role)); if (!string.IsNullOrWhiteSpace(role))
{
identity.AddClaim(new Claim(ClaimTypes.Role, role));
}
foreach (var permission in permissions) foreach (var permission in permissions)
{ {

61
src/Squidex.Web/Resource.cs

@ -0,0 +1,61 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace Squidex.Web
{
public abstract class Resource
{
[JsonProperty("_links")]
[Required]
[Display(Description = "The links.")]
public Dictionary<string, ResourceLink> Links { get; } = new Dictionary<string, ResourceLink>();
public void AddSelfLink(string href)
{
AddGetLink("self", href);
}
public void AddGetLink(string rel, string href)
{
AddLink(rel, "GET", href);
}
public void AddPatchLink(string rel, string href)
{
AddLink(rel, "PATCH", href);
}
public void AddPostLink(string rel, string href)
{
AddLink(rel, "POST", href);
}
public void AddPutLink(string rel, string href)
{
AddLink(rel, "PUT", href);
}
public void AddDeleteLink(string rel, string href)
{
AddLink(rel, "DELETE", href);
}
public void AddLink(string rel, string method, string href)
{
Guard.NotNullOrEmpty(rel, nameof(rel));
Guard.NotNullOrEmpty(href, nameof(href));
Guard.NotNullOrEmpty(method, nameof(method));
Links[rel] = new ResourceLink { Href = href, Method = method };
}
}
}

16
src/Squidex/Areas/Api/Controllers/Users/Models/UserCreatedDto.cs → src/Squidex.Web/ResourceLink.cs

@ -7,20 +7,16 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Squidex.Areas.Api.Controllers.Users.Models namespace Squidex.Web
{ {
public sealed class UserCreatedDto public class ResourceLink
{ {
/// <summary>
/// The id of the user.
/// </summary>
[Required] [Required]
public string Id { get; set; } [Display(Description = "The link url.")]
public string Href { get; set; }
/// <summary>
/// Additional permissions for the user.
/// </summary>
[Required] [Required]
public string[] Permissions { get; set; } [Display(Description = "The link method.")]
public string Method { get; set; }
} }
} }

44
src/Squidex.Web/UrlHelperExtensions.cs

@ -0,0 +1,44 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Mvc;
using System;
namespace Squidex.Web
{
public static class UrlHelperExtensions
{
private static class NameOf<T>
{
public static readonly string Controller;
static NameOf()
{
const string suffix = "Controller";
var name = typeof(T).Name;
if (name.EndsWith(suffix))
{
name = name.Substring(0, name.Length - suffix.Length);
}
Controller = name;
}
}
public static string Url<T>(this IUrlHelper urlHelper, Func<T, string> action, object values = null) where T : Controller
{
return urlHelper.Action(action(null), NameOf<T>.Controller, values);
}
public static string Url<T>(this Controller controller, Func<T, string> action, object values = null) where T : Controller
{
return controller.Url.Url<T>(action, values);
}
}
}

70
src/Squidex/Areas/Api/Config/Swagger/ErrorDtoProcessor.cs

@ -0,0 +1,70 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Linq;
using System.Threading.Tasks;
using NJsonSchema;
using NSwag;
using NSwag.SwaggerGeneration.Processors;
using NSwag.SwaggerGeneration.Processors.Contexts;
using Squidex.ClientLibrary.Management;
using Squidex.Pipeline.Swagger;
namespace Squidex.Areas.Api.Config.Swagger
{
public sealed class ErrorDtoProcessor : IDocumentProcessor
{
public async Task ProcessAsync(DocumentProcessorContext context)
{
var errorSchema = await GetErrorSchemaAsync(context);
foreach (var operation in context.Document.Paths.Values.SelectMany(x => x.Values))
{
AddErrorResponses(operation, errorSchema);
CleanupResponses(operation);
}
}
private static void AddErrorResponses(SwaggerOperation operation, JsonSchema4 errorSchema)
{
if (!operation.Responses.ContainsKey("500"))
{
operation.AddResponse("500", "Operation failed", errorSchema);
}
foreach (var (code, response) in operation.Responses)
{
if (code != "404" && code.StartsWith("4", StringComparison.OrdinalIgnoreCase) && response.Schema == null)
{
response.Schema = errorSchema;
}
}
}
private static void CleanupResponses(SwaggerOperation operation)
{
foreach (var (code, response) in operation.Responses.ToList())
{
if (string.IsNullOrWhiteSpace(response.Description) ||
response.Description?.Contains("=&gt;") == true ||
response.Description?.Contains("=>") == true)
{
operation.Responses.Remove(code);
}
}
}
private Task<JsonSchema4> GetErrorSchemaAsync(DocumentProcessorContext context)
{
var errorType = typeof(ErrorDto);
return context.SchemaGenerator.GenerateWithReferenceAsync<JsonSchema4>(errorType, Enumerable.Empty<Attribute>(), context.SchemaResolver);
}
}
}

2
src/Squidex/Areas/Api/Config/Swagger/FixProcessor.cs

@ -13,7 +13,7 @@ using Squidex.Infrastructure.Tasks;
namespace Squidex.Areas.Api.Config.Swagger namespace Squidex.Areas.Api.Config.Swagger
{ {
public class FixProcessor : IOperationProcessor public sealed class FixProcessor : IOperationProcessor
{ {
private static readonly JsonSchema4 StringSchema = new JsonSchema4 { Type = JsonObjectType.String }; private static readonly JsonSchema4 StringSchema = new JsonSchema4 { Type = JsonObjectType.String };

29
src/Squidex/Areas/Api/Config/Swagger/SecurityProcessor.cs

@ -15,7 +15,7 @@ using Squidex.Web;
namespace Squidex.Areas.Api.Config.Swagger namespace Squidex.Areas.Api.Config.Swagger
{ {
public class SecurityProcessor : SecurityDefinitionAppender public sealed class SecurityProcessor : SecurityDefinitionAppender
{ {
public SecurityProcessor(IOptions<UrlsOptions> urlOptions) public SecurityProcessor(IOptions<UrlsOptions> urlOptions)
: base(Constants.SecurityDefinition, Enumerable.Empty<string>(), CreateOAuthSchema(urlOptions.Value)) : base(Constants.SecurityDefinition, Enumerable.Empty<string>(), CreateOAuthSchema(urlOptions.Value))
@ -24,26 +24,33 @@ namespace Squidex.Areas.Api.Config.Swagger
private static SwaggerSecurityScheme CreateOAuthSchema(UrlsOptions urlOptions) private static SwaggerSecurityScheme CreateOAuthSchema(UrlsOptions urlOptions)
{ {
var securityScheme = new SwaggerSecurityScheme(); var security = new SwaggerSecurityScheme
{
Type = SwaggerSecuritySchemeType.OAuth2
};
var tokenUrl = urlOptions.BuildUrl($"{Constants.IdentityServerPrefix}/connect/token", false); var tokenUrl = urlOptions.BuildUrl($"{Constants.IdentityServerPrefix}/connect/token", false);
securityScheme.TokenUrl = tokenUrl; security.TokenUrl = tokenUrl;
var securityDocs = NSwagHelper.LoadDocs("security"); SetupDescription(security, tokenUrl);
var securityText = securityDocs.Replace("<TOKEN_URL>", tokenUrl);
securityScheme.Description = securityText; security.Flow = SwaggerOAuth2Flow.Application;
securityScheme.Type = SwaggerSecuritySchemeType.OAuth2;
securityScheme.Flow = SwaggerOAuth2Flow.Application;
securityScheme.Scopes = new Dictionary<string, string> security.Scopes = new Dictionary<string, string>
{ {
[Constants.ApiScope] = "Read and write access to the API" [Constants.ApiScope] = "Read and write access to the API"
}; };
return securityScheme; return security;
}
private static void SetupDescription(SwaggerSecurityScheme securityScheme, string tokenUrl)
{
var securityDocs = NSwagHelper.LoadDocs("security");
var securityText = securityDocs.Replace("<TOKEN_URL>", tokenUrl);
securityScheme.Description = securityText;
} }
} }
} }

7
src/Squidex/Areas/Api/Config/Swagger/SwaggerServices.cs

@ -14,6 +14,7 @@ using NSwag.SwaggerGeneration;
using NSwag.SwaggerGeneration.Processors; using NSwag.SwaggerGeneration.Processors;
using Squidex.Areas.Api.Controllers.Contents.Generator; using Squidex.Areas.Api.Controllers.Contents.Generator;
using Squidex.Areas.Api.Controllers.Rules.Models; using Squidex.Areas.Api.Controllers.Rules.Models;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure; using Squidex.Infrastructure;
namespace Squidex.Areas.Api.Config.Swagger namespace Squidex.Areas.Api.Config.Swagger
@ -22,6 +23,9 @@ namespace Squidex.Areas.Api.Config.Swagger
{ {
public static void AddMySwaggerSettings(this IServiceCollection services) public static void AddMySwaggerSettings(this IServiceCollection services)
{ {
services.AddSingletonAs<ErrorDtoProcessor>()
.As<IDocumentProcessor>();
services.AddSingletonAs<RuleActionProcessor>() services.AddSingletonAs<RuleActionProcessor>()
.As<IDocumentProcessor>(); .As<IDocumentProcessor>();
@ -73,7 +77,8 @@ namespace Squidex.Areas.Api.Config.Swagger
}), }),
new PrimitiveTypeMapper(typeof(Language), s => s.Type = JsonObjectType.String), new PrimitiveTypeMapper(typeof(Language), s => s.Type = JsonObjectType.String),
new PrimitiveTypeMapper(typeof(RefToken), s => s.Type = JsonObjectType.String) new PrimitiveTypeMapper(typeof(RefToken), s => s.Type = JsonObjectType.String),
new PrimitiveTypeMapper(typeof(Status), s => s.Type = JsonObjectType.String),
}; };
} }
} }

52
src/Squidex/Areas/Api/Config/Swagger/XmlResponseTypesProcessor.cs

@ -5,16 +5,13 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Linq; using System;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using NJsonSchema.Infrastructure; using NJsonSchema.Infrastructure;
using NSwag; using NSwag;
using NSwag.SwaggerGeneration.Processors; using NSwag.SwaggerGeneration.Processors;
using NSwag.SwaggerGeneration.Processors.Contexts; using NSwag.SwaggerGeneration.Processors.Contexts;
using Squidex.Pipeline.Swagger;
#pragma warning disable RECS0033 // Convert 'if' to '||' expression
namespace Squidex.Areas.Api.Config.Swagger namespace Squidex.Areas.Api.Config.Swagger
{ {
@ -26,46 +23,33 @@ namespace Squidex.Areas.Api.Config.Swagger
{ {
var operation = context.OperationDescription.Operation; var operation = context.OperationDescription.Operation;
var returnsDescription = await context.MethodInfo.GetXmlDocumentationTagAsync("returns") ?? string.Empty; var returnsDescription = await context.MethodInfo.GetXmlDocumentationTagAsync("returns");
foreach (Match match in ResponseRegex.Matches(returnsDescription)) if (!string.IsNullOrWhiteSpace(returnsDescription))
{ {
var statusCode = match.Groups["Code"].Value; foreach (Match match in ResponseRegex.Matches(returnsDescription))
if (!operation.Responses.TryGetValue(statusCode, out var response))
{ {
response = new SwaggerResponse(); var statusCode = match.Groups["Code"].Value;
operation.Responses[statusCode] = response; if (!operation.Responses.TryGetValue(statusCode, out var response))
} {
response = new SwaggerResponse();
response.Description = match.Groups["Description"].Value; operation.Responses[statusCode] = response;
} }
await AddInternalErrorResponseAsync(context, operation); var description = match.Groups["Description"].Value;
CleanupResponses(operation); if (description.Contains("=&gt;"))
{
throw new InvalidOperationException("Description not formatted correcly.");
}
return true; response.Description = description;
}
private static async Task AddInternalErrorResponseAsync(OperationProcessorContext context, SwaggerOperation operation)
{
if (!operation.Responses.ContainsKey("500"))
{
operation.AddResponse("500", "Operation failed", await context.SchemaGenerator.GetErrorDtoSchemaAsync(context.SchemaResolver));
}
}
private static void CleanupResponses(SwaggerOperation operation)
{
foreach (var (code, response) in operation.Responses.ToList())
{
if (string.IsNullOrWhiteSpace(response.Description) || response.Description?.Contains("=>") == true)
{
operation.Responses.Remove(code);
} }
} }
return true;
} }
} }
} }

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

Loading…
Cancel
Save