From be677b70c3aad235a7a024c5f9499b7d59dc25a9 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Tue, 3 Sep 2019 23:28:43 +0200 Subject: [PATCH] Feature/app infos (#407) * Fallback avatar for apps. * App label and description. * Api to upload image. * Schema service unified to use response version instead of header. --- .../Apps/AppImage.cs | 35 ++++ .../Apps/AppCommandMiddleware.cs | 25 ++- .../Apps/AppGrain.cs | 45 ++++ .../Apps/Commands/RemoveAppImage.cs | 13 ++ .../Apps/Commands/UpdateApp.cs | 16 ++ .../Apps/Commands/UploadAppImage.cs | 20 ++ .../Apps/Guards/GuardApp.cs | 15 ++ .../Apps/IAppEntity.cs | 6 + .../Apps/State/AppState.cs | 24 +++ .../Apps/AppImageRemoved.cs | 16 ++ .../Apps/AppImageUploaded.cs | 18 ++ .../Apps/AppUpdated.cs | 19 ++ src/Squidex.Infrastructure/RandomHash.cs | 12 +- src/Squidex.Shared/Permissions.cs | 3 + .../Api/Controllers/Apps/AppsController.cs | 197 +++++++++++++++++- .../Api/Controllers/Apps/Models/AppDto.cs | 47 ++++- .../Controllers/Apps/Models/UpdateAppDto.cs | 30 +++ .../Assets/AssetContentController.cs | 2 +- .../Frontend/Middlewares/IndexMiddleware.cs | 15 +- src/Squidex/app-config/webpack.config.js | 15 +- .../pages/users/user-page.component.ts | 4 +- .../app/features/api/api-area.component.html | 2 +- .../pages/graphql/graphql-page.component.html | 2 +- .../apps/pages/apps-page.component.html | 33 ++- .../apps/pages/apps-page.component.scss | 19 +- .../assets/pages/assets-page.component.html | 2 +- .../pages/content/content-page.component.html | 2 +- .../contents/contents-page.component.html | 2 +- .../pages/schemas/schemas-page.component.html | 2 +- .../pages/dashboard-page.component.html | 4 +- .../events/rule-events-page.component.html | 2 +- .../pages/rules/rules-page.component.html | 2 +- .../pages/schema/schema-page.component.html | 2 +- .../pages/schemas/schemas-page.component.html | 2 +- src/Squidex/app/features/settings/module.ts | 5 +- .../pages/backups/backups-page.component.html | 2 +- .../pages/clients/clients-page.component.html | 2 +- .../contributors-page.component.html | 2 +- .../languages/languages-page.component.html | 2 +- .../pages/more/more-page.component.html | 88 +++++++- .../pages/more/more-page.component.scss | 71 ++++++- .../pages/more/more-page.component.ts | 94 ++++++++- .../patterns/patterns-page.component.html | 2 +- .../pages/plans/plans-page.component.html | 2 +- .../pages/roles/roles-page.component.html | 2 +- .../workflows/workflows-page.component.html | 2 +- .../settings/settings-area.component.html | 12 +- .../settings/settings-area.component.scss | 10 + .../app/framework/angular/avatar.component.ts | 46 ++++ .../angular/forms/control-errors.component.ts | 8 +- .../framework/angular/http/http-extensions.ts | 16 +- .../app/framework/angular/safe-html.pipe.ts | 15 ++ src/Squidex/app/framework/declarations.ts | 1 + src/Squidex/app/framework/internal.ts | 1 + src/Squidex/app/framework/module.ts | 6 + src/Squidex/app/framework/utils/hateos.ts | 10 +- src/Squidex/app/framework/utils/picasso.ts | 71 +++++++ .../app/shared/services/apps.service.spec.ts | 124 ++++++++++- .../app/shared/services/apps.service.ts | 107 +++++++++- .../shared/services/assets.service.spec.ts | 22 +- .../app/shared/services/assets.service.ts | 30 +-- .../app/shared/services/rules.service.spec.ts | 6 +- .../shared/services/schemas.service.spec.ts | 142 ++++--------- .../app/shared/services/schemas.service.ts | 72 +++---- src/Squidex/app/shared/state/apps.forms.ts | 9 + .../app/shared/state/apps.state.spec.ts | 80 ++++--- src/Squidex/app/shared/state/apps.state.ts | 57 ++++- .../shared/state/asset-uploader.state.spec.ts | 16 +- .../app/shared/state/asset-uploader.state.ts | 4 +- .../app/shared/state/rules.state.spec.ts | 8 +- .../app/shared/state/schemas.state.spec.ts | 42 ++-- .../pages/internal/apps-menu.component.html | 4 +- src/Squidex/package-lock.json | 62 ++---- src/Squidex/package.json | 4 +- src/Squidex/tsconfig.json | 1 + .../Model/Apps/AppImageTests.cs | 47 +++++ .../Apps/AppCommandMiddlewareTests.cs | 42 +++- .../Apps/AppGrainTests.cs | 58 ++++++ 78 files changed, 1650 insertions(+), 410 deletions(-) create mode 100644 src/Squidex.Domain.Apps.Core.Model/Apps/AppImage.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs create mode 100644 src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs create mode 100644 src/Squidex.Domain.Apps.Events/Apps/AppImageRemoved.cs create mode 100644 src/Squidex.Domain.Apps.Events/Apps/AppImageUploaded.cs create mode 100644 src/Squidex.Domain.Apps.Events/Apps/AppUpdated.cs create mode 100644 src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppDto.cs create mode 100644 src/Squidex/app/framework/angular/avatar.component.ts create mode 100644 src/Squidex/app/framework/utils/picasso.ts create mode 100644 tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppImageTests.cs diff --git a/src/Squidex.Domain.Apps.Core.Model/Apps/AppImage.cs b/src/Squidex.Domain.Apps.Core.Model/Apps/AppImage.cs new file mode 100644 index 000000000..603b73af4 --- /dev/null +++ b/src/Squidex.Domain.Apps.Core.Model/Apps/AppImage.cs @@ -0,0 +1,35 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Core.Apps +{ + public sealed class AppImage + { + public string MimeType { get; } + + public string Etag { get; } + + public AppImage(string mimeType, string etag = null) + { + Guard.NotNullOrEmpty(mimeType, nameof(mimeType)); + + MimeType = mimeType; + + if (string.IsNullOrWhiteSpace(etag)) + { + Etag = RandomHash.Simple(); + } + else + { + Etag = etag; + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs index 63b8f2833..0c8ee32e7 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppCommandMiddleware.cs @@ -10,24 +10,47 @@ using System.Threading.Tasks; using Orleans; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Apps { public sealed class AppCommandMiddleware : GrainCommandMiddleware { + private readonly IAssetStore assetStore; + private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IContextProvider contextProvider; - public AppCommandMiddleware(IGrainFactory grainFactory, IContextProvider contextProvider) + public AppCommandMiddleware( + IGrainFactory grainFactory, + IAssetStore assetStore, + IAssetThumbnailGenerator assetThumbnailGenerator, + IContextProvider contextProvider) : base(grainFactory) { Guard.NotNull(contextProvider, nameof(contextProvider)); + Guard.NotNull(assetStore, nameof(assetStore)); + Guard.NotNull(assetThumbnailGenerator, nameof(assetThumbnailGenerator)); + this.assetStore = assetStore; + this.assetThumbnailGenerator = assetThumbnailGenerator; this.contextProvider = contextProvider; } public override async Task HandleAsync(CommandContext context, Func next) { + if (context.Command is UploadAppImage uploadImage) + { + var image = await assetThumbnailGenerator.GetImageInfoAsync(uploadImage.File()); + + if (image == null) + { + throw new ValidationException("File is not an image."); + } + + await assetStore.UploadAsync(uploadImage.AppId.ToString(), uploadImage.File(), true); + } + await ExecuteCommandAsync(context); if (context.PlainResult is IAppEntity app) diff --git a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs index 268c80e96..09b65e1e7 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs @@ -69,6 +69,36 @@ namespace Squidex.Domain.Apps.Entities.Apps return Snapshot; }); + case UpdateApp updateApp: + return UpdateReturn(updateApp, c => + { + GuardApp.CanUpdate(c); + + Update(c); + + return Snapshot; + }); + + case UploadAppImage uploadImage: + return UpdateReturn(uploadImage, c => + { + GuardApp.CanUploadImage(c); + + UploadImage(c); + + return Snapshot; + }); + + case RemoveAppImage removeImage: + return UpdateReturn(removeImage, c => + { + GuardApp.CanRemoveImage(c); + + RemoveImage(c); + + return Snapshot; + }); + case AssignContributor assignContributor: return UpdateReturnAsync(assignContributor, async c => { @@ -324,6 +354,21 @@ namespace Squidex.Domain.Apps.Entities.Apps } } + public void Update(UpdateApp command) + { + RaiseEvent(SimpleMapper.Map(command, new AppUpdated())); + } + + public void UploadImage(UploadAppImage command) + { + RaiseEvent(SimpleMapper.Map(command, new AppImageUploaded())); + } + + public void RemoveImage(RemoveAppImage command) + { + RaiseEvent(SimpleMapper.Map(command, new AppImageRemoved())); + } + public void UpdateLanguage(UpdateLanguage command) { RaiseEvent(SimpleMapper.Map(command, new AppLanguageUpdated())); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs new file mode 100644 index 000000000..c88fa5c56 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/RemoveAppImage.cs @@ -0,0 +1,13 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class RemoveAppImage : AppCommand + { + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs new file mode 100644 index 000000000..8b5f0ef32 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UpdateApp.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class UpdateApp : AppCommand + { + public string Label { get; set; } + + public string Description { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs new file mode 100644 index 000000000..465ed4f83 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs @@ -0,0 +1,20 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System; +using System.IO; +using Squidex.Domain.Apps.Core.Apps; + +namespace Squidex.Domain.Apps.Entities.Apps.Commands +{ + public sealed class UploadAppImage : AppCommand + { + public AppImage Image { get; set; } + + public Func File { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs index ea2cad6a9..7da070db5 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/Guards/GuardApp.cs @@ -28,6 +28,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.Guards }); } + public static void CanUpdate(UpdateApp command) + { + Guard.NotNull(command, nameof(command)); + } + + public static void CanUploadImage(UploadAppImage command) + { + Guard.NotNull(command, nameof(command)); + } + + public static void CanRemoveImage(RemoveAppImage command) + { + Guard.NotNull(command, nameof(command)); + } + public static void CanChangePlan(ChangePlan command, AppPlan plan, IAppPlansProvider appPlans) { Guard.NotNull(command, nameof(command)); diff --git a/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs index a41e3368f..4b959e6e4 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/IAppEntity.cs @@ -18,10 +18,16 @@ namespace Squidex.Domain.Apps.Entities.Apps { string Name { get; } + string Label { get; } + + string Description { get; } + Roles Roles { get; } AppPlan Plan { get; } + AppImage Image { get; } + AppClients Clients { get; } AppPatterns Patterns { get; } diff --git a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs index 7e870d56c..cf9f08a55 100644 --- a/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs +++ b/src/Squidex.Domain.Apps.Entities/Apps/State/AppState.cs @@ -25,12 +25,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.State [DataMember] public string Name { get; set; } + [DataMember] + public string Label { get; set; } + + [DataMember] + public string Description { get; set; } + [DataMember] public Roles Roles { get; set; } = Roles.Empty; [DataMember] public AppPlan Plan { get; set; } + [DataMember] + public AppImage Image { get; set; } + [DataMember] public AppClients Clients { get; set; } = AppClients.Empty; @@ -56,6 +65,21 @@ namespace Squidex.Domain.Apps.Entities.Apps.State SimpleMapper.Map(@event, this); } + protected void On(AppUpdated @event) + { + SimpleMapper.Map(@event, this); + } + + protected void On(AppImageUploaded @event) + { + Image = @event.Image; + } + + protected void On(AppImageRemoved @event) + { + Image = null; + } + protected void On(AppPlanChanged @event) { Plan = AppPlan.Build(@event.Actor, @event.PlanId); diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppImageRemoved.cs b/src/Squidex.Domain.Apps.Events/Apps/AppImageRemoved.cs new file mode 100644 index 000000000..1835d2941 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppImageRemoved.cs @@ -0,0 +1,16 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppImageRemoved))] + public sealed class AppImageRemoved : AppEvent + { + } +} diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppImageUploaded.cs b/src/Squidex.Domain.Apps.Events/Apps/AppImageUploaded.cs new file mode 100644 index 000000000..9a237e6d8 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppImageUploaded.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.Apps; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppImageUploaded))] + public sealed class AppImageUploaded : AppEvent + { + public AppImage Image { get; set; } + } +} diff --git a/src/Squidex.Domain.Apps.Events/Apps/AppUpdated.cs b/src/Squidex.Domain.Apps.Events/Apps/AppUpdated.cs new file mode 100644 index 000000000..bcc4f8896 --- /dev/null +++ b/src/Squidex.Domain.Apps.Events/Apps/AppUpdated.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Events.Apps +{ + [EventType(nameof(AppUpdated))] + public sealed class AppUpdated : AppEvent + { + public string Label { get; set; } + + public string Description { get; set; } + } +} diff --git a/src/Squidex.Infrastructure/RandomHash.cs b/src/Squidex.Infrastructure/RandomHash.cs index e64115342..020a3568c 100644 --- a/src/Squidex.Infrastructure/RandomHash.cs +++ b/src/Squidex.Infrastructure/RandomHash.cs @@ -15,7 +15,17 @@ namespace Squidex.Infrastructure { public static string New() { - return Guid.NewGuid().ToString().Sha256Base64().Replace("+", "x"); + return Guid.NewGuid() + .ToString().Sha256Base64() + .ToLowerInvariant() + .Replace("+", "x") + .Replace("=", "x") + .Replace("/", "x"); + } + + public static string Simple() + { + return Guid.NewGuid().ToString().Replace("-", string.Empty); } public static string Sha256Base64(this string value) diff --git a/src/Squidex.Shared/Permissions.cs b/src/Squidex.Shared/Permissions.cs index 10ceb8fef..66e631ea2 100644 --- a/src/Squidex.Shared/Permissions.cs +++ b/src/Squidex.Shared/Permissions.cs @@ -53,6 +53,9 @@ namespace Squidex.Shared public const string AppCommon = "squidex.apps.{app}.common"; public const string AppDelete = "squidex.apps.{app}.delete"; + public const string AppUpdate = "squidex.apps.{app}.update"; + public const string AppUpdateImage = "squidex.apps.{app}.update"; + public const string AppUpdateGeneral = "squidex.apps.{app}.general"; public const string AppClients = "squidex.apps.{app}.clients"; public const string AppClientsRead = "squidex.apps.{app}.clients.read"; diff --git a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index ea4d0f4b6..b2669bae1 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -5,16 +5,26 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; +using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; +using NSwag.Annotations; using Squidex.Areas.Api.Controllers.Apps.Models; +using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Services; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Assets; using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Security; using Squidex.Shared; using Squidex.Web; @@ -27,14 +37,20 @@ namespace Squidex.Areas.Api.Controllers.Apps [ApiExplorerSettings(GroupName = nameof(Apps))] public sealed class AppsController : ApiController { + private readonly IAssetStore assetStore; + private readonly IAssetThumbnailGenerator assetThumbnailGenerator; private readonly IAppProvider appProvider; private readonly IAppPlansProvider appPlansProvider; public AppsController(ICommandBus commandBus, + IAssetStore assetStore, + IAssetThumbnailGenerator assetThumbnailGenerator, IAppProvider appProvider, IAppPlansProvider appPlansProvider) : base(commandBus) { + this.assetStore = assetStore; + this.assetThumbnailGenerator = assetThumbnailGenerator; this.appProvider = appProvider; this.appPlansProvider = appPlansProvider; } @@ -63,7 +79,7 @@ namespace Squidex.Areas.Api.Controllers.Apps var response = Deferred.Response(() => { - return apps.Select(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)).ToArray(); + return apps.OrderBy(x => x.Name).Select(a => AppDto.FromApp(a, userOrClientId, userPermissions, appPlansProvider, this)).ToArray(); }); Response.Headers[HeaderNames.ETag] = apps.ToEtag(); @@ -88,18 +104,143 @@ namespace Squidex.Areas.Api.Controllers.Apps [Route("apps/")] [ProducesResponseType(typeof(AppDto), 201)] [ApiPermission] - [ApiCosts(1)] + [ApiCosts(0)] public async Task PostApp([FromBody] CreateAppDto request) { - var context = await CommandBus.PublishAsync(request.ToCommand()); + var response = await InvokeCommandAsync(request.ToCommand()); - var userOrClientId = HttpContext.User.UserOrClientId(); - var userPermissions = HttpContext.Permissions(); + return CreatedAtAction(nameof(GetApps), response); + } - var result = context.Result(); - var response = AppDto.FromApp(result, userOrClientId, userPermissions, appPlansProvider, this); + /// + /// Update the app. + /// + /// The name of the app to update. + /// The values to update. + /// + /// 200 => App updated. + /// 404 => App not found. + /// + [HttpPut] + [Route("apps/{app}/")] + [ProducesResponseType(typeof(AppDto), 200)] + [ApiPermission(Permissions.AppUpdateGeneral)] + [ApiCosts(0)] + public async Task UpdateApp(string app, [FromBody] UpdateAppDto request) + { + var response = await InvokeCommandAsync(request.ToCommand()); - return CreatedAtAction(nameof(GetApps), response); + return Ok(response); + } + + /// + /// Get the app image. + /// + /// The name of the app to update. + /// The file to upload. + /// + /// 200 => App image uploaded. + /// 404 => App not found. + /// + [HttpPost] + [Route("apps/{app}/image")] + [ProducesResponseType(typeof(AppDto), 201)] + [ApiPermission(Permissions.AppUpdateImage)] + [ApiCosts(0)] + public async Task UploadImage(string app, [OpenApiIgnore] List file) + { + var response = await InvokeCommandAsync(CreateCommand(file)); + + return Ok(response); + } + + /// + /// Get the app image. + /// + /// The name of the app. + /// + /// 200 => App image found and content or (resized) image returned. + /// 404 => App not found. + /// + [HttpGet] + [Route("apps/{app}/image")] + [ProducesResponseType(typeof(FileResult), 200)] + [AllowAnonymous] + [ApiCosts(0)] + public IActionResult GetImage(string app) + { + if (App.Image == null) + { + return NotFound(); + } + + var etag = App.Image.Etag; + + Response.Headers[HeaderNames.ETag] = etag; + + var handler = new Func(async bodyStream => + { + var assetId = App.Id.ToString(); + var assetResizedId = $"{assetId}_{etag}_Resized"; + + try + { + await assetStore.DownloadAsync(assetResizedId, bodyStream); + } + catch (AssetNotFoundException) + { + using (Profiler.Trace("Resize")) + { + using (var sourceStream = GetTempStream()) + { + using (var destinationStream = GetTempStream()) + { + using (Profiler.Trace("ResizeDownload")) + { + await assetStore.DownloadAsync(assetId, sourceStream); + sourceStream.Position = 0; + } + + using (Profiler.Trace("ResizeImage")) + { + await assetThumbnailGenerator.CreateThumbnailAsync(sourceStream, destinationStream, 150, 150, "Crop"); + destinationStream.Position = 0; + } + + using (Profiler.Trace("ResizeUpload")) + { + await assetStore.UploadAsync(assetResizedId, destinationStream); + destinationStream.Position = 0; + } + + await destinationStream.CopyToAsync(bodyStream); + } + } + } + } + }); + + return new FileCallbackResult(App.Image.MimeType, null, true, handler); + } + + /// + /// Remove the app image. + /// + /// The name of the app to update. + /// + /// 200 => App image removed. + /// 404 => App not found. + /// + [HttpDelete] + [Route("apps/{app}/image")] + [ProducesResponseType(typeof(AppDto), 201)] + [ApiPermission(Permissions.AppUpdate)] + [ApiCosts(0)] + public async Task DeleteImage(string app) + { + var response = await InvokeCommandAsync(new RemoveAppImage()); + + return Ok(response); } /// @@ -113,12 +254,50 @@ namespace Squidex.Areas.Api.Controllers.Apps [HttpDelete] [Route("apps/{app}/")] [ApiPermission(Permissions.AppDelete)] - [ApiCosts(1)] + [ApiCosts(0)] public async Task DeleteApp(string app) { await CommandBus.PublishAsync(new ArchiveApp()); return NoContent(); } + + private async Task InvokeCommandAsync(AppCommand command) + { + var context = await CommandBus.PublishAsync(command); + + var userOrClientId = HttpContext.User.UserOrClientId(); + var userPermissions = HttpContext.Permissions(); + + var result = context.Result(); + var response = AppDto.FromApp(result, userOrClientId, userPermissions, appPlansProvider, this); + + return response; + } + + private UploadAppImage CreateCommand(IReadOnlyList file) + { + if (file.Count != 1) + { + var error = new ValidationError($"Can only upload one file, found {file.Count} files."); + + throw new ValidationException("Cannot create asset.", error); + } + + return new UploadAppImage { File = file[0].OpenReadStream, Image = new AppImage(file[0].ContentType) }; + } + + private static FileStream GetTempStream() + { + var tempFileName = Path.GetTempFileName(); + + return new FileStream(tempFileName, + FileMode.Create, + FileAccess.ReadWrite, + FileShare.Delete, 1024 * 16, + FileOptions.Asynchronous | + FileOptions.DeleteOnClose | + FileOptions.SequentialScan); + } } } diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs index 426d7fbe5..aa91fc5fe 100644 --- a/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/AppDto.cs @@ -36,6 +36,16 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models [RegularExpression("^[a-z0-9]+(\\-[a-z0-9]+)*$")] public string Name { get; set; } + /// + /// The optional label of the app. + /// + public string Label { get; set; } + + /// + /// The optional description of the app. + /// + public string Description { get; set; } + /// /// The version of the app. /// @@ -88,7 +98,6 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models var result = SimpleMapper.Map(app, new AppDto()); result.Permissions = permissions.ToIds(); - result.PlanName = plans.GetPlanForApp(app)?.Name; if (controller.Includes(AllPermissions.ForApp(AllPermissions.AppApi, app.Name), permissions)) { @@ -100,10 +109,8 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models result.CanAccessContent = true; } - if (controller.HasPermission(AllPermissions.AppPlansChange, app.Name)) - { - result.PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; - } + result.SetPlan(app, plans, controller); + result.SetImage(app, controller); return result.CreateLinks(controller, permissions); } @@ -125,6 +132,24 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models return new PermissionSet(permissions); } + private void SetPlan(IAppEntity app, IAppPlansProvider plans, ApiController controller) + { + if (controller.HasPermission(AllPermissions.AppPlansChange, app.Name)) + { + PlanUpgrade = plans.GetPlanUpgradeForApp(app)?.Name; + } + + PlanName = plans.GetPlanForApp(app)?.Name; + } + + private void SetImage(IAppEntity app, ApiController controller) + { + if (app.Image != null) + { + AddGetLink("image", controller.Url(x => nameof(x.GetImage), new { app = app.Name })); + } + } + private AppDto CreateLinks(ApiController controller, PermissionSet permissions) { var values = new { app = Name }; @@ -136,6 +161,18 @@ namespace Squidex.Areas.Api.Controllers.Apps.Models AddDeleteLink("delete", controller.Url(x => nameof(x.DeleteApp), values)); } + if (controller.HasPermission(AllPermissions.AppUpdateGeneral, Name, additional: permissions)) + { + AddPutLink("update", controller.Url(x => nameof(x.UpdateApp), values)); + } + + if (controller.HasPermission(AllPermissions.AppUpdateImage, Name, additional: permissions)) + { + AddPostLink("image/upload", controller.Url(x => nameof(x.UploadImage), values)); + + AddDeleteLink("image/delete", controller.Url(x => nameof(x.DeleteImage), values)); + } + if (controller.HasPermission(AllPermissions.AppAssetsRead, Name, additional: permissions)) { AddGetLink("assets", controller.Url(x => nameof(x.GetAssets), values)); diff --git a/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppDto.cs b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppDto.cs new file mode 100644 index 000000000..7bd05b95a --- /dev/null +++ b/src/Squidex/Areas/Api/Controllers/Apps/Models/UpdateAppDto.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Apps.Models +{ + public sealed class UpdateAppDto + { + /// + /// The optional label of your app. + /// + public string Label { get; set; } + + /// + /// The optional description of your app. + /// + public string Description { get; set; } + + public UpdateApp ToCommand() + { + return SimpleMapper.Map(this, new UpdateApp()); + } + } +} diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index 1017d85ee..dcfcd5491 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -91,7 +91,7 @@ namespace Squidex.Areas.Api.Controllers.Assets [Route("assets/{id}/")] [ProducesResponseType(typeof(FileResult), 200)] [ApiCosts(0.5)] - public async Task GetAssetContent(Guid id, string more, [FromQuery] AssetQuery query) + public async Task GetAssetContent(Guid id, [FromQuery] AssetQuery query) { var asset = await assetRepository.FindAssetAsync(id); diff --git a/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs b/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs index cf4a2dde8..cd7bfbb74 100644 --- a/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs +++ b/src/Squidex/Areas/Frontend/Middlewares/IndexMiddleware.cs @@ -34,16 +34,19 @@ namespace Squidex.Areas.Frontend.Middlewares await next(context); - context.Response.Body = responseBody; + if (context.Response.StatusCode != 304) + { + context.Response.Body = responseBody; - var html = Encoding.UTF8.GetString(responseBuffer.ToArray()); + var html = Encoding.UTF8.GetString(responseBuffer.ToArray()); - html = html.AdjustHtml(context); + html = html.AdjustHtml(context); - context.Response.ContentLength = Encoding.UTF8.GetByteCount(html); - context.Response.Body = responseBody; + context.Response.ContentLength = Encoding.UTF8.GetByteCount(html); + context.Response.Body = responseBody; - await context.Response.WriteAsync(html); + await context.Response.WriteAsync(html); + } } else { diff --git a/src/Squidex/app-config/webpack.config.js b/src/Squidex/app-config/webpack.config.js index 9a4117253..f755c7f79 100644 --- a/src/Squidex/app-config/webpack.config.js +++ b/src/Squidex/app-config/webpack.config.js @@ -18,8 +18,8 @@ const plugins = { CircularDependencyPlugin: require('circular-dependency-plugin'), // https://github.com/jantimon/html-webpack-plugin HtmlWebpackPlugin: require('html-webpack-plugin'), - // https://github.com/mishoo/UglifyJS2/tree/harmony - UglifyJsPlugin: require('uglifyjs-webpack-plugin'), + // https://webpack.js.org/plugins/terser-webpack-plugin/ + TerserPlugin: require('terser-webpack-plugin'), // https://www.npmjs.com/package/@ngtools/webpack NgToolsWebpack: require('@ngtools/webpack'), // https://github.com/NMFR/optimize-css-assets-webpack-plugin @@ -268,14 +268,15 @@ module.exports = function (env) { if (isProduction) { config.optimization = { minimizer: [ - new plugins.UglifyJsPlugin({ - uglifyOptions: { - compress: false, - ecma: 6, + new plugins.TerserPlugin({ + terserOptions: { + compress: true, + ecma: 5, mangle: true, output: { comments: false - } + }, + safari10: true }, extractComments: true }), diff --git a/src/Squidex/app/features/administration/pages/users/user-page.component.ts b/src/Squidex/app/features/administration/pages/users/user-page.component.ts index c930eb1ee..dcc115d66 100644 --- a/src/Squidex/app/features/administration/pages/users/user-page.component.ts +++ b/src/Squidex/app/features/administration/pages/users/user-page.component.ts @@ -42,9 +42,9 @@ export class UserPageComponent extends ResourceOwner implements OnInit { this.own( this.usersState.selectedUser .subscribe(selectedUser => { - this.user = selectedUser!; - if (selectedUser) { + this.user = selectedUser; + this.isEditable = this.user.canUpdate; this.userForm.load(selectedUser); diff --git a/src/Squidex/app/features/api/api-area.component.html b/src/Squidex/app/features/api/api-area.component.html index f53c81b01..8d6b3ee9f 100644 --- a/src/Squidex/app/features/api/api-area.component.html +++ b/src/Squidex/app/features/api/api-area.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html index d762911d5..53fcce4f3 100644 --- a/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html +++ b/src/Squidex/app/features/api/pages/graphql/graphql-page.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.html b/src/Squidex/app/features/apps/pages/apps-page.component.html index 104f08827..60fac9e55 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.html +++ b/src/Squidex/app/features/apps/pages/apps-page.component.html @@ -16,17 +16,28 @@
-

{{app.name}}

- -
- Edit - - -  | - Content · - Assets · - Settings - +
+
+ +
+
+

{{app.displayName}}

+ + + +
+ {{app.description}} +
+
diff --git a/src/Squidex/app/features/apps/pages/apps-page.component.scss b/src/Squidex/app/features/apps/pages/apps-page.component.scss index ca1de18bf..49daad2ed 100644 --- a/src/Squidex/app/features/apps/pages/apps-page.component.scss +++ b/src/Squidex/app/features/apps/pages/apps-page.component.scss @@ -21,7 +21,7 @@ & { margin-right: 1rem; margin-bottom: 1rem; - width: 16rem; + width: 20rem; float: left; } @@ -29,6 +29,14 @@ width: 33rem; } + &-links { + margin-top: .5rem; + } + + &-left { + padding-right: .75rem; + } + &-image { text-align: center; } @@ -37,6 +45,7 @@ color: $color-text-decent; font-weight: normal; font-size: .9rem; + margin-top: .5rem; } &-more { @@ -51,13 +60,19 @@ color: $color-title; font-weight: light; font-size: 1.2rem; - margin-top: .4rem; + margin-top: 0; + margin-bottom: 0; } &-template { .card-body { min-height: 15.5rem; } + + .card-title { + margin-top: 1rem; + margin-bottom: .75rem; + } } &-href { diff --git a/src/Squidex/app/features/assets/pages/assets-page.component.html b/src/Squidex/app/features/assets/pages/assets-page.component.html index 07aed4612..b946bf314 100644 --- a/src/Squidex/app/features/assets/pages/assets-page.component.html +++ b/src/Squidex/app/features/assets/pages/assets-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/content/pages/content/content-page.component.html b/src/Squidex/app/features/content/pages/content/content-page.component.html index 21a0c6170..1a56a578a 100644 --- a/src/Squidex/app/features/content/pages/content/content-page.component.html +++ b/src/Squidex/app/features/content/pages/content/content-page.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/Squidex/app/features/content/pages/contents/contents-page.component.html b/src/Squidex/app/features/content/pages/contents/contents-page.component.html index 762adaa88..5283e4160 100644 --- a/src/Squidex/app/features/content/pages/contents/contents-page.component.html +++ b/src/Squidex/app/features/content/pages/contents/contents-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html b/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html index ac121fd9e..9adbbd8a0 100644 --- a/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html +++ b/src/Squidex/app/features/content/pages/schemas/schemas-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html index 5a9c1cd00..f11416433 100644 --- a/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html +++ b/src/Squidex/app/features/dashboard/pages/dashboard-page.component.html @@ -1,5 +1,5 @@ - +
@@ -7,7 +7,7 @@

Hi {{authState.user?.displayName}}

- Welcome to {{app.name}} dashboard. + Welcome to {{app.displayName}} dashboard.
diff --git a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html index 9c9c6f5cb..a53333758 100644 --- a/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html +++ b/src/Squidex/app/features/rules/pages/events/rule-events-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html index bbc51051f..7a346488f 100644 --- a/src/Squidex/app/features/rules/pages/rules/rules-page.component.html +++ b/src/Squidex/app/features/rules/pages/rules/rules-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html index a3aad8268..d55b9e6f6 100644 --- a/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html +++ b/src/Squidex/app/features/schemas/pages/schema/schema-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html index 7ffc491fd..6af0c2b56 100644 --- a/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html +++ b/src/Squidex/app/features/schemas/pages/schemas/schemas-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/settings/module.ts b/src/Squidex/app/features/settings/module.ts index 18d5347c5..2cb535224 100644 --- a/src/Squidex/app/features/settings/module.ts +++ b/src/Squidex/app/features/settings/module.ts @@ -43,10 +43,7 @@ const routes: Routes = [ component: SettingsAreaComponent, children: [ { - path: '' - }, - { - path: 'more', + path: '', component: MorePageComponent }, { diff --git a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html index 562999057..820645e5b 100644 --- a/src/Squidex/app/features/settings/pages/backups/backups-page.component.html +++ b/src/Squidex/app/features/settings/pages/backups/backups-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/settings/pages/clients/clients-page.component.html b/src/Squidex/app/features/settings/pages/clients/clients-page.component.html index 9093184f7..420a3d3bb 100644 --- a/src/Squidex/app/features/settings/pages/clients/clients-page.component.html +++ b/src/Squidex/app/features/settings/pages/clients/clients-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html index 3242e97b4..e3bf65586 100644 --- a/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html +++ b/src/Squidex/app/features/settings/pages/contributors/contributors-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/settings/pages/languages/languages-page.component.html b/src/Squidex/app/features/settings/pages/languages/languages-page.component.html index 0081e6c18..ab5435476 100644 --- a/src/Squidex/app/features/settings/pages/languages/languages-page.component.html +++ b/src/Squidex/app/features/settings/pages/languages/languages-page.component.html @@ -1,4 +1,4 @@ - + diff --git a/src/Squidex/app/features/settings/pages/more/more-page.component.html b/src/Squidex/app/features/settings/pages/more/more-page.component.html index 3b8869f24..b3340a151 100644 --- a/src/Squidex/app/features/settings/pages/more/more-page.component.html +++ b/src/Squidex/app/features/settings/pages/more/more-page.component.html @@ -1,11 +1,91 @@ - + - + - More + Settings +
+

General

+ +
+ + + +
+ + + +
+ +
+ + + + + +
+ +
+ + + + + +
+ +
+ +
+ +
+
+
+

Image

+ +
+
+
+
+
+ +
+ + +
+ + + + + +
+ +
+
+
Drop to update
+
+
+
+
+
+ Drop an file to replace the app image. Use a square size. + + + Upload File + + + +
+
+
+
+

Danger Zone

@@ -19,7 +99,7 @@
-