From 5d1c322f04db5c7bc3e2bbdd08c2dc153cc152c2 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Fri, 1 Feb 2019 19:12:03 +0100 Subject: [PATCH 1/7] Improve swagger. --- .../Schemas/FieldCollection.cs | 2 +- .../ValidateContent/ContentValidator.cs | 2 +- .../ValidateContent/ValidatorsFactory.cs | 2 +- .../Apps/Indexes/AppsByNameIndexGrain.cs | 2 +- .../Areas/Api/Controllers/Assets/AssetContentController.cs | 2 +- .../Api/Controllers/Backups/BackupContentController.cs | 2 +- .../Areas/Api/Controllers/Backups/BackupsController.cs | 2 +- .../Areas/Api/Controllers/Backups/RestoreController.cs | 7 ++++++- src/Squidex/Areas/Api/Controllers/Ping/PingController.cs | 2 +- src/Squidex/Areas/Api/Controllers/Users/UsersController.cs | 2 +- 10 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs index 3e865aeea..d09841b55 100644 --- a/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs +++ b/src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs @@ -61,7 +61,7 @@ namespace Squidex.Domain.Apps.Core.Schemas } else { - fieldsByName = fieldsOrdered.ToDictionary(x => x.Name, StringComparer.OrdinalIgnoreCase); + fieldsByName = fieldsOrdered.ToDictionary(x => x.Name); } } diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs index ab089b5d7..11b072b8c 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs @@ -69,7 +69,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent private IValidator CreateSchemaValidator(bool isPartial) { - var fieldsValidators = new Dictionary(); + var fieldsValidators = new Dictionary(schema.Fields.Count); foreach (var field in schema.FieldsByName) { diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs index afd76321d..464ad7748 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ValidatorsFactory.cs @@ -38,7 +38,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent yield return new CollectionValidator(field.Properties.IsRequired, field.Properties.MinItems, field.Properties.MaxItems); } - var nestedSchema = new Dictionary(); + var nestedSchema = new Dictionary(field.Fields.Count); foreach (var nestedField in field.Fields) { diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexGrain.cs index a76be1ae3..ffd7d333a 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsByNameIndexGrain.cs @@ -23,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [CollectionName("Index_AppsByName")] public sealed class GrainState { - public Dictionary Apps { get; set; } = new Dictionary(); + public Dictionary Apps { get; set; } = new Dictionary(StringComparer.Ordinal); } public AppsByNameIndexGrain(IStore store) diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index f0cfeccbf..6f243f8bf 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -57,7 +57,7 @@ namespace Squidex.Areas.Api.Controllers.Assets /// [HttpGet] [Route("assets/{id}/")] - [ProducesResponseType(200)] + [ProducesResponseType(typeof(FileResult), 200)] [ApiCosts(0.5)] public async Task GetAssetContent(Guid id, [FromQuery] long version = EtagVersion.Any, [FromQuery] int? width = null, [FromQuery] int? height = null, [FromQuery] string mode = null) { diff --git a/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs b/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs index f94233fad..ba2dbe410 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/BackupContentController.cs @@ -40,7 +40,7 @@ namespace Squidex.Areas.Api.Controllers.Backups [HttpGet] [Route("apps/{app}/backups/{id}")] [ResponseCache(Duration = 3600 * 24 * 30)] - [ProducesResponseType(200)] + [ProducesResponseType(typeof(FileResult), 200)] [ApiCosts(0)] [AllowAnonymous] public IActionResult GetBackupContent(string app, Guid id) diff --git a/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs b/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs index 5819a1a16..3c433fbdb 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/BackupsController.cs @@ -21,7 +21,7 @@ using Squidex.Shared; namespace Squidex.Areas.Api.Controllers.Backups { /// - /// Manages backups for app. + /// Manages backups for apps. /// [ApiExplorerSettings(GroupName = nameof(Backups))] public class BackupsController : ApiController diff --git a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs index 273bacc90..a8a63b02b 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs @@ -17,6 +17,10 @@ using Squidex.Shared; namespace Squidex.Areas.Api.Controllers.Backups { + /// + /// Manages backups for apps. + /// + [ApiExplorerSettings(GroupName = nameof(Backups))] public class RestoreController : ApiController { private readonly IGrainFactory grainFactory; @@ -28,13 +32,14 @@ namespace Squidex.Areas.Api.Controllers.Backups } /// - /// Get current status. + /// Get current restore status. /// /// /// 200 => Status returned. /// [HttpGet] [Route("apps/restore/")] + [ProducesResponseType(typeof(RestoreJobDto), 200)] [ApiPermission(Permissions.AdminRestoreRead)] public async Task GetJob() { diff --git a/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs b/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs index fd72eee9a..c27e14477 100644 --- a/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs +++ b/src/Squidex/Areas/Api/Controllers/Ping/PingController.cs @@ -53,7 +53,7 @@ namespace Squidex.Areas.Api.Controllers.Ping [Route("ping/{app}/")] [ApiPermission(Permissions.AppCommon)] [ApiCosts(0)] - public IActionResult GetPing(string app) + public IActionResult GetAppPing(string app) { return NoContent(); } diff --git a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs index f7983a639..3aef01d84 100644 --- a/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs +++ b/src/Squidex/Areas/Api/Controllers/Users/UsersController.cs @@ -136,7 +136,7 @@ namespace Squidex.Areas.Api.Controllers.Users /// [HttpGet] [Route("users/{id}/picture/")] - [ProducesResponseType(200)] + [ProducesResponseType(typeof(FileResult), 200)] [ResponseCache(Duration = 3600)] public async Task GetUserPicture(string id) { From c437d096eeb724137fe541437de162d51fc9a27f Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 2 Feb 2019 17:01:59 +0100 Subject: [PATCH 2/7] Custom admin client. --- .../Security/Extensions.cs | 5 +++ .../Identity/ClaimsPrincipalExtensions.cs | 11 ++++- .../Identity/SquidexClaimTypes.cs | 4 ++ .../Api/Controllers/Apps/AppsController.cs | 6 +-- .../Controllers/Backups/RestoreController.cs | 4 +- .../IdentityServer/Config/LazyClientStore.cs | 40 ++++++++++++++++--- src/Squidex/Config/Logging.cs | 9 ++++- src/Squidex/Config/MyIdentityOptions.cs | 9 +++++ src/Squidex/Pipeline/Extensions.cs | 11 +++-- src/Squidex/appsettings.json | 10 +++++ 10 files changed, 93 insertions(+), 16 deletions(-) diff --git a/src/Squidex.Infrastructure/Security/Extensions.cs b/src/Squidex.Infrastructure/Security/Extensions.cs index 25b9a8b88..2f28f2fb2 100644 --- a/src/Squidex.Infrastructure/Security/Extensions.cs +++ b/src/Squidex.Infrastructure/Security/Extensions.cs @@ -23,6 +23,11 @@ namespace Squidex.Infrastructure.Security return principal.Claims.FirstOrDefault(x => x.Type == OpenIdClaims.ClientId)?.Value; } + public static string UserOrClientId(this ClaimsPrincipal principal) + { + return principal.OpenIdSubject() ?? principal.OpenIdClientId(); + } + public static string OpenIdPreferredUserName(this ClaimsPrincipal principal) { return principal.Claims.FirstOrDefault(x => x.Type == OpenIdClaims.PreferredUserName)?.Value; diff --git a/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs b/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs index cb4511d41..0995e9a2e 100644 --- a/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs +++ b/src/Squidex.Shared/Identity/ClaimsPrincipalExtensions.cs @@ -27,12 +27,19 @@ namespace Squidex.Shared.Identity public static PermissionSet Permissions(this ClaimsPrincipal principal) { - return new PermissionSet(principal.Claims.Where(x => x.Type == SquidexClaimTypes.Permissions).Select(x => new Permission(x.Value))); + return new PermissionSet(principal.Claims + .Where(x => + x.Type == SquidexClaimTypes.Permissions || + x.Type == SquidexClaimTypes.PermissionsClient) + .Select(x => new Permission(x.Value))); } public static IEnumerable GetSquidexClaims(this ClaimsPrincipal principal) { - return principal.Claims.Where(c => c.Type.StartsWith(SquidexClaimTypes.Prefix, StringComparison.Ordinal)); + return principal.Claims + .Where(x => + x.Type.StartsWith(SquidexClaimTypes.Prefix, StringComparison.Ordinal) || + x.Type.StartsWith(SquidexClaimTypes.PrefixClient, StringComparison.Ordinal)); } } } diff --git a/src/Squidex.Shared/Identity/SquidexClaimTypes.cs b/src/Squidex.Shared/Identity/SquidexClaimTypes.cs index bd32adb0d..32ba4f90c 100644 --- a/src/Squidex.Shared/Identity/SquidexClaimTypes.cs +++ b/src/Squidex.Shared/Identity/SquidexClaimTypes.cs @@ -21,8 +21,12 @@ namespace Squidex.Shared.Identity public static readonly string Permissions = "urn:squidex:permissions"; + public static readonly string PermissionsClient = "client_urn:squidex:permissions"; + public static readonly string Prefix = "urn:squidex:"; + public static readonly string PrefixClient = "client_urn:squidex:"; + public static readonly string PictureUrlStore = "store"; } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 5deacb593..fb765658c 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -57,12 +57,12 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiCosts(0)] public async Task GetApps() { - var userId = HttpContext.User.OpenIdSubject(); + var userOrClientId = HttpContext.User.UserOrClientId(); var userPermissions = HttpContext.User.Permissions(); - var entities = await appProvider.GetUserApps(userId, userPermissions); + var entities = await appProvider.GetUserApps(userOrClientId, userPermissions); - var response = entities.ToArray(a => AppDto.FromApp(a, userId, userPermissions, appPlansProvider)); + var response = entities.ToArray(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider)); Response.Headers[HeaderNames.ETag] = response.ToManyEtag(); diff --git a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs index a8a63b02b..10c519799 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs @@ -43,7 +43,7 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiPermission(Permissions.AdminRestoreRead)] public async Task GetJob() { - var restoreGrain = grainFactory.GetGrain(User.OpenIdSubject()); + var restoreGrain = grainFactory.GetGrain(User.UserOrClientId()); var job = await restoreGrain.GetJobAsync(); @@ -69,7 +69,7 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiPermission(Permissions.AdminRestoreCreate)] public async Task PostRestore([FromBody] RestoreRequest request) { - var restoreGrain = grainFactory.GetGrain(User.OpenIdSubject()); + var restoreGrain = grainFactory.GetGrain(User.UserOrClientId()); await restoreGrain.RestoreAsync(request.Url, request.Name); diff --git a/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs b/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs index adc1451ad..af60dc5e3 100644 --- a/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs +++ b/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; +using System.Security.Claims; using System.Threading.Tasks; using IdentityServer4; using IdentityServer4.Models; @@ -17,6 +18,8 @@ using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities; using Squidex.Infrastructure; using Squidex.Pipeline; +using Squidex.Shared; +using Squidex.Shared.Identity; namespace Squidex.Areas.IdentityServer.Config { @@ -25,14 +28,17 @@ namespace Squidex.Areas.IdentityServer.Config private readonly IAppProvider appProvider; private readonly Dictionary staticClients = new Dictionary(StringComparer.OrdinalIgnoreCase); - public LazyClientStore(IOptions urlsOptions, IAppProvider appProvider) + public LazyClientStore( + IOptions urlsOptions, + IOptions identityOptions, + IAppProvider appProvider) { Guard.NotNull(urlsOptions, nameof(urlsOptions)); Guard.NotNull(appProvider, nameof(appProvider)); this.appProvider = appProvider; - CreateStaticClients(urlsOptions); + CreateStaticClients(urlsOptions, identityOptions); } public async Task FindClientByIdAsync(string clientId) @@ -83,15 +89,15 @@ namespace Squidex.Areas.IdentityServer.Config }; } - private void CreateStaticClients(IOptions urlsOptions) + private void CreateStaticClients(IOptions urlsOptions, IOptions identityOptions) { - foreach (var client in CreateStaticClients(urlsOptions.Value)) + foreach (var client in CreateStaticClients(urlsOptions.Value, identityOptions.Value)) { staticClients[client.ClientId] = client; } } - private static IEnumerable CreateStaticClients(MyUrlsOptions urlsOptions) + private static IEnumerable CreateStaticClients(MyUrlsOptions urlsOptions, MyIdentityOptions identityOptions) { var frontendId = Constants.FrontendClient; @@ -150,6 +156,30 @@ namespace Squidex.Areas.IdentityServer.Config }, RequireConsent = false }; + + if (identityOptions.IsAdminClientConfigured()) + { + var id = identityOptions.AdminClientId; + + yield return new Client + { + ClientId = id, + ClientName = id, + ClientSecrets = new List { new Secret(identityOptions.AdminClientSecret.Sha256()) }, + AccessTokenLifetime = (int)TimeSpan.FromDays(30).TotalSeconds, + AllowedGrantTypes = GrantTypes.ClientCredentials, + AllowedScopes = new List + { + Constants.ApiScope, + Constants.RoleScope, + Constants.PermissionsScope + }, + Claims = new List + { + new Claim(SquidexClaimTypes.Permissions, Permissions.Admin) + } + }; + } } } } diff --git a/src/Squidex/Config/Logging.cs b/src/Squidex/Config/Logging.cs index bea1bbd60..ca61f4a2a 100644 --- a/src/Squidex/Config/Logging.cs +++ b/src/Squidex/Config/Logging.cs @@ -5,6 +5,8 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +#define LOG_ALL_IDENTITY_SERVER_NONE + using System; using Microsoft.Extensions.Logging; @@ -40,7 +42,12 @@ namespace Squidex.Config { return level > LogLevel.Information; } - +#if LOG_ALL_IDENTITY_SERVER + if (category.StartsWith("IdentityServer4.", StringComparison.OrdinalIgnoreCase)) + { + return true; + } +#endif return level >= LogLevel.Information; }); } diff --git a/src/Squidex/Config/MyIdentityOptions.cs b/src/Squidex/Config/MyIdentityOptions.cs index 01a3045b8..e8547ff90 100644 --- a/src/Squidex/Config/MyIdentityOptions.cs +++ b/src/Squidex/Config/MyIdentityOptions.cs @@ -13,6 +13,10 @@ namespace Squidex.Config public string AdminPassword { get; set; } + public string AdminClientId { get; set; } + + public string AdminClientSecret { get; set; } + public string GithubClient { get; set; } public string GithubSecret { get; set; } @@ -48,6 +52,11 @@ namespace Squidex.Config return !string.IsNullOrWhiteSpace(AdminEmail) && !string.IsNullOrWhiteSpace(AdminPassword); } + public bool IsAdminClientConfigured() + { + return !string.IsNullOrWhiteSpace(AdminClientId) && !string.IsNullOrWhiteSpace(AdminClientSecret); + } + public bool IsOidcConfigured() { return !string.IsNullOrWhiteSpace(OidcAuthority) && !string.IsNullOrWhiteSpace(OidcClient) && !string.IsNullOrWhiteSpace(OidcSecret); diff --git a/src/Squidex/Pipeline/Extensions.cs b/src/Squidex/Pipeline/Extensions.cs index 35db046e0..98f693727 100644 --- a/src/Squidex/Pipeline/Extensions.cs +++ b/src/Squidex/Pipeline/Extensions.cs @@ -29,12 +29,17 @@ namespace Squidex.Pipeline { var parts = clientId.Split(':', '~'); - if (parts.Length != 2) + if (parts.Length == 1) { - return (null, null); + return (null, parts[0]); } - return (parts[0], parts[1]); + if (parts.Length == 2) + { + return (parts[0], parts[1]); + } + + return (null, null); } } } diff --git a/src/Squidex/appsettings.json b/src/Squidex/appsettings.json index 1b571af54..14e289162 100644 --- a/src/Squidex/appsettings.json +++ b/src/Squidex/appsettings.json @@ -263,6 +263,16 @@ * Enable password auth. Set this to false if you want to disable local login, leaving only 3rd party login options. */ "allowPasswordAuth": true, + /* + * Initial admin user. + */ + "adminEmail": "", + "adminPassword": "", + /* + * Client with all admin permissions. + */ + "adminClientId": "", + "adminClientSecret": "", /* * Settings for Google auth (keep empty to disable). */ From 085c705c8be1c40ba866be3320d4088b6e234e1d Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 2 Feb 2019 17:51:27 +0100 Subject: [PATCH 3/7] Single restore grain for all clients. --- .../Areas/Api/Controllers/Backups/RestoreController.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs index 10c519799..b9eafaac4 100644 --- a/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs +++ b/src/Squidex/Areas/Api/Controllers/Backups/RestoreController.cs @@ -11,7 +11,7 @@ using Orleans; using Squidex.Areas.Api.Controllers.Backups.Models; using Squidex.Domain.Apps.Entities.Backup; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Security; +using Squidex.Infrastructure.Orleans; using Squidex.Pipeline; using Squidex.Shared; @@ -43,7 +43,7 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiPermission(Permissions.AdminRestoreRead)] public async Task GetJob() { - var restoreGrain = grainFactory.GetGrain(User.UserOrClientId()); + var restoreGrain = grainFactory.GetGrain(SingleGrain.Id); var job = await restoreGrain.GetJobAsync(); @@ -69,7 +69,7 @@ namespace Squidex.Areas.Api.Controllers.Backups [ApiPermission(Permissions.AdminRestoreCreate)] public async Task PostRestore([FromBody] RestoreRequest request) { - var restoreGrain = grainFactory.GetGrain(User.UserOrClientId()); + var restoreGrain = grainFactory.GetGrain(SingleGrain.Id); await restoreGrain.RestoreAsync(request.Url, request.Name); From 1e95e1d2ddd804a00e12f30b282427581fba0d8b Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 3 Feb 2019 14:05:57 +0100 Subject: [PATCH 4/7] Validation fix. --- .../shared/components/permission.directive.ts | 56 ++++++++++++++++--- .../app/shared/state/contents.forms.ts | 2 +- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/Squidex/app/shared/components/permission.directive.ts b/src/Squidex/app/shared/components/permission.directive.ts index 61f92a175..0a7128a84 100644 --- a/src/Squidex/app/shared/components/permission.directive.ts +++ b/src/Squidex/app/shared/components/permission.directive.ts @@ -5,7 +5,7 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Directive, Input, OnChanges, TemplateRef, ViewContainerRef } from '@angular/core'; +import { ChangeDetectorRef, Directive, Input, OnChanges, OnDestroy, OnInit, TemplateRef, ViewContainerRef } from '@angular/core'; import { AppDto, @@ -15,11 +15,15 @@ import { SchemaDto, SchemasState } from '@app/shared/internal'; +import { Subscription } from 'rxjs'; @Directive({ selector: '[sqxPermission]' }) -export class PermissionDirective implements OnChanges { +export class PermissionDirective implements OnChanges, OnInit, OnDestroy { + private selectedAppSubscription: Subscription; + private selectedSchemaSubscription: Subscription; + private viewCreated = false; @Input('sqxPermissionApp') @@ -34,14 +38,54 @@ export class PermissionDirective implements OnChanges { constructor( private readonly authService: AuthService, private readonly appsState: AppsState, + private readonly changeDetector: ChangeDetectorRef, private readonly schemasState: SchemasState, private readonly templateRef: TemplateRef, private readonly viewContainer: ViewContainerRef ) { } + public ngOnDestroy() { + if (this.selectedAppSubscription) { + this.selectedAppSubscription.unsubscribe(); + } + + if (this.selectedSchemaSubscription) { + this.selectedSchemaSubscription.unsubscribe(); + } + } + + public ngOnInit() { + this.selectedAppSubscription = + this.appsState.selectedApp.subscribe(app => { + if (app && !this.app) { + this.update(app, this.schemasState.snapshot.selectedSchema); + } + }); + + this.selectedSchemaSubscription = + this.schemasState.selectedSchema.subscribe(schema => { + if (schema && !this.schema) { + this.update(this.appsState.snapshot.selectedApp, schema); + } + }); + } + public ngOnChanges() { + this.update(this.appsState.snapshot.selectedApp, this.schemasState.snapshot.selectedSchema); + } + + private update(app?: AppDto | null, schema?: SchemaDto | null) { + if (this.app) { + app = this.app; + } + + if (this.schema) { + schema = this.schema; + } + let permissions = this.permissions; + let show = false; if (permissions) { @@ -54,14 +98,10 @@ export class PermissionDirective implements OnChanges { const array = permissions.split(';'); for (let id of array) { - const app = this.app || this.appsState.snapshot.selectedApp; - if (app) { id = id.replace('{app}', app.name); } - const schema = this.schema || this.schemasState.snapshot.selectedSchema; - if (schema) { id = id.replace('{name}', schema.name); } @@ -95,9 +135,11 @@ export class PermissionDirective implements OnChanges { if (show && !this.viewCreated) { this.viewContainer.createEmbeddedView(this.templateRef); this.viewCreated = true; - } else if (show && this.viewCreated) { + } else if (!show && this.viewCreated) { this.viewContainer.clear(); this.viewCreated = false; } + + this.changeDetector.markForCheck(); } } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/contents.forms.ts b/src/Squidex/app/shared/state/contents.forms.ts index 02f96ce36..956d5e255 100644 --- a/src/Squidex/app/shared/state/contents.forms.ts +++ b/src/Squidex/app/shared/state/contents.forms.ts @@ -147,7 +147,7 @@ export class FieldValidatorsFactory implements FieldPropertiesVisitor 0) { From 50e6ca95ab14494191f53c2a3fde4a0de3ba3a35 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 3 Feb 2019 16:32:50 +0100 Subject: [PATCH 5/7] Copy and paste images. --- .../assets/pages/assets-page.component.ts | 2 - .../shared/assets-editor.component.html | 4 +- .../content/shared/assets-editor.component.ts | 22 +------ .../shared/references-editor.component.ts | 2 - .../angular/forms/file-drop.directive.ts | 56 +++++++++++++++++- .../components/assets-list.component.html | 2 +- .../components/assets-list.component.ts | 22 +------ .../components/assets-selector.component.ts | 2 - .../components/markdown-editor.component.html | 6 +- .../components/markdown-editor.component.ts | 47 +++++++++++++++ .../components/rich-editor.component.html | 6 +- .../components/rich-editor.component.ts | 57 ++++++++++++++++++- .../app/shared/state/contents.forms.ts | 1 - 13 files changed, 165 insertions(+), 64 deletions(-) diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.ts b/src/Squidex/app/features/assets/pages/assets-page.component.ts index aa0e805ce..2f795ab59 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.ts +++ b/src/Squidex/app/features/assets/pages/assets-page.component.ts @@ -5,8 +5,6 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -// tslint:disable:prefer-for-of - import { Component, OnInit } from '@angular/core'; import { FormControl } from '@angular/forms'; import { onErrorResumeNext } from 'rxjs/operators'; diff --git a/src/Squidex/app/features/content/shared/assets-editor.component.html b/src/Squidex/app/features/content/shared/assets-editor.component.html index 176b2dfb9..eb98de9f4 100644 --- a/src/Squidex/app/features/content/shared/assets-editor.component.html +++ b/src/Squidex/app/features/content/shared/assets-editor.component.html @@ -1,8 +1,8 @@ -
+
-
+
Drop files or click here to add assets.
diff --git a/src/Squidex/app/features/content/shared/assets-editor.component.ts b/src/Squidex/app/features/content/shared/assets-editor.component.ts index 5a59e5951..bf725821b 100644 --- a/src/Squidex/app/features/content/shared/assets-editor.component.ts +++ b/src/Squidex/app/features/content/shared/assets-editor.component.ts @@ -5,8 +5,6 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -// tslint:disable:prefer-for-of - import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, OnDestroy, OnInit } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Subscription } from 'rxjs'; @@ -127,23 +125,9 @@ export class AssetsEditorComponent implements ControlValueAccessor, OnInit, OnDe this.callTouched = fn; } - public pasteFiles(event: ClipboardEvent) { - for (let i = 0; i < event.clipboardData.items.length; i++) { - const file = event.clipboardData.items[i].getAsFile(); - - if (file) { - this.newAssets = this.newAssets.pushFront(file); - } - } - } - - public addFiles(files: FileList) { - for (let i = 0; i < files.length; i++) { - const file = files[i]; - - if (file) { - this.newAssets = this.newAssets.pushFront(file); - } + public addFiles(files: File[]) { + for (let file of files) { + this.newAssets = this.newAssets.pushFront(file); } } diff --git a/src/Squidex/app/features/content/shared/references-editor.component.ts b/src/Squidex/app/features/content/shared/references-editor.component.ts index bbe28ba16..0fc562f6e 100644 --- a/src/Squidex/app/features/content/shared/references-editor.component.ts +++ b/src/Squidex/app/features/content/shared/references-editor.component.ts @@ -5,8 +5,6 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -// tslint:disable:prefer-for-of - import { ChangeDetectionStrategy, ChangeDetectorRef, Component, forwardRef, Input, OnInit } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; diff --git a/src/Squidex/app/framework/angular/forms/file-drop.directive.ts b/src/Squidex/app/framework/angular/forms/file-drop.directive.ts index 4167ed6be..30bbcff60 100644 --- a/src/Squidex/app/framework/angular/forms/file-drop.directive.ts +++ b/src/Squidex/app/framework/angular/forms/file-drop.directive.ts @@ -5,18 +5,33 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -import { Directive, ElementRef, EventEmitter, HostListener, Output, Renderer2 } from '@angular/core'; +// tslint:disable:prefer-for-of + +import { Directive, ElementRef, EventEmitter, HostListener, Input, Output, Renderer2 } from '@angular/core'; import { Types } from './../../utils/types'; +const ImageTypes = [ + 'image/jpeg', + 'image/png', + 'image/jpg', + 'image/gif' +]; + @Directive({ selector: '[sqxFileDrop]' }) export class FileDropDirective { private dragCounter = 0; + @Input() + public allowedFiles: string[]; + + @Input() + public onlyImages: boolean; + @Output('sqxFileDrop') - public drop = new EventEmitter(); + public drop = new EventEmitter(); constructor( private readonly element: ElementRef, @@ -24,6 +39,25 @@ export class FileDropDirective { ) { } + @HostListener('paste', ['$event']) + public onPaste(event: ClipboardEvent) { + const result: File[] = []; + + for (let i = 0; i < event.clipboardData.items.length; i++) { + const file = event.clipboardData.items[i].getAsFile(); + + if (this.isAllowedFile(file)) { + result.push(file!); + } + } + + if (result.length > 0) { + this.drop.emit(result); + } + + this.stopEvent(event); + } + @HostListener('dragend', ['$event']) @HostListener('dragleave', ['$event']) public onDragEnd(event: DragDropEvent) { @@ -57,7 +91,19 @@ export class FileDropDirective { const hasFiles = this.hasFiles(event.dataTransfer.types); if (hasFiles) { - this.drop.emit(event.dataTransfer.files); + const result: File[] = []; + + for (let i = 0; i < event.dataTransfer.files.length; i++) { + const file = event.dataTransfer.files.item(i); + + if (this.isAllowedFile(file)) { + result.push(file!); + } + } + + if (result.length > 0) { + this.drop.emit(result); + } this.dragEnd(0); this.stopEvent(event); @@ -85,6 +131,10 @@ export class FileDropDirective { } } + private isAllowedFile(file: File | null) { + return file && (!this.allowedFiles || this.allowedFiles.indexOf(file.type) >= 0) && (!this.onlyImages || ImageTypes.indexOf(file.type) >= 0); + } + private hasFiles(types: any): boolean { if (!types) { return false; diff --git a/src/Squidex/app/shared/components/assets-list.component.html b/src/Squidex/app/shared/components/assets-list.component.html index d2f849a28..fb8b2b304 100644 --- a/src/Squidex/app/shared/components/assets-list.component.html +++ b/src/Squidex/app/shared/components/assets-list.component.html @@ -1,4 +1,4 @@ -
+

Drop files here to upload

or
diff --git a/src/Squidex/app/shared/components/assets-list.component.ts b/src/Squidex/app/shared/components/assets-list.component.ts index 7909634b5..eb518e3c9 100644 --- a/src/Squidex/app/shared/components/assets-list.component.ts +++ b/src/Squidex/app/shared/components/assets-list.component.ts @@ -5,8 +5,6 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -// tslint:disable:prefer-for-of - import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; import { onErrorResumeNext } from 'rxjs/operators'; @@ -78,23 +76,9 @@ export class AssetsListComponent { this.newFiles = this.newFiles.remove(file); } - public pasteFiles(event: ClipboardEvent) { - for (let i = 0; i < event.clipboardData.items.length; i++) { - const file = event.clipboardData.items[i].getAsFile(); - - if (file) { - this.newFiles = this.newFiles.pushFront(file); - } - } - } - - public addFiles(files: FileList) { - for (let i = 0; i < files.length; i++) { - const file = files[i]; - - if (file) { - this.newFiles = this.newFiles.pushFront(file); - } + public addFiles(files: File[]) { + for (let file of files) { + this.newFiles = this.newFiles.pushFront(file); } return true; diff --git a/src/Squidex/app/shared/components/assets-selector.component.ts b/src/Squidex/app/shared/components/assets-selector.component.ts index ab9cbd615..711af55e0 100644 --- a/src/Squidex/app/shared/components/assets-selector.component.ts +++ b/src/Squidex/app/shared/components/assets-selector.component.ts @@ -5,8 +5,6 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ -// tslint:disable:prefer-for-of - import { ChangeDetectionStrategy, Component, EventEmitter, OnInit, Output } from '@angular/core'; import { onErrorResumeNext } from 'rxjs/operators'; diff --git a/src/Squidex/app/shared/components/markdown-editor.component.html b/src/Squidex/app/shared/components/markdown-editor.component.html index 7480c92da..042807d13 100644 --- a/src/Squidex/app/shared/components/markdown-editor.component.html +++ b/src/Squidex/app/shared/components/markdown-editor.component.html @@ -1,11 +1,7 @@ -
+
- -
-
Drop assets here to add them.
-
diff --git a/src/Squidex/app/shared/components/markdown-editor.component.ts b/src/Squidex/app/shared/components/markdown-editor.component.ts index 3d673176a..dde539e6e 100644 --- a/src/Squidex/app/shared/components/markdown-editor.component.ts +++ b/src/Squidex/app/shared/components/markdown-editor.component.ts @@ -9,7 +9,11 @@ import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, E import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { + AppsState, AssetDto, + AssetsService, + AuthService, + DateTime, DialogModel, ResourceLoaderService, Types @@ -49,6 +53,9 @@ export class MarkdownEditorComponent implements ControlValueAccessor, AfterViewI public isFullscreen = false; constructor( + private readonly appsState: AppsState, + private readonly assetsService: AssetsService, + private readonly authState: AuthService, private readonly changeDetector: ChangeDetectorRef, private readonly renderer: Renderer2, private readonly resourceLoader: ResourceLoaderService @@ -214,4 +221,44 @@ export class MarkdownEditorComponent implements ControlValueAccessor, AfterViewI this.assetsDialog.hide(); } + + public insertFiles(files: File[]) { + const doc = this.simplemde.codemirror.getDoc(); + + for (let file of files) { + this.uploadFile(doc, file); + } + } + + private uploadFile(doc: any, file: File) { + const uploadCursor = doc.getCursor(); + const uploadText = `![Uploading file...${new Date()}]()`; + + doc.replaceSelection(uploadText); + + const replaceText = (replacement: string) => { + const cursor = doc.getCursor(); + + const text = doc.getValue().replace(uploadText, replacement); + + doc.setValue(text); + + if (uploadCursor && uploadCursor.line === cursor.line) { + const offset = replacement.length - uploadText.length; + + doc.setCursor({ line: cursor.line, ch: cursor.ch + offset }); + } else { + doc.setCursor(cursor); + } + }; + + this.assetsService.uploadFile(this.appsState.appName, file, this.authState.user!.token, DateTime.now()) + .subscribe(asset => { + if (Types.is(asset, AssetDto)) { + replaceText(`![${asset.fileName}](${asset.url} '${asset.fileName}')`); + } + }, () => { + replaceText('FAILED'); + }); + } } \ No newline at end of file diff --git a/src/Squidex/app/shared/components/rich-editor.component.html b/src/Squidex/app/shared/components/rich-editor.component.html index 29b87e620..8e4c1e999 100644 --- a/src/Squidex/app/shared/components/rich-editor.component.html +++ b/src/Squidex/app/shared/components/rich-editor.component.html @@ -1,9 +1,5 @@ -
+
- -
-
Drop assets here to add them.
-
diff --git a/src/Squidex/app/shared/components/rich-editor.component.ts b/src/Squidex/app/shared/components/rich-editor.component.ts index 5eee435e5..44f797847 100644 --- a/src/Squidex/app/shared/components/rich-editor.component.ts +++ b/src/Squidex/app/shared/components/rich-editor.component.ts @@ -5,11 +5,17 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ +// tslint:disable:prefer-for-of + import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, forwardRef, OnDestroy, Output, ViewChild } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { + AppsState, AssetDto, + AssetsService, + AuthService, + DateTime, DialogModel, ResourceLoaderService, Types @@ -21,6 +27,13 @@ export const SQX_RICH_EDITOR_CONTROL_VALUE_ACCESSOR: any = { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RichEditorComponent), multi: true }; +const ImageTypes = [ + 'image/jpeg', + 'image/png', + 'image/jpg', + 'image/gif' +]; + @Component({ selector: 'sqx-rich-editor', styleUrls: ['./rich-editor.component.scss'], @@ -45,6 +58,9 @@ export class RichEditorComponent implements ControlValueAccessor, AfterViewInit, public assetPluginClicked = new EventEmitter(); constructor( + private readonly appsState: AppsState, + private readonly assetsService: AssetsService, + private readonly authState: AuthService, private readonly changeDetector: ChangeDetectorRef, private readonly resourceLoader: ResourceLoaderService ) { @@ -59,7 +75,7 @@ export class RichEditorComponent implements ControlValueAccessor, AfterViewInit, public ngAfterViewInit() { const self = this; - this.resourceLoader.loadScript('https://cdnjs.cloudflare.com/ajax/libs/tinymce/4.5.4/tinymce.min.js').then(() => { + this.resourceLoader.loadScript('https://cdnjs.cloudflare.com/ajax/libs/tinymce/4.9.3/tinymce.min.js').then(() => { tinymce.init(self.getEditorOptions()); }); } @@ -76,10 +92,9 @@ export class RichEditorComponent implements ControlValueAccessor, AfterViewInit, return { convert_fonts_to_spans: true, convert_urls: false, - plugins: 'code image media link lists advlist', + plugins: 'code image media link lists advlist paste', removed_menuitems: 'newdocument', resize: true, - theme: 'modern', toolbar: 'undo redo | styleselect | bold italic | alignleft aligncenter | bullist numlist outdent indent | link image media | assets', setup: (editor: any) => { self.tinyEditor = editor; @@ -102,6 +117,15 @@ export class RichEditorComponent implements ControlValueAccessor, AfterViewInit, } }); + self.tinyEditor.on('paste', (event: ClipboardEvent) => { + for (let i = 0; i < event.clipboardData.items.length; i++) { + const file = event.clipboardData.items[i].getAsFile(); + + if (file && ImageTypes.indexOf(file.type) >= 0) { + self.uploadFile(file); + } + } + }); self.tinyEditor.on('blur', () => { self.callTouched(); }); @@ -153,4 +177,31 @@ export class RichEditorComponent implements ControlValueAccessor, AfterViewInit, this.assetsDialog.hide(); } + + public insertFiles(files: File[]) { + for (let file of files) { + this.uploadFile(file); + } + } + + private uploadFile(file: File) { + const uploadText = `[Uploading file...${new Date()}]`; + + this.tinyEditor.execCommand('mceInsertContent', false, uploadText); + + const replaceText = (replacement: string) => { + const content = this.tinyEditor.getContent().replace(uploadText, replacement); + + this.tinyEditor.setContent(content); + }; + + this.assetsService.uploadFile(this.appsState.appName, file, this.authState.user!.token, DateTime.now()) + .subscribe(asset => { + if (Types.is(asset, AssetDto)) { + replaceText(`${asset.fileName}`); + } + }, () => { + replaceText('FAILED'); + }); + } } \ No newline at end of file diff --git a/src/Squidex/app/shared/state/contents.forms.ts b/src/Squidex/app/shared/state/contents.forms.ts index 956d5e255..9b6e0f795 100644 --- a/src/Squidex/app/shared/state/contents.forms.ts +++ b/src/Squidex/app/shared/state/contents.forms.ts @@ -5,7 +5,6 @@ * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. */ - // tslint:disable:prefer-for-of import { FormArray, FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; From 36b63c79dee8728078c75d6d75c5e5f81f7f9037 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 3 Feb 2019 19:24:12 +0100 Subject: [PATCH 6/7] Improved image handling for rich editor. --- .../components/rich-editor.component.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/Squidex/app/shared/components/rich-editor.component.ts b/src/Squidex/app/shared/components/rich-editor.component.ts index 44f797847..66b618840 100644 --- a/src/Squidex/app/shared/components/rich-editor.component.ts +++ b/src/Squidex/app/shared/components/rich-editor.component.ts @@ -96,6 +96,20 @@ export class RichEditorComponent implements ControlValueAccessor, AfterViewInit, removed_menuitems: 'newdocument', resize: true, toolbar: 'undo redo | styleselect | bold italic | alignleft aligncenter | bullist numlist outdent indent | link image media | assets', + + images_upload_handler: (blob: any, success: (url: string) => void, failed: () => void) => { + const file = new File([blob.blob()], blob.filename(), { lastModified: new Date().getTime() }); + + this.assetsService.uploadFile(this.appsState.appName, file, this.authState.user!.token, DateTime.now()) + .subscribe(asset => { + if (Types.is(asset, AssetDto)) { + success(asset.url); + } + }, () => { + failed(); + }); + }, + setup: (editor: any) => { self.tinyEditor = editor; self.tinyEditor.setMode(this.isDisabled ? 'readonly' : 'design'); @@ -126,6 +140,19 @@ export class RichEditorComponent implements ControlValueAccessor, AfterViewInit, } } }); + + self.tinyEditor.on('drop', (event: DragEvent) => { + if (event.dataTransfer) { + for (let i = 0; i < event.dataTransfer.files.length; i++) { + const file = event.dataTransfer.files.item(i); + + if (file && ImageTypes.indexOf(file.type) >= 0) { + self.uploadFile(file); + } + } + } + }); + self.tinyEditor.on('blur', () => { self.callTouched(); }); From 1cd04db3c3b767a9a6186453ac0fb3b8c8b86208 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sun, 3 Feb 2019 19:25:17 +0100 Subject: [PATCH 7/7] Placeholder for coniditions. --- .../pages/rules/triggers/asset-changed-trigger.component.html | 2 +- .../pages/rules/triggers/content-changed-trigger.component.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Squidex/app/features/rules/pages/rules/triggers/asset-changed-trigger.component.html b/src/Squidex/app/features/rules/pages/rules/triggers/asset-changed-trigger.component.html index e5b367c24..1ae2b45f6 100644 --- a/src/Squidex/app/features/rules/pages/rules/triggers/asset-changed-trigger.component.html +++ b/src/Squidex/app/features/rules/pages/rules/triggers/asset-changed-trigger.component.html @@ -4,7 +4,7 @@ - +
diff --git a/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html b/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html index b45b40677..df54ee885 100644 --- a/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html +++ b/src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html @@ -21,7 +21,7 @@ {{schema.schema.displayName}} - +