Browse Source

Merge branch 'next' of github.com:Squidex/squidex into next

pull/383/head
Sebastian Stehle 7 years ago
parent
commit
9d84c69101
  1. 2
      src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs
  2. 2
      src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs
  3. 6
      src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs
  4. 8
      src/Squidex.Domain.Apps.Core.Model/Apps/Role.cs
  5. 4
      src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs
  6. 2
      src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs
  7. 4
      src/Squidex.Domain.Apps.Core.Model/Contents/Json/StatusConverter.cs
  8. 37
      src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowConverter.cs
  9. 2
      src/Squidex.Domain.Apps.Core.Model/Contents/Status.cs
  10. 36
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusConverter.cs
  11. 88
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs
  12. 31
      src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs
  13. 23
      src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs
  14. 43
      src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs
  15. 2
      src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs
  16. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs
  17. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs
  18. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs
  19. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs
  20. 1
      src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs
  21. 2
      src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs
  22. 2
      src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  23. 32
      src/Squidex.Domain.Apps.Entities/AppProvider.cs
  24. 15
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  25. 10
      src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs
  26. 76
      src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs
  27. 3
      src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs
  28. 9
      src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs
  29. 8
      src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs
  30. 2
      src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs
  31. 9
      src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs
  32. 44
      src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs
  33. 72
      src/Squidex.Domain.Apps.Entities/Contents/ContentQueryService.cs
  34. 139
      src/Squidex.Domain.Apps.Entities/Contents/ContextExtensions.cs
  35. 9
      src/Squidex.Domain.Apps.Entities/Contents/DefaultContentWorkflow.cs
  36. 129
      src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  37. 4
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLService.cs
  38. 2
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLExecutionContext.cs
  39. 4
      src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs
  40. 2
      src/Squidex.Domain.Apps.Entities/Contents/Guards/GuardContent.cs
  41. 5
      src/Squidex.Domain.Apps.Entities/Contents/IContentEnricher.cs
  42. 8
      src/Squidex.Domain.Apps.Entities/Contents/IContentQueryService.cs
  43. 7
      src/Squidex.Domain.Apps.Entities/Contents/IContentWorkflow.cs
  44. 4
      src/Squidex.Domain.Apps.Entities/Contents/QueryExecutionContext.cs
  45. 45
      src/Squidex.Domain.Apps.Entities/Context.cs
  46. 2
      src/Squidex.Domain.Apps.Entities/IAppProvider.cs
  47. 7
      src/Squidex.Domain.Apps.Entities/IContextProvider.cs
  48. 116
      src/Squidex.Domain.Apps.Entities/QueryContext.cs
  49. 18
      src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs
  50. 12
      src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs
  51. 6
      src/Squidex.Shared/Permissions.cs
  52. 12
      src/Squidex.Web/ApiController.cs
  53. 25
      src/Squidex.Web/ApiPermissionAttribute.cs
  54. 18
      src/Squidex.Web/CommandMiddlewares/EnrichWithAppIdCommandMiddleware.cs
  55. 7
      src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs
  56. 37
      src/Squidex.Web/ContextExtensions.cs
  57. 30
      src/Squidex.Web/ContextProvider.cs
  58. 2
      src/Squidex.Web/EntityCreatedDto.cs
  59. 4
      src/Squidex.Web/ErrorDto.cs
  60. 22
      src/Squidex.Web/PermissionExtensions.cs
  61. 8
      src/Squidex.Web/Pipeline/ApiCostsFilter.cs
  62. 18
      src/Squidex.Web/Pipeline/AppResolver.cs
  63. 10
      src/Squidex.Web/Pipeline/RequestLogPerformanceMiddleware.cs
  64. 4
      src/Squidex.Web/Resource.cs
  65. 2
      src/Squidex.Web/ResourceLink.cs
  66. 2
      src/Squidex.Web/Squidex.Web.csproj
  67. 6
      src/Squidex.Web/UrlHelperExtensions.cs
  68. 86
      src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs
  69. 5
      src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs
  70. 45
      src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs
  71. 61
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs
  72. 32
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs
  73. 32
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs
  74. 22
      src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs
  75. 9
      src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  76. 57
      src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  77. 13
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs
  78. 2
      src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs
  79. 3
      src/Squidex/Areas/Api/Controllers/UI/UIController.cs
  80. 2
      src/Squidex/Config/Domain/EntitiesServices.cs
  81. 3
      src/Squidex/Config/Domain/SerializationServices.cs
  82. 4
      src/Squidex/Config/Web/WebServices.cs
  83. 2
      src/Squidex/app/features/administration/module.ts
  84. 34
      src/Squidex/app/features/administration/services/event-consumers.service.ts
  85. 52
      src/Squidex/app/features/administration/services/users.service.ts
  86. 2
      src/Squidex/app/features/api/module.ts
  87. 2
      src/Squidex/app/features/apps/module.ts
  88. 2
      src/Squidex/app/features/assets/module.ts
  89. 2
      src/Squidex/app/features/content/module.ts
  90. 2
      src/Squidex/app/features/dashboard/module.ts
  91. 2
      src/Squidex/app/features/rules/module.ts
  92. 2
      src/Squidex/app/features/schemas/module.ts
  93. 4
      src/Squidex/app/features/schemas/pages/schema/schema-page.component.html
  94. 3
      src/Squidex/app/features/settings/declarations.ts
  95. 25
      src/Squidex/app/features/settings/module.ts
  96. 76
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html
  97. 76
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.scss
  98. 95
      src/Squidex/app/features/settings/pages/workflows/workflow-step.component.ts
  99. 40
      src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.html
  100. 24
      src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.scss

2
src/Squidex.Domain.Apps.Core.Model/Apps/AppClient.cs

@ -21,7 +21,7 @@ namespace Squidex.Domain.Apps.Core.Apps
{
Guard.NotNullOrEmpty(secret, nameof(secret));
Guard.NotNullOrEmpty(role, nameof(role));
Role = role;
Secret = secret;

2
src/Squidex.Domain.Apps.Core.Model/Apps/AppPattern.cs

@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Core.Apps
: base(name)
{
Guard.NotNullOrEmpty(pattern, nameof(pattern));
Pattern = pattern;
Message = message;

6
src/Squidex.Domain.Apps.Core.Model/Apps/Json/RolesConverter.cs

@ -5,12 +5,12 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using Squidex.Infrastructure.Security;
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using Squidex.Infrastructure.Security;
namespace Squidex.Domain.Apps.Core.Apps.Json
{

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

@ -5,11 +5,11 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using P = Squidex.Shared.Permissions;
namespace Squidex.Domain.Apps.Core.Apps
@ -71,7 +71,8 @@ namespace Squidex.Domain.Apps.Core.Apps
return new Role(Editor,
P.ForApp(P.AppAssets, app),
P.ForApp(P.AppCommon, app),
P.ForApp(P.AppContents, app));
P.ForApp(P.AppContents, app),
P.ForApp(P.AppWorkflowsRead, app));
}
public static Role CreateReader(string app)
@ -90,6 +91,7 @@ namespace Squidex.Domain.Apps.Core.Apps
P.ForApp(P.AppCommon, app),
P.ForApp(P.AppContents, app),
P.ForApp(P.AppPatterns, app),
P.ForApp(P.AppWorkflows, app),
P.ForApp(P.AppRules, app),
P.ForApp(P.AppSchemas, app));
}

4
src/Squidex.Domain.Apps.Core.Model/Apps/Roles.cs

@ -5,12 +5,12 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Linq;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
namespace Squidex.Domain.Apps.Core.Apps
{

2
src/Squidex.Domain.Apps.Core.Model/Comments/Comment.cs

@ -5,9 +5,9 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using NodaTime;
using Squidex.Infrastructure;
using System;
namespace Squidex.Domain.Apps.Core.Comments
{

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

@ -5,10 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
namespace Squidex.Domain.Apps.Core.Contents.Json
{

37
src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowConverter.cs

@ -0,0 +1,37 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json;
using Squidex.Infrastructure.Json.Newtonsoft;
namespace Squidex.Domain.Apps.Core.Contents.Json
{
public sealed class WorkflowConverter : JsonClassConverter<Workflows>
{
protected override void WriteValue(JsonWriter writer, Workflows value, JsonSerializer serializer)
{
var json = new Dictionary<Guid, Workflow>(value.Count);
foreach (var workflow in value)
{
json.Add(workflow.Key, workflow.Value);
}
serializer.Serialize(writer, json);
}
protected override Workflows ReadValue(JsonReader reader, Type objectType, JsonSerializer serializer)
{
var json = serializer.Deserialize<Dictionary<Guid, Workflow>>(reader);
return new Workflows(json.ToArray());
}
}
}

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

@ -6,9 +6,11 @@
// ==========================================================================
using System;
using System.ComponentModel;
namespace Squidex.Domain.Apps.Core.Contents
{
[TypeConverter(typeof(StatusConverter))]
public struct Status : IEquatable<Status>
{
public static readonly Status Archived = new Status("Archived");

36
src/Squidex.Domain.Apps.Core.Model/Contents/StatusConverter.cs

@ -0,0 +1,36 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.ComponentModel;
using System.Globalization;
namespace Squidex.Domain.Apps.Core.Contents
{
public sealed class StatusConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType)
{
return sourceType == typeof(string);
}
public override bool CanConvertTo(ITypeDescriptorContext context, Type destinationType)
{
return destinationType == typeof(string);
}
public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
{
return new Status(value?.ToString());
}
public override object ConvertTo(ITypeDescriptorContext context, CultureInfo culture, object value, Type destinationType)
{
return value.ToString();
}
}
}

88
src/Squidex.Domain.Apps.Core.Model/Contents/Workflow.cs

@ -0,0 +1,88 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Core.Contents
{
public sealed class Workflow
{
private static readonly IReadOnlyDictionary<Status, WorkflowStep> EmptySteps = new Dictionary<Status, WorkflowStep>();
public static readonly Workflow Default = new Workflow(
new Dictionary<Status, WorkflowStep>
{
[Status.Archived] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Draft] = new WorkflowTransition()
},
StatusColors.Archived, true),
[Status.Draft] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Archived] = new WorkflowTransition(),
[Status.Published] = new WorkflowTransition()
},
StatusColors.Draft),
[Status.Published] =
new WorkflowStep(
new Dictionary<Status, WorkflowTransition>
{
[Status.Archived] = new WorkflowTransition(),
[Status.Draft] = new WorkflowTransition()
},
StatusColors.Published)
}, Status.Draft);
public IReadOnlyDictionary<Status, WorkflowStep> Steps { get; }
public Status Initial { get; }
public Workflow(IReadOnlyDictionary<Status, WorkflowStep> steps, Status initial)
{
Steps = steps ?? EmptySteps;
Initial = initial;
}
public IEnumerable<(Status Status, WorkflowStep Step, WorkflowTransition Transition)> GetTransitions(Status status)
{
if (TryGetStep(status, out var step))
{
foreach (var transition in step.Transitions)
{
yield return (transition.Key, Steps[transition.Key], transition.Value);
}
}
}
public bool TryGetTransition(Status from, Status to, out WorkflowTransition transition)
{
if (TryGetStep(from, out var step) && step.Transitions.TryGetValue(to, out transition))
{
return true;
}
transition = null;
return false;
}
public bool TryGetStep(Status status, out WorkflowStep step)
{
return Steps.TryGetValue(status, out step);
}
public (Status Key, WorkflowStep) GetInitialStep()
{
return (Initial, Steps[Initial]);
}
}
}

31
src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowStep.cs

@ -0,0 +1,31 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
namespace Squidex.Domain.Apps.Core.Contents
{
public sealed class WorkflowStep
{
private static readonly IReadOnlyDictionary<Status, WorkflowTransition> EmptyTransitions = new Dictionary<Status, WorkflowTransition>();
public IReadOnlyDictionary<Status, WorkflowTransition> Transitions { get; }
public string Color { get; }
public bool NoUpdate { get; }
public WorkflowStep(IReadOnlyDictionary<Status, WorkflowTransition> transitions = null, string color = null, bool noUpdate = false)
{
Transitions = transitions ?? EmptyTransitions;
Color = color;
NoUpdate = noUpdate;
}
}
}

23
src/Squidex.Domain.Apps.Core.Model/Contents/WorkflowTransition.cs

@ -0,0 +1,23 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.Contents
{
public sealed class WorkflowTransition
{
public string Expression { get; }
public string Role { get; }
public WorkflowTransition(string expression = null, string role = null)
{
Expression = expression;
Role = role;
}
}
}

43
src/Squidex.Domain.Apps.Core.Model/Contents/Workflows.cs

@ -0,0 +1,43 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using System.Linq;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
namespace Squidex.Domain.Apps.Core.Contents
{
public sealed class Workflows : ArrayDictionary<Guid, Workflow>
{
public static readonly Workflows Empty = new Workflows();
private Workflows()
{
}
public Workflows(KeyValuePair<Guid, Workflow>[] items)
: base(items)
{
}
[Pure]
public Workflows Set(Workflow workflow)
{
Guard.NotNull(workflow, nameof(workflow));
return new Workflows(With(Guid.Empty, workflow));
}
public Workflow GetFirst()
{
return Values.FirstOrDefault() ?? Workflow.Default;
}
}
}

2
src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs

@ -5,8 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System.Collections.ObjectModel;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Rules.Triggers
{

2
src/Squidex.Domain.Apps.Core.Model/Schemas/ArrayField.cs

@ -5,10 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Schemas
{

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

@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Core.Schemas
public sealed class FieldCollection<T> : Cloneable<FieldCollection<T>> where T : IField
{
public static readonly FieldCollection<T> Empty = new FieldCollection<T>();
private static readonly Dictionary<long, T> EmptyById = new Dictionary<long, T>();
private static readonly Dictionary<string, T> EmptyByString = new Dictionary<string, T>();

2
src/Squidex.Domain.Apps.Core.Model/Schemas/FieldExtensions.cs

@ -5,9 +5,9 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System.Collections.Generic;
using System.Linq;
using Squidex.Infrastructure;
using NamedIdStatic = Squidex.Infrastructure.NamedId;
namespace Squidex.Domain.Apps.Core.Schemas

2
src/Squidex.Domain.Apps.Core.Model/Schemas/Json/JsonFieldModel.cs

@ -5,9 +5,9 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Newtonsoft.Json;
using Squidex.Infrastructure;
using System;
using P = Squidex.Domain.Apps.Core.Partitioning;
namespace Squidex.Domain.Apps.Core.Schemas.Json

1
src/Squidex.Domain.Apps.Core.Model/Schemas/JsonFieldProperties.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.Schemas
{
public sealed class JsonFieldProperties : FieldProperties

2
src/Squidex.Domain.Apps.Core.Model/Schemas/SchemaExtensions.cs

@ -5,8 +5,8 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
using System;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Schemas
{

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

@ -14,6 +14,8 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
<PackageReference Include="Freezable.Fody" Version="1.9.3" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="1.5.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="4.5.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

32
src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -65,6 +65,17 @@ namespace Squidex.Domain.Apps.Entities
});
}
public Task<IAppEntity> GetAppAsync(Guid appId)
{
return localCache.GetOrCreateAsync($"GetAppAsync({appId})", async () =>
{
using (Profiler.TraceMethod<AppProvider>())
{
return await GetAppByIdAsync(appId);
}
});
}
public Task<IAppEntity> GetAppAsync(string appName)
{
return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () =>
@ -78,14 +89,7 @@ namespace Squidex.Domain.Apps.Entities
return null;
}
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync();
if (!IsExisting(app))
{
return null;
}
return app.Value;
return await GetAppByIdAsync(appId);
}
});
}
@ -184,6 +188,18 @@ namespace Squidex.Domain.Apps.Entities
});
}
private async Task<IAppEntity> GetAppByIdAsync(Guid appId)
{
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync();
if (!IsExisting(app))
{
return null;
}
return app.Value;
}
private async Task<List<Guid>> GetAppIdsByUserAsync(string userId)
{
using (Profiler.TraceMethod<AppProvider>())

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

@ -119,6 +119,16 @@ namespace Squidex.Domain.Apps.Entities.Apps
return Snapshot;
});
case ConfigureWorkflow configureWorkflow:
return UpdateReturn(configureWorkflow, c =>
{
GuardAppWorkflows.CanConfigure(c);
ConfigureWorkflow(c);
return Snapshot;
});
case AddLanguage addLanguage:
return UpdateReturn(addLanguage, c =>
{
@ -319,6 +329,11 @@ namespace Squidex.Domain.Apps.Entities.Apps
RaiseEvent(SimpleMapper.Map(command, new AppClientRevoked()));
}
public void ConfigureWorkflow(ConfigureWorkflow command)
{
RaiseEvent(SimpleMapper.Map(command, new AppWorkflowConfigured()));
}
public void AddLanguage(AddLanguage command)
{
RaiseEvent(SimpleMapper.Map(command, new AppLanguageAdded()));

10
src/Squidex.Web/IAppFeature.cs → src/Squidex.Domain.Apps.Entities/Apps/Commands/ConfigureWorkflow.cs

@ -1,16 +1,16 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Web
namespace Squidex.Domain.Apps.Entities.Apps.Commands
{
public interface IAppFeature
public sealed class ConfigureWorkflow : AppCommand
{
IAppEntity App { get; }
public Workflow Workflow { get; set; }
}
}

76
src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardAppWorkflows.cs

@ -0,0 +1,76 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Apps.Guards
{
public static class GuardAppWorkflows
{
public static void CanConfigure(ConfigureWorkflow command)
{
Guard.NotNull(command, nameof(command));
Validate.It(() => "Cannot configure workflow.", e =>
{
if (command.Workflow == null)
{
e(Not.Defined("Workflow"), nameof(command.Workflow));
return;
}
var workflow = command.Workflow;
if (!workflow.Steps.ContainsKey(workflow.Initial))
{
e(Not.Defined("Initial step"), $"{nameof(command.Workflow)}.{nameof(workflow.Initial)}");
}
if (workflow.Initial == Status.Published)
{
e("Initial step cannot be published step.", $"{nameof(command.Workflow)}.{nameof(workflow.Initial)}");
}
var stepsPrefix = $"{nameof(command.Workflow)}.{nameof(workflow.Steps)}";
if (!workflow.Steps.ContainsKey(Status.Published))
{
e("Workflow must have a published step.", stepsPrefix);
}
foreach (var step in workflow.Steps)
{
var stepPrefix = $"{stepsPrefix}.{step.Key}";
if (step.Value == null)
{
e(Not.Defined("Step"), stepPrefix);
}
else
{
foreach (var transition in step.Value.Transitions)
{
var transitionPrefix = $"{stepPrefix}.{nameof(step.Value.Transitions)}.{transition.Key}";
if (!workflow.Steps.ContainsKey(transition.Key))
{
e("Transition has an invalid target.", transitionPrefix);
}
if (transition.Value == null)
{
e(Not.Defined("Transition"), transitionPrefix);
}
}
}
}
});
}
}
}

3
src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs

@ -6,6 +6,7 @@
// ==========================================================================
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Entities.Apps
{
@ -29,6 +30,8 @@ namespace Squidex.Domain.Apps.Entities.Apps
LanguagesConfig LanguagesConfig { get; }
Workflows Workflows { get; }
bool IsArchived { get; }
}
}

9
src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs

@ -7,6 +7,7 @@
using System.Runtime.Serialization;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Apps;
using Squidex.Infrastructure.Dispatching;
@ -42,6 +43,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
[DataMember]
public LanguagesConfig LanguagesConfig { get; set; } = LanguagesConfig.English;
[DataMember]
public Workflows Workflows { get; set; } = Workflows.Empty;
[DataMember]
public bool IsArchived { get; set; }
@ -92,6 +96,11 @@ namespace Squidex.Domain.Apps.Entities.Apps.State
Clients = Clients.Revoke(@event.Id);
}
protected void On(AppWorkflowConfigured @event)
{
Workflows = Workflows.Set(@event.Workflow);
}
protected void On(AppPatternAdded @event)
{
Patterns = Patterns.Add(@event.PatternId, @event.Name, @event.Pattern, @event.Message);

8
src/Squidex.Domain.Apps.Entities/Assets/AssetQueryService.cs

@ -70,7 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
return await assetEnricher.EnrichAsync(assets);
}
public async Task<IResultList<IEnrichedAssetEntity>> QueryAsync(QueryContext context, Q query)
public async Task<IResultList<IEnrichedAssetEntity>> QueryAsync(Context context, Q query)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(query, nameof(query));
@ -91,14 +91,14 @@ namespace Squidex.Domain.Apps.Entities.Assets
return ResultList.Create(assets.Total, enriched);
}
private async Task<IResultList<IAssetEntity>> QueryByQueryAsync(QueryContext context, Q query)
private async Task<IResultList<IAssetEntity>> QueryByQueryAsync(Context context, Q query)
{
var parsedQuery = ParseQuery(context, query.ODataQuery);
return await assetRepository.QueryAsync(context.App.Id, parsedQuery);
}
private async Task<IResultList<IAssetEntity>> QueryByIdsAsync(QueryContext context, Q query)
private async Task<IResultList<IAssetEntity>> QueryByIdsAsync(Context context, Q query)
{
var assets = await assetRepository.QueryAsync(context.App.Id, new HashSet<Guid>(query.Ids));
@ -110,7 +110,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
return assets.SortSet(x => x.Id, ids);
}
private Query ParseQuery(QueryContext context, string query)
private Query ParseQuery(Context context, string query)
{
try
{

2
src/Squidex.Domain.Apps.Entities/Assets/IAssetQueryService.cs

@ -18,7 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
Task<IReadOnlyList<IEnrichedAssetEntity>> QueryByHashAsync(Guid appId, string hash);
Task<IResultList<IEnrichedAssetEntity>> QueryAsync(QueryContext contex, Q query);
Task<IResultList<IEnrichedAssetEntity>> QueryAsync(Context contex, Q query);
Task<IEnrichedAssetEntity> FindAssetAsync(Guid id);
}

9
src/Squidex.Domain.Apps.Entities/Contents/ContentCommandMiddleware.cs

@ -30,12 +30,17 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
await base.HandleAsync(context, next);
if (context.PlainResult is IContentEntity content && !(context.PlainResult is IEnrichedContentEntity))
if (context.Command is SquidexCommand command && context.PlainResult is IContentEntity content && NotEnriched(context))
{
var enriched = await contentEnricher.EnrichAsync(content);
var enriched = await contentEnricher.EnrichAsync(content, command.User);
context.Complete(enriched);
}
}
private static bool NotEnriched(CommandContext context)
{
return !(context.PlainResult is IEnrichedContentEntity);
}
}
}

44
src/Squidex.Domain.Apps.Entities/Contents/ContentEnricher.cs

@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
@ -20,24 +21,30 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
private const string DefaultColor = StatusColors.Draft;
private readonly IContentWorkflow contentWorkflow;
private readonly IContextProvider contextProvider;
public ContentEnricher(IContentWorkflow contentWorkflow)
public ContentEnricher(IContentWorkflow contentWorkflow, IContextProvider contextProvider)
{
Guard.NotNull(contentWorkflow, nameof(contentWorkflow));
Guard.NotNull(contextProvider, nameof(contextProvider));
this.contentWorkflow = contentWorkflow;
this.contextProvider = contextProvider;
}
public async Task<IEnrichedContentEntity> EnrichAsync(IContentEntity content)
public async Task<IEnrichedContentEntity> EnrichAsync(IContentEntity content, ClaimsPrincipal user)
{
Guard.NotNull(content, nameof(content));
var enriched = await EnrichAsync(Enumerable.Repeat(content, 1));
var enriched = await EnrichAsync(Enumerable.Repeat(content, 1), user);
return enriched[0];
}
public async Task<IReadOnlyList<IEnrichedContentEntity>> EnrichAsync(IEnumerable<IContentEntity> contents)
public async Task<IReadOnlyList<IEnrichedContentEntity>> EnrichAsync(IEnumerable<IContentEntity> contents, ClaimsPrincipal user)
{
Guard.NotNull(contents, nameof(contents));
Guard.NotNull(user, nameof(user));
using (Profiler.TraceMethod<ContentEnricher>())
{
@ -50,8 +57,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
var result = SimpleMapper.Map(content, new ContentEntity());
await ResolveColorAsync(content, result, cache);
await ResolveNextsAsync(content, result);
await ResolveCanUpdateAsync(content, result);
if (ShouldEnrichWithStatuses())
{
await ResolveNextsAsync(content, result, user);
await ResolveCanUpdateAsync(content, result);
}
results.Add(result);
}
@ -60,33 +71,38 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private bool ShouldEnrichWithStatuses()
{
return contextProvider.Context.IsFrontendClient || contextProvider.Context.IsResolveFlow();
}
private async Task ResolveCanUpdateAsync(IContentEntity content, ContentEntity result)
{
result.CanUpdate = await contentWorkflow.CanUpdateAsync(content);
}
private async Task ResolveNextsAsync(IContentEntity content, ContentEntity result)
private async Task ResolveNextsAsync(IContentEntity content, ContentEntity result, ClaimsPrincipal user)
{
result.Nexts = await contentWorkflow.GetNextsAsync(content);
result.Nexts = await contentWorkflow.GetNextsAsync(content, user);
}
private async Task ResolveColorAsync(IContentEntity content, ContentEntity result, Dictionary<(Guid, Status), StatusInfo> cache)
{
result.StatusColor = await GetColorAsync(content.SchemaId, content.Status, cache);
result.StatusColor = await GetColorAsync(content, cache);
}
private async Task<string> GetColorAsync(NamedId<Guid> schemaId, Status status, Dictionary<(Guid, Status), StatusInfo> cache)
private async Task<string> GetColorAsync(IContentEntity content, Dictionary<(Guid, Status), StatusInfo> cache)
{
if (!cache.TryGetValue((schemaId.Id, status), out var info))
if (!cache.TryGetValue((content.SchemaId.Id, content.Status), out var info))
{
info = await contentWorkflow.GetInfoAsync(status);
info = await contentWorkflow.GetInfoAsync(content);
if (info == null)
{
info = new StatusInfo(status, DefaultColor);
info = new StatusInfo(content.Status, DefaultColor);
}
cache[(schemaId.Id, status)] = info;
cache[(content.SchemaId.Id, content.Status)] = info;
}
return info.Color;

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

@ -78,13 +78,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
this.scriptEngine = scriptEngine;
}
public async Task<IEnrichedContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = -1)
public async Task<IEnrichedContentEntity> FindContentAsync(Context context, string schemaIdOrName, Guid id, long version = -1)
{
Guard.NotNull(context, nameof(context));
var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName);
CheckPermission(context.User, schema);
CheckPermission(context, schema);
using (Profiler.TraceMethod<ContentQueryService>())
{
@ -108,13 +108,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public async Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query)
public async Task<IResultList<IEnrichedContentEntity>> QueryAsync(Context context, string schemaIdOrName, Q query)
{
Guard.NotNull(context, nameof(context));
var schema = await GetSchemaOrThrowAsync(context, schemaIdOrName);
CheckPermission(context.User, schema);
CheckPermission(context, schema);
using (Profiler.TraceMethod<ContentQueryService>())
{
@ -133,7 +133,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public async Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids)
public async Task<IResultList<IEnrichedContentEntity>> QueryAsync(Context context, IReadOnlyList<Guid> ids)
{
Guard.NotNull(context, nameof(context));
@ -148,13 +148,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
var contents = await QueryCoreAsync(context, ids);
var permissions = context.User.Permissions();
foreach (var group in contents.GroupBy(x => x.Schema.Id))
{
var schema = group.First().Schema;
if (HasPermission(permissions, schema))
if (HasPermission(context, schema))
{
var enriched = await TransformCoreAsync(context, schema, group.Select(x => x.Content));
@ -166,21 +164,21 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private async Task<IResultList<IEnrichedContentEntity>> TransformAsync(QueryContext context, ISchemaEntity schema, IResultList<IContentEntity> contents)
private async Task<IResultList<IEnrichedContentEntity>> TransformAsync(Context context, ISchemaEntity schema, IResultList<IContentEntity> contents)
{
var transformed = await TransformCoreAsync(context, schema, contents);
return ResultList.Create(contents.Total, transformed);
}
private async Task<IEnrichedContentEntity> TransformAsync(QueryContext context, ISchemaEntity schema, IContentEntity content)
private async Task<IEnrichedContentEntity> TransformAsync(Context context, ISchemaEntity schema, IContentEntity content)
{
var transformed = await TransformCoreAsync(context, schema, Enumerable.Repeat(content, 1));
return transformed[0];
}
private async Task<IReadOnlyList<IEnrichedContentEntity>> TransformCoreAsync(QueryContext context, ISchemaEntity schema, IEnumerable<IContentEntity> contents)
private async Task<IReadOnlyList<IEnrichedContentEntity>> TransformCoreAsync(Context context, ISchemaEntity schema, IEnumerable<IContentEntity> contents)
{
using (Profiler.TraceMethod<ContentQueryService>())
{
@ -191,7 +189,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
var scriptText = schema.SchemaDef.Scripts.Query;
var scripting = !string.IsNullOrWhiteSpace(scriptText);
var enriched = await contentEnricher.EnrichAsync(contents);
var enriched = await contentEnricher.EnrichAsync(contents, context.User);
foreach (var content in enriched)
{
@ -209,7 +207,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
result.Data = result.Data.ConvertName2Name(schema.SchemaDef, converters);
}
if (result.DataDraft != null && (context.ApiStatus == StatusForApi.All || context.IsFrontendClient))
if (result.DataDraft != null && (context.IsUnpublished() || context.IsFrontendClient))
{
result.DataDraft = result.DataDraft.ConvertName2Name(schema.SchemaDef, converters);
}
@ -225,7 +223,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private IEnumerable<FieldConverter> GenerateConverters(QueryContext context)
private IEnumerable<FieldConverter> GenerateConverters(Context context)
{
if (!context.IsFrontendClient)
{
@ -243,19 +241,23 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
yield return FieldConverters.ResolveFallbackLanguages(context.App.LanguagesConfig);
if (context.Languages?.Any() == true)
var languages = context.Languages();
if (languages.Any())
{
yield return FieldConverters.FilterLanguages(context.App.LanguagesConfig, context.Languages);
yield return FieldConverters.FilterLanguages(context.App.LanguagesConfig, languages);
}
if (context.AssetUrlsToResolve?.Any() == true)
var assetUrls = context.AssetUrls();
if (assetUrls.Any() == true)
{
yield return FieldConverters.ResolveAssetUrls(context.AssetUrlsToResolve, assetUrlGenerator);
yield return FieldConverters.ResolveAssetUrls(assetUrls.ToList(), assetUrlGenerator);
}
}
}
private Query ParseQuery(QueryContext context, string query, ISchemaEntity schema)
private Query ParseQuery(Context context, string query, ISchemaEntity schema)
{
using (Profiler.TraceMethod<ContentQueryService>())
{
@ -292,7 +294,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public async Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName)
public async Task<ISchemaEntity> GetSchemaOrThrowAsync(Context context, string schemaIdOrName)
{
ISchemaEntity schema = null;
@ -314,29 +316,27 @@ namespace Squidex.Domain.Apps.Entities.Contents
return schema;
}
private static void CheckPermission(ClaimsPrincipal user, params ISchemaEntity[] schemas)
private static void CheckPermission(Context context, params ISchemaEntity[] schemas)
{
var permissions = user.Permissions();
foreach (var schema in schemas)
{
if (!HasPermission(permissions, schema))
if (!HasPermission(context, schema))
{
throw new DomainForbiddenException("You do not have permission for this schema.");
}
}
}
private static bool HasPermission(PermissionSet permissions, ISchemaEntity schema)
private static bool HasPermission(Context context, ISchemaEntity schema)
{
var permission = Permissions.ForApp(Permissions.AppContentsRead, schema.AppId.Name, schema.SchemaDef.Name);
return permissions.Allows(permission);
return context.Permissions.Allows(permission);
}
private static Status[] GetStatus(QueryContext context)
private static Status[] GetStatus(Context context)
{
if (context.IsFrontendClient || context.ApiStatus == StatusForApi.All)
if (context.IsFrontendClient || context.IsUnpublished())
{
return null;
}
@ -346,36 +346,36 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
private async Task<IResultList<IContentEntity>> QueryByQueryAsync(QueryContext context, Q query, ISchemaEntity schema)
private async Task<IResultList<IContentEntity>> QueryByQueryAsync(Context context, Q query, ISchemaEntity schema)
{
var parsedQuery = ParseQuery(context, query.ODataQuery, schema);
return await QueryCoreAsync(context, schema, parsedQuery);
}
private async Task<IResultList<IContentEntity>> QueryByIdsAsync(QueryContext context, Q query, ISchemaEntity schema)
private async Task<IResultList<IContentEntity>> QueryByIdsAsync(Context context, Q query, ISchemaEntity schema)
{
var contents = await QueryCoreAsync(context, schema, query.Ids.ToHashSet());
return contents.SortSet(x => x.Id, query.Ids);
}
private Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryCoreAsync(QueryContext context, IReadOnlyList<Guid> ids)
private Task<List<(IContentEntity Content, ISchemaEntity Schema)>> QueryCoreAsync(Context context, IReadOnlyList<Guid> ids)
{
return contentRepository.QueryAsync(context.App, GetStatus(context), new HashSet<Guid>(ids), WithDraft(context));
}
private Task<IResultList<IContentEntity>> QueryCoreAsync(QueryContext context, ISchemaEntity schema, Query query)
private Task<IResultList<IContentEntity>> QueryCoreAsync(Context context, ISchemaEntity schema, Query query)
{
return contentRepository.QueryAsync(context.App, schema, GetStatus(context), context.IsFrontendClient, query, WithDraft(context));
}
private Task<IResultList<IContentEntity>> QueryCoreAsync(QueryContext context, ISchemaEntity schema, HashSet<Guid> ids)
private Task<IResultList<IContentEntity>> QueryCoreAsync(Context context, ISchemaEntity schema, HashSet<Guid> ids)
{
return contentRepository.QueryAsync(context.App, schema, GetStatus(context), ids, WithDraft(context));
}
private Task<IContentEntity> FindCoreAsync(QueryContext context, Guid id, ISchemaEntity schema)
private Task<IContentEntity> FindCoreAsync(Context context, Guid id, ISchemaEntity schema)
{
return contentRepository.FindContentAsync(context.App, schema, GetStatus(context), id, WithDraft(context));
}
@ -385,9 +385,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
return contentVersionLoader.LoadAsync(id, version);
}
private static bool WithDraft(QueryContext context)
private static bool WithDraft(Context context)
{
return context.ApiStatus == StatusForApi.All || context.IsFrontendClient;
return context.IsUnpublished() || context.IsFrontendClient;
}
}
}

139
src/Squidex.Domain.Apps.Entities/Contents/ContextExtensions.cs

@ -0,0 +1,139 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents
{
public static class ContextExtensions
{
private const string HeaderUnpublished = "X-Unpublished";
private const string HeaderFlatten = "X-Flatten";
private const string HeaderLanguages = "X-Languages";
private const string HeaderResolveFlow = "X-ResolveFlow";
private const string HeaderResolveAssetUrls = "X-Resolve-Urls";
private static readonly char[] Separators = { ',', ';' };
public static bool IsUnpublished(this Context context)
{
return context.Headers.ContainsKey(HeaderUnpublished);
}
public static Context WithUnpublished(this Context context, bool value = true)
{
if (value)
{
context.Headers[HeaderUnpublished] = "1";
}
else
{
context.Headers.Remove(HeaderUnpublished);
}
return context;
}
public static bool IsFlatten(this Context context)
{
return context.Headers.ContainsKey(HeaderFlatten);
}
public static Context WithFlatten(this Context context, bool value = true)
{
if (value)
{
context.Headers[HeaderFlatten] = "1";
}
else
{
context.Headers.Remove(HeaderFlatten);
}
return context;
}
public static bool IsResolveFlow(this Context context)
{
return context.Headers.ContainsKey(HeaderResolveFlow);
}
public static Context WithResolveFlow(this Context context, bool value = true)
{
if (value)
{
context.Headers[HeaderResolveFlow] = "1";
}
else
{
context.Headers.Remove(HeaderResolveFlow);
}
return context;
}
public static IEnumerable<string> AssetUrls(this Context context)
{
if (context.Headers.TryGetValue(HeaderResolveAssetUrls, out var value))
{
return value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToHashSet();
}
return Enumerable.Empty<string>();
}
public static Context WithAssetUrlsToResolve(this Context context, IEnumerable<string> fieldNames)
{
if (fieldNames?.Any() == true)
{
context.Headers[HeaderResolveAssetUrls] = string.Join(",", fieldNames);
}
else
{
context.Headers.Remove(HeaderResolveAssetUrls);
}
return context;
}
public static IEnumerable<Language> Languages(this Context context)
{
if (context.Headers.TryGetValue(HeaderResolveAssetUrls, out var value))
{
var languages = new HashSet<Language>();
foreach (var iso2Code in value.Split(Separators, StringSplitOptions.RemoveEmptyEntries))
{
if (Language.TryGetLanguage(iso2Code.Trim(), out var language))
{
languages.Add(language);
}
}
return languages;
}
return Enumerable.Empty<Language>();
}
public static Context WithLanguages(this Context context, IEnumerable<string> fieldNames)
{
if (fieldNames?.Any() == true)
{
context.Headers[HeaderLanguages] = string.Join(",", fieldNames);
}
else
{
context.Headers.Remove(HeaderLanguages);
}
return context;
}
}
}

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

@ -8,6 +8,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
@ -53,7 +54,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Task.FromResult(result);
}
public Task<bool> CanMoveToAsync(IContentEntity content, Status next)
public Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user)
{
var result = Flow.TryGetValue(content.Status, out var step) && step.Transitions.Any(x => x.Status == next);
@ -67,14 +68,14 @@ namespace Squidex.Domain.Apps.Entities.Contents
return Task.FromResult(result);
}
public Task<StatusInfo> GetInfoAsync(Status status)
public Task<StatusInfo> GetInfoAsync(IContentEntity content)
{
var result = Flow[status].Info;
var result = Flow[content.Status].Info;
return Task.FromResult(result);
}
public Task<StatusInfo[]> GetNextsAsync(IContentEntity content)
public Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user)
{
var result = Flow.TryGetValue(content.Status, out var step) ? step.Transitions : Array.Empty<StatusInfo>();

129
src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs

@ -0,0 +1,129 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class DynamicContentWorkflow : IContentWorkflow
{
private readonly IScriptEngine scriptEngine;
private readonly IAppProvider appProvider;
public DynamicContentWorkflow(IScriptEngine scriptEngine, IAppProvider appProvider)
{
Guard.NotNull(scriptEngine, nameof(scriptEngine));
Guard.NotNull(appProvider, nameof(appProvider));
this.scriptEngine = scriptEngine;
this.appProvider = appProvider;
}
public async Task<StatusInfo[]> GetAllAsync(ISchemaEntity schema)
{
var workflow = await GetWorkflowAsync(schema.AppId.Id);
return workflow.Steps.Select(x => new StatusInfo(x.Key, GetColor(x.Value))).ToArray();
}
public async Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user)
{
var workflow = await GetWorkflowAsync(content.AppId.Id);
return workflow.TryGetTransition(content.Status, next, out var transition) && CanUse(transition, content, user);
}
public async Task<bool> CanUpdateAsync(IContentEntity content)
{
var workflow = await GetWorkflowAsync(content.AppId.Id);
if (workflow.TryGetStep(content.Status, out var step))
{
return !step.NoUpdate;
}
return true;
}
public async Task<StatusInfo> GetInfoAsync(IContentEntity content)
{
var workflow = await GetWorkflowAsync(content.AppId.Id);
if (workflow.TryGetStep(content.Status, out var step))
{
return new StatusInfo(content.Status, GetColor(step));
}
return new StatusInfo(content.Status, StatusColors.Draft);
}
public async Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema)
{
var workflow = await GetWorkflowAsync(schema.AppId.Id);
var (status, step) = workflow.GetInitialStep();
return new StatusInfo(status, GetColor(step));
}
public async Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user)
{
var result = new List<StatusInfo>();
var workflow = await GetWorkflowAsync(content.AppId.Id);
foreach (var (to, step, transition) in workflow.GetTransitions(content.Status))
{
if (CanUse(transition, content, user))
{
result.Add(new StatusInfo(to, GetColor(step)));
}
}
return result.ToArray();
}
private bool CanUse(WorkflowTransition transition, IContentEntity content, ClaimsPrincipal user)
{
if (!string.IsNullOrWhiteSpace(transition.Role))
{
if (!user.Claims.Any(x => x.Type == ClaimTypes.Role && x.Value == transition.Role))
{
return false;
}
}
if (!string.IsNullOrWhiteSpace(transition.Expression))
{
return scriptEngine.Evaluate("data", content.DataDraft, transition.Expression);
}
return true;
}
private async Task<Workflow> GetWorkflowAsync(Guid appId)
{
var app = await appProvider.GetAppAsync(appId);
return app?.Workflows.GetFirst();
}
private static string GetColor(WorkflowStep step)
{
return step.Color ?? StatusColors.Draft;
}
}
}

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

@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
this.resolver = resolver;
}
public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries)
public async Task<(bool HasError, object Response)> QueryAsync(Context context, params GraphQLQuery[] queries)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(queries, nameof(queries));
@ -43,7 +43,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
return (result.Any(x => x.HasError), result.ToArray(x => x.Response));
}
public async Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query)
public async Task<(bool HasError, object Response)> QueryAsync(Context context, GraphQLQuery query)
{
Guard.NotNull(context, nameof(context));
Guard.NotNull(query, nameof(query));

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

@ -29,7 +29,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
public ISemanticLog Log { get; }
public GraphQLExecutionContext(QueryContext context, IDependencyResolver resolver)
public GraphQLExecutionContext(Context context, IDependencyResolver resolver)
: base(context,
resolver.Resolve<IAssetQueryService>(),
resolver.Resolve<IContentQueryService>())

4
src/Squidex.Domain.Apps.Entities/Contents/GraphQL/IGraphQLService.cs

@ -11,8 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{
public interface IGraphQLService
{
Task<(bool HasError, object Response)> QueryAsync(QueryContext context, params GraphQLQuery[] queries);
Task<(bool HasError, object Response)> QueryAsync(Context context, params GraphQLQuery[] queries);
Task<(bool HasError, object Response)> QueryAsync(QueryContext context, GraphQLQuery query);
Task<(bool HasError, object Response)> QueryAsync(Context context, GraphQLQuery query);
}
}

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

@ -76,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Guards
return Validate.It(() => "Cannot change status.", async e =>
{
if (!await contentWorkflow.CanMoveToAsync(content, command.Status))
if (!await contentWorkflow.CanMoveToAsync(content, command.Status, command.User))
{
if (content.Status == command.Status && content.Status == Status.Published)
{

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

@ -6,14 +6,15 @@
// ==========================================================================
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IContentEnricher
{
Task<IEnrichedContentEntity> EnrichAsync(IContentEntity content);
Task<IEnrichedContentEntity> EnrichAsync(IContentEntity content, ClaimsPrincipal user);
Task<IReadOnlyList<IEnrichedContentEntity>> EnrichAsync(IEnumerable<IContentEntity> contents);
Task<IReadOnlyList<IEnrichedContentEntity>> EnrichAsync(IEnumerable<IContentEntity> contents, ClaimsPrincipal user);
}
}

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

@ -17,12 +17,12 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
int DefaultPageSizeGraphQl { get; }
Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, IReadOnlyList<Guid> ids);
Task<IResultList<IEnrichedContentEntity>> QueryAsync(Context context, IReadOnlyList<Guid> ids);
Task<IResultList<IEnrichedContentEntity>> QueryAsync(QueryContext context, string schemaIdOrName, Q query);
Task<IResultList<IEnrichedContentEntity>> QueryAsync(Context context, string schemaIdOrName, Q query);
Task<IEnrichedContentEntity> FindContentAsync(QueryContext context, string schemaIdOrName, Guid id, long version = EtagVersion.Any);
Task<IEnrichedContentEntity> FindContentAsync(Context context, string schemaIdOrName, Guid id, long version = EtagVersion.Any);
Task<ISchemaEntity> GetSchemaOrThrowAsync(QueryContext context, string schemaIdOrName);
Task<ISchemaEntity> GetSchemaOrThrowAsync(Context context, string schemaIdOrName);
}
}

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

@ -5,6 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Security.Claims;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
@ -15,13 +16,13 @@ namespace Squidex.Domain.Apps.Entities.Contents
{
Task<StatusInfo> GetInitialStatusAsync(ISchemaEntity schema);
Task<bool> CanMoveToAsync(IContentEntity content, Status next);
Task<bool> CanMoveToAsync(IContentEntity content, Status next, ClaimsPrincipal user);
Task<bool> CanUpdateAsync(IContentEntity content);
Task<StatusInfo> GetInfoAsync(Status status);
Task<StatusInfo> GetInfoAsync(IContentEntity content);
Task<StatusInfo[]> GetNextsAsync(IContentEntity content);
Task<StatusInfo[]> GetNextsAsync(IContentEntity content, ClaimsPrincipal user);
Task<StatusInfo[]> GetAllAsync(ISchemaEntity schema);
}

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

@ -21,9 +21,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
private readonly ConcurrentDictionary<Guid, IEnrichedAssetEntity> cachedAssets = new ConcurrentDictionary<Guid, IEnrichedAssetEntity>();
private readonly IContentQueryService contentQuery;
private readonly IAssetQueryService assetQuery;
private readonly QueryContext context;
private readonly Context context;
public QueryExecutionContext(QueryContext context, IAssetQueryService assetQuery, IContentQueryService contentQuery)
public QueryExecutionContext(Context context, IAssetQueryService assetQuery, IContentQueryService contentQuery)
{
Guard.NotNull(assetQuery, nameof(assetQuery));
Guard.NotNull(contentQuery, nameof(contentQuery));

45
src/Squidex.Domain.Apps.Entities/Context.cs

@ -0,0 +1,45 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Security.Claims;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure.Security;
using Squidex.Shared.Identity;
namespace Squidex.Domain.Apps.Entities
{
public sealed class Context
{
public IDictionary<string, string> Headers { get; } = new Dictionary<string, string>();
public IAppEntity App { get; set; }
public ClaimsPrincipal User { get; set; }
public PermissionSet Permissions
{
get { return User?.Permissions() ?? PermissionSet.Empty; }
}
public Context()
{
}
public Context(ClaimsPrincipal user, IAppEntity app)
{
User = user;
App = app;
}
public bool IsFrontendClient
{
get { return User != null && User.IsInClient("squidex-frontend"); }
}
}
}

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

@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities
{
Task<(IAppEntity, ISchemaEntity)> GetAppWithSchemaAsync(Guid appId, Guid id);
Task<IAppEntity> GetAppAsync(Guid appId);
Task<IAppEntity> GetAppAsync(string appName);
Task<ISchemaEntity> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false);

7
src/Squidex.Domain.Apps.Entities/Contents/StatusForApi.cs → src/Squidex.Domain.Apps.Entities/IContextProvider.cs

@ -5,11 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Entities.Contents
namespace Squidex.Domain.Apps.Entities
{
public enum StatusForApi
public interface IContextProvider
{
PublishedOnly,
All,
Context Context { get; }
}
}

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

@ -1,116 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Security.Claims;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
namespace Squidex.Domain.Apps.Entities
{
public sealed class QueryContext : Cloneable<QueryContext>
{
private static readonly char[] Separators = { ',', ';' };
public ClaimsPrincipal User { get; private set; }
public IAppEntity App { get; private set; }
public bool Flatten { get; set; }
public StatusForApi ApiStatus { get; private set; }
public IReadOnlyCollection<string> AssetUrlsToResolve { get; private set; }
public IReadOnlyCollection<Language> Languages { get; private set; }
private QueryContext()
{
}
public static QueryContext Create(IAppEntity app, ClaimsPrincipal user)
{
return new QueryContext { App = app, User = user };
}
public QueryContext WithFlatten(bool flatten)
{
return Clone(c => c.Flatten = flatten);
}
public QueryContext WithUnpublished(bool unpublished)
{
return WithApiStatus(unpublished ? StatusForApi.All : StatusForApi.PublishedOnly);
}
public QueryContext WithApiStatus(StatusForApi status)
{
return Clone(c => c.ApiStatus = status);
}
public QueryContext WithAssetUrlsToResolve(IEnumerable<string> fieldNames)
{
if (fieldNames != null)
{
return Clone(c =>
{
var fields = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
c.AssetUrlsToResolve?.Foreach(x => fields.Add(x));
foreach (var part in fieldNames)
{
foreach (var fieldName in part.Split(Separators, StringSplitOptions.RemoveEmptyEntries))
{
fields.Add(fieldName.Trim());
}
}
c.AssetUrlsToResolve = fields;
});
}
return this;
}
public QueryContext WithLanguages(IEnumerable<string> languageCodes)
{
if (languageCodes != null)
{
return Clone(c =>
{
var languages = new HashSet<Language>();
c.Languages?.Foreach(x => languages.Add(x));
foreach (var part in languageCodes)
{
foreach (var iso2Code in part.Split(Separators, StringSplitOptions.RemoveEmptyEntries))
{
if (Language.TryGetLanguage(iso2Code.Trim(), out var language))
{
languages.Add(language);
}
}
}
c.Languages = languages;
});
}
return this;
}
public bool IsFrontendClient
{
get { return User.IsInClient("squidex-frontend"); }
}
}
}

18
src/Squidex.Domain.Apps.Events/Apps/AppWorkflowConfigured.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events.Apps
{
[EventType(nameof(AppWorkflowConfigured))]
public sealed class AppWorkflowConfigured : AppEvent
{
public Workflow Workflow { get; set; }
}
}

12
src/Squidex.Infrastructure/Json/Newtonsoft/ConverterContractResolver.cs

@ -36,6 +36,18 @@ namespace Squidex.Infrastructure.Json.Newtonsoft
}
}
protected override JsonDictionaryContract CreateDictionaryContract(Type objectType)
{
if (objectType.IsGenericType && objectType.GetGenericTypeDefinition() == typeof(IReadOnlyDictionary<,>))
{
var implementationType = typeof(Dictionary<,>).MakeGenericType(objectType.GetGenericArguments());
return base.CreateDictionaryContract(implementationType);
}
return base.CreateDictionaryContract(objectType);
}
protected override JsonConverter ResolveContractConverter(Type objectType)
{
var result = base.ResolveContractConverter(objectType);

6
src/Squidex.Shared/Permissions.cs

@ -81,6 +81,12 @@ namespace Squidex.Shared
public const string AppPatternsUpdate = "squidex.apps.{app}.patterns.update";
public const string AppPatternsDelete = "squidex.apps.{app}.patterns.delete";
public const string AppWorkflows = "squidex.apps.{app}.workflows";
public const string AppWorkflowsRead = "squidex.apps.{app}.workflows.read";
public const string AppWorkflowsCreate = "squidex.apps.{app}.workflows.create";
public const string AppWorkflowsUpdate = "squidex.apps.{app}.workflows.update";
public const string AppWorkflowsDelete = "squidex.apps.{app}.workflows.delete";
public const string AppBackups = "squidex.apps.{app}.backups";
public const string AppBackupsRead = "squidex.apps.{app}.backups.read";
public const string AppBackupsCreate = "squidex.apps.{app}.backups.create";

12
src/Squidex.Web/ApiController.cs

@ -8,6 +8,7 @@
using System;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
@ -26,17 +27,22 @@ namespace Squidex.Web
{
get
{
var appFeature = HttpContext.Features.Get<IAppFeature>();
var app = HttpContext.Context().App;
if (appFeature == null)
if (app == null)
{
throw new InvalidOperationException("Not in a app context.");
}
return appFeature.App;
return app;
}
}
protected Context Context
{
get { return HttpContext.Context(); }
}
protected Guid AppId
{
get { return App.Id; }

25
src/Squidex.Web/ApiPermissionAttribute.cs

@ -36,23 +36,26 @@ namespace Squidex.Web
{
if (permissionIds.Length > 0)
{
var set = context.HttpContext.User.Permissions();
var permissions = context.HttpContext.Context().Permissions;
var hasPermission = false;
foreach (var permissionId in permissionIds)
if (permissions != null)
{
var id = permissionId;
foreach (var routeParam in context.RouteData.Values)
foreach (var permissionId in permissionIds)
{
id = id.Replace($"{{{routeParam.Key}}}", routeParam.Value?.ToString());
}
var id = permissionId;
if (set.Allows(new Permission(id)))
{
hasPermission = true;
break;
foreach (var routeParam in context.RouteData.Values)
{
id = id.Replace($"{{{routeParam.Key}}}", routeParam.Value?.ToString());
}
if (permissions.Allows(new Permission(id)))
{
hasPermission = true;
break;
}
}
}

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

@ -7,7 +7,6 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure;
@ -17,20 +16,15 @@ namespace Squidex.Web.CommandMiddlewares
{
public sealed class EnrichWithAppIdCommandMiddleware : ICommandMiddleware
{
private readonly IHttpContextAccessor httpContextAccessor;
private readonly IContextProvider contextProvider;
public EnrichWithAppIdCommandMiddleware(IHttpContextAccessor httpContextAccessor)
public EnrichWithAppIdCommandMiddleware(IContextProvider contextProvider)
{
this.httpContextAccessor = httpContextAccessor;
this.contextProvider = contextProvider;
}
public Task HandleAsync(CommandContext context, Func<Task> next)
{
if (httpContextAccessor.HttpContext == null)
{
return next();
}
if (context.Command is IAppCommand appCommand && appCommand.AppId == null)
{
var appId = GetAppId();
@ -50,14 +44,14 @@ namespace Squidex.Web.CommandMiddlewares
private NamedId<Guid> GetAppId()
{
var appFeature = httpContextAccessor.HttpContext.Features.Get<IAppFeature>();
var context = contextProvider.Context;
if (appFeature?.App == null)
if (context.App == null)
{
throw new InvalidOperationException("Cannot resolve app.");
}
return appFeature.App.NamedId();
return context.App.NamedId();
}
}
}

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

@ -65,12 +65,7 @@ namespace Squidex.Web.CommandMiddlewares
if (appId == null)
{
var appFeature = actionContextAccessor.ActionContext.HttpContext.Features.Get<IAppFeature>();
if (appFeature?.App != null)
{
appId = appFeature.App.NamedId();
}
appId = actionContextAccessor.ActionContext.HttpContext.Context().App?.NamedId();
}
if (appId != null)

37
src/Squidex.Web/ContextExtensions.cs

@ -0,0 +1,37 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Http;
using Squidex.Domain.Apps.Entities;
namespace Squidex.Web
{
public static class ContextExtensions
{
public static Context Context(this HttpContext httpContext)
{
var context = httpContext.Features.Get<Context>();
if (context == null)
{
context = new Context { User = httpContext.User };
foreach (var header in httpContext.Request.Headers)
{
if (header.Key.StartsWith("X-", System.StringComparison.Ordinal))
{
context.Headers.Add(header.Key, header.Value.ToString());
}
}
httpContext.Features.Set(context);
}
return context;
}
}
}

30
src/Squidex.Web/ContextProvider.cs

@ -0,0 +1,30 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Http;
using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure;
namespace Squidex.Web
{
public sealed class ContextProvider : IContextProvider
{
private readonly IHttpContextAccessor httpContextAccessor;
public Context Context
{
get { return httpContextAccessor.HttpContext.Context(); }
}
public ContextProvider(IHttpContextAccessor httpContextAccessor)
{
Guard.NotNull(httpContextAccessor, nameof(httpContextAccessor));
this.httpContextAccessor = httpContextAccessor;
}
}
}

2
src/Squidex.Web/EntityCreatedDto.cs

@ -15,7 +15,7 @@ namespace Squidex.Web
[Required]
[Display(Description = "Id of the created entity.")]
public string Id { get; set; }
[Display(Description = "The new version of the entity.")]
public long Version { get; set; }

4
src/Squidex.Web/ErrorDto.cs

@ -14,10 +14,10 @@ namespace Squidex.Web
[Required]
[Display(Description = "Error message.")]
public string Message { get; set; }
[Display(Description = "Detailed error messages.")]
public string[] Details { get; set; }
[Display(Description = "Status code of the http response.")]
public int? StatusCode { get; set; } = 400;
}

22
src/Squidex.Web/PermissionExtensions.cs

@ -7,34 +7,14 @@
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;
return httpContext.Context().Permissions;
}
public static bool HasPermission(this HttpContext httpContext, Permission permission, PermissionSet permissions = null)

8
src/Squidex.Web/Pipeline/ApiCostsFilter.cs

@ -47,15 +47,15 @@ namespace Squidex.Web.Pipeline
{
context.HttpContext.Features.Set<IApiCostsFeature>(FilterDefinition);
var appFeature = context.HttpContext.Features.Get<IAppFeature>();
var app = context.HttpContext.Context().App;
if (appFeature?.App != null && FilterDefinition.Weight > 0)
if (app != null && FilterDefinition.Weight > 0)
{
var appId = appFeature.App.Id.ToString();
var appId = app.Id.ToString();
using (Profiler.Trace("CheckUsage"))
{
var plan = appPlansProvider.GetPlanForApp(appFeature.App);
var plan = appPlansProvider.GetPlanForApp(app);
var usage = await usageTracker.GetMonthlyCallsAsync(appId, DateTime.Today);

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

@ -23,16 +23,6 @@ namespace Squidex.Web.Pipeline
{
private readonly IAppProvider appProvider;
public class AppFeature : IAppFeature
{
public IAppEntity App { get; }
public AppFeature(IAppEntity app)
{
App = app;
}
}
public AppResolver(IAppProvider appProvider)
{
this.appProvider = appProvider;
@ -76,15 +66,15 @@ namespace Squidex.Web.Pipeline
}
}
var set = user.Permissions();
var permissionSet = user.Permissions();
if (!set.Includes(Permissions.ForApp(Permissions.App, appName))&& !AllowAnonymous(context))
context.HttpContext.Context().App = app;
if (!permissionSet.Includes(Permissions.ForApp(Permissions.App, appName)) && !AllowAnonymous(context))
{
context.Result = new NotFoundResult();
return;
}
context.HttpContext.Features.Set<IAppFeature>(new AppFeature(app));
}
await next();

10
src/Squidex.Web/Pipeline/RequestLogPerformanceMiddleware.cs

@ -47,9 +47,9 @@ namespace Squidex.Web.Pipeline
}
}
private static void LogFilters(HttpContext context, IObjectWriter c)
private static void LogFilters(HttpContext httpContext, IObjectWriter c)
{
var app = context.Features.Get<IAppFeature>()?.App;
var app = httpContext.Context().App;
if (app != null)
{
@ -57,21 +57,21 @@ namespace Squidex.Web.Pipeline
c.WriteProperty("appName", app.Name);
}
var userId = context.User.OpenIdSubject();
var userId = httpContext.User.OpenIdSubject();
if (!string.IsNullOrWhiteSpace(userId))
{
c.WriteProperty(nameof(userId), userId);
}
var clientId = context.User.OpenIdClientId();
var clientId = httpContext.User.OpenIdClientId();
if (!string.IsNullOrWhiteSpace(clientId))
{
c.WriteProperty(nameof(clientId), clientId);
}
var costs = context.Features.Get<IApiCostsFeature>()?.Weight ?? 0;
var costs = httpContext.Features.Get<IApiCostsFeature>()?.Weight ?? 0;
c.WriteProperty(nameof(costs), costs);
}

4
src/Squidex.Web/Resource.cs

@ -5,10 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Newtonsoft.Json;
using Squidex.Infrastructure;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Newtonsoft.Json;
using Squidex.Infrastructure;
namespace Squidex.Web
{

2
src/Squidex.Web/ResourceLink.cs

@ -17,7 +17,7 @@ namespace Squidex.Web
[Required]
[Display(Description = "The link method.")]
public string Method { get; set; }
public string Method { get; set; }
[Required]
[Display(Description = "Additional data about the link.")]

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

@ -9,6 +9,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.2.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Squidex.Domain.Apps.Entities\Squidex.Domain.Apps.Entities.csproj" />

6
src/Squidex.Web/UrlHelperExtensions.cs

@ -5,8 +5,10 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Microsoft.AspNetCore.Mvc;
using System;
using Microsoft.AspNetCore.Mvc;
#pragma warning disable RECS0108 // Warns about static fields in generic types
namespace Squidex.Web
{
@ -22,7 +24,7 @@ namespace Squidex.Web
var name = typeof(T).Name;
if (name.EndsWith(suffix))
if (name.EndsWith(suffix, StringComparison.Ordinal))
{
name = name.Substring(0, name.Length - suffix.Length);
}

86
src/Squidex/Areas/Api/Controllers/Apps/AppWorkflowsController.cs

@ -0,0 +1,86 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Apps.Models;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Infrastructure.Commands;
using Squidex.Shared;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Apps
{
/// <summary>
/// Manages and configures apps.
/// </summary>
[ApiExplorerSettings(GroupName = nameof(Apps))]
public sealed class AppWorkflowsController : ApiController
{
public AppWorkflowsController(ICommandBus commandBus)
: base(commandBus)
{
}
/// <summary>
/// Get app workflow.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => App workflows returned.
/// 404 => App not found.
/// </returns>
[HttpGet]
[Route("apps/{app}/workflow/")]
[ProducesResponseType(typeof(WorkflowResponseDto), 200)]
[ApiPermission(Permissions.AppWorkflowsRead)]
[ApiCosts(0)]
public IActionResult GetWorkflow(string app)
{
var response = WorkflowResponseDto.FromApp(App, this);
Response.Headers[HeaderNames.ETag] = App.Version.ToString();
return Ok(response);
}
/// <summary>
/// Configure workflow of the app.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="request">The new workflow.</param>
/// <returns>
/// 200 => Workflow configured.
/// 400 => Workflow is not valid.
/// 404 => App not found.
/// </returns>
[HttpPut]
[Route("apps/{app}/workflow/")]
[ProducesResponseType(typeof(WorkflowResponseDto), 200)]
[ApiPermission(Permissions.AppWorkflowsUpdate)]
[ApiCosts(1)]
public async Task<IActionResult> PutWorkflow(string app, [FromBody] UpsertWorkflowDto request)
{
var command = request.ToCommand();
var response = await InvokeCommandAsync(command);
return Ok(response);
}
private async Task<WorkflowResponseDto> InvokeCommandAsync(ICommand command)
{
var context = await CommandBus.PublishAsync(command);
var result = context.Result<IAppEntity>();
var response = WorkflowResponseDto.FromApp(result, this);
return response;
}
}
}

5
src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs

@ -177,6 +177,11 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models
AddGetLink("schemas", controller.Url<SchemasController>(x => nameof(x.GetSchemas), values));
}
if (controller.HasPermission(AllPermissions.AppWorkflowsRead, Name, permissions: permissions))
{
AddGetLink("workflows", controller.Url<AppWorkflowsController>(x => nameof(x.GetWorkflow), values));
}
if (controller.HasPermission(AllPermissions.AppSchemasCreate, Name, permissions: permissions))
{
AddPostLink("schemas/create", controller.Url<SchemasController>(x => nameof(x.PostSchema), values));

45
src/Squidex/Areas/Api/Controllers/Apps/Models/UpsertWorkflowDto.cs

@ -0,0 +1,45 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Apps.Commands;
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class UpsertWorkflowDto
{
/// <summary>
/// The workflow steps.
/// </summary>
[Required]
public Dictionary<Status, WorkflowStepDto> Steps { get; set; }
/// <summary>
/// The initial step.
/// </summary>
public Status Initial { get; set; }
public ConfigureWorkflow ToCommand()
{
var workflow = new Workflow(
Steps?.ToDictionary(
x => x.Key,
x => new WorkflowStep(
x.Value?.Transitions.ToDictionary(
y => y.Key,
y => new WorkflowTransition(y.Value.Expression, y.Value.Role)),
x.Value.Color,
x.Value.NoUpdate)),
Initial);
return new ConfigureWorkflow { Workflow = workflow };
}
}
}

61
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowDto.cs

@ -0,0 +1,61 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class WorkflowDto : Resource
{
/// <summary>
/// The workflow steps.
/// </summary>
[Required]
public Dictionary<Status, WorkflowStepDto> Steps { get; set; }
/// <summary>
/// The initial step.
/// </summary>
public Status Initial { get; set; }
public static WorkflowDto FromWorkflow(Workflow workflow, ApiController controller, string app)
{
var result = new WorkflowDto
{
Steps = workflow.Steps.ToDictionary(
x => x.Key,
x => SimpleMapper.Map(x.Value, new WorkflowStepDto
{
Transitions = x.Value.Transitions.ToDictionary(
y => y.Key,
y => new WorkflowTransitionDto { Expression = y.Value.Expression, Role = y.Value.Role })
})),
Initial = workflow.Initial
};
return result.CreateLinks(controller, app);
}
private WorkflowDto CreateLinks(ApiController controller, string app)
{
var values = new { app };
if (controller.HasPermission(Permissions.AppWorkflowsUpdate, app))
{
AddPutLink("update", controller.Url<AppWorkflowsController>(x => nameof(x.PutWorkflow), values));
}
return this;
}
}
}

32
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowResponseDto.cs

@ -0,0 +1,32 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class WorkflowResponseDto : Resource
{
/// <summary>
/// The workflow.
/// </summary>
[Required]
public WorkflowDto Workflow { get; set; }
public static WorkflowResponseDto FromApp(IAppEntity app, ApiController controller)
{
var result = new WorkflowResponseDto
{
Workflow = WorkflowDto.FromWorkflow(app.Workflows.GetFirst(), controller, app.Name)
};
return result;
}
}
}

32
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowStepDto.cs

@ -0,0 +1,32 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class WorkflowStepDto
{
/// <summary>
/// The transitions.
/// </summary>
[Required]
public Dictionary<Status, WorkflowTransitionDto> Transitions { get; set; }
/// <summary>
/// The optional color.
/// </summary>
public string Color { get; set; }
/// <summary>
/// Indicates if updates should not be allowed.
/// </summary>
public bool NoUpdate { get; set; }
}
}

22
src/Squidex/Areas/Api/Controllers/Apps/Models/WorkflowTransitionDto.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Areas.Api.Controllers.Apps.Models
{
public sealed class WorkflowTransitionDto
{
/// <summary>
/// The optional expression.
/// </summary>
public string Expression { get; set; }
/// <summary>
/// The optional restricted role.
/// </summary>
public string Role { get; set; }
}
}

9
src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -101,9 +101,7 @@ namespace Squidex.Areas.Api.Controllers.Assets
[ApiCosts(1)]
public async Task<IActionResult> GetAssets(string app, [FromQuery] string ids = null)
{
var context = Context();
var assets = await assetQuery.QueryAsync(context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids));
var assets = await assetQuery.QueryAsync(Context, Q.Empty.WithODataQuery(Request.QueryString.ToString()).WithIds(ids));
var response = AssetsDto.FromAssets(assets, this, app);
@ -304,10 +302,5 @@ namespace Squidex.Areas.Api.Controllers.Assets
return assetFile;
}
private QueryContext Context()
{
return QueryContext.Create(App, User);
}
}
}

57
src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -62,7 +62,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(2)]
public async Task<IActionResult> PostGraphQL(string app, [FromBody] GraphQLQuery query)
{
var result = await graphQl.QueryAsync(Context(), query);
var result = await graphQl.QueryAsync(Context, query);
if (result.HasError)
{
@ -93,7 +93,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(2)]
public async Task<IActionResult> PostGraphQLBatch(string app, [FromBody] GraphQLQuery[] batch)
{
var result = await graphQl.QueryAsync(Context(), batch);
var result = await graphQl.QueryAsync(Context, batch);
if (result.HasError)
{
@ -124,17 +124,16 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> GetAllContents(string app, [FromQuery] string ids)
{
var context = Context();
var contents = await contentQuery.QueryAsync(context, Q.Empty.WithIds(ids).Ids);
var contents = await contentQuery.QueryAsync(Context, Q.Empty.WithIds(ids).Ids);
var response = await ContentsDto.FromContentsAsync(contents, context, this, null, contentWorkflow);
var response = await ContentsDto.FromContentsAsync(contents, Context, this, null, contentWorkflow);
if (controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys)
{
Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys();
}
Response.Headers[HeaderNames.ETag] = response.ToEtag();
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}";
return Ok(response);
}
@ -159,19 +158,18 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> GetContents(string app, string name, [FromQuery] string ids = null)
{
var context = Context();
var contents = await contentQuery.QueryAsync(context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString()));
var contents = await contentQuery.QueryAsync(Context, name, Q.Empty.WithIds(ids).WithODataQuery(Request.QueryString.ToString()));
var schema = await contentQuery.GetSchemaOrThrowAsync(context, name);
var schema = await contentQuery.GetSchemaOrThrowAsync(Context, name);
var response = await ContentsDto.FromContentsAsync(contents, context, this, schema, contentWorkflow);
var response = await ContentsDto.FromContentsAsync(contents, Context, this, schema, contentWorkflow);
if (ShouldProvideSurrogateKeys(response))
{
Response.Headers["Surrogate-Key"] = response.ToSurrogateKeys();
}
Response.Headers[HeaderNames.ETag] = response.ToEtag();
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}";
return Ok(response);
}
@ -196,17 +194,16 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> GetContent(string app, string name, Guid id)
{
var context = Context();
var content = await contentQuery.FindContentAsync(context, name, id);
var content = await contentQuery.FindContentAsync(Context, name, id);
var response = ContentDto.FromContent(context, content, this);
var response = ContentDto.FromContent(Context, content, this);
if (controllerOptions.Value.EnableSurrogateKeys)
{
Response.Headers["Surrogate-Key"] = content.Id.ToString();
}
Response.Headers[HeaderNames.ETag] = content.Version.ToString();
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}";
return Ok(response);
}
@ -232,17 +229,16 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> GetContentVersion(string app, string name, Guid id, int version)
{
var context = Context();
var content = await contentQuery.FindContentAsync(context, name, id, version);
var content = await contentQuery.FindContentAsync(Context, name, id, version);
var response = ContentDto.FromContent(context, content, this);
var response = ContentDto.FromContent(Context, content, this);
if (controllerOptions.Value.EnableSurrogateKeys)
{
Response.Headers["Surrogate-Key"] = content.Id.ToString();
}
Response.Headers[HeaderNames.ETag] = content.Version.ToString();
Response.Headers[HeaderNames.ETag] = $"{response.ToEtag()}_{App.Version}";
return Ok(response.Data);
}
@ -269,7 +265,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PostContent(string app, string name, [FromBody] NamedContentData request, [FromQuery] bool publish = false)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
if (publish && !this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{
@ -306,7 +302,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PutContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
var command = new UpdateContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
@ -338,7 +334,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PatchContent(string app, string name, Guid id, [FromBody] NamedContentData request, [FromQuery] bool asDraft = false)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
var command = new PatchContent { ContentId = id, Data = request.ToCleaned(), AsDraft = asDraft };
@ -369,7 +365,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> PutContentStatus(string app, string name, Guid id, ChangeStatusDto request)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
if (!this.HasPermission(Helper.StatusPermission(app, name, Status.Published)))
{
@ -404,7 +400,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> DiscardDraft(string app, string name, Guid id)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
var command = new DiscardChanges { ContentId = id };
@ -432,7 +428,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(1)]
public async Task<IActionResult> DeleteContent(string app, string name, Guid id)
{
await contentQuery.GetSchemaOrThrowAsync(Context(), name);
await contentQuery.GetSchemaOrThrowAsync(Context, name);
var command = new DeleteContent { ContentId = id };
@ -446,20 +442,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
var context = await CommandBus.PublishAsync(command);
var result = context.Result<IEnrichedContentEntity>();
var response = ContentDto.FromContent(null, result, this);
var response = ContentDto.FromContent(Context, result, this);
return response;
}
private QueryContext Context()
{
return QueryContext.Create(App, User)
.WithAssetUrlsToResolve(Request.Headers["X-Resolve-Urls"])
.WithFlatten(Request.Headers.ContainsKey("X-Flatten"))
.WithLanguages(Request.Headers["X-Languages"])
.WithUnpublished(Request.Headers.ContainsKey("X-Unpublished"));
}
private bool ShouldProvideSurrogateKeys(ContentsDto response)
{
return controllerOptions.Value.EnableSurrogateKeys && response.Items.Length <= controllerOptions.Value.MaxItemsForSurrogateKeys;

13
src/Squidex/Areas/Api/Controllers/Contents/Models/ContentDto.cs

@ -84,11 +84,11 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// </summary>
public long Version { get; set; }
public static ContentDto FromContent(QueryContext context, IEnrichedContentEntity content, ApiController controller)
public static ContentDto FromContent(Context context, IEnrichedContentEntity content, ApiController controller)
{
var response = SimpleMapper.Map(content, new ContentDto());
if (context?.Flatten == true)
if (context.IsFlatten())
{
response.Data = content.Data?.ToFlatten();
response.DataDraft = content.DataDraft?.ToFlatten();
@ -153,11 +153,14 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
AddDeleteLink("delete", controller.Url<ContentsController>(x => nameof(x.DeleteContent), values));
}
foreach (var next in content.Nexts)
if (content.Nexts != null)
{
if (controller.HasPermission(Helper.StatusPermission(app, schema, next.Status)))
foreach (var next in content.Nexts)
{
AddPutLink($"status/{next.Status}", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values), next.Color);
if (controller.HasPermission(Helper.StatusPermission(app, schema, next.Status)))
{
AddPutLink($"status/{next.Status}", controller.Url<ContentsController>(x => nameof(x.PutContentStatus), values), next.Color);
}
}
}

2
src/Squidex/Areas/Api/Controllers/Contents/Models/ContentsDto.cs

@ -48,7 +48,7 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
}
public static async Task<ContentsDto> FromContentsAsync(IResultList<IEnrichedContentEntity> contents,
QueryContext context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow)
Context context, ApiController controller, ISchemaEntity schema, IContentWorkflow contentWorkflow)
{
var result = new ContentsDto
{

3
src/Squidex/Areas/Api/Controllers/UI/UIController.cs

@ -16,7 +16,6 @@ using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
using Squidex.Shared.Identity;
using Squidex.Web;
namespace Squidex.Areas.Api.Controllers.UI
@ -53,7 +52,7 @@ namespace Squidex.Areas.Api.Controllers.UI
MapKey = uiOptions.Map?.GoogleMaps?.Key
};
var canCreateApps = !uiOptions.OnlyAdminsCanCreateApps || User.Permissions().Includes(CreateAppPermission);
var canCreateApps = !uiOptions.OnlyAdminsCanCreateApps || Context.Permissions.Includes(CreateAppPermission);
result.CanCreateApps = canCreateApps;

2
src/Squidex/Config/Domain/EntitiesServices.cs

@ -120,7 +120,7 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<SchemaHistoryEventsCreator>()
.As<IHistoryEventsCreator>();
services.AddSingletonAs<DefaultContentWorkflow>()
services.AddSingletonAs<DynamicContentWorkflow>()
.AsOptional<IContentWorkflow>();
services.AddSingletonAs<RolePermissionsProvider>()

3
src/Squidex/Config/Domain/SerializationServices.cs

@ -46,7 +46,8 @@ namespace Squidex.Config.Domain
new RuleConverter(),
new SchemaConverter(),
new StatusConverter(),
new StringEnumConverter());
new StringEnumConverter(),
new WorkflowConverter());
settings.NullValueHandling = NullValueHandling.Ignore;

4
src/Squidex/Config/Web/WebServices.cs

@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Config.Domain;
using Squidex.Domain.Apps.Entities;
using Squidex.Pipeline.Plugins;
using Squidex.Pipeline.Robots;
using Squidex.Web;
@ -41,6 +42,9 @@ namespace Squidex.Config.Web
services.AddSingletonAs<RequestLogPerformanceMiddleware>()
.AsSelf();
services.AddSingletonAs<ContextProvider>()
.As<IContextProvider>();
services.AddSingletonAs<ApiPermissionUnifier>()
.As<IClaimsTransformation>();

2
src/Squidex/app/features/administration/module.ts

@ -87,4 +87,4 @@ const routes: Routes = [
UsersState
]
})
export class SqxFeatureAdministrationModule { }
export class SqxFeatureAdministrationModule {}

34
src/Squidex/app/features/administration/services/event-consumers.service.ts

@ -62,12 +62,12 @@ export class EventConsumersService {
const url = this.apiUrl.buildUrl('/api/event-consumers');
return this.http.get<{ items: any[] } & Resource>(url).pipe(
map(({ items, _links }) => {
const eventConsumers = items.map(item => parseEventConsumer(item));
map(({ items, _links }) => {
const eventConsumers = items.map(item => parseEventConsumer(item));
return new EventConsumersDto(eventConsumers, _links);
}),
pretifyError('Failed to load event consumers. Please reload.'));
return new EventConsumersDto(eventConsumers, _links);
}),
pretifyError('Failed to load event consumers. Please reload.'));
}
public putStart(eventConsumer: Resource): Observable<EventConsumerDto> {
@ -76,10 +76,10 @@ export class EventConsumersService {
const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe(
map(body => {
return parseEventConsumer(body);
}),
pretifyError('Failed to start event consumer. Please reload.'));
map(body => {
return parseEventConsumer(body);
}),
pretifyError('Failed to start event consumer. Please reload.'));
}
public putStop(eventConsumer: Resource): Observable<EventConsumerDto> {
@ -88,10 +88,10 @@ export class EventConsumersService {
const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe(
map(body => {
return parseEventConsumer(body);
}),
pretifyError('Failed to stop event consumer. Please reload.'));
map(body => {
return parseEventConsumer(body);
}),
pretifyError('Failed to stop event consumer. Please reload.'));
}
public putReset(eventConsumer: Resource): Observable<EventConsumerDto> {
@ -100,10 +100,10 @@ export class EventConsumersService {
const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe(
map(body => {
return parseEventConsumer(body);
}),
pretifyError('Failed to reset event consumer. Please reload.'));
map(body => {
return parseEventConsumer(body);
}),
pretifyError('Failed to reset event consumer. Please reload.'));
}
}

52
src/Squidex/app/features/administration/services/users.service.ts

@ -19,7 +19,7 @@ import {
ResultSet
} from '@app/shared';
export class UsersDto extends ResultSet<UserDto> {
export class UsersDto extends ResultSet<UserDto> {
public get canCreate() {
return hasAnyLink(this._links, 'create');
}
@ -73,32 +73,32 @@ export class UsersService {
const url = this.apiUrl.buildUrl(`api/user-management?take=${take}&skip=${skip}&query=${query || ''}`);
return this.http.get<{ total: number, items: any[] } & Resource>(url).pipe(
map(({ total, items, _links }) => {
const users = items.map(item => parseUser(item));
map(({ total, items, _links }) => {
const users = items.map(item => parseUser(item));
return new UsersDto(total, users, _links);
}),
pretifyError('Failed to load users. Please reload.'));
return new UsersDto(total, users, _links);
}),
pretifyError('Failed to load users. Please reload.'));
}
public getUser(id: string): Observable<UserDto> {
const url = this.apiUrl.buildUrl(`api/user-management/${id}`);
return this.http.get(url).pipe(
map(body => {
return parseUser(body);
}),
pretifyError('Failed to load user. Please reload.'));
map(body => {
return parseUser(body);
}),
pretifyError('Failed to load user. Please reload.'));
}
public postUser(dto: CreateUserDto): Observable<UserDto> {
const url = this.apiUrl.buildUrl('api/user-management');
return this.http.post(url, dto).pipe(
map(body => {
return parseUser(body);
}),
pretifyError('Failed to create user. Please reload.'));
map(body => {
return parseUser(body);
}),
pretifyError('Failed to create user. Please reload.'));
}
public putUser(user: Resource, dto: UpdateUserDto): Observable<UserDto> {
@ -107,10 +107,10 @@ export class UsersService {
const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url, { body: dto }).pipe(
map(body => {
return parseUser(body);
}),
pretifyError('Failed to update user. Please reload.'));
map(body => {
return parseUser(body);
}),
pretifyError('Failed to update user. Please reload.'));
}
public lockUser(user: Resource): Observable<UserDto> {
@ -119,10 +119,10 @@ export class UsersService {
const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe(
map(body => {
return parseUser(body);
}),
pretifyError('Failed to load users. Please retry.'));
map(body => {
return parseUser(body);
}),
pretifyError('Failed to load users. Please retry.'));
}
public unlockUser(user: Resource): Observable<UserDto> {
@ -131,10 +131,10 @@ export class UsersService {
const url = this.apiUrl.buildUrl(link.href);
return this.http.request(link.method, url).pipe(
map(body => {
return parseUser(body);
}),
pretifyError('Failed to load users. Please retry.'));
map(body => {
return parseUser(body);
}),
pretifyError('Failed to load users. Please retry.'));
}
}

2
src/Squidex/app/features/api/module.ts

@ -45,4 +45,4 @@ const routes: Routes = [
GraphQLPageComponent
]
})
export class SqxFeatureApiModule { }
export class SqxFeatureApiModule {}

2
src/Squidex/app/features/apps/module.ts

@ -35,4 +35,4 @@ const routes: Routes = [
OnboardingDialogComponent
]
})
export class SqxFeatureAppsModule { }
export class SqxFeatureAppsModule {}

2
src/Squidex/app/features/assets/module.ts

@ -39,4 +39,4 @@ const routes: Routes = [
AssetsPageComponent
]
})
export class SqxFeatureAssetsModule { }
export class SqxFeatureAssetsModule {}

2
src/Squidex/app/features/content/module.ts

@ -128,4 +128,4 @@ const routes: Routes = [
SchemasPageComponent
]
})
export class SqxFeatureContentModule { }
export class SqxFeatureContentModule {}

2
src/Squidex/app/features/dashboard/module.ts

@ -33,4 +33,4 @@ const routes: Routes = [
DashboardPageComponent
]
})
export class SqxFeatureDashboardModule { }
export class SqxFeatureDashboardModule {}

2
src/Squidex/app/features/rules/module.ts

@ -66,4 +66,4 @@ const routes: Routes = [
UsageTriggerComponent
]
})
export class SqxFeatureRulesModule { }
export class SqxFeatureRulesModule {}

2
src/Squidex/app/features/schemas/module.ts

@ -112,4 +112,4 @@ const routes: Routes = [
TagsValidationComponent
]
})
export class SqxFeatureSchemasModule { }
export class SqxFeatureSchemasModule {}

4
src/Squidex/app/features/schemas/pages/schema/schema-page.component.html

@ -25,10 +25,10 @@
</button>
<div class="dropdown-menu" *sqxModalView="editOptionsDropdown;closeAlways:true" [sqxModalTarget]="buttonOptions" @fade>
<a class="dropdown-item" (click)="configureScriptsDialog.show()">
Edit Scripts
Configure Scripts
</a>
<a class="dropdown-item" (click)="configurePreviewUrlsDialog.show()">
Edit Preview Urls
Configure Preview Urls
</a>
<ng-container *ngIf="schemasState.canCreate">

3
src/Squidex/app/features/settings/declarations.ts

@ -18,5 +18,8 @@ export * from './pages/patterns/patterns-page.component';
export * from './pages/plans/plans-page.component';
export * from './pages/roles/role.component';
export * from './pages/roles/roles-page.component';
export * from './pages/workflows/workflow-step.component';
export * from './pages/workflows/workflow-transition.component';
export * from './pages/workflows/workflows-page.component';
export * from './settings-area.component';

25
src/Squidex/app/features/settings/module.ts

@ -30,7 +30,10 @@ import {
PlansPageComponent,
RoleComponent,
RolesPageComponent,
SettingsAreaComponent
SettingsAreaComponent,
WorkflowsPageComponent,
WorkflowStepComponent,
WorkflowTransitionComponent
} from './declarations';
const routes: Routes = [
@ -170,6 +173,19 @@ const routes: Routes = [
}
}
]
},
{
path: 'workflows',
component: WorkflowsPageComponent,
children: [
{
path: 'help',
component: HelpComponent,
data: {
helpPage: '05-integrated/workflows'
}
}
]
}
]
}
@ -196,7 +212,10 @@ const routes: Routes = [
PlansPageComponent,
RoleComponent,
RolesPageComponent,
SettingsAreaComponent
SettingsAreaComponent,
WorkflowsPageComponent,
WorkflowTransitionComponent,
WorkflowStepComponent
]
})
export class SqxFeatureSettingsModule { }
export class SqxFeatureSettingsModule {}

76
src/Squidex/app/features/settings/pages/workflows/workflow-step.component.html

@ -0,0 +1,76 @@
<div class="step">
<div class="row no-gutters step-header">
<div class="col-auto">
<button class="btn btn-initial mr-1" (click)="makeInitial.emit()"
[class.enabled]="step.name !== workflow.initial && !step.isLocked"
[class.active]="step.name === workflow.initial"
[disabled]="step.name === workflow.initial || step.isLocked || disabled">
<i class="icon-arrow-right text-decent" *ngIf="!step.isLocked"></i>
</button>
</div>
<div class="col-auto color pr-2">
<sqx-color-picker mode="Circle"
[ngModelOptions]="onBlur"
[ngModel]="step.color"
(ngModelChange)="changeColor($event)"
[disabled]="step.isLocked || disabled">
</sqx-color-picker>
</div>
<div class="col">
<sqx-editable-title
[name]="step.name"
(nameChanged)="changeName($event)"
[disabled]="step.isLocked || disabled">
</sqx-editable-title>
</div>
<div class="col">
</div>
<div class="col-auto" *ngIf="step.isLocked">
<small class="text-decent">(Cannot be removed)</small>
</div>
<div class="col-auto">
<button type="button" class="btn btn-text-danger" (click)="remove.emit()" *ngIf="!step.isLocked" [disabled]="disabled">
<i class="icon-bin2"></i>
</button>
</div>
</div>
<div class="step-inner">
<sqx-workflow-transition *ngFor="let transition of transitions; trackBy: trackByTransition"
[transition]="transition"
[disabled]="disabled"
[roles]="roles"
(remove)="transitionRemove.emit(transition)"
(update)="changeTransition(transition, $event)">
</sqx-workflow-transition>
<div class="row transition no-gutters" *ngIf="openSteps.length > 0 && !disabled">
<div class="col-auto">
<i class="icon-corner-down-right text-decent"></i>
</div>
<div class="col col-step">
<sqx-dropdown [items]="openSteps" [(ngModel)]="openStep">
<ng-template let-target="$implicit">
<div class="color-circle" [style.background]="target.color"></div> {{target.name}}
</ng-template>
</sqx-dropdown>
</div>
<div class="col pl-2">
<button class="btn btn-outline-secondary" (click)="transitionAdd.emit(openStep)">
<i class="icon-plus"></i>
</button>
</div>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="preventUpdates_{{step.name}}"
[disabled]="disabled"
[ngModel]="step.noUpdate"
(ngModelChange)="changeNoUpdate($event)" />
<label class="form-check-label" for="preventUpdates_{{step.name}}">
Prevent updates
</label>
</div>
</div>
</div>

76
src/Squidex/app/features/settings/pages/workflows/workflow-step.component.scss

@ -0,0 +1,76 @@
@import '_vars';
@import '_mixins';
:host ::ng-deep {
.color {
line-height: 2.8rem;
}
.color-circle {
@include circle(12px);
border: 1px solid $color-border-dark;
background: $color-border;
display: inline-block;
}
.col-label {
padding: 0 .5rem;
}
.col-step {
min-width: 10rem;
max-width: 10rem;
padding-left: .5rem;
}
.transition {
& {
margin-top: .25rem;
margin-bottom: .5rem;
line-height: 2.2rem;
}
&-to {
padding: .5rem .75rem;
padding-right: 0;
line-height: 1.2rem;
}
}
}
.step {
& {
margin-bottom: 1.5rem;
}
&-inner {
padding-left: 2.4rem;
}
&-header {
&:hover {
.enabled {
visibility: visible;
}
}
}
}
.btn-initial {
& {
visibility: hidden;
line-height: 1.6rem;
padding-left: 0;
padding-right: 0;
width: 2rem;
text-align: center;
}
&:disabled {
@include opacity(1);
}
&.active {
visibility: visible;
}
}

95
src/Squidex/app/features/settings/pages/workflows/workflow-step.component.ts

@ -0,0 +1,95 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import {
RoleDto,
WorkflowDto,
WorkflowStep,
WorkflowStepValues,
WorkflowTransition,
WorkflowTransitionValues,
WorkflowTransitionView
} from '@app/shared';
@Component({
selector: 'sqx-workflow-step',
styleUrls: ['./workflow-step.component.scss'],
templateUrl: './workflow-step.component.html'
})
export class WorkflowStepComponent implements OnChanges {
@Input()
public workflow: WorkflowDto;
@Input()
public step: WorkflowStep;
@Input()
public roles: RoleDto[];
@Input()
public disabled: boolean;
@Output()
public makeInitial = new EventEmitter();
@Output()
public transitionAdd = new EventEmitter<WorkflowStep>();
@Output()
public transitionRemove = new EventEmitter<WorkflowTransition>();
@Output()
public transitionUpdate = new EventEmitter<{ transition: WorkflowTransition, values: WorkflowTransitionValues }>();
@Output()
public update = new EventEmitter<WorkflowStepValues>();
@Output()
public rename = new EventEmitter<string>();
@Output()
public remove = new EventEmitter();
public onBlur = { updateOn: 'blur' };
public openSteps: WorkflowStep[];
public openStep: WorkflowStep;
public transitions: WorkflowTransitionView[];
public ngOnChanges(changes: SimpleChanges) {
if (changes['workflow'] || changes['step'] || false) {
this.openSteps = this.workflow.getOpenSteps(this.step);
this.openStep = this.openSteps[0];
this.transitions = this.workflow.getTransitions(this.step);
}
}
public changeTransition(transition: WorkflowTransition, values: WorkflowTransitionValues) {
this.transitionUpdate.emit({ transition, values });
}
public changeName(name: string) {
this.rename.emit(name);
}
public changeColor(color: string) {
this.update.emit({ color });
}
public changeNoUpdate(noUpdate: boolean) {
this.update.emit({ noUpdate });
}
public trackByTransition(index: number, transition: WorkflowTransition) {
return transition.to;
}
}

40
src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.html

@ -0,0 +1,40 @@
<div class="row transition no-gutters">
<div class="col-auto">
<i class="icon-corner-down-right text-decent"></i>
</div>
<div class="col col-step">
<div class="transition-to">
<div class="color-circle" [style.background]="transition.step.color"></div> {{transition.to}}
</div>
</div>
<div class="col-auto col-label">
<span class="text-decent">when</span>
</div>
<div class="col">
<input class="form-control" [class.dashed]="!transition.expression" spellcheck="false"
[disabled]="disabled"
[ngModelOptions]="onBlur"
[ngModel]="transition.expression"
(ngModelChange)="changeExpression($event)"
placeholder="Add Expression" />
</div>
<div class="col-auto col-label">
<span class="text-decent">for</span>
</div>
<div class="col">
<select class="form-control" [class.dashed]="!transition.role || transition.role === ''"
[disabled]="disabled"
[ngModel]="transition.role"
(ngModelChange)="changeRole($event)">
<option></option>
<option *ngFor="let role of roles; trackBy: trackByRole" [ngValue]="role.name">{{role.name}}</option>
</select>
<span class="select-placeholder" *ngIf="!transition.role || transition.role === ''">Add Role</span>
</div>
<div class="col-auto pl-2">
<button type="button" class="btn btn-text-danger" (click)="remove.emit()" [disabled]="disabled">
<i class="icon-bin2"></i>
</button>
</div>
</div>

24
src/Squidex/app/features/settings/pages/workflows/workflow-transition.component.scss

@ -0,0 +1,24 @@
@import '_vars';
@import '_mixins';
.dashed {
@include placeholder-color($color-theme-secondary);
border-style: dashed;
border-width: 1px;
}
.select-placeholder {
@include absolute(0, 0, 0, 0);
color: $color-theme-secondary;
padding: .5rem .75rem;
pointer-events: none;
line-height: 1.2rem;
border: 1px solid transparent;
}
.form-control {
&:disabled,
&.disabled {
background: $panel-light-background;
}
}

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

Loading…
Cancel
Save