diff --git a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/IAssetInfo.cs b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/IAssetInfo.cs index ff3e01ada..aeeae13d6 100644 --- a/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/IAssetInfo.cs +++ b/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/IAssetInfo.cs @@ -22,5 +22,7 @@ namespace Squidex.Domain.Apps.Core.ValidateContent int? PixelHeight { get; } string FileName { get; } + + string FileNameSlug { get; } } } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs index f553712c8..11073b902 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetEntity.cs @@ -38,6 +38,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets [BsonElement] public string FileName { get; set; } + [BsonIgnoreIfDefault] + [BsonElement] + public string FileNameSlug { get; set; } + [BsonRequired] [BsonElement] public long FileSize { get; set; } diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs index 556522c71..a64cf8949 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository.cs @@ -35,15 +35,20 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets protected override Task SetupCollectionAsync(IMongoCollection collection, CancellationToken ct = default) { - return collection.Indexes.CreateOneAsync( - new CreateIndexModel( - Index - .Ascending(x => x.AppId) - .Ascending(x => x.IsDeleted) - .Ascending(x => x.FileName) - .Ascending(x => x.Tags) - .Descending(x => x.LastModified)), - cancellationToken: ct); + return collection.Indexes.CreateManyAsync( + new[] + { + new CreateIndexModel( + Index + .Ascending(x => x.AppId) + .Ascending(x => x.IsDeleted) + .Ascending(x => x.FileName) + .Ascending(x => x.Tags) + .Descending(x => x.LastModified)), + new CreateIndexModel( + Index.Ascending(x => x.FileNameSlug)) + }, + ct); } public async Task> QueryAsync(Guid appId, Query query) @@ -97,6 +102,18 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets } } + public async Task FindAssetAsync(string slug) + { + using (Profiler.TraceMethod()) + { + var assetEntity = + await Collection.Find(x => x.FileNameSlug == slug) + .FirstOrDefaultAsync(); + + return assetEntity; + } + } + public async Task FindAssetAsync(Guid id) { using (Profiler.TraceMethod()) diff --git a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs index 3c51b334b..da08ac54a 100644 --- a/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs +++ b/src/Squidex.Domain.Apps.Entities.MongoDb/Assets/MongoAssetRepository_SnapshotStore.cs @@ -8,10 +8,12 @@ using System; using System.Threading; using System.Threading.Tasks; +using MongoDB.Bson; using MongoDB.Driver; using Squidex.Domain.Apps.Entities.Assets.State; using Squidex.Infrastructure; using Squidex.Infrastructure.Log; +using Squidex.Infrastructure.MongoDb; using Squidex.Infrastructure.Reflection; using Squidex.Infrastructure.States; @@ -29,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets if (existing != null) { - return (SimpleMapper.Map(existing, new AssetState()), existing.Version); + return (Map(existing), existing.Version); } return (null, EtagVersion.NotFound); @@ -49,14 +51,25 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Assets } } - Task ISnapshotStore.ReadAllAsync(Func callback, CancellationToken ct) + async Task ISnapshotStore.ReadAllAsync(Func callback, CancellationToken ct) { - throw new NotSupportedException(); + using (Profiler.TraceMethod()) + { + await Collection.Find(new BsonDocument()).ForEachPipelineAsync(x => callback(Map(x), x.Version), ct); + } + } + + async Task ISnapshotStore.RemoveAsync(Guid key) + { + using (Profiler.TraceMethod()) + { + await Collection.DeleteOneAsync(x => x.Id == key); + } } - Task ISnapshotStore.RemoveAsync(Guid key) + private static AssetState Map(MongoAssetEntity existing) { - throw new NotSupportedException(); + return SimpleMapper.Map(existing, new AssetState()); } } } diff --git a/src/Squidex.Domain.Apps.Entities/Assets/AssetSlug.cs b/src/Squidex.Domain.Apps.Entities/Assets/AssetSlug.cs new file mode 100644 index 000000000..b6075f583 --- /dev/null +++ b/src/Squidex.Domain.Apps.Entities/Assets/AssetSlug.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Collections.Generic; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Assets +{ + public static class AssetSlug + { + private static readonly HashSet Dot = new HashSet(new[] { '.' }); + + public static string ToAssetSlug(this string value) + { + return value.Slugify(Dot); + } + } +} diff --git a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs index e3e86324d..939fc65ab 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/Repositories/IAssetRepository.cs @@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Entities.Assets.Repositories Task> QueryAsync(Guid appId, HashSet ids); + Task FindAssetAsync(string slug); + Task FindAssetAsync(Guid id); Task RemoveAsync(Guid appId); diff --git a/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs b/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs index 4361f04a5..d5503c1ec 100644 --- a/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs +++ b/src/Squidex.Domain.Apps.Entities/Assets/State/AssetState.cs @@ -29,6 +29,9 @@ namespace Squidex.Domain.Apps.Entities.Assets.State [DataMember] public string MimeType { get; set; } + [DataMember] + public string FileNameSlug { get; set; } + [DataMember] public long FileVersion { get; set; } @@ -62,6 +65,9 @@ namespace Squidex.Domain.Apps.Entities.Assets.State { SimpleMapper.Map(@event, this); + FileName = @event.FileName; + FileNameSlug = @event.FileName.ToAssetSlug(); + TotalSize += @event.FileSize; } @@ -72,14 +78,15 @@ namespace Squidex.Domain.Apps.Entities.Assets.State TotalSize += @event.FileSize; } - protected void On(AssetTagged @event) + protected void On(AssetRenamed @event) { - Tags = @event.Tags; + FileName = @event.FileName; + FileNameSlug = @event.FileName.ToAssetSlug(); } - protected void On(AssetRenamed @event) + protected void On(AssetTagged @event) { - FileName = @event.FileName; + Tags = @event.Tags; } protected void On(AssetDeleted @event) diff --git a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs index c23f2c69c..9275af29f 100644 --- a/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs +++ b/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AssetGraphType.cs @@ -99,6 +99,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types Description = "The file name." }); + AddField(new FieldType + { + Name = "fileNameSlug", + ResolvedType = AllTypes.NonNullString, + Resolver = Resolve(x => x.FileNameSlug), + Description = "The file name as slug." + }); + AddField(new FieldType { Name = "fileType", diff --git a/src/Squidex.Infrastructure/StringExtensions.cs b/src/Squidex.Infrastructure/StringExtensions.cs index eb0c309bd..2cd609a2b 100644 --- a/src/Squidex.Infrastructure/StringExtensions.cs +++ b/src/Squidex.Infrastructure/StringExtensions.cs @@ -510,6 +510,11 @@ namespace Squidex.Infrastructure public static string Slugify(this string value, ISet preserveHash = null, bool singleCharDiactric = false, char separator = '-') { + if (value == null) + { + return null; + } + var result = new StringBuilder(value.Length); var lastChar = (char)0; diff --git a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs index cf71326c0..c9ff22e9b 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/AssetContentController.cs @@ -10,6 +10,7 @@ using System.IO; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; +using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Infrastructure; using Squidex.Infrastructure.Assets; @@ -61,14 +62,23 @@ namespace Squidex.Areas.Api.Controllers.Assets [Route("assets/{id}/{*more}")] [ProducesResponseType(typeof(FileResult), 200)] [ApiCosts(0.5)] - public async Task GetAssetContent(Guid id, string more, + public async Task GetAssetContent(string id, string more, [FromQuery] long version = EtagVersion.Any, [FromQuery] int? width = null, [FromQuery] int? height = null, [FromQuery] int? quality = null, [FromQuery] string mode = null) { - var entity = await assetRepository.FindAssetAsync(id); + IAssetEntity entity; + + if (Guid.TryParse(id, out var guid)) + { + entity = await assetRepository.FindAssetAsync(guid); + } + else + { + entity = await assetRepository.FindAssetAsync(id); + } if (entity == null || entity.FileVersion < version || width == 0 || height == 0 || quality == 0) { diff --git a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs index 733a3ab18..6c099c78f 100644 --- a/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs +++ b/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs @@ -29,6 +29,12 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models [Required] public string FileName { get; set; } + /// + /// The file name as a slug. + /// + [Required] + public string FileNameSlug { get; set; } + /// /// The mime type. /// diff --git a/src/Squidex/Config/Domain/EntitiesServices.cs b/src/Squidex/Config/Domain/EntitiesServices.cs index b1cb19309..c99f44e15 100644 --- a/src/Squidex/Config/Domain/EntitiesServices.cs +++ b/src/Squidex/Config/Domain/EntitiesServices.cs @@ -268,6 +268,9 @@ namespace Squidex.Config.Domain services.AddTransientAs() .As(); + services.AddTransientAs() + .As(); + services.AddTransientAs() .As(); diff --git a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs index b366b9f02..f2bfe8bc4 100644 --- a/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs +++ b/tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/AssetsFieldTests.cs @@ -28,6 +28,8 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent public string FileName { get; set; } + public string FileNameSlug { get; set; } + public long FileSize { get; set; } public bool IsImage { get; set; } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs index 6a0906ba3..82c578265 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetGrainTests.cs @@ -111,7 +111,7 @@ namespace Squidex.Domain.Apps.Entities.Assets [Fact] public async Task Rename_should_create_events() { - var command = new RenameAsset { FileName = "my-new-image.png" }; + var command = new RenameAsset { FileName = "My New Image.png" }; await ExecuteCreateAsync(); @@ -119,11 +119,12 @@ namespace Squidex.Domain.Apps.Entities.Assets result.ShouldBeEquivalent(new EntitySavedResult(1)); - Assert.Equal("my-new-image.png", sut.Snapshot.FileName); + Assert.Equal("My New Image.png", sut.Snapshot.FileName); + Assert.Equal("my-new-image.png", sut.Snapshot.FileNameSlug); LastEvents .ShouldHaveSameEvents( - CreateAssetEvent(new AssetRenamed { FileName = "my-new-image.png" }) + CreateAssetEvent(new AssetRenamed { FileName = "My New Image.png" }) ); } diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index 9763d1425..524955036 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -51,6 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL sourceUrl mimeType fileName + fileNameSlug fileSize fileVersion isImage @@ -85,6 +86,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL sourceUrl = $"assets/source/{asset.Id}", mimeType = "image/png", fileName = "MyFile.png", + fileNameSlug = "myfile.png", fileSize = 1024, fileVersion = 123, isImage = true, @@ -117,6 +119,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL sourceUrl mimeType fileName + fileNameSlug fileSize fileVersion isImage @@ -155,6 +158,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL sourceUrl = $"assets/source/{asset.Id}", mimeType = "image/png", fileName = "MyFile.png", + fileNameSlug = "myfile.png", fileSize = 1024, fileVersion = 123, isImage = true, @@ -189,6 +193,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL sourceUrl mimeType fileName + fileNameSlug fileSize fileVersion isImage @@ -219,6 +224,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL sourceUrl = $"assets/source/{asset.Id}", mimeType = "image/png", fileName = "MyFile.png", + fileNameSlug = "myfile.png", fileSize = 1024, fileVersion = 123, isImage = true, diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index d921779b6..60bc92d97 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -170,6 +170,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL LastModified = now, LastModifiedBy = new RefToken(RefTokenType.Subject, "user2"), FileName = "MyFile.png", + FileNameSlug = "myfile.png", FileSize = 1024, FileVersion = 123, MimeType = "image/png", diff --git a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs index aaabf1630..4dfb34856 100644 --- a/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs +++ b/tests/Squidex.Domain.Apps.Entities.Tests/Contents/TestData/FakeAssetEntity.cs @@ -37,6 +37,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.TestData public string FileName { get; set; } + public string FileNameSlug { get; set; } + public long FileSize { get; set; } public long FileVersion { get; set; } diff --git a/tools/Migrate_01/MigrationPath.cs b/tools/Migrate_01/MigrationPath.cs index 7c28de24d..3d62e722f 100644 --- a/tools/Migrate_01/MigrationPath.cs +++ b/tools/Migrate_01/MigrationPath.cs @@ -17,7 +17,7 @@ namespace Migrate_01 { public sealed class MigrationPath : IMigrationPath { - private const int CurrentVersion = 15; + private const int CurrentVersion = 16; private readonly IServiceProvider serviceProvider; public MigrationPath(IServiceProvider serviceProvider) @@ -103,6 +103,12 @@ namespace Migrate_01 yield return serviceProvider.GetService(); } + // Version 16: Introduce file name slugs for assets. + if (version < 16) + { + yield return serviceProvider.GetService(); + } + yield return serviceProvider.GetRequiredService(); } } diff --git a/tools/Migrate_01/Migrations/CreateAssetSlugs.cs b/tools/Migrate_01/Migrations/CreateAssetSlugs.cs new file mode 100644 index 000000000..70249d441 --- /dev/null +++ b/tools/Migrate_01/Migrations/CreateAssetSlugs.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.Threading.Tasks; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Assets.State; +using Squidex.Infrastructure.Migrations; +using Squidex.Infrastructure.States; + +namespace Migrate_01.Migrations +{ + public sealed class CreateAssetSlugs : IMigration + { + private readonly ISnapshotStore stateForAssets; + + public CreateAssetSlugs(ISnapshotStore stateForAssets) + { + this.stateForAssets = stateForAssets; + } + + public Task UpdateAsync() + { + return stateForAssets.ReadAllAsync(async (state, version) => + { + state.FileNameSlug = state.FileName.ToAssetSlug(); + + await stateForAssets.WriteAsync(state.Id, state, version, version); + }); + } + } +}